diff --git a/src/app/modules/main/profile_section/privacy/io_interface.nim b/src/app/modules/main/profile_section/privacy/io_interface.nim index 7633868a9d..f2b18b6565 100644 --- a/src/app/modules/main/profile_section/privacy/io_interface.nim +++ b/src/app/modules/main/profile_section/privacy/io_interface.nim @@ -31,6 +31,9 @@ method changePassword*(self: AccessInterface, password: string, newPassword: str method getBiometricCredentialForStorage*(self: AccessInterface, keyUid: string, password: string): string {.base.} = raise newException(ValueError, "No implementation available") +method setBiometricPreferenceNotNow*(self: AccessInterface) {.base.} = + raise newException(ValueError, "No implementation available") + method isProfileMigratedToDEKEncryption*(self: AccessInterface): bool {.base.} = raise newException(ValueError, "No implementation available") diff --git a/src/app/modules/main/profile_section/privacy/module.nim b/src/app/modules/main/profile_section/privacy/module.nim index 63d6d18680..4182de793c 100644 --- a/src/app/modules/main/profile_section/privacy/module.nim +++ b/src/app/modules/main/profile_section/privacy/module.nim @@ -58,6 +58,9 @@ method isProfileMigratedToDEKEncryption*(self: Module): bool = method getBiometricCredentialForStorage*(self: Module, keyUid: string, password: string): string = return self.controller.getBiometricCredentialForStorage(keyUid, password) +method setBiometricPreferenceNotNow*(self: Module) = + singletonInstance.localAccountSettings.setStoreToKeychainValue(LS_VALUE_NOT_NOW) + method isMnemonicBackedUp*(self: Module): bool = return self.controller.isMnemonicBackedUp() diff --git a/src/app/modules/main/profile_section/privacy/view.nim b/src/app/modules/main/profile_section/privacy/view.nim index 93af249c88..e4fad3548a 100644 --- a/src/app/modules/main/profile_section/privacy/view.nim +++ b/src/app/modules/main/profile_section/privacy/view.nim @@ -25,6 +25,9 @@ QtObject: proc getBiometricCredentialForStorage*(self: View, keyUid: string, password: string): string {.slot.} = return self.delegate.getBiometricCredentialForStorage(keyUid, password) + proc setBiometricPreferenceNotNow*(self: View) {.slot.} = + self.delegate.setBiometricPreferenceNotNow() + proc passwordChanged(self: View, success: bool, errorMsg: string) {.signal.} proc emitPasswordChangedSignal*(self: View, success: bool, errorMsg: string) = self.passwordChanged(success, errorMsg) diff --git a/storybook/pages/ChangePasswordViewPage.qml b/storybook/pages/ChangePasswordViewPage.qml index fbed1d26a1..5e442863ba 100644 --- a/storybook/pages/ChangePasswordViewPage.qml +++ b/storybook/pages/ChangePasswordViewPage.qml @@ -47,6 +47,18 @@ SplitView { } } + // The view invokes these on the store itself (the storybook stub is empty, + // so they are defined on the instance) + function getBiometricCredentialForStorage(keyUid, password) { + // mimics the backend rule: DEK-migrated profiles store the tagged DEK, + // legacy ones the raw password + return ctrlFastPasswordChange.checked ? "dek:mocked-dek-hex" : password + } + + function setBiometricPreferenceNotNow() { + logs.logEvent("privacyStore::setBiometricPreferenceNotNow") + } + readonly property string keyUid: keyUidInput.text function tryStoreToKeyChain(errorDescription) { diff --git a/storybook/pages/KeychainMock.qml b/storybook/pages/KeychainMock.qml index ad2ffd45f0..722421dede 100644 --- a/storybook/pages/KeychainMock.qml +++ b/storybook/pages/KeychainMock.qml @@ -44,6 +44,12 @@ Keychain { return Keychain.StatusSuccess } + function updateCredential(account, password) { + if (d.store[account] === undefined) + return Keychain.StatusSuccess + return saveCredential(account, password) + } + function requestGetCredential(reason, account) { if (!root.available) { root.getCredentialRequestCompleted(Keychain.StatusUnavailable, "") diff --git a/storybook/qmlTests/tests/tst_AuthSignPopupBase.qml b/storybook/qmlTests/tests/tst_AuthSignPopupBase.qml index 2e26ab410c..e0d42c1d08 100644 --- a/storybook/qmlTests/tests/tst_AuthSignPopupBase.qml +++ b/storybook/qmlTests/tests/tst_AuthSignPopupBase.qml @@ -46,6 +46,7 @@ Item { // Shadows the C++ members so no real OS keychain/biometrics is touched Keychain { property int requestCount: 0 + property var updatedCredentials: [] readonly property bool available: true function hasCredential(account) { return account === "key-uid-1" ? Keychain.StatusSuccess @@ -55,6 +56,10 @@ Item { requestCount++ return Keychain.StatusSuccess } + function updateCredential(account, credential) { + updatedCredentials.push({ account, credential }) + return Keychain.StatusSuccess + } } } @@ -127,6 +132,101 @@ Item { } } + TestCase { + name: "AuthSignPopupBase_dekCredential" + when: windowShown + + property var popup: null + property var mockKeychain: null + + function init() { + mockKeychain = createTemporaryObject(mockedKeychainComponent, root) + verify(!!mockKeychain) + } + + function cleanup() { + if (popup) { + popup.destroy() + popup = null + } + } + + function createPasswordPopup(extraProperties) { + const properties = Object.assign({ + keychain: mockKeychain, + isKeycardKeyPair: false, + userProfileMigratedToColdWallet: false + }, extraProperties || {}) + const created = createTemporaryObject(componentUnderTest, root, properties) + verify(!!created) + return created + } + + // A dek-tagged biometric secret is stripped and used on the password rails; + // the stored item is already current, so no keychain update happens. + function test_dekSecretIsStrippedAndUsedAsPassword() { + let capturedPassword = "" + popup = createPasswordPopup() + popup.performPasswordAction = (password) => { + capturedPassword = password + return true + } + popup.open() + tryCompare(popup, "opened", true) + tryCompare(mockKeychain, "requestCount", 1) + + mockKeychain.getCredentialRequestCompleted(Keychain.StatusSuccess, "dek:aabbcc001122") + + tryCompare(popup, "lastUsedPin", "") // password path, not keycard + compare(capturedPassword, "aabbcc001122") + compare(mockKeychain.updatedCredentials.length, 0) + } + + // A legacy (untagged) biometric secret that authenticates successfully is refreshed + // in the keychain through the injected transform (upgrade to the wrapped DEK). + function test_legacySecretUpgradesStoredCredential() { + let capturedPassword = "" + popup = createPasswordPopup({ + getCredentialForStorage: (keyUid, password) => "dek:transformed-" + password + }) + popup.performPasswordAction = (password) => { + capturedPassword = password + return true + } + popup.open() + tryCompare(popup, "opened", true) + tryCompare(mockKeychain, "requestCount", 1) + + mockKeychain.getCredentialRequestCompleted(Keychain.StatusSuccess, "legacy-password") + + compare(capturedPassword, "legacy-password") + tryCompare(mockKeychain.updatedCredentials, "length", 1) + compare(mockKeychain.updatedCredentials[0].account, "key-uid-1") + compare(mockKeychain.updatedCredentials[0].credential, "dek:transformed-legacy-password") + } + + // Flows that transfer the password itself (device syncing) cannot accept a DEK: + // the popup must fall back to manual password entry without running the action. + function test_requirePlainCredentialFallsBackToManualEntry() { + let actionRuns = 0 + popup = createPasswordPopup({ requirePlainCredential: true }) + popup.performPasswordAction = (password) => { + actionRuns++ + return true + } + popup.open() + tryCompare(popup, "opened", true) + tryCompare(mockKeychain, "requestCount", 1) + + mockKeychain.getCredentialRequestCompleted(Keychain.StatusSuccess, "dek:aabbcc001122") + + waitForRendering(popup.contentItem) + compare(actionRuns, 0) + compare(mockKeychain.updatedCredentials.length, 0) + verify(popup.opened) // stays open for manual entry + } + } + TestCase { name: "AuthSignPopupBase_keycardPin" when: windowShown diff --git a/storybook/qmlTests/tests/tst_ChangePasswordView.qml b/storybook/qmlTests/tests/tst_ChangePasswordView.qml new file mode 100644 index 0000000000..f7523c7433 --- /dev/null +++ b/storybook/qmlTests/tests/tst_ChangePasswordView.qml @@ -0,0 +1,224 @@ +import QtQuick +import QtQuick.Controls + +import QtTest + +import StatusQ + +import AppLayouts.Profile.views +import AppLayouts.Profile.stores + +import utils + +Item { + id: root + + width: 600 + height: 700 + + readonly property string testKeyUid: "key-uid-1" + + // Simulates the app-level biometric preference ("store"/"notNow"/"never"). + property string preference: "store" + + // What the credential transform returns; "" simulates a helper failure. + property string transformResult: "dek:fresh-dek-hex" + + property var keychainMock: null + property var view: null + + Component { + id: mockedKeychainComponent + + // Shadows the C++ members so no real OS keychain/biometrics is touched + Keychain { + property var creds: ({}) + property bool asyncDelete: false // Android-style delivery of credentialDeleted + property var updateCalls: [] + property int updateStatus: Keychain.StatusSuccess + property int deleteStatus: Keychain.StatusSuccess + + function hasCredential(account) { + return creds[account] !== undefined ? Keychain.StatusSuccess + : Keychain.StatusNotFound + } + function updateCredential(account, credential) { + updateCalls.push({ account, credential }) + if (updateStatus === Keychain.StatusSuccess && creds[account] !== undefined) + creds[account] = credential + return updateStatus + } + function deleteCredential(account) { + if (deleteStatus !== Keychain.StatusSuccess) + return deleteStatus // failure: item untouched, no signal emitted + delete creds[account] + if (asyncDelete) + Qt.callLater(() => credentialDeleted(account)) + else + credentialDeleted(account) + return Keychain.StatusSuccess + } + function requestGetCredential(reason, account) {} + } + } + + // Mimics ui/main.qml's app-level handler: ANY deletion records a permanent opt-out. + // Declared before the view is created, matching the app's connection order. + Connections { + target: root.keychainMock + + function onCredentialDeleted(account) { + root.preference = "never" + } + } + + Component { + id: viewComponent + + ChangePasswordView { + sectionTitle: "Password" + contentWidth: 500 + passwordStrengthScoreFunction: (newPass) => Math.min(newPass.length - 1, 4) + + privacyStore: PrivacyStore { + // The storybook PrivacyStore stub is empty; the API used by the view is + // declared here, following the established qmlTests store-mock pattern. + readonly property string keyUid: root.testKeyUid + + property QtObject privacyModule: QtObject { + signal passwordChanged(success: bool, errorMsg: string) + } + + function changePassword(password, newPassword, rekey = false) {} + + function isProfileMigratedToDEKEncryption() { + return true + } + + function getBiometricCredentialForStorage(keyUid, password) { + return root.transformResult + } + + function setBiometricPreferenceNotNow() { + root.preference = "notNow" + } + } + + keychain: root.keychainMock + } + } + + TestCase { + name: "ChangePasswordView_keychainFlow" + when: windowShown + + function init() { + root.preference = "store" + root.transformResult = "dek:fresh-dek-hex" + root.keychainMock = createTemporaryObject(mockedKeychainComponent, root) + verify(!!root.keychainMock) + root.keychainMock.deleteStatus = Keychain.StatusSuccess + root.view = createTemporaryObject(viewComponent, root) + verify(!!root.view) + } + + function cleanup() { + if (root.view) { + root.view.destroy() + root.view = null + } + // keychainMock is left in place until init() replaces it: the view's destroy is + // deferred and its bindings would re-evaluate against a null keychain. + } + + function emitPasswordChanged(success) { + const newPswInput = findChild(root.view, "passwordViewNewPassword") + verify(!!newPswInput) + newPswInput.text = "new-password-1" + root.view.privacyStore.privacyModule.passwordChanged(success, success ? "" : "some error") + } + + // Successful change with a stored item: the item is refreshed with the transformed + // credential (wrapped DEK); the preference is untouched. + function test_successRefreshesStoredCredential() { + root.keychainMock.creds[root.testKeyUid] = "old-password" + + emitPasswordChanged(true) + + compare(root.keychainMock.updateCalls.length, 1) + compare(root.keychainMock.updateCalls[0].account, root.testKeyUid) + compare(root.keychainMock.updateCalls[0].credential, "dek:fresh-dek-hex") + compare(root.keychainMock.creds[root.testKeyUid], "dek:fresh-dek-hex") + compare(root.preference, "store") + } + + // No stored item: the keychain is never touched. + function test_successWithoutItemLeavesKeychainAlone() { + emitPasswordChanged(true) + + compare(root.keychainMock.updateCalls.length, 0) + compare(root.preference, "store") + } + + // Failed password change: the keychain is never touched. + function test_failureLeavesKeychainAlone() { + root.keychainMock.creds[root.testKeyUid] = "old-password" + + emitPasswordChanged(false) + + compare(root.keychainMock.updateCalls.length, 0) + compare(root.keychainMock.creds[root.testKeyUid], "old-password") + compare(root.preference, "store") + } + + function test_storageFailureDisablesBiometrics_data() { + return [ + { tag: "helper failure, sync delete", transform: "", updateStatus: Keychain.StatusSuccess, asyncDelete: false }, + { tag: "helper failure, async delete (Android)", transform: "", updateStatus: Keychain.StatusSuccess, asyncDelete: true }, + { tag: "update failure, sync delete", transform: "dek:fresh-dek-hex", updateStatus: Keychain.StatusGenericError, asyncDelete: false }, + { tag: "update failure, async delete (Android)", transform: "dek:fresh-dek-hex", updateStatus: Keychain.StatusGenericError, asyncDelete: true }, + ] + } + + // A storage failure deletes the stale item and must end with the preference on + // "notNow" — even though the deletion handler records "never", and even when + // credentialDeleted arrives asynchronously (Android). + function test_storageFailureDisablesBiometrics(data) { + root.transformResult = data.transform + root.keychainMock.updateStatus = data.updateStatus + root.keychainMock.asyncDelete = data.asyncDelete + root.keychainMock.creds[root.testKeyUid] = "old-password" + + emitPasswordChanged(true) + + tryVerify(() => root.keychainMock.creds[root.testKeyUid] === undefined) + tryCompare(root, "preference", "notNow") + } + + // A FAILED deletion emits no credentialDeleted: the failure preference must still be + // applied directly, and the pending flag must not leak onto a later explicit deletion + // (which records a permanent "never" opt-out and must stay that way). + function test_deletionFailureAppliesPreferenceAndDoesNotPoisonLaterDeletes() { + root.transformResult = "" // helper failure triggers the deletion path + root.keychainMock.deleteStatus = Keychain.StatusGenericError + root.keychainMock.creds[root.testKeyUid] = "old-password" + + emitPasswordChanged(true) + + compare(root.keychainMock.creds[root.testKeyUid], "old-password") // deletion failed + tryCompare(root, "preference", "notNow") + + // later, the user explicitly disables biometrics + root.preference = "store" + root.keychainMock.deleteStatus = Keychain.StatusSuccess + root.keychainMock.deleteCredential(root.testKeyUid) + + // drain the Qt.callLater queue deterministically: any (wrongly) deferred + // correction was queued before this sentinel, so it has run once we see it + let drained = false + Qt.callLater(() => drained = true) + tryVerify(() => drained) + compare(root.preference, "never") + } + } +} diff --git a/storybook/qmlTests/tests/tst_OnboardingLayout.qml b/storybook/qmlTests/tests/tst_OnboardingLayout.qml index aa715cc923..83592b159c 100644 --- a/storybook/qmlTests/tests/tst_OnboardingLayout.qml +++ b/storybook/qmlTests/tests/tst_OnboardingLayout.qml @@ -683,6 +683,8 @@ Item { const resultData = loginSpy.signalArguments[0][2] verify(!!resultData) compare(resultData.password, data.password) + // a keychain item exists while biometrics is on -> ask the backend to refresh it post-login + compare(!!resultData.updateBiometrics, !!data.biometrics) // verify validation & pass error tryCompare(passwordInput, "hasError", data.password !== mockDriver.dummyNewPassword) @@ -744,6 +746,47 @@ Item { } } + function test_loginScreen_biometricsWithDek_data() { + return [{ tag: "dek-tagged biometric secret" }] // dummy to skip global data, and run just once + } + + // Biometric login for a DEK-migrated profile: the keychain returns a dek-tagged + // secret which must be submitted as { dek } (raw, un-hashed path) — never as a + // password, and never shown in the password box. + function test_loginScreen_biometricsWithDek() { + verify(!!controlUnderTest) + controlUnderTest.onboardingStore.loginAccountsModel = loginAccountsModel + controlUnderTest.restartFlow() + + mockDriver.biometricsAvailable = true + + const page = getCurrentPage(controlUnderTest.stack, LoginScreen) + const userSelector = findChild(page, "loginUserSelector") + verify(!!userSelector) + userSelector.setSelection("uid_1") + tryCompare(userSelector, "selectedProfileKeyId", "uid_1") + + const passwordInput = findChild(page, "loginPasswordInput") + verify(!!passwordInput) + const passwordBox = findChild(page, "passwordBox") + verify(!!passwordBox) + + const dekHex = "8a9f7d2b6c1e4f3a5d8b9c0e2f4a6b8d0c2e4f6a8b0d2c4e6f8a0b2d4c6e8f0a" + controlUnderTest.keychain.getCredentialRequestCompleted( + Keychain.StatusSuccess, "dek:" + dekHex) + + // the DEK is not a password: it never appears in the password box + compare(passwordInput.text, "") + + tryCompare(loginSpy, "count", 1) + compare(loginSpy.signalArguments[0][0], "uid_1") + compare(loginSpy.signalArguments[0][1], Onboarding.LoginMethod.Password) + const resultData = loginSpy.signalArguments[0][2] + verify(!!resultData) + compare(resultData.dek, dekHex) + compare(resultData.password, undefined) + } + function test_loginScreen_profileSelectionIsSavedAndRestoredAfterWrongPassword_data() { return [{ tag: "profile selection persisted after wrong password" }] // dummy to skip global data, and run just once } diff --git a/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml b/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml index 30fc642247..70683eafa2 100644 --- a/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml +++ b/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml @@ -79,6 +79,11 @@ OnboardingPage { if (d.currentProfileIsKeycard) { keycardBox.setPin(secret) // automatic login, emits loginRequested() already + } else if (secret.startsWith(Constants.keychain.dekPrefix)) { + passwordBox.validationError = "" + root.loginRequested(root.selectedProfileKeyId, Onboarding.LoginMethod.Password,{ + dek: secret.slice(Constants.keychain.dekPrefix.length) + }) } else { passwordBox.validationError = "" passwordBox.password = secret @@ -127,7 +132,10 @@ OnboardingPage { if (password.length === 0) return - root.loginRequested(root.selectedProfileKeyId, Onboarding.LoginMethod.Password, { password }) + root.loginRequested(root.selectedProfileKeyId, Onboarding.LoginMethod.Password,{ + password, + updateBiometrics: root.isBiometricsLogin + }) } property string lastPin: "" @@ -140,8 +148,7 @@ OnboardingPage { root.loginRequested(root.selectedProfileKeyId, Onboarding.LoginMethod.Keycard, { pin, - pairingPassword: - keycardBox.pairingPassword + pairingPassword: keycardBox.pairingPassword }) } @@ -151,8 +158,7 @@ OnboardingPage { root.loginRequested(root.selectedProfileKeyId, Onboarding.LoginMethod.Keycard, { pin: d.lastPin, - pairingPassword: - keycardBox.pairingPassword + pairingPassword: keycardBox.pairingPassword }) } } diff --git a/ui/app/AppLayouts/Profile/stores/PrivacyStore.qml b/ui/app/AppLayouts/Profile/stores/PrivacyStore.qml index 35536a8130..120fbed5a0 100644 --- a/ui/app/AppLayouts/Profile/stores/PrivacyStore.qml +++ b/ui/app/AppLayouts/Profile/stores/PrivacyStore.qml @@ -27,6 +27,14 @@ QtObject { return root.privacyModule.isProfileMigratedToDEKEncryption() } + function getBiometricCredentialForStorage(keyUid, password) { + return root.privacyModule.getBiometricCredentialForStorage(keyUid, password) + } + + function setBiometricPreferenceNotNow() { + root.privacyModule.setBiometricPreferenceNotNow() + } + function getMnemonic() { return root.privacyModule.getMnemonic() } diff --git a/ui/app/AppLayouts/Profile/views/ChangePasswordView.qml b/ui/app/AppLayouts/Profile/views/ChangePasswordView.qml index e9d06f18d6..6f241099e9 100644 --- a/ui/app/AppLayouts/Profile/views/ChangePasswordView.qml +++ b/ui/app/AppLayouts/Profile/views/ChangePasswordView.qml @@ -42,6 +42,9 @@ SettingsContentBase { property bool enablingBiometrics: false + // Set when a credential-update failure forces a deletion + property bool pendingFailureDelete: false + readonly property bool biometricsEnabled: { reevaluateTrigger // Reference for binding return keychain.hasCredential(privacyStore.keyUid) === Keychain.StatusSuccess @@ -75,8 +78,10 @@ SettingsContentBase { return d.enablingBiometrics = false - const credential = pin !== "" ? pin : password - // If credential not retrieved (cancelled or failed) + const credential = pin !== "" ? + pin + : root.privacyStore.getBiometricCredentialForStorage(keyUid, password) + // If credential not retrieved (cancelled, failed, or could not be prepared) if (keyUid === "" || credential === "") { d.showErrorToast() return @@ -100,6 +105,15 @@ SettingsContentBase { d.reevaluateHasCredential() } + function onCredentialDeleted(account: string) { + if (!d.pendingFailureDelete || account !== root.privacyStore.keyUid) + return + d.pendingFailureDelete = false + // The global deletion handler records a permanent "never" opt-out + Qt.callLater(() => root.privacyStore.setBiometricPreferenceNotNow()) + d.reevaluateHasCredential() + } + function onGetCredentialRequestCompleted(status, secret) { if (status !== Keychain.StatusSuccess) { d.showErrorToast() @@ -213,8 +227,29 @@ SettingsContentBase { function onPasswordChanged(success: bool, errorMsg: string) { if (success) { confirmPasswordChangePopup.passwordSuccessfulyChanged() - keychain.updateCredential(privacyStore.keyUid, - choosePasswordForm.newPswText) + if (root.keychain.hasCredential(root.privacyStore.keyUid) === Keychain.StatusSuccess) { + const cred = root.privacyStore.getBiometricCredentialForStorage(root.privacyStore.keyUid, choosePasswordForm.newPswText) + const ok = cred !== "" + && root.keychain.updateCredential(root.privacyStore.keyUid, cred) === Keychain.StatusSuccess + if (!ok) { + // Never leave a stale credential behind: disable biometrics + d.pendingFailureDelete = true + const deleteStatus = root.keychain.deleteCredential(root.privacyStore.keyUid) + if (deleteStatus !== Keychain.StatusSuccess) { + // No credentialDeleted signal will arrive - clear the pending flag + d.pendingFailureDelete = false + root.privacyStore.setBiometricPreferenceNotNow() + } + Global.displayToastMessage( + qsTr("Biometric login disabled — re-enable it in Settings"), + "", + "warning", + false, + Constants.ephemeralNotificationType.danger, + "") + d.reevaluateHasCredential() + } + } // Reset, cause no restart in this case. choosePasswordForm.reset() return diff --git a/ui/app/mainui/Handlers/EnableBiometricsPopupHandler.qml b/ui/app/mainui/Handlers/EnableBiometricsPopupHandler.qml index 8da9cc0133..62d28a4791 100644 --- a/ui/app/mainui/Handlers/EnableBiometricsPopupHandler.qml +++ b/ui/app/mainui/Handlers/EnableBiometricsPopupHandler.qml @@ -49,8 +49,10 @@ QtObject { return popup.enablingBiometrics = false - const credential = pin !== "" ? pin : password - // If credential not retrieved (cancelled or failed) + const credential = pin !== "" ? + pin + : root.privacyStore.getBiometricCredentialForStorage(keyUid, password) + // If credential not retrieved (cancelled, failed, or could not be prepared) if (keyUid === "" || credential === "") { popup.loading = false popup.errorText = qsTr("Biometric setup failed. Try again.") diff --git a/ui/app/mainui/Popups.qml b/ui/app/mainui/Popups.qml index cabd8a7995..3dbc7d5fce 100644 --- a/ui/app/mainui/Popups.qml +++ b/ui/app/mainui/Popups.qml @@ -757,6 +757,8 @@ QtObject { AuthenticationPopup { store: root.authenticationStore keychain: root.keychain + getCredentialForStorage: (keyUid, password) => root.privacyStore.getBiometricCredentialForStorage(keyUid, password) + requirePlainCredential: reason === Constants.authenticationReason.syncDevice onAuthenticationSuccess: function(reason, password, pin, keyUid, chatPrivateKey) { root.authenticationStore.passwordProvided(keyUid, password) Global.authenticationResult(reason, password, pin, keyUid, chatPrivateKey) @@ -776,6 +778,7 @@ QtObject { store: root.signingStore keychain: root.keychain + getCredentialForStorage: (keyUid, password) => root.privacyStore.getBiometricCredentialForStorage(keyUid, password) onPasswordProvided: function(password) { // in case of signing tx via keycard no password (enc pub key) diff --git a/ui/i18n/qml_base_en.ts b/ui/i18n/qml_base_en.ts index 9011a17175..4c52f576a0 100644 --- a/ui/i18n/qml_base_en.ts +++ b/ui/i18n/qml_base_en.ts @@ -2983,6 +2983,10 @@ Do you wish to override the security check and continue? Cancel + + Biometric login disabled — re-enable it in Settings + + Change @@ -13360,6 +13364,10 @@ to load Continue + + Please enter your password — biometrics cannot be used for this action + + Failed to update stored credentials diff --git a/ui/i18n/qml_cs.ts b/ui/i18n/qml_cs.ts index 220bea95a1..ff24d4dd16 100644 --- a/ui/i18n/qml_cs.ts +++ b/ui/i18n/qml_cs.ts @@ -2998,6 +2998,10 @@ Přejete si obejít bezpečnostní kontrolu a pokračovat? Cancel Zrušit + + Biometric login disabled — re-enable it in Settings + + Change Změnit @@ -13441,6 +13445,10 @@ selhalo Continue Pokračovat + + Please enter your password — biometrics cannot be used for this action + + Failed to update stored credentials diff --git a/ui/i18n/qml_es.ts b/ui/i18n/qml_es.ts index 3b92750251..6ba2f98b9e 100644 --- a/ui/i18n/qml_es.ts +++ b/ui/i18n/qml_es.ts @@ -2985,6 +2985,10 @@ Do you wish to override the security check and continue? Cancel Cancelar + + Biometric login disabled — re-enable it in Settings + + Change Cambiar @@ -13375,6 +13379,10 @@ al cargar Continue Continuar + + Please enter your password — biometrics cannot be used for this action + + Failed to update stored credentials diff --git a/ui/i18n/qml_fr.ts b/ui/i18n/qml_fr.ts index 4e619413b5..309d8dd77d 100644 --- a/ui/i18n/qml_fr.ts +++ b/ui/i18n/qml_fr.ts @@ -2984,6 +2984,10 @@ Do you wish to override the security check and continue? Cancel Annuler + + Biometric login disabled — re-enable it in Settings + + Change Changer @@ -13374,6 +13378,10 @@ Seul le détenteur du jeton Owner peut distribuer des jetons TokenMaster. Ces je Continue Continuer + + Please enter your password — biometrics cannot be used for this action + + Failed to update stored credentials Échec de la mise à jour des informations d’identification enregistrées diff --git a/ui/i18n/qml_ko.ts b/ui/i18n/qml_ko.ts index 79a9c71cd0..86872b44b9 100644 --- a/ui/i18n/qml_ko.ts +++ b/ui/i18n/qml_ko.ts @@ -2971,6 +2971,10 @@ Do you wish to override the security check and continue? Change your password 비밀번호 변경 + + Biometric login disabled — re-enable it in Settings + + Change 변경 @@ -13310,6 +13314,10 @@ to load Continue 계속 + + Please enter your password — biometrics cannot be used for this action + + Failed to update stored credentials diff --git a/ui/i18n/qml_uk.ts b/ui/i18n/qml_uk.ts index 924ba42569..366ba0abd8 100644 --- a/ui/i18n/qml_uk.ts +++ b/ui/i18n/qml_uk.ts @@ -2998,6 +2998,10 @@ Do you wish to override the security check and continue? Cancel Скасувати + + Biometric login disabled — re-enable it in Settings + + Change Змінити @@ -13444,6 +13448,10 @@ to load Continue Продовжити + + Please enter your password — biometrics cannot be used for this action + + Failed to update stored credentials Не вдалося оновити збережені облікові дані diff --git a/ui/imports/shared/popups/auth_sign_base/PopupBase.qml b/ui/imports/shared/popups/auth_sign_base/PopupBase.qml index d946e425d2..a29290c2bf 100644 --- a/ui/imports/shared/popups/auth_sign_base/PopupBase.qml +++ b/ui/imports/shared/popups/auth_sign_base/PopupBase.qml @@ -47,6 +47,14 @@ StatusDialog { property bool externalAuthorization: false // when set, the credential is not collected here, instead an "Authorize" buutton is shown, emitting authorizeRequested signal + property var getCredentialForStorage: null // (keyUid: string, password: string) => string - value to store in the OS keychain, if "" signals failure + + // Set for flows that need the actual password and cannot accept the profile's DEK + // (e.g. device syncing, where the password itself is validated against and transferred + // with the keystore). When the biometric secret turns out to be a DEK, the popup falls + // back to manual password entry instead. + property bool requirePlainCredential: false + signal authorizeRequested() // buttons @@ -207,7 +215,25 @@ StatusDialog { d.biometricsInProgress = false if (!contentLoader.item) return - contentLoader.item.password = secret + + let value = secret + d.lastSecretWasLegacy = true + if (value.startsWith(Constants.keychain.dekPrefix)) { + value = value.slice(Constants.keychain.dekPrefix.length) + d.lastSecretWasLegacy = false + if (root.requirePlainCredential) { + d.credentialCameFromBiometrics = false + Global.displayToastMessage( + qsTr("Please enter your password — biometrics cannot be used for this action"), + "", + "warning", + false, + Constants.ephemeralNotificationType.danger, + "") + return + } + } + contentLoader.item.password = value d.performPasswordActionInternal() } } @@ -237,18 +263,30 @@ StatusDialog { property bool biometricsInProgress: false property bool credentialMismatchAfterBiometrics: false property bool credentialCameFromBiometrics: false + property bool lastSecretWasLegacy: false property bool verifying: false property bool success: false property string error: "" property string lastPin: "" - function updateKeychainCredentialIfNeeded(credential) { - if (!d.credentialMismatchAfterBiometrics) { + function updateKeychainCredentialIfNeeded(credential, isPin) { + // If successful action: + // - biometrics produced a stale credential, the user typed a working one + // - biometrics produced a legacy (untagged) credential - refresh it so DEK-migrated profiles store the wrapped DEK instead of the raw password + const upgrade = d.credentialCameFromBiometrics && d.lastSecretWasLegacy && !isPin + if (!d.credentialMismatchAfterBiometrics && !upgrade) { return } - const status = root.keychain.updateCredential(root.useKeyUid, credential) - if (status !== Keychain.StatusSuccess) { + let toStore = credential + if (!isPin) { + if (!root.getCredentialForStorage) + return + toStore = root.getCredentialForStorage(root.useKeyUid, credential) + } + + if (toStore === "" + || root.keychain.updateCredential(root.useKeyUid, toStore) !== Keychain.StatusSuccess) { Global.displayToastMessage(qsTr("Failed to update stored credentials"), "", "warning", false, Constants.ephemeralNotificationType.danger, "") } } @@ -282,6 +320,7 @@ StatusDialog { d.error = "" d.credentialCameFromBiometrics = false d.credentialMismatchAfterBiometrics = false + d.lastSecretWasLegacy = false root.keychain.requestGetCredential("authenticate", root.useKeyUid) } @@ -306,7 +345,7 @@ StatusDialog { return } - d.updateKeychainCredentialIfNeeded(password) + d.updateKeychainCredentialIfNeeded(password, false) d.success = true } @@ -334,7 +373,7 @@ StatusDialog { function handleKeycardSuccess() { d.verifying = false d.success = true - d.updateKeychainCredentialIfNeeded(d.lastPin) + d.updateKeychainCredentialIfNeeded(d.lastPin, true) } // Called by the concrete popup when keycard action fails diff --git a/ui/imports/utils/Constants.qml b/ui/imports/utils/Constants.qml index 70757617ca..0314944935 100644 --- a/ui/imports/utils/Constants.qml +++ b/ui/imports/utils/Constants.qml @@ -205,6 +205,8 @@ QtObject { readonly property string notNow: "notNow" readonly property string never: "never" } + + readonly property string dekPrefix: "dek:" } readonly property int chatSectionLeftColumnWidth: 304