diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 3fe98527..53e66397 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -18,6 +18,8 @@ "@react-navigation/stack": "^7.6.4", "expo": "~54.0.23", "expo-linear-gradient": "^15.0.7", + "expo-local-authentication": "^17.0.7", + "expo-secure-store": "^15.0.7", "expo-status-bar": "~3.0.8", "i18next": "^25.6.2", "react": "19.1.0", @@ -5439,6 +5441,18 @@ "react-native": "*" } }, + "node_modules/expo-local-authentication": { + "version": "17.0.7", + "resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-17.0.7.tgz", + "integrity": "sha512-yRWcgYn/OIwxEDEk7cM7tRjQSHaTp5hpKwzq+g9NmSMJ1etzUzt0yGzkDiOjObj3YqFo0ucyDJ8WfanLhZDtMw==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.21", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.21.tgz", @@ -5538,6 +5552,15 @@ "react-native": "*" } }, + "node_modules/expo-secure-store": { + "version": "15.0.7", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.7.tgz", + "integrity": "sha512-9q7+G1Zxr5P6J5NRIlm86KulvmYwc6UnQlYPjQLDu1drDnerz6AT6l884dPu29HgtDTn4rR0heYeeGFhMKM7/Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.4.tgz", diff --git a/mobile/package.json b/mobile/package.json index aac06cd6..8b507d30 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -19,6 +19,8 @@ "@react-navigation/stack": "^7.6.4", "expo": "~54.0.23", "expo-linear-gradient": "^15.0.7", + "expo-local-authentication": "^17.0.7", + "expo-secure-store": "^15.0.7", "expo-status-bar": "~3.0.8", "i18next": "^25.6.2", "react": "19.1.0", diff --git a/mobile/src/contexts/BiometricAuthContext.tsx b/mobile/src/contexts/BiometricAuthContext.tsx new file mode 100644 index 00000000..f008ff4e --- /dev/null +++ b/mobile/src/contexts/BiometricAuthContext.tsx @@ -0,0 +1,343 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import * as LocalAuthentication from 'expo-local-authentication'; +import * as SecureStore from 'expo-secure-store'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +/** + * CRITICAL SECURITY NOTE: + * ALL DATA STAYS ON DEVICE - NEVER SENT TO SERVER + * + * Storage Strategy: + * - Biometric settings: AsyncStorage (local device only) + * - PIN code: SecureStore (encrypted on device) + * - Lock timer: AsyncStorage (local device only) + * - Last unlock time: AsyncStorage (local device only) + * + * NO DATA IS EVER TRANSMITTED TO EXTERNAL SERVERS + */ + +const BIOMETRIC_ENABLED_KEY = '@biometric_enabled'; // Local only +const PIN_CODE_KEY = 'user_pin_code'; // Encrypted SecureStore +const AUTO_LOCK_TIMER_KEY = '@auto_lock_timer'; // Local only +const LAST_UNLOCK_TIME_KEY = '@last_unlock_time'; // Local only + +interface BiometricAuthContextType { + isBiometricSupported: boolean; + isBiometricEnrolled: boolean; + biometricType: 'fingerprint' | 'facial' | 'iris' | 'none'; + isBiometricEnabled: boolean; + isLocked: boolean; + autoLockTimer: number; // minutes + authenticate: () => Promise; + enableBiometric: () => Promise; + disableBiometric: () => Promise; + setPinCode: (pin: string) => Promise; + verifyPinCode: (pin: string) => Promise; + setAutoLockTimer: (minutes: number) => Promise; + lock: () => void; + unlock: () => void; + checkAutoLock: () => Promise; +} + +const BiometricAuthContext = createContext( + undefined +); + +export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [isBiometricSupported, setIsBiometricSupported] = useState(false); + const [isBiometricEnrolled, setIsBiometricEnrolled] = useState(false); + const [biometricType, setBiometricType] = useState<'fingerprint' | 'facial' | 'iris' | 'none'>('none'); + const [isBiometricEnabled, setIsBiometricEnabled] = useState(false); + const [isLocked, setIsLocked] = useState(true); + const [autoLockTimer, setAutoLockTimerState] = useState(5); // Default 5 minutes + + useEffect(() => { + initBiometric(); + loadSettings(); + }, []); + + /** + * Initialize biometric capabilities + * Checks device support - NO DATA SENT ANYWHERE + */ + const initBiometric = async () => { + try { + // Check if device supports biometrics + const compatible = await LocalAuthentication.hasHardwareAsync(); + setIsBiometricSupported(compatible); + + if (compatible) { + // Check if user has enrolled biometrics + const enrolled = await LocalAuthentication.isEnrolledAsync(); + setIsBiometricEnrolled(enrolled); + + // Get supported authentication types + const types = await LocalAuthentication.supportedAuthenticationTypesAsync(); + + if (types.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION)) { + setBiometricType('facial'); + } else if (types.includes(LocalAuthentication.AuthenticationType.FINGERPRINT)) { + setBiometricType('fingerprint'); + } else if (types.includes(LocalAuthentication.AuthenticationType.IRIS)) { + setBiometricType('iris'); + } + } + } catch (error) { + console.error('Biometric init error:', error); + } + }; + + /** + * Load settings from LOCAL STORAGE ONLY + * Data never leaves the device + */ + const loadSettings = async () => { + try { + // Load biometric enabled status (local only) + const enabled = await AsyncStorage.getItem(BIOMETRIC_ENABLED_KEY); + setIsBiometricEnabled(enabled === 'true'); + + // Load auto-lock timer (local only) + const timer = await AsyncStorage.getItem(AUTO_LOCK_TIMER_KEY); + if (timer) { + setAutoLockTimerState(parseInt(timer, 10)); + } + + // Check if app should be locked + await checkAutoLock(); + } catch (error) { + console.error('Error loading settings:', error); + } + }; + + /** + * Authenticate using biometric + * Authentication happens ON DEVICE ONLY + */ + const authenticate = async (): Promise => { + try { + if (!isBiometricSupported || !isBiometricEnrolled) { + return false; + } + + const result = await LocalAuthentication.authenticateAsync({ + promptMessage: 'Authenticate to unlock PezkuwiChain', + cancelLabel: 'Cancel', + disableDeviceFallback: false, + fallbackLabel: 'Use PIN', + }); + + if (result.success) { + unlock(); + return true; + } + + return false; + } catch (error) { + console.error('Authentication error:', error); + return false; + } + }; + + /** + * Enable biometric authentication + * Settings saved LOCALLY ONLY + */ + const enableBiometric = async (): Promise => { + try { + // First authenticate to enable + const authenticated = await authenticate(); + + if (authenticated) { + // Save enabled status LOCALLY + await AsyncStorage.setItem(BIOMETRIC_ENABLED_KEY, 'true'); + setIsBiometricEnabled(true); + return true; + } + + return false; + } catch (error) { + console.error('Enable biometric error:', error); + return false; + } + }; + + /** + * Disable biometric authentication + * Settings saved LOCALLY ONLY + */ + const disableBiometric = async (): Promise => { + try { + await AsyncStorage.setItem(BIOMETRIC_ENABLED_KEY, 'false'); + setIsBiometricEnabled(false); + } catch (error) { + console.error('Disable biometric error:', error); + } + }; + + /** + * Set PIN code as backup + * PIN stored in ENCRYPTED SECURE STORE on device + * NEVER sent to server + */ + const setPinCode = async (pin: string): Promise => { + try { + // Hash the PIN before storing (simple hash for demo) + // In production, use proper cryptographic hashing + const hashedPin = await hashPin(pin); + + // Store in SecureStore (encrypted on device) + await SecureStore.setItemAsync(PIN_CODE_KEY, hashedPin); + } catch (error) { + console.error('Set PIN error:', error); + throw error; + } + }; + + /** + * Verify PIN code + * Verification happens LOCALLY on device + */ + const verifyPinCode = async (pin: string): Promise => { + try { + // Get stored PIN from SecureStore (local encrypted storage) + const storedPin = await SecureStore.getItemAsync(PIN_CODE_KEY); + + if (!storedPin) { + return false; + } + + // Hash entered PIN + const hashedPin = await hashPin(pin); + + // Compare (happens on device) + if (hashedPin === storedPin) { + unlock(); + return true; + } + + return false; + } catch (error) { + console.error('Verify PIN error:', error); + return false; + } + }; + + /** + * Simple PIN hashing (for demo) + * In production, use bcrypt or similar + * All happens ON DEVICE + */ + const hashPin = async (pin: string): Promise => { + // Simple hash for demo - replace with proper crypto in production + let hash = 0; + for (let i = 0; i < pin.length; i++) { + const char = pin.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return hash.toString(); + }; + + /** + * Set auto-lock timer + * Setting saved LOCALLY ONLY + */ + const setAutoLockTimer = async (minutes: number): Promise => { + try { + await AsyncStorage.setItem(AUTO_LOCK_TIMER_KEY, minutes.toString()); + setAutoLockTimerState(minutes); + } catch (error) { + console.error('Set auto-lock timer error:', error); + } + }; + + /** + * Lock the app + * State change is LOCAL ONLY + */ + const lock = () => { + setIsLocked(true); + }; + + /** + * Unlock the app + * Saves timestamp LOCALLY for auto-lock + */ + const unlock = async () => { + setIsLocked(false); + + // Save unlock time LOCALLY for auto-lock check + try { + await AsyncStorage.setItem(LAST_UNLOCK_TIME_KEY, Date.now().toString()); + } catch (error) { + console.error('Save unlock time error:', error); + } + }; + + /** + * Check if app should auto-lock + * All checks happen LOCALLY + */ + const checkAutoLock = async (): Promise => { + try { + // Get last unlock time from LOCAL storage + const lastUnlockTime = await AsyncStorage.getItem(LAST_UNLOCK_TIME_KEY); + + if (!lastUnlockTime) { + // First time or no previous unlock - lock the app + setIsLocked(true); + return; + } + + const lastUnlock = parseInt(lastUnlockTime, 10); + const now = Date.now(); + const minutesPassed = (now - lastUnlock) / 1000 / 60; + + // If more time passed than timer, lock the app + if (minutesPassed >= autoLockTimer) { + setIsLocked(true); + } else { + setIsLocked(false); + } + } catch (error) { + console.error('Check auto-lock error:', error); + // On error, lock for safety + setIsLocked(true); + } + }; + + return ( + + {children} + + ); +}; + +export const useBiometricAuth = () => { + const context = useContext(BiometricAuthContext); + if (context === undefined) { + throw new Error('useBiometricAuth must be used within BiometricAuthProvider'); + } + return context; +}; diff --git a/mobile/src/screens/LockScreen.tsx b/mobile/src/screens/LockScreen.tsx new file mode 100644 index 00000000..2591f92e --- /dev/null +++ b/mobile/src/screens/LockScreen.tsx @@ -0,0 +1,316 @@ +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + Image, + Pressable, + Alert, +} from 'react-native'; +import { useBiometricAuth } from '../contexts/BiometricAuthContext'; +import { AppColors, KurdistanColors } from '../theme/colors'; +import { Button, Input } from '../components'; + +/** + * Lock Screen + * Shown when app is locked - requires biometric or PIN + * + * PRIVACY: All authentication happens locally + */ +export default function LockScreen() { + const { + isBiometricSupported, + isBiometricEnrolled, + isBiometricEnabled, + biometricType, + authenticate, + verifyPinCode, + unlock, + } = useBiometricAuth(); + + const [showPinInput, setShowPinInput] = useState(false); + const [pin, setPin] = useState(''); + const [verifying, setVerifying] = useState(false); + + useEffect(() => { + // Auto-trigger biometric on mount if enabled + if (isBiometricEnabled && isBiometricSupported && isBiometricEnrolled) { + handleBiometricAuth(); + } + }, []); + + const handleBiometricAuth = async () => { + const success = await authenticate(); + if (!success) { + // Biometric failed, show PIN option + setShowPinInput(true); + } + }; + + const handlePinSubmit = async () => { + if (!pin || pin.length < 4) { + Alert.alert('Error', 'Please enter your PIN'); + return; + } + + try { + setVerifying(true); + const success = await verifyPinCode(pin); + + if (!success) { + Alert.alert('Error', 'Incorrect PIN. Please try again.'); + setPin(''); + } + } catch (error) { + Alert.alert('Error', 'Failed to verify PIN'); + } finally { + setVerifying(false); + } + }; + + const getBiometricIcon = () => { + switch (biometricType) { + case 'facial': return '😊'; + case 'fingerprint': return 'šŸ‘†'; + case 'iris': return 'šŸ‘ļø'; + default: return 'šŸ”’'; + } + }; + + const getBiometricLabel = () => { + switch (biometricType) { + case 'facial': return 'Face ID'; + case 'fingerprint': return 'Fingerprint'; + case 'iris': return 'Iris'; + default: return 'Biometric'; + } + }; + + return ( + + {/* Logo */} + + 🌟 + PezkuwiChain + Digital Kurdistan + + + {/* Lock Icon */} + + šŸ”’ + + + {/* Title */} + App Locked + + Authenticate to unlock and access your wallet + + + {/* Biometric or PIN */} + + {!showPinInput ? ( + // Biometric Button + isBiometricEnabled && isBiometricSupported && isBiometricEnrolled ? ( + + + {getBiometricIcon()} + + + Tap to use {getBiometricLabel()} + + setShowPinInput(true)} + style={styles.usePinButton} + > + Use PIN instead + + + ) : ( + // No biometric, show PIN immediately + + + Biometric authentication not available + +