开篇
移动端安全的目标不是「绝对安全」,而是把攻击成本抬高到不划算。设备丢失、越狱设备、被篡改的包、中间人代理,这些威胁在真实世界里都会遇到。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 |
| 系统 | 沙箱、代码签名、ASLR | iOS 内核 |
| 数据 | 文件加密、Keychain | Data Protection |
| 传输 | 加密、证书校验 | TLS / ATS |
| 应用 | 业务逻辑、令牌 | 开发者 |
开发者能控制的是最上面两层,下面三层依赖系统。这个认知很重要:客户端的一切校验都可以被绕过,因为攻击者拥有设备。所以安全设计的原则是:
- 密钥与凭证永远不进沙箱文件,交给 Keychain。
- 涉及权限的判断以服务端为准,客户端校验只用于提升体验。
- 加固手段是提高门槛,不是保证安全。越狱检测、反调试、完整性校验都会被打绕过,但能让低成本攻击失效。
一个常被忽略的事实: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 网络层设计与 URLSession — ATS 配置、会话与证书校验的落地细节
- App Store 上架流程与签名机制 — 隐私清单、能力声明与审核对安全配置的要求
- iOS 数据持久化 SwiftData 与 Core Data — 存储文件的保护等级与加密配置
- Swift 内存管理与 ARC 循环引用 — 密钥材料在内存中的生命周期与清理
小结
iOS 安全的第一原则是认清信任边界:客户端不可信,服务端才是权限的最终裁决者。因此敏感数据要交给 Keychain 与 Secure Enclave,敏感判断要交给服务端,客户端的加固手段(越狱检测、完整性校验、证书固定)都只是「抬高成本」。
工程落地的优先级是:令牌一律进 Keychain 并选对 kSecAttrAccessible;需要生物识别的场景用 SecAccessControl 绑定密钥而不是判断布尔值;传输层保持 ATS 开启并固定 SPKI;有条件就接入 App Attest 做服务端 attestation。把这四件事做对,就已经超过了绝大多数 App 的安全水位。下一步建议把安全设计与实际的登录态管理结合,梳理一遍「登录、刷新、登出、换机」全流程中的凭证生命周期。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。