Add settings page stack and add unlock method options (#771)

This commit is contained in:
Leendert de Borst
2025-04-23 13:53:17 +02:00
parent 31d3c1d1a2
commit b19ee32b28
15 changed files with 586 additions and 120 deletions

View File

@@ -0,0 +1,27 @@
import { Stack } from 'expo-router';
import { useColors } from '@/hooks/useColorScheme';
export default function SettingsLayout() {
const colors = useColors();
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: false,
}}
/>
<Stack.Screen
name="vault-unlock"
options={{
title: 'Vault Unlock Method',
headerBackTitle: 'Settings',
headerStyle: {
backgroundColor: colors.headerBackground,
},
}}
/>
</Stack>
);
}

View File

@@ -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<ScrollView>(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 (
<ThemedSafeAreaView style={styles.container}>
<CollapsibleHeader
title="Settings"
scrollY={scrollY}
showNavigationHeader={true}
/>
<ThemedView style={styles.content}>
<Animated.ScrollView
ref={scrollViewRef}
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
{ useNativeDriver: true }
)}
scrollEventThrottle={16}
contentContainerStyle={{ paddingBottom: 40, paddingTop: 4 }}
scrollIndicatorInsets={{ bottom: 40 }}
style={styles.scrollView}
>
<TitleContainer title="Settings" />
<View style={styles.userInfoContainer}>
<Image
source={require('@/assets/images/avatar.webp')}
style={styles.avatar}
/>
<ThemedText style={styles.usernameText}>Logged in as: {username}</ThemedText>
</View>
<View style={styles.section}>
<TouchableOpacity
style={styles.settingItem}
onPress={handleVaultUnlockPress}
>
<View style={styles.settingItemIcon}>
<Ionicons name="lock-closed" size={20} color={colors.text} />
</View>
<View style={[styles.settingItemContent]}>
<ThemedText style={styles.settingItemText}>Vault Unlock Method</ThemedText>
<ThemedText style={styles.settingItemValue}>{getAuthMethodDisplay()}</ThemedText>
<Ionicons name="chevron-forward" size={20} color={colors.textMuted} />
</View>
</TouchableOpacity>
</View>
<View style={styles.section}>
<TouchableOpacity
style={styles.settingItem}
onPress={handleLogout}
>
<View style={styles.settingItemIcon}>
<Ionicons name="log-out" size={20} color="#FF3B30" />
</View>
<View style={[styles.settingItemContent]}>
<ThemedText style={[styles.settingItemText, { color: '#FF3B30' }]}>Logout</ThemedText>
</View>
</TouchableOpacity>
</View>
<View style={styles.versionContainer}>
<ThemedText style={styles.versionText}>App version {AppInfo.VERSION}</ThemedText>
</View>
</Animated.ScrollView>
</ThemedView>
</ThemedSafeAreaView>
);
}

View File

@@ -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 (
<ThemedSafeAreaView style={styles.container}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
<View style={styles.header}>
<ThemedText style={styles.headerText}>
Choose how you want to unlock your vault
</ThemedText>
</View>
<View style={styles.optionContainer}>
<View style={styles.option}>
<View style={styles.optionHeader}>
<ThemedText style={[styles.optionText, !hasFaceID && styles.disabledText]}>
Face ID / Touch ID
</ThemedText>
<Switch
value={isFaceIDEnabled}
onValueChange={handleFaceIDToggle}
disabled={!hasFaceID}
/>
</View>
<ThemedText style={styles.helpText}>
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.
</ThemedText>
</View>
<View style={styles.option}>
<View style={styles.optionHeader}>
<ThemedText style={styles.optionText}>Password</ThemedText>
<Switch
value={true}
disabled={true}
/>
</View>
<ThemedText style={styles.helpText}>
Re-enter your full master password to unlock your vault. This is always enabled as fallback option.
</ThemedText>
</View>
</View>
</ScrollView>
</ThemedSafeAreaView>
);
}

View File

@@ -70,7 +70,7 @@ export default function TabLayout() {
}}
/>
<Tabs.Screen
name="settings"
name="(settings)"
options={{
title: 'Settings',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="gear" color={color} />,

View File

@@ -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 (
<SafeAreaView style={styles.container}>
<ThemedView style={styles.content}>
<TitleContainer title="Settings" />
<ThemedView style={styles.settingsContainer}>
<TouchableOpacity
style={styles.logoutButton}
onPress={handleLogout}
>
<ThemedText style={styles.logoutButtonText}>Logout</ThemedText>
</TouchableOpacity>
<ThemedText style={styles.versionText}>Version {AppInfo.VERSION}</ThemedText>
</ThemedView>
</ThemedView>
</SafeAreaView>
);
}

View File

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

View File

@@ -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() {
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={styles.faceIdButton}
onPress={handleFaceIDRetry}
>
<ThemedText style={styles.faceIdButtonText}>Try Face ID Again</ThemedText>
</TouchableOpacity>
{isFaceIDEnabled() && (
<TouchableOpacity
style={styles.faceIdButton}
onPress={handleFaceIDRetry}
>
<ThemedText style={styles.faceIdButtonText}>Try Face ID Again</ThemedText>
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.logoutButton}

View File

@@ -1,17 +1,24 @@
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useDb } from './DbContext';
import { NativeModules } from 'react-native';
export type AuthMethod = 'faceid' | 'password';
type AuthContextType = {
isLoggedIn: boolean;
isInitialized: boolean;
username: string | null;
enabledAuthMethods: AuthMethod[];
isFaceIDEnabled: () => boolean;
setAuthTokens: (username: string, accessToken: string, refreshToken: string) => Promise<void>;
initializeAuth: () => Promise<boolean>;
initializeAuth: () => Promise<{ isLoggedIn: boolean; enabledAuthMethods: AuthMethod[] }>;
login: () => Promise<void>;
logout: (errorMessage?: string) => Promise<void>;
globalMessage: string | null;
clearGlobalMessage: () => void;
setAuthMethods: (methods: AuthMethod[]) => Promise<void>;
getAuthMethodDisplay: () => string;
}
/**
@@ -27,8 +34,16 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [isInitialized, setIsInitialized] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const [globalMessage, setGlobalMessage] = useState<string | null>(null);
const [enabledAuthMethods, setEnabledAuthMethods] = useState<AuthMethod[]>(['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<boolean> => {
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<void> => {
// 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 (
<AuthContext.Provider value={contextValue}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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