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