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
This commit is contained in:
2026-05-17 19:15:42 -04:00
parent 06ca3ec0cf
commit a8a5930ced
15 changed files with 290 additions and 32 deletions

View File

@@ -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;

View File

@@ -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<boolean> => {
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 };
}

View File

@@ -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 {

View File

@@ -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<NavigationProp<RootStackParamList>>();
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);
}
};

View File

@@ -11,9 +11,28 @@ export function DarkWatchScreen() {
const [newItemName, setNewItemName] = useState('');
const [newItemValue, setNewItemValue] = useState('');
const [newItemType, setNewItemType] = useState<WatchListItem['entityType']>('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() {
<View style={styles.modalContent}>
<Text style={styles.modalTitle}>Add Watch Item</Text>
{validationError && (
<Text style={{ color: COLORS.danger, fontSize: FONT_SIZES.sm, marginBottom: SPACING.sm }}>
{validationError}
</Text>
)}
<Input
label="Name"
placeholder="e.g., John Doe"

View File

@@ -80,7 +80,18 @@ export function SettingsScreen() {
value={features.maxExposures === -1 ? 'Unlimited' : `${features.maxExposures}`}
/>
</View>
<Button title="Upgrade Plan" onPress={() => {}} variant="secondary" fullWidth />
<Button
title="Upgrade Plan"
onPress={() =>
Alert.alert(
'Upgrade Plan',
'Billing integration coming soon. Contact support@shieldai.freno.me to upgrade.',
[{ text: 'OK' }]
)
}
variant="secondary"
fullWidth
/>
</Card>
<Card title="Notifications">

View File

@@ -1,6 +1,11 @@
import { createApiClient } from '@shieldai/mobile-api-client';
import { API_URL } from '@/constants/theme';
// TODO SEC-007: Add certificate pinning for production builds.
// For Expo managed workflow, use expo-config-plugin with
// react-native-config or implement via EAS build post-install hook.
// Reference: https://docs.expo.dev/guides/using-network-security/
createApiClient({
baseURL: API_URL,
timeout: 30000,

View File

@@ -0,0 +1,27 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { StateStorage } from 'zustand/middleware';
import { createJSONStorage } from 'zustand/middleware';
import { encryptText, decryptText } from './secureStorage';
function createEncryptedAsyncStorage(): StateStorage {
return {
getItem: async (name: string): Promise<string | null> => {
const encrypted = await AsyncStorage.getItem(name);
if (encrypted === null) return null;
try {
return await decryptText(encrypted);
} catch {
return null;
}
},
setItem: async (name: string, value: string): Promise<void> => {
const encrypted = await encryptText(value);
await AsyncStorage.setItem(name, encrypted);
},
removeItem: async (name: string): Promise<void> => {
await AsyncStorage.removeItem(name);
},
};
}
export const encryptedStorage = createJSONStorage(() => createEncryptedAsyncStorage());

View File

@@ -1 +1,2 @@
export * from './api';
export * from './secureStorage';

View File

@@ -0,0 +1,75 @@
import * as SecureStore from 'expo-secure-store';
import { randomUUID } from 'expo-crypto';
const ACCESS_TOKEN_KEY = '@shieldai_access_token';
const REFRESH_TOKEN_KEY = '@shieldai_refresh_token';
export async function getAccessToken(): Promise<string | null> {
return SecureStore.getItemAsync(ACCESS_TOKEN_KEY);
}
export async function setAccessToken(token: string): Promise<void> {
await SecureStore.setItemAsync(ACCESS_TOKEN_KEY, token);
}
export async function getRefreshToken(): Promise<string | null> {
return SecureStore.getItemAsync(REFRESH_TOKEN_KEY);
}
export async function setRefreshToken(token: string): Promise<void> {
await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, token);
}
export async function clearTokens(): Promise<void> {
try {
await SecureStore.deleteItemAsync(ACCESS_TOKEN_KEY);
} catch { /* may not exist */ }
try {
await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY);
} catch { /* may not exist */ }
}
// XOR cipher for PII in AsyncStorage.
// Key stored in SecureStore (Keychain/Keystore).
const CIPHER_KEY_FILE = '@shieldai_cipher_key';
async function getCipherKey(): Promise<number[]> {
let keyStr = await SecureStore.getItemAsync(CIPHER_KEY_FILE);
if (!keyStr) {
keyStr = randomUUID();
await SecureStore.setItemAsync(CIPHER_KEY_FILE, keyStr);
}
return keyStr.split('').map((c) => c.charCodeAt(0));
}
function xorEncode(str: string, key: number[]): string {
const codePoints: number[] = [];
for (let i = 0; i < str.length; i++) {
codePoints.push(str.charCodeAt(i) ^ key[i % key.length]);
}
// Encode as base64 using Buffer (available in RN)
return Buffer.from(codePoints).toString('base64');
}
function xorDecode(encoded: string, key: number[]): string {
try {
const codePoints = Array.from(Buffer.from(encoded, 'base64'));
const chars: string[] = [];
for (let i = 0; i < codePoints.length; i++) {
chars.push(String.fromCharCode(codePoints[i] ^ key[i % key.length]));
}
return chars.join('');
} catch {
return '';
}
}
export async function encryptText(plain: string): Promise<string> {
const key = await getCipherKey();
return xorEncode(plain, key);
}
export async function decryptText(encrypted: string): Promise<string> {
const key = await getCipherKey();
return xorDecode(encrypted, key);
}

View File

@@ -1,13 +1,19 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { persist } from 'zustand/middleware';
import type { User } from '@/types';
import { authService } from '@shieldai/mobile-api-client';
import { AUTH_STORAGE_KEY } from '@/constants';
import {
getAccessToken,
setAccessToken,
getRefreshToken,
setRefreshToken,
clearTokens,
} from '@/services/secureStorage';
import { encryptedStorage } from '@/services/encryptedStorage';
interface AuthState {
user: User | null;
accessToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
@@ -16,6 +22,8 @@ interface AuthState {
logout: () => Promise<void>;
clearError: () => void;
setUser: (user: User | null) => void;
refreshSession: () => Promise<boolean>;
hydrateTokens: () => Promise<void>;
}
const toAppUser = (apiUser: any): User => ({
@@ -31,7 +39,6 @@ export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
accessToken: null,
isAuthenticated: false,
isLoading: false,
error: null,
@@ -40,9 +47,12 @@ export const useAuthStore = create<AuthState>()(
set({ isLoading: true, error: null });
try {
const { user: apiUser, tokens } = await authService.login({ email, password });
await setAccessToken(tokens.accessToken);
if (tokens.refreshToken) {
await setRefreshToken(tokens.refreshToken);
}
set({
user: toAppUser(apiUser),
accessToken: tokens.accessToken,
isAuthenticated: true,
isLoading: false,
});
@@ -64,9 +74,12 @@ export const useAuthStore = create<AuthState>()(
firstName,
lastName,
});
await setAccessToken(tokens.accessToken);
if (tokens.refreshToken) {
await setRefreshToken(tokens.refreshToken);
}
set({
user: toAppUser(apiUser),
accessToken: tokens.accessToken,
isAuthenticated: true,
isLoading: false,
});
@@ -83,24 +96,55 @@ export const useAuthStore = create<AuthState>()(
try {
await authService.logout();
} finally {
await clearTokens();
set({
user: null,
accessToken: null,
isAuthenticated: false,
error: null,
});
}
},
refreshSession: async (): Promise<boolean> => {
const refreshToken = await getRefreshToken();
if (!refreshToken) return false;
try {
const newTokens = await authService.refreshToken();
await setAccessToken(newTokens.accessToken);
if (newTokens.refreshToken) {
await setRefreshToken(newTokens.refreshToken);
}
return true;
} catch {
await clearTokens();
set({ user: null, isAuthenticated: false });
return false;
}
},
hydrateTokens: async () => {
const token = await getAccessToken();
if (token) {
try {
const user = await authService.getCurrentUser();
if (user) {
set({ user: toAppUser(user), isAuthenticated: true });
}
} catch {
await clearTokens();
set({ user: null, isAuthenticated: false });
}
}
},
clearError: () => set({ error: null }),
setUser: (user) => set({ user, isAuthenticated: !!user }),
}),
{
name: AUTH_STORAGE_KEY,
storage: createJSONStorage(() => AsyncStorage),
storage: encryptedStorage,
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
isAuthenticated: state.isAuthenticated,
}),
}

View File

@@ -1,8 +1,8 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { persist } from 'zustand/middleware';
import { randomUUID } from 'expo-crypto';
import type { WatchListItem, Exposure } from '@/types';
import { encryptedStorage } from '@/services/encryptedStorage';
/**
* TODO: Wire store operations to @shieldai/mobile-api-client for production.
@@ -62,7 +62,7 @@ export const useDarkWatchStore = create<DarkWatchState>()(
}),
{
name: '@shieldai_darkwatch',
storage: createJSONStorage(() => AsyncStorage),
storage: encryptedStorage,
}
)
);

View File

@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { persist } from 'zustand/middleware';
import type { NotificationPreference } from '@/types';
import { encryptedStorage } from '@/services/encryptedStorage';
/**
* TODO: Wire updatePreferences to notificationService.updatePreferences() for production.
@@ -39,7 +39,7 @@ export const useSettingsStore = create<SettingsState>()(
}),
{
name: '@shieldai_settings',
storage: createJSONStorage(() => AsyncStorage),
storage: encryptedStorage,
}
)
);

View File

@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { persist } from 'zustand/middleware';
import type { SpamRecord } from '@/types';
import { encryptedStorage } from '@/services/encryptedStorage';
/**
* TODO: Wire store operations to @shieldai/mobile-api-client for production.
@@ -57,7 +57,7 @@ export const useSpamShieldStore = create<SpamShieldState>()(
}),
{
name: '@shieldai_spamshield',
storage: createJSONStorage(() => AsyncStorage),
storage: encryptedStorage,
}
)
);

View File

@@ -1,8 +1,8 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { persist } from 'zustand/middleware';
import { randomUUID } from 'expo-crypto';
import type { VoiceProfile, VoiceAnalysis } from '@/types';
import { encryptedStorage } from '@/services/encryptedStorage';
/**
* TODO: Wire store operations to @shieldai/mobile-api-client for production.
@@ -50,7 +50,7 @@ export const useVoicePrintStore = create<VoicePrintState>()(
}),
{
name: '@shieldai_voiceprint',
storage: createJSONStorage(() => AsyncStorage),
storage: encryptedStorage,
}
)
);