diff --git a/README.md b/README.md index 9fdbbf1..65b2c73 100644 --- a/README.md +++ b/README.md @@ -827,6 +827,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/docs/CRYPTOGRAPHIC_KEYS.md b/docs/CRYPTOGRAPHIC_KEYS.md index 43a6051..3bfa592 100644 --- a/docs/CRYPTOGRAPHIC_KEYS.md +++ b/docs/CRYPTOGRAPHIC_KEYS.md @@ -245,6 +245,22 @@ async function debugKeyCreation() { ## Migration Guide +### iOS Access-Control Migration (`.biometryAny` vs `.biometryCurrentSet`) + +On iOS, key access-control policy is fixed at key creation time: + +- Existing keys remain bound to the policy they were created with. +- New keys can use: + - `.biometryAny` (default/backward-compatible) + - `.biometryCurrentSet` (use `biometricStrength: 'strong'` during `createKeys`) + - `.userPresence` (when `allowDeviceCredentials` is `true`) + +Migration recommendations: + +1. Keep existing aliases on `.biometryAny` for compatibility unless stricter behavior is required. +2. To adopt `.biometryCurrentSet`, roll keys by alias (`deleteKeys` then `createKeys` with `biometricStrength: 'strong'`). +3. Communicate to users that `.biometryCurrentSet` keys are invalidated after biometric enrollment changes and require key re-enrollment. + ### From Legacy Implementations If you're upgrading from an older version that only supported RSA: diff --git a/ios/ReactNativeBiometrics.swift b/ios/ReactNativeBiometrics.swift index f44289c..87acb77 100644 --- a/ios/ReactNativeBiometrics.swift +++ b/ios/ReactNativeBiometrics.swift @@ -34,6 +34,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 @@ -391,6 +450,12 @@ class ReactNativeBiometrics: RCTEventEmitter { } else { 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 { @@ -430,7 +495,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) @@ -469,6 +535,14 @@ class ReactNativeBiometrics: RCTEventEmitter { let result: [String: Any] = [ "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 +571,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 +582,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 +596,7 @@ class ReactNativeBiometrics: RCTEventEmitter { } case errSecItemNotFound: + clearStoredBiometricDomainState(for: keyTag) ReactNativeBiometricDebug.debugLog("No key found with tag '\(keyTag)' - nothing to delete") resolve(["success": true]) @@ -709,6 +786,15 @@ class ReactNativeBiometrics: RCTEventEmitter { checks["keyFormatValid"] = true 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)! @@ -879,6 +965,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 diff --git a/ios/ReactNativeBiometricsError.swift b/ios/ReactNativeBiometricsError.swift index ed56895..3e5b93e 100644 --- a/ios/ReactNativeBiometricsError.swift +++ b/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/ios/Utils.swift b/ios/Utils.swift index 50010e0..427dcfc 100644 --- a/ios/Utils.swift +++ b/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 }