diff --git a/__mocks__/react-native-keychain.ts b/__mocks__/react-native-keychain.ts index bb4e65a..ef82184 100644 --- a/__mocks__/react-native-keychain.ts +++ b/__mocks__/react-native-keychain.ts @@ -18,6 +18,7 @@ interface Credential { const store = new Map(); let nextError: Error | null = null; +let hangNext = false; function serviceOf(options?: Options): string { return options?.service ?? 'default'; @@ -43,6 +44,29 @@ export const ACCESSIBLE = { WHEN_PASSCODE_SET_THIS_DEVICE_ONLY: 'AccessibleWhenPasscodeSetThisDeviceOnly', } as const; +/** String values mirror the library's `BIOMETRY_TYPE` enum (enums.ts). */ +export const BIOMETRY_TYPE = { + TOUCH_ID: 'TouchID', + FACE_ID: 'FaceID', + FINGERPRINT: 'Fingerprint', + FACE: 'Face', + IRIS: 'Iris', +} as const; + +type BiometryType = (typeof BIOMETRY_TYPE)[keyof typeof BIOMETRY_TYPE]; + +let supportedBiometryType: BiometryType | null = null; + +export async function getSupportedBiometryType(): Promise { + takeError(); + return supportedBiometryType; +} + +/** Test helper: set what `getSupportedBiometryType` resolves to (default null). */ +export function __setSupportedBiometryType(type: BiometryType | null): void { + supportedBiometryType = type; +} + export async function setGenericPassword( username: string, password: string, @@ -57,6 +81,11 @@ export async function getGenericPassword( options?: Options, ): Promise<(Credential & { service: string }) | false> { takeError(); + if (hangNext) { + hangNext = false; + // The OS auth sheet is up and the user hasn't answered yet. + return new Promise(() => undefined); + } const credential = store.get(serviceOf(options)); return credential ? { ...credential, service: serviceOf(options) } : false; } @@ -71,13 +100,20 @@ export async function hasGenericPassword(options?: Options): Promise { return store.has(serviceOf(options)); } -/** Test helper: clear all stored credentials and any queued error. */ +/** Test helper: clear all stored credentials, queued error/hang, and biometry type. */ export function __resetKeychain(): void { store.clear(); nextError = null; + hangNext = false; + supportedBiometryType = null; } /** Test helper: make the next keychain op reject with `error`. */ export function __failNextKeychainOp(error: Error): void { nextError = error; } + +/** Test helper: make the next `getGenericPassword` never settle (OS sheet up). */ +export function __hangNextKeychainOp(): void { + hangNext = true; +} diff --git a/src/components/PinKeypad.tsx b/src/components/PinKeypad.tsx index c5b4eec..9e4515f 100644 --- a/src/components/PinKeypad.tsx +++ b/src/components/PinKeypad.tsx @@ -21,11 +21,13 @@ interface PinKeypadProps { readonly onComplete: (pin: string) => void; /** When true, clears the entered digits (e.g. after a mismatch). */ readonly error?: boolean; + /** Ignores every key press and dims the pad (A3 lockout/verifying). */ + readonly disabled?: boolean; } const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', '⌫'] as const; -export function PinKeypad({ length, onComplete, error = false }: PinKeypadProps) { +export function PinKeypad({ length, onComplete, error = false, disabled = false }: PinKeypadProps) { const [digits, setDigits] = useState(''); // Mirrors `digits` synchronously. `press` reads/writes this instead of the // `digits` state closure so a same-tick double-tap (multi-touch) sees the @@ -48,7 +50,9 @@ export function PinKeypad({ length, onComplete, error = false }: PinKeypadProps) }, [error]); function press(key: string) { - if (key === '') { + // Guarded here as well as via the Pressable's `disabled` so a press that + // was already dispatched when the pad flipped to disabled is still ignored. + if (disabled || key === '') { return; } if (key === '⌫') { @@ -70,7 +74,7 @@ export function PinKeypad({ length, onComplete, error = false }: PinKeypadProps) } return ( - + {Array.from({ length }, (_, i) => ( press(key)} className="aspect-square w-[62px] items-center justify-center rounded-full border border-borderDefault" > diff --git a/src/components/__tests__/PinKeypad.test.tsx b/src/components/__tests__/PinKeypad.test.tsx index 9b067c1..0925fed 100644 --- a/src/components/__tests__/PinKeypad.test.tsx +++ b/src/components/__tests__/PinKeypad.test.tsx @@ -137,6 +137,39 @@ describe('PinKeypad', () => { expect(litDots(tree!, 4)).toBe(0); }); + it('disabled ignores digit and backspace presses (A3 lockout/verifying)', () => { + const onComplete = jest.fn(); + let tree: TestRenderer.ReactTestRenderer; + act(() => { + tree = TestRenderer.create(); + }); + + pressKey(tree!, '1'); + pressKey(tree!, '2'); + pressKey(tree!, '⌫'); + + expect(onComplete).not.toHaveBeenCalled(); + expect(litDots(tree!, 2)).toBe(0); + }); + + it('re-enabling after disabled restores input from a clean slate', () => { + const onComplete = jest.fn(); + let tree: TestRenderer.ReactTestRenderer; + act(() => { + tree = TestRenderer.create(); + }); + pressKey(tree!, '1'); // swallowed while disabled + + act(() => { + tree!.update(); + }); + pressKey(tree!, '1'); + pressKey(tree!, '2'); + + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith('12'); + }); + it('the error prop triggers the shared shake (not just a dot reset — Stage-2 A2b retrofit)', () => { const triggerShake = jest.fn(); useShakeMock.mockReturnValue({ diff --git a/src/lib/__tests__/deviceWallet.test.ts b/src/lib/__tests__/deviceWallet.test.ts index bb14bd6..b1ba540 100644 --- a/src/lib/__tests__/deviceWallet.test.ts +++ b/src/lib/__tests__/deviceWallet.test.ts @@ -175,6 +175,46 @@ describe('unlock', () => { await expect(unlock()).rejects.toThrow(/unexpected shape/i); }); + + it('dedupes concurrent calls via single-flight (one keychain retrieve for both callers)', async () => { + const created = await createTestWallet(); + const retrieveSpy = jest.spyOn(Keychain, 'getGenericPassword'); + + // Double-trigger race from the A3 screen: biometric-circle tap while the + // PIN path is already unlocking. One OS prompt, one import — shared result. + const [a, b] = await Promise.all([unlock(), unlock()]); + + expect(a.address).toBe(created.address); + expect(b.address).toBe(created.address); + expect(retrieveSpy).toHaveBeenCalledTimes(1); + }); + + it('performs a fresh retrieve once the previous unlock settles (stateless — no session caching)', async () => { + await createTestWallet(); + const retrieveSpy = jest.spyOn(Keychain, 'getGenericPassword'); + + await unlock(); + await unlock(); + + expect(retrieveSpy).toHaveBeenCalledTimes(2); + }); + + it('does not cache the NoWalletError precondition as an in-flight unlock', async () => { + await expect(unlock()).rejects.toBeInstanceOf(NoWalletError); + + // A wallet committed right after must unlock — a cached rejection here + // would mean the sync precondition leaked into the single-flight slot. + const created = await createTestWallet(); + await expect(unlock()).resolves.toMatchObject({ address: created.address }); + }); + + it('clears the in-flight slot after a failed unlock so a retry can succeed', async () => { + const created = await createTestWallet(); + keychainMock.__failNextKeychainOp(new Error('biometric canceled')); + + await expect(unlock()).rejects.toThrow('biometric canceled'); + await expect(unlock()).resolves.toMatchObject({ address: created.address }); + }); }); describe('reconcile', () => { diff --git a/src/lib/deviceWallet.ts b/src/lib/deviceWallet.ts index 51823e2..6258f24 100644 --- a/src/lib/deviceWallet.ts +++ b/src/lib/deviceWallet.ts @@ -138,21 +138,41 @@ async function commit(mnemonic: string): Promise { }; } +let inFlightUnlock: Promise | null = null; + /** * Unlocks the stored wallet: retrieves the keystore secret (biometric prompt), * reads the blob, and re-imports the wallet. A wrong/rotated secret or a corrupt * blob surfaces as an `FfiError::Wallet` from `importKeystore`, which propagates. * + * Concurrent calls dedupe via single-flight — the A3 screen can race a + * biometric-circle tap against an in-progress PIN-path unlock, and that must + * not stack two OS prompts / imports; every caller wants the same outcome, so + * they share one result. The slot clears when the attempt settles (fresh + * retrieve per unlock — the FFI is stateless, nothing is cached), and the + * synchronous NoWalletError precondition stays OUT of the slot (same pattern + * as `commitWallet`) so its rejection can never be handed to a later caller. + * * @throws {NoWalletError} if no wallet is stored. */ -export async function unlock(): Promise { +export function unlock(): Promise { const record = readWalletRecord(); if (record === null) { - throw new NoWalletError(); + return Promise.reject(new NoWalletError()); + } + if (inFlightUnlock !== null) { + return inFlightUnlock; } + inFlightUnlock = doUnlock(record.blob).finally(() => { + inFlightUnlock = null; + }); + return inFlightUnlock; +} + +async function doUnlock(blob: ArrayBuffer): Promise { const secretHex = await retrieveKeystoreSecret(); const password = asciiToArrayBuffer(secretHex); - const wallet: FfiWalletLike = await FfiWallet.importKeystore(record.blob, password); + const wallet: FfiWalletLike = await FfiWallet.importKeystore(blob, password); return { address: wallet.address(), signMessage: (message: ArrayBuffer): Promise => @@ -183,7 +203,10 @@ export async function wipeWallet(): Promise { * Reconciles a partial-write state on startup so phase never lies: * - blob without secret → clear the blob (unloadable → clean re-onboarding); * - secret without blob → wipe the orphan secret. - * Idempotent; safe to call on every launch before routing. + * Idempotent; safe to call on every launch before routing. Besides cold start + * it is re-run (via `hydrate`) by the A3 key-invalidated recovery restart — + * everything is already wiped by then, so it reduces to a probe that also + * retries a failed secret wipe. */ export async function reconcile(): Promise { const blobPresent = hasWalletRecord(); diff --git a/src/lib/keystoreSecret.ts b/src/lib/keystoreSecret.ts index bf89964..9410209 100644 --- a/src/lib/keystoreSecret.ts +++ b/src/lib/keystoreSecret.ts @@ -114,6 +114,12 @@ function mapKeychainError(error: unknown): KeystoreSecretException { * Persists a keystore secret (64 lowercase-hex) to the Keychain, replacing any * existing entry for this service. The caller (deviceWallet) generates the secret * so it can encrypt the keyring with the same value before storing it here. + * + * May trigger the OS auth prompt too: the library's auth-required key is + * timeout-based (5s validity after the last authentication, + * `CipherStorageKeystoreAesGcm.kt:167-179`), and BOTH encrypt and decrypt + * need authorization outside that window — "prompt only on retrieve" was + * never the real contract (carry-over #20, resolved at the A3 grill). */ export async function saveKeystoreSecret(secretHex: string): Promise { const stored = await Keychain.setGenericPassword(ACCOUNT, secretHex, SET_OPTIONS).catch( diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx index 4a97c02..08bc092 100644 --- a/src/navigation/RootNavigator.tsx +++ b/src/navigation/RootNavigator.tsx @@ -1,6 +1,7 @@ import { assertNever } from '../lib/assertNever'; import { PlaceholderScreen } from '../screens/PlaceholderScreen'; import { SplashScreen } from '../screens/SplashScreen'; +import { UnlockPin } from '../screens/locked/UnlockPin'; import { useWalletStore } from '../stores/walletStore'; import { OnboardingNavigator } from './OnboardingNavigator'; @@ -13,7 +14,6 @@ import { OnboardingNavigator } from './OnboardingNavigator'; */ export function RootNavigator() { const phase = useWalletStore((state) => state.phase); - const address = useWalletStore((state) => state.address); switch (phase) { case 'loading': @@ -21,13 +21,7 @@ export function RootNavigator() { case 'no_wallet': return ; case 'locked': - return ( - - ); + return ; case 'unlocked': return ; default: diff --git a/src/navigation/__tests__/RootNavigator.test.tsx b/src/navigation/__tests__/RootNavigator.test.tsx index ca9c074..3517f5c 100644 --- a/src/navigation/__tests__/RootNavigator.test.tsx +++ b/src/navigation/__tests__/RootNavigator.test.tsx @@ -39,10 +39,12 @@ describe('RootNavigator', () => { expect(renderAt('no_wallet')).toContain('RUST'); }); - it('renders the locked destination carrying the stored address', () => { + it('renders the UnlockPin screen for the locked phase (A3 — no placeholder, no address)', () => { const view = renderAt('locked', '0xABCDEF'); - expect(view).toContain('Wallet locked'); - expect(view).toContain('0xABCDEF'); + expect(view).toContain('LOCKED'); + expect(view).toContain('Enter your PIN'); + // The flipbook frame shows no address on the lock screen. + expect(view).not.toContain('0xABCDEF'); }); it('renders the home destination when unlocked', () => { diff --git a/src/screens/locked/UnlockPin.tsx b/src/screens/locked/UnlockPin.tsx new file mode 100644 index 0000000..984e63b --- /dev/null +++ b/src/screens/locked/UnlockPin.tsx @@ -0,0 +1,344 @@ +/** + * UnlockPin — the `locked`-phase unlock screen (Stage-2 A3), built from the + * flipbook's "Вход — Locked" frame (`design-handoff/prototypes/Rustok + * Flipbook.dc.html:228-261`; strings are English per the Captain's 2026-07-06 + * ratification — the flipbook is the visual source only). + * + * Flow: 6th digit → `pinAttemptsStore.verify` (Argon2id + lockout ladder, all + * inside pinAuth) → `ok` → `walletStore.unlock()` (Keychain retrieve behind + * the OS biometric/device-passcode sheet → `import_keystore`). On success the + * store flips to `unlocked` and RootNavigator swaps this screen out. + * + * The ladder is CONSUMED here, never driven: a `wrong` result has already + * been counted and any lock persisted by pinAuth — this screen only mirrors + * `lockedUntil` into a 1s countdown and disables input. Computing the + * remainder per tick means an already-expired persisted lock enables the pad + * immediately on a cold start (/check F3). + * + * `NoPinError` (PIN record died while the wallet lives, e.g. a corrupted + * `rustok.pin` MMKV instance) recovers through the biometric circle when the + * device has one — `unlock()` does not depend on the PIN (/check F2). WITHOUT + * an enrolled biometric this build has no unlock trigger left and the copy + * says so honestly; a PIN-reset / device-credential entry path is a + * pre-go-live carry-over (PLAN-OF-RECORD #23) — acceptable pre-release only. + * + * No BackHandler: this is the root screen of the `locked` phase — the system + * back gesture backgrounds the app (Android default), same as the reference. + */ + +import { useEffect, useRef, useState } from 'react'; +import { ActivityIndicator, Alert, Pressable, Text, View } from 'react-native'; +import * as Keychain from 'react-native-keychain'; + +import { PinKeypad } from '../../components/PinKeypad'; +import { assertNever } from '../../lib/assertNever'; +import { wipeWallet } from '../../lib/deviceWallet'; +import { KeystoreSecretException } from '../../lib/keystoreSecret'; +import type { PinVerifyResult } from '../../lib/pinAuth'; +import { NoPinError } from '../../lib/pinAuth'; +import { usePinAttemptsStore } from '../../stores/pinAttemptsStore'; +import { useWalletStore } from '../../stores/walletStore'; +import { themes } from '../../theme/theme'; + +const PIN_LENGTH = 6; +/** Mismatch flash window — same choreography length as the A2b shake. */ +const ERROR_FLASH_MS = 300; +/** Below this a verify feels instant; beyond it the overlay shows progress. */ +const SPINNER_THRESHOLD_MS = 200; +// ActivityIndicator takes a color prop, not a className (SplashScreen pattern). +const SPINNER_COLOR = themes.dark.accent; + +/** + * `KeyPermanentlyInvalidated` discrimination — the call-site substring match + * the `keystoreSecret` wrapper docstring mandates (it deliberately does not + * parse native messages itself). Under our timeout-based key with the + * device-credential fallback this fires when the device screen lock is + * removed/reset — the Keychain key is then cryptographically dead and the + * blob is unrecoverable; the only way forward is the written recovery phrase. + */ +function isKeyInvalidatedError(error: unknown): boolean { + return ( + error instanceof KeystoreSecretException && + error.kind === 'crypto_failed' && + typeof error.nativeMessage === 'string' && + error.nativeMessage.includes('Key permanently invalidated') + ); +} + +export function UnlockPin() { + const unlock = useWalletStore((state) => state.unlock); + const verify = usePinAttemptsStore((state) => state.verify); + const refresh = usePinAttemptsStore((state) => state.refresh); + const lockedUntil = usePinAttemptsStore((state) => state.lockedUntil); + + const [busy, setBusy] = useState(false); + const [showSpinner, setShowSpinner] = useState(false); + const [error, setError] = useState(false); + const [message, setMessage] = useState(null); + const [lockRemainingMs, setLockRemainingMs] = useState(0); + const [keyInvalidated, setKeyInvalidated] = useState(false); + const [biometryType, setBiometryType] = useState(null); + const timersRef = useRef[]>([]); + + // Cold start over a persisted lock: mirror storage before routing input. + useEffect(() => { + refresh(); + }, [refresh]); + + // The biometric circle shows only when the device has an enrolled strong + // biometric; a probe failure just means no shortcut (PIN path is complete). + useEffect(() => { + let cancelled = false; + Keychain.getSupportedBiometryType() + .then((type) => { + if (!cancelled) { + setBiometryType(type); + } + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, []); + + // Countdown driven off the store's deadline, recomputed per tick. The + // interval retires itself at expiry (no idle no-op setState churn) and is + // never started at all over an already-expired persisted lock. + useEffect(() => { + if (lockedUntil === null) { + setLockRemainingMs(0); + return undefined; + } + const initialRemaining = lockedUntil - Date.now(); + setLockRemainingMs(initialRemaining > 0 ? initialRemaining : 0); + if (initialRemaining <= 0) { + return undefined; + } + const interval = setInterval(() => { + const remaining = lockedUntil - Date.now(); + if (remaining > 0) { + setLockRemainingMs(remaining); + return; + } + setLockRemainingMs(0); + clearInterval(interval); + }, 1000); + return () => clearInterval(interval); + }, [lockedUntil]); + + // Flash/spinner timers die with the screen (the array ref is shared, so + // timers pushed after mount are still visible to this cleanup). + useEffect(() => { + const timers = timersRef.current; + return () => timers.forEach(clearTimeout); + }, []); + + const locked = lockRemainingMs > 0; + + function flashError() { + // The toggle back to `false` is load-bearing: PinKeypad's effect fires on + // the error VALUE, so a second wrong attempt must see a fresh false→true + // edge to shake again (/check F5). + setError(true); + const timer = setTimeout(() => setError(false), ERROR_FLASH_MS); + timersRef.current.push(timer); + } + + /** Busy-guards an unlock attempt and drives the spinner threshold around it. */ + function runBusy(attempt: () => Promise) { + if (busy || locked) { + return; + } + setBusy(true); + setMessage(null); + const spinnerTimer = setTimeout(() => setShowSpinner(true), SPINNER_THRESHOLD_MS); + timersRef.current.push(spinnerTimer); + attempt() + .finally(() => { + clearTimeout(spinnerTimer); + setShowSpinner(false); + setBusy(false); + }) + // Attempts handle their own failures; this catch only satisfies + // no-floating-promises for a rejection that cannot happen. + .catch(() => undefined); + } + + function handleComplete(pin: string) { + runBusy(() => attemptPinUnlock(pin)); + } + + function handleBiometricPress() { + // Skips the app PIN gate on purpose: the OS sheet behind the Keychain + // retrieve IS the trust boundary (spec М1); the PIN adds nothing here. + runBusy(runUnlock); + } + + async function attemptPinUnlock(pin: string): Promise { + let result: PinVerifyResult; + try { + result = await verify(pin); + } catch (err: unknown) { + if (err instanceof NoPinError) { + // Honest copy per hardware: without an enrolled biometric there is no + // unlock trigger left in this build (carry-over #23). + setMessage( + biometryType !== null + ? 'PIN unavailable — use the biometric circle' + : 'PIN unavailable — this version cannot restore access without biometrics yet', + ); + return; + } + setMessage('Could not verify PIN — try again'); + return; + } + // Exhaustive on purpose: a new verify status must never fall through into + // the unlock (auth) path by default. + switch (result.status) { + case 'wrong': + flashError(); + return; + case 'locked': + return; // the store mirror flips `lockedUntil`; the countdown takes over + case 'ok': + await runUnlock(); + return; + default: + assertNever(result); + } + } + + async function runUnlock(): Promise { + try { + await unlock(); + // Success: walletStore is `unlocked`; RootNavigator swaps this screen out. + } catch (err: unknown) { + if (isKeyInvalidatedError(err)) { + setKeyInvalidated(true); + return; + } + // Includes an OS-sheet cancel — on the PIN path the PIN was correct and + // the ladder is already cleared, so this is a soft retry, never a punishment. + setMessage('Could not unlock — try again'); + } + } + + function handleRecovery() { + Alert.alert( + 'Restore wallet', + 'This permanently deletes the wallet from this device. The only way to bring it back is your written 12-word recovery phrase. Continue?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete & start over', + style: 'destructive', + onPress: () => { + wipeAndRestart().catch(() => undefined); + }, + }, + ], + ); + } + + async function wipeAndRestart(): Promise { + try { + await wipeWallet(); + } finally { + // Route by storage truth — the same router as a cold start. Safe here: + // the locked phase has no onboarding in flight, and the post-wipe + // reconcile probe is an idempotent no-op (blob/secret/PIN already gone; + // the blob clear is synchronous, so even a failed secret wipe still + // routes to no_wallet and the orphan is cleaned on the next cold start). + await useWalletStore.getState().hydrate(); + } + } + + if (keyInvalidated) { + return ( + + + + Your device security has changed + + + The key protecting this wallet is no longer accessible. This can happen when the + device screen lock is removed or reset. + + + Restore access with your written 12-word recovery phrase. + + + + + Restore with recovery phrase + + + + ); + } + + const lockSeconds = Math.ceil(lockRemainingMs / 1000); + let statusTestID = 'unlock-caption'; + let statusClass = 'font-ui text-sm text-textSecondary'; + let statusText = biometryType !== null ? 'Touch to unlock' : 'Enter your PIN'; + if (locked) { + statusTestID = 'unlock-lockout'; + statusClass = 'font-ui text-sm text-riskWarnFg'; + statusText = `Too many attempts — wait ${lockSeconds}s`; + } else if (message !== null) { + statusTestID = 'unlock-message'; + statusClass = 'font-ui text-sm text-riskWarnFg'; + statusText = message; + } + + return ( + + + RUSTOK LOCKED + + {biometryType !== null && ( + + + + )} + + {statusText} + + + {showSpinner && ( + + + + )} + + ); +} diff --git a/src/screens/locked/__tests__/UnlockPin.test.tsx b/src/screens/locked/__tests__/UnlockPin.test.tsx new file mode 100644 index 0000000..f9733bc --- /dev/null +++ b/src/screens/locked/__tests__/UnlockPin.test.tsx @@ -0,0 +1,333 @@ +/** + * UnlockPin screen tests — the PIN path over REAL stores (walletStore, + * pinAttemptsStore, pinAuth, deviceWallet); only the native boundaries are + * mocked (keychain, argon2, MMKV, FFI bridge — via `__mocks__`). Fake timers + * pin `Date.now` so ladder deadlines and the countdown are exact. + */ + +import TestRenderer, { act } from 'react-test-renderer'; +import { Alert, Platform } from 'react-native'; +import * as Keychain from 'react-native-keychain'; +import * as MMKV from 'react-native-mmkv'; + +import { PinKeypad } from '../../../components/PinKeypad'; +import { commitWallet, generateMnemonic, hasWallet } from '../../../lib/deviceWallet'; +import { setPin } from '../../../lib/pinAuth'; +import { setConsecutiveFailures, setLockedUntil } from '../../../lib/pinStorage'; +import { usePinAttemptsStore } from '../../../stores/pinAttemptsStore'; +import { useWalletStore } from '../../../stores/walletStore'; +import { UnlockPin } from '../UnlockPin'; + +const keychainMock = Keychain as unknown as { + __resetKeychain: () => void; + __failNextKeychainOp: (error: Error) => void; + __hangNextKeychainOp: () => void; + __setSupportedBiometryType: (type: string | null) => void; +}; +const bridgeMock = jest.requireMock('react-native-rustok-bridge') as { __resetBridge: () => void }; +const mmkvMock = MMKV as unknown as { __resetMMKV: () => void }; + +const START = 1_750_000_000_000; +const PIN = '123456'; +const WRONG_PIN = '000000'; +const KEYSTORE_SERVICE = 'com.rustokwallet.keystore'; + +beforeEach(() => { + keychainMock.__resetKeychain(); + bridgeMock.__resetBridge(); + mmkvMock.__resetMMKV(); + useWalletStore.setState({ phase: 'loading', address: null, session: null }); + usePinAttemptsStore.setState({ consecutiveFailures: 0, lockedUntil: null }); + jest.useFakeTimers({ now: START }); +}); + +afterEach(() => { + // Unmount before the next test's store resets so no stale tree re-renders + // outside act() on the beforeEach setState. + act(() => { + mountedTrees.forEach((tree) => tree.unmount()); + }); + mountedTrees.length = 0; + jest.useRealTimers(); + jest.restoreAllMocks(); // Alert spies + Platform.OS replaceProperty +}); + +const mountedTrees: TestRenderer.ReactTestRenderer[] = []; + +/** Commit a wallet + PIN and land the store in `locked`, as a cold start would. */ +async function setupLockedWallet(): Promise { + const created = await commitWallet(generateMnemonic().mnemonic); + await setPin(PIN); + useWalletStore.setState({ phase: 'locked', address: created.address, session: null }); + return created.address; +} + +/** Mounts the screen and flushes the async biometry probe effect. */ +async function render(): Promise { + let tree: TestRenderer.ReactTestRenderer; + await act(async () => { + tree = TestRenderer.create(); + }); + mountedTrees.push(tree!); + return tree!; +} + +/** Types a full PIN and flushes the async verify/unlock chain. */ +async function enterPin(tree: TestRenderer.ReactTestRenderer, pin: string): Promise { + await act(async () => { + for (const digit of pin) { + tree.root.findByProps({ testID: `pin-key-${digit}` }).props.onPress(); + } + }); +} + +function keypadProps(tree: TestRenderer.ReactTestRenderer) { + return tree.root.findByType(PinKeypad).props; +} + +function statusText(tree: TestRenderer.ReactTestRenderer, testID: string): string { + const { children } = tree.root.findByProps({ testID }).props as { children: unknown }; + return Array.isArray(children) ? children.join('') : String(children); +} + +describe('UnlockPin', () => { + it('renders the caption and an enabled keypad over a locked wallet', async () => { + await setupLockedWallet(); + + const tree = await render(); + + expect(tree.root.findByProps({ testID: 'unlock-caption' })).toBeDefined(); + expect(keypadProps(tree).disabled).toBe(false); + }); + + it('unlocks with the correct PIN — phase flips and the session goes live', async () => { + const address = await setupLockedWallet(); + const tree = await render(); + + await enterPin(tree, PIN); + + const state = useWalletStore.getState(); + expect(state.phase).toBe('unlocked'); + expect(state.address).toBe(address); + expect(state.session).not.toBeNull(); + }); + + it('flashes the keypad error on a wrong PIN, and again on the next wrong PIN (fresh edge each time)', async () => { + await setupLockedWallet(); + const tree = await render(); + + await enterPin(tree, WRONG_PIN); + expect(keypadProps(tree).error).toBe(true); + expect(usePinAttemptsStore.getState().consecutiveFailures).toBe(1); + + act(() => { + jest.advanceTimersByTime(300); // flash window closes → false edge + }); + expect(keypadProps(tree).error).toBe(false); + + await enterPin(tree, WRONG_PIN); + expect(keypadProps(tree).error).toBe(true); // second shake needs this edge + expect(useWalletStore.getState().phase).toBe('locked'); + }); + + it('the third wrong PIN locks the pad with a ticking countdown, then re-enables on expiry', async () => { + await setupLockedWallet(); + const tree = await render(); + + await enterPin(tree, WRONG_PIN); + await enterPin(tree, WRONG_PIN); + await enterPin(tree, WRONG_PIN); + + expect(statusText(tree, 'unlock-lockout')).toBe('Too many attempts — wait 30s'); + expect(keypadProps(tree).disabled).toBe(true); + + await act(async () => { + jest.advanceTimersByTime(10_000); + }); + expect(statusText(tree, 'unlock-lockout')).toBe('Too many attempts — wait 20s'); + + await act(async () => { + jest.advanceTimersByTime(21_000); + }); + expect(tree.root.findAllByProps({ testID: 'unlock-lockout' })).toHaveLength(0); + expect(keypadProps(tree).disabled).toBe(false); + }); + + it('an already-expired persisted lock enables the pad immediately on cold start (/check F3)', async () => { + await setupLockedWallet(); + setConsecutiveFailures(3); + setLockedUntil(START - 1_000); // lock expired while the app was dead + + const tree = await render(); + + expect(keypadProps(tree).disabled).toBe(false); + expect(tree.root.findAllByProps({ testID: 'unlock-lockout' })).toHaveLength(0); + }); + + it('recovers from a dead PIN record through the biometric circle — pointed to AND proven (/check F2, Gate-2 M4)', async () => { + keychainMock.__setSupportedBiometryType('Fingerprint'); + const created = await commitWallet(generateMnemonic().mnemonic); + // No setPin: the record died while the wallet lives (corrupted rustok.pin). + useWalletStore.setState({ phase: 'locked', address: created.address, session: null }); + const tree = await render(); + + await enterPin(tree, PIN); + expect(statusText(tree, 'unlock-message')).toBe('PIN unavailable — use the biometric circle'); + expect(useWalletStore.getState().phase).toBe('locked'); + + // The promised recovery, actually exercised: + await act(async () => { + tree.root.findByProps({ testID: 'unlock-biometric' }).props.onPress(); + }); + expect(useWalletStore.getState().phase).toBe('unlocked'); + }); + + it('states the limitation honestly when the PIN record is gone and no biometrics exist (Gate-2 M2)', async () => { + const created = await commitWallet(generateMnemonic().mnemonic); + useWalletStore.setState({ phase: 'locked', address: created.address, session: null }); + const tree = await render(); // biometry stays null → no circle, no unlock trigger + + await enterPin(tree, PIN); + + expect(statusText(tree, 'unlock-message')).toBe( + 'PIN unavailable — this version cannot restore access without biometrics yet', + ); + expect(tree.root.findAllByProps({ testID: 'unlock-biometric' })).toHaveLength(0); + expect(useWalletStore.getState().phase).toBe('locked'); + }); + + it('a non-KPI import failure (wrong secret) is a soft retry, never the destructive recovery banner (Gate-2 M3)', async () => { + await setupLockedWallet(); + const tree = await render(); + // Well-formed but wrong secret: passes the shape guard, fails decryption + // inside importKeystore with a plain FfiWallet Error — NOT a + // KeystoreSecretException, so the KPI predicate must not fire. + await Keychain.setGenericPassword('rustok-keystore', 'f'.repeat(64), { + service: KEYSTORE_SERVICE, + }); + + await enterPin(tree, PIN); + + expect(tree.root.findAllByProps({ testID: 'unlock-recovery-banner' })).toHaveLength(0); + expect(statusText(tree, 'unlock-message')).toBe('Could not unlock — try again'); + expect(hasWallet()).toBe(true); + expect(useWalletStore.getState().phase).toBe('locked'); + }); + + it('a failed Keychain retrieve after a correct PIN is a soft retry, not a ladder punishment', async () => { + await setupLockedWallet(); + const tree = await render(); + keychainMock.__failNextKeychainOp(new Error('user canceled the OS sheet')); + + await enterPin(tree, PIN); + + expect(statusText(tree, 'unlock-message')).toBe('Could not unlock — try again'); + expect(useWalletStore.getState().phase).toBe('locked'); + expect(usePinAttemptsStore.getState().consecutiveFailures).toBe(0); + expect(keypadProps(tree).disabled).toBe(false); + }); + + it('hides the biometric circle when the device has no enrolled biometrics', async () => { + await setupLockedWallet(); + + const tree = await render(); + + expect(tree.root.findAllByProps({ testID: 'unlock-biometric' })).toHaveLength(0); + expect(statusText(tree, 'unlock-caption')).toBe('Enter your PIN'); + }); + + it('unlocks via the biometric circle without any PIN entry', async () => { + keychainMock.__setSupportedBiometryType('Fingerprint'); + const address = await setupLockedWallet(); + const tree = await render(); + expect(statusText(tree, 'unlock-caption')).toBe('Touch to unlock'); + + await act(async () => { + tree.root.findByProps({ testID: 'unlock-biometric' }).props.onPress(); + }); + + const state = useWalletStore.getState(); + expect(state.phase).toBe('unlocked'); + expect(state.address).toBe(address); + }); + + it('disables the biometric circle during a lockout (locked means locked)', async () => { + keychainMock.__setSupportedBiometryType('Fingerprint'); + await setupLockedWallet(); + const tree = await render(); + + await enterPin(tree, WRONG_PIN); + await enterPin(tree, WRONG_PIN); + await enterPin(tree, WRONG_PIN); + + expect(tree.root.findByProps({ testID: 'unlock-biometric' }).props.disabled).toBe(true); + }); + + it('a Key-permanently-invalidated retrieve routes to the recovery banner, not a soft retry', async () => { + jest.replaceProperty(Platform, 'OS', 'android'); + await setupLockedWallet(); + const tree = await render(); + keychainMock.__failNextKeychainOp( + Object.assign(new Error('Key permanently invalidated by biometric enrollment change'), { + code: 'E_CRYPTO_FAILED', + }), + ); + + await enterPin(tree, PIN); + + expect(tree.root.findByProps({ testID: 'unlock-recovery-banner' })).toBeDefined(); + expect(tree.root.findAllByProps({ testID: 'pin-keypad' })).toHaveLength(0); + expect(hasWallet()).toBe(true); // never auto-wiped — the user opts in below + }); + + it('the recovery CTA confirms with the exact irreversibility copy, then wipes and routes to onboarding', async () => { + jest.replaceProperty(Platform, 'OS', 'android'); + const alertSpy = jest.spyOn(Alert, 'alert'); + await setupLockedWallet(); + const tree = await render(); + keychainMock.__failNextKeychainOp( + Object.assign(new Error('Key permanently invalidated'), { code: 'E_CRYPTO_FAILED' }), + ); + await enterPin(tree, PIN); + + await act(async () => { + tree.root.findByProps({ testID: 'unlock-recovery-cta' }).props.onPress(); + }); + + // Gate-1 MINOR-1: the confirmation copy is an acceptance criterion, not an + // implementation choice — asserted verbatim. + expect(alertSpy).toHaveBeenCalledWith( + 'Restore wallet', + 'This permanently deletes the wallet from this device. The only way to bring it back is your written 12-word recovery phrase. Continue?', + expect.arrayContaining([ + expect.objectContaining({ text: 'Cancel', style: 'cancel' }), + expect.objectContaining({ text: 'Delete & start over', style: 'destructive' }), + ]), + ); + + const buttons = alertSpy.mock.calls[0]?.[2] as Array<{ text: string; onPress?: () => void }>; + const destructive = buttons.find((b) => b.text === 'Delete & start over'); + await act(async () => { + destructive?.onPress?.(); + }); + + expect(hasWallet()).toBe(false); + expect(useWalletStore.getState().phase).toBe('no_wallet'); + }); + + it('shows the spinner overlay and disables the pad while the OS sheet is up past 200ms', async () => { + await setupLockedWallet(); + const tree = await render(); + keychainMock.__hangNextKeychainOp(); + + await enterPin(tree, PIN); + expect(tree.root.findAllByProps({ testID: 'unlock-spinner' })).toHaveLength(0); + + await act(async () => { + jest.advanceTimersByTime(200); + }); + + expect(tree.root.findAllByProps({ testID: 'unlock-spinner' }).length).toBeGreaterThan(0); + expect(keypadProps(tree).disabled).toBe(true); + }); +}); diff --git a/src/stores/__tests__/walletStore.test.ts b/src/stores/__tests__/walletStore.test.ts index f3b72d3..b1b563b 100644 --- a/src/stores/__tests__/walletStore.test.ts +++ b/src/stores/__tests__/walletStore.test.ts @@ -81,4 +81,60 @@ describe('useWalletStore', () => { expect(useWalletStore.getState().session).toBe(session); }); }); + + describe('unlock', () => { + /** Commit a wallet, then land the store in `locked` as a cold start would. */ + async function lockedStoreOverWallet(): Promise { + const created = await commitWallet(generateMnemonic().mnemonic); + resetStore(); + await useWalletStore.getState().hydrate(); + expect(useWalletStore.getState().phase).toBe('locked'); + return created.address; + } + + it('retrieves the secret, re-imports the wallet, and publishes the live session', async () => { + const address = await lockedStoreOverWallet(); + + await useWalletStore.getState().unlock(); + + const state = useWalletStore.getState(); + expect(state.phase).toBe('unlocked'); + expect(state.address).toBe(address); + expect(state.session).not.toBeNull(); + const signature = await state.session?.signMessage(new ArrayBuffer(4)); + expect(signature?.byteLength).toBe(65); + }); + + it('stays locked and propagates the error when the Keychain retrieve fails', async () => { + await lockedStoreOverWallet(); + keychainMock.__failNextKeychainOp(new Error('biometric canceled')); + + await expect(useWalletStore.getState().unlock()).rejects.toThrow('biometric canceled'); + + const state = useWalletStore.getState(); + expect(state.phase).toBe('locked'); + expect(state.session).toBeNull(); + }); + + it('is a no-op outside the locked phase (never prompts without a wallet to unlock)', async () => { + await useWalletStore.getState().hydrate(); // no wallet → no_wallet + const retrieveSpy = jest.spyOn(Keychain, 'getGenericPassword'); + + await expect(useWalletStore.getState().unlock()).resolves.toBeUndefined(); + + expect(retrieveSpy).not.toHaveBeenCalled(); + expect(useWalletStore.getState().phase).toBe('no_wallet'); + }); + + it('is a no-op when already unlocked (a late second trigger cannot re-prompt)', async () => { + await lockedStoreOverWallet(); + const retrieveSpy = jest.spyOn(Keychain, 'getGenericPassword'); + + await useWalletStore.getState().unlock(); + await useWalletStore.getState().unlock(); + + expect(retrieveSpy).toHaveBeenCalledTimes(1); + expect(useWalletStore.getState().phase).toBe('unlocked'); + }); + }); }); diff --git a/src/stores/walletStore.ts b/src/stores/walletStore.ts index daea209..c98678c 100644 --- a/src/stores/walletStore.ts +++ b/src/stores/walletStore.ts @@ -21,7 +21,12 @@ import { create } from 'zustand'; import type { UnlockedSession } from '../lib/deviceWallet'; -import { getStoredAddress, hasWallet, reconcile } from '../lib/deviceWallet'; +import { + getStoredAddress, + hasWallet, + reconcile, + unlock as unlockDevice, +} from '../lib/deviceWallet'; export type WalletPhase = 'loading' | 'no_wallet' | 'locked' | 'unlocked'; @@ -33,18 +38,30 @@ interface WalletState { readonly session: UnlockedSession | null; /** * Reads storage truth and routes: reconciles any partial write, then sets the - * phase from blob presence. Called once when the shell mounts. Idempotent. + * phase from blob presence. Called once when the shell mounts, and re-run by + * the A3 key-invalidated recovery after its wipe (post-wipe there is no + * onboarding in flight, so the reconcile inside stays cold-start-equivalent). + * Idempotent. */ hydrate: () => Promise; /** * Moves straight to `unlocked` with an already-live session — the producer - * (onboarding's post-quiz commit, or later the real-unlock flow) already did + * (onboarding's post-quiz commit, or the real-unlock flow) already did * the work of proving possession; this just publishes the result. */ setUnlocked: (session: UnlockedSession) => void; + /** + * The real unlock for the `locked` phase (Stage-2 A3): retrieves the + * Keychain secret (OS biometric/passcode prompt) and re-imports the wallet, + * then publishes the session via `setUnlocked`. A no-op outside `locked` — + * a late second trigger must never re-prompt. Failures propagate to the + * caller (the screen owns the error UI) and the phase stays `locked`; it + * never calls `reconcile` (cold-start-only contract, A1c). + */ + unlock: () => Promise; } -export const useWalletStore = create((set) => ({ +export const useWalletStore = create((set, get) => ({ phase: 'loading', address: null, session: null, @@ -68,4 +85,11 @@ export const useWalletStore = create((set) => ({ setUnlocked: (session: UnlockedSession) => { set({ phase: 'unlocked', address: session.address, session }); }, + unlock: async () => { + if (get().phase !== 'locked') { + return; + } + const session = await unlockDevice(); + get().setUnlocked(session); + }, }));