Weak Biometric Access Control Flags

ID

swift.weak_biometric_acl

Severity

high

Resource

Authentication

Language

Swift

Tags

CWE:287, CWE:305, MASVS:auth-8, MASWE:0014, NIST.SP.800-53, OWASP:2021:A07, PCI-DSS:8.2.1

Description

When protecting sensitive cryptographic keys in the iOS Keychain with biometric authentication, developers must carefully choose access control flags. Some flags allow authentication with any enrolled biometric on the device, including fingerprints or facial data added after the key was created. This creates a critical security vulnerability where an attacker with physical device access can enroll their own biometric data and gain unauthorized access to protected keys.

The SecAccessControlCreateWithFlags function allows developers to specify which biometric authentication methods are acceptable. Weak biometric access control flags do not invalidate Keychain items when new biometrics are enrolled, allowing potential attackers to bypass authentication.

Weak biometric ACL flags include:

  • .biometryAny - Allows authentication with any enrolled biometric, regardless of when it was enrolled. Keys remain valid even after new fingerprints or faces are added to the device.

  • .touchIDAny - Similar to .biometryAny but uses deprecated Touch ID naming. Has the same security weakness.

  • .userPresence - Can fall back to device passcode authentication, which is vulnerable to shoulder surfing and social engineering.

Rationale

The following example demonstrates vulnerable code that uses weak biometric access control flags:

import Security
import Foundation

enum KeychainError: Error {
    case accessControlCreationFailed
    case keyGenerationFailed
}

func createKeyWithWeakBiometrics() throws {
    // VULNERABLE: Using .biometryAny allows any enrolled biometric
    guard let accessControl = SecAccessControlCreateWithFlags(
        kCFAllocatorDefault,
        kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        .biometryAny,  // WEAK! Allows newly enrolled biometrics
        nil
    ) else {
        throw KeychainError.accessControlCreationFailed
    }

    let privateKeyAttrs: [String: Any] = [
        kSecAttrIsPermanent as String: true,
        kSecAttrApplicationTag as String: "com.example.payment".data(using: .utf8)!,
        kSecAttrAccessControl as String: accessControl
    ]

    let attributes: [String: Any] = [
        kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
        kSecAttrKeySizeInBits as String: 256,
        kSecPrivateKeyAttrs as String: privateKeyAttrs
    ]

    var error: Unmanaged<CFError>?
    guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
        throw KeychainError.keyGenerationFailed
    }
}

func createKeyWithUserPresence() throws {
    // VULNERABLE: .userPresence can fall back to passcode
    guard let accessControl = SecAccessControlCreateWithFlags(
        kCFAllocatorDefault,
        kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        .userPresence,  // WEAK! Falls back to passcode
        nil
    ) else {
        throw KeychainError.accessControlCreationFailed
    }

    let privateKeyAttrs: [String: Any] = [
        kSecAttrIsPermanent as String: true,
        kSecAttrApplicationTag as String: "com.example.auth".data(using: .utf8)!,
        kSecAttrAccessControl as String: accessControl
    ]

    let attributes: [String: Any] = [
        kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
        kSecAttrKeySizeInBits as String: 256,
        kSecPrivateKeyAttrs as String: privateKeyAttrs
    ]

    var error: Unmanaged<CFError>?
    _ = SecKeyCreateRandomKey(attributes as CFDictionary, &error)
}

func createKeyWithDeprecatedTouchIDAny() throws {
    // VULNERABLE: .touchIDAny (deprecated) has same issue as .biometryAny
    guard let accessControl = SecAccessControlCreateWithFlags(
        kCFAllocatorDefault,
        kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        .touchIDAny,  // WEAK! Deprecated and allows any Touch ID
        nil
    ) else {
        throw KeychainError.accessControlCreationFailed
    }

    let privateKeyAttrs: [String: Any] = [
        kSecAttrIsPermanent as String: true,
        kSecAttrApplicationTag as String: "com.example.legacy".data(using: .utf8)!,
        kSecAttrAccessControl as String: accessControl
    ]

    let attributes: [String: Any] = [
        kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
        kSecAttrKeySizeInBits as String: 256,
        kSecPrivateKeyAttrs as String: privateKeyAttrs
    ]

    var error: Unmanaged<CFError>?
    _ = SecKeyCreateRandomKey(attributes as CFDictionary, &error)
}

This code has several critical security problems:

  1. Vulnerable to biometric enrollment attacks: An attacker with temporary physical access to an unlocked device can enroll their own fingerprint or face. The protected keys will then be accessible using the attacker’s newly enrolled biometric data.

  2. No invalidation on biometric changes: When .biometryAny is used, adding new biometrics does not invalidate existing Keychain items. This means keys created with legitimate user biometrics remain accessible even after an attacker enrolls additional biometrics.

  3. Compromised device scenarios: If a device is compromised (stolen, lost, or accessed without authorization), an attacker can add their biometric data and access all keys protected with .biometryAny.

  4. Weak passcode fallback: The .userPresence flag allows falling back to device passcode authentication, which is significantly weaker than biometric authentication due to shoulder surfing risks.

  5. Persistence across re-enrollment: Keys remain valid even when legitimate users re-enroll their biometrics after device compromise, potentially leaving backdoor access for attackers.

Remediation

Use strong biometric access control flags that invalidate keys when biometrics change:

Option 1: .biometryCurrentSet (Recommended)

This is the most secure option that restricts authentication to biometrics enrolled at key creation time:

import Security
import Foundation

enum KeychainError: Error {
    case accessControlCreationFailed
    case keyGenerationFailed
    case storageFailed
}

class SecureBiometricKeychain {
    func createKeyWithStrongBiometrics() throws {
        // SECURE: Using .biometryCurrentSet restricts to current biometrics only
        guard let accessControl = SecAccessControlCreateWithFlags(
            kCFAllocatorDefault,
            kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
            .biometryCurrentSet,  // Keys invalidate when new biometrics are added
            nil
        ) else {
            throw KeychainError.accessControlCreationFailed
        }

        let privateKeyAttrs: [String: Any] = [
            kSecAttrIsPermanent as String: true,
            kSecAttrApplicationTag as String: "com.example.secure.payment".data(using: .utf8)!,
            kSecAttrAccessControl as String: accessControl
        ]

        let attributes: [String: Any] = [
            kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
            kSecAttrKeySizeInBits as String: 256,
            kSecPrivateKeyAttrs as String: privateKeyAttrs
        ]

        var error: Unmanaged<CFError>?
        guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
            throw KeychainError.keyGenerationFailed
        }

        print("✅ Key created with strong biometric protection")
        print("   • Keys will invalidate if new biometrics are enrolled")
        print("   • Maximum security for sensitive operations")
    }

    func createSigningKeyWithBiometryCurrentSet() throws {
        // SECURE: Combining .biometryCurrentSet with .privateKeyUsage
        guard let accessControl = SecAccessControlCreateWithFlags(
            kCFAllocatorDefault,
            kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
            [.biometryCurrentSet, .privateKeyUsage],
            nil
        ) else {
            throw KeychainError.accessControlCreationFailed
        }

        let privateKeyAttrs: [String: Any] = [
            kSecAttrIsPermanent as String: true,
            kSecAttrApplicationTag as String: "com.example.signing".data(using: .utf8)!,
            kSecAttrAccessControl as String: accessControl
        ]

        let attributes: [String: Any] = [
            kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
            kSecAttrKeySizeInBits as String: 256,
            kSecPrivateKeyAttrs as String: privateKeyAttrs
        ]

        var error: Unmanaged<CFError>?
        _ = SecKeyCreateRandomKey(attributes as CFDictionary, &error)
    }
}

Option 2: .touchIDCurrentSet (For Touch ID specific apps)

Use this when you need Touch ID specific authentication:

func createKeyWithTouchIDCurrentSet() throws {
    // SECURE: Using .touchIDCurrentSet for Touch ID devices
    guard let accessControl = SecAccessControlCreateWithFlags(
        kCFAllocatorDefault,
        kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        .touchIDCurrentSet,  // Equivalent to .biometryCurrentSet for Touch ID
        nil
    ) else {
        throw KeychainError.accessControlCreationFailed
    }

    let privateKeyAttrs: [String: Any] = [
        kSecAttrIsPermanent as String: true,
        kSecAttrApplicationTag as String: "com.example.touchid".data(using: .utf8)!,
        kSecAttrAccessControl as String: accessControl
    ]

    let attributes: [String: Any] = [
        kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
        kSecAttrKeySizeInBits as String: 256,
        kSecPrivateKeyAttrs as String: privateKeyAttrs
    ]

    var error: Unmanaged<CFError>?
    _ = SecKeyCreateRandomKey(attributes as CFDictionary, &error)
}

Option 3: Handling biometric availability

Always check for biometric availability and handle errors gracefully:

import LocalAuthentication

class SecureBiometricManager {
    func createKeyWithBiometricCheck() throws {
        let context = LAContext()
        var error: NSError?

        // Check if biometrics are available
        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
            if let err = error {
                print("Biometrics not available: \(err.localizedDescription)")
                // Handle appropriately - perhaps use different protection or inform user
            }
            throw KeychainError.accessControlCreationFailed
        }

        // Create with .biometryCurrentSet knowing biometrics are available
        guard let accessControl = SecAccessControlCreateWithFlags(
            kCFAllocatorDefault,
            kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
            .biometryCurrentSet,
            nil
        ) else {
            throw KeychainError.accessControlCreationFailed
        }

        let privateKeyAttrs: [String: Any] = [
            kSecAttrIsPermanent as String: true,
            kSecAttrApplicationTag as String: "com.example.checked".data(using: .utf8)!,
            kSecAttrAccessControl as String: accessControl
        ]

        let attributes: [String: Any] = [
            kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
            kSecAttrKeySizeInBits as String: 256,
            kSecPrivateKeyAttrs as String: privateKeyAttrs
        ]

        var cfError: Unmanaged<CFError>?
        _ = SecKeyCreateRandomKey(attributes as CFDictionary, &cfError)
    }
}

Comparison of Biometric Access Control Flags:

Access Control Flag Behavior on New Biometric Enrollment Security Level

.biometryAny

Keys remain valid ❌

⚠️ Weak (allows attacker biometrics)

.touchIDAny

Keys remain valid ❌

⚠️ Weak (deprecated, same as .biometryAny)

.userPresence

Keys remain valid, allows passcode ❌

⚠️ Weak (passcode fallback)

.biometryCurrentSet

Keys automatically invalidated ✅

✅ Strong (maximum security)

.touchIDCurrentSet

Keys automatically invalidated ✅

✅ Strong (equivalent to .biometryCurrentSet)

Best Practices:

  1. Always use .biometryCurrentSet: For maximum security, always use .biometryCurrentSet instead of .biometryAny when protecting sensitive keys.

  2. Handle invalidation gracefully: When keys are invalidated due to biometric changes, provide a clear user experience explaining why re-authentication or key recreation is needed.

  3. Check biometric availability: Before creating keys with biometric protection, verify that biometrics are available on the device using LAContext.

  4. Avoid .userPresence for sensitive operations: The .userPresence flag should not be used for highly sensitive operations like payment authentication or signing, as it allows passcode fallback.

  5. Document security assumptions: Clearly document in your code why .biometryCurrentSet is being used and what security guarantees it provides.

  6. Plan for biometric changes: Implement proper workflows for when users legitimately change their biometrics (e.g., after getting a new phone or re-enrolling fingerprints).

  7. Consider user experience: While .biometryCurrentSet provides maximum security, it may require users to recreate keys when they add new fingerprints. Balance security with usability.

  8. Test edge cases: Test scenarios where users add/remove biometrics, use different fingers, or switch between Face ID and passcode.

Attack Scenarios

Understanding how attackers can exploit weak biometric ACL flags:

Scenario 1: Physical Device Access 1. Attacker gains temporary physical access to unlocked device 2. Attacker navigates to Settings → Face ID/Touch ID 3. Attacker enrolls their own biometric data 4. Later, attacker can unlock device and access keys protected with .biometryAny

Scenario 2: Compromised Device 1. User’s device is stolen while unlocked (e.g., at a café) 2. Attacker quickly enrolls their fingerprint before device locks 3. All keys protected with .biometryAny are now accessible to attacker 4. Even if user remotely locks device, attacker retains biometric access

Scenario 3: Social Engineering 1. Attacker convinces user to "help" with a biometric enrollment 2. User unknowingly allows attacker’s biometric to be enrolled 3. Keys protected with .biometryAny remain accessible to attacker indefinitely 4. Attacker can access sensitive data without further interaction

Configuration

This detector can be configured with the list of weak biometric ACL flags to detect:

weakBiometricFlags:
  - biometryAny
  - touchIDAny
  - userPresence

You can customize this list if your security policy requires stricter or different controls.

References