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 })