From a8a5930ced0ffba6037475ea003798c4fbf024c7 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 17 May 2026 19:15:42 -0400 Subject: [PATCH] security: fix 10 security review findings (FRE-4572) CRITICAL: - SEC-001: Auth tokens now stored in SecureStore (Keychain/Keystore) - SEC-002: Biometric bypass removed - alerts user and disables when unavailable HIGH: - SEC-003: Push projectId moved to EXPO_PUBLIC_EAS_PROJECT_ID env var - SEC-004: Token refresh mechanism added with refreshSession/hydrateTokens - SEC-005: debug already gated on __DEV__ (confirmed) MEDIUM: - SEC-006: All PII stores (darkwatch, voiceprint, spamshield, settings, auth) now use encrypted AsyncStorage - SEC-007: Certificate pinning documented with TODO for production - SEC-008: Login brute force protection: 5 attempts then 5-minute lockout LOW: - SEC-009: Watch list input validation with format checks per entity type - SEC-010: Upgrade Plan button shows billing coming soon alert --- packages/mobile/src/constants/theme.ts | 2 + packages/mobile/src/hooks/useBiometricAuth.ts | 37 +++++++-- .../mobile/src/hooks/usePushNotifications.ts | 8 +- .../mobile/src/screens/auth/LoginScreen.tsx | 41 +++++++++- .../src/screens/darkwatch/DarkWatchScreen.tsx | 27 ++++++- .../src/screens/settings/SettingsScreen.tsx | 13 +++- packages/mobile/src/services/api.ts | 5 ++ .../mobile/src/services/encryptedStorage.ts | 27 +++++++ packages/mobile/src/services/index.ts | 1 + packages/mobile/src/services/secureStorage.ts | 75 +++++++++++++++++++ packages/mobile/src/store/authStore.ts | 62 ++++++++++++--- packages/mobile/src/store/darkWatchStore.ts | 6 +- packages/mobile/src/store/settingsStore.ts | 6 +- packages/mobile/src/store/spamShieldStore.ts | 6 +- packages/mobile/src/store/voicePrintStore.ts | 6 +- 15 files changed, 290 insertions(+), 32 deletions(-) create mode 100644 packages/mobile/src/services/encryptedStorage.ts create mode 100644 packages/mobile/src/services/secureStorage.ts diff --git a/packages/mobile/src/constants/theme.ts b/packages/mobile/src/constants/theme.ts index c661414..9748830 100644 --- a/packages/mobile/src/constants/theme.ts +++ b/packages/mobile/src/constants/theme.ts @@ -52,6 +52,8 @@ export const SHADOWS = { export const API_URL = process.env.EXPO_PUBLIC_API_URL || 'https://api.shieldai.freno.me/api/v1'; +export const EAS_PROJECT_ID = process.env.EXPO_PUBLIC_EAS_PROJECT_ID || ''; + export const getSeverityColor = (severity: string): string => { switch (severity) { case 'critical': return COLORS.danger; diff --git a/packages/mobile/src/hooks/useBiometricAuth.ts b/packages/mobile/src/hooks/useBiometricAuth.ts index 501f23e..b1ba01e 100644 --- a/packages/mobile/src/hooks/useBiometricAuth.ts +++ b/packages/mobile/src/hooks/useBiometricAuth.ts @@ -1,24 +1,42 @@ -import { useCallback } from 'react'; +import { useCallback, useState, useEffect } from 'react'; import * as LocalAuthentication from 'expo-local-authentication'; +import { Alert } from 'react-native'; import { useSettingsStore } from '@/store/settingsStore'; export function useBiometricAuth() { const { isBiometricEnabled } = useSettingsStore(); + const [biometricsAvailable, setBiometricsAvailable] = useState(false); + + useEffect(() => { + (async () => { + const hasHardware = await LocalAuthentication.hasHardwareAsync(); + const isEnrolled = await LocalAuthentication.isEnrolledAsync(); + setBiometricsAvailable(hasHardware && isEnrolled); + })(); + }, []); const authenticate = useCallback(async (): Promise => { if (!isBiometricEnabled) return true; try { const isAvailable = await LocalAuthentication.hasHardwareAsync(); - if (!isAvailable) return true; - const isEnrolled = await LocalAuthentication.isEnrolledAsync(); - if (!isEnrolled) return true; + + if (!isAvailable || !isEnrolled) { + Alert.alert( + 'Biometric Unavailable', + 'Biometric authentication requires hardware and enrollment. Please use your password.', + [{ text: 'OK' }] + ); + useSettingsStore.getState().toggleBiometric(false); + return false; + } const result = await LocalAuthentication.authenticateAsync({ promptMessage: 'Authenticate with biometrics', fallbackLabel: 'Use passcode', cancelLabel: 'Cancel', + disableDeviceFallback: false, }); return result.success; @@ -31,7 +49,14 @@ export function useBiometricAuth() { const isAvailable = await LocalAuthentication.hasHardwareAsync(); const isEnrolled = await LocalAuthentication.isEnrolledAsync(); - if (!isAvailable || !isEnrolled) return false; + if (!isAvailable || !isEnrolled) { + Alert.alert( + 'Biometric Not Available', + 'Your device does not support biometric authentication or no credentials are enrolled.', + [{ text: 'OK' }] + ); + return false; + } const result = await LocalAuthentication.authenticateAsync({ promptMessage: 'Enable biometric authentication', @@ -49,5 +74,5 @@ export function useBiometricAuth() { useSettingsStore.getState().toggleBiometric(false); }, []); - return { authenticate, enableBiometric, disableBiometric, isBiometricEnabled }; + return { authenticate, enableBiometric, disableBiometric, isBiometricEnabled, biometricsAvailable }; } diff --git a/packages/mobile/src/hooks/usePushNotifications.ts b/packages/mobile/src/hooks/usePushNotifications.ts index 07bd8c9..96a4692 100644 --- a/packages/mobile/src/hooks/usePushNotifications.ts +++ b/packages/mobile/src/hooks/usePushNotifications.ts @@ -4,6 +4,7 @@ import { Platform } from 'react-native'; import * as Device from 'expo-device'; import { deviceService, notificationService } from '@shieldai/mobile-api-client'; import { useSettingsStore } from '@/store/settingsStore'; +import { EAS_PROJECT_ID } from '@/constants/theme'; export function usePushNotifications() { const preferencesRef = useRef(useSettingsStore.getState().preferences); @@ -40,8 +41,13 @@ export function usePushNotifications() { return null; } + if (!EAS_PROJECT_ID) { + console.warn('EAS_PROJECT_ID not configured — push notifications disabled'); + return null; + } + const token = (await Notifications.getExpoPushTokenAsync({ - projectId: 'shieldai-project-id', + projectId: EAS_PROJECT_ID, })).data; try { diff --git a/packages/mobile/src/screens/auth/LoginScreen.tsx b/packages/mobile/src/screens/auth/LoginScreen.tsx index d71b861..bfdde75 100644 --- a/packages/mobile/src/screens/auth/LoginScreen.tsx +++ b/packages/mobile/src/screens/auth/LoginScreen.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { StyleSheet, Text, View, SafeAreaView, KeyboardAvoidingView, Platform, ScrollView, Alert } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { useNavigation, NavigationProp } from '@react-navigation/native'; import { useAuthStore } from '@/store/authStore'; import { Button, Input } from '@/components'; @@ -10,6 +11,10 @@ type RootStackParamList = { Register: undefined; }; +const LOGIN_ATTEMPT_KEY = '@shieldai_login_attempts'; +const MAX_ATTEMPTS = 5; +const LOCKOUT_MS = 5 * 60 * 1000; + export function LoginScreen() { const navigation = useNavigation>(); const { login, isLoading, clearError } = useAuthStore(); @@ -27,11 +32,43 @@ export function LoginScreen() { return; } + const now = Date.now(); + const stored = await AsyncStorage.getItem(LOGIN_ATTEMPT_KEY); + let attempts = { count: 0, lockedUntil: 0 }; + if (stored) { + try { + attempts = JSON.parse(stored); + } catch { /* ignore corrupt data */ } + } + + if (attempts.lockedUntil > now) { + const remaining = Math.ceil((attempts.lockedUntil - now) / 1000 / 60); + setFormError(`Too many failed attempts. Try again in ${remaining} minute${remaining !== 1 ? 's' : ''}.`); + return; + } + + if (attempts.count >= MAX_ATTEMPTS && attempts.lockedUntil <= now) { + attempts.lockedUntil = now + LOCKOUT_MS; + attempts.count = 0; + await AsyncStorage.setItem(LOGIN_ATTEMPT_KEY, JSON.stringify(attempts)); + const remaining = Math.ceil(LOCKOUT_MS / 1000 / 60); + setFormError(`Too many failed attempts. Locked for ${remaining} minutes.`); + return; + } + try { await login(email, password); + await AsyncStorage.removeItem(LOGIN_ATTEMPT_KEY); } catch (err: any) { - setFormError(err.message || 'Login failed. Please try again.'); - Alert.alert('Login Failed', err.message || 'Please check your credentials and try again.'); + attempts.count = (attempts.count || 0) + 1; + if (attempts.count >= MAX_ATTEMPTS) { + attempts.lockedUntil = Date.now() + LOCKOUT_MS; + } + await AsyncStorage.setItem(LOGIN_ATTEMPT_KEY, JSON.stringify(attempts)); + const remaining = MAX_ATTEMPTS - attempts.count; + const warning = remaining > 0 ? ` ${remaining} attempt${remaining !== 1 ? 's' : ''} remaining.` : ''; + setFormError(err.message || 'Login failed. Please try again.' + warning); + Alert.alert('Login Failed', err.message || 'Please check your credentials and try again.' + warning); } }; diff --git a/packages/mobile/src/screens/darkwatch/DarkWatchScreen.tsx b/packages/mobile/src/screens/darkwatch/DarkWatchScreen.tsx index af646df..e55573b 100644 --- a/packages/mobile/src/screens/darkwatch/DarkWatchScreen.tsx +++ b/packages/mobile/src/screens/darkwatch/DarkWatchScreen.tsx @@ -11,9 +11,28 @@ export function DarkWatchScreen() { const [newItemName, setNewItemName] = useState(''); const [newItemValue, setNewItemValue] = useState(''); const [newItemType, setNewItemType] = useState('person'); + const [validationError, setValidationError] = useState(''); + + const validateWatchItem = (name: string, value: string, type: string): string | null => { + if (!name.trim() || !value.trim()) return 'Name and value are required'; + if (name.length > 100) return 'Name must be 100 characters or less'; + if (value.length > 200) return 'Value must be 200 characters or less'; + if (type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { + return 'Enter a valid email address'; + } + if (type === 'phone' && !/^\+?[\d\s-()]{7,20}$/.test(value)) { + return 'Enter a valid phone number'; + } + return null; + }; const handleAddItem = async () => { - if (!newItemName || !newItemValue) return; + const error = validateWatchItem(newItemName, newItemValue, newItemType); + if (error) { + setValidationError(error); + return; + } + setValidationError(''); await addWatchItem({ name: newItemName, @@ -125,6 +144,12 @@ export function DarkWatchScreen() { Add Watch Item + {validationError && ( + + {validationError} + + )} + -