diff --git a/cspell.json b/cspell.json index 3b835e29d5cbc..efae6be2eb952 100644 --- a/cspell.json +++ b/cspell.json @@ -652,6 +652,7 @@ "Salagatan", "samltool", "Saqbd", + "sbaiahmed", "SBFJ", "Scaleway", "Scaleway's", @@ -670,10 +671,10 @@ "Sepa", "serveo", "setuptools", - "shareeEmail", - "Sharees", "sharee", + "shareeEmail", "sharees", + "Sharees", "Sharons", "shellcheck", "shellenv", diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ea0bb775ad6fb..c0531436bcf92 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3515,6 +3515,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 @@ -4356,6 +4384,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`)" @@ -4676,6 +4705,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: @@ -4887,6 +4918,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 0eb286cc274abb059ee601b862ebddac2e681d01 ReactCodegen: d663254bf59e57e5ed7c65638bd45f358a373bba ReactCommon: 15e1e727fa34f760beb7dd52928687fda8edf8dc + ReactNativeBiometrics: f2356e3e148ff77f0e4763b4b79183eaa044a0dd ReactNativeHybridApp: 16ebccf5382436fcb9303ab5f4b50d9942bccf5c RNAppleAuthentication: 9027af8aa92b4719ef1b6030a8e954d37079473a RNCClipboard: e560338bf6cc4656a09ff90610b62ddc0dbdad65 diff --git a/jest/setup.ts b/jest/setup.ts index ee2b1702ba1e9..f9a2fd1989279 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({success: 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( diff --git a/package-lock.json b/package-lock.json index f75d146a1b939..a00fe77d3f31e 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.14.0", "@sentry/react-native": "8.2.0", "@shopify/flash-list": "2.3.0", "@shopify/react-native-skia": "^2.4.14", @@ -13213,6 +13214,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@sbaiahmed1/react-native-biometrics": { + "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" + ], + "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 79676de77d430..3c0217193552f 100644 --- a/package.json +++ b/package.json @@ -110,6 +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", "@sentry/react-native": "8.2.0", "@shopify/flash-list": "2.3.0", "@shopify/react-native-skia": "^2.4.14", 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..e872e36355965 --- /dev/null +++ b/patches/@sbaiahmed1/react-native-biometrics/@sbaiahmed1+react-native-biometrics+0.14.0+001+biometry-temporary-new-release.patch @@ -0,0 +1,511 @@ +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..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 + 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/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/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/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 ++++ 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/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 ++++ 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/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) diff --git a/src/components/MultifactorAuthentication/Context/Main.tsx b/src/components/MultifactorAuthentication/Context/Main.tsx index 5bc771de99915..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'; @@ -219,7 +220,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'); @@ -283,7 +284,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', ); @@ -300,7 +301,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent const registrationResponse = await processRegistration({ keyInfo: result.keyInfo, - authenticationMethod: result.authenticationMethod.marqetaValue, }); addMFABreadcrumb( @@ -384,6 +384,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', ); @@ -392,7 +393,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}); @@ -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/shared/types.ts b/src/components/MultifactorAuthentication/biometrics/shared/types.ts index e6fec8d5b2174..d6006a1648773 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 = @@ -16,6 +15,7 @@ type RegisterResult = | ({ success: false; reason: MultifactorAuthenticationReason; + message?: string; } & Partial); type AuthorizeParams = { @@ -32,6 +32,7 @@ type AuthorizeResultSuccess = { type AuthorizeResultFailure = { success: false; reason: MultifactorAuthenticationReason; + message?: string; }; type AuthorizeResult = AuthorizeResultSuccess | AuthorizeResultFailure; @@ -50,7 +51,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/useBiometrics/index.native.ts b/src/components/MultifactorAuthentication/biometrics/useBiometrics/index.native.ts index cb40a9d657c45..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 useNativeBiometrics from '@components/MultifactorAuthentication/biometrics/useNativeBiometrics'; +import useNativeBiometricsHSM from '@components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM'; -export default useNativeBiometrics; +export default useNativeBiometricsHSM; diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts index 82efbe0778ca2..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; }, []); @@ -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 new file mode 100644 index 0000000000000..d2016f37ed9b8 --- /dev/null +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts @@ -0,0 +1,220 @@ +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'; +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'; +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'; + +/** + * 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); +} + +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). + * Private keys never enter JS memory. + */ +function useNativeBiometricsHSM(): UseBiometricsReturn { + const {accountID} = useCurrentUserPersonalDetails(); + const {translate} = useLocalize(); + const {serverKnownCredentialIDs, haveCredentialsEverBeenConfigured} = useServerCredentials(); + + const doesDeviceSupportAuthenticationMethod = async () => { + const sensorResult = await isSensorAvailable(); + return sensorResult.isDeviceSecure; + }; + + const getLocalCredentialID = async () => { + try { + const keyAlias = getKeyAlias(accountID); + const {keys} = await getAllKeys(keyAlias); + const entry = keys.at(0); + if (!entry) { + return undefined; + } + return Base64URL.base64ToBase64url(entry.publicKey); + } catch (error) { + let reason = mapLibraryErrorToReason(error); + if (reason === undefined) { + reason = VALUES.REASON.HSM.GENERIC; + } + const errorMessage = getErrorMessage(error); + addMFABreadcrumb('Failed to get local credential ID', {reason, message: errorMessage}, 'error'); + return undefined; + } + }; + + const areLocalCredentialsKnownToServer = async () => { + const key = await getLocalCredentialID(); + return !!key && serverKnownCredentialIDs.includes(key); + }; + + const deleteLocalKeysForAccount = async () => { + try { + const keyAlias = getKeyAlias(accountID); + await deleteKeys(keyAlias); + } catch (error) { + let reason = mapLibraryErrorToReason(error); + if (reason === undefined) { + reason = VALUES.REASON.HSM.GENERIC; + } + const errorMessage = getErrorMessage(error); + addMFABreadcrumb('Failed to delete local keys', {reason, message: errorMessage}, 'error'); + } + }; + + const register = async (onResult: (result: RegisterResult) => Promise | void, registrationChallenge: Parameters[1]) => { + try { + const keyAlias = getKeyAlias(accountID); + + /** + * 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 = Base64URL.base64ToBase64url(publicKey); + + const clientDataJSON = JSON.stringify({challenge: registrationChallenge.challenge}); + const keyInfo: NativeBiometricsHSMKeyInfo = { + rawId: credentialID, + type: CONST.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_HSM_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, + }); + } catch (error) { + let reason = mapLibraryErrorToReason(error); + if (reason === undefined) { + reason = VALUES.REASON.HSM.KEY_CREATION_FAILED; + } + onResult({ + success: false, + reason, + message: getErrorMessage(error), + }); + } + }; + + 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((credential: {id: string; type: string}) => credential.id); + + if (!isCredentialAllowed(credentialID, allowedIDs)) { + await deleteLocalKeysForAccount(); + onResult({success: false, reason: VALUES.REASON.HSM.KEY_NOT_FOUND}); + return; + } + + const {authenticatorData, clientDataJSON, dataToSignB64} = await buildSigningData(challenge.rpId, challenge.challenge); + + // Sign with biometric prompt โ€” signWithOptions + const signResult = await signWithOptions({ + keyAlias, + data: dataToSignB64, + inputEncoding: InputEncoding.Base64, + promptTitle: translate('multifactorAuthentication.letsVerifyItsYou'), + returnAuthType: true, + }); + + if (!hasValidSignature(signResult)) { + let failReason = mapSignErrorCodeToReason(signResult.errorCode); + if (failReason === undefined) { + failReason = VALUES.REASON.HSM.GENERIC; + } + onResult({ + success: false, + reason: failReason, + message: failReason === VALUES.REASON.HSM.GENERIC ? signResult.errorCode : undefined, + }); + 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.BIOMETRICS_HSM_TYPE, + response: { + authenticatorData: Base64URL.base64ToBase64url(authenticatorData.toString('base64')), + clientDataJSON: Base64URL.encode(clientDataJSON), + signature: Base64URL.base64ToBase64url(signResult.signature), + }, + }, + authenticationMethod: authType, + }); + } catch (error) { + let reason = mapLibraryErrorToReason(error); + if (reason === undefined) { + reason = VALUES.REASON.HSM.GENERIC; + } + onResult({ + success: false, + reason, + message: getErrorMessage(error), + }); + } + }; + + const hasLocalCredentials = async () => !!(await getLocalCredentialID()); + + return { + deviceVerificationType: CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM, + serverKnownCredentialIDs, + haveCredentialsEverBeenConfigured, + getLocalCredentialID, + doesDeviceSupportAuthenticationMethod, + deviceCheckFailureReason: VALUES.REASON.GENERIC.NO_AUTHENTICATION_METHODS_ENROLLED, + hasLocalCredentials, + areLocalCredentialsKnownToServer, + register, + authorize, + deleteLocalKeysForAccount, + }; +} + +export default useNativeBiometricsHSM; diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index 9781c03b246be..f51087e1bb071 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; @@ -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; } @@ -105,10 +107,6 @@ function usePasskeys(): UseBiometricsReturn { attestationObject, }, }, - authenticationMethod: { - name: PASSKEY_AUTH_TYPE.NAME, - marqetaValue: PASSKEY_AUTH_TYPE.MARQETA_VALUE, - }, }); }; @@ -137,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/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx b/src/components/MultifactorAuthentication/components/AuthenticationMethodDescription.tsx index dc6536938acfe..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 {SECURE_STORE_VALUES} from '@libs/MultifactorAuthentication/NativeBiometrics/SecureStore'; +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 ?? SECURE_STORE_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/components/MultifactorAuthentication/config/scenarios/AuthorizeTransaction.tsx b/src/components/MultifactorAuthentication/config/scenarios/AuthorizeTransaction.tsx index e518f7bb01320..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.BIOMETRICS, 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 bd97a4e7ed9ac..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.BIOMETRICS, 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 42d411982dff2..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.BIOMETRICS, 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 a0dcf6557b61d..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.BIOMETRICS, 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 5d2ed736c71dd..77eb5811a82d5 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.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 4d5cb515cbfab..326a0f7d9baea 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/names.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/names.ts @@ -18,6 +18,7 @@ const SCENARIO_NAMES = { * Prompt identifiers for multifactor authentication scenarios. */ const PROMPT_NAMES = { + 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 73860d77d75e8..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.BIOMETRICS]: { + [VALUES.PROMPT.BIOMETRICS_HSM]: { illustration: LottieAnimations.Fingerprint, title: 'multifactorAuthentication.verifyYourself.biometrics', subtitle: 'multifactorAuthentication.enableQuickVerification.biometrics', 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/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/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']; }; /** diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts new file mode 100644 index 0000000000000..f705130e09034 --- /dev/null +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/VALUES.ts @@ -0,0 +1,109 @@ +/** + * Constants specific to native biometrics (HSM / react-native-biometrics). + */ +import {AuthType} from '@sbaiahmed1/react-native-biometrics/types'; +import MARQETA_VALUES from '@libs/MultifactorAuthentication/shared/MarqetaValues'; + +const NATIVE_BIOMETRICS_HSM_VALUES = { + /** + * HSM key type identifier sent in API requests to identify the HSM-backed biometric authentication method. + */ + BIOMETRICS_HSM_TYPE: 'biometric-hsm', + + /** + * Key alias suffix for HSM keys managed by react-native-biometrics. + */ + HSM_KEY_SUFFIX: 'HSM_KEY', + + /** + * Authentication types mapped to Marqeta values + */ + AUTH_TYPE: { + UNKNOWN: { + CODE: AuthType.Unknown, + 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, + }, + }, + /** + * 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 }. + */ + 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', + 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 + }, +} as const; + +export default NATIVE_BIOMETRICS_HSM_VALUES; diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts new file mode 100644 index 0000000000000..f6d4adedb010f --- /dev/null +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.ts @@ -0,0 +1,125 @@ +/** + * 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'; +import VALUES from '@libs/MultifactorAuthentication/VALUES'; +import CONST from '@src/CONST'; +import NATIVE_BIOMETRICS_HSM_VALUES from './VALUES'; + +type NativeBiometricsHSMTypeEntry = ValueOf; + +/** + * Builds the key alias for a given account. + */ +function getKeyAlias(accountID: number): string { + return `${accountID}_${CONST.MULTIFACTOR_AUTHENTICATION.HSM_KEY_SUFFIX}`; +} + +/** + * 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], +]); + +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 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_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, + [NATIVE_BIOMETRICS_HSM_VALUES.ERROR_CODE.AUTHENTICATION_FAILED]: VALUES.REASON.HSM.AUTHENTICATION_FAILED, +}; + +function mapSignErrorCodeToReason(errorCode?: string): MultifactorAuthenticationReason | undefined { + if (!errorCode) { + return undefined; + } + 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_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, +}; + +/** + * Maps caught exceptions from the library to REASON values. + */ +function mapLibraryErrorToReason(error: unknown): MultifactorAuthenticationReason | undefined { + if (!(error instanceof Error && 'code' in error && typeof error.code === 'string')) { + return undefined; + } + return LIBRARY_ERROR_CODE_MAP[error.code]; +} + +/** + * 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'); + + // 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); + + 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, mapAuthTypeNumber, mapSignErrorCodeToReason, mapLibraryErrorToReason, buildSigningData}; diff --git a/src/libs/MultifactorAuthentication/NativeBiometricsHSM/types.ts b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/types.ts new file mode 100644 index 0000000000000..a47f2f1cc5000 --- /dev/null +++ b/src/libs/MultifactorAuthentication/NativeBiometricsHSM/types.ts @@ -0,0 +1,20 @@ +/** + * 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 NativeBiometricsHSMKeyInfo = { + rawId: Base64URLString; + type: typeof VALUES.BIOMETRICS_HSM_TYPE; + response: { + clientDataJSON: Base64URLString; + biometric: { + publicKey: Base64URLString; + algorithm: typeof CONST.COSE_ALGORITHM.ES256; + }; + }; +}; + +export default NativeBiometricsHSMKeyInfo; diff --git a/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts b/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts index f6cc359aafdbf..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'; @@ -131,14 +132,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 { - Log.info('[Passkey] WebAuthn error', false, {error: error instanceof Error ? error.message : String(error)}); +function decodeWebAuthnError(error: unknown): DecodedWebAuthnError { + Log.info('[Passkey] WebAuthn error', false, {error: getErrorMessage(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: getErrorMessage(error)}; } export { diff --git a/src/libs/MultifactorAuthentication/VALUES.ts b/src/libs/MultifactorAuthentication/VALUES.ts index 4fd2a07354f44..5a8e72a0b1434 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_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, ...PASSKEY_VALUES, } as const; diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index 2853493a4cf0e..cecc0ffc30190 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -106,6 +106,18 @@ const REASON = { UNEXPECTED_RESPONSE: 'WebAuthn credential response type is unexpected', GENERIC: 'An unknown WebAuthn error occurred', }, + HSM: { + CANCELED: 'Biometric 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', + KEY_ACCESS_FAILED: 'Failed to access cryptographic key', + AUTHENTICATION_FAILED: 'Biometric authentication failed', + GENERIC: 'An HSM error occurred', + }, } as const; const HTTP_STATUS = { @@ -224,6 +236,10 @@ 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, + 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). */ @@ -254,6 +270,12 @@ 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.KEY_ACCESS_FAILED, + REASON.HSM.GENERIC, ]); const SHARED_VALUES = { @@ -271,14 +293,16 @@ const SHARED_VALUES = { * Maps authentication type to the corresponding prompt type. */ PROMPT_TYPE_MAP: { + BIOMETRICS_HSM: PROMPT_NAMES.BIOMETRICS_HSM, BIOMETRICS: PROMPT_NAMES.BIOMETRICS, PASSKEYS: PROMPT_NAMES.PASSKEYS, }, /** - * Authentication type identifiers. + * Authentication type identifiers used for identification of allowed authentication methods in scenarios */ TYPE: { + BIOMETRICS_HSM: 'BIOMETRICS_HSM', BIOMETRICS: 'BIOMETRICS', PASSKEYS: 'PASSKEYS', }, diff --git a/src/libs/MultifactorAuthentication/shared/types.ts b/src/libs/MultifactorAuthentication/shared/types.ts index 7b362492387f0..98cd57160c22d 100644 --- a/src/libs/MultifactorAuthentication/shared/types.ts +++ b/src/libs/MultifactorAuthentication/shared/types.ts @@ -4,19 +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 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'; 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; @@ -24,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. @@ -52,7 +53,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 | NativeBiometricsHSMKeyInfo | PasskeyRegistrationKeyInfo; type ChallengeType = ValueOf; 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/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/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/config/scenarios/index.test.ts b/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts index 9509a60800861..ec1790886e15e 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.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.BIOMETRICS, 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/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); 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 new file mode 100644 index 0000000000000..6c4d67d7224da --- /dev/null +++ b/tests/unit/components/MultifactorAuthentication/useNativeBiometricsHSM.test.ts @@ -0,0 +1,503 @@ +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'; +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(); +const mockIsSensorAvailable = 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), + // 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', () => ({ + MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG: new Proxy( + {}, + { + get: () => ({ + nativePromptTitle: 'multifactorAuthentication.biometricsTest.promptTitle', + }), + }, + ), +})); +jest.mock('@userActions/MultifactorAuthentication/processing'); + +const DEFAULT_SENSOR_RESULT = {available: true, biometryType: 'FaceID', isDeviceSecure: true}; + +describe('useNativeBiometricsHSM hook', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockMultifactorAuthenticationPublicKeyIDs = []; + mockIsSensorAvailable.mockResolvedValue(DEFAULT_SENSOR_RESULT); + + mockGetAllKeys.mockResolvedValue({keys: []}); + }); + + 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(() => useNativeBiometricsHSM()); + + 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', () => { + // 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(() => useNativeBiometricsHSM()); + + expect(result.current.deviceVerificationType).toBe(CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM); + }); + }); + + describe('doesDeviceSupportAuthenticationMethod', () => { + 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()); + + await expect(result.current.doesDeviceSupportAuthenticationMethod()).resolves.toBe(true); + }); + + 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 + mockIsSensorAvailable.mockResolvedValue({available: false, isDeviceSecure: true}); + + const {result} = renderHook(() => useNativeBiometricsHSM()); + + await expect(result.current.doesDeviceSupportAuthenticationMethod()).resolves.toBe(true); + }); + + 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 + mockIsSensorAvailable.mockResolvedValue({available: false, isDeviceSecure: false}); + + const {result} = renderHook(() => useNativeBiometricsHSM()); + + await expect(result.current.doesDeviceSupportAuthenticationMethod()).resolves.toBe(false); + }); + }); + + describe('getLocalCredentialID', () => { + it('should return undefined when no local key exists', async () => { + // 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(() => useNativeBiometricsHSM()); + + const key = await result.current.getLocalCredentialID(); + expect(key).toBeUndefined(); + }); + + it('should return base64url-encoded public key when key exists', async () => { + // 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_HSM_KEY'; + mockGetAllKeys.mockResolvedValue({keys: [{alias: keyAlias, publicKey: 'abc+def/ghi='}]}); + + const {result} = renderHook(() => useNativeBiometricsHSM()); + + const key = await result.current.getLocalCredentialID(); + expect(key).toBe('abc-def_ghi'); + }); + }); + + describe('areLocalCredentialsKnownToServer', () => { + it('should return false when no local credential exists', async () => { + // 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(() => 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 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_HSM_KEY'; + mockMultifactorAuthenticationPublicKeyIDs = ['abc-def_ghi']; + mockGetAllKeys.mockResolvedValue({keys: [{alias: keyAlias, publicKey: 'abc+def/ghi='}]}); + + const {result} = renderHook(() => useNativeBiometricsHSM()); + + const isKnown = await result.current.areLocalCredentialsKnownToServer(); + expect(isKnown).toBe(true); + }); + }); + + 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(() => useNativeBiometricsHSM()); + + expect(result.current.serverKnownCredentialIDs).toEqual(['key-1', 'key-2']); + }); + + 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(() => useNativeBiometricsHSM()); + + expect(result.current.serverKnownCredentialIDs).toEqual([]); + }); + }); + + 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(() => useNativeBiometricsHSM()); + + expect(result.current.haveCredentialsEverBeenConfigured).toBe(false); + }); + + 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(() => useNativeBiometricsHSM()); + + expect(result.current.haveCredentialsEverBeenConfigured).toBe(true); + }); + + 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(() => useNativeBiometricsHSM()); + + 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 () => { + // Given a valid registration challenge from the server + // When registering a new biometric credential + // 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_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 HSM key pair + // When the registration completes + // 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 () => { + 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.BIOMETRICS_HSM_TYPE, + }), + }), + ); + }); + + 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(() => useNativeBiometricsHSM()); + 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_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: AuthType.FaceID}); + }); + + it('should sign challenge and return success', async () => { + // 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 HSM type so the server can verify the signature + const {result} = renderHook(() => useNativeBiometricsHSM()); + 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.BIOMETRICS_HSM_TYPE, + }), + }), + ); + }); + + it('should call signWithOptions with biometric prompt', async () => { + // 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(() => useNativeBiometricsHSM()); + const onResult = jest.fn(); + + await act(async () => { + await result.current.authorize({challenge: mockChallenge}, onResult); + }); + + expect(mockSignWithOptions).toHaveBeenCalledWith( + expect.objectContaining({ + keyAlias: '12345_HSM_KEY', + promptTitle: 'translated_multifactorAuthentication.letsVerifyItsYou', + returnAuthType: true, + }), + ); + }); + + 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(() => useNativeBiometricsHSM()); + 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 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 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(); + + await act(async () => { + await result.current.authorize({challenge: mockChallenge}, onResult); + }); + + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + reason: VALUES.REASON.HSM.CANCELED, + }), + ); + }); + + 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 + // 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, + }), + ); + }); + }); + + describe('deleteLocalKeysForAccount', () => { + it('should delete keys with correct alias', async () => { + // 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(() => useNativeBiometricsHSM()); + + await act(async () => { + await result.current.deleteLocalKeysForAccount(); + }); + + expect(mockDeleteKeys).toHaveBeenCalledWith('12345_HSM_KEY'); + }); + }); +}); 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: () => ({ diff --git a/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts new file mode 100644 index 0000000000000..c7f46ef49a8da --- /dev/null +++ b/tests/unit/libs/MultifactorAuthentication/NativeBiometricsHSM/helpers.test.ts @@ -0,0 +1,334 @@ +import {Buffer} from 'buffer'; +import {buildSigningData, getKeyAlias, mapAuthTypeNumber, mapLibraryErrorToReason, mapSignErrorCodeToReason} 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', () => { + describe('getKeyAlias', () => { + 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 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_HSM_KEY'); + expect(getKeyAlias(999999)).toBe('999999_HSM_KEY'); + }); + }); + + 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_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, + }); + }); + + 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'); + }); + }); + + 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(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(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_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_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_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_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_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_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_BIOMETRICS_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(mapSignErrorCodeToReason('some_unknown_error')).toBe(VALUES.REASON.HSM.GENERIC); + }); + }); + + 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_BIOMETRICS_HSM_VALUES.ERROR_CODE.USER_CANCEL}); + expect(mapLibraryErrorToReason(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_BIOMETRICS_HSM_VALUES.ERROR_CODE.USER_CANCELED}); + expect(mapLibraryErrorToReason(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 HSM.KEY_CREATION_FAILED + 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); + }); + + 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 HSM.KEY_CREATION_FAILED since the key creation operation failed + 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); + }); + + 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 HSM.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); + }); + + 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 HSM.KEY_ACCESS_FAILED to trigger re-registration + 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); + }); + + 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(mapLibraryErrorToReason(new Error('Network error'))).toBeUndefined(); + }); + + 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(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(mapLibraryErrorToReason('some string error')).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.subarray(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.subarray(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})); + }); + }); +});