Skip to content
91 changes: 52 additions & 39 deletions packages/core-mobile/app/new/common/components/PinScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>
}): JSX.Element => {
const walletState = useSelector(selectWalletState)
usePreventScreenRemoval(walletState === WalletState.INACTIVE)
Expand Down Expand Up @@ -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,
Expand All @@ -106,6 +112,8 @@ export const PinScreen = ({
bioType,
isBiometricAvailable
} = usePinOrBiometryLogin({
isInitialLogin,
onBiometricPrompt,
onWrongPin: handleWrongPin,
onStartLoading: handleStartLoading,
onStopLoading: handleStopLoading
Expand Down Expand Up @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -25,6 +26,10 @@ export const PinScreenOverlay = (): JSX.Element => {
deleteWallet()
}, [deleteWallet])

const handleBiometricPrompt = useCallback(async () => {
return BiometricsSDK.authenticateAsync()
}, [])

return (
<FullWindowOverlay
// @ts-ignore: FullWindowOverlayProps is not typed with style, but we can still apply style to Android React Native View component
Expand Down Expand Up @@ -52,7 +57,10 @@ export const PinScreenOverlay = (): JSX.Element => {
contentContainerStyle={{
flex: 1
}}>
<PinScreen onForgotPin={() => setShowForgotPin(true)} />
<PinScreen
onForgotPin={() => setShowForgotPin(true)}
onBiometricPrompt={handleBiometricPrompt}
/>
</KeyboardAwareScrollView>
)}
</View>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export const VerifyWithPinOrBiometry = ({
pinInputRef.current?.stopLoadingAnimation(onComplete)
}

const handleBiometricPrompt = useCallback(async () => {
return BiometricsSDK.authenticateAsync()
}, [])

const {
enteredPin,
onEnterPin,
Expand All @@ -35,6 +39,7 @@ export const VerifyWithPinOrBiometry = ({
disableKeypad,
timeRemaining
} = usePinOrBiometryLogin({
onBiometricPrompt: handleBiometricPrompt,
onWrongPin: handleWrongPin,
onStartLoading: handleStartLoading,
onStopLoading: handleStopLoading
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>
}): {
enteredPin: string
onEnterPin: (pinKey: string) => void
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -126,10 +133,11 @@ export function usePinOrBiometryLogin({
[
onStartLoading,
activeWalletId,
isInitialLogin,
resetRateLimiter,
onStopLoading,
increaseAttempt,
onWrongPin,
resetRateLimiter,
alertBadData
]
)
Expand All @@ -146,6 +154,7 @@ export function usePinOrBiometryLogin({
}

const verifyBiometric =
// eslint-disable-next-line sonarjs/cognitive-complexity
useCallback(async (): Promise<WalletLoadingResults> => {
try {
if (!activeWalletId) {
Expand All @@ -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)
Expand All @@ -201,7 +213,13 @@ export function usePinOrBiometryLogin({
setVerified(false)
throw err
}
}, [activeWalletId, alertBadData, resetRateLimiter])
}, [
activeWalletId,
alertBadData,
resetRateLimiter,
isInitialLogin,
onBiometricPrompt
])

useEffect(() => {
async function getBiometryType(): Promise<void> {
Expand Down
13 changes: 11 additions & 2 deletions packages/core-mobile/app/new/routes/loginWithPinOrBiometry.tsx
Original file line number Diff line number Diff line change
@@ -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 => {
Expand All @@ -11,13 +12,21 @@ const LoginWithPinOrBiometry = (): JSX.Element => {
router.navigate('/forgotPin')
}

const handleBiometricPrompt = useCallback(async () => {
return BiometricsSDK.loadEncryptionKeyWithBiometry()
}, [])

return (
<ScrollScreen
shouldAvoidKeyboard
hideHeaderBackground
scrollEnabled={false}
contentContainerStyle={{ flex: 1 }}>
<PinScreen onForgotPin={handleForgotPin} />
<PinScreen
onForgotPin={handleForgotPin}
isInitialLogin={true}
onBiometricPrompt={handleBiometricPrompt}
/>
</ScrollScreen>
)
}
Expand Down
Loading
Loading