diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt index 78e922b19..f906df7ab 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt @@ -1346,6 +1346,24 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : } } + /** + * Check if biometric unlock is actually available (device + key validation). + * This checks not only if biometrics are configured in auth methods, + * but also validates that the encryption key in KeyStore is valid. + * Returns false if key has been invalidated (e.g., biometric enrollment changed). + * @param promise The promise to resolve with boolean result. + */ + @ReactMethod + override fun isBiometricUnlockAvailable(promise: Promise) { + try { + val available = vaultStore.isBiometricAuthEnabled() + promise.resolve(available) + } catch (e: Exception) { + Log.e(TAG, "Error checking biometric unlock availability", e) + promise.reject("ERR_BIOMETRIC_CHECK", "Failed to check biometric unlock availability: ${e.message}", e) + } + } + /** * Show native PIN setup UI. * Launches the native PinUnlockActivity in setup mode. diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultCrypto.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultCrypto.kt index 24b4a99c1..4689ed036 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultCrypto.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultCrypto.kt @@ -128,7 +128,7 @@ class VaultCrypto( fun storeEncryptionKey(base64EncryptionKey: String, authMethods: String) { this.encryptionKey = Base64.decode(base64EncryptionKey, Base64.NO_WRAP) - if (authMethods.contains(BIOMETRICS_AUTH_METHOD) && keystoreProvider.isBiometricAvailable()) { + if (authMethods.contains(BIOMETRICS_AUTH_METHOD)) { try { val latch = java.util.concurrent.CountDownLatch(1) var error: Exception? = null diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt index befffc599..c79733f04 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt @@ -478,7 +478,7 @@ class VaultStore( auth.setAuthMethods(authMethods) - if (!wasBiometricEnabled && isBiometricEnabled && crypto.encryptionKey != null && keystoreProvider.isBiometricAvailable()) { + if (!wasBiometricEnabled && isBiometricEnabled) { try { crypto.storeEncryptionKey( android.util.Base64.encodeToString(crypto.encryptionKey, android.util.Base64.NO_WRAP), diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/AndroidKeystoreProvider.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/AndroidKeystoreProvider.kt index 18c012290..fe5118496 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/AndroidKeystoreProvider.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/keystoreprovider/AndroidKeystoreProvider.kt @@ -66,14 +66,87 @@ class AndroidKeystoreProvider( /** * Whether the biometric is available. - * @return Whether the biometric is available + * Checks both device biometric support AND validates that the keystore key is valid. + * This prevents showing biometric unlock when the key has been invalidated + * (e.g., after biometric enrollment changes). + * @return Whether the biometric is available and key is valid */ override fun isBiometricAvailable(): Boolean { - return _biometricManager.canAuthenticate( + // First check if device supports biometrics + val deviceSupported = _biometricManager.canAuthenticate( BiometricManager.Authenticators.BIOMETRIC_WEAK or BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL, ) == BiometricManager.BIOMETRIC_SUCCESS + + if (!deviceSupported) { + return false + } + + // Check if we have the encrypted key file + val keyFile = File(context.filesDir, ENCRYPTED_KEY_FILE) + if (!keyFile.exists()) { + return false + } + + // Validate that the keystore key exists and is not invalidated + try { + val keyStore = KeyStore.getInstance("AndroidKeyStore") + keyStore.load(null) + + // Check if key alias exists + if (!keyStore.containsAlias(KEYSTORE_ALIAS)) { + // Key doesn't exist - clean up orphaned encrypted key file + Log.d(TAG, "Keystore key not found, removing orphaned encrypted key file") + keyFile.delete() + return false + } + + val secretKey = keyStore.getKey(KEYSTORE_ALIAS, null) as? SecretKey + if (secretKey == null) { + Log.d(TAG, "Failed to retrieve keystore key") + keyFile.delete() + return false + } + + // Try to initialize cipher to detect key invalidation + val cipher = Cipher.getInstance( + "${KeyProperties.KEY_ALGORITHM_AES}/" + + "${KeyProperties.BLOCK_MODE_GCM}/" + + KeyProperties.ENCRYPTION_PADDING_NONE, + ) + + // Read IV from encrypted key file for validation + val encryptedKeyB64 = keyFile.readText() + val combined = Base64.decode(encryptedKeyB64, Base64.NO_WRAP) + val byteBuffer = ByteBuffer.wrap(combined) + val iv = ByteArray(12) + byteBuffer.get(iv) + val spec = GCMParameterSpec(128, iv) + + // Attempt to initialize cipher - this will throw KeyPermanentlyInvalidatedException + // if biometric enrollment has changed + cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) + + // Key is valid + return true + } catch (e: KeyPermanentlyInvalidatedException) { + // Key has been invalidated due to biometric enrollment change + Log.w(TAG, "Keystore key permanently invalidated, cleaning up", e) + try { + val keyStore = KeyStore.getInstance("AndroidKeyStore") + keyStore.load(null) + keyStore.deleteEntry(KEYSTORE_ALIAS) + keyFile.delete() + } catch (cleanupError: Exception) { + Log.e(TAG, "Error during cleanup of invalidated key", cleanupError) + } + return false + } catch (e: Exception) { + // Any other error means biometric is not available + Log.e(TAG, "Error validating biometric availability: ${e.message}", e) + return false + } } /** diff --git a/apps/mobile-app/app/(tabs)/settings/index.tsx b/apps/mobile-app/app/(tabs)/settings/index.tsx index 56e4023c9..afc5e5f24 100644 --- a/apps/mobile-app/app/(tabs)/settings/index.tsx +++ b/apps/mobile-app/app/(tabs)/settings/index.tsx @@ -6,6 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useApiUrl } from '@/utils/ApiUrlUtility'; import { AppInfo } from '@/utils/AppInfo'; +import { AppUnlockUtility } from '@/utils/AppUnlockUtility'; import { useColors } from '@/hooks/useColorScheme'; import { useLogout } from '@/hooks/useLogout'; @@ -31,7 +32,7 @@ export default function SettingsScreen() : React.ReactNode { const { t } = useTranslation(); const { showAlert, showConfirm } = useDialog(); const insets = useSafeAreaInsets(); - const { getAuthMethodDisplayKey, shouldShowAutofillReminder } = useApp(); + const { shouldShowAutofillReminder } = useApp(); const { getAutoLockTimeout } = useApp(); const { logoutUserInitiated } = useLogout(); const { loadApiUrl, getDisplayUrl } = useApiUrl(); @@ -97,7 +98,7 @@ export default function SettingsScreen() : React.ReactNode { * Load the auth method display. */ const loadAuthMethodDisplay = async () : Promise => { - const authMethodKey = await getAuthMethodDisplayKey(); + const authMethodKey = await AppUnlockUtility.getAuthMethodDisplayKey(); setAuthMethodDisplay(t(authMethodKey)); }; @@ -110,7 +111,7 @@ export default function SettingsScreen() : React.ReactNode { }; loadData(); - }, [getAutoLockTimeout, getAuthMethodDisplayKey, setIsFirstLoad, loadApiUrl, t]) + }, [getAutoLockTimeout, setIsFirstLoad, loadApiUrl, t]) ); /** diff --git a/apps/mobile-app/app/(tabs)/settings/vault-unlock.tsx b/apps/mobile-app/app/(tabs)/settings/vault-unlock.tsx index 1387c7fdc..04272b6aa 100644 --- a/apps/mobile-app/app/(tabs)/settings/vault-unlock.tsx +++ b/apps/mobile-app/app/(tabs)/settings/vault-unlock.tsx @@ -1,15 +1,15 @@ -import * as LocalAuthentication from 'expo-local-authentication'; import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View, Platform, Linking, Switch, TouchableOpacity } from 'react-native'; import Toast from 'react-native-toast-message'; +import { AppUnlockUtility } from '@/utils/AppUnlockUtility'; + import { useColors } from '@/hooks/useColorScheme'; import { ThemedContainer } from '@/components/themed/ThemedContainer'; import { ThemedScrollView } from '@/components/themed/ThemedScrollView'; import { ThemedText } from '@/components/themed/ThemedText'; -import { AuthMethod, useAuth } from '@/context/AuthContext'; import { useDialog } from '@/context/DialogContext'; import NativeVaultManager from '@/specs/NativeVaultManager'; @@ -20,12 +20,9 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode { const colors = useColors(); const { t } = useTranslation(); const { showAlert, showDialog } = useDialog(); - const [initialized, setInitialized] = useState(false); - const { setAuthMethods, getEnabledAuthMethods, getBiometricDisplayName } = useAuth(); const [hasBiometrics, setHasBiometrics] = useState(false); const [isBiometricsEnabled, setIsBiometricsEnabled] = useState(false); const [biometricDisplayName, setBiometricDisplayName] = useState(''); - const [_, setEnabledAuthMethods] = useState([]); // PIN state const [pinEnabled, setPinEnabled] = useState(false); @@ -36,64 +33,44 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode { */ const initializeAuth = async () : Promise => { try { - // Check for hardware support - const compatible = await LocalAuthentication.hasHardwareAsync(); + // Check if device has biometric hardware and enrollment + const deviceAvailable = await AppUnlockUtility.isBiometricsAvailableOnDevice(); + setHasBiometrics(deviceAvailable); - // Check if any biometrics are enrolled - const enrolled = await LocalAuthentication.isEnrolledAsync(); - - // Set biometric availability based on all checks - const isBiometricAvailable = compatible && enrolled; - setHasBiometrics(isBiometricAvailable); - - // Get appropriate display name from auth context - const displayName = await getBiometricDisplayName(); + // Get appropriate display name + const displayName = await AppUnlockUtility.getBiometricDisplayName(); setBiometricDisplayName(displayName); - const methods = await getEnabledAuthMethods(); - setEnabledAuthMethods(methods); + const methods = await AppUnlockUtility.getEnabledAuthMethods(); - if (methods.includes('faceid') && enrolled) { - setIsBiometricsEnabled(true); + // Check if biometric unlock is actually functional (validates stored key) + if (methods.includes('faceid') && deviceAvailable) { + const unlockAvailable = await AppUnlockUtility.isBiometricUnlockAvailable(); + + if (!unlockAvailable) { + /* + * Key is invalid (e.g., biometric enrollment changed) + * Remove biometrics from auth methods so user must re-enable it + */ + console.info('Biometric key invalid, removing from auth methods'); + await AppUnlockUtility.disableAuthMethod('faceid'); + setIsBiometricsEnabled(false); + } else { + setIsBiometricsEnabled(true); + } } - // Load PIN settings (locked state removed - automatically handled by native code) + // Load PIN settings const enabled = await NativeVaultManager.isPinEnabled(); setPinEnabled(enabled); - - setInitialized(true); } catch (error) { console.error('Failed to initialize auth:', error); setHasBiometrics(false); - setInitialized(true); } }; initializeAuth(); - }, [getEnabledAuthMethods, getBiometricDisplayName, t]); - - useEffect(() => { - if (!initialized) { - return; - } - - /** - * Update the auth methods. - */ - const updateAuthMethods = async () : Promise => { - const currentAuthMethods = await getEnabledAuthMethods(); - const newAuthMethods = isBiometricsEnabled ? ['faceid', 'password'] : ['password']; - - if (currentAuthMethods.length === newAuthMethods.length && - currentAuthMethods.every(method => newAuthMethods.includes(method))) { - return; - } - - setAuthMethods(newAuthMethods as AuthMethod[]); - }; - - updateAuthMethods(); - }, [isBiometricsEnabled, setAuthMethods, getEnabledAuthMethods, initialized]); + }, [t]); const handleBiometricsToggle = useCallback(async (value: boolean) : Promise => { if (value && !hasBiometrics) { @@ -107,9 +84,9 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode { /** * Handle the open settings press. */ - onPress: () : void => { + onPress: async () : Promise => { + await AppUnlockUtility.enableAuthMethod('faceid'); setIsBiometricsEnabled(true); - setAuthMethods(['faceid', 'password']); if (Platform.OS === 'ios') { Linking.openURL('app-settings:'); } else { @@ -123,9 +100,9 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode { /** * Handle the cancel press. */ - onPress: () : void => { + onPress: async () : Promise => { + await AppUnlockUtility.disableAuthMethod('faceid'); setIsBiometricsEnabled(false); - setAuthMethods(['password']); }, }, ] @@ -133,8 +110,8 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode { return; } - // Check if keystore is available when enabling biometrics (iOS requires device passcode) - if (value && Platform.OS === 'ios') { + // Check if keystore is available when enabling biometrics (requires device passcode) + if (value) { const keystoreAvailable = await NativeVaultManager.isKeystoreAvailable(); if (!keystoreAvailable) { showAlert( @@ -146,13 +123,16 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode { } /* - * Biometrics and PIN can now both be enabled simultaneously. - * Biometrics takes priority during unlock, PIN serves as fallback. + * Save new biometrics state. */ + if (value) { + await AppUnlockUtility.enableAuthMethod('faceid'); + } else { + await AppUnlockUtility.disableAuthMethod('faceid'); + } setIsBiometricsEnabled(value); - setAuthMethods(value ? ['faceid', 'password'] : ['password']); - // Show toast notification only on biometrics enabled + // Show toast notification if (value) { Toast.show({ type: 'success', @@ -161,7 +141,7 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode { visibilityTime: 1200, }); } - }, [hasBiometrics, setAuthMethods, biometricDisplayName, showDialog, showAlert, t]); + }, [hasBiometrics, biometricDisplayName, showDialog, showAlert, t]); /** * Handle enable PIN - launches native PIN setup UI. diff --git a/apps/mobile-app/app/login.tsx b/apps/mobile-app/app/login.tsx index 69cd2f4e2..ac9843cd6 100644 --- a/apps/mobile-app/app/login.tsx +++ b/apps/mobile-app/app/login.tsx @@ -8,6 +8,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { StyleSheet, View, Text, SafeAreaView, TextInput, ActivityIndicator, Animated, ScrollView, KeyboardAvoidingView, Platform, Dimensions } from 'react-native'; import { useApiUrl } from '@/utils/ApiUrlUtility'; +import { AppUnlockUtility } from '@/utils/AppUnlockUtility'; import ConversionUtility from '@/utils/ConversionUtility'; import type { EncryptionKeyDerivationParams } from '@/utils/dist/core/models/metadata'; import type { LoginResponse } from '@/utils/dist/core/models/webapi'; @@ -166,9 +167,9 @@ export default function LoginScreen() : React.ReactNode { passwordHashBase64: string, initiateLoginResponse: LoginResponse ) : Promise => { - // Get biometric display name from auth context - const biometricDisplayName = await authContext.getBiometricDisplayName(); - const isBiometricsEnabledOnDevice = await authContext.isBiometricsEnabledOnDevice(); + // Get biometric display name + const biometricDisplayName = await AppUnlockUtility.getBiometricDisplayName(); + const isBiometricsEnabledOnDevice = await AppUnlockUtility.isBiometricsAvailableOnDevice(); const isKeystoreAvailable = await NativeVaultManager.isKeystoreAvailable(); /* diff --git a/apps/mobile-app/app/unlock.tsx b/apps/mobile-app/app/unlock.tsx index 4981928e6..a29de5028 100644 --- a/apps/mobile-app/app/unlock.tsx +++ b/apps/mobile-app/app/unlock.tsx @@ -4,6 +4,7 @@ import { router } from 'expo-router'; import { useState, useEffect, useCallback } from 'react'; import { StyleSheet, View, KeyboardAvoidingView, Platform, ScrollView, Dimensions, Text } from 'react-native'; +import { AppUnlockUtility } from '@/utils/AppUnlockUtility'; import { HapticsUtility } from '@/utils/HapticsUtility'; import { AppErrorCode, getAppErrorCode, getErrorTranslationKey, formatErrorWithCode } from '@/utils/types/errors/AppErrorCodes'; import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError'; @@ -26,7 +27,7 @@ import NativeVaultManager from '@/specs/NativeVaultManager'; * Unlock screen. */ export default function UnlockScreen() : React.ReactNode { - const { isLoggedIn, username, isBiometricsEnabled, getBiometricDisplayName, getEncryptionKeyDerivationParams } = useApp(); + const { isLoggedIn, username, getEncryptionKeyDerivationParams } = useApp(); const { logoutUserInitiated, logoutForced } = useLogout(); const dbContext = useDb(); const [isLoading, setIsLoading] = useState(true); @@ -75,13 +76,13 @@ export default function UnlockScreen() : React.ReactNode { } // Check if biometrics is available - const enabled = await isBiometricsEnabled(); + const enabled = await AppUnlockUtility.isBiometricUnlockAvailable(); if (!isMounted) { return; } setIsBiometricsAvailable(enabled); - const displayName = await getBiometricDisplayName(); + const displayName = await AppUnlockUtility.getBiometricDisplayName(); if (!isMounted) { return; } @@ -159,7 +160,7 @@ export default function UnlockScreen() : React.ReactNode { return (): void => { isMounted = false; }; - }, [isBiometricsEnabled, getKeyDerivationParams, getBiometricDisplayName, dbContext, isLoggedIn, username, t, logoutForced]); + }, [getKeyDerivationParams, dbContext, isLoggedIn, username, t, logoutForced]); /** * Hide the alert dialog. diff --git a/apps/mobile-app/context/AppContext.tsx b/apps/mobile-app/context/AppContext.tsx index b44ee4f7f..9cb314e5a 100644 --- a/apps/mobile-app/context/AppContext.tsx +++ b/apps/mobile-app/context/AppContext.tsx @@ -18,14 +18,9 @@ type AppContextType = { setAuthTokens: (username: string, accessToken: string, refreshToken: string) => Promise; login: () => Promise; // Auth methods from AuthContext - getEnabledAuthMethods: () => Promise; - isBiometricsEnabled: () => Promise; setAuthMethods: (methods: AuthMethod[]) => Promise; - getAuthMethodDisplayKey: () => Promise; getAutoLockTimeout: () => Promise; setAutoLockTimeout: (timeout: number) => Promise; - getBiometricDisplayName: () => Promise; - isBiometricsEnabledOnDevice: () => Promise; setOfflineMode: (isOffline: boolean) => void; verifyPassword: (password: string) => Promise; getEncryptionKeyDerivationParams: () => Promise<{ salt: string; encryptionType: string; encryptionSettings: string } | null>; @@ -108,14 +103,9 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children setAuthTokens: auth.setAuthTokens, login: auth.login, // Pass through other auth methods - getEnabledAuthMethods: auth.getEnabledAuthMethods, - isBiometricsEnabled: auth.isBiometricsEnabled, setAuthMethods: auth.setAuthMethods, - getAuthMethodDisplayKey: auth.getAuthMethodDisplayKey, getAutoLockTimeout: auth.getAutoLockTimeout, setAutoLockTimeout: auth.setAutoLockTimeout, - getBiometricDisplayName: auth.getBiometricDisplayName, - isBiometricsEnabledOnDevice: auth.isBiometricsEnabledOnDevice, setOfflineMode: auth.setOfflineMode, verifyPassword: auth.verifyPassword, getEncryptionKeyDerivationParams: auth.getEncryptionKeyDerivationParams, @@ -129,14 +119,9 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children auth.initializeAuth, auth.setAuthTokens, auth.login, - auth.getEnabledAuthMethods, - auth.isBiometricsEnabled, auth.setAuthMethods, - auth.getAuthMethodDisplayKey, auth.getAutoLockTimeout, auth.setAutoLockTimeout, - auth.getBiometricDisplayName, - auth.isBiometricsEnabledOnDevice, auth.setOfflineMode, auth.verifyPassword, auth.getEncryptionKeyDerivationParams, diff --git a/apps/mobile-app/context/AuthContext.tsx b/apps/mobile-app/context/AuthContext.tsx index 1496b1853..6cac7a39d 100644 --- a/apps/mobile-app/context/AuthContext.tsx +++ b/apps/mobile-app/context/AuthContext.tsx @@ -2,10 +2,9 @@ import { Buffer } from 'buffer'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { NavigationContainerRef, ParamListBase } from '@react-navigation/native'; -import * as LocalAuthentication from 'expo-local-authentication'; import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'; -import { Platform } from 'react-native'; import EncryptionUtility from '@/utils/EncryptionUtility'; +import type { AuthMethod } from '@/utils/AppUnlockUtility'; import { useDb } from '@/context/DbContext'; import { dialogEventEmitter } from '@/events/DialogEventEmitter'; @@ -13,18 +12,14 @@ import NativeVaultManager from '@/specs/NativeVaultManager'; import i18n from '@/i18n'; import { LocalPreferencesService } from '@/services/LocalPreferencesService'; -// Create a navigation reference export const navigationRef = React.createRef>(); - -export type AuthMethod = 'faceid' | 'password'; +export type { AuthMethod } from '@/utils/AppUnlockUtility'; type AuthContextType = { isLoggedIn: boolean; isInitialized: boolean; username: string | null; isOffline: boolean; - getEnabledAuthMethods: () => Promise; - isBiometricsEnabled: () => Promise; setAuthTokens: (username: string, accessToken: string, refreshToken: string) => Promise; initializeAuth: () => Promise<{ isLoggedIn: boolean; enabledAuthMethods: AuthMethod[] }>; login: () => Promise; @@ -39,11 +34,8 @@ type AuthContextType = { */ clearAuthForced: (errorMessage?: string) => Promise; setAuthMethods: (methods: AuthMethod[]) => Promise; - getAuthMethodDisplayKey: () => Promise; getAutoLockTimeout: () => Promise; setAutoLockTimeout: (timeout: number) => Promise; - getBiometricDisplayName: () => Promise; - isBiometricsEnabledOnDevice: () => Promise; setOfflineMode: (isOffline: boolean) => void; verifyPassword: (password: string) => Promise; getEncryptionKeyDerivationParams: () => Promise<{ salt: string; encryptionType: string; encryptionSettings: string } | null>; @@ -71,52 +63,6 @@ export const AuthProvider: React.FC<{ const [isOffline, setIsOffline] = useState(false); const dbContext = useDb(); - /** - * Get enabled auth methods from the native module - */ - const getEnabledAuthMethods = useCallback(async (): Promise => { - try { - let methods = await NativeVaultManager.getAuthMethods() as AuthMethod[]; - // Check if Face ID is actually available despite being enabled - if (methods.includes('faceid')) { - const isEnrolled = await LocalAuthentication.isEnrolledAsync(); - if (!isEnrolled) { - // Remove Face ID from the list of enabled auth methods - methods = methods.filter(method => method !== 'faceid'); - } - } - return methods; - } catch (error) { - console.error('Failed to get enabled auth methods:', error); - return ['password']; - } - }, []); - - /** - * Check if biometrics is enabled on the device (regardless of whether it's enabled in the AliasVault app). - */ - const isBiometricsEnabledOnDevice = useCallback(async (): Promise => { - const hasBiometrics = await LocalAuthentication.hasHardwareAsync(); - if (!hasBiometrics) { - return false; - } - - return await LocalAuthentication.isEnrolledAsync(); - }, []); - - /** - * Check if biometrics is enabled based on enabled auth methods - */ - const isBiometricsEnabled = useCallback(async (): Promise => { - const deviceHasBiometrics = await isBiometricsEnabledOnDevice(); - if (!deviceHasBiometrics) { - return false; - } - - const methods = await getEnabledAuthMethods(); - return methods.includes('faceid'); - }, [getEnabledAuthMethods, isBiometricsEnabledOnDevice]); - /** * Set auth tokens in storage as part of the login process. After db is initialized, the login method should be called as well. */ @@ -149,7 +95,8 @@ export const AuthProvider: React.FC<{ setUsername(username); setIsLoggedIn(true); isAuthenticated = true; - methods = await getEnabledAuthMethods(); + const { AppUnlockUtility } = await import('@/utils/AppUnlockUtility'); + methods = await AppUnlockUtility.getEnabledAuthMethods(); } const offline = await NativeVaultManager.getOfflineMode(); @@ -157,7 +104,7 @@ export const AuthProvider: React.FC<{ setIsInitialized(true); setIsOffline(offline); return { isLoggedIn: isAuthenticated, enabledAuthMethods: methods }; - }, [getEnabledAuthMethods]); + }, []); /** * Sync legacy config to native layer @@ -248,83 +195,14 @@ export const AuthProvider: React.FC<{ }, [dbContext, clearAuthForced]); /** - * Set the authentication methods and save them to storage + * Set the authentication methods and save them to storage. + * Delegates to AppUnlockUtility for consistent auth method management. */ const setAuthMethods = useCallback(async (methods: AuthMethod[]): Promise => { - // Ensure password is always included - const methodsToSave = methods.includes('password') ? methods : [...methods, 'password']; - - // Update native credentials manager - try { - await NativeVaultManager.setAuthMethods(methodsToSave); - } catch (error) { - console.error('Failed to update native auth methods:', error); - } + const { AppUnlockUtility } = await import('@/utils/AppUnlockUtility'); + await AppUnlockUtility.setAuthMethods(methods); }, []); - /** - * Get the appropriate biometric display name translation key based on device capabilities - */ - const getBiometricDisplayName = useCallback(async (): Promise => { - try { - const hasBiometrics = await LocalAuthentication.hasHardwareAsync(); - const enrolled = await LocalAuthentication.isEnrolledAsync(); - - // For Android, we use the term "Biometrics" for facial recognition and fingerprint. - if (Platform.OS === 'android') { - return i18n.t('settings.vaultUnlockSettings.biometrics'); - } - - // For iOS, we check if the device has explicit Face ID or Touch ID support. - if (!hasBiometrics || !enrolled) { - return i18n.t('settings.vaultUnlockSettings.faceIdTouchId'); - } - - const types = await LocalAuthentication.supportedAuthenticationTypesAsync(); - const hasFaceIDSupport = types.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION); - const hasTouchIDSupport = types.includes(LocalAuthentication.AuthenticationType.FINGERPRINT); - - if (hasFaceIDSupport) { - return i18n.t('settings.vaultUnlockSettings.faceId'); - } else if (hasTouchIDSupport) { - return i18n.t('settings.vaultUnlockSettings.touchId'); - } - - return i18n.t('settings.vaultUnlockSettings.faceIdTouchId'); - } catch (error) { - console.error('Failed to get biometric display name:', error); - return i18n.t('settings.vaultUnlockSettings.faceIdTouchId'); - } - }, []); - - /** - * Get the display label translation key for the current auth method - * Priority: Biometrics > PIN > Password - */ - const getAuthMethodDisplayKey = useCallback(async (): Promise => { - try { - // Check for biometrics first (highest priority) - const methods = await getEnabledAuthMethods(); - if (methods.includes('faceid')) { - if (await isBiometricsEnabledOnDevice()) { - return await getBiometricDisplayName(); - } - } - - // Check for PIN (second priority) - const pinEnabled = await NativeVaultManager.isPinEnabled(); - if (pinEnabled) { - return 'settings.vaultUnlockSettings.pin'; - } - - // Fallback to password - return 'items.password'; - } catch (error) { - console.error('Failed to get auth method display key:', error); - return 'items.password'; - } - }, [getEnabledAuthMethods, getBiometricDisplayName, isBiometricsEnabledOnDevice]); - /** * Get the auto-lock timeout from the iOS credentials manager */ @@ -433,19 +311,14 @@ export const AuthProvider: React.FC<{ username, shouldShowAutofillReminder, isOffline, - getEnabledAuthMethods, - isBiometricsEnabled, setAuthTokens, initializeAuth, login, clearAuthUserInitiated, clearAuthForced, setAuthMethods, - getAuthMethodDisplayKey, - isBiometricsEnabledOnDevice, getAutoLockTimeout, setAutoLockTimeout, - getBiometricDisplayName, markAutofillConfigured, verifyPassword, getEncryptionKeyDerivationParams, @@ -456,19 +329,14 @@ export const AuthProvider: React.FC<{ username, shouldShowAutofillReminder, isOffline, - getEnabledAuthMethods, - isBiometricsEnabled, setAuthTokens, initializeAuth, login, clearAuthUserInitiated, clearAuthForced, setAuthMethods, - getAuthMethodDisplayKey, - isBiometricsEnabledOnDevice, getAutoLockTimeout, setAutoLockTimeout, - getBiometricDisplayName, markAutofillConfigured, verifyPassword, getEncryptionKeyDerivationParams, diff --git a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm index 69b60735e..bcda5625d 100644 --- a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm +++ b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm @@ -273,6 +273,10 @@ [vaultManager isKeystoreAvailable:resolve rejecter:reject]; } +- (void)isBiometricUnlockAvailable:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [vaultManager isBiometricUnlockAvailable:resolve rejecter:reject]; +} + - (void)removeAndDisablePin:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [vaultManager removeAndDisablePin:resolve rejecter:reject]; } diff --git a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift index 7c19aa06c..aa9f79bf5 100644 --- a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift +++ b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift @@ -732,6 +732,16 @@ public class VaultManager: NSObject { resolve(vaultStore.isKeystoreAvailable()) } + /// Check if biometric unlock is actually available (device + key validation). + /// This checks not only if biometrics are configured in auth methods, + /// but also validates that the encryption key in Keychain is valid. + /// Returns false if key has been invalidated (e.g., biometric enrollment changed). + @objc + func isBiometricUnlockAvailable(_ resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + resolve(vaultStore.isBiometricAuthEnabled()) + } + @objc func getPinFailedAttempts(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { diff --git a/apps/mobile-app/specs/NativeVaultManager.ts b/apps/mobile-app/specs/NativeVaultManager.ts index 149d9a005..93ed77b0d 100644 --- a/apps/mobile-app/specs/NativeVaultManager.ts +++ b/apps/mobile-app/specs/NativeVaultManager.ts @@ -94,6 +94,12 @@ export interface Spec extends TurboModule { // PIN unlock methods isPinEnabled(): Promise; isKeystoreAvailable(): Promise; + + // Biometric unlock validation - checks if biometric unlock is actually available + // Returns true only if device supports biometrics AND the encryption key is valid + // Returns false if key has been invalidated (e.g., biometric enrollment changed) + isBiometricUnlockAvailable(): Promise; + removeAndDisablePin(): Promise; showPinUnlock(): Promise; showPinSetup(): Promise; diff --git a/apps/mobile-app/utils/AppUnlockUtility.ts b/apps/mobile-app/utils/AppUnlockUtility.ts new file mode 100644 index 000000000..6f5083adb --- /dev/null +++ b/apps/mobile-app/utils/AppUnlockUtility.ts @@ -0,0 +1,216 @@ +import { Platform } from 'react-native'; +import * as LocalAuthentication from 'expo-local-authentication'; +import NativeVaultManager from '@/specs/NativeVaultManager'; +import i18n from '@/i18n'; + +export type AuthMethod = 'faceid' | 'password'; + +/** + * Comprehensive utility for all app unlock-related functionality. + * Centralizes biometric availability checks, display name logic, auth method management, + * and unlock configuration. + */ +export class AppUnlockUtility { + /** + * Get enabled auth methods from the native module. + * Filters out Face ID if biometrics are not enrolled. + */ + static async getEnabledAuthMethods(): Promise { + try { + let methods = await NativeVaultManager.getAuthMethods() as AuthMethod[]; + // Check if Face ID is actually available despite being enabled + if (methods.includes('faceid')) { + const isEnrolled = await LocalAuthentication.isEnrolledAsync(); + if (!isEnrolled) { + // Remove Face ID from the list of enabled auth methods + methods = methods.filter(method => method !== 'faceid'); + } + } + return methods; + } catch (error) { + console.error('Failed to get enabled auth methods:', error); + return ['password']; + } + } + + /** + * Set the authentication methods and save them to native storage. + * Always ensures password is included as a fallback. + */ + static async setAuthMethods(methods: AuthMethod[]): Promise { + // Ensure password is always included + const methodsToSave = methods.includes('password') ? methods : [...methods, 'password']; + + // Update native credentials manager + try { + await NativeVaultManager.setAuthMethods(methodsToSave); + } catch (error) { + console.error('Failed to update native auth methods:', error); + throw error; + } + } + + /** + * Enable an authentication method by adding it to the current auth methods. + * This handles getting current methods, adding the new one, and saving. + */ + static async enableAuthMethod(method: AuthMethod): Promise { + try { + const currentMethods = await this.getEnabledAuthMethods(); + if (!currentMethods.includes(method)) { + await this.setAuthMethods([...currentMethods, method]); + } + } catch (error) { + console.error(`Failed to enable auth method ${method}:`, error); + throw error; + } + } + + /** + * Disable an authentication method by removing it from the current auth methods. + * This handles getting current methods, removing the specified one, and saving. + * Password cannot be disabled as it's always required as a fallback. + */ + static async disableAuthMethod(method: AuthMethod): Promise { + if (method === 'password') { + console.warn('Cannot disable password auth method - it is always required'); + return; + } + + try { + const currentMethods = await this.getEnabledAuthMethods(); + const updatedMethods = currentMethods.filter(m => m !== method); + await this.setAuthMethods(updatedMethods); + } catch (error) { + console.error(`Failed to disable auth method ${method}:`, error); + throw error; + } + } + /** + * Check if biometrics are available on the device (hardware + enrollment). + * This only checks device capabilities, not key validity. + */ + static async isBiometricsAvailableOnDevice(): Promise { + try { + const hasHardware = await LocalAuthentication.hasHardwareAsync(); + if (!hasHardware) { + return false; + } + + return await LocalAuthentication.isEnrolledAsync(); + } catch (error) { + console.error('Error checking biometric device availability:', error); + return false; + } + } + + /** + * Check if biometric unlock is actually available and functional. + * This performs comprehensive validation: + * - Device has biometric hardware + * - Biometrics are enrolled + * - Biometrics are enabled in auth methods + * - Encryption key in native KeyStore/Keychain is valid + * + * Returns false if the key has been invalidated (e.g., biometric enrollment changed). + * Use this method when determining whether to show biometric unlock UI. + * + * IMPORTANT: If the key is invalid but 'faceid' is still in auth methods, + * this method will automatically remove it to keep state consistent. + */ + static async isBiometricUnlockAvailable(): Promise { + try { + // First check device capabilities + const deviceAvailable = await this.isBiometricsAvailableOnDevice(); + if (!deviceAvailable) { + return false; + } + + // Then validate that biometric unlock is actually functional + // This checks auth methods AND validates the encryption key + const isAvailable = await NativeVaultManager.isBiometricUnlockAvailable(); + + // If biometric unlock is NOT available but 'faceid' is still in auth methods, + // automatically remove it to keep state consistent + if (!isAvailable) { + const currentMethods = await this.getEnabledAuthMethods(); + if (currentMethods.includes('faceid')) { + console.log('Biometric key invalid but faceid still in auth methods - cleaning up'); + const methodsWithoutBiometric = currentMethods.filter(m => m !== 'faceid'); + await this.setAuthMethods(methodsWithoutBiometric); + } + } + + return isAvailable; + } catch (error) { + console.error('Error checking biometric unlock availability:', error); + return false; + } + } + + /** + * Get the appropriate biometric display name translation key based on device capabilities. + * Returns localization keys like 'settings.vaultUnlockSettings.faceId', 'biometrics', etc. + */ + static async getBiometricDisplayName(): Promise { + try { + const hasBiometrics = await LocalAuthentication.hasHardwareAsync(); + const enrolled = await LocalAuthentication.isEnrolledAsync(); + + // For Android, we use the term "Biometrics" for facial recognition and fingerprint. + if (Platform.OS === 'android') { + return i18n.t('settings.vaultUnlockSettings.biometrics'); + } + + // For iOS, we check if the device has explicit Face ID or Touch ID support. + if (!hasBiometrics || !enrolled) { + return i18n.t('settings.vaultUnlockSettings.faceIdTouchId'); + } + + const types = await LocalAuthentication.supportedAuthenticationTypesAsync(); + const hasFaceIDSupport = types.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION); + const hasTouchIDSupport = types.includes(LocalAuthentication.AuthenticationType.FINGERPRINT); + + if (hasFaceIDSupport) { + return i18n.t('settings.vaultUnlockSettings.faceId'); + } else if (hasTouchIDSupport) { + return i18n.t('settings.vaultUnlockSettings.touchId'); + } + + return i18n.t('settings.vaultUnlockSettings.faceIdTouchId'); + } catch (error) { + console.error('Failed to get biometric display name:', error); + return i18n.t('settings.vaultUnlockSettings.faceIdTouchId'); + } + } + + /** + * Get the display label translation key for the current auth method. + * Priority: Biometrics > PIN > Password + * + * @returns Translation key for the primary unlock method + */ + static async getAuthMethodDisplayKey(): Promise { + try { + // Check for biometrics first (highest priority) + const methods = await this.getEnabledAuthMethods(); + if (methods.includes('faceid')) { + if (await this.isBiometricUnlockAvailable()) { + return await this.getBiometricDisplayName(); + } + } + + // Check for PIN (second priority) + const pinEnabled = await NativeVaultManager.isPinEnabled(); + if (pinEnabled) { + return 'settings.vaultUnlockSettings.pin'; + } + + // Fallback to password + return 'items.password'; + } catch (error) { + console.error('Failed to get auth method display key:', error); + return 'items.password'; + } + } +}