Merge pull request #1816 from aliasvault/1809-bug-code-e511

Improve biometric unlock error flow in Android in case it fails due to changed fingerprints/faces
This commit is contained in:
Leendert de Borst
2026-03-05 17:16:38 +01:00
committed by GitHub
14 changed files with 392 additions and 229 deletions

View File

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

View File

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

View File

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

View File

@@ -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
}
}
/**

View File

@@ -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<void> => {
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])
);
/**

View File

@@ -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<AuthMethod[]>([]);
// PIN state
const [pinEnabled, setPinEnabled] = useState(false);
@@ -36,64 +33,44 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode {
*/
const initializeAuth = async () : Promise<void> => {
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<void> => {
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<void> => {
if (value && !hasBiometrics) {
@@ -107,9 +84,9 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode {
/**
* Handle the open settings press.
*/
onPress: () : void => {
onPress: async () : Promise<void> => {
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<void> => {
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.

View File

@@ -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<void> => {
// 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();
/*

View File

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

View File

@@ -18,14 +18,9 @@ type AppContextType = {
setAuthTokens: (username: string, accessToken: string, refreshToken: string) => Promise<void>;
login: () => Promise<void>;
// Auth methods from AuthContext
getEnabledAuthMethods: () => Promise<AuthMethod[]>;
isBiometricsEnabled: () => Promise<boolean>;
setAuthMethods: (methods: AuthMethod[]) => Promise<void>;
getAuthMethodDisplayKey: () => Promise<string>;
getAutoLockTimeout: () => Promise<number>;
setAutoLockTimeout: (timeout: number) => Promise<void>;
getBiometricDisplayName: () => Promise<string>;
isBiometricsEnabledOnDevice: () => Promise<boolean>;
setOfflineMode: (isOffline: boolean) => void;
verifyPassword: (password: string) => Promise<string | null>;
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,

View File

@@ -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<NavigationContainerRef<ParamListBase>>();
export type AuthMethod = 'faceid' | 'password';
export type { AuthMethod } from '@/utils/AppUnlockUtility';
type AuthContextType = {
isLoggedIn: boolean;
isInitialized: boolean;
username: string | null;
isOffline: boolean;
getEnabledAuthMethods: () => Promise<AuthMethod[]>;
isBiometricsEnabled: () => Promise<boolean>;
setAuthTokens: (username: string, accessToken: string, refreshToken: string) => Promise<void>;
initializeAuth: () => Promise<{ isLoggedIn: boolean; enabledAuthMethods: AuthMethod[] }>;
login: () => Promise<void>;
@@ -39,11 +34,8 @@ type AuthContextType = {
*/
clearAuthForced: (errorMessage?: string) => Promise<void>;
setAuthMethods: (methods: AuthMethod[]) => Promise<void>;
getAuthMethodDisplayKey: () => Promise<string>;
getAutoLockTimeout: () => Promise<number>;
setAutoLockTimeout: (timeout: number) => Promise<void>;
getBiometricDisplayName: () => Promise<string>;
isBiometricsEnabledOnDevice: () => Promise<boolean>;
setOfflineMode: (isOffline: boolean) => void;
verifyPassword: (password: string) => Promise<string | null>;
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<AuthMethod[]> => {
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<boolean> => {
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<boolean> => {
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<void> => {
// 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<string> => {
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<string> => {
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,

View File

@@ -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];
}

View File

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

View File

@@ -94,6 +94,12 @@ export interface Spec extends TurboModule {
// PIN unlock methods
isPinEnabled(): Promise<boolean>;
isKeystoreAvailable(): Promise<boolean>;
// 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<boolean>;
removeAndDisablePin(): Promise<void>;
showPinUnlock(): Promise<void>;
showPinSetup(): Promise<void>;

View File

@@ -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<AuthMethod[]> {
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<void> {
// 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<void> {
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<void> {
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<boolean> {
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<boolean> {
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<string> {
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<string> {
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';
}
}
}