feat: enable device PIN fallback for authentication

This commit is contained in:
keshav0479
2026-03-10 12:58:42 +01:00
committed by minibits-cash
parent 3909660529
commit 14cfb5302b
7 changed files with 167 additions and 103 deletions
+74 -21
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import FlashMessage from "react-native-flash-message"
import {
SafeAreaProvider,
@@ -14,9 +14,8 @@ import {KeyChain, WalletKeys} from './services'
import {ErrorBoundary} from './screens/ErrorScreen/ErrorBoundary'
import Config from './config'
import {log} from './services'
import AppError, { Err } from './utils/AppError'
import { Image, TextStyle, View } from 'react-native'
import { spacing, typography } from './theme'
import { Image, Pressable, Text as RNText, TextStyle, View } from 'react-native'
import { colors, spacing, typography } from './theme'
import { displayName } from '../app.json'
import { Text } from './components/Text'
import useIsInternetReachable from './utils/useIsInternetReachable'
@@ -35,6 +34,8 @@ function App() {
const [isUserAuthenticated, setIsUserAuthenticated] = useState(false)
const [isDeviceAuthenticated, setIsDeviceAuthenticated] = useState(false)
const [isAuthLocked, setIsAuthLocked] = useState(false)
const isAuthInProgressRef = useRef(false)
const isInternetReachable = useIsInternetReachable() // boolean | null
const [isNetworkChecked, setIsNetworkChecked] = useState(false)
@@ -46,20 +47,51 @@ function App() {
}
}, [isInternetReachable])
const attemptAuth = useCallback(async () => {
if (isAuthInProgressRef.current) {
log.trace('[App] attemptAuth skipped, auth already in progress')
return
}
isAuthInProgressRef.current = true
try {
const isAuthEnabled = userSettingsStore.isAuthOn
log.trace('[App] attemptAuth called')
const result = await KeyChain.authenticateOnAppStart(isAuthEnabled)
log.trace('[App] attemptAuth result:', { success: result.success, shouldExitApp: result.shouldExitApp })
if (result.success) {
setIsUserAuthenticated(true)
setIsAuthLocked(false)
return
}
if (result.shouldExitApp) {
RNExitApp.exitApp()
return
}
log.trace('[App] attemptAuth failed, locking app')
setIsAuthLocked(true)
} catch (e: any) {
log.error('[App] attemptAuth caught error', { message: e.message })
setIsAuthLocked(true)
} finally {
isAuthInProgressRef.current = false
}
}, [userSettingsStore])
// === Only safe, always-run startup logic inside useInitialRootStore ===
const { rehydrated } = useInitialRootStore(() => {
log.trace('[App]', 'Root store rehydrated')
// User authentication (biometrics / PIN)
if (userSettingsStore.isAuthOn) {
KeyChain.authenticateOnAppStart(userSettingsStore.isAuthOn)
.then((result) => {
if (result.success) {
setIsUserAuthenticated(true)
} else if (result.shouldExitApp) {
RNExitApp.exitApp()
}
})
// Keep UI in locked mode until authentication succeeds.
setIsAuthLocked(true)
attemptAuth()
} else {
setIsUserAuthenticated(true)
}
@@ -73,7 +105,6 @@ function App() {
relaysStore.resetStatuses()
})
useEffect(() => {
if (
isInternetReachable === null ||
@@ -126,19 +157,41 @@ function App() {
rehydrated
])
// Show splash screen until fully ready
// Splash / Locked screen
if (
!rehydrated ||
!isUserAuthenticated ||
!isDeviceAuthenticated
!isDeviceAuthenticated ||
isAuthLocked
) {
return (
<ErrorBoundary catchErrors={Config.catchErrors}>
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text text={displayName} style={$title} />
<Image source={require('../android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png')} />
</View>
</ErrorBoundary>
<View style={{flex: 1}}>
<ErrorBoundary catchErrors={Config.catchErrors}>
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text text={displayName} style={$title} />
<Image source={require('../android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png')} />
</View>
</ErrorBoundary>
{isAuthLocked && (
<View style={{alignItems: 'center', paddingHorizontal: spacing.large, paddingBottom: 60}}>
<RNText style={{color: colors.palette.neutral400, fontSize: 14, textAlign: 'center', marginBottom: spacing.large}}>
Minibits is locked. Authentication is required to continue.
</RNText>
<Pressable
style={{backgroundColor: colors.palette.accent400, paddingVertical: spacing.small, paddingHorizontal: spacing.extraLarge, borderRadius: spacing.small, marginBottom: spacing.medium, minWidth: 200, alignItems: 'center'}}
onPress={attemptAuth}
>
<RNText style={{color: 'white', fontSize: 16, fontWeight: '600'}}>Authenticate</RNText>
</Pressable>
<Pressable
style={{paddingVertical: spacing.small, paddingHorizontal: spacing.extraLarge, minWidth: 200, alignItems: 'center'}}
onPress={() => RNExitApp.exitApp()}
>
<RNText style={{color: colors.palette.neutral400, fontSize: 16}}>Close app</RNText>
</Pressable>
</View>
)}
</View>
)
}
+6 -6
View File
@@ -473,13 +473,13 @@
"saveContactPrivateDesc": "Save %{nip05} to your Private contacts so you can pay faster",
"scanReceiveExtractFail": "Could not extract ecash token nor LNURL withdraw link to receive.",
"scanScreen_onPasteEmptyClipboard": "First copy ecash token, invoice, LNURL link or lightning address. Then paste.",
"securityScreen_biometricAuth": "Biometric authentication",
"securityScreen_biometricAuthDescription": "Require biometric authentication for Minibits to start.",
"securityScreen_biometry": "Biometric authentication",
"securityScreen_biometryAvailable": "Biometric authentication is now required for Minibits to start.",
"securityScreen_biometryNone": "You have not setup biometric authentication or your device does not support it.",
"securityScreen_biometricAuth": "Authentication",
"securityScreen_biometricAuthDescription": "Require authentication for Minibits to start.",
"securityScreen_biometry": "Authentication",
"securityScreen_biometryAvailable": "Authentication is now required for Minibits to start.",
"securityScreen_biometryNone": "Your device does not support authentication.",
"securityScreen_posAuth": "POS mode authentication",
"securityScreen_posAuthDescription": "Require biometric authentication when leaving wallet POS mode.",
"securityScreen_posAuthDescription": "Require authentication when leaving wallet POS mode.",
"securityScreenTitle": "Security",
"seedRecoveryMints": "Mints",
"seedRecoveryScreenTitle": "Wallet recovery",
+7 -5
View File
@@ -472,11 +472,13 @@
"saveContactPrivateDesc": "Guarda %{nip05} en tus contactos privados para que puedas pagar más rápido",
"scanReceiveExtractFail": "No se pudo extraer el token ecash ni el enlace de retiro de LNURL para recibirlo.",
"scanScreen_onPasteEmptyClipboard": "Primero, copia el token de ecash, la factura, el enlace LNURL o la dirección Lightning. Luego, pégalo.",
"securityScreen_biometricAuth": "Autenticación biométrica",
"securityScreen_biometricAuthDescription": "Requerir autenticación biométrica para que Minibits comience.",
"securityScreen_biometry": "Autenticación biométrica",
"securityScreen_biometryAvailable": "Ahora se requiere autenticación biométrica para iniciar Minibits.",
"securityScreen_biometryNone": "No ha configurado la autenticación biométrica o su dispositivo no la admite.",
"securityScreen_biometricAuth": "Autenticación",
"securityScreen_biometricAuthDescription": "Requerir autenticación para que Minibits comience.",
"securityScreen_biometry": "Autenticación",
"securityScreen_biometryAvailable": "Ahora se requiere autenticación para iniciar Minibits.",
"securityScreen_biometryNone": "Su dispositivo no admite autenticación.",
"securityScreen_posAuth": "Autenticación de modo POS",
"securityScreen_posAuthDescription": "Requerir autenticación al salir del modo POS.",
"securityScreenTitle": "Seguridad",
"seedRecoveryMints": "Mentas",
"seedRecoveryScreenTitle": "Recuperación de billetera",
+7 -5
View File
@@ -473,11 +473,13 @@
"saveContactPrivateDesc": "Salve %{nip05} em seus contatos privados para pagar mais rápido",
"scanReceiveExtractFail": "Não foi possível extrair token ecash nem link de saque LNURL para receber.",
"scanScreen_onPasteEmptyClipboard": "Primeiro copie token ecash, invoice, link LNURL ou endereço Lightning. Depois cole.",
"securityScreen_biometricAuth": "Autenticação biométrica",
"securityScreen_biometricAuthDescription": "Exija autenticação biométrica para iniciar Minibits.",
"securityScreen_biometry": "Autenticação biométrica",
"securityScreen_biometryAvailable": "Autenticação biométrica agora é necessária para iniciar Minibits.",
"securityScreen_biometryNone": "Você não configurou autenticação biométrica ou seu dispositivo não suporta.",
"securityScreen_biometricAuth": "Autenticação",
"securityScreen_biometricAuthDescription": "Exija autenticação para iniciar Minibits.",
"securityScreen_biometry": "Autenticação",
"securityScreen_biometryAvailable": "Autenticação agora é necessária para iniciar Minibits.",
"securityScreen_biometryNone": "Seu dispositivo não suporta autenticação.",
"securityScreen_posAuth": "Autenticação do modo POS",
"securityScreen_posAuthDescription": "Exigir autenticação ao sair do modo POS.",
"securityScreenTitle": "Segurança",
"seedRecoveryMints": "Mints",
"seedRecoveryScreenTitle": "Recuperação de carteira",
+7 -5
View File
@@ -473,11 +473,13 @@
"saveContactPrivateDesc": "Ulož %{nip05} do svojich súkromných kontaktov a plať rýchlejšie.",
"scanReceiveExtractFail": "Nenašiel sa ecash token ani LNURL výber na prijatie.",
"scanScreen_onPasteEmptyClipboard": "Najprv skopíruj ecash token, invoice, LNURL kód, alebo lightning adresu. Potom vlož.",
"securityScreen_biometricAuth": "Biometrická autentifikácia",
"securityScreen_biometricAuthDescription": "Vyžaduj biometrickú autentifikáciu na vstup do Minibits.",
"securityScreen_biometry": "Biometrická autentifikácia",
"securityScreen_biometryAvailable": "Biometrická autentifikácia je potrebná na vstup do Minibits.",
"securityScreen_biometryNone": "Nenastavil si biometrickú autentifikáciu, alebo ju tvoj mobil nepodporuje.",
"securityScreen_biometricAuth": "Autentifikácia",
"securityScreen_biometricAuthDescription": "Vyžaduj autentifikáciu na vstup do Minibits.",
"securityScreen_biometry": "Autentifikácia",
"securityScreen_biometryAvailable": "Autentifikácia je potrebná na vstup do Minibits.",
"securityScreen_biometryNone": "Tvoj mobil nepodporuje autentifikáciu.",
"securityScreen_posAuth": "POS autentifikácia",
"securityScreen_posAuthDescription": "Vyžaduj autentifikáciu pri opúšťaní POS režimu.",
"securityScreenTitle": "Bezpečnosť",
"seedRecoveryMints": "Minty",
"seedRecoveryScreenTitle": "Obnova peňaženky",
+3 -32
View File
@@ -1,16 +1,14 @@
import {observer} from 'mobx-react-lite'
import React, {FC, useEffect, useState} from 'react'
import React, {useState} from 'react'
import {ScrollView, Switch, TextStyle, View, ViewStyle} from 'react-native'
import {colors, spacing, useThemeColor} from '../theme'
import {
Icon,
ListItem,
Screen,
Text,
Card,
Loading,
ErrorModal,
InfoModal,
BottomModal,
PosIcon,
} from '../components'
@@ -19,9 +17,6 @@ import {useHeader} from '../utils/useHeader'
import {useStores} from '../models'
import AppError from '../utils/AppError'
import {ResultModalInfo} from './Wallet/ResultModalInfo'
import { KeyChain } from '../services'
import { BIOMETRY_TYPE } from 'react-native-keychain'
import { log } from '../services/logService'
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
type Props = StaticScreenProps<undefined>
@@ -34,41 +29,18 @@ export const SecurityScreen = observer(function SecurityScreen({ route }: Props)
})
const {userSettingsStore} = useStores()
const [info, setInfo] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isBiometricAuthOn, setIsBiometricAuthOn] = useState<boolean>(
userSettingsStore.isAuthOn,
)
const [biometryType, setBiometryType] = useState<BIOMETRY_TYPE | null>(null)
const [error, setError] = useState<AppError | undefined>()
const [isAuthModalVisible, setIsAuthModalVisible] = useState<boolean>(false)
const [resultMessage, setResultMessage] = useState<string>()
useEffect(() => {
const getBiometry = async () => {
const biometry: BIOMETRY_TYPE | null = await KeyChain.getSupportedBiometryType()
log.trace('[getBiometry]', {biometry, isAuthOn: userSettingsStore.isAuthOn})
setBiometryType(biometry)
}
getBiometry()
return () => {}
}, [])
const toggleBiometricAuthSwitch = async () => {
try {
setIsLoading(true)
// check device has biometric support - disabled for testing
if(!isBiometricAuthOn) {
const biometryType = await KeyChain.getSupportedBiometryType()
if(!biometryType) {
setInfo('Your device does not support any biometric authentication method.')
return
}
}
const result = await userSettingsStore.setIsAuthOn(
!isBiometricAuthOn,
@@ -78,13 +50,13 @@ export const SecurityScreen = observer(function SecurityScreen({ route }: Props)
if (result === true) {
setResultMessage(
'Biometric authentication to access the wallet has been turned on.',
'Authentication to access the wallet has been turned on.',
)
toggleAuthModal()
return
}
setResultMessage('Biometric authentication has been disabled.')
setResultMessage('Authentication has been disabled.')
toggleAuthModal()
} catch (e: any) {
handleError(e)
@@ -233,7 +205,6 @@ export const SecurityScreen = observer(function SecurityScreen({ route }: Props)
onBackdropPress={toggleAuthModal}
/>
{error && <ErrorModal error={error} />}
{info && <InfoModal message={info} />}
</Screen>
)
})
+63 -29
View File
@@ -344,7 +344,8 @@ const saveAuthToken = async function (
const getAuthToken = async function (isAuthOn: boolean): Promise<string | undefined> {
try {
const result = await _Keychain.getGenericPassword({
service: KeyChainServiceName.BIOMETRIC_AUTH,
service: KeyChainServiceName.BIOMETRIC_AUTH,
accessControl: isAuthOn ? _Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE : undefined,
authenticationPrompt: isAuthOn ? {
title: 'Please authenticate',
subtitle: '',
@@ -391,6 +392,39 @@ export type AuthResult = {
shouldExitApp: boolean
}
const getAuthErrorContext = (e: any) => {
const codeCandidates = [
e?.code,
e?.params?.code,
e?.params?.error?.code,
e?.params?.cause?.code,
]
.filter((value) => value !== undefined && value !== null)
.map((value) => String(value))
const serialized = `${e?.message ?? ''} ${JSON.stringify(e)}`
const isBackPressed =
codeCandidates.includes('10') ||
/"code"\s*:\s*10|code:\s*10/.test(serialized)
const isCancelPressed =
codeCandidates.includes('13') ||
/"code"\s*:\s*13|code:\s*13/.test(serialized)
const isIOSCancel = codeCandidates.includes('-128')
const isCryptoFailed = serialized.includes('E_CRYPTO_FAILED')
return {
codeCandidates,
serialized,
isBackPressed,
isCancelPressed,
isIOSCancel,
isCryptoFailed,
}
}
/**
* Authenticate user on app start
@@ -407,28 +441,32 @@ const authenticateOnAppStart = async function (isAuthOn: boolean): Promise<AuthR
}
log.trace('[authenticateOnAppStart]', 'No auth token found')
return { success: false, shouldExitApp: false }
return { success: false, shouldExitApp: true }
} catch (e: any) {
// Handle specific error codes for user actions
if (e && typeof e === 'object') {
const errString = JSON.stringify(e)
const isBackPressed = errString.includes('code: 10')
const isCancelPressed = errString.includes('code: 13')
const isIOSCancel = 'code' in e && String(e.code) === '-128'
const authErrorContext = getAuthErrorContext(e)
if (isBackPressed) {
log.trace('[authenticateOnAppStart]', 'User pressed back button')
return { success: false, shouldExitApp: true }
}
log.warn('[authenticateOnAppStart]', 'Auth error caught', {
message: e.message,
code: e.code,
name: e.name,
params: e.params,
stringified: authErrorContext.serialized,
codeCandidates: authErrorContext.codeCandidates,
})
if (isCancelPressed || isIOSCancel) {
log.trace('[authenticateOnAppStart]', 'User cancelled authentication')
return { success: false, shouldExitApp: true }
}
if (authErrorContext.isBackPressed) {
log.trace('[authenticateOnAppStart]', 'User pressed back button')
return { success: false, shouldExitApp: false }
}
if (authErrorContext.isCancelPressed || authErrorContext.isIOSCancel || authErrorContext.isCryptoFailed) {
log.trace('[authenticateOnAppStart]', 'User cancelled authentication')
return { success: false, shouldExitApp: false }
}
log.warn('[authenticateOnAppStart]', 'Authentication failed', { message: e.message })
return { success: false, shouldExitApp: false }
return { success: false, shouldExitApp: true }
}
}
@@ -457,6 +495,7 @@ const authenticatePOSMode = async function (): Promise<boolean> {
try {
const result = await _Keychain.getGenericPassword({
service: KeyChainServiceName.BIOMETRIC_AUTH,
accessControl: _Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE,
authenticationPrompt: {
title: 'Authentication required',
subtitle: '',
@@ -474,21 +513,16 @@ const authenticatePOSMode = async function (): Promise<boolean> {
return false
} catch (e: any) {
// Handle specific error codes for user actions
if (e && typeof e === 'object') {
const errString = JSON.stringify(e)
const isBackPressed = errString.includes('code: 10')
const isCancelPressed = errString.includes('code: 13')
const isIOSCancel = 'code' in e && String(e.code) === '-128'
const authErrorContext = getAuthErrorContext(e)
if (isBackPressed) {
log.trace('[authenticatePOSMode]', 'User pressed back button')
return false
}
if (authErrorContext.isBackPressed) {
log.trace('[authenticatePOSMode]', 'User pressed back button')
return false
}
if (isCancelPressed || isIOSCancel) {
log.trace('[authenticatePOSMode]', 'User cancelled authentication')
return false
}
if (authErrorContext.isCancelPressed || authErrorContext.isIOSCancel || authErrorContext.isCryptoFailed) {
log.trace('[authenticatePOSMode]', 'User cancelled authentication')
return false
}
log.trace('[authenticatePOSMode]', 'Authentication failed', { message: e.message })