From 1dd4ec7f45cb6bf8cb4e014f660805c4367cce33 Mon Sep 17 00:00:00 2001 From: No-bodyq Date: Sun, 28 Jun 2026 12:01:36 +0100 Subject: [PATCH] feat(security): re-sync biometric enabled state from SecureStore on foreground Stale Zustand state could show biometricEnabled: true after an OS update, reinstall, or external process cleared the SecureStore entry while the app was backgrounded. useBiometricAuth now re-reads isBiometricEnabled() on every active foreground transition and resets to false on divergence. isLoading blocks biometric UI until the initial sync completes. --- src/__tests__/hooks/useBiometricAuth.test.ts | 145 +++++++++++++++++++ src/hooks/useBiometricAuth.ts | 43 +++++- src/store/deviceStore.ts | 6 + 3 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/hooks/useBiometricAuth.test.ts diff --git a/src/__tests__/hooks/useBiometricAuth.test.ts b/src/__tests__/hooks/useBiometricAuth.test.ts new file mode 100644 index 00000000..64d7b305 --- /dev/null +++ b/src/__tests__/hooks/useBiometricAuth.test.ts @@ -0,0 +1,145 @@ +import { renderHook, act, waitFor } from '@testing-library/react-native'; + +import { useBiometricAuth } from '../../hooks/useBiometricAuth'; +import { isBiometricEnabled } from '../../services/secureStorage'; +import { useDeviceStore } from '../../store/deviceStore'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +jest.mock('../../services/secureStorage', () => ({ + isBiometricEnabled: jest.fn(), +})); + +const mockSetBiometricEnabled = jest.fn(); +const mockStore = { + isDeviceCompromised: false, + biometricEnabled: false, + setBiometricEnabled: mockSetBiometricEnabled, +}; + +jest.mock('../../store/deviceStore', () => ({ + useDeviceStore: jest.fn((selector: (s: typeof mockStore) => unknown) => selector(mockStore)), +})); + +let capturedAppStateCallback: ((state: string) => void) | null = null; + +jest.mock('react-native', () => ({ + AppState: { + currentState: 'active', + addEventListener: jest.fn().mockImplementation((_, cb) => { + capturedAppStateCallback = cb; + return { remove: jest.fn() }; + }), + }, +})); + +jest.mock('../../services/mobileAuth', () => ({})); + +const mockIsBiometricEnabled = isBiometricEnabled as jest.Mock; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('useBiometricAuth', () => { + beforeEach(() => { + jest.clearAllMocks(); + capturedAppStateCallback = null; + mockStore.isDeviceCompromised = false; + mockStore.biometricEnabled = false; + mockIsBiometricEnabled.mockResolvedValue(false); + }); + + it('syncs biometricEnabled from SecureStore on mount', async () => { + mockIsBiometricEnabled.mockResolvedValue(true); + + renderHook(() => useBiometricAuth()); + + await waitFor(() => { + expect(mockSetBiometricEnabled).toHaveBeenCalledWith(true); + }); + }); + + it('isLoading is true until the initial sync completes', async () => { + let resolveEnabled!: (v: boolean) => void; + mockIsBiometricEnabled.mockReturnValue(new Promise(r => (resolveEnabled = r))); + + const { result } = renderHook(() => useBiometricAuth()); + + expect(result.current.isLoading).toBe(true); + + await act(async () => resolveEnabled(false)); + + expect(result.current.isLoading).toBe(false); + }); + + it('re-syncs from SecureStore when app returns to foreground', async () => { + mockIsBiometricEnabled.mockResolvedValue(false); + renderHook(() => useBiometricAuth()); + await waitFor(() => expect(mockSetBiometricEnabled).toHaveBeenCalledTimes(1)); + + mockIsBiometricEnabled.mockResolvedValue(true); + + await act(async () => { + // Simulate background → foreground transition + capturedAppStateCallback!('background'); + capturedAppStateCallback!('active'); + }); + + await waitFor(() => expect(mockSetBiometricEnabled).toHaveBeenCalledWith(true)); + }); + + it('divergence: SecureStore cleared while Zustand cached true → resets to false', async () => { + // Zustand has stale true, SecureStore no longer has the entry + mockStore.biometricEnabled = true; + mockIsBiometricEnabled.mockResolvedValue(false); + + renderHook(() => useBiometricAuth()); + + // Foreground event triggers re-sync + await act(async () => { + capturedAppStateCallback?.('background'); + capturedAppStateCallback?.('active'); + }); + + await waitFor(() => { + expect(mockSetBiometricEnabled).toHaveBeenCalledWith(false); + }); + }); + + it('isEnabled is false while syncing is in progress', async () => { + mockStore.biometricEnabled = true; + let resolveEnabled!: (v: boolean) => void; + mockIsBiometricEnabled.mockReturnValue(new Promise(r => (resolveEnabled = r))); + + const { result } = renderHook(() => useBiometricAuth()); + + expect(result.current.isLoading).toBe(true); + + await act(async () => resolveEnabled(true)); + }); + + it('isEnabled is false when device is compromised regardless of SecureStore', async () => { + mockStore.isDeviceCompromised = true; + mockStore.biometricEnabled = true; + mockIsBiometricEnabled.mockResolvedValue(true); + + const { result } = renderHook(() => useBiometricAuth()); + + await waitFor(() => expect(mockSetBiometricEnabled).toHaveBeenCalled()); + + expect(result.current.isEnabled).toBe(false); + expect(result.current.error).toBeTruthy(); + }); + + it('does not re-sync on active → background transition', async () => { + mockIsBiometricEnabled.mockResolvedValue(false); + renderHook(() => useBiometricAuth()); + await waitFor(() => expect(mockSetBiometricEnabled).toHaveBeenCalledTimes(1)); + + await act(async () => { + capturedAppStateCallback!('background'); + }); + + // No additional sync — only foreground triggers re-sync + expect(mockSetBiometricEnabled).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/useBiometricAuth.ts b/src/hooks/useBiometricAuth.ts index 1c67841f..5546f910 100644 --- a/src/hooks/useBiometricAuth.ts +++ b/src/hooks/useBiometricAuth.ts @@ -1,19 +1,56 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { AppState, AppStateStatus } from 'react-native'; import { BiometricType, AuthResult } from '../services/mobileAuth'; +import { isBiometricEnabled } from '../services/secureStorage'; import { useDeviceStore } from '../store/deviceStore'; export function useBiometricAuth() { const isDeviceCompromised = useDeviceStore(state => state.isDeviceCompromised); + const biometricEnabled = useDeviceStore(state => state.biometricEnabled); + const setBiometricEnabled = useDeviceStore(state => state.setBiometricEnabled); + + const [isSyncing, setIsSyncing] = useState(true); + const appStateRef = useRef(AppState.currentState); + + const syncWithSecureStore = useCallback(async () => { + setIsSyncing(true); + try { + const enabled = await isBiometricEnabled(); + // If secure store no longer has the entry, reset to the safe default. + // This covers: OS update wiping the keychain, app reinstall, or any + // external process clearing the key while the app was backgrounded. + setBiometricEnabled(enabled); + } finally { + setIsSyncing(false); + } + }, [setBiometricEnabled]); + + // Sync once on mount so the initial render reflects SecureStore truth. + useEffect(() => { + syncWithSecureStore(); + }, [syncWithSecureStore]); + + // Re-sync on every foreground transition so a stale Zustand value is + // corrected before any biometric UI can appear. + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextState: AppStateStatus) => { + if (appStateRef.current !== 'active' && nextState === 'active') { + syncWithSecureStore(); + } + appStateRef.current = nextState; + }); + return () => subscription.remove(); + }, [syncWithSecureStore]); return { isAvailable: false, - isEnabled: false, + isEnabled: !isDeviceCompromised && biometricEnabled, biometricType: 'none' as BiometricType, authenticate: useCallback(async (): Promise => null, []), enable: useCallback(async () => false, []), disable: useCallback(async () => {}, []), - isLoading: false, + isLoading: isSyncing, error: isDeviceCompromised ? 'Biometric authentication is unavailable on this device.' : null, clearError: useCallback(() => {}, []), }; diff --git a/src/store/deviceStore.ts b/src/store/deviceStore.ts index 18fde16f..2af961f6 100644 --- a/src/store/deviceStore.ts +++ b/src/store/deviceStore.ts @@ -13,12 +13,14 @@ interface DeviceState { /** True when the device is jailbroken (iOS) or rooted (Android) */ isDeviceCompromised: boolean; lastBiometricAuth: number | null; + biometricEnabled: boolean; // Actions updateBatteryInfo: (level: number, state: Battery.BatteryState, lowPowerMode: boolean) => void; setIsInBackground: (isBg: boolean) => void; setDeviceCompromised: (compromised: boolean) => void; setLastBiometricAuth: (timestamp: number | null) => void; + setBiometricEnabled: (enabled: boolean) => void; /** Runs jailbreak/root detection and updates state */ runDeviceCompromisedCheck: () => Promise; } @@ -31,6 +33,7 @@ export const useDeviceStore = create(set => ({ isInBackground: false, isDeviceCompromised: false, lastBiometricAuth: null, + biometricEnabled: false, updateBatteryInfo: (level, state, lowPowerMode) => { const isLowBattery = level > 0 && level < 0.2; @@ -50,6 +53,9 @@ export const useDeviceStore = create(set => ({ setLastBiometricAuth: (timestamp) => { set({ lastBiometricAuth: timestamp }); }, + setBiometricEnabled: (enabled) => { + set({ biometricEnabled: enabled }); + }, runDeviceCompromisedCheck: async () => { const compromised = await checkDeviceCompromised(); set({ isDeviceCompromised: compromised });