diff --git a/app/_layout.tsx b/app/_layout.tsx index da9bc50..32ee2a9 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -71,6 +71,7 @@ function useSentryUserContext() { function RootLayout() { const [i18nReady, setI18nReady] = useState(false); + const [securityHydrated, setSecurityHydrated] = useState(false); const hydrate = useAuthStore((s) => s.hydrate); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isLoading = useAuthStore((s) => s.isLoading); @@ -99,6 +100,10 @@ function RootLayout() { useEffect(() => { void hydrate(); void useUserStore.getState().hydrate(); + useSecurityStore + .getState() + .hydrate() + .finally(() => setSecurityHydrated(true)); initPromise.then(() => setI18nReady(true)); }, [hydrate]); @@ -106,6 +111,8 @@ function RootLayout() { useSentryUserContext(); useEffect(() => { + if (!securityHydrated) return; + if (!isLoading && isAuthenticated && !biometricCheckDone) { useSecurityStore.getState().markBiometricCheckDone(); biometricService.isBiometricsEnabled().then((enabled) => { @@ -118,7 +125,7 @@ function RootLayout() { if (!isAuthenticated) { useSecurityStore.getState().reset(); } - }, [isLoading, isAuthenticated, biometricCheckDone]); + }, [isLoading, isAuthenticated, biometricCheckDone, securityHydrated]); useEffect(() => { const subscription = AppState.addEventListener('change', (state) => { @@ -172,7 +179,7 @@ function RootLayout() { return () => unsubscribe(); }, []); - if (isLoading || !i18nReady) { + if (isLoading || !i18nReady || !securityHydrated) { return null; } diff --git a/src/components/BiometricGate.tsx b/src/components/BiometricGate.tsx index 221d36c..ee26058 100644 --- a/src/components/BiometricGate.tsx +++ b/src/components/BiometricGate.tsx @@ -4,12 +4,11 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { colors } from '../../constants/colors'; import { biometricService } from '../security/biometric.service'; import { useSecurityStore } from '../security/security.store'; +import { evaluateFailedAttempt } from '../security/lockout'; import { useAuthStore } from '../../stores/auth.store'; import { useUserStore } from '../../stores/user.store'; import { useWalletStore } from '../../stores/wallet.store'; -const MAX_FAILED_ATTEMPTS = 3; - type GateMode = 'loading' | 'biometric' | 'pin' | 'error'; export function BiometricGate() { @@ -31,14 +30,14 @@ export function BiometricGate() { const handleFailure = useCallback(async () => { const store = useSecurityStore.getState(); - const newCount = store.failedAttempts + 1; + const outcome = evaluateFailedAttempt(store.failedAttempts); store.incrementFailedAttempts(); - if (newCount >= MAX_FAILED_ATTEMPTS) { + if (outcome.lockedOut) { await handleLogout(); } else { - const remaining = MAX_FAILED_ATTEMPTS - newCount; + const { remainingAttempts } = outcome; setErrorMessage( - `Verification failed. ${remaining} attempt${remaining > 1 ? 's' : ''} remaining.` + `Verification failed. ${remainingAttempts} attempt${remainingAttempts > 1 ? 's' : ''} remaining.` ); } }, [handleLogout]); diff --git a/src/security/lockout.test.ts b/src/security/lockout.test.ts new file mode 100644 index 0000000..b37b777 --- /dev/null +++ b/src/security/lockout.test.ts @@ -0,0 +1,60 @@ +import { MAX_FAILED_ATTEMPTS, evaluateFailedAttempt } from './lockout'; + +declare const describe: any; +declare const it: any; +declare const expect: any; + +describe('lockout', () => { + it('exposes a three-attempt threshold', () => { + expect(MAX_FAILED_ATTEMPTS).toBe(3); + }); + + describe('evaluateFailedAttempt', () => { + it('counts up and reports remaining attempts before the threshold', () => { + expect(evaluateFailedAttempt(0)).toEqual({ + failedAttempts: 1, + lockedOut: false, + remainingAttempts: 2, + }); + expect(evaluateFailedAttempt(1)).toEqual({ + failedAttempts: 2, + lockedOut: false, + remainingAttempts: 1, + }); + }); + + it('locks out once the threshold is reached', () => { + expect(evaluateFailedAttempt(2)).toEqual({ + failedAttempts: 3, + lockedOut: true, + remainingAttempts: 0, + }); + }); + + it('stays locked out past the threshold without negative remaining', () => { + expect(evaluateFailedAttempt(5)).toEqual({ + failedAttempts: 6, + lockedOut: true, + remainingAttempts: 0, + }); + }); + + it('treats negative, fractional, or non-finite counts as zero', () => { + expect(evaluateFailedAttempt(-4)).toEqual({ + failedAttempts: 1, + lockedOut: false, + remainingAttempts: 2, + }); + expect(evaluateFailedAttempt(1.9)).toEqual({ + failedAttempts: 2, + lockedOut: false, + remainingAttempts: 1, + }); + expect(evaluateFailedAttempt(Number.NaN)).toEqual({ + failedAttempts: 1, + lockedOut: false, + remainingAttempts: 2, + }); + }); + }); +}); diff --git a/src/security/lockout.ts b/src/security/lockout.ts new file mode 100644 index 0000000..c2db81b --- /dev/null +++ b/src/security/lockout.ts @@ -0,0 +1,34 @@ +/** + * Lockout policy for the biometric/PIN gate. + * + * Extracted as a pure, dependency-free module so the attempt-counting logic can + * be unit-tested and shared, rather than living inline in the UI. The count it + * evaluates is persisted by the security store, so the lockout survives an app + * relaunch (a user cannot reset their remaining attempts by killing the app). + */ + +export const MAX_FAILED_ATTEMPTS = 3; + +export interface AttemptOutcome { + /** The failed-attempt count after applying this attempt. */ + failedAttempts: number; + /** True once the threshold is reached; the caller should force sign-out. */ + lockedOut: boolean; + /** Attempts remaining before lockout (never negative). */ + remainingAttempts: number; +} + +/** + * Evaluate a single failed unlock attempt against the current count. Negative or + * malformed input is treated as zero so a corrupted persisted value can never + * grant extra attempts. + */ +export const evaluateFailedAttempt = (currentFailedAttempts: number): AttemptOutcome => { + const safeCurrent = Number.isFinite(currentFailedAttempts) + ? Math.max(0, Math.floor(currentFailedAttempts)) + : 0; + const failedAttempts = safeCurrent + 1; + const lockedOut = failedAttempts >= MAX_FAILED_ATTEMPTS; + const remainingAttempts = Math.max(0, MAX_FAILED_ATTEMPTS - failedAttempts); + return { failedAttempts, lockedOut, remainingAttempts }; +}; diff --git a/src/security/security.store.ts b/src/security/security.store.ts index ca60f6e..a4fdb2e 100644 --- a/src/security/security.store.ts +++ b/src/security/security.store.ts @@ -1,9 +1,41 @@ +import { Platform } from 'react-native'; +import * as SecureStore from 'expo-secure-store'; import { create } from 'zustand'; +const LOCKED_KEY = 'stepfi.security.isLocked'; +const FAILED_ATTEMPTS_KEY = 'stepfi.security.failedAttempts'; + +// Mirror of the auth store's storage shape: SecureStore on native, localStorage +// on web. Only the lockout-relevant fields (isLocked, failedAttempts) are +// persisted so the gate cannot be reset by relaunching the app. +const storage = { + async getItem(key: string): Promise { + if (Platform.OS === 'web') { + return localStorage.getItem(key); + } + return SecureStore.getItemAsync(key); + }, + async setItem(key: string, value: string): Promise { + if (Platform.OS === 'web') { + localStorage.setItem(key, value); + return; + } + await SecureStore.setItemAsync(key, value); + }, + async removeItem(key: string): Promise { + if (Platform.OS === 'web') { + localStorage.removeItem(key); + return; + } + await SecureStore.deleteItemAsync(key); + }, +}; + interface SecurityState { isLocked: boolean; failedAttempts: number; biometricCheckDone: boolean; + hydrate: () => Promise; lock: () => void; unlock: () => void; incrementFailedAttempts: () => void; @@ -17,20 +49,52 @@ export const useSecurityStore = create((set) => ({ failedAttempts: 0, biometricCheckDone: false, - lock: () => set({ isLocked: true }), + // Restore the persisted lockout state on launch. Must resolve before the app + // renders so a locked session is not briefly shown as unlocked. + hydrate: async () => { + const [lockedRaw, attemptsRaw] = await Promise.all([ + storage.getItem(LOCKED_KEY), + storage.getItem(FAILED_ATTEMPTS_KEY), + ]); + const parsedAttempts = Number.parseInt(attemptsRaw ?? '', 10); + set({ + isLocked: lockedRaw === 'true', + failedAttempts: Number.isNaN(parsedAttempts) ? 0 : Math.max(0, parsedAttempts), + }); + }, + + lock: () => { + set({ isLocked: true }); + void storage.setItem(LOCKED_KEY, 'true'); + }, - unlock: () => set({ isLocked: false, failedAttempts: 0 }), + unlock: () => { + set({ isLocked: false, failedAttempts: 0 }); + void storage.setItem(LOCKED_KEY, 'false'); + void storage.setItem(FAILED_ATTEMPTS_KEY, '0'); + }, - incrementFailedAttempts: () => set((state) => ({ failedAttempts: state.failedAttempts + 1 })), + incrementFailedAttempts: () => + set((state) => { + const failedAttempts = state.failedAttempts + 1; + void storage.setItem(FAILED_ATTEMPTS_KEY, String(failedAttempts)); + return { failedAttempts }; + }), - resetFailedAttempts: () => set({ failedAttempts: 0 }), + resetFailedAttempts: () => { + set({ failedAttempts: 0 }); + void storage.setItem(FAILED_ATTEMPTS_KEY, '0'); + }, markBiometricCheckDone: () => set({ biometricCheckDone: true }), - reset: () => + // Cleared on sign-out: drop the persisted lockout so the next user starts fresh. + reset: () => { set({ isLocked: false, failedAttempts: 0, biometricCheckDone: false, - }), + }); + void Promise.all([storage.removeItem(LOCKED_KEY), storage.removeItem(FAILED_ATTEMPTS_KEY)]); + }, }));