Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Comment thread
sbaiahmed1 marked this conversation as resolved.
> 📖 **For detailed key type information, security considerations, and advanced usage patterns, see the [Cryptographic Keys Guide](./docs/CRYPTOGRAPHIC_KEYS.md)**

Expand Down
16 changes: 16 additions & 0 deletions docs/CRYPTOGRAPHIC_KEYS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
103 changes: 101 additions & 2 deletions ios/ReactNativeBiometrics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return storedDomainState != currentDomainState
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@objc
override static func requiresMainQueueSetup() -> Bool {
return false
Expand Down Expand Up @@ -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"

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Check if key already exists when failIfExists is true
if failIfKeyExists {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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])

Expand Down Expand Up @@ -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)!
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions ios/ReactNativeBiometricsError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public enum ReactNativeBiometricsError: Error {
case keychainQueryFailed
case invalidKeyReference
case keyIntegrityCheckFailed
case biometryCurrentSetChanged

case signatureCreationFailed
case signatureVerificationFailed
Expand Down Expand Up @@ -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:
Expand Down
36 changes: 25 additions & 11 deletions ios/Utils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}

Expand Down
Loading