# Biometric Authentication

Severity when violated: **CRITICAL.** Of all the insecure patterns that code
generators produce, a lone `LAContext.evaluatePolicy()` call guarding sensitive
content is the most dangerous, because it can be defeated at runtime without
any exploit.

Before any Face ID prompt can appear, the app's `Info.plist` needs
`NSFaceIDUsageDescription` with a sentence explaining why the app uses Face ID.
Apple's LocalAuthentication documentation notes that requests can fail without
it. This applies equally to `evaluatePolicy` and to keychain reads that trigger
Face ID through an access control. Touch ID needs no usage string.

## Contents

- [Why a Boolean Gate Fails](#why-a-boolean-gate-fails)
- [The Secure Pattern: Let the Keychain Ask](#the-secure-pattern-let-the-keychain-ask)
- [evaluatePolicy and evaluateAccessControl](#evaluatepolicy-and-evaluateaccesscontrol)
- [Which Flag](#which-flag)
- [Availability and Degradation](#availability-and-degradation)
- [Detecting Enrollment Changes](#detecting-enrollment-changes)
- [Threads and async/await](#threads-and-asyncawait)
- [Secure Enclave Keys Behind Biometrics](#secure-enclave-keys-behind-biometrics)
- [Controls in the Development Process](#controls-in-the-development-process)
- [Verifying at Runtime](#verifying-at-runtime)
- [Checklist](#checklist)
- [Sources and Related Files](#sources-and-related-files)

## Why a Boolean Gate Fails

`evaluatePolicy` asks the system to authenticate the user and then calls your
closure with `true` or `false`. The Secure Enclave did the matching correctly,
but all your app receives is a Boolean in ordinary process memory, and nothing
cryptographic depends on it.

```swift
// Wrong: the only barrier is a Bool the attacker can flip.
@MainActor
final class StatementsGate {
    var unlocked = false
    func unlock(show: @escaping @MainActor () -> Void) {
        let ctx = LAContext()
        var probe: NSError?
        guard ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &probe) else { return }
        ctx.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
                           localizedReason: "View statements") { @Sendable ok, _ in
            Task { @MainActor in
                self.unlocked = ok
                if ok { show() }
            }
        }
    }
}
```

On a jailbroken or instrumented device:

- objection, a Frida-based toolkit, does it in two commands: attach with
  `objection -g "<bundle id>" explore`, then run `ios ui biometrics_bypass`.
- A hand-written Frida script hooks
  `-[LAContext evaluatePolicy:localizedReason:reply:]`, swaps the reply block
  (argument index 4 counting `self` and `_cmd`) for one that reports success
  with a nil error, and the app proceeds.

This weakness is CWE-288, Authentication Bypass Using an Alternate Path or
Channel. OWASP MASTG fails apps whose only protection is `evaluatePolicy`
(test MASTG-TEST-0266; requirements MSTG-AUTH-8 and MSTG-AUTH-12): biometrics
must unlock something in the keychain, not flip an event-driven true or false.

The objection documentation states that its bypass does not work against
keychain items protected with `.biometryCurrentSet` or `.biometryAny`. That
limitation is exactly the defence.

## The Secure Pattern: Let the Keychain Ask

Store the secret as a keychain item with a biometric access control. The
item's key is held by the Secure Enclave, which runs its own microkernel
(sepOS) and has its own encrypted memory. The enclave checks the biometric
match itself and releases the decryption key only on success, so there is no
Boolean in the app's process to patch.

WWDC 2014 Session 711 frames the difference as trusting the OS (the
`evaluatePolicy` result) versus trusting the Secure Enclave (a keychain ACL
evaluated in hardware).

### Step 1: an access control

```swift
import LocalAuthentication
import Security

enum BioVaultError: Error {
    case policyCreation(Error?)
    case keychain(OSStatus)
    case badData
    case biometryUnavailable(LAError.Code?)
}

func makeBiometricPolicy() throws -> SecAccessControl {
    var cfError: Unmanaged<CFError>?
    guard let acl = SecAccessControlCreateWithFlags(
        kCFAllocatorDefault,
        kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
        .biometryCurrentSet,
        &cfError) else {
        throw BioVaultError.policyCreation(cfError?.takeRetainedValue())
    }
    return acl
}
```

### Step 2: store it

Save with add, then update on duplicate. The update of a protected item needs
the user to authenticate, so it runs with an `LAContext`. Changing the policy
of an existing item (for example from `.biometryAny` to
`.biometryCurrentSet`) is the one case for delete and re-add, because
`SecItemUpdate` cannot alter access control; see
[keychain-access-control.md](keychain-access-control.md).

```swift
func storeAccountNumber(_ digits: Data, context: LAContext) throws {
    let match: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: "com.example.bank",
        kSecAttrAccount as String: "account-number",
        kSecAttrSynchronizable as String: kCFBooleanFalse as Any
    ]
    var insert = match
    insert[kSecAttrAccessControl as String] = try makeBiometricPolicy()
    insert[kSecValueData as String] = digits

    let added = SecItemAdd(insert as CFDictionary, nil)
    if added == errSecSuccess { return }
    guard added == errSecDuplicateItem else { throw BioVaultError.keychain(added) }

    var existing = match
    existing[kSecUseAuthenticationContext as String] = context
    let changed = SecItemUpdate(existing as CFDictionary,
                                [kSecValueData as String: digits] as CFDictionary)
    guard changed == errSecSuccess else { throw BioVaultError.keychain(changed) }
}
```

- The dictionary has `kSecAttrAccessControl` and no `kSecAttrAccessible`;
  both together are `errSecParam`.
- Biometric-protected secrets use a `ThisDeviceOnly` class and are never
  synchronizable. Syncing would put the secret on devices whose biometrics the
  policy never saw.

### Step 3: read it

```swift
func readAccountNumber() throws -> Data {
    let ctx = LAContext()
    ctx.localizedReason = "Show your account number"
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: "com.example.bank",
        kSecAttrAccount as String: "account-number",
        kSecReturnData as String: true,
        kSecMatchLimit as String: kSecMatchLimitOne,
        kSecUseAuthenticationContext as String: ctx
    ]
    var out: CFTypeRef?
    let status = SecItemCopyMatching(query as CFDictionary, &out)
    switch status {
    case errSecSuccess:
        guard let digits = out as? Data else { throw BioVaultError.badData }
        return digits
    case errSecItemNotFound:
        throw BioVaultError.keychain(status)
    case errSecUserCanceled:
        throw BioVaultError.keychain(status)
    case errSecAuthFailed:
        throw BioVaultError.keychain(status)
    default:
        throw BioVaultError.keychain(status)
    }
}
```

`errSecUserCanceled` is -128 (the user backed out). `errSecAuthFailed` is -25293
(the match failed, or the enrollment behind a `.biometryCurrentSet` item
changed). Authentication and decryption are a single operation: the query
itself raises the system prompt, the enclave unwraps the item's AES-256-GCM
key, and there is no callback to intercept.

## evaluatePolicy and evaluateAccessControl

- `evaluatePolicy(_:localizedReason:reply:)`: the enclave checks the user, but
  only a Boolean reaches your process. No key is released.
- `evaluateAccessControl(_:operation:localizedReason:reply:)`: checks a
  `SecAccessControl` for a given operation (`.useItem`, `.useKeySign`,
  `.useKeyDecrypt`). You then pass the same context to `SecItemCopyMatching`
  through `kSecUseAuthenticationContext`, and the enclave accepts the earlier
  authentication.

You rarely need `evaluateAccessControl` directly. Store with an ACL through
`SecItemAdd`, read with `SecItemCopyMatching`, and let the system prompt.

`evaluatePolicy` still has one legitimate use: UI decisions that carry no
security weight, such as whether to show an "Unlock with Face ID" button.

## Which Flag

| Flag | Behaviour | Fits | Pair with |
| --- | --- | --- | --- |
| `.biometryCurrentSet` | tied to the exact enrollment; adding a finger, re-enrolling Face ID or removing an entry makes the item unreadable forever | banking, payments, stored credentials | `WhenPasscodeSetThisDeviceOnly` |
| `.biometryAny` | survives enrollment changes, so someone who enrols their own biometrics on a compromised device gets in | "remember me", non-critical app locks, preferences | `WhenUnlockedThisDeviceOnly` |
| `.userPresence` | biometrics or passcode; the passcode can be shoulder-surfed | accessibility-first apps, devices without biometrics, degradation path | `WhenPasscodeSetThisDeviceOnly` |

With `.biometryCurrentSet`, users who change their biometrics must re-verify
through the app's own sign-in. Detect the change with
`LAContext.evaluatedPolicyDomainState` (below).

For most production apps, `[.biometryCurrentSet, .or, .devicePasscode]` with
`WhenPasscodeSetThisDeviceOnly` gives tight binding and still leaves a way in
when Face ID fails.

## Availability and Degradation

```swift
// Wrong: a bare Bool hides why biometrics are unavailable.
func biometricsReady() -> Bool {
    LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
}
```

```swift
enum BiometricState {
    case ready(LABiometryType)
    case notEnrolled
    case lockedOut
    case unsupported
    case noPasscode
}

func biometricState() -> BiometricState {
    let ctx = LAContext()
    var nsError: NSError?
    if ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &nsError) {
        return .ready(ctx.biometryType)
    }
    switch LAError.Code(rawValue: nsError?.code ?? 0) {
    case .biometryNotEnrolled: return .notEnrolled
    case .biometryLockout: return .lockedOut
    case .passcodeNotSet: return .noPasscode
    default: return .unsupported
    }
}
```

| State | What to do |
| --- | --- |
| `.biometryNotEnrolled` | explain how to turn on Face ID or Touch ID in Settings |
| `.biometryLockout` | too many failures; entering the device passcode resets the sensor |
| `.biometryNotAvailable` | no hardware, or restricted by MDM; hide biometric UI |
| `.passcodeNotSet` | ask the user to set a passcode |
| ready | `context.biometryType` (`LABiometryType`) says Face ID, Touch ID or Optic ID |

A degradation flow reads the biometric item when biometrics are ready, falls
back to an item protected with `.userPresence` when the sensor is locked out
(so the passcode works), and otherwise throws an error carrying guidance for the
user. An unhandled `.biometryLockout` strands users: the app cannot clear a
lockout, and only a successful passcode entry on the device does.

`canEvaluatePolicy()` is for deciding what UI to show. It is never a security
control.

## Detecting Enrollment Changes

```swift
struct EnrollmentWatcher {
    private let storeKey = "bio.domainState"

    private func currentState() -> Data? {
        let ctx = LAContext()
        guard ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) else { return nil }
        if #available(iOS 18.0, macOS 15.0, *) {
            return ctx.domainState.biometry.stateHash
        }
        return ctx.evaluatedPolicyDomainState
    }

    func remember() {
        guard let state = currentState() else { return }
        UserDefaults.standard.set(state, forKey: storeKey)
    }

    func hasChanged() -> Bool {
        guard let now = currentState(),
              let before = UserDefaults.standard.data(forKey: storeKey) else { return true }
        return now != before
    }
}
```

The state hash is opaque `Data` that changes whenever the enrolled set changes
and reveals nothing about the biometrics themselves, so `UserDefaults` is an
acceptable place for it. When it cannot be read, or no earlier value was saved,
treat that as a change. From iOS 18 it is `domainState.biometry.stateHash`;
`evaluatedPolicyDomainState` is deprecated there and stays only as the fallback
for older systems. Nothing promises the two values are equal, so the first check
after an update past iOS 18 may report a change, which fails safe.

## Threads and async/await

- A `SecItemCopyMatching` on a biometric item blocks until the user finishes
  with the prompt. Never call it on `@MainActor` or the main thread.
- The completion of the callback-based `evaluatePolicy` runs on a private queue.
  Touching UI from there crashes, and iOS 18 is stricter about it.

```swift
actor BiometricVault {
    func read(account: String, reason: String) async throws -> Data {
        try await withCheckedThrowingContinuation { continuation in
            DispatchQueue.global(qos: .userInitiated).async {
                let ctx = LAContext()
                ctx.localizedReason = reason
                let query: [String: Any] = [
                    kSecClass as String: kSecClassGenericPassword,
                    kSecAttrAccount as String: account,
                    kSecReturnData as String: true,
                    kSecMatchLimit as String: kSecMatchLimitOne,
                    kSecUseAuthenticationContext as String: ctx
                ]
                var out: CFTypeRef?
                let status = SecItemCopyMatching(query as CFDictionary, &out)
                if status == errSecSuccess, let data = out as? Data {
                    continuation.resume(returning: data)
                } else {
                    continuation.resume(throwing: BioVaultError.keychain(status))
                }
            }
        }
    }
}

@MainActor
@Observable
final class RevealModel {
    var revealed: String?
    var problem: String?
    private let vault = BiometricVault()

    func reveal() {
        Task {
            do {
                let data = try await vault.read(account: "account-number", reason: "Reveal")
                revealed = String(decoding: data, as: UTF8.self)
            } catch {
                problem = "Could not unlock the account number."
            }
        }
    }
}
```

The continuation keeps the blocking call off the actor's executor. iOS 15 added
`evaluatePolicy(_:localizedReason:) async throws -> Bool`, but it only helps
with UI gating; SecItem has no async variant, hence the wrapper.

## Secure Enclave Keys Behind Biometrics

```swift
import CryptoKit

func makeApprovalKey() throws -> SecureEnclave.P256.Signing.PrivateKey {
    var cfError: Unmanaged<CFError>?
    guard let acl = SecAccessControlCreateWithFlags(
        nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        [.privateKeyUsage, .biometryCurrentSet], &cfError) else {
        throw BioVaultError.policyCreation(cfError?.takeRetainedValue())
    }
    return try SecureEnclave.P256.Signing.PrivateKey(accessControl: acl)
}
```

Each `signature(for:)` on that key raises the biometric prompt (WWDC 2019
Session 709, "Cryptography and Your Apps"). The private key never leaves the
enclave. Frida runs in user space on the Application Processor and has no path
into the enclave; the enclave's memory is encrypted by its own AES engine, and
even a kernel compromise cannot pull the key out. More in
[secure-enclave.md](secure-enclave.md).

## Controls in the Development Process

- A static-analysis rule, call it `INSECURE_BIOMETRIC_GATE`, flags any
  `evaluatePolicy` whose success value unlocks a resource when the same flow
  has no `SecItemCopyMatching` against an item with `SecAccessControl`.
- Sign-off requires: no standalone gates on sensitive operations; `SecItemAdd`
  calls using `kSecAttrAccessControl` with a `ThisDeviceOnly` class; a recorded
  run showing the objection bypass fails; every `LAError` case handled; a tested
  re-enrollment path wherever `.biometryCurrentSet` is used;
  `NSFaceIDUsageDescription` present.

## Verifying at Runtime

On a jailbroken or instrumented test device, hook
`-[LAContext evaluatePolicy:localizedReason:reply:]` so it always reports
success, then try to reach the protected data.

- Pass: the data stays locked.
- Fail: the hook grants access.

## Checklist

- No standalone Boolean gates; secrets sit in the keychain behind an ACL.
- Every biometric-protected item is created with
  `SecAccessControlCreateWithFlags`, so the enclave enforces the ACL.
- Flag choice: `.biometryCurrentSet` for high security, `.biometryAny` for
  convenience, `.userPresence` for compatibility and fallback.
- Never both accessibility attributes on one item.
- Biometric items use `WhenPasscodeSetThisDeviceOnly` or
  `WhenUnlockedThisDeviceOnly` and are never synchronizable.
- `Info.plist` contains `NSFaceIDUsageDescription`.
- `LAError` cases handled: `.biometryNotEnrolled`, `.biometryLockout`,
  `.biometryNotAvailable`, `.passcodeNotSet`, `.userCancel`, `.userFallback`.
- A passcode or password fallback exists for when biometrics are unavailable or
  locked out.
- With `.biometryCurrentSet`, `evaluatedPolicyDomainState` is monitored and a
  re-enrollment flow exists.
- Biometric ACL reads never run on `@MainActor`.
- The objection or Frida bypass test is part of release testing.
- CI runs the static rule against standalone `evaluatePolicy`.

## Sources and Related Files

Sources: WWDC 2014 Session 711; WWDC 2019 Session 709; Apple Platform Security
Guide; MSTG-AUTH-8, MSTG-AUTH-12 and MASTG-TEST-0266; the objection wiki page
"Understanding the iOS Biometrics Bypass"; TN3137.

Related: [keychain-fundamentals.md](keychain-fundamentals.md),
[keychain-access-control.md](keychain-access-control.md),
[secure-enclave.md](secure-enclave.md),
[common-anti-patterns.md](common-anti-patterns.md) (the `LAContext`-only gate),
[credential-storage-patterns.md](credential-storage-patterns.md),
[testing-security-code.md](testing-security-code.md),
[compliance-owasp-mapping.md](compliance-owasp-mapping.md) (M3).
