mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-03-27 02:52:04 -04:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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])
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
/*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
216
apps/mobile-app/utils/AppUnlockUtility.ts
Normal file
216
apps/mobile-app/utils/AppUnlockUtility.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user