diff --git a/App.tsx b/App.tsx index ff4d2bd..b5ce1a3 100644 --- a/App.tsx +++ b/App.tsx @@ -4,13 +4,13 @@ import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { - Alert, - AppState, - AppStateStatus, - InteractionManager, - LogBox, - Text, - View, + Alert, + AppState, + AppStateStatus, + InteractionManager, + LogBox, + Text, + View, } from 'react-native'; import { Asset } from 'expo-asset'; @@ -26,25 +26,25 @@ import { initializeLogging } from './src/config/logging'; import { AuthProvider, useAdaptiveTheme, useReviewMetrics } from './src/hooks'; import AppNavigator from './src/navigation/AppNavigator'; import { - getCacheStatus, - getRevalidatingCacheKeys, - subscribeToCacheStatus + getCacheStatus, + getRevalidatingCacheKeys, + subscribeToCacheStatus } from './src/services/api'; import { warmCriticalCaches } from './src/services/cacheWarming'; import { crashReportingService } from './src/services/crashReporting'; import { featureCapabilities } from './src/services/featureCapabilities'; import { - CRITICAL_FONTS, - fontService, - SECONDARY_FONTS, + CRITICAL_FONTS, + fontService, + SECONDARY_FONTS, } from './src/services/fontService'; import { inAppReviewService } from './src/services/inAppReview'; import { mobileAuthService } from './src/services/mobileAuth'; import { - registerForPushNotifications, // Added missing native push helpers - registerTokenWithBackend, - removeNotificationListener, - setupForegroundBadgeSync, + registerForPushNotifications, // Added missing native push helpers + registerTokenWithBackend, + removeNotificationListener, + setupForegroundBadgeSync, } from './src/services/pushNotifications'; import { searchIndexService } from './src/services/searchIndex'; import { checkSessionValidity, initializeSecureStorage } from './src/services/secureStorage'; @@ -54,8 +54,8 @@ import { useAppStore, useDeviceStore, useNotificationStore } from './src/store'; import { waitForHydration } from './src/store/createStore'; import { useDegradationStore } from './src/store/degradationStore'; import { - consumeHydrationResetToast, - subscribeToHydrationResetToast, + consumeHydrationResetToast, + subscribeToHydrationResetToast, } from './src/store/persistence'; import { handleCacheVersionUpdate } from './src/utils/cacheVersioning'; import { requireEnvVariables } from './src/utils/env'; @@ -172,6 +172,7 @@ const App = () => { useReviewMetrics(); const appStateRef = useRef(AppState.currentState); + const debounceTimerRef = useRef(null); const [appIsReady, setAppIsReady] = React.useState(false); const [showPreferencesResetToast, setShowPreferencesResetToast] = useState(false); const [showUpdateModal, setShowUpdateModal] = useState(false); @@ -258,29 +259,7 @@ const App = () => { } }, []); - useEffect(() => { - if (!appIsReady) return; - - // #848: track previous state locally so this listener detects foreground - // transitions on its own, rather than depending on another effect to keep - // a shared ref current. The subscription is removed on cleanup below. - let previousState = AppState.currentState; - const appStateSubscription = AppState.addEventListener('change', nextAppState => { - const wasInBackground = previousState.match(/inactive|background/); - const isForegrounded = nextAppState === 'active'; - if (wasInBackground && isForegrounded) { - void checkForOtaUpdate(); - } - previousState = nextAppState; - }); - // Check once on first foreground after app ready - void checkForOtaUpdate(); - - return () => { - appStateSubscription.remove(); - }; - }, [appIsReady, checkForOtaUpdate]); const handleOtaUpdate = useCallback(async () => { try { @@ -504,6 +483,10 @@ if ((Object.values(FeatureType) as string[]).includes(feature)) { }, []); useEffect(() => { + // This effect consolidates all AppState 'change' listeners to prevent race + // conditions and duplicate calls when the app is rapidly backgrounded and + // foregrounded. A 200ms debounce is used to handle these events. + const checkSessionOnForeground = async () => { // Don't read the store before it has rehydrated — destructured actions // would be undefined and calling them would throw / silently no-op. @@ -555,29 +538,45 @@ if ((Object.values(FeatureType) as string[]).includes(feature)) { await useDeviceStore.getState().runDeviceCompromisedCheck(); }; - // Wait for the persisted store to rehydrate before the first session check - // so we never read store actions before they exist. - void waitForHydration(useAppStore).then(() => { - void checkSessionOnForeground(); - }); - checkCompromisedOnForeground(); + const handleAppStateChange = (nextAppState: AppStateStatus) => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } - const appStateSubscription = AppState.addEventListener('change', nextAppState => { - const wasInBackground = appStateRef.current.match(/inactive|background/); - const isForegrounded = nextAppState === 'active'; + debounceTimerRef.current = setTimeout(() => { + const wasInBackground = appStateRef.current.match(/inactive|background/); + const isForegrounded = nextAppState === 'active'; - if (wasInBackground && isForegrounded) { + if (wasInBackground && isForegrounded) { + // All foreground actions are consolidated here + void checkForOtaUpdate(); + void checkSessionOnForeground(); + void checkCompromisedOnForeground(); + } + + appStateRef.current = nextAppState; + }, 200); + }; + + // Initial checks on app ready. The AppState listener will handle subsequent + // foregrounding events. + if (appIsReady) { + void checkForOtaUpdate(); + void waitForHydration(useAppStore).then(() => { void checkSessionOnForeground(); - void checkCompromisedOnForeground(); - } + }); + checkCompromisedOnForeground(); + } - appStateRef.current = nextAppState; - }); + const appStateSubscription = AppState.addEventListener('change', handleAppStateChange); return () => { appStateSubscription.remove(); + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } }; - }, []); + }, [appIsReady, checkForOtaUpdate]); if (!appIsReady) { return null; diff --git a/src/services/mobileAuth.ts b/src/services/mobileAuth.ts index 992b23f..adb7576 100644 --- a/src/services/mobileAuth.ts +++ b/src/services/mobileAuth.ts @@ -166,9 +166,17 @@ class MobileAuthService { throw new Error('No refresh token available. Please log in again.'); } - const { data } = await apiClient.post(ENDPOINTS.REFRESH, { - refreshToken, - }); + const { data } = await apiClient.post( + ENDPOINTS.REFRESH, + {}, + { + headers: { + // The refresh token is sent in the Authorization header for security, + // preventing it from being logged in server-side request bodies. + Authorization: `Bearer ${refreshToken}`, + }, + } + ); await this._persistSession(data, false); return data; diff --git a/src/services/secureStorage.ts b/src/services/secureStorage.ts index 3ff79e4..f513123 100644 --- a/src/services/secureStorage.ts +++ b/src/services/secureStorage.ts @@ -106,27 +106,52 @@ export function getSecureStoragePlatformInfo(): { // ─── Generic helpers ────────────────────────────────────────────────────────── +/** + * Create a tamper-evident audit log for secure storage access. + * Logs every read/write operation with a timestamp, key name (never the value), + * and a call-site tag. This log is sent to Sentry as a breadcrumb to + * help detect unauthorized access or token theft. + * + * @param action - The action performed (e.g., 'read', 'write', 'delete'). + * @param key - The key being accessed (the value is NEVER logged). + * @param tag - A unique identifier for the call-site (e.g., 'session-refresh'). + */ +function auditLog(action: 'read' | 'write' | 'delete', key: string, tag: string): void { + const breadcrumb = { + category: 'audit.secure_storage', + message: `[${action.toUpperCase()}] key: ${key}`, + level: 'info', + data: { + key, + action, + tag, + platform: Platform.OS, + timestamp: new Date().toISOString(), + }, + }; + + crashReportingService.addBreadcrumb(breadcrumb); + logger.info(`AUDIT: ${action} on ${key} from ${tag}`); +} + /** * Set item in encrypted secure storage * Throws error on failure - no silent fallback */ -async function setItem(key: string, value: string, isSensitive: boolean = true): Promise { +async function setItem( + key: string, + value: string, + isSensitive: boolean = true, + tag: string = 'unknown' +): Promise { try { - // Log sensitivity level (never log the actual value) - if (isSensitive) { - logger.info(`Setting sensitive data in Keychain/Keystore: ${key}`); - } - + auditLog('write', key, tag); await SecureStore.setItemAsync(key, value, SECURE_OPTIONS); - - if (isSensitive) { - logger.info( - `✅ Sensitive data stored securely: ${key} (${Platform.OS}/${Platform.OS === 'ios' ? 'Keychain' : 'Keystore'})` - ); - } } catch (error) { - const errorMsg = `❌ CRITICAL: SecureStorage.set failed for key "${key}": ${error instanceof Error ? error.message : String(error)}`; - logger.error(errorMsg, { key, platform: Platform.OS }); + const errorMsg = `❌ CRITICAL: SecureStorage.set failed for key "${key}": ${ + error instanceof Error ? error.message : String(error) + }`; + logger.error(errorMsg, { key, platform: Platform.OS, tag }); throw error; } } @@ -135,20 +160,21 @@ async function setItem(key: string, value: string, isSensitive: boolean = true): * Get item from encrypted secure storage * Throws error on failure - no silent fallback for sensitive data */ -async function getItem(key: string, isSensitive: boolean = true): Promise { +async function getItem( + key: string, + isSensitive: boolean = true, + tag: string = 'unknown' +): Promise { try { + auditLog('read', key, tag); const value = await SecureStore.getItemAsync(key, SECURE_OPTIONS); - - if (!value && isSensitive) { - logger.warn(`Sensitive data not found in secure storage: ${key}`); - } - return value; } catch (error) { - const errorMsg = `❌ CRITICAL: SecureStorage.get failed for key "${key}": ${error instanceof Error ? error.message : String(error)}`; - logger.error(errorMsg, { key, platform: Platform.OS }); + const errorMsg = `❌ CRITICAL: SecureStorage.get failed for key "${key}": ${ + error instanceof Error ? error.message : String(error) + }`; + logger.error(errorMsg, { key, platform: Platform.OS, tag }); - // For sensitive data, throw error instead of returning null if (isSensitive) { throw error; } @@ -160,12 +186,12 @@ async function getItem(key: string, isSensitive: boolean = true): Promise { +async function removeItem(key: string, tag: string = 'unknown'): Promise { try { + auditLog('delete', key, tag); await SecureStore.deleteItemAsync(key, SECURE_OPTIONS); - logger.info(`Removed item from secure storage: ${key}`); } catch (error) { - logger.error(`SecureStorage.remove failed for key "${key}":`, error); + logger.error(`SecureStorage.remove failed for key "${key}" from ${tag}:`, error); throw error; } } @@ -226,9 +252,9 @@ export async function saveTokens( } await Promise.all([ - setItem(KEYS.ACCESS_TOKEN, accessToken, true), - setItem(KEYS.REFRESH_TOKEN, refreshToken, true), - setItem(KEYS.SESSION_EXPIRES_AT, String(expiresAt), false), + setItem(KEYS.ACCESS_TOKEN, accessToken, true, 'saveTokens'), + setItem(KEYS.REFRESH_TOKEN, refreshToken, true, 'saveTokens'), + setItem(KEYS.SESSION_EXPIRES_AT, String(expiresAt), false, 'saveTokens'), ]); logger.info('✅ Tokens saved securely to Keychain/Keystore', { @@ -242,7 +268,7 @@ export async function saveTokens( * Throws error if retrieval fails (sensitive data) */ export async function getAccessToken(): Promise { - return getItem(KEYS.ACCESS_TOKEN, true); + return getItem(KEYS.ACCESS_TOKEN, true, 'getAccessToken'); } /** @@ -250,14 +276,14 @@ export async function getAccessToken(): Promise { * Throws error if retrieval fails (sensitive data) */ export async function getRefreshToken(): Promise { - return getItem(KEYS.REFRESH_TOKEN, true); + return getItem(KEYS.REFRESH_TOKEN, true, 'getRefreshToken'); } /** * Get session expiration timestamp from secure storage */ export async function getSessionExpiresAt(): Promise { - const raw = await getItem(KEYS.SESSION_EXPIRES_AT, false); + const raw = await getItem(KEYS.SESSION_EXPIRES_AT, false, 'getSessionExpiresAt'); return raw ? Number(raw) : null; } @@ -266,9 +292,9 @@ export async function getSessionExpiresAt(): Promise { */ export async function clearTokens(): Promise { await Promise.all([ - removeItem(KEYS.ACCESS_TOKEN), - removeItem(KEYS.REFRESH_TOKEN), - removeItem(KEYS.SESSION_EXPIRES_AT), + removeItem(KEYS.ACCESS_TOKEN, 'clearTokens'), + removeItem(KEYS.REFRESH_TOKEN, 'clearTokens'), + removeItem(KEYS.SESSION_EXPIRES_AT, 'clearTokens'), ]); logger.info('✅ All authentication tokens cleared from Keychain/Keystore'); @@ -285,7 +311,7 @@ export async function saveUserData(user: Record): Promise throw new Error('SecureStorage not initialized - cannot save user data'); } - await setItem(KEYS.USER_DATA, JSON.stringify(user), true); + await setItem(KEYS.USER_DATA, JSON.stringify(user), true, 'saveUserData'); logger.info('✅ User data saved securely to Keychain/Keystore'); } @@ -293,7 +319,7 @@ export async function saveUserData(user: Record): Promise * Get user profile data from encrypted storage */ export async function getUserData>(): Promise { - const raw = await getItem(KEYS.USER_DATA, true); + const raw = await getItem(KEYS.USER_DATA, true, 'getUserData'); if (!raw) return null; try { return JSON.parse(raw) as T; @@ -307,7 +333,7 @@ export async function getUserData>(): Promise { - await removeItem(KEYS.USER_DATA); + await removeItem(KEYS.USER_DATA, 'clearUserData'); logger.info('✅ User data cleared from Keychain/Keystore'); } @@ -317,7 +343,7 @@ export async function clearUserData(): Promise { * Save biometric authentication preference to secure storage */ export async function setBiometricEnabled(enabled: boolean): Promise { - await setItem(KEYS.BIOMETRIC_ENABLED, enabled ? '1' : '0', false); + await setItem(KEYS.BIOMETRIC_ENABLED, enabled ? '1' : '0', false, 'setBiometricEnabled'); logger.info(`Biometric setting updated: ${enabled ? 'enabled' : 'disabled'}`); } @@ -325,7 +351,7 @@ export async function setBiometricEnabled(enabled: boolean): Promise { * Check if biometric authentication is enabled */ export async function isBiometricEnabled(): Promise { - const value = await getItem(KEYS.BIOMETRIC_ENABLED, false); + const value = await getItem(KEYS.BIOMETRIC_ENABLED, false, 'isBiometricEnabled'); return value === '1'; } @@ -352,7 +378,7 @@ export async function isBiometricEnabled(): Promise { * @param enrollmentId A UUID that uniquely identifies the enrollment session. */ export async function saveBiometricEnrollmentId(enrollmentId: string): Promise { - await setItem(KEYS.BIOMETRIC_ENROLLMENT_ID, enrollmentId, false); + await setItem(KEYS.BIOMETRIC_ENROLLMENT_ID, enrollmentId, false, 'saveBiometricEnrollmentId'); logger.info('Biometric enrollment id saved to secure storage'); } @@ -362,7 +388,7 @@ export async function saveBiometricEnrollmentId(enrollmentId: string): Promise { - return getItem(KEYS.BIOMETRIC_ENROLLMENT_ID, false); + return getItem(KEYS.BIOMETRIC_ENROLLMENT_ID, false, 'getBiometricEnrollmentId'); } /** @@ -371,7 +397,7 @@ export async function getBiometricEnrollmentId(): Promise { * Called during re-enrollment or when biometric login is disabled. */ export async function clearBiometricEnrollmentId(): Promise { - await removeItem(KEYS.BIOMETRIC_ENROLLMENT_ID); + await removeItem(KEYS.BIOMETRIC_ENROLLMENT_ID, 'clearBiometricEnrollmentId'); logger.info('Biometric enrollment id cleared from secure storage'); } @@ -530,7 +556,7 @@ export async function verifyBiometricOnReinstall(): Promise { * Save remembered email to secure storage */ export async function saveRememberedEmail(email: string): Promise { - await setItem(KEYS.REMEMBERED_EMAIL, email, false); + await setItem(KEYS.REMEMBERED_EMAIL, email, false, 'saveRememberedEmail'); logger.info('Email address remembered in secure storage'); } @@ -538,14 +564,14 @@ export async function saveRememberedEmail(email: string): Promise { * Get remembered email from secure storage */ export async function getRememberedEmail(): Promise { - return getItem(KEYS.REMEMBERED_EMAIL, false); + return getItem(KEYS.REMEMBERED_EMAIL, false, 'getRememberedEmail'); } /** * Save remember-me preference to secure storage */ export async function setRememberMe(enabled: boolean): Promise { - await setItem(KEYS.REMEMBER_ME, enabled ? '1' : '0', false); + await setItem(KEYS.REMEMBER_ME, enabled ? '1' : '0', false, 'setRememberMe'); logger.info(`Remember me setting updated: ${enabled ? 'enabled' : 'disabled'}`); } @@ -553,7 +579,7 @@ export async function setRememberMe(enabled: boolean): Promise { * Check if remember-me is enabled */ export async function isRememberMeEnabled(): Promise { - const value = await getItem(KEYS.REMEMBER_ME, false); + const value = await getItem(KEYS.REMEMBER_ME, false, 'isRememberMeEnabled'); return value === '1'; } @@ -565,7 +591,9 @@ export async function isRememberMeEnabled(): Promise { */ export async function clearAllAuthData(): Promise { try { - await Promise.all(Object.values(KEYS).map(removeItem)); + await Promise.all( + Object.values(KEYS).map(key => removeItem(key, 'clearAllAuthData')) + ); logger.info('✅ All secure data cleared from Keychain/Keystore'); } catch (error) { logger.error('Error clearing all auth data from secure storage:', error); @@ -661,4 +689,4 @@ export const STORAGE_SENSITIVE_KEYS = SENSITIVE_KEYS; // ─── Test Helpers ───────────────────────────────────────────────────────────── export function __resetSecureStorageVerification__(): void { isSecureStorageVerified = false; -} +} \ No newline at end of file