diff --git a/src/App.tsx b/src/App.tsx index 8241d091..e392c806 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( - - - - - - + + + + + + + + {isAuthLocked && ( + + + Minibits is locked. Authentication is required to continue. + + + Authenticate + + RNExitApp.exitApp()} + > + Close app + + + )} + ) } diff --git a/src/i18n_messages/en.json b/src/i18n_messages/en.json index 8bf61313..00fbf8cb 100644 --- a/src/i18n_messages/en.json +++ b/src/i18n_messages/en.json @@ -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", diff --git a/src/i18n_messages/es.json b/src/i18n_messages/es.json index 0ad2d196..e943c4cd 100644 --- a/src/i18n_messages/es.json +++ b/src/i18n_messages/es.json @@ -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", diff --git a/src/i18n_messages/pt.json b/src/i18n_messages/pt.json index b38fd25f..02ec7463 100644 --- a/src/i18n_messages/pt.json +++ b/src/i18n_messages/pt.json @@ -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", diff --git a/src/i18n_messages/sk.json b/src/i18n_messages/sk.json index 0e9ee8d7..2255026a 100644 --- a/src/i18n_messages/sk.json +++ b/src/i18n_messages/sk.json @@ -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", diff --git a/src/screens/SecurityScreen.tsx b/src/screens/SecurityScreen.tsx index e209f1c4..2e159c90 100644 --- a/src/screens/SecurityScreen.tsx +++ b/src/screens/SecurityScreen.tsx @@ -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 @@ -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( userSettingsStore.isAuthOn, ) - const [biometryType, setBiometryType] = useState(null) const [error, setError] = useState() const [isAuthModalVisible, setIsAuthModalVisible] = useState(false) const [resultMessage, setResultMessage] = useState() - 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 && } - {info && } ) }) diff --git a/src/services/keyChain.ts b/src/services/keyChain.ts index acce8f6d..f9001931 100644 --- a/src/services/keyChain.ts +++ b/src/services/keyChain.ts @@ -344,7 +344,8 @@ const saveAuthToken = async function ( const getAuthToken = async function (isAuthOn: boolean): Promise { 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 { 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 { 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 })