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",