iOS 安全:Keychain、生物识别与传输安全

从 iOS 的安全边界讲起,梳理数据保护等级与 Keychain 的 kSecAttrAccessible 六种策略、SecAccessControl 访问控制、LocalAuthentication 生物识别与 Secure Enclave 密钥存储、App Transport Security 与证书固定、越狱检测与完整性校验,并给出常见的密钥与令牌管理反模式。

开篇

移动端安全的目标不是「绝对安全」,而是把攻击成本抬高到不划算。设备丢失、越狱设备、被篡改的包、中间人代理,这些威胁在真实世界里都会遇到。iOS 提供的工具链其实相当完整——数据保护等级、Keychain、Secure Enclave、App Transport Security——但绝大多数工程只用了其中一小部分,或者用错了。

最典型的三类错误:把 token 存在 UserDefaults 里(明文,越狱设备可读);证书固定配了但只固定叶子证书(证书轮换后 App 全部连不上);生物识别只在前端做判断(越狱设备可绕过,因为校验发生在客户端)。这三类问题的共同点是把安全当成了 UI 逻辑,而不是数据与密钥的管理问题。

本文按「安全边界 → 数据保护 → Keychain → 访问控制 → 生物识别 → Secure Enclave → 传输安全 → 完整性 → 反模式」的顺序展开,基于 iOS 17/18 与 Swift 5.9/6。结论先给:敏感数据交给 Keychain 和 Secure Enclave,敏感判断交给服务端,客户端只做「尽力而为」的加固。

一、iOS 的安全边界

先建立威胁模型。iOS 的安全边界有几层:

层保护对象由谁提供
硬件密钥、加解密Secure Enclave
系统沙箱、代码签名、ASLRiOS 内核
数据文件加密、KeychainData Protection
传输加密、证书校验TLS / ATS
应用业务逻辑、令牌开发者

开发者能控制的是最上面两层,下面三层依赖系统。这个认知很重要:客户端的一切校验都可以被绕过,因为攻击者拥有设备。所以安全设计的原则是:

  1. 密钥与凭证永远不进沙箱文件,交给 Keychain。
  2. 涉及权限的判断以服务端为准,客户端校验只用于提升体验。
  3. 加固手段是提高门槛,不是保证安全。越狱检测、反调试、完整性校验都会被打绕过,但能让低成本攻击失效。

一个常被忽略的事实:iOS 设备默认全盘加密(FileVault 等价物),但加密密钥在设备解锁后驻留在内存中。所以「文件加密」在设备解锁状态下等于没有额外保护,真正需要保护的数据必须绑定到「设备解锁状态」或「生物识别」。

二、数据保护等级与文件属性

iOS 的 Data Protection 通过文件的 NSFileProtectionKey 属性实现,有四个等级:

等级可读时机适用数据
NSFileProtectionComplete仅在设备解锁时最敏感的业务数据
NSFileProtectionCompleteUnlessOpen解锁后可持续读写,即使再次锁屏正在下载的大文件
NSFileProtectionCompleteUntilFirstUserAuthentication开机后首次解锁起默认值,后台任务可读
NSFileProtectionNone始终无敏感数据

设置方式:

let url = documentsURL.appendingPathComponent("secrets.json")
try data.write(to: url, options: [.completeFileProtection])

.completeFileProtection 对应 NSFileProtectionComplete。也可以在 Xcode 的 Capabilities 里给整个 App 开启 Data Protection,在构建时给所有文件设置默认等级。

这里有个关键权衡:Complete 等级的文件在设备锁屏后无法读取,如果你的后台任务或推送扩展需要读某个文件,它会失败。所以后台要访问的数据应该放在 UntilFirstUserAuthentication,或者干脆放 Keychain(Keychain 有自己的 accessible 策略,粒度更细)。

SwiftData / Core Data 的存储文件默认是 UntilFirstUserAuthentication,如果要提升等级,需要在 ModelConfiguration 或 NSPersistentStoreDescription 上显式设置,细节可参考 iOS 数据持久化 SwiftData 与 Core Data 里的存储配置部分。

三、Keychain 的基本用法

Keychain 是系统提供的加密存储,数据由系统密钥加密,只有你的 App(以及同 Access Group 的 App)能读。它适合存:登录令牌、刷新令牌、加密密钥、密码、证书。

Swift 里没有官方的高层封装,SecItem* 系列是 C API,需要 CFDictionary 参数。一个实用的最小封装:

import Security

enum Keychain {
    static func save(_ data: Data, service: String, account: String) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
        ]
        SecItemDelete(query as CFDictionary)          // 先删再加,避免 duplicate
        var attributes = query
        attributes[kSecValueData as String] = data
        let status = SecItemAdd(attributes as CFDictionary, nil)
        guard status == errSecSuccess else { throw KeychainError(status) }
    }

    static func read(service: String, account: String) throws -> Data {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne,
        ]
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        guard status == errSecSuccess, let data = result as? Data else {
            throw KeychainError(status)
        }
        return data
    }
}

四个必须记住的点:

  • Keychain 的读写不是幂等的。SecItemAdd 遇到已存在的 item 返回 errSecDuplicateItem,标准做法是「先删后加」或「先 SecItemUpdate」。
  • kSecAttrService + kSecAttrAccount 组成唯一键,命名要有约定,否则容易互相覆盖。
  • errSecInteractionNotAllowed(-25308) 表示设备锁屏且当前策略不允许访问,这是后台读取失败的常见原因。
  • 模拟器上 Keychain 行为与真机不完全一致,尤其是生物识别和 Access Control 部分,必须真机验证。

kSecClassGenericPassword 存任意数据,kSecClassInternetPassword 存带 URL/账户的凭证(会自动附加域名等属性),kSecClassKey 存密钥(配合 Secure Enclave)。

四、kSecAttrAccessible 的六个等级

kSecAttrAccessible 决定 Keychain 条目的可访问时机,比文件保护等级更细:

常量可读时机是否迁移到新设备
WhenUnlocked仅解锁时是(加密备份)
WhenUnlockedThisDeviceOnly仅解锁时否
AfterFirstUnlock首次解锁后始终是
AfterFirstUnlockThisDeviceOnly首次解锁后始终否
WhenPasscodeSetThisDeviceOnly解锁时,且必须设了密码否
Always(已废弃)始终是

选择规则:

  • 需要后台访问的令牌(如刷新令牌、推送注册信息)用 AfterFirstUnlock。设备重启后用户至少解锁一次,后台任务就能读到。
  • 只在前台用的高敏感数据(如支付凭证)用 WhenUnlocked。
  • 绝不应该离开本机的密钥(如设备绑定的加密密钥)用 ThisDeviceOnly 变体。这样备份到 iCloud 或恢复到新设备时不会迁移,从根上避免密钥泄露。
  • 需要「必须有密码」的强制约束用 WhenPasscodeSetThisDeviceOnly。如果用户没设密码,写入会失败,这本身就是一个有用的信号。

Always 在 iOS 12 后已废弃,不要再使用。

一个实际场景:登录令牌用 AfterFirstUnlock(后台要刷新),而生物识别解锁用的「解锁密钥」用 WhenPasscodeSetThisDeviceOnly + Access Control,这样即使备份被拖走也无法还原。

五、SecAccessControl 与访问控制

kSecAttrAccessible 只控制时机,SecAccessControl 才控制谁能读——它可以把 Keychain 条目的读取绑定到生物识别或设备密码。

var error: Unmanaged<CFError>?
guard let access = SecAccessControlCreateWithFlags(
    nil,
    kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
    [.biometryCurrentSet, .privateKeyUsage],   // 当前生物特征集合 + 私钥用途
    &error
) else { throw KeychainError.accessControl }

let attributes: [String: Any] = [
    kSecClass as String: kSecClassKey,
    kSecAttrApplicationTag as String: keyTag,
    kSecAttrAccessControl as String: access,
    kSecValueData as String: privateKeyData,
]
SecItemAdd(attributes as CFDictionary, nil)

常用 flag 组合:

flag语义
.userPresence生物识别或密码任一
.biometryAny已录入的任一生物特征
.biometryCurrentSet必须是当前录入的那一套(新增指纹即失效)
.devicePasscode仅设备密码
.privateKeyUsage允许该密钥用于签名/解密(密钥类必需)

.biometryCurrentSet 是安全敏感场景的正确选择:如果攻击者拿到了设备密码并录入了自己的指纹,旧条目会失效。代价是用户每次新增指纹都要重新登录。

访问 Keychain 条目时,系统会自动弹出生物识别提示:

let query: [String: Any] = [
    kSecClass as String: kSecClassKey,
    kSecAttrApplicationTag as String: keyTag,
    kSecReturnData as String: true,
    kSecUseOperationPrompt as String: "解锁以继续",   // 弹窗文案
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecUserCanceled { /* 用户取消 */ }

注意 kSecUseOperationPrompt 在 iOS 11 后应改用 LAContext 的 localizedReason,或者在查询时传 kSecUseAuthenticationContext 复用已认证的上下文,避免连续两次弹窗。

六、LocalAuthentication 生物识别

LocalAuthentication 提供 Face ID / Touch ID / Optic ID 的调用接口。基础用法:

import LocalAuthentication

func authenticate() async throws -> Bool {
    let context = LAContext()
    context.localizedCancelTitle = "使用密码"

    var error: NSError?
    guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
        throw AuthError.unavailable(error)
    }

    return try await context.evaluatePolicy(
        .deviceOwnerAuthentication,
        localizedReason: "验证身份以查看账户信息"
    )
}

策略有三种,选择直接影响可用性:

策略含义回退
.deviceOwnerAuthenticationWithBiometrics仅生物识别无(失败即失败)
.deviceOwnerAuthentication生物识别或设备密码自动回退到密码
.deviceOwnerAuthenticationWithWristDetection生物识别 + 手腕检测watchOS

绝大多数场景应该用 .deviceOwnerAuthentication——只允许生物识别会让「指纹识别失败三次」的用户彻底进不去。

最重要的一点:evaluatePolicy 返回的 Bool 是客户端校验结果,越狱设备可以通过 hook 直接返回 true。所以:

  • 生物识别不能作为「是否有权限」的唯一判据。
  • 正确的用法是用它保护一个密钥:验证通过后从 Keychain 取出一个令牌,用这个令牌去服务端换取访问权限。密钥取不出来,绕过 evaluatePolicy 也没用。
// 正确姿势:生物识别保护密钥,而不是保护「判断」
func unlock() async throws -> Data {
    let context = LAContext()
    _ = try await context.evaluatePolicy(.deviceOwnerAuthentication,
                                         localizedReason: "解锁保险箱")
    // 取出的条目本身由 SecAccessControl 保护,绕过 evaluatePolicy 也拿不到
    return try Keychain.read(service: "vault", account: "masterKey",
                             context: context)
}

LAContext 有 10 秒的复用窗口:认证成功后 10 秒内用同一个 context 访问其他受保护条目不会再弹窗。所以要在一次流程里复用同一个 LAContext 实例。

还要处理 LAError 的几个关键分支:biometryNotEnrolled(用户没录入)、biometryLockout(连续失败被锁,需要密码解锁)、userFallback(用户点了「使用密码」)、systemCancel(App 切后台导致取消)。把这些都当成「认证失败」处理,用户体验会很差。

七、Secure Enclave 与密钥管理

Secure Enclave 是独立于主处理器的一块安全芯片,负责处理指纹/面容数据、设备密码校验,以及存储不可导出的密钥。用它生成的私钥永远不离开芯片,只能通过它做签名或解密操作。

import CryptoKit

func makeSecureEnclaveKey(tag: String) throws -> SecureEnclave.P256.Signing.PrivateKey {
    var error: Unmanaged<CFError>?
    guard let access = SecAccessControlCreateWithFlags(
        nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        [.privateKeyUsage, .biometryCurrentSet], &error
    ) else { throw KeychainError.accessControl }

    return try SecureEnclave.P256.Signing.PrivateKey(
        accessControl: access,
        authenticationContext: LAContext()
    )
}

// 签名:私钥不出芯片
func sign(_ payload: Data, with key: SecureEnclave.P256.Signing.PrivateKey) throws -> Data {
    try key.signature(for: payload).derRepresentation
}

Secure Enclave 的能力与限制:

能力限制
生成 P256 签名密钥仅支持 P-256(secp256r1)
密钥不可导出无法备份、无法迁移
支持 ECDH 密钥协商不支持 RSA、不支持对称密钥
可用于签名与解密操作有性能开销,不适合批量

因此它适合的场景是:设备绑定身份(用私钥签名,服务端用公钥验签)、端到端加密的密钥协商、支付类凭证。不适合的场景是:大量数据加密(应该用对称密钥 + CryptoKit 的 AES-GCM,密钥本身再用 Secure Enclave 保护)。

一个实用的组合:用 SecureEnclave 生成一对长期身份密钥,用 CryptoKit 的 SymmetricKey 加密业务数据,对称密钥用身份公钥加密后存 Keychain。这样既不牺牲性能,又让密钥不可导出。

八、App Transport Security 与证书固定

ATS 从 iOS 9 起默认开启,强制 TLS 1.2+、前向保密密码套件、禁止明文 HTTP。绝大多数情况下不要关闭它:

<!-- 不推荐:全局关闭 ATS -->
<key>NSAppTransportSecurity</key>
<dict><key>NSAllowsArbitraryLoads</key><true/></dict>

<!-- 推荐:只对特定域名例外 -->
<key>NSAppTransportSecurity</key>
<dict>
  <key>NSExceptionDomains</key>
  <dict>
    <key>legacy.example.com</key>
    <dict>
      <key>NSExceptionAllowsInsecureHTTPLoads</key><true/>
      <key>NSIncludesSubdomains</key><true/>
    </dict>
  </dict>
</dict>

上架审核时,声明了 NSAllowsArbitraryLoads 必须说明理由,否则会被要求整改。

证书固定(certificate pinning)用来防中间人攻击。iOS 支持三种固定方式:

final class PinningDelegate: NSObject, URLSessionDelegate {
    private let pinnedHashes: Set<String>   // SPKI 的 SHA-256 base64

    func urlSession(_ session: URLSession,
                    didReceive challenge: URLAuthenticationChallenge,
                    completionHandler: @escaping (URLSession.AuthChallengeDisposition,
                                                  URLCredential?) -> Void) {
        guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
              let trust = challenge.protectionSpace.serverTrust else {
            completionHandler(.performDefaultHandling, nil); return
        }

        // 校验 SPKI 哈希,而不是整张证书
        guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
              let leaf = chain.first,
              pinnedHashes.contains(spkiHash(of: leaf)) else {
            completionHandler(.cancelAuthenticationChallenge, nil); return
        }
        completionHandler(.useCredential, URLCredential(trust: trust))
    }
}

三种固定粒度的对比:

固定对象抗中间人证书轮换风险推荐度
整张叶子证书强高(每年必须发版)不推荐
叶子证书公钥(SPKI)强中(换密钥才需更新)推荐
中间 CA 公钥中低备选

实践建议:

  • 固定 SPKI 而不是整张证书,这样证书续期(密钥不变)不会导致 App 连不上。
  • 同时固定当前和下一个备用密钥(“pin set”),给密钥轮换留窗口。
  • 必须有降级路径。固定失败时如果直接崩溃或白屏,一次运维事故就是全量用户不可用。
  • 本地开发环境要能关闭固定,否则抓包调试无法进行。

ATS 的其他配置项(URLCache、超时、waitsForConnectivity)和证书固定的接入方式,在 iOS 网络层设计与 URLSession 里有更完整的讨论。

九、越狱检测与完整性校验

越狱检测的价值是提高攻击成本,不是阻止攻击。任何纯客户端的检测都能被绕过,所以设计原则是:检测到异常时不要立刻崩溃,而是静默降级或上报。

常见的检测手段(需要组合使用):

func isJailbroken() -> Bool {
    // 1. 可疑文件路径
    let paths = ["/Applications/Cydia.app", "/Library/MobileSubstrate/MobileSubstrate.dylib",
                 "/bin/bash", "/usr/sbin/sshd", "/etc/apt"]
    if paths.contains(where: { FileManager.default.fileExists(atPath: $0) }) { return true }

    // 2. 能否写出沙箱外
    let testPath = "/private/jailbreak_test.txt"
    do {
        try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
        try FileManager.default.removeItem(atPath: testPath)
        return true
    } catch { }

    // 3. 能否打开 cydia:// scheme
    if let url = URL(string: "cydia://package/com.example.package"),
       UIApplication.shared.canOpenURL(url) { return true }

    // 4. 检查 dyld 镜像里是否注入了可疑库
    let suspicious = ["MobileSubstrate", "SubstrateLoader", "FridaGadget", "cynject"]
    for i in 0..<_dyld_image_count() {
        if let name = String(validatingUTF8: _dyld_get_image_name(i)),
           suspicious.contains(where: { name.contains($0) }) { return true }
    }
    return false
}

更可靠的做法是服务端 attestation。iOS 16 起支持 DCAppAttestService(App Attest),由 Apple 的服务器签发一个证明,服务端可以验证「这个请求确实来自未被篡改的、运行在真实 Apple 设备上的你的 App」:

let service = DCAppAttestService.shared
guard service.isSupported else { /* 走降级路径 */ }

service.generateKey { keyID, error in
    service.attestKey(keyID, clientDataHash: hash) { attestation, error in
        api.registerAttestation(attestation)   // 服务端验证并绑定 keyID
    }
}

// 后续请求用 assertion 签名
service.generateAssertion(keyID, clientDataHash: requestHash) { assertion, error in
    // 把 assertion 放进请求头,服务端验签
}

App Attest 的强度远高于本地检测,因为验证发生在 Apple 的服务器和你的服务器上,客户端无法伪造。它的代价是需要服务端配合,且模拟器和旧设备不支持。

十、常见的安全反模式

  • 令牌存 UserDefaults:明文、进备份、越狱设备直接读。一律 Keychain。
  • 令牌存 plist/json 文件:同上,即使加了文件保护,等级也常常配错。
  • 把 API Secret 硬编码在客户端:二进制里可以直接 strings 出来。第三方密钥必须走服务端代理。
  • 用生物识别的 Bool 当权限判据:越狱设备 hook 一下就是 true。必须绑定密钥。
  • 日志里打令牌:print("token: \(token)") 会进系统日志,任何能读日志的工具都能拿到。
  • 固定整张证书:证书续期后 App 全量连不上,必须发版才能恢复。
  • 自己实现加密算法:用 CryptoKit 的 AES-GCM / ChaChaPoly,不要手搓。
  • 明文存密码:即使是本地「记住密码」也应该用 Keychain,且加 Access Control。
  • 忽略 errSecInteractionNotAllowed:后台读 Keychain 失败就当成「用户未登录」,导致用户被登出。
  • 调试开关带上线:#if DEBUG 之外的绕过逻辑(如固定开关、越狱白名单)必须彻底移除。

权衡取舍

需求方案代价
存登录令牌Keychain AfterFirstUnlock后台可读,锁屏设备被物理接触仍有风险
存设备绑定密钥Keychain ThisDeviceOnly + Secure Enclave无法备份、换机需重新绑定
高敏感操作前验证SecAccessControl + .biometryCurrentSet新增指纹后需重新登录
防中间人SPKI 固定 + 备用 pin需要维护轮换流程
防篡改与重放App Attest需服务端配合,旧设备降级
全量数据加密CryptoKit AES-GCM + Keychain 存密钥需要处理密钥丢失场景

一个必须接受的现实:越狱设备上的安全无法保证。你的目标是让「未越狱设备 + 正常使用」的场景足够安全,让「越狱设备」上的攻击成本足够高,而不是追求理论上的不可破解。

常见坑清单

  • Keychain 写入不先删导致 errSecDuplicateItem:SecItemAdd 不覆盖已有条目,要先 SecItemDelete 或改 SecItemUpdate。
  • 后台读 Keychain 失败被当成登出:errSecInteractionNotAllowed 是锁屏策略导致,应延迟重试而非清除凭证。
  • WhenUnlockedThisDeviceOnly 用在后台任务:设备一锁屏就读不到,后台刷新直接失败。
  • 生物识别策略选了 WithBiometrics:识别失败三次用户彻底进不去,应该用 .deviceOwnerAuthentication 允许密码回退。
  • 每次访问都新建 LAContext:10 秒复用窗口失效,连续操作反复弹窗。一次流程复用一个 context。
  • 证书固定绑死叶子证书:证书续期后全量用户连不上,必须固定 SPKI 并预留备用 pin。
  • 固定失败直接崩溃:一次运维事故变成全量不可用,必须有降级与上报路径。
  • NSAllowsArbitraryLoads 全局开启:上架被要求整改,且失去 ATS 全部保护。只对必要域名开例外。
  • Secure Enclave 拿来做批量加解密:只支持 P-256 且性能有限,批量数据应用对称密钥。
  • 越狱检测结果直接用于封禁:误报(如企业设备管理环境)会导致正常用户被误伤,应只做风险标记上报。

相关阅读

小结

iOS 安全的第一原则是认清信任边界:客户端不可信,服务端才是权限的最终裁决者。因此敏感数据要交给 Keychain 与 Secure Enclave,敏感判断要交给服务端,客户端的加固手段(越狱检测、完整性校验、证书固定)都只是「抬高成本」。

工程落地的优先级是:令牌一律进 Keychain 并选对 kSecAttrAccessible;需要生物识别的场景用 SecAccessControl 绑定密钥而不是判断布尔值;传输层保持 ATS 开启并固定 SPKI;有条件就接入 App Attest 做服务端 attestation。把这四件事做对,就已经超过了绝大多数 App 的安全水位。下一步建议把安全设计与实际的登录态管理结合,梳理一遍「登录、刷新、登出、换机」全流程中的凭证生命周期。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「iOS 开发」更多文章

  1. Swift Package Manager 与模块化拆分
  2. Core Animation 与 SwiftUI 动画
  3. iOS 后台任务与 APNs 推送