diff --git a/mobile-app/app/(tabs)/(settings)/_layout.tsx b/mobile-app/app/(tabs)/(settings)/_layout.tsx new file mode 100644 index 000000000..f7fd539b5 --- /dev/null +++ b/mobile-app/app/(tabs)/(settings)/_layout.tsx @@ -0,0 +1,27 @@ +import { Stack } from 'expo-router'; +import { useColors } from '@/hooks/useColorScheme'; + +export default function SettingsLayout() { + const colors = useColors(); + + return ( + + + + + ); +} \ No newline at end of file diff --git a/mobile-app/app/(tabs)/(settings)/index.tsx b/mobile-app/app/(tabs)/(settings)/index.tsx new file mode 100644 index 000000000..147cf5b35 --- /dev/null +++ b/mobile-app/app/(tabs)/(settings)/index.tsx @@ -0,0 +1,194 @@ +import { StyleSheet, View, ScrollView, TouchableOpacity, Image, Animated } from 'react-native'; +import { ThemedText } from '@/components/ThemedText'; +import { ThemedView } from '@/components/ThemedView'; +import { useWebApi } from '@/context/WebApiContext'; +import { router } from 'expo-router'; +import { AppInfo } from '@/utils/AppInfo'; +import { useColors } from '@/hooks/useColorScheme'; +import { TitleContainer } from '@/components/TitleContainer'; +import { useAuth } from '@/context/AuthContext'; +import { ThemedSafeAreaView } from '@/components/ThemedSafeAreaView'; +import { Ionicons } from '@expo/vector-icons'; +import { CollapsibleHeader } from '@/components/CollapsibleHeader'; +import { useRef } from 'react'; + +export default function SettingsScreen() { + const webApi = useWebApi(); + const colors = useColors(); + const { username, getAuthMethodDisplay } = useAuth(); + const scrollY = useRef(new Animated.Value(0)).current; + const scrollViewRef = useRef(null); + + const handleLogout = async () => { + await webApi.logout(); + router.replace('/login'); + }; + + const handleVaultUnlockPress = () => { + router.push('/(tabs)/(settings)/vault-unlock'); + }; + + const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + flex: 1, + padding: 16, + marginTop: 22, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingBottom: 40, + }, + section: { + marginTop: 20, + backgroundColor: colors.accentBackground, + borderRadius: 10, + overflow: 'hidden', + }, + settingItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + backgroundColor: colors.accentBackground, + }, + settingItemIcon: { + width: 24, + height: 24, + marginRight: 12, + justifyContent: 'center', + alignItems: 'center', + }, + settingItemContent: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + }, + settingItemContentWithBorder: { + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.accentBorder, + }, + settingItemText: { + flex: 1, + fontSize: 16, + color: colors.text, + }, + settingItemValue: { + fontSize: 16, + color: colors.textMuted, + marginRight: 8, + }, + userInfoContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.background, + borderRadius: 10, + marginBottom: 20, + }, + avatar: { + width: 40, + height: 40, + borderRadius: 20, + marginRight: 12, + }, + usernameText: { + fontSize: 16, + fontWeight: '600', + color: colors.text, + }, + logoutButton: { + backgroundColor: '#FF3B30', + padding: 16, + borderRadius: 10, + marginHorizontal: 16, + marginTop: 20, + }, + logoutButtonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: 'bold', + textAlign: 'center', + }, + versionContainer: { + marginTop: 20, + alignItems: 'center', + paddingBottom: 16, + }, + versionText: { + color: colors.textMuted, + fontSize: 14, + textAlign: 'center', + }, + }); + + return ( + + + + + + + + Logged in as: {username} + + + + + + + + + Vault Unlock Method + {getAuthMethodDisplay()} + + + + + + + + + + + + Logout + + + + + + App version {AppInfo.VERSION} + + + + + ); +} \ No newline at end of file diff --git a/mobile-app/app/(tabs)/(settings)/vault-unlock.tsx b/mobile-app/app/(tabs)/(settings)/vault-unlock.tsx new file mode 100644 index 000000000..2309a45d2 --- /dev/null +++ b/mobile-app/app/(tabs)/(settings)/vault-unlock.tsx @@ -0,0 +1,157 @@ +import { StyleSheet, View, ScrollView, Alert, Platform, Linking, Switch } from 'react-native'; +import { ThemedText } from '@/components/ThemedText'; +import { ThemedSafeAreaView } from '@/components/ThemedSafeAreaView'; +import { useColors } from '@/hooks/useColorScheme'; +import * as LocalAuthentication from 'expo-local-authentication'; +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useAuth } from '@/context/AuthContext'; + +export default function VaultUnlockSettingsScreen() { + const colors = useColors(); + const { setAuthMethods, enabledAuthMethods } = useAuth(); + const [hasFaceID, setHasFaceID] = useState(false); + const [isFaceIDEnabled, setIsFaceIDEnabled] = useState(false); + + useEffect(() => { + const checkFaceIDAvailability = async () => { + const compatible = await LocalAuthentication.hasHardwareAsync(); + const enrolled = await LocalAuthentication.isEnrolledAsync(); + setHasFaceID(compatible && enrolled); + }; + checkFaceIDAvailability(); + + if (enabledAuthMethods.includes('faceid')) { + setIsFaceIDEnabled(true); + } + }, []); + + useEffect(() => { + if (isFaceIDEnabled) { + setAuthMethods(['faceid', 'password']); + } else { + setAuthMethods(['password']); + } + }, [isFaceIDEnabled, setAuthMethods]); + + const handleFaceIDToggle = useCallback(async (value: boolean) => { + if (value && !hasFaceID) { + Alert.alert( + 'Face ID Not Available', + 'Face ID is not set up on this device. Please set up Face ID in your device settings to use this feature.', + [ + { + text: 'Open Settings', + onPress: () => { + if (Platform.OS === 'ios') { + Linking.openURL('app-settings:'); + } + }, + }, + { + text: 'Cancel', + style: 'cancel', + }, + ] + ); + return; + } + + setIsFaceIDEnabled(value); + setAuthMethods(value ? ['faceid', 'password'] : ['password']); + }, [hasFaceID]); + + const styles = useMemo(() => StyleSheet.create({ + container: { + flex: 1, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingBottom: 40, + }, + header: { + padding: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.accentBorder, + }, + headerText: { + fontSize: 13, + color: colors.textMuted, + }, + optionContainer: { + backgroundColor: colors.background, + }, + option: { + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.accentBorder, + }, + optionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 4, + }, + optionText: { + fontSize: 16, + color: colors.text, + }, + helpText: { + fontSize: 13, + color: colors.textMuted, + marginTop: 4, + }, + disabledText: { + color: colors.textMuted, + opacity: 0.5, + }, + }), [colors]); + + return ( + + + + + Choose how you want to unlock your vault + + + + + + + + Face ID / Touch ID + + + + + Your vault decryption key will be securely stored on your local device in the iOS Keychain and can only be accessed with your face or fingerprint. + + + + + + Password + + + + Re-enter your full master password to unlock your vault. This is always enabled as fallback option. + + + + + + ); +} \ No newline at end of file diff --git a/mobile-app/app/(tabs)/_layout.tsx b/mobile-app/app/(tabs)/_layout.tsx index a6cd6a830..e6246d841 100644 --- a/mobile-app/app/(tabs)/_layout.tsx +++ b/mobile-app/app/(tabs)/_layout.tsx @@ -70,7 +70,7 @@ export default function TabLayout() { }} /> , diff --git a/mobile-app/app/(tabs)/settings.tsx b/mobile-app/app/(tabs)/settings.tsx deleted file mode 100644 index 496591d0c..000000000 --- a/mobile-app/app/(tabs)/settings.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { StyleSheet, Button, SafeAreaView, TouchableOpacity } from 'react-native'; -import { ThemedText } from '@/components/ThemedText'; -import { ThemedView } from '@/components/ThemedView'; -import { useWebApi } from '@/context/WebApiContext'; -import { router } from 'expo-router'; -import { AppInfo } from '@/utils/AppInfo'; -import { useColors } from '@/hooks/useColorScheme'; -import { TitleContainer } from '@/components/TitleContainer'; - -export default function SettingsScreen() { - const webApi = useWebApi(); - const colors = useColors(); - - const handleLogout = async () => { - await webApi.logout(); - router.replace('/login'); - }; - - const styles = StyleSheet.create({ - container: { - flex: 1, - }, - content: { - flex: 1, - padding: 16, - }, - settingsContainer: { - flex: 1, - gap: 8, - }, - logoutButton: { - backgroundColor: '#FF3B30', - padding: 16, - borderRadius: 8, - }, - logoutButtonText: { - color: '#FFFFFF', - fontSize: 16, - fontWeight: 'bold', - }, - versionContainer: { - marginTop: 'auto', - alignItems: 'center', - paddingBottom: 16, - }, - versionText: { - color: colors.textMuted, - fontSize: 14, - textAlign: 'center', - }, - }); - - return ( - - - - - - Logout - - Version {AppInfo.VERSION} - - - - ); -} \ No newline at end of file diff --git a/mobile-app/app/index.tsx b/mobile-app/app/index.tsx index 80fdfeae9..60fee5b4d 100644 --- a/mobile-app/app/index.tsx +++ b/mobile-app/app/index.tsx @@ -35,7 +35,7 @@ export default function InitialLoadingScreen() { hasInitialized.current = true; async function initialize() { - const isLoggedIn = await authContext.initializeAuth(); + const { isLoggedIn, enabledAuthMethods } = await authContext.initializeAuth(); // If user is not logged in, navigate to login immediately if (!isLoggedIn) { @@ -60,6 +60,13 @@ export default function InitialLoadingScreen() { // as we're just checking if the file exists. const isInitialized = await NativeModules.CredentialManager.isVaultInitialized(); if (isInitialized) { + const isFaceIDEnabled = enabledAuthMethods.includes('faceid'); + if (!isFaceIDEnabled) { + console.log('FaceID is not enabled, navigating to unlock screen'); + router.replace('/unlock'); + return; + } + // Attempt to unlock the vault with FaceID. setStatus('Unlocking vault|'); const isUnlocked = await NativeModules.CredentialManager.unlockVault(); diff --git a/mobile-app/app/unlock.tsx b/mobile-app/app/unlock.tsx index 5e83513b5..1099d497d 100644 --- a/mobile-app/app/unlock.tsx +++ b/mobile-app/app/unlock.tsx @@ -13,7 +13,7 @@ import { SrpUtility } from '@/utils/SrpUtility'; import { useWebApi } from '@/context/WebApiContext'; export default function UnlockScreen() { - const { isLoggedIn, username } = useAuth(); + const { isLoggedIn, username, isFaceIDEnabled } = useAuth(); const { testDatabaseConnection } = useDb(); const [password, setPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -221,12 +221,14 @@ export default function UnlockScreen() { - - Try Face ID Again - + {isFaceIDEnabled() && ( + + Try Face ID Again + + )} boolean; setAuthTokens: (username: string, accessToken: string, refreshToken: string) => Promise; - initializeAuth: () => Promise; + initializeAuth: () => Promise<{ isLoggedIn: boolean; enabledAuthMethods: AuthMethod[] }>; login: () => Promise; logout: (errorMessage?: string) => Promise; globalMessage: string | null; clearGlobalMessage: () => void; + setAuthMethods: (methods: AuthMethod[]) => Promise; + getAuthMethodDisplay: () => string; } /** @@ -27,8 +34,16 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const [isInitialized, setIsInitialized] = useState(false); const [username, setUsername] = useState(null); const [globalMessage, setGlobalMessage] = useState(null); + const [enabledAuthMethods, setEnabledAuthMethods] = useState(['password']); const dbContext = useDb(); + /** + * Check if Face ID is enabled based on enabled auth methods + */ + const isFaceIDEnabled = useCallback(() : boolean => { + return enabledAuthMethods.includes('faceid'); + }, [enabledAuthMethods]); + /** * Set auth tokens in storage as part of the login process. After db is initialized, the login method should be called as well. */ @@ -42,20 +57,35 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children /** * Initialize the authentication state, called on initial load by _layout.tsx. - * @returns boolean indicating whether the user is logged in + * @returns object containing whether the user is logged in and enabled auth methods */ - const initializeAuth = useCallback(async () : Promise => { + const initializeAuth = useCallback(async () : Promise<{ isLoggedIn: boolean; enabledAuthMethods: AuthMethod[] }> => { const accessToken = await AsyncStorage.getItem('accessToken') as string; const refreshToken = await AsyncStorage.getItem('refreshToken') as string; const username = await AsyncStorage.getItem('username') as string; + const savedAuthMethods = await AsyncStorage.getItem('authMethods'); let isAuthenticated = false; + let methods: AuthMethod[] = ['password']; + if (accessToken && refreshToken && username) { setUsername(username); setIsLoggedIn(true); isAuthenticated = true; + if (savedAuthMethods) { + try { + const parsedMethods = JSON.parse(savedAuthMethods) as AuthMethod[]; + if (Array.isArray(parsedMethods) && parsedMethods.every(method => method === 'faceid' || method === 'password')) { + methods = parsedMethods; + setEnabledAuthMethods(parsedMethods); + } + } catch (e) { + // If parsing fails, use default + setEnabledAuthMethods(['password']); + } + } } setIsInitialized(true); - return isAuthenticated; + return { isLoggedIn: isAuthenticated, enabledAuthMethods: methods }; }, []); /** @@ -72,6 +102,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children await AsyncStorage.removeItem('username'); await AsyncStorage.removeItem('accessToken'); await AsyncStorage.removeItem('refreshToken'); + await AsyncStorage.removeItem('authMethods'); dbContext?.clearDatabase(); // Set local storage global message that will be shown on the login page. @@ -81,6 +112,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children setUsername(null); setIsLoggedIn(false); + setEnabledAuthMethods(['password']); }, [dbContext]); /** @@ -90,17 +122,57 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children setGlobalMessage(null); }, []); + /** + * Set the authentication methods and save them to storage + */ + const setAuthMethods = useCallback(async (methods: AuthMethod[]) : Promise => { + // Ensure password is always included + const methodsToSave = methods.includes('password') ? methods : [...methods, 'password']; + + // Save to AsyncStorage + await AsyncStorage.setItem('authMethods', JSON.stringify(methodsToSave)); + + // Update iOS credentials manager + try { + await NativeModules.CredentialManager.setAuthMethods(methodsToSave); + } catch (error) { + console.error('Failed to update iOS auth methods:', error); + // Continue with the update even if iOS update fails + } + + // Use a state update function to ensure we're working with the latest state + setEnabledAuthMethods(prevMethods => { + // Only update if the methods have actually changed + if (JSON.stringify(prevMethods) !== JSON.stringify(methodsToSave)) { + return methodsToSave as AuthMethod[]; + } + return prevMethods; + }); + }, []); + + /** + * Get the display label for the current auth method + * Prefers Face ID if enabled, otherwise falls back to Password + */ + const getAuthMethodDisplay = useCallback(() : string => { + return enabledAuthMethods.includes('faceid') ? 'Face ID' : 'Password'; + }, [enabledAuthMethods]); + const contextValue = useMemo(() => ({ isLoggedIn, isInitialized, username, + enabledAuthMethods, + isFaceIDEnabled, initializeAuth, setAuthTokens, login, logout, globalMessage, clearGlobalMessage, - }), [isLoggedIn, isInitialized, username, globalMessage, setAuthTokens, login, logout, clearGlobalMessage, initializeAuth]); + setAuthMethods, + getAuthMethodDisplay, + }), [isLoggedIn, isInitialized, username, globalMessage, enabledAuthMethods, setAuthTokens, login, logout, clearGlobalMessage, initializeAuth, setAuthMethods, getAuthMethodDisplay, isFaceIDEnabled]); return ( diff --git a/mobile-app/hooks/useVaultSync.ts b/mobile-app/hooks/useVaultSync.ts index 236cbdacf..36e86253a 100644 --- a/mobile-app/hooks/useVaultSync.ts +++ b/mobile-app/hooks/useVaultSync.ts @@ -43,7 +43,7 @@ export const useVaultSync = () => { console.log('syncVault called with initialSync:', initialSync); try { - const isLoggedIn = await authContext.initializeAuth(); + const { isLoggedIn } = await authContext.initializeAuth(); if (!isLoggedIn) { console.log('Vault sync: Not authenticated'); diff --git a/mobile-app/ios/CredentialManager/CredentialManager.m b/mobile-app/ios/CredentialManager/CredentialManager.m index 5b59012f3..32378b428 100644 --- a/mobile-app/ios/CredentialManager/CredentialManager.m +++ b/mobile-app/ios/CredentialManager/CredentialManager.m @@ -18,6 +18,10 @@ RCT_EXTERN_METHOD(storeDatabase:(NSString *)base64EncryptedDb resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(setAuthMethods:(NSArray *)authMethods + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + RCT_EXTERN_METHOD(storeEncryptionKey:(NSString *)base64EncryptionKey resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) diff --git a/mobile-app/ios/CredentialManager/CredentialManager.swift b/mobile-app/ios/CredentialManager/CredentialManager.swift index bc50c76d2..0f841216e 100644 --- a/mobile-app/ios/CredentialManager/CredentialManager.swift +++ b/mobile-app/ios/CredentialManager/CredentialManager.swift @@ -23,22 +23,39 @@ class CredentialManager: NSObject { } } + @objc + func setAuthMethods(_ authMethods: [String], + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + do { + var methods: AuthMethods = [] + + for method in authMethods { + switch method.lowercased() { + case "faceid": + methods.insert(.faceID) + case "password": + methods.insert(.password) + default: + reject("INVALID_AUTH_METHOD", "Invalid authentication method: \(method)", nil) + return + } + } + + try credentialStore.setAuthMethods(methods) + resolve(nil) + } catch { + reject("AUTH_METHOD_ERROR", "Failed to set authentication methods: \(error.localizedDescription)", error) + } + } + @objc func storeEncryptionKey(_ base64EncryptionKey: String, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { do { - // Store the encryption key in the keychain with biometric or PIN protection - let context = LAContext() - var error: NSError? - - guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { - reject("AUTH_UNAVAILABLE", "Authentication not available: \(error?.localizedDescription ?? "Unknown error")", error) - return - } - // Store the key in the keychain with authentication protection - try credentialStore.storeEncryptionKey(base64EncryptionKey) + try credentialStore.storeEncryptionKey(base64Key: base64EncryptionKey) resolve(nil) } catch { reject("KEYCHAIN_ERROR", "Failed to store encryption key: \(error.localizedDescription)", error) diff --git a/mobile-app/ios/CredentialManager/SharedCredentialStore.swift b/mobile-app/ios/CredentialManager/SharedCredentialStore.swift index 4cad24629..d43b2a1ff 100644 --- a/mobile-app/ios/CredentialManager/SharedCredentialStore.swift +++ b/mobile-app/ios/CredentialManager/SharedCredentialStore.swift @@ -5,16 +5,38 @@ import LocalAuthentication import CryptoKit import CommonCrypto +struct AuthMethods: OptionSet { + let rawValue: Int + + static let faceID = AuthMethods(rawValue: 1 << 0) + static let password = AuthMethods(rawValue: 1 << 1) + + static let all: AuthMethods = [.faceID, .password] + static let none: AuthMethods = [] +} + class SharedCredentialStore { static let shared = SharedCredentialStore() private let keychain = Keychain(service: "net.aliasvault.autofill", accessGroup: "group.net.aliasvault.autofill") - .accessibility(.whenPasscodeSetThisDeviceOnly, authenticationPolicy: [.biometryAny]) private let encryptionKeyKey = "aliasvault_encryption_key" private let encryptedDbFileName = "encrypted_db.sqlite" + private let authMethodsKey = "aliasvault_auth_methods" private var db: Connection? private var encryptionKey: Data? + private var enabledAuthMethods: AuthMethods = .none - public init() {} + public init() { + // Load saved auth methods from UserDefaults + let savedRawValue = UserDefaults.standard.integer(forKey: authMethodsKey) + enabledAuthMethods = AuthMethods(rawValue: savedRawValue) + } + + // MARK: - Auth Methods Management + func setAuthMethods(_ methods: AuthMethods) throws { + enabledAuthMethods = methods + UserDefaults.standard.set(methods.rawValue, forKey: authMethodsKey) + UserDefaults.standard.synchronize() + } // MARK: - Vault Status func isVaultInitialized() -> Bool { @@ -27,26 +49,33 @@ class SharedCredentialStore { // MARK: - Encryption Key Management private func getEncryptionKey() throws -> Data { if let key = encryptionKey { - // print as base64 for debugging - print("Key found in memory: \(key.base64EncodedString())") return key } - guard let keyData = try? keychain - .authenticationPrompt("Authenticate to unlock your vault") - .getData(encryptionKeyKey) else { - throw NSError(domain: "SharedCredentialStore", code: 2, userInfo: [NSLocalizedDescriptionKey: "No encryption key found"]) + // If Face ID is enabled, try to get the key from keychain + if enabledAuthMethods.contains(.faceID) { + let context = LAContext() + var error: NSError? + + guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { + throw NSError(domain: "SharedCredentialStore", code: 2, userInfo: [NSLocalizedDescriptionKey: "Face ID not available: \(error?.localizedDescription ?? "Unknown error")"]) + } + + guard let keyData = try? keychain + .authenticationPrompt("Authenticate to unlock your vault") + .getData(encryptionKeyKey) else { + throw NSError(domain: "SharedCredentialStore", code: 2, userInfo: [NSLocalizedDescriptionKey: "No encryption key found"]) + } + + encryptionKey = keyData + return keyData } - encryptionKey = keyData - // print as base64 for debugging - print("Key found in keychain: \(keyData.base64EncodedString())") - return keyData + // If Face ID is not enabled and we don't have a key in memory, throw an error + throw NSError(domain: "SharedCredentialStore", code: 3, userInfo: [NSLocalizedDescriptionKey: "No encryption key found in memory"]) } - func storeEncryptionKey(_ base64Key: String) throws { - print("Storing encryption key") - + func storeEncryptionKey(base64Key: String) throws { // Convert base64 string to bytes guard let keyData = Data(base64Encoded: base64Key) else { throw NSError(domain: "SharedCredentialStore", code: 6, userInfo: [NSLocalizedDescriptionKey: "Invalid base64 key"]) @@ -57,16 +86,19 @@ class SharedCredentialStore { throw NSError(domain: "SharedCredentialStore", code: 7, userInfo: [NSLocalizedDescriptionKey: "Invalid key length. Expected 32 bytes"]) } - do { - try keychain - .authenticationPrompt("Authenticate to unlock your vault") - .set(keyData, key: encryptionKeyKey) - encryptionKey = keyData - // print as base64 for debugging - print("Key saved in keychain: \(keyData.base64EncodedString())") - } catch { - print("Failed to save key to keychain: \(error)") - throw error + // Store the key in memory + encryptionKey = keyData + + // Store the key in the keychain if Face ID is enabled + if enabledAuthMethods.contains(.faceID) { + do { + try keychain + .authenticationPrompt("Authenticate to unlock your vault") + .set(keyData, key: encryptionKeyKey) + } catch { + print("Failed to save key to keychain: \(error)") + throw error + } } } @@ -107,6 +139,9 @@ class SharedCredentialStore { return UserDefaults.standard.string(forKey: "vault_metadata") } + /** + * Initialize the database. + */ func initializeDatabase() throws { // Get the encrypted database guard let encryptedDbBase64 = getEncryptedDatabase() else { diff --git a/mobile-app/ios/Podfile.lock b/mobile-app/ios/Podfile.lock index 585b31bd6..c7b901041 100644 --- a/mobile-app/ios/Podfile.lock +++ b/mobile-app/ios/Podfile.lock @@ -234,6 +234,8 @@ PODS: - ExpoModulesCore - ExpoLinking (7.0.5): - ExpoModulesCore + - ExpoLocalAuthentication (15.0.2): + - ExpoModulesCore - ExpoModulesCore (2.2.3): - DoubleConversion - glog @@ -2190,6 +2192,7 @@ DEPENDENCIES: - ExpoHead (from `../node_modules/expo-router/ios`) - ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`) - ExpoLinking (from `../node_modules/expo-linking/ios`) + - ExpoLocalAuthentication (from `../node_modules/expo-local-authentication/ios`) - ExpoModulesCore (from `../node_modules/expo-modules-core`) - ExpoSharing (from `../node_modules/expo-sharing/ios`) - ExpoSplashScreen (from `../node_modules/expo-splash-screen/ios`) @@ -2323,6 +2326,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-keep-awake/ios" ExpoLinking: :path: "../node_modules/expo-linking/ios" + ExpoLocalAuthentication: + :path: "../node_modules/expo-local-authentication/ios" ExpoModulesCore: :path: "../node_modules/expo-modules-core" ExpoSharing: @@ -2508,6 +2513,7 @@ SPEC CHECKSUMS: ExpoHead: cee2d16ef197aaadb0ac481cf221a663636eb074 ExpoKeepAwake: b0171a73665bfcefcfcc311742a72a956e6aa680 ExpoLinking: 8d12bee174ba0cdf31239706578e29e74a417402 + ExpoLocalAuthentication: eb2be9c7bcdc68e9434d4be4bb5aa5cf7943e816 ExpoModulesCore: c25d77625038b1968ea1afefc719862c0d8dd993 ExpoSharing: 849a5ce9985c22598c16ec027e32969be8062e8e ExpoSplashScreen: 0f281e3c2ded4757d2309276c682d023c6299c77 diff --git a/mobile-app/package-lock.json b/mobile-app/package-lock.json index 7f6ec1a1b..f1ec86f90 100644 --- a/mobile-app/package-lock.json +++ b/mobile-app/package-lock.json @@ -21,6 +21,7 @@ "expo-font": "~13.0.4", "expo-haptics": "~14.0.1", "expo-linking": "~7.0.5", + "expo-local-authentication": "~15.0.2", "expo-router": "~4.0.20", "expo-sharing": "~13.0.1", "expo-splash-screen": "~0.29.22", @@ -7584,6 +7585,18 @@ "react-native": "*" } }, + "node_modules/expo-local-authentication": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-15.0.2.tgz", + "integrity": "sha512-v7TOfovuivGWffA8B0FudEas+njMKTrjhaPpJYLASiTLTP3zUYqtumNgZ9pkaDB6Cagq0+DeNH39AI8tdEBUkQ==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-manifests": { "version": "0.15.8", "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-0.15.8.tgz", diff --git a/mobile-app/package.json b/mobile-app/package.json index 0701cba84..452c3aefe 100644 --- a/mobile-app/package.json +++ b/mobile-app/package.json @@ -52,7 +52,8 @@ "react-native-toast-message": "^2.2.1", "react-native-web": "~0.19.13", "react-native-webview": "13.12.5", - "secure-remote-password": "github:LinusU/secure-remote-password#73e5f732b6ca0cdbdc19da1a0c5f48cdbad2cbf0" + "secure-remote-password": "github:LinusU/secure-remote-password#73e5f732b6ca0cdbdc19da1a0c5f48cdbad2cbf0", + "expo-local-authentication": "~15.0.2" }, "devDependencies": { "@babel/core": "^7.25.2",