mirror of
https://github.com/status-im/status-app.git
synced 2026-08-27 15:11:20 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 d’identification enregistrées</translation>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user