# Keychain and Biometrics

The minimum storage and biometric patterns that sign-in code needs. This is a
quick reference, not a security architecture: Keychain migration,
access-control policy design, CryptoKit, Secure Enclave, pinning, sharing
items across apps, hardening and MASVS/MASTG mapping belong to `swift-security`.
Back to the [skill overview](../SKILL.md).

## Where secrets go

Session tokens, refresh tokens, passwords and API keys belong in the Keychain.
`UserDefaults`, loose files and Core Data are all wrong for them.

## A small token vault

```swift
import Foundation
import Security

struct TokenVault {
    let service: String

    private func query(_ account: String) -> [String: Any] {
        [kSecClass as String: kSecClassGenericPassword,
         kSecAttrService as String: service,
         kSecAttrAccount as String: account]
    }

    func store(_ secret: Data, account: String) throws {
        var insert = query(account)
        insert[kSecValueData as String] = secret
        insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly

        switch SecItemAdd(insert as CFDictionary, nil) {
        case errSecSuccess:
            return
        case errSecDuplicateItem:
            let changes = [kSecValueData as String: secret] as CFDictionary
            let status = SecItemUpdate(query(account) as CFDictionary, changes)
            if status != errSecSuccess { throw VaultError.updateFailed(status) }
        case let status:
            throw VaultError.saveFailed(status)
        }
    }

    func fetch(account: String) throws -> Data {
        var lookup = query(account)
        lookup[kSecReturnData as String] = kCFBooleanTrue
        lookup[kSecMatchLimit as String] = kSecMatchLimitOne
        var item: AnyObject?
        let status = SecItemCopyMatching(lookup as CFDictionary, &item)
        guard status == errSecSuccess else { throw VaultError.readFailed(status) }
        guard let secret = item as? Data else { throw VaultError.readFailed(errSecDecode) }
        return secret
    }

    func remove(account: String) throws {
        let status = SecItemDelete(query(account) as CFDictionary)
        // A missing item already counts as removed.
        if status != errSecSuccess && status != errSecItemNotFound {
            throw VaultError.deleteFailed(status)
        }
    }
}
```

Every operation goes through the same `query(_:)`, so add, update, read and
delete always match the same item.

### Accessibility

Pick a `ThisDeviceOnly` class for sensitive credentials unless backup restore
or sharing is an explicit requirement; anything broader is a `swift-security`
decision. Do not use `kSecAttrAccessibleAlways`. Apple lists it as not
recommended because the item stays readable whatever the lock state.

## Biometrics with `LAContext`

An `LAContext` prompt is a local gate for the interface. It does not prove that
a secret may be released. For high-value secrets, attach access control to the
Keychain item and let `SecItemCopyMatching` run the authentication.

Falling back to the device passcode when biometrics are not available:

```swift
import LocalAuthentication

func unlockSettings(reason: String) async throws -> Bool {
    let context = LAContext()
    let policy: LAPolicy
    if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) {
        policy = .deviceOwnerAuthenticationWithBiometrics
    } else {
        var passcodeProblem: NSError?
        guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &passcodeProblem) else {
            throw passcodeProblem ?? LAError(.passcodeNotSet)
        }
        policy = .deviceOwnerAuthentication   // biometrics or passcode
    }
    return try await context.evaluatePolicy(policy, localizedReason: reason)
}
```

### Info.plist

Add `NSFaceIDUsageDescription` with a sentence explaining why the app uses
Face ID. The `LAContext` header asks for this key; the system shows the string
the first time the app uses Face ID, and evaluations fail with
`LAError.biometryNotAvailable` once the user denies Face ID for the app.

### Tuning the context

```swift
let context = LAContext()
context.localizedFallbackTitle = "Enter Passcode"
context.touchIDAuthenticationAllowableReuseDuration = 60   // seconds
let enrollmentHash = context.domainState.biometry.stateHash   // store it; a different value later means enrollment changed
```

`evaluatedPolicyDomainState` served the same purpose but is deprecated since
iOS 18 in favor of `domainState.biometry.stateHash`.

## Items that require biometrics to read

```swift
func sealBehindBiometrics(_ payload: Data, in item: (service: String, account: String)) throws {
    guard let control = SecAccessControlCreateWithFlags(
        nil,
        kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
        .biometryCurrentSet,
        nil
    ) else { throw VaultError.saveFailed(errSecParam) }

    let attributes: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: item.service,
        kSecAttrAccount as String: item.account,
        kSecValueData as String: payload,
        kSecAttrAccessControl as String: control,
    ]
    let added = SecItemAdd(attributes as CFDictionary, nil)
    if added != errSecSuccess { throw VaultError.saveFailed(added) }
}

func openBiometricItem(_ item: (service: String, account: String)) throws -> Data {  // prompts via Keychain
    let context = LAContext()
    context.localizedReason = "Unlock your saved card"
    let lookup: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: item.service,
        kSecAttrAccount as String: item.account,
        kSecReturnData as String: true,
        kSecUseAuthenticationContext as String: context,
    ]
    var item: AnyObject?
    let status = SecItemCopyMatching(lookup as CFDictionary, &item)
    guard status == errSecSuccess, let data = item as? Data else { throw VaultError.readFailed(status) }
    return data
}
```

Here the Keychain decides whether the data is released. `.biometryCurrentSet`
makes the item unreadable once enrolled fingers or faces change; choosing
between that and other flags is a `swift-security` question.

## Errors

```swift
enum VaultError: Error, CustomStringConvertible {
    case saveFailed(OSStatus)
    case updateFailed(OSStatus)
    case readFailed(OSStatus)
    case deleteFailed(OSStatus)

    var description: String {
        let status: OSStatus
        switch self {
        case .saveFailed(let s), .updateFailed(let s), .readFailed(let s), .deleteFailed(let s):
            status = s
        }
        let message = SecCopyErrorMessageString(status, nil).map { $0 as String }
        return message ?? "OSStatus \(status)"
    }
}
```

## Apple documentation

- [LAContext](https://developer.apple.com/documentation/localauthentication/lacontext)
- [biometryCurrentSet](https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/biometrycurrentset)
  and [userPresence](https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/userpresence) access-control flags
- [kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly](https://developer.apple.com/documentation/security/ksecattraccessiblewhenpasscodesetthisdeviceonly)
- Security framework article on limiting when keychain items are accessible
- LocalAuthentication article on reading keychain items behind Face ID or Touch ID
- The `NSFaceIDUsageDescription` Info.plist key reference
