feat(biometrics): read and refresh wrapped-DEK keychain credentials (qml part)

Completes the DEK-as-biometrics-value feature: the QML part now recognizes dek-tagged keychain
items everywhere they are written, so biometric login and in-app auth work end to end.
This commit is contained in:
Sale Djenic
2026-08-27 12:05:47 +02:00
committed by saledjenic
parent 205cced078
commit 1e84b549a0
21 changed files with 555 additions and 18 deletions
@@ -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")
@@ -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()
@@ -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)
@@ -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) {
+6
View File
@@ -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, "")
@@ -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
@@ -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")
}
}
}
@@ -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
}
@@ -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
})
}
}
@@ -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()
}
@@ -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
@@ -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.")
+3
View File
@@ -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)
+8
View File
@@ -2983,6 +2983,10 @@ Do you wish to override the security check and continue?</source>
<source>Cancel</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Biometric login disabled re-enable it in Settings</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Change</source>
<translation type="unfinished"></translation>
@@ -13360,6 +13364,10 @@ to load</source>
<source>Continue</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Please enter your password biometrics cannot be used for this action</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Failed to update stored credentials</source>
<translation type="unfinished"></translation>
+8
View File
@@ -2998,6 +2998,10 @@ Přejete si obejít bezpečnostní kontrolu a pokračovat?</translation>
<source>Cancel</source>
<translation>Zrušit</translation>
</message>
<message>
<source>Biometric login disabled re-enable it in Settings</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Change</source>
<translation>Změnit</translation>
@@ -13441,6 +13445,10 @@ selhalo</translation>
<source>Continue</source>
<translation type="unfinished">Pokračovat</translation>
</message>
<message>
<source>Please enter your password biometrics cannot be used for this action</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Failed to update stored credentials</source>
<translation type="unfinished"></translation>
+8
View File
@@ -2985,6 +2985,10 @@ Do you wish to override the security check and continue?</source>
<source>Cancel</source>
<translation>Cancelar</translation>
</message>
<message>
<source>Biometric login disabled re-enable it in Settings</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Change</source>
<translation>Cambiar</translation>
@@ -13375,6 +13379,10 @@ al cargar</translation>
<source>Continue</source>
<translation type="unfinished">Continuar</translation>
</message>
<message>
<source>Please enter your password biometrics cannot be used for this action</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Failed to update stored credentials</source>
<translation type="unfinished"></translation>
+8
View File
@@ -2984,6 +2984,10 @@ Do you wish to override the security check and continue?</source>
<source>Cancel</source>
<translation>Annuler</translation>
</message>
<message>
<source>Biometric login disabled re-enable it in Settings</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Change</source>
<translation>Changer</translation>
@@ -13374,6 +13378,10 @@ Seul le détenteur du jeton Owner peut distribuer des jetons TokenMaster. Ces je
<source>Continue</source>
<translation>Continuer</translation>
</message>
<message>
<source>Please enter your password biometrics cannot be used for this action</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Failed to update stored credentials</source>
<translation>Échec de la mise à jour des informations didentification enregistrées</translation>
+8
View File
@@ -2971,6 +2971,10 @@ Do you wish to override the security check and continue?</source>
<source>Change your password</source>
<translation> </translation>
</message>
<message>
<source>Biometric login disabled re-enable it in Settings</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Change</source>
<translation></translation>
@@ -13310,6 +13314,10 @@ to load</source>
<source>Continue</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Please enter your password biometrics cannot be used for this action</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Failed to update stored credentials</source>
<translation type="unfinished"></translation>
+8
View File
@@ -2998,6 +2998,10 @@ Do you wish to override the security check and continue?</source>
<source>Cancel</source>
<translation>Скасувати</translation>
</message>
<message>
<source>Biometric login disabled re-enable it in Settings</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Change</source>
<translation>Змінити</translation>
@@ -13444,6 +13448,10 @@ to load</source>
<source>Continue</source>
<translation>Продовжити</translation>
</message>
<message>
<source>Please enter your password biometrics cannot be used for this action</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Failed to update stored credentials</source>
<translation>Не вдалося оновити збережені облікові дані</translation>
@@ -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
+2
View File
@@ -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