From 8e846183dd1e5e822bfae3b0710179f823f06d63 Mon Sep 17 00:00:00 2001 From: Promise Raji Date: Sat, 18 Jul 2026 10:17:02 +0100 Subject: [PATCH] Closes #45 --- .npmrc | 1 + app/(tabs)/settings.tsx | 17 +- app/_layout.tsx | 22 +- jest.unit.config.js | 19 ++ services/__tests__/sign-out.service.test.ts | 129 ++++++++++ services/sign-out.service.ts | 61 +++++ src/components/BiometricGate.tsx | 168 ++++++++++--- src/security/__tests__/security.store.test.ts | 231 ++++++++++++++++++ src/security/secure-storage.ts | 36 +++ src/security/security.store.ts | 188 +++++++++++++- tsconfig.json | 2 + 11 files changed, 817 insertions(+), 57 deletions(-) create mode 100644 .npmrc create mode 100644 jest.unit.config.js create mode 100644 services/__tests__/sign-out.service.test.ts create mode 100644 services/sign-out.service.ts create mode 100644 src/security/__tests__/security.store.test.ts create mode 100644 src/security/secure-storage.ts diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 5ba48f9..17ef696 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -27,8 +27,8 @@ import { colors } from '../../constants/colors'; import { Card } from '../../components/shared/Card'; import { useAuthStore } from '../../stores/auth.store'; import { useUserStore } from '../../stores/user.store'; -import { useWalletStore } from '../../stores/wallet.store'; import { biometricService } from '../../src/security/biometric.service'; +import { signOutService } from '../../services/sign-out.service'; import { useTranslation } from '../../hooks/useTranslation'; interface MenuItemProps { @@ -90,12 +90,10 @@ const LANGUAGES = [ export default function SettingsScreen() { const { t, currentLanguage, changeLanguage } = useTranslation(); - const clearAuth = useAuthStore((s) => s.clearAuth); const walletAddress = useAuthStore((s) => s.walletAddress); const profile = useUserStore((s) => s.profile); - const clearUser = useUserStore((s) => s.clearUser); - const setDisconnected = useWalletStore((s) => s.setDisconnected); const [copied, setCopied] = useState(false); + const [isSigningOut, setIsSigningOut] = useState(false); const [biometricsEnabled, setBiometricsEnabled] = useState(false); const [biometricsAvailable, setBiometricsAvailable] = useState(false); @@ -139,9 +137,14 @@ export default function SettingsScreen() { text: t('settings.signOutConfirm'), style: 'destructive', onPress: async () => { - setDisconnected(); - clearUser(); - await clearAuth(); + setIsSigningOut(true); + try { + await signOutService.signOut(); + } catch { + // Teardown is best-effort; auth will still be cleared + } finally { + setIsSigningOut(false); + } }, }, ], diff --git a/app/_layout.tsx b/app/_layout.tsx index eb0169f..d83aa4c 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -88,14 +88,20 @@ function RootLayout() { clearIdleTimer(); idleTimerRef.current = setTimeout(() => { if (useAuthStore.getState().isAuthenticated) { - useSecurityStore.getState().lock(); + void useSecurityStore.getState().lock(); } }, IDLE_TIMEOUT_MS); }, [clearIdleTimer]); useEffect(() => { - void hydrate(); - void useUserStore.getState().hydrate(); + async function hydrateAll() { + await Promise.all([ + hydrate(), + useUserStore.getState().hydrate(), + useSecurityStore.getState().hydrate(), + ]); + } + hydrateAll(); initPromise.then(() => setI18nReady(true)); }, [hydrate]); @@ -105,15 +111,15 @@ function RootLayout() { useEffect(() => { if (!isLoading && isAuthenticated && !biometricCheckDone) { useSecurityStore.getState().markBiometricCheckDone(); - biometricService.isBiometricsEnabled().then((enabled) => { + biometricService.isBiometricsEnabled().then(async (enabled) => { if (enabled) { - useSecurityStore.getState().lock(); + await useSecurityStore.getState().lock(); } }); } if (!isAuthenticated) { - useSecurityStore.getState().reset(); + void useSecurityStore.getState().reset(); } }, [isLoading, isAuthenticated, biometricCheckDone]); @@ -127,7 +133,7 @@ function RootLayout() { elapsed >= IDLE_TIMEOUT_MS && useAuthStore.getState().isAuthenticated ) { - useSecurityStore.getState().lock(); + void useSecurityStore.getState().lock(); } if (!useSecurityStore.getState().isLocked) { startIdleTimer(); @@ -135,6 +141,8 @@ function RootLayout() { } else if (state === 'background' || state === 'inactive') { lastActiveRef.current = Date.now(); clearIdleTimer(); + // Persist lastActiveAt so kill-during-background is detected on next cold start + void useSecurityStore.getState().recordActivity(); } }); diff --git a/jest.unit.config.js b/jest.unit.config.js new file mode 100644 index 0000000..e64d040 --- /dev/null +++ b/jest.unit.config.js @@ -0,0 +1,19 @@ +/** @type {import('jest').Config} */ +module.exports = { + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.json', + }, + ], + }, + moduleNameMapper: { + '^@/(.*)$': '/$1', + }, + testEnvironment: 'node', + testMatch: [ + '**/src/security/__tests__/**/*.test.ts', + '**/services/__tests__/**/*.test.ts', + ], +}; diff --git a/services/__tests__/sign-out.service.test.ts b/services/__tests__/sign-out.service.test.ts new file mode 100644 index 0000000..c67ee8a --- /dev/null +++ b/services/__tests__/sign-out.service.test.ts @@ -0,0 +1,129 @@ +// ─── Mock all store modules ────────────────────────────────────────── +const mockDisconnect = jest.fn().mockResolvedValue(undefined); +const mockClearAuth = jest.fn().mockResolvedValue(undefined); +const mockClearUser = jest.fn(); +const mockClearLoans = jest.fn(); +const mockSecurityReset = jest.fn().mockResolvedValue(undefined); +const mockDisableBiometrics = jest.fn().mockResolvedValue(undefined); + +jest.mock('../../stores/wallet.store', () => ({ + useWalletStore: { + getState: () => ({ disconnect: mockDisconnect }), + }, +})); + +jest.mock('../../stores/auth.store', () => ({ + useAuthStore: { + getState: () => ({ clearAuth: mockClearAuth }), + }, +})); + +jest.mock('../../stores/user.store', () => ({ + useUserStore: { + getState: () => ({ clearUser: mockClearUser }), + }, +})); + +jest.mock('../../stores/loans.store', () => ({ + useLoansStore: { + getState: () => ({ clearLoans: mockClearLoans }), + }, +})); + +jest.mock('../../src/security/security.store', () => ({ + useSecurityStore: { + getState: () => ({ reset: mockSecurityReset }), + }, +})); + +jest.mock('../../src/security/biometric.service', () => ({ + biometricService: { + disableBiometrics: mockDisableBiometrics, + }, +})); + +import { signOutService } from '../sign-out.service'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('signOutService.signOut', () => { + it('calls disconnect on wallet store', async () => { + await signOutService.signOut(); + expect(mockDisconnect).toHaveBeenCalledTimes(1); + }); + + it('clears auth tokens', async () => { + await signOutService.signOut(); + expect(mockClearAuth).toHaveBeenCalledTimes(1); + }); + + it('clears user profile', async () => { + await signOutService.signOut(); + expect(mockClearUser).toHaveBeenCalledTimes(1); + }); + + it('clears loans', async () => { + await signOutService.signOut(); + expect(mockClearLoans).toHaveBeenCalledTimes(1); + }); + + it('resets security store', async () => { + await signOutService.signOut(); + expect(mockSecurityReset).toHaveBeenCalledTimes(1); + }); + + it('disables biometrics', async () => { + await signOutService.signOut(); + expect(mockDisableBiometrics).toHaveBeenCalledTimes(1); + }); + + it('calls all teardown steps in order', async () => { + const callOrder: string[] = []; + mockDisconnect.mockImplementation(async () => { callOrder.push('disconnect'); }); + mockClearAuth.mockImplementation(async () => { callOrder.push('clearAuth'); }); + mockClearUser.mockImplementation(() => { callOrder.push('clearUser'); }); + mockClearLoans.mockImplementation(() => { callOrder.push('clearLoans'); }); + mockSecurityReset.mockImplementation(async () => { callOrder.push('securityReset'); }); + mockDisableBiometrics.mockImplementation(async () => { callOrder.push('disableBiometrics'); }); + + await signOutService.signOut(); + + expect(callOrder).toEqual([ + 'disconnect', + 'clearAuth', + 'clearUser', + 'clearLoans', + 'securityReset', + 'disableBiometrics', + ]); + }); + + it('completes teardown even if WC disconnect throws', async () => { + mockDisconnect.mockRejectedValueOnce(new Error('WC session expired')); + + await signOutService.signOut(); + + // All subsequent steps should still be called + expect(mockClearAuth).toHaveBeenCalledTimes(1); + expect(mockClearUser).toHaveBeenCalledTimes(1); + expect(mockClearLoans).toHaveBeenCalledTimes(1); + expect(mockSecurityReset).toHaveBeenCalledTimes(1); + expect(mockDisableBiometrics).toHaveBeenCalledTimes(1); + }); + + it('completes teardown even if clearAuth throws', async () => { + mockClearAuth.mockRejectedValueOnce(new Error('Storage error')); + + await signOutService.signOut(); + + expect(mockClearUser).toHaveBeenCalledTimes(1); + expect(mockSecurityReset).toHaveBeenCalledTimes(1); + }); + + it('is idempotent — calling twice does not throw', async () => { + await signOutService.signOut(); + await expect(signOutService.signOut()).resolves.toBeUndefined(); + }); +}); diff --git a/services/sign-out.service.ts b/services/sign-out.service.ts new file mode 100644 index 0000000..eb84247 --- /dev/null +++ b/services/sign-out.service.ts @@ -0,0 +1,61 @@ +import { useAuthStore } from '../stores/auth.store'; +import { useUserStore } from '../stores/user.store'; +import { useWalletStore } from '../stores/wallet.store'; +import { useLoansStore } from '../stores/loans.store'; +import { useSecurityStore } from '../src/security/security.store'; +import { biometricService } from '../src/security/biometric.service'; + +/** + * Atomic sign-out orchestrator. + * + * Clears every store and disconnects the WalletConnect v2 session + * so no orphaned sessions survive after the user signs out. + * + * Individual failures are caught so the teardown always completes. + * WC disconnect is best-effort; local state wipe is mandatory. + */ +export const signOutService = { + async signOut(): Promise { + // 1. Disconnect WalletConnect v2 session (best-effort) + try { + await useWalletStore.getState().disconnect(); + } catch { + // Session may already be gone — safe to ignore + } + + // 2. Clear auth tokens from secure storage + try { + await useAuthStore.getState().clearAuth(); + } catch { + // Best-effort + } + + // 3. Clear user profile + try { + useUserStore.getState().clearUser(); + } catch { + // Best-effort + } + + // 4. Clear loans + try { + useLoansStore.getState().clearLoans(); + } catch { + // Best-effort + } + + // 5. Reset security state (wipes persisted lock / attempt counters) + try { + await useSecurityStore.getState().reset(); + } catch { + // Best-effort + } + + // 6. Clear biometric / PIN preferences + try { + await biometricService.disableBiometrics(); + } catch { + // Best-effort + } + }, +}; diff --git a/src/components/BiometricGate.tsx b/src/components/BiometricGate.tsx index 7dcd1cc..5894514 100644 --- a/src/components/BiometricGate.tsx +++ b/src/components/BiometricGate.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useRef } from 'react'; import { View, Text, @@ -9,59 +9,101 @@ 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 { useAuthStore } from '../../stores/auth.store'; -import { useUserStore } from '../../stores/user.store'; -import { useWalletStore } from '../../stores/wallet.store'; +import { signOutService } from '../../services/sign-out.service'; -const MAX_FAILED_ATTEMPTS = 3; +type GateMode = 'loading' | 'biometric' | 'pin' | 'lockout' | 'error'; -type GateMode = 'loading' | 'biometric' | 'pin' | 'error'; +/** Format remaining ms as "Xm Ys" */ +function formatCountdown(ms: number): string { + const totalSeconds = Math.ceil(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } + return `${seconds}s`; +} export function BiometricGate() { const [mode, setMode] = useState('loading'); const [pin, setPin] = useState(''); const [errorMessage, setErrorMessage] = useState(''); + const [lockoutCountdown, setLockoutCountdown] = useState(''); const unlock = useSecurityStore((s) => s.unlock); + const countdownRef = useRef | null>(null); - const clearAuth = useAuthStore((s) => s.clearAuth); - const clearUser = useUserStore((s) => s.clearUser); - const setDisconnected = useWalletStore((s) => s.setDisconnected); + const clearCountdownTimer = useCallback(() => { + if (countdownRef.current !== null) { + clearInterval(countdownRef.current); + countdownRef.current = null; + } + }, []); const handleLogout = useCallback(async () => { - setDisconnected(); - clearUser(); - await clearAuth(); - }, [clearAuth, clearUser, setDisconnected]); + clearCountdownTimer(); + try { + await signOutService.signOut(); + } catch { + // Best-effort teardown + } + }, [clearCountdownTimer]); + + const startLockoutCountdown = useCallback(() => { + clearCountdownTimer(); + setMode('lockout'); + + const tick = () => { + const remaining = useSecurityStore.getState().getLockoutRemainingMs(); + if (remaining <= 0) { + clearCountdownTimer(); + setLockoutCountdown(''); + setMode('pin'); + setErrorMessage(''); + return; + } + setLockoutCountdown(formatCountdown(remaining)); + }; + + tick(); // immediate first tick + countdownRef.current = setInterval(tick, 1000); + }, [clearCountdownTimer]); const handleFailure = useCallback(async () => { - const store = useSecurityStore.getState(); - const newCount = store.failedAttempts + 1; - store.incrementFailedAttempts(); - if (newCount >= MAX_FAILED_ATTEMPTS) { - await handleLogout(); + await useSecurityStore.getState().incrementFailedAttempts(); + + // Check if we're now in lockout + if (useSecurityStore.getState().getIsLockedOut()) { + startLockoutCountdown(); } else { - const remaining = MAX_FAILED_ATTEMPTS - newCount; + const { failedAttempts } = useSecurityStore.getState(); + const remaining = Math.max(0, 6 - failedAttempts); // show remaining before max lockout setErrorMessage( - `Verification failed. ${remaining} attempt${remaining > 1 ? 's' : ''} remaining.`, + `Verification failed. ${remaining} attempt${remaining !== 1 ? 's' : ''} before lockout.`, ); } - }, [handleLogout]); + }, [startLockoutCountdown]); - const handleSuccess = useCallback(() => { + const handleSuccess = useCallback(async () => { + clearCountdownTimer(); setPin(''); setErrorMessage(''); - unlock(); - }, [unlock]); + await unlock(); + }, [unlock, clearCountdownTimer]); const tryBiometric = useCallback(async () => { const result = await biometricService.authenticateBiometric(); if (result.success) { - handleSuccess(); + await handleSuccess(); } else { const hasPinSet = await biometricService.hasPin(); if (hasPinSet) { - setMode('pin'); + // Check lockout before allowing PIN entry + if (useSecurityStore.getState().getIsLockedOut()) { + startLockoutCountdown(); + } else { + setMode('pin'); + } if ( result.error !== 'user_cancel' && result.error !== 'USER_CANCELED' @@ -73,12 +115,18 @@ export function BiometricGate() { setErrorMessage('Biometric failed and no PIN is configured.'); } } - }, [handleSuccess, handleFailure]); + }, [handleSuccess, handleFailure, startLockoutCountdown]); useEffect(() => { let cancelled = false; async function init() { + // Check if already in lockout + if (useSecurityStore.getState().getIsLockedOut()) { + if (!cancelled) startLockoutCountdown(); + return; + } + const { isAvailable, isEnrolled } = await biometricService.checkBiometricAvailability(); @@ -105,22 +153,30 @@ export function BiometricGate() { return () => { cancelled = true; + clearCountdownTimer(); }; - }, [tryBiometric]); + }, [tryBiometric, startLockoutCountdown, clearCountdownTimer]); const handlePinSubmit = useCallback(async () => { if (pin.length < 4) return; setErrorMessage(''); + // Double-check lockout before verifying + if (useSecurityStore.getState().getIsLockedOut()) { + startLockoutCountdown(); + return; + } + const isValid = await biometricService.verifyPin(pin); if (isValid) { - handleSuccess(); + await handleSuccess(); } else { setPin(''); await handleFailure(); } - }, [pin, handleSuccess, handleFailure]); + }, [pin, handleSuccess, handleFailure, startLockoutCountdown]); + // ── Loading ── if (mode === 'loading') { return ( + + + Too Many Attempts + + + + Please wait before trying again. + + + + + {lockoutCountdown} + + + + + + Sign out + + + + + ); + } + + // ── PIN entry / error ── return ( = {}; + +jest.mock('../secure-storage', () => ({ + secureStorage: { + getItem: jest.fn(async (key: string) => mockStore[key] ?? null), + setItem: jest.fn(async (key: string, value: string) => { + mockStore[key] = value; + }), + removeItem: jest.fn(async (key: string) => { + delete mockStore[key]; + }), + }, +})); + +function clearMockStore() { + for (const key of Object.keys(mockStore)) { + delete mockStore[key]; + } +} + +// ─── Helper: reset Zustand store between tests ─────────────────────── +function resetStore() { + useSecurityStore.setState({ + isLocked: false, + failedAttempts: 0, + lockoutUntil: null, + lastActiveAt: Date.now(), + biometricCheckDone: false, + isHydrated: false, + }); +} + +beforeEach(() => { + clearMockStore(); + resetStore(); + jest.clearAllMocks(); +}); + +// ─── lockoutDurationMs ─────────────────────────────────────────────── +describe('lockoutDurationMs', () => { + it('returns 0 for attempts 1 and 2', () => { + expect(lockoutDurationMs(1)).toBe(0); + expect(lockoutDurationMs(2)).toBe(0); + }); + + it('returns 30 s for attempt 3', () => { + expect(lockoutDurationMs(3)).toBe(30_000); + }); + + it('returns 2 min for attempt 4', () => { + expect(lockoutDurationMs(4)).toBe(120_000); + }); + + it('returns 5 min for attempt 5', () => { + expect(lockoutDurationMs(5)).toBe(300_000); + }); + + it('returns 15 min for attempt 6+', () => { + expect(lockoutDurationMs(6)).toBe(900_000); + expect(lockoutDurationMs(10)).toBe(900_000); + }); +}); + +// ─── Persistence: lock / unlock ────────────────────────────────────── +describe('lock and unlock persistence', () => { + it('persists isLocked = true to secure storage', async () => { + await useSecurityStore.getState().lock(); + expect(mockStore[SECURITY_KEYS.KEY_IS_LOCKED]).toBe('true'); + expect(useSecurityStore.getState().isLocked).toBe(true); + }); + + it('persists isLocked = false on unlock and resets counters', async () => { + // Set up failed state + await useSecurityStore.getState().lock(); + await useSecurityStore.getState().incrementFailedAttempts(); + await useSecurityStore.getState().incrementFailedAttempts(); + await useSecurityStore.getState().incrementFailedAttempts(); + + expect(useSecurityStore.getState().failedAttempts).toBe(3); + + await useSecurityStore.getState().unlock(); + + expect(mockStore[SECURITY_KEYS.KEY_IS_LOCKED]).toBe('false'); + expect(mockStore[SECURITY_KEYS.KEY_FAILED_ATTEMPTS]).toBe('0'); + expect(mockStore[SECURITY_KEYS.KEY_LOCKOUT_UNTIL]).toBeUndefined(); + expect(useSecurityStore.getState().isLocked).toBe(false); + expect(useSecurityStore.getState().failedAttempts).toBe(0); + expect(useSecurityStore.getState().lockoutUntil).toBeNull(); + }); +}); + +// ─── Failed attempts survive hydrate ───────────────────────────────── +describe('failed attempts survive hydrate cycle', () => { + it('persists and restores failedAttempts across store recreation', async () => { + // Simulate 4 failed attempts + await useSecurityStore.getState().incrementFailedAttempts(); + await useSecurityStore.getState().incrementFailedAttempts(); + await useSecurityStore.getState().incrementFailedAttempts(); + await useSecurityStore.getState().incrementFailedAttempts(); + + expect(useSecurityStore.getState().failedAttempts).toBe(4); + expect(mockStore[SECURITY_KEYS.KEY_FAILED_ATTEMPTS]).toBe('4'); + + // Simulate app restart: reset in-memory state, then hydrate from storage + resetStore(); + expect(useSecurityStore.getState().failedAttempts).toBe(0); + + await useSecurityStore.getState().hydrate(); + + expect(useSecurityStore.getState().failedAttempts).toBe(4); + expect(useSecurityStore.getState().isHydrated).toBe(true); + }); +}); + +// ─── Lockout backoff computation ───────────────────────────────────── +describe('lockout backoff', () => { + it('sets lockoutUntil after 3rd failed attempt', async () => { + await useSecurityStore.getState().incrementFailedAttempts(); // 1 + await useSecurityStore.getState().incrementFailedAttempts(); // 2 + expect(useSecurityStore.getState().lockoutUntil).toBeNull(); + + const before = Date.now(); + await useSecurityStore.getState().incrementFailedAttempts(); // 3 + const after = Date.now(); + + const lockoutUntil = useSecurityStore.getState().lockoutUntil; + expect(lockoutUntil).not.toBeNull(); + // lockoutUntil should be ~30 seconds from now + expect(lockoutUntil).toBeGreaterThanOrEqual(before + 30_000); + expect(lockoutUntil).toBeLessThanOrEqual(after + 30_000); + }); + + it('getIsLockedOut returns true when in lockout period', async () => { + // Set lockoutUntil to the future + useSecurityStore.setState({ lockoutUntil: Date.now() + 60_000 }); + expect(useSecurityStore.getState().getIsLockedOut()).toBe(true); + }); + + it('getIsLockedOut returns false when lockout expired', async () => { + useSecurityStore.setState({ lockoutUntil: Date.now() - 1000 }); + expect(useSecurityStore.getState().getIsLockedOut()).toBe(false); + }); + + it('getLockoutRemainingMs returns positive value during lockout', () => { + useSecurityStore.setState({ lockoutUntil: Date.now() + 5000 }); + const remaining = useSecurityStore.getState().getLockoutRemainingMs(); + expect(remaining).toBeGreaterThan(0); + expect(remaining).toBeLessThanOrEqual(5000); + }); + + it('getLockoutRemainingMs returns 0 when no lockout', () => { + expect(useSecurityStore.getState().getLockoutRemainingMs()).toBe(0); + }); +}); + +// ─── Hydrate: idle timeout detection ───────────────────────────────── +describe('hydrate idle timeout detection', () => { + it('auto-locks when lastActiveAt + IDLE_TIMEOUT exceeds now', async () => { + // Simulate: app was last active 10 minutes ago, stored as unlocked + const tenMinutesAgo = Date.now() - 10 * 60 * 1000; + mockStore[SECURITY_KEYS.KEY_IS_LOCKED] = 'false'; + mockStore[SECURITY_KEYS.KEY_LAST_ACTIVE_AT] = String(tenMinutesAgo); + mockStore[SECURITY_KEYS.KEY_FAILED_ATTEMPTS] = '0'; + + await useSecurityStore.getState().hydrate(); + + expect(useSecurityStore.getState().isLocked).toBe(true); + }); + + it('stays unlocked when idle timeout has not expired', async () => { + const oneMinuteAgo = Date.now() - 1 * 60 * 1000; + mockStore[SECURITY_KEYS.KEY_IS_LOCKED] = 'false'; + mockStore[SECURITY_KEYS.KEY_LAST_ACTIVE_AT] = String(oneMinuteAgo); + mockStore[SECURITY_KEYS.KEY_FAILED_ATTEMPTS] = '0'; + + await useSecurityStore.getState().hydrate(); + + expect(useSecurityStore.getState().isLocked).toBe(false); + }); + + it('biometricCheckDone is always false after hydrate', async () => { + await useSecurityStore.getState().hydrate(); + expect(useSecurityStore.getState().biometricCheckDone).toBe(false); + }); +}); + +// ─── Reset clears everything ───────────────────────────────────────── +describe('reset', () => { + it('clears all in-memory and persisted state', async () => { + await useSecurityStore.getState().lock(); + await useSecurityStore.getState().incrementFailedAttempts(); + await useSecurityStore.getState().incrementFailedAttempts(); + await useSecurityStore.getState().incrementFailedAttempts(); + await useSecurityStore.getState().recordActivity(); + + // Verify state was persisted + expect(mockStore[SECURITY_KEYS.KEY_IS_LOCKED]).toBe('true'); + expect(mockStore[SECURITY_KEYS.KEY_FAILED_ATTEMPTS]).toBe('3'); + expect(mockStore[SECURITY_KEYS.KEY_LAST_ACTIVE_AT]).toBeDefined(); + + await useSecurityStore.getState().reset(); + + // In-memory state cleared + expect(useSecurityStore.getState().isLocked).toBe(false); + expect(useSecurityStore.getState().failedAttempts).toBe(0); + expect(useSecurityStore.getState().lockoutUntil).toBeNull(); + expect(useSecurityStore.getState().biometricCheckDone).toBe(false); + + // Persisted keys removed + expect(mockStore[SECURITY_KEYS.KEY_IS_LOCKED]).toBeUndefined(); + expect(mockStore[SECURITY_KEYS.KEY_FAILED_ATTEMPTS]).toBeUndefined(); + expect(mockStore[SECURITY_KEYS.KEY_LOCKOUT_UNTIL]).toBeUndefined(); + expect(mockStore[SECURITY_KEYS.KEY_LAST_ACTIVE_AT]).toBeUndefined(); + }); +}); + +// ─── recordActivity ────────────────────────────────────────────────── +describe('recordActivity', () => { + it('persists lastActiveAt to secure storage', async () => { + const before = Date.now(); + await useSecurityStore.getState().recordActivity(); + const after = Date.now(); + + const stored = parseInt(mockStore[SECURITY_KEYS.KEY_LAST_ACTIVE_AT], 10); + expect(stored).toBeGreaterThanOrEqual(before); + expect(stored).toBeLessThanOrEqual(after); + }); +}); diff --git a/src/security/secure-storage.ts b/src/security/secure-storage.ts new file mode 100644 index 0000000..d80a966 --- /dev/null +++ b/src/security/secure-storage.ts @@ -0,0 +1,36 @@ +import { Platform } from 'react-native'; +import * as SecureStore from 'expo-secure-store'; + +/** + * Platform-aware secure storage adapter. + * + * Native (iOS / Android): uses expo-secure-store (Keychain / Keystore). + * Web: falls back to localStorage (no hardware-backed option available). + * + * Every security-related value in the app must flow through this adapter + * so nothing leaks into plain AsyncStorage. + */ +export const secureStorage = { + 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); + }, +}; diff --git a/src/security/security.store.ts b/src/security/security.store.ts index c1da31e..f474b67 100644 --- a/src/security/security.store.ts +++ b/src/security/security.store.ts @@ -1,37 +1,199 @@ import { create } from 'zustand'; +import { secureStorage } from './secure-storage'; +// ─── Secure-store keys ─────────────────────────────────────────────── +const KEY_IS_LOCKED = 'stepfi.security.isLocked'; +const KEY_FAILED_ATTEMPTS = 'stepfi.security.failedAttempts'; +const KEY_LOCKOUT_UNTIL = 'stepfi.security.lockoutUntil'; +const KEY_LAST_ACTIVE_AT = 'stepfi.security.lastActiveAt'; + +// ─── Constants ─────────────────────────────────────────────────────── +const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Backoff schedule — returns lockout duration in ms for a given attempt count. + * Attempts 1–2 have no lockout; 3 → 30 s; 4 → 2 min; 5 → 5 min; 6+ → 15 min. + */ +function lockoutDurationMs(failedAttempts: number): number { + if (failedAttempts < 3) return 0; + if (failedAttempts === 3) return 30_000; + if (failedAttempts === 4) return 2 * 60_000; + if (failedAttempts === 5) return 5 * 60_000; + return 15 * 60_000; +} + +// ─── Interface ─────────────────────────────────────────────────────── interface SecurityState { + // Persisted across restart isLocked: boolean; failedAttempts: number; + lockoutUntil: number | null; + lastActiveAt: number; + + // Ephemeral — always false on cold start (forces biometric re-check) biometricCheckDone: boolean; - lock: () => void; - unlock: () => void; - incrementFailedAttempts: () => void; - resetFailedAttempts: () => void; + + // Hydration flag + isHydrated: boolean; + + // ── Actions ── + hydrate: () => Promise; + lock: () => Promise; + unlock: () => Promise; + incrementFailedAttempts: () => Promise; + resetFailedAttempts: () => Promise; markBiometricCheckDone: () => void; - reset: () => void; + recordActivity: () => Promise; + reset: () => Promise; + + // ── Computed helpers (call on getState()) ── + getIsLockedOut: () => boolean; + getLockoutRemainingMs: () => number; } -export const useSecurityStore = create((set) => ({ +export const useSecurityStore = create((set, get) => ({ + // ── Defaults (safe-by-default: locked until hydrate proves otherwise) ── isLocked: false, failedAttempts: 0, + lockoutUntil: null, + lastActiveAt: Date.now(), biometricCheckDone: false, + isHydrated: false, + + // ── Hydrate from secure storage ── + hydrate: async () => { + try { + const [lockedStr, attemptsStr, lockoutStr, lastActiveStr] = + await Promise.all([ + secureStorage.getItem(KEY_IS_LOCKED), + secureStorage.getItem(KEY_FAILED_ATTEMPTS), + secureStorage.getItem(KEY_LOCKOUT_UNTIL), + secureStorage.getItem(KEY_LAST_ACTIVE_AT), + ]); + + const isLocked = lockedStr === 'true'; + const failedAttempts = attemptsStr ? parseInt(attemptsStr, 10) : 0; + const lockoutUntil = lockoutStr ? parseInt(lockoutStr, 10) : null; + const lastActiveAt = lastActiveStr + ? parseInt(lastActiveStr, 10) + : Date.now(); - lock: () => set({ isLocked: true }), + // Check if idle timeout expired while the app was killed + const idleExpired = Date.now() - lastActiveAt >= IDLE_TIMEOUT_MS; - unlock: () => set({ isLocked: false, failedAttempts: 0 }), + set({ + isLocked: isLocked || idleExpired, + failedAttempts, + lockoutUntil, + lastActiveAt, + biometricCheckDone: false, // always force re-check on cold start + isHydrated: true, + }); + } catch { + // If we can't read persisted state, default to locked (safe-by-default) + set({ + isLocked: true, + failedAttempts: 0, + lockoutUntil: null, + lastActiveAt: Date.now(), + biometricCheckDone: false, + isHydrated: true, + }); + } + }, - incrementFailedAttempts: () => - set((state) => ({ failedAttempts: state.failedAttempts + 1 })), + // ── Lock ── + lock: async () => { + set({ isLocked: true }); + await secureStorage.setItem(KEY_IS_LOCKED, 'true').catch(() => {}); + }, - resetFailedAttempts: () => set({ failedAttempts: 0 }), + // ── Unlock ── + unlock: async () => { + set({ isLocked: false, failedAttempts: 0, lockoutUntil: null }); + await Promise.all([ + secureStorage.setItem(KEY_IS_LOCKED, 'false'), + secureStorage.setItem(KEY_FAILED_ATTEMPTS, '0'), + secureStorage.removeItem(KEY_LOCKOUT_UNTIL), + ]).catch(() => {}); + }, + // ── Failed attempt with backoff ── + incrementFailedAttempts: async () => { + const newCount = get().failedAttempts + 1; + const duration = lockoutDurationMs(newCount); + const lockoutUntil = duration > 0 ? Date.now() + duration : null; + + set({ failedAttempts: newCount, lockoutUntil }); + + await Promise.all([ + secureStorage.setItem(KEY_FAILED_ATTEMPTS, String(newCount)), + lockoutUntil + ? secureStorage.setItem(KEY_LOCKOUT_UNTIL, String(lockoutUntil)) + : secureStorage.removeItem(KEY_LOCKOUT_UNTIL), + ]).catch(() => {}); + }, + + // ── Reset failed attempts (without full unlock) ── + resetFailedAttempts: async () => { + set({ failedAttempts: 0, lockoutUntil: null }); + await Promise.all([ + secureStorage.setItem(KEY_FAILED_ATTEMPTS, '0'), + secureStorage.removeItem(KEY_LOCKOUT_UNTIL), + ]).catch(() => {}); + }, + + // ── Biometric check flag (ephemeral) ── markBiometricCheckDone: () => set({ biometricCheckDone: true }), - reset: () => + // ── Record user activity (for idle detection) ── + recordActivity: async () => { + const now = Date.now(); + set({ lastActiveAt: now }); + await secureStorage + .setItem(KEY_LAST_ACTIVE_AT, String(now)) + .catch(() => {}); + }, + + // ── Full reset (sign-out) — wipe all persisted security keys ── + reset: async () => { set({ isLocked: false, failedAttempts: 0, + lockoutUntil: null, + lastActiveAt: Date.now(), biometricCheckDone: false, - }), + isHydrated: false, + }); + await Promise.all([ + secureStorage.removeItem(KEY_IS_LOCKED), + secureStorage.removeItem(KEY_FAILED_ATTEMPTS), + secureStorage.removeItem(KEY_LOCKOUT_UNTIL), + secureStorage.removeItem(KEY_LAST_ACTIVE_AT), + ]).catch(() => {}); + }, + + // ── Computed: is the user currently in a lockout period? ── + getIsLockedOut: () => { + const { lockoutUntil } = get(); + return lockoutUntil !== null && Date.now() < lockoutUntil; + }, + + // ── Computed: milliseconds remaining in lockout ── + getLockoutRemainingMs: () => { + const { lockoutUntil } = get(); + if (lockoutUntil === null) return 0; + return Math.max(0, lockoutUntil - Date.now()); + }, })); + +// Re-export for tests +export { lockoutDurationMs, IDLE_TIMEOUT_MS }; + +// Export storage keys for tests +export const SECURITY_KEYS = { + KEY_IS_LOCKED, + KEY_FAILED_ATTEMPTS, + KEY_LOCKOUT_UNTIL, + KEY_LAST_ACTIVE_AT, +} as const; diff --git a/tsconfig.json b/tsconfig.json index 0552ad9..13610ba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,8 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, "baseUrl": ".", "jsx": "react-native", "paths": {