diff --git a/.maestro/scripts/data-setup.js b/.maestro/scripts/data-setup.js index 59d044b4326..57dc034ef03 100644 --- a/.maestro/scripts/data-setup.js +++ b/.maestro/scripts/data-setup.js @@ -263,5 +263,6 @@ output.utils = { post, login, getDeepLink, - createDM + createDM, + sleep }; \ No newline at end of file diff --git a/.maestro/tests/assorted/screen-lock.yaml b/.maestro/tests/assorted/screen-lock.yaml new file mode 100644 index 00000000000..69a85c9f2a6 --- /dev/null +++ b/.maestro/tests/assorted/screen-lock.yaml @@ -0,0 +1,261 @@ +appId: ${APP_ID} +name: Screen Lock +onFlowStart: + - runFlow: '../../helpers/setup.yaml' +onFlowComplete: + - evalScript: ${output.utils.deleteCreatedUsers()} +tags: + - test-8 + +--- +- evalScript: ${output.user = output.utils.createUser()} +- runFlow: + file: '../../helpers/login-with-deeplink.yaml' + env: + USERNAME: ${output.user.username} + PASSWORD: ${output.user.password} + +# Navigate to Screen Lock config +- extendedWaitUntil: + visible: + id: 'rooms-list-view' + timeout: 60000 +- tapOn: + id: 'rooms-list-view-sidebar' +- extendedWaitUntil: + visible: + id: 'sidebar-settings' + timeout: 60000 +- tapOn: + id: 'sidebar-settings' +- extendedWaitUntil: + visible: + id: 'settings-view-security-privacy' + timeout: 60000 +- tapOn: + id: 'settings-view-security-privacy' +- extendedWaitUntil: + visible: + id: 'security-privacy-view-screen-lock' + timeout: 60000 +- tapOn: + id: 'security-privacy-view-screen-lock' +- extendedWaitUntil: + visible: + id: 'screen-lock-config-view' + timeout: 60000 + +# Enable "Unlock with passcode" -> opens PasscodeChoose +- tapOn: + id: 'screen-lock-config-view-auto-lock' +- extendedWaitUntil: + visible: + id: 'passcode-button-1' + timeout: 60000 + +# Choose passcode 123456 +- tapOn: + id: 'passcode-button-1' +- tapOn: + id: 'passcode-button-2' +- tapOn: + id: 'passcode-button-3' +- tapOn: + id: 'passcode-button-4' +- tapOn: + id: 'passcode-button-5' +- tapOn: + id: 'passcode-button-6' + +# Confirm passcode 123456 +- extendedWaitUntil: + visible: + text: 'Confirm your new passcode' + timeout: 60000 +- tapOn: + id: 'passcode-button-1' +- tapOn: + id: 'passcode-button-2' +- tapOn: + id: 'passcode-button-3' +- tapOn: + id: 'passcode-button-4' +- tapOn: + id: 'passcode-button-5' +- tapOn: + id: 'passcode-button-6' + +# Back on Screen Lock config; choose "After 1 minute" +- extendedWaitUntil: + visible: + id: 'screen-lock-config-view-auto-lock-time-60' + timeout: 60000 +- tapOn: + id: 'screen-lock-config-view-auto-lock-time-60' + +# Background the app, wait past the auto-lock interval, relaunch. +# RUNNING_E2E_TESTS shortens the auto-lock threshold to 5s (see localAuthentication.ts). +- pressKey: Home +- evalScript: ${output.utils.sleep(10000)} +- launchApp: + appId: ${APP_ID} + +# Screen Lock modal must appear; unlock with current passcode +- extendedWaitUntil: + visible: + id: 'passcode-button-1' + timeout: 60000 +- tapOn: + id: 'passcode-button-1' +- tapOn: + id: 'passcode-button-2' +- tapOn: + id: 'passcode-button-3' +- tapOn: + id: 'passcode-button-4' +- tapOn: + id: 'passcode-button-5' +- tapOn: + id: 'passcode-button-6' + +# After unlock, we land back on Screen Lock config. Tap "Change passcode" +- extendedWaitUntil: + visible: + id: 'rooms-list-view' + timeout: 60000 +- tapOn: + id: 'rooms-list-view-sidebar' +- extendedWaitUntil: + visible: + id: 'sidebar-settings' + timeout: 60000 +- tapOn: + id: 'sidebar-settings' +- extendedWaitUntil: + visible: + id: 'settings-view-security-privacy' + timeout: 60000 +- tapOn: + id: 'settings-view-security-privacy' +- extendedWaitUntil: + visible: + id: 'security-privacy-view-screen-lock' + timeout: 60000 +- tapOn: + id: 'security-privacy-view-screen-lock' + +# Entering the Screen Lock screen requires re-auth (PR #4052); unlock with current passcode +- extendedWaitUntil: + visible: + id: 'passcode-button-1' + timeout: 60000 +- tapOn: + id: 'passcode-button-1' +- tapOn: + id: 'passcode-button-2' +- tapOn: + id: 'passcode-button-3' +- tapOn: + id: 'passcode-button-4' +- tapOn: + id: 'passcode-button-5' +- tapOn: + id: 'passcode-button-6' + +- extendedWaitUntil: + visible: + id: 'screen-lock-config-view' + timeout: 60000 +- extendedWaitUntil: + visible: + id: 'screen-lock-config-view-change-passcode' + timeout: 60000 +- tapOn: + id: 'screen-lock-config-view-change-passcode' + +# Re-authenticate with current passcode (autoLock is on -> handleLocalAuthentication) +- extendedWaitUntil: + visible: + id: 'passcode-button-1' + timeout: 60000 +- tapOn: + id: 'passcode-button-1' +- tapOn: + id: 'passcode-button-2' +- tapOn: + id: 'passcode-button-3' +- tapOn: + id: 'passcode-button-4' +- tapOn: + id: 'passcode-button-5' +- tapOn: + id: 'passcode-button-6' + +# Now the ChangePasscodeView (PasscodeChoose) opens; choose new passcode 3455678 +- extendedWaitUntil: + visible: + text: 'Choose your new passcode' + timeout: 60000 +- tapOn: + id: 'passcode-button-3' +- tapOn: + id: 'passcode-button-4' +- tapOn: + id: 'passcode-button-5' +- tapOn: + id: 'passcode-button-6' +- tapOn: + id: 'passcode-button-7' +- tapOn: + id: 'passcode-button-8' + +# Confirm new passcode 3455678 +- extendedWaitUntil: + visible: + text: 'Confirm your new passcode' + timeout: 60000 +- tapOn: + id: 'passcode-button-3' +- tapOn: + id: 'passcode-button-4' +- tapOn: + id: 'passcode-button-5' +- tapOn: + id: 'passcode-button-6' +- tapOn: + id: 'passcode-button-7' +- tapOn: + id: 'passcode-button-8' + +# Modal closes; we are back on the Screen Lock config screen +- extendedWaitUntil: + visible: + id: 'screen-lock-config-view' + timeout: 60000 +- assertVisible: + id: 'screen-lock-config-view-change-passcode' + +# Background the app, wait past the auto-lock interval, relaunch. +# RUNNING_E2E_TESTS shortens the auto-lock threshold to 5s (see localAuthentication.ts). +- pressKey: Home +- evalScript: ${output.utils.sleep(10000)} +- launchApp: + appId: ${APP_ID} + +# Screen Lock modal must appear; unlock with current passcode +- extendedWaitUntil: + visible: + id: 'passcode-button-3' + timeout: 60000 +- tapOn: + id: 'passcode-button-3' +- tapOn: + id: 'passcode-button-4' +- tapOn: + id: 'passcode-button-5' +- tapOn: + id: 'passcode-button-6' +- tapOn: + id: 'passcode-button-7' +- tapOn: + id: 'passcode-button-8' diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt index 56c88d46277..4ed84b04061 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -24,6 +24,7 @@ import chat.rocket.reactnative.notification.PushNotificationTurboPackage import chat.rocket.reactnative.VoipTurboPackage import chat.rocket.reactnative.scroll.InvertedScrollPackage import chat.rocket.reactnative.input.ExternalInputPackage +import chat.rocket.reactnative.biometric.BiometricEnrollmentPackage /** * Main Application class. @@ -51,6 +52,7 @@ open class MainApplication : Application(), ReactApplication { add(SecureStoragePackage()) add(InvertedScrollPackage()) add(ExternalInputPackage()) + add(BiometricEnrollmentPackage()) } override fun getJSMainModuleName(): String = "index" diff --git a/android/app/src/main/java/chat/rocket/reactnative/biometric/BiometricEnrollmentModule.kt b/android/app/src/main/java/chat/rocket/reactnative/biometric/BiometricEnrollmentModule.kt new file mode 100644 index 00000000000..5854363cc67 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/biometric/BiometricEnrollmentModule.kt @@ -0,0 +1,144 @@ +package chat.rocket.reactnative.biometric + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import android.util.Log + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod + +import java.security.KeyStore + +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey + +/** + * Silent biometric enrollment-change detection for Android. + * + * iOS gets this for free: a BIOMETRY_CURRENT_SET keychain item is dropped by the OS when the + * enrollment set changes, so the JS trust store's cheap existence check reveals it without a prompt. + * Android's keystore key is NOT deleted on an enrollment change — it is only invalidated, and that + * invalidation surfaces as a KeyPermanentlyInvalidatedException the first time the key is *used*. + * react-native-keychain only exposes a combined init+doFinal read, which shows the BiometricPrompt + * for a still-valid key, so it cannot serve as a silent probe. + * + * This module keeps a dedicated AES key bound to the current enrollment + * (setInvalidatedByBiometricEnrollment = true). Calling Cipher.init() on it is silent: it succeeds + * for a valid enrollment (no prompt — auth is only enforced at doFinal, which we never call) and + * throws KeyPermanentlyInvalidatedException once the enrollment has changed. The key is created in + * lockstep with the JS trust sentinel (enroll/disenroll) and lazily (re)created on first check, so + * existing biometry users get a baseline without being forced through a passcode on upgrade. + */ +class BiometricEnrollmentModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + companion object { + private const val TAG = "BiometricEnrollment" + private const val KEYSTORE_PROVIDER = "AndroidKeyStore" + private const val KEY_ALIAS = "rc_biometric_enrollment_probe" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + } + + override fun getName(): String = "BiometricEnrollment" + + private fun loadKeyStore(): KeyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } + + private fun createProbeKey() { + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER) + keyGenerator.init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setUserAuthenticationRequired(true) + // The whole point: the OS invalidates this key when the biometric enrollment changes. + .setInvalidatedByBiometricEnrollment(true) + .build() + ) + keyGenerator.generateKey() + } + + /** Create the probe key bound to the current enrollment (idempotent). */ + @ReactMethod + fun enrollProbe(promise: Promise) { + try { + val keyStore = loadKeyStore() + if (!keyStore.containsAlias(KEY_ALIAS)) { + createProbeKey() + } + promise.resolve(true) + } catch (e: Exception) { + // Creating the probe needs a current biometric enrollment + secure lock screen. If that's + // missing the JS layer simply won't have a probe; the modal verify() path stays as backstop. + Log.w(TAG, "enrollProbe failed", e) + promise.resolve(false) + } + } + + /** Delete the probe key, kept in lockstep with the JS trust sentinel teardown. */ + @ReactMethod + fun disenrollProbe(promise: Promise) { + try { + val keyStore = loadKeyStore() + if (keyStore.containsAlias(KEY_ALIAS)) { + keyStore.deleteEntry(KEY_ALIAS) + } + promise.resolve(true) + } catch (e: Exception) { + Log.w(TAG, "disenrollProbe failed", e) + promise.resolve(false) + } + } + + /** + * Silent check. Resolves true when the current enrollment still matches the probe key (or the + * probe was just created as a fresh baseline), false only when the key was invalidated by an + * enrollment change. Never shows a biometric prompt. + */ + @ReactMethod + fun isEnrollmentValid(promise: Promise) { + try { + val keyStore = loadKeyStore() + + if (!keyStore.containsAlias(KEY_ALIAS)) { + // No baseline yet (fresh install/upgrade, or just disenrolled). Establish one bound to + // the current enrollment and report valid — there is no prior enrollment to differ from. + try { + createProbeKey() + promise.resolve(true) + } catch (e: Exception) { + // Couldn't bind a baseline — most likely biometrics were removed entirely. Treat as + // a change so the passcode is required. + Log.w(TAG, "isEnrollmentValid: probe baseline creation failed", e) + promise.resolve(false) + } + return + } + + val key = keyStore.getKey(KEY_ALIAS, null) as? SecretKey + if (key == null) { + promise.resolve(false) + return + } + + val cipher = Cipher.getInstance(TRANSFORMATION) + // init() does NOT prompt and does NOT run crypto; it only fails if the key was invalidated. + cipher.init(Cipher.ENCRYPT_MODE, key) + promise.resolve(true) + } catch (e: KeyPermanentlyInvalidatedException) { + promise.resolve(false) + } catch (e: Exception) { + // Unknown keystore failure: fail open to avoid nagging the user on transient errors. The + // modal-based verify() remains the backstop for a real enrollment change. + Log.w(TAG, "isEnrollmentValid failed", e) + promise.resolve(true) + } + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/biometric/BiometricEnrollmentPackage.kt b/android/app/src/main/java/chat/rocket/reactnative/biometric/BiometricEnrollmentPackage.kt new file mode 100644 index 00000000000..a99a344bbb7 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/biometric/BiometricEnrollmentPackage.kt @@ -0,0 +1,13 @@ +package chat.rocket.reactnative.biometric + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class BiometricEnrollmentPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List = + listOf(BiometricEnrollmentModule(reactContext)) + + override fun createViewManagers(reactContext: ReactApplicationContext): List> = emptyList() +} diff --git a/app/containers/Passcode/Base/Locked.test.tsx b/app/containers/Passcode/Base/Locked.test.tsx new file mode 100644 index 00000000000..06edf661dab --- /dev/null +++ b/app/containers/Passcode/Base/Locked.test.tsx @@ -0,0 +1,93 @@ +import { act, render, waitFor } from '@testing-library/react-native'; + +import Locked from './Locked'; +import { TYPE } from '../constants'; +import { getLockedUntil } from '../utils'; +import { resetAttempts } from '../../../lib/methods/helpers/localAuthentication'; +import log from '../../../lib/methods/helpers/log'; + +jest.mock('../../../theme', () => ({ + useTheme: () => ({ + theme: 'light', + colors: { + strokeExtraLight: '#e1e1e1', + fontTitlesLabels: '#111111', + fontSecondaryInfo: '#222222' + } + }) +})); + +jest.mock('../../../i18n', () => ({ + t: (key: string, params?: { timeLeft?: number }) => (params?.timeLeft ? `${key}:${params.timeLeft}` : key) +})); + +jest.mock('../utils', () => { + const actual = jest.requireActual('../utils'); + + return { + ...actual, + getLockedUntil: jest.fn() + }; +}); + +jest.mock('../../../lib/methods/helpers/localAuthentication', () => ({ + resetAttempts: jest.fn() +})); + +jest.mock('../../../lib/methods/helpers/log', () => ({ + __esModule: true, + default: jest.fn() +})); + +const mockedGetLockedUntil = getLockedUntil as jest.MockedFunction; +const mockedResetAttempts = resetAttempts as jest.MockedFunction; +const mockedLog = log as jest.MockedFunction; + +describe('Locked', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-03T12:00:00.000Z')); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('resets attempts and returns to enter mode after the lock expires', async () => { + mockedGetLockedUntil.mockResolvedValue(new Date(Date.now() + 1500)); + mockedResetAttempts.mockResolvedValue(undefined); + const setStatus = jest.fn(); + + render(); + + await waitFor(() => expect(mockedGetLockedUntil).toHaveBeenCalledTimes(1)); + + await act(async () => { + jest.advanceTimersByTime(2000); + await Promise.resolve(); + }); + + await waitFor(() => expect(mockedResetAttempts).toHaveBeenCalledTimes(1)); + expect(setStatus).toHaveBeenCalledWith(TYPE.ENTER); + }); + + it('still returns to enter mode when clearing attempts fails', async () => { + mockedGetLockedUntil.mockResolvedValue(new Date(Date.now() + 1500)); + mockedResetAttempts.mockRejectedValue(new Error('storage failed')); + const setStatus = jest.fn(); + + render(); + + await waitFor(() => expect(mockedGetLockedUntil).toHaveBeenCalledTimes(1)); + + await act(async () => { + jest.advanceTimersByTime(2000); + await Promise.resolve(); + }); + + await waitFor(() => expect(mockedResetAttempts).toHaveBeenCalledTimes(1)); + expect(mockedLog).toHaveBeenCalledWith(expect.any(Error)); + expect(setStatus).toHaveBeenCalledWith(TYPE.ENTER); + }); +}); diff --git a/app/containers/Passcode/Base/Locked.tsx b/app/containers/Passcode/Base/Locked.tsx index 9435e0b9ebd..71dde1528bb 100644 --- a/app/containers/Passcode/Base/Locked.tsx +++ b/app/containers/Passcode/Base/Locked.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, memo } from 'react'; import { Grid } from 'react-native-easy-grid'; import { resetAttempts } from '../../../lib/methods/helpers/localAuthentication'; +import log from '../../../lib/methods/helpers/log'; import { TYPE } from '../constants'; import { getDiff, getLockedUntil } from '../utils'; import I18n from '../../../i18n'; @@ -31,14 +32,50 @@ const Timer = memo(({ time, setStatus }: IPasscodeTimer) => { const [timeLeft, setTimeLeft] = useState(calcTimeLeft()); useEffect(() => { - setTimeout(() => { - setTimeLeft(calcTimeLeft()); - if (timeLeft && timeLeft <= 1) { - resetAttempts(); + const unlock = async () => { + try { + // Await the storage clear before flipping status: PasscodeEnter's readStorage re-seeds + // the attempts counter from ATTEMPTS_KEY on the status change, so the key must already + // be gone or the user would re-lock after a single wrong attempt. + await resetAttempts(); + } catch (e) { + log(e); + } finally { setStatus(TYPE.ENTER); } + }; + + if (!time) { + setTimeLeft(undefined); + return; + } + + const syncTimeLeft = () => { + const nextTimeLeft = calcTimeLeft(); + setTimeLeft(nextTimeLeft); + + if (nextTimeLeft !== undefined) { + return false; + } + + unlock().catch(e => { + log(e); + }); + return true; + }; + + if (syncTimeLeft()) { + return; + } + + const intervalId = setInterval(() => { + if (syncTimeLeft()) { + clearInterval(intervalId); + } }, 1000); - }); + + return () => clearInterval(intervalId); + }, [time, setStatus]); if (!timeLeft) { return null; diff --git a/app/containers/Passcode/Base/styles.ts b/app/containers/Passcode/Base/styles.ts index 737ce0c93cd..58b4b587c77 100644 --- a/app/containers/Passcode/Base/styles.ts +++ b/app/containers/Passcode/Base/styles.ts @@ -10,8 +10,7 @@ export default StyleSheet.create({ justifyContent: 'center' }, subtitleView: { - justifyContent: 'center', - height: 32 + justifyContent: 'center' }, row: { flex: 0, diff --git a/app/containers/Passcode/PasscodeEnter.test.tsx b/app/containers/Passcode/PasscodeEnter.test.tsx new file mode 100644 index 00000000000..985a4ab976d --- /dev/null +++ b/app/containers/Passcode/PasscodeEnter.test.tsx @@ -0,0 +1,123 @@ +import { fireEvent, render, waitFor } from '@testing-library/react-native'; + +import PasscodeEnter from './PasscodeEnter'; +import { biometryAuth } from '../../lib/methods/helpers/localAuthentication'; +import { biometricTrustStore } from '../../lib/biometricTrustStore'; + +jest.mock('../../lib/methods/helpers/localAuthentication', () => ({ + biometryAuth: jest.fn(), + resetAttempts: jest.fn(() => Promise.resolve()) +})); + +jest.mock('../../lib/biometricTrustStore', () => ({ + biometricTrustStore: { + enroll: jest.fn(), + disenroll: jest.fn(() => Promise.resolve()), + verify: jest.fn(), + hasEnrollment: jest.fn(), + isEnabled: jest.fn(), + setEnabled: jest.fn(), + setBiometryEnabled: jest.fn() + } +})); + +jest.mock('../../lib/methods/userPreferences', () => ({ + __esModule: true, + default: { + getBool: jest.fn(), + setBool: jest.fn(), + getString: jest.fn(), + setString: jest.fn() + }, + useUserPreferences: () => [null, jest.fn()] +})); + +jest.mock('../../i18n', () => ({ t: (key: string) => key })); + +const mockedBiometryAuth = biometryAuth as jest.Mock; +const mockedDisenroll = biometricTrustStore.disenroll as jest.Mock; +const mockedSetEnabled = biometricTrustStore.setEnabled as jest.Mock; + +// biometry() runs on mount (auto, from behind the modal) and on button press; both share the same +// trust-resolution logic. These cover the auto path plus a manual re-trigger. +describe('PasscodeEnter biometry', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedDisenroll.mockResolvedValue(undefined); + }); + + it('enrollmentChanged on mount → disenrolls, clears flag, hides biometry button', async () => { + mockedBiometryAuth.mockResolvedValueOnce({ kind: 'enrollmentChanged' }); + const finishProcess = jest.fn(); + + const { queryByTestId } = render(); + + await waitFor(() => expect(mockedDisenroll).toHaveBeenCalledTimes(1)); + expect(mockedSetEnabled).toHaveBeenCalledWith(false); + expect(finishProcess).not.toHaveBeenCalled(); + await waitFor(() => expect(queryByTestId('biometry-button')).toBeNull()); + }); + + it('success on mount → finishes process, no invalidation', async () => { + mockedBiometryAuth.mockResolvedValueOnce({ kind: 'success' }); + const finishProcess = jest.fn(); + + render(); + + await waitFor(() => expect(finishProcess).toHaveBeenCalledTimes(1)); + expect(mockedDisenroll).not.toHaveBeenCalled(); + expect(mockedSetEnabled).not.toHaveBeenCalled(); + }); + + it('canceled on mount → flag untouched, biometry button stays', async () => { + mockedBiometryAuth.mockResolvedValueOnce({ kind: 'canceled' }); + const finishProcess = jest.fn(); + + const { getByTestId } = render(); + + await waitFor(() => expect(mockedBiometryAuth).toHaveBeenCalledTimes(1)); + expect(mockedDisenroll).not.toHaveBeenCalled(); + expect(mockedSetEnabled).not.toHaveBeenCalled(); + expect(finishProcess).not.toHaveBeenCalled(); + expect(getByTestId('biometry-button')).toBeTruthy(); + }); + + it('button press re-triggers verification after a canceled auto-attempt', async () => { + mockedBiometryAuth.mockResolvedValueOnce({ kind: 'canceled' }).mockResolvedValueOnce({ kind: 'success' }); + const finishProcess = jest.fn(); + + const { getByTestId } = render(); + + await waitFor(() => expect(mockedBiometryAuth).toHaveBeenCalledTimes(1)); + + fireEvent.press(getByTestId('biometry-button')); + + await waitFor(() => expect(finishProcess).toHaveBeenCalledTimes(1)); + expect(mockedBiometryAuth).toHaveBeenCalledTimes(2); + }); + + it('does not auto-trigger biometry when hasBiometry is false', async () => { + const { queryByTestId } = render(); + + await waitFor(() => expect(queryByTestId('biometry-button')).toBeNull()); + expect(mockedBiometryAuth).not.toHaveBeenCalled(); + }); +}); + +describe('PasscodeEnter enrollmentChanged subtitle', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders explanatory subtitle when reason === "enrollmentChanged"', () => { + const { getByText } = render(); + + expect(getByText('Local_authentication_biometric_enrollment_changed')).toBeTruthy(); + }); + + it('does not render subtitle when reason is undefined', () => { + const { queryByText } = render(); + + expect(queryByText('Local_authentication_biometric_enrollment_changed')).toBeNull(); + }); +}); diff --git a/app/containers/Passcode/PasscodeEnter.tsx b/app/containers/Passcode/PasscodeEnter.tsx index 05e4ad46d1a..b99b8250d3d 100644 --- a/app/containers/Passcode/PasscodeEnter.tsx +++ b/app/containers/Passcode/PasscodeEnter.tsx @@ -9,39 +9,58 @@ import Locked from './Base/Locked'; import { TYPE } from './constants'; import { ATTEMPTS_KEY, LOCKED_OUT_TIMER_KEY, MAX_ATTEMPTS, PASSCODE_KEY } from '../../lib/constants/localAuthentication'; import { biometryAuth, resetAttempts } from '../../lib/methods/helpers/localAuthentication'; +import { resolveBiometricTrust } from '../../lib/biometricTrustStore/resolveBiometricTrust'; +import { type BiometricInvalidationReason } from '../../definitions'; import { getDiff, getLockedUntil } from './utils'; import { useUserPreferences } from '../../lib/methods/userPreferences'; import I18n from '../../i18n'; interface IPasscodePasscodeEnter { hasBiometry: boolean; + reason?: BiometricInvalidationReason; finishProcess: Function; } -const PasscodeEnter = ({ hasBiometry, finishProcess }: IPasscodePasscodeEnter) => { +const PasscodeEnter = ({ hasBiometry: initialHasBiometry, reason: initialReason, finishProcess }: IPasscodePasscodeEnter) => { const ref = useRef(null); - let attempts = 0; - let lockedUntil: any = false; + const attempts = useRef(0); const [passcode] = useUserPreferences(PASSCODE_KEY); const [status, setStatus] = useState(null); - const { setItem: setAttempts } = useAsyncStorage(ATTEMPTS_KEY); + // Mirror hasBiometry/reason locally so an enrollment-change invalidation triggered from the + // biometry button immediately hides the button within the same modal session, without + // re-emitting LOCAL_AUTHENTICATE_EMITTER (which would orphan the upstream openModal promise). + const [hasBiometry, setHasBiometry] = useState(initialHasBiometry); + const [reason, setReason] = useState(initialReason); + const { getItem: getAttempts, setItem: setAttempts } = useAsyncStorage(ATTEMPTS_KEY); const { setItem: setLockedUntil } = useAsyncStorage(LOCKED_OUT_TIMER_KEY); const biometry = async () => { - if (hasBiometry && status === TYPE.ENTER) { - const result = await biometryAuth(); - if (result?.success) { - finishProcess(); - } + if (!hasBiometry || status !== TYPE.ENTER) { + return; } + const result = await biometryAuth(); + const outcome = await resolveBiometricTrust(result); + if (outcome.unlocked) { + finishProcess(); + return; + } + const { modal } = outcome; + setHasBiometry(modal.hasBiometry); + setReason(modal.reason); }; const readStorage = async () => { - lockedUntil = await getLockedUntil(); + // Seed the counter from storage: a remount mid-session must not grant a fresh attempt + // budget, and a lockout expiry (Locked awaits resetAttempts(), clearing the key, before + // flipping status back to ENTER) must land here as 0. + const storedAttempts = await getAttempts(); + attempts.current = storedAttempts ? parseInt(storedAttempts, 10) : 0; + const lockedUntil = await getLockedUntil(); if (lockedUntil) { const diff = getDiff(lockedUntil); if (diff <= 1) { await resetAttempts(); + attempts.current = 0; setStatus(TYPE.ENTER); } else { setStatus(TYPE.LOCKED); @@ -49,6 +68,8 @@ const PasscodeEnter = ({ hasBiometry, finishProcess }: IPasscodePasscodeEnter) = } else { setStatus(TYPE.ENTER); } + // Auto-prompt biometry from behind this modal so the app content stays covered during the OS + // prompt. biometry() no-ops unless hasBiometry and status === ENTER. biometry(); }; @@ -61,14 +82,14 @@ const PasscodeEnter = ({ hasBiometry, finishProcess }: IPasscodePasscodeEnter) = if (sha256(p) === passcode) { finishProcess(); } else { - attempts += 1; - if (attempts >= MAX_ATTEMPTS) { + attempts.current += 1; + if (attempts.current >= MAX_ATTEMPTS) { setStatus(TYPE.LOCKED); setLockedUntil(new Date().toISOString()); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); } else { ref?.current?.wrongPasscode(); - setAttempts(attempts?.toString()); + setAttempts(attempts.current.toString()); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); } } @@ -79,11 +100,14 @@ const PasscodeEnter = ({ hasBiometry, finishProcess }: IPasscodePasscodeEnter) = return ; } + const subtitle = reason === 'enrollmentChanged' ? I18n.t('Local_authentication_biometric_enrollment_changed') : null; + return ( ; + disenroll(): Promise; + verify(opts: { promptCopy: { title: string; cancel: string } }): Promise; + // Silent check for whether the trust sentinel exists, without triggering a biometric prompt. + // Rejects on probe/storage failures so callers can distinguish errors from true absence. + hasEnrollment(): Promise; + // Silent check for whether the current biometric enrollment still matches what trust was bound to. + // iOS surfaces enrollment changes through the sentinel (hasEnrollment), so this returns true there; + // Android's sentinel survives a change, so this consults a native keystore probe instead. Never + // prompts. Returns false only when an Android enrollment change is detected. + isEnrollmentValid(): Promise; + // Whether the user has biometric unlock enabled. Owns the persisted flag so callers don't + // have to touch UserPreferences / BIOMETRY_ENABLED_KEY directly. + isEnabled(): boolean; + setEnabled(enabled: boolean): void; + // "Relock pending" marker. Set when an enrollment change is detected at a point that can't show the + // passcode itself (the init migration), so the next unlock is forced to demand it regardless of the + // auto-lock window. Owns the persisted flag so callers don't touch UserPreferences directly. + isRelockPending(): boolean; + setRelockPending(pending: boolean): void; + // Applies a biometry on/off toggle as one operation: enroll/disenroll the sentinel and persist + // the flag, keeping the keychain state and flag in sync. Returns the enroll result so callers + // can roll back their UI when enrollment fails (e.g. user cancels the OS prompt). + setBiometryEnabled(enabled: boolean): Promise; +} diff --git a/app/definitions/index.ts b/app/definitions/index.ts index b2566469043..528e937b7cd 100644 --- a/app/definitions/index.ts +++ b/app/definitions/index.ts @@ -7,6 +7,7 @@ import { type TColors, type TSupportedThemes } from '../theme'; export * from './ERoomType'; export * from './IAttachment'; +export * from './IBiometricTrustStore'; export * from './ICannedResponse'; export * from './ICertificate'; export * from './ICredentials'; diff --git a/app/i18n/locales/ar.json b/app/i18n/locales/ar.json index 53e24e6431b..3b6ba49db8b 100644 --- a/app/i18n/locales/ar.json +++ b/app/i18n/locales/ar.json @@ -324,6 +324,7 @@ "Local_authentication_auto_lock_3600": "بعد ساعة", "Local_authentication_auto_lock_60": "بعد دقيقة", "Local_authentication_auto_lock_900": "بعد 15 دقيقة", + "Local_authentication_biometric_enrollment_changed": "تم تغيير تسجيل المقاييس الحيوية،\nيرجى استخدام كلمة المرور", "Local_authentication_biometry_fallback": "استخدم كلمة المرور", "Local_authentication_biometry_title": "صادق", "Local_authentication_change_passcode": "تغيير كلمة المرور", diff --git a/app/i18n/locales/bn-IN.json b/app/i18n/locales/bn-IN.json index 9daf3675e2f..b6f0780aba9 100644 --- a/app/i18n/locales/bn-IN.json +++ b/app/i18n/locales/bn-IN.json @@ -451,6 +451,7 @@ "Local_authentication_auto_lock_3600": "1 ঘণ্টা পরে", "Local_authentication_auto_lock_60": "1 মিনিট পরে", "Local_authentication_auto_lock_900": "15 মিনিট পরে", + "Local_authentication_biometric_enrollment_changed": "বায়োমেট্রিক নিবন্ধন পরিবর্তিত হয়েছে,\nঅনুগ্রহ করে আপনার পাসকোড ব্যবহার করুন", "Local_authentication_biometry_fallback": "পাসকোড ব্যবহার করুন", "Local_authentication_biometry_title": "প্রমাণীকরণ", "Local_authentication_change_passcode": "পাসকোড পরিবর্তন করুন", diff --git a/app/i18n/locales/cs.json b/app/i18n/locales/cs.json index 1d18ac3efbb..1bb54958cf7 100644 --- a/app/i18n/locales/cs.json +++ b/app/i18n/locales/cs.json @@ -483,6 +483,7 @@ "Local_authentication_auto_lock_3600": "Po 1 hodině", "Local_authentication_auto_lock_60": "Po 1 minutě", "Local_authentication_auto_lock_900": "Po 15 minutách", + "Local_authentication_biometric_enrollment_changed": "Biometrické údaje se změnily,\npoužijte prosím přístupový kód", "Local_authentication_biometry_fallback": "Použít přístupový kód", "Local_authentication_biometry_title": "Ověřit", "Local_authentication_change_passcode": "Změnit heslo", diff --git a/app/i18n/locales/de.json b/app/i18n/locales/de.json index c1925e399ea..fc26ec0f516 100644 --- a/app/i18n/locales/de.json +++ b/app/i18n/locales/de.json @@ -445,6 +445,7 @@ "Local_authentication_auto_lock_3600": "Nach 1 Stunde", "Local_authentication_auto_lock_60": "Nach 1 Minute", "Local_authentication_auto_lock_900": "Nach 15 Minuten", + "Local_authentication_biometric_enrollment_changed": "Die biometrischen Daten wurden geändert,\nbitte verwenden Sie Ihren Sicherheitscode", "Local_authentication_biometry_fallback": "Sicherheitscode benutzen", "Local_authentication_biometry_title": "Authentifizieren", "Local_authentication_change_passcode": "Ändere Sicherheitscode", diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 8f9606fe55d..41f14628816 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -498,6 +498,7 @@ "Local_authentication_auto_lock_3600": "After 1 hour", "Local_authentication_auto_lock_60": "After 1 minute", "Local_authentication_auto_lock_900": "After 15 minutes", + "Local_authentication_biometric_enrollment_changed": "Biometric enrollment changed,\nplease use your passcode", "Local_authentication_biometry_fallback": "Use passcode", "Local_authentication_biometry_title": "Authenticate", "Local_authentication_change_passcode": "Change passcode", diff --git a/app/i18n/locales/fi.json b/app/i18n/locales/fi.json index 45e7e8c9264..346a94519fe 100644 --- a/app/i18n/locales/fi.json +++ b/app/i18n/locales/fi.json @@ -426,6 +426,7 @@ "Local_authentication_auto_lock_3600": "1 tunnin kuluttua", "Local_authentication_auto_lock_60": "1 minuutin kuluttua", "Local_authentication_auto_lock_900": "15 minuutin kuluttua", + "Local_authentication_biometric_enrollment_changed": "Biometriset tiedot ovat muuttuneet,\nkäytä salasanaasi", "Local_authentication_biometry_fallback": "Käytä salasanaa", "Local_authentication_biometry_title": "Todenna", "Local_authentication_change_passcode": "Vaihda salasana", diff --git a/app/i18n/locales/fr.json b/app/i18n/locales/fr.json index fa613486ddc..5e7708a8e03 100644 --- a/app/i18n/locales/fr.json +++ b/app/i18n/locales/fr.json @@ -391,6 +391,7 @@ "Local_authentication_auto_lock_3600": "Après 1 heure", "Local_authentication_auto_lock_60": "Après 1 minute", "Local_authentication_auto_lock_900": "Après 15 minutes", + "Local_authentication_biometric_enrollment_changed": "Les données biométriques ont changé,\nveuillez utiliser votre code d'accès", "Local_authentication_biometry_fallback": "Utiliser le code d'accès", "Local_authentication_biometry_title": "Authentifier", "Local_authentication_change_passcode": "Changer le code d'accès", diff --git a/app/i18n/locales/hi-IN.json b/app/i18n/locales/hi-IN.json index 97f3343177e..62577c0ae11 100644 --- a/app/i18n/locales/hi-IN.json +++ b/app/i18n/locales/hi-IN.json @@ -451,6 +451,7 @@ "Local_authentication_auto_lock_3600": "1 घंटा के बाद", "Local_authentication_auto_lock_60": "1 मिनट के बाद", "Local_authentication_auto_lock_900": "15 मिनट के बाद", + "Local_authentication_biometric_enrollment_changed": "बायोमेट्रिक पंजीकरण बदल गया है,\nकृपया अपना पासकोड उपयोग करें", "Local_authentication_biometry_fallback": "पासकोड का उपयोग करें", "Local_authentication_biometry_title": "प्रमाणीकरण करें", "Local_authentication_change_passcode": "पासकोड बदलें", diff --git a/app/i18n/locales/hu.json b/app/i18n/locales/hu.json index 89f68d4a3b2..814f2492978 100644 --- a/app/i18n/locales/hu.json +++ b/app/i18n/locales/hu.json @@ -452,6 +452,7 @@ "Local_authentication_auto_lock_3600": "1 óra elteltével", "Local_authentication_auto_lock_60": "1 perc elteltével", "Local_authentication_auto_lock_900": "15 perc elteltével", + "Local_authentication_biometric_enrollment_changed": "A biometrikus adatok megváltoztak,\nhasználja a jelkódját", "Local_authentication_biometry_fallback": "Jelkód használata", "Local_authentication_biometry_title": "Hitelesítés", "Local_authentication_change_passcode": "Jelkód módosítása", diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json index 5bd67b24060..0d1fa75fe8e 100644 --- a/app/i18n/locales/it.json +++ b/app/i18n/locales/it.json @@ -353,6 +353,7 @@ "Local_authentication_auto_lock_3600": "Dopo 1 ora", "Local_authentication_auto_lock_60": "Dopo 1 minuto", "Local_authentication_auto_lock_900": "Dopo 15 minuti", + "Local_authentication_biometric_enrollment_changed": "I dati biometrici sono cambiati,\nusa il Passcode", "Local_authentication_biometry_fallback": "Usa passcode", "Local_authentication_biometry_title": "Autenticazione", "Local_authentication_change_passcode": "Cambia Passcode", diff --git a/app/i18n/locales/nl.json b/app/i18n/locales/nl.json index 0d86ce4995c..aac9d50880b 100644 --- a/app/i18n/locales/nl.json +++ b/app/i18n/locales/nl.json @@ -391,6 +391,7 @@ "Local_authentication_auto_lock_3600": "Na 1 uur", "Local_authentication_auto_lock_60": "Na 1 minuut", "Local_authentication_auto_lock_900": "Na 15 minuten", + "Local_authentication_biometric_enrollment_changed": "Biometrische gegevens zijn gewijzigd,\ngebruik je toegangscode", "Local_authentication_biometry_fallback": "Gebruik toegangscode", "Local_authentication_biometry_title": "Authenticeren", "Local_authentication_change_passcode": "Wijzig toegangscode", diff --git a/app/i18n/locales/no.json b/app/i18n/locales/no.json index 097da054fa0..6d79ffad27f 100644 --- a/app/i18n/locales/no.json +++ b/app/i18n/locales/no.json @@ -477,6 +477,7 @@ "Local_authentication_auto_lock_3600": "Etter 1 time", "Local_authentication_auto_lock_60": "Etter 1 minutt", "Local_authentication_auto_lock_900": "Etter 15 minutter", + "Local_authentication_biometric_enrollment_changed": "Biometrisk registrering er endret,\nbruk passordet ditt", "Local_authentication_biometry_fallback": "Bruk passord", "Local_authentication_biometry_title": "Autentiser", "Local_authentication_change_passcode": "Endre passord", diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json index 9b2e3db9f8f..b75a635f8e7 100644 --- a/app/i18n/locales/pt-BR.json +++ b/app/i18n/locales/pt-BR.json @@ -493,6 +493,7 @@ "Local_authentication_auto_lock_3600": "Após 1 hora", "Local_authentication_auto_lock_60": "Após 1 minuto", "Local_authentication_auto_lock_900": "Após 15 minutos", + "Local_authentication_biometric_enrollment_changed": "O cadastro biométrico foi alterado,\nuse sua senha", "Local_authentication_biometry_fallback": "Usar senha", "Local_authentication_biometry_title": "Autenticar", "Local_authentication_change_passcode": "Alterar senha", diff --git a/app/i18n/locales/ru.json b/app/i18n/locales/ru.json index 950a186f227..0629d7aa640 100644 --- a/app/i18n/locales/ru.json +++ b/app/i18n/locales/ru.json @@ -416,6 +416,7 @@ "Local_authentication_auto_lock_3600": "Через 1 час", "Local_authentication_auto_lock_60": "Через 1 минуту", "Local_authentication_auto_lock_900": "Через 15 минут", + "Local_authentication_biometric_enrollment_changed": "Биометрические данные изменились,\nпожалуйста, используйте ваш Пароль", "Local_authentication_biometry_fallback": "Использовать пароль", "Local_authentication_biometry_title": "Аутентификация", "Local_authentication_change_passcode": "Изменить Пароль", diff --git a/app/i18n/locales/sl-SI.json b/app/i18n/locales/sl-SI.json index 0957302775d..8fc407744b2 100644 --- a/app/i18n/locales/sl-SI.json +++ b/app/i18n/locales/sl-SI.json @@ -401,6 +401,7 @@ "Local_authentication_auto_lock_3600": "Po 1 uri", "Local_authentication_auto_lock_60": "Po 1 minuti", "Local_authentication_auto_lock_900": "Po 15 minutah", + "Local_authentication_biometric_enrollment_changed": "Biometrični podatki so se spremenili,\nuporabite svoje geslo", "Local_authentication_biometry_fallback": "Uporabite geslo", "Local_authentication_biometry_title": "Overiti", "Local_authentication_change_passcode": "Spremenite geslo", diff --git a/app/i18n/locales/sv.json b/app/i18n/locales/sv.json index 3466cfea7ca..7bcec7b983f 100644 --- a/app/i18n/locales/sv.json +++ b/app/i18n/locales/sv.json @@ -425,6 +425,7 @@ "Local_authentication_auto_lock_3600": "Efter 1 timme", "Local_authentication_auto_lock_60": "Efter 1 minut", "Local_authentication_auto_lock_900": "Efter 15 minuter", + "Local_authentication_biometric_enrollment_changed": "Biometriska uppgifter har ändrats,\nanvänd din lösenkod", "Local_authentication_biometry_fallback": "Använd lösenkod", "Local_authentication_biometry_title": "Autentisering", "Local_authentication_change_passcode": "Ändra lösenkod", diff --git a/app/i18n/locales/ta-IN.json b/app/i18n/locales/ta-IN.json index 83f1a3251fd..acd27fd6e4c 100644 --- a/app/i18n/locales/ta-IN.json +++ b/app/i18n/locales/ta-IN.json @@ -451,6 +451,7 @@ "Local_authentication_auto_lock_3600": "1 மணி நேரத்திற்கு பிற", "Local_authentication_auto_lock_60": "1 நிமிடத்திற்கு பிற", "Local_authentication_auto_lock_900": "15 நிமிடத்திற்கு பிற", + "Local_authentication_biometric_enrollment_changed": "உயிர்முறை பதிவில் மாற்றம் ஏற்பட்டுள்ளது,\nதயவுசெய்து உங்கள் கடவுச்சொல்லைப் பயன்படுத்தவும்", "Local_authentication_biometry_fallback": "கடவுச்சொல் பயன்படுத்துக", "Local_authentication_biometry_title": "அங்கீகரிக்க", "Local_authentication_change_passcode": "கடவுச்சொல் மாற்று", diff --git a/app/i18n/locales/te-IN.json b/app/i18n/locales/te-IN.json index dab8b05080e..e9957f6ad20 100644 --- a/app/i18n/locales/te-IN.json +++ b/app/i18n/locales/te-IN.json @@ -450,6 +450,7 @@ "Local_authentication_auto_lock_3600": "1 గంట తరువాత", "Local_authentication_auto_lock_60": "1 నిమిషాల తరువాత", "Local_authentication_auto_lock_900": "15 నిమిషాల తరువాత", + "Local_authentication_biometric_enrollment_changed": "బయోమెట్రిక్ నమోదు మారింది,\nదయచేసి మీ పాస్‌కోడ్‌ను ఉపయోగించండి", "Local_authentication_biometry_fallback": "పాస్‌కోడ్ ఉపయోగించండి", "Local_authentication_biometry_title": "ధ్యానం పెట్టినవారు", "Local_authentication_change_passcode": "పాస్‌కోడ్ను మార్చండి", diff --git a/app/i18n/locales/tr.json b/app/i18n/locales/tr.json index 8c03a15c015..b2888fe6255 100644 --- a/app/i18n/locales/tr.json +++ b/app/i18n/locales/tr.json @@ -338,6 +338,7 @@ "Local_authentication_auto_lock_3600": "1 saat sonra", "Local_authentication_auto_lock_60": "1 dakika sonra", "Local_authentication_auto_lock_900": "15 dakika sonra", + "Local_authentication_biometric_enrollment_changed": "Biyometrik kayıt değişti,\nlütfen parolanızı kullanın", "Local_authentication_biometry_fallback": "Parola kullan", "Local_authentication_biometry_title": "Doğrula", "Local_authentication_change_passcode": "Parolayı Değiştir", diff --git a/app/i18n/locales/zh-CN.json b/app/i18n/locales/zh-CN.json index 396b899cefe..ac8b8aa5c78 100644 --- a/app/i18n/locales/zh-CN.json +++ b/app/i18n/locales/zh-CN.json @@ -323,6 +323,7 @@ "Local_authentication_auto_lock_3600": "一小时后", "Local_authentication_auto_lock_60": "1分钟后", "Local_authentication_auto_lock_900": "15分钟后", + "Local_authentication_biometric_enrollment_changed": "生物识别设置已更改,\n请使用您的通关密码", "Local_authentication_biometry_fallback": "使用通关密码", "Local_authentication_biometry_title": "验证", "Local_authentication_change_passcode": "变更通关密码", diff --git a/app/i18n/locales/zh-TW.json b/app/i18n/locales/zh-TW.json index a82f36c4aa9..927f3d53ef8 100644 --- a/app/i18n/locales/zh-TW.json +++ b/app/i18n/locales/zh-TW.json @@ -339,6 +339,7 @@ "Local_authentication_auto_lock_3600": "一小時後", "Local_authentication_auto_lock_60": "1分鐘後", "Local_authentication_auto_lock_900": "15分鐘後", + "Local_authentication_biometric_enrollment_changed": "生物辨識設定已變更,\n請使用您的通關密碼", "Local_authentication_biometry_fallback": "使用通關密碼", "Local_authentication_biometry_title": "驗證", "Local_authentication_change_passcode": "變更通關密碼", diff --git a/app/lib/biometricTrustStore/docs/ARCHITECTURE.md b/app/lib/biometricTrustStore/docs/ARCHITECTURE.md new file mode 100644 index 00000000000..b53d67530af --- /dev/null +++ b/app/lib/biometricTrustStore/docs/ARCHITECTURE.md @@ -0,0 +1,146 @@ +# Biometric Trust Store Architecture + +Load-bearing reference for the structure of the biometric trust store. Read this before `FLOWS.md` and `PLATFORMS.md` — those documents assume the vocabulary defined here. + +## Overview + +The biometric trust store is a single-runtime **TypeScript** subsystem layered on [`react-native-keychain`](https://github.com/oblador/react-native-keychain). It exists to answer one question at unlock time: + +> _Is the device's biometric enrollment still the same one the user opted into?_ + +It answers this by storing a **sentinel** keychain item bound to the _current_ biometric enrollment set. When the enrollment changes (a face/fingerprint added or removed), the OS invalidates that item. The trust store reads the invalidation as a signal to drop biometric unlock and force passcode re-authentication. + +The trust store is **not** the screen-lock feature itself. Screen lock (passcode, auto-lock timer, the lock modal) lives in [`../../methods/helpers/localAuthentication.ts`](../../methods/helpers/localAuthentication.ts) and [`../../../containers/Passcode/`](../../../containers/Passcode/). The trust store is the narrow component screen lock calls to decide whether biometric unlock is _trustworthy right now_. + +--- + +## The sentinel + +The security primitive is a single keychain entry, defined in [`../../constants/localAuthentication.ts`](../../constants/localAuthentication.ts): + +| Constant | Value | +| ----------------------------------- | -------------------- | +| `BIOMETRIC_TRUST_SENTINEL_SERVICE` | `rc-biometric-trust` | +| `BIOMETRIC_TRUST_SENTINEL_USERNAME` | `biometric-trust` | +| `BIOMETRIC_TRUST_SENTINEL_VALUE` | `v1` | + +It is written with two keychain options that together make it a tripwire (`index.ts`, `writeOptions`): + +- `accessControl: BIOMETRY_CURRENT_SET` — binds the item to the **current** biometric enrollment. This is the crux: the OS tears the item down when the enrollment set changes. See `PLATFORMS.md` for how each OS does this. +- `accessible: WHEN_UNLOCKED_THIS_DEVICE_ONLY` — never leaves the device, never restores from a backup to a different device. + +Writing and probing the sentinel are **silent** (no biometric prompt). Only _reading the value back_ (`verify()`) presents the OS biometric sheet. This distinction drives the whole API design — see "Why writing the sentinel is not consent" below. + +--- + +## The `TrustResult` union + +Every trust operation returns a discriminated union ([`../../../definitions/IBiometricTrustStore.ts`](../../../definitions/IBiometricTrustStore.ts)): + +```ts +type TrustResult = + | { kind: 'success' } // sentinel read back, biometric matched + | { kind: 'canceled' } // user dismissed the OS prompt + | { kind: 'enrollmentChanged' } // enrollment changed -> item invalidated + | { kind: 'unavailable' } // sentinel absent before any prompt + | { kind: 'error'; cause: unknown }; +``` + +`classifyError(e)` (`index.ts`) maps raw native errors onto these kinds: + +| Raw signal | Mapped kind | +| --------------------------------------------------------- | ------------------------- | +| `errSecUserCancel` / `UserCancel` / `-128` | `canceled` | +| `KeyPermanentlyInvalidatedException` (Android) | `enrollmentChanged` | +| `errSecItemNotFound` / `-25300` (iOS, _after_ the prompt) | `enrollmentChanged` | +| anything else | `error` (carries `cause`) | + +The `unavailable` kind is **not** produced by `classifyError`. It is returned by `verify()` when `hasEnrollment()` finds no sentinel _before_ any prompt is shown. This separation matters on iOS — see `PLATFORMS.md`. + +--- + +## The store API (`IBiometricTrustStore`) + +The `biometricTrustStore` singleton (`index.ts`) implements: + +| Method | Prompts? | Purpose | +| ------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enroll()` | no | Write the sentinel. On success also sets the migration marker (see below). Returns `TrustResult`. | +| `disenroll()` | no | Delete the sentinel. Best-effort; swallows errors (item may already be gone). | +| `verify({ promptCopy })` | **yes** | Probe the sentinel; if present, read it back behind the OS biometric sheet. Returns `TrustResult`. | +| `hasEnrollment()` | no | Silent existence check for the sentinel. | +| `isEnabled()` / `setEnabled(b)` | no | Own the persisted `BIOMETRY_ENABLED_KEY` flag so callers never touch `UserPreferences` directly. | +| `setBiometryEnabled(b)` | yes if enabling | One-shot toggle: enroll+enable or disenroll+disable, keeping keychain state and the flag in sync. Returns the enroll `TrustResult` so the caller can roll back its UI on failure. | + +### Two pieces of persisted state + +Both live in `UserPreferences` (keys in `constants/localAuthentication.ts`): + +1. **`BIOMETRY_ENABLED_KEY`** (`kBiometryEnabled`) — "the user wants biometric unlock." Owned by `isEnabled`/`setEnabled`. Referred to throughout the docs as **the flag**. +2. **`BIOMETRIC_TRUST_MIGRATION_V1_DONE`** (`kBiometricTrustMigrationV1Done`) — "this install is trust-initialized." Referred to as **the migration marker** or **the marker**. + +The sentinel (keychain) is the third piece of state. The interplay between flag, marker, and sentinel is the entire subtlety of this subsystem — see "Migration" below. + +### Why writing the sentinel is not consent + +`enroll()` writes the sentinel silently — no biometric prompt — so it **cannot** double as proof the user agreed to biometric unlock. Callers that need real consent (the first-passcode opt-in in `checkBiometry`) must follow `enroll()` with a `verify()` prompt and tear the sentinel back down if the user declines. This is why `enroll` and the consent prompt are separate steps rather than one call. See `FLOWS.md` §2. + +--- + +## Migration + +`runBiometricTrustMigration` (`migration.ts`) is a one-shot upgrade path, run once at app init from the `restore` saga ([`../../../sagas/init.js`](../../../sagas/init.js)) **before** server/user restoration. It exists because users who enabled biometry _before_ the sentinel feature shipped have the flag set but no sentinel — there is nothing to detect enrollment changes against. + +It is a pure function of three inputs: **flag** (`isEnabled()`), **sentinel** (`hasEnrollment()`), **marker** (the migration bool). + +| flag | sentinel | marker | Action | Why | +| :---: | :------: | :-------: | ------------------------------ | -------------------------------------------------------- | +| false | — | — | no-op | biometry not enabled; nothing to reconcile | +| true | present | — | no-op | healthy: flag and sentinel agree | +| true | absent | **false** | `enroll()`, set marker | **grandfather**: pre-feature user, bind a sentinel once | +| true | absent | **true** | `setEnabled(false)`, no enroll | **reconciliation**: flag/sentinel desync, clear the flag | + +If `enroll()` fails during the grandfather path, the marker is intentionally left unset so the next boot retries, and the flag is left as-is so the next unlock falls into `verify()`'s `unavailable` branch and asks for the passcode. + +### The invariant that makes the grandfather path safe + +> **Every successful `enroll()` sets the migration marker** (`index.ts`). + +This is the security-critical line. Without it, an app-driven enroll (settings toggle or first-passcode opt-in) would leave `marker = false`. A later enrollment-change invalidation (flag set, sentinel gone) would then route to the **grandfather** row instead of **reconciliation** — silently re-binding the sentinel to the _new_ (attacker-inclusive) enrollment on the next launch, since writing the sentinel doesn't prompt. That is exactly the bypass this subsystem exists to close. + +Because `enroll()` sets the marker, any post-feature user always has `marker = true`, so a missing sentinel routes to **reconciliation** (clear the flag). Only genuine pre-feature users (`marker = false`) ever take the one-time grandfather branch. + +--- + +## `resolveBiometricTrust` — outcome mapping + +`resolveBiometricTrust(result)` (`resolveBiometricTrust.ts`) is the policy layer: it maps a `verify()` `TrustResult` onto a `BiometricTrustOutcome` — whether the app unlocks, and the modal config to show next. + +```ts +type BiometricTrustOutcome = + | { unlocked: true } + | { unlocked: false; modal: { hasBiometry: boolean; reason?: 'enrollmentChanged' } }; +``` + +| `verify()` kind | Side effects | Outcome | +| -------------------- | ----------------------------------- | ---------------------------------------------------------------------------------- | +| `success` | — | `{ unlocked: true }` | +| `enrollmentChanged` | `disenroll()` → `setEnabled(false)` | locked; modal hides biometry, shows the enrollment-changed subtitle | +| `unavailable` | `disenroll()` → `setEnabled(false)` | locked; modal hides biometry, **no** subtitle (can be benign — see `PLATFORMS.md`) | +| `canceled` / `error` | none | locked; modal **keeps** the biometry button so the user can retry | + +### The disenroll-before-clear ordering invariant + +> On any invalidation, `disenroll()` **must** run before `setEnabled(false)`. + +If a crash happens between the two, the surviving state is _flag set, sentinel gone_ — which the migration's **reconciliation** row cleans up on the next launch. The reverse order would leave _flag cleared, sentinel live_, which looks like a healthy disabled state and orphans the sentinel forever (no path ever reconciles it). + +--- + +## Invariants summary + +1. **Writing/probing the sentinel never prompts; only `verify()` does.** Consent requires a `verify()`, not an `enroll()`. +2. **Every successful `enroll()` sets the migration marker.** Keeps app-driven enrolls out of the grandfather branch. +3. **On invalidation, `disenroll()` precedes `setEnabled(false)`.** Keeps a crash recoverable by reconciliation. +4. **Flag and sentinel are kept in lockstep.** `setBiometryEnabled`, `checkBiometry`, and `resolveBiometricTrust` never leave one set without the other (the migration is the safety net for crashes that break this). +5. **The sentinel is `THIS_DEVICE_ONLY`.** It never restores across devices, so a restored backup correctly reads as `unavailable`. diff --git a/app/lib/biometricTrustStore/docs/FLOWS.md b/app/lib/biometricTrustStore/docs/FLOWS.md new file mode 100644 index 00000000000..0dcbf57cf5a --- /dev/null +++ b/app/lib/biometricTrustStore/docs/FLOWS.md @@ -0,0 +1,170 @@ +# Biometric Trust Store Flows + +Sequence diagrams for the handshakes between the screen-lock UI, the trust store, and the OS keychain. Each diagram describes ordering and ownership; method signatures live in the code, not here. Read `ARCHITECTURE.md` first — these diagrams use its vocabulary (sentinel, flag, marker, `TrustResult` kinds). + +Participants used below: + +- **Settings** — `ScreenLockConfigView.tsx` +- **Passcode** — `PasscodeEnter.tsx` +- **LocalAuth** — `methods/helpers/localAuthentication.ts` +- **Store** — `biometricTrustStore` (`index.ts`) +- **Resolve** — `resolveBiometricTrust.ts` +- **OS** — `react-native-keychain` → platform keychain / keystore + +--- + +## 1. Enable / disable from the settings toggle + +`toggleBiometry` routes the whole on/off operation through `setBiometryEnabled`, which keeps the keychain and the flag in sync and reports failure so the switch can roll back. + +```mermaid +sequenceDiagram + autonumber + participant User + participant Settings + participant Store + participant OS + + User->>Settings: flip biometry switch ON + Settings->>Store: setBiometryEnabled(true) + Store->>OS: enroll() — setGenericPassword (BIOMETRY_CURRENT_SET, silent) + alt write succeeds + OS-->>Store: ok + Store->>Store: set migration marker = true + Store->>Store: setEnabled(true) + Store-->>Settings: { kind: 'success' } + else write fails / unsupported + OS-->>Store: error + Store->>Store: setEnabled(false) + Store-->>Settings: failure TrustResult + Settings->>Settings: revert switch to OFF + end + + User->>Settings: flip biometry switch OFF + Settings->>Store: setBiometryEnabled(false) + Store->>OS: disenroll() — resetGenericPassword (best-effort) + Store->>Store: setEnabled(false) + Store-->>Settings: { kind: 'success' } +``` + +Note the enable path does **not** prompt for biometrics — writing the sentinel is silent. The toggle treats "sentinel written" as enough; explicit consent is only required on the first-passcode opt-in (flow 2), where there is no prior screen-lock context. + +--- + +## 2. First-passcode opt-in (`checkBiometry`) + +When the user sets their first passcode, screen lock asks whether to also enable biometric unlock. Because `enroll()` is silent, consent is captured with a **second** call — a `verify()` prompt — and the sentinel is torn down if the user declines. + +```mermaid +sequenceDiagram + autonumber + participant LocalAuth + participant Store + participant OS + participant User + + Note over LocalAuth: checkHasPasscode set a new passcode → checkBiometry() + LocalAuth->>Store: enroll() — write sentinel (silent) + alt enroll fails + Store-->>LocalAuth: failure + LocalAuth->>Store: setEnabled(false) + else enroll succeeds + Store->>Store: set migration marker = true + Store-->>LocalAuth: success + LocalAuth->>Store: verify({ cancel: "Don't activate" }) + Store->>OS: getGenericPassword → OS biometric sheet + OS->>User: prompt + alt user authenticates + User-->>OS: ok + OS-->>Store: sentinel value + Store-->>LocalAuth: { kind: 'success' } + LocalAuth->>Store: setEnabled(true) + else user taps "Don't activate" + User-->>OS: cancel + Store-->>LocalAuth: { kind: 'canceled' } + LocalAuth->>Store: disenroll() — tear sentinel back down + LocalAuth->>Store: setEnabled(false) + end + end +``` + +The `verify()` here doubles as the consent prompt: succeeding means the user agreed _and_ proved the current enrollment works; declining opts out and cleans up. + +--- + +## 3. Auto-unlock and enrollment-change detection + +The most security-sensitive flow. When auto-lock fires, `handleLocalAuthentication` opens the passcode modal **first** so the app content is covered, then `PasscodeEnter` prompts biometry from _behind_ the modal. Prompting before the modal exists would flash the app content under the OS sheet and defeat screen lock. + +```mermaid +sequenceDiagram + autonumber + participant LocalAuth + participant Passcode + participant Store + participant OS + participant Resolve + + LocalAuth->>Passcode: openModal(hasBiometry) — modal now covers the app + Note over Passcode: on mount, status === ENTER → auto-run biometry() + Passcode->>Store: verify({ promptCopy }) + Store->>OS: hasEnrollment()? (silent) + alt sentinel present + OS-->>Store: yes + Store->>OS: getGenericPassword → OS biometric sheet + alt biometric matches & value read back + OS-->>Store: 'v1' + Store-->>Passcode: { kind: 'success' } + else iOS: errSecItemNotFound after prompt + OS-->>Store: -25300 + Store-->>Passcode: { kind: 'enrollmentChanged' } + else Android: KeyPermanentlyInvalidatedException + OS-->>Store: exception + Store-->>Passcode: { kind: 'enrollmentChanged' } + else user cancels + OS-->>Store: -128 + Store-->>Passcode: { kind: 'canceled' } + end + else sentinel absent (iOS often lands here first — see PLATFORMS.md) + OS-->>Store: no + Store-->>Passcode: { kind: 'unavailable' } + end + + Passcode->>Resolve: resolveBiometricTrust(result) + alt success + Resolve-->>Passcode: { unlocked: true } + Passcode->>LocalAuth: finishProcess() — app unlocks + else enrollmentChanged / unavailable + Resolve->>Store: disenroll() + Resolve->>Store: setEnabled(false) + Resolve-->>Passcode: { unlocked:false, modal:{ hasBiometry:false, reason? } } + Note over Passcode: hide biometry button in-place,
show enrollment-changed subtitle if reason set,
user must enter passcode + else canceled / error + Resolve-->>Passcode: { unlocked:false, modal:{ hasBiometry:true } } + Note over Passcode: keep biometry button for manual retry + end +``` + +`PasscodeEnter` mirrors `hasBiometry`/`reason` in local state so an invalidation hides the button **within the same modal session** without re-emitting `LOCAL_AUTHENTICATE_EMITTER` (which would orphan the upstream `openModal` promise). + +--- + +## 4. Init-time migration + +Runs once per launch from the `restore` saga, before any server/user restoration. Pure decision over flag/sentinel/marker — see the truth table in `ARCHITECTURE.md`. + +```mermaid +flowchart TD + A[runBiometricTrustMigration] --> B{flag enabled?} + B -- no --> Z[no-op] + B -- yes --> C{sentinel exists?} + C -- yes --> Z + C -- no --> D{marker set?} + D -- "no (pre-feature user)" --> E[enroll] + E --> F{enroll ok?} + F -- yes --> G[set marker = true] + F -- no --> H[leave marker & flag
next boot retries; unlock asks passcode] + D -- "yes (post-feature desync)" --> I[setEnabled false
clear the flag] +``` + +The grandfather branch (`marker = no`) is reachable only by users who enabled biometry before the sentinel feature existed. Every app-driven `enroll()` sets the marker, so post-feature users with a missing sentinel always reach the reconciliation branch instead — closing the silent re-bind. diff --git a/app/lib/biometricTrustStore/docs/PLATFORMS.md b/app/lib/biometricTrustStore/docs/PLATFORMS.md new file mode 100644 index 00000000000..516af712ba4 --- /dev/null +++ b/app/lib/biometricTrustStore/docs/PLATFORMS.md @@ -0,0 +1,53 @@ +# Platforms + +iOS- and Android-specific behaviour of the biometric trust store. The shared model (sentinel, `TrustResult` kinds, migration, invariants) lives in `ARCHITECTURE.md`; this file does not duplicate it. + +Both platforms share the same intent: an entry stored under `accessControl: BIOMETRY_CURRENT_SET` is invalidated by the OS when the biometric enrollment set changes. They differ in _how_ that invalidation surfaces, and the difference is load-bearing for the `unavailable`-vs-`enrollmentChanged` split in `verify()`. + +## iOS + +### How an enrollment change surfaces + +On iOS, changing the Face ID / Touch ID enrollment **deletes** the keychain item bound to `BIOMETRY_CURRENT_SET`. The item is gone, not merely locked. This produces two distinct observations depending on _when_ the store looks: + +1. **Before any prompt** — `verify()` calls `hasEnrollment()` first (`hasGenericPassword`, silent). The item is already gone, so this returns false and `verify()` returns **`unavailable`** — no biometric sheet is ever shown. +2. **After a prompt** — if the item still appeared to exist and the read (`getGenericPassword`) raised `errSecItemNotFound` (`-25300`), `classifyError` maps it to **`enrollmentChanged`**. + +In practice on iOS the enrollment-change case usually lands as **`unavailable`** via path 1, because the deletion is observed by the silent existence check before the read path runs. `resolveBiometricTrust` treats both the same way at the security level — disenroll, clear the flag, hide the biometry button — but `unavailable` shows **no** "enrollment changed" subtitle, because it is not necessarily an enrollment change (see below). + +### Why `unavailable` has no subtitle + +A missing sentinel on iOS is not _always_ an attack signal. The sentinel is `WHEN_UNLOCKED_THIS_DEVICE_ONLY`, so it legitimately does not exist after: + +- restoring an app backup onto a new device (the item never transfers), or +- any other benign loss of the keychain item. + +So `unavailable` clears biometric unlock defensively (fail closed — require the passcode) but does **not** accuse the user of an enrollment change. Only the explicit `enrollmentChanged` kind shows the subtitle copy. + +### Prompt-behind-modal requirement + +The OS biometric sheet must never appear with app content visible behind it, or screen lock is defeated for the duration of the sheet. iOS makes this easy to get wrong because the sheet can be triggered from anywhere. The contract: `handleLocalAuthentication` opens the passcode modal first, and `PasscodeEnter` is the only place that calls `verify()` (auto on mount and via the retry button). There is intentionally **no** upstream biometric preflight. See `FLOWS.md` §3. + +## Android + +### How an enrollment change surfaces + +On Android the keystore key backing the item is **invalidated but not deleted**. Reading it raises `KeyPermanentlyInvalidatedException`, which `classifyError` maps to **`enrollmentChanged`**. So Android typically reaches the explicit `enrollmentChanged` kind (and its subtitle), where iOS more often reaches `unavailable`. + +This is the key asymmetry to keep in mind when reading or testing the flow: **the same user action (adding a fingerprint) can produce `unavailable` on iOS and `enrollmentChanged` on Android.** The security response is identical; only the subtitle differs. Tests must not assume one kind across both platforms. + +### Cancel signal + +A dismissed prompt surfaces as an `AuthenticationCanceled`/`UserCancel`-style error, mapped to `canceled` — the biometry button is kept for manual retry, matching iOS's `errSecUserCancel` / `-128`. + +## Quick comparison + +| | iOS | Android | +| ----------------------------------------------- | --------------------------------------------- | ------------------------------------------- | +| Enrollment change on the item | item **deleted** | key **invalidated**, not deleted | +| Usual `verify()` kind after a change | `unavailable` (silent existence check) | `enrollmentChanged` (read raises exception) | +| Native signal classified to `enrollmentChanged` | `errSecItemNotFound` / `-25300` (post-prompt) | `KeyPermanentlyInvalidatedException` | +| Cancel signal | `errSecUserCancel` / `-128` | `AuthenticationCanceled` / `UserCancel` | +| Sentinel survives device migration? | no (`THIS_DEVICE_ONLY`) | no (`THIS_DEVICE_ONLY`) | + +In all cases the user-facing result is the same fail-closed behaviour: biometric unlock is dropped and the passcode is required. diff --git a/app/lib/biometricTrustStore/docs/README.md b/app/lib/biometricTrustStore/docs/README.md new file mode 100644 index 00000000000..d3173458507 --- /dev/null +++ b/app/lib/biometricTrustStore/docs/README.md @@ -0,0 +1,36 @@ +# Biometric Trust Store Documentation + +Entry point for documentation of the biometric trust store — the subsystem that lets screen lock detect when a device's biometric enrollment has changed and refuse to auto-unlock with it. + +This is a **security control**, not a UX convenience. Its whole reason to exist is to defend against an *authentication-bypass-via-biometric-enrollment-change* attack: someone who knows the device passcode adds their own face/fingerprint, then expects to unlock the app with it. The trust store turns that enrollment change into a forced re-authentication. + +## Index + +| Document | Purpose | +| -------- | ------- | +| [`ARCHITECTURE.md`](ARCHITECTURE.md) | Subsystem structure: files, the keychain sentinel, the trust-store API, the `TrustResult` union, the migration state machine, and the invariants that keep keychain state and the enabled flag in sync | +| [`FLOWS.md`](FLOWS.md) | Sequence diagrams: enable/disable toggle, first-passcode opt-in, auto-unlock + enrollment-change detection, and the init-time migration | +| [`PLATFORMS.md`](PLATFORMS.md) | iOS vs Android quirks: how each OS signals an enrollment change, the `unavailable`-vs-`enrollmentChanged` divergence, and backup/restore edge cases | + +## The subsystem at a glance + +``` +app/lib/biometricTrustStore/ + index.ts biometricTrustStore singleton + classifyError + migration.ts runBiometricTrustMigration (one-shot, runs at init) + resolveBiometricTrust.ts maps a verify() TrustResult -> unlock outcome + modal config + docs/ you are here +``` + +Type contract and shared vocabulary live in [`../../../definitions/IBiometricTrustStore.ts`](../../../definitions/IBiometricTrustStore.ts). Keychain sentinel and storage keys live in [`../../constants/localAuthentication.ts`](../../constants/localAuthentication.ts). + +### Consumers + +- [`../../methods/helpers/localAuthentication.ts`](../../methods/helpers/localAuthentication.ts) — `checkBiometry` (first-passcode opt-in), `biometryAuth` (verify wrapper), `handleLocalAuthentication` (opens the passcode modal). +- [`../../../containers/Passcode/PasscodeEnter.tsx`](../../../containers/Passcode/PasscodeEnter.tsx) — runs the biometry prompt *behind* the passcode modal and reacts to the outcome. +- [`../../../views/ScreenLockConfigView.tsx`](../../../views/ScreenLockConfigView.tsx) — the Screen Lock settings screen with the biometry toggle. +- [`../../../sagas/init.js`](../../../sagas/init.js) — runs `runBiometricTrustMigration` once during app restore, before server/user restoration. + +## Read order + +Start with `ARCHITECTURE.md` — `FLOWS.md` and `PLATFORMS.md` assume the vocabulary it defines (sentinel, `TrustResult` kinds, enabled flag, migration marker). diff --git a/app/lib/biometricTrustStore/index.test.ts b/app/lib/biometricTrustStore/index.test.ts new file mode 100644 index 00000000000..de0766f2685 --- /dev/null +++ b/app/lib/biometricTrustStore/index.test.ts @@ -0,0 +1,278 @@ +import * as Keychain from 'react-native-keychain'; + +import { biometricTrustStore, classifyError } from './index'; +import { disenrollProbe, enrollProbe, isEnrollmentValid } from './nativeEnrollmentProbe'; +import UserPreferences from '../methods/userPreferences'; +import { BIOMETRIC_TRUST_MIGRATION_V1_DONE } from '../constants/localAuthentication'; + +jest.mock('../methods/userPreferences', () => ({ + __esModule: true, + default: { getBool: jest.fn(), setBool: jest.fn(), getString: jest.fn(), setString: jest.fn() } +})); + +jest.mock('./nativeEnrollmentProbe', () => ({ + enrollProbe: jest.fn(() => Promise.resolve()), + disenrollProbe: jest.fn(() => Promise.resolve()), + isEnrollmentValid: jest.fn(() => Promise.resolve(true)) +})); + +const mockedKeychain = Keychain as jest.Mocked; +const mockedSetBool = UserPreferences.setBool as jest.Mock; +const mockedEnrollProbe = enrollProbe as jest.Mock; +const mockedDisenrollProbe = disenrollProbe as jest.Mock; +const mockedIsEnrollmentValid = isEnrollmentValid as jest.Mock; + +const promptCopy = { title: 'Authenticate', cancel: 'Cancel' }; + +describe('biometricTrustStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('classifyError', () => { + it('maps Android KeyPermanentlyInvalidatedException to enrollmentChanged', () => { + expect(classifyError({ message: 'android.security.keystore.KeyPermanentlyInvalidatedException: ...' })).toEqual({ + kind: 'enrollmentChanged' + }); + }); + + it('maps iOS errSecItemNotFound (-25300) to enrollmentChanged', () => { + expect(classifyError({ code: '-25300', message: 'errSecItemNotFound' })).toEqual({ kind: 'enrollmentChanged' }); + }); + + it('maps errSecUserCancel to canceled', () => { + expect(classifyError({ message: 'errSecUserCancel' })).toEqual({ kind: 'canceled' }); + }); + + it('maps Android user cancellation to canceled', () => { + expect(classifyError({ message: 'AuthenticationCanceled' })).toEqual({ kind: 'canceled' }); + }); + + it('maps errSecUserCanceled code -128 to canceled', () => { + expect(classifyError({ code: -128, message: 'The operation was aborted' })).toEqual({ kind: 'canceled' }); + }); + + it('does not classify unrelated errors mentioning -128 in the message as canceled', () => { + const cause = { code: '-34018', message: 'keychain failed with status -128 in payload' }; + expect(classifyError(cause)).toEqual({ kind: 'error', cause }); + }); + + it('falls back to error with original cause for unknown failures', () => { + const cause = new Error('boom'); + expect(classifyError(cause)).toEqual({ kind: 'error', cause }); + }); + }); + + describe('enroll', () => { + it('writes sentinel with BIOMETRY_CURRENT_SET and WHEN_UNLOCKED_THIS_DEVICE_ONLY', async () => { + mockedKeychain.setGenericPassword.mockResolvedValueOnce(true as any); + + const result = await biometricTrustStore.enroll(); + + expect(result).toEqual({ kind: 'success' }); + expect(mockedKeychain.setGenericPassword).toHaveBeenCalledTimes(1); + const [, , options] = mockedKeychain.setGenericPassword.mock.calls[0]; + expect(options).toMatchObject({ + accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET, + accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY + }); + }); + + it('marks the install trust-initialized on success so the migration grandfather path cannot fire', async () => { + mockedKeychain.setGenericPassword.mockResolvedValueOnce(true as any); + + await biometricTrustStore.enroll(); + + expect(mockedSetBool).toHaveBeenCalledWith(BIOMETRIC_TRUST_MIGRATION_V1_DONE, true); + }); + + it('binds the Android native probe in lockstep with the sentinel', async () => { + mockedKeychain.setGenericPassword.mockResolvedValueOnce(true as any); + + await biometricTrustStore.enroll(); + + expect(mockedEnrollProbe).toHaveBeenCalledTimes(1); + }); + + it('does not bind the native probe when the sentinel write fails', async () => { + mockedKeychain.setGenericPassword.mockRejectedValueOnce(new Error('errSecUserCancel')); + + await biometricTrustStore.enroll(); + + expect(mockedEnrollProbe).not.toHaveBeenCalled(); + }); + + it('classifies setGenericPassword failures and leaves the marker untouched', async () => { + mockedKeychain.setGenericPassword.mockRejectedValueOnce(new Error('errSecUserCancel')); + expect(await biometricTrustStore.enroll()).toEqual({ kind: 'canceled' }); + expect(mockedSetBool).not.toHaveBeenCalledWith(BIOMETRIC_TRUST_MIGRATION_V1_DONE, true); + }); + }); + + describe('disenroll', () => { + it('deletes the sentinel via resetGenericPassword', async () => { + mockedKeychain.resetGenericPassword.mockResolvedValueOnce(true as any); + + await biometricTrustStore.disenroll(); + + expect(mockedKeychain.resetGenericPassword).toHaveBeenCalledTimes(1); + }); + + it('swallows errors so a missing sentinel is not fatal', async () => { + mockedKeychain.resetGenericPassword.mockRejectedValueOnce(new Error('not found')); + await expect(biometricTrustStore.disenroll()).resolves.toBeUndefined(); + }); + + it('tears down the Android native probe alongside the sentinel', async () => { + mockedKeychain.resetGenericPassword.mockResolvedValueOnce(true as any); + + await biometricTrustStore.disenroll(); + + expect(mockedDisenrollProbe).toHaveBeenCalledTimes(1); + }); + + it('still tears down the native probe even when the sentinel delete throws', async () => { + mockedKeychain.resetGenericPassword.mockRejectedValueOnce(new Error('not found')); + + await biometricTrustStore.disenroll(); + + expect(mockedDisenrollProbe).toHaveBeenCalledTimes(1); + }); + }); + + describe('isEnrollmentValid', () => { + it('delegates to the native probe (true → valid)', async () => { + mockedIsEnrollmentValid.mockResolvedValueOnce(true); + expect(await biometricTrustStore.isEnrollmentValid()).toBe(true); + }); + + it('delegates to the native probe (false → Android enrollment changed)', async () => { + mockedIsEnrollmentValid.mockResolvedValueOnce(false); + expect(await biometricTrustStore.isEnrollmentValid()).toBe(false); + }); + }); + + describe('hasEnrollment', () => { + it('uses hasGenericPassword and does not prompt', async () => { + mockedKeychain.hasGenericPassword.mockResolvedValueOnce(true); + + const exists = await biometricTrustStore.hasEnrollment(); + + expect(exists).toBe(true); + expect(mockedKeychain.hasGenericPassword).toHaveBeenCalledTimes(1); + expect(mockedKeychain.getGenericPassword).not.toHaveBeenCalled(); + }); + + it('rejects when the silent probe throws', async () => { + mockedKeychain.hasGenericPassword.mockRejectedValueOnce(new Error('broken')); + await expect(biometricTrustStore.hasEnrollment()).rejects.toThrow('broken'); + }); + }); + + describe('verify', () => { + it('returns unavailable when sentinel does not exist (no prompt)', async () => { + mockedKeychain.hasGenericPassword.mockResolvedValueOnce(false); + + const result = await biometricTrustStore.verify({ promptCopy }); + + expect(result).toEqual({ kind: 'unavailable' }); + expect(mockedKeychain.getGenericPassword).not.toHaveBeenCalled(); + }); + + it('returns success when sentinel matches', async () => { + mockedKeychain.hasGenericPassword.mockResolvedValueOnce(true); + mockedKeychain.getGenericPassword.mockResolvedValueOnce({ + service: 'svc', + username: 'biometric-trust', + password: 'v1', + storage: 'keychain' + } as any); + + const result = await biometricTrustStore.verify({ promptCopy }); + + expect(result).toEqual({ kind: 'success' }); + }); + + it('returns enrollmentChanged when Android raises KeyPermanentlyInvalidatedException', async () => { + mockedKeychain.hasGenericPassword.mockResolvedValueOnce(true); + mockedKeychain.getGenericPassword.mockRejectedValueOnce(new Error('KeyPermanentlyInvalidatedException')); + + expect(await biometricTrustStore.verify({ promptCopy })).toEqual({ kind: 'enrollmentChanged' }); + }); + + it('returns enrollmentChanged when iOS raises errSecItemNotFound after the prompt', async () => { + mockedKeychain.hasGenericPassword.mockResolvedValueOnce(true); + mockedKeychain.getGenericPassword.mockRejectedValueOnce({ code: '-25300', message: 'errSecItemNotFound' }); + + expect(await biometricTrustStore.verify({ promptCopy })).toEqual({ kind: 'enrollmentChanged' }); + }); + + it('returns canceled when the user dismisses the prompt', async () => { + mockedKeychain.hasGenericPassword.mockResolvedValueOnce(true); + mockedKeychain.getGenericPassword.mockRejectedValueOnce({ message: 'errSecUserCancel' }); + + expect(await biometricTrustStore.verify({ promptCopy })).toEqual({ kind: 'canceled' }); + }); + + it('returns error when the silent probe throws', async () => { + const cause = new Error('broken'); + mockedKeychain.hasGenericPassword.mockRejectedValueOnce(cause); + + expect(await biometricTrustStore.verify({ promptCopy })).toEqual({ kind: 'error', cause }); + expect(mockedKeychain.getGenericPassword).not.toHaveBeenCalled(); + }); + + it('forwards the prompt copy to keychain', async () => { + mockedKeychain.hasGenericPassword.mockResolvedValueOnce(true); + mockedKeychain.getGenericPassword.mockResolvedValueOnce({ + service: 'svc', + username: 'biometric-trust', + password: 'v1', + storage: 'keychain' + } as any); + + await biometricTrustStore.verify({ promptCopy }); + + const [options] = mockedKeychain.getGenericPassword.mock.calls[0]; + expect(options).toMatchObject({ authenticationPrompt: { title: 'Authenticate', cancel: 'Cancel' } }); + }); + }); + + describe('setBiometryEnabled', () => { + it('enabling: enrolls then persists the flag as enabled', async () => { + const enroll = jest.spyOn(biometricTrustStore, 'enroll').mockResolvedValueOnce({ kind: 'success' }); + const setEnabled = jest.spyOn(biometricTrustStore, 'setEnabled').mockImplementation(() => {}); + + const result = await biometricTrustStore.setBiometryEnabled(true); + + expect(result).toEqual({ kind: 'success' }); + expect(enroll).toHaveBeenCalledTimes(1); + expect(setEnabled).toHaveBeenCalledWith(true); + }); + + it('enabling: enroll failure forces the flag off and returns the failure', async () => { + const enroll = jest.spyOn(biometricTrustStore, 'enroll').mockResolvedValueOnce({ kind: 'canceled' }); + const setEnabled = jest.spyOn(biometricTrustStore, 'setEnabled').mockImplementation(() => {}); + + const result = await biometricTrustStore.setBiometryEnabled(true); + + expect(result).toEqual({ kind: 'canceled' }); + expect(enroll).toHaveBeenCalledTimes(1); + expect(setEnabled).toHaveBeenCalledWith(false); + expect(setEnabled).toHaveBeenCalledTimes(1); + }); + + it('disabling: disenrolls then persists the flag as disabled', async () => { + const enroll = jest.spyOn(biometricTrustStore, 'enroll'); + const disenroll = jest.spyOn(biometricTrustStore, 'disenroll').mockResolvedValueOnce(); + const setEnabled = jest.spyOn(biometricTrustStore, 'setEnabled').mockImplementation(() => {}); + + const result = await biometricTrustStore.setBiometryEnabled(false); + + expect(result).toEqual({ kind: 'success' }); + expect(enroll).not.toHaveBeenCalled(); + expect(disenroll).toHaveBeenCalledTimes(1); + expect(setEnabled).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/app/lib/biometricTrustStore/index.ts b/app/lib/biometricTrustStore/index.ts new file mode 100644 index 00000000000..a4aefdd7049 --- /dev/null +++ b/app/lib/biometricTrustStore/index.ts @@ -0,0 +1,147 @@ +import * as Keychain from 'react-native-keychain'; + +import { type IBiometricTrustStore, type TrustResult } from '../../definitions'; +import UserPreferences from '../methods/userPreferences'; +import { disenrollProbe, enrollProbe, isEnrollmentValid } from './nativeEnrollmentProbe'; +import { + BIOMETRIC_TRUST_MIGRATION_V1_DONE, + BIOMETRIC_TRUST_SENTINEL_SERVICE as SENTINEL_SERVICE, + BIOMETRIC_TRUST_SENTINEL_USERNAME as SENTINEL_USERNAME, + BIOMETRIC_TRUST_SENTINEL_VALUE as SENTINEL_VALUE, + BIOMETRIC_PENDING_RELOCK_KEY, + BIOMETRY_ENABLED_KEY +} from '../constants/localAuthentication'; + +// BIOMETRY_CURRENT_SET binds the item to the *current* biometric enrollment on both platforms; iOS +// invalidates the keychain entry when the enrollment set changes (errSecItemNotFound on read), and +// Android raises KeyPermanentlyInvalidatedException. That invalidation signal is the security +// primitive this whole module exists for. +const writeOptions = (): Keychain.SetOptions => ({ + service: SENTINEL_SERVICE, + accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET, + accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY +}); + +const readOptions = (promptCopy: { title: string; cancel: string }): Keychain.GetOptions => ({ + service: SENTINEL_SERVICE, + authenticationPrompt: { + title: promptCopy.title, + cancel: promptCopy.cancel + } +}); + +// errSecUserCancel — biometric prompt dismissed by the user. +// errSecItemNotFound (-25300) when raised *after* the OS prompt indicates the keychain item was +// invalidated by an enrollment change on iOS. +// KeyPermanentlyInvalidatedException is the Android signal for the same condition. +export const classifyError = (e: unknown): TrustResult => { + const err = e as { code?: string | number; name?: string; message?: string } | null | undefined; + const code = err?.code != null ? String(err.code) : ''; + const name = err?.name ?? ''; + const message = err?.message ?? ''; + const blob = `${code} ${name} ${message}`; + + // -128 (errSecUserCanceled) is matched against the code only — testing it against the whole blob + // would misclassify any unrelated failure that happens to mention "-128" in its message as a + // benign cancel. + if (code === '-128' || /errSecUserCancel|UserCancel|user.?cancel|AuthenticationCanceled/i.test(blob)) { + return { kind: 'canceled' }; + } + if (/KeyPermanentlyInvalidatedException/i.test(blob)) { + return { kind: 'enrollmentChanged' }; + } + if (/errSecItemNotFound|-25300/i.test(blob)) { + return { kind: 'enrollmentChanged' }; + } + return { kind: 'error', cause: e }; +}; + +export const biometricTrustStore: IBiometricTrustStore = { + async enroll() { + try { + await Keychain.setGenericPassword(SENTINEL_USERNAME, SENTINEL_VALUE, writeOptions()); + // Writing the sentinel means this install is trust-initialized, so persist the migration + // marker. Without it, an app-driven enroll (settings toggle or first-passcode setup) leaves + // migrated=false; a later enrollment-change invalidation (flag set, sentinel gone) would then + // hit the migration's grandfather path and be silently re-bound to the new biometrics on the + // next launch instead of forcing the user to re-enable. See runBiometricTrustMigration. + UserPreferences.setBool(BIOMETRIC_TRUST_MIGRATION_V1_DONE, true); + // Bind the Android native probe key to the current enrollment in lockstep with the sentinel. + // No-op on iOS (the sentinel alone detects changes there). Best effort — see nativeEnrollmentProbe. + await enrollProbe(); + return { kind: 'success' }; + } catch (e) { + return classifyError(e); + } + }, + + async disenroll() { + try { + await Keychain.resetGenericPassword({ service: SENTINEL_SERVICE }); + } catch { + // best-effort delete; sentinel may already be absent + } + // Tear down the Android native probe key alongside the sentinel. No-op on iOS. + await disenrollProbe(); + }, + + async verify({ promptCopy }) { + try { + const exists = await biometricTrustStore.hasEnrollment(); + if (!exists) { + return { kind: 'unavailable' }; + } + const result = await Keychain.getGenericPassword(readOptions(promptCopy)); + if (result && result.password === SENTINEL_VALUE) { + return { kind: 'success' }; + } + // OS prompt succeeded but the sentinel is gone — treat as enrollment change. + return { kind: 'enrollmentChanged' }; + } catch (e) { + return classifyError(e); + } + }, + + async hasEnrollment() { + const result = await Keychain.hasGenericPassword({ service: SENTINEL_SERVICE }); + return !!result; + }, + + isEnrollmentValid() { + // iOS: nativeEnrollmentProbe resolves true (the sentinel already covers enrollment changes). + // Android: silent keystore cipher.init() probe — false only when the enrollment changed. + return isEnrollmentValid(); + }, + + isEnabled() { + return UserPreferences.getBool(BIOMETRY_ENABLED_KEY) ?? false; + }, + + setEnabled(enabled: boolean) { + UserPreferences.setBool(BIOMETRY_ENABLED_KEY, enabled); + }, + + isRelockPending() { + return UserPreferences.getBool(BIOMETRIC_PENDING_RELOCK_KEY) ?? false; + }, + + setRelockPending(pending: boolean) { + UserPreferences.setBool(BIOMETRIC_PENDING_RELOCK_KEY, pending); + }, + + async setBiometryEnabled(enabled: boolean) { + if (enabled) { + const result = await biometricTrustStore.enroll(); + if (result.kind !== 'success') { + biometricTrustStore.setEnabled(false); + return result; + } + } else { + await biometricTrustStore.disenroll(); + } + biometricTrustStore.setEnabled(enabled); + return { kind: 'success' }; + } +}; + +export default biometricTrustStore; diff --git a/app/lib/biometricTrustStore/migration.test.ts b/app/lib/biometricTrustStore/migration.test.ts new file mode 100644 index 00000000000..21ce5c97b80 --- /dev/null +++ b/app/lib/biometricTrustStore/migration.test.ts @@ -0,0 +1,151 @@ +import UserPreferences from '../methods/userPreferences'; +import log from '../methods/helpers/log'; +import { BIOMETRIC_TRUST_MIGRATION_V1_DONE } from '../constants/localAuthentication'; +import { biometricTrustStore } from './index'; +import { runBiometricTrustMigration } from './migration'; + +jest.mock('../methods/userPreferences', () => ({ + __esModule: true, + default: { + getBool: jest.fn(), + setBool: jest.fn(), + getString: jest.fn(), + setString: jest.fn() + } +})); + +jest.mock('../methods/helpers/log', () => ({ __esModule: true, default: jest.fn() })); + +jest.mock('./index', () => ({ + biometricTrustStore: { + enroll: jest.fn(), + disenroll: jest.fn(), + verify: jest.fn(), + hasEnrollment: jest.fn(), + isEnabled: jest.fn(), + setEnabled: jest.fn(), + setBiometryEnabled: jest.fn(), + isRelockPending: jest.fn(), + setRelockPending: jest.fn() + } +})); + +const mockedGetBool = UserPreferences.getBool as jest.Mock; +const mockedSetBool = UserPreferences.setBool as jest.Mock; +const mockedEnroll = biometricTrustStore.enroll as jest.Mock; +const mockedHasEnrollment = biometricTrustStore.hasEnrollment as jest.Mock; +const mockedIsEnabled = biometricTrustStore.isEnabled as jest.Mock; +const mockedSetEnabled = biometricTrustStore.setEnabled as jest.Mock; +const mockedSetRelockPending = biometricTrustStore.setRelockPending as jest.Mock; +const mockedLog = log as unknown as jest.Mock; + +// Drives the biometry-enabled flag and migration marker the migration needs to see for the +// branch under test, so each test reads like a state machine input row. +const setPrefs = ({ biometryEnabled, migrated }: { biometryEnabled: boolean; migrated: boolean }) => { + mockedIsEnabled.mockReturnValue(biometryEnabled); + mockedGetBool.mockImplementation((key: string) => { + if (key === BIOMETRIC_TRUST_MIGRATION_V1_DONE) return migrated; + return undefined; + }); +}; + +describe('runBiometricTrustMigration', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('upgrade path: !migrated && flag && !sentinel → enroll() once and mark migrated', async () => { + setPrefs({ biometryEnabled: true, migrated: false }); + mockedHasEnrollment.mockResolvedValueOnce(false); + mockedEnroll.mockResolvedValueOnce({ kind: 'success' }); + + await runBiometricTrustMigration(); + + expect(mockedEnroll).toHaveBeenCalledTimes(1); + expect(mockedSetBool).toHaveBeenCalledWith(BIOMETRIC_TRUST_MIGRATION_V1_DONE, true); + expect(mockedSetEnabled).not.toHaveBeenCalled(); + // Grandfather re-bind is not an enrollment change — no relock should be forced. + expect(mockedSetRelockPending).not.toHaveBeenCalled(); + }); + + it('reconciliation path: migrated && flag && !sentinel → clear flag, mark relock pending, no enroll()', async () => { + setPrefs({ biometryEnabled: true, migrated: true }); + mockedHasEnrollment.mockResolvedValueOnce(false); + + await runBiometricTrustMigration(); + + expect(mockedEnroll).not.toHaveBeenCalled(); + expect(mockedSetEnabled).toHaveBeenCalledWith(false); + // The enrollment-change signal would be consumed here, so it must be persisted for the next unlock. + expect(mockedSetRelockPending).toHaveBeenCalledWith(true); + expect(mockedSetBool).not.toHaveBeenCalledWith(BIOMETRIC_TRUST_MIGRATION_V1_DONE, expect.anything()); + }); + + it('flag=false → no-op (no probe, no enroll, no setBool)', async () => { + setPrefs({ biometryEnabled: false, migrated: false }); + + await runBiometricTrustMigration(); + + expect(mockedHasEnrollment).not.toHaveBeenCalled(); + expect(mockedEnroll).not.toHaveBeenCalled(); + expect(mockedSetBool).not.toHaveBeenCalled(); + expect(mockedSetEnabled).not.toHaveBeenCalled(); + }); + + it('flag=true && sentinel exists → no-op (no enroll, no flag clear)', async () => { + setPrefs({ biometryEnabled: true, migrated: false }); + mockedHasEnrollment.mockResolvedValueOnce(true); + + await runBiometricTrustMigration(); + + expect(mockedEnroll).not.toHaveBeenCalled(); + expect(mockedSetBool).not.toHaveBeenCalled(); + expect(mockedSetEnabled).not.toHaveBeenCalled(); + }); + + it('idempotent: after successful migration, second run is a no-op', async () => { + // first run: upgrade path + setPrefs({ biometryEnabled: true, migrated: false }); + mockedHasEnrollment.mockResolvedValueOnce(false); + mockedEnroll.mockResolvedValueOnce({ kind: 'success' }); + await runBiometricTrustMigration(); + expect(mockedEnroll).toHaveBeenCalledTimes(1); + + // second run: sentinel now exists AND marker is set + jest.clearAllMocks(); + setPrefs({ biometryEnabled: true, migrated: true }); + mockedHasEnrollment.mockResolvedValueOnce(true); + + await runBiometricTrustMigration(); + + expect(mockedEnroll).not.toHaveBeenCalled(); + expect(mockedSetBool).not.toHaveBeenCalled(); + expect(mockedSetEnabled).not.toHaveBeenCalled(); + }); + + it('enroll() error → logged, flag untouched, marker NOT set so next boot retries', async () => { + setPrefs({ biometryEnabled: true, migrated: false }); + mockedHasEnrollment.mockResolvedValueOnce(false); + const cause = new Error('keychain unavailable'); + mockedEnroll.mockResolvedValueOnce({ kind: 'error', cause }); + + await runBiometricTrustMigration(); + + expect(mockedLog).toHaveBeenCalledWith(cause); + expect(mockedSetBool).not.toHaveBeenCalledWith(BIOMETRIC_TRUST_MIGRATION_V1_DONE, expect.anything()); + expect(mockedSetEnabled).not.toHaveBeenCalled(); + }); + + it('hasEnrollment throws → swallowed, logged, no enroll(), no flag mutation', async () => { + setPrefs({ biometryEnabled: true, migrated: false }); + const boom = new Error('probe failed'); + mockedHasEnrollment.mockRejectedValueOnce(boom); + + await runBiometricTrustMigration(); + + expect(mockedLog).toHaveBeenCalledWith(boom); + expect(mockedEnroll).not.toHaveBeenCalled(); + expect(mockedSetBool).not.toHaveBeenCalled(); + expect(mockedSetEnabled).not.toHaveBeenCalled(); + }); +}); diff --git a/app/lib/biometricTrustStore/migration.ts b/app/lib/biometricTrustStore/migration.ts new file mode 100644 index 00000000000..c63132e80fc --- /dev/null +++ b/app/lib/biometricTrustStore/migration.ts @@ -0,0 +1,54 @@ +import UserPreferences from '../methods/userPreferences'; +import log from '../methods/helpers/log'; +import { BIOMETRIC_TRUST_MIGRATION_V1_DONE } from '../constants/localAuthentication'; +import { biometricTrustStore } from './index'; + +// One-shot upgrade migration for users who had biometry enabled before the trust-store sentinel +// existed. Runs at app init. +// +// State machine: +// !migrated && flag && !sentinel → silent enroll(), mark migrated. (grandfather upgrade path) +// migrated && flag && !sentinel → clear flag, do NOT enroll(). (reconciliation, e.g. crash +// between disenroll() and the +// flag-clear during an +// invalidation) +// flag && sentinel → no-op. +// !flag → no-op. +// +// On enroll() failure the marker is intentionally left unset so the next boot retries; the flag is +// left as-is so the next unlock falls into the `unavailable` branch and asks for the passcode. +export const runBiometricTrustMigration = async (): Promise => { + try { + const biometryEnabled = biometricTrustStore.isEnabled(); + if (!biometryEnabled) { + return; + } + + const sentinelExists = await biometricTrustStore.hasEnrollment(); + if (sentinelExists) { + return; + } + + const migrated = UserPreferences.getBool(BIOMETRIC_TRUST_MIGRATION_V1_DONE) ?? false; + + if (!migrated) { + const result = await biometricTrustStore.enroll(); + if (result.kind === 'success') { + UserPreferences.setBool(BIOMETRIC_TRUST_MIGRATION_V1_DONE, true); + } else if (result.kind === 'error') { + log(result.cause); + } + return; + } + + // migrated && flag && !sentinel: the OS dropped the sentinel because the enrollment set changed + // (or a deliberate disable crashed mid-way). Clear the flag — and because this migration runs + // *before* localAuthenticate on cold launch, it would otherwise swallow the enrollment-change + // signal entirely. Persist a relock marker so the next unlock is forced to demand the passcode + // regardless of the auto-lock window. See handleLocalAuthentication. + biometricTrustStore.setEnabled(false); + biometricTrustStore.setRelockPending(true); + } catch (e) { + log(e); + } +}; diff --git a/app/lib/biometricTrustStore/nativeEnrollmentProbe.ts b/app/lib/biometricTrustStore/nativeEnrollmentProbe.ts new file mode 100644 index 00000000000..2f8fb240d51 --- /dev/null +++ b/app/lib/biometricTrustStore/nativeEnrollmentProbe.ts @@ -0,0 +1,54 @@ +import { NativeModules, Platform } from 'react-native'; + +// Android-only native bridge. On iOS the BIOMETRY_CURRENT_SET sentinel already surfaces enrollment +// changes for free (the OS drops the keychain item), so there is no native counterpart and these +// helpers are no-ops that report "valid". On Android the keystore key survives an enrollment change +// and only throws on use, so a dedicated probe key does a silent cipher.init() to detect invalidation +// without ever showing the biometric prompt. See android/.../biometric/BiometricEnrollmentModule.kt. +type BiometricEnrollmentNative = { + enrollProbe(): Promise; + disenrollProbe(): Promise; + isEnrollmentValid(): Promise; +}; + +const native: BiometricEnrollmentNative | undefined = Platform.OS === 'android' ? NativeModules.BiometricEnrollment : undefined; + +// Create the probe key bound to the current enrollment, in lockstep with the trust sentinel. Best +// effort: a failure just means the silent path is unavailable and the modal verify() backstop applies. +export const enrollProbe = async (): Promise => { + if (!native) { + return; + } + try { + await native.enrollProbe(); + } catch { + // best effort + } +}; + +// Delete the probe key alongside the sentinel teardown. +export const disenrollProbe = async (): Promise => { + if (!native) { + return; + } + try { + await native.disenrollProbe(); + } catch { + // best effort + } +}; + +// Returns true when the current biometric enrollment still matches what the probe key was bound to +// (or when not applicable, e.g. iOS). Returns false only on Android when the keystore key was +// invalidated by an enrollment change. A bridge failure resolves to true so a transient native error +// never forces the passcode on its own — the modal verify() path stays as the backstop. +export const isEnrollmentValid = async (): Promise => { + if (!native) { + return true; + } + try { + return await native.isEnrollmentValid(); + } catch { + return true; + } +}; diff --git a/app/lib/biometricTrustStore/resolveBiometricTrust.test.ts b/app/lib/biometricTrustStore/resolveBiometricTrust.test.ts new file mode 100644 index 00000000000..7a38b45bf39 --- /dev/null +++ b/app/lib/biometricTrustStore/resolveBiometricTrust.test.ts @@ -0,0 +1,88 @@ +import { biometricTrustStore } from './index'; +import { resolveBiometricTrust } from './resolveBiometricTrust'; + +jest.mock('./index', () => ({ + biometricTrustStore: { + enroll: jest.fn(), + disenroll: jest.fn(() => Promise.resolve()), + verify: jest.fn(), + hasEnrollment: jest.fn(), + isEnabled: jest.fn(), + setEnabled: jest.fn(), + setBiometryEnabled: jest.fn() + } +})); + +const mockedSetEnabled = biometricTrustStore.setEnabled as jest.Mock; +const mockedDisenroll = biometricTrustStore.disenroll as jest.Mock; + +describe('resolveBiometricTrust', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('success → unlocked, no modal, no invalidation', async () => { + const outcome = await resolveBiometricTrust({ kind: 'success' }); + + expect(outcome).toEqual({ unlocked: true }); + expect(mockedDisenroll).not.toHaveBeenCalled(); + expect(mockedSetEnabled).not.toHaveBeenCalled(); + }); + + it('enrollmentChanged → disenroll() runs before biometry is disabled', async () => { + const order: string[] = []; + mockedDisenroll.mockImplementation(() => { + order.push('disenroll'); + return Promise.resolve(); + }); + mockedSetEnabled.mockImplementation((value: boolean) => { + order.push(`setEnabled:${value}`); + }); + + const outcome = await resolveBiometricTrust({ kind: 'enrollmentChanged' }); + + expect(order).toEqual(['disenroll', 'setEnabled:false']); + expect(outcome).toEqual({ + unlocked: false, + modal: { hasBiometry: false, reason: 'enrollmentChanged' } + }); + }); + + it('canceled → no disenroll, no flag clear, modal keeps biometry', async () => { + const outcome = await resolveBiometricTrust({ kind: 'canceled' }); + + expect(mockedDisenroll).not.toHaveBeenCalled(); + expect(mockedSetEnabled).not.toHaveBeenCalled(); + expect(outcome).toEqual({ + unlocked: false, + modal: { hasBiometry: true } + }); + }); + + it('error → no disenroll, no flag clear, modal keeps biometry', async () => { + const outcome = await resolveBiometricTrust({ kind: 'error', cause: new Error('boom') }); + + expect(mockedDisenroll).not.toHaveBeenCalled(); + expect(mockedSetEnabled).not.toHaveBeenCalled(); + expect(outcome).toEqual({ + unlocked: false, + modal: { hasBiometry: true } + }); + }); + + it('unavailable → clears the flag (sentinel gone) before disabling, passcode-only modal, no reason', async () => { + const order: string[] = []; + mockedDisenroll.mockImplementation(() => { + order.push('disenroll'); + return Promise.resolve(); + }); + mockedSetEnabled.mockImplementation((value: boolean) => { + order.push(`setEnabled:${value}`); + }); + + const outcome = await resolveBiometricTrust({ kind: 'unavailable' }); + + expect(order).toEqual(['disenroll', 'setEnabled:false']); + expect(outcome).toEqual({ unlocked: false, modal: { hasBiometry: false } }); + }); +}); diff --git a/app/lib/biometricTrustStore/resolveBiometricTrust.ts b/app/lib/biometricTrustStore/resolveBiometricTrust.ts new file mode 100644 index 00000000000..e97ac930ede --- /dev/null +++ b/app/lib/biometricTrustStore/resolveBiometricTrust.ts @@ -0,0 +1,45 @@ +import { type BiometricInvalidationReason, type TrustResult } from '../../definitions'; +import { biometricTrustStore } from './index'; + +export type BiometricModalRequest = { + hasBiometry: boolean; + reason?: BiometricInvalidationReason; +}; + +// Discriminated on `unlocked`: a locked outcome always carries the modal config to show next, an +// unlocked one never does. Narrowing on `unlocked` (without destructuring) makes `modal` present. +export type BiometricTrustOutcome = { unlocked: true } | { unlocked: false; modal: BiometricModalRequest }; + +// Maps a verify() TrustResult to an unlock outcome plus the modal config to show next. Called from +// PasscodeEnter.biometry() for both the auto-prompt (fired behind the modal on mount) and the manual +// retry button. +// +// On any invalidation we MUST disenroll() before clearing the enabled flag: a crash between the two +// then leaves a flag/sentinel mismatch the migration's reconciliation can still clean up, whereas a +// cleared flag with a live sentinel would look like a healthy disabled state and orphan the sentinel. +export const resolveBiometricTrust = async (result: TrustResult): Promise => { + switch (result.kind) { + case 'success': + return { unlocked: true }; + case 'enrollmentChanged': + await biometricTrustStore.disenroll(); + biometricTrustStore.setEnabled(false); + return { unlocked: false, modal: { hasBiometry: false, reason: 'enrollmentChanged' } }; + case 'unavailable': + // On iOS an enrollment change deletes the sentinel, so verify() returns `unavailable` (via + // hasEnrollment()) before the errSecItemNotFound read-path can classify it as enrollmentChanged. + // Either way the flag is now out of sync with a missing sentinel, so clear it here rather than + // leaving the migration to reconcile it on a later launch. No reason subtitle: `unavailable` + // can also be benign (e.g. a THIS_DEVICE_ONLY sentinel not restored from a device backup), + // not necessarily an enrollment change. + await biometricTrustStore.disenroll(); + biometricTrustStore.setEnabled(false); + return { unlocked: false, modal: { hasBiometry: false } }; + case 'canceled': + case 'error': + default: + // Keep the biometry button so the user can retry manually; the upstream verify() already + // prompted, so there's no auto-prompt to suppress here. + return { unlocked: false, modal: { hasBiometry: true } }; + } +}; diff --git a/app/lib/constants/localAuthentication.ts b/app/lib/constants/localAuthentication.ts index bd74997a3c2..d46f808a40c 100644 --- a/app/lib/constants/localAuthentication.ts +++ b/app/lib/constants/localAuthentication.ts @@ -2,6 +2,16 @@ export const PASSCODE_KEY = 'kPasscode'; export const LOCKED_OUT_TIMER_KEY = 'kLockedOutTimer'; export const ATTEMPTS_KEY = 'kAttempts'; export const BIOMETRY_ENABLED_KEY = 'kBiometryEnabled'; +export const BIOMETRIC_TRUST_MIGRATION_V1_DONE = 'kBiometricTrustMigrationV1Done'; +// Set when a biometric enrollment change is reconciled away by the init migration (which runs before +// localAuthenticate and would otherwise consume the signal). Forces the next unlock to demand the +// passcode regardless of the auto-lock window; cleared once that forced passcode modal is shown. +export const BIOMETRIC_PENDING_RELOCK_KEY = 'kBiometricPendingRelock'; + +// Keychain sentinel used by the biometric trust store to detect enrollment changes. +export const BIOMETRIC_TRUST_SENTINEL_SERVICE = 'rc-biometric-trust'; +export const BIOMETRIC_TRUST_SENTINEL_USERNAME = 'biometric-trust'; +export const BIOMETRIC_TRUST_SENTINEL_VALUE = 'v1'; export const LOCAL_AUTHENTICATE_EMITTER = 'LOCAL_AUTHENTICATE'; export const CHANGE_PASSCODE_EMITTER = 'CHANGE_PASSCODE'; @@ -11,3 +21,7 @@ export const MAX_ATTEMPTS = 6; export const TIME_TO_LOCK = 30000; export const DEFAULT_AUTO_LOCK = 1800; + +// During E2E runs we shorten the auto-lock threshold so tests don't have to wait +// past the smallest user-facing option (60s) to trigger the screen lock. +export const E2E_TESTS_AUTO_LOCK_TIME = 5; diff --git a/app/lib/hooks/useDeferredModalSettle.test.ts b/app/lib/hooks/useDeferredModalSettle.test.ts new file mode 100644 index 00000000000..8e9deb52b53 --- /dev/null +++ b/app/lib/hooks/useDeferredModalSettle.test.ts @@ -0,0 +1,67 @@ +import { renderHook } from '@testing-library/react-native'; + +import { useDeferredModalSettle } from './useDeferredModalSettle'; + +interface IRequest { + submit?: () => void; + cancel?: () => void; +} + +describe('useDeferredModalSettle', () => { + it('runs the deferred settle on onModalHide, exactly once', () => { + const { result } = renderHook(() => useDeferredModalSettle()); + const submit = jest.fn(); + + result.current.onShow({ submit }); + result.current.defer(submit); + expect(submit).not.toHaveBeenCalled(); + + result.current.onModalHide(); + expect(submit).toHaveBeenCalledTimes(1); + + // A later hide (e.g. re-fired animation callback) must not double-settle. + result.current.onModalHide(); + expect(submit).toHaveBeenCalledTimes(1); + }); + + it('flushes a settle left pending mid-animation when a new request arrives', () => { + const { result } = renderHook(() => useDeferredModalSettle()); + const submit = jest.fn(); + + result.current.onShow({ submit }); + result.current.defer(submit); + + // New request before onModalHide consumed the previous settle. + result.current.onShow({ submit: jest.fn() }); + expect(submit).toHaveBeenCalledTimes(1); + + // The old settle is consumed; hide must not re-run it. + result.current.onModalHide(); + expect(submit).toHaveBeenCalledTimes(1); + }); + + it('cancels a previous request still awaiting input so its caller is not orphaned', () => { + const { result } = renderHook(() => useDeferredModalSettle()); + const cancel = jest.fn(); + + // Request 1 shown, user never submitted or canceled. + result.current.onShow({ submit: jest.fn(), cancel }); + + // Request 2 replaces it: request 1's promise must reject instead of hanging. + result.current.onShow({ submit: jest.fn(), cancel: jest.fn() }); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('does not cancel a request the user already settled', () => { + const { result } = renderHook(() => useDeferredModalSettle()); + const submit = jest.fn(); + const cancel = jest.fn(); + + result.current.onShow({ submit, cancel }); + result.current.defer(submit); + + result.current.onShow({ submit: jest.fn(), cancel: jest.fn() }); + expect(submit).toHaveBeenCalledTimes(1); + expect(cancel).not.toHaveBeenCalled(); + }); +}); diff --git a/app/lib/hooks/useDeferredModalSettle.ts b/app/lib/hooks/useDeferredModalSettle.ts new file mode 100644 index 00000000000..9a6bccad729 --- /dev/null +++ b/app/lib/hooks/useDeferredModalSettle.ts @@ -0,0 +1,34 @@ +import { useRef } from 'react'; + +interface ISettleableRequest { + cancel?: () => void; +} + +export const useDeferredModalSettle = () => { + const pendingSettle = useRef<(() => void) | null>(null); + const activeRequest = useRef(null); + + // Call when a new request arrives, before storing it in state. + const onShow = (args: T) => { + const flush = pendingSettle.current; + pendingSettle.current = null; + flush?.(); + const previous = activeRequest.current; + activeRequest.current = args; + previous?.cancel?.(); + }; + + // Call when the user settles the modal; `settle` runs once the modal has animated out. + const defer = (settle: (() => void) | null) => { + activeRequest.current = null; + pendingSettle.current = settle; + }; + + const onModalHide = () => { + const settle = pendingSettle.current; + pendingSettle.current = null; + settle?.(); + }; + + return { onShow, defer, onModalHide }; +}; diff --git a/app/lib/methods/helpers/events.ts b/app/lib/methods/helpers/events.ts index f8f52140cee..bc7832d4fb8 100644 --- a/app/lib/methods/helpers/events.ts +++ b/app/lib/methods/helpers/events.ts @@ -1,4 +1,4 @@ -import { type ICredentials } from '../../../definitions'; +import { type BiometricInvalidationReason, type ICredentials } from '../../../definitions'; import { type IEmitUserInteraction } from '../../../containers/UIKit/interfaces'; import log from './log'; @@ -10,6 +10,7 @@ type TEventEmitterEmmitArgs = | { invalid: boolean } | { force: boolean } | { hasBiometry: boolean } + | { reason: BiometricInvalidationReason } | { visible: boolean; onCancel?: null | Function } | { cancel: () => void } | { submit: (param: string) => void } diff --git a/app/lib/methods/helpers/localAuthentication.test.ts b/app/lib/methods/helpers/localAuthentication.test.ts new file mode 100644 index 00000000000..7ee38bda8d4 --- /dev/null +++ b/app/lib/methods/helpers/localAuthentication.test.ts @@ -0,0 +1,228 @@ +import * as LocalAuthentication from 'expo-local-authentication'; + +import EventEmitter from './events'; +import { checkHasPasscode, handleLocalAuthentication } from './localAuthentication'; +import { biometricTrustStore } from '../../biometricTrustStore'; +import { CHANGE_PASSCODE_EMITTER, LOCAL_AUTHENTICATE_EMITTER } from '../../constants/localAuthentication'; + +jest.mock('expo-local-authentication', () => ({ + authenticateAsync: jest.fn(), + isEnrolledAsync: jest.fn(() => Promise.resolve(true)), + supportedAuthenticationTypesAsync: jest.fn(() => Promise.resolve([2])), + AuthenticationType: { FINGERPRINT: 1, FACIAL_RECOGNITION: 2, IRIS: 3 } +})); + +jest.mock('react-native-bootsplash', () => ({ hide: jest.fn(() => Promise.resolve()) })); + +jest.mock('../userPreferences', () => ({ + __esModule: true, + default: { + getBool: jest.fn(), + setBool: jest.fn(), + getString: jest.fn(), + setString: jest.fn() + } +})); + +jest.mock('../../store/auxStore', () => ({ store: { dispatch: jest.fn() } })); +jest.mock('../../services/getServerTimeSync', () => ({ getServerTimeSync: jest.fn(() => Promise.resolve(Date.now())) })); +jest.mock('../../../i18n', () => ({ t: (key: string) => key })); + +jest.mock('../../biometricTrustStore', () => ({ + biometricTrustStore: { + verify: jest.fn(), + enroll: jest.fn(), + disenroll: jest.fn(), + hasEnrollment: jest.fn(), + isEnrollmentValid: jest.fn(), + isEnabled: jest.fn(), + setEnabled: jest.fn(), + setBiometryEnabled: jest.fn(), + isRelockPending: jest.fn(), + setRelockPending: jest.fn() + } +})); + +jest.mock('./events', () => ({ + __esModule: true, + default: { emit: jest.fn(), addEventListener: jest.fn(), removeListener: jest.fn() } +})); + +const mockedEmit = EventEmitter.emit as jest.Mock; +const mockedVerify = biometricTrustStore.verify as jest.Mock; +const mockedEnroll = biometricTrustStore.enroll as jest.Mock; +const mockedDisenroll = biometricTrustStore.disenroll as jest.Mock; +const mockedSetEnabled = biometricTrustStore.setEnabled as jest.Mock; +const mockedIsEnabled = biometricTrustStore.isEnabled as jest.Mock; +const mockedHasEnrollment = biometricTrustStore.hasEnrollment as jest.Mock; +const mockedIsEnrollmentValid = biometricTrustStore.isEnrollmentValid as jest.Mock; +const mockedIsRelockPending = biometricTrustStore.isRelockPending as jest.Mock; +const mockedSetRelockPending = biometricTrustStore.setRelockPending as jest.Mock; +const mockedIsEnrolled = LocalAuthentication.isEnrolledAsync as jest.Mock; + +const lastEmitPayload = () => { + const calls = mockedEmit.mock.calls.filter(([event]) => event === LOCAL_AUTHENTICATE_EMITTER); + return calls.length ? calls[calls.length - 1][1] : null; +}; + +// handleLocalAuthentication opens the passcode modal and computes whether to show the biometry +// button. It does NOT prompt biometry itself — that happens from behind the modal in PasscodeEnter, +// so the OS prompt never appears over uncovered app content. The verify()/invalidation flow is +// exercised in PasscodeEnter.test.tsx and resolveBiometricTrust.test.ts. +describe('handleLocalAuthentication', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedIsEnrolled.mockResolvedValue(true); + // Sentinel present by default → no enrollment change. Tests that exercise the invalidation path + // override this per-case. + mockedHasEnrollment.mockResolvedValue(true); + // Enrollment intact by default (Android native probe reports valid). Overridden per-case. + mockedIsEnrollmentValid.mockResolvedValue(true); + mockedIsRelockPending.mockReturnValue(false); + mockedDisenroll.mockResolvedValue(undefined); + mockedEmit.mockImplementation((event, payload) => { + if (event === LOCAL_AUTHENTICATE_EMITTER && payload?.submit) { + setImmediate(() => payload.submit()); + } + }); + }); + + it('biometry disabled → opens modal with hasBiometry: false, no upstream prompt', async () => { + mockedIsEnabled.mockReturnValue(false); + + await handleLocalAuthentication(); + + expect(lastEmitPayload()).toMatchObject({ hasBiometry: false }); + expect(mockedVerify).not.toHaveBeenCalled(); + }); + + it('biometry enabled and supported → opens modal with hasBiometry: true, no upstream prompt', async () => { + mockedIsEnabled.mockReturnValue(true); + + await handleLocalAuthentication(); + + expect(lastEmitPayload()).toMatchObject({ hasBiometry: true }); + expect(mockedVerify).not.toHaveBeenCalled(); + }); + + it('warm path: biometry enabled but sentinel gone (enrollment changed) → forces passcode, disables biometry, sets reason', async () => { + mockedIsEnabled.mockReturnValue(true); + mockedHasEnrollment.mockResolvedValueOnce(false); + + await handleLocalAuthentication(); + + // Modal opens with biometry hidden and the enrollment-changed notice... + expect(lastEmitPayload()).toMatchObject({ hasBiometry: false, reason: 'enrollmentChanged' }); + // ...trust state torn down (disenroll before clearing the flag), relock marker cleared, no prompt. + expect(mockedDisenroll).toHaveBeenCalledTimes(1); + expect(mockedSetEnabled).toHaveBeenCalledWith(false); + expect(mockedSetRelockPending).toHaveBeenCalledWith(false); + expect(mockedVerify).not.toHaveBeenCalled(); + }); + + it('Android path: sentinel survives but native probe reports invalidated → forces passcode with reason', async () => { + mockedIsEnabled.mockReturnValue(true); + mockedHasEnrollment.mockResolvedValue(true); // Android keeps the sentinel after an enrollment change + mockedIsEnrollmentValid.mockResolvedValueOnce(false); // ...but the keystore probe key is invalidated + + await handleLocalAuthentication(); + + expect(lastEmitPayload()).toMatchObject({ hasBiometry: false, reason: 'enrollmentChanged' }); + expect(mockedDisenroll).toHaveBeenCalledTimes(1); + expect(mockedSetEnabled).toHaveBeenCalledWith(false); + expect(mockedVerify).not.toHaveBeenCalled(); + }); + + it('cold-launch path: migration already disabled biometry but left relock pending → still forces passcode with reason', async () => { + // Init migration ran first, reconciled the flag off and persisted the relock marker. + mockedIsEnabled.mockReturnValue(false); + mockedIsRelockPending.mockReturnValue(true); + + await handleLocalAuthentication(); + + expect(lastEmitPayload()).toMatchObject({ hasBiometry: false, reason: 'enrollmentChanged' }); + // Flag already cleared by the migration, so no disenroll here; the marker is consumed. + expect(mockedDisenroll).not.toHaveBeenCalled(); + expect(mockedSetRelockPending).toHaveBeenCalledWith(false); + expect(mockedVerify).not.toHaveBeenCalled(); + }); + + it('biometry disabled → does not probe the sentinel', async () => { + mockedIsEnabled.mockReturnValue(false); + + await handleLocalAuthentication(); + + expect(mockedHasEnrollment).not.toHaveBeenCalled(); + }); + + it('biometry enabled with an enrolled unlabeled biometric type → still opens modal with hasBiometry: true', async () => { + mockedIsEnabled.mockReturnValue(true); + (LocalAuthentication.supportedAuthenticationTypesAsync as jest.Mock).mockResolvedValueOnce([ + LocalAuthentication.AuthenticationType.IRIS + ]); + + await handleLocalAuthentication(); + + expect(lastEmitPayload()).toMatchObject({ hasBiometry: true }); + expect(mockedVerify).not.toHaveBeenCalled(); + }); + + it('biometry enabled but device not enrolled → opens modal with hasBiometry: false', async () => { + mockedIsEnabled.mockReturnValue(true); + mockedIsEnrolled.mockResolvedValueOnce(false); + + await handleLocalAuthentication(); + + expect(lastEmitPayload()).toMatchObject({ hasBiometry: false }); + expect(mockedVerify).not.toHaveBeenCalled(); + }); +}); + +// First-passcode setup must keep biometry opt-in: enroll writes the sentinel silently, then a single +// verify() prompt asks for consent. Declining tears the sentinel back down and leaves biometry off. +describe('checkHasPasscode → biometry consent on first passcode', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedDisenroll.mockResolvedValue(undefined); + // No stored passcode → checkHasPasscode runs changePasscode then checkBiometry. + (LocalAuthentication.isEnrolledAsync as jest.Mock).mockResolvedValue(true); + mockedEmit.mockImplementation((event, payload) => { + if (event === CHANGE_PASSCODE_EMITTER && payload?.submit) { + setImmediate(() => payload.submit('1234')); + } + }); + }); + + it('enroll succeeds and user consents → prompts once, biometry enabled, no disenroll', async () => { + mockedEnroll.mockResolvedValueOnce({ kind: 'success' }); + mockedVerify.mockResolvedValueOnce({ kind: 'success' }); + + await checkHasPasscode({}); + + expect(mockedEnroll).toHaveBeenCalledTimes(1); + expect(mockedVerify).toHaveBeenCalledTimes(1); + expect(mockedSetEnabled).toHaveBeenCalledWith(true); + expect(mockedDisenroll).not.toHaveBeenCalled(); + }); + + it("user declines consent ('Don't activate') → disenrolls and leaves biometry disabled", async () => { + mockedEnroll.mockResolvedValueOnce({ kind: 'success' }); + mockedVerify.mockResolvedValueOnce({ kind: 'canceled' }); + + await checkHasPasscode({}); + + expect(mockedVerify).toHaveBeenCalledTimes(1); + expect(mockedDisenroll).toHaveBeenCalledTimes(1); + expect(mockedSetEnabled).toHaveBeenCalledWith(false); + }); + + it('enroll fails → biometry disabled, no consent prompt', async () => { + mockedEnroll.mockResolvedValueOnce({ kind: 'error', cause: new Error('keychain') }); + + await checkHasPasscode({}); + + expect(mockedVerify).not.toHaveBeenCalled(); + expect(mockedDisenroll).not.toHaveBeenCalled(); + expect(mockedSetEnabled).toHaveBeenCalledWith(false); + }); +}); diff --git a/app/lib/methods/helpers/localAuthentication.ts b/app/lib/methods/helpers/localAuthentication.ts index 8dd1b005f55..9144427a3d7 100644 --- a/app/lib/methods/helpers/localAuthentication.ts +++ b/app/lib/methods/helpers/localAuthentication.ts @@ -8,17 +8,18 @@ import UserPreferences from '../userPreferences'; import { store } from '../../store/auxStore'; import database from '../../database'; import { getServerTimeSync } from '../../services/getServerTimeSync'; +import { biometricTrustStore } from '../../biometricTrustStore'; import { ATTEMPTS_KEY, - BIOMETRY_ENABLED_KEY, CHANGE_PASSCODE_EMITTER, + E2E_TESTS_AUTO_LOCK_TIME, LOCAL_AUTHENTICATE_EMITTER, LOCKED_OUT_TIMER_KEY, PASSCODE_KEY } from '../../constants/localAuthentication'; import I18n from '../../../i18n'; import { setLocalAuthenticated } from '../../../actions/login'; -import { type TServerModel } from '../../../definitions'; +import { type BiometricInvalidationReason, type TServerModel, type TrustResult } from '../../../definitions'; import EventEmitter from './events'; import { isIOS } from './deviceInfo'; @@ -50,13 +51,23 @@ export const saveLastLocalAuthenticationSession = async ( export const resetAttempts = (): Promise => AsyncStorage.multiRemove([LOCKED_OUT_TIMER_KEY, ATTEMPTS_KEY]); -const openModal = (hasBiometry: boolean, force?: boolean) => +// Typed rejection reason for the modal promises so catch blocks can tell a benign user-cancel +// (or a request superseded by a newer one) apart from a real failure like a storage write throwing. +export class UserCanceledError extends Error { + constructor() { + super('User canceled local authentication'); + this.name = 'UserCanceledError'; + } +} + +const openModal = (hasBiometry: boolean, force?: boolean, reason?: BiometricInvalidationReason) => new Promise((resolve, reject) => { EventEmitter.emit(LOCAL_AUTHENTICATE_EMITTER, { submit: () => resolve(), hasBiometry, force, - cancel: () => reject() + reason, + cancel: () => reject(new UserCanceledError()) }); }); @@ -64,7 +75,7 @@ const openChangePasscodeModal = ({ force }: { force: boolean }) => new Promise((resolve, reject) => { EventEmitter.emit(CHANGE_PASSCODE_EMITTER, { submit: (passcode: string) => resolve(passcode), - cancel: () => reject(), + cancel: () => reject(new UserCanceledError()), force }); }); @@ -74,21 +85,34 @@ export const changePasscode = async ({ force = false }: { force: boolean }): Pro UserPreferences.setString(PASSCODE_KEY, sha256(passcode)); }; -export const biometryAuth = (force?: boolean): Promise => - LocalAuthentication.authenticateAsync({ - disableDeviceFallback: true, - cancelLabel: force ? I18n.t('Dont_activate') : I18n.t('Local_authentication_biometry_fallback'), - promptMessage: I18n.t('Local_authentication_biometry_title') - }); +const buildPromptCopy = (force?: boolean) => ({ + title: I18n.t('Local_authentication_biometry_title'), + cancel: force ? I18n.t('Dont_activate') : I18n.t('Local_authentication_biometry_fallback') +}); + +export const biometryAuth = (force?: boolean): Promise => + biometricTrustStore.verify({ promptCopy: buildPromptCopy(force) }); /* * It'll help us to get the permission to use FaceID * and enable/disable the biometry when user put their first passcode */ const checkBiometry = async () => { - const result = await biometryAuth(true); - const isBiometryEnabled = !!result?.success; - UserPreferences.setBool(BIOMETRY_ENABLED_KEY, isBiometryEnabled); + // Writing the sentinel is silent on both platforms, so it can't double as consent. Enroll, then + // prompt once to ask the user to opt in to biometric unlock — tapping "Don't activate" (the cancel + // label from buildPromptCopy(true)) opts out, and we tear the sentinel back down. + const enrollResult = await biometricTrustStore.enroll(); + if (enrollResult.kind !== 'success') { + biometricTrustStore.setEnabled(false); + return false; + } + + const consent = await biometricTrustStore.verify({ promptCopy: buildPromptCopy(true) }); + const isBiometryEnabled = consent.kind === 'success'; + if (!isBiometryEnabled) { + await biometricTrustStore.disenroll(); + } + biometricTrustStore.setEnabled(isBiometryEnabled); return isBiometryEnabled; }; @@ -110,17 +134,68 @@ const hideSplashScreen = async () => { } }; +const hasSupportedBiometry = async (): Promise => { + try { + return await LocalAuthentication.isEnrolledAsync(); + } catch { + return false; + } +}; + +// Non-prompting detection of a biometric enrollment change. This is the security primitive that lets +// an enrollment change FORCE the passcode even inside the auto-lock window: without it, re-enrolling a +// face/fingerprint and returning before the window elapses would keep the session unlocked — the very +// bypass screen lock exists to prevent. +// +// Two platform signals, neither of which shows an OS prompt: +// - iOS: BIOMETRY_CURRENT_SET binds the sentinel to the current enrollment, so the OS drops it when +// the enrollment set changes → a missing sentinel means re-enrollment. +// - Android: the sentinel survives an enrollment change (the keystore key is only invalidated, not +// deleted), so the sentinel check can't see it. A native keystore probe does a silent cipher.init() +// that throws only when the enrollment changed; isEnrollmentValid() returns false in that case. +const hasBiometricEnrollmentChanged = async (): Promise => { + if (!biometricTrustStore.isEnabled()) { + return false; + } + // iOS path: sentinel gone → changed. + if (!(await biometricTrustStore.hasEnrollment())) { + return true; + } + // Android path: sentinel present but the native probe key was invalidated → changed. Always valid + // on iOS, so this never produces a false positive there. + return !(await biometricTrustStore.isEnrollmentValid()); +}; + export const handleLocalAuthentication = async (canCloseModal = false) => { - // let hasBiometry = false; - let hasBiometry = UserPreferences.getBool(BIOMETRY_ENABLED_KEY) ?? false; + // Check the cheap persisted flag first; passcode-only users shouldn't pay the native capability + // check on every lock event. + const biometryEnabled = biometricTrustStore.isEnabled(); - // if biometry is enabled on the app - if (hasBiometry) { - const isEnrolled = await LocalAuthentication.isEnrolledAsync(); - hasBiometry = isEnrolled; + // An enrollment change reaches us two ways: + // - warm foreground: the flag is still enabled but the sentinel was dropped → live check catches it. + // - cold launch: the init migration already reconciled the flag off (it runs before us) and left a + // relock marker, since it would otherwise have consumed the signal silently. + // Either way, surface it explicitly: tear down any remaining trust state (mirroring + // resolveBiometricTrust's invalidation path), clear the marker, and show the passcode with the + // "enrollment changed" notice and biometry hidden — rather than letting PasscodeEnter rediscover it + // as a generic `unavailable` outcome that carries no reason subtitle. + const enrollmentChanged = (await hasBiometricEnrollmentChanged()) || biometricTrustStore.isRelockPending(); + if (enrollmentChanged) { + if (biometryEnabled) { + await biometricTrustStore.disenroll(); + biometricTrustStore.setEnabled(false); + } + biometricTrustStore.setRelockPending(false); + await openModal(false, canCloseModal, 'enrollmentChanged'); + return; } - // Authenticate + const hasBiometry = biometryEnabled && (await hasSupportedBiometry()); + + // Open the passcode modal first so it covers the app, then let PasscodeEnter prompt biometry from + // behind it (its mount-time auto-biometry). Prompting here as an upstream preflight would fire the + // OS biometric sheet with the app content still visible underneath, defeating screen lock — so the + // verify()/invalidation flow lives in PasscodeEnter's biometry() for both the auto and button paths. await openModal(hasBiometry, canCloseModal); }; @@ -143,13 +218,31 @@ export const localAuthenticate = async (server: string): Promise => { // Check if the app has passcode const result = await checkHasPasscode({}); + // The session timestamp we persist below. Defaults to the `timesync` captured above, but if + // the lock modal is shown it's refreshed to the moment authentication actually completes — + // the user may sit on the lock screen longer than the auto-lock window, and persisting the + // stale pre-modal `timesync` would let the next lock check (e.g. a late localAuthenticate + // from the login/connect flow) see a gap >= autoLockTime and immediately re-lock a session + // the user just unlocked. + let authenticatedTimesync = timesync; + // `checkHasPasscode` results newPasscode = true if a passcode has been set if (!result?.newPasscode) { // diff to last authenticated session const diffToLastSession = dayjs(timesync).diff(serverRecord?.lastLocalAuthenticatedSession, 'seconds'); - // if it was not possible to get `timesync` from server or the last authenticated session is older than the configured auto lock time, authentication is required - if (!timesync || (serverRecord?.autoLockTime && diffToLastSession >= serverRecord.autoLockTime)) { + // During E2E runs we use a shorter threshold so tests don't have to wait past the smallest user-facing option (60s) + const autoLockTime = process.env.RUNNING_E2E_TESTS === 'true' ? E2E_TESTS_AUTO_LOCK_TIME : serverRecord?.autoLockTime; + + // A biometric enrollment change must force the lock screen regardless of how recently the user + // authenticated — otherwise re-enrolling a face/fingerprint inside the auto-lock window would + // bypass authentication entirely. We detect it both live (warm foreground, flag still set) and + // via the relock marker the init migration leaves on cold launch. handleLocalAuthentication + // re-detects and shows the passcode with biometry disabled and the enrollment-changed notice. + const enrollmentChanged = (await hasBiometricEnrollmentChanged()) || biometricTrustStore.isRelockPending(); + + // if it was not possible to get `timesync` from server, the biometric enrollment changed, or the last authenticated session is older than the configured auto lock time, authentication is required + if (!timesync || enrollmentChanged || (autoLockTime && diffToLastSession >= autoLockTime)) { await hideSplashScreen(); // set isLocalAuthenticated to false @@ -159,17 +252,21 @@ export const localAuthenticate = async (server: string): Promise => { // set isLocalAuthenticated to true store.dispatch(setLocalAuthenticated(true)); + + // Re-read the clock now that the user has authenticated, so the persisted session + // reflects the unlock moment rather than when this check started. + authenticatedTimesync = await getServerTimeSync(server); } } await resetAttempts(); - await saveLastLocalAuthenticationSession(server, serverRecord, timesync); + await saveLastLocalAuthenticationSession(server, serverRecord, authenticatedTimesync); } }; export const supportedBiometryLabel = async (): Promise => { try { - const enrolled = await LocalAuthentication.isEnrolledAsync(); + const enrolled = await hasSupportedBiometry(); if (!enrolled) { return null; diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index c7d33d68f0e..bd4a8e285e6 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -102,6 +102,7 @@ import { connectSuccess } from '../../actions/connect'; import { appStart } from '../../actions/app'; import { LOGIN } from '../../actions/actionsTypes'; import { RootEnum } from '../../definitions'; +import { TOKEN_KEY } from '../../lib/constants/keys'; import reducers from '../../reducers'; import deepLinkingRoot from '../deepLinking'; import UserPreferences from '../../lib/methods/userPreferences'; @@ -109,6 +110,7 @@ import { getServerById } from '../../lib/database/services/Server'; import { canOpenRoom } from '../../lib/methods/canOpenRoom'; import { getServerInfo } from '../../lib/methods/getServerInfo'; import { goRoom, navigateToRoom } from '../../lib/methods/helpers/goRoom'; +import { localAuthenticate } from '../../lib/methods/helpers/localAuthentication'; import { waitForNavigationReady } from '../../lib/navigation/appNavigation'; import sdk from '../../lib/services/sdk'; import EventEmitter from '../../lib/methods/helpers/events'; @@ -180,6 +182,7 @@ describe('deepLinking saga — Regression race (new server + token + room path)' jest.mocked(getServerById).mockReset(); jest.mocked(canOpenRoom).mockReset(); jest.mocked(getServerInfo).mockReset(); + jest.mocked(localAuthenticate).mockReset(); jest.mocked(goRoom).mockReset(); jest.mocked(waitForNavigationReady).mockReset(); @@ -194,6 +197,7 @@ describe('deepLinking saga — Regression race (new server + token + room path)' // getServerInfo succeeds → unknown-server-with-token path jest.mocked(getServerInfo).mockResolvedValue({ success: true, version: '6.0.0' } as any); + jest.mocked(localAuthenticate).mockResolvedValue(undefined); // canOpenRoom returns a room object jest.mocked(canOpenRoom).mockResolvedValue({ rid: 'room-1', name: 'general', t: 'c' } as any); @@ -471,6 +475,7 @@ describe('deepLinking saga — server already connected, should skip changing se jest.mocked(getServerById).mockReset(); jest.mocked(canOpenRoom).mockReset(); jest.mocked(getServerInfo).mockReset(); + jest.mocked(localAuthenticate).mockReset(); jest.mocked(goRoom).mockReset(); jest.mocked(waitForNavigationReady).mockReset(); @@ -483,6 +488,7 @@ describe('deepLinking saga — server already connected, should skip changing se }); jest.mocked(getServerById).mockResolvedValue(null); jest.mocked(getServerInfo).mockResolvedValue({ success: true, version: '6.0.0' } as any); + jest.mocked(localAuthenticate).mockResolvedValue(undefined); jest.mocked(canOpenRoom).mockResolvedValue({ rid: 'room-1', name: 'general', t: 'c' } as any); jest.mocked(waitForNavigationReady).mockResolvedValue(undefined); jest.mocked(goRoom).mockResolvedValue(undefined); @@ -552,6 +558,31 @@ describe('deepLinking saga — server already connected, should skip changing se expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); emitSpy.mockRestore(); }); + + it('drops the deep link when unlock is canceled for an existing secondary server', async () => { + const emitSpy = jest.spyOn(EventEmitter, 'emit'); + const { store, actions } = setupRecordingStore(); + + jest.mocked(UserPreferences.getString).mockImplementation((key: string) => { + if (key === 'currentServer') return 'https://other.server.com'; + if (key === `${TOKEN_KEY}-${HOST}`) return makeStoredUser(); + return null; + }); + jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); + jest.mocked(localAuthenticate).mockRejectedValue(new Error('unlock canceled')); + + store.dispatch(deepLinkingOpen(makeParams({ path: 'channel/general' }))); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(jest.mocked(localAuthenticate)).toHaveBeenCalledWith(HOST); + expect(jest.mocked(getServerInfo)).not.toHaveBeenCalled(); + expect(actions).not.toEqual(expect.arrayContaining([expect.objectContaining({ type: 'SERVER.SELECT_REQUEST' })])); + expect(emitSpy).not.toHaveBeenCalledWith('NewServer', expect.anything()); + expect(jest.mocked(goRoom)).not.toHaveBeenCalled(); + + emitSpy.mockRestore(); + }); }); // ─── handleClickCallPush (OPEN_VIDEO_CONF) — new server + token ────────────────── @@ -566,6 +597,7 @@ describe('deepLinking saga — handleClickCallPush (new server + token + call ro jest.mocked(UserPreferences.getString).mockReset(); jest.mocked(getServerById).mockReset(); jest.mocked(getServerInfo).mockReset(); + jest.mocked(localAuthenticate).mockReset(); jest.mocked(navigateToRoom).mockReset(); jest.mocked(database.active.get).mockReset(); @@ -576,6 +608,7 @@ describe('deepLinking saga — handleClickCallPush (new server + token + call ro }); jest.mocked(getServerById).mockResolvedValue(null); jest.mocked(getServerInfo).mockResolvedValue({ success: true, version: '6.0.0' } as any); + jest.mocked(localAuthenticate).mockResolvedValue(undefined); // handleNavigateCallRoom resolves the subscription for params.rid. jest.mocked(database.active.get).mockReturnValue({ diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 9ca8d2261fb..fa55887d155 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -147,7 +147,14 @@ const handleShareExtension = function* handleOpen({ params }) { } yield put(appStart({ root: RootEnum.ROOT_LOADING_SHARE_EXTENSION })); - yield localAuthenticate(server); + try { + yield localAuthenticate(server); + } catch { + // Unlock canceled or superseded by another lock request — restart the normal flow instead + // of leaving the share extension stuck on the loading root. + yield put(appInit()); + return; + } const serverRecord = yield getServerById(server); if (!serverRecord) { return; @@ -197,7 +204,12 @@ const handleOpen = function* handleOpen({ params }) { if (server === host && user && serverRecord) { const connected = yield select(state => state.server.connected); if (!connected) { - yield localAuthenticate(host); + try { + yield localAuthenticate(host); + } catch { + // Unlock canceled or superseded by another lock request — drop the deep link. + return; + } yield put(selectServerRequest(host, serverRecord.version, true)); yield take(types.LOGIN.SUCCESS); } @@ -212,8 +224,9 @@ const handleOpen = function* handleOpen({ params }) { yield completeDeepLinkNavigation(params); return; } - } catch (e) { - // do nothing? + } catch { + // Unlock canceled or superseded by another lock request — drop the deep link. + return; } // if deep link is from a different server const result = yield getServerInfo(host); @@ -312,14 +325,24 @@ const handleClickCallPush = function* handleClickCallPush({ params }) { if (server === host && user && serverRecord) { const connected = yield select(state => state.server.connected); if (!connected) { - yield localAuthenticate(host); + try { + yield localAuthenticate(host); + } catch { + // Unlock canceled or superseded by another lock request — drop the call navigation. + return; + } yield put(selectServerRequest(host, serverRecord.version, true)); yield take(types.LOGIN.SUCCESS); } yield handleNavigateCallRoom({ params }); } else { if (user && serverRecord) { - yield localAuthenticate(host); + try { + yield localAuthenticate(host); + } catch { + // Unlock canceled or superseded by another lock request — drop the call navigation. + return; + } yield put(selectServerRequest(host, serverRecord.version, true, true)); yield take(types.LOGIN.SUCCESS); yield handleNavigateCallRoom({ params }); diff --git a/app/sagas/init.js b/app/sagas/init.js index d9d6024abe8..3d4839af07a 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -9,7 +9,8 @@ import { setAllPreferences } from '../actions/sortPreferences'; import { APP } from '../actions/actionsTypes'; import log from '../lib/methods/helpers/log'; import database from '../lib/database'; -import { localAuthenticate } from '../lib/methods/helpers/localAuthentication'; +import { localAuthenticate, UserCanceledError } from '../lib/methods/helpers/localAuthentication'; +import { runBiometricTrustMigration } from '../lib/biometricTrustStore/migration'; import { appReady, appStart } from '../actions/app'; import { RootEnum } from '../definitions'; import { getSortPreferences } from '../lib/methods/userPreferencesMethods'; @@ -23,6 +24,7 @@ export const initLocalSettings = function* initLocalSettings() { const restore = function* restore() { try { + yield call(runBiometricTrustMigration); const server = UserPreferences.getString(CURRENT_SERVER); let userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); @@ -45,7 +47,15 @@ const restore = function* restore() { } yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); } else { - yield localAuthenticate(server); + try { + yield localAuthenticate(server); + } catch (e) { + // A concurrent unlock (cold-boot deep-link/push race) superseded this one. The newer + // modal will gate the screen — keep booting instead of ejecting to the login screen. + if (!(e instanceof UserCanceledError)) { + throw e; // real failure → outer catch → ROOT_OUTSIDE + } + } const serverRecord = yield getServerById(server); if (!serverRecord) { return; diff --git a/app/sagas/login.js b/app/sagas/login.js index 10740600a33..f039d1c62fd 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -14,7 +14,7 @@ import database from '../lib/database'; import EventEmitter from '../lib/methods/helpers/events'; import { inviteLinksRequest } from '../actions/inviteLinks'; import { showErrorAlert } from '../lib/methods/helpers/info'; -import { localAuthenticate } from '../lib/methods/helpers/localAuthentication'; +import { localAuthenticate, UserCanceledError } from '../lib/methods/helpers/localAuthentication'; import { encryptionInit, encryptionStop } from '../actions/encryption'; import { initTroubleshootingNotification } from '../actions/troubleshootingNotification'; import UserPreferences from '../lib/methods/userPreferences'; @@ -88,7 +88,15 @@ const handleLoginRequest = function* handleLoginRequest({ credentials, logoutOnE yield put(appStart({ root: RootEnum.ROOT_SET_USERNAME })); } else { const server = yield select(getServer); - yield localAuthenticate(server); + try { + yield localAuthenticate(server); + } catch (e) { + // Login already succeeded; a concurrent unlock superseded this gate. Proceed to + // loginSuccess instead of falling through to loginFailure. Real errors still propagate. + if (!(e instanceof UserCanceledError)) { + throw e; + } + } // Saves username on server history const serversDB = database.servers; diff --git a/app/views/ChangePasscodeView.tsx b/app/views/ChangePasscodeView.tsx index ff4c5f25f03..ac8d0caef95 100644 --- a/app/views/ChangePasscodeView.tsx +++ b/app/views/ChangePasscodeView.tsx @@ -10,6 +10,7 @@ import { PasscodeChoose } from '../containers/Passcode'; import EventEmitter from '../lib/methods/helpers/events'; import { CustomIcon } from '../containers/CustomIcon'; import { CHANGE_PASSCODE_EMITTER } from '../lib/constants/localAuthentication'; +import { useDeferredModalSettle } from '../lib/hooks/useDeferredModalSettle'; import Touch from '../containers/Touch'; const styles = StyleSheet.create({ @@ -35,6 +36,7 @@ interface IArgs { const ChangePasscodeView = memo(() => { const [visible, setVisible] = useState(false); const [data, setData] = useState>({}); + const { onShow, defer, onModalHide } = useDeferredModalSettle>(); useDeepCompareEffect(() => { if (!isEmpty(data)) { @@ -45,22 +47,18 @@ const ChangePasscodeView = memo(() => { }, [data]); const showChangePasscode = (args: IArgs) => { + onShow(args); setData(args); }; const onSubmit = (passcode: string) => { const { submit } = data; - if (submit) { - submit(passcode); - } + defer(submit ? () => submit(passcode) : null); setData({}); }; const onCancel = () => { - const { cancel } = data; - if (cancel) { - cancel(); - } + defer(data.cancel || null); setData({}); }; @@ -72,7 +70,7 @@ const ChangePasscodeView = memo(() => { }, []); return ( - + {!data?.force ? ( diff --git a/app/views/RoomsListView/components/ServersList.tsx b/app/views/RoomsListView/components/ServersList.tsx index 1a15a4489dd..29d84007a47 100644 --- a/app/views/RoomsListView/components/ServersList.tsx +++ b/app/views/RoomsListView/components/ServersList.tsx @@ -92,7 +92,12 @@ const ServersList = () => { EventEmitter.emit('NewServer', { server: serverParam }); }, 300); } else { - await localAuthenticate(serverParam); + try { + await localAuthenticate(serverParam); + } catch { + // Unlock canceled or superseded by another lock request — keep the current server. + return; + } dispatch(selectServerRequest(serverParam, version, true, true)); } } diff --git a/app/views/ScreenLockConfigView.tsx b/app/views/ScreenLockConfigView.tsx index dc5f2f92d4c..3a64592eabf 100644 --- a/app/views/ScreenLockConfigView.tsx +++ b/app/views/ScreenLockConfigView.tsx @@ -10,13 +10,14 @@ import { changePasscode, checkHasPasscode, supportedBiometryLabel, - handleLocalAuthentication + handleLocalAuthentication, + UserCanceledError } from '../lib/methods/helpers/localAuthentication'; -import { BIOMETRY_ENABLED_KEY, DEFAULT_AUTO_LOCK } from '../lib/constants/localAuthentication'; +import { DEFAULT_AUTO_LOCK } from '../lib/constants/localAuthentication'; +import { biometricTrustStore } from '../lib/biometricTrustStore'; import { themes } from '../lib/constants/colors'; import SafeAreaView from '../containers/SafeAreaView'; -import { events, logEvent } from '../lib/methods/helpers/log'; -import userPreferences from '../lib/methods/userPreferences'; +import log, { events, logEvent } from '../lib/methods/helpers/log'; import { type IApplicationState, type TServerModel } from '../definitions'; import Switch from '../containers/Switch'; @@ -125,31 +126,66 @@ class ScreenLockConfigView extends Component { - const biometry = userPreferences.getBool(BIOMETRY_ENABLED_KEY) ?? DEFAULT_BIOMETRY; + const biometry = biometricTrustStore.isEnabled(); this.setState({ biometry }); }; changePasscode = async ({ force }: { force: boolean }) => { const { autoLock } = this.state; if (autoLock) { - await handleLocalAuthentication(true); + try { + await handleLocalAuthentication(true); + } catch { + // User dismissed the unlock modal — abort the passcode change. + return; + } } logEvent(events.SLC_CHANGE_PASSCODE); - await changePasscode({ force }); + try { + await changePasscode({ force }); + } catch (e) { + // A dismissed modal is benign; anything else (e.g. persisting the new passcode failed) + // must not be silently swallowed as if it were a cancel. + if (!(e instanceof UserCanceledError)) { + log(e); + } + } }; - toggleAutoLock = () => { - logEvent(events.SLC_TOGGLE_AUTOLOCK); + // Accepts the Switch's target value so the row onPress (List.Item passes the title string — + // any non-boolean arg → flip) and the Switch's onValueChange converge on the same target; the + // updater guard makes a double fire from a single tap (row press + switch value-change) a no-op + // instead of toggling back, and skips the side effects (checkHasPasscode/save) for the + // discarded duplicate. + toggleAutoLock = (value?: boolean) => { + if (this.props.Force_Screen_Lock) { + return; + } + const target = typeof value === 'boolean' ? value : !this.state.autoLock; + let applied = false; this.setState( - ({ autoLock }) => ({ autoLock: !autoLock, autoLockTime: DEFAULT_AUTO_LOCK }), + ({ autoLock }) => { + if (autoLock === target) { + return null; + } + applied = true; + return { autoLock: target, autoLockTime: DEFAULT_AUTO_LOCK }; + }, async () => { + if (!applied) { + return; + } + logEvent(events.SLC_TOGGLE_AUTOLOCK); const { autoLock } = this.state; if (autoLock) { try { await checkHasPasscode({ force: false }); this.hasBiometry(); } catch { + // Revert the toggle; its own callback persists the reverted state, so skip the + // save() below — otherwise one canceled toggle issues two writes. this.toggleAutoLock(); + return; } } this.save(); @@ -161,9 +197,15 @@ class ScreenLockConfigView extends Component ({ biometry: !biometry }), - () => { + async () => { const { biometry } = this.state; - userPreferences.setBool(BIOMETRY_ENABLED_KEY, biometry); + const result = await biometricTrustStore.setBiometryEnabled(biometry); + if (result.kind !== 'success') { + // setBiometryEnabled only fails on the enable path and always forces the persisted + // flag off, so the correct UI state is unconditionally `false` — a relative flip + // could land on `true` if another toggle interleaved during the await. + this.setState({ biometry: false }); + } } ); }; @@ -195,6 +237,7 @@ class ScreenLockConfigView extends Component @@ -263,20 +306,30 @@ class ScreenLockConfigView extends Component + this.renderAutoLockSwitch()} additionalAccessibilityLabel={autoLock} + onPress={Force_Screen_Lock ? undefined : this.toggleAutoLock} + disabled={Force_Screen_Lock} + accessibilityRole='switch' /> {autoLock ? ( <> - + this.changePasscode({ force: false })} + showActionIndicator + testID='screen-lock-config-view-change-passcode' + /> ) : null} diff --git a/app/views/ScreenLockedView.tsx b/app/views/ScreenLockedView.tsx index 41fdc67ed39..c3c83370bc5 100644 --- a/app/views/ScreenLockedView.tsx +++ b/app/views/ScreenLockedView.tsx @@ -10,6 +10,8 @@ import { LOCAL_AUTHENTICATE_EMITTER } from '../lib/constants/localAuthentication import { CustomIcon } from '../containers/CustomIcon'; import { hasNotch } from '../lib/methods/helpers'; import EventEmitter from '../lib/methods/helpers/events'; +import { useDeferredModalSettle } from '../lib/hooks/useDeferredModalSettle'; +import { type BiometricInvalidationReason } from '../definitions'; import Touch from '../containers/Touch'; interface IData { @@ -17,6 +19,7 @@ interface IData { cancel?: () => void; hasBiometry?: boolean; force?: boolean; + reason?: BiometricInvalidationReason; } const styles = StyleSheet.create({ @@ -33,6 +36,8 @@ const styles = StyleSheet.create({ const ScreenLockedView = () => { const [visible, setVisible] = useState(false); const [data, setData] = useState({}); + const [requestId, setRequestId] = useState(0); + const { onShow, defer, onModalHide } = useDeferredModalSettle(); useDeepCompareEffect(() => { if (!isEmpty(data)) { @@ -43,6 +48,8 @@ const ScreenLockedView = () => { }, [data]); const showScreenLock = (args: IData) => { + onShow(args); + setRequestId(current => current + 1); setData(args); }; @@ -54,18 +61,12 @@ const ScreenLockedView = () => { }, []); const onSubmit = () => { - const { submit } = data; - if (submit) { - submit(); - } + defer(data.submit || null); setData({}); }; const onCancel = () => { - const { cancel } = data; - if (cancel) { - cancel(); - } + defer(data.cancel || null); setData({}); }; @@ -76,9 +77,10 @@ const ScreenLockedView = () => { hideModalContentWhileAnimating style={{ margin: 0 }} animationIn='fadeIn' - animationOut='fadeOut'> + animationOut='fadeOut' + onModalHide={onModalHide}> - + {data?.force ? ( diff --git a/app/views/SecurityPrivacyView.tsx b/app/views/SecurityPrivacyView.tsx index 590fc29012d..8909d97b1d7 100644 --- a/app/views/SecurityPrivacyView.tsx +++ b/app/views/SecurityPrivacyView.tsx @@ -59,7 +59,12 @@ const SecurityPrivacyView = ({ navigation }: ISecurityPrivacyViewProps) => { const navigateToScreenLockConfigView = async () => { if (server?.autoLock) { - await handleLocalAuthentication(true); + try { + await handleLocalAuthentication(true); + } catch { + // User dismissed the unlock modal — stay on this screen. + return; + } } navigateToScreen('ScreenLockConfigView'); }; diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a80d616f080..712e677d604 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3191,6 +3191,34 @@ PODS: - SocketRocket - TOCropViewController (~> 2.7.4) - Yoga + - RNKeychain (10.0.0): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - RNLocalize (2.1.1): - React-Core - RNReanimated (4.1.3): @@ -3668,6 +3696,7 @@ DEPENDENCIES: - RNFileViewer (from `../node_modules/react-native-file-viewer`) - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`) + - RNKeychain (from `../node_modules/react-native-keychain`) - RNLocalize (from `../node_modules/react-native-localize`) - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) @@ -3956,6 +3985,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-gesture-handler" RNImageCropPicker: :path: "../node_modules/react-native-image-crop-picker" + RNKeychain: + :path: "../node_modules/react-native-keychain" RNLocalize: :path: "../node_modules/react-native-localize" RNReanimated: @@ -4119,6 +4150,7 @@ SPEC CHECKSUMS: RNFileViewer: f9424017fa643c115c1444e11292e84fb16ddd68 RNGestureHandler: b8d2e75c2e88fc2a1f6be3b3beeeed80b88fa37d RNImageCropPicker: 0a63af4b79e514c1edd6c3152f19300c5ed85312 + RNKeychain: 76d042fc1dfba47f3945920bc82e0bce9ad1ff55 RNLocalize: ca86348d88b9a89da0e700af58d428ab3f343c4e RNReanimated: e1690cdd7f215cfb96a3b7986b81889867dfdb4f RNScreens: ccfcc2f7d9c0d458b7fc41b3f4f0bea054602b3a diff --git a/jest.setup.js b/jest.setup.js index c5815af3787..9737dca4770 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -329,6 +329,16 @@ jest.mock('react-native-math-view', () => { jest.mock('react-native-keyboard-controller'); +jest.mock('react-native-keychain', () => ({ + ACCESS_CONTROL: { BIOMETRY_CURRENT_SET: 'BiometryCurrentSet' }, + ACCESSIBLE: { WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'AccessibleWhenUnlockedThisDeviceOnly' }, + AUTHENTICATION_TYPE: { BIOMETRICS: 'Biometrics' }, + setGenericPassword: jest.fn(() => Promise.resolve(true)), + getGenericPassword: jest.fn(() => Promise.resolve(false)), + resetGenericPassword: jest.fn(() => Promise.resolve(true)), + hasGenericPassword: jest.fn(() => Promise.resolve(false)) +})); + jest.mock('./app/lib/methods/helpers/externalInput', () => ({ isExternalKeyboardConnected: jest.fn(() => false) })); diff --git a/package.json b/package.json index b1bb535948c..af1ca253c64 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "react-native-image-crop-picker": "RocketChat/react-native-image-crop-picker#f028aac24373d05166747ef6d9e59bb037fe3224", "react-native-incall-manager": "^4.2.1", "react-native-katex": "git+https://github.com/RocketChat/react-native-katex.git", + "react-native-keychain": "10.0.0", "react-native-keyboard-controller": "1.18.5", "react-native-linear-gradient": "2.6.2", "react-native-localize": "2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8f5cc92591..acdcb338d20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,6 +249,9 @@ importers: react-native-keyboard-controller: specifier: 1.18.5 version: 1.18.5(react-native-reanimated@4.1.3(@babel/core@7.25.9)(react-native-worklets@0.6.1(@babel/core@7.25.9)(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-keychain: + specifier: 10.0.0 + version: 10.0.0 react-native-linear-gradient: specifier: 2.6.2 version: 2.6.2(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -6451,6 +6454,10 @@ packages: react-native: '*' react-native-reanimated: '>=3.0.0' + react-native-keychain@10.0.0: + resolution: {integrity: sha512-YzPKSAnSzGEJ12IK6CctNLU79T1W15WDrElRQ+1/FsOazGX9ucFPTQwgYe8Dy8jiSEDJKM4wkVa3g4lD2Z+Pnw==} + engines: {node: '>=16'} + react-native-linear-gradient@2.6.2: resolution: {integrity: sha512-Z8Xxvupsex+9BBFoSYS87bilNPWcRfRsGC0cpJk72Nxb5p2nEkGSBv73xZbEHnW2mUFvP+huYxrVvjZkr/gRjQ==} peerDependencies: @@ -15120,6 +15127,8 @@ snapshots: react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-reanimated: 4.1.3(@babel/core@7.25.9)(react-native-worklets@0.6.1(@babel/core@7.25.9)(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-keychain@10.0.0: {} + react-native-linear-gradient@2.6.2(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0