diff --git a/.maestro/tests/assorted/screen-lock.yaml b/.maestro/tests/assorted/screen-lock.yaml new file mode 100644 index 00000000000..4ea169bfcb4 --- /dev/null +++ b/.maestro/tests/assorted/screen-lock.yaml @@ -0,0 +1,270 @@ +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. +- pressKey: Home +- evalScript: ${output.utils.sleep(7000)} +- 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' + +# launchApp cold-starts, so unlocking lands on the rooms list. Navigate back 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' + +# 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 345678 +- 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 345678 +- 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. +- pressKey: Home +- evalScript: ${output.utils.sleep(7000)} +- 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' + +# The taps above pass whether or not the app accepts the digits, so assert the lock actually lifted. +# launchApp cold-starts, so navigation resets to the rooms list rather than the Screen Lock screen. +- extendedWaitUntil: + notVisible: + id: 'passcode-button-3' + timeout: 60000 +- extendedWaitUntil: + visible: + id: 'rooms-list-view' + timeout: 60000 diff --git a/.sniffler/test-map.json b/.sniffler/test-map.json index 8fa1d384893..9cc71acc8b0 100644 --- a/.sniffler/test-map.json +++ b/.sniffler/test-map.json @@ -101,6 +101,25 @@ "test": ".maestro/tests/assorted/profile.yaml", "dependsOn": ["app/views/ProfileView/**", "app/views/ChangePasswordView/**"] }, + { + "test": ".maestro/tests/assorted/screen-lock.yaml", + "dependsOn": [ + "app/views/ScreenLockConfigView.tsx", + "app/views/ScreenLockedView.tsx", + "app/views/ChangePasscodeView.tsx", + "app/views/SecurityPrivacyView.tsx", + "app/containers/Passcode/**", + "app/lib/hooks/useDeferredModalSettle.ts", + "app/lib/methods/helpers/localAuthentication.ts", + "app/lib/biometricTrustStore/**", + "app/lib/native/NativeBiometricEnrollment.ts", + "app/definitions/IBiometricTrustStore.ts", + "app/sagas/init.js", + "app/sagas/deepLinking.js", + "app/sagas/login.js", + "android/app/src/main/java/chat/rocket/reactnative/biometric/**" + ] + }, { "test": ".maestro/tests/assorted/setting.yaml", "dependsOn": [ 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..8426bba53fc --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/biometric/BiometricEnrollmentModule.kt @@ -0,0 +1,123 @@ +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 chat.rocket.reactnative.networking.NativeBiometricEnrollmentSpec + +import java.security.KeyStore +import java.security.UnrecoverableKeyException + +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey + +/** Silent biometric enrollment-change detection for Android. See docs/PLATFORMS.md. */ +class BiometricEnrollmentModule(reactContext: ReactApplicationContext) : + NativeBiometricEnrollmentSpec(reactContext) { + + companion object { + private const val TAG = "BiometricEnrollment" + private const val KEYSTORE_PROVIDER = "AndroidKeyStore" + // Alias is frozen: renaming it orphans every existing key, and isEnrollmentValid fails closed + // on the missing alias — one spurious passcode for every Android user with biometry on. + private const val KEY_ALIAS = "rc_biometric_enrollment_probe" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + } + + private fun loadKeyStore(): KeyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } + + private fun createEnrollmentKey() { + 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) + .setInvalidatedByBiometricEnrollment(true) + .build() + ) + keyGenerator.generateKey() + } + + /** Create the enrollment key bound to the current enrollment (idempotent). */ + override fun bindEnrollmentKey(promise: Promise) { + try { + val keyStore = loadKeyStore() + if (!keyStore.containsAlias(KEY_ALIAS)) { + createEnrollmentKey() + } + promise.resolve(true) + } catch (e: Exception) { + // Needs a current enrollment + secure lock screen; the JS caller refuses to enable biometry. + Log.w(TAG, "bindEnrollmentKey failed", e) + promise.resolve(false) + } + } + + /** Delete the enrollment key, kept in lockstep with the JS trust sentinel teardown. */ + override fun clearEnrollmentKey(promise: Promise) { + try { + val keyStore = loadKeyStore() + if (keyStore.containsAlias(KEY_ALIAS)) { + keyStore.deleteEntry(KEY_ALIAS) + } + promise.resolve(true) + } catch (e: Exception) { + Log.w(TAG, "clearEnrollmentKey failed", e) + promise.resolve(false) + } + } + + /** Silent, never creates a key: only a clean check of an existing key resolves true. */ + override fun isEnrollmentValid(promise: Promise) { + val keyStore: KeyStore + try { + keyStore = loadKeyStore() + } catch (e: Exception) { + // Provider unavailable says nothing about the key's validity, so fail open here only. + Log.w(TAG, "isEnrollmentValid: keystore unavailable", e) + promise.resolve(true) + return + } + + // Sole gate on a warm unlock, and OEMs report invalidation inconsistently: any failure is a change. + try { + if (!keyStore.containsAlias(KEY_ALIAS)) { + // Fail closed rather than self-heal a baseline onto the current enrollment. + Log.w(TAG, "isEnrollmentValid: enrollment key alias missing — treating as enrollment change") + 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() neither prompts nor runs 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: UnrecoverableKeyException) { + // Some OEMs report an enrollment-invalidated key here rather than at init(). + Log.w(TAG, "enrollment key unrecoverable — treating as enrollment change", e) + promise.resolve(false) + } catch (e: Exception) { + Log.w(TAG, "enrollment check failed — treating as enrollment change", e) + promise.resolve(false) + } + } +} 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..fb37262b017 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/biometric/BiometricEnrollmentPackage.kt @@ -0,0 +1,33 @@ +package chat.rocket.reactnative.biometric + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider +import chat.rocket.reactnative.networking.NativeBiometricEnrollmentSpec + +class BiometricEnrollmentPackage : BaseReactPackage() { + + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return when (name) { + NativeBiometricEnrollmentSpec.NAME -> BiometricEnrollmentModule(reactContext) + else -> null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + mapOf( + NativeBiometricEnrollmentSpec.NAME to ReactModuleInfo( + NativeBiometricEnrollmentSpec.NAME, + NativeBiometricEnrollmentSpec.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // isCxxModule + true // isTurboModule + ) + ) + } + } +} 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..da15e50d0f7 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,48 @@ const Timer = memo(({ time, setStatus }: IPasscodeTimer) => { const [timeLeft, setTimeLeft] = useState(calcTimeLeft()); useEffect(() => { - setTimeout(() => { - setTimeLeft(calcTimeLeft()); - if (timeLeft && timeLeft <= 1) { - resetAttempts(); + const unlock = async () => { + try { + // Clear before flipping status, or PasscodeEnter's readStorage re-seeds the old attempts count. + 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/__snapshots__/Base.test.tsx.snap b/app/containers/Passcode/Base/__snapshots__/Base.test.tsx.snap index a613d7c8ad5..32964571263 100644 --- a/app/containers/Passcode/Base/__snapshots__/Base.test.tsx.snap +++ b/app/containers/Passcode/Base/__snapshots__/Base.test.tsx.snap @@ -5275,7 +5275,6 @@ exports[`Story Snapshots: EnterWithSubtitleAndBiometry should match snapshot 1`] void; } +const HEADER_HEIGHT = 206; +const BUTTON_ROWS_TRIM = 56; + const Base = forwardRef( ({ type, onEndProcess, previousPasscode, title, subtitle, onError, showBiometry, onBiometryPress }, ref) => { const { colors } = useTheme(); const { height } = useResponsiveLayout(); - // 206 is the height of the header calculating the margins, icon size height, title font size and subtitle height. - // 56 is a fixed number to decrease the height of button numbers. - const dinamicHeight = (height - 206 - 56) / 4; + const dinamicHeight = (height - HEADER_HEIGHT - BUTTON_ROWS_TRIM) / 4; const heightButtonRow = { height: dinamicHeight > 102 ? 102 : dinamicHeight }; const rootRef = useRef(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..e07c2e4672b --- /dev/null +++ b/app/containers/Passcode/PasscodeEnter.test.tsx @@ -0,0 +1,186 @@ +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import PasscodeEnter from './PasscodeEnter'; +import { ATTEMPTS_KEY, LOCKED_OUT_TIMER_KEY, MAX_ATTEMPTS, PASSCODE_LENGTH } from '../../lib/constants/localAuthentication'; +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(), + setRelockPending: jest.fn(), + invalidate: jest.fn(() => Promise.resolve()), + disableBiometry: 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; +const mockedSetRelockPending = biometricTrustStore.setRelockPending as jest.Mock; +const mockedInvalidate = biometricTrustStore.invalidate 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); + // invalidate() is the single teardown primitive (ordering verified in index.test.ts); delegate to + // the same mocks so the disenroll/flag/relock assertions below still exercise the real sequence. + mockedInvalidate.mockImplementation(async () => { + biometricTrustStore.setRelockPending(true); + await biometricTrustStore.disenroll(); + biometricTrustStore.setEnabled(false); + }); + }); + + it('enrollmentChanged on mount → invalidates (arms relock debt, disenrolls, clears flag), hides biometry button', async () => { + mockedBiometryAuth.mockResolvedValueOnce({ kind: 'enrollmentChanged' }); + const finishProcess = jest.fn(); + + const { queryByTestId } = render(); + + await waitFor(() => expect(mockedInvalidate).toHaveBeenCalledTimes(1)); + expect(mockedDisenroll).toHaveBeenCalledTimes(1); + expect(mockedSetEnabled).toHaveBeenCalledWith(false); + // The relock debt must be armed here so a force-kill at this passcode screen still forces the + // passcode on the next cold launch (the flag is now off, so the live check can't re-detect it). + expect(mockedSetRelockPending).toHaveBeenCalledWith(true); + // ...and NOT cleared: the passcode has not been entered yet (finishProcess never fired). + expect(mockedSetRelockPending).not.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 invalidation subtitle', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + ['enrollmentChanged', 'Local_authentication_biometric_enrollment_changed'], + ['trustLost', 'Local_authentication_biometric_trust_lost'], + ['relockRequired', 'Local_authentication_biometric_relock_required'] + ] as const)('renders the %s subtitle', (reason, key) => { + const { getByText } = render(); + + expect(getByText(key)).toBeTruthy(); + }); + + it('does not render subtitle when reason is undefined', () => { + const { queryByText } = render(); + + expect(queryByText('Local_authentication_biometric_enrollment_changed')).toBeNull(); + }); +}); + +// The stored passcode is null (see the userPreferences mock), so any entered code is a wrong one. +describe('PasscodeEnter failed attempts', () => { + const enterWrongPasscode = (getByTestId: ReturnType['getByTestId']) => { + for (let i = 0; i < PASSCODE_LENGTH; i += 1) { + fireEvent.press(getByTestId('passcode-button-1')); + } + }; + + beforeEach(async () => { + jest.clearAllMocks(); + await AsyncStorage.clear(); + }); + + it('persists a failure that does not reach the lockout', async () => { + const { getByTestId } = render(); + await waitFor(() => expect(getByTestId('passcode-button-1')).toBeTruthy()); + + enterWrongPasscode(getByTestId); + + await waitFor(() => expect(AsyncStorage.getItem(ATTEMPTS_KEY)).resolves.toBe('1')); + await expect(AsyncStorage.getItem(LOCKED_OUT_TIMER_KEY)).resolves.toBeNull(); + }); + + // readStorage seeds the counter from ATTEMPTS_KEY, so a lockout-triggering failure that skipped the + // write would leave "5" behind and hand a remount a free sixth attempt with no lock. + it('persists the failure that triggers the lockout, and writes the timer before locking', async () => { + await AsyncStorage.setItem(ATTEMPTS_KEY, String(MAX_ATTEMPTS - 1)); + + const { getByTestId, getByText } = render(); + await waitFor(() => expect(getByTestId('passcode-button-1')).toBeTruthy()); + + enterWrongPasscode(getByTestId); + + // The locked screen only renders once the timer is readable, which is what the awaited write buys. + await waitFor(() => expect(getByText('Passcode_app_locked_title')).toBeTruthy()); + await expect(AsyncStorage.getItem(ATTEMPTS_KEY)).resolves.toBe(String(MAX_ATTEMPTS)); + await expect(AsyncStorage.getItem(LOCKED_OUT_TIMER_KEY)).resolves.not.toBeNull(); + }); +}); diff --git a/app/containers/Passcode/PasscodeEnter.tsx b/app/containers/Passcode/PasscodeEnter.tsx index 05e4ad46d1a..4f10866850c 100644 --- a/app/containers/Passcode/PasscodeEnter.tsx +++ b/app/containers/Passcode/PasscodeEnter.tsx @@ -9,39 +9,62 @@ 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 log from '../../lib/methods/helpers/log'; +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'; +const SUBTITLE_KEYS: Record = { + enrollmentChanged: 'Local_authentication_biometric_enrollment_changed', + trustLost: 'Local_authentication_biometric_trust_lost', + relockRequired: 'Local_authentication_biometric_relock_required' +}; + 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 autoPrompted = useRef(false); const [passcode] = useUserPreferences(PASSCODE_KEY); const [status, setStatus] = useState(null); - const { setItem: setAttempts } = useAsyncStorage(ATTEMPTS_KEY); + // Mirrored locally so an invalidation hides the biometry button within the same modal session. + 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 from storage so a remount mid-session doesn't grant a fresh attempt budget. + 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,28 +72,47 @@ const PasscodeEnter = ({ hasBiometry, finishProcess }: IPasscodePasscodeEnter) = } else { setStatus(TYPE.ENTER); } - biometry(); }; useEffect(() => { readStorage(); }, [status]); + // Auto-prompt from behind this modal so app content stays covered during the OS prompt. Latched: + // readStorage re-runs on every status change, and a lockout expiry must not re-prompt unasked. + useEffect(() => { + if (status !== TYPE.ENTER || autoPrompted.current) { + return; + } + autoPrompted.current = true; + biometry(); + }, [status]); + const onEndProcess = (p: string) => { - setTimeout(() => { + setTimeout(async () => { if (sha256(p) === passcode) { finishProcess(); - } else { - attempts += 1; - if (attempts >= MAX_ATTEMPTS) { - setStatus(TYPE.LOCKED); - setLockedUntil(new Date().toISOString()); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); - } else { - ref?.current?.wrongPasscode(); - setAttempts(attempts?.toString()); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + return; + } + attempts.current += 1; + const locked = attempts.current >= MAX_ATTEMPTS; + try { + // Every failure, the lockout-triggering one included: readStorage re-seeds the counter from + // here, so a count that stopped at MAX_ATTEMPTS - 1 would grant a remount an extra attempt. + await setAttempts(attempts.current.toString()); + if (locked) { + // Awaited before the status flip, which mounts Locked and has it read this timer back. + await setLockedUntil(new Date().toISOString()); } + } catch (e) { + log(e); + } + if (locked) { + setStatus(TYPE.LOCKED); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + } else { + ref?.current?.wrongPasscode(); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); } }, 200); }; @@ -79,11 +121,14 @@ const PasscodeEnter = ({ hasBiometry, finishProcess }: IPasscodePasscodeEnter) = return ; } + const subtitle = reason ? I18n.t(SUBTITLE_KEYS[reason]) : null; + return ( ; + disenroll(): Promise; + verify(opts: { promptCopy: BiometricPromptCopy }): Promise; + // Silent. Rejects on keystore/storage failures so callers can tell errors from true absence. + hasEnrollment(): Promise; + // Silent. False only on a detected Android enrollment change; always true on iOS, where + // hasEnrollment covers it. + isEnrollmentValid(): Promise; + isEnabled(): boolean; + setEnabled(enabled: boolean): void; + // Forces a passcode on the next unlock regardless of the auto-lock window. + isRelockPending(): boolean; + setRelockPending(pending: boolean): void; + invalidate(): Promise; + // Disenroll and clear the flag as one operation. Disenroll is best-effort, so this cannot fail: + // the user must always be able to turn biometry off. Enabling goes through enableBiometry. + disableBiometry(): Promise; +} diff --git a/app/definitions/index.ts b/app/definitions/index.ts index 45d4bdc2dd6..8d70fa25c20 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 './ILoginCredentials'; diff --git a/app/i18n/locales/ar.json b/app/i18n/locales/ar.json index 53e24e6431b..5211d2ed93d 100644 --- a/app/i18n/locales/ar.json +++ b/app/i18n/locales/ar.json @@ -324,8 +324,11 @@ "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_enable_failed": "تعذر تفعيل إلغاء القفل باستخدام القياسات الحيوية.", "Local_authentication_biometry_fallback": "استخدم كلمة المرور", "Local_authentication_biometry_title": "صادق", + "Local_authentication_biometry_unavailable": "لا يمكن استخدام هذا الجهاز لإلغاء القفل باستخدام القياسات الحيوية.", "Local_authentication_change_passcode": "تغيير كلمة المرور", "Local_authentication_facial_recognition": "التعرف على الوجه", "Local_authentication_fingerprint": "البصمة", diff --git a/app/i18n/locales/bn-IN.json b/app/i18n/locales/bn-IN.json index 9daf3675e2f..0d91bd45938 100644 --- a/app/i18n/locales/bn-IN.json +++ b/app/i18n/locales/bn-IN.json @@ -451,8 +451,11 @@ "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_enable_failed": "বায়োমেট্রিক আনলক সক্রিয় করা যায়নি।", "Local_authentication_biometry_fallback": "পাসকোড ব্যবহার করুন", "Local_authentication_biometry_title": "প্রমাণীকরণ", + "Local_authentication_biometry_unavailable": "এই ডিভাইসটি বায়োমেট্রিক আনলকের জন্য ব্যবহার করা যাবে না।", "Local_authentication_change_passcode": "পাসকোড পরিবর্তন করুন", "Local_authentication_facial_recognition": "মুখের চিহ্নিতকরণ", "Local_authentication_fingerprint": "আঙুলচিহ্ন", diff --git a/app/i18n/locales/cs.json b/app/i18n/locales/cs.json index 1d18ac3efbb..e38cf19878a 100644 --- a/app/i18n/locales/cs.json +++ b/app/i18n/locales/cs.json @@ -483,8 +483,11 @@ "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_enable_failed": "Biometrické odemknutí se nepodařilo povolit.", "Local_authentication_biometry_fallback": "Použít přístupový kód", "Local_authentication_biometry_title": "Ověřit", + "Local_authentication_biometry_unavailable": "Toto zařízení nelze použít k biometrickému odemknutí.", "Local_authentication_change_passcode": "Změnit heslo", "Local_authentication_facial_recognition": "Rozpoznávání obličeje", "Local_authentication_fingerprint": "otisk prstu", diff --git a/app/i18n/locales/de.json b/app/i18n/locales/de.json index c1925e399ea..b78161bb2fc 100644 --- a/app/i18n/locales/de.json +++ b/app/i18n/locales/de.json @@ -445,8 +445,11 @@ "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_enable_failed": "Die biometrische Entsperrung konnte nicht aktiviert werden.", "Local_authentication_biometry_fallback": "Sicherheitscode benutzen", "Local_authentication_biometry_title": "Authentifizieren", + "Local_authentication_biometry_unavailable": "Dieses Gerät kann nicht für die biometrische Entsperrung verwendet werden.", "Local_authentication_change_passcode": "Ändere Sicherheitscode", "Local_authentication_facial_recognition": "Gesichtserkennung", "Local_authentication_fingerprint": "Fingerabdruck", diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 8f9606fe55d..aec2b16f742 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -498,8 +498,13 @@ "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_biometric_relock_required": "Please confirm your passcode\nto continue", + "Local_authentication_biometric_trust_lost": "Biometric unlock was turned off,\nplease use your passcode", + "Local_authentication_biometry_enable_failed": "Couldn't enable biometric unlock.", "Local_authentication_biometry_fallback": "Use passcode", "Local_authentication_biometry_title": "Authenticate", + "Local_authentication_biometry_unavailable": "This device can't be used for biometric unlock.", "Local_authentication_change_passcode": "Change passcode", "Local_authentication_facial_recognition": "facial recognition", "Local_authentication_fingerprint": "fingerprint", diff --git a/app/i18n/locales/es.json b/app/i18n/locales/es.json index d15ce6d6cb1..8254a646920 100644 --- a/app/i18n/locales/es.json +++ b/app/i18n/locales/es.json @@ -265,6 +265,9 @@ "License": "Licencia", "Light": "Claro", "Loading": "Cargando", + "Local_authentication_biometric_enrollment_changed": "El registro biométrico ha cambiado,\npor favor usa tu código", + "Local_authentication_biometry_enable_failed": "No se pudo activar el desbloqueo biométrico.", + "Local_authentication_biometry_unavailable": "Este dispositivo no se puede usar para el desbloqueo biométrico.", "Login": "Inicio de sesión", "Login_error": "¡Sus credenciales fueron rechazadas! Por favor, inténtelo de nuevo.", "Login_on_web": "Iniciar sesión en la web", diff --git a/app/i18n/locales/fi.json b/app/i18n/locales/fi.json index 45e7e8c9264..cae169a2936 100644 --- a/app/i18n/locales/fi.json +++ b/app/i18n/locales/fi.json @@ -426,8 +426,11 @@ "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_enable_failed": "Biometristä avausta ei voitu ottaa käyttöön.", "Local_authentication_biometry_fallback": "Käytä salasanaa", "Local_authentication_biometry_title": "Todenna", + "Local_authentication_biometry_unavailable": "Tätä laitetta ei voi käyttää biometriseen avaukseen.", "Local_authentication_change_passcode": "Vaihda salasana", "Local_authentication_facial_recognition": "kasvojentunnistus", "Local_authentication_fingerprint": "sormenjälki", diff --git a/app/i18n/locales/fr.json b/app/i18n/locales/fr.json index fa613486ddc..f39db7b0129 100644 --- a/app/i18n/locales/fr.json +++ b/app/i18n/locales/fr.json @@ -391,8 +391,11 @@ "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_enable_failed": "Impossible d’activer le déverrouillage biométrique.", "Local_authentication_biometry_fallback": "Utiliser le code d'accès", "Local_authentication_biometry_title": "Authentifier", + "Local_authentication_biometry_unavailable": "Cet appareil ne peut pas être utilisé pour le déverrouillage biométrique.", "Local_authentication_change_passcode": "Changer le code d'accès", "Local_authentication_facial_recognition": "reconnaissance faciale", "Local_authentication_fingerprint": "empreinte digitale", diff --git a/app/i18n/locales/hi-IN.json b/app/i18n/locales/hi-IN.json index 97f3343177e..b73e97882ef 100644 --- a/app/i18n/locales/hi-IN.json +++ b/app/i18n/locales/hi-IN.json @@ -451,8 +451,11 @@ "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_enable_failed": "बायोमेट्रिक अनलॉक सक्षम नहीं किया जा सका।", "Local_authentication_biometry_fallback": "पासकोड का उपयोग करें", "Local_authentication_biometry_title": "प्रमाणीकरण करें", + "Local_authentication_biometry_unavailable": "इस डिवाइस का उपयोग बायोमेट्रिक अनलॉक के लिए नहीं किया जा सकता।", "Local_authentication_change_passcode": "पासकोड बदलें", "Local_authentication_facial_recognition": "चेहरा पहचान", "Local_authentication_fingerprint": "अंगुली की पहचान", diff --git a/app/i18n/locales/hu.json b/app/i18n/locales/hu.json index 89f68d4a3b2..2281e852529 100644 --- a/app/i18n/locales/hu.json +++ b/app/i18n/locales/hu.json @@ -452,8 +452,11 @@ "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_enable_failed": "Nem sikerült engedélyezni a biometrikus feloldást.", "Local_authentication_biometry_fallback": "Jelkód használata", "Local_authentication_biometry_title": "Hitelesítés", + "Local_authentication_biometry_unavailable": "Ez az eszköz nem használható biometrikus feloldásra.", "Local_authentication_change_passcode": "Jelkód módosítása", "Local_authentication_facial_recognition": "arcfelismerés", "Local_authentication_fingerprint": "ujjlenyomat", diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json index 5bd67b24060..cc1a557891c 100644 --- a/app/i18n/locales/it.json +++ b/app/i18n/locales/it.json @@ -353,8 +353,11 @@ "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_enable_failed": "Impossibile abilitare lo sblocco biometrico.", "Local_authentication_biometry_fallback": "Usa passcode", "Local_authentication_biometry_title": "Autenticazione", + "Local_authentication_biometry_unavailable": "Questo dispositivo non può essere utilizzato per lo sblocco biometrico.", "Local_authentication_change_passcode": "Cambia Passcode", "Local_authentication_facial_recognition": "riconoscimento facciale", "Local_authentication_fingerprint": "impronta digitale", diff --git a/app/i18n/locales/ja.json b/app/i18n/locales/ja.json index 86da11d01d9..92bbad0725d 100644 --- a/app/i18n/locales/ja.json +++ b/app/i18n/locales/ja.json @@ -320,6 +320,9 @@ "License": "ライセンス", "Light": "ライト", "Loading": "読み込み中", + "Local_authentication_biometric_enrollment_changed": "生体認証の登録が変更されました。\nパスコードを使用してください", + "Local_authentication_biometry_enable_failed": "生体認証によるロック解除を有効にできませんでした。", + "Local_authentication_biometry_unavailable": "このデバイスは生体認証によるロック解除に使用できません。", "Logged_out_by_server": "サーバーからログアウトします。もう一度ログインしてください。", "Logging_out": "ログアウトしています。", "Login": "ログイン", diff --git a/app/i18n/locales/nl.json b/app/i18n/locales/nl.json index 0d86ce4995c..a82dabcebb0 100644 --- a/app/i18n/locales/nl.json +++ b/app/i18n/locales/nl.json @@ -391,8 +391,11 @@ "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_enable_failed": "Biometrisch ontgrendelen kon niet worden ingeschakeld.", "Local_authentication_biometry_fallback": "Gebruik toegangscode", "Local_authentication_biometry_title": "Authenticeren", + "Local_authentication_biometry_unavailable": "Dit apparaat kan niet worden gebruikt voor biometrisch ontgrendelen.", "Local_authentication_change_passcode": "Wijzig toegangscode", "Local_authentication_facial_recognition": "gezichtsherkenning", "Local_authentication_fingerprint": "vingerafdruk", diff --git a/app/i18n/locales/nn.json b/app/i18n/locales/nn.json index 84f8b93d623..4683f8eab8f 100644 --- a/app/i18n/locales/nn.json +++ b/app/i18n/locales/nn.json @@ -248,6 +248,9 @@ "License": "Tillatelse", "Load_More": "Last mer", "Loading": "Laster", + "Local_authentication_biometric_enrollment_changed": "Den biometriske registreringa er endra,\nbruk passordkoden din", + "Local_authentication_biometry_enable_failed": "Klarte ikkje å aktivere biometrisk opplåsing.", + "Local_authentication_biometry_unavailable": "Denne eininga kan ikkje brukast til biometrisk opplåsing.", "Logged_out_of_other_clients_successfully": "Logget ut av andre klienter", "Login": "Logg inn", "Login_has_been_temporarily_blocked_for_this_IP": "Innlogging er midlertidig blokkert for denne IP-adressen", diff --git a/app/i18n/locales/no.json b/app/i18n/locales/no.json index 097da054fa0..a4b3d6d3bc3 100644 --- a/app/i18n/locales/no.json +++ b/app/i18n/locales/no.json @@ -477,8 +477,11 @@ "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_enable_failed": "Kunne ikke aktivere biometrisk opplåsing.", "Local_authentication_biometry_fallback": "Bruk passord", "Local_authentication_biometry_title": "Autentiser", + "Local_authentication_biometry_unavailable": "Denne enheten kan ikke brukes til biometrisk opplåsing.", "Local_authentication_change_passcode": "Endre passord", "Local_authentication_facial_recognition": "ansiktsgjenkjenning", "Local_authentication_fingerprint": "fingeravtrykk", diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json index 9b2e3db9f8f..7e024fcf5ae 100644 --- a/app/i18n/locales/pt-BR.json +++ b/app/i18n/locales/pt-BR.json @@ -493,8 +493,11 @@ "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_enable_failed": "Não foi possível ativar o desbloqueio biométrico.", "Local_authentication_biometry_fallback": "Usar senha", "Local_authentication_biometry_title": "Autenticar", + "Local_authentication_biometry_unavailable": "Este dispositivo não pode ser usado para desbloqueio biométrico.", "Local_authentication_change_passcode": "Alterar senha", "Local_authentication_facial_recognition": "reconhecimento facial", "Local_authentication_fingerprint": "impressão digital", diff --git a/app/i18n/locales/pt-PT.json b/app/i18n/locales/pt-PT.json index 22b18117bb4..b25e54caf95 100644 --- a/app/i18n/locales/pt-PT.json +++ b/app/i18n/locales/pt-PT.json @@ -315,6 +315,9 @@ "License": "Licença", "Light": "Luz", "Loading": "A carregar", + "Local_authentication_biometric_enrollment_changed": "O registo biométrico foi alterado,\nutilize o seu código", + "Local_authentication_biometry_enable_failed": "Não foi possível ativar o desbloqueio biométrico.", + "Local_authentication_biometry_unavailable": "Este dispositivo não pode ser utilizado para desbloqueio biométrico.", "Logging_out": "A terminar a sessão.", "Login": "Entrar", "Login_error": "As suas credenciais foram rejeitadas! Por favor, tente novamente.", diff --git a/app/i18n/locales/ru.json b/app/i18n/locales/ru.json index 950a186f227..cac01e0f930 100644 --- a/app/i18n/locales/ru.json +++ b/app/i18n/locales/ru.json @@ -416,8 +416,11 @@ "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_enable_failed": "Не удалось включить биометрическую разблокировку.", "Local_authentication_biometry_fallback": "Использовать пароль", "Local_authentication_biometry_title": "Аутентификация", + "Local_authentication_biometry_unavailable": "Это устройство нельзя использовать для биометрической разблокировки.", "Local_authentication_change_passcode": "Изменить Пароль", "Local_authentication_facial_recognition": "распознавания лица", "Local_authentication_fingerprint": "отпечатка пальца", diff --git a/app/i18n/locales/sl-SI.json b/app/i18n/locales/sl-SI.json index 0957302775d..7573923a178 100644 --- a/app/i18n/locales/sl-SI.json +++ b/app/i18n/locales/sl-SI.json @@ -401,8 +401,11 @@ "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_enable_failed": "Biometričnega odklepanja ni bilo mogoče omogočiti.", "Local_authentication_biometry_fallback": "Uporabite geslo", "Local_authentication_biometry_title": "Overiti", + "Local_authentication_biometry_unavailable": "Te naprave ni mogoče uporabiti za biometrično odklepanje.", "Local_authentication_change_passcode": "Spremenite geslo", "Local_authentication_facial_recognition": "Prepoznavanje obraza", "Local_authentication_fingerprint": "prstni odtis", diff --git a/app/i18n/locales/sv.json b/app/i18n/locales/sv.json index 3466cfea7ca..86af6b2451f 100644 --- a/app/i18n/locales/sv.json +++ b/app/i18n/locales/sv.json @@ -425,8 +425,11 @@ "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_enable_failed": "Det gick inte att aktivera biometrisk upplåsning.", "Local_authentication_biometry_fallback": "Använd lösenkod", "Local_authentication_biometry_title": "Autentisering", + "Local_authentication_biometry_unavailable": "Den här enheten kan inte användas för biometrisk upplåsning.", "Local_authentication_change_passcode": "Ändra lösenkod", "Local_authentication_facial_recognition": "ansiktsigenkänning", "Local_authentication_fingerprint": "fingeravtryck", diff --git a/app/i18n/locales/ta-IN.json b/app/i18n/locales/ta-IN.json index 83f1a3251fd..5a115563496 100644 --- a/app/i18n/locales/ta-IN.json +++ b/app/i18n/locales/ta-IN.json @@ -451,8 +451,11 @@ "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_enable_failed": "பயோமெட்ரிக் திறப்பை இயக்க முடியவில்லை.", "Local_authentication_biometry_fallback": "கடவுச்சொல் பயன்படுத்துக", "Local_authentication_biometry_title": "அங்கீகரிக்க", + "Local_authentication_biometry_unavailable": "இந்தச் சாதனத்தை பயோமெட்ரிக் திறப்பதற்குப் பயன்படுத்த முடியாது.", "Local_authentication_change_passcode": "கடவுச்சொல் மாற்று", "Local_authentication_facial_recognition": "முகமூலம் அங்கீகரிப்பு", "Local_authentication_fingerprint": "கைகால் முட்டாள்", diff --git a/app/i18n/locales/te-IN.json b/app/i18n/locales/te-IN.json index dab8b05080e..c2326b7e4b3 100644 --- a/app/i18n/locales/te-IN.json +++ b/app/i18n/locales/te-IN.json @@ -450,8 +450,11 @@ "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_enable_failed": "బయోమెట్రిక్ అన్‌లాక్‌ను ప్రారంభించడం సాధ్యం కాలేదు.", "Local_authentication_biometry_fallback": "పాస్‌కోడ్ ఉపయోగించండి", "Local_authentication_biometry_title": "ధ్యానం పెట్టినవారు", + "Local_authentication_biometry_unavailable": "బయోమెట్రిక్ అన్‌లాక్ కోసం ఈ పరికరాన్ని ఉపయోగించలేరు.", "Local_authentication_change_passcode": "పాస్‌కోడ్ను మార్చండి", "Local_authentication_facial_recognition": "ముఖంలో గుర్తింపు", "Local_authentication_fingerprint": "వంగ్ల గుర్తింపు", diff --git a/app/i18n/locales/tr.json b/app/i18n/locales/tr.json index 8c03a15c015..18f8055d006 100644 --- a/app/i18n/locales/tr.json +++ b/app/i18n/locales/tr.json @@ -338,8 +338,11 @@ "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_enable_failed": "Biyometrik kilit açma etkinleştirilemedi.", "Local_authentication_biometry_fallback": "Parola kullan", "Local_authentication_biometry_title": "Doğrula", + "Local_authentication_biometry_unavailable": "Bu cihaz biyometrik kilit açma için kullanılamaz.", "Local_authentication_change_passcode": "Parolayı Değiştir", "Local_authentication_facial_recognition": "yüz tanıma", "Local_authentication_fingerprint": "parmak izi", diff --git a/app/i18n/locales/zh-CN.json b/app/i18n/locales/zh-CN.json index 396b899cefe..0242b92cfd2 100644 --- a/app/i18n/locales/zh-CN.json +++ b/app/i18n/locales/zh-CN.json @@ -323,8 +323,11 @@ "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_enable_failed": "无法启用生物识别解锁。", "Local_authentication_biometry_fallback": "使用通关密码", "Local_authentication_biometry_title": "验证", + "Local_authentication_biometry_unavailable": "此设备无法用于生物识别解锁。", "Local_authentication_change_passcode": "变更通关密码", "Local_authentication_facial_recognition": "脸部辨识", "Local_authentication_fingerprint": "指纹辨识", diff --git a/app/i18n/locales/zh-TW.json b/app/i18n/locales/zh-TW.json index a82f36c4aa9..6bd8e146316 100644 --- a/app/i18n/locales/zh-TW.json +++ b/app/i18n/locales/zh-TW.json @@ -339,8 +339,11 @@ "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_enable_failed": "無法啟用生物識別解鎖。", "Local_authentication_biometry_fallback": "使用通關密碼", "Local_authentication_biometry_title": "驗證", + "Local_authentication_biometry_unavailable": "此裝置無法用於生物識別解鎖。", "Local_authentication_change_passcode": "變更通關密碼", "Local_authentication_facial_recognition": "臉部辨識", "Local_authentication_fingerprint": "指紋辨識", diff --git a/app/lib/biometricTrustStore/docs/ARCHITECTURE.md b/app/lib/biometricTrustStore/docs/ARCHITECTURE.md new file mode 100644 index 00000000000..54df255e3a9 --- /dev/null +++ b/app/lib/biometricTrustStore/docs/ARCHITECTURE.md @@ -0,0 +1,222 @@ +# 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. + +On **Android** that read is not a reliable prompt either: the keystore key behind it carries a 5-second auth window that accepts device credentials, so it can resolve silently. `verify()` is consequently an **iOS-only** presence check; Android proves presence with `expo-local-authentication` instead. `biometryAuth` owns that split — see `PLATFORMS.md`, "Why the sentinel read can't prove presence". + +--- + +## 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 (and, on Android, bind the native enrollment key — see below; an enrollment key that cannot be bound tears the sentinel back down and returns `unavailable`). On success also sets the migration marker (see below). Returns `TrustResult`. | +| `disenroll()` | no | Delete the sentinel (and the Android enrollment key). Best-effort; swallows errors (item may already be gone). | +| `verify({ promptCopy })` | **yes** | Check the sentinel; if present, read it back behind the OS biometric sheet. Returns `TrustResult`. | +| `hasEnrollment()` | no | Silent existence check for the sentinel. | +| `isEnrollmentValid()` | no | Silent check that the _current_ enrollment still matches what trust was bound to. iOS always returns `true` (the sentinel already covers changes); Android consults the native enrollment key. Returns `false` only on a detected Android enrollment change. See `PLATFORMS.md`. | +| `isEnabled()` / `setEnabled(b)` | no | Own the persisted `BIOMETRY_ENABLED_KEY` flag so callers never touch `UserPreferences` directly. | +| `isRelockPending()` / `setRelockPending(b)` | no | Own the persisted relock marker (see "persisted state" below). Set when an enrollment change is detected at a point that cannot show the passcode itself (the init migration), so the next unlock is forced to demand it regardless of the auto-lock window. | +| `disableBiometry()` | no | Disenroll and clear the flag as one operation. Cannot fail — `disenroll()` is best-effort, and the user must always be able to turn biometry off. There is deliberately no enable counterpart: enabling has to capture consent, which is `enableBiometry`'s job (`localAuthentication.ts`). | + +### Pieces of persisted state + +Three 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**. +3. **`BIOMETRIC_PENDING_RELOCK_KEY`** (`kBiometricPendingRelock`) — "force the passcode on the next unlock." Owned by `isRelockPending`/`setRelockPending`. Referred to as **the relock marker**. Set by the init migration when it consumes an enrollment-change signal (or grandfathers an untrusted baseline) on cold launch — because the migration runs _before_ `localAuthenticate`, it would otherwise swallow the signal silently and the session would unlock inside the auto-lock window. `handleLocalAuthentication`/`localAuthenticate` read it (OR-ed with the live `hasBiometricEnrollmentChanged()` check) and clear it once the forced passcode modal is shown. See `FLOWS.md` §3–§4. + +The sentinel (keychain) is a further piece of state. On Android there is also the native enrollment key (see below), kept in lockstep with the sentinel. The interplay between flag, marker, relock marker, and sentinel is the entire subtlety of this subsystem — see "Migration" below. + +### The Android native enrollment key + +On iOS a `BIOMETRY_CURRENT_SET` keychain item is _deleted_ by the OS when the enrollment set changes, so `hasEnrollment()` alone detects a change silently. On Android the keystore key backing such an item is only _invalidated_, not deleted — the sentinel survives an enrollment change, and the invalidation surfaces only as a `KeyPermanentlyInvalidatedException` the first time the key is _used_ (which, via `react-native-keychain`'s combined read, would show the biometric prompt). So Android needs a separate detector. + +`nativeEnrollmentCheck.ts` bridges a dedicated native module (`android/.../biometric/BiometricEnrollmentModule.kt`) that keeps a standalone AES keystore key (`setInvalidatedByBiometricEnrollment = true`) bound to the current enrollment: + +- **`bindEnrollmentKey()`** — create the enrollment key bound to the current enrollment, in lockstep with `enroll()`. **Fails closed:** `enroll()` refuses to enable biometry (`unavailable`, sentinel torn down, migration marker not written) when the enrollment key cannot be bound. There is no `verify()` backstop to fall back on — the warm auto-lock unlock consults the enrollment key _only_ (see `PLATFORMS.md`, "Where each guarantee comes from"), so an install with a sentinel but no enrollment key would report a change on its very next unlock and tear biometry down for a change that never happened. Better to fail the toggle the user is looking at than to break the unlock afterwards. +- **`clearEnrollmentKey()`** — delete the enrollment key, in lockstep with `disenroll()`. +- **`isEnrollmentValid()`** — silent `cipher.init()` on the enrollment key: succeeds (no prompt) for a valid enrollment, throws `KeyPermanentlyInvalidatedException` once it changed. Returns `false` only on a detected change. A keystore-provider failure inside the native module resolves `true` so a transient error never forces the passcode on its own; a bridge-level failure (module missing, call rejects) resolves `false` and forces the passcode. + +On iOS these helpers are no-ops that resolve `true` (the sentinel already covers enrollment changes). **Caveat:** `isEnrollmentValid()` never creates a key — a missing alias resolves `false`, i.e. "treat as changed", rather than self-healing to whatever is currently enrolled. It therefore says nothing about a change that happened _before_ a baseline was ever bound; it can only refuse to vouch for one. Establishing trust for a pre-feature user is the migration's job (see "Migration"), not the enrollment key's. + +### 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. Every enable path must therefore follow `enroll()` with a `biometryAuth(true)` prompt and tear the sentinel back down if the user declines. That sequence lives in one place — `enableBiometry` in `localAuthentication.ts` — used by both the first-passcode opt-in (`checkBiometry`) and the settings toggle. This is why `enroll` and the consent prompt are separate steps rather than one call. See `FLOWS.md` §2. + +The consent step goes through `biometryAuth`, not `verify()` directly, for the Android reason above: a `verify()` that resolves inside the keystore's auth window shows no prompt, which would silently enable biometric unlock the user was never asked about. + +--- + +## 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** | `setRelockPending(true)`, **then** `enroll()` | **grandfather**: pre-feature user, bind a sentinel once and force a confirming passcode | +| true | absent | **true** | `setRelockPending(true)`, **then** `setEnabled(false)`, no enroll | **reconciliation**: flag/sentinel desync, clear the flag and force the passcode | + +If `enroll()` fails during the grandfather path, the migration 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 relock marker armed up front stays set; it is self-clearing on the next forced unlock, so the cost is at most one spurious passcode prompt. + +> **Ordering is load-bearing.** The relock marker is armed **before** `enroll()`, not after. `enroll()` persists both the sentinel and the migration marker before it resolves (and then awaits `bindEnrollmentKey()`), so arming afterwards leaves a window in which a force-kill or OOM strands a trusted — possibly attacker-inclusive — baseline with no debt recorded. The next launch would see flag + sentinel + marker, take the early return, and unlock with it: the exact bypass below, re-entered through its own fix. Over-arming costs one passcode prompt; under-arming costs the security property, so the ordering must fail toward the prompt. + +### Why the grandfather path forces a passcode + +A pre-feature user has the flag set but no sentinel — and, on Android, no enrollment key. There is therefore **no prior baseline** to compare the current enrollment against, so the migration cannot tell the user's _original_ enrollment apart from one an attacker altered before this first upgrade (adding their own fingerprint/face on a stolen device). If the grandfather path merely bound a sentinel and enrollment key silently and trusted it, the next biometric unlock would succeed with that attacker-inclusive enrollment — the exact bypass this subsystem exists to close. + +Nothing below the migration closes that gap, because **binding a baseline is not the same as validating one**. `enroll()` binds whatever is enrolled at that moment, by construction; the enrollment-change primitives can then only report that it hasn't changed _since_, which is trivially true and says nothing about what happened before. iOS has no enrollment key at all, so its freshly-written sentinel simply reads back as valid. The relock marker is what supplies the missing evidence — a passcode the attacker doesn't have — and it is the migration's responsibility precisely because no lower layer is in a position to demand it. + +> The Android enrollment key happens to reach the same verdict by a different route: with no key yet, `isEnrollmentValid()` fails closed on the missing alias and reports a change, so it refuses to vouch for the grandfathered enrollment rather than trusting it. **That is a redundant second line, not the reason the relock exists** — it is Android-only and would disappear the moment the enrollment key's missing-alias branch changed. Do not treat the relock as dead code on the strength of it. + +So the grandfather branch sets the relock marker **and then binds the baseline**, forcing the next unlock to demand the passcode regardless of the auto-lock window. When that forced unlock fires, `handleLocalAuthentication` treats the pending relock like any enrollment-change signal: it tears the freshly-bound sentinel back down (`disenroll()`), clears the biometry flag (`setEnabled(false)`), and shows the passcode. The migration-bound baseline is therefore **not** trusted going forward — the user re-opts into biometry deliberately from settings, which re-binds the baseline through an explicit `biometryAuth(true)` consent prompt. This is a one-time cost on the first post-upgrade unlock for pre-feature biometry users: they authenticate with the passcode once and re-enable biometry if they still want it. + +> **Copy.** The grandfather relock does **not** show the "Biometric enrollment changed" subtitle, because nothing changed. `BiometricInvalidationReason` carries three codes, all with the identical fail-closed handling and differing only in what the subtitle claims: +> +> | Reason | Shown when | Claim | +> | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------- | +> | `enrollmentChanged` | The Android enrollment key reports the enrollment set changed, or a sentinel read raised `errSecItemNotFound` | The enrollment demonstrably changed | +> | `trustLost` | The sentinel is gone (the usual shape of a real iOS enrollment change — but equally of a restore onto a new device), or the init migration reconciled the flag off | Biometric unlock was turned off; no cause asserted | +> | `relockRequired` | The enrollment checks all pass and only the relock marker is outstanding — i.e. this grandfather path | Nothing changed; one confirming passcode is owed | +> +> The three-way split is derived in `getRelockReason`, so the persisted state stays a single boolean: `isEnabled()` plus the live enrollment check already distinguish the cases at the moment the modal opens. Replacing `kBiometricPendingRelock` with a persisted reason enum is still deliberately deferred — it would put a wider security-critical value on disk to buy nothing the derivation doesn't already give. + +### The invariant that makes the grandfather marker safe + +> **Every successful `enroll()` sets the migration marker** (`index.ts`). + +This is a 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** — re-binding the sentinel to the _new_ (attacker-inclusive) enrollment on the next launch, since writing the sentinel doesn't prompt. (The grandfather relock above contains the damage even then, but routing through reconciliation is the correct, fail-closed path.) + +Because `enroll()` sets the marker, any post-feature user always has `marker = true`, so a missing sentinel routes to **reconciliation** (clear the flag, force the passcode). 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 | + +Only `not_enrolled` and a genuinely absent sentinel produce `unavailable`; anything that merely +_failed_ produces `error`. See "A failed check is not a change" below. + +### A failed check is not a change + +`invalidate()` is irreversible: it deletes the sentinel and the enrollment key, clears the flag, and the +user has to re-opt-in from settings with no idea why the feature vanished. So only evidence about the +_enrollment_ may reach it. A check that could not complete is not that evidence, and three routes +used to conflate the two: + +- **`classifyPresenceError`** — expo flattens the transient `ERROR_HW_UNAVAILABLE` (busy sensor, HAL + hiccup) onto `not_available` together with `ERROR_NO_BIOMETRICS`, so mapping it to `unavailable` + destroyed the enrollment over a momentarily busy sensor. It falls through to `error` instead, as + `lockout` already did. +- **`getRelockReason`** (`localAuthentication.ts`) — collapsed every `checkBiometricEnrollment()` + outcome that was not `valid` into "enrollment changed", including a throw from `hasEnrollment()` on + either platform. It now returns a third state, **`checkFailed`**: the passcode is forced (still + fail-closed, still overriding the auto-lock window) but nothing is torn down, no + "enrollment changed" subtitle is shown, and the relock marker is deliberately left as it is so a + persistent failure keeps forcing the passcode. +- **`NativeBiometricEnrollment`'s fallback** — answered `false` on Android when the module was absent + from the build, which reads as an enrollment change for _every_ user on that build. It rejects + instead, which surfaces as `checkFailed`. `nativeEnrollmentCheck.isEnrollmentValid()` no longer + swallows that rejection into `false` either. + +A `checkFailed` unlock keeps the biometry opt-in, so it self-recovers: once the transient condition +clears, the next check reads `valid` and biometry works again. A genuinely un-enrolled device instead +keeps a dead biometry button for one session, and `hasSupportedBiometry()` re-gates it on the next +lock. + +> Known gap: `BiometricEnrollmentModule.isEnrollmentValid` still answers `false` for _any_ enrollment-key +> exception (`BiometricEnrollmentModule.kt`, final `catch`), because OEMs report invalidation +> inconsistently. That route still reaches `invalidate()`; narrowing it trades detection strictness +> for availability and has not been done. + +### 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, and presence is proved per platform.** Consent requires a prompt, so it requires `biometryAuth(true)`, not an `enroll()`. `verify()` proves presence on **iOS only**; Android uses `authenticateAsync({ disableDeviceFallback: true, biometricsSecurityLevel: 'strong' })`, because the sentinel's keystore key accepts a 5s device-credential window and can resolve with no prompt at all. +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.** `disableBiometry`, `enableBiometry`, and `resolveBiometricTrust` never leave one set without the other (the migration is the safety net for crashes that break this). On Android the native enrollment key is bound/torn down in lockstep with the sentinel inside `enroll()`/`disenroll()`. +5. **The sentinel is `THIS_DEVICE_ONLY`.** It never restores across devices, so a restored backup correctly reads as `unavailable`. +6. **A baseline bound without a prior baseline is never trusted silently.** When the migration consumes an enrollment-change signal (reconciliation) or binds a first baseline for a pre-feature user (grandfather) on cold launch, it sets the **relock marker** so the next unlock forces the passcode regardless of the auto-lock window — the migration runs before `localAuthenticate` and would otherwise swallow the signal. diff --git a/app/lib/biometricTrustStore/docs/FLOWS.md b/app/lib/biometricTrustStore/docs/FLOWS.md new file mode 100644 index 00000000000..7cb82d1b977 --- /dev/null +++ b/app/lib/biometricTrustStore/docs/FLOWS.md @@ -0,0 +1,198 @@ +# 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` enables through `enableBiometry` (the shared enroll-then-consent path, §2) and disables through `disableBiometry()`. Either way the keychain and the flag stay in sync; only the enable path can fail, and it reports the 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->>LocalAuth: enableBiometry() + LocalAuth->>OS: hasSupportedBiometry() — strong (Class 3) only + LocalAuth->>Store: enroll() — setGenericPassword (BIOMETRY_CURRENT_SET, silent) + alt sentinel written + Store->>Store: set migration marker = true + LocalAuth->>OS: biometryAuth(true) — consent prompt + alt user confirms + OS-->>LocalAuth: success + LocalAuth->>Store: setEnabled(true) + LocalAuth-->>Settings: { kind: 'success' } + else user declines / cancels + LocalAuth->>Store: disenroll() + setEnabled(false) + LocalAuth-->>Settings: failure TrustResult + Settings->>Settings: revert switch to OFF + end + else write fails / unsupported + LocalAuth->>Store: setEnabled(false) + LocalAuth-->>Settings: failure TrustResult + Settings->>Settings: revert switch to OFF + end + + User->>Settings: flip biometry switch OFF + Settings->>Store: disableBiometry() + Store->>OS: disenroll() — resetGenericPassword (best-effort) + Store->>Store: setEnabled(false) + Store-->>Settings: { kind: 'success' } +``` + +Note the enable path **does** prompt: writing the sentinel is silent, so it cannot stand in for consent. This matters most for the grandfathered cohort, whose baseline was torn down by the migration (flow 4) and who re-enable through exactly this toggle — binding a new baseline without a prompt would hand trust back to whatever enrollment is currently on the device. + +--- + +## 2. First-passcode opt-in (`checkBiometry` → `enableBiometry`) + +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 `biometryAuth(true)` 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->>LocalAuth: biometryAuth(true) — cancel: "Don't activate" + alt iOS + LocalAuth->>Store: verify() + Store->>OS: getGenericPassword → OS biometric sheet + else Android + LocalAuth->>Store: hasEnrollment() + isEnrollmentValid() (both silent) + LocalAuth->>OS: authenticateAsync({ disableDeviceFallback: true, strong }) + end + OS->>User: prompt + alt user authenticates + User-->>OS: ok + OS-->>LocalAuth: { kind: 'success' } + LocalAuth->>Store: setEnabled(true) + else user taps "Don't activate" + User-->>OS: cancel + OS-->>LocalAuth: { kind: 'canceled' } + LocalAuth->>Store: disenroll() — tear sentinel back down + LocalAuth->>Store: setEnabled(false) + end + end +``` + +The prompt here doubles as the consent gate: succeeding means the user agreed _and_ proved the current enrollment works; declining opts out and cleans up. It must go through `biometryAuth` rather than `verify()` — on Android a bare `verify()` can resolve inside the keystore's 5s auth window with no prompt shown, which would enable biometric unlock without ever asking. See `PLATFORMS.md`. + +--- + +## 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->>Passcode: biometryAuth() + alt iOS — the sentinel read is itself the biometric evaluation + 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-->>Passcode: { kind: 'success' } + else errSecItemNotFound after prompt (-25300) + OS-->>Passcode: { kind: 'enrollmentChanged' } + else user cancels (-128) + OS-->>Passcode: { kind: 'canceled' } + end + else sentinel absent (iOS often lands here first — see PLATFORMS.md) + OS-->>Passcode: { kind: 'unavailable' } + end + else Android — the sentinel read can't prove presence (5s device-credential window) + Passcode->>Store: hasEnrollment() (silent) + alt sentinel absent + Store-->>Passcode: { kind: 'unavailable' } + else sentinel present + Passcode->>Store: isEnrollmentValid() — silent enrollment-key cipher.init() + alt enrollment key invalidated + Store-->>Passcode: { kind: 'enrollmentChanged' } + else enrollment intact + Passcode->>OS: authenticateAsync({ disableDeviceFallback: true, strong }) + alt biometric matches + OS-->>Passcode: { kind: 'success' } + else user_cancel / authentication_failed / … + OS-->>Passcode: { kind: 'canceled' } + end + end + end + 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)" --> E0[setRelockPending true
armed before the baseline is bound] + E0 --> E[enroll
sets marker itself] + E --> F{enroll ok?} + F -- yes --> G[marker set, relock armed] + F -- no --> H[leave marker & flag; relock stays armed
next boot retries; unlock asks passcode] + D -- "yes (post-feature desync)" --> I[setEnabled false
clear the flag
setRelockPending true] +``` + +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. + +Both the grandfather and reconciliation branches set the **relock marker** — and the grandfather branch sets it _first_, before `enroll()` binds anything, so a crash can never leave a trusted baseline with no debt recorded (see ARCHITECTURE.md, "Ordering is load-bearing"). The freshly-bound grandfather baseline is untrusted — there was no prior enrollment to compare against, so an attacker who altered the enrollment before this first upgrade would otherwise be silently trusted. No lower layer can catch that: binding a baseline is not validating one, and the enrollment-change primitives only report that nothing changed _since_ the bind. The migration runs **before** `localAuthenticate`, so it persists the relock marker; the next unlock (§3) reads it (OR-ed with the live enrollment-change check), forces the passcode regardless of the auto-lock window, and clears it once the modal is shown. That forced unlock routes through the same `enrollmentChanged` branch as a real change, so it also tears the migration-bound sentinel back down (`disenroll()`) and clears the biometry flag (`setEnabled(false)`) — the grandfather baseline is never trusted, and the user re-opts into biometry from settings afterward. See ARCHITECTURE.md, "Why the grandfather path forces a passcode." diff --git a/app/lib/biometricTrustStore/docs/PLATFORMS.md b/app/lib/biometricTrustStore/docs/PLATFORMS.md new file mode 100644 index 00000000000..db1ab6ec99f --- /dev/null +++ b/app/lib/biometricTrustStore/docs/PLATFORMS.md @@ -0,0 +1,100 @@ +# 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` does not show the "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 that copy. + +It is not left silent either: `unavailable` — and, on the non-prompting path, an `absent` sentinel — carries the neutral **`trustLost`** reason ("Biometric unlock was turned off, please use your passcode"). Since on iOS this is how a genuine enrollment change _usually_ surfaces, showing nothing at all would leave the common case unexplained — the user finds biometric unlock switched off in Settings with no indication why. `trustLost` states the outcome, which is true in both the enrollment-change and the restored-backup reading. See ARCHITECTURE.md, "Copy", for the full reason table. + +### 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. + +### The silent enrollment key + +Because the Android sentinel **survives** an enrollment change (the key is invalidated, not deleted), the JS layer's silent `hasEnrollment()` existence check can't see the change — and the only `react-native-keychain` read that would surface the `KeyPermanentlyInvalidatedException` also shows the OS biometric prompt, so it can't be used as a silent at-rest check (it would defeat the prompt-behind-modal contract below). Android therefore carries a **dedicated enrollment key**, implemented natively in `android/app/src/main/java/chat/rocket/reactnative/biometric/BiometricEnrollmentModule.kt` and bridged by `nativeEnrollmentCheck.ts`: + +- It is a standalone AES/GCM keystore key (`setUserAuthenticationRequired = true`, `setInvalidatedByBiometricEnrollment = true`) under alias `rc_biometric_enrollment_probe` — separate from the sentinel, used only for detection, never for crypto. The alias keeps its original spelling deliberately: renaming it orphans every key already on a device, and `isEnrollmentValid()` fails closed on the missing alias, costing one spurious passcode for every Android user with biometry on. +- **`bindEnrollmentKey()` / `clearEnrollmentKey()`** create and delete the key in lockstep with the sentinel, called from the store's `enroll()` / `disenroll()`. `bindEnrollmentKey()` **fails closed**: it answers `false` for a native failure or a rejecting bridge, and `enroll()` then tears the sentinel down and returns `unavailable` rather than enabling biometry with no enrollment key — which the next warm unlock would read as an enrollment change (see "No lazy baseline" below and `ARCHITECTURE.md`, "The Android native enrollment key"). +- **`isEnrollmentValid()`** runs `Cipher.init(ENCRYPT_MODE, key)` on it. `init()` does **not** prompt and does **not** run crypto (auth is only enforced at `doFinal`, which is never called); it simply throws `KeyPermanentlyInvalidatedException` once the enrollment has changed. So it is a fully silent at-rest check — the missing counterpart to iOS's free `hasEnrollment()` signal. `handleLocalAuthentication`/`localAuthenticate` call it (via `biometricTrustStore.isEnrollmentValid()`) to force the passcode even inside the auto-lock window. +- **No lazy baseline.** `isEnrollmentValid()` never creates a key. If no enrollment key exists when it is called (fresh install/upgrade, or just disenrolled), it logs and resolves `false` — treat as changed — rather than self-healing to whatever is currently enrolled. Key creation happens only in `bindEnrollmentKey()`, in lockstep with the sentinel. A pre-feature user's first upgrade therefore cannot be validated by the enrollment key; the migration's grandfather branch forces a confirming passcode instead (see `ARCHITECTURE.md`, "Why the grandfather path forces a passcode"). +- **Failure mode.** Fail-open is deliberately narrow, because on a warm auto-lock unlock this check is the _sole_ gate — `verify()` never runs there. It resolves `true` **only** when the keystore provider itself is unavailable (`KeyStore.getInstance`/`load(null)` throw) — an environmental error that says nothing about the key's validity. Once the provider is instantiated, the **only** path to `true` is a clean `containsAlias()` + `getKey()` + `Cipher.init()`; **any** failure in that region resolves `false` (fail closed) — including a `KeyStoreException` from `containsAlias()` on a readable-but-corrupted keystore, which forces the passcode and tears biometry down via `invalidate()`, recoverable by re-enabling. This matters because a changed/invalidated enrollment does **not** always surface as `KeyPermanentlyInvalidatedException` at `init()` — across OEMs / API levels / StrongBox it can appear as `UnrecoverableKeyException` or a generic `KeyStoreException` at `getKey()`, or another `InvalidKeyException` at `init()`; treating only KPIE as a change would let those slip through fail-open. The JS bridge (`nativeEnrollmentCheck.ts`) likewise fails **closed** for `isEnrollmentValid` — the native module never rejects, so a rejection means a broken bridge on a device where the module should exist, which forces the passcode rather than silently reporting valid. Creating a baseline when biometrics were removed entirely fails, which is treated as a change (`false`) so the passcode is required. + +On **iOS** there is no native counterpart: `bindEnrollmentKey`/`clearEnrollmentKey` are no-ops and `isEnrollmentValid()` resolves `true`, because the sentinel deletion already covers enrollment changes for free. The iOS `bindEnrollmentKey` fallback resolves `false` (there is no key to bind), so `enroll()`'s fail-closed check is guarded by `isAndroid` — dropping that guard would make enabling biometry impossible on every iOS device. + +### Weak (Class 2) biometrics + +The keystore only binds a user-auth key to a **strong (Class 3)** biometric, so a device whose only enrollment is Class 2 (much mid-range Android face unlock) can hold no real sentinel. Three places have to know this, because none of them fails loudly on its own: + +- **`hasSupportedBiometry()`** (`localAuthentication.ts`) requires `getEnrolledLevelAsync() === BIOMETRIC_STRONG`. `isEnrolledAsync()` alone is a `BIOMETRIC_WEAK` query — it resolves `true` on a Class 2 device, which would offer an opt-in that has to be revoked moments later. `enableBiometry` gates on this before writing anything. (iOS reports any biometry as `BIOMETRIC_STRONG`; the Swift module hardcodes `case biometric = 3`, so this is an Android-only narrowing.) +- **`biometryAuth()`** (`localAuthentication.ts`) passes `biometricsSecurityLevel: 'strong'` to `authenticateAsync`. Expo defaults that option to `'weak'`, and with `disableDeviceFallback: true` the allowed set is exactly what it maps to — so the default prompt accepts a Class 2 enrollment. Neither trust artifact can see one (`setInvalidatedByBiometricEnrollment` only fires on Class 3 changes, and the sentinel key is `BIOMETRIC_STRONG`/`DEVICE_CREDENTIAL`-bound), so on Android — where `biometryAuth` never calls `verify()` — a newly enrolled Class 2 face would otherwise unlock the app, and could even give the opt-in consent at `enableBiometry`. Side effect: on a device whose strong enrollment was removed, `authenticateAsync` errors instead of prompting, which `classifyPresenceError` maps to `unavailable`. +- **`enroll()`** (`index.ts`) rejects a sentinel that landed in a non-authenticated cipher storage. Asking for `BIOMETRY_CURRENT_SET` does **not** guarantee one: `getCipherStorageForCurrentAPILevel` ANDs the requested access control with `isStrongBiometricAuthAvailable`, and on a Class 2 device skips every auth-backed storage and falls back to a plain one. The write then _succeeds_ — leaving a sentinel with no user-auth requirement and no enrollment binding, which `hasEnrollment()` would happily report as trust. `enroll()` therefore checks the returned `storage` against `KeystoreAESGCM`/`KeystoreRSAECB`, tears the entry down, and returns `unavailable` before the migration marker is set. + +### Why the sentinel read can't prove presence + +On iOS a successful `verify()` proves two things at once: the enrollment is unchanged **and** a live user just authenticated. On Android it only proves the first, so `biometryAuth` (in `localAuthentication.ts`) does **not** use `verify()` there. + +`react-native-keychain` builds the sentinel's keystore key with a 5-second auth window that accepts device credentials, not only biometrics (`CipherStorageKeystoreAesGcm.getKeyGenSpecBuilder`): + +```kotlin +setUserAuthenticationParameters(5, KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL) +``` + +and `CipherStorageKeystoreAesGcm.decrypt()` tries the cipher first, reaching the `BiometricPrompt` **only** from its `UserNotAuthenticatedException` catch branch. Inside the window no exception is raised, so the decrypt simply succeeds and the prompt is never shown. Concretely: unlock the phone with the **PIN**, open the app within 5s, and a bare `verify()` returns `success` with nothing on screen. (The prompt is also handed to `BiometricPrompt.authenticate(promptInfo)` without a `CryptoObject`, so even when it does appear it authorizes nothing cryptographically.) + +Android therefore splits the two concerns, keeping the total at one OS prompt: + +| Concern | Android mechanism | +| -------------------- | ------------------------------------------------------------------------ | +| Enrollment unchanged | `isEnrollmentValid()` — the silent enrollment key above | +| Live user present | `LocalAuthentication.authenticateAsync({ disableDeviceFallback: true })` | + +This costs nothing on the detection side: `KeyPermanentlyInvalidatedException` is raised at key extraction regardless of the auth window, so the enrollment signal was never dependent on the prompt firing. It also means the Android sentinel's only remaining job is "is trust initialized" (`hasEnrollment()`, a silent existence check) — the enrollment key carries the enrollment binding and `authenticateAsync` carries presence. + +The same reasoning applies to the consent prompt: `enableBiometry` captures consent through `biometryAuth(true)` rather than `verify()`, because a prompt that never appeared is not consent. + +### Cancel signal + +On iOS a dismissed prompt surfaces as an `errSecUserCancel` / `-128` error from the keychain read, mapped to `canceled`. On Android it comes back as an `expo-local-authentication` result with `success: false` and `error: 'user_cancel'` (or `app_cancel` / `system_cancel` / `user_fallback` / `authentication_failed`), mapped to `canceled` by `classifyPresenceError`. Either way the biometry button is kept for manual retry. `not_enrolled` / `not_available` map to `unavailable` (fail closed, tear trust down); anything else is a real `error`. + +## 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` | expo `error: 'user_cancel'` (and friends) | +| Silent at-rest enrollment check | `hasEnrollment()` (item deleted on change) | native enrollment key `isEnrollmentValid()` (`cipher.init`) | +| What proves a live user (`biometryAuth`) | `verify()` — the sentinel read itself | `authenticateAsync` (no fallback, `'strong'` only) | +| 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..779b57037c2 --- /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) — `enableBiometry` (shared enroll-then-consent path), `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..94b825d26c3 --- /dev/null +++ b/app/lib/biometricTrustStore/index.test.ts @@ -0,0 +1,392 @@ +import * as Keychain from 'react-native-keychain'; + +import { biometricTrustStore, classifyError } from './index'; +import { clearEnrollmentKey, bindEnrollmentKey, isEnrollmentValid } from './nativeEnrollmentCheck'; +import UserPreferences from '../methods/userPreferences'; +import { + BIOMETRIC_TRUST_MIGRATION_V1_DONE, + BIOMETRIC_TRUST_SENTINEL_SERVICE as SENTINEL_SERVICE +} from '../constants/localAuthentication'; + +jest.mock('../methods/userPreferences', () => ({ + __esModule: true, + default: { getBool: jest.fn(), setBool: jest.fn(), getString: jest.fn(), setString: jest.fn() } +})); + +// Getter, not a literal: the storage downgrade guard is Android-only, and the platform has to be +// switchable per test. +let mockIsAndroid = false; +jest.mock('../methods/helpers/deviceInfo', () => ({ + get isAndroid() { + return mockIsAndroid; + }, + get isIOS() { + return !mockIsAndroid; + } +})); + +jest.mock('./nativeEnrollmentCheck', () => ({ + bindEnrollmentKey: jest.fn(() => Promise.resolve(true)), + clearEnrollmentKey: jest.fn(() => Promise.resolve()), + isEnrollmentValid: jest.fn(() => Promise.resolve(true)) +})); + +const mockedKeychain = Keychain as jest.Mocked; +const mockedSetBool = UserPreferences.setBool as jest.Mock; +const mockedBindEnrollmentKey = bindEnrollmentKey as jest.Mock; +const mockedClearEnrollmentKey = clearEnrollmentKey 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('does not bind the native enrollment key when the sentinel write fails', async () => { + mockedKeychain.setGenericPassword.mockRejectedValueOnce(new Error('errSecUserCancel')); + + await biometricTrustStore.enroll(); + + expect(mockedBindEnrollmentKey).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); + }); + + it('reports unavailable when the write resolves false', async () => { + mockedKeychain.setGenericPassword.mockResolvedValueOnce(false as any); + + expect(await biometricTrustStore.enroll()).toEqual({ kind: 'unavailable' }); + expect(mockedSetBool).not.toHaveBeenCalledWith(BIOMETRIC_TRUST_MIGRATION_V1_DONE, true); + expect(mockedBindEnrollmentKey).not.toHaveBeenCalled(); + }); + + // Android with no strong biometric: react-native-keychain writes to a non-authenticated storage + // instead of failing, producing a sentinel that can never detect an enrollment change. + describe('Android storage downgrade', () => { + beforeEach(() => { + mockIsAndroid = true; + }); + afterEach(() => { + mockIsAndroid = false; + }); + + it.each([Keychain.STORAGE_TYPE.AES_CBC, Keychain.STORAGE_TYPE.AES_GCM_NO_AUTH])( + 'rejects a sentinel written to %s and tears it back down', + async storage => { + mockedKeychain.setGenericPassword.mockResolvedValueOnce({ service: SENTINEL_SERVICE, storage } as any); + + expect(await biometricTrustStore.enroll()).toEqual({ kind: 'unavailable' }); + expect(mockedKeychain.resetGenericPassword).toHaveBeenCalledTimes(1); + expect(mockedSetBool).not.toHaveBeenCalledWith(BIOMETRIC_TRUST_MIGRATION_V1_DONE, true); + expect(mockedBindEnrollmentKey).not.toHaveBeenCalled(); + } + ); + + it.each([Keychain.STORAGE_TYPE.AES_GCM, Keychain.STORAGE_TYPE.RSA])('accepts an auth-backed %s sentinel', async storage => { + mockedKeychain.setGenericPassword.mockResolvedValueOnce({ service: SENTINEL_SERVICE, storage } as any); + + expect(await biometricTrustStore.enroll()).toEqual({ kind: 'success' }); + expect(mockedSetBool).toHaveBeenCalledWith(BIOMETRIC_TRUST_MIGRATION_V1_DONE, true); + expect(mockedBindEnrollmentKey).toHaveBeenCalledTimes(1); + }); + }); + + describe('Android enrollment-key binding', () => { + beforeEach(() => { + mockIsAndroid = true; + }); + afterEach(() => { + mockIsAndroid = false; + }); + + it('binds the native enrollment key in lockstep with the sentinel', async () => { + mockedKeychain.setGenericPassword.mockResolvedValueOnce({ + service: SENTINEL_SERVICE, + storage: Keychain.STORAGE_TYPE.AES_GCM + } as any); + + expect(await biometricTrustStore.enroll()).toEqual({ kind: 'success' }); + expect(mockedBindEnrollmentKey).toHaveBeenCalledTimes(1); + }); + + // The enrollment key is the sole gate on a warm auto-lock unlock, so enabling biometry without one + // would hand the user a bogus "enrollment changed" teardown on the next unlock. + it('refuses to enable biometry when the enrollment key cannot be bound, and tears the sentinel down', async () => { + mockedKeychain.setGenericPassword.mockResolvedValueOnce({ + service: SENTINEL_SERVICE, + storage: Keychain.STORAGE_TYPE.AES_GCM + } as any); + mockedBindEnrollmentKey.mockResolvedValueOnce(false); + + expect(await biometricTrustStore.enroll()).toEqual({ kind: 'unavailable' }); + expect(mockedKeychain.resetGenericPassword).toHaveBeenCalledTimes(1); + // Marker left set would block the migration's grandfather rescue on every later launch. + expect(mockedSetBool).not.toHaveBeenCalledWith(BIOMETRIC_TRUST_MIGRATION_V1_DONE, true); + }); + }); + + it("accepts iOS's 'keychain' storage, which has no downgrade fallback", async () => { + mockedKeychain.setGenericPassword.mockResolvedValueOnce({ service: SENTINEL_SERVICE, storage: 'keychain' } as any); + + expect(await biometricTrustStore.enroll()).toEqual({ kind: 'success' }); + }); + + // There is no iOS enrollment key and its fallback resolves false, so the check stays behind isAndroid. + it('does not consult the enrollment key on iOS', async () => { + mockedKeychain.setGenericPassword.mockResolvedValueOnce({ service: SENTINEL_SERVICE, storage: 'keychain' } as any); + mockedBindEnrollmentKey.mockResolvedValueOnce(false); + + expect(await biometricTrustStore.enroll()).toEqual({ kind: 'success' }); + expect(mockedBindEnrollmentKey).not.toHaveBeenCalled(); + expect(mockedSetBool).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 enrollment key alongside the sentinel', async () => { + mockedKeychain.resetGenericPassword.mockResolvedValueOnce(true as any); + + await biometricTrustStore.disenroll(); + + expect(mockedClearEnrollmentKey).toHaveBeenCalledTimes(1); + }); + + it('still tears down the native enrollment key even when the sentinel delete throws', async () => { + mockedKeychain.resetGenericPassword.mockRejectedValueOnce(new Error('not found')); + + await biometricTrustStore.disenroll(); + + expect(mockedClearEnrollmentKey).toHaveBeenCalledTimes(1); + }); + }); + + describe('invalidate', () => { + it('arms the relock debt first, then disenrolls, then clears the flag — the security-critical order', async () => { + const order: string[] = []; + const setRelockPending = jest.spyOn(biometricTrustStore, 'setRelockPending').mockImplementation((v: boolean) => { + order.push(`relockPending:${v}`); + }); + const disenroll = jest.spyOn(biometricTrustStore, 'disenroll').mockImplementation(() => { + order.push('disenroll'); + return Promise.resolve(); + }); + const setEnabled = jest.spyOn(biometricTrustStore, 'setEnabled').mockImplementation(() => { + order.push('setEnabled:false'); + }); + + await biometricTrustStore.invalidate(); + + // Debt persisted BEFORE teardown so a kill mid-invalidation still carries it forward; disenroll + // before flag-clear so a crash leaves a reconcilable mismatch, never an orphaned live sentinel. + expect(order).toEqual(['relockPending:true', 'disenroll', 'setEnabled:false']); + expect(setRelockPending).toHaveBeenCalledWith(true); + expect(setEnabled).toHaveBeenCalledWith(false); + expect(disenroll).toHaveBeenCalledTimes(1); + + setRelockPending.mockRestore(); + disenroll.mockRestore(); + setEnabled.mockRestore(); + }); + + it('still clears the flag when disenroll rejects (best-effort teardown must complete)', async () => { + jest.spyOn(biometricTrustStore, 'setRelockPending').mockImplementation(() => {}); + jest.spyOn(biometricTrustStore, 'disenroll').mockRejectedValueOnce(new Error('boom')); + const setEnabled = jest.spyOn(biometricTrustStore, 'setEnabled').mockImplementation(() => {}); + + await expect(biometricTrustStore.invalidate()).rejects.toThrow('boom'); + + expect(setEnabled).toHaveBeenCalledWith(false); + jest.restoreAllMocks(); + }); + }); + + describe('isEnrollmentValid', () => { + it('delegates to the native enrollment key (true → valid)', async () => { + mockedIsEnrollmentValid.mockResolvedValueOnce(true); + expect(await biometricTrustStore.isEnrollmentValid()).toBe(true); + }); + + it('delegates to the native enrollment key (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 check 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 check 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]; + // `cancel` is deliberately absent: the iOS read path forwards only the title. + expect(options).toMatchObject({ authenticationPrompt: { title: 'Authenticate' } }); + expect((options as { authenticationPrompt?: Record }).authenticationPrompt).not.toHaveProperty('cancel'); + }); + }); + + describe('disableBiometry', () => { + it('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(() => {}); + + await biometricTrustStore.disableBiometry(); + + 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..8410b688ffb --- /dev/null +++ b/app/lib/biometricTrustStore/index.ts @@ -0,0 +1,157 @@ +import * as Keychain from 'react-native-keychain'; + +import { type BiometricPromptCopy, type IBiometricTrustStore, type TrustResult } from '../../definitions'; +import UserPreferences from '../methods/userPreferences'; +import { isAndroid } from '../methods/helpers/deviceInfo'; +import { clearEnrollmentKey, bindEnrollmentKey, isEnrollmentValid } from './nativeEnrollmentCheck'; +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 enrollment — the invalidation signal. +const writeOptions = (): Keychain.SetOptions => ({ + service: SENTINEL_SERVICE, + accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET, + accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY +}); + +// No `cancel`: this read is iOS-only (biometryAuth calls verify() under isIOS) and the library's iOS +// path forwards only `title`, as kSecUseOperationPrompt. The Android negative button comes from +// authenticateAsync's cancelLabel instead. +const readOptions = (promptCopy: BiometricPromptCopy): Keychain.GetOptions => ({ + service: SENTINEL_SERVICE, + authenticationPrompt: { + title: promptCopy.title + } +}); + +// See PLATFORMS.md, "Weak (Class 2) biometrics". +const AUTH_BACKED_ANDROID_STORAGES: string[] = [Keychain.STORAGE_TYPE.AES_GCM, Keychain.STORAGE_TYPE.RSA]; + +const isAuthBackedStorage = (storage: string | undefined): boolean => + !isAndroid || (storage != null && AUTH_BACKED_ANDROID_STORAGES.includes(storage)); + +// -128 = dismissed; -25300 (iOS) and KeyPermanentlyInvalidatedException (Android) = enrollment changed. +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 is matched on the code only, so an unrelated error merely quoting it in its message stays an + // error. The two enrollmentChanged branches below do read the message, which only over-forces a prompt. + if (code === '-128' || /errSecUserCancel|UserCancel|user.?cancel|AuthenticationCanceled/i.test(blob)) { + return { kind: 'canceled' }; + } + if (/KeyPermanentlyInvalidatedException/i.test(blob)) { + return { kind: 'enrollmentChanged' }; + } + if (code === '-25300' || /errSecItemNotFound/i.test(blob)) { + return { kind: 'enrollmentChanged' }; + } + return { kind: 'error', cause: e }; +}; + +export const biometricTrustStore: IBiometricTrustStore = { + async enroll() { + try { + const written = await Keychain.setGenericPassword(SENTINEL_USERNAME, SENTINEL_VALUE, writeOptions()); + if (!written) { + return { kind: 'unavailable' }; + } + if (!isAuthBackedStorage(written.storage)) { + await biometricTrustStore.disenroll(); + return { kind: 'unavailable' }; + } + // Binds the Android enrollment key in lockstep with the sentinel. The iOS fallback answers false, + // hence the guard. Without an enrollment key the next warm unlock reads the missing alias as a change. + if (isAndroid && !(await bindEnrollmentKey())) { + await biometricTrustStore.disenroll(); + return { kind: 'unavailable' }; + } + // Marks the install trust-initialized so invalidation can't reach the grandfather path. + // After the enrollment key: a marker left behind on the failure path would block the grandfather rescue. + UserPreferences.setBool(BIOMETRIC_TRUST_MIGRATION_V1_DONE, true); + 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 + } + await clearEnrollmentKey(); + }, + + 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; + }, + + // Android: silent keystore cipher.init() check. iOS: always true, the sentinel covers it. + isEnrollmentValid() { + 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 invalidate() { + biometricTrustStore.setRelockPending(true); + + try { + await biometricTrustStore.disenroll(); + } finally { + biometricTrustStore.setEnabled(false); + } + }, + + // Disable only. Enabling has to capture consent, which is enableBiometry's job — a second enable + // path here would be one that silently skips the prompt. + async disableBiometry() { + await biometricTrustStore.disenroll(); + biometricTrustStore.setEnabled(false); + } +}; + +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..a4b4ce4f64d --- /dev/null +++ b/app/lib/biometricTrustStore/migration.test.ts @@ -0,0 +1,184 @@ +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(), + disableBiometry: 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 → force relock, enroll() once', async () => { + setPrefs({ biometryEnabled: true, migrated: false }); + mockedHasEnrollment.mockResolvedValueOnce(false); + mockedEnroll.mockResolvedValueOnce({ kind: 'success' }); + + await runBiometricTrustMigration(); + + expect(mockedEnroll).toHaveBeenCalledTimes(1); + // The migration marker is enroll()'s to persist (see index.ts), not the migration's. + expect(mockedSetBool).not.toHaveBeenCalledWith(BIOMETRIC_TRUST_MIGRATION_V1_DONE, expect.anything()); + expect(mockedSetEnabled).not.toHaveBeenCalled(); + // The grandfathered enrollment is untrusted (no prior baseline to compare against), so the + // freshly-bound baseline must be confirmed by a passcode on the next unlock before it is trusted. + expect(mockedSetRelockPending).toHaveBeenCalledWith(true); + }); + + // The relock debt must be durable before enroll() makes the baseline trustable: enroll() persists the + // sentinel and the migration marker before it resolves, so a kill after it but before the marker would + // strand a trusted, possibly attacker-inclusive baseline that no later run forces a passcode for. + it('grandfather path: relock is armed before enroll() binds the baseline', async () => { + setPrefs({ biometryEnabled: true, migrated: false }); + mockedHasEnrollment.mockResolvedValueOnce(false); + // Recorded rather than asserted inside the mock: the migration wraps everything in a try/catch, so + // a failing expect() thrown from enroll() would be swallowed and the test would pass either way. + const order: string[] = []; + mockedSetRelockPending.mockImplementationOnce(() => order.push('relock')); + mockedEnroll.mockImplementationOnce(() => { + order.push('enroll'); + return Promise.resolve({ kind: 'success' }); + }); + + await runBiometricTrustMigration(); + + expect(order).toEqual(['relock', 'enroll']); + }); + + it('grandfather enroll() failure → relock stays armed (self-clears on the next forced unlock)', async () => { + setPrefs({ biometryEnabled: true, migrated: false }); + mockedHasEnrollment.mockResolvedValueOnce(false); + mockedEnroll.mockResolvedValueOnce({ kind: 'error', cause: new Error('keychain unavailable') }); + + await runBiometricTrustMigration(); + + expect(mockedSetBool).not.toHaveBeenCalledWith(BIOMETRIC_TRUST_MIGRATION_V1_DONE, expect.anything()); + expect(mockedSetRelockPending).toHaveBeenCalledWith(true); + }); + + 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 enrollment key, 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('enrollment check 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..c6aa1b959a2 --- /dev/null +++ b/app/lib/biometricTrustStore/migration.ts @@ -0,0 +1,43 @@ +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 for users who had biometry enabled before the sentinel existed. Runs at app init. +// Truth table and rationale in docs/ARCHITECTURE.md. +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) { + // Arm the relock debt before enroll() binds the baseline, so a crash in between can't strand a trusted baseline with no debt recorded. + biometricTrustStore.setRelockPending(true); + const result = await biometricTrustStore.enroll(); + // Every non-success kind: the marker stays unset so the next boot retries, and without this + // a user whose hardware can't produce a sentinel retries silently on every launch forever. + if (result.kind !== 'success') { + log(new Error(`biometric trust migration: enroll returned ${result.kind}`)); + if (result.kind === 'error') { + log(result.cause); + } + } + return; + } + + // Sentinel gone with the marker set means the enrollment changed: arm the relock debt before clearing the enabled flag. + biometricTrustStore.setRelockPending(true); + biometricTrustStore.setEnabled(false); + } catch (e) { + log(e); + } +}; diff --git a/app/lib/biometricTrustStore/nativeEnrollmentCheck.test.ts b/app/lib/biometricTrustStore/nativeEnrollmentCheck.test.ts new file mode 100644 index 00000000000..1a05b71fca4 --- /dev/null +++ b/app/lib/biometricTrustStore/nativeEnrollmentCheck.test.ts @@ -0,0 +1,58 @@ +import NativeBiometricEnrollment from '../native/NativeBiometricEnrollment'; +import { clearEnrollmentKey, bindEnrollmentKey, isEnrollmentValid } from './nativeEnrollmentCheck'; + +jest.mock('../native/NativeBiometricEnrollment', () => ({ + __esModule: true, + default: { + bindEnrollmentKey: jest.fn(), + clearEnrollmentKey: jest.fn(), + isEnrollmentValid: jest.fn() + } +})); + +const mockedNative = NativeBiometricEnrollment as jest.Mocked; + +describe('nativeEnrollmentCheck', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('bindEnrollmentKey', () => { + it('passes the native verdict through', async () => { + mockedNative.bindEnrollmentKey.mockResolvedValueOnce(true); + expect(await bindEnrollmentKey()).toBe(true); + + mockedNative.bindEnrollmentKey.mockResolvedValueOnce(false); + expect(await bindEnrollmentKey()).toBe(false); + }); + + // Swallowing this as a success would leave no enrollment key, which the next warm unlock reads as + // an enrollment change and tears biometry down for a change that never happened. + it('reports false when the bridge rejects', async () => { + mockedNative.bindEnrollmentKey.mockRejectedValueOnce(new Error('module unavailable')); + expect(await bindEnrollmentKey()).toBe(false); + }); + }); + + describe('clearEnrollmentKey', () => { + it('swallows a rejection: the key may already be gone', async () => { + mockedNative.clearEnrollmentKey.mockRejectedValueOnce(new Error('module unavailable')); + await expect(clearEnrollmentKey()).resolves.toBeUndefined(); + }); + }); + + describe('isEnrollmentValid', () => { + // The caller turns a rejection into `checkFailed` (passcode, enrollment kept) rather than a + // permanent teardown, so the rejection has to propagate. + it('propagates a rejection instead of answering false', async () => { + const cause = new Error('module unavailable'); + mockedNative.isEnrollmentValid.mockRejectedValueOnce(cause); + await expect(isEnrollmentValid()).rejects.toBe(cause); + }); + + it('passes the native verdict through', async () => { + mockedNative.isEnrollmentValid.mockResolvedValueOnce(false); + expect(await isEnrollmentValid()).toBe(false); + }); + }); +}); diff --git a/app/lib/biometricTrustStore/nativeEnrollmentCheck.ts b/app/lib/biometricTrustStore/nativeEnrollmentCheck.ts new file mode 100644 index 00000000000..4b3737822e2 --- /dev/null +++ b/app/lib/biometricTrustStore/nativeEnrollmentCheck.ts @@ -0,0 +1,25 @@ +import NativeBiometricEnrollment from '../native/NativeBiometricEnrollment'; + +// Android-only silent enrollment check; no-op on iOS. See PLATFORMS.md, "The silent enrollment key". + +// Fails closed: with no key bound, the next warm unlock reads the missing alias as a change. +export const bindEnrollmentKey = async (): Promise => { + try { + return await NativeBiometricEnrollment.bindEnrollmentKey(); + } catch { + return false; + } +}; + +export const clearEnrollmentKey = async (): Promise => { + try { + await NativeBiometricEnrollment.clearEnrollmentKey(); + } catch {} +}; + +/* + * The module never rejects, so a throw means a broken bridge — which says nothing about the key's + * validity. Let it propagate: the caller turns it into `checkFailed` (passcode, enrollment kept) + * rather than `invalid` (permanent teardown). See ARCHITECTURE.md, "A failed check is not a change". + */ +export const isEnrollmentValid = (): Promise => NativeBiometricEnrollment.isEnrollmentValid(); diff --git a/app/lib/biometricTrustStore/resolveBiometricTrust.test.ts b/app/lib/biometricTrustStore/resolveBiometricTrust.test.ts new file mode 100644 index 00000000000..376be75bb90 --- /dev/null +++ b/app/lib/biometricTrustStore/resolveBiometricTrust.test.ts @@ -0,0 +1,71 @@ +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(), + setRelockPending: jest.fn(), + invalidate: jest.fn(() => Promise.resolve()), + disableBiometry: jest.fn() + } +})); + +const mockedSetRelockPending = biometricTrustStore.setRelockPending as jest.Mock; +const mockedInvalidate = biometricTrustStore.invalidate 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(mockedInvalidate).not.toHaveBeenCalled(); + }); + + it('enrollmentChanged → invalidates trust and returns the passcode-only modal with a reason', async () => { + const outcome = await resolveBiometricTrust({ kind: 'enrollmentChanged' }); + + expect(mockedInvalidate).toHaveBeenCalledTimes(1); + expect(outcome).toEqual({ + unlocked: false, + modal: { hasBiometry: false, reason: 'enrollmentChanged' } + }); + }); + + it('canceled → no invalidation, no relock debt, modal keeps biometry', async () => { + const outcome = await resolveBiometricTrust({ kind: 'canceled' }); + + expect(mockedInvalidate).not.toHaveBeenCalled(); + expect(mockedSetRelockPending).not.toHaveBeenCalled(); + expect(outcome).toEqual({ + unlocked: false, + modal: { hasBiometry: true } + }); + }); + + it('error → no invalidation, no relock debt, modal keeps biometry', async () => { + const outcome = await resolveBiometricTrust({ kind: 'error', cause: new Error('boom') }); + + expect(mockedInvalidate).not.toHaveBeenCalled(); + expect(mockedSetRelockPending).not.toHaveBeenCalled(); + expect(outcome).toEqual({ + unlocked: false, + modal: { hasBiometry: true } + }); + }); + + it('unavailable → invalidates trust, passcode-only modal, neutral reason', async () => { + const outcome = await resolveBiometricTrust({ kind: 'unavailable' }); + + expect(mockedInvalidate).toHaveBeenCalledTimes(1); + expect(outcome).toEqual({ unlocked: false, modal: { hasBiometry: false, reason: 'trustLost' } }); + }); +}); diff --git a/app/lib/biometricTrustStore/resolveBiometricTrust.ts b/app/lib/biometricTrustStore/resolveBiometricTrust.ts new file mode 100644 index 00000000000..b5aa66c63f1 --- /dev/null +++ b/app/lib/biometricTrustStore/resolveBiometricTrust.ts @@ -0,0 +1,28 @@ +import { type BiometricInvalidationReason, type TrustResult } from '../../definitions'; +import { biometricTrustStore } from './index'; + +export type BiometricModalRequest = { + hasBiometry: boolean; + reason?: BiometricInvalidationReason; +}; + +export type BiometricTrustOutcome = { unlocked: true } | { unlocked: false; modal: BiometricModalRequest }; + +// Maps a verify() TrustResult to an unlock outcome plus the modal config to show next. +export const resolveBiometricTrust = async (result: TrustResult): Promise => { + switch (result.kind) { + case 'success': + return { unlocked: true }; + case 'enrollmentChanged': + await biometricTrustStore.invalidate(); + return { unlocked: false, modal: { hasBiometry: false, reason: 'enrollmentChanged' } }; + case 'unavailable': + await biometricTrustStore.invalidate(); + return { unlocked: false, modal: { hasBiometry: false, reason: 'trustLost' } }; + // No `default:` — exhaustiveness must break the build on a new TrustResult variant. + case 'canceled': + case 'error': + // Keep the biometry button for a manual retry. + return { unlocked: false, modal: { hasBiometry: true } }; + } +}; diff --git a/app/lib/constants/localAuthentication.ts b/app/lib/constants/localAuthentication.ts index bd74997a3c2..c8701371e37 100644 --- a/app/lib/constants/localAuthentication.ts +++ b/app/lib/constants/localAuthentication.ts @@ -2,6 +2,14 @@ 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 the init migration consumes an enrollment change; forces a passcode on the next unlock. +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 +19,6 @@ export const MAX_ATTEMPTS = 6; export const TIME_TO_LOCK = 30000; export const DEFAULT_AUTO_LOCK = 1800; + +// Shortened for E2E so tests don't wait past the smallest user-facing option (60s). +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..50fdddbeb9c --- /dev/null +++ b/app/lib/hooks/useDeferredModalSettle.test.ts @@ -0,0 +1,94 @@ +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(); + }); + + it('keeps the first settle when a second lands before the modal hid', () => { + const { result } = renderHook(() => useDeferredModalSettle()); + const submit = jest.fn(); + const cancel = jest.fn(); + + result.current.onShow({ submit, cancel }); + result.current.defer(submit); + // Same-batch second settle (last passcode digit + close button pressed together). + result.current.defer(cancel); + + result.current.onModalHide(); + expect(submit).toHaveBeenCalledTimes(1); + expect(cancel).not.toHaveBeenCalled(); + }); + + it('does not let a null second settle strand the caller', () => { + const { result } = renderHook(() => useDeferredModalSettle()); + const submit = jest.fn(); + + result.current.onShow({ submit }); + result.current.defer(submit); + result.current.defer(null); + + result.current.onModalHide(); + expect(submit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/lib/hooks/useDeferredModalSettle.ts b/app/lib/hooks/useDeferredModalSettle.ts new file mode 100644 index 00000000000..81a33f265e5 --- /dev/null +++ b/app/lib/hooks/useDeferredModalSettle.ts @@ -0,0 +1,42 @@ +import { useEffect, 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; + // First settle wins: two can land in one batch, and overwriting drops the one that already won. + if (pendingSettle.current) { + return; + } + pendingSettle.current = settle; + }; + + const onModalHide = () => { + const settle = pendingSettle.current; + pendingSettle.current = null; + settle?.(); + }; + + // Third flush path, for the host that unmounts with a settle still pending: onModalHide can no + // longer fire and the caller's promise would hang until the next request arrives to flush it. + useEffect(() => () => onModalHide(), []); + + return { onShow, defer, onModalHide }; +}; diff --git a/app/lib/methods/helpers/events.ts b/app/lib/methods/helpers/events.ts index f9953e2c731..40cc655d706 100644 --- a/app/lib/methods/helpers/events.ts +++ b/app/lib/methods/helpers/events.ts @@ -1,4 +1,4 @@ -import { type ILoginCredentials } from '../../../definitions'; +import { type BiometricInvalidationReason, type ILoginCredentials } from '../../../definitions'; import { type IEmitUserInteraction } from '../../../containers/UIKit/interfaces'; import log from './log'; @@ -9,7 +9,9 @@ type TEventEmitterEmmitArgs = | { method: string } | { invalid: boolean } | { force: boolean } + | { canClose: 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..72291aa27c0 --- /dev/null +++ b/app/lib/methods/helpers/localAuthentication.test.ts @@ -0,0 +1,755 @@ +import * as LocalAuthentication from 'expo-local-authentication'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import EventEmitter from './events'; +import UserPreferences from '../userPreferences'; +import database from '../../database'; +import { getServerTimeSync } from '../../services/getServerTimeSync'; +import { store as reduxStore } from '../../store/auxStore'; +import { + biometryAuth, + checkHasPasscode, + enableBiometry, + handleLocalAuthentication, + localAuthenticate +} from './localAuthentication'; +import { biometricTrustStore } from '../../biometricTrustStore'; +import { CHANGE_PASSCODE_EMITTER, LOCAL_AUTHENTICATE_EMITTER } from '../../constants/localAuthentication'; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + multiRemove: jest.fn(() => Promise.resolve()) +})); + +jest.mock('expo-local-authentication', () => ({ + authenticateAsync: jest.fn(), + isEnrolledAsync: jest.fn(() => Promise.resolve(true)), + getEnrolledLevelAsync: jest.fn(() => Promise.resolve(3)), + supportedAuthenticationTypesAsync: jest.fn(() => Promise.resolve([2])), + AuthenticationType: { FINGERPRINT: 1, FACIAL_RECOGNITION: 2, IRIS: 3 }, + SecurityLevel: { NONE: 0, SECRET: 1, BIOMETRIC_WEAK: 2, BIOMETRIC_STRONG: 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('../../database', () => ({ + __esModule: true, + default: { + servers: { + get: jest.fn(), + write: jest.fn(callback => callback()) + } + } +})); + +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(), + disableBiometry: jest.fn(), + isRelockPending: jest.fn(), + setRelockPending: jest.fn(), + invalidate: jest.fn() + } +})); + +jest.mock('./events', () => ({ + __esModule: true, + default: { emit: jest.fn(), addEventListener: jest.fn(), removeListener: jest.fn() } +})); + +// biometryAuth branches on platform (verify() proves presence on iOS only), so the flag has to be +// switchable per test. A getter keeps it live — the module reads `isIOS` at call time, not import time. +let mockIsIOS = true; +jest.mock('./deviceInfo', () => ({ + get isIOS() { + return mockIsIOS; + }, + get isAndroid() { + return !mockIsIOS; + } +})); + +const mockedEmit = EventEmitter.emit as jest.Mock; +const mockedGetString = UserPreferences.getString as jest.Mock; +const mockedDispatch = reduxStore.dispatch as jest.Mock; +const mockedGetServerTimeSync = getServerTimeSync as jest.Mock; +const mockedServersGet = database.servers.get as unknown as jest.Mock; +const mockedServersWrite = database.servers.write as unknown as jest.Mock; +const mockedMultiRemove = AsyncStorage.multiRemove 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 mockedInvalidate = biometricTrustStore.invalidate 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 enrollment key reports valid). Overridden per-case. + mockedIsEnrollmentValid.mockResolvedValue(true); + mockedIsRelockPending.mockReturnValue(false); + mockedDisenroll.mockResolvedValue(undefined); + mockedInvalidate.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 → forces passcode, disables biometry, sets a neutral reason', async () => { + mockedIsEnabled.mockReturnValue(true); + mockedHasEnrollment.mockResolvedValueOnce(false); + + await handleLocalAuthentication(); + + // Modal opens with biometry hidden and a notice... + expect(lastEmitPayload()).toMatchObject({ hasBiometry: false, reason: 'trustLost' }); + // ...trust state torn down, and the relock debt cleared once the passcode came back. + expect(mockedInvalidate).toHaveBeenCalledTimes(1); + expect(mockedSetRelockPending).toHaveBeenCalledWith(false); + expect(mockedVerify).not.toHaveBeenCalled(); + }); + + it('Android path: sentinel survives but native enrollment key 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 enrollment key is invalidated + + await handleLocalAuthentication(); + + expect(lastEmitPayload()).toMatchObject({ hasBiometry: false, reason: 'enrollmentChanged' }); + expect(mockedInvalidate).toHaveBeenCalledTimes(1); + expect(mockedVerify).not.toHaveBeenCalled(); + }); + + /* + * A check that could not complete says nothing about the enrollment, so it must fail closed + * (passcode, no biometry button) without failing destructive: invalidate() is irreversible and + * would silently revoke an opted-in feature over a busy sensor or a one-off keychain error. + */ + it.each([ + [ + 'hasEnrollment() rejects (keychain error)', + () => { + mockedHasEnrollment.mockRejectedValueOnce(new Error('keychain read failed')); + } + ], + [ + 'isEnrollmentValid() rejects (broken bridge)', + () => { + mockedHasEnrollment.mockResolvedValue(true); + mockedIsEnrollmentValid.mockRejectedValueOnce(new Error('enrollment bridge failed')); + } + ] + ])('fail closed, not destructive: %s → forces passcode and keeps the enrollment', async (_label, arrange) => { + mockedIsEnabled.mockReturnValue(true); + arrange(); + + await handleLocalAuthentication(); + + // No `reason`: this is not an enrollment change, and the copy must not claim it was. + expect(lastEmitPayload()).toMatchObject({ hasBiometry: false }); + expect(lastEmitPayload()?.reason).toBeUndefined(); + expect(mockedInvalidate).not.toHaveBeenCalled(); + expect(mockedDisenroll).not.toHaveBeenCalled(); + expect(mockedSetEnabled).not.toHaveBeenCalled(); + expect(mockedVerify).not.toHaveBeenCalled(); + }); + + it('grandfather relock: enrollment intact with a debt outstanding → forces passcode, neutral reason', async () => { + mockedIsEnabled.mockReturnValue(true); + mockedIsRelockPending.mockReturnValue(true); + + await handleLocalAuthentication(); + + expect(lastEmitPayload()).toMatchObject({ hasBiometry: false, reason: 'relockRequired' }); + expect(mockedInvalidate).toHaveBeenCalledTimes(1); + expect(mockedSetRelockPending).toHaveBeenLastCalledWith(false); + }); + + // The marker is the only thing that survives the process, so a persistent failure must not clear it. + it('leaves a pending relock marker set when the check fails', async () => { + mockedIsEnabled.mockReturnValue(true); + mockedIsRelockPending.mockReturnValue(true); + mockedHasEnrollment.mockRejectedValueOnce(new Error('keychain read failed')); + + await handleLocalAuthentication(); + + expect(mockedSetRelockPending).not.toHaveBeenCalledWith(false); + }); + + 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: 'trustLost' }); + // Flag already cleared by the migration, so no teardown here. + expect(mockedInvalidate).not.toHaveBeenCalled(); + // The marker is only cleared, never re-armed: reaching this branch with the flag off already + // requires isRelockPending() to be true. + expect(mockedSetRelockPending).toHaveBeenCalledTimes(1); + expect(mockedSetRelockPending).toHaveBeenCalledWith(false); + expect(mockedVerify).not.toHaveBeenCalled(); + }); + + it('kill-before-passcode: modal never resolves → relock debt is persisted and NOT cleared', async () => { + mockedIsEnabled.mockReturnValue(true); + mockedHasEnrollment.mockResolvedValueOnce(false); + mockedEmit.mockImplementation(() => {}); + + let settled = false; + handleLocalAuthentication().then( + () => { + settled = true; + }, + () => { + settled = true; + } + ); + await new Promise(resolve => setImmediate(resolve)); + + expect(settled).toBe(false); + expect(lastEmitPayload()).toMatchObject({ hasBiometry: false, reason: 'trustLost' }); + // invalidate() arms the debt (see biometricTrustStore/index.test.ts); what matters here is that + // nothing clears it while the modal is still up. + expect(mockedInvalidate).toHaveBeenCalledTimes(1); + expect(mockedSetRelockPending).not.toHaveBeenCalledWith(false); + }); + + it('biometry disabled → does not read 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(); + }); +}); + +describe('localAuthenticate', () => { + const mockedFindServer = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockedGetString.mockReturnValue('stored-passcode'); + mockedIsEnabled.mockReturnValue(true); + mockedHasEnrollment.mockResolvedValue(false); + mockedIsEnrollmentValid.mockResolvedValue(true); + mockedIsRelockPending.mockReturnValue(false); + mockedIsEnrolled.mockResolvedValue(true); + mockedDisenroll.mockResolvedValue(undefined); + mockedInvalidate.mockResolvedValue(undefined); + mockedGetServerTimeSync.mockResolvedValueOnce(1_000_000).mockResolvedValueOnce(1_000_001); + mockedServersGet.mockReturnValue({ find: mockedFindServer }); + mockedServersWrite.mockImplementation(callback => callback()); + mockedMultiRemove.mockResolvedValue(undefined); + mockedEmit.mockImplementation((event, payload) => { + if (event === LOCAL_AUTHENTICATE_EMITTER && payload?.submit) { + setImmediate(() => payload.submit()); + } + }); + }); + + it('a lost enrollment forces the passcode modal inside the auto-lock window', async () => { + const serverRecord = { + autoLock: true, + autoLockTime: 60, + lastLocalAuthenticatedSession: new Date(990_000), + update: jest.fn(updater => { + updater(serverRecord); + return Promise.resolve(); + }) + }; + mockedFindServer.mockResolvedValue(serverRecord); + + await localAuthenticate('server-id'); + + expect(lastEmitPayload()).toMatchObject({ hasBiometry: false, reason: 'trustLost' }); + expect(mockedGetServerTimeSync).toHaveBeenCalledTimes(2); + expect(mockedDispatch).toHaveBeenNthCalledWith(1, expect.objectContaining({ isLocalAuthenticated: false })); + expect(mockedDispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ isLocalAuthenticated: true })); + }); + + it('fail closed on warm resume: hasEnrollment() rejects inside the auto-lock window → forces passcode, keeps biometry', async () => { + mockedHasEnrollment.mockReset(); + mockedHasEnrollment.mockRejectedValue(new Error('keychain read failed')); + const serverRecord = { + autoLock: true, + autoLockTime: 60, + // Well within the auto-lock window: without fail-closed detection the session would stay unlocked. + lastLocalAuthenticatedSession: new Date(999_999), + update: jest.fn(updater => { + updater(serverRecord); + return Promise.resolve(); + }) + }; + mockedFindServer.mockResolvedValue(serverRecord); + + await expect(localAuthenticate('server-id')).resolves.toBeUndefined(); + + // Still forced despite the fresh session, but the enrollment survives a transient read failure. + expect(lastEmitPayload()).toMatchObject({ hasBiometry: false }); + expect(lastEmitPayload()?.reason).toBeUndefined(); + expect(mockedInvalidate).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. clearAllMocks() + // does not reset implementations, so explicitly clear the 'stored-passcode' return leaked from the + // localAuthenticate block above (otherwise checkHasPasscode early-returns and never enrolls). + mockedGetString.mockReturnValue(undefined); + (LocalAuthentication.isEnrolledAsync as jest.Mock).mockResolvedValue(true); + (LocalAuthentication.getEnrolledLevelAsync as jest.Mock).mockResolvedValue(3); + mockedEmit.mockImplementation((event, payload) => { + if (event === CHANGE_PASSCODE_EMITTER && payload?.submit) { + setImmediate(() => payload.submit('1234')); + } + }); + }); + + // Class 2 face unlock passes isEnrolledAsync but the keystore can't bind a user-auth key to it, so + // enrolling would only produce a sentinel we'd immediately have to revoke. + it('weak-only biometry → never enrolls, never prompts, biometry left disabled', async () => { + (LocalAuthentication.getEnrolledLevelAsync as jest.Mock).mockResolvedValue(2); + + await checkHasPasscode({}); + + expect(mockedEnroll).not.toHaveBeenCalled(); + expect(mockedVerify).not.toHaveBeenCalled(); + expect(LocalAuthentication.authenticateAsync).not.toHaveBeenCalled(); + expect(mockedSetEnabled).toHaveBeenCalledWith(false); + }); + + 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(); + // Cleans up before clearing the flag, so a partial enroll can't orphan a sentinel. + expect(mockedDisenroll).toHaveBeenCalledTimes(1); + expect(mockedSetEnabled).toHaveBeenCalledWith(false); + expect(mockedDisenroll.mock.invocationCallOrder[0]).toBeLessThan(mockedSetEnabled.mock.invocationCallOrder[0]); + }); +}); + +// biometryAuth must prove a live user is present, not merely that the biometric enrollment is +// unchanged. On iOS the sentinel read does both. On Android it does not: react-native-keychain builds +// the sentinel's keystore key with setUserAuthenticationParameters(5, AUTH_BIOMETRIC_STRONG or +// AUTH_DEVICE_CREDENTIAL) and only shows the BiometricPrompt from decrypt()'s +// UserNotAuthenticatedException branch, so within 5s of a *PIN* unlock verify() resolves success with +// no prompt at all. These tests pin the platform split that closes that hole. +const mockedAuthenticateAsync = LocalAuthentication.authenticateAsync as jest.Mock; + +describe('biometryAuth', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsIOS = true; + mockedHasEnrollment.mockResolvedValue(true); + mockedIsEnrollmentValid.mockResolvedValue(true); + }); + + describe('iOS', () => { + it('delegates to verify() — the keychain read is itself the biometric evaluation', async () => { + mockedVerify.mockResolvedValueOnce({ kind: 'success' }); + + await expect(biometryAuth()).resolves.toEqual({ kind: 'success' }); + + expect(mockedVerify).toHaveBeenCalledTimes(1); + expect(mockedVerify).toHaveBeenCalledWith({ + promptCopy: { title: 'Local_authentication_biometry_title', cancel: 'Local_authentication_biometry_fallback' } + }); + // No second prompt: iOS gets presence for free from the same read. + expect(mockedAuthenticateAsync).not.toHaveBeenCalled(); + }); + + it('uses the "Don\'t activate" cancel label when force is set', async () => { + mockedVerify.mockResolvedValueOnce({ kind: 'success' }); + + await biometryAuth(true); + + expect(mockedVerify).toHaveBeenCalledWith({ + promptCopy: { title: 'Local_authentication_biometry_title', cancel: 'Dont_activate' } + }); + }); + + it('passes a non-success verify() kind straight through', async () => { + mockedVerify.mockResolvedValueOnce({ kind: 'enrollmentChanged' }); + + await expect(biometryAuth()).resolves.toEqual({ kind: 'enrollmentChanged' }); + }); + }); + + describe('Android', () => { + beforeEach(() => { + mockIsIOS = false; + }); + + it('never reads the sentinel for presence — demands a fresh biometric instead', async () => { + mockedAuthenticateAsync.mockResolvedValueOnce({ success: true }); + + await expect(biometryAuth()).resolves.toEqual({ kind: 'success' }); + + // The regression this guards: verify() can succeed with no prompt shown, so it must not be + // what gates the unlock on Android. + expect(mockedVerify).not.toHaveBeenCalled(); + expect(mockedAuthenticateAsync).toHaveBeenCalledTimes(1); + // disableDeviceFallback is the whole point: a device PIN must not satisfy this. 'strong' is the + // other half — expo defaults to 'weak', which would accept a Class 2 face neither the enrollment key + // nor the sentinel can see. + expect(mockedAuthenticateAsync).toHaveBeenCalledWith({ + disableDeviceFallback: true, + biometricsSecurityLevel: 'strong', + promptMessage: 'Local_authentication_biometry_title', + cancelLabel: 'Local_authentication_biometry_fallback' + }); + }); + + it('does not unlock when the biometric prompt fails, even though the sentinel is intact', async () => { + mockedAuthenticateAsync.mockResolvedValueOnce({ success: false, error: 'user_cancel' }); + + await expect(biometryAuth()).resolves.toEqual({ kind: 'canceled' }); + }); + + it('reports unavailable without prompting when no sentinel exists', async () => { + mockedHasEnrollment.mockResolvedValueOnce(false); + + await expect(biometryAuth()).resolves.toEqual({ kind: 'unavailable' }); + + expect(mockedIsEnrollmentValid).not.toHaveBeenCalled(); + expect(mockedAuthenticateAsync).not.toHaveBeenCalled(); + }); + + it('reports enrollmentChanged from the silent check without prompting', async () => { + mockedIsEnrollmentValid.mockResolvedValueOnce(false); + + await expect(biometryAuth()).resolves.toEqual({ kind: 'enrollmentChanged' }); + + expect(mockedAuthenticateAsync).not.toHaveBeenCalled(); + }); + + it('checks the enrollment binding before prompting, not after', async () => { + mockedAuthenticateAsync.mockResolvedValueOnce({ success: true }); + + await biometryAuth(); + + expect(mockedIsEnrollmentValid.mock.invocationCallOrder[0]).toBeLessThan( + mockedAuthenticateAsync.mock.invocationCallOrder[0] + ); + }); + + it('maps a removed biometric enrollment to unavailable so trust is torn down', async () => { + mockedAuthenticateAsync.mockResolvedValueOnce({ success: false, error: 'not_enrolled' }); + + await expect(biometryAuth()).resolves.toEqual({ kind: 'unavailable' }); + }); + + it('maps a lockout to error rather than a user cancel', async () => { + mockedAuthenticateAsync.mockResolvedValueOnce({ success: false, error: 'lockout' }); + + await expect(biometryAuth()).resolves.toEqual({ kind: 'error', cause: 'lockout' }); + }); + + // expo flattens the transient ERROR_HW_UNAVAILABLE into 'not_available' together with + // ERROR_NO_BIOMETRICS, and `unavailable` would permanently tear the enrollment down. + it('maps an unavailable sensor to error, not to a teardown', async () => { + mockedAuthenticateAsync.mockResolvedValueOnce({ success: false, error: 'not_available' }); + + await expect(biometryAuth()).resolves.toEqual({ kind: 'error', cause: 'not_available' }); + }); + + it('fails closed when the sentinel check throws', async () => { + const cause = new Error('keystore unavailable'); + mockedHasEnrollment.mockRejectedValueOnce(cause); + + await expect(biometryAuth()).resolves.toEqual({ kind: 'error', cause }); + + expect(mockedAuthenticateAsync).not.toHaveBeenCalled(); + }); + + it('fails closed when the OS prompt throws', async () => { + const cause = new Error('no activity'); + mockedAuthenticateAsync.mockRejectedValueOnce(cause); + + await expect(biometryAuth()).resolves.toEqual({ kind: 'error', cause }); + }); + }); +}); + +// Consent is only consent if a prompt actually appeared. On Android a bare verify() can resolve inside +// the keystore's 5s auth window with nothing shown, which would enable biometric unlock the user was +// never asked about — so enableBiometry captures consent through biometryAuth(true). +describe('checkHasPasscode → biometry consent on Android', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsIOS = false; + mockedDisenroll.mockResolvedValue(undefined); + mockedGetString.mockReturnValue(undefined); + mockedHasEnrollment.mockResolvedValue(true); + mockedIsEnrollmentValid.mockResolvedValue(true); + mockedIsEnrolled.mockResolvedValue(true); + mockedEmit.mockImplementation((event, payload) => { + if (event === CHANGE_PASSCODE_EMITTER && payload?.submit) { + setImmediate(() => payload.submit('1234')); + } + }); + }); + + afterEach(() => { + mockIsIOS = true; + }); + + it('asks with a real OS prompt, not a sentinel read', async () => { + mockedEnroll.mockResolvedValueOnce({ kind: 'success' }); + mockedAuthenticateAsync.mockResolvedValueOnce({ success: true }); + + await checkHasPasscode({}); + + expect(mockedVerify).not.toHaveBeenCalled(); + expect(mockedAuthenticateAsync).toHaveBeenCalledTimes(1); + expect(mockedAuthenticateAsync).toHaveBeenCalledWith(expect.objectContaining({ cancelLabel: 'Dont_activate' })); + expect(mockedSetEnabled).toHaveBeenCalledWith(true); + expect(mockedDisenroll).not.toHaveBeenCalled(); + }); + + it('declining the prompt disenrolls and leaves biometry disabled', async () => { + mockedEnroll.mockResolvedValueOnce({ kind: 'success' }); + mockedAuthenticateAsync.mockResolvedValueOnce({ success: false, error: 'user_cancel' }); + + await checkHasPasscode({}); + + expect(mockedDisenroll).toHaveBeenCalledTimes(1); + expect(mockedSetEnabled).toHaveBeenCalledWith(false); + }); +}); + +// Every enable path shares this one, including the settings toggle the grandfathered cohort re-enables +// from — a silent re-bind there would hand trust back to whatever enrollment is on the device now. +describe('enableBiometry', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsIOS = false; + mockedDisenroll.mockResolvedValue(undefined); + mockedHasEnrollment.mockResolvedValue(true); + mockedIsEnrollmentValid.mockResolvedValue(true); + mockedIsEnrolled.mockResolvedValue(true); + }); + + afterEach(() => { + mockIsIOS = true; + }); + + it('prompts for consent after binding the sentinel', async () => { + mockedEnroll.mockResolvedValueOnce({ kind: 'success' }); + mockedAuthenticateAsync.mockResolvedValueOnce({ success: true }); + + await expect(enableBiometry()).resolves.toEqual({ kind: 'success' }); + + expect(mockedEnroll).toHaveBeenCalledTimes(1); + expect(mockedAuthenticateAsync).toHaveBeenCalledTimes(1); + expect(mockedSetEnabled).toHaveBeenCalledWith(true); + expect(mockedDisenroll).not.toHaveBeenCalled(); + }); + + it('tears the fresh sentinel back down when consent is declined', async () => { + mockedEnroll.mockResolvedValueOnce({ kind: 'success' }); + mockedAuthenticateAsync.mockResolvedValueOnce({ success: false, error: 'user_cancel' }); + + await expect(enableBiometry()).resolves.toEqual({ kind: 'canceled' }); + + expect(mockedDisenroll).toHaveBeenCalledTimes(1); + expect(mockedSetEnabled).toHaveBeenCalledWith(false); + expect(mockedSetEnabled).not.toHaveBeenCalledWith(true); + }); + + it('refuses without a strong biometric, before writing anything', async () => { + mockedIsEnrolled.mockResolvedValueOnce(false); + + await expect(enableBiometry()).resolves.toEqual({ kind: 'unavailable' }); + + expect(mockedEnroll).not.toHaveBeenCalled(); + expect(mockedSetEnabled).toHaveBeenCalledWith(false); + }); + + it('surfaces an enroll failure without prompting', async () => { + mockedEnroll.mockResolvedValueOnce({ kind: 'unavailable' }); + + await expect(enableBiometry()).resolves.toEqual({ kind: 'unavailable' }); + + expect(mockedAuthenticateAsync).not.toHaveBeenCalled(); + expect(mockedSetEnabled).toHaveBeenCalledWith(false); + }); +}); + +describe('handleLocalAuthentication relockReason option', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedIsEnabled.mockReturnValue(true); + mockedIsEnrolled.mockResolvedValue(true); + mockedHasEnrollment.mockResolvedValue(true); + mockedIsEnrollmentValid.mockResolvedValue(true); + mockedIsRelockPending.mockReturnValue(false); + mockedEmit.mockImplementation((event, payload) => { + if (event === LOCAL_AUTHENTICATE_EMITTER && payload?.submit) { + setImmediate(() => payload.submit()); + } + }); + }); + + it("trusts an explicit 'none' instead of re-running the check", async () => { + await handleLocalAuthentication({ relockReason: 'none' }); + + expect(mockedHasEnrollment).not.toHaveBeenCalled(); + expect(mockedIsEnrollmentValid).not.toHaveBeenCalled(); + expect(mockedIsRelockPending).not.toHaveBeenCalled(); + expect(lastEmitPayload()?.reason).toBeUndefined(); + }); + + it('still computes the check when the option is omitted', async () => { + await handleLocalAuthentication(); + + expect(mockedHasEnrollment).toHaveBeenCalled(); + }); + + it("forces the enrollment-changed unlock when passed 'enrollmentChanged'", async () => { + mockedInvalidate.mockResolvedValueOnce(undefined); + + await handleLocalAuthentication({ relockReason: 'enrollmentChanged' }); + + expect(mockedInvalidate).toHaveBeenCalledTimes(1); + expect(lastEmitPayload()?.reason).toBe('enrollmentChanged'); + expect(mockedSetRelockPending).toHaveBeenLastCalledWith(false); + }); + + it.each(['trustLost', 'relockRequired'] as const)( + "forces the enrollment-changed teardown when passed '%s', with that reason's own copy", + async relockReason => { + mockedInvalidate.mockResolvedValueOnce(undefined); + + await handleLocalAuthentication({ relockReason }); + + expect(mockedInvalidate).toHaveBeenCalledTimes(1); + expect(lastEmitPayload()?.reason).toBe(relockReason); + expect(mockedSetRelockPending).toHaveBeenLastCalledWith(false); + } + ); + + it("forces the passcode without a teardown when passed 'checkFailed'", async () => { + await handleLocalAuthentication({ relockReason: 'checkFailed' }); + + expect(mockedHasEnrollment).not.toHaveBeenCalled(); + expect(lastEmitPayload()).toMatchObject({ hasBiometry: false }); + expect(lastEmitPayload()?.reason).toBeUndefined(); + expect(mockedInvalidate).not.toHaveBeenCalled(); + }); +}); diff --git a/app/lib/methods/helpers/localAuthentication.ts b/app/lib/methods/helpers/localAuthentication.ts index 8dd1b005f55..ac88ba61807 100644 --- a/app/lib/methods/helpers/localAuthentication.ts +++ b/app/lib/methods/helpers/localAuthentication.ts @@ -8,17 +8,24 @@ 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 BiometricPromptCopy, + type TServerModel, + type TrustResult +} from '../../../definitions'; +import log from './log'; import EventEmitter from './events'; import { isIOS } from './deviceInfo'; @@ -50,13 +57,29 @@ export const saveLastLocalAuthenticationSession = async ( export const resetAttempts = (): Promise => AsyncStorage.multiRemove([LOCKED_OUT_TIMER_KEY, ATTEMPTS_KEY]); -const openModal = (hasBiometry: boolean, force?: boolean) => +// Lets catch blocks tell a benign cancel/supersede apart from a real failure. +export class UserCanceledError extends Error { + constructor() { + super('User canceled local authentication'); + this.name = 'UserCanceledError'; + } +} + +// A dismissed unlock modal is benign; a real failure must not be swallowed as a cancel. +export const logUnlessUserCanceled = (e: unknown): void => { + if (!(e instanceof UserCanceledError)) { + log(e); + } +}; + +const openModal = (hasBiometry: boolean, canClose?: boolean, reason?: BiometricInvalidationReason) => new Promise((resolve, reject) => { EventEmitter.emit(LOCAL_AUTHENTICATE_EMITTER, { submit: () => resolve(), hasBiometry, - force, - cancel: () => reject() + canClose, + reason, + cancel: () => reject(new UserCanceledError()) }); }); @@ -64,7 +87,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,24 +97,127 @@ 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): BiometricPromptCopy => ({ + title: I18n.t('Local_authentication_biometry_title'), + cancel: force ? I18n.t('Dont_activate') : I18n.t('Local_authentication_biometry_fallback') +}); + +const classifyPresenceError = (error?: LocalAuthentication.LocalAuthenticationError): TrustResult => { + switch (error) { + case 'user_cancel': + case 'app_cancel': + case 'system_cancel': + case 'user_fallback': + // Deliberate: a non-match ends the prompt with nothing proven, same as a cancel. The toggle + // flipping back is the feedback; an alert here would fire on every mistimed finger. + case 'authentication_failed': + return { kind: 'canceled' }; + case 'not_enrolled': + return { kind: 'unavailable' }; + // 'not_available' deliberately falls through to the non-destructive default, as 'lockout' does: + // expo maps the transient ERROR_HW_UNAVAILABLE onto it alongside ERROR_NO_BIOMETRICS, and + // `unavailable` tears the enrollment down for good. + default: + return { kind: 'error', cause: error }; + } +}; + +// Single source for the sentinel-then-key-check order; the platform split is in PLATFORMS.md. +type EnrollmentCheck = { state: 'valid' | 'absent' | 'invalid' } | { state: 'error'; cause: unknown }; + +const checkBiometricEnrollment = async (): Promise => { + try { + if (!(await biometricTrustStore.hasEnrollment())) { + return { state: 'absent' }; + } + return { state: (await biometricTrustStore.isEnrollmentValid()) ? 'valid' : 'invalid' }; + } catch (cause) { + return { state: 'error', cause }; + } +}; + +// Proves presence, not just an unchanged enrollment. See PLATFORMS.md, "Why the sentinel read can't prove presence". +export const biometryAuth = async (force?: boolean): Promise => { + const promptCopy = buildPromptCopy(force); + + if (isIOS) { + return biometricTrustStore.verify({ promptCopy }); + } + + const enrollment = await checkBiometricEnrollment(); + if (enrollment.state === 'absent') { + return { kind: 'unavailable' }; + } + if (enrollment.state === 'invalid') { + return { kind: 'enrollmentChanged' }; + } + if (enrollment.state === 'error') { + return { kind: 'error', cause: enrollment.cause }; + } + + try { + const presence = await LocalAuthentication.authenticateAsync({ + disableDeviceFallback: true, + // Class 3 only; expo defaults to 'weak'. See PLATFORMS.md, "Weak (Class 2) biometrics". + biometricsSecurityLevel: 'strong', + cancelLabel: promptCopy.cancel, + promptMessage: promptCopy.title + }); + return presence.success ? { kind: 'success' } : classifyPresenceError(presence.error); + } catch (e) { + return { kind: 'error', cause: e }; + } +}; + +// Class 3 only. See PLATFORMS.md, "Weak (Class 2) biometrics". +export const hasSupportedBiometry = async (): Promise => { + try { + if (!(await LocalAuthentication.isEnrolledAsync())) { + return false; + } + const level = await LocalAuthentication.getEnrolledLevelAsync(); + return level === LocalAuthentication.SecurityLevel.BIOMETRIC_STRONG; + } catch { + return false; + } +}; /* - * It'll help us to get the permission to use FaceID - * and enable/disable the biometry when user put their first passcode + * Binds the trust sentinel and captures the user's consent for biometric unlock. Every enable path + * (first passcode, settings toggle) must go through here: the sentinel write is silent, so it can't + * double as consent. See ARCHITECTURE.md, "Why writing the sentinel is not consent". */ -const checkBiometry = async () => { - const result = await biometryAuth(true); - const isBiometryEnabled = !!result?.success; - UserPreferences.setBool(BIOMETRY_ENABLED_KEY, isBiometryEnabled); - return isBiometryEnabled; +export const enableBiometry = async (): Promise => { + // Without a strong biometric enroll() can only produce a downgraded sentinel, so don't offer the + // opt-in at all rather than offering and revoking it. + if (!(await hasSupportedBiometry())) { + biometricTrustStore.setEnabled(false); + return { kind: 'unavailable' }; + } + + const enrollResult = await biometricTrustStore.enroll(); + if (enrollResult.kind !== 'success') { + // disenroll() first: the reverse order can orphan a sentinel a partial enroll left behind. + await biometricTrustStore.disenroll(); + biometricTrustStore.setEnabled(false); + return enrollResult; + } + + // Via biometryAuth, not verify(): a prompt that never appeared isn't consent. + const consent = await biometryAuth(true); + if (consent.kind !== 'success') { + await biometricTrustStore.disenroll(); + biometricTrustStore.setEnabled(false); + return consent; + } + + biometricTrustStore.setEnabled(true); + return { kind: 'success' }; }; +// Captures the biometry opt-in when the user sets their first passcode. +const checkBiometry = async () => (await enableBiometry()).kind === 'success'; + export const checkHasPasscode = async ({ force = true }: { force?: boolean }): Promise<{ newPasscode?: boolean } | void> => { const storedPasscode = UserPreferences.getString(PASSCODE_KEY); if (!storedPasscode) { @@ -110,18 +236,70 @@ const hideSplashScreen = async () => { } }; -export const handleLocalAuthentication = async (canCloseModal = false) => { - // let hasBiometry = false; - let hasBiometry = UserPreferences.getBool(BIOMETRY_ENABLED_KEY) ?? false; +/* + * Non-prompting. Lets an enrollment change force the passcode even inside the auto-lock window. + * `checkFailed` is a separate state on purpose: a trust check that could not complete must fail + * closed without failing destructive. See ARCHITECTURE.md, "A failed check is not a change". + */ +export type TRelockReason = 'none' | 'checkFailed' | BiometricInvalidationReason; - // if biometry is enabled on the app - if (hasBiometry) { - const isEnrolled = await LocalAuthentication.isEnrolledAsync(); - hasBiometry = isEnrolled; +const getRelockReason = async (): Promise => { + // Cheap flag first: passcode-only users shouldn't pay the native capability check per lock event. + if (biometricTrustStore.isEnabled()) { + const enrollment = await checkBiometricEnrollment(); + if (enrollment.state === 'error') { + log(enrollment.cause); + return 'checkFailed'; + } + if (enrollment.state === 'invalid') { + return 'enrollmentChanged'; + } + if (enrollment.state === 'absent') { + return 'trustLost'; + } + return biometricTrustStore.isRelockPending() ? 'relockRequired' : 'none'; + } + // Warm foreground surfaces the change live; cold launch reads the marker the init migration left. + return biometricTrustStore.isRelockPending() ? 'trustLost' : 'none'; +}; + +interface IHandleLocalAuthentication { + canCloseModal?: boolean; + // Result of a check the caller already ran; omit it to compute here. `'none'` skips the recheck. + relockReason?: TRelockReason; +} + +export const handleLocalAuthentication = async ({ canCloseModal = false, relockReason }: IHandleLocalAuthentication = {}) => { + const biometryEnabled = biometricTrustStore.isEnabled(); + + const reason = relockReason ?? (await getRelockReason()); + if (reason !== 'none' && reason !== 'checkFailed') { + // No else: with the flag off, both routes to a non-'none' reason run through getRelockReason, + // whose only such return is gated on isRelockPending() — already true, so re-arming was a no-op. + if (biometryEnabled) { + await biometricTrustStore.invalidate(); + } + await openModal(false, canCloseModal, reason); + biometricTrustStore.setRelockPending(false); + return; } - // Authenticate + /* + * The check itself failed — a busy sensor, a one-off keychain error, a broken bridge. Demand the + * passcode and hide a biometry button we can't vouch for, but leave the enrollment intact: the + * teardown is irreversible and the user would have to re-opt-in with no idea why. The relock + * marker is deliberately left as it is, so a persistent failure keeps forcing the passcode. + */ + if (reason === 'checkFailed') { + await openModal(false, canCloseModal); + return; + } + + const hasBiometry = biometryEnabled && (await hasSupportedBiometry()); + + // Modal first so it covers the app; PasscodeEnter prompts biometry from behind it. await openModal(hasBiometry, canCloseModal); + biometricTrustStore.setRelockPending(false); }; export const localAuthenticate = async (server: string): Promise => { @@ -143,33 +321,44 @@ export const localAuthenticate = async (server: string): Promise => { // Check if the app has passcode const result = await checkHasPasscode({}); + // Refreshed after the modal: the stale pre-modal timesync would immediately re-lock the session. + 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; + + // Must force the lock screen regardless of how recently the user authenticated. + const relockReason = await getRelockReason(); + + // 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 || relockReason !== 'none' || (autoLockTime && diffToLastSession >= autoLockTime)) { await hideSplashScreen(); // set isLocalAuthenticated to false store.dispatch(setLocalAuthenticated(false)); - await handleLocalAuthentication(); + await handleLocalAuthentication({ relockReason }); // set isLocalAuthenticated to true store.dispatch(setLocalAuthenticated(true)); + + 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/lib/native/NativeBiometricEnrollment.ts b/app/lib/native/NativeBiometricEnrollment.ts new file mode 100644 index 00000000000..51ec44afeeb --- /dev/null +++ b/app/lib/native/NativeBiometricEnrollment.ts @@ -0,0 +1,32 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +import { isIOS } from '../methods/helpers/deviceInfo'; + +export interface Spec extends TurboModule { + /** Creates the enrollment key bound to the current enrollment (idempotent); false when none is available. @platform android */ + bindEnrollmentKey(): Promise; + + /** Deletes the enrollment key, kept in lockstep with the JS trust sentinel teardown. @platform android */ + clearEnrollmentKey(): Promise; + + /** Silent check (never prompts): false only when an enrollment change invalidated the key. @platform android */ + isEnrollmentValid(): Promise; +} + +/* + * There is no iOS counterpart, so the fallback answers for iOS. On Android the module is expected: + * a missing one must reject rather than answer `false`, which the trust store would read as an + * enrollment change and permanently disenroll every user on the build. + */ +const missingModule = () => Promise.reject(new Error('BiometricEnrollment native module is unavailable')); + +const NativeBiometricEnrollment = + TurboModuleRegistry.get('BiometricEnrollment') ?? + ({ + bindEnrollmentKey: () => (isIOS ? Promise.resolve(false) : missingModule()), + clearEnrollmentKey: () => (isIOS ? Promise.resolve(false) : missingModule()), + isEnrollmentValid: () => (isIOS ? Promise.resolve(true) : missingModule()) + } as Spec); + +export default NativeBiometricEnrollment; diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index c7639a5349b..7a5fca4cf04 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -24,9 +24,19 @@ jest.mock('../../lib/methods/helpers/goRoom', () => ({ navigateToRoom: jest.fn() })); -jest.mock('../../lib/methods/helpers/localAuthentication', () => ({ - localAuthenticate: jest.fn() -})); +jest.mock('../../lib/methods/helpers/localAuthentication', () => { + class UserCanceledError extends Error { + constructor() { + super('User canceled local authentication'); + this.name = 'UserCanceledError'; + } + } + return { + localAuthenticate: jest.fn(), + logUnlessUserCanceled: jest.fn(), + UserCanceledError + }; +}); jest.mock('../../lib/services/connect', () => ({ loginOAuthOrSso: jest.fn() @@ -94,10 +104,11 @@ import { selectServerFailure, selectServerSuccess } from '../../actions/server'; import { appStart } from '../../actions/app'; import { APP, LOGOUT, SERVER } from '../../actions/actionsTypes'; import { RootEnum } from '../../definitions'; +import { TOKEN_KEY } from '../../lib/constants/keys'; import deepLinkingRoot from '../deepLinking'; import UserPreferences from '../../lib/methods/userPreferences'; import { getServerById } from '../../lib/database/services/Server'; -import { localAuthenticate } from '../../lib/methods/helpers/localAuthentication'; +import { localAuthenticate, logUnlessUserCanceled, UserCanceledError } from '../../lib/methods/helpers/localAuthentication'; import { canOpenRoom } from '../../lib/methods/canOpenRoom'; import { getServerInfo } from '../../lib/methods/getServerInfo'; import { goRoom, navigateToRoom } from '../../lib/methods/helpers/goRoom'; @@ -149,6 +160,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(); @@ -163,6 +175,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); @@ -355,6 +368,8 @@ 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(logUnlessUserCanceled).mockReset(); jest.mocked(goRoom).mockReset(); jest.mocked(waitForNavigationReady).mockReset(); @@ -367,6 +382,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); @@ -430,6 +446,74 @@ describe('deepLinking saga — server already connected, should skip changing se expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); emitSpy.mockRestore(); }); + + const setupFailedUnlock = (error: Error) => { + 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(error); + }; + + // The catch must hand the error to logUnlessUserCanceled and bail, not fall through to the + // unknown-server path — a canceled unlock would otherwise re-add the server the user just locked. + it.each([ + ['a failed unlock', () => new Error('unlock failed')], + ['a canceled unlock', () => new UserCanceledError()] + ])('drops the deep link on %s for an existing secondary server', async (_label, makeError) => { + const emitSpy = jest.spyOn(EventEmitter, 'emit'); + const { store, dispatchedActions } = setupStore(); + const error = makeError(); + setupFailedUnlock(error); + + store.dispatch(deepLinkingOpen(makeParams({ path: 'channel/general' }))); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(jest.mocked(localAuthenticate)).toHaveBeenCalledWith(HOST); + expect(jest.mocked(logUnlessUserCanceled)).toHaveBeenCalledWith(error); + expect(jest.mocked(getServerInfo)).not.toHaveBeenCalled(); + expect(dispatchedActions).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(); + }); + + // Cold start: the deep link arrived over the splash screen, so bailing out with no root would + // leave the app with no navigator and only a force-quit to recover. + it('recovers the app root when the unlock fails with no root set', async () => { + const { store, dispatchedActions } = setupStore(); + setupFailedUnlock(new UserCanceledError()); + + expect(store.getState().app.root).toBeUndefined(); + + store.dispatch(deepLinkingOpen(makeParams({ path: 'channel/general' }))); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(dispatchedActions).toEqual(expect.arrayContaining([expect.objectContaining({ type: APP.INIT })])); + }); + + // Warm app: a failed unlock must not re-initialize and throw the user out of where they were. + it('leaves an already-initialized root alone when the unlock fails', async () => { + const { store, dispatchedActions } = setupStore(); + setupFailedUnlock(new UserCanceledError()); + + store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); + const dispatchedBefore = dispatchedActions.length; + + store.dispatch(deepLinkingOpen(makeParams({ path: 'channel/general' }))); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(dispatchedActions.slice(dispatchedBefore)).not.toEqual( + expect.arrayContaining([expect.objectContaining({ type: APP.INIT })]) + ); + expect(store.getState().app.root).toBe(RootEnum.ROOT_INSIDE); + }); }); // ─── handleClickCallPush (OPEN_VIDEO_CONF) — new server + token ─────────────── @@ -444,6 +528,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(); @@ -454,6 +539,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 636dc297ab2..a1dc97e9912 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -19,7 +19,7 @@ import { getUidDirectMessage, normalizeDeepLinkingServerHost } from '../lib/meth import EventEmitter from '../lib/methods/helpers/events'; import { goRoom, navigateToRoom } from '../lib/methods/helpers/goRoom'; import { getIsMasterDetail } from '../lib/hooks/useMasterDetail'; -import { localAuthenticate } from '../lib/methods/helpers/localAuthentication'; +import { localAuthenticate, logUnlessUserCanceled, UserCanceledError } from '../lib/methods/helpers/localAuthentication'; import log from '../lib/methods/helpers/log'; import { showToast } from '../lib/methods/helpers/showToast'; import UserPreferences from '../lib/methods/userPreferences'; @@ -169,7 +169,17 @@ const handleShareExtension = function* handleOpen({ params }) { yield put(appStart({ root: RootEnum.ROOT_LOADING_SHARE_EXTENSION })); try { - yield localAuthenticate(server); + try { + yield localAuthenticate(server); + } catch (e) { + if (!(e instanceof UserCanceledError)) { + throw e; + } + // 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) { yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); @@ -237,7 +247,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 (e) { + logUnlessUserCanceled(e); + return; + } yield put(selectServerRequest(host, serverRecord.version, true)); yield take(types.LOGIN.SUCCESS); } @@ -253,7 +268,12 @@ const handleOpen = function* handleOpen({ params }) { return; } } catch (e) { - // do nothing? + logUnlessUserCanceled(e); + // Don't fall through to the add-server flow — it would connect to the very server whose + // unlock just failed. fallbackNavigation only acts on a cold start, where returning here + // would otherwise leave the app stuck on the splash screen with no navigator. + yield fallbackNavigation(); + return; } // if deep link is from a different server const result = yield getServerInfo(host); @@ -347,14 +367,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 (e) { + logUnlessUserCanceled(e); + 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 (e) { + logUnlessUserCanceled(e); + 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 5c7b0487c1b..0f09aaa45a4 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -9,7 +9,8 @@ import { selectServerRequest } from '../actions/server'; import { setAllPreferences } from '../actions/sortPreferences'; import { APP } from '../actions/actionsTypes'; import log from '../lib/methods/helpers/log'; -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'; @@ -28,7 +29,14 @@ const restoreServer = async () => { const restoredServer = isLoggedInServer(server) ? await getServerById(server) : await findLoggedInServer(); if (restoredServer) { - await localAuthenticate(restoredServer.id); + try { + await localAuthenticate(restoredServer.id); + } catch (e) { + // A superseded unlock still has a newer modal gating the screen, so keep booting. + if (!(e instanceof UserCanceledError)) { + throw e; + } + } } return restoredServer; @@ -61,6 +69,8 @@ const deliverPendingPushNotification = function* deliverPendingPushNotification( }; const restore = function* restore() { + yield call(runBiometricTrustMigration); + const restoredServer = yield* getServerToRestore(); if (restoredServer) { diff --git a/app/sagas/login.js b/app/sagas/login.js index 3a24534fb89..814db64b130 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -15,7 +15,7 @@ import { findLoggedInServer } from '../lib/methods/loggedInServer'; 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'; @@ -102,7 +102,14 @@ 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, so a superseded unlock shouldn't fall through to loginFailure. + 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..119773ea37a 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,8 @@ interface IArgs { const ChangePasscodeView = memo(() => { const [visible, setVisible] = useState(false); const [data, setData] = useState>({}); + const [requestId, setRequestId] = useState(0); + const { onShow, defer, onModalHide } = useDeferredModalSettle>(); useDeepCompareEffect(() => { if (!isEmpty(data)) { @@ -45,22 +48,19 @@ const ChangePasscodeView = memo(() => { }, [data]); const showChangePasscode = (args: IArgs) => { + onShow(args); + setRequestId(current => current + 1); 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,9 +72,9 @@ const ChangePasscodeView = memo(() => { }, []); return ( - + - + {!data?.force ? ( diff --git a/app/views/RoomsListView/components/ServersList.tsx b/app/views/RoomsListView/components/ServersList.tsx index 29616a6ad8c..3c7ebf81c00 100644 --- a/app/views/RoomsListView/components/ServersList.tsx +++ b/app/views/RoomsListView/components/ServersList.tsx @@ -19,7 +19,7 @@ import { removeServer } from '../../../lib/methods/logout'; import EventEmitter from '../../../lib/methods/helpers/events'; import { goRoom } from '../../../lib/methods/helpers/goRoom'; import { showConfirmationAlert } from '../../../lib/methods/helpers/info'; -import { localAuthenticate } from '../../../lib/methods/helpers/localAuthentication'; +import { localAuthenticate, logUnlessUserCanceled } from '../../../lib/methods/helpers/localAuthentication'; import { events, logEvent } from '../../../lib/methods/helpers/log'; import UserPreferences from '../../../lib/methods/userPreferences'; import { useTheme } from '../../../theme'; @@ -88,7 +88,12 @@ const ServersList = () => { EventEmitter.emit('NewServer', { server: serverParam }); }, 300); } else { - await localAuthenticate(serverParam); + try { + await localAuthenticate(serverParam); + } catch (e) { + logUnlessUserCanceled(e); + return; + } dispatch(selectServerRequest(serverParam, version, true, true)); } } diff --git a/app/views/ScreenLockConfigView.tsx b/app/views/ScreenLockConfigView.tsx index dc5f2f92d4c..be999c8b630 100644 --- a/app/views/ScreenLockConfigView.tsx +++ b/app/views/ScreenLockConfigView.tsx @@ -10,15 +10,18 @@ import { changePasscode, checkHasPasscode, supportedBiometryLabel, - handleLocalAuthentication + enableBiometry, + handleLocalAuthentication, + logUnlessUserCanceled } 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 { type IApplicationState, type TServerModel } from '../definitions'; import Switch from '../containers/Switch'; +import { showErrorAlert } from '../lib/methods/helpers/info'; const DEFAULT_BIOMETRY = false; @@ -40,6 +43,7 @@ interface IScreenLockConfigViewState { autoLockTime?: number | null; biometry: boolean; biometryLabel: string | null; + biometryBusy: boolean; } class ScreenLockConfigView extends Component { @@ -57,7 +61,8 @@ 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({ canCloseModal: true }); + } catch (e) { + logUnlessUserCanceled(e); + return; + } } logEvent(events.SLC_CHANGE_PASSCODE); - await changePasscode({ force }); + try { + await changePasscode({ force }); + } catch (e) { + logUnlessUserCanceled(e); + } }; - toggleAutoLock = () => { - logEvent(events.SLC_TOGGLE_AUTOLOCK); + // Takes the Switch's target value; the row onPress passes a non-boolean, so it flips instead. The + // updater guard makes a double fire from one tap a no-op rather than a toggle back. + 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(); @@ -158,12 +191,34 @@ class ScreenLockConfigView extends Component { + if (this.state.biometryBusy) { + return; + } logEvent(events.SLC_TOGGLE_BIOMETRY); this.setState( - ({ biometry }) => ({ biometry: !biometry }), - () => { + ({ biometry }) => ({ biometry: !biometry, biometryBusy: true }), + async () => { const { biometry } = this.state; - userPreferences.setBool(BIOMETRY_ENABLED_KEY, biometry); + if (!biometry) { + // Best-effort teardown that cannot fail; the switch must always be able to go off. + await biometricTrustStore.disableBiometry(); + this.setState({ biometryBusy: false }); + return; + } + // Via enableBiometry so a re-bind carries an explicit consent prompt. + const result = await enableBiometry(); + if (result.kind !== 'success') { + // enableBiometry always forces the persisted flag off on failure, so the correct UI + // state is unconditionally `false`. + this.setState({ biometry: false, biometryBusy: false }); + if (result.kind === 'unavailable') { + showErrorAlert(I18n.t('Local_authentication_biometry_unavailable'), I18n.t('Oops')); + } else if (result.kind !== 'canceled') { + showErrorAlert(I18n.t('Local_authentication_biometry_enable_failed'), I18n.t('Oops')); + } + return; + } + this.setState({ biometryBusy: false }); } ); }; @@ -195,6 +250,7 @@ class ScreenLockConfigView extends Component @@ -208,8 +264,8 @@ class ScreenLockConfigView extends Component { - const { biometry } = this.state; - return ; + const { biometry, biometryBusy } = this.state; + return ; }; renderAutoLockItems = () => { @@ -263,20 +319,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.stories.tsx b/app/views/ScreenLockedView.stories.tsx index 6a029cd702a..7e5c930a87f 100644 --- a/app/views/ScreenLockedView.stories.tsx +++ b/app/views/ScreenLockedView.stories.tsx @@ -17,19 +17,19 @@ export default { interface IStoryWrapperProps { hasBiometry?: boolean; - force?: boolean; + canClose?: boolean; } -const StoryWrapper = ({ hasBiometry = false, force = false }: IStoryWrapperProps) => { +const StoryWrapper = ({ hasBiometry = false, canClose = false }: IStoryWrapperProps) => { useEffect(() => { // Emit the event to show the ScreenLockedView EventEmitter.emit(LOCAL_AUTHENTICATE_EMITTER, { submit: () => {}, cancel: () => {}, hasBiometry, - force + canClose }); - }, [hasBiometry, force]); + }, [hasBiometry, canClose]); return ( @@ -42,6 +42,6 @@ export const Default = () => ; export const WithBiometry = () => ; -export const WithCloseButton = () => ; +export const WithCloseButton = () => ; -export const WithBiometryAndClose = () => ; +export const WithBiometryAndClose = () => ; diff --git a/app/views/ScreenLockedView.tsx b/app/views/ScreenLockedView.tsx index 41fdc67ed39..a87e59caf89 100644 --- a/app/views/ScreenLockedView.tsx +++ b/app/views/ScreenLockedView.tsx @@ -10,13 +10,16 @@ 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 { submit?: () => void; cancel?: () => void; hasBiometry?: boolean; - force?: boolean; + canClose?: 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,9 +48,13 @@ const ScreenLockedView = () => { }, [data]); const showScreenLock = (args: IData) => { + onShow(args); + setRequestId(current => current + 1); setData(args); }; + // Empty deps, so this closes over the first render's showScreenLock: safe only because + // useDeferredModalSettle is entirely ref-backed. Adding state there wedges it on a stale closure. useEffect(() => { const listener = EventEmitter.addEventListener(LOCAL_AUTHENTICATE_EMITTER, showScreenLock); return () => { @@ -54,18 +63,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,10 +79,11 @@ const ScreenLockedView = () => { hideModalContentWhileAnimating style={{ margin: 0 }} animationIn='fadeIn' - animationOut='fadeOut'> + animationOut='fadeOut' + onModalHide={onModalHide}> - - {data?.force ? ( + + {data?.canClose ? ( diff --git a/app/views/SecurityPrivacyView.tsx b/app/views/SecurityPrivacyView.tsx index 590fc29012d..7a36d726ebc 100644 --- a/app/views/SecurityPrivacyView.tsx +++ b/app/views/SecurityPrivacyView.tsx @@ -9,7 +9,7 @@ import { ANALYTICS_EVENTS_KEY, CRASH_REPORT_KEY } from '../lib/constants/keys'; import { useAppSelector } from '../lib/hooks/useAppSelector'; import useServer from '../lib/methods/useServer'; import { type SettingsStackParamList } from '../stacks/types'; -import { handleLocalAuthentication } from '../lib/methods/helpers/localAuthentication'; +import { handleLocalAuthentication, logUnlessUserCanceled } from '../lib/methods/helpers/localAuthentication'; import { events, getReportAnalyticsEventsValue, @@ -59,7 +59,12 @@ const SecurityPrivacyView = ({ navigation }: ISecurityPrivacyViewProps) => { const navigateToScreenLockConfigView = async () => { if (server?.autoLock) { - await handleLocalAuthentication(true); + try { + await handleLocalAuthentication({ canCloseModal: true }); + } catch (e) { + logUnlessUserCanceled(e); + return; + } } navigateToScreen('ScreenLockConfigView'); }; diff --git a/app/views/__snapshots__/ChangePasscodeView.test.tsx.snap b/app/views/__snapshots__/ChangePasscodeView.test.tsx.snap index d66895cb34e..6b9b5972d7f 100644 --- a/app/views/__snapshots__/ChangePasscodeView.test.tsx.snap +++ b/app/views/__snapshots__/ChangePasscodeView.test.tsx.snap @@ -2164,7 +2164,6 @@ exports[`Story Snapshots: Forced should match snapshot 1`] = ` 2.8.0) - 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: @@ -4118,7 +4149,8 @@ SPEC CHECKSUMS: RNFBCrashlytics: c3bb5533f9957eddc88f3ea383583309a4ce9f89 RNFileViewer: f9424017fa643c115c1444e11292e84fb16ddd68 RNGestureHandler: b8d2e75c2e88fc2a1f6be3b3beeeed80b88fa37d - RNImageCropPicker: 5432d75a3bc7209dc8b6ab189fcfe19bd6339fb9 + RNImageCropPicker: 0a63af4b79e514c1edd6c3152f19300c5ed85312 + RNKeychain: 76d042fc1dfba47f3945920bc82e0bce9ad1ff55 RNLocalize: ca86348d88b9a89da0e700af58d428ab3f343c4e RNReanimated: e1690cdd7f215cfb96a3b7986b81889867dfdb4f RNScreens: ccfcc2f7d9c0d458b7fc41b3f4f0bea054602b3a diff --git a/jest.setup.js b/jest.setup.js index c5815af3787..9e3e184fc8f 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -61,10 +61,16 @@ jest.mock('react-native-incall-manager', () => ({ jest.mock('expo-haptics', () => ({ impactAsync: jest.fn(), + notificationAsync: jest.fn(), ImpactFeedbackStyle: { Light: 'light', Medium: 'medium', Heavy: 'heavy' + }, + NotificationFeedbackType: { + Success: 'success', + Warning: 'warning', + Error: 'error' } })); @@ -329,6 +335,22 @@ 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' }, + STORAGE_TYPE: { + AES_CBC: 'KeystoreAESCBC', + AES_GCM_NO_AUTH: 'KeystoreAESGCM_NoAuth', + AES_GCM: 'KeystoreAESGCM', + RSA: 'KeystoreRSAECB' + }, + 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 09c92e61b0e..17ec96c2ee2 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "react-native-incall-manager": "^4.2.1", "react-native-katex": "git+https://github.com/RocketChat/react-native-katex.git", "react-native-keyboard-controller": "1.18.5", + "react-native-keychain": "10.0.0", "react-native-linear-gradient": "2.6.2", "react-native-localize": "2.1.1", "react-native-math-view": "3.9.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93329222281..19933bdd456 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@7.0.2))(@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@7.0.2))(@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@7.0.2))(@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@7.0.2))(@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) @@ -6512,6 +6515,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: @@ -14979,6 +14986,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@7.0.2))(@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@7.0.2))(@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@7.0.2))(@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@7.0.2))(@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