diff --git a/packages/core-mobile/app/new/common/components/PinScreen.tsx b/packages/core-mobile/app/new/common/components/PinScreen.tsx index e377f08d57..6d68769e29 100644 --- a/packages/core-mobile/app/new/common/components/PinScreen.tsx +++ b/packages/core-mobile/app/new/common/components/PinScreen.tsx @@ -40,9 +40,13 @@ import { commonStorage } from 'utils/mmkv' import { StorageKey } from 'resources/Constants' export const PinScreen = ({ - onForgotPin + onForgotPin, + isInitialLogin = false, + onBiometricPrompt }: { onForgotPin: () => void + isInitialLogin?: boolean + onBiometricPrompt: () => Promise }): JSX.Element => { const walletState = useSelector(selectWalletState) usePreventScreenRemoval(walletState === WalletState.INACTIVE) @@ -82,19 +86,21 @@ export const PinScreen = ({ // JS thread is blocked, so we need to wait for the animation to finish for updating the UI after the keyboard is closed setTimeout(async () => { try { - if (!walletId) { - throw new Error('Wallet ID is not set') - } - const result = await BiometricsSDK.loadWalletSecret(walletId) //for now we only support one wallet, multiple wallets will be supported in the upcoming PR - if (!result.success) { - throw result.error + if (isInitialLogin) { + if (!walletId) { + throw new Error('Wallet ID is not set') + } + const result = await BiometricsSDK.loadWalletSecret(walletId) //for now we only support one wallet, multiple wallets will be supported in the upcoming PR + if (!result.success) { + throw result.error + } } await unlock() } catch (error) { Logger.error('Failed to login:', error) } }, 0) - }, [handleStartLoading, isProcessing, unlock, walletId]) + }, [handleStartLoading, isInitialLogin, isProcessing, unlock, walletId]) const { enteredPin, @@ -106,6 +112,8 @@ export const PinScreen = ({ bioType, isBiometricAvailable } = usePinOrBiometryLogin({ + isInitialLogin, + onBiometricPrompt, onWrongPin: handleWrongPin, onStartLoading: handleStartLoading, onStopLoading: handleStopLoading @@ -201,43 +209,48 @@ export const PinScreen = ({ } }, []) + const handleLoginOptions = useCallback(() => { + const accessType = BiometricsSDK.getAccessType() + if (accessType === 'BIO') { + handlePromptBioLogin() + } else { + focusPinInput() + } + }, [handlePromptBioLogin, focusPinInput]) + + const handleBrokenBioState = useCallback(() => { + InteractionManager.runAfterInteractions(() => { + let accessType = BiometricsSDK.getAccessType() + + /* + * Fix inconsistent state: if accessType is 'BIO' but biometrics are disabled or not available + * This is needed due to previous bug in BiometricsSDK.storeEncryptionKeyWithBiometry() + * which was called by KeychainMigrator during app updates/migrations. + * The bug set SECURE_ACCESS_SET to 'BIO' before checking if biometric storage succeeded, + * leaving users in an inconsistent state when biometrics weren't available. + * We automatically correct this by setting SECURE_ACCESS_SET back to 'PIN' + */ + const isBrokenBioState = + accessType === 'BIO' && (!useBiometrics || !isBiometricAvailable) + + if (isBrokenBioState) { + commonStorage.set(StorageKey.SECURE_ACCESS_SET, 'PIN') + accessType = BiometricsSDK.getAccessType() + } + }) + }, [useBiometrics, isBiometricAvailable]) + useFocusEffect( useCallback(() => { - InteractionManager.runAfterInteractions(() => { - let accessType = BiometricsSDK.getAccessType() - - /* - * Fix inconsistent state: if accessType is 'BIO' but biometrics are disabled or not available - * This is needed due to previous bug in BiometricsSDK.storeEncryptionKeyWithBiometry() - * which was called by KeychainMigrator during app updates/migrations. - * The bug set SECURE_ACCESS_SET to 'BIO' before checking if biometric storage succeeded, - * leaving users in an inconsistent state when biometrics weren't available. - * We automatically correct this by setting SECURE_ACCESS_SET back to 'PIN' - */ - const isBrokenBioState = - accessType === 'BIO' && (!useBiometrics || !isBiometricAvailable) - - if (isBrokenBioState) { - commonStorage.set(StorageKey.SECURE_ACCESS_SET, 'PIN') - accessType = BiometricsSDK.getAccessType() - } - - if (accessType === 'BIO') { - handlePromptBioLogin() - } else { - focusPinInput() - } - }) + if (isInitialLogin) { + handleBrokenBioState() + } + handleLoginOptions() return () => { blurPinInput() } - }, [ - isBiometricAvailable, - useBiometrics, - handlePromptBioLogin, - focusPinInput - ]) + }, [isInitialLogin, handleBrokenBioState, handleLoginOptions]) ) useEffect(() => { diff --git a/packages/core-mobile/app/new/common/components/PinScreenOverlay.tsx b/packages/core-mobile/app/new/common/components/PinScreenOverlay.tsx index 64083df1a1..a9766631dd 100644 --- a/packages/core-mobile/app/new/common/components/PinScreenOverlay.tsx +++ b/packages/core-mobile/app/new/common/components/PinScreenOverlay.tsx @@ -5,6 +5,7 @@ import { FullWindowOverlay } from 'react-native-screens' import { useFocusEffect } from 'expo-router' import { Keyboard } from 'react-native' import { useDeleteWallet } from 'common/hooks/useDeleteWallet' +import BiometricsSDK from 'utils/BiometricsSDK' import { ForgotPinComponent } from './ForgotPinComponent' import { PinScreen } from './PinScreen' @@ -25,6 +26,10 @@ export const PinScreenOverlay = (): JSX.Element => { deleteWallet() }, [deleteWallet]) + const handleBiometricPrompt = useCallback(async () => { + return BiometricsSDK.authenticateAsync() + }, []) + return ( { contentContainerStyle={{ flex: 1 }}> - setShowForgotPin(true)} /> + setShowForgotPin(true)} + onBiometricPrompt={handleBiometricPrompt} + /> )} diff --git a/packages/core-mobile/app/new/common/components/VerifyPin.tsx b/packages/core-mobile/app/new/common/components/VerifyPin.tsx index b3bf72949f..c4cf55e2d1 100644 --- a/packages/core-mobile/app/new/common/components/VerifyPin.tsx +++ b/packages/core-mobile/app/new/common/components/VerifyPin.tsx @@ -3,6 +3,7 @@ import { useFocusEffect } from 'expo-router' import React, { useCallback, useEffect, useRef } from 'react' import { InteractionManager, Keyboard, Platform } from 'react-native' import { usePinOrBiometryLogin } from 'common/hooks/usePinOrBiometryLogin' +import BiometricsSDK from 'utils/BiometricsSDK' import { ScrollScreen } from './ScrollScreen' /** @@ -34,8 +35,13 @@ export const VerifyPin = ({ pinInputRef.current?.stopLoadingAnimation(onComplete) } + const handleBiometricPrompt = useCallback(async () => { + return BiometricsSDK.authenticateAsync() + }, []) + const { enteredPin, onEnterPin, disableKeypad, timeRemaining, verified } = usePinOrBiometryLogin({ + onBiometricPrompt: handleBiometricPrompt, onWrongPin: handleWrongPin, onStartLoading: handleStartLoading, onStopLoading: handleStopLoading diff --git a/packages/core-mobile/app/new/common/components/VerifyWithPinOrBiometry.tsx b/packages/core-mobile/app/new/common/components/VerifyWithPinOrBiometry.tsx index bd3e7d6447..44c8334f05 100644 --- a/packages/core-mobile/app/new/common/components/VerifyWithPinOrBiometry.tsx +++ b/packages/core-mobile/app/new/common/components/VerifyWithPinOrBiometry.tsx @@ -27,6 +27,10 @@ export const VerifyWithPinOrBiometry = ({ pinInputRef.current?.stopLoadingAnimation(onComplete) } + const handleBiometricPrompt = useCallback(async () => { + return BiometricsSDK.authenticateAsync() + }, []) + const { enteredPin, onEnterPin, @@ -35,6 +39,7 @@ export const VerifyWithPinOrBiometry = ({ disableKeypad, timeRemaining } = usePinOrBiometryLogin({ + onBiometricPrompt: handleBiometricPrompt, onWrongPin: handleWrongPin, onStartLoading: handleStartLoading, onStopLoading: handleStopLoading diff --git a/packages/core-mobile/app/new/common/hooks/usePinOrBiometryLogin.ts b/packages/core-mobile/app/new/common/hooks/usePinOrBiometryLogin.ts index 354d106b5b..1c1bdc84a1 100644 --- a/packages/core-mobile/app/new/common/hooks/usePinOrBiometryLogin.ts +++ b/packages/core-mobile/app/new/common/hooks/usePinOrBiometryLogin.ts @@ -18,11 +18,15 @@ import { useRateLimiter } from './useRateLimiter' export function usePinOrBiometryLogin({ onStartLoading, onStopLoading, - onWrongPin + onWrongPin, + isInitialLogin = false, + onBiometricPrompt }: { onStartLoading: () => void onStopLoading: (onComplete?: () => void) => void onWrongPin: () => void + isInitialLogin?: boolean + onBiometricPrompt: () => Promise }): { enteredPin: string onEnterPin: (pinKey: string) => void @@ -86,8 +90,11 @@ export function usePinOrBiometryLogin({ if (!activeWalletId) { throw new Error('Active wallet ID is not set') } - const migrator = new KeychainMigrator(activeWalletId) - await migrator.migrateIfNeeded('PIN', pin) + + if (isInitialLogin) { + const migrator = new KeychainMigrator(activeWalletId) + await migrator.migrateIfNeeded('PIN', pin) + } // Load encryption key const isValidPin = await BiometricsSDK.loadEncryptionKeyWithPin(pin) @@ -126,10 +133,11 @@ export function usePinOrBiometryLogin({ [ onStartLoading, activeWalletId, + isInitialLogin, + resetRateLimiter, onStopLoading, increaseAttempt, onWrongPin, - resetRateLimiter, alertBadData ] ) @@ -146,6 +154,7 @@ export function usePinOrBiometryLogin({ } const verifyBiometric = + // eslint-disable-next-line sonarjs/cognitive-complexity useCallback(async (): Promise => { try { if (!activeWalletId) { @@ -158,27 +167,30 @@ export function usePinOrBiometryLogin({ if (accessType === 'BIO') { // Check if migration is needed first - const migrator = new KeychainMigrator(activeWalletId) - const result = await migrator.migrateIfNeeded('BIO') - if ( - result.success && - result.value === MigrationStatus.RunBiometricMigration - ) { - //already prompted user for bio, assume verified - setVerified(true) - resetRateLimiter() - return new NothingToLoad() - } - if ( - result.success && - result.value !== MigrationStatus.NoMigrationNeeded - ) { - throw new Error( - 'Invalid state: migration status is not RunBiometricMigration' - ) + + if (isInitialLogin) { + const migrator = new KeychainMigrator(activeWalletId) + const result = await migrator.migrateIfNeeded('BIO') + if ( + result.success && + result.value === MigrationStatus.RunBiometricMigration + ) { + //already prompted user for bio, assume verified + setVerified(true) + resetRateLimiter() + return new NothingToLoad() + } + if ( + result.success && + result.value !== MigrationStatus.NoMigrationNeeded + ) { + throw new Error( + 'Invalid state: migration status is not RunBiometricMigration' + ) + } } //already migrated - const isSuccess = await BiometricsSDK.loadEncryptionKeyWithBiometry() + const isSuccess = await onBiometricPrompt() if (isSuccess) { setVerified(true) @@ -201,7 +213,13 @@ export function usePinOrBiometryLogin({ setVerified(false) throw err } - }, [activeWalletId, alertBadData, resetRateLimiter]) + }, [ + activeWalletId, + alertBadData, + resetRateLimiter, + isInitialLogin, + onBiometricPrompt + ]) useEffect(() => { async function getBiometryType(): Promise { diff --git a/packages/core-mobile/app/new/routes/loginWithPinOrBiometry.tsx b/packages/core-mobile/app/new/routes/loginWithPinOrBiometry.tsx index 91067fc443..ea53e250c2 100644 --- a/packages/core-mobile/app/new/routes/loginWithPinOrBiometry.tsx +++ b/packages/core-mobile/app/new/routes/loginWithPinOrBiometry.tsx @@ -1,6 +1,7 @@ import { ScrollScreen } from 'common/components/ScrollScreen' -import React from 'react' +import React, { useCallback } from 'react' import { useRouter } from 'expo-router' +import BiometricsSDK from 'utils/BiometricsSDK' import { PinScreen } from '../common/components/PinScreen' const LoginWithPinOrBiometry = (): JSX.Element => { @@ -11,13 +12,21 @@ const LoginWithPinOrBiometry = (): JSX.Element => { router.navigate('/forgotPin') } + const handleBiometricPrompt = useCallback(async () => { + return BiometricsSDK.loadEncryptionKeyWithBiometry() + }, []) + return ( - + ) } diff --git a/packages/core-mobile/app/utils/BiometricsSDK.ts b/packages/core-mobile/app/utils/BiometricsSDK.ts index f115200f6e..108dcba660 100644 --- a/packages/core-mobile/app/utils/BiometricsSDK.ts +++ b/packages/core-mobile/app/utils/BiometricsSDK.ts @@ -1,19 +1,31 @@ -import Keychain, { - getSupportedBiometryType, - SetOptions, - GetOptions, - BaseOptions, - hasGenericPassword -} from 'react-native-keychain' +import * as LocalAuthentication from 'expo-local-authentication' import { StorageKey } from 'resources/Constants' -import { Platform } from 'react-native' import { commonStorage } from 'utils/mmkv' import { decrypt, encrypt } from 'utils/EncryptionHelper' import Aes from 'react-native-aes-crypto' import { Result } from 'types/result' +import Keychain, { + BaseOptions, + GetOptions, + getSupportedBiometryType, + hasGenericPassword, + SetOptions +} from 'react-native-keychain' +import { Platform } from 'react-native' import Logger from './Logger' import { assertNotNull } from './assertions' +const OVERLAY_BIO_PROMPT = { + promptMessage: 'Access Wallet', + fallbackLabel: 'Use passcode' +} + +const COMMON_BIO_PROMPT = { + title: 'Access Wallet', + subtitle: 'Use biometric data to access securely stored wallet information', + cancel: 'Cancel' +} + /** * @deprecated Legacy service keys used for backwards compatibility */ @@ -30,11 +42,12 @@ export const ENCRYPTION_KEY_SERVICE = 'encryption-key-service' export const ENCRYPTION_KEY_SERVICE_BIO = 'encryption-key-service-bio' const iOS = Platform.OS === 'ios' -const COMMON_BIO_PROMPT = { - title: 'Access Wallet', - subtitle: 'Use biometric data to access securely stored wallet information', - cancel: 'Cancel' -} +const bioAuthenticationOptions: LocalAuthentication.LocalAuthenticationOptions = + { + promptMessage: OVERLAY_BIO_PROMPT.promptMessage, + fallbackLabel: OVERLAY_BIO_PROMPT.fallbackLabel, + cancelLabel: 'Cancel' + } export const passcodeGetOptions: GetOptions = { service: ENCRYPTION_KEY_SERVICE, @@ -77,8 +90,6 @@ class BiometricsSDK { return commonStorage.getString(StorageKey.SECURE_ACCESS_SET) } - // Generate a new encryption key during onboarding, it is not used for migration - // it returns the key if it is already generated, to avoid generating it again during re-render async generateEncryptionKey(): Promise { if (this.#encryptionKey) { return this.#encryptionKey @@ -91,7 +102,6 @@ class BiometricsSDK { this.#encryptionKey = null } - // Generate a new encryption key during migration, it is not used for onboarding async generateMigrationEncryptionKey(): Promise { this.clearEncryptionKey() this.#encryptionKey = await Aes.randomKey(32) @@ -394,6 +404,26 @@ class BiometricsSDK { return false } } + + async authenticateAsync(): Promise { + try { + const isEnrolled = await LocalAuthentication.isEnrolledAsync() + if (!isEnrolled) { + Logger.error( + 'Failed to authenticate with biometric', + new Error('Biometric not enrolled') + ) + return false + } + const result = await LocalAuthentication.authenticateAsync( + bioAuthenticationOptions + ) + return result.success + } catch (error) { + Logger.error('Failed to authenticate with biometric', error) + return false + } + } } export default new BiometricsSDK() diff --git a/packages/core-mobile/ios/Podfile.lock b/packages/core-mobile/ios/Podfile.lock index f1b53b87b8..3f96119480 100644 --- a/packages/core-mobile/ios/Podfile.lock +++ b/packages/core-mobile/ios/Podfile.lock @@ -3857,7 +3857,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f - boost: 1dca942403ed9342f98334bf4c3621f011aa7946 + boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 CatCrypto: a477899b6be4954e75be4897e732da098cc0a5a8 ComputableLayout: c50faffac4ed9f8f05b0ce5e6f3a60df1f6042c8 ContextMenuAuxiliaryPreview: 20be0be795b783b68f8792732eed4bed9f202c1c @@ -3870,7 +3870,7 @@ SPEC CHECKSUMS: DatadogTrace: 23df8545911d219d4c15446a4c6c04862ff76175 DatadogWebViewTracking: e88b8057eb5ff06a68baf232ae37637b8bb7518b DGSwiftUtilities: 567f8d5ee618f0b7afb185b17aa45ff356315a0f - DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 + DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb EXApplication: 50cc8ea58c138da6e3f25cd789634219c86b90d5 EXConstants: 9d62a46a36eae6d28cb978efcbc68aef354d1704 EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd @@ -3908,8 +3908,8 @@ SPEC CHECKSUMS: FirebaseCoreInternal: f47dd28ae7782e6a4738aad3106071a8fe0af604 FirebaseInstallations: d8063d302a426d114ac531cd82b1e335a0565745 FirebaseMessaging: 9f4e42053241bd45ce8565c881bfdd9c1df2f7da - fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6 - glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a + fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd + glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleSignIn: d4281ab6cf21542b1cfaff85c191f230b399d2db GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 diff --git a/yarn.lock b/yarn.lock index f42ab868be..959716c936 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28153,7 +28153,7 @@ react-native-webview@ava-labs/react-native-webview: peerDependencies: react: "*" react-native: "*" - checksum: d6ceb75e1d0f5755370d6d91f67902bd2b8a23a3ca43cb853d2964b43798f30477e8ab0223ea8b620534fbdf5f56d39550f40d24bec2d2398c323ccfe8a5a556 + checksum: e08d1254d04f3074970b63cdf0a363d6b189009270d457e868a26ef534addd69b320b85bef2a22e4eddb79006751bded431b56aaf2baf41976a6d0a5a2a2b91e languageName: node linkType: hard