From a4e8758e5fef3c3fe3adebe84af5b6ae81b770c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Tue, 17 Mar 2026 18:50:46 +0100 Subject: [PATCH 01/41] PoC @sbaiahmed1/react-native-biometrics --- package-lock.json | 14 + package.json | 1 + .../biometrics/useBiometrics/index.native.ts | 4 +- .../biometrics/useNativeBiometricsEC256.ts | 285 ++++++++++++++++++ .../NativeBiometrics/VALUES.ts | 5 + .../NativeBiometrics/types.ts | 14 +- src/libs/MultifactorAuthentication/VALUES.ts | 5 + .../MultifactorAuthentication/shared/types.ts | 4 +- 8 files changed, 328 insertions(+), 4 deletions(-) create mode 100644 src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts diff --git a/package-lock.json b/package-lock.json index de54d3aa4f4c0..83fbcbf7ab108 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "@react-navigation/stack": "7.3.3", "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "10.1.44", + "@sbaiahmed1/react-native-biometrics": "^0.13.0", "@sentry/react-native": "8.2.0", "@shopify/flash-list": "2.3.0", "@shopify/react-native-skia": "^2.4.14", @@ -13215,6 +13216,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@sbaiahmed1/react-native-biometrics": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@sbaiahmed1/react-native-biometrics/-/react-native-biometrics-0.13.0.tgz", + "integrity": "sha512-pApLuo6D3+W4yNCfc2KNb60ILCvcYy1hT90fmGdDVRuxOGrAQ+HLxBm9/uQWYs0Q+Sk2RcFVwPS8MTGlgqEaLg==", + "license": "MIT", + "workspaces": [ + "example" + ], + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@sentry-internal/browser-utils": { "version": "10.39.0", "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.39.0.tgz", diff --git a/package.json b/package.json index 877e8557c1ba9..dd765606e488b 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "@react-navigation/stack": "7.3.3", "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "10.1.44", + "@sbaiahmed1/react-native-biometrics": "^0.13.0", "@sentry/react-native": "8.2.0", "@shopify/flash-list": "2.3.0", "@shopify/react-native-skia": "^2.4.14", diff --git a/src/components/MultifactorAuthentication/biometrics/useBiometrics/index.native.ts b/src/components/MultifactorAuthentication/biometrics/useBiometrics/index.native.ts index cb40a9d657c45..dc1e444fab64b 100644 --- a/src/components/MultifactorAuthentication/biometrics/useBiometrics/index.native.ts +++ b/src/components/MultifactorAuthentication/biometrics/useBiometrics/index.native.ts @@ -1,3 +1,5 @@ import useNativeBiometrics from '@components/MultifactorAuthentication/biometrics/useNativeBiometrics'; +import useNativeBiometricsEC256 from '@components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256'; +import CONST from '@src/CONST'; -export default useNativeBiometrics; +export default CONST.MULTIFACTOR_AUTHENTICATION.USE_NATIVE_EC256 ? useNativeBiometricsEC256 : useNativeBiometrics; diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts new file mode 100644 index 0000000000000..7dd125a3023e4 --- /dev/null +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts @@ -0,0 +1,285 @@ +import {Buffer} from 'buffer'; +import {useCallback} from 'react'; +import type {ValueOf} from 'type-fest'; +import type {BiometricSensorInfo} from '@sbaiahmed1/react-native-biometrics'; +import {createKeys, deleteKeys, getAllKeys, InputEncoding, isSensorAvailable, sha256, signWithOptions} from '@sbaiahmed1/react-native-biometrics'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/NativeBiometrics/KeyStore'; +import {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'; +import type {NativeBiometricsEC256KeyInfo} from '@libs/MultifactorAuthentication/NativeBiometrics/types'; +import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; +import VALUES from '@libs/MultifactorAuthentication/VALUES'; +import CONST from '@src/CONST'; +import Base64URL from '@src/utils/Base64URL'; +import type {AuthorizeParams, AuthorizeResult, RegisterResult, UseBiometricsReturn} from './shared/types'; +import useServerCredentials from './shared/useServerCredentials'; + +/** + * Converts standard base64 to base64url encoding. + */ +function base64ToBase64url(b64: string): string { + return b64.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); +} + +/** + * Builds the key alias for a given account. + */ +function getKeyAlias(accountID: number): string { + return `${accountID}_${CONST.MULTIFACTOR_AUTHENTICATION.EC256_KEY_SUFFIX}`; +} + +// Called once at module load. The MFA flow goes through multiple state machine steps +// (validate code → challenge → soft prompt) before doesDeviceSupportAuthenticationMethod() +// is checked, giving ample time for this to resolve. +let sensorResult: BiometricSensorInfo = {available: false}; + +isSensorAvailable() + .then((result) => { + sensorResult = result; + }) + .catch(() => { + // sensorResult stays { available: false } + }); + +type SecureStoreAuthTypeEntry = ValueOf; + +/** + * Maps authType number from signWithOptions (with returnAuthType: true) to AuthTypeInfo. + * Native layer returns: 1=DeviceCredentials, 3=FaceID, 4=TouchID, 5=OpticID + */ +const AUTH_TYPE_NUMBER_MAP = new Map([ + [1, SECURE_STORE_VALUES.AUTH_TYPE.CREDENTIALS], + [3, SECURE_STORE_VALUES.AUTH_TYPE.FACE_ID], + [4, SECURE_STORE_VALUES.AUTH_TYPE.TOUCH_ID], + [5, SECURE_STORE_VALUES.AUTH_TYPE.OPTIC_ID], +]); + +function mapAuthTypeNumber(authType?: number): AuthTypeInfo | undefined { + if (authType === undefined) { + return undefined; + } + const entry = AUTH_TYPE_NUMBER_MAP.get(authType); + if (!entry) { + return undefined; + } + return {code: entry.CODE, name: entry.NAME, marqetaValue: entry.MARQETA_VALUE}; +} + +/** + * Maps biometryType string from isSensorAvailable to AuthTypeInfo (used during registration). + */ +const BIOMETRY_TYPE_MAP: Record = { + FaceID: SECURE_STORE_VALUES.AUTH_TYPE.FACE_ID, + TouchID: SECURE_STORE_VALUES.AUTH_TYPE.TOUCH_ID, + Biometrics: SECURE_STORE_VALUES.AUTH_TYPE.BIOMETRICS, + OpticID: SECURE_STORE_VALUES.AUTH_TYPE.OPTIC_ID, +}; + +function mapBiometryTypeToAuthType(biometryType?: string): AuthTypeInfo | undefined { + const entry = BIOMETRY_TYPE_MAP[biometryType ?? '']; + if (!entry) { + return undefined; + } + return {code: entry.CODE, name: entry.NAME, marqetaValue: entry.MARQETA_VALUE}; +} + +/** + * Maps library errorCode strings to existing REASON values. + * TODO: Investigate actual errorCode values from the library on both platforms. + */ +function mapSignErrorCode(errorCode?: string): MultifactorAuthenticationReason | undefined { + if (!errorCode) { + return undefined; + } + if (errorCode.toLowerCase().includes('cancel')) { + return VALUES.REASON.EXPO.CANCELED; + } + if (errorCode.toLowerCase().includes('not available')) { + return VALUES.REASON.EXPO.NOT_SUPPORTED; + } + return VALUES.REASON.EXPO.GENERIC; +} + +/** + * Maps caught exceptions from the library to REASON values. + */ +function mapLibraryError(e: unknown): MultifactorAuthenticationReason | undefined { + const msg = e instanceof Error ? e.message : String(e); + if (msg.toLowerCase().includes('cancel')) { + return VALUES.REASON.EXPO.CANCELED; + } + return undefined; +} + +/** + * Native biometrics hook using EC P-256 keys via react-native-biometrics. + * All cryptographic operations happen in native code (Secure Enclave / Android Keystore). + * Private keys never enter JS memory. + */ +function useNativeBiometricsEC256(): UseBiometricsReturn { + const {accountID} = useCurrentUserPersonalDetails(); + const {translate} = useLocalize(); + const {serverKnownCredentialIDs, haveCredentialsEverBeenConfigured} = useServerCredentials(); + + const doesDeviceSupportAuthenticationMethod = useCallback(() => { + return sensorResult.available; + }, []); + + const getLocalCredentialID = useCallback(async () => { + const keyAlias = getKeyAlias(accountID); + const {keys} = await getAllKeys(keyAlias); + const entry = keys.find((k) => k.alias === keyAlias); + if (!entry) { + return undefined; + } + return base64ToBase64url(entry.publicKey); + }, [accountID]); + + const areLocalCredentialsKnownToServer = useCallback(async () => { + const key = await getLocalCredentialID(); + return !!key && serverKnownCredentialIDs.includes(key); + }, [getLocalCredentialID, serverKnownCredentialIDs]); + + const deleteLocalKeysForAccount = useCallback(async () => { + const keyAlias = getKeyAlias(accountID); + await deleteKeys(keyAlias); + // Also clean up legacy ED25519 keys for migration + await Promise.all([PrivateKeyStore.delete(accountID), PublicKeyStore.delete(accountID)]); + }, [accountID]); + + const register = async (onResult: (result: RegisterResult) => Promise | void, registrationChallenge: Parameters[1]) => { + try { + const keyAlias = getKeyAlias(accountID); + + // createKeys with failIfExists=false auto-deletes existing key and recreates + const {publicKey} = await createKeys(keyAlias, 'ec256', undefined, false, false); + + const credentialID = base64ToBase64url(publicKey); + + // Map biometryType from module-level cache to auth type + const authType = mapBiometryTypeToAuthType(sensorResult.biometryType); + if (!authType) { + onResult({success: false, reason: VALUES.REASON.GENERIC.BAD_REQUEST}); + return; + } + + const clientDataJSON = JSON.stringify({challenge: registrationChallenge.challenge}); + const keyInfo: NativeBiometricsEC256KeyInfo = { + rawId: credentialID, + type: CONST.MULTIFACTOR_AUTHENTICATION.ED25519_TYPE, + response: { + clientDataJSON: Base64URL.encode(clientDataJSON), + biometric: { + publicKey: credentialID, + algorithm: CONST.COSE_ALGORITHM.ES256, + }, + }, + }; + + await onResult({ + success: true, + reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.LOCAL_REGISTRATION_COMPLETE, + keyInfo, + authenticationMethod: authType, + }); + } catch (e) { + onResult({ + success: false, + reason: mapLibraryError(e) ?? VALUES.REASON.KEYSTORE.UNABLE_TO_SAVE_KEY, + }); + } + }; + + const authorize = async (params: AuthorizeParams, onResult: (result: AuthorizeResult) => Promise | void) => { + const {challenge} = params; + + try { + const keyAlias = getKeyAlias(accountID); + const credentialID = await getLocalCredentialID(); + const allowedIDs = challenge.allowCredentials?.map((c: {id: string; type: string}) => c.id) ?? []; + + if (!credentialID || !allowedIDs.includes(credentialID)) { + onResult({success: false, reason: VALUES.REASON.KEYSTORE.REGISTRATION_REQUIRED}); + return; + } + + // Build authenticatorData: rpIdHash(32B) || flags(1B) || signCount(4B) + const {hash: rpIdHashB64} = await sha256(challenge.rpId); + const rpIdHash = Buffer.from(rpIdHashB64, 'base64'); + + const flags = Buffer.from([0x05]); // UP (0x01) | UV (0x04) + const signCount = Buffer.alloc(4); // 4 zero bytes, big-endian + + const authenticatorData = Buffer.concat([rpIdHash, flags, signCount]); + + // Build dataToSign: authenticatorData || sha256(clientDataJSON) + const clientDataJSON = JSON.stringify({challenge: challenge.challenge}); + const {hash: clientDataHashB64} = await sha256(clientDataJSON); + const clientDataHash = Buffer.from(clientDataHashB64, 'base64'); + + const dataToSign = Buffer.concat([authenticatorData, clientDataHash]); + const dataToSignB64 = dataToSign.toString('base64'); + + // Sign with biometric prompt — signWithOptions handles iOS/Android differences + const signResult = await signWithOptions({ + keyAlias, + data: dataToSignB64, + inputEncoding: InputEncoding.Base64, + promptTitle: translate('multifactorAuthentication.letsVerifyItsYou'), + returnAuthType: true, + }); + + if (!signResult.success || !signResult.signature) { + onResult({ + success: false, + reason: mapSignErrorCode(signResult.errorCode) ?? VALUES.REASON.GENERIC.BAD_REQUEST, + }); + return; + } + + const authType = mapAuthTypeNumber(signResult.authType); + if (!authType) { + onResult({success: false, reason: VALUES.REASON.GENERIC.BAD_REQUEST}); + return; + } + + await onResult({ + success: true, + reason: VALUES.REASON.CHALLENGE.CHALLENGE_SIGNED, + signedChallenge: { + rawId: credentialID, + type: CONST.MULTIFACTOR_AUTHENTICATION.ED25519_TYPE, + response: { + authenticatorData: base64ToBase64url(authenticatorData.toString('base64')), + clientDataJSON: Base64URL.encode(clientDataJSON), + signature: base64ToBase64url(signResult.signature), + }, + }, + authenticationMethod: authType, + }); + } catch (e) { + onResult({ + success: false, + reason: mapLibraryError(e) ?? VALUES.REASON.GENERIC.BAD_REQUEST, + }); + } + }; + + const hasLocalCredentials = async () => !!(await getLocalCredentialID()); + + return { + deviceVerificationType: CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS, + serverKnownCredentialIDs, + haveCredentialsEverBeenConfigured, + getLocalCredentialID, + doesDeviceSupportAuthenticationMethod, + hasLocalCredentials, + areLocalCredentialsKnownToServer, + register, + authorize, + deleteLocalKeysForAccount, + }; +} + +export default useNativeBiometricsEC256; diff --git a/src/libs/MultifactorAuthentication/NativeBiometrics/VALUES.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/VALUES.ts index 5825c90ba9bb1..b2c501ee4b3ae 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometrics/VALUES.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometrics/VALUES.ts @@ -31,6 +31,11 @@ const NATIVE_BIOMETRICS_VALUES = { */ ED25519_TYPE: 'biometric', + /** + * Key alias suffix for EC256 keys managed by react-native-biometrics. + */ + EC256_KEY_SUFFIX: 'EC256_KEY', + /** * Key alias identifiers for secure storage. */ diff --git a/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts index d5e69bd6c8dcc..8ae6e1b670f7b 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts @@ -45,4 +45,16 @@ type NativeBiometricsKeyInfo = { }; }; -export type {MultifactorAuthenticationKeyStoreStatus, MultifactorAuthenticationKeyType, MultifactorKeyStoreOptions, NativeBiometricsKeyInfo}; +type NativeBiometricsEC256KeyInfo = { + rawId: Base64URLString; + type: typeof VALUES.ED25519_TYPE; + response: { + clientDataJSON: Base64URLString; + biometric: { + publicKey: Base64URLString; + algorithm: typeof CONST.COSE_ALGORITHM.ES256; + }; + }; +}; + +export type {MultifactorAuthenticationKeyStoreStatus, MultifactorAuthenticationKeyType, MultifactorKeyStoreOptions, NativeBiometricsKeyInfo, NativeBiometricsEC256KeyInfo}; diff --git a/src/libs/MultifactorAuthentication/VALUES.ts b/src/libs/MultifactorAuthentication/VALUES.ts index 4fd2a07354f44..928475972d573 100644 --- a/src/libs/MultifactorAuthentication/VALUES.ts +++ b/src/libs/MultifactorAuthentication/VALUES.ts @@ -11,6 +11,11 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = { ...SHARED_VALUES, ...NATIVE_BIOMETRICS_VALUES, ...PASSKEY_VALUES, + + /** + * Feature flag to switch native biometrics from ED25519 (noble/JS) to EC256 (react-native-biometrics/native). + */ + USE_NATIVE_EC256: false, } as const; export default MULTIFACTOR_AUTHENTICATION_VALUES; diff --git a/src/libs/MultifactorAuthentication/shared/types.ts b/src/libs/MultifactorAuthentication/shared/types.ts index 7b362492387f0..fce6cd15ac8eb 100644 --- a/src/libs/MultifactorAuthentication/shared/types.ts +++ b/src/libs/MultifactorAuthentication/shared/types.ts @@ -5,7 +5,7 @@ import type {ValueOf} from 'type-fest'; import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioAdditionalParams} from '@components/MultifactorAuthentication/config/types'; import type {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'; -import type {NativeBiometricsKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometrics/types'; +import type {NativeBiometricsEC256KeyInfo, NativeBiometricsKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometrics/types'; import type {PasskeyRegistrationKeyInfo} from '@libs/MultifactorAuthentication/Passkeys/types'; import type {PASSKEY_AUTH_TYPE} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; import type {SignedChallenge} from './challengeTypes'; @@ -52,7 +52,7 @@ type MultifactorAuthenticationResponseMap = typeof VALUES.API_RESPONSE_MAP; type MultifactorAuthenticationActionParams, R extends keyof AllMultifactorAuthenticationBaseParameters> = T & Pick & {authenticationMethod: MarqetaAuthTypeName}; -type RegistrationKeyInfo = NativeBiometricsKeyInfo | PasskeyRegistrationKeyInfo; +type RegistrationKeyInfo = NativeBiometricsKeyInfo | NativeBiometricsEC256KeyInfo | PasskeyRegistrationKeyInfo; type ChallengeType = ValueOf; From fdff7cc3a2d38901584d44ec8de34cd793004655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Fri, 20 Mar 2026 15:59:10 +0100 Subject: [PATCH 02/41] updated version --- package-lock.json | 8 ++++---- package.json | 2 +- .../biometrics/useNativeBiometricsEC256.ts | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 83fbcbf7ab108..14d9a0f18c8b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,7 @@ "@react-navigation/stack": "7.3.3", "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "10.1.44", - "@sbaiahmed1/react-native-biometrics": "^0.13.0", + "@sbaiahmed1/react-native-biometrics": "^0.14.0", "@sentry/react-native": "8.2.0", "@shopify/flash-list": "2.3.0", "@shopify/react-native-skia": "^2.4.14", @@ -13217,9 +13217,9 @@ "license": "MIT" }, "node_modules/@sbaiahmed1/react-native-biometrics": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@sbaiahmed1/react-native-biometrics/-/react-native-biometrics-0.13.0.tgz", - "integrity": "sha512-pApLuo6D3+W4yNCfc2KNb60ILCvcYy1hT90fmGdDVRuxOGrAQ+HLxBm9/uQWYs0Q+Sk2RcFVwPS8MTGlgqEaLg==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@sbaiahmed1/react-native-biometrics/-/react-native-biometrics-0.14.0.tgz", + "integrity": "sha512-Q20kLHiMi6QjHzZJJFmfNYFlsPiKJxj4lDO5yf6svu7oK1NM9a4paPXQoxQaXzcvPNsItGtciTAR80vbV0jVCw==", "license": "MIT", "workspaces": [ "example" diff --git a/package.json b/package.json index dd765606e488b..d83e7de04bc4c 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,7 @@ "@react-navigation/stack": "7.3.3", "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "10.1.44", - "@sbaiahmed1/react-native-biometrics": "^0.13.0", + "@sbaiahmed1/react-native-biometrics": "^0.14.0", "@sentry/react-native": "8.2.0", "@shopify/flash-list": "2.3.0", "@shopify/react-native-skia": "^2.4.14", diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts index 7dd125a3023e4..0b02526c86a2d 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts @@ -1,8 +1,8 @@ +import type {BiometricSensorInfo} from '@sbaiahmed1/react-native-biometrics'; +import {createKeys, deleteKeys, getAllKeys, InputEncoding, isSensorAvailable, sha256, signWithOptions} from '@sbaiahmed1/react-native-biometrics'; import {Buffer} from 'buffer'; import {useCallback} from 'react'; import type {ValueOf} from 'type-fest'; -import type {BiometricSensorInfo} from '@sbaiahmed1/react-native-biometrics'; -import {createKeys, deleteKeys, getAllKeys, InputEncoding, isSensorAvailable, sha256, signWithOptions} from '@sbaiahmed1/react-native-biometrics'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/NativeBiometrics/KeyStore'; @@ -153,8 +153,8 @@ function useNativeBiometricsEC256(): UseBiometricsReturn { const keyAlias = getKeyAlias(accountID); // createKeys with failIfExists=false auto-deletes existing key and recreates - const {publicKey} = await createKeys(keyAlias, 'ec256', undefined, false, false); - + const {publicKey} = await createKeys(keyAlias, 'ec256', undefined, true, false); + console.log('[Network-less] created keys'); const credentialID = base64ToBase64url(publicKey); // Map biometryType from module-level cache to auth type From 91efcd7c86e537c2caba1c408dd65a3f6c8cd737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Tue, 24 Mar 2026 16:29:16 +0100 Subject: [PATCH 03/41] fix available auth methods --- ios/Podfile.lock | 34 ++++++++++++++++++- .../biometrics/useNativeBiometricsEC256.ts | 16 +++++---- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 253f0fd93e180..f933edd96fb49 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3501,6 +3501,34 @@ PODS: - React-perflogger (= 0.83.1) - React-utils (= 0.83.1) - SocketRocket + - ReactNativeBiometrics (0.14.0): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - ReactNativeHybridApp (0.0.0): - boost - DoubleConversion @@ -4342,6 +4370,7 @@ DEPENDENCIES: - ReactAppDependencyProvider (from `build/generated/ios/ReactAppDependencyProvider`) - ReactCodegen (from `build/generated/ios/ReactCodegen`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) + - "ReactNativeBiometrics (from `../node_modules/@sbaiahmed1/react-native-biometrics`)" - "ReactNativeHybridApp (from `../node_modules/@expensify/react-native-hybrid-app`)" - "RNAppleAuthentication (from `../node_modules/@invertase/react-native-apple-authentication`)" - "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)" @@ -4662,6 +4691,8 @@ EXTERNAL SOURCES: :path: build/generated/ios/ReactCodegen ReactCommon: :path: "../node_modules/react-native/ReactCommon" + ReactNativeBiometrics: + :path: "../node_modules/@sbaiahmed1/react-native-biometrics" ReactNativeHybridApp: :path: "../node_modules/@expensify/react-native-hybrid-app" RNAppleAuthentication: @@ -4763,7 +4794,7 @@ SPEC CHECKSUMS: GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 GzipSwift: 893f3e48e597a1a4f62fafcb6514220fcf8287fa - hermes-engine: 843645b51c329c98a1b61df2e32c10be463486fe + hermes-engine: 0711ccb14bd615969ef611bc6c2483ea2ed3b09e libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 @@ -4873,6 +4904,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 0eb286cc274abb059ee601b862ebddac2e681d01 ReactCodegen: d663254bf59e57e5ed7c65638bd45f358a373bba ReactCommon: 15e1e727fa34f760beb7dd52928687fda8edf8dc + ReactNativeBiometrics: f2356e3e148ff77f0e4763b4b79183eaa044a0dd ReactNativeHybridApp: 16ebccf5382436fcb9303ab5f4b50d9942bccf5c RNAppleAuthentication: 9027af8aa92b4719ef1b6030a8e954d37079473a RNCClipboard: e560338bf6cc4656a09ff90610b62ddc0dbdad65 diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts index 0b02526c86a2d..c491d239963f1 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts @@ -76,10 +76,14 @@ const BIOMETRY_TYPE_MAP: Record = { OpticID: SECURE_STORE_VALUES.AUTH_TYPE.OPTIC_ID, }; -function mapBiometryTypeToAuthType(biometryType?: string): AuthTypeInfo | undefined { - const entry = BIOMETRY_TYPE_MAP[biometryType ?? '']; +function mapBiometryTypeToAuthType(biometryType?: string, isDeviceSecure?: boolean): AuthTypeInfo | undefined { + let entry = BIOMETRY_TYPE_MAP[biometryType ?? '']; if (!entry) { - return undefined; + if (isDeviceSecure) { + entry = SECURE_STORE_VALUES.AUTH_TYPE.CREDENTIALS; + } else { + return undefined; + } } return {code: entry.CODE, name: entry.NAME, marqetaValue: entry.MARQETA_VALUE}; } @@ -123,7 +127,7 @@ function useNativeBiometricsEC256(): UseBiometricsReturn { const {serverKnownCredentialIDs, haveCredentialsEverBeenConfigured} = useServerCredentials(); const doesDeviceSupportAuthenticationMethod = useCallback(() => { - return sensorResult.available; + return sensorResult.isDeviceSecure ?? sensorResult.available; }, []); const getLocalCredentialID = useCallback(async () => { @@ -154,11 +158,11 @@ function useNativeBiometricsEC256(): UseBiometricsReturn { // createKeys with failIfExists=false auto-deletes existing key and recreates const {publicKey} = await createKeys(keyAlias, 'ec256', undefined, true, false); - console.log('[Network-less] created keys'); + const credentialID = base64ToBase64url(publicKey); // Map biometryType from module-level cache to auth type - const authType = mapBiometryTypeToAuthType(sensorResult.biometryType); + const authType = mapBiometryTypeToAuthType(sensorResult.biometryType, sensorResult.isDeviceSecure); if (!authType) { onResult({success: false, reason: VALUES.REASON.GENERIC.BAD_REQUEST}); return; From f392f7d1e387fcdc18055ff3fe54637f61685ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Tue, 24 Mar 2026 18:33:19 +0100 Subject: [PATCH 04/41] add missing authType values --- .../biometrics/useNativeBiometricsEC256.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts index c491d239963f1..2872742a426e4 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts @@ -49,7 +49,10 @@ type SecureStoreAuthTypeEntry = ValueOf; * Native layer returns: 1=DeviceCredentials, 3=FaceID, 4=TouchID, 5=OpticID */ const AUTH_TYPE_NUMBER_MAP = new Map([ + [-1, SECURE_STORE_VALUES.AUTH_TYPE.UNKNOWN], + [0, SECURE_STORE_VALUES.AUTH_TYPE.NONE], [1, SECURE_STORE_VALUES.AUTH_TYPE.CREDENTIALS], + [2, SECURE_STORE_VALUES.AUTH_TYPE.BIOMETRICS], [3, SECURE_STORE_VALUES.AUTH_TYPE.FACE_ID], [4, SECURE_STORE_VALUES.AUTH_TYPE.TOUCH_ID], [5, SECURE_STORE_VALUES.AUTH_TYPE.OPTIC_ID], From 85c2a02c37f7e59a1a8a8c6c5996c2d7215013aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Wed, 25 Mar 2026 10:13:52 +0100 Subject: [PATCH 05/41] switch flag --- src/libs/MultifactorAuthentication/VALUES.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/MultifactorAuthentication/VALUES.ts b/src/libs/MultifactorAuthentication/VALUES.ts index 928475972d573..3c6526051e702 100644 --- a/src/libs/MultifactorAuthentication/VALUES.ts +++ b/src/libs/MultifactorAuthentication/VALUES.ts @@ -15,7 +15,7 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = { /** * Feature flag to switch native biometrics from ED25519 (noble/JS) to EC256 (react-native-biometrics/native). */ - USE_NATIVE_EC256: false, + USE_NATIVE_EC256: true, } as const; export default MULTIFACTOR_AUTHENTICATION_VALUES; From 5b02e71f24048d881671792082e3c285f1f89da3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Wed, 25 Mar 2026 12:53:10 +0100 Subject: [PATCH 06/41] fix comments --- .../biometrics/useNativeBiometricsEC256.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts index 2872742a426e4..403e77a229bfe 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts @@ -5,7 +5,6 @@ import {useCallback} from 'react'; import type {ValueOf} from 'type-fest'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; -import {PrivateKeyStore, PublicKeyStore} from '@libs/MultifactorAuthentication/NativeBiometrics/KeyStore'; import {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'; import type {NativeBiometricsEC256KeyInfo} from '@libs/MultifactorAuthentication/NativeBiometrics/types'; import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; @@ -29,9 +28,9 @@ function getKeyAlias(accountID: number): string { return `${accountID}_${CONST.MULTIFACTOR_AUTHENTICATION.EC256_KEY_SUFFIX}`; } -// Called once at module load. The MFA flow goes through multiple state machine steps -// (validate code → challenge → soft prompt) before doesDeviceSupportAuthenticationMethod() -// is checked, giving ample time for this to resolve. +/** + * Called once at module load. + */ let sensorResult: BiometricSensorInfo = {available: false}; isSensorAvailable() @@ -46,7 +45,7 @@ type SecureStoreAuthTypeEntry = ValueOf; /** * Maps authType number from signWithOptions (with returnAuthType: true) to AuthTypeInfo. - * Native layer returns: 1=DeviceCredentials, 3=FaceID, 4=TouchID, 5=OpticID + * Native layer returns: -1=Unknown, 0=None, 1=DeviceCredentials, 2=Biometrics, 3=FaceID, 4=TouchID, 5=OpticID */ const AUTH_TYPE_NUMBER_MAP = new Map([ [-1, SECURE_STORE_VALUES.AUTH_TYPE.UNKNOWN], @@ -93,7 +92,6 @@ function mapBiometryTypeToAuthType(biometryType?: string, isDeviceSecure?: boole /** * Maps library errorCode strings to existing REASON values. - * TODO: Investigate actual errorCode values from the library on both platforms. */ function mapSignErrorCode(errorCode?: string): MultifactorAuthenticationReason | undefined { if (!errorCode) { @@ -151,8 +149,6 @@ function useNativeBiometricsEC256(): UseBiometricsReturn { const deleteLocalKeysForAccount = useCallback(async () => { const keyAlias = getKeyAlias(accountID); await deleteKeys(keyAlias); - // Also clean up legacy ED25519 keys for migration - await Promise.all([PrivateKeyStore.delete(accountID), PublicKeyStore.delete(accountID)]); }, [accountID]); const register = async (onResult: (result: RegisterResult) => Promise | void, registrationChallenge: Parameters[1]) => { @@ -228,7 +224,7 @@ function useNativeBiometricsEC256(): UseBiometricsReturn { const dataToSign = Buffer.concat([authenticatorData, clientDataHash]); const dataToSignB64 = dataToSign.toString('base64'); - // Sign with biometric prompt — signWithOptions handles iOS/Android differences + // Sign with biometric prompt — signWithOptions const signResult = await signWithOptions({ keyAlias, data: dataToSignB64, From ce03e8143f728ec5fbb81b0d8f332845f69d360f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Wed, 25 Mar 2026 14:47:12 +0100 Subject: [PATCH 07/41] separate helper methods and types into new files --- .../biometrics/useNativeBiometricsEC256.ts | 126 +++--------------- .../NativeBiometrics/VALUES.ts | 5 - .../NativeBiometrics/types.ts | 14 +- .../NativeBiometricsEC256/VALUES.ts | 17 +++ .../NativeBiometricsEC256/helpers.ts | 119 +++++++++++++++++ .../NativeBiometricsEC256/types.ts | 21 +++ src/libs/MultifactorAuthentication/VALUES.ts | 2 + .../MultifactorAuthentication/shared/types.ts | 3 +- 8 files changed, 177 insertions(+), 130 deletions(-) create mode 100644 src/libs/MultifactorAuthentication/NativeBiometricsEC256/VALUES.ts create mode 100644 src/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.ts create mode 100644 src/libs/MultifactorAuthentication/NativeBiometricsEC256/types.ts diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts index 403e77a229bfe..9c5947559fbf1 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts @@ -1,122 +1,24 @@ -import type {BiometricSensorInfo} from '@sbaiahmed1/react-native-biometrics'; -import {createKeys, deleteKeys, getAllKeys, InputEncoding, isSensorAvailable, sha256, signWithOptions} from '@sbaiahmed1/react-native-biometrics'; +import {createKeys, deleteKeys, getAllKeys, InputEncoding, sha256, signWithOptions} from '@sbaiahmed1/react-native-biometrics'; import {Buffer} from 'buffer'; import {useCallback} from 'react'; -import type {ValueOf} from 'type-fest'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; -import {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'; -import type {NativeBiometricsEC256KeyInfo} from '@libs/MultifactorAuthentication/NativeBiometrics/types'; -import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; +import { + base64ToBase64url, + getKeyAlias, + getSensorResult, + mapAuthTypeNumber, + mapBiometryTypeToAuthType, + mapLibraryError, + mapSignErrorCode, +} from '@libs/MultifactorAuthentication/NativeBiometricsEC256/helpers'; +import type {NativeBiometricsEC256KeyInfo} from '@libs/MultifactorAuthentication/NativeBiometricsEC256/types'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; import Base64URL from '@src/utils/Base64URL'; import type {AuthorizeParams, AuthorizeResult, RegisterResult, UseBiometricsReturn} from './shared/types'; import useServerCredentials from './shared/useServerCredentials'; -/** - * Converts standard base64 to base64url encoding. - */ -function base64ToBase64url(b64: string): string { - return b64.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); -} - -/** - * Builds the key alias for a given account. - */ -function getKeyAlias(accountID: number): string { - return `${accountID}_${CONST.MULTIFACTOR_AUTHENTICATION.EC256_KEY_SUFFIX}`; -} - -/** - * Called once at module load. - */ -let sensorResult: BiometricSensorInfo = {available: false}; - -isSensorAvailable() - .then((result) => { - sensorResult = result; - }) - .catch(() => { - // sensorResult stays { available: false } - }); - -type SecureStoreAuthTypeEntry = ValueOf; - -/** - * Maps authType number from signWithOptions (with returnAuthType: true) to AuthTypeInfo. - * Native layer returns: -1=Unknown, 0=None, 1=DeviceCredentials, 2=Biometrics, 3=FaceID, 4=TouchID, 5=OpticID - */ -const AUTH_TYPE_NUMBER_MAP = new Map([ - [-1, SECURE_STORE_VALUES.AUTH_TYPE.UNKNOWN], - [0, SECURE_STORE_VALUES.AUTH_TYPE.NONE], - [1, SECURE_STORE_VALUES.AUTH_TYPE.CREDENTIALS], - [2, SECURE_STORE_VALUES.AUTH_TYPE.BIOMETRICS], - [3, SECURE_STORE_VALUES.AUTH_TYPE.FACE_ID], - [4, SECURE_STORE_VALUES.AUTH_TYPE.TOUCH_ID], - [5, SECURE_STORE_VALUES.AUTH_TYPE.OPTIC_ID], -]); - -function mapAuthTypeNumber(authType?: number): AuthTypeInfo | undefined { - if (authType === undefined) { - return undefined; - } - const entry = AUTH_TYPE_NUMBER_MAP.get(authType); - if (!entry) { - return undefined; - } - return {code: entry.CODE, name: entry.NAME, marqetaValue: entry.MARQETA_VALUE}; -} - -/** - * Maps biometryType string from isSensorAvailable to AuthTypeInfo (used during registration). - */ -const BIOMETRY_TYPE_MAP: Record = { - FaceID: SECURE_STORE_VALUES.AUTH_TYPE.FACE_ID, - TouchID: SECURE_STORE_VALUES.AUTH_TYPE.TOUCH_ID, - Biometrics: SECURE_STORE_VALUES.AUTH_TYPE.BIOMETRICS, - OpticID: SECURE_STORE_VALUES.AUTH_TYPE.OPTIC_ID, -}; - -function mapBiometryTypeToAuthType(biometryType?: string, isDeviceSecure?: boolean): AuthTypeInfo | undefined { - let entry = BIOMETRY_TYPE_MAP[biometryType ?? '']; - if (!entry) { - if (isDeviceSecure) { - entry = SECURE_STORE_VALUES.AUTH_TYPE.CREDENTIALS; - } else { - return undefined; - } - } - return {code: entry.CODE, name: entry.NAME, marqetaValue: entry.MARQETA_VALUE}; -} - -/** - * Maps library errorCode strings to existing REASON values. - */ -function mapSignErrorCode(errorCode?: string): MultifactorAuthenticationReason | undefined { - if (!errorCode) { - return undefined; - } - if (errorCode.toLowerCase().includes('cancel')) { - return VALUES.REASON.EXPO.CANCELED; - } - if (errorCode.toLowerCase().includes('not available')) { - return VALUES.REASON.EXPO.NOT_SUPPORTED; - } - return VALUES.REASON.EXPO.GENERIC; -} - -/** - * Maps caught exceptions from the library to REASON values. - */ -function mapLibraryError(e: unknown): MultifactorAuthenticationReason | undefined { - const msg = e instanceof Error ? e.message : String(e); - if (msg.toLowerCase().includes('cancel')) { - return VALUES.REASON.EXPO.CANCELED; - } - return undefined; -} - /** * Native biometrics hook using EC P-256 keys via react-native-biometrics. * All cryptographic operations happen in native code (Secure Enclave / Android Keystore). @@ -128,6 +30,7 @@ function useNativeBiometricsEC256(): UseBiometricsReturn { const {serverKnownCredentialIDs, haveCredentialsEverBeenConfigured} = useServerCredentials(); const doesDeviceSupportAuthenticationMethod = useCallback(() => { + const sensorResult = getSensorResult(); return sensorResult.isDeviceSecure ?? sensorResult.available; }, []); @@ -161,6 +64,7 @@ function useNativeBiometricsEC256(): UseBiometricsReturn { const credentialID = base64ToBase64url(publicKey); // Map biometryType from module-level cache to auth type + const sensorResult = getSensorResult(); const authType = mapBiometryTypeToAuthType(sensorResult.biometryType, sensorResult.isDeviceSecure); if (!authType) { onResult({success: false, reason: VALUES.REASON.GENERIC.BAD_REQUEST}); @@ -170,7 +74,7 @@ function useNativeBiometricsEC256(): UseBiometricsReturn { const clientDataJSON = JSON.stringify({challenge: registrationChallenge.challenge}); const keyInfo: NativeBiometricsEC256KeyInfo = { rawId: credentialID, - type: CONST.MULTIFACTOR_AUTHENTICATION.ED25519_TYPE, + type: CONST.MULTIFACTOR_AUTHENTICATION.EC256_TYPE, response: { clientDataJSON: Base64URL.encode(clientDataJSON), biometric: { @@ -252,7 +156,7 @@ function useNativeBiometricsEC256(): UseBiometricsReturn { reason: VALUES.REASON.CHALLENGE.CHALLENGE_SIGNED, signedChallenge: { rawId: credentialID, - type: CONST.MULTIFACTOR_AUTHENTICATION.ED25519_TYPE, + type: CONST.MULTIFACTOR_AUTHENTICATION.EC256_TYPE, response: { authenticatorData: base64ToBase64url(authenticatorData.toString('base64')), clientDataJSON: Base64URL.encode(clientDataJSON), diff --git a/src/libs/MultifactorAuthentication/NativeBiometrics/VALUES.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/VALUES.ts index b2c501ee4b3ae..5825c90ba9bb1 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometrics/VALUES.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometrics/VALUES.ts @@ -31,11 +31,6 @@ const NATIVE_BIOMETRICS_VALUES = { */ ED25519_TYPE: 'biometric', - /** - * Key alias suffix for EC256 keys managed by react-native-biometrics. - */ - EC256_KEY_SUFFIX: 'EC256_KEY', - /** * Key alias identifiers for secure storage. */ diff --git a/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts index 8ae6e1b670f7b..d5e69bd6c8dcc 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts @@ -45,16 +45,4 @@ type NativeBiometricsKeyInfo = { }; }; -type NativeBiometricsEC256KeyInfo = { - rawId: Base64URLString; - type: typeof VALUES.ED25519_TYPE; - response: { - clientDataJSON: Base64URLString; - biometric: { - publicKey: Base64URLString; - algorithm: typeof CONST.COSE_ALGORITHM.ES256; - }; - }; -}; - -export type {MultifactorAuthenticationKeyStoreStatus, MultifactorAuthenticationKeyType, MultifactorKeyStoreOptions, NativeBiometricsKeyInfo, NativeBiometricsEC256KeyInfo}; +export type {MultifactorAuthenticationKeyStoreStatus, MultifactorAuthenticationKeyType, MultifactorKeyStoreOptions, NativeBiometricsKeyInfo}; diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsEC256/VALUES.ts b/src/libs/MultifactorAuthentication/NativeBiometricsEC256/VALUES.ts new file mode 100644 index 0000000000000..3327c84224974 --- /dev/null +++ b/src/libs/MultifactorAuthentication/NativeBiometricsEC256/VALUES.ts @@ -0,0 +1,17 @@ +/** + * Constants specific to native biometrics (EC256 / react-native-biometrics). + */ + +const NATIVE_BIOMETRICS_EC256_VALUES = { + /** + * EC256 key type identifier + */ + EC256_TYPE: 'biometric', + + /** + * Key alias suffix for EC256 keys managed by react-native-biometrics. + */ + EC256_KEY_SUFFIX: 'EC256_KEY', +} as const; + +export default NATIVE_BIOMETRICS_EC256_VALUES; diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.ts b/src/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.ts new file mode 100644 index 0000000000000..2b08a0e388e6f --- /dev/null +++ b/src/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.ts @@ -0,0 +1,119 @@ +/** + * Helper utilities for native biometrics EC256 (react-native-biometrics). + */ +import type {BiometricSensorInfo} from '@sbaiahmed1/react-native-biometrics'; +import {isSensorAvailable} from '@sbaiahmed1/react-native-biometrics'; +import type {ValueOf} from 'type-fest'; +import {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'; +import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; +import VALUES from '@libs/MultifactorAuthentication/VALUES'; +import CONST from '@src/CONST'; + +type SecureStoreAuthTypeEntry = ValueOf; + +/** + * Converts standard base64 to base64url encoding. + */ +function base64ToBase64url(b64: string): string { + return b64.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); +} + +/** + * Builds the key alias for a given account. + */ +function getKeyAlias(accountID: number): string { + return `${accountID}_${CONST.MULTIFACTOR_AUTHENTICATION.EC256_KEY_SUFFIX}`; +} + +/** + * Module-level cache for sensor availability, called once at module load. + */ +let sensorResult: BiometricSensorInfo = {available: false}; + +isSensorAvailable() + .then((result) => { + sensorResult = result; + }) + .catch(() => { + // sensorResult stays { available: false } + }); + +function getSensorResult(): BiometricSensorInfo { + return sensorResult; +} + +/** + * Maps authType number from signWithOptions (with returnAuthType: true) to AuthTypeInfo. + * Native layer returns: -1=Unknown, 0=None, 1=DeviceCredentials, 2=Biometrics, 3=FaceID, 4=TouchID, 5=OpticID + */ +const AUTH_TYPE_NUMBER_MAP = new Map([ + [-1, SECURE_STORE_VALUES.AUTH_TYPE.UNKNOWN], + [0, SECURE_STORE_VALUES.AUTH_TYPE.NONE], + [1, SECURE_STORE_VALUES.AUTH_TYPE.CREDENTIALS], + [2, SECURE_STORE_VALUES.AUTH_TYPE.BIOMETRICS], + [3, SECURE_STORE_VALUES.AUTH_TYPE.FACE_ID], + [4, SECURE_STORE_VALUES.AUTH_TYPE.TOUCH_ID], + [5, SECURE_STORE_VALUES.AUTH_TYPE.OPTIC_ID], +]); + +function mapAuthTypeNumber(authType?: number): AuthTypeInfo | undefined { + if (authType === undefined) { + return undefined; + } + const entry = AUTH_TYPE_NUMBER_MAP.get(authType); + if (!entry) { + return undefined; + } + return {code: entry.CODE, name: entry.NAME, marqetaValue: entry.MARQETA_VALUE}; +} + +/** + * Maps biometryType string from isSensorAvailable to AuthTypeInfo (used during registration). + */ +const BIOMETRY_TYPE_MAP: Record = { + FaceID: SECURE_STORE_VALUES.AUTH_TYPE.FACE_ID, + TouchID: SECURE_STORE_VALUES.AUTH_TYPE.TOUCH_ID, + Biometrics: SECURE_STORE_VALUES.AUTH_TYPE.BIOMETRICS, + OpticID: SECURE_STORE_VALUES.AUTH_TYPE.OPTIC_ID, +}; + +function mapBiometryTypeToAuthType(biometryType?: string, isDeviceSecure?: boolean): AuthTypeInfo | undefined { + let entry = BIOMETRY_TYPE_MAP[biometryType ?? '']; + if (!entry) { + if (isDeviceSecure) { + entry = SECURE_STORE_VALUES.AUTH_TYPE.CREDENTIALS; + } else { + return undefined; + } + } + return {code: entry.CODE, name: entry.NAME, marqetaValue: entry.MARQETA_VALUE}; +} + +/** + * Maps library errorCode strings to existing REASON values. + */ +function mapSignErrorCode(errorCode?: string): MultifactorAuthenticationReason | undefined { + if (!errorCode) { + return undefined; + } + if (errorCode.toLowerCase().includes('cancel')) { + return VALUES.REASON.EXPO.CANCELED; + } + if (errorCode.toLowerCase().includes('not available')) { + return VALUES.REASON.EXPO.NOT_SUPPORTED; + } + return VALUES.REASON.EXPO.GENERIC; +} + +/** + * Maps caught exceptions from the library to REASON values. + */ +function mapLibraryError(e: unknown): MultifactorAuthenticationReason | undefined { + const msg = e instanceof Error ? e.message : String(e); + if (msg.toLowerCase().includes('cancel')) { + return VALUES.REASON.EXPO.CANCELED; + } + return undefined; +} + +export {base64ToBase64url, getKeyAlias, getSensorResult, mapAuthTypeNumber, mapBiometryTypeToAuthType, mapSignErrorCode, mapLibraryError}; diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsEC256/types.ts b/src/libs/MultifactorAuthentication/NativeBiometricsEC256/types.ts new file mode 100644 index 0000000000000..3d7d9774cf5a7 --- /dev/null +++ b/src/libs/MultifactorAuthentication/NativeBiometricsEC256/types.ts @@ -0,0 +1,21 @@ +/** + * Type definitions specific to native biometrics (EC256). + */ +import type CONST from '@src/CONST'; +import type {Base64URLString} from '@src/utils/Base64URL'; +import type VALUES from './VALUES'; + +type NativeBiometricsEC256KeyInfo = { + rawId: Base64URLString; + type: typeof VALUES.EC256_TYPE; + response: { + clientDataJSON: Base64URLString; + biometric: { + publicKey: Base64URLString; + algorithm: typeof CONST.COSE_ALGORITHM.ES256; + }; + }; +}; + +// eslint-disable-next-line import/prefer-default-export +export type {NativeBiometricsEC256KeyInfo}; diff --git a/src/libs/MultifactorAuthentication/VALUES.ts b/src/libs/MultifactorAuthentication/VALUES.ts index 3c6526051e702..c9076f0f6046c 100644 --- a/src/libs/MultifactorAuthentication/VALUES.ts +++ b/src/libs/MultifactorAuthentication/VALUES.ts @@ -4,12 +4,14 @@ * This ensures CONST.MULTIFACTOR_AUTHENTICATION continues working everywhere. */ import NATIVE_BIOMETRICS_VALUES from './NativeBiometrics/VALUES'; +import NATIVE_BIOMETRICS_EC256_VALUES from './NativeBiometricsEC256/VALUES'; import PASSKEY_VALUES from './Passkeys/VALUES'; import SHARED_VALUES from './shared/VALUES'; const MULTIFACTOR_AUTHENTICATION_VALUES = { ...SHARED_VALUES, ...NATIVE_BIOMETRICS_VALUES, + ...NATIVE_BIOMETRICS_EC256_VALUES, ...PASSKEY_VALUES, /** diff --git a/src/libs/MultifactorAuthentication/shared/types.ts b/src/libs/MultifactorAuthentication/shared/types.ts index fce6cd15ac8eb..8a20df64cc8f1 100644 --- a/src/libs/MultifactorAuthentication/shared/types.ts +++ b/src/libs/MultifactorAuthentication/shared/types.ts @@ -5,7 +5,8 @@ import type {ValueOf} from 'type-fest'; import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioAdditionalParams} from '@components/MultifactorAuthentication/config/types'; import type {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'; -import type {NativeBiometricsEC256KeyInfo, NativeBiometricsKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometrics/types'; +import type {NativeBiometricsKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometrics/types'; +import type {NativeBiometricsEC256KeyInfo} from '@libs/MultifactorAuthentication/NativeBiometricsEC256/types'; import type {PasskeyRegistrationKeyInfo} from '@libs/MultifactorAuthentication/Passkeys/types'; import type {PASSKEY_AUTH_TYPE} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; import type {SignedChallenge} from './challengeTypes'; From e1ca00b6113d1fb536197aa9186ea285a126d771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Wed, 25 Mar 2026 16:31:30 +0100 Subject: [PATCH 08/41] cspell updated --- cspell.json | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/cspell.json b/cspell.json index 3fa504ae25a9c..3a011cbf50f02 100644 --- a/cspell.json +++ b/cspell.json @@ -551,6 +551,7 @@ "phonenumber", "Picklist", "picklists", + "PINATM", "PINGPONG", "pkill", "Pluginfile", @@ -650,6 +651,7 @@ "Salagatan", "samltool", "Saqbd", + "sbaiahmed", "SBFJ", "Scaleway", "Scaleway's", @@ -668,10 +670,10 @@ "Sepa", "serveo", "setuptools", - "shareeEmail", - "Sharees", "sharee", + "shareeEmail", "sharees", + "Sharees", "Sharons", "shellcheck", "shellenv", @@ -864,8 +866,7 @@ "zoneinfo", "zxcv", "zxldvw", - "مثال", - "PINATM" + "مثال" ], "ignorePaths": [ "src/languages/de.ts", @@ -928,6 +929,8 @@ "web/snippets/gib.js", "tests/unit/hooks/useLetterAvatars.test.tsx" ], - "ignoreRegExpList": ["@assets/.*"], + "ignoreRegExpList": [ + "@assets/.*" + ], "useGitignore": true } From 2b4f19ae4702de740600f202be8d31082a8ba92b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Wed, 25 Mar 2026 17:36:19 +0100 Subject: [PATCH 09/41] mocked turbomodule functions --- jest/setup.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/jest/setup.ts b/jest/setup.ts index ee2b1702ba1e9..88516f6788856 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -322,6 +322,16 @@ jest.mock('@shopify/react-native-skia', () => ({ listFontFamilies: jest.fn(() => []), })); +jest.mock('@sbaiahmed1/react-native-biometrics', () => ({ + isSensorAvailable: jest.fn(() => Promise.resolve({available: false})), + createKeys: jest.fn(() => Promise.resolve({publicKey: ''})), + deleteKeys: jest.fn(() => Promise.resolve({keysDeleted: true})), + getAllKeys: jest.fn(() => Promise.resolve({keys: []})), + signWithOptions: jest.fn(() => Promise.resolve({success: false})), + sha256: jest.fn(() => Promise.resolve({hash: ''})), + InputEncoding: {Base64: 'base64', Utf8: 'utf8'}, +})); + jest.mock('victory-native', () => ({ Bar: jest.fn(() => null), CartesianChart: jest.fn( From 307609728955f8ac619ba802122b6c9d68f5241e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Thu, 26 Mar 2026 15:06:19 +0100 Subject: [PATCH 10/41] create separate auth-to-marqueta values --- .../AuthenticationMethodDescription.tsx | 4 +- .../NativeBiometricsEC256/VALUES.ts | 50 +++++++++++++++++++ .../NativeBiometricsEC256/helpers.ts | 28 +++++------ .../MultifactorAuthentication/shared/types.ts | 10 ++-- 4 files changed, 71 insertions(+), 21 deletions(-) diff --git a/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx b/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx index dc6536938acfe..9ed5ccb43d62f 100644 --- a/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx +++ b/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx @@ -3,7 +3,7 @@ import {useMultifactorAuthenticationState} from '@components/MultifactorAuthenti import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'; +import NATIVE_BIOMETRICS_EC256_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsEC256/VALUES'; import type {AuthTypeName} from '@libs/MultifactorAuthentication/shared/types'; import type {TranslationPaths} from '@src/languages/types'; @@ -25,7 +25,7 @@ function AuthenticationMethodDescription() { const {translate} = useLocalize(); const {authenticationMethod} = useMultifactorAuthenticationState(); - const authType = translate(AUTH_TYPE_TRANSLATION_KEY[authenticationMethod?.name ?? SECURE_STORE_VALUES.AUTH_TYPE.UNKNOWN.NAME]); + const authType = translate(AUTH_TYPE_TRANSLATION_KEY[authenticationMethod?.name ?? NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.UNKNOWN.NAME]); return {translate('multifactorAuthentication.biometricsTest.successfullyAuthenticatedUsing', {authType})}; } diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsEC256/VALUES.ts b/src/libs/MultifactorAuthentication/NativeBiometricsEC256/VALUES.ts index 3327c84224974..b49109891da89 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsEC256/VALUES.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsEC256/VALUES.ts @@ -1,6 +1,8 @@ /** * Constants specific to native biometrics (EC256 / react-native-biometrics). */ +import {AuthType} from '@sbaiahmed1/react-native-biometrics'; +import MARQETA_VALUES from '@libs/MultifactorAuthentication/shared/MarqetaValues'; const NATIVE_BIOMETRICS_EC256_VALUES = { /** @@ -12,6 +14,54 @@ const NATIVE_BIOMETRICS_EC256_VALUES = { * Key alias suffix for EC256 keys managed by react-native-biometrics. */ EC256_KEY_SUFFIX: 'EC256_KEY', + + /** + * Authentication types mapped to Marqeta values + */ + AUTH_TYPE: { + /** + * AuthType.Unknown will be released in the next version of the @sbaiahmed1/react-native-biometrics + */ + UNKNOWN: { + CODE: -1, + NAME: 'Unknown', + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.KNOWLEDGE_BASED, + }, + NONE: { + CODE: AuthType.None, + NAME: 'None', + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.NONE, + }, + CREDENTIALS: { + CODE: AuthType.DeviceCredentials, + NAME: 'Credentials', + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.KNOWLEDGE_BASED, + }, + BIOMETRICS: { + CODE: AuthType.Biometrics, + NAME: 'Biometrics', + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FINGERPRINT, + }, + FACE_ID: { + CODE: AuthType.FaceID, + NAME: 'Face ID', + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FACE, + }, + TOUCH_ID: { + CODE: AuthType.TouchID, + NAME: 'Touch ID', + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FINGERPRINT, + }, + /** + * OpticID is reserved by apple, used on Apple Vision Pro and not iOS. + * It is declared here for completeness but is not currently supported. + */ + OPTIC_ID: { + CODE: AuthType.OpticID, + NAME: 'Optic ID', + MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FACE, + }, + }, } as const; export default NATIVE_BIOMETRICS_EC256_VALUES; diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.ts b/src/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.ts index 2b08a0e388e6f..789ad92cafa04 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.ts @@ -4,12 +4,12 @@ import type {BiometricSensorInfo} from '@sbaiahmed1/react-native-biometrics'; import {isSensorAvailable} from '@sbaiahmed1/react-native-biometrics'; import type {ValueOf} from 'type-fest'; -import {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'; import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; +import NATIVE_BIOMETRICS_EC256_VALUES from './VALUES'; -type SecureStoreAuthTypeEntry = ValueOf; +type SecureStoreAuthTypeEntry = ValueOf; /** * Converts standard base64 to base64url encoding. @@ -47,13 +47,13 @@ function getSensorResult(): BiometricSensorInfo { * Native layer returns: -1=Unknown, 0=None, 1=DeviceCredentials, 2=Biometrics, 3=FaceID, 4=TouchID, 5=OpticID */ const AUTH_TYPE_NUMBER_MAP = new Map([ - [-1, SECURE_STORE_VALUES.AUTH_TYPE.UNKNOWN], - [0, SECURE_STORE_VALUES.AUTH_TYPE.NONE], - [1, SECURE_STORE_VALUES.AUTH_TYPE.CREDENTIALS], - [2, SECURE_STORE_VALUES.AUTH_TYPE.BIOMETRICS], - [3, SECURE_STORE_VALUES.AUTH_TYPE.FACE_ID], - [4, SECURE_STORE_VALUES.AUTH_TYPE.TOUCH_ID], - [5, SECURE_STORE_VALUES.AUTH_TYPE.OPTIC_ID], + [-1, NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.UNKNOWN], + [0, NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.NONE], + [1, NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.CREDENTIALS], + [2, NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.BIOMETRICS], + [3, NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.FACE_ID], + [4, NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.TOUCH_ID], + [5, NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.OPTIC_ID], ]); function mapAuthTypeNumber(authType?: number): AuthTypeInfo | undefined { @@ -71,17 +71,17 @@ function mapAuthTypeNumber(authType?: number): AuthTypeInfo | undefined { * Maps biometryType string from isSensorAvailable to AuthTypeInfo (used during registration). */ const BIOMETRY_TYPE_MAP: Record = { - FaceID: SECURE_STORE_VALUES.AUTH_TYPE.FACE_ID, - TouchID: SECURE_STORE_VALUES.AUTH_TYPE.TOUCH_ID, - Biometrics: SECURE_STORE_VALUES.AUTH_TYPE.BIOMETRICS, - OpticID: SECURE_STORE_VALUES.AUTH_TYPE.OPTIC_ID, + FaceID: NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.FACE_ID, + TouchID: NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.TOUCH_ID, + Biometrics: NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.BIOMETRICS, + OpticID: NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.OPTIC_ID, }; function mapBiometryTypeToAuthType(biometryType?: string, isDeviceSecure?: boolean): AuthTypeInfo | undefined { let entry = BIOMETRY_TYPE_MAP[biometryType ?? '']; if (!entry) { if (isDeviceSecure) { - entry = SECURE_STORE_VALUES.AUTH_TYPE.CREDENTIALS; + entry = NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.CREDENTIALS; } else { return undefined; } diff --git a/src/libs/MultifactorAuthentication/shared/types.ts b/src/libs/MultifactorAuthentication/shared/types.ts index 8a20df64cc8f1..4748009b93356 100644 --- a/src/libs/MultifactorAuthentication/shared/types.ts +++ b/src/libs/MultifactorAuthentication/shared/types.ts @@ -4,20 +4,20 @@ */ import type {ValueOf} from 'type-fest'; import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioAdditionalParams} from '@components/MultifactorAuthentication/config/types'; -import type {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'; import type {NativeBiometricsKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometrics/types'; import type {NativeBiometricsEC256KeyInfo} from '@libs/MultifactorAuthentication/NativeBiometricsEC256/types'; +import type NATIVE_BIOMETRICS_EC256_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsEC256/VALUES'; import type {PasskeyRegistrationKeyInfo} from '@libs/MultifactorAuthentication/Passkeys/types'; import type {PASSKEY_AUTH_TYPE} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; import type {SignedChallenge} from './challengeTypes'; import type VALUES from './VALUES'; /** - * Authentication type name derived from secure store values and passkey auth type. + * Authentication type name derived from react-native-biometrics values and passkey auth type. */ -type AuthTypeName = ValueOf['NAME'] | (typeof PASSKEY_AUTH_TYPE)['NAME']; +type AuthTypeName = ValueOf['NAME'] | (typeof PASSKEY_AUTH_TYPE)['NAME']; -type MarqetaAuthTypeName = ValueOf['MARQETA_VALUE'] | (typeof PASSKEY_AUTH_TYPE)['MARQETA_VALUE']; +type MarqetaAuthTypeName = ValueOf['MARQETA_VALUE'] | (typeof PASSKEY_AUTH_TYPE)['MARQETA_VALUE']; type AuthTypeInfo = { code?: MultifactorAuthenticationMethodCode; @@ -25,7 +25,7 @@ type AuthTypeInfo = { marqetaValue: MarqetaAuthTypeName; }; -type MultifactorAuthenticationMethodCode = ValueOf['CODE']; +type MultifactorAuthenticationMethodCode = ValueOf['CODE']; /** * Represents the reason for a multifactor authentication response from the backend. From ded5e47085383b3dc8fda468a78253eaaf818c48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Thu, 26 Mar 2026 15:07:07 +0100 Subject: [PATCH 11/41] added tests similarly to previous native biometrics --- .../useNativeBiometricsEC256.test.ts | 364 ++++++++++++++++++ .../NativeBiometricsEC256/helpers.test.ts | 167 ++++++++ 2 files changed, 531 insertions(+) create mode 100644 tests/unit/components/MultifactorAuthentication/useNativeBiometricsEC256.test.ts create mode 100644 tests/unit/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.test.ts diff --git a/tests/unit/components/MultifactorAuthentication/useNativeBiometricsEC256.test.ts b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsEC256.test.ts new file mode 100644 index 0000000000000..f241821fc2130 --- /dev/null +++ b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsEC256.test.ts @@ -0,0 +1,364 @@ +import type {BiometricSensorInfo} from '@sbaiahmed1/react-native-biometrics'; +import {act, renderHook} from '@testing-library/react-native'; +import useNativeBiometricsEC256 from '@components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256'; +import * as EC256Helpers from '@libs/MultifactorAuthentication/NativeBiometricsEC256/helpers'; +import type {AuthenticationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; +import VALUES from '@libs/MultifactorAuthentication/VALUES'; +import CONST from '@src/CONST'; + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: () => ({ + accountID: 12345, + }), +})); + +jest.mock('@hooks/useLocalize', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: () => ({ + translate: (key: string) => `translated_${key}`, + }), +})); + +let mockMultifactorAuthenticationPublicKeyIDs: string[] | undefined = []; + +jest.mock('@hooks/useOnyx', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: () => [mockMultifactorAuthenticationPublicKeyIDs], +})); + +jest.mock('@userActions/MultifactorAuthentication'); + +const mockCreateKeys = jest.fn(); +const mockDeleteKeys = jest.fn(); +const mockGetAllKeys = jest.fn(); +const mockSignWithOptions = jest.fn(); +const mockSha256 = jest.fn(); + +jest.mock('@sbaiahmed1/react-native-biometrics', () => ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + createKeys: (...args: unknown[]) => mockCreateKeys(...args), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + deleteKeys: (...args: unknown[]) => mockDeleteKeys(...args), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + getAllKeys: (...args: unknown[]) => mockGetAllKeys(...args), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + signWithOptions: (...args: unknown[]) => mockSignWithOptions(...args), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + sha256: (...args: unknown[]) => mockSha256(...args), + isSensorAvailable: jest.fn().mockResolvedValue({available: true, biometryType: 'FaceID', isDeviceSecure: true}), + InputEncoding: {Base64: 'base64'}, +})); + +jest.mock('@components/MultifactorAuthentication/config', () => ({ + MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG: new Proxy( + {}, + { + get: () => ({ + nativePromptTitle: 'multifactorAuthentication.biometricsTest.promptTitle', + }), + }, + ), +})); +jest.mock('@userActions/MultifactorAuthentication/processing'); + +const DEFAULT_SENSOR_RESULT: BiometricSensorInfo = {available: true, biometryType: 'FaceID', isDeviceSecure: true}; + +describe('useNativeBiometricsEC256 hook', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockMultifactorAuthenticationPublicKeyIDs = []; + jest.spyOn(EC256Helpers, 'getSensorResult').mockReturnValue(DEFAULT_SENSOR_RESULT); + + mockGetAllKeys.mockResolvedValue({keys: []}); + }); + + describe('Hook initialization', () => { + it('should return hook with required properties', () => { + const {result} = renderHook(() => useNativeBiometricsEC256()); + + expect(result.current).toHaveProperty('serverKnownCredentialIDs'); + expect(result.current).toHaveProperty('doesDeviceSupportAuthenticationMethod'); + expect(result.current).toHaveProperty('getLocalCredentialID'); + expect(result.current).toHaveProperty('areLocalCredentialsKnownToServer'); + expect(result.current).toHaveProperty('register'); + expect(result.current).toHaveProperty('authorize'); + expect(result.current).toHaveProperty('deleteLocalKeysForAccount'); + }); + + it('should return biometrics device verification type', () => { + const {result} = renderHook(() => useNativeBiometricsEC256()); + + expect(result.current.deviceVerificationType).toBe(CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS); + }); + }); + + describe('doesDeviceSupportAuthenticationMethod', () => { + it('should return true when sensor is available', () => { + const {result} = renderHook(() => useNativeBiometricsEC256()); + + expect(result.current.doesDeviceSupportAuthenticationMethod()).toBe(true); + }); + + it('should return true when device is secure but no biometrics', () => { + jest.spyOn(EC256Helpers, 'getSensorResult').mockReturnValue({available: false, isDeviceSecure: true}); + + const {result} = renderHook(() => useNativeBiometricsEC256()); + + expect(result.current.doesDeviceSupportAuthenticationMethod()).toBe(true); + }); + + it('should return false when sensor unavailable and device not secure', () => { + jest.spyOn(EC256Helpers, 'getSensorResult').mockReturnValue({available: false}); + + const {result} = renderHook(() => useNativeBiometricsEC256()); + + expect(result.current.doesDeviceSupportAuthenticationMethod()).toBe(false); + }); + }); + + describe('getLocalCredentialID', () => { + it('should return undefined when no local key exists', async () => { + mockGetAllKeys.mockResolvedValue({keys: []}); + + const {result} = renderHook(() => useNativeBiometricsEC256()); + + const key = await result.current.getLocalCredentialID(); + expect(key).toBeUndefined(); + }); + + it('should return base64url-encoded public key when key exists', async () => { + const keyAlias = '12345_EC256_KEY'; + mockGetAllKeys.mockResolvedValue({keys: [{alias: keyAlias, publicKey: 'abc+def/ghi='}]}); + + const {result} = renderHook(() => useNativeBiometricsEC256()); + + const key = await result.current.getLocalCredentialID(); + expect(key).toBe('abc-def_ghi'); + }); + }); + + describe('areLocalCredentialsKnownToServer', () => { + it('should return false when no local credential exists', async () => { + const {result} = renderHook(() => useNativeBiometricsEC256()); + + const isKnown = await result.current.areLocalCredentialsKnownToServer(); + expect(isKnown).toBe(false); + }); + + it('should return true when local credential is known to server', async () => { + const keyAlias = '12345_EC256_KEY'; + mockMultifactorAuthenticationPublicKeyIDs = ['abc-def_ghi']; + mockGetAllKeys.mockResolvedValue({keys: [{alias: keyAlias, publicKey: 'abc+def/ghi='}]}); + + const {result} = renderHook(() => useNativeBiometricsEC256()); + + const isKnown = await result.current.areLocalCredentialsKnownToServer(); + expect(isKnown).toBe(true); + }); + }); + + describe('serverKnownCredentialIDs', () => { + it('should expose credential IDs from Onyx state', () => { + mockMultifactorAuthenticationPublicKeyIDs = ['key-1', 'key-2']; + const {result} = renderHook(() => useNativeBiometricsEC256()); + + expect(result.current.serverKnownCredentialIDs).toEqual(['key-1', 'key-2']); + }); + + it('should return empty array when Onyx state is empty', () => { + mockMultifactorAuthenticationPublicKeyIDs = []; + const {result} = renderHook(() => useNativeBiometricsEC256()); + + expect(result.current.serverKnownCredentialIDs).toEqual([]); + }); + }); + + describe('haveCredentialsEverBeenConfigured', () => { + it('should return false when Onyx state is undefined', () => { + mockMultifactorAuthenticationPublicKeyIDs = undefined; + const {result} = renderHook(() => useNativeBiometricsEC256()); + + expect(result.current.haveCredentialsEverBeenConfigured).toBe(false); + }); + + it('should return true when Onyx state is an empty array', () => { + mockMultifactorAuthenticationPublicKeyIDs = []; + const {result} = renderHook(() => useNativeBiometricsEC256()); + + expect(result.current.haveCredentialsEverBeenConfigured).toBe(true); + }); + + it('should return true when Onyx state has credential IDs', () => { + mockMultifactorAuthenticationPublicKeyIDs = ['key-1']; + const {result} = renderHook(() => useNativeBiometricsEC256()); + + expect(result.current.haveCredentialsEverBeenConfigured).toBe(true); + }); + }); + + describe('register', () => { + const mockRegistrationChallenge = { + challenge: 'test-challenge-string', + rp: {id: 'expensify.com'}, + user: {id: 'user-123', displayName: 'Test User'}, + pubKeyCredParams: [{type: 'public-key' as const, alg: -7}], + timeout: 60000, + }; + + beforeEach(() => { + mockCreateKeys.mockResolvedValue({publicKey: 'abc+def/ghi='}); + }); + + it('should create keys with correct alias', async () => { + const {result} = renderHook(() => useNativeBiometricsEC256()); + const onResult = jest.fn(); + + await act(async () => { + await result.current.register(onResult, mockRegistrationChallenge); + }); + + expect(mockCreateKeys).toHaveBeenCalledWith('12345_EC256_KEY', 'ec256', undefined, true, false); + }); + + it('should call onResult with success and keyInfo on successful registration', async () => { + const {result} = renderHook(() => useNativeBiometricsEC256()); + const onResult = jest.fn(); + + await act(async () => { + await result.current.register(onResult, mockRegistrationChallenge); + }); + + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.LOCAL_REGISTRATION_COMPLETE, + keyInfo: expect.objectContaining({ + rawId: 'abc-def_ghi', + type: CONST.MULTIFACTOR_AUTHENTICATION.EC256_TYPE, + }), + }), + ); + }); + + it('should call onResult with failure when createKeys throws', async () => { + mockCreateKeys.mockRejectedValue(new Error('Key creation failed')); + + const {result} = renderHook(() => useNativeBiometricsEC256()); + const onResult = jest.fn(); + + await act(async () => { + await result.current.register(onResult, mockRegistrationChallenge); + }); + + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + }), + ); + }); + }); + + describe('authorize', () => { + const mockChallenge: AuthenticationChallenge = { + allowCredentials: [{id: 'abc-def_ghi', type: 'public-key'}], + rpId: 'expensify.com', + challenge: 'test-challenge', + userVerification: 'required', + timeout: 60000, + }; + + beforeEach(() => { + const keyAlias = '12345_EC256_KEY'; + mockGetAllKeys.mockResolvedValue({keys: [{alias: keyAlias, publicKey: 'abc+def/ghi='}]}); + mockSha256.mockResolvedValue({hash: Buffer.alloc(32).toString('base64')}); + mockSignWithOptions.mockResolvedValue({success: true, signature: 'c2lnbmF0dXJl', authType: 3}); + }); + + it('should sign challenge and return success', async () => { + const {result} = renderHook(() => useNativeBiometricsEC256()); + const onResult = jest.fn(); + + await act(async () => { + await result.current.authorize({challenge: mockChallenge}, onResult); + }); + + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + reason: VALUES.REASON.CHALLENGE.CHALLENGE_SIGNED, + signedChallenge: expect.objectContaining({ + type: CONST.MULTIFACTOR_AUTHENTICATION.EC256_TYPE, + }), + }), + ); + }); + + it('should call signWithOptions with biometric prompt', async () => { + const {result} = renderHook(() => useNativeBiometricsEC256()); + const onResult = jest.fn(); + + await act(async () => { + await result.current.authorize({challenge: mockChallenge}, onResult); + }); + + expect(mockSignWithOptions).toHaveBeenCalledWith( + expect.objectContaining({ + keyAlias: '12345_EC256_KEY', + promptTitle: 'translated_multifactorAuthentication.letsVerifyItsYou', + returnAuthType: true, + }), + ); + }); + + it('should handle sign failure', async () => { + mockSignWithOptions.mockResolvedValue({success: false, errorCode: 'canceled'}); + + const {result} = renderHook(() => useNativeBiometricsEC256()); + const onResult = jest.fn(); + + await act(async () => { + await result.current.authorize({challenge: mockChallenge}, onResult); + }); + + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + }), + ); + }); + + it('should handle thrown errors', async () => { + mockSignWithOptions.mockRejectedValue(new Error('Biometric canceled')); + + const {result} = renderHook(() => useNativeBiometricsEC256()); + const onResult = jest.fn(); + + await act(async () => { + await result.current.authorize({challenge: mockChallenge}, onResult); + }); + + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + reason: VALUES.REASON.EXPO.CANCELED, + }), + ); + }); + }); + + describe('deleteLocalKeysForAccount', () => { + it('should delete keys with correct alias', async () => { + const {result} = renderHook(() => useNativeBiometricsEC256()); + + await act(async () => { + await result.current.deleteLocalKeysForAccount(); + }); + + expect(mockDeleteKeys).toHaveBeenCalledWith('12345_EC256_KEY'); + }); + }); +}); diff --git a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.test.ts new file mode 100644 index 0000000000000..c087d7c084c67 --- /dev/null +++ b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.test.ts @@ -0,0 +1,167 @@ +import {base64ToBase64url, getKeyAlias, mapAuthTypeNumber, mapBiometryTypeToAuthType, mapLibraryError, mapSignErrorCode} from '@libs/MultifactorAuthentication/NativeBiometricsEC256/helpers'; +import NATIVE_BIOMETRICS_EC256_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsEC256/VALUES'; +import VALUES from '@libs/MultifactorAuthentication/VALUES'; + +jest.mock('@sbaiahmed1/react-native-biometrics', () => ({ + isSensorAvailable: jest.fn().mockResolvedValue({available: true, biometryType: 'FaceID', isDeviceSecure: true}), +})); + +describe('NativeBiometricsEC256 helpers', () => { + describe('base64ToBase64url', () => { + it('should replace + with -', () => { + expect(base64ToBase64url('abc+def')).toBe('abc-def'); + }); + + it('should replace / with _', () => { + expect(base64ToBase64url('abc/def')).toBe('abc_def'); + }); + + it('should strip trailing = padding', () => { + expect(base64ToBase64url('abc==')).toBe('abc'); + expect(base64ToBase64url('abcd=')).toBe('abcd'); + }); + + it('should handle all replacements together', () => { + expect(base64ToBase64url('abc+def/ghi==')).toBe('abc-def_ghi'); + }); + + it('should leave already-safe strings unchanged', () => { + expect(base64ToBase64url('abcdef')).toBe('abcdef'); + }); + }); + + describe('getKeyAlias', () => { + it('should build alias from accountID and EC256_KEY_SUFFIX', () => { + expect(getKeyAlias(12345)).toBe('12345_EC256_KEY'); + }); + + it('should handle different account IDs', () => { + expect(getKeyAlias(0)).toBe('0_EC256_KEY'); + expect(getKeyAlias(999999)).toBe('999999_EC256_KEY'); + }); + }); + + describe('mapAuthTypeNumber', () => { + it('should return undefined for undefined input', () => { + expect(mapAuthTypeNumber(undefined)).toBeUndefined(); + }); + + it('should return undefined for unmapped number', () => { + expect(mapAuthTypeNumber(99)).toBeUndefined(); + }); + + it('should map -1 to Unknown', () => { + const result = mapAuthTypeNumber(-1); + expect(result).toEqual({ + code: NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.UNKNOWN.CODE, + name: NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.UNKNOWN.NAME, + marqetaValue: NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.UNKNOWN.MARQETA_VALUE, + }); + }); + + it('should map 0 to None', () => { + const result = mapAuthTypeNumber(0); + expect(result?.name).toBe('None'); + }); + + it('should map 1 to Credentials', () => { + const result = mapAuthTypeNumber(1); + expect(result?.name).toBe('Credentials'); + }); + + it('should map 2 to Biometrics', () => { + const result = mapAuthTypeNumber(2); + expect(result?.name).toBe('Biometrics'); + }); + + it('should map 3 to Face ID', () => { + const result = mapAuthTypeNumber(3); + expect(result?.name).toBe('Face ID'); + }); + + it('should map 4 to Touch ID', () => { + const result = mapAuthTypeNumber(4); + expect(result?.name).toBe('Touch ID'); + }); + + it('should map 5 to Optic ID', () => { + const result = mapAuthTypeNumber(5); + expect(result?.name).toBe('Optic ID'); + }); + }); + + describe('mapBiometryTypeToAuthType', () => { + it('should map FaceID string to Face ID auth type', () => { + const result = mapBiometryTypeToAuthType('FaceID'); + expect(result?.name).toBe('Face ID'); + }); + + it('should map TouchID string to Touch ID auth type', () => { + const result = mapBiometryTypeToAuthType('TouchID'); + expect(result?.name).toBe('Touch ID'); + }); + + it('should map Biometrics string to Biometrics auth type', () => { + const result = mapBiometryTypeToAuthType('Biometrics'); + expect(result?.name).toBe('Biometrics'); + }); + + it('should map OpticID string to Optic ID auth type', () => { + const result = mapBiometryTypeToAuthType('OpticID'); + expect(result?.name).toBe('Optic ID'); + }); + + it('should fall back to Credentials when biometryType is unknown but device is secure', () => { + const result = mapBiometryTypeToAuthType(undefined, true); + expect(result?.name).toBe('Credentials'); + }); + + it('should return undefined when biometryType is unknown and device is not secure', () => { + expect(mapBiometryTypeToAuthType(undefined, false)).toBeUndefined(); + expect(mapBiometryTypeToAuthType(undefined)).toBeUndefined(); + }); + + it('should return undefined for unrecognized biometryType when device is not secure', () => { + expect(mapBiometryTypeToAuthType('SomeNewType', false)).toBeUndefined(); + }); + }); + + describe('mapSignErrorCode', () => { + it('should return undefined for undefined input', () => { + expect(mapSignErrorCode(undefined)).toBeUndefined(); + }); + + it('should return CANCELED for cancel-related error codes', () => { + expect(mapSignErrorCode('UserCancel')).toBe(VALUES.REASON.EXPO.CANCELED); + expect(mapSignErrorCode('CANCELED')).toBe(VALUES.REASON.EXPO.CANCELED); + expect(mapSignErrorCode('user_cancel')).toBe(VALUES.REASON.EXPO.CANCELED); + }); + + it('should return NOT_SUPPORTED for "not available" error codes', () => { + expect(mapSignErrorCode('Biometrics not available')).toBe(VALUES.REASON.EXPO.NOT_SUPPORTED); + expect(mapSignErrorCode('NOT AVAILABLE')).toBe(VALUES.REASON.EXPO.NOT_SUPPORTED); + }); + + it('should return GENERIC for other error codes', () => { + expect(mapSignErrorCode('some_unknown_error')).toBe(VALUES.REASON.EXPO.GENERIC); + }); + }); + + describe('mapLibraryError', () => { + it('should return CANCELED for Error with cancel message', () => { + expect(mapLibraryError(new Error('User canceled the operation'))).toBe(VALUES.REASON.EXPO.CANCELED); + }); + + it('should return CANCELED for string with cancel', () => { + expect(mapLibraryError('Canceled by user')).toBe(VALUES.REASON.EXPO.CANCELED); + }); + + it('should return undefined for non-cancel errors', () => { + expect(mapLibraryError(new Error('Network error'))).toBeUndefined(); + }); + + it('should return undefined for non-cancel strings', () => { + expect(mapLibraryError('timeout')).toBeUndefined(); + }); + }); +}); From a42ba8d35b3d491a1696ae83f1a15e268cebe2cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Mon, 30 Mar 2026 16:10:45 +0200 Subject: [PATCH 12/41] documented tests --- .../useNativeBiometricsEC256.test.ts | 66 +++++++++++++ .../NativeBiometricsEC256/helpers.test.ts | 93 +++++++++++++++++++ 2 files changed, 159 insertions(+) diff --git a/tests/unit/components/MultifactorAuthentication/useNativeBiometricsEC256.test.ts b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsEC256.test.ts index f241821fc2130..b64b664b1cba8 100644 --- a/tests/unit/components/MultifactorAuthentication/useNativeBiometricsEC256.test.ts +++ b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsEC256.test.ts @@ -78,6 +78,9 @@ describe('useNativeBiometricsEC256 hook', () => { describe('Hook initialization', () => { it('should return hook with required properties', () => { + // Given a device with biometrics available and an authenticated user + // When the hook is initialized + // Then it should expose all required interface methods so consumers can register, authorize, and manage biometric credentials const {result} = renderHook(() => useNativeBiometricsEC256()); expect(result.current).toHaveProperty('serverKnownCredentialIDs'); @@ -90,6 +93,9 @@ describe('useNativeBiometricsEC256 hook', () => { }); it('should return biometrics device verification type', () => { + // Given a device with biometrics available + // When the hook is initialized + // Then it should report BIOMETRICS as its device verification type so the MFA system can distinguish it from other verification methods const {result} = renderHook(() => useNativeBiometricsEC256()); expect(result.current.deviceVerificationType).toBe(CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS); @@ -98,12 +104,18 @@ describe('useNativeBiometricsEC256 hook', () => { describe('doesDeviceSupportAuthenticationMethod', () => { it('should return true when sensor is available', () => { + // Given a device with a biometric sensor available (e.g., Face ID or Touch ID) + // When checking device support for biometric authentication + // Then it should return true because the device can perform biometric verification const {result} = renderHook(() => useNativeBiometricsEC256()); expect(result.current.doesDeviceSupportAuthenticationMethod()).toBe(true); }); it('should return true when device is secure but no biometrics', () => { + // Given a device without biometric hardware but with a secure lock screen (PIN/password) + // When checking device support for biometric authentication + // Then it should return true because device credentials can serve as a fallback verification method jest.spyOn(EC256Helpers, 'getSensorResult').mockReturnValue({available: false, isDeviceSecure: true}); const {result} = renderHook(() => useNativeBiometricsEC256()); @@ -112,6 +124,9 @@ describe('useNativeBiometricsEC256 hook', () => { }); it('should return false when sensor unavailable and device not secure', () => { + // Given a device with no biometric sensor and no secure lock screen configured + // When checking device support for biometric authentication + // Then it should return false because there is no way to verify the user's identity on this device jest.spyOn(EC256Helpers, 'getSensorResult').mockReturnValue({available: false}); const {result} = renderHook(() => useNativeBiometricsEC256()); @@ -122,6 +137,9 @@ describe('useNativeBiometricsEC256 hook', () => { describe('getLocalCredentialID', () => { it('should return undefined when no local key exists', async () => { + // Given no EC256 keys have been created on the device for this account + // When retrieving the local credential ID + // Then undefined should be returned because the user has not yet registered biometrics on this device mockGetAllKeys.mockResolvedValue({keys: []}); const {result} = renderHook(() => useNativeBiometricsEC256()); @@ -131,6 +149,9 @@ describe('useNativeBiometricsEC256 hook', () => { }); it('should return base64url-encoded public key when key exists', async () => { + // Given an EC256 key exists on the device for this account with a base64 public key + // When retrieving the local credential ID + // Then the public key should be returned in base64url format because credential IDs must be URL-safe for server communication const keyAlias = '12345_EC256_KEY'; mockGetAllKeys.mockResolvedValue({keys: [{alias: keyAlias, publicKey: 'abc+def/ghi='}]}); @@ -143,6 +164,9 @@ describe('useNativeBiometricsEC256 hook', () => { describe('areLocalCredentialsKnownToServer', () => { it('should return false when no local credential exists', async () => { + // Given no EC256 keys exist on the device + // When checking if local credentials are known to the server + // Then it should return false because there is no local key to match against server-known credential IDs const {result} = renderHook(() => useNativeBiometricsEC256()); const isKnown = await result.current.areLocalCredentialsKnownToServer(); @@ -150,6 +174,9 @@ describe('useNativeBiometricsEC256 hook', () => { }); it('should return true when local credential is known to server', async () => { + // Given an EC256 key exists on the device and its base64url-encoded public key matches a server-known credential ID + // When checking if local credentials are known to the server + // Then it should return true because the device's biometric registration is still valid on the server const keyAlias = '12345_EC256_KEY'; mockMultifactorAuthenticationPublicKeyIDs = ['abc-def_ghi']; mockGetAllKeys.mockResolvedValue({keys: [{alias: keyAlias, publicKey: 'abc+def/ghi='}]}); @@ -163,6 +190,9 @@ describe('useNativeBiometricsEC256 hook', () => { describe('serverKnownCredentialIDs', () => { it('should expose credential IDs from Onyx state', () => { + // Given the server has registered multiple biometric credential IDs stored in Onyx + // When accessing serverKnownCredentialIDs from the hook + // Then it should return all credential IDs mockMultifactorAuthenticationPublicKeyIDs = ['key-1', 'key-2']; const {result} = renderHook(() => useNativeBiometricsEC256()); @@ -170,6 +200,9 @@ describe('useNativeBiometricsEC256 hook', () => { }); it('should return empty array when Onyx state is empty', () => { + // Given no biometric credentials are registered on the server (empty Onyx state) + // When accessing serverKnownCredentialIDs from the hook + // Then it should return an empty array rather than undefined mockMultifactorAuthenticationPublicKeyIDs = []; const {result} = renderHook(() => useNativeBiometricsEC256()); @@ -179,6 +212,9 @@ describe('useNativeBiometricsEC256 hook', () => { describe('haveCredentialsEverBeenConfigured', () => { it('should return false when Onyx state is undefined', () => { + // Given the Onyx state for MFA public key IDs is undefined, meaning biometrics have never been set up for this account + // When checking if credentials have ever been configured + // Then it should return false because undefined indicates the key was never initialized in Onyx mockMultifactorAuthenticationPublicKeyIDs = undefined; const {result} = renderHook(() => useNativeBiometricsEC256()); @@ -186,6 +222,9 @@ describe('useNativeBiometricsEC256 hook', () => { }); it('should return true when Onyx state is an empty array', () => { + // Given the Onyx state is an empty array, meaning biometrics were configured but all credentials have since been removed + // When checking if credentials have ever been configured + // Then it should return true because an empty array (vs undefined) indicates the user previously set up biometrics mockMultifactorAuthenticationPublicKeyIDs = []; const {result} = renderHook(() => useNativeBiometricsEC256()); @@ -193,6 +232,9 @@ describe('useNativeBiometricsEC256 hook', () => { }); it('should return true when Onyx state has credential IDs', () => { + // Given the Onyx state contains active credential IDs + // When checking if credentials have ever been configured + // Then it should return true because credentials are currently registered mockMultifactorAuthenticationPublicKeyIDs = ['key-1']; const {result} = renderHook(() => useNativeBiometricsEC256()); @@ -214,6 +256,9 @@ describe('useNativeBiometricsEC256 hook', () => { }); it('should create keys with correct alias', async () => { + // Given a valid registration challenge from the server + // When registering a new biometric credential + // Then it should create an EC256 key with the account-specific alias so the key is uniquely tied to the current user const {result} = renderHook(() => useNativeBiometricsEC256()); const onResult = jest.fn(); @@ -225,6 +270,9 @@ describe('useNativeBiometricsEC256 hook', () => { }); it('should call onResult with success and keyInfo on successful registration', async () => { + // Given a valid registration challenge and the biometric library successfully creates an EC256 key pair + // When the registration completes + // Then onResult should receive a success result with the base64url-encoded public key as rawId and EC256 type for server registration const {result} = renderHook(() => useNativeBiometricsEC256()); const onResult = jest.fn(); @@ -245,6 +293,9 @@ describe('useNativeBiometricsEC256 hook', () => { }); it('should call onResult with failure when createKeys throws', async () => { + // Given the biometric library fails to create keys + // When the registration is attempted + // Then onResult should receive a failure result mockCreateKeys.mockRejectedValue(new Error('Key creation failed')); const {result} = renderHook(() => useNativeBiometricsEC256()); @@ -279,6 +330,9 @@ describe('useNativeBiometricsEC256 hook', () => { }); it('should sign challenge and return success', async () => { + // Given a valid authentication challenge from the server and a local EC256 key that can sign it + // When the user successfully authenticates via biometrics + // Then onResult should receive a success with the signed challenge and EC256 type so the server can verify the signature const {result} = renderHook(() => useNativeBiometricsEC256()); const onResult = jest.fn(); @@ -298,6 +352,9 @@ describe('useNativeBiometricsEC256 hook', () => { }); it('should call signWithOptions with biometric prompt', async () => { + // Given a valid authentication challenge and a local EC256 key + // When initiating the authorize flow + // Then signWithOptions should be called with the correct key alias and a localized prompt title const {result} = renderHook(() => useNativeBiometricsEC256()); const onResult = jest.fn(); @@ -315,6 +372,9 @@ describe('useNativeBiometricsEC256 hook', () => { }); it('should handle sign failure', async () => { + // Given the biometric sign operation returns a failure result (e.g., user canceled the biometric prompt) + // When the authorize flow completes + // Then onResult should receive a failure so the app can prompt the user to retry or use an alternative method mockSignWithOptions.mockResolvedValue({success: false, errorCode: 'canceled'}); const {result} = renderHook(() => useNativeBiometricsEC256()); @@ -332,6 +392,9 @@ describe('useNativeBiometricsEC256 hook', () => { }); it('should handle thrown errors', async () => { + // Given the biometric library throws an error containing "canceled" + // When the authorize flow catches the thrown error + // Then onResult should receive a failure with CANCELED reason mockSignWithOptions.mockRejectedValue(new Error('Biometric canceled')); const {result} = renderHook(() => useNativeBiometricsEC256()); @@ -352,6 +415,9 @@ describe('useNativeBiometricsEC256 hook', () => { describe('deleteLocalKeysForAccount', () => { it('should delete keys with correct alias', async () => { + // Given an authenticated user with a locally stored EC256 key + // When deleting local biometric keys for the account + // Then deleteKeys should be called with the account-specific alias to remove only this user's key without affecting other accounts on the device const {result} = renderHook(() => useNativeBiometricsEC256()); await act(async () => { diff --git a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.test.ts index c087d7c084c67..54187bc1ec550 100644 --- a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.test.ts @@ -9,33 +9,54 @@ jest.mock('@sbaiahmed1/react-native-biometrics', () => ({ describe('NativeBiometricsEC256 helpers', () => { describe('base64ToBase64url', () => { it('should replace + with -', () => { + // Given a base64 string containing '+' characters, which are not URL-safe + // When converting to base64url format + // Then '+' should be replaced with '-' because base64url encoding requires URL-safe characters for use in credential IDs expect(base64ToBase64url('abc+def')).toBe('abc-def'); }); it('should replace / with _', () => { + // Given a base64 string containing '/' characters, which are not URL-safe + // When converting to base64url format + // Then '/' should be replaced with '_' because base64url encoding requires URL-safe characters for use in credential IDs expect(base64ToBase64url('abc/def')).toBe('abc_def'); }); it('should strip trailing = padding', () => { + // Given a base64 string with trailing '=' padding characters + // When converting to base64url format + // Then padding should be stripped because base64url omits padding per RFC 4648 §5 expect(base64ToBase64url('abc==')).toBe('abc'); expect(base64ToBase64url('abcd=')).toBe('abcd'); }); it('should handle all replacements together', () => { + // Given a base64 string with '+', '/', and '=' characters combined + // When converting to base64url format + // Then all unsafe characters should be replaced in a single pass to produce a valid base64url credential ID expect(base64ToBase64url('abc+def/ghi==')).toBe('abc-def_ghi'); }); it('should leave already-safe strings unchanged', () => { + // Given a base64 string that already contains only URL-safe characters + // When converting to base64url format + // Then the string should remain unchanged because no substitution is needed expect(base64ToBase64url('abcdef')).toBe('abcdef'); }); }); describe('getKeyAlias', () => { it('should build alias from accountID and EC256_KEY_SUFFIX', () => { + // Given a valid account ID + // When generating a key alias for biometric key storage + // Then the alias should combine the account ID with the EC256 suffix to uniquely identify the key per account expect(getKeyAlias(12345)).toBe('12345_EC256_KEY'); }); it('should handle different account IDs', () => { + // Given various account IDs including edge cases like 0 + // When generating key aliases + // Then each alias should be unique per account to prevent key collisions across different users on the same device expect(getKeyAlias(0)).toBe('0_EC256_KEY'); expect(getKeyAlias(999999)).toBe('999999_EC256_KEY'); }); @@ -43,14 +64,23 @@ describe('NativeBiometricsEC256 helpers', () => { describe('mapAuthTypeNumber', () => { it('should return undefined for undefined input', () => { + // Given an undefined auth type number, which occurs when the biometric library does not report an auth type + // When mapping the auth type number + // Then undefined should be returned because there is no auth type to map expect(mapAuthTypeNumber(undefined)).toBeUndefined(); }); it('should return undefined for unmapped number', () => { + // Given an auth type number that does not correspond to any known biometric method + // When mapping the auth type number + // Then undefined should be returned to avoid misrepresenting an unknown authentication method expect(mapAuthTypeNumber(99)).toBeUndefined(); }); it('should map -1 to Unknown', () => { + // Given auth type number -1 + // When mapping the auth type number + // Then it should resolve to the Unknown auth type, because the method could not be determined, but the authentication was successful const result = mapAuthTypeNumber(-1); expect(result).toEqual({ code: NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.UNKNOWN.CODE, @@ -60,31 +90,49 @@ describe('NativeBiometricsEC256 helpers', () => { }); it('should map 0 to None', () => { + // Given auth type number 0, indicating no biometric authentication was used + // When mapping the auth type number + // Then it should resolve to "None" so we can distinguish unauthenticated from authenticated flows const result = mapAuthTypeNumber(0); expect(result?.name).toBe('None'); }); it('should map 1 to Credentials', () => { + // Given auth type number 1, indicating device credential (PIN/password) was used instead of biometrics + // When mapping the auth type number + // Then it should resolve to "Credentials" to accurately report the fallback authentication method const result = mapAuthTypeNumber(1); expect(result?.name).toBe('Credentials'); }); it('should map 2 to Biometrics', () => { + // Given auth type number 2, indicating a generic biometric method was used (common on Android) + // When mapping the auth type number + // Then it should resolve to "Biometrics" as the platform does not distinguish the specific biometric type const result = mapAuthTypeNumber(2); expect(result?.name).toBe('Biometrics'); }); it('should map 3 to Face ID', () => { + // Given auth type number 3, indicating Apple Face ID was used + // When mapping the auth type number + // Then it should resolve to "Face ID" const result = mapAuthTypeNumber(3); expect(result?.name).toBe('Face ID'); }); it('should map 4 to Touch ID', () => { + // Given auth type number 4, indicating Apple Touch ID was used + // When mapping the auth type number + // Then it should resolve to "Touch ID" const result = mapAuthTypeNumber(4); expect(result?.name).toBe('Touch ID'); }); it('should map 5 to Optic ID', () => { + // Given auth type number 5, indicating Apple Optic ID (Vision Pro) was used + // When mapping the auth type number + // Then it should resolve to "Optic ID" const result = mapAuthTypeNumber(5); expect(result?.name).toBe('Optic ID'); }); @@ -92,75 +140,120 @@ describe('NativeBiometricsEC256 helpers', () => { describe('mapBiometryTypeToAuthType', () => { it('should map FaceID string to Face ID auth type', () => { + // Given the biometric sensor reports "FaceID" as the available biometry type (iOS devices with Face ID) + // When mapping the biometry type string to an auth type + // Then it should resolve to "Face ID" for accurate biometric method reporting const result = mapBiometryTypeToAuthType('FaceID'); expect(result?.name).toBe('Face ID'); }); it('should map TouchID string to Touch ID auth type', () => { + // Given the biometric sensor reports "TouchID" as the available biometry type (older iOS devices) + // When mapping the biometry type string to an auth type + // Then it should resolve to "Touch ID" for accurate biometric method reporting const result = mapBiometryTypeToAuthType('TouchID'); expect(result?.name).toBe('Touch ID'); }); it('should map Biometrics string to Biometrics auth type', () => { + // Given the biometric sensor reports "Biometrics" as the available type (Android devices) + // When mapping the biometry type string to an auth type + // Then it should resolve to "Biometrics" since Android does not distinguish specific biometric hardware const result = mapBiometryTypeToAuthType('Biometrics'); expect(result?.name).toBe('Biometrics'); }); it('should map OpticID string to Optic ID auth type', () => { + // Given the biometric sensor reports "OpticID" as the available biometry type (Apple Vision Pro) + // When mapping the biometry type string to an auth type + // Then it should resolve to "Optic ID" for accurate biometric method reporting const result = mapBiometryTypeToAuthType('OpticID'); expect(result?.name).toBe('Optic ID'); }); it('should fall back to Credentials when biometryType is unknown but device is secure', () => { + // Given an unknown biometry type but the device has a secure lock screen + // When mapping the biometry type to an auth type + // Then it should fall back to "Credentials" because the device can still verify the user via PIN/password const result = mapBiometryTypeToAuthType(undefined, true); expect(result?.name).toBe('Credentials'); }); it('should return undefined when biometryType is unknown and device is not secure', () => { + // Given an unknown biometry type and the device has no secure lock screen + // When mapping the biometry type to an auth type + // Then undefined should be returned because no verification method is available on this device expect(mapBiometryTypeToAuthType(undefined, false)).toBeUndefined(); expect(mapBiometryTypeToAuthType(undefined)).toBeUndefined(); }); it('should return undefined for unrecognized biometryType when device is not secure', () => { + // Given an unrecognized biometry type string and the device has no secure lock screen + // When mapping the biometry type to an auth type + // Then undefined should be returned because neither the biometric type nor a fallback is available expect(mapBiometryTypeToAuthType('SomeNewType', false)).toBeUndefined(); }); }); describe('mapSignErrorCode', () => { it('should return undefined for undefined input', () => { + // Given no error code was provided, which happens when the sign operation succeeds + // When mapping the error code + // Then undefined should be returned because there is no error to classify expect(mapSignErrorCode(undefined)).toBeUndefined(); }); it('should return CANCELED for cancel-related error codes', () => { + // Given various error code strings that indicate the user canceled the biometric prompt + // When mapping the error codes + // Then all cancel variants should resolve to CANCELED so the UI can show a consistent cancellation message regardless of platform-specific error strings expect(mapSignErrorCode('UserCancel')).toBe(VALUES.REASON.EXPO.CANCELED); expect(mapSignErrorCode('CANCELED')).toBe(VALUES.REASON.EXPO.CANCELED); expect(mapSignErrorCode('user_cancel')).toBe(VALUES.REASON.EXPO.CANCELED); }); it('should return NOT_SUPPORTED for "not available" error codes', () => { + // Given error codes indicating biometrics are not available on the device + // When mapping the error codes + // Then they should resolve to NOT_SUPPORTED so the app can guide the user to enable biometrics or use an alternative method expect(mapSignErrorCode('Biometrics not available')).toBe(VALUES.REASON.EXPO.NOT_SUPPORTED); expect(mapSignErrorCode('NOT AVAILABLE')).toBe(VALUES.REASON.EXPO.NOT_SUPPORTED); }); it('should return GENERIC for other error codes', () => { + // Given an error code that does not match any known cancel or availability pattern + // When mapping the error code + // Then it should fall back to GENERIC so the error is still surfaced to the user with a general error message expect(mapSignErrorCode('some_unknown_error')).toBe(VALUES.REASON.EXPO.GENERIC); }); }); describe('mapLibraryError', () => { it('should return CANCELED for Error with cancel message', () => { + // Given an Error object whose message contains "cancel", thrown by the biometric library when the user dismisses the prompt + // When mapping the library error + // Then it should resolve to CANCELED so the app treats thrown errors the same as error-code-based cancellations expect(mapLibraryError(new Error('User canceled the operation'))).toBe(VALUES.REASON.EXPO.CANCELED); }); it('should return CANCELED for string with cancel', () => { + // Given a plain string error containing "cancel", which some library versions throw instead of Error objects + // When mapping the library error + // Then it should resolve to CANCELED regardless of the error type to handle inconsistent library error formats expect(mapLibraryError('Canceled by user')).toBe(VALUES.REASON.EXPO.CANCELED); }); it('should return undefined for non-cancel errors', () => { + // Given an Error object with a message that does not indicate cancellation + // When mapping the library error + // Then undefined should be returned because the error does not match a known cancellation pattern and needs separate handling expect(mapLibraryError(new Error('Network error'))).toBeUndefined(); }); it('should return undefined for non-cancel strings', () => { + // Given a plain string error that does not contain "cancel" + // When mapping the library error + // Then undefined should be returned because only cancellation errors have special handling in this mapper expect(mapLibraryError('timeout')).toBeUndefined(); }); }); From 81fda0cc1440f214738a64d878a4d1662f1486cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Mon, 30 Mar 2026 16:11:17 +0200 Subject: [PATCH 13/41] removed EC256 flag --- .../biometrics/useBiometrics/index.native.ts | 4 +--- src/libs/MultifactorAuthentication/VALUES.ts | 5 ----- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/useBiometrics/index.native.ts b/src/components/MultifactorAuthentication/biometrics/useBiometrics/index.native.ts index dc1e444fab64b..2add9dfe6f494 100644 --- a/src/components/MultifactorAuthentication/biometrics/useBiometrics/index.native.ts +++ b/src/components/MultifactorAuthentication/biometrics/useBiometrics/index.native.ts @@ -1,5 +1,3 @@ -import useNativeBiometrics from '@components/MultifactorAuthentication/biometrics/useNativeBiometrics'; import useNativeBiometricsEC256 from '@components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256'; -import CONST from '@src/CONST'; -export default CONST.MULTIFACTOR_AUTHENTICATION.USE_NATIVE_EC256 ? useNativeBiometricsEC256 : useNativeBiometrics; +export default useNativeBiometricsEC256; diff --git a/src/libs/MultifactorAuthentication/VALUES.ts b/src/libs/MultifactorAuthentication/VALUES.ts index c9076f0f6046c..58b8362923086 100644 --- a/src/libs/MultifactorAuthentication/VALUES.ts +++ b/src/libs/MultifactorAuthentication/VALUES.ts @@ -13,11 +13,6 @@ const MULTIFACTOR_AUTHENTICATION_VALUES = { ...NATIVE_BIOMETRICS_VALUES, ...NATIVE_BIOMETRICS_EC256_VALUES, ...PASSKEY_VALUES, - - /** - * Feature flag to switch native biometrics from ED25519 (noble/JS) to EC256 (react-native-biometrics/native). - */ - USE_NATIVE_EC256: true, } as const; export default MULTIFACTOR_AUTHENTICATION_VALUES; From 34dedf535647eece3ab70504ee2e37bf645f1499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Mon, 30 Mar 2026 16:59:38 +0200 Subject: [PATCH 14/41] renamed ec256 to hsm and documented the used arguments --- .../biometrics/useBiometrics/index.native.ts | 4 +- ...ricsEC256.ts => useNativeBiometricsHSM.ts} | 26 +++-- .../AuthenticationMethodDescription.tsx | 4 +- .../VALUES.ts | 14 +-- .../helpers.ts | 32 +++---- .../types.ts | 8 +- src/libs/MultifactorAuthentication/VALUES.ts | 4 +- .../MultifactorAuthentication/shared/types.ts | 12 +-- ...test.ts => useNativeBiometricsHSM.test.ts} | 94 +++++++++---------- .../helpers.test.ts | 22 ++--- 10 files changed, 114 insertions(+), 106 deletions(-) rename src/components/MultifactorAuthentication/biometrics/{useNativeBiometricsEC256.ts => useNativeBiometricsHSM.ts} (86%) rename src/libs/MultifactorAuthentication/{NativeBiometricsEC256 => NativeBiometricsHSM}/VALUES.ts (84%) rename src/libs/MultifactorAuthentication/{NativeBiometricsEC256 => NativeBiometricsHSM}/helpers.ts (76%) rename src/libs/MultifactorAuthentication/{NativeBiometricsEC256 => NativeBiometricsHSM}/types.ts (70%) rename tests/unit/components/MultifactorAuthentication/{useNativeBiometricsEC256.test.ts => useNativeBiometricsHSM.test.ts} (89%) rename tests/unit/libs/MultifactorAuthentication/{NativeBiometricsEC256 => NativeBiometricsHSM}/helpers.test.ts (94%) diff --git a/src/components/MultifactorAuthentication/biometrics/useBiometrics/index.native.ts b/src/components/MultifactorAuthentication/biometrics/useBiometrics/index.native.ts index 2add9dfe6f494..3d112df0f20b8 100644 --- a/src/components/MultifactorAuthentication/biometrics/useBiometrics/index.native.ts +++ b/src/components/MultifactorAuthentication/biometrics/useBiometrics/index.native.ts @@ -1,3 +1,3 @@ -import useNativeBiometricsEC256 from '@components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256'; +import useNativeBiometricsHSM from '@components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM'; -export default useNativeBiometricsEC256; +export default useNativeBiometricsHSM; diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts similarity index 86% rename from src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts rename to src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts index 9c5947559fbf1..3ab8b23469fd7 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts @@ -11,8 +11,8 @@ import { mapBiometryTypeToAuthType, mapLibraryError, mapSignErrorCode, -} from '@libs/MultifactorAuthentication/NativeBiometricsEC256/helpers'; -import type {NativeBiometricsEC256KeyInfo} from '@libs/MultifactorAuthentication/NativeBiometricsEC256/types'; +} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; +import type {NativeBiometricsHSMKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/types'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; import Base64URL from '@src/utils/Base64URL'; @@ -20,11 +20,11 @@ import type {AuthorizeParams, AuthorizeResult, RegisterResult, UseBiometricsRetu import useServerCredentials from './shared/useServerCredentials'; /** - * Native biometrics hook using EC P-256 keys via react-native-biometrics. + * Native biometrics hook using HSM-backed EC P-256 keys via react-native-biometrics. * All cryptographic operations happen in native code (Secure Enclave / Android Keystore). * Private keys never enter JS memory. */ -function useNativeBiometricsEC256(): UseBiometricsReturn { +function useNativeBiometricsHSM(): UseBiometricsReturn { const {accountID} = useCurrentUserPersonalDetails(); const {translate} = useLocalize(); const {serverKnownCredentialIDs, haveCredentialsEverBeenConfigured} = useServerCredentials(); @@ -58,7 +58,14 @@ function useNativeBiometricsEC256(): UseBiometricsReturn { try { const keyAlias = getKeyAlias(accountID); - // createKeys with failIfExists=false auto-deletes existing key and recreates + /** + * createKeys called with: + * keyAlias - alias associated with the key stored on the device + * keyType: 'ec256' - Elliptic Curve P-256 key + * biometricStrength: undefined - currently ignored when allowDeviceCredentials is set to true + * allowDeviceCredentials: true - allow device credentials fallback when biometrics are unavailable + * failIfExists: false - overwrite any existing key for this alias to support re-registration + */ const {publicKey} = await createKeys(keyAlias, 'ec256', undefined, true, false); const credentialID = base64ToBase64url(publicKey); @@ -72,9 +79,9 @@ function useNativeBiometricsEC256(): UseBiometricsReturn { } const clientDataJSON = JSON.stringify({challenge: registrationChallenge.challenge}); - const keyInfo: NativeBiometricsEC256KeyInfo = { + const keyInfo: NativeBiometricsHSMKeyInfo = { rawId: credentialID, - type: CONST.MULTIFACTOR_AUTHENTICATION.EC256_TYPE, + type: CONST.MULTIFACTOR_AUTHENTICATION.HSM_TYPE, response: { clientDataJSON: Base64URL.encode(clientDataJSON), biometric: { @@ -156,7 +163,7 @@ function useNativeBiometricsEC256(): UseBiometricsReturn { reason: VALUES.REASON.CHALLENGE.CHALLENGE_SIGNED, signedChallenge: { rawId: credentialID, - type: CONST.MULTIFACTOR_AUTHENTICATION.EC256_TYPE, + type: CONST.MULTIFACTOR_AUTHENTICATION.HSM_TYPE, response: { authenticatorData: base64ToBase64url(authenticatorData.toString('base64')), clientDataJSON: Base64URL.encode(clientDataJSON), @@ -181,6 +188,7 @@ function useNativeBiometricsEC256(): UseBiometricsReturn { haveCredentialsEverBeenConfigured, getLocalCredentialID, doesDeviceSupportAuthenticationMethod, + deviceCheckFailureReason: VALUES.REASON.GENERIC.NO_AUTHENTICATION_METHODS_ENROLLED, hasLocalCredentials, areLocalCredentialsKnownToServer, register, @@ -189,4 +197,4 @@ function useNativeBiometricsEC256(): UseBiometricsReturn { }; } -export default useNativeBiometricsEC256; +export default useNativeBiometricsHSM; diff --git a/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx b/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx index 9ed5ccb43d62f..a8e64b2aff12b 100644 --- a/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx +++ b/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx @@ -3,7 +3,7 @@ import {useMultifactorAuthenticationState} from '@components/MultifactorAuthenti import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import NATIVE_BIOMETRICS_EC256_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsEC256/VALUES'; +import NATIVE_BIOMETRICS_HSM_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES'; import type {AuthTypeName} from '@libs/MultifactorAuthentication/shared/types'; import type {TranslationPaths} from '@src/languages/types'; @@ -25,7 +25,7 @@ function AuthenticationMethodDescription() { const {translate} = useLocalize(); const {authenticationMethod} = useMultifactorAuthenticationState(); - const authType = translate(AUTH_TYPE_TRANSLATION_KEY[authenticationMethod?.name ?? NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.UNKNOWN.NAME]); + const authType = translate(AUTH_TYPE_TRANSLATION_KEY[authenticationMethod?.name ?? NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.UNKNOWN.NAME]); return {translate('multifactorAuthentication.biometricsTest.successfullyAuthenticatedUsing', {authType})}; } diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsEC256/VALUES.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts similarity index 84% rename from src/libs/MultifactorAuthentication/NativeBiometricsEC256/VALUES.ts rename to src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts index b49109891da89..9712fdd516b35 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsEC256/VALUES.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts @@ -1,19 +1,19 @@ /** - * Constants specific to native biometrics (EC256 / react-native-biometrics). + * Constants specific to native biometrics (HSM / react-native-biometrics). */ import {AuthType} from '@sbaiahmed1/react-native-biometrics'; import MARQETA_VALUES from '@libs/MultifactorAuthentication/shared/MarqetaValues'; -const NATIVE_BIOMETRICS_EC256_VALUES = { +const NATIVE_BIOMETRICS_HSM_VALUES = { /** - * EC256 key type identifier + * HSM key type identifier */ - EC256_TYPE: 'biometric', + HSM_TYPE: 'biometric-hsm', /** - * Key alias suffix for EC256 keys managed by react-native-biometrics. + * Key alias suffix for HSM keys managed by react-native-biometrics. */ - EC256_KEY_SUFFIX: 'EC256_KEY', + HSM_KEY_SUFFIX: 'HSM_KEY', /** * Authentication types mapped to Marqeta values @@ -64,4 +64,4 @@ const NATIVE_BIOMETRICS_EC256_VALUES = { }, } as const; -export default NATIVE_BIOMETRICS_EC256_VALUES; +export default NATIVE_BIOMETRICS_HSM_VALUES; diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts similarity index 76% rename from src/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.ts rename to src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts index 789ad92cafa04..9f8c732fcc4f5 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts @@ -1,5 +1,5 @@ /** - * Helper utilities for native biometrics EC256 (react-native-biometrics). + * Helper utilities for native biometrics HSM (react-native-biometrics). */ import type {BiometricSensorInfo} from '@sbaiahmed1/react-native-biometrics'; import {isSensorAvailable} from '@sbaiahmed1/react-native-biometrics'; @@ -7,9 +7,9 @@ import type {ValueOf} from 'type-fest'; import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; -import NATIVE_BIOMETRICS_EC256_VALUES from './VALUES'; +import NATIVE_BIOMETRICS_HSM_VALUES from './VALUES'; -type SecureStoreAuthTypeEntry = ValueOf; +type SecureStoreAuthTypeEntry = ValueOf; /** * Converts standard base64 to base64url encoding. @@ -22,7 +22,7 @@ function base64ToBase64url(b64: string): string { * Builds the key alias for a given account. */ function getKeyAlias(accountID: number): string { - return `${accountID}_${CONST.MULTIFACTOR_AUTHENTICATION.EC256_KEY_SUFFIX}`; + return `${accountID}_${CONST.MULTIFACTOR_AUTHENTICATION.HSM_KEY_SUFFIX}`; } /** @@ -47,13 +47,13 @@ function getSensorResult(): BiometricSensorInfo { * Native layer returns: -1=Unknown, 0=None, 1=DeviceCredentials, 2=Biometrics, 3=FaceID, 4=TouchID, 5=OpticID */ const AUTH_TYPE_NUMBER_MAP = new Map([ - [-1, NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.UNKNOWN], - [0, NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.NONE], - [1, NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.CREDENTIALS], - [2, NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.BIOMETRICS], - [3, NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.FACE_ID], - [4, NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.TOUCH_ID], - [5, NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.OPTIC_ID], + [-1, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.UNKNOWN], + [0, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.NONE], + [1, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.CREDENTIALS], + [2, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.BIOMETRICS], + [3, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.FACE_ID], + [4, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.TOUCH_ID], + [5, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.OPTIC_ID], ]); function mapAuthTypeNumber(authType?: number): AuthTypeInfo | undefined { @@ -71,17 +71,17 @@ function mapAuthTypeNumber(authType?: number): AuthTypeInfo | undefined { * Maps biometryType string from isSensorAvailable to AuthTypeInfo (used during registration). */ const BIOMETRY_TYPE_MAP: Record = { - FaceID: NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.FACE_ID, - TouchID: NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.TOUCH_ID, - Biometrics: NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.BIOMETRICS, - OpticID: NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.OPTIC_ID, + FaceID: NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.FACE_ID, + TouchID: NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.TOUCH_ID, + Biometrics: NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.BIOMETRICS, + OpticID: NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.OPTIC_ID, }; function mapBiometryTypeToAuthType(biometryType?: string, isDeviceSecure?: boolean): AuthTypeInfo | undefined { let entry = BIOMETRY_TYPE_MAP[biometryType ?? '']; if (!entry) { if (isDeviceSecure) { - entry = NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.CREDENTIALS; + entry = NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.CREDENTIALS; } else { return undefined; } diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsEC256/types.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/types.ts similarity index 70% rename from src/libs/MultifactorAuthentication/NativeBiometricsEC256/types.ts rename to src/libs/MultifactorAuthentication/NativeBiometricsHSM/types.ts index 3d7d9774cf5a7..8f98e853d8b6d 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsEC256/types.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/types.ts @@ -1,13 +1,13 @@ /** - * Type definitions specific to native biometrics (EC256). + * Type definitions specific to native biometrics (HSM). */ import type CONST from '@src/CONST'; import type {Base64URLString} from '@src/utils/Base64URL'; import type VALUES from './VALUES'; -type NativeBiometricsEC256KeyInfo = { +type NativeBiometricsHSMKeyInfo = { rawId: Base64URLString; - type: typeof VALUES.EC256_TYPE; + type: typeof VALUES.HSM_TYPE; response: { clientDataJSON: Base64URLString; biometric: { @@ -18,4 +18,4 @@ type NativeBiometricsEC256KeyInfo = { }; // eslint-disable-next-line import/prefer-default-export -export type {NativeBiometricsEC256KeyInfo}; +export type {NativeBiometricsHSMKeyInfo}; diff --git a/src/libs/MultifactorAuthentication/VALUES.ts b/src/libs/MultifactorAuthentication/VALUES.ts index 58b8362923086..5a8e72a0b1434 100644 --- a/src/libs/MultifactorAuthentication/VALUES.ts +++ b/src/libs/MultifactorAuthentication/VALUES.ts @@ -4,14 +4,14 @@ * This ensures CONST.MULTIFACTOR_AUTHENTICATION continues working everywhere. */ import NATIVE_BIOMETRICS_VALUES from './NativeBiometrics/VALUES'; -import NATIVE_BIOMETRICS_EC256_VALUES from './NativeBiometricsEC256/VALUES'; +import NATIVE_BIOMETRICS_HSM_VALUES from './NativeBiometricsHSM/VALUES'; import PASSKEY_VALUES from './Passkeys/VALUES'; import SHARED_VALUES from './shared/VALUES'; const MULTIFACTOR_AUTHENTICATION_VALUES = { ...SHARED_VALUES, ...NATIVE_BIOMETRICS_VALUES, - ...NATIVE_BIOMETRICS_EC256_VALUES, + ...NATIVE_BIOMETRICS_HSM_VALUES, ...PASSKEY_VALUES, } as const; diff --git a/src/libs/MultifactorAuthentication/shared/types.ts b/src/libs/MultifactorAuthentication/shared/types.ts index 4748009b93356..953955dbf2284 100644 --- a/src/libs/MultifactorAuthentication/shared/types.ts +++ b/src/libs/MultifactorAuthentication/shared/types.ts @@ -5,8 +5,8 @@ import type {ValueOf} from 'type-fest'; import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioAdditionalParams} from '@components/MultifactorAuthentication/config/types'; import type {NativeBiometricsKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometrics/types'; -import type {NativeBiometricsEC256KeyInfo} from '@libs/MultifactorAuthentication/NativeBiometricsEC256/types'; -import type NATIVE_BIOMETRICS_EC256_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsEC256/VALUES'; +import type {NativeBiometricsHSMKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/types'; +import type NATIVE_BIOMETRICS_HSM_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES'; import type {PasskeyRegistrationKeyInfo} from '@libs/MultifactorAuthentication/Passkeys/types'; import type {PASSKEY_AUTH_TYPE} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; import type {SignedChallenge} from './challengeTypes'; @@ -15,9 +15,9 @@ import type VALUES from './VALUES'; /** * Authentication type name derived from react-native-biometrics values and passkey auth type. */ -type AuthTypeName = ValueOf['NAME'] | (typeof PASSKEY_AUTH_TYPE)['NAME']; +type AuthTypeName = ValueOf['NAME'] | (typeof PASSKEY_AUTH_TYPE)['NAME']; -type MarqetaAuthTypeName = ValueOf['MARQETA_VALUE'] | (typeof PASSKEY_AUTH_TYPE)['MARQETA_VALUE']; +type MarqetaAuthTypeName = ValueOf['MARQETA_VALUE'] | (typeof PASSKEY_AUTH_TYPE)['MARQETA_VALUE']; type AuthTypeInfo = { code?: MultifactorAuthenticationMethodCode; @@ -25,7 +25,7 @@ type AuthTypeInfo = { marqetaValue: MarqetaAuthTypeName; }; -type MultifactorAuthenticationMethodCode = ValueOf['CODE']; +type MultifactorAuthenticationMethodCode = ValueOf['CODE']; /** * Represents the reason for a multifactor authentication response from the backend. @@ -53,7 +53,7 @@ type MultifactorAuthenticationResponseMap = typeof VALUES.API_RESPONSE_MAP; type MultifactorAuthenticationActionParams, R extends keyof AllMultifactorAuthenticationBaseParameters> = T & Pick & {authenticationMethod: MarqetaAuthTypeName}; -type RegistrationKeyInfo = NativeBiometricsKeyInfo | NativeBiometricsEC256KeyInfo | PasskeyRegistrationKeyInfo; +type RegistrationKeyInfo = NativeBiometricsKeyInfo | NativeBiometricsHSMKeyInfo | PasskeyRegistrationKeyInfo; type ChallengeType = ValueOf; diff --git a/tests/unit/components/MultifactorAuthentication/useNativeBiometricsEC256.test.ts b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts similarity index 89% rename from tests/unit/components/MultifactorAuthentication/useNativeBiometricsEC256.test.ts rename to tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts index b64b664b1cba8..9e349da39835b 100644 --- a/tests/unit/components/MultifactorAuthentication/useNativeBiometricsEC256.test.ts +++ b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts @@ -1,7 +1,7 @@ import type {BiometricSensorInfo} from '@sbaiahmed1/react-native-biometrics'; import {act, renderHook} from '@testing-library/react-native'; -import useNativeBiometricsEC256 from '@components/MultifactorAuthentication/biometrics/useNativeBiometricsEC256'; -import * as EC256Helpers from '@libs/MultifactorAuthentication/NativeBiometricsEC256/helpers'; +import useNativeBiometricsHSM from '@components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM'; +import * as HSMHelpers from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; import type {AuthenticationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; @@ -67,11 +67,11 @@ jest.mock('@userActions/MultifactorAuthentication/processing'); const DEFAULT_SENSOR_RESULT: BiometricSensorInfo = {available: true, biometryType: 'FaceID', isDeviceSecure: true}; -describe('useNativeBiometricsEC256 hook', () => { +describe('useNativeBiometricsHSM hook', () => { beforeEach(() => { jest.clearAllMocks(); mockMultifactorAuthenticationPublicKeyIDs = []; - jest.spyOn(EC256Helpers, 'getSensorResult').mockReturnValue(DEFAULT_SENSOR_RESULT); + jest.spyOn(HSMHelpers, 'getSensorResult').mockReturnValue(DEFAULT_SENSOR_RESULT); mockGetAllKeys.mockResolvedValue({keys: []}); }); @@ -81,7 +81,7 @@ describe('useNativeBiometricsEC256 hook', () => { // Given a device with biometrics available and an authenticated user // When the hook is initialized // Then it should expose all required interface methods so consumers can register, authorize, and manage biometric credentials - const {result} = renderHook(() => useNativeBiometricsEC256()); + const {result} = renderHook(() => useNativeBiometricsHSM()); expect(result.current).toHaveProperty('serverKnownCredentialIDs'); expect(result.current).toHaveProperty('doesDeviceSupportAuthenticationMethod'); @@ -96,7 +96,7 @@ describe('useNativeBiometricsEC256 hook', () => { // Given a device with biometrics available // When the hook is initialized // Then it should report BIOMETRICS as its device verification type so the MFA system can distinguish it from other verification methods - const {result} = renderHook(() => useNativeBiometricsEC256()); + const {result} = renderHook(() => useNativeBiometricsHSM()); expect(result.current.deviceVerificationType).toBe(CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS); }); @@ -107,7 +107,7 @@ describe('useNativeBiometricsEC256 hook', () => { // Given a device with a biometric sensor available (e.g., Face ID or Touch ID) // When checking device support for biometric authentication // Then it should return true because the device can perform biometric verification - const {result} = renderHook(() => useNativeBiometricsEC256()); + const {result} = renderHook(() => useNativeBiometricsHSM()); expect(result.current.doesDeviceSupportAuthenticationMethod()).toBe(true); }); @@ -116,9 +116,9 @@ describe('useNativeBiometricsEC256 hook', () => { // Given a device without biometric hardware but with a secure lock screen (PIN/password) // When checking device support for biometric authentication // Then it should return true because device credentials can serve as a fallback verification method - jest.spyOn(EC256Helpers, 'getSensorResult').mockReturnValue({available: false, isDeviceSecure: true}); + jest.spyOn(HSMHelpers, 'getSensorResult').mockReturnValue({available: false, isDeviceSecure: true}); - const {result} = renderHook(() => useNativeBiometricsEC256()); + const {result} = renderHook(() => useNativeBiometricsHSM()); expect(result.current.doesDeviceSupportAuthenticationMethod()).toBe(true); }); @@ -127,9 +127,9 @@ describe('useNativeBiometricsEC256 hook', () => { // Given a device with no biometric sensor and no secure lock screen configured // When checking device support for biometric authentication // Then it should return false because there is no way to verify the user's identity on this device - jest.spyOn(EC256Helpers, 'getSensorResult').mockReturnValue({available: false}); + jest.spyOn(HSMHelpers, 'getSensorResult').mockReturnValue({available: false}); - const {result} = renderHook(() => useNativeBiometricsEC256()); + const {result} = renderHook(() => useNativeBiometricsHSM()); expect(result.current.doesDeviceSupportAuthenticationMethod()).toBe(false); }); @@ -137,25 +137,25 @@ describe('useNativeBiometricsEC256 hook', () => { describe('getLocalCredentialID', () => { it('should return undefined when no local key exists', async () => { - // Given no EC256 keys have been created on the device for this account + // Given no HSM keys have been created on the device for this account // When retrieving the local credential ID // Then undefined should be returned because the user has not yet registered biometrics on this device mockGetAllKeys.mockResolvedValue({keys: []}); - const {result} = renderHook(() => useNativeBiometricsEC256()); + const {result} = renderHook(() => useNativeBiometricsHSM()); const key = await result.current.getLocalCredentialID(); expect(key).toBeUndefined(); }); it('should return base64url-encoded public key when key exists', async () => { - // Given an EC256 key exists on the device for this account with a base64 public key + // Given an HSM key exists on the device for this account with a base64 public key // When retrieving the local credential ID // Then the public key should be returned in base64url format because credential IDs must be URL-safe for server communication - const keyAlias = '12345_EC256_KEY'; + const keyAlias = '12345_HSM_KEY'; mockGetAllKeys.mockResolvedValue({keys: [{alias: keyAlias, publicKey: 'abc+def/ghi='}]}); - const {result} = renderHook(() => useNativeBiometricsEC256()); + const {result} = renderHook(() => useNativeBiometricsHSM()); const key = await result.current.getLocalCredentialID(); expect(key).toBe('abc-def_ghi'); @@ -164,24 +164,24 @@ describe('useNativeBiometricsEC256 hook', () => { describe('areLocalCredentialsKnownToServer', () => { it('should return false when no local credential exists', async () => { - // Given no EC256 keys exist on the device + // Given no HSM keys exist on the device // When checking if local credentials are known to the server // Then it should return false because there is no local key to match against server-known credential IDs - const {result} = renderHook(() => useNativeBiometricsEC256()); + const {result} = renderHook(() => useNativeBiometricsHSM()); const isKnown = await result.current.areLocalCredentialsKnownToServer(); expect(isKnown).toBe(false); }); it('should return true when local credential is known to server', async () => { - // Given an EC256 key exists on the device and its base64url-encoded public key matches a server-known credential ID + // Given an HSM key exists on the device and its base64url-encoded public key matches a server-known credential ID // When checking if local credentials are known to the server // Then it should return true because the device's biometric registration is still valid on the server - const keyAlias = '12345_EC256_KEY'; + const keyAlias = '12345_HSM_KEY'; mockMultifactorAuthenticationPublicKeyIDs = ['abc-def_ghi']; mockGetAllKeys.mockResolvedValue({keys: [{alias: keyAlias, publicKey: 'abc+def/ghi='}]}); - const {result} = renderHook(() => useNativeBiometricsEC256()); + const {result} = renderHook(() => useNativeBiometricsHSM()); const isKnown = await result.current.areLocalCredentialsKnownToServer(); expect(isKnown).toBe(true); @@ -194,7 +194,7 @@ describe('useNativeBiometricsEC256 hook', () => { // When accessing serverKnownCredentialIDs from the hook // Then it should return all credential IDs mockMultifactorAuthenticationPublicKeyIDs = ['key-1', 'key-2']; - const {result} = renderHook(() => useNativeBiometricsEC256()); + const {result} = renderHook(() => useNativeBiometricsHSM()); expect(result.current.serverKnownCredentialIDs).toEqual(['key-1', 'key-2']); }); @@ -204,7 +204,7 @@ describe('useNativeBiometricsEC256 hook', () => { // When accessing serverKnownCredentialIDs from the hook // Then it should return an empty array rather than undefined mockMultifactorAuthenticationPublicKeyIDs = []; - const {result} = renderHook(() => useNativeBiometricsEC256()); + const {result} = renderHook(() => useNativeBiometricsHSM()); expect(result.current.serverKnownCredentialIDs).toEqual([]); }); @@ -216,7 +216,7 @@ describe('useNativeBiometricsEC256 hook', () => { // When checking if credentials have ever been configured // Then it should return false because undefined indicates the key was never initialized in Onyx mockMultifactorAuthenticationPublicKeyIDs = undefined; - const {result} = renderHook(() => useNativeBiometricsEC256()); + const {result} = renderHook(() => useNativeBiometricsHSM()); expect(result.current.haveCredentialsEverBeenConfigured).toBe(false); }); @@ -226,7 +226,7 @@ describe('useNativeBiometricsEC256 hook', () => { // When checking if credentials have ever been configured // Then it should return true because an empty array (vs undefined) indicates the user previously set up biometrics mockMultifactorAuthenticationPublicKeyIDs = []; - const {result} = renderHook(() => useNativeBiometricsEC256()); + const {result} = renderHook(() => useNativeBiometricsHSM()); expect(result.current.haveCredentialsEverBeenConfigured).toBe(true); }); @@ -236,7 +236,7 @@ describe('useNativeBiometricsEC256 hook', () => { // When checking if credentials have ever been configured // Then it should return true because credentials are currently registered mockMultifactorAuthenticationPublicKeyIDs = ['key-1']; - const {result} = renderHook(() => useNativeBiometricsEC256()); + const {result} = renderHook(() => useNativeBiometricsHSM()); expect(result.current.haveCredentialsEverBeenConfigured).toBe(true); }); @@ -258,22 +258,22 @@ describe('useNativeBiometricsEC256 hook', () => { it('should create keys with correct alias', async () => { // Given a valid registration challenge from the server // When registering a new biometric credential - // Then it should create an EC256 key with the account-specific alias so the key is uniquely tied to the current user - const {result} = renderHook(() => useNativeBiometricsEC256()); + // Then it should create an HSM key with the account-specific alias so the key is uniquely tied to the current user + const {result} = renderHook(() => useNativeBiometricsHSM()); const onResult = jest.fn(); await act(async () => { await result.current.register(onResult, mockRegistrationChallenge); }); - expect(mockCreateKeys).toHaveBeenCalledWith('12345_EC256_KEY', 'ec256', undefined, true, false); + expect(mockCreateKeys).toHaveBeenCalledWith('12345_HSM_KEY', 'ec256', undefined, true, false); }); it('should call onResult with success and keyInfo on successful registration', async () => { - // Given a valid registration challenge and the biometric library successfully creates an EC256 key pair + // Given a valid registration challenge and the biometric library successfully creates an HSM key pair // When the registration completes - // Then onResult should receive a success result with the base64url-encoded public key as rawId and EC256 type for server registration - const {result} = renderHook(() => useNativeBiometricsEC256()); + // Then onResult should receive a success result with the base64url-encoded public key as rawId and HSM type for server registration + const {result} = renderHook(() => useNativeBiometricsHSM()); const onResult = jest.fn(); await act(async () => { @@ -286,7 +286,7 @@ describe('useNativeBiometricsEC256 hook', () => { reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.LOCAL_REGISTRATION_COMPLETE, keyInfo: expect.objectContaining({ rawId: 'abc-def_ghi', - type: CONST.MULTIFACTOR_AUTHENTICATION.EC256_TYPE, + type: CONST.MULTIFACTOR_AUTHENTICATION.HSM_TYPE, }), }), ); @@ -298,7 +298,7 @@ describe('useNativeBiometricsEC256 hook', () => { // Then onResult should receive a failure result mockCreateKeys.mockRejectedValue(new Error('Key creation failed')); - const {result} = renderHook(() => useNativeBiometricsEC256()); + const {result} = renderHook(() => useNativeBiometricsHSM()); const onResult = jest.fn(); await act(async () => { @@ -323,17 +323,17 @@ describe('useNativeBiometricsEC256 hook', () => { }; beforeEach(() => { - const keyAlias = '12345_EC256_KEY'; + const keyAlias = '12345_HSM_KEY'; mockGetAllKeys.mockResolvedValue({keys: [{alias: keyAlias, publicKey: 'abc+def/ghi='}]}); mockSha256.mockResolvedValue({hash: Buffer.alloc(32).toString('base64')}); mockSignWithOptions.mockResolvedValue({success: true, signature: 'c2lnbmF0dXJl', authType: 3}); }); it('should sign challenge and return success', async () => { - // Given a valid authentication challenge from the server and a local EC256 key that can sign it + // Given a valid authentication challenge from the server and a local HSM key that can sign it // When the user successfully authenticates via biometrics - // Then onResult should receive a success with the signed challenge and EC256 type so the server can verify the signature - const {result} = renderHook(() => useNativeBiometricsEC256()); + // Then onResult should receive a success with the signed challenge and HSM type so the server can verify the signature + const {result} = renderHook(() => useNativeBiometricsHSM()); const onResult = jest.fn(); await act(async () => { @@ -345,17 +345,17 @@ describe('useNativeBiometricsEC256 hook', () => { success: true, reason: VALUES.REASON.CHALLENGE.CHALLENGE_SIGNED, signedChallenge: expect.objectContaining({ - type: CONST.MULTIFACTOR_AUTHENTICATION.EC256_TYPE, + type: CONST.MULTIFACTOR_AUTHENTICATION.HSM_TYPE, }), }), ); }); it('should call signWithOptions with biometric prompt', async () => { - // Given a valid authentication challenge and a local EC256 key + // Given a valid authentication challenge and a local HSM key // When initiating the authorize flow // Then signWithOptions should be called with the correct key alias and a localized prompt title - const {result} = renderHook(() => useNativeBiometricsEC256()); + const {result} = renderHook(() => useNativeBiometricsHSM()); const onResult = jest.fn(); await act(async () => { @@ -364,7 +364,7 @@ describe('useNativeBiometricsEC256 hook', () => { expect(mockSignWithOptions).toHaveBeenCalledWith( expect.objectContaining({ - keyAlias: '12345_EC256_KEY', + keyAlias: '12345_HSM_KEY', promptTitle: 'translated_multifactorAuthentication.letsVerifyItsYou', returnAuthType: true, }), @@ -377,7 +377,7 @@ describe('useNativeBiometricsEC256 hook', () => { // Then onResult should receive a failure so the app can prompt the user to retry or use an alternative method mockSignWithOptions.mockResolvedValue({success: false, errorCode: 'canceled'}); - const {result} = renderHook(() => useNativeBiometricsEC256()); + const {result} = renderHook(() => useNativeBiometricsHSM()); const onResult = jest.fn(); await act(async () => { @@ -397,7 +397,7 @@ describe('useNativeBiometricsEC256 hook', () => { // Then onResult should receive a failure with CANCELED reason mockSignWithOptions.mockRejectedValue(new Error('Biometric canceled')); - const {result} = renderHook(() => useNativeBiometricsEC256()); + const {result} = renderHook(() => useNativeBiometricsHSM()); const onResult = jest.fn(); await act(async () => { @@ -415,16 +415,16 @@ describe('useNativeBiometricsEC256 hook', () => { describe('deleteLocalKeysForAccount', () => { it('should delete keys with correct alias', async () => { - // Given an authenticated user with a locally stored EC256 key + // Given an authenticated user with a locally stored HSM key // When deleting local biometric keys for the account // Then deleteKeys should be called with the account-specific alias to remove only this user's key without affecting other accounts on the device - const {result} = renderHook(() => useNativeBiometricsEC256()); + const {result} = renderHook(() => useNativeBiometricsHSM()); await act(async () => { await result.current.deleteLocalKeysForAccount(); }); - expect(mockDeleteKeys).toHaveBeenCalledWith('12345_EC256_KEY'); + expect(mockDeleteKeys).toHaveBeenCalledWith('12345_HSM_KEY'); }); }); }); diff --git a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts similarity index 94% rename from tests/unit/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.test.ts rename to tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts index 54187bc1ec550..8cad8e395f8f0 100644 --- a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsEC256/helpers.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts @@ -1,12 +1,12 @@ -import {base64ToBase64url, getKeyAlias, mapAuthTypeNumber, mapBiometryTypeToAuthType, mapLibraryError, mapSignErrorCode} from '@libs/MultifactorAuthentication/NativeBiometricsEC256/helpers'; -import NATIVE_BIOMETRICS_EC256_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsEC256/VALUES'; +import {base64ToBase64url, getKeyAlias, mapAuthTypeNumber, mapBiometryTypeToAuthType, mapLibraryError, mapSignErrorCode} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; +import NATIVE_BIOMETRICS_HSM_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; jest.mock('@sbaiahmed1/react-native-biometrics', () => ({ isSensorAvailable: jest.fn().mockResolvedValue({available: true, biometryType: 'FaceID', isDeviceSecure: true}), })); -describe('NativeBiometricsEC256 helpers', () => { +describe('NativeBiometricsHSM helpers', () => { describe('base64ToBase64url', () => { it('should replace + with -', () => { // Given a base64 string containing '+' characters, which are not URL-safe @@ -46,19 +46,19 @@ describe('NativeBiometricsEC256 helpers', () => { }); describe('getKeyAlias', () => { - it('should build alias from accountID and EC256_KEY_SUFFIX', () => { + it('should build alias from accountID and HSM_KEY_SUFFIX', () => { // Given a valid account ID // When generating a key alias for biometric key storage - // Then the alias should combine the account ID with the EC256 suffix to uniquely identify the key per account - expect(getKeyAlias(12345)).toBe('12345_EC256_KEY'); + // Then the alias should combine the account ID with the HSM suffix to uniquely identify the key per account + expect(getKeyAlias(12345)).toBe('12345_HSM_KEY'); }); it('should handle different account IDs', () => { // Given various account IDs including edge cases like 0 // When generating key aliases // Then each alias should be unique per account to prevent key collisions across different users on the same device - expect(getKeyAlias(0)).toBe('0_EC256_KEY'); - expect(getKeyAlias(999999)).toBe('999999_EC256_KEY'); + expect(getKeyAlias(0)).toBe('0_HSM_KEY'); + expect(getKeyAlias(999999)).toBe('999999_HSM_KEY'); }); }); @@ -83,9 +83,9 @@ describe('NativeBiometricsEC256 helpers', () => { // Then it should resolve to the Unknown auth type, because the method could not be determined, but the authentication was successful const result = mapAuthTypeNumber(-1); expect(result).toEqual({ - code: NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.UNKNOWN.CODE, - name: NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.UNKNOWN.NAME, - marqetaValue: NATIVE_BIOMETRICS_EC256_VALUES.AUTH_TYPE.UNKNOWN.MARQETA_VALUE, + code: NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.UNKNOWN.CODE, + name: NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.UNKNOWN.NAME, + marqetaValue: NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.UNKNOWN.MARQETA_VALUE, }); }); From df6edec1c9b24046420b70bac8710243f778c2c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Mon, 30 Mar 2026 20:00:02 +0200 Subject: [PATCH 15/41] moved base64ToBase64url and its tests; modified authType in register; fixed one naming problem; removed unnecessary mapping function --- .../biometrics/useNativeBiometricsHSM.ts | 44 ++++----- .../NativeBiometricsHSM/VALUES.ts | 2 +- .../NativeBiometricsHSM/helpers.ts | 31 +----- .../NativeBiometricsHSM/types.ts | 2 +- .../shared/VALUES.ts | 1 + src/utils/Base64URL.ts | 6 ++ tests/unit/Base64URL.test.ts | 38 ++++++++ .../useNativeBiometricsHSM.test.ts | 4 +- .../NativeBiometricsHSM/helpers.test.ts | 97 +------------------ 9 files changed, 68 insertions(+), 157 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts index 3ab8b23469fd7..92f74a999ac8a 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts @@ -3,15 +3,7 @@ import {Buffer} from 'buffer'; import {useCallback} from 'react'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; -import { - base64ToBase64url, - getKeyAlias, - getSensorResult, - mapAuthTypeNumber, - mapBiometryTypeToAuthType, - mapLibraryError, - mapSignErrorCode, -} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; +import {getKeyAlias, getSensorResult, mapAuthTypeNumber, mapLibraryError, mapSignErrorCode} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; import type {NativeBiometricsHSMKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/types'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; @@ -41,7 +33,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { if (!entry) { return undefined; } - return base64ToBase64url(entry.publicKey); + return Base64URL.base64ToBase64url(entry.publicKey); }, [accountID]); const areLocalCredentialsKnownToServer = useCallback(async () => { @@ -68,20 +60,16 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { */ const {publicKey} = await createKeys(keyAlias, 'ec256', undefined, true, false); - const credentialID = base64ToBase64url(publicKey); + const credentialID = Base64URL.base64ToBase64url(publicKey); - // Map biometryType from module-level cache to auth type - const sensorResult = getSensorResult(); - const authType = mapBiometryTypeToAuthType(sensorResult.biometryType, sensorResult.isDeviceSecure); - if (!authType) { - onResult({success: false, reason: VALUES.REASON.GENERIC.BAD_REQUEST}); - return; - } + // TODO: Remove once the backend no longer requires a Marqeta auth method at registration. + // No actual authentication happens during key creation, so this value is a placeholder. + const authType = VALUES.AUTH_TYPE.CREDENTIALS; const clientDataJSON = JSON.stringify({challenge: registrationChallenge.challenge}); const keyInfo: NativeBiometricsHSMKeyInfo = { rawId: credentialID, - type: CONST.MULTIFACTOR_AUTHENTICATION.HSM_TYPE, + type: CONST.MULTIFACTOR_AUTHENTICATION.BIOMETRIC_HSM_TYPE, response: { clientDataJSON: Base64URL.encode(clientDataJSON), biometric: { @@ -95,7 +83,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { success: true, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.LOCAL_REGISTRATION_COMPLETE, keyInfo, - authenticationMethod: authType, + authenticationMethod: {code: authType.CODE, name: authType.NAME, marqetaValue: authType.MARQETA_VALUE}, }); } catch (e) { onResult({ @@ -111,7 +99,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { try { const keyAlias = getKeyAlias(accountID); const credentialID = await getLocalCredentialID(); - const allowedIDs = challenge.allowCredentials?.map((c: {id: string; type: string}) => c.id) ?? []; + const allowedIDs = challenge.allowCredentials?.map((credential: {id: string; type: string}) => credential.id) ?? []; if (!credentialID || !allowedIDs.includes(credentialID)) { onResult({success: false, reason: VALUES.REASON.KEYSTORE.REGISTRATION_REQUIRED}); @@ -122,8 +110,10 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { const {hash: rpIdHashB64} = await sha256(challenge.rpId); const rpIdHash = Buffer.from(rpIdHashB64, 'base64'); - const flags = Buffer.from([0x05]); // UP (0x01) | UV (0x04) - const signCount = Buffer.alloc(4); // 4 zero bytes, big-endian + // UP (0x01) | UV (0x04) + const flags = Buffer.from([0x05]); + // 4 zero bytes, big-endian + const signCount = Buffer.alloc(4); const authenticatorData = Buffer.concat([rpIdHash, flags, signCount]); @@ -163,11 +153,11 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { reason: VALUES.REASON.CHALLENGE.CHALLENGE_SIGNED, signedChallenge: { rawId: credentialID, - type: CONST.MULTIFACTOR_AUTHENTICATION.HSM_TYPE, + type: CONST.MULTIFACTOR_AUTHENTICATION.BIOMETRIC_HSM_TYPE, response: { - authenticatorData: base64ToBase64url(authenticatorData.toString('base64')), + authenticatorData: Base64URL.base64ToBase64url(authenticatorData.toString('base64')), clientDataJSON: Base64URL.encode(clientDataJSON), - signature: base64ToBase64url(signResult.signature), + signature: Base64URL.base64ToBase64url(signResult.signature), }, }, authenticationMethod: authType, @@ -183,7 +173,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { const hasLocalCredentials = async () => !!(await getLocalCredentialID()); return { - deviceVerificationType: CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS, + deviceVerificationType: CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRIC_HSM, serverKnownCredentialIDs, haveCredentialsEverBeenConfigured, getLocalCredentialID, diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts index 9712fdd516b35..fddd466386eff 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts @@ -8,7 +8,7 @@ const NATIVE_BIOMETRICS_HSM_VALUES = { /** * HSM key type identifier */ - HSM_TYPE: 'biometric-hsm', + BIOMETRIC_HSM_TYPE: 'biometric-hsm', /** * Key alias suffix for HSM keys managed by react-native-biometrics. diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts index 9f8c732fcc4f5..ac7e8bcc8277e 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts @@ -11,13 +11,6 @@ import NATIVE_BIOMETRICS_HSM_VALUES from './VALUES'; type SecureStoreAuthTypeEntry = ValueOf; -/** - * Converts standard base64 to base64url encoding. - */ -function base64ToBase64url(b64: string): string { - return b64.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); -} - /** * Builds the key alias for a given account. */ @@ -67,28 +60,6 @@ function mapAuthTypeNumber(authType?: number): AuthTypeInfo | undefined { return {code: entry.CODE, name: entry.NAME, marqetaValue: entry.MARQETA_VALUE}; } -/** - * Maps biometryType string from isSensorAvailable to AuthTypeInfo (used during registration). - */ -const BIOMETRY_TYPE_MAP: Record = { - FaceID: NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.FACE_ID, - TouchID: NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.TOUCH_ID, - Biometrics: NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.BIOMETRICS, - OpticID: NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.OPTIC_ID, -}; - -function mapBiometryTypeToAuthType(biometryType?: string, isDeviceSecure?: boolean): AuthTypeInfo | undefined { - let entry = BIOMETRY_TYPE_MAP[biometryType ?? '']; - if (!entry) { - if (isDeviceSecure) { - entry = NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.CREDENTIALS; - } else { - return undefined; - } - } - return {code: entry.CODE, name: entry.NAME, marqetaValue: entry.MARQETA_VALUE}; -} - /** * Maps library errorCode strings to existing REASON values. */ @@ -116,4 +87,4 @@ function mapLibraryError(e: unknown): MultifactorAuthenticationReason | undefine return undefined; } -export {base64ToBase64url, getKeyAlias, getSensorResult, mapAuthTypeNumber, mapBiometryTypeToAuthType, mapSignErrorCode, mapLibraryError}; +export {getKeyAlias, getSensorResult, mapAuthTypeNumber, mapSignErrorCode, mapLibraryError}; diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/types.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/types.ts index 8f98e853d8b6d..154f213a1d648 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/types.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/types.ts @@ -7,7 +7,7 @@ import type VALUES from './VALUES'; type NativeBiometricsHSMKeyInfo = { rawId: Base64URLString; - type: typeof VALUES.HSM_TYPE; + type: typeof VALUES.BIOMETRIC_HSM_TYPE; response: { clientDataJSON: Base64URLString; biometric: { diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index 2853493a4cf0e..6939cde0f1957 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -279,6 +279,7 @@ const SHARED_VALUES = { * Authentication type identifiers. */ TYPE: { + BIOMETRIC_HSM: 'BIOMETRIC_HSM', BIOMETRICS: 'BIOMETRICS', PASSKEYS: 'PASSKEYS', }, diff --git a/src/utils/Base64URL.ts b/src/utils/Base64URL.ts index e68f03db54e15..c586549f25ff2 100644 --- a/src/utils/Base64URL.ts +++ b/src/utils/Base64URL.ts @@ -39,6 +39,12 @@ const Base64URL = { // Convert the base64 string back to bytes return Buffer.from(base64, 'base64'); }, + /** + * Converts standard base64 to base64url encoding. + */ + base64ToBase64url(b64: string): string { + return b64.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); + }, }; export default Base64URL; diff --git a/tests/unit/Base64URL.test.ts b/tests/unit/Base64URL.test.ts index 658fa9fa8c29e..10e90e735d9e4 100644 --- a/tests/unit/Base64URL.test.ts +++ b/tests/unit/Base64URL.test.ts @@ -69,6 +69,44 @@ describe('Base64URL', () => { }); }); + describe('base64ToBase64url', () => { + it('should replace + with -', () => { + // Given a base64 string containing '+' characters, which are not URL-safe + // When converting to base64url format + // Then '+' should be replaced with '-' because base64url encoding requires URL-safe characters for use in credential IDs + expect(Base64URL.base64ToBase64url('abc+def')).toBe('abc-def'); + }); + + it('should replace / with _', () => { + // Given a base64 string containing '/' characters, which are not URL-safe + // When converting to base64url format + // Then '/' should be replaced with '_' because base64url encoding requires URL-safe characters for use in credential IDs + expect(Base64URL.base64ToBase64url('abc/def')).toBe('abc_def'); + }); + + it('should strip trailing = padding', () => { + // Given a base64 string with trailing '=' padding characters + // When converting to base64url format + // Then padding should be stripped because base64url omits padding per RFC 4648 §5 + expect(Base64URL.base64ToBase64url('abc==')).toBe('abc'); + expect(Base64URL.base64ToBase64url('abcd=')).toBe('abcd'); + }); + + it('should handle all replacements together', () => { + // Given a base64 string with '+', '/', and '=' characters combined + // When converting to base64url format + // Then all unsafe characters should be replaced in a single pass to produce a valid base64url credential ID + expect(Base64URL.base64ToBase64url('abc+def/ghi==')).toBe('abc-def_ghi'); + }); + + it('should leave already-safe strings unchanged', () => { + // Given a base64 string that already contains only URL-safe characters + // When converting to base64url format + // Then the string should remain unchanged because no substitution is needed + expect(Base64URL.base64ToBase64url('abcdef')).toBe('abcdef'); + }); + }); + describe('decode', () => { it('should decode a Base64URL string back to Buffer', () => { const encoded = Base64URL.encode('hello'); diff --git a/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts index 9e349da39835b..4fd1a1492bc3d 100644 --- a/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts +++ b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts @@ -286,7 +286,7 @@ describe('useNativeBiometricsHSM hook', () => { reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.LOCAL_REGISTRATION_COMPLETE, keyInfo: expect.objectContaining({ rawId: 'abc-def_ghi', - type: CONST.MULTIFACTOR_AUTHENTICATION.HSM_TYPE, + type: CONST.MULTIFACTOR_AUTHENTICATION.BIOMETRIC_HSM_TYPE, }), }), ); @@ -345,7 +345,7 @@ describe('useNativeBiometricsHSM hook', () => { success: true, reason: VALUES.REASON.CHALLENGE.CHALLENGE_SIGNED, signedChallenge: expect.objectContaining({ - type: CONST.MULTIFACTOR_AUTHENTICATION.HSM_TYPE, + type: CONST.MULTIFACTOR_AUTHENTICATION.BIOMETRIC_HSM_TYPE, }), }), ); diff --git a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts index 8cad8e395f8f0..d7d472898b622 100644 --- a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts @@ -1,4 +1,4 @@ -import {base64ToBase64url, getKeyAlias, mapAuthTypeNumber, mapBiometryTypeToAuthType, mapLibraryError, mapSignErrorCode} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; +import {getKeyAlias, mapAuthTypeNumber, mapLibraryError, mapSignErrorCode} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; import NATIVE_BIOMETRICS_HSM_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; @@ -7,44 +7,6 @@ jest.mock('@sbaiahmed1/react-native-biometrics', () => ({ })); describe('NativeBiometricsHSM helpers', () => { - describe('base64ToBase64url', () => { - it('should replace + with -', () => { - // Given a base64 string containing '+' characters, which are not URL-safe - // When converting to base64url format - // Then '+' should be replaced with '-' because base64url encoding requires URL-safe characters for use in credential IDs - expect(base64ToBase64url('abc+def')).toBe('abc-def'); - }); - - it('should replace / with _', () => { - // Given a base64 string containing '/' characters, which are not URL-safe - // When converting to base64url format - // Then '/' should be replaced with '_' because base64url encoding requires URL-safe characters for use in credential IDs - expect(base64ToBase64url('abc/def')).toBe('abc_def'); - }); - - it('should strip trailing = padding', () => { - // Given a base64 string with trailing '=' padding characters - // When converting to base64url format - // Then padding should be stripped because base64url omits padding per RFC 4648 §5 - expect(base64ToBase64url('abc==')).toBe('abc'); - expect(base64ToBase64url('abcd=')).toBe('abcd'); - }); - - it('should handle all replacements together', () => { - // Given a base64 string with '+', '/', and '=' characters combined - // When converting to base64url format - // Then all unsafe characters should be replaced in a single pass to produce a valid base64url credential ID - expect(base64ToBase64url('abc+def/ghi==')).toBe('abc-def_ghi'); - }); - - it('should leave already-safe strings unchanged', () => { - // Given a base64 string that already contains only URL-safe characters - // When converting to base64url format - // Then the string should remain unchanged because no substitution is needed - expect(base64ToBase64url('abcdef')).toBe('abcdef'); - }); - }); - describe('getKeyAlias', () => { it('should build alias from accountID and HSM_KEY_SUFFIX', () => { // Given a valid account ID @@ -138,63 +100,6 @@ describe('NativeBiometricsHSM helpers', () => { }); }); - describe('mapBiometryTypeToAuthType', () => { - it('should map FaceID string to Face ID auth type', () => { - // Given the biometric sensor reports "FaceID" as the available biometry type (iOS devices with Face ID) - // When mapping the biometry type string to an auth type - // Then it should resolve to "Face ID" for accurate biometric method reporting - const result = mapBiometryTypeToAuthType('FaceID'); - expect(result?.name).toBe('Face ID'); - }); - - it('should map TouchID string to Touch ID auth type', () => { - // Given the biometric sensor reports "TouchID" as the available biometry type (older iOS devices) - // When mapping the biometry type string to an auth type - // Then it should resolve to "Touch ID" for accurate biometric method reporting - const result = mapBiometryTypeToAuthType('TouchID'); - expect(result?.name).toBe('Touch ID'); - }); - - it('should map Biometrics string to Biometrics auth type', () => { - // Given the biometric sensor reports "Biometrics" as the available type (Android devices) - // When mapping the biometry type string to an auth type - // Then it should resolve to "Biometrics" since Android does not distinguish specific biometric hardware - const result = mapBiometryTypeToAuthType('Biometrics'); - expect(result?.name).toBe('Biometrics'); - }); - - it('should map OpticID string to Optic ID auth type', () => { - // Given the biometric sensor reports "OpticID" as the available biometry type (Apple Vision Pro) - // When mapping the biometry type string to an auth type - // Then it should resolve to "Optic ID" for accurate biometric method reporting - const result = mapBiometryTypeToAuthType('OpticID'); - expect(result?.name).toBe('Optic ID'); - }); - - it('should fall back to Credentials when biometryType is unknown but device is secure', () => { - // Given an unknown biometry type but the device has a secure lock screen - // When mapping the biometry type to an auth type - // Then it should fall back to "Credentials" because the device can still verify the user via PIN/password - const result = mapBiometryTypeToAuthType(undefined, true); - expect(result?.name).toBe('Credentials'); - }); - - it('should return undefined when biometryType is unknown and device is not secure', () => { - // Given an unknown biometry type and the device has no secure lock screen - // When mapping the biometry type to an auth type - // Then undefined should be returned because no verification method is available on this device - expect(mapBiometryTypeToAuthType(undefined, false)).toBeUndefined(); - expect(mapBiometryTypeToAuthType(undefined)).toBeUndefined(); - }); - - it('should return undefined for unrecognized biometryType when device is not secure', () => { - // Given an unrecognized biometry type string and the device has no secure lock screen - // When mapping the biometry type to an auth type - // Then undefined should be returned because neither the biometric type nor a fallback is available - expect(mapBiometryTypeToAuthType('SomeNewType', false)).toBeUndefined(); - }); - }); - describe('mapSignErrorCode', () => { it('should return undefined for undefined input', () => { // Given no error code was provided, which happens when the sign operation succeeds From ba36e1a10bd55b1d948c8f4a054685aae692b01c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Tue, 31 Mar 2026 13:48:18 +0200 Subject: [PATCH 16/41] rename prompttitle variable name, hardcode AuthType constants, remove authResult from all registration flows --- .../MultifactorAuthentication/Context/Main.tsx | 2 -- .../biometrics/shared/types.ts | 1 - .../biometrics/useNativeBiometrics.ts | 1 - .../biometrics/useNativeBiometricsHSM.ts | 5 ----- .../biometrics/usePasskeys.ts | 4 ---- .../config/scenarios/names.ts | 2 ++ .../MultifactorAuthentication/config/types.ts | 13 ++++++++----- .../NativeBiometricsHSM/VALUES.ts | 16 ++++++++-------- .../MultifactorAuthentication/shared/VALUES.ts | 1 + .../actions/MultifactorAuthentication/index.ts | 4 ++-- .../MultifactorAuthentication/processing.ts | 4 +--- .../MultifactorAuthentication/processing.test.ts | 10 ++-------- 12 files changed, 24 insertions(+), 39 deletions(-) diff --git a/src/components/MultifactorAuthentication/Context/Main.tsx b/src/components/MultifactorAuthentication/Context/Main.tsx index 5bc771de99915..383b753d12233 100644 --- a/src/components/MultifactorAuthentication/Context/Main.tsx +++ b/src/components/MultifactorAuthentication/Context/Main.tsx @@ -283,7 +283,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent { success: result.success, reason: result.reason, - authMethod: result.success ? result.authenticationMethod.code : undefined, }, result.success ? 'info' : 'error', ); @@ -300,7 +299,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent const registrationResponse = await processRegistration({ keyInfo: result.keyInfo, - authenticationMethod: result.authenticationMethod.marqetaValue, }); addMFABreadcrumb( diff --git a/src/components/MultifactorAuthentication/biometrics/shared/types.ts b/src/components/MultifactorAuthentication/biometrics/shared/types.ts index e6fec8d5b2174..a95b0425dd1b5 100644 --- a/src/components/MultifactorAuthentication/biometrics/shared/types.ts +++ b/src/components/MultifactorAuthentication/biometrics/shared/types.ts @@ -5,7 +5,6 @@ import type CONST from '@src/CONST'; type BaseRegisterResult = { keyInfo: RegistrationKeyInfo; - authenticationMethod: AuthTypeInfo; }; type RegisterResult = diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts index 82efbe0778ca2..e55cb70abc991 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts @@ -121,7 +121,6 @@ function useNativeBiometrics(): UseBiometricsReturn { success: true, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.LOCAL_REGISTRATION_COMPLETE, keyInfo, - authenticationMethod: authType, }); }; diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts index 92f74a999ac8a..be12a2cdcf02e 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts @@ -62,10 +62,6 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { const credentialID = Base64URL.base64ToBase64url(publicKey); - // TODO: Remove once the backend no longer requires a Marqeta auth method at registration. - // No actual authentication happens during key creation, so this value is a placeholder. - const authType = VALUES.AUTH_TYPE.CREDENTIALS; - const clientDataJSON = JSON.stringify({challenge: registrationChallenge.challenge}); const keyInfo: NativeBiometricsHSMKeyInfo = { rawId: credentialID, @@ -83,7 +79,6 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { success: true, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.LOCAL_REGISTRATION_COMPLETE, keyInfo, - authenticationMethod: {code: authType.CODE, name: authType.NAME, marqetaValue: authType.MARQETA_VALUE}, }); } catch (e) { onResult({ diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index 9781c03b246be..22f5858024c85 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -105,10 +105,6 @@ function usePasskeys(): UseBiometricsReturn { attestationObject, }, }, - authenticationMethod: { - name: PASSKEY_AUTH_TYPE.NAME, - marqetaValue: PASSKEY_AUTH_TYPE.MARQETA_VALUE, - }, }); }; diff --git a/src/components/MultifactorAuthentication/config/scenarios/names.ts b/src/components/MultifactorAuthentication/config/scenarios/names.ts index 4d5cb515cbfab..707169b993872 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/names.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/names.ts @@ -16,8 +16,10 @@ const SCENARIO_NAMES = { /** * Prompt identifiers for multifactor authentication scenarios. + * TODO: update the BIOMETRIC_HSM type */ const PROMPT_NAMES = { + BIOMETRIC_HSM: 'biometrics', BIOMETRICS: 'biometrics', PASSKEYS: 'passkeys', } as const; diff --git a/src/components/MultifactorAuthentication/config/types.ts b/src/components/MultifactorAuthentication/config/types.ts index bb3b9267ffaeb..0fb74ea4505b4 100644 --- a/src/components/MultifactorAuthentication/config/types.ts +++ b/src/components/MultifactorAuthentication/config/types.ts @@ -171,11 +171,14 @@ type MultifactorAuthenticationPromptType = keyof typeof MULTIFACTOR_AUTHENTICATI /** * Parameters required for biometrics registration scenario. */ -type RegisterBiometricsParams = MultifactorAuthenticationActionParams< - { - keyInfo: RegistrationKeyInfo; - }, - 'validateCode' +type RegisterBiometricsParams = Omit< + MultifactorAuthenticationActionParams< + { + keyInfo: RegistrationKeyInfo; + }, + 'validateCode' + >, + 'authenticationMethod' >; /** diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts index fddd466386eff..da9238fafa3a2 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts @@ -1,7 +1,7 @@ /** * Constants specific to native biometrics (HSM / react-native-biometrics). */ -import {AuthType} from '@sbaiahmed1/react-native-biometrics'; +// import {AuthType} from '@sbaiahmed1/react-native-biometrics/types'; import MARQETA_VALUES from '@libs/MultifactorAuthentication/shared/MarqetaValues'; const NATIVE_BIOMETRICS_HSM_VALUES = { @@ -20,7 +20,7 @@ const NATIVE_BIOMETRICS_HSM_VALUES = { */ AUTH_TYPE: { /** - * AuthType.Unknown will be released in the next version of the @sbaiahmed1/react-native-biometrics + * TODO: replace codes with the exported AuthType enum values once the new export '@sbaiahmed1/react-native-biometrics/types' is added */ UNKNOWN: { CODE: -1, @@ -28,27 +28,27 @@ const NATIVE_BIOMETRICS_HSM_VALUES = { MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.KNOWLEDGE_BASED, }, NONE: { - CODE: AuthType.None, + CODE: 0, NAME: 'None', MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.NONE, }, CREDENTIALS: { - CODE: AuthType.DeviceCredentials, + CODE: 1, NAME: 'Credentials', MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.KNOWLEDGE_BASED, }, BIOMETRICS: { - CODE: AuthType.Biometrics, + CODE: 2, NAME: 'Biometrics', MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FINGERPRINT, }, FACE_ID: { - CODE: AuthType.FaceID, + CODE: 3, NAME: 'Face ID', MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FACE, }, TOUCH_ID: { - CODE: AuthType.TouchID, + CODE: 4, NAME: 'Touch ID', MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FINGERPRINT, }, @@ -57,7 +57,7 @@ const NATIVE_BIOMETRICS_HSM_VALUES = { * It is declared here for completeness but is not currently supported. */ OPTIC_ID: { - CODE: AuthType.OpticID, + CODE: 5, NAME: 'Optic ID', MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FACE, }, diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index 6939cde0f1957..d5cdc56ae3d61 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -271,6 +271,7 @@ const SHARED_VALUES = { * Maps authentication type to the corresponding prompt type. */ PROMPT_TYPE_MAP: { + BIOMETRIC_HSM: PROMPT_NAMES.BIOMETRIC_HSM, BIOMETRICS: PROMPT_NAMES.BIOMETRICS, PASSKEYS: PROMPT_NAMES.PASSKEYS, }, diff --git a/src/libs/actions/MultifactorAuthentication/index.ts b/src/libs/actions/MultifactorAuthentication/index.ts index 99f997fe01cc7..9267b9b1bdb8d 100644 --- a/src/libs/actions/MultifactorAuthentication/index.ts +++ b/src/libs/actions/MultifactorAuthentication/index.ts @@ -71,9 +71,9 @@ function cleanUpLocallyProcessed3DSTransactionReviews(entriesToDelete: string[]) * Please consult before using this pattern. */ -async function registerAuthenticationKey({keyInfo, authenticationMethod}: MultifactorAuthenticationScenarioParameters['REGISTER-BIOMETRICS']) { +async function registerAuthenticationKey({keyInfo}: MultifactorAuthenticationScenarioParameters['REGISTER-BIOMETRICS']) { try { - const response = await makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.REGISTER_AUTHENTICATION_KEY, {keyInfo: JSON.stringify(keyInfo), authenticationMethod}); + const response = await makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.REGISTER_AUTHENTICATION_KEY, {keyInfo: JSON.stringify(keyInfo)}); const {jsonCode, message} = response ?? {}; return parseHttpRequest(jsonCode, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.REGISTER_AUTHENTICATION_KEY, message); diff --git a/src/libs/actions/MultifactorAuthentication/processing.ts b/src/libs/actions/MultifactorAuthentication/processing.ts index 316b01cd51405..3078b6a8046c7 100644 --- a/src/libs/actions/MultifactorAuthentication/processing.ts +++ b/src/libs/actions/MultifactorAuthentication/processing.ts @@ -1,5 +1,5 @@ import type {MultifactorAuthenticationScenarioConfig} from '@components/MultifactorAuthentication/config/types'; -import type {MarqetaAuthTypeName, MultifactorAuthenticationReason, RegistrationKeyInfo} from '@libs/MultifactorAuthentication/shared/types'; +import type {MultifactorAuthenticationReason, RegistrationKeyInfo} from '@libs/MultifactorAuthentication/shared/types'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; import {registerAuthenticationKey} from './index'; @@ -26,7 +26,6 @@ function isHttpSuccess(httpStatusCode: number | undefined): boolean { type RegistrationParams = { keyInfo: RegistrationKeyInfo; - authenticationMethod: MarqetaAuthTypeName; }; /** @@ -36,7 +35,6 @@ type RegistrationParams = { async function processRegistration(params: RegistrationParams): Promise { const {httpStatusCode, reason, message} = await registerAuthenticationKey({ keyInfo: params.keyInfo, - authenticationMethod: params.authenticationMethod, }); return { diff --git a/tests/unit/components/MultifactorAuthentication/processing.test.ts b/tests/unit/components/MultifactorAuthentication/processing.test.ts index 661bb620250ac..6e6f24e4a8ef1 100644 --- a/tests/unit/components/MultifactorAuthentication/processing.test.ts +++ b/tests/unit/components/MultifactorAuthentication/processing.test.ts @@ -19,7 +19,7 @@ describe('MultifactorAuthentication processing', () => { // Given a keyInfo object with biometric type (NativeBiometrics) // When processRegistration is called - // Then it should forward keyInfo and authenticationMethod to registerAuthenticationKey + // Then it should forward keyInfo to registerAuthenticationKey it('should call registerAuthenticationKey with the provided keyInfo', async () => { const keyInfo = { rawId: 'public-key-123', @@ -32,18 +32,16 @@ describe('MultifactorAuthentication processing', () => { await processRegistration({ keyInfo, - authenticationMethod: 'BIOMETRIC_FACE', }); expect(registerAuthenticationKey).toHaveBeenCalledWith({ keyInfo, - authenticationMethod: 'BIOMETRIC_FACE', }); }); // Given a keyInfo object with public-key type (Passkeys) // When processRegistration is called - // Then it should forward keyInfo and authenticationMethod to registerAuthenticationKey + // Then it should forward keyInfo to registerAuthenticationKey it('should call registerAuthenticationKey with passkey keyInfo', async () => { const keyInfo = { rawId: 'passkey-raw-id', @@ -56,12 +54,10 @@ describe('MultifactorAuthentication processing', () => { await processRegistration({ keyInfo, - authenticationMethod: 'BIOMETRIC_FACE', }); expect(registerAuthenticationKey).toHaveBeenCalledWith({ keyInfo, - authenticationMethod: 'BIOMETRIC_FACE', }); }); @@ -76,7 +72,6 @@ describe('MultifactorAuthentication processing', () => { const result = await processRegistration({ keyInfo: {rawId: 'key', type: 'biometric' as const, response: {clientDataJSON: 'cdj', biometric: {publicKey: 'key', algorithm: CONST.COSE_ALGORITHM.EDDSA}}}, - authenticationMethod: 'BIOMETRIC_FACE', }); expect(result.success).toBe(true); @@ -93,7 +88,6 @@ describe('MultifactorAuthentication processing', () => { const result = await processRegistration({ keyInfo: {rawId: 'key', type: 'biometric' as const, response: {clientDataJSON: 'cdj', biometric: {publicKey: 'key', algorithm: CONST.COSE_ALGORITHM.EDDSA}}}, - authenticationMethod: 'BIOMETRIC_FACE', }); expect(result.success).toBe(false); From 306d9bd56b9ffcc87269294b9b6f756db5b4c591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Tue, 31 Mar 2026 14:07:51 +0200 Subject: [PATCH 17/41] move data to sign builder to helpers, added tests --- .../biometrics/useNativeBiometricsHSM.ts | 24 +----- .../NativeBiometricsHSM/helpers.ts | 32 ++++++- .../NativeBiometricsHSM/helpers.test.ts | 84 ++++++++++++++++++- 3 files changed, 116 insertions(+), 24 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts index be12a2cdcf02e..55fac55442064 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts @@ -1,9 +1,8 @@ -import {createKeys, deleteKeys, getAllKeys, InputEncoding, sha256, signWithOptions} from '@sbaiahmed1/react-native-biometrics'; -import {Buffer} from 'buffer'; +import {createKeys, deleteKeys, getAllKeys, InputEncoding, signWithOptions} from '@sbaiahmed1/react-native-biometrics'; import {useCallback} from 'react'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; -import {getKeyAlias, getSensorResult, mapAuthTypeNumber, mapLibraryError, mapSignErrorCode} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; +import {buildSigningData, getKeyAlias, getSensorResult, mapAuthTypeNumber, mapLibraryError, mapSignErrorCode} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; import type {NativeBiometricsHSMKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/types'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; @@ -101,24 +100,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { return; } - // Build authenticatorData: rpIdHash(32B) || flags(1B) || signCount(4B) - const {hash: rpIdHashB64} = await sha256(challenge.rpId); - const rpIdHash = Buffer.from(rpIdHashB64, 'base64'); - - // UP (0x01) | UV (0x04) - const flags = Buffer.from([0x05]); - // 4 zero bytes, big-endian - const signCount = Buffer.alloc(4); - - const authenticatorData = Buffer.concat([rpIdHash, flags, signCount]); - - // Build dataToSign: authenticatorData || sha256(clientDataJSON) - const clientDataJSON = JSON.stringify({challenge: challenge.challenge}); - const {hash: clientDataHashB64} = await sha256(clientDataJSON); - const clientDataHash = Buffer.from(clientDataHashB64, 'base64'); - - const dataToSign = Buffer.concat([authenticatorData, clientDataHash]); - const dataToSignB64 = dataToSign.toString('base64'); + const {authenticatorData, clientDataJSON, dataToSignB64} = await buildSigningData(challenge.rpId, challenge.challenge); // Sign with biometric prompt — signWithOptions const signResult = await signWithOptions({ diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts index ac7e8bcc8277e..d9995df2e242e 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts @@ -2,7 +2,8 @@ * Helper utilities for native biometrics HSM (react-native-biometrics). */ import type {BiometricSensorInfo} from '@sbaiahmed1/react-native-biometrics'; -import {isSensorAvailable} from '@sbaiahmed1/react-native-biometrics'; +import {isSensorAvailable, sha256} from '@sbaiahmed1/react-native-biometrics'; +import {Buffer} from 'buffer'; import type {ValueOf} from 'type-fest'; import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; @@ -87,4 +88,31 @@ function mapLibraryError(e: unknown): MultifactorAuthenticationReason | undefine return undefined; } -export {getKeyAlias, getSensorResult, mapAuthTypeNumber, mapSignErrorCode, mapLibraryError}; +/** + * Builds the WebAuthn-style authenticatorData, clientDataJSON and dataToSign for a challenge. + * + * authenticatorData = rpIdHash(32B) || flags(1B: UP|UV = 0x05) || signCount(4B: zeros) + * dataToSign = authenticatorData || sha256(clientDataJSON) + */ +async function buildSigningData(rpId: string, challenge: string): Promise<{authenticatorData: Buffer; clientDataJSON: string; dataToSignB64: string}> { + const {hash: rpIdHashB64} = await sha256(rpId); + const rpIdHash = Buffer.from(rpIdHashB64, 'base64'); + + // UP (0x01) | UV (0x04) + const flags = Buffer.from([0x05]); + // 4 zero bytes, big-endian + const signCount = Buffer.alloc(4); + + const authenticatorData = Buffer.concat([rpIdHash, flags, signCount]); + + const clientDataJSON = JSON.stringify({challenge}); + const {hash: clientDataHashB64} = await sha256(clientDataJSON); + const clientDataHash = Buffer.from(clientDataHashB64, 'base64'); + + const dataToSign = Buffer.concat([authenticatorData, clientDataHash]); + const dataToSignB64 = dataToSign.toString('base64'); + + return {authenticatorData, clientDataJSON, dataToSignB64}; +} + +export {getKeyAlias, getSensorResult, mapAuthTypeNumber, mapSignErrorCode, mapLibraryError, buildSigningData}; diff --git a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts index d7d472898b622..29f63be1eb01f 100644 --- a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts @@ -1,9 +1,13 @@ -import {getKeyAlias, mapAuthTypeNumber, mapLibraryError, mapSignErrorCode} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; +import {Buffer} from 'buffer'; +import {buildSigningData, getKeyAlias, mapAuthTypeNumber, mapLibraryError, mapSignErrorCode} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; import NATIVE_BIOMETRICS_HSM_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; +const mockSha256 = jest.fn(); + jest.mock('@sbaiahmed1/react-native-biometrics', () => ({ isSensorAvailable: jest.fn().mockResolvedValue({available: true, biometryType: 'FaceID', isDeviceSecure: true}), + sha256: (...args: unknown[]): Promise<{hash: string}> => mockSha256(...args) as Promise<{hash: string}>, })); describe('NativeBiometricsHSM helpers', () => { @@ -162,4 +166,82 @@ describe('NativeBiometricsHSM helpers', () => { expect(mapLibraryError('timeout')).toBeUndefined(); }); }); + + describe('buildSigningData', () => { + const rpId = 'example.com'; + const challenge = 'test-challenge-123'; + // 32 bytes of 0xAA, base64-encoded + const fakeRpIdHash = Buffer.alloc(32, 0xaa).toString('base64'); + // 32 bytes of 0xBB, base64-encoded + const fakeClientDataHash = Buffer.alloc(32, 0xbb).toString('base64'); + + beforeEach(() => { + mockSha256.mockReset(); + mockSha256.mockImplementation((input: string) => { + if (input === rpId) { + return Promise.resolve({hash: fakeRpIdHash}); + } + return Promise.resolve({hash: fakeClientDataHash}); + }); + }); + + it('should return authenticatorData with correct structure (37 bytes: 32 rpIdHash + 1 flags + 4 signCount)', async () => { + // Given a valid rpId and challenge + // When building signing data + // Then authenticatorData should be exactly 37 bytes: rpIdHash(32) || flags(1) || signCount(4) + const result = await buildSigningData(rpId, challenge); + expect(result.authenticatorData.length).toBe(37); + }); + + it('should set flags byte to 0x05 (UP | UV)', async () => { + // Given a valid rpId and challenge + // When building signing data + // Then the flags byte (index 32) should be 0x05 to indicate User Present and User Verified + const result = await buildSigningData(rpId, challenge); + expect(result.authenticatorData[32]).toBe(0x05); + }); + + it('should set signCount to 4 zero bytes', async () => { + // Given a valid rpId and challenge + // When building signing data + // Then signCount bytes (indices 33-36) should all be zero as we don't track sign counts + const result = await buildSigningData(rpId, challenge); + expect(result.authenticatorData.slice(33, 37)).toEqual(Buffer.alloc(4)); + }); + + it('should embed rpIdHash as the first 32 bytes of authenticatorData', async () => { + // Given a known rpId hash + // When building signing data + // Then the first 32 bytes of authenticatorData should match the sha256 of rpId + const result = await buildSigningData(rpId, challenge); + expect(result.authenticatorData.slice(0, 32)).toEqual(Buffer.alloc(32, 0xaa)); + }); + + it('should return clientDataJSON as stringified JSON containing the challenge', async () => { + // Given a challenge string + // When building signing data + // Then clientDataJSON should be a JSON string with the challenge field + const result = await buildSigningData(rpId, challenge); + expect(result.clientDataJSON).toBe(JSON.stringify({challenge})); + }); + + it('should return dataToSignB64 as base64 of authenticatorData || clientDataHash', async () => { + // Given known hashes for rpId and clientDataJSON + // When building signing data + // Then dataToSignB64 should be base64(authenticatorData || sha256(clientDataJSON)) + const result = await buildSigningData(rpId, challenge); + const expectedDataToSign = Buffer.concat([result.authenticatorData, Buffer.alloc(32, 0xbb)]); + expect(result.dataToSignB64).toBe(expectedDataToSign.toString('base64')); + }); + + it('should call sha256 with rpId and clientDataJSON', async () => { + // Given rpId and challenge inputs + // When building signing data + // Then sha256 should be called twice: once for rpId and once for the clientDataJSON string + await buildSigningData(rpId, challenge); + expect(mockSha256).toHaveBeenCalledTimes(2); + expect(mockSha256).toHaveBeenCalledWith(rpId); + expect(mockSha256).toHaveBeenCalledWith(JSON.stringify({challenge})); + }); + }); }); From a71ab233b00d287159d1a7c133db83f774924da8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Tue, 31 Mar 2026 14:37:18 +0200 Subject: [PATCH 18/41] more naming updates --- .../NativeBiometricsHSM/VALUES.ts | 4 ++-- .../NativeBiometricsHSM/helpers.ts | 20 +++++++++---------- src/libs/MultifactorAuthentication/VALUES.ts | 4 ++-- .../MultifactorAuthentication/shared/types.ts | 8 ++++---- .../NativeBiometricsHSM/helpers.test.ts | 8 ++++---- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts index da9238fafa3a2..976e1008e2d8c 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts @@ -4,7 +4,7 @@ // import {AuthType} from '@sbaiahmed1/react-native-biometrics/types'; import MARQETA_VALUES from '@libs/MultifactorAuthentication/shared/MarqetaValues'; -const NATIVE_BIOMETRICS_HSM_VALUES = { +const NATIVE_BIOMETRIC_HSM_VALUES = { /** * HSM key type identifier */ @@ -64,4 +64,4 @@ const NATIVE_BIOMETRICS_HSM_VALUES = { }, } as const; -export default NATIVE_BIOMETRICS_HSM_VALUES; +export default NATIVE_BIOMETRIC_HSM_VALUES; diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts index d9995df2e242e..dd36c80bc937b 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts @@ -8,9 +8,9 @@ import type {ValueOf} from 'type-fest'; import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; -import NATIVE_BIOMETRICS_HSM_VALUES from './VALUES'; +import NATIVE_BIOMETRIC_HSM_VALUES from './VALUES'; -type SecureStoreAuthTypeEntry = ValueOf; +type NativeBiometricsHSMTypeEntry = ValueOf; /** * Builds the key alias for a given account. @@ -40,14 +40,14 @@ function getSensorResult(): BiometricSensorInfo { * Maps authType number from signWithOptions (with returnAuthType: true) to AuthTypeInfo. * Native layer returns: -1=Unknown, 0=None, 1=DeviceCredentials, 2=Biometrics, 3=FaceID, 4=TouchID, 5=OpticID */ -const AUTH_TYPE_NUMBER_MAP = new Map([ - [-1, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.UNKNOWN], - [0, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.NONE], - [1, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.CREDENTIALS], - [2, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.BIOMETRICS], - [3, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.FACE_ID], - [4, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.TOUCH_ID], - [5, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.OPTIC_ID], +const AUTH_TYPE_NUMBER_MAP = new Map([ + [-1, NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.UNKNOWN], + [0, NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.NONE], + [1, NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.CREDENTIALS], + [2, NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.BIOMETRICS], + [3, NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.FACE_ID], + [4, NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.TOUCH_ID], + [5, NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.OPTIC_ID], ]); function mapAuthTypeNumber(authType?: number): AuthTypeInfo | undefined { diff --git a/src/libs/MultifactorAuthentication/VALUES.ts b/src/libs/MultifactorAuthentication/VALUES.ts index 5a8e72a0b1434..156eff3267307 100644 --- a/src/libs/MultifactorAuthentication/VALUES.ts +++ b/src/libs/MultifactorAuthentication/VALUES.ts @@ -4,14 +4,14 @@ * This ensures CONST.MULTIFACTOR_AUTHENTICATION continues working everywhere. */ import NATIVE_BIOMETRICS_VALUES from './NativeBiometrics/VALUES'; -import NATIVE_BIOMETRICS_HSM_VALUES from './NativeBiometricsHSM/VALUES'; +import NATIVE_BIOMETRIC_HSM_VALUES from './NativeBiometricsHSM/VALUES'; import PASSKEY_VALUES from './Passkeys/VALUES'; import SHARED_VALUES from './shared/VALUES'; const MULTIFACTOR_AUTHENTICATION_VALUES = { ...SHARED_VALUES, ...NATIVE_BIOMETRICS_VALUES, - ...NATIVE_BIOMETRICS_HSM_VALUES, + ...NATIVE_BIOMETRIC_HSM_VALUES, ...PASSKEY_VALUES, } as const; diff --git a/src/libs/MultifactorAuthentication/shared/types.ts b/src/libs/MultifactorAuthentication/shared/types.ts index 953955dbf2284..122ab209cd156 100644 --- a/src/libs/MultifactorAuthentication/shared/types.ts +++ b/src/libs/MultifactorAuthentication/shared/types.ts @@ -6,7 +6,7 @@ import type {ValueOf} from 'type-fest'; import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioAdditionalParams} from '@components/MultifactorAuthentication/config/types'; import type {NativeBiometricsKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometrics/types'; import type {NativeBiometricsHSMKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/types'; -import type NATIVE_BIOMETRICS_HSM_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES'; +import type NATIVE_BIOMETRIC_HSM_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES'; import type {PasskeyRegistrationKeyInfo} from '@libs/MultifactorAuthentication/Passkeys/types'; import type {PASSKEY_AUTH_TYPE} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; import type {SignedChallenge} from './challengeTypes'; @@ -15,9 +15,9 @@ import type VALUES from './VALUES'; /** * Authentication type name derived from react-native-biometrics values and passkey auth type. */ -type AuthTypeName = ValueOf['NAME'] | (typeof PASSKEY_AUTH_TYPE)['NAME']; +type AuthTypeName = ValueOf['NAME'] | (typeof PASSKEY_AUTH_TYPE)['NAME']; -type MarqetaAuthTypeName = ValueOf['MARQETA_VALUE'] | (typeof PASSKEY_AUTH_TYPE)['MARQETA_VALUE']; +type MarqetaAuthTypeName = ValueOf['MARQETA_VALUE'] | (typeof PASSKEY_AUTH_TYPE)['MARQETA_VALUE']; type AuthTypeInfo = { code?: MultifactorAuthenticationMethodCode; @@ -25,7 +25,7 @@ type AuthTypeInfo = { marqetaValue: MarqetaAuthTypeName; }; -type MultifactorAuthenticationMethodCode = ValueOf['CODE']; +type MultifactorAuthenticationMethodCode = ValueOf['CODE']; /** * Represents the reason for a multifactor authentication response from the backend. diff --git a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts index 29f63be1eb01f..fcf17e14d79b7 100644 --- a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts @@ -1,6 +1,6 @@ import {Buffer} from 'buffer'; import {buildSigningData, getKeyAlias, mapAuthTypeNumber, mapLibraryError, mapSignErrorCode} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; -import NATIVE_BIOMETRICS_HSM_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES'; +import NATIVE_BIOMETRIC_HSM_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; const mockSha256 = jest.fn(); @@ -49,9 +49,9 @@ describe('NativeBiometricsHSM helpers', () => { // Then it should resolve to the Unknown auth type, because the method could not be determined, but the authentication was successful const result = mapAuthTypeNumber(-1); expect(result).toEqual({ - code: NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.UNKNOWN.CODE, - name: NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.UNKNOWN.NAME, - marqetaValue: NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.UNKNOWN.MARQETA_VALUE, + code: NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.UNKNOWN.CODE, + name: NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.UNKNOWN.NAME, + marqetaValue: NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.UNKNOWN.MARQETA_VALUE, }); }); From c72d967767afdbc9b5ad6af00162a2bd705330f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Tue, 31 Mar 2026 17:18:25 +0200 Subject: [PATCH 19/41] improved error handling and new error constants --- .../biometrics/useNativeBiometricsHSM.ts | 31 ++-- .../NativeBiometricsHSM/VALUES.ts | 32 +++++ .../NativeBiometricsHSM/helpers.ts | 46 ++++-- .../shared/VALUES.ts | 18 +++ .../useNativeBiometricsHSM.test.ts | 33 ++++- .../NativeBiometricsHSM/helpers.test.ts | 132 ++++++++++++++---- 6 files changed, 234 insertions(+), 58 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts index 55fac55442064..0f5a91ef7271e 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts @@ -1,5 +1,6 @@ import {createKeys, deleteKeys, getAllKeys, InputEncoding, signWithOptions} from '@sbaiahmed1/react-native-biometrics'; import {useCallback} from 'react'; +import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import {buildSigningData, getKeyAlias, getSensorResult, mapAuthTypeNumber, mapLibraryError, mapSignErrorCode} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; @@ -26,13 +27,18 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { }, []); const getLocalCredentialID = useCallback(async () => { - const keyAlias = getKeyAlias(accountID); - const {keys} = await getAllKeys(keyAlias); - const entry = keys.find((k) => k.alias === keyAlias); - if (!entry) { + try { + const keyAlias = getKeyAlias(accountID); + const {keys} = await getAllKeys(keyAlias); + const entry = keys.find((k) => k.alias === keyAlias); + if (!entry) { + return undefined; + } + return Base64URL.base64ToBase64url(entry.publicKey); + } catch (e) { + addMFABreadcrumb('Failed to get local credential ID', {reason: mapLibraryError(e) ?? VALUES.REASON.HSM.GENERIC}, 'error'); return undefined; } - return Base64URL.base64ToBase64url(entry.publicKey); }, [accountID]); const areLocalCredentialsKnownToServer = useCallback(async () => { @@ -41,8 +47,12 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { }, [getLocalCredentialID, serverKnownCredentialIDs]); const deleteLocalKeysForAccount = useCallback(async () => { - const keyAlias = getKeyAlias(accountID); - await deleteKeys(keyAlias); + try { + const keyAlias = getKeyAlias(accountID); + await deleteKeys(keyAlias); + } catch (e) { + addMFABreadcrumb('Failed to delete local keys', {reason: mapLibraryError(e) ?? VALUES.REASON.HSM.GENERIC}, 'error'); + } }, [accountID]); const register = async (onResult: (result: RegisterResult) => Promise | void, registrationChallenge: Parameters[1]) => { @@ -82,7 +92,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { } catch (e) { onResult({ success: false, - reason: mapLibraryError(e) ?? VALUES.REASON.KEYSTORE.UNABLE_TO_SAVE_KEY, + reason: mapLibraryError(e) ?? VALUES.REASON.HSM.KEY_CREATION_FAILED, }); } }; @@ -96,6 +106,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { const allowedIDs = challenge.allowCredentials?.map((credential: {id: string; type: string}) => credential.id) ?? []; if (!credentialID || !allowedIDs.includes(credentialID)) { + await deleteLocalKeysForAccount(); onResult({success: false, reason: VALUES.REASON.KEYSTORE.REGISTRATION_REQUIRED}); return; } @@ -114,7 +125,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { if (!signResult.success || !signResult.signature) { onResult({ success: false, - reason: mapSignErrorCode(signResult.errorCode) ?? VALUES.REASON.GENERIC.BAD_REQUEST, + reason: mapSignErrorCode(signResult.errorCode) ?? VALUES.REASON.HSM.GENERIC, }); return; } @@ -142,7 +153,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { } catch (e) { onResult({ success: false, - reason: mapLibraryError(e) ?? VALUES.REASON.GENERIC.BAD_REQUEST, + reason: mapLibraryError(e) ?? VALUES.REASON.HSM.GENERIC, }); } }; diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts index 976e1008e2d8c..c301ab33d55e6 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts @@ -62,6 +62,38 @@ const NATIVE_BIOMETRIC_HSM_VALUES = { MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FACE, }, }, + /** + * Error codes returned by react-native-biometrics. + * + * signWithOptions resolves with { errorCode?: string }. + * createKeys/deleteKeys/getAllKeys reject with Error objects having { code: string, message: string }. + */ + ERROR_CODE: { + // User cancellation + USER_CANCEL: 'USER_CANCEL', // iOS + USER_CANCELED: 'USER_CANCELED', // Android + + // Biometric not available + BIOMETRY_NOT_AVAILABLE: 'BIOMETRY_NOT_AVAILABLE', // iOS + BIOMETRIC_NOT_AVAILABLE: 'BIOMETRIC_NOT_AVAILABLE', // Android (signWithOptions) + BIOMETRIC_UNAVAILABLE: 'BIOMETRIC_UNAVAILABLE', // Android (BiometricPrompt) + + // Lockout + BIOMETRY_LOCKOUT: 'BIOMETRY_LOCKOUT', // iOS + BIOMETRIC_LOCKOUT: 'BIOMETRIC_LOCKOUT', // Android + BIOMETRY_LOCKOUT_PERMANENT: 'BIOMETRY_LOCKOUT_PERMANENT', // iOS + BIOMETRIC_LOCKOUT_PERMANENT: 'BIOMETRIC_LOCKOUT_PERMANENT', // Android + + // Signature/key errors + SIGNATURE_CREATION_FAILED: 'SIGNATURE_CREATION_FAILED', + KEY_NOT_FOUND: 'KEY_NOT_FOUND', + CREATE_KEYS_ERROR: 'CREATE_KEYS_ERROR', + KEY_ALREADY_EXISTS: 'KEY_ALREADY_EXISTS', + + // System cancel + SYSTEM_CANCEL: 'SYSTEM_CANCEL', // iOS + SYSTEM_CANCELED: 'SYSTEM_CANCELED', // Android + }, } as const; export default NATIVE_BIOMETRIC_HSM_VALUES; diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts index dd36c80bc937b..adecef7658979 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts @@ -62,28 +62,50 @@ function mapAuthTypeNumber(authType?: number): AuthTypeInfo | undefined { } /** - * Maps library errorCode strings to existing REASON values. + * Maps errorCode strings from signWithOptions results to REASON values. + * Uses exact error code matching against constants from ERRORS.md. */ +const SIGN_ERROR_CODE_MAP: Record = { + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCEL]: VALUES.REASON.HSM.CANCELED, + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCELED]: VALUES.REASON.HSM.CANCELED, + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.SYSTEM_CANCEL]: VALUES.REASON.HSM.CANCELED, + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.SYSTEM_CANCELED]: VALUES.REASON.HSM.CANCELED, + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND]: VALUES.REASON.HSM.KEY_NOT_FOUND, + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRY_NOT_AVAILABLE]: VALUES.REASON.HSM.NOT_AVAILABLE, + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_NOT_AVAILABLE]: VALUES.REASON.HSM.NOT_AVAILABLE, + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_UNAVAILABLE]: VALUES.REASON.HSM.NOT_AVAILABLE, + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRY_LOCKOUT]: VALUES.REASON.HSM.LOCKOUT, + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_LOCKOUT]: VALUES.REASON.HSM.LOCKOUT, + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRY_LOCKOUT_PERMANENT]: VALUES.REASON.HSM.LOCKOUT_PERMANENT, + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_LOCKOUT_PERMANENT]: VALUES.REASON.HSM.LOCKOUT_PERMANENT, + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.SIGNATURE_CREATION_FAILED]: VALUES.REASON.HSM.SIGNATURE_FAILED, +}; + function mapSignErrorCode(errorCode?: string): MultifactorAuthenticationReason | undefined { if (!errorCode) { return undefined; } - if (errorCode.toLowerCase().includes('cancel')) { - return VALUES.REASON.EXPO.CANCELED; - } - if (errorCode.toLowerCase().includes('not available')) { - return VALUES.REASON.EXPO.NOT_SUPPORTED; - } - return VALUES.REASON.EXPO.GENERIC; + return SIGN_ERROR_CODE_MAP[errorCode] ?? VALUES.REASON.HSM.GENERIC; } +/** + * Maps errorCode strings from rejected library promises to REASON values. + */ +const LIBRARY_ERROR_CODE_MAP: Record = { + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCEL]: VALUES.REASON.HSM.CANCELED, + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCELED]: VALUES.REASON.HSM.CANCELED, + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND]: VALUES.REASON.HSM.KEY_NOT_FOUND, + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ALREADY_EXISTS]: VALUES.REASON.HSM.KEY_CREATION_FAILED, + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.CREATE_KEYS_ERROR]: VALUES.REASON.HSM.KEY_CREATION_FAILED, +}; + /** * Maps caught exceptions from the library to REASON values. */ -function mapLibraryError(e: unknown): MultifactorAuthenticationReason | undefined { - const msg = e instanceof Error ? e.message : String(e); - if (msg.toLowerCase().includes('cancel')) { - return VALUES.REASON.EXPO.CANCELED; +function mapLibraryError(error: unknown): MultifactorAuthenticationReason | undefined { + const code = error instanceof Error ? (error as Error & {code?: string}).code : undefined; + if (code) { + return LIBRARY_ERROR_CODE_MAP[code]; } return undefined; } diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index d5cdc56ae3d61..4e90bac6d9124 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -106,6 +106,16 @@ const REASON = { UNEXPECTED_RESPONSE: 'WebAuthn credential response type is unexpected', GENERIC: 'An unknown WebAuthn error occurred', }, + HSM: { + CANCELED: 'Authentication canceled by user', + NOT_AVAILABLE: 'Biometric authentication not available', + LOCKOUT: 'Biometric authentication locked out', + LOCKOUT_PERMANENT: 'Biometric authentication permanently locked out', + KEY_NOT_FOUND: 'Key not found', + SIGNATURE_FAILED: 'Signature creation failed', + KEY_CREATION_FAILED: 'Key creation failed', + GENERIC: 'An error occurred', + }, } as const; const HTTP_STATUS = { @@ -224,6 +234,9 @@ const ROUTINE_FAILURES = new Set([ REASON.WEBAUTHN.NOT_ALLOWED, REASON.WEBAUTHN.ABORT, REASON.WEBAUTHN.NOT_SUPPORTED, + REASON.HSM.CANCELED, + REASON.HSM.NOT_AVAILABLE, + REASON.HSM.LOCKOUT, ]); /** Known errors that should rarely happen and may indicate a bug or unexpected state. Logged at 'error' level. Any reason not in either set is treated as UNCLASSIFIED (e.g. 5xx, missing reason). */ @@ -254,6 +267,11 @@ const ANOMALOUS_FAILURES = new Set([ REASON.WEBAUTHN.REGISTRATION_REQUIRED, REASON.WEBAUTHN.UNEXPECTED_RESPONSE, REASON.WEBAUTHN.GENERIC, + REASON.HSM.LOCKOUT_PERMANENT, + REASON.HSM.SIGNATURE_FAILED, + REASON.HSM.KEY_NOT_FOUND, + REASON.HSM.KEY_CREATION_FAILED, + REASON.HSM.GENERIC, ]); const SHARED_VALUES = { diff --git a/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts index 4fd1a1492bc3d..6a9521e44d099 100644 --- a/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts +++ b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts @@ -98,7 +98,7 @@ describe('useNativeBiometricsHSM hook', () => { // Then it should report BIOMETRICS as its device verification type so the MFA system can distinguish it from other verification methods const {result} = renderHook(() => useNativeBiometricsHSM()); - expect(result.current.deviceVerificationType).toBe(CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS); + expect(result.current.deviceVerificationType).toBe(CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRIC_HSM); }); }); @@ -391,11 +391,11 @@ describe('useNativeBiometricsHSM hook', () => { ); }); - it('should handle thrown errors', async () => { - // Given the biometric library throws an error containing "canceled" + it('should handle thrown errors with known error code', async () => { + // Given the biometric library throws an error with a USER_CANCEL code property // When the authorize flow catches the thrown error - // Then onResult should receive a failure with CANCELED reason - mockSignWithOptions.mockRejectedValue(new Error('Biometric canceled')); + // Then onResult should receive a failure with CANCELED reason based on the exact error code + mockSignWithOptions.mockRejectedValue(Object.assign(new Error('User canceled authentication'), {code: 'USER_CANCEL'})); const {result} = renderHook(() => useNativeBiometricsHSM()); const onResult = jest.fn(); @@ -407,7 +407,28 @@ describe('useNativeBiometricsHSM hook', () => { expect(onResult).toHaveBeenCalledWith( expect.objectContaining({ success: false, - reason: VALUES.REASON.EXPO.CANCELED, + reason: VALUES.REASON.HSM.CANCELED, + }), + ); + }); + + it('should handle thrown errors with unknown error code', async () => { + // Given the biometric library throws an error without a recognized code property + // When the authorize flow catches the thrown error + // Then onResult should receive a failure with GENERIC as the fallback reason + mockSignWithOptions.mockRejectedValue(new Error('Unexpected error')); + + const {result} = renderHook(() => useNativeBiometricsHSM()); + const onResult = jest.fn(); + + await act(async () => { + await result.current.authorize({challenge: mockChallenge}, onResult); + }); + + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + reason: VALUES.REASON.HSM.GENERIC, }), ); }); diff --git a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts index fcf17e14d79b7..b9f2c0a7d904b 100644 --- a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts @@ -112,58 +112,130 @@ describe('NativeBiometricsHSM helpers', () => { expect(mapSignErrorCode(undefined)).toBeUndefined(); }); - it('should return CANCELED for cancel-related error codes', () => { - // Given various error code strings that indicate the user canceled the biometric prompt + it('should return CANCELED for user cancel error codes', () => { + // Given exact error code strings from the library indicating the user canceled the biometric prompt // When mapping the error codes - // Then all cancel variants should resolve to CANCELED so the UI can show a consistent cancellation message regardless of platform-specific error strings - expect(mapSignErrorCode('UserCancel')).toBe(VALUES.REASON.EXPO.CANCELED); - expect(mapSignErrorCode('CANCELED')).toBe(VALUES.REASON.EXPO.CANCELED); - expect(mapSignErrorCode('user_cancel')).toBe(VALUES.REASON.EXPO.CANCELED); + // Then both iOS (USER_CANCEL) and Android (USER_CANCELED) variants should resolve to HSM.CANCELED + expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCEL)).toBe(VALUES.REASON.HSM.CANCELED); + expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCELED)).toBe(VALUES.REASON.HSM.CANCELED); }); - it('should return NOT_SUPPORTED for "not available" error codes', () => { - // Given error codes indicating biometrics are not available on the device + it('should return CANCELED for system cancel error codes', () => { + // Given exact error code strings indicating the system canceled authentication (e.g., app backgrounded) // When mapping the error codes - // Then they should resolve to NOT_SUPPORTED so the app can guide the user to enable biometrics or use an alternative method - expect(mapSignErrorCode('Biometrics not available')).toBe(VALUES.REASON.EXPO.NOT_SUPPORTED); - expect(mapSignErrorCode('NOT AVAILABLE')).toBe(VALUES.REASON.EXPO.NOT_SUPPORTED); + // Then both iOS (SYSTEM_CANCEL) and Android (SYSTEM_CANCELED) variants should resolve to HSM.CANCELED + expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.SYSTEM_CANCEL)).toBe(VALUES.REASON.HSM.CANCELED); + expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.SYSTEM_CANCELED)).toBe(VALUES.REASON.HSM.CANCELED); }); - it('should return GENERIC for other error codes', () => { - // Given an error code that does not match any known cancel or availability pattern + it('should return NOT_AVAILABLE for biometric unavailability error codes', () => { + // Given exact error codes indicating biometrics are not available on the device + // When mapping the error codes + // Then they should resolve to HSM.NOT_AVAILABLE so the app can guide the user to enable biometrics or use an alternative method + expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRY_NOT_AVAILABLE)).toBe(VALUES.REASON.HSM.NOT_AVAILABLE); + expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_NOT_AVAILABLE)).toBe(VALUES.REASON.HSM.NOT_AVAILABLE); + expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_UNAVAILABLE)).toBe(VALUES.REASON.HSM.NOT_AVAILABLE); + }); + + it('should return LOCKOUT for temporary lockout error codes', () => { + // Given exact error codes indicating temporary biometric lockout after too many failed attempts + // When mapping the error codes + // Then both iOS and Android variants should resolve to HSM.LOCKOUT + expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRY_LOCKOUT)).toBe(VALUES.REASON.HSM.LOCKOUT); + expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_LOCKOUT)).toBe(VALUES.REASON.HSM.LOCKOUT); + }); + + it('should return LOCKOUT_PERMANENT for permanent lockout error codes', () => { + // Given exact error codes indicating permanent biometric lockout requiring device credential to reset + // When mapping the error codes + // Then both iOS and Android variants should resolve to HSM.LOCKOUT_PERMANENT + expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRY_LOCKOUT_PERMANENT)).toBe(VALUES.REASON.HSM.LOCKOUT_PERMANENT); + expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_LOCKOUT_PERMANENT)).toBe(VALUES.REASON.HSM.LOCKOUT_PERMANENT); + }); + + it('should return SIGNATURE_FAILED for signature creation failure', () => { + // Given the exact error code for signature creation failure // When mapping the error code - // Then it should fall back to GENERIC so the error is still surfaced to the user with a general error message - expect(mapSignErrorCode('some_unknown_error')).toBe(VALUES.REASON.EXPO.GENERIC); + // Then it should resolve to HSM.SIGNATURE_FAILED + expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.SIGNATURE_CREATION_FAILED)).toBe(VALUES.REASON.HSM.SIGNATURE_FAILED); + }); + + it('should return KEY_NOT_FOUND for key not found error code', () => { + // Given the exact error code for when the signing key does not exist in the keystore + // When mapping the error code + // Then it should resolve to KEYSTORE.KEY_NOT_FOUND + expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND)).toBe(VALUES.REASON.KEYSTORE.KEY_NOT_FOUND); + }); + + it('should return GENERIC for unrecognized error codes', () => { + // Given an error code that does not match any known library error code constant + // When mapping the error code + // Then it should fall back to HSM.GENERIC so the error is still surfaced to the user with a general error message + expect(mapSignErrorCode('some_unknown_error')).toBe(VALUES.REASON.HSM.GENERIC); }); }); describe('mapLibraryError', () => { - it('should return CANCELED for Error with cancel message', () => { - // Given an Error object whose message contains "cancel", thrown by the biometric library when the user dismisses the prompt + it('should return CANCELED for Error with USER_CANCEL code', () => { + // Given an Error object with a code property matching the iOS user cancel error code + // When mapping the library error + // Then it should resolve to HSM.CANCELED based on the exact error code + const error = Object.assign(new Error('User canceled authentication'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCEL}); + expect(mapLibraryError(error)).toBe(VALUES.REASON.HSM.CANCELED); + }); + + it('should return CANCELED for Error with USER_CANCELED code', () => { + // Given an Error object with a code property matching the Android user cancel error code + // When mapping the library error + // Then it should resolve to HSM.CANCELED based on the exact error code + const error = Object.assign(new Error('User canceled the operation'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCELED}); + expect(mapLibraryError(error)).toBe(VALUES.REASON.HSM.CANCELED); + }); + + it('should return KEY_CREATION_FAILED for Error with CREATE_KEYS_ERROR code', () => { + // Given an Error object with a code property matching the key creation error code // When mapping the library error - // Then it should resolve to CANCELED so the app treats thrown errors the same as error-code-based cancellations - expect(mapLibraryError(new Error('User canceled the operation'))).toBe(VALUES.REASON.EXPO.CANCELED); + // Then it should resolve to HSM.KEY_CREATION_FAILED + const error = Object.assign(new Error('Failed to create keys'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.CREATE_KEYS_ERROR}); + expect(mapLibraryError(error)).toBe(VALUES.REASON.HSM.KEY_CREATION_FAILED); }); - it('should return CANCELED for string with cancel', () => { - // Given a plain string error containing "cancel", which some library versions throw instead of Error objects + it('should return KEY_CREATION_FAILED for Error with KEY_ALREADY_EXISTS code', () => { + // Given an Error object with a code property matching the key already exists error code // When mapping the library error - // Then it should resolve to CANCELED regardless of the error type to handle inconsistent library error formats - expect(mapLibraryError('Canceled by user')).toBe(VALUES.REASON.EXPO.CANCELED); + // Then it should resolve to HSM.KEY_CREATION_FAILED since the key creation operation failed + const error = Object.assign(new Error('Key already exists'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ALREADY_EXISTS}); + expect(mapLibraryError(error)).toBe(VALUES.REASON.HSM.KEY_CREATION_FAILED); }); - it('should return undefined for non-cancel errors', () => { - // Given an Error object with a message that does not indicate cancellation + it('should return KEY_NOT_FOUND for Error with KEY_NOT_FOUND code', () => { + // Given an Error object with a code property matching the key not found error code // When mapping the library error - // Then undefined should be returned because the error does not match a known cancellation pattern and needs separate handling + // Then it should resolve to KEYSTORE.KEY_NOT_FOUND + const error = Object.assign(new Error('Cryptographic key not found'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND}); + expect(mapLibraryError(error)).toBe(VALUES.REASON.KEYSTORE.KEY_NOT_FOUND); + }); + + it('should return undefined for Error without code property', () => { + // Given an Error object without a code property (generic JS error, not from the library) + // When mapping the library error + // Then undefined should be returned because the error cannot be classified without a code expect(mapLibraryError(new Error('Network error'))).toBeUndefined(); }); - it('should return undefined for non-cancel strings', () => { - // Given a plain string error that does not contain "cancel" + it('should return undefined for Error with unrecognized code', () => { + // Given an Error object with a code property that does not match any known library error code + // When mapping the library error + // Then undefined should be returned so the caller can provide a fallback reason + const error = Object.assign(new Error('Some error'), {code: 'UNKNOWN_CODE'}); + expect(mapLibraryError(error)).toBeUndefined(); + }); + + it('should return undefined for non-Error values', () => { + // Given a plain string error (not an Error object, so no code property) // When mapping the library error - // Then undefined should be returned because only cancellation errors have special handling in this mapper - expect(mapLibraryError('timeout')).toBeUndefined(); + // Then undefined should be returned because only Error objects with code properties are classified + expect(mapLibraryError('some string error')).toBeUndefined(); }); }); From e16489666dfc78e4f207b42fb1d894bdb9341358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Wed, 1 Apr 2026 16:38:30 +0200 Subject: [PATCH 20/41] added the new eror handling, fixed allowed authentication methods, made doesDeviceSupportAuthenticationMethod async --- .../Context/Main.tsx | 4 +-- .../biometrics/shared/types.ts | 2 +- .../biometrics/useNativeBiometrics.ts | 2 +- .../biometrics/useNativeBiometricsHSM.ts | 10 +++---- .../biometrics/usePasskeys.ts | 2 +- .../config/scenarios/AuthorizeTransaction.tsx | 2 +- .../config/scenarios/BiometricsTest.tsx | 2 +- .../config/scenarios/ChangePIN.tsx | 2 +- .../config/scenarios/RevealPIN.tsx | 2 +- .../config/scenarios/SetPINOrderCard.tsx | 2 +- .../config/scenarios/prompts.ts | 2 +- .../NativeBiometricsHSM/VALUES.ts | 1 + .../NativeBiometricsHSM/helpers.ts | 24 +++-------------- .../shared/VALUES.ts | 2 ++ .../useNavigateTo3DSAuthorizationChallenge.ts | 27 +++++++++---------- .../config/scenarios/index.test.ts | 4 +-- .../useNativeBiometrics.test.ts | 11 ++++---- .../useNativeBiometricsHSM.test.ts | 26 +++++++++--------- .../NativeBiometricsHSM/helpers.test.ts | 19 +++++++++++-- 19 files changed, 73 insertions(+), 73 deletions(-) diff --git a/src/components/MultifactorAuthentication/Context/Main.tsx b/src/components/MultifactorAuthentication/Context/Main.tsx index 383b753d12233..9845f373b0f14 100644 --- a/src/components/MultifactorAuthentication/Context/Main.tsx +++ b/src/components/MultifactorAuthentication/Context/Main.tsx @@ -219,7 +219,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent } // 2b. Check if the device can actually perform the allowed authentication method - if (!biometrics.doesDeviceSupportAuthenticationMethod()) { + if (!(await biometrics.doesDeviceSupportAuthenticationMethod())) { const reason = biometrics.deviceCheckFailureReason; const message = `Device check failed (deviceVerificationType: ${biometrics.deviceVerificationType})`; addMFABreadcrumb('Device check failed', {reason, deviceVerificationType: biometrics.deviceVerificationType, message}, 'warning'); @@ -390,7 +390,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // Re-registration may be needed even though we checked credentials above, because: // - The local public key was deleted between the check and authorization // - The server no longer accepts the local public key (not in allowCredentials) - if (result.reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.KEYSTORE.REGISTRATION_REQUIRED) { + if (result.reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.HSM.KEY_ACCESS_FAILED || result.reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.HSM.KEY_NOT_FOUND) { addMFABreadcrumb('Authorization key reset', {reason: result.reason}, 'warning'); await biometrics.deleteLocalKeysForAccount(); dispatch({type: 'SET_REGISTRATION_COMPLETE', payload: false}); diff --git a/src/components/MultifactorAuthentication/biometrics/shared/types.ts b/src/components/MultifactorAuthentication/biometrics/shared/types.ts index a95b0425dd1b5..b4184fbff44d7 100644 --- a/src/components/MultifactorAuthentication/biometrics/shared/types.ts +++ b/src/components/MultifactorAuthentication/biometrics/shared/types.ts @@ -49,7 +49,7 @@ type UseBiometricsReturn = { getLocalCredentialID: () => Promise; /** Check if device supports the authentication method */ - doesDeviceSupportAuthenticationMethod: () => boolean; + doesDeviceSupportAuthenticationMethod: () => Promise; /** Reason to use when doesDeviceSupportAuthenticationMethod() returns false (platform-specific) */ deviceCheckFailureReason: MultifactorAuthenticationReason; diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts index e55cb70abc991..ed1f78fd9a793 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts @@ -40,7 +40,7 @@ function useNativeBiometrics(): UseBiometricsReturn { * Verifies both biometrics and credentials authentication capabilities. * @returns True if biometrics or credentials authentication is supported on the device. */ - const doesDeviceSupportAuthenticationMethod = useCallback(() => { + const doesDeviceSupportAuthenticationMethod = useCallback(async () => { const {biometrics, credentials} = PublicKeyStore.supportedAuthentication; return biometrics || credentials; }, []); diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts index 0f5a91ef7271e..09ded13957d67 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts @@ -1,9 +1,9 @@ -import {createKeys, deleteKeys, getAllKeys, InputEncoding, signWithOptions} from '@sbaiahmed1/react-native-biometrics'; +import {createKeys, deleteKeys, getAllKeys, InputEncoding, isSensorAvailable, signWithOptions} from '@sbaiahmed1/react-native-biometrics'; import {useCallback} from 'react'; import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; -import {buildSigningData, getKeyAlias, getSensorResult, mapAuthTypeNumber, mapLibraryError, mapSignErrorCode} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; +import {buildSigningData, getKeyAlias, mapAuthTypeNumber, mapLibraryError, mapSignErrorCode} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; import type {NativeBiometricsHSMKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/types'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; @@ -21,8 +21,8 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { const {translate} = useLocalize(); const {serverKnownCredentialIDs, haveCredentialsEverBeenConfigured} = useServerCredentials(); - const doesDeviceSupportAuthenticationMethod = useCallback(() => { - const sensorResult = getSensorResult(); + const doesDeviceSupportAuthenticationMethod = useCallback(async () => { + const sensorResult = await isSensorAvailable(); return sensorResult.isDeviceSecure ?? sensorResult.available; }, []); @@ -107,7 +107,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { if (!credentialID || !allowedIDs.includes(credentialID)) { await deleteLocalKeysForAccount(); - onResult({success: false, reason: VALUES.REASON.KEYSTORE.REGISTRATION_REQUIRED}); + onResult({success: false, reason: VALUES.REASON.HSM.KEY_NOT_FOUND}); return; } diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index 22f5858024c85..bffcc45ee3bf5 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -26,7 +26,7 @@ function usePasskeys(): UseBiometricsReturn { const {serverKnownCredentialIDs, haveCredentialsEverBeenConfigured} = useServerCredentials(); const [localPasskeyCredentials] = useOnyx(getPasskeyOnyxKey(userId)); - const doesDeviceSupportAuthenticationMethod = () => isWebAuthnSupported(); + const doesDeviceSupportAuthenticationMethod = async () => isWebAuthnSupported(); const getLocalCredentialID = async (): Promise => { return (localPasskeyCredentials ?? []).at(0)?.id; diff --git a/src/components/MultifactorAuthentication/config/scenarios/AuthorizeTransaction.tsx b/src/components/MultifactorAuthentication/config/scenarios/AuthorizeTransaction.tsx index e518f7bb01320..103f4fc78b746 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/AuthorizeTransaction.tsx +++ b/src/components/MultifactorAuthentication/config/scenarios/AuthorizeTransaction.tsx @@ -124,7 +124,7 @@ export { export default { // Allowed methods are hardcoded here; keep in sync with allowedAuthenticationMethods in useNavigateTo3DSAuthorizationChallenge. - allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], + allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRIC_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], action: authorizeTransaction, // AuthorizeTransaction's callback navigates to the outcome screen, but if it knows the user is going to see an error outcome, we explicitly deny the transaction to make sure the user can't re-approve it on another device diff --git a/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.tsx b/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.tsx index bd97a4e7ed9ac..51ef272ceeb46 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.tsx +++ b/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.tsx @@ -7,7 +7,7 @@ import CONST from '@src/CONST'; import SCREENS from '@src/SCREENS'; export default { - allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], + allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRIC_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], action: troubleshootMultifactorAuthentication, screen: SCREENS.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_TEST, pure: true, diff --git a/src/components/MultifactorAuthentication/config/scenarios/ChangePIN.tsx b/src/components/MultifactorAuthentication/config/scenarios/ChangePIN.tsx index 42d411982dff2..d4a2f111f64f1 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/ChangePIN.tsx +++ b/src/components/MultifactorAuthentication/config/scenarios/ChangePIN.tsx @@ -47,7 +47,7 @@ const ChangePINSuccessScreen = createScreenWithDefaults( * This scenario is used when a UK/EU cardholder changes the PIN of their physical card. */ export default { - allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], + allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRIC_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], action: changePINForCard, successScreen: , defaultClientFailureScreen: , diff --git a/src/components/MultifactorAuthentication/config/scenarios/RevealPIN.tsx b/src/components/MultifactorAuthentication/config/scenarios/RevealPIN.tsx index a0dcf6557b61d..0463cefc4685a 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/RevealPIN.tsx +++ b/src/components/MultifactorAuthentication/config/scenarios/RevealPIN.tsx @@ -52,7 +52,7 @@ const ServerFailureScreen = createScreenWithDefaults( * - Authentication failure: Return SHOW_OUTCOME_SCREEN to show failure screen */ export default { - allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], + allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRIC_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], action: revealPINForCard, callback: async (isSuccessful, callbackInput, payload) => { if (isSuccessful && isRevealPINPayload(payload)) { diff --git a/src/components/MultifactorAuthentication/config/scenarios/SetPINOrderCard.tsx b/src/components/MultifactorAuthentication/config/scenarios/SetPINOrderCard.tsx index 0809a428abd88..a642277ac1f96 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/SetPINOrderCard.tsx +++ b/src/components/MultifactorAuthentication/config/scenarios/SetPINOrderCard.tsx @@ -65,7 +65,7 @@ const ServerFailureScreen = createScreenWithDefaults( * - Authentication failure: Return SHOW_OUTCOME_SCREEN to show failure screen */ export default { - allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], + allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRIC_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], action: setPersonalDetailsAndShipExpensifyCardsWithPIN, callback: async (isSuccessful, _callbackInput, payload) => { diff --git a/src/components/MultifactorAuthentication/config/scenarios/prompts.ts b/src/components/MultifactorAuthentication/config/scenarios/prompts.ts index 73860d77d75e8..4236faacffe30 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/prompts.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/prompts.ts @@ -8,7 +8,7 @@ import VALUES from '@libs/MultifactorAuthentication/VALUES'; * Exported to a separate file to avoid circular dependencies. */ export default { - [VALUES.PROMPT.BIOMETRICS]: { + [VALUES.PROMPT.BIOMETRIC_HSM]: { illustration: LottieAnimations.Fingerprint, title: 'multifactorAuthentication.verifyYourself.biometrics', subtitle: 'multifactorAuthentication.enableQuickVerification.biometrics', diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts index c301ab33d55e6..47e7772cfe894 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts @@ -89,6 +89,7 @@ const NATIVE_BIOMETRIC_HSM_VALUES = { KEY_NOT_FOUND: 'KEY_NOT_FOUND', CREATE_KEYS_ERROR: 'CREATE_KEYS_ERROR', KEY_ALREADY_EXISTS: 'KEY_ALREADY_EXISTS', + KEY_ACCESS_FAILED: 'KEY_ACCESS_FAILED', // System cancel SYSTEM_CANCEL: 'SYSTEM_CANCEL', // iOS diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts index adecef7658979..31dc8f3be37b8 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts @@ -1,8 +1,7 @@ /** * Helper utilities for native biometrics HSM (react-native-biometrics). */ -import type {BiometricSensorInfo} from '@sbaiahmed1/react-native-biometrics'; -import {isSensorAvailable, sha256} from '@sbaiahmed1/react-native-biometrics'; +import {sha256} from '@sbaiahmed1/react-native-biometrics'; import {Buffer} from 'buffer'; import type {ValueOf} from 'type-fest'; import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; @@ -19,23 +18,6 @@ function getKeyAlias(accountID: number): string { return `${accountID}_${CONST.MULTIFACTOR_AUTHENTICATION.HSM_KEY_SUFFIX}`; } -/** - * Module-level cache for sensor availability, called once at module load. - */ -let sensorResult: BiometricSensorInfo = {available: false}; - -isSensorAvailable() - .then((result) => { - sensorResult = result; - }) - .catch(() => { - // sensorResult stays { available: false } - }); - -function getSensorResult(): BiometricSensorInfo { - return sensorResult; -} - /** * Maps authType number from signWithOptions (with returnAuthType: true) to AuthTypeInfo. * Native layer returns: -1=Unknown, 0=None, 1=DeviceCredentials, 2=Biometrics, 3=FaceID, 4=TouchID, 5=OpticID @@ -79,6 +61,7 @@ const SIGN_ERROR_CODE_MAP: Record = { [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRY_LOCKOUT_PERMANENT]: VALUES.REASON.HSM.LOCKOUT_PERMANENT, [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_LOCKOUT_PERMANENT]: VALUES.REASON.HSM.LOCKOUT_PERMANENT, [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.SIGNATURE_CREATION_FAILED]: VALUES.REASON.HSM.SIGNATURE_FAILED, + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ACCESS_FAILED]: VALUES.REASON.HSM.KEY_ACCESS_FAILED, }; function mapSignErrorCode(errorCode?: string): MultifactorAuthenticationReason | undefined { @@ -97,6 +80,7 @@ const LIBRARY_ERROR_CODE_MAP: Record = [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND]: VALUES.REASON.HSM.KEY_NOT_FOUND, [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ALREADY_EXISTS]: VALUES.REASON.HSM.KEY_CREATION_FAILED, [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.CREATE_KEYS_ERROR]: VALUES.REASON.HSM.KEY_CREATION_FAILED, + [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ACCESS_FAILED]: VALUES.REASON.HSM.KEY_ACCESS_FAILED, }; /** @@ -137,4 +121,4 @@ async function buildSigningData(rpId: string, challenge: string): Promise<{authe return {authenticatorData, clientDataJSON, dataToSignB64}; } -export {getKeyAlias, getSensorResult, mapAuthTypeNumber, mapSignErrorCode, mapLibraryError, buildSigningData}; +export {getKeyAlias, mapAuthTypeNumber, mapSignErrorCode, mapLibraryError, buildSigningData}; diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index 4e90bac6d9124..276f5f66085d8 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -114,6 +114,7 @@ const REASON = { KEY_NOT_FOUND: 'Key not found', SIGNATURE_FAILED: 'Signature creation failed', KEY_CREATION_FAILED: 'Key creation failed', + KEY_ACCESS_FAILED: 'Failed to access cryptographic key', GENERIC: 'An error occurred', }, } as const; @@ -271,6 +272,7 @@ const ANOMALOUS_FAILURES = new Set([ REASON.HSM.SIGNATURE_FAILED, REASON.HSM.KEY_NOT_FOUND, REASON.HSM.KEY_CREATION_FAILED, + REASON.HSM.KEY_ACCESS_FAILED, REASON.HSM.GENERIC, ]); diff --git a/src/libs/Navigation/useNavigateTo3DSAuthorizationChallenge.ts b/src/libs/Navigation/useNavigateTo3DSAuthorizationChallenge.ts index ebb49864ce4f2..43e2162986d75 100644 --- a/src/libs/Navigation/useNavigateTo3DSAuthorizationChallenge.ts +++ b/src/libs/Navigation/useNavigateTo3DSAuthorizationChallenge.ts @@ -116,22 +116,21 @@ function useNavigateTo3DSAuthorizationChallenge() { return; } - const doesDeviceSupportAnAllowedAuthenticationMethod = - doesDeviceSupportAuthenticationMethod() && - (AuthorizeTransaction.allowedAuthenticationMethods as Array>).includes(deviceVerificationType); - - // Do not navigate the user to the 3DS challenge if we can tell that they won't be able to complete it on this device - if (!doesDeviceSupportAnAllowedAuthenticationMethod) { - Log.info('[useNavigateTo3DSAuthorizationChallenge] Ignoring navigation - device does not support an allowed authentication method', undefined, { - transactionID: transactionPending3DSReview.transactionID, - }); - addBreadcrumb('Skipped - device unsupported', {transactionID: transactionPending3DSReview.transactionID}, 'warning'); - return; - } - let cancel = false; async function maybeNavigateTo3DSChallenge() { + const doesDeviceSupportAnAllowedAuthenticationMethod = + (await doesDeviceSupportAuthenticationMethod()) && + (AuthorizeTransaction.allowedAuthenticationMethods as Array>).includes(deviceVerificationType); + + // Do not navigate the user to the 3DS challenge if we can tell that they won't be able to complete it on this device + if (!doesDeviceSupportAnAllowedAuthenticationMethod) { + Log.info('[useNavigateTo3DSAuthorizationChallenge] Ignoring navigation - device does not support an allowed authentication method', undefined, { + transactionID: transactionPending3DSReview?.transactionID, + }); + addBreadcrumb('Skipped - device unsupported', {transactionID: transactionPending3DSReview?.transactionID}, 'warning'); + return; + } // It's actually not possible to reach this return. We're using an arrow function for the body of the effect, which captures the value // of transactionPending3DSReview. If the transactionID was undefined when we started the effect, we would've returned above, and if // it became undefined between then and now, Onyx will return a whole new object reference, so this effect will still be holding onto @@ -174,7 +173,7 @@ function useNavigateTo3DSAuthorizationChallenge() { return () => { cancel = true; }; - }, [transactionPending3DSReview?.transactionID, doesDeviceSupportAuthenticationMethod, deviceVerificationType, isCurrentlyActingOn3DSChallenge]); + }, [transactionPending3DSReview?.transactionID, deviceVerificationType, isCurrentlyActingOn3DSChallenge, doesDeviceSupportAuthenticationMethod]); } export default useNavigateTo3DSAuthorizationChallenge; diff --git a/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts b/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts index a42bfbccaf7a4..7e6ed22b0a60d 100644 --- a/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts +++ b/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts @@ -31,7 +31,7 @@ describe('MultifactorAuthentication Scenarios Config', () => { const biometricsTestScenario = config[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST]; expect(biometricsTestScenario).toBeDefined(); - expect(biometricsTestScenario.allowedAuthenticationMethods).toStrictEqual([CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS]); + expect(biometricsTestScenario.allowedAuthenticationMethods).toStrictEqual([CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRIC_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS]); expect(biometricsTestScenario.screen).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_TEST); expect(biometricsTestScenario.pure).toBe(true); expect(biometricsTestScenario.action).toBeDefined(); @@ -108,7 +108,7 @@ describe('MultifactorAuthentication Scenarios Config', () => { const setPinScenario = config[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.SET_PIN_ORDER_CARD]; expect(setPinScenario).toBeDefined(); - expect(setPinScenario.allowedAuthenticationMethods).toStrictEqual([CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS]); + expect(setPinScenario.allowedAuthenticationMethods).toStrictEqual([CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRIC_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS]); expect(setPinScenario.action).toBeDefined(); expect(setPinScenario.callback).toBeDefined(); expect(typeof setPinScenario.callback).toBe('function'); diff --git a/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts b/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts index a9a21835a44ab..61c8e5eb70b79 100644 --- a/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts +++ b/tests/unit/components/MultifactorAuthentication/useNativeBiometrics.test.ts @@ -98,24 +98,23 @@ describe('useNativeBiometrics hook', () => { it('should initialize info with biometrics status', async () => { const {result} = renderHook(() => useNativeBiometrics()); - expect(result.current.doesDeviceSupportAuthenticationMethod()).toBe(true); + await expect(result.current.doesDeviceSupportAuthenticationMethod()).resolves.toBe(true); await expect(result.current.getLocalCredentialID()).resolves.toBeUndefined(); await expect(result.current.areLocalCredentialsKnownToServer()).resolves.toBe(false); }); }); describe('doesDeviceSupportAuthenticationMethod', () => { - it('should return true when device supports biometrics', () => { + it('should return true when device supports biometrics', async () => { const {result} = renderHook(() => useNativeBiometrics()); - expect(typeof result.current.doesDeviceSupportAuthenticationMethod()).toBe('boolean'); - expect(result.current.doesDeviceSupportAuthenticationMethod()).toBe(true); + await expect(result.current.doesDeviceSupportAuthenticationMethod()).resolves.toBe(true); }); - it('should return boolean based on supportedAuthentication', () => { + it('should return boolean based on supportedAuthentication', async () => { const {result} = renderHook(() => useNativeBiometrics()); - const support = result.current.doesDeviceSupportAuthenticationMethod(); + const support = await result.current.doesDeviceSupportAuthenticationMethod(); const {biometrics, credentials} = PublicKeyStore.supportedAuthentication; const expectedValue = biometrics || credentials; diff --git a/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts index 6a9521e44d099..9baebf9d35668 100644 --- a/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts +++ b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts @@ -1,7 +1,5 @@ -import type {BiometricSensorInfo} from '@sbaiahmed1/react-native-biometrics'; import {act, renderHook} from '@testing-library/react-native'; import useNativeBiometricsHSM from '@components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM'; -import * as HSMHelpers from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; import type {AuthenticationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; @@ -37,6 +35,7 @@ const mockDeleteKeys = jest.fn(); const mockGetAllKeys = jest.fn(); const mockSignWithOptions = jest.fn(); const mockSha256 = jest.fn(); +const mockIsSensorAvailable = jest.fn(); jest.mock('@sbaiahmed1/react-native-biometrics', () => ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-return @@ -49,7 +48,8 @@ jest.mock('@sbaiahmed1/react-native-biometrics', () => ({ signWithOptions: (...args: unknown[]) => mockSignWithOptions(...args), // eslint-disable-next-line @typescript-eslint/no-unsafe-return sha256: (...args: unknown[]) => mockSha256(...args), - isSensorAvailable: jest.fn().mockResolvedValue({available: true, biometryType: 'FaceID', isDeviceSecure: true}), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + isSensorAvailable: (...args: unknown[]) => mockIsSensorAvailable(...args), InputEncoding: {Base64: 'base64'}, })); @@ -65,13 +65,13 @@ jest.mock('@components/MultifactorAuthentication/config', () => ({ })); jest.mock('@userActions/MultifactorAuthentication/processing'); -const DEFAULT_SENSOR_RESULT: BiometricSensorInfo = {available: true, biometryType: 'FaceID', isDeviceSecure: true}; +const DEFAULT_SENSOR_RESULT = {available: true, biometryType: 'FaceID', isDeviceSecure: true}; describe('useNativeBiometricsHSM hook', () => { beforeEach(() => { jest.clearAllMocks(); mockMultifactorAuthenticationPublicKeyIDs = []; - jest.spyOn(HSMHelpers, 'getSensorResult').mockReturnValue(DEFAULT_SENSOR_RESULT); + mockIsSensorAvailable.mockResolvedValue(DEFAULT_SENSOR_RESULT); mockGetAllKeys.mockResolvedValue({keys: []}); }); @@ -103,35 +103,35 @@ describe('useNativeBiometricsHSM hook', () => { }); describe('doesDeviceSupportAuthenticationMethod', () => { - it('should return true when sensor is available', () => { + it('should return true when sensor is available', async () => { // Given a device with a biometric sensor available (e.g., Face ID or Touch ID) // When checking device support for biometric authentication // Then it should return true because the device can perform biometric verification const {result} = renderHook(() => useNativeBiometricsHSM()); - expect(result.current.doesDeviceSupportAuthenticationMethod()).toBe(true); + await expect(result.current.doesDeviceSupportAuthenticationMethod()).resolves.toBe(true); }); - it('should return true when device is secure but no biometrics', () => { + it('should return true when device is secure but no biometrics', async () => { // Given a device without biometric hardware but with a secure lock screen (PIN/password) // When checking device support for biometric authentication // Then it should return true because device credentials can serve as a fallback verification method - jest.spyOn(HSMHelpers, 'getSensorResult').mockReturnValue({available: false, isDeviceSecure: true}); + mockIsSensorAvailable.mockResolvedValue({available: false, isDeviceSecure: true}); const {result} = renderHook(() => useNativeBiometricsHSM()); - expect(result.current.doesDeviceSupportAuthenticationMethod()).toBe(true); + await expect(result.current.doesDeviceSupportAuthenticationMethod()).resolves.toBe(true); }); - it('should return false when sensor unavailable and device not secure', () => { + it('should return false when sensor unavailable and device not secure', async () => { // Given a device with no biometric sensor and no secure lock screen configured // When checking device support for biometric authentication // Then it should return false because there is no way to verify the user's identity on this device - jest.spyOn(HSMHelpers, 'getSensorResult').mockReturnValue({available: false}); + mockIsSensorAvailable.mockResolvedValue({available: false}); const {result} = renderHook(() => useNativeBiometricsHSM()); - expect(result.current.doesDeviceSupportAuthenticationMethod()).toBe(false); + await expect(result.current.doesDeviceSupportAuthenticationMethod()).resolves.toBe(false); }); }); diff --git a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts index b9f2c0a7d904b..d2264056d2628 100644 --- a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts @@ -164,7 +164,14 @@ describe('NativeBiometricsHSM helpers', () => { // Given the exact error code for when the signing key does not exist in the keystore // When mapping the error code // Then it should resolve to KEYSTORE.KEY_NOT_FOUND - expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND)).toBe(VALUES.REASON.KEYSTORE.KEY_NOT_FOUND); + expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND)).toBe(VALUES.REASON.HSM.KEY_NOT_FOUND); + }); + + it('should return REGISTRATION_REQUIRED for key access failed error code', () => { + // Given the exact error code for when the key cannot be accessed (e.g. biometric enrollment changed) + // When mapping the error code + // Then it should resolve to KEYSTORE.REGISTRATION_REQUIRED to trigger re-registration + expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ACCESS_FAILED)).toBe(VALUES.REASON.HSM.KEY_ACCESS_FAILED); }); it('should return GENERIC for unrecognized error codes', () => { @@ -213,7 +220,15 @@ describe('NativeBiometricsHSM helpers', () => { // When mapping the library error // Then it should resolve to KEYSTORE.KEY_NOT_FOUND const error = Object.assign(new Error('Cryptographic key not found'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND}); - expect(mapLibraryError(error)).toBe(VALUES.REASON.KEYSTORE.KEY_NOT_FOUND); + expect(mapLibraryError(error)).toBe(VALUES.REASON.HSM.KEY_NOT_FOUND); + }); + + it('should return REGISTRATION_REQUIRED for Error with KEY_ACCESS_FAILED code', () => { + // Given an Error object with a code property matching the key access failed error code + // When mapping the library error + // Then it should resolve to KEYSTORE.REGISTRATION_REQUIRED to trigger re-registration + const error = Object.assign(new Error('Failed to access cryptographic key'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ACCESS_FAILED}); + expect(mapLibraryError(error)).toBe(VALUES.REASON.HSM.KEY_ACCESS_FAILED); }); it('should return undefined for Error without code property', () => { From 5129616264a0b3c74c8a73aee86db5edd4bc24a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Wed, 1 Apr 2026 17:27:14 +0200 Subject: [PATCH 21/41] removed useCallbacks, corrected an export --- .../biometrics/useNativeBiometricsHSM.ts | 19 +++++++++---------- .../NativeBiometricsHSM/types.ts | 3 +-- .../MultifactorAuthentication/shared/types.ts | 2 +- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts index 09ded13957d67..f46ffab6d68b9 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts @@ -1,10 +1,9 @@ import {createKeys, deleteKeys, getAllKeys, InputEncoding, isSensorAvailable, signWithOptions} from '@sbaiahmed1/react-native-biometrics'; -import {useCallback} from 'react'; import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import {buildSigningData, getKeyAlias, mapAuthTypeNumber, mapLibraryError, mapSignErrorCode} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; -import type {NativeBiometricsHSMKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/types'; +import type NativeBiometricsHSMKeyInfo from '@libs/MultifactorAuthentication/NativeBiometricsHSM/types'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; import Base64URL from '@src/utils/Base64URL'; @@ -21,12 +20,12 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { const {translate} = useLocalize(); const {serverKnownCredentialIDs, haveCredentialsEverBeenConfigured} = useServerCredentials(); - const doesDeviceSupportAuthenticationMethod = useCallback(async () => { + const doesDeviceSupportAuthenticationMethod = async () => { const sensorResult = await isSensorAvailable(); return sensorResult.isDeviceSecure ?? sensorResult.available; - }, []); + }; - const getLocalCredentialID = useCallback(async () => { + const getLocalCredentialID = async () => { try { const keyAlias = getKeyAlias(accountID); const {keys} = await getAllKeys(keyAlias); @@ -39,21 +38,21 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { addMFABreadcrumb('Failed to get local credential ID', {reason: mapLibraryError(e) ?? VALUES.REASON.HSM.GENERIC}, 'error'); return undefined; } - }, [accountID]); + }; - const areLocalCredentialsKnownToServer = useCallback(async () => { + const areLocalCredentialsKnownToServer = async () => { const key = await getLocalCredentialID(); return !!key && serverKnownCredentialIDs.includes(key); - }, [getLocalCredentialID, serverKnownCredentialIDs]); + }; - const deleteLocalKeysForAccount = useCallback(async () => { + const deleteLocalKeysForAccount = async () => { try { const keyAlias = getKeyAlias(accountID); await deleteKeys(keyAlias); } catch (e) { addMFABreadcrumb('Failed to delete local keys', {reason: mapLibraryError(e) ?? VALUES.REASON.HSM.GENERIC}, 'error'); } - }, [accountID]); + }; const register = async (onResult: (result: RegisterResult) => Promise | void, registrationChallenge: Parameters[1]) => { try { diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/types.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/types.ts index 154f213a1d648..532a2f7677636 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/types.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/types.ts @@ -17,5 +17,4 @@ type NativeBiometricsHSMKeyInfo = { }; }; -// eslint-disable-next-line import/prefer-default-export -export type {NativeBiometricsHSMKeyInfo}; +export default NativeBiometricsHSMKeyInfo; diff --git a/src/libs/MultifactorAuthentication/shared/types.ts b/src/libs/MultifactorAuthentication/shared/types.ts index 122ab209cd156..36e8775465455 100644 --- a/src/libs/MultifactorAuthentication/shared/types.ts +++ b/src/libs/MultifactorAuthentication/shared/types.ts @@ -5,7 +5,7 @@ import type {ValueOf} from 'type-fest'; import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioAdditionalParams} from '@components/MultifactorAuthentication/config/types'; import type {NativeBiometricsKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometrics/types'; -import type {NativeBiometricsHSMKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/types'; +import type NativeBiometricsHSMKeyInfo from '@libs/MultifactorAuthentication/NativeBiometricsHSM/types'; import type NATIVE_BIOMETRIC_HSM_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES'; import type {PasskeyRegistrationKeyInfo} from '@libs/MultifactorAuthentication/Passkeys/types'; import type {PASSKEY_AUTH_TYPE} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; From 154fc8f4f5d099a5682464a5fd5e44fbdea4725b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Wed, 1 Apr 2026 17:50:52 +0200 Subject: [PATCH 22/41] renamed error mapping functions --- .../biometrics/useNativeBiometricsHSM.ts | 22 ++++---- .../NativeBiometricsHSM/helpers.ts | 6 +- .../NativeBiometricsHSM/helpers.test.ts | 56 +++++++++---------- 3 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts index f46ffab6d68b9..8559bea97a57c 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts @@ -2,7 +2,7 @@ import {createKeys, deleteKeys, getAllKeys, InputEncoding, isSensorAvailable, si import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; -import {buildSigningData, getKeyAlias, mapAuthTypeNumber, mapLibraryError, mapSignErrorCode} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; +import {buildSigningData, getKeyAlias, mapAuthTypeNumber, mapLibraryErrorToReason, mapSignErrorCodeToReason} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; import type NativeBiometricsHSMKeyInfo from '@libs/MultifactorAuthentication/NativeBiometricsHSM/types'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; @@ -29,13 +29,13 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { try { const keyAlias = getKeyAlias(accountID); const {keys} = await getAllKeys(keyAlias); - const entry = keys.find((k) => k.alias === keyAlias); + const entry = keys.find((key) => key.alias === keyAlias); if (!entry) { return undefined; } return Base64URL.base64ToBase64url(entry.publicKey); - } catch (e) { - addMFABreadcrumb('Failed to get local credential ID', {reason: mapLibraryError(e) ?? VALUES.REASON.HSM.GENERIC}, 'error'); + } catch (error) { + addMFABreadcrumb('Failed to get local credential ID', {reason: mapLibraryErrorToReason(error) ?? VALUES.REASON.HSM.GENERIC}, 'error'); return undefined; } }; @@ -49,8 +49,8 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { try { const keyAlias = getKeyAlias(accountID); await deleteKeys(keyAlias); - } catch (e) { - addMFABreadcrumb('Failed to delete local keys', {reason: mapLibraryError(e) ?? VALUES.REASON.HSM.GENERIC}, 'error'); + } catch (error) { + addMFABreadcrumb('Failed to delete local keys', {reason: mapLibraryErrorToReason(error) ?? VALUES.REASON.HSM.GENERIC}, 'error'); } }; @@ -88,10 +88,10 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.LOCAL_REGISTRATION_COMPLETE, keyInfo, }); - } catch (e) { + } catch (error) { onResult({ success: false, - reason: mapLibraryError(e) ?? VALUES.REASON.HSM.KEY_CREATION_FAILED, + reason: mapLibraryErrorToReason(error) ?? VALUES.REASON.HSM.KEY_CREATION_FAILED, }); } }; @@ -124,7 +124,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { if (!signResult.success || !signResult.signature) { onResult({ success: false, - reason: mapSignErrorCode(signResult.errorCode) ?? VALUES.REASON.HSM.GENERIC, + reason: mapSignErrorCodeToReason(signResult.errorCode) ?? VALUES.REASON.HSM.GENERIC, }); return; } @@ -149,10 +149,10 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { }, authenticationMethod: authType, }); - } catch (e) { + } catch (error) { onResult({ success: false, - reason: mapLibraryError(e) ?? VALUES.REASON.HSM.GENERIC, + reason: mapLibraryErrorToReason(error) ?? VALUES.REASON.HSM.GENERIC, }); } }; diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts index 31dc8f3be37b8..d1f48498768fa 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts @@ -64,7 +64,7 @@ const SIGN_ERROR_CODE_MAP: Record = { [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ACCESS_FAILED]: VALUES.REASON.HSM.KEY_ACCESS_FAILED, }; -function mapSignErrorCode(errorCode?: string): MultifactorAuthenticationReason | undefined { +function mapSignErrorCodeToReason(errorCode?: string): MultifactorAuthenticationReason | undefined { if (!errorCode) { return undefined; } @@ -86,7 +86,7 @@ const LIBRARY_ERROR_CODE_MAP: Record = /** * Maps caught exceptions from the library to REASON values. */ -function mapLibraryError(error: unknown): MultifactorAuthenticationReason | undefined { +function mapLibraryErrorToReason(error: unknown): MultifactorAuthenticationReason | undefined { const code = error instanceof Error ? (error as Error & {code?: string}).code : undefined; if (code) { return LIBRARY_ERROR_CODE_MAP[code]; @@ -121,4 +121,4 @@ async function buildSigningData(rpId: string, challenge: string): Promise<{authe return {authenticatorData, clientDataJSON, dataToSignB64}; } -export {getKeyAlias, mapAuthTypeNumber, mapSignErrorCode, mapLibraryError, buildSigningData}; +export {getKeyAlias, mapAuthTypeNumber, mapSignErrorCodeToReason, mapLibraryErrorToReason, buildSigningData}; diff --git a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts index d2264056d2628..f8132226987ec 100644 --- a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts @@ -1,5 +1,5 @@ import {Buffer} from 'buffer'; -import {buildSigningData, getKeyAlias, mapAuthTypeNumber, mapLibraryError, mapSignErrorCode} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; +import {buildSigningData, getKeyAlias, mapAuthTypeNumber, mapLibraryErrorToReason, mapSignErrorCodeToReason} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; import NATIVE_BIOMETRIC_HSM_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; @@ -104,91 +104,91 @@ describe('NativeBiometricsHSM helpers', () => { }); }); - describe('mapSignErrorCode', () => { + describe('mapSignErrorCodeToReason', () => { it('should return undefined for undefined input', () => { // Given no error code was provided, which happens when the sign operation succeeds // When mapping the error code // Then undefined should be returned because there is no error to classify - expect(mapSignErrorCode(undefined)).toBeUndefined(); + expect(mapSignErrorCodeToReason(undefined)).toBeUndefined(); }); it('should return CANCELED for user cancel error codes', () => { // Given exact error code strings from the library indicating the user canceled the biometric prompt // When mapping the error codes // Then both iOS (USER_CANCEL) and Android (USER_CANCELED) variants should resolve to HSM.CANCELED - expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCEL)).toBe(VALUES.REASON.HSM.CANCELED); - expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCELED)).toBe(VALUES.REASON.HSM.CANCELED); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCEL)).toBe(VALUES.REASON.HSM.CANCELED); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCELED)).toBe(VALUES.REASON.HSM.CANCELED); }); it('should return CANCELED for system cancel error codes', () => { // Given exact error code strings indicating the system canceled authentication (e.g., app backgrounded) // When mapping the error codes // Then both iOS (SYSTEM_CANCEL) and Android (SYSTEM_CANCELED) variants should resolve to HSM.CANCELED - expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.SYSTEM_CANCEL)).toBe(VALUES.REASON.HSM.CANCELED); - expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.SYSTEM_CANCELED)).toBe(VALUES.REASON.HSM.CANCELED); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.SYSTEM_CANCEL)).toBe(VALUES.REASON.HSM.CANCELED); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.SYSTEM_CANCELED)).toBe(VALUES.REASON.HSM.CANCELED); }); it('should return NOT_AVAILABLE for biometric unavailability error codes', () => { // Given exact error codes indicating biometrics are not available on the device // When mapping the error codes // Then they should resolve to HSM.NOT_AVAILABLE so the app can guide the user to enable biometrics or use an alternative method - expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRY_NOT_AVAILABLE)).toBe(VALUES.REASON.HSM.NOT_AVAILABLE); - expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_NOT_AVAILABLE)).toBe(VALUES.REASON.HSM.NOT_AVAILABLE); - expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_UNAVAILABLE)).toBe(VALUES.REASON.HSM.NOT_AVAILABLE); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRY_NOT_AVAILABLE)).toBe(VALUES.REASON.HSM.NOT_AVAILABLE); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_NOT_AVAILABLE)).toBe(VALUES.REASON.HSM.NOT_AVAILABLE); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_UNAVAILABLE)).toBe(VALUES.REASON.HSM.NOT_AVAILABLE); }); it('should return LOCKOUT for temporary lockout error codes', () => { // Given exact error codes indicating temporary biometric lockout after too many failed attempts // When mapping the error codes // Then both iOS and Android variants should resolve to HSM.LOCKOUT - expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRY_LOCKOUT)).toBe(VALUES.REASON.HSM.LOCKOUT); - expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_LOCKOUT)).toBe(VALUES.REASON.HSM.LOCKOUT); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRY_LOCKOUT)).toBe(VALUES.REASON.HSM.LOCKOUT); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_LOCKOUT)).toBe(VALUES.REASON.HSM.LOCKOUT); }); it('should return LOCKOUT_PERMANENT for permanent lockout error codes', () => { // Given exact error codes indicating permanent biometric lockout requiring device credential to reset // When mapping the error codes // Then both iOS and Android variants should resolve to HSM.LOCKOUT_PERMANENT - expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRY_LOCKOUT_PERMANENT)).toBe(VALUES.REASON.HSM.LOCKOUT_PERMANENT); - expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_LOCKOUT_PERMANENT)).toBe(VALUES.REASON.HSM.LOCKOUT_PERMANENT); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRY_LOCKOUT_PERMANENT)).toBe(VALUES.REASON.HSM.LOCKOUT_PERMANENT); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_LOCKOUT_PERMANENT)).toBe(VALUES.REASON.HSM.LOCKOUT_PERMANENT); }); it('should return SIGNATURE_FAILED for signature creation failure', () => { // Given the exact error code for signature creation failure // When mapping the error code // Then it should resolve to HSM.SIGNATURE_FAILED - expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.SIGNATURE_CREATION_FAILED)).toBe(VALUES.REASON.HSM.SIGNATURE_FAILED); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.SIGNATURE_CREATION_FAILED)).toBe(VALUES.REASON.HSM.SIGNATURE_FAILED); }); it('should return KEY_NOT_FOUND for key not found error code', () => { // Given the exact error code for when the signing key does not exist in the keystore // When mapping the error code // Then it should resolve to KEYSTORE.KEY_NOT_FOUND - expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND)).toBe(VALUES.REASON.HSM.KEY_NOT_FOUND); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND)).toBe(VALUES.REASON.HSM.KEY_NOT_FOUND); }); it('should return REGISTRATION_REQUIRED for key access failed error code', () => { // Given the exact error code for when the key cannot be accessed (e.g. biometric enrollment changed) // When mapping the error code // Then it should resolve to KEYSTORE.REGISTRATION_REQUIRED to trigger re-registration - expect(mapSignErrorCode(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ACCESS_FAILED)).toBe(VALUES.REASON.HSM.KEY_ACCESS_FAILED); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ACCESS_FAILED)).toBe(VALUES.REASON.HSM.KEY_ACCESS_FAILED); }); it('should return GENERIC for unrecognized error codes', () => { // Given an error code that does not match any known library error code constant // When mapping the error code // Then it should fall back to HSM.GENERIC so the error is still surfaced to the user with a general error message - expect(mapSignErrorCode('some_unknown_error')).toBe(VALUES.REASON.HSM.GENERIC); + expect(mapSignErrorCodeToReason('some_unknown_error')).toBe(VALUES.REASON.HSM.GENERIC); }); }); - describe('mapLibraryError', () => { + describe('mapLibraryErrorToReason', () => { it('should return CANCELED for Error with USER_CANCEL code', () => { // Given an Error object with a code property matching the iOS user cancel error code // When mapping the library error // Then it should resolve to HSM.CANCELED based on the exact error code const error = Object.assign(new Error('User canceled authentication'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCEL}); - expect(mapLibraryError(error)).toBe(VALUES.REASON.HSM.CANCELED); + expect(mapLibraryErrorToReason(error)).toBe(VALUES.REASON.HSM.CANCELED); }); it('should return CANCELED for Error with USER_CANCELED code', () => { @@ -196,7 +196,7 @@ describe('NativeBiometricsHSM helpers', () => { // When mapping the library error // Then it should resolve to HSM.CANCELED based on the exact error code const error = Object.assign(new Error('User canceled the operation'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCELED}); - expect(mapLibraryError(error)).toBe(VALUES.REASON.HSM.CANCELED); + expect(mapLibraryErrorToReason(error)).toBe(VALUES.REASON.HSM.CANCELED); }); it('should return KEY_CREATION_FAILED for Error with CREATE_KEYS_ERROR code', () => { @@ -204,7 +204,7 @@ describe('NativeBiometricsHSM helpers', () => { // When mapping the library error // Then it should resolve to HSM.KEY_CREATION_FAILED const error = Object.assign(new Error('Failed to create keys'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.CREATE_KEYS_ERROR}); - expect(mapLibraryError(error)).toBe(VALUES.REASON.HSM.KEY_CREATION_FAILED); + expect(mapLibraryErrorToReason(error)).toBe(VALUES.REASON.HSM.KEY_CREATION_FAILED); }); it('should return KEY_CREATION_FAILED for Error with KEY_ALREADY_EXISTS code', () => { @@ -212,7 +212,7 @@ describe('NativeBiometricsHSM helpers', () => { // When mapping the library error // Then it should resolve to HSM.KEY_CREATION_FAILED since the key creation operation failed const error = Object.assign(new Error('Key already exists'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ALREADY_EXISTS}); - expect(mapLibraryError(error)).toBe(VALUES.REASON.HSM.KEY_CREATION_FAILED); + expect(mapLibraryErrorToReason(error)).toBe(VALUES.REASON.HSM.KEY_CREATION_FAILED); }); it('should return KEY_NOT_FOUND for Error with KEY_NOT_FOUND code', () => { @@ -220,7 +220,7 @@ describe('NativeBiometricsHSM helpers', () => { // When mapping the library error // Then it should resolve to KEYSTORE.KEY_NOT_FOUND const error = Object.assign(new Error('Cryptographic key not found'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND}); - expect(mapLibraryError(error)).toBe(VALUES.REASON.HSM.KEY_NOT_FOUND); + expect(mapLibraryErrorToReason(error)).toBe(VALUES.REASON.HSM.KEY_NOT_FOUND); }); it('should return REGISTRATION_REQUIRED for Error with KEY_ACCESS_FAILED code', () => { @@ -228,14 +228,14 @@ describe('NativeBiometricsHSM helpers', () => { // When mapping the library error // Then it should resolve to KEYSTORE.REGISTRATION_REQUIRED to trigger re-registration const error = Object.assign(new Error('Failed to access cryptographic key'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ACCESS_FAILED}); - expect(mapLibraryError(error)).toBe(VALUES.REASON.HSM.KEY_ACCESS_FAILED); + expect(mapLibraryErrorToReason(error)).toBe(VALUES.REASON.HSM.KEY_ACCESS_FAILED); }); it('should return undefined for Error without code property', () => { // Given an Error object without a code property (generic JS error, not from the library) // When mapping the library error // Then undefined should be returned because the error cannot be classified without a code - expect(mapLibraryError(new Error('Network error'))).toBeUndefined(); + expect(mapLibraryErrorToReason(new Error('Network error'))).toBeUndefined(); }); it('should return undefined for Error with unrecognized code', () => { @@ -243,14 +243,14 @@ describe('NativeBiometricsHSM helpers', () => { // When mapping the library error // Then undefined should be returned so the caller can provide a fallback reason const error = Object.assign(new Error('Some error'), {code: 'UNKNOWN_CODE'}); - expect(mapLibraryError(error)).toBeUndefined(); + expect(mapLibraryErrorToReason(error)).toBeUndefined(); }); it('should return undefined for non-Error values', () => { // Given a plain string error (not an Error object, so no code property) // When mapping the library error // Then undefined should be returned because only Error objects with code properties are classified - expect(mapLibraryError('some string error')).toBeUndefined(); + expect(mapLibraryErrorToReason('some string error')).toBeUndefined(); }); }); From 644efc917c27033508dce234850ba8297aab4f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Wed, 1 Apr 2026 18:08:33 +0200 Subject: [PATCH 23/41] remove unnecessary array search --- .../biometrics/useNativeBiometricsHSM.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts index 8559bea97a57c..8bb160a1acd0c 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts @@ -29,7 +29,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { try { const keyAlias = getKeyAlias(accountID); const {keys} = await getAllKeys(keyAlias); - const entry = keys.find((key) => key.alias === keyAlias); + const entry = keys.at(0); if (!entry) { return undefined; } From 8b3d5f09302790c3f460c5191c5e12b30aceefe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Wed, 1 Apr 2026 19:04:00 +0200 Subject: [PATCH 24/41] fix .slice deprecated and prettier --- cspell.json | 4 +--- .../NativeBiometricsHSM/helpers.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/cspell.json b/cspell.json index 91aecd07a70cf..7a0efd69a5d9c 100644 --- a/cspell.json +++ b/cspell.json @@ -964,8 +964,6 @@ "web/snippets/gib.js", "tests/unit/hooks/useLetterAvatars.test.tsx" ], - "ignoreRegExpList": [ - "@assets/.*" - ], + "ignoreRegExpList": ["@assets/.*"], "useGitignore": true } diff --git a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts index f8132226987ec..204244628be75 100644 --- a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts @@ -293,7 +293,7 @@ describe('NativeBiometricsHSM helpers', () => { // When building signing data // Then signCount bytes (indices 33-36) should all be zero as we don't track sign counts const result = await buildSigningData(rpId, challenge); - expect(result.authenticatorData.slice(33, 37)).toEqual(Buffer.alloc(4)); + expect(result.authenticatorData.subarray(33, 37)).toEqual(Buffer.alloc(4)); }); it('should embed rpIdHash as the first 32 bytes of authenticatorData', async () => { @@ -301,7 +301,7 @@ describe('NativeBiometricsHSM helpers', () => { // When building signing data // Then the first 32 bytes of authenticatorData should match the sha256 of rpId const result = await buildSigningData(rpId, challenge); - expect(result.authenticatorData.slice(0, 32)).toEqual(Buffer.alloc(32, 0xaa)); + expect(result.authenticatorData.subarray(0, 32)).toEqual(Buffer.alloc(32, 0xaa)); }); it('should return clientDataJSON as stringified JSON containing the challenge', async () => { From de72266ddc230bf22b62a482b0f5b74f897a4f36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Thu, 2 Apr 2026 12:29:44 +0200 Subject: [PATCH 25/41] fix usebiometricregistration test --- tests/unit/hooks/useBiometricRegistrationStatusTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/hooks/useBiometricRegistrationStatusTest.tsx b/tests/unit/hooks/useBiometricRegistrationStatusTest.tsx index fac2ae61ef75f..75926ebf1c511 100644 --- a/tests/unit/hooks/useBiometricRegistrationStatusTest.tsx +++ b/tests/unit/hooks/useBiometricRegistrationStatusTest.tsx @@ -9,7 +9,7 @@ let mockGetLocalCredentialID: jest.Mock; let mockServerKnownCredentialIDs: string[]; let mockHaveCredentialsEverBeenConfigured: boolean; -jest.mock('@components/MultifactorAuthentication/biometrics/useNativeBiometrics', () => ({ +jest.mock('@components/MultifactorAuthentication/biometrics/useBiometrics', () => ({ // eslint-disable-next-line @typescript-eslint/naming-convention __esModule: true, default: () => ({ From 324ae50256fe6c757e6006c24bdc640f76b8cba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Thu, 2 Apr 2026 16:56:38 +0200 Subject: [PATCH 26/41] added temporary patch, TODO: remove after bumping up the version --- ...0+001+biometry-temporary-new-release.patch | 389 ++++++++++++++++++ .../react-native-biometrics/details.md | 14 + 2 files changed, 403 insertions(+) create mode 100644 patches/@sbaiahmed1/react-native-biometrics/@sbaiahmed1+react-native-biometrics+0.14.0+001+biometry-temporary-new-release.patch create mode 100644 patches/@sbaiahmed1/react-native-biometrics/details.md diff --git a/patches/@sbaiahmed1/react-native-biometrics/@sbaiahmed1+react-native-biometrics+0.14.0+001+biometry-temporary-new-release.patch b/patches/@sbaiahmed1/react-native-biometrics/@sbaiahmed1+react-native-biometrics+0.14.0+001+biometry-temporary-new-release.patch new file mode 100644 index 0000000000000..c579d017ef643 --- /dev/null +++ b/patches/@sbaiahmed1/react-native-biometrics/@sbaiahmed1+react-native-biometrics+0.14.0+001+biometry-temporary-new-release.patch @@ -0,0 +1,389 @@ +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/README.md b/node_modules/@sbaiahmed1/react-native-biometrics/README.md +index 9fdbbf1..af3a1e2 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/README.md ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/README.md +@@ -305,6 +305,14 @@ android { + -keep class com.sbaiahmed1.reactnativebiometrics.** { *; } + ``` + ++### Importing Types on Non-Native Platforms ++ ++The `AuthType` and `BiometricStrength` enums can be safely imported from `@sbaiahmed1/react-native-biometrics/types` on non-mobile platforms (e.g. web), as this entry point does not load native modules. ++ ++```typescript ++import { AuthType, BiometricStrength } from '@sbaiahmed1/react-native-biometrics/types'; ++``` ++ + ## 📖 Usage + + ### 🔍 Quick Start +@@ -745,7 +753,7 @@ const isSensorAvailable = (): Promise => { + type SensorInfo = { + available: boolean; // Whether biometric auth is available + biometryType?: string; // Type of biometry ('FaceID', 'TouchID', 'Fingerprint', etc.) +- isDeviceSecure?: boolean; // Whether the device has a passcode/PIN/password set ++ isDeviceSecure: boolean; // Whether the device has a passcode/PIN/password set + error?: string; // Error message if not available + errorCode?: string; // Error code if not available (platform-specific) + } +@@ -827,6 +835,14 @@ type KeyResult = { + - `biometricStrength` (optional): Biometric strength requirement (`'strong'` or `'weak'`). + - `allowDeviceCredentials` (optional, default `false`): When `true`, the key can be unlocked by biometrics OR device credentials (PIN/passcode). Requires Android API 30+. + - `failIfExists` (optional, default `false`): When `true`, rejects with `KEY_ALREADY_EXISTS` if a key with the alias already exists instead of overwriting it. ++- `biometricStrength` (optional): Uses `BiometricStrength.Strong` or `BiometricStrength.Weak`. On iOS, `Strong` binds new keys to `.biometryCurrentSet`; `Weak`/unset uses `.biometryAny` for backward compatibility. ++- `allowDeviceCredentials` (optional, default `false`): When `true`, the key can be unlocked by biometrics OR device credentials (PIN/passcode). On iOS this uses `.userPresence` to allow passcode fallback; on Android this requires API 30+. ++- `failIfExists` (optional, default `false`): When `true`, rejects with `KEY_ALREADY_EXISTS` if a key with the alias already exists instead of overwriting it. ++ ++**iOS migration guidance** ++- Existing keys keep the access-control policy they were created with; this setting only affects newly created keys. ++- If you switch iOS key creation to `BiometricStrength.Strong`, recreate keys (`deleteKeys` + `createKeys`) to migrate. ++- Keys created with `.biometryCurrentSet` are invalidated when biometric enrollment changes. + + > 📖 **For detailed key type information, security considerations, and advanced usage patterns, see the [Cryptographic Keys Guide](./docs/CRYPTOGRAPHIC_KEYS.md)** + +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/android/src/main/java/com/sbaiahmed1/reactnativebiometrics/ReactNativeBiometricsSharedImpl.kt b/node_modules/@sbaiahmed1/react-native-biometrics/android/src/main/java/com/sbaiahmed1/reactnativebiometrics/ReactNativeBiometricsSharedImpl.kt +index ad238c8..ee32aeb 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/android/src/main/java/com/sbaiahmed1/reactnativebiometrics/ReactNativeBiometricsSharedImpl.kt ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/android/src/main/java/com/sbaiahmed1/reactnativebiometrics/ReactNativeBiometricsSharedImpl.kt +@@ -760,6 +760,7 @@ class ReactNativeBiometricsSharedImpl(private val context: ReactApplicationConte + val authTypeValue = when (authResult.authenticationType) { + BiometricPrompt.AUTHENTICATION_RESULT_TYPE_DEVICE_CREDENTIAL -> 1 + BiometricPrompt.AUTHENTICATION_RESULT_TYPE_BIOMETRIC -> 2 ++ BiometricPrompt.AUTHENTICATION_RESULT_TYPE_UNKNOWN -> -1 + else -> 0 + } + successResult.putInt("authType", authTypeValue) +@@ -1296,6 +1297,7 @@ class ReactNativeBiometricsSharedImpl(private val context: ReactApplicationConte + val authTypeValue = when (authResult.authenticationType) { + BiometricPrompt.AUTHENTICATION_RESULT_TYPE_DEVICE_CREDENTIAL -> 1 + BiometricPrompt.AUTHENTICATION_RESULT_TYPE_BIOMETRIC -> 2 ++ BiometricPrompt.AUTHENTICATION_RESULT_TYPE_UNKNOWN -> -1 + else -> 0 + } + result.putInt("authType", authTypeValue) +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/ios/ReactNativeBiometrics.swift b/node_modules/@sbaiahmed1/react-native-biometrics/ios/ReactNativeBiometrics.swift +index f44289c..58143f3 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/ios/ReactNativeBiometrics.swift ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/ios/ReactNativeBiometrics.swift +@@ -3,6 +3,7 @@ import LocalAuthentication + import React + import Security + import CryptoKit ++import CryptoTokenKit + + @objc(ReactNativeBiometrics) + class ReactNativeBiometrics: RCTEventEmitter { +@@ -34,6 +35,65 @@ class ReactNativeBiometrics: RCTEventEmitter { + return generateKeyAlias(customAlias: customAlias, configuredAlias: configuredKeyAlias) + } + ++ private func biometricDomainStateStorageKey(for keyTag: String) -> String { ++ return "ReactNativeBiometricsDomainState.\(keyTag)" ++ } ++ ++ private func clearStoredBiometricDomainState(for keyTag: String) { ++ UserDefaults.standard.removeObject(forKey: biometricDomainStateStorageKey(for: keyTag)) ++ } ++ ++ private func persistCurrentBiometricDomainState(for keyTag: String) { ++ let context = LAContext() ++ var error: NSError? ++ ++ guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error), ++ let domainState = context.evaluatedPolicyDomainState else { ++ clearStoredBiometricDomainState(for: keyTag) ++ return ++ } ++ ++ UserDefaults.standard.set( ++ domainState.base64EncodedString(), ++ forKey: biometricDomainStateStorageKey(for: keyTag) ++ ) ++ } ++ ++ private func hasBiometricDomainStateChanged(for keyTag: String) -> Bool { ++ guard let storedDomainStateBase64 = UserDefaults.standard.string( ++ forKey: biometricDomainStateStorageKey(for: keyTag) ++ ), ++ let storedDomainState = Data(base64Encoded: storedDomainStateBase64) else { ++ // Keys that do not use .biometryCurrentSet intentionally do not store domain state. ++ return false ++ } ++ ++ let context = LAContext() ++ var error: NSError? ++ ++ let canEvaluate = context.canEvaluatePolicy( ++ .deviceOwnerAuthenticationWithBiometrics, ++ error: &error ++ ) ++ ++ if !canEvaluate { ++ switch error.flatMap({ LAError.Code(rawValue: $0.code) }) { ++ case .biometryNotEnrolled: ++ return true ++ case .biometryLockout: ++ return false ++ default: ++ return false ++ } ++ } ++ ++ guard let currentDomainState = context.evaluatedPolicyDomainState else { ++ return true ++ } ++ ++ return storedDomainState != currentDomainState ++ } ++ + @objc + override static func requiresMainQueueSetup() -> Bool { + return false +@@ -392,6 +452,12 @@ class ReactNativeBiometrics: RCTEventEmitter { + biometricKeyType = .ec256 + } + ++ // iOS migration-safe behavior: ++ // - default/weak -> .biometryAny (backward-compatible with existing keys) ++ // - strong -> .biometryCurrentSet (invalidated on biometric enrollment change) ++ let biometricStrengthValue = (biometricStrength as String?)?.lowercased() ++ let useBiometryCurrentSet = biometricStrengthValue == "strong" ++ + // Check if key already exists when failIfExists is true + if failIfKeyExists { + let checkQuery = createKeychainQuery(keyTag: keyTag, includeSecureEnclave: biometricKeyType == .ec256) +@@ -430,7 +496,8 @@ class ReactNativeBiometrics: RCTEventEmitter { + // Create access control for biometric authentication + guard let accessControl = createBiometricAccessControl( + for: biometricKeyType, +- allowDeviceCredentialsFallback: deviceCredentialsFallback ++ allowDeviceCredentialsFallback: deviceCredentialsFallback, ++ useBiometryCurrentSet: useBiometryCurrentSet + ) else { + ReactNativeBiometricDebug.debugLog("createKeys failed - Could not create access control") + handleError(.accessControlCreationFailed, reject: reject) +@@ -470,6 +537,14 @@ class ReactNativeBiometrics: RCTEventEmitter { + "publicKey": publicKeyBase64 + ] + ++ let shouldPersistBiometricDomainState = !deviceCredentialsFallback && useBiometryCurrentSet ++ ++ if !shouldPersistBiometricDomainState { ++ clearStoredBiometricDomainState(for: keyTag) ++ } else { ++ persistCurrentBiometricDomainState(for: keyTag) ++ } ++ + ReactNativeBiometricDebug.debugLog("Keys created successfully with tag: \(keyTag), type: \(biometricKeyType)") + resolve(result) + } +@@ -497,6 +572,7 @@ class ReactNativeBiometrics: RCTEventEmitter { + let checkStatus = SecItemCopyMatching(query as CFDictionary, nil) + + if checkStatus == errSecItemNotFound { ++ clearStoredBiometricDomainState(for: keyTag) + ReactNativeBiometricDebug.debugLog("No key found with tag '\(keyTag)' - nothing to delete") + resolve(["success": true]) + return +@@ -507,11 +583,12 @@ class ReactNativeBiometrics: RCTEventEmitter { + + switch deleteStatus { + case errSecSuccess: +- ReactNativeBiometricDebug.debugLog("Key with tag '\(keyTag)' deleted successfully") ++ ReactNativeBiometricDebug.debugLog("Deletion succeeded for key with tag '\(keyTag)'; verifying removal") + + // Verify deletion + let verifyStatus = SecItemCopyMatching(query as CFDictionary, nil) + if verifyStatus == errSecItemNotFound { ++ clearStoredBiometricDomainState(for: keyTag) + ReactNativeBiometricDebug.debugLog("Keys deleted and verified successfully") + resolve(["success": true]) + } else { +@@ -520,6 +597,7 @@ class ReactNativeBiometrics: RCTEventEmitter { + } + + case errSecItemNotFound: ++ clearStoredBiometricDomainState(for: keyTag) + ReactNativeBiometricDebug.debugLog("No key found with tag '\(keyTag)' - nothing to delete") + resolve(["success": true]) + +@@ -710,6 +788,15 @@ class ReactNativeBiometrics: RCTEventEmitter { + checks["keyAccessible"] = true + checks["hardwareBacked"] = isHardwareBacked + ++ if hasBiometricDomainStateChanged(for: keyTag) { ++ checks["keyAccessible"] = false ++ integrityResult["integrityChecks"] = checks ++ integrityResult["error"] = ReactNativeBiometricsError.biometryCurrentSetChanged.errorInfo.message ++ ReactNativeBiometricDebug.debugLog("validateKeyIntegrity - Biometric enrollment change detected for keyTag: \(keyTag)") ++ resolve(integrityResult) ++ return ++ } ++ + // Perform signature test + let testData = "integrity_test_data".data(using: .utf8)! + let algorithm = getSignatureAlgorithm(for: keyRef) +@@ -740,6 +827,8 @@ class ReactNativeBiometrics: RCTEventEmitter { + biometricsError = .userCancel + } else if errorCode == errSecAuthFailed { + biometricsError = .authenticationFailed ++ } else if errorCode == TKError.Code.corruptedData.rawValue { ++ biometricsError = .keyAccessFailed + } else { + biometricsError = .signatureCreationFailed + } +@@ -879,6 +968,19 @@ class ReactNativeBiometrics: RCTEventEmitter { + + // Force cast SecKey since conditional downcast to CoreFoundation types always succeeds + let keyRef = result as! SecKey ++ ++ if hasBiometricDomainStateChanged(for: keyTag) { ++ let biometricsError = ReactNativeBiometricsError.biometryCurrentSetChanged ++ ReactNativeBiometricDebug.debugLog("verifyKeySignature failed - \(biometricsError.errorInfo.message)") ++ resolve([ ++ "success": false, ++ "error": biometricsError.errorInfo.message, ++ "errorCode": biometricsError.errorInfo.code, ++ "authType": 0 ++ ]) ++ return ++ } ++ + let algorithm = getSignatureAlgorithm(for: keyRef) + + // Decode data based on input encoding +@@ -910,6 +1012,8 @@ class ReactNativeBiometrics: RCTEventEmitter { + biometricsError = ReactNativeBiometricsError.userCancel + } else if errorCode == errSecAuthFailed { + biometricsError = ReactNativeBiometricsError.authenticationFailed ++ } else if errorCode == TKError.Code.corruptedData.rawValue { ++ biometricsError = ReactNativeBiometricsError.keyAccessFailed + } else { + biometricsError = ReactNativeBiometricsError.signatureCreationFailed + } +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/ios/ReactNativeBiometricsError.swift b/node_modules/@sbaiahmed1/react-native-biometrics/ios/ReactNativeBiometricsError.swift +index ed56895..3e5b93e 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/ios/ReactNativeBiometricsError.swift ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/ios/ReactNativeBiometricsError.swift +@@ -35,6 +35,7 @@ public enum ReactNativeBiometricsError: Error { + case keychainQueryFailed + case invalidKeyReference + case keyIntegrityCheckFailed ++ case biometryCurrentSetChanged + + case signatureCreationFailed + case signatureVerificationFailed +@@ -123,6 +124,11 @@ public enum ReactNativeBiometricsError: Error { + return ("INVALID_KEY_REFERENCE", "Invalid key reference") + case .keyIntegrityCheckFailed: + return ("KEY_INTEGRITY_CHECK_FAILED", "Key integrity verification failed") ++ case .biometryCurrentSetChanged: ++ return ( ++ "BIOMETRY_CURRENT_SET_CHANGED", ++ "Biometric enrollment changed. Re-enrollment required" ++ ) + + // Signature Errors + case .signatureCreationFailed: +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/ios/Utils.swift b/node_modules/@sbaiahmed1/react-native-biometrics/ios/Utils.swift +index 50010e0..427dcfc 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/ios/Utils.swift ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/ios/Utils.swift +@@ -160,19 +160,22 @@ public func createKeychainQuery( + */ + public func createBiometricAccessControl( + for keyType: BiometricKeyType = .ec256, +- allowDeviceCredentialsFallback: Bool = false ++ allowDeviceCredentialsFallback: Bool = false, ++ useBiometryCurrentSet: Bool = false + ) -> SecAccessControl? { + // Determine the authentication constraint: +- // - .biometryAny: biometrics only (no passcode fallback) +- // - .userPresence: biometry first, with passcode fallback if biometry fails or is unavailable ++ // - .biometryAny: biometrics only, supports legacy key behavior across enrollments. ++ // - .biometryCurrentSet: biometrics only, bound to the currently enrolled set. ++ // Any enrollment change invalidates the key and requires re-enrollment. ++ // - .userPresence: biometry first, with passcode fallback if biometry fails ++ // or is unavailable. This cannot be tied to the current biometric set. + let authConstraint: SecAccessControlCreateFlags = allowDeviceCredentialsFallback + ? .userPresence +- : .biometryAny ++ : (useBiometryCurrentSet ? .biometryCurrentSet : .biometryAny) + + // For RSA keys (not in Secure Enclave), we use access control matching old Objective-C implementation + if keyType == .rsa2048 { +- // Use kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly with authConstraint, preserving the old default behavior +- // (when allowDeviceCredentials is false, authConstraint = .biometryAny) ++ // Use kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly with authConstraint. + return SecAccessControlCreateWithFlags( + kCFAllocatorDefault, + kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, +@@ -311,14 +314,25 @@ public func exportPublicKeyToBase64(_ publicKey: SecKey) -> String? { + #if targetEnvironment(simulator) + /// Derives the appropriate LAPolicy from a key's SecAccessControl. + /// Compares the access control against known biometry-only configurations +-/// (the same .biometryAny / .userPresence split used in createBiometricAccessControl). +-/// .biometryAny -> .deviceOwnerAuthenticationWithBiometrics ++/// (the same split used in createBiometricAccessControl). ++/// .biometryAny/.biometryCurrentSet -> .deviceOwnerAuthenticationWithBiometrics + /// .userPresence -> .deviceOwnerAuthentication + public func deriveLAPolicy(from accessControl: SecAccessControl) -> LAPolicy { + // On simulator, .privateKeyUsage is omitted so the flags are just the auth constraint. +- +- if let ref = SecAccessControlCreateWithFlags(kCFAllocatorDefault, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .biometryAny, nil), +- CFEqual(accessControl, ref) { ++ if let currentSetRef = SecAccessControlCreateWithFlags( ++ kCFAllocatorDefault, ++ kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, ++ .biometryCurrentSet, ++ nil ++ ), CFEqual(accessControl, currentSetRef) { ++ return .deviceOwnerAuthenticationWithBiometrics ++ } ++ if let anyRef = SecAccessControlCreateWithFlags( ++ kCFAllocatorDefault, ++ kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, ++ .biometryAny, ++ nil ++ ), CFEqual(accessControl, anyRef) { + return .deviceOwnerAuthenticationWithBiometrics + } + +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/src/NativeReactNativeBiometrics.ts b/node_modules/@sbaiahmed1/react-native-biometrics/src/NativeReactNativeBiometrics.ts +index 02e63af..80f6178 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/src/NativeReactNativeBiometrics.ts ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/src/NativeReactNativeBiometrics.ts +@@ -20,7 +20,7 @@ export interface Spec extends TurboModule { + available: boolean; + biometryType?: 'Biometrics' | 'FaceID' | 'TouchID' | 'None' | 'Unknown'; + error?: string; +- isDeviceSecure?: boolean; ++ isDeviceSecure: boolean; + }>; + simplePrompt( + promptMessage: string, +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/src/index.tsx b/node_modules/@sbaiahmed1/react-native-biometrics/src/index.tsx +index a8ef764..1a8d4e9 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/src/index.tsx ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/src/index.tsx +@@ -668,7 +668,7 @@ export type BiometricSensorInfo = { + errorCode?: string; + fallbackUsed?: boolean; + biometricStrength?: BiometricStrength; +- isDeviceSecure?: boolean; ++ isDeviceSecure: boolean; + }; + + export type BiometricAuthOptions = { +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/src/types.ts b/node_modules/@sbaiahmed1/react-native-biometrics/src/types.ts +index cbfd187..7e9a545 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/src/types.ts ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/src/types.ts +@@ -10,6 +10,7 @@ export enum BiometricStrength { + * available on the device, due to platform limitations. + */ + export enum AuthType { ++ Unknown = -1, + None = 0, + DeviceCredentials = 1, + Biometrics = 2, diff --git a/patches/@sbaiahmed1/react-native-biometrics/details.md b/patches/@sbaiahmed1/react-native-biometrics/details.md new file mode 100644 index 0000000000000..64f25c9a56556 --- /dev/null +++ b/patches/@sbaiahmed1/react-native-biometrics/details.md @@ -0,0 +1,14 @@ +# `@sbaiahmed1/react-native-biometrics` patches + + +### [@sbaiahmed1+react-native-biometrics+0.14.0+001+biometry-temporary-new-release.patch](@sbaiahmed1+react-native-biometrics+0.14.0+001+biometry-temporary-new-release.patch) + +- Reason: + + ``` + Temporary patch to include unreleased changes from the upstream library. Will be removed once a new version is published to npm. + ``` + +- Upstream PR/issue: [#81](https://github.com/sbaiahmed1/react-native-biometrics/pull/81), [#84](https://github.com/sbaiahmed1/react-native-biometrics/pull/84), [#86](https://github.com/sbaiahmed1/react-native-biometrics/pull/86), [#87](https://github.com/sbaiahmed1/react-native-biometrics/pull/87) +- E/App issue: [#86440](https://github.com/Expensify/App/pull/86440) +- PR introducing patch: [#86310](https://github.com/Expensify/App/pull/86310) From fbb60e151ccb21ab3a6592c5991aaabc31208553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Thu, 2 Apr 2026 17:31:53 +0200 Subject: [PATCH 27/41] resolve comments --- jest/setup.ts | 2 +- package.json | 2 +- .../NativeBiometricsHSM/helpers.ts | 7 ++- .../shared/VALUES.ts | 4 +- .../useNativeBiometricsHSM.test.ts | 50 +++++++++++++++++++ .../NativeBiometricsHSM/helpers.test.ts | 8 +-- 6 files changed, 61 insertions(+), 12 deletions(-) diff --git a/jest/setup.ts b/jest/setup.ts index 88516f6788856..f9a2fd1989279 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -325,7 +325,7 @@ jest.mock('@shopify/react-native-skia', () => ({ jest.mock('@sbaiahmed1/react-native-biometrics', () => ({ isSensorAvailable: jest.fn(() => Promise.resolve({available: false})), createKeys: jest.fn(() => Promise.resolve({publicKey: ''})), - deleteKeys: jest.fn(() => Promise.resolve({keysDeleted: true})), + deleteKeys: jest.fn(() => Promise.resolve({success: true})), getAllKeys: jest.fn(() => Promise.resolve({keys: []})), signWithOptions: jest.fn(() => Promise.resolve({success: false})), sha256: jest.fn(() => Promise.resolve({hash: ''})), diff --git a/package.json b/package.json index ba2b208ac0e04..483de26ca7e87 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "@react-navigation/stack": "7.3.3", "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "10.1.44", - "@sbaiahmed1/react-native-biometrics": "^0.14.0", + "@sbaiahmed1/react-native-biometrics": "0.14.0", "@sentry/react-native": "8.2.0", "@shopify/flash-list": "2.3.0", "@shopify/react-native-skia": "^2.4.14", diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts index d1f48498768fa..56d476d6cc6b8 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts @@ -87,11 +87,10 @@ const LIBRARY_ERROR_CODE_MAP: Record = * Maps caught exceptions from the library to REASON values. */ function mapLibraryErrorToReason(error: unknown): MultifactorAuthenticationReason | undefined { - const code = error instanceof Error ? (error as Error & {code?: string}).code : undefined; - if (code) { - return LIBRARY_ERROR_CODE_MAP[code]; + if (!(error instanceof Error && 'code' in error && typeof error.code === 'string')) { + return undefined; } - return undefined; + return LIBRARY_ERROR_CODE_MAP[error.code]; } /** diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index 276f5f66085d8..8f81767a65637 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -107,7 +107,7 @@ const REASON = { GENERIC: 'An unknown WebAuthn error occurred', }, HSM: { - CANCELED: 'Authentication canceled by user', + CANCELED: 'Biometric authentication canceled by user', NOT_AVAILABLE: 'Biometric authentication not available', LOCKOUT: 'Biometric authentication locked out', LOCKOUT_PERMANENT: 'Biometric authentication permanently locked out', @@ -115,7 +115,7 @@ const REASON = { SIGNATURE_FAILED: 'Signature creation failed', KEY_CREATION_FAILED: 'Key creation failed', KEY_ACCESS_FAILED: 'Failed to access cryptographic key', - GENERIC: 'An error occurred', + GENERIC: 'An HSM error occurred', }, } as const; diff --git a/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts index 9baebf9d35668..cf689eb93ffbe 100644 --- a/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts +++ b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts @@ -412,6 +412,56 @@ describe('useNativeBiometricsHSM hook', () => { ); }); + it('should delete local keys and return KEY_NOT_FOUND when local credential is not in allowCredentials', async () => { + // Given a local HSM key exists but its credential ID does not match any ID in the challenge's allowCredentials list + // When the authorize flow checks for a matching credential + // Then it should delete the orphaned local key and return KEY_NOT_FOUND so the app can prompt re-registration + const keyAlias = '12345_HSM_KEY'; + mockGetAllKeys.mockResolvedValue({keys: [{alias: keyAlias, publicKey: 'abc+def/ghi='}]}); + + const challengeWithDifferentCredential: AuthenticationChallenge = { + ...mockChallenge, + allowCredentials: [{id: 'different-credential-id', type: 'public-key'}], + }; + + const {result} = renderHook(() => useNativeBiometricsHSM()); + const onResult = jest.fn(); + + await act(async () => { + await result.current.authorize({challenge: challengeWithDifferentCredential}, onResult); + }); + + expect(mockDeleteKeys).toHaveBeenCalledWith(keyAlias); + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + reason: VALUES.REASON.HSM.KEY_NOT_FOUND, + }), + ); + expect(mockSignWithOptions).not.toHaveBeenCalled(); + }); + + it('should return BAD_REQUEST when mapAuthTypeNumber returns undefined', async () => { + // Given the biometric sign operation succeeds but returns an unrecognized authType number + // When mapAuthTypeNumber cannot map the authType to a known value and returns undefined + // Then onResult should receive a failure with BAD_REQUEST because the response cannot be trusted without a valid auth type + mockSignWithOptions.mockResolvedValue({success: true, signature: 'c2lnbmF0dXJl', authType: 999}); + + const {result} = renderHook(() => useNativeBiometricsHSM()); + const onResult = jest.fn(); + + await act(async () => { + await result.current.authorize({challenge: mockChallenge}, onResult); + }); + + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + reason: VALUES.REASON.GENERIC.BAD_REQUEST, + }), + ); + }); + it('should handle thrown errors with unknown error code', async () => { // Given the biometric library throws an error without a recognized code property // When the authorize flow catches the thrown error diff --git a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts index 204244628be75..ca42e23c2ae88 100644 --- a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts @@ -163,14 +163,14 @@ describe('NativeBiometricsHSM helpers', () => { it('should return KEY_NOT_FOUND for key not found error code', () => { // Given the exact error code for when the signing key does not exist in the keystore // When mapping the error code - // Then it should resolve to KEYSTORE.KEY_NOT_FOUND + // Then it should resolve to HSM.KEY_NOT_FOUND expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND)).toBe(VALUES.REASON.HSM.KEY_NOT_FOUND); }); it('should return REGISTRATION_REQUIRED for key access failed error code', () => { // Given the exact error code for when the key cannot be accessed (e.g. biometric enrollment changed) // When mapping the error code - // Then it should resolve to KEYSTORE.REGISTRATION_REQUIRED to trigger re-registration + // Then it should resolve to HSM.KEY_ACCESS_FAILED to trigger re-registration expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ACCESS_FAILED)).toBe(VALUES.REASON.HSM.KEY_ACCESS_FAILED); }); @@ -218,7 +218,7 @@ describe('NativeBiometricsHSM helpers', () => { it('should return KEY_NOT_FOUND for Error with KEY_NOT_FOUND code', () => { // Given an Error object with a code property matching the key not found error code // When mapping the library error - // Then it should resolve to KEYSTORE.KEY_NOT_FOUND + // Then it should resolve to HSM.KEY_NOT_FOUND const error = Object.assign(new Error('Cryptographic key not found'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND}); expect(mapLibraryErrorToReason(error)).toBe(VALUES.REASON.HSM.KEY_NOT_FOUND); }); @@ -226,7 +226,7 @@ describe('NativeBiometricsHSM helpers', () => { it('should return REGISTRATION_REQUIRED for Error with KEY_ACCESS_FAILED code', () => { // Given an Error object with a code property matching the key access failed error code // When mapping the library error - // Then it should resolve to KEYSTORE.REGISTRATION_REQUIRED to trigger re-registration + // Then it should resolve to HSM.KEY_ACCESS_FAILED to trigger re-registration const error = Object.assign(new Error('Failed to access cryptographic key'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ACCESS_FAILED}); expect(mapLibraryErrorToReason(error)).toBe(VALUES.REASON.HSM.KEY_ACCESS_FAILED); }); From 92c941dc9c03b297073fbb1b544ec765031018c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Thu, 2 Apr 2026 18:07:08 +0200 Subject: [PATCH 28/41] modify usenativebiometricsHSM so it's compiled by rncompiler --- .../biometrics/useNativeBiometricsHSM.ts | 51 ++++++++++++++++--- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts index 8bb160a1acd0c..11c700506d5ca 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts @@ -1,4 +1,5 @@ import {createKeys, deleteKeys, getAllKeys, InputEncoding, isSensorAvailable, signWithOptions} from '@sbaiahmed1/react-native-biometrics'; +import type {SignatureResult} from '@sbaiahmed1/react-native-biometrics'; import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; @@ -10,6 +11,20 @@ import Base64URL from '@src/utils/Base64URL'; import type {AuthorizeParams, AuthorizeResult, RegisterResult, UseBiometricsReturn} from './shared/types'; import useServerCredentials from './shared/useServerCredentials'; +/** + * UTILS START + */ +function isCredentialAllowed(credentialID: string | undefined, allowedIDs: string[]): credentialID is string { + return !!credentialID && allowedIDs.includes(credentialID); +} + +function hasValidSignature(signResult: SignatureResult): signResult is SignatureResult & {signature: string} { + return signResult.success && !!signResult.signature; +} +/** + * UTILS END + */ + /** * Native biometrics hook using HSM-backed EC P-256 keys via react-native-biometrics. * All cryptographic operations happen in native code (Secure Enclave / Android Keystore). @@ -35,7 +50,11 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { } return Base64URL.base64ToBase64url(entry.publicKey); } catch (error) { - addMFABreadcrumb('Failed to get local credential ID', {reason: mapLibraryErrorToReason(error) ?? VALUES.REASON.HSM.GENERIC}, 'error'); + let reason = mapLibraryErrorToReason(error); + if (reason === undefined) { + reason = VALUES.REASON.HSM.GENERIC; + } + addMFABreadcrumb('Failed to get local credential ID', {reason}, 'error'); return undefined; } }; @@ -50,7 +69,11 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { const keyAlias = getKeyAlias(accountID); await deleteKeys(keyAlias); } catch (error) { - addMFABreadcrumb('Failed to delete local keys', {reason: mapLibraryErrorToReason(error) ?? VALUES.REASON.HSM.GENERIC}, 'error'); + let reason = mapLibraryErrorToReason(error); + if (reason === undefined) { + reason = VALUES.REASON.HSM.GENERIC; + } + addMFABreadcrumb('Failed to delete local keys', {reason}, 'error'); } }; @@ -89,9 +112,13 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { keyInfo, }); } catch (error) { + let reason = mapLibraryErrorToReason(error); + if (reason === undefined) { + reason = VALUES.REASON.HSM.KEY_CREATION_FAILED; + } onResult({ success: false, - reason: mapLibraryErrorToReason(error) ?? VALUES.REASON.HSM.KEY_CREATION_FAILED, + reason, }); } }; @@ -102,9 +129,9 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { try { const keyAlias = getKeyAlias(accountID); const credentialID = await getLocalCredentialID(); - const allowedIDs = challenge.allowCredentials?.map((credential: {id: string; type: string}) => credential.id) ?? []; + const allowedIDs = challenge.allowCredentials.map((credential: {id: string; type: string}) => credential.id); - if (!credentialID || !allowedIDs.includes(credentialID)) { + if (!isCredentialAllowed(credentialID, allowedIDs)) { await deleteLocalKeysForAccount(); onResult({success: false, reason: VALUES.REASON.HSM.KEY_NOT_FOUND}); return; @@ -121,10 +148,14 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { returnAuthType: true, }); - if (!signResult.success || !signResult.signature) { + if (!hasValidSignature(signResult)) { + let failReason = mapSignErrorCodeToReason(signResult.errorCode); + if (failReason === undefined) { + failReason = VALUES.REASON.HSM.GENERIC; + } onResult({ success: false, - reason: mapSignErrorCodeToReason(signResult.errorCode) ?? VALUES.REASON.HSM.GENERIC, + reason: failReason, }); return; } @@ -150,9 +181,13 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { authenticationMethod: authType, }); } catch (error) { + let reason = mapLibraryErrorToReason(error); + if (reason === undefined) { + reason = VALUES.REASON.HSM.GENERIC; + } onResult({ success: false, - reason: mapLibraryErrorToReason(error) ?? VALUES.REASON.HSM.GENERIC, + reason, }); } }; From d268009309fc3a8b863a8a98e7cd7ff79cf90850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Thu, 2 Apr 2026 18:17:48 +0200 Subject: [PATCH 29/41] fixed patch --- ...0+001+biometry-temporary-new-release.patch | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/patches/@sbaiahmed1/react-native-biometrics/@sbaiahmed1+react-native-biometrics+0.14.0+001+biometry-temporary-new-release.patch b/patches/@sbaiahmed1/react-native-biometrics/@sbaiahmed1+react-native-biometrics+0.14.0+001+biometry-temporary-new-release.patch index c579d017ef643..8b7d7eab546f4 100644 --- a/patches/@sbaiahmed1/react-native-biometrics/@sbaiahmed1+react-native-biometrics+0.14.0+001+biometry-temporary-new-release.patch +++ b/patches/@sbaiahmed1/react-native-biometrics/@sbaiahmed1+react-native-biometrics+0.14.0+001+biometry-temporary-new-release.patch @@ -349,6 +349,28 @@ index 50010e0..427dcfc 100644 return .deviceOwnerAuthenticationWithBiometrics } +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/package.json b/node_modules/@sbaiahmed1/react-native-biometrics/package.json +index 51dae57..06cef2c 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/package.json ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/package.json +@@ -16,6 +16,17 @@ + "default": "./lib/commonjs/index.js" + } + }, ++ "./types": { ++ "source": "./src/types.ts", ++ "import": { ++ "types": "./lib/typescript/module/src/types.d.ts", ++ "default": "./lib/module/types.js" ++ }, ++ "require": { ++ "types": "./lib/typescript/commonjs/src/types.d.ts", ++ "default": "./lib/commonjs/types.js" ++ } ++ }, + "./package.json": "./package.json" + }, + "files": [ diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/src/NativeReactNativeBiometrics.ts b/node_modules/@sbaiahmed1/react-native-biometrics/src/NativeReactNativeBiometrics.ts index 02e63af..80f6178 100644 --- a/node_modules/@sbaiahmed1/react-native-biometrics/src/NativeReactNativeBiometrics.ts From 38294d8d25feacb7deebd26021c30f65edafe3fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Thu, 2 Apr 2026 18:35:54 +0200 Subject: [PATCH 30/41] rename biometric_hsm to biometrics_hsm --- .../biometrics/useNativeBiometricsHSM.ts | 6 +- .../config/scenarios/AuthorizeTransaction.tsx | 2 +- .../config/scenarios/BiometricsTest.tsx | 2 +- .../config/scenarios/ChangePIN.tsx | 2 +- .../config/scenarios/RevealPIN.tsx | 2 +- .../config/scenarios/SetPINOrderCard.tsx | 2 +- .../config/scenarios/names.ts | 4 +- .../config/scenarios/prompts.ts | 2 +- .../NativeBiometricsHSM/VALUES.ts | 6 +- .../NativeBiometricsHSM/helpers.ts | 58 +++++++++---------- .../NativeBiometricsHSM/types.ts | 2 +- src/libs/MultifactorAuthentication/VALUES.ts | 4 +- .../shared/VALUES.ts | 4 +- .../MultifactorAuthentication/shared/types.ts | 8 +-- .../config/scenarios/index.test.ts | 4 +- .../useNativeBiometricsHSM.test.ts | 6 +- .../NativeBiometricsHSM/helpers.test.ts | 48 +++++++-------- 17 files changed, 81 insertions(+), 81 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts index 11c700506d5ca..a4e4bad13982a 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts @@ -96,7 +96,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { const clientDataJSON = JSON.stringify({challenge: registrationChallenge.challenge}); const keyInfo: NativeBiometricsHSMKeyInfo = { rawId: credentialID, - type: CONST.MULTIFACTOR_AUTHENTICATION.BIOMETRIC_HSM_TYPE, + type: CONST.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_HSM_TYPE, response: { clientDataJSON: Base64URL.encode(clientDataJSON), biometric: { @@ -171,7 +171,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { reason: VALUES.REASON.CHALLENGE.CHALLENGE_SIGNED, signedChallenge: { rawId: credentialID, - type: CONST.MULTIFACTOR_AUTHENTICATION.BIOMETRIC_HSM_TYPE, + type: CONST.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_HSM_TYPE, response: { authenticatorData: Base64URL.base64ToBase64url(authenticatorData.toString('base64')), clientDataJSON: Base64URL.encode(clientDataJSON), @@ -195,7 +195,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { const hasLocalCredentials = async () => !!(await getLocalCredentialID()); return { - deviceVerificationType: CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRIC_HSM, + deviceVerificationType: CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM, serverKnownCredentialIDs, haveCredentialsEverBeenConfigured, getLocalCredentialID, diff --git a/src/components/MultifactorAuthentication/config/scenarios/AuthorizeTransaction.tsx b/src/components/MultifactorAuthentication/config/scenarios/AuthorizeTransaction.tsx index 103f4fc78b746..a09f46a158eb3 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/AuthorizeTransaction.tsx +++ b/src/components/MultifactorAuthentication/config/scenarios/AuthorizeTransaction.tsx @@ -124,7 +124,7 @@ export { export default { // Allowed methods are hardcoded here; keep in sync with allowedAuthenticationMethods in useNavigateTo3DSAuthorizationChallenge. - allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRIC_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], + allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], action: authorizeTransaction, // AuthorizeTransaction's callback navigates to the outcome screen, but if it knows the user is going to see an error outcome, we explicitly deny the transaction to make sure the user can't re-approve it on another device diff --git a/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.tsx b/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.tsx index 51ef272ceeb46..1b6dc20b85b98 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.tsx +++ b/src/components/MultifactorAuthentication/config/scenarios/BiometricsTest.tsx @@ -7,7 +7,7 @@ import CONST from '@src/CONST'; import SCREENS from '@src/SCREENS'; export default { - allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRIC_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], + allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], action: troubleshootMultifactorAuthentication, screen: SCREENS.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_TEST, pure: true, diff --git a/src/components/MultifactorAuthentication/config/scenarios/ChangePIN.tsx b/src/components/MultifactorAuthentication/config/scenarios/ChangePIN.tsx index d4a2f111f64f1..c9aa776785401 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/ChangePIN.tsx +++ b/src/components/MultifactorAuthentication/config/scenarios/ChangePIN.tsx @@ -47,7 +47,7 @@ const ChangePINSuccessScreen = createScreenWithDefaults( * This scenario is used when a UK/EU cardholder changes the PIN of their physical card. */ export default { - allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRIC_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], + allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], action: changePINForCard, successScreen: , defaultClientFailureScreen: , diff --git a/src/components/MultifactorAuthentication/config/scenarios/RevealPIN.tsx b/src/components/MultifactorAuthentication/config/scenarios/RevealPIN.tsx index 0463cefc4685a..9c6373c2ec475 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/RevealPIN.tsx +++ b/src/components/MultifactorAuthentication/config/scenarios/RevealPIN.tsx @@ -52,7 +52,7 @@ const ServerFailureScreen = createScreenWithDefaults( * - Authentication failure: Return SHOW_OUTCOME_SCREEN to show failure screen */ export default { - allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRIC_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], + allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], action: revealPINForCard, callback: async (isSuccessful, callbackInput, payload) => { if (isSuccessful && isRevealPINPayload(payload)) { diff --git a/src/components/MultifactorAuthentication/config/scenarios/SetPINOrderCard.tsx b/src/components/MultifactorAuthentication/config/scenarios/SetPINOrderCard.tsx index a642277ac1f96..83f058d4ee41d 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/SetPINOrderCard.tsx +++ b/src/components/MultifactorAuthentication/config/scenarios/SetPINOrderCard.tsx @@ -65,7 +65,7 @@ const ServerFailureScreen = createScreenWithDefaults( * - Authentication failure: Return SHOW_OUTCOME_SCREEN to show failure screen */ export default { - allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRIC_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], + allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS], action: setPersonalDetailsAndShipExpensifyCardsWithPIN, callback: async (isSuccessful, _callbackInput, payload) => { diff --git a/src/components/MultifactorAuthentication/config/scenarios/names.ts b/src/components/MultifactorAuthentication/config/scenarios/names.ts index 707169b993872..e51e440d638df 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/names.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/names.ts @@ -16,10 +16,10 @@ const SCENARIO_NAMES = { /** * Prompt identifiers for multifactor authentication scenarios. - * TODO: update the BIOMETRIC_HSM type + * TODO: update the BIOMETRICS_HSM type */ const PROMPT_NAMES = { - BIOMETRIC_HSM: 'biometrics', + BIOMETRICS_HSM: 'biometrics', BIOMETRICS: 'biometrics', PASSKEYS: 'passkeys', } as const; diff --git a/src/components/MultifactorAuthentication/config/scenarios/prompts.ts b/src/components/MultifactorAuthentication/config/scenarios/prompts.ts index 4236faacffe30..2d220a382195f 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/prompts.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/prompts.ts @@ -8,7 +8,7 @@ import VALUES from '@libs/MultifactorAuthentication/VALUES'; * Exported to a separate file to avoid circular dependencies. */ export default { - [VALUES.PROMPT.BIOMETRIC_HSM]: { + [VALUES.PROMPT.BIOMETRICS_HSM]: { illustration: LottieAnimations.Fingerprint, title: 'multifactorAuthentication.verifyYourself.biometrics', subtitle: 'multifactorAuthentication.enableQuickVerification.biometrics', diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts index 47e7772cfe894..c27368001067f 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts @@ -4,11 +4,11 @@ // import {AuthType} from '@sbaiahmed1/react-native-biometrics/types'; import MARQETA_VALUES from '@libs/MultifactorAuthentication/shared/MarqetaValues'; -const NATIVE_BIOMETRIC_HSM_VALUES = { +const NATIVE_BIOMETRICS_HSM_VALUES = { /** * HSM key type identifier */ - BIOMETRIC_HSM_TYPE: 'biometric-hsm', + BIOMETRICS_HSM_TYPE: 'biometric-hsm', /** * Key alias suffix for HSM keys managed by react-native-biometrics. @@ -97,4 +97,4 @@ const NATIVE_BIOMETRIC_HSM_VALUES = { }, } as const; -export default NATIVE_BIOMETRIC_HSM_VALUES; +export default NATIVE_BIOMETRICS_HSM_VALUES; diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts index 56d476d6cc6b8..9ddb8dec29c7e 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts @@ -7,9 +7,9 @@ import type {ValueOf} from 'type-fest'; import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; -import NATIVE_BIOMETRIC_HSM_VALUES from './VALUES'; +import NATIVE_BIOMETRICS_HSM_VALUES from './VALUES'; -type NativeBiometricsHSMTypeEntry = ValueOf; +type NativeBiometricsHSMTypeEntry = ValueOf; /** * Builds the key alias for a given account. @@ -23,13 +23,13 @@ function getKeyAlias(accountID: number): string { * Native layer returns: -1=Unknown, 0=None, 1=DeviceCredentials, 2=Biometrics, 3=FaceID, 4=TouchID, 5=OpticID */ const AUTH_TYPE_NUMBER_MAP = new Map([ - [-1, NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.UNKNOWN], - [0, NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.NONE], - [1, NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.CREDENTIALS], - [2, NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.BIOMETRICS], - [3, NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.FACE_ID], - [4, NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.TOUCH_ID], - [5, NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.OPTIC_ID], + [-1, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.UNKNOWN], + [0, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.NONE], + [1, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.CREDENTIALS], + [2, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.BIOMETRICS], + [3, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.FACE_ID], + [4, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.TOUCH_ID], + [5, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.OPTIC_ID], ]); function mapAuthTypeNumber(authType?: number): AuthTypeInfo | undefined { @@ -48,20 +48,20 @@ function mapAuthTypeNumber(authType?: number): AuthTypeInfo | undefined { * Uses exact error code matching against constants from ERRORS.md. */ const SIGN_ERROR_CODE_MAP: Record = { - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCEL]: VALUES.REASON.HSM.CANCELED, - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCELED]: VALUES.REASON.HSM.CANCELED, - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.SYSTEM_CANCEL]: VALUES.REASON.HSM.CANCELED, - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.SYSTEM_CANCELED]: VALUES.REASON.HSM.CANCELED, - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND]: VALUES.REASON.HSM.KEY_NOT_FOUND, - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRY_NOT_AVAILABLE]: VALUES.REASON.HSM.NOT_AVAILABLE, - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_NOT_AVAILABLE]: VALUES.REASON.HSM.NOT_AVAILABLE, - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_UNAVAILABLE]: VALUES.REASON.HSM.NOT_AVAILABLE, - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRY_LOCKOUT]: VALUES.REASON.HSM.LOCKOUT, - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_LOCKOUT]: VALUES.REASON.HSM.LOCKOUT, - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRY_LOCKOUT_PERMANENT]: VALUES.REASON.HSM.LOCKOUT_PERMANENT, - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_LOCKOUT_PERMANENT]: VALUES.REASON.HSM.LOCKOUT_PERMANENT, - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.SIGNATURE_CREATION_FAILED]: VALUES.REASON.HSM.SIGNATURE_FAILED, - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ACCESS_FAILED]: VALUES.REASON.HSM.KEY_ACCESS_FAILED, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.USER_CANCEL]: VALUES.REASON.HSM.CANCELED, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.USER_CANCELED]: VALUES.REASON.HSM.CANCELED, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.SYSTEM_CANCEL]: VALUES.REASON.HSM.CANCELED, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.SYSTEM_CANCELED]: VALUES.REASON.HSM.CANCELED, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND]: VALUES.REASON.HSM.KEY_NOT_FOUND, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.BIOMETRY_NOT_AVAILABLE]: VALUES.REASON.HSM.NOT_AVAILABLE, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.BIOMETRIC_NOT_AVAILABLE]: VALUES.REASON.HSM.NOT_AVAILABLE, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.BIOMETRIC_UNAVAILABLE]: VALUES.REASON.HSM.NOT_AVAILABLE, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.BIOMETRY_LOCKOUT]: VALUES.REASON.HSM.LOCKOUT, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.BIOMETRIC_LOCKOUT]: VALUES.REASON.HSM.LOCKOUT, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.BIOMETRY_LOCKOUT_PERMANENT]: VALUES.REASON.HSM.LOCKOUT_PERMANENT, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.BIOMETRIC_LOCKOUT_PERMANENT]: VALUES.REASON.HSM.LOCKOUT_PERMANENT, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.SIGNATURE_CREATION_FAILED]: VALUES.REASON.HSM.SIGNATURE_FAILED, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.KEY_ACCESS_FAILED]: VALUES.REASON.HSM.KEY_ACCESS_FAILED, }; function mapSignErrorCodeToReason(errorCode?: string): MultifactorAuthenticationReason | undefined { @@ -75,12 +75,12 @@ function mapSignErrorCodeToReason(errorCode?: string): MultifactorAuthentication * Maps errorCode strings from rejected library promises to REASON values. */ const LIBRARY_ERROR_CODE_MAP: Record = { - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCEL]: VALUES.REASON.HSM.CANCELED, - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCELED]: VALUES.REASON.HSM.CANCELED, - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND]: VALUES.REASON.HSM.KEY_NOT_FOUND, - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ALREADY_EXISTS]: VALUES.REASON.HSM.KEY_CREATION_FAILED, - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.CREATE_KEYS_ERROR]: VALUES.REASON.HSM.KEY_CREATION_FAILED, - [NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ACCESS_FAILED]: VALUES.REASON.HSM.KEY_ACCESS_FAILED, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.USER_CANCEL]: VALUES.REASON.HSM.CANCELED, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.USER_CANCELED]: VALUES.REASON.HSM.CANCELED, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND]: VALUES.REASON.HSM.KEY_NOT_FOUND, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.KEY_ALREADY_EXISTS]: VALUES.REASON.HSM.KEY_CREATION_FAILED, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.CREATE_KEYS_ERROR]: VALUES.REASON.HSM.KEY_CREATION_FAILED, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.KEY_ACCESS_FAILED]: VALUES.REASON.HSM.KEY_ACCESS_FAILED, }; /** diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/types.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/types.ts index 532a2f7677636..a47f2f1cc5000 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/types.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/types.ts @@ -7,7 +7,7 @@ import type VALUES from './VALUES'; type NativeBiometricsHSMKeyInfo = { rawId: Base64URLString; - type: typeof VALUES.BIOMETRIC_HSM_TYPE; + type: typeof VALUES.BIOMETRICS_HSM_TYPE; response: { clientDataJSON: Base64URLString; biometric: { diff --git a/src/libs/MultifactorAuthentication/VALUES.ts b/src/libs/MultifactorAuthentication/VALUES.ts index 156eff3267307..5a8e72a0b1434 100644 --- a/src/libs/MultifactorAuthentication/VALUES.ts +++ b/src/libs/MultifactorAuthentication/VALUES.ts @@ -4,14 +4,14 @@ * This ensures CONST.MULTIFACTOR_AUTHENTICATION continues working everywhere. */ import NATIVE_BIOMETRICS_VALUES from './NativeBiometrics/VALUES'; -import NATIVE_BIOMETRIC_HSM_VALUES from './NativeBiometricsHSM/VALUES'; +import NATIVE_BIOMETRICS_HSM_VALUES from './NativeBiometricsHSM/VALUES'; import PASSKEY_VALUES from './Passkeys/VALUES'; import SHARED_VALUES from './shared/VALUES'; const MULTIFACTOR_AUTHENTICATION_VALUES = { ...SHARED_VALUES, ...NATIVE_BIOMETRICS_VALUES, - ...NATIVE_BIOMETRIC_HSM_VALUES, + ...NATIVE_BIOMETRICS_HSM_VALUES, ...PASSKEY_VALUES, } as const; diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index 8f81767a65637..01131ca53f9fb 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -291,7 +291,7 @@ const SHARED_VALUES = { * Maps authentication type to the corresponding prompt type. */ PROMPT_TYPE_MAP: { - BIOMETRIC_HSM: PROMPT_NAMES.BIOMETRIC_HSM, + BIOMETRICS_HSM: PROMPT_NAMES.BIOMETRICS_HSM, BIOMETRICS: PROMPT_NAMES.BIOMETRICS, PASSKEYS: PROMPT_NAMES.PASSKEYS, }, @@ -300,7 +300,7 @@ const SHARED_VALUES = { * Authentication type identifiers. */ TYPE: { - BIOMETRIC_HSM: 'BIOMETRIC_HSM', + BIOMETRICS_HSM: 'BIOMETRIC_HSM', BIOMETRICS: 'BIOMETRICS', PASSKEYS: 'PASSKEYS', }, diff --git a/src/libs/MultifactorAuthentication/shared/types.ts b/src/libs/MultifactorAuthentication/shared/types.ts index 36e8775465455..98cd57160c22d 100644 --- a/src/libs/MultifactorAuthentication/shared/types.ts +++ b/src/libs/MultifactorAuthentication/shared/types.ts @@ -6,7 +6,7 @@ import type {ValueOf} from 'type-fest'; import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioAdditionalParams} from '@components/MultifactorAuthentication/config/types'; import type {NativeBiometricsKeyInfo} from '@libs/MultifactorAuthentication/NativeBiometrics/types'; import type NativeBiometricsHSMKeyInfo from '@libs/MultifactorAuthentication/NativeBiometricsHSM/types'; -import type NATIVE_BIOMETRIC_HSM_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES'; +import type NATIVE_BIOMETRICS_HSM_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES'; import type {PasskeyRegistrationKeyInfo} from '@libs/MultifactorAuthentication/Passkeys/types'; import type {PASSKEY_AUTH_TYPE} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; import type {SignedChallenge} from './challengeTypes'; @@ -15,9 +15,9 @@ import type VALUES from './VALUES'; /** * Authentication type name derived from react-native-biometrics values and passkey auth type. */ -type AuthTypeName = ValueOf['NAME'] | (typeof PASSKEY_AUTH_TYPE)['NAME']; +type AuthTypeName = ValueOf['NAME'] | (typeof PASSKEY_AUTH_TYPE)['NAME']; -type MarqetaAuthTypeName = ValueOf['MARQETA_VALUE'] | (typeof PASSKEY_AUTH_TYPE)['MARQETA_VALUE']; +type MarqetaAuthTypeName = ValueOf['MARQETA_VALUE'] | (typeof PASSKEY_AUTH_TYPE)['MARQETA_VALUE']; type AuthTypeInfo = { code?: MultifactorAuthenticationMethodCode; @@ -25,7 +25,7 @@ type AuthTypeInfo = { marqetaValue: MarqetaAuthTypeName; }; -type MultifactorAuthenticationMethodCode = ValueOf['CODE']; +type MultifactorAuthenticationMethodCode = ValueOf['CODE']; /** * Represents the reason for a multifactor authentication response from the backend. diff --git a/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts b/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts index 7e6ed22b0a60d..4916ee71a705c 100644 --- a/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts +++ b/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts @@ -31,7 +31,7 @@ describe('MultifactorAuthentication Scenarios Config', () => { const biometricsTestScenario = config[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST]; expect(biometricsTestScenario).toBeDefined(); - expect(biometricsTestScenario.allowedAuthenticationMethods).toStrictEqual([CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRIC_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS]); + expect(biometricsTestScenario.allowedAuthenticationMethods).toStrictEqual([CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS]); expect(biometricsTestScenario.screen).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_TEST); expect(biometricsTestScenario.pure).toBe(true); expect(biometricsTestScenario.action).toBeDefined(); @@ -108,7 +108,7 @@ describe('MultifactorAuthentication Scenarios Config', () => { const setPinScenario = config[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.SET_PIN_ORDER_CARD]; expect(setPinScenario).toBeDefined(); - expect(setPinScenario.allowedAuthenticationMethods).toStrictEqual([CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRIC_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS]); + expect(setPinScenario.allowedAuthenticationMethods).toStrictEqual([CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM, CONST.MULTIFACTOR_AUTHENTICATION.TYPE.PASSKEYS]); expect(setPinScenario.action).toBeDefined(); expect(setPinScenario.callback).toBeDefined(); expect(typeof setPinScenario.callback).toBe('function'); diff --git a/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts index cf689eb93ffbe..97b3816aedcc6 100644 --- a/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts +++ b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts @@ -98,7 +98,7 @@ describe('useNativeBiometricsHSM hook', () => { // Then it should report BIOMETRICS as its device verification type so the MFA system can distinguish it from other verification methods const {result} = renderHook(() => useNativeBiometricsHSM()); - expect(result.current.deviceVerificationType).toBe(CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRIC_HSM); + expect(result.current.deviceVerificationType).toBe(CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM); }); }); @@ -286,7 +286,7 @@ describe('useNativeBiometricsHSM hook', () => { reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.LOCAL_REGISTRATION_COMPLETE, keyInfo: expect.objectContaining({ rawId: 'abc-def_ghi', - type: CONST.MULTIFACTOR_AUTHENTICATION.BIOMETRIC_HSM_TYPE, + type: CONST.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_HSM_TYPE, }), }), ); @@ -345,7 +345,7 @@ describe('useNativeBiometricsHSM hook', () => { success: true, reason: VALUES.REASON.CHALLENGE.CHALLENGE_SIGNED, signedChallenge: expect.objectContaining({ - type: CONST.MULTIFACTOR_AUTHENTICATION.BIOMETRIC_HSM_TYPE, + type: CONST.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_HSM_TYPE, }), }), ); diff --git a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts index ca42e23c2ae88..c7f46ef49a8da 100644 --- a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts +++ b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts @@ -1,6 +1,6 @@ import {Buffer} from 'buffer'; import {buildSigningData, getKeyAlias, mapAuthTypeNumber, mapLibraryErrorToReason, mapSignErrorCodeToReason} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; -import NATIVE_BIOMETRIC_HSM_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES'; +import NATIVE_BIOMETRICS_HSM_VALUES from '@libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; const mockSha256 = jest.fn(); @@ -49,9 +49,9 @@ describe('NativeBiometricsHSM helpers', () => { // Then it should resolve to the Unknown auth type, because the method could not be determined, but the authentication was successful const result = mapAuthTypeNumber(-1); expect(result).toEqual({ - code: NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.UNKNOWN.CODE, - name: NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.UNKNOWN.NAME, - marqetaValue: NATIVE_BIOMETRIC_HSM_VALUES.AUTH_TYPE.UNKNOWN.MARQETA_VALUE, + code: NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.UNKNOWN.CODE, + name: NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.UNKNOWN.NAME, + marqetaValue: NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.UNKNOWN.MARQETA_VALUE, }); }); @@ -116,62 +116,62 @@ describe('NativeBiometricsHSM helpers', () => { // Given exact error code strings from the library indicating the user canceled the biometric prompt // When mapping the error codes // Then both iOS (USER_CANCEL) and Android (USER_CANCELED) variants should resolve to HSM.CANCELED - expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCEL)).toBe(VALUES.REASON.HSM.CANCELED); - expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCELED)).toBe(VALUES.REASON.HSM.CANCELED); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.USER_CANCEL)).toBe(VALUES.REASON.HSM.CANCELED); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.USER_CANCELED)).toBe(VALUES.REASON.HSM.CANCELED); }); it('should return CANCELED for system cancel error codes', () => { // Given exact error code strings indicating the system canceled authentication (e.g., app backgrounded) // When mapping the error codes // Then both iOS (SYSTEM_CANCEL) and Android (SYSTEM_CANCELED) variants should resolve to HSM.CANCELED - expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.SYSTEM_CANCEL)).toBe(VALUES.REASON.HSM.CANCELED); - expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.SYSTEM_CANCELED)).toBe(VALUES.REASON.HSM.CANCELED); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.SYSTEM_CANCEL)).toBe(VALUES.REASON.HSM.CANCELED); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.SYSTEM_CANCELED)).toBe(VALUES.REASON.HSM.CANCELED); }); it('should return NOT_AVAILABLE for biometric unavailability error codes', () => { // Given exact error codes indicating biometrics are not available on the device // When mapping the error codes // Then they should resolve to HSM.NOT_AVAILABLE so the app can guide the user to enable biometrics or use an alternative method - expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRY_NOT_AVAILABLE)).toBe(VALUES.REASON.HSM.NOT_AVAILABLE); - expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_NOT_AVAILABLE)).toBe(VALUES.REASON.HSM.NOT_AVAILABLE); - expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_UNAVAILABLE)).toBe(VALUES.REASON.HSM.NOT_AVAILABLE); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.BIOMETRY_NOT_AVAILABLE)).toBe(VALUES.REASON.HSM.NOT_AVAILABLE); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.BIOMETRIC_NOT_AVAILABLE)).toBe(VALUES.REASON.HSM.NOT_AVAILABLE); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.BIOMETRIC_UNAVAILABLE)).toBe(VALUES.REASON.HSM.NOT_AVAILABLE); }); it('should return LOCKOUT for temporary lockout error codes', () => { // Given exact error codes indicating temporary biometric lockout after too many failed attempts // When mapping the error codes // Then both iOS and Android variants should resolve to HSM.LOCKOUT - expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRY_LOCKOUT)).toBe(VALUES.REASON.HSM.LOCKOUT); - expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_LOCKOUT)).toBe(VALUES.REASON.HSM.LOCKOUT); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.BIOMETRY_LOCKOUT)).toBe(VALUES.REASON.HSM.LOCKOUT); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.BIOMETRIC_LOCKOUT)).toBe(VALUES.REASON.HSM.LOCKOUT); }); it('should return LOCKOUT_PERMANENT for permanent lockout error codes', () => { // Given exact error codes indicating permanent biometric lockout requiring device credential to reset // When mapping the error codes // Then both iOS and Android variants should resolve to HSM.LOCKOUT_PERMANENT - expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRY_LOCKOUT_PERMANENT)).toBe(VALUES.REASON.HSM.LOCKOUT_PERMANENT); - expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.BIOMETRIC_LOCKOUT_PERMANENT)).toBe(VALUES.REASON.HSM.LOCKOUT_PERMANENT); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.BIOMETRY_LOCKOUT_PERMANENT)).toBe(VALUES.REASON.HSM.LOCKOUT_PERMANENT); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.BIOMETRIC_LOCKOUT_PERMANENT)).toBe(VALUES.REASON.HSM.LOCKOUT_PERMANENT); }); it('should return SIGNATURE_FAILED for signature creation failure', () => { // Given the exact error code for signature creation failure // When mapping the error code // Then it should resolve to HSM.SIGNATURE_FAILED - expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.SIGNATURE_CREATION_FAILED)).toBe(VALUES.REASON.HSM.SIGNATURE_FAILED); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.SIGNATURE_CREATION_FAILED)).toBe(VALUES.REASON.HSM.SIGNATURE_FAILED); }); it('should return KEY_NOT_FOUND for key not found error code', () => { // Given the exact error code for when the signing key does not exist in the keystore // When mapping the error code // Then it should resolve to HSM.KEY_NOT_FOUND - expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND)).toBe(VALUES.REASON.HSM.KEY_NOT_FOUND); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND)).toBe(VALUES.REASON.HSM.KEY_NOT_FOUND); }); it('should return REGISTRATION_REQUIRED for key access failed error code', () => { // Given the exact error code for when the key cannot be accessed (e.g. biometric enrollment changed) // When mapping the error code // Then it should resolve to HSM.KEY_ACCESS_FAILED to trigger re-registration - expect(mapSignErrorCodeToReason(NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ACCESS_FAILED)).toBe(VALUES.REASON.HSM.KEY_ACCESS_FAILED); + expect(mapSignErrorCodeToReason(NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.KEY_ACCESS_FAILED)).toBe(VALUES.REASON.HSM.KEY_ACCESS_FAILED); }); it('should return GENERIC for unrecognized error codes', () => { @@ -187,7 +187,7 @@ describe('NativeBiometricsHSM helpers', () => { // Given an Error object with a code property matching the iOS user cancel error code // When mapping the library error // Then it should resolve to HSM.CANCELED based on the exact error code - const error = Object.assign(new Error('User canceled authentication'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCEL}); + const error = Object.assign(new Error('User canceled authentication'), {code: NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.USER_CANCEL}); expect(mapLibraryErrorToReason(error)).toBe(VALUES.REASON.HSM.CANCELED); }); @@ -195,7 +195,7 @@ describe('NativeBiometricsHSM helpers', () => { // Given an Error object with a code property matching the Android user cancel error code // When mapping the library error // Then it should resolve to HSM.CANCELED based on the exact error code - const error = Object.assign(new Error('User canceled the operation'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.USER_CANCELED}); + const error = Object.assign(new Error('User canceled the operation'), {code: NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.USER_CANCELED}); expect(mapLibraryErrorToReason(error)).toBe(VALUES.REASON.HSM.CANCELED); }); @@ -203,7 +203,7 @@ describe('NativeBiometricsHSM helpers', () => { // Given an Error object with a code property matching the key creation error code // When mapping the library error // Then it should resolve to HSM.KEY_CREATION_FAILED - const error = Object.assign(new Error('Failed to create keys'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.CREATE_KEYS_ERROR}); + const error = Object.assign(new Error('Failed to create keys'), {code: NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.CREATE_KEYS_ERROR}); expect(mapLibraryErrorToReason(error)).toBe(VALUES.REASON.HSM.KEY_CREATION_FAILED); }); @@ -211,7 +211,7 @@ describe('NativeBiometricsHSM helpers', () => { // Given an Error object with a code property matching the key already exists error code // When mapping the library error // Then it should resolve to HSM.KEY_CREATION_FAILED since the key creation operation failed - const error = Object.assign(new Error('Key already exists'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ALREADY_EXISTS}); + const error = Object.assign(new Error('Key already exists'), {code: NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.KEY_ALREADY_EXISTS}); expect(mapLibraryErrorToReason(error)).toBe(VALUES.REASON.HSM.KEY_CREATION_FAILED); }); @@ -219,7 +219,7 @@ describe('NativeBiometricsHSM helpers', () => { // Given an Error object with a code property matching the key not found error code // When mapping the library error // Then it should resolve to HSM.KEY_NOT_FOUND - const error = Object.assign(new Error('Cryptographic key not found'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND}); + const error = Object.assign(new Error('Cryptographic key not found'), {code: NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.KEY_NOT_FOUND}); expect(mapLibraryErrorToReason(error)).toBe(VALUES.REASON.HSM.KEY_NOT_FOUND); }); @@ -227,7 +227,7 @@ describe('NativeBiometricsHSM helpers', () => { // Given an Error object with a code property matching the key access failed error code // When mapping the library error // Then it should resolve to HSM.KEY_ACCESS_FAILED to trigger re-registration - const error = Object.assign(new Error('Failed to access cryptographic key'), {code: NATIVE_BIOMETRIC_HSM_VALUES.ERROR_CODE.KEY_ACCESS_FAILED}); + const error = Object.assign(new Error('Failed to access cryptographic key'), {code: NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.KEY_ACCESS_FAILED}); expect(mapLibraryErrorToReason(error)).toBe(VALUES.REASON.HSM.KEY_ACCESS_FAILED); }); From 16ff3d9255d1787f31cb1fa56e21d6b07df84e3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Thu, 2 Apr 2026 18:44:07 +0200 Subject: [PATCH 31/41] fixed one more rename --- src/libs/MultifactorAuthentication/shared/VALUES.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index 01131ca53f9fb..7134bd76cbc2c 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -300,7 +300,7 @@ const SHARED_VALUES = { * Authentication type identifiers. */ TYPE: { - BIOMETRICS_HSM: 'BIOMETRIC_HSM', + BIOMETRICS_HSM: 'BIOMETRICS_HSM', BIOMETRICS: 'BIOMETRICS', PASSKEYS: 'PASSKEYS', }, From 82b513a6b9f2351a0436ddc3fa275a2088a6cdd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Fri, 3 Apr 2026 14:12:18 +0200 Subject: [PATCH 32/41] update comments --- .../biometrics/useNativeBiometricsHSM.ts | 2 ++ .../MultifactorAuthentication/config/scenarios/names.ts | 1 - .../MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts | 2 +- .../MultifactorAuthentication/NativeBiometricsHSM/helpers.ts | 2 +- src/libs/MultifactorAuthentication/shared/VALUES.ts | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts index a4e4bad13982a..846c06d8bfaee 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts @@ -13,6 +13,8 @@ import useServerCredentials from './shared/useServerCredentials'; /** * UTILS START + * These utils were added to comply with react compiler requirements: + * "Error: Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement" */ function isCredentialAllowed(credentialID: string | undefined, allowedIDs: string[]): credentialID is string { return !!credentialID && allowedIDs.includes(credentialID); diff --git a/src/components/MultifactorAuthentication/config/scenarios/names.ts b/src/components/MultifactorAuthentication/config/scenarios/names.ts index e51e440d638df..326a0f7d9baea 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/names.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/names.ts @@ -16,7 +16,6 @@ const SCENARIO_NAMES = { /** * Prompt identifiers for multifactor authentication scenarios. - * TODO: update the BIOMETRICS_HSM type */ const PROMPT_NAMES = { BIOMETRICS_HSM: 'biometrics', diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts index c27368001067f..4b6ee0006a05a 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts @@ -6,7 +6,7 @@ import MARQETA_VALUES from '@libs/MultifactorAuthentication/shared/MarqetaValues const NATIVE_BIOMETRICS_HSM_VALUES = { /** - * HSM key type identifier + * HSM key type identifier sent in API requests to identify the HSM-backed biometric authentication method. */ BIOMETRICS_HSM_TYPE: 'biometric-hsm', diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts index 9ddb8dec29c7e..45e9f6a710555 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts @@ -103,7 +103,7 @@ async function buildSigningData(rpId: string, challenge: string): Promise<{authe const {hash: rpIdHashB64} = await sha256(rpId); const rpIdHash = Buffer.from(rpIdHashB64, 'base64'); - // UP (0x01) | UV (0x04) + // User Presence and User Verification flags - UP (0x01) | UV (0x04) const flags = Buffer.from([0x05]); // 4 zero bytes, big-endian const signCount = Buffer.alloc(4); diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index 7134bd76cbc2c..fba113655c18c 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -297,7 +297,7 @@ const SHARED_VALUES = { }, /** - * Authentication type identifiers. + * Authentication type identifiers used for identification of allowed authentication methods in scenarios */ TYPE: { BIOMETRICS_HSM: 'BIOMETRICS_HSM', From 4e5d3ee6a82557a2be1ed4862f5705efbadaace6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Fri, 3 Apr 2026 14:38:01 +0200 Subject: [PATCH 33/41] update package-lock.json --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 6ab142d293ab1..a00fe77d3f31e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,7 @@ "@react-navigation/stack": "7.3.3", "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "10.1.44", - "@sbaiahmed1/react-native-biometrics": "^0.14.0", + "@sbaiahmed1/react-native-biometrics": "0.14.0", "@sentry/react-native": "8.2.0", "@shopify/flash-list": "2.3.0", "@shopify/react-native-skia": "^2.4.14", From cae73c02a8d2cf5173c967341754a5670d6a721b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Fri, 3 Apr 2026 15:14:49 +0200 Subject: [PATCH 34/41] fix patch, use AuthType --- ...0+001+biometry-temporary-new-release.patch | 48 +++++++++++++++++++ .../NativeBiometricsHSM/VALUES.ts | 19 ++++---- .../NativeBiometricsHSM/helpers.ts | 7 +-- .../useNativeBiometricsHSM.test.ts | 3 +- 4 files changed, 62 insertions(+), 15 deletions(-) diff --git a/patches/@sbaiahmed1/react-native-biometrics/@sbaiahmed1+react-native-biometrics+0.14.0+001+biometry-temporary-new-release.patch b/patches/@sbaiahmed1/react-native-biometrics/@sbaiahmed1+react-native-biometrics+0.14.0+001+biometry-temporary-new-release.patch index 8b7d7eab546f4..fed2345d4252f 100644 --- a/patches/@sbaiahmed1/react-native-biometrics/@sbaiahmed1+react-native-biometrics+0.14.0+001+biometry-temporary-new-release.patch +++ b/patches/@sbaiahmed1/react-native-biometrics/@sbaiahmed1+react-native-biometrics+0.14.0+001+biometry-temporary-new-release.patch @@ -409,3 +409,51 @@ index cbfd187..7e9a545 100644 None = 0, DeviceCredentials = 1, Biometrics = 2, +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/lib/commonjs/types.js b/node_modules/@sbaiahmed1/react-native-biometrics/lib/commonjs/types.js +index 3d0dfae..c4cee42 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/lib/commonjs/types.js ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/lib/commonjs/types.js +@@ -16,6 +16,7 @@ let BiometricStrength = exports.BiometricStrength = /*#__PURE__*/function (Biome + * available on the device, due to platform limitations. + */ + let AuthType = exports.AuthType = /*#__PURE__*/function (AuthType) { ++ AuthType[AuthType["Unknown"] = -1] = "Unknown"; + AuthType[AuthType["None"] = 0] = "None"; + AuthType[AuthType["DeviceCredentials"] = 1] = "DeviceCredentials"; + AuthType[AuthType["Biometrics"] = 2] = "Biometrics"; +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/lib/module/types.js b/node_modules/@sbaiahmed1/react-native-biometrics/lib/module/types.js +index 4d45270..d01ff06 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/lib/module/types.js ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/lib/module/types.js +@@ -13,6 +13,7 @@ export let BiometricStrength = /*#__PURE__*/function (BiometricStrength) { + * available on the device, due to platform limitations. + */ + export let AuthType = /*#__PURE__*/function (AuthType) { ++ AuthType[AuthType["Unknown"] = -1] = "Unknown"; + AuthType[AuthType["None"] = 0] = "None"; + AuthType[AuthType["DeviceCredentials"] = 1] = "DeviceCredentials"; + AuthType[AuthType["Biometrics"] = 2] = "Biometrics"; +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/commonjs/src/types.d.ts b/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/commonjs/src/types.d.ts +index cd3d3c4..a9a0e8c 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/commonjs/src/types.d.ts ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/commonjs/src/types.d.ts +@@ -9,6 +9,7 @@ export declare enum BiometricStrength { + * available on the device, due to platform limitations. + */ + export declare enum AuthType { ++ Unknown = -1, + None = 0, + DeviceCredentials = 1, + Biometrics = 2, +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/module/src/types.d.ts b/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/module/src/types.d.ts +index cd3d3c4..a9a0e8c 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/module/src/types.d.ts ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/module/src/types.d.ts +@@ -9,6 +9,7 @@ export declare enum BiometricStrength { + * available on the device, due to platform limitations. + */ + export declare enum AuthType { ++ Unknown = -1, + None = 0, + DeviceCredentials = 1, + Biometrics = 2, diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts index 4b6ee0006a05a..9fab5c59046f5 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts @@ -1,7 +1,7 @@ /** * Constants specific to native biometrics (HSM / react-native-biometrics). */ -// import {AuthType} from '@sbaiahmed1/react-native-biometrics/types'; +import {AuthType} from '@sbaiahmed1/react-native-biometrics/types'; import MARQETA_VALUES from '@libs/MultifactorAuthentication/shared/MarqetaValues'; const NATIVE_BIOMETRICS_HSM_VALUES = { @@ -19,36 +19,33 @@ const NATIVE_BIOMETRICS_HSM_VALUES = { * Authentication types mapped to Marqeta values */ AUTH_TYPE: { - /** - * TODO: replace codes with the exported AuthType enum values once the new export '@sbaiahmed1/react-native-biometrics/types' is added - */ UNKNOWN: { - CODE: -1, + CODE: AuthType.Unknown, NAME: 'Unknown', MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.KNOWLEDGE_BASED, }, NONE: { - CODE: 0, + CODE: AuthType.None, NAME: 'None', MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.NONE, }, CREDENTIALS: { - CODE: 1, + CODE: AuthType.DeviceCredentials, NAME: 'Credentials', MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.KNOWLEDGE_BASED, }, BIOMETRICS: { - CODE: 2, + CODE: AuthType.Biometrics, NAME: 'Biometrics', MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FINGERPRINT, }, FACE_ID: { - CODE: 3, + CODE: AuthType.FaceID, NAME: 'Face ID', MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FACE, }, TOUCH_ID: { - CODE: 4, + CODE: AuthType.TouchID, NAME: 'Touch ID', MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FINGERPRINT, }, @@ -57,7 +54,7 @@ const NATIVE_BIOMETRICS_HSM_VALUES = { * It is declared here for completeness but is not currently supported. */ OPTIC_ID: { - CODE: 5, + CODE: AuthType.OpticID, NAME: 'Optic ID', MARQETA_VALUE: MARQETA_VALUES.AUTHENTICATION_METHOD.BIOMETRIC_FACE, }, diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts index 45e9f6a710555..51001061fb4f1 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts @@ -2,6 +2,7 @@ * Helper utilities for native biometrics HSM (react-native-biometrics). */ import {sha256} from '@sbaiahmed1/react-native-biometrics'; +import type {AuthType} from '@sbaiahmed1/react-native-biometrics/types'; import {Buffer} from 'buffer'; import type {ValueOf} from 'type-fest'; import type {AuthTypeInfo, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; @@ -14,7 +15,7 @@ type NativeBiometricsHSMTypeEntry = ValueOf([ +const AUTH_TYPE_NUMBER_MAP = new Map([ [-1, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.UNKNOWN], [0, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.NONE], [1, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.CREDENTIALS], @@ -32,7 +33,7 @@ const AUTH_TYPE_NUMBER_MAP = new Map([ [5, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.OPTIC_ID], ]); -function mapAuthTypeNumber(authType?: number): AuthTypeInfo | undefined { +function mapAuthTypeNumber(authType?: AuthType): AuthTypeInfo | undefined { if (authType === undefined) { return undefined; } diff --git a/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts index 97b3816aedcc6..e35e5eb8d788e 100644 --- a/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts +++ b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts @@ -1,3 +1,4 @@ +import {AuthType} from '@sbaiahmed1/react-native-biometrics'; import {act, renderHook} from '@testing-library/react-native'; import useNativeBiometricsHSM from '@components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM'; import type {AuthenticationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; @@ -326,7 +327,7 @@ describe('useNativeBiometricsHSM hook', () => { const keyAlias = '12345_HSM_KEY'; mockGetAllKeys.mockResolvedValue({keys: [{alias: keyAlias, publicKey: 'abc+def/ghi='}]}); mockSha256.mockResolvedValue({hash: Buffer.alloc(32).toString('base64')}); - mockSignWithOptions.mockResolvedValue({success: true, signature: 'c2lnbmF0dXJl', authType: 3}); + mockSignWithOptions.mockResolvedValue({success: true, signature: 'c2lnbmF0dXJl', authType: AuthType.FaceID}); }); it('should sign challenge and return success', async () => { From 511f419bd719553094608d5ef880c46e5cf5847d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Fri, 3 Apr 2026 15:19:47 +0200 Subject: [PATCH 35/41] fix types --- .../MultifactorAuthentication/NativeBiometricsHSM/helpers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts index 51001061fb4f1..dbbf46a65c9ff 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts @@ -15,7 +15,7 @@ type NativeBiometricsHSMTypeEntry = ValueOf([ [5, NATIVE_BIOMETRICS_HSM_VALUES.AUTH_TYPE.OPTIC_ID], ]); -function mapAuthTypeNumber(authType?: AuthType): AuthTypeInfo | undefined { +function mapAuthTypeNumber(authType?: number): AuthTypeInfo | undefined { if (authType === undefined) { return undefined; } From 9f228aeb6b6e59fcb17860eaa4f0cadf61c0c04c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Fri, 3 Apr 2026 15:30:58 +0200 Subject: [PATCH 36/41] fix patch - isDeviceSecure types --- ...0+001+biometry-temporary-new-release.patch | 136 ++++++++++++------ .../biometrics/useNativeBiometricsHSM.ts | 2 +- 2 files changed, 95 insertions(+), 43 deletions(-) diff --git a/patches/@sbaiahmed1/react-native-biometrics/@sbaiahmed1+react-native-biometrics+0.14.0+001+biometry-temporary-new-release.patch b/patches/@sbaiahmed1/react-native-biometrics/@sbaiahmed1+react-native-biometrics+0.14.0+001+biometry-temporary-new-release.patch index fed2345d4252f..e872e36355965 100644 --- a/patches/@sbaiahmed1/react-native-biometrics/@sbaiahmed1+react-native-biometrics+0.14.0+001+biometry-temporary-new-release.patch +++ b/patches/@sbaiahmed1/react-native-biometrics/@sbaiahmed1+react-native-biometrics+0.14.0+001+biometry-temporary-new-release.patch @@ -62,7 +62,7 @@ index ad238c8..ee32aeb 100644 } result.putInt("authType", authTypeValue) diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/ios/ReactNativeBiometrics.swift b/node_modules/@sbaiahmed1/react-native-biometrics/ios/ReactNativeBiometrics.swift -index f44289c..58143f3 100644 +index f44289c..79eab8f 100644 --- a/node_modules/@sbaiahmed1/react-native-biometrics/ios/ReactNativeBiometrics.swift +++ b/node_modules/@sbaiahmed1/react-native-biometrics/ios/ReactNativeBiometrics.swift @@ -3,6 +3,7 @@ import LocalAuthentication @@ -142,7 +142,7 @@ index f44289c..58143f3 100644 @@ -392,6 +452,12 @@ class ReactNativeBiometrics: RCTEventEmitter { biometricKeyType = .ec256 } - + + // iOS migration-safe behavior: + // - default/weak -> .biometryAny (backward-compatible with existing keys) + // - strong -> .biometryCurrentSet (invalidated on biometric enrollment change) @@ -165,7 +165,7 @@ index f44289c..58143f3 100644 @@ -470,6 +537,14 @@ class ReactNativeBiometrics: RCTEventEmitter { "publicKey": publicKeyBase64 ] - + + let shouldPersistBiometricDomainState = !deviceCredentialsFallback && useBiometryCurrentSet + + if !shouldPersistBiometricDomainState { @@ -210,7 +210,7 @@ index f44289c..58143f3 100644 @@ -710,6 +788,15 @@ class ReactNativeBiometrics: RCTEventEmitter { checks["keyAccessible"] = true checks["hardwareBacked"] = isHardwareBacked - + + if hasBiometricDomainStateChanged(for: keyTag) { + checks["keyAccessible"] = false + integrityResult["integrityChecks"] = checks @@ -371,44 +371,6 @@ index 51dae57..06cef2c 100644 "./package.json": "./package.json" }, "files": [ -diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/src/NativeReactNativeBiometrics.ts b/node_modules/@sbaiahmed1/react-native-biometrics/src/NativeReactNativeBiometrics.ts -index 02e63af..80f6178 100644 ---- a/node_modules/@sbaiahmed1/react-native-biometrics/src/NativeReactNativeBiometrics.ts -+++ b/node_modules/@sbaiahmed1/react-native-biometrics/src/NativeReactNativeBiometrics.ts -@@ -20,7 +20,7 @@ export interface Spec extends TurboModule { - available: boolean; - biometryType?: 'Biometrics' | 'FaceID' | 'TouchID' | 'None' | 'Unknown'; - error?: string; -- isDeviceSecure?: boolean; -+ isDeviceSecure: boolean; - }>; - simplePrompt( - promptMessage: string, -diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/src/index.tsx b/node_modules/@sbaiahmed1/react-native-biometrics/src/index.tsx -index a8ef764..1a8d4e9 100644 ---- a/node_modules/@sbaiahmed1/react-native-biometrics/src/index.tsx -+++ b/node_modules/@sbaiahmed1/react-native-biometrics/src/index.tsx -@@ -668,7 +668,7 @@ export type BiometricSensorInfo = { - errorCode?: string; - fallbackUsed?: boolean; - biometricStrength?: BiometricStrength; -- isDeviceSecure?: boolean; -+ isDeviceSecure: boolean; - }; - - export type BiometricAuthOptions = { -diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/src/types.ts b/node_modules/@sbaiahmed1/react-native-biometrics/src/types.ts -index cbfd187..7e9a545 100644 ---- a/node_modules/@sbaiahmed1/react-native-biometrics/src/types.ts -+++ b/node_modules/@sbaiahmed1/react-native-biometrics/src/types.ts -@@ -10,6 +10,7 @@ export enum BiometricStrength { - * available on the device, due to platform limitations. - */ - export enum AuthType { -+ Unknown = -1, - None = 0, - DeviceCredentials = 1, - Biometrics = 2, diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/lib/commonjs/types.js b/node_modules/@sbaiahmed1/react-native-biometrics/lib/commonjs/types.js index 3d0dfae..c4cee42 100644 --- a/node_modules/@sbaiahmed1/react-native-biometrics/lib/commonjs/types.js @@ -433,6 +395,32 @@ index 4d45270..d01ff06 100644 AuthType[AuthType["None"] = 0] = "None"; AuthType[AuthType["DeviceCredentials"] = 1] = "DeviceCredentials"; AuthType[AuthType["Biometrics"] = 2] = "Biometrics"; +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/commonjs/src/NativeReactNativeBiometrics.d.ts b/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/commonjs/src/NativeReactNativeBiometrics.d.ts +index ef7dfc7..22d53c9 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/commonjs/src/NativeReactNativeBiometrics.d.ts ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/commonjs/src/NativeReactNativeBiometrics.d.ts +@@ -12,7 +12,7 @@ export interface Spec extends TurboModule { + available: boolean; + biometryType?: 'Biometrics' | 'FaceID' | 'TouchID' | 'None' | 'Unknown'; + error?: string; +- isDeviceSecure?: boolean; ++ isDeviceSecure: boolean; + }>; + simplePrompt(promptMessage: string, biometricStrength?: 'weak' | 'strong'): Promise<{ + success: boolean; +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/commonjs/src/index.d.ts b/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/commonjs/src/index.d.ts +index 5e3544c..4b96fc9 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/commonjs/src/index.d.ts ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/commonjs/src/index.d.ts +@@ -55,7 +55,7 @@ export type BiometricSensorInfo = { + errorCode?: string; + fallbackUsed?: boolean; + biometricStrength?: BiometricStrength; +- isDeviceSecure?: boolean; ++ isDeviceSecure: boolean; + }; + export type BiometricAuthOptions = { + title?: string; diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/commonjs/src/types.d.ts b/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/commonjs/src/types.d.ts index cd3d3c4..a9a0e8c 100644 --- a/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/commonjs/src/types.d.ts @@ -445,6 +433,32 @@ index cd3d3c4..a9a0e8c 100644 None = 0, DeviceCredentials = 1, Biometrics = 2, +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/module/src/NativeReactNativeBiometrics.d.ts b/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/module/src/NativeReactNativeBiometrics.d.ts +index ef7dfc7..22d53c9 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/module/src/NativeReactNativeBiometrics.d.ts ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/module/src/NativeReactNativeBiometrics.d.ts +@@ -12,7 +12,7 @@ export interface Spec extends TurboModule { + available: boolean; + biometryType?: 'Biometrics' | 'FaceID' | 'TouchID' | 'None' | 'Unknown'; + error?: string; +- isDeviceSecure?: boolean; ++ isDeviceSecure: boolean; + }>; + simplePrompt(promptMessage: string, biometricStrength?: 'weak' | 'strong'): Promise<{ + success: boolean; +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/module/src/index.d.ts b/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/module/src/index.d.ts +index 5e3544c..4b96fc9 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/module/src/index.d.ts ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/module/src/index.d.ts +@@ -55,7 +55,7 @@ export type BiometricSensorInfo = { + errorCode?: string; + fallbackUsed?: boolean; + biometricStrength?: BiometricStrength; +- isDeviceSecure?: boolean; ++ isDeviceSecure: boolean; + }; + export type BiometricAuthOptions = { + title?: string; diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/module/src/types.d.ts b/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/module/src/types.d.ts index cd3d3c4..a9a0e8c 100644 --- a/node_modules/@sbaiahmed1/react-native-biometrics/lib/typescript/module/src/types.d.ts @@ -457,3 +471,41 @@ index cd3d3c4..a9a0e8c 100644 None = 0, DeviceCredentials = 1, Biometrics = 2, +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/src/NativeReactNativeBiometrics.ts b/node_modules/@sbaiahmed1/react-native-biometrics/src/NativeReactNativeBiometrics.ts +index 02e63af..80f6178 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/src/NativeReactNativeBiometrics.ts ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/src/NativeReactNativeBiometrics.ts +@@ -20,7 +20,7 @@ export interface Spec extends TurboModule { + available: boolean; + biometryType?: 'Biometrics' | 'FaceID' | 'TouchID' | 'None' | 'Unknown'; + error?: string; +- isDeviceSecure?: boolean; ++ isDeviceSecure: boolean; + }>; + simplePrompt( + promptMessage: string, +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/src/index.tsx b/node_modules/@sbaiahmed1/react-native-biometrics/src/index.tsx +index a8ef764..1a8d4e9 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/src/index.tsx ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/src/index.tsx +@@ -668,7 +668,7 @@ export type BiometricSensorInfo = { + errorCode?: string; + fallbackUsed?: boolean; + biometricStrength?: BiometricStrength; +- isDeviceSecure?: boolean; ++ isDeviceSecure: boolean; + }; + + export type BiometricAuthOptions = { +diff --git a/node_modules/@sbaiahmed1/react-native-biometrics/src/types.ts b/node_modules/@sbaiahmed1/react-native-biometrics/src/types.ts +index cbfd187..7e9a545 100644 +--- a/node_modules/@sbaiahmed1/react-native-biometrics/src/types.ts ++++ b/node_modules/@sbaiahmed1/react-native-biometrics/src/types.ts +@@ -10,6 +10,7 @@ export enum BiometricStrength { + * available on the device, due to platform limitations. + */ + export enum AuthType { ++ Unknown = -1, + None = 0, + DeviceCredentials = 1, + Biometrics = 2, diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts index 846c06d8bfaee..915452274c172 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts @@ -39,7 +39,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { const doesDeviceSupportAuthenticationMethod = async () => { const sensorResult = await isSensorAvailable(); - return sensorResult.isDeviceSecure ?? sensorResult.available; + return sensorResult.isDeviceSecure; }; const getLocalCredentialID = async () => { From b79096507eff492b114e885251b27583b7579984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Fri, 3 Apr 2026 15:58:18 +0200 Subject: [PATCH 37/41] better logging for errors --- .../MultifactorAuthentication/Context/Main.tsx | 2 ++ .../biometrics/shared/types.ts | 2 ++ .../biometrics/useNativeBiometricsHSM.ts | 9 +++++++-- .../biometrics/usePasskeys.ts | 8 ++++++-- .../MultifactorAuthentication/Passkeys/WebAuthn.ts | 11 ++++++++--- 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/components/MultifactorAuthentication/Context/Main.tsx b/src/components/MultifactorAuthentication/Context/Main.tsx index 9845f373b0f14..236dc0bc2286d 100644 --- a/src/components/MultifactorAuthentication/Context/Main.tsx +++ b/src/components/MultifactorAuthentication/Context/Main.tsx @@ -283,6 +283,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent { success: result.success, reason: result.reason, + message: result.success ? undefined : result?.message, }, result.success ? 'info' : 'error', ); @@ -382,6 +383,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent success: result.success, reason: result.reason, authMethod: result.success ? result.authenticationMethod.code : undefined, + message: result.success ? undefined : result?.message, }, result.success ? 'info' : 'error', ); diff --git a/src/components/MultifactorAuthentication/biometrics/shared/types.ts b/src/components/MultifactorAuthentication/biometrics/shared/types.ts index b4184fbff44d7..d6006a1648773 100644 --- a/src/components/MultifactorAuthentication/biometrics/shared/types.ts +++ b/src/components/MultifactorAuthentication/biometrics/shared/types.ts @@ -15,6 +15,7 @@ type RegisterResult = | ({ success: false; reason: MultifactorAuthenticationReason; + message?: string; } & Partial); type AuthorizeParams = { @@ -31,6 +32,7 @@ type AuthorizeResultSuccess = { type AuthorizeResultFailure = { success: false; reason: MultifactorAuthenticationReason; + message?: string; }; type AuthorizeResult = AuthorizeResultSuccess | AuthorizeResultFailure; diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts index 915452274c172..6d17ed4d5cee9 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts @@ -56,7 +56,8 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { if (reason === undefined) { reason = VALUES.REASON.HSM.GENERIC; } - addMFABreadcrumb('Failed to get local credential ID', {reason}, 'error'); + const errorMessage = error instanceof Error ? error.message : String(error); + addMFABreadcrumb('Failed to get local credential ID', {reason, message: errorMessage}, 'error'); return undefined; } }; @@ -75,7 +76,8 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { if (reason === undefined) { reason = VALUES.REASON.HSM.GENERIC; } - addMFABreadcrumb('Failed to delete local keys', {reason}, 'error'); + const errorMessage = error instanceof Error ? error.message : String(error); + addMFABreadcrumb('Failed to delete local keys', {reason, message: errorMessage}, 'error'); } }; @@ -121,6 +123,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { onResult({ success: false, reason, + message: error instanceof Error ? error.message : String(error), }); } }; @@ -158,6 +161,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { onResult({ success: false, reason: failReason, + message: failReason === VALUES.REASON.HSM.GENERIC ? signResult.errorCode : undefined, }); return; } @@ -190,6 +194,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { onResult({ success: false, reason, + message: error instanceof Error ? error.message : String(error), }); } }; diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index bffcc45ee3bf5..f51087e1bb071 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -56,9 +56,11 @@ function usePasskeys(): UseBiometricsReturn { try { credential = await createPasskeyCredential(publicKeyOptions); } catch (error) { + const {reason, message} = decodeWebAuthnError(error); await onResult({ success: false, - reason: decodeWebAuthnError(error), + reason, + message, }); return; } @@ -133,9 +135,11 @@ function usePasskeys(): UseBiometricsReturn { try { assertion = await authenticateWithPasskey(publicKeyOptions); } catch (error) { + const {reason, message} = decodeWebAuthnError(error); await onResult({ success: false, - reason: decodeWebAuthnError(error), + reason, + message, }); return; } diff --git a/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts b/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts index f6cc359aafdbf..019ea0f7159d8 100644 --- a/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts +++ b/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts @@ -131,14 +131,19 @@ function isWebAuthnReason(name: string): name is MultifactorAuthenticationReason return Object.values(VALUES.REASON.WEBAUTHN).includes(name); } +type DecodedWebAuthnError = { + reason: MultifactorAuthenticationReason; + message?: string; +}; + /** Decodes WebAuthn DOMException errors and maps them to authentication error reasons. */ -function decodeWebAuthnError(error: unknown): MultifactorAuthenticationReason { +function decodeWebAuthnError(error: unknown): DecodedWebAuthnError { Log.info('[Passkey] WebAuthn error', false, {error: error instanceof Error ? error.message : String(error)}); if (error instanceof DOMException && isWebAuthnReason(error.name)) { - return error.name; + return {reason: error.name}; } - return VALUES.REASON.WEBAUTHN.GENERIC; + return {reason: VALUES.REASON.WEBAUTHN.GENERIC, message: error instanceof Error ? error.message : String(error)}; } export { From dd50120b3611bd8319045e3b3a879e083d3c0120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Fri, 3 Apr 2026 16:32:29 +0200 Subject: [PATCH 38/41] update error handling --- .../NativeBiometricsHSM/VALUES.ts | 14 +++++++++++++- .../NativeBiometricsHSM/helpers.ts | 1 + .../MultifactorAuthentication/shared/VALUES.ts | 2 ++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts index 9fab5c59046f5..f705130e09034 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts @@ -60,7 +60,16 @@ const NATIVE_BIOMETRICS_HSM_VALUES = { }, }, /** - * Error codes returned by react-native-biometrics. + * Subset of error codes returned by react-native-biometrics. + * + * Specified codes are user-actionable or are connected to particular flow: + * - Cancellation (user/system) — distinguish from failures + * - Availability/lockout — inform the user why biometrics can't be used + * - Key/signature errors — trigger re-registration or specific recovery paths + * - Authentication failed — biometric match failure (wrong finger/face) + * + * Codes not listed here (INVALID_INPUT_ENCODING, INVALID_KEY_TYPE, etc.) are mainly implementation errors + * that fallback to REASON.HSM.GENERIC. * * signWithOptions resolves with { errorCode?: string }. * createKeys/deleteKeys/getAllKeys reject with Error objects having { code: string, message: string }. @@ -88,6 +97,9 @@ const NATIVE_BIOMETRICS_HSM_VALUES = { KEY_ALREADY_EXISTS: 'KEY_ALREADY_EXISTS', KEY_ACCESS_FAILED: 'KEY_ACCESS_FAILED', + // Authentication failed + AUTHENTICATION_FAILED: 'AUTHENTICATION_FAILED', // Both + // System cancel SYSTEM_CANCEL: 'SYSTEM_CANCEL', // iOS SYSTEM_CANCELED: 'SYSTEM_CANCELED', // Android diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts index dbbf46a65c9ff..f6d4adedb010f 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts @@ -63,6 +63,7 @@ const SIGN_ERROR_CODE_MAP: Record = { [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.BIOMETRIC_LOCKOUT_PERMANENT]: VALUES.REASON.HSM.LOCKOUT_PERMANENT, [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.SIGNATURE_CREATION_FAILED]: VALUES.REASON.HSM.SIGNATURE_FAILED, [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.KEY_ACCESS_FAILED]: VALUES.REASON.HSM.KEY_ACCESS_FAILED, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.AUTHENTICATION_FAILED]: VALUES.REASON.HSM.AUTHENTICATION_FAILED, }; function mapSignErrorCodeToReason(errorCode?: string): MultifactorAuthenticationReason | undefined { diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index fba113655c18c..cecc0ffc30190 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -115,6 +115,7 @@ const REASON = { SIGNATURE_FAILED: 'Signature creation failed', KEY_CREATION_FAILED: 'Key creation failed', KEY_ACCESS_FAILED: 'Failed to access cryptographic key', + AUTHENTICATION_FAILED: 'Biometric authentication failed', GENERIC: 'An HSM error occurred', }, } as const; @@ -238,6 +239,7 @@ const ROUTINE_FAILURES = new Set([ REASON.HSM.CANCELED, REASON.HSM.NOT_AVAILABLE, REASON.HSM.LOCKOUT, + REASON.HSM.AUTHENTICATION_FAILED, ]); /** Known errors that should rarely happen and may indicate a bug or unexpected state. Logged at 'error' level. Any reason not in either set is treated as UNCLASSIFIED (e.g. 5xx, missing reason). */ From 7ab8ec03d624dc4152198a6e0a93188aa4577790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Fri, 3 Apr 2026 16:43:56 +0200 Subject: [PATCH 39/41] geterrormessage util --- .../MultifactorAuthentication/Context/Main.tsx | 5 +++-- .../biometrics/useNativeBiometricsHSM.ts | 9 +++++---- src/libs/ErrorUtils.ts | 9 +++++++++ src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts | 5 +++-- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/components/MultifactorAuthentication/Context/Main.tsx b/src/components/MultifactorAuthentication/Context/Main.tsx index 236dc0bc2286d..1e05ba86ba0d8 100644 --- a/src/components/MultifactorAuthentication/Context/Main.tsx +++ b/src/components/MultifactorAuthentication/Context/Main.tsx @@ -12,6 +12,7 @@ import trackMFAFlowStart from '@components/MultifactorAuthentication/observabili import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useNetwork from '@hooks/useNetwork'; import {requestValidateCodeAction} from '@libs/actions/User'; +import {getErrorMessage} from '@libs/ErrorUtils'; import getPlatform from '@libs/getPlatform'; import type {ChallengeType, MultifactorAuthenticationCallbackInput} from '@libs/MultifactorAuthentication/shared/types'; import Navigation from '@navigation/Navigation'; @@ -476,12 +477,12 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent } process().catch((error: unknown) => { - addMFABreadcrumb('Unhandled error', {message: error instanceof Error ? error.message : String(error)}, 'error'); + addMFABreadcrumb('Unhandled error', {message: getErrorMessage(error)}, 'error'); dispatch({ type: 'SET_ERROR', payload: { reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.GENERIC.UNHANDLED_ERROR, - message: error instanceof Error ? error.message : String(error), + message: getErrorMessage(error), }, }); }); diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts index 6d17ed4d5cee9..d2016f37ed9b8 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts @@ -3,6 +3,7 @@ import type {SignatureResult} from '@sbaiahmed1/react-native-biometrics'; import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; +import {getErrorMessage} from '@libs/ErrorUtils'; import {buildSigningData, getKeyAlias, mapAuthTypeNumber, mapLibraryErrorToReason, mapSignErrorCodeToReason} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; import type NativeBiometricsHSMKeyInfo from '@libs/MultifactorAuthentication/NativeBiometricsHSM/types'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; @@ -56,7 +57,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { if (reason === undefined) { reason = VALUES.REASON.HSM.GENERIC; } - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = getErrorMessage(error); addMFABreadcrumb('Failed to get local credential ID', {reason, message: errorMessage}, 'error'); return undefined; } @@ -76,7 +77,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { if (reason === undefined) { reason = VALUES.REASON.HSM.GENERIC; } - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = getErrorMessage(error); addMFABreadcrumb('Failed to delete local keys', {reason, message: errorMessage}, 'error'); } }; @@ -123,7 +124,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { onResult({ success: false, reason, - message: error instanceof Error ? error.message : String(error), + message: getErrorMessage(error), }); } }; @@ -194,7 +195,7 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { onResult({ success: false, reason, - message: error instanceof Error ? error.message : String(error), + message: getErrorMessage(error), }); } }; diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 351756738d0e8..06cf48c04bd2f 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -73,6 +73,14 @@ function getMicroSecondOnyxErrorObject(error: Errors, errorKey?: number): ErrorF return {[errorKey ?? DateUtils.getMicroseconds()]: error}; } +/** + * Extracts a string message from an unknown error value. + * Use this in catch blocks where the caught value has type `unknown`. + */ +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + // We can assume that if error is a string, it has already been translated because it is server error function getErrorMessageWithTranslationData(error: string | null): string { return error ?? ''; @@ -231,6 +239,7 @@ export { addErrorMessage, getAuthenticateErrorMessage, getEarliestErrorField, + getErrorMessage, getErrorMessageWithTranslationData, getErrorsWithTranslationData, getLatestErrorField, diff --git a/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts b/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts index 019ea0f7159d8..6d9a8db757d6b 100644 --- a/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts +++ b/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts @@ -1,4 +1,5 @@ import type {ValueOf} from 'type-fest'; +import {getErrorMessage} from '@libs/ErrorUtils'; import Log from '@libs/Log'; import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; import MARQETA_VALUES from '@libs/MultifactorAuthentication/shared/MarqetaValues'; @@ -138,12 +139,12 @@ type DecodedWebAuthnError = { /** Decodes WebAuthn DOMException errors and maps them to authentication error reasons. */ function decodeWebAuthnError(error: unknown): DecodedWebAuthnError { - Log.info('[Passkey] WebAuthn error', false, {error: error instanceof Error ? error.message : String(error)}); + Log.info('[Passkey] WebAuthn error', false, {error: getErrorMessage(error)}); if (error instanceof DOMException && isWebAuthnReason(error.name)) { return {reason: error.name}; } - return {reason: VALUES.REASON.WEBAUTHN.GENERIC, message: error instanceof Error ? error.message : String(error)}; + return {reason: VALUES.REASON.WEBAUTHN.GENERIC, message: getErrorMessage(error)}; } export { From 777df95c46389deb7efa2e0642ebd98b02f74bb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Fri, 3 Apr 2026 17:13:15 +0200 Subject: [PATCH 40/41] fix eslint --- src/libs/MultifactorAuthentication/NativeBiometrics/types.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts b/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts index d5e69bd6c8dcc..33f6948de7573 100644 --- a/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts +++ b/src/libs/MultifactorAuthentication/NativeBiometrics/types.ts @@ -2,9 +2,10 @@ * Type definitions specific to native biometrics (ED25519 / KeyStore). */ import type {ValueOf} from 'type-fest'; -import type {MultifactorAuthenticationMethodCode, MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; +import type {MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; import type CONST from '@src/CONST'; import type {Base64URLString} from '@src/utils/Base64URL'; +import type {SECURE_STORE_VALUES} from './SecureStore'; import type VALUES from './VALUES'; /** @@ -16,7 +17,7 @@ type MultifactorAuthenticationKeyStoreStatus = { reason: MultifactorAuthenticationReason; - type?: MultifactorAuthenticationMethodCode; + type?: ValueOf['CODE']; }; /** From 7f32516f81288cbcad6ebc799225bd2733dd4771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rejdak?= Date: Fri, 3 Apr 2026 17:25:16 +0200 Subject: [PATCH 41/41] fix tests --- .../MultifactorAuthentication/useNativeBiometricsHSM.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts index e35e5eb8d788e..6c4d67d7224da 100644 --- a/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts +++ b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts @@ -52,6 +52,7 @@ jest.mock('@sbaiahmed1/react-native-biometrics', () => ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-return isSensorAvailable: (...args: unknown[]) => mockIsSensorAvailable(...args), InputEncoding: {Base64: 'base64'}, + AuthType: {Unknown: -1, None: 0, DeviceCredentials: 1, Biometrics: 2, FaceID: 3, TouchID: 4, OpticID: 5}, })); jest.mock('@components/MultifactorAuthentication/config', () => ({ @@ -128,7 +129,7 @@ describe('useNativeBiometricsHSM hook', () => { // Given a device with no biometric sensor and no secure lock screen configured // When checking device support for biometric authentication // Then it should return false because there is no way to verify the user's identity on this device - mockIsSensorAvailable.mockResolvedValue({available: false}); + mockIsSensorAvailable.mockResolvedValue({available: false, isDeviceSecure: false}); const {result} = renderHook(() => useNativeBiometricsHSM());