关于 axiom-keychain
面向 iOS 和 macOS 开发者的 Apple Keychain Services 学习与故障排查技能,讲解 SecItem 操作、唯一性、数据保护、生物识别访问控制、共享及常见错误。
主要功能
- 01讲解四种 SecItem 操作
- 02排查唯一性和重复项目错误
- 03按执行场景选择可访问性级别
- 04配置生物识别与密码访问控制
- 05介绍共享及 macOS 钥匙串差异
适用场景
- 01安全存储令牌或 API 密钥
- 02调查钥匙串读写失败
- 03选择后台访问所需的保护级别
- 04与应用扩展共享凭据
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-keychain安装后在 Claude Code 中输入/axiom-keychain确认已加载。
详细介绍
钥匙串服务
面向 iOS/macOS 应用的安全凭据存储、SecItem API 心智模型、唯一性约束、数据保护类别、生物识别访问控制、钥匙串共享以及 Mac 钥匙串差异。
何时使用此技能
在以下情况下使用:
- ☑ 安全地存储令牌、密码、API 密钥或加密密钥
- ☑ 调试 errSecDuplicateItem、errSecItemNotFound 或 errSecInteractionNotAllowed
- ☑ 根据使用场景选择合适的 kSecAttrAccessible 级别
- ☑ 为钥匙串项目添加生物识别或密码保护
- ☑ 通过访问组在应用或应用扩展之间共享凭据
- ☑ 将敏感数据从 UserDefaults/@AppStorage 迁移到钥匙串
- ☑ 了解 SecItemCopyMatching 返回的结果为何与预期不同
- ☑ 在后台环境中存储或检索钥匙串项目
示例提示词
“如何在钥匙串中存储身份验证令牌?” “保存到钥匙串时出现 errSecDuplicateItem,但项目并不存在” “我的钥匙串读取在后台刷新时失败” “如何为钥匙串项目添加 Face ID 保护?” “在我的应用和小组件扩展之间共享钥匙串项目” “kSecAttrAccessibleAfterFirstUnlock 和 WhenUnlocked 有什么区别?” “SecItemCopyMatching 返回 errSecItemNotFound,但我刚刚保存了项目” “如何在 macOS 上通过 Catalyst 使用钥匙串?” “我的钥匙串封装返回 nil,该如何调试?” “后台任务中出现 errSecInteractionNotAllowed”
警示信号
以下情况表明你把事情弄得比实际需要更复杂:
- ❌ 不理解 SecItem 就使用钥匙串封装 —— 封装隐藏了由 4 个函数构成的模型,还会引入自己的错误。Quinn “The Eskimo!” 明确提醒:大多数封装问题都源于封装本身,而不是钥匙串。先了解模型。
- ❌ 将令牌存储在 UserDefaults 或 @AppStorage 中 —— 这些是任何能访问设备的人(或备份)都可读取的 plist 文件。凭据应存放在钥匙串中,没有例外。
- ❌ 强制解包 SecItemCopyMatching 的结果 —— 返回类型取决于传入了哪些 kSecReturn* 标志。不匹配时会得到出乎意料的类型,而不是所需数据。
- ❌ 未指定 kSecAttrAccessible —— 默认值是 kSecAttrAccessibleWhenUnlocked,在后台执行时会失败。如果应用会进行后台刷新、推送处理或 VoIP,这就会带来问题。
- ❌ 遇到 errSecDuplicateItem 时先删除再重新添加 —— 这会造成竞态条件并丢失元数据(创建日期、访问控制)。请改用 SecItemUpdate。
- ❌ 不了解删除语义就使用 kSecMatchLimitAll —— SecItemDelete 没有 kSecMatchLimit;它会删除所有匹配的项目。一次不谨慎的查询就可能清空凭据。
- ❌ 忽略 errSecDuplicateItem —— “上次还能用”意味着查询条件与唯一性条件不匹配。这是钥匙串中最常见的问题。
4 函数模型
Quinn 的心智模型:钥匙串是一个按类别划分表的数据库。4 个 SecItem 函数与 SQL 直接对应。
| SecItem 函数 | SQL 对应项 | 用途 |
|---|---|---|
| SecItemAdd | INSERT | 创建新项目 |
| SecItemCopyMatching | SELECT | 读取一个或多个项目 |
| SecItemUpdate | UPDATE | 修改现有项目 |
| SecItemDelete | DELETE | 删除项目 |
按类别划分的表
每种项目类别都是一个独立的表,具有不同的列(属性):
| 类别 | kSecClass 值 | 常见用途 |
|---|---|---|
| 通用密码 | kSecClassGenericPassword | 应用凭据、令牌、机密 |
| 互联网密码 | kSecClassInternetPassword | 与 URL 关联的凭据 |
| 证书 | kSecClassCertificate | X.509 证书 |
| 密钥 | kSecClassKey | 加密密钥 |
| 身份 | kSecClassIdentity | 证书与私钥对 |
对于大多数 iOS 应用,只需要 kSecClassGenericPassword。互联网密码用于与特定服务器/协议/端口绑定的凭据。证书和密钥用于自定义加密操作。
唯一性约束
大多数钥匙串错误都源于此。每个类别都有一个主键——由必须唯一的一组属性构成。
通用密码主键
kSecAttrService + kSecAttrAccount + kSecAttrAccessGroup
互联网密码主键
kSecAttrServer + kSecAttrAccount + kSecAttrPort
+ kSecAttrProtocol + kSecAttrAuthenticationType + kSecAttrSecurityDomain
+ kSecAttrAccessGroup
有关各类别的完整属性明细,请参阅 axiom-keychain-ref。
errSecDuplicateItem 陷阱
你使用查询调用 SecItemCopyMatching,得到 errSecItemNotFound。接着使用你认为是同一项的内容调用 SecItemAdd,却得到 errSecDuplicateItem。这是怎么回事?
查询属性与唯一性属性不匹配。 你的复制查询可能只按 kSecAttrService 搜索,但唯一性由 service + account + accessGroup 决定。某项的 service 相同、account 不同——查询找不到它,但添加操作会因属性组合触发唯一性约束。
// This query finds nothing (searching by service + label, but label isn't a primary key)
let copyQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.auth",
kSecAttrLabel as String: "auth-token",
kSecReturnData as String: true
]
// Result: errSecItemNotFound (no item has this label)
// This add fails — an item with service "com.app.auth" + account "user-token"
// already exists. The add hits the primary key (service + account + accessGroup).
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.auth",
kSecAttrAccount as String: "user-token",
kSecValueData as String: tokenData
]
// Result: errSecDuplicateItem
修复方法:每次查询都要指定所有主键属性。对于通用密码,始终同时包含 kSecAttrService 和 kSecAttrAccount。
安全的添加或更新模式
func saveToKeychain(service: String, account: String, data: Data) -> OSStatus {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let attributes: [String: Any] = [
kSecValueData as String: data
]
// Try update first
var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
// Item doesn't exist — add it
var addQuery = query
addQuery[kSecValueData as String] = data
status = SecItemAdd(addQuery as CFDictionary, nil)
}
return status
}
与先删除再添加相比,这种方式更安全,因为它会保留项目元数据,并避免竞态条件。
参数字典剖析
每个 SecItem 函数都接受一个字典。该字典中的属性分为 5 组,但哪些组有效取决于所调用的函数。
5 组属性
| 组别 | 前缀/模式 | 用途 |
|---|---|---|
| 类别 | kSecClass | 指定表(通用密码、密钥等) |
| 属性 | kSecAttr* | 列值(服务、账户、标签) |
| 搜索 | kSecMatch* | 查询修饰项(限制、区分大小写) |
| 返回类型 | kSecReturn* | 结果的形式 |
| 值类型 | kSecValue* | 实际数据/引用 |
各组适用于哪些函数
digraph param_blocks {
rankdir=LR;
"SecItemAdd" [shape=box];
"SecItemCopyMatching" [shape=box];
"SecItemUpdate\n(query dict)" [shape=box];
"SecItemUpdate\n(attributes dict)" [shape=box];
"SecItemDelete" [shape=box];
"Class" [shape=ellipse];
"Attributes" [shape=ellipse];
"Search" [shape=ellipse];
"Return type" [shape=ellipse];
"Value type" [shape=ellipse];
"SecItemAdd" -> "Class";
"SecItemAdd" -> "Attributes";
"SecItemAdd" -> "Return type";
"SecItemAdd" -> "Value type";
"SecItemCopyMatching" -> "Class";
"SecItemCopyMatching" -> "Attributes";
"SecItemCopyMatching" -> "Search";
"SecItemCopyMatching" -> "Return type";
"SecItemUpdate\n(query dict)" -> "Class";
"SecItemUpdate\n(query dict)" -> "Attributes";
"SecItemUpdate\n(query dict)" -> "Search";
"SecItemUpdate\n(attributes dict)" -> "Attributes";
"SecItemUpdate\n(attributes dict)" -> "Value type";
"SecItemDelete" -> "Class";
"SecItemDelete" -> "Attributes";
"SecItemDelete" -> "Search";
}
kSecMatchLimit 默认值
这是 Quinn 的帖子中提到的一个常见陷阱。kSecMatchLimit 的默认值取决于上下文:
| 上下文 | kSecMatchLimit 默认值 | 行为 |
|---|---|---|
| 使用 kSecReturnData 调用 SecItemCopyMatching | kSecMatchLimitOne | 返回一项 |
| 使用 kSecReturnAttributes 调用 SecItemCopyMatching | kSecMatchLimitOne | 返回一项 |
| 使用 kSecReturnRef 调用 SecItemCopyMatching | kSecMatchLimitOne | 返回一项 |
| SecItemDelete | 无限制概念 | 删除所有匹配项 |
SecItemDelete 没有 kSecMatchLimit 参数。它会删除所有符合查询条件的项目。如果查询范围过宽(只有 kSecClass 和 kSecAttrService),就会删除该服务在所有账户下的每个项目。
返回类型决定输出类型
| kSecReturn* 标志 | 输出类型 |
|---|---|
| 仅 kSecReturnData | CFData(原始机密) |
| 仅 kSecReturnAttributes | CFDictionary(项目元数据) |
| 仅 kSecReturnRef | SecKey / SecCertificate / SecIdentity |
| kSecReturnData + kSecReturnAttributes | CFDictionary(元数据 + kSecValueData 键) |
| kSecReturnPersistentRef | CFData(持久引用,可在钥匙串重置后保留) |
将结果强制转换为错误类型是常见的崩溃原因。请始终确保转换类型与返回标志相匹配。
可访问性与数据保护
kSecAttrAccessible 控制何时可以使用钥匙串项目的解密密钥。这直接对应设备的数据保护类别。
| 级别 | 可用时机 | 用途 |
|---|---|---|
| kSecAttrAccessibleWhenUnlocked | 设备已解锁时 | 默认值。由 UI 驱动的凭据 |
| kSecAttrAccessibleWhenUnlockedThisDeviceOnly | 设备已解锁时,不参与备份 | 高安全性令牌 |
| kSecAttrAccessibleAfterFirstUnlock | 首次解锁后直至重启 | 后台刷新、推送处理 |
| kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly | 首次解锁后,不参与备份 | 后台处理 + 高安全性 |
| kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly | 已设置密码且设备已解锁时 | 受生物识别验证保护的项目 |
后台执行陷阱
这是生产环境中最常见的钥匙串故障。你的应用在正常使用时运行完美,但在后台刷新期间会因 errSecInteractionNotAllowed (-25308) 而失败。
危险模式:
// Background task tries to read a token
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.auth",
kSecAttrAccount as String: "refresh-token",
kSecReturnData as String: true
// Item was stored with default WhenUnlocked accessibility (set at add time)
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
// status == errSecInteractionNotAllowed when device is locked
// Developer's instinct: "the token is corrupted, delete and re-auth"
if status != errSecSuccess {
SecItemDelete(query as CFDictionary) // DELETES THE CREDENTIAL
}
Quinn 说:“这是一个一旦学会,就永远不会忘记的教训!”
项目本身没有问题。设备处于锁定状态,因此无法获取解密密钥。开发者的错误处理程序误解了错误,删除了完全有效的凭据。
解决方法:将后台所需的令牌存储为 kSecAttrAccessibleAfterFirstUnlock:
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.auth",
kSecAttrAccount as String: "refresh-token",
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
kSecValueData as String: tokenData
]
绝不要因 errSecInteractionNotAllowed 而删除项目——它的意思是“设备解锁后重试”,而不是“此项目已损坏”。
访问控制与生物识别
SecAccessControl 可通过生物识别认证或设备密码为单个钥匙串项目设置访问门槛。
创建受生物识别保护的项目
var error: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet,
&error
) else {
// Handle error — usually means device has no passcode
return
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.auth",
kSecAttrAccount as String: "biometric-secret",
kSecAttrAccessControl as String: accessControl,
kSecValueData as String: secretData
]
let status = SecItemAdd(query as CFDictionary, nil)
选择访问控制标志
digraph acl_decision {
"What gates access?" [shape=diamond];
"Enrollment change\nmatters?" [shape=diamond];
"Biometric or\npasscode fallback?" [shape=diamond];
".biometryCurrentSet" [shape=box, label=".biometryCurrentSet\nInvalidates if fingerprints/face change"];
".biometryAny" [shape=box, label=".biometryAny\nSurvives enrollment changes"];
".userPresence" [shape=box, label=".userPresence\nBiometry with passcode fallback"];
".devicePasscode" [shape=box, label=".devicePasscode\nPasscode only, no biometry"];
"What gates access?" -> "Enrollment change\nmatters?" [label="biometry"];
"What gates access?" -> ".devicePasscode" [label="passcode only"];
"What gates access?" -> "Biometric or\npasscode fallback?" [label="either"];
"Enrollment change\nmatters?" -> ".biometryCurrentSet" [label="yes, re-auth\non change"];
"Enrollment change\nmatters?" -> ".biometryAny" [label="no, any enrolled\nbiometry"];
"Biometric or\npasscode fallback?" -> ".userPresence" [label="user convenience"];
}
| 标志 | 行为 | 使用场景 |
|---|---|---|
| .biometryCurrentSet | 生物识别注册信息发生变化时失效 | 银行、医疗场景——新增指纹或面容后重新认证 |
| .biometryAny | 生物识别注册信息发生变化后仍有效 | 便捷认证、应用锁 |
| .userPresence | 生物识别失败时可回退到设备密码 | 最常见的选择——即使生物识别失败也能使用 |
| .devicePasscode | 仅使用设备密码,不提示进行生物识别 | 优先考虑无障碍功能或不愿使用生物识别的用户 |
用于设置复用时长的 LAContext
默认情况下,每次读取钥匙串都会提示进行生物识别认证。若要允许在一段时间内复用认证:
let context = LAContext()
context.touchIDAuthenticationAllowableReuseDuration = 10 // seconds
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.auth",
kSecAttrAccount as String: "biometric-secret",
kSecUseAuthenticationContext as String: context,
kSecReturnData as String: true
]
一次生物识别认证成功后,接下来 10 秒内的读取将跳过提示。最大复用时长为 LATouchIDAuthenticationMaximumAllowableReuseDuration(当前硬件上为 5 分钟)。
共享与访问组
钥匙串访问组与 App Groups
| 功能 | 钥匙串访问组 | App Groups |
|---|---|---|
| Entitlement | keychain-access-groups | com.apple.security.application-groups |
| 前缀 | Team ID(自动添加) | 无前缀(完整字符串由你控制) |
| 范围 | 仅钥匙串项目 | 文件、UserDefaults 和钥匙串 |
| 设置位置 | Signing & Capabilities → Keychain Sharing | Signing & Capabilities → App Groups |
访问组的工作方式
每个钥匙串项目都恰好属于一个访问组。如果未指定 kSecAttrAccessGroup,项目将存入应用的默认访问组(TEAMID.your.bundle.id)。
要在应用或扩展之间共享:
- 为两个目标添加相同的钥匙串访问组(Signing & Capabilities → Keychain Sharing)
- 写入时指定该组:
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.shared-auth",
kSecAttrAccount as String: "user-token",
kSecAttrAccessGroup as String: "TEAMID.com.app.shared",
kSecValueData as String: tokenData
]
Team ID 前缀陷阱
系统会自动为钥匙串访问组添加 Team ID 前缀。你在 entitlement 中写入 com.app.shared,系统存储的则是 ABCD1234.com.app.shared。
但代码中的 kSecAttrAccessGroup 必须包含此前缀:"ABCD1234.com.app.shared"。
App Groups 不会添加此前缀。如果你使用 App Group 共享钥匙串(通过带有 group. 前缀的 kSecAttrAccessGroup),请直接使用完整字符串。
App ID 前缀变更陷阱
根据 Quinn 总结的注意事项:如果应用更改了 App ID 前缀(Team ID),就会失去对所有现有钥匙串项目的访问权限。以下情况可能导致此问题:
- 在不同开发者帐户之间转移应用
- 某些旧版 App ID 配置
无法恢复这些项目,它们会成为孤立项目。在迁移逻辑中做好应对准备——检测这种情况,并提示用户重新认证。
Mac 钥匙串差异
macOS 有两种钥匙串系统。了解两者的区别可以避免出现“在 iOS 上能用,在 Mac 上却失败”的问题。
基于文件的钥匙串(旧版)
传统的 macOS 钥匙串(~/Library/Keychains/login.keychain-db):
- 可在用户界面的 Keychain Access.app 中查看
- 支持针对各个应用设置 ACL(访问控制列表)
- 默认可在所有应用之间共享项目
- 没有数据保护类别
数据保护钥匙串(现代)
与 iOS 钥匙串行为保持一致(参见 TN3137):
- 在 Keychain Access.app 中不可见(macOS 15 之前)
- 使用数据保护类别(kSecAttrAccessible)
- 项目限定在访问组内(与 iOS 相同)
- Catalyst 和 SwiftUI 多平台应用必需
始终使用数据保护钥匙串
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.auth",
kSecAttrAccount as String: "token",
kSecUseDataProtectionKeychain as String: true, // Critical for Mac
kSecValueData as String: tokenData
]
在 macOS 上的所有钥匙串操作中设置 kSecUseDataProtectionKeychain: true。这能确保在 iOS、macOS、Catalyst 和 Mac Catalyst 上的行为一致。
在 macOS 上不设置此标记时,项目会存入基于文件的钥匙串,其中:
- 访问控制行为不同(使用 ACL,而非数据保护)
- 共享语义会改变(默认情况下,任何应用都能读取)
- 项目会显示在 Keychain Access.app 中(令用户困惑)
- 基于文件的钥匙串与数据保护钥匙串之间的迁移是单向的,且需手动完成
自我合理化
| 合理化说法 | 为何不成立 | 时间成本 |
|---|---|---|
| “令牌存放在 UserDefaults 里没问题,这是私有应用” | 备份提取工具、设备访问工具和 MDM 配置描述文件都能读取 UserDefaults。钥匙串在硬件层面经过加密。 | 处理安全事件需 4-8 小时 |
| “我会封装一个 KeychainHelper 类,让事情简单些” | 封装会抽象掉由 4 个函数组成的模型,掩盖唯一性和参数块语义。封装出问题时,你既要调试封装,也要调试钥匙串。 | 调试封装错误需 2-4 小时,而学习 SecItem 只需 30 分钟 |
| “先删除再重新添加比更新简单” | 这会破坏创建日期、访问控制设置和持久引用。如果另一个进程在删除和添加之间读取,还会产生 TOCTOU 竞态。 | 调试间歇性故障需 1-2 小时 |
| “WhenUnlocked 已经足够安全” | 对 UI 驱动的读取来说是正确的,但对后台刷新、推送处理和静默通知来说则是致命的。凌晨 3 点设备被锁定、无人解锁时,会出现 errSecInteractionNotAllowed。 | 诊断仅在设备锁定时出现的后台故障需 2-6 小时 |
| “我在 Simulator 上测试过,能用” | Simulator 没有 Secure Enclave,也不会强制执行数据保护,钥匙串行为也不同。生物识别项目总是会成功。它不会强制执行可访问性限制。 | 调试仅在真机上出现的故障需 4-8 小时 |
| “errSecInteractionNotAllowed 表示令牌已损坏” | 这表示设备已锁定。令牌没有问题。删除它会销毁有效凭据,并迫使用户重新认证。 | 每次事件的用户投诉处理周期需 15-30 分钟 |
| “访问组太复杂了,我会对所有内容都使用 App Groups” | App Groups 会作为副作用增加钥匙串共享,但其语义与钥匙串访问组不同。Team ID 前缀行为也不同。混用两者会导致隐蔽的访问范围错误。 | 调试跨目标访问故障需 2-4 小时 |
压力场景
场景 1:“先把它存到 UserDefaults 里”
背景:冲刺截止日期临近,新功能需要存储 API 令牌。钥匙串代码看起来很复杂。
压力:“用 UserDefaults 实现更快。我们会在安全冲刺时把它迁移到钥匙串。”
实际情况:之后从 UserDefaults 迁移到钥匙串需要编写迁移代码(从 UserDefaults 读取、写入钥匙串、从 UserDefaults 删除、处理部分迁移)。迁移本身就会形成安全风险——迁移期间,令牌会同时存在于两个位置。“安全冲刺”永远不会到来,因为总有优先级更高的功能。同时,令牌以明文形式存放在应用的 plist 中,并包含在每次 iCloud 备份里。
正确做法:使用此技能中的安全新增或更新模式。代码只有 15 行。SecItem 调用本身并不比 UserDefaults 难多少——所谓的复杂性,源于不了解其模型。
反驳话术模板:“钥匙串调用只有 15 行,和 UserDefaults 一样。现在用 UserDefaults,意味着之后还要编写迁移代码,实际上更难。让我一开始就正确实现。”
场景 2:“发生错误时删除钥匙串项目”
背景:后台刷新间歇性地因 errSecInteractionNotAllowed 而失败。快速修复:删除“损坏的”令牌并强制重新登录。
压力:“用户在抱怨数据过时。直接清除令牌,让用户下次启动时重新认证。”
实际情况:令牌并没有损坏。设备处于锁定状态,而 kSecAttrAccessibleWhenUnlocked 会阻止访问。删除令牌会导致每位用户在任何后台故障后都必须重新认证——而手机每晚锁定时都会发生这种情况。实际修复只需一行:将可访问性级别改为 kSecAttrAccessibleAfterFirstUnlock。
正确做法:将项目的可访问性改为 kSecAttrAccessibleAfterFirstUnlock。对于已经以错误级别存储的现有项目,在应用启动时添加一次性迁移(读取、删除,再以正确的可访问性重新添加)。
反对意见模板:“令牌没有损坏——设备只是处于锁定状态。删除令牌会让每位用户每天早上都必须重新登录。修复只需一行:将可访问性类别改为 AfterFirstUnlock。我也可以为现有用户添加迁移。”
场景 3:“用这个钥匙串封装库吧,大家都在用”
背景:团队正在集成一个热门的开源钥匙串封装库,以“简化”钥匙串访问。
压力:“它在 GitHub 上有 5,000 颗星标,而且能处理所有边缘情况。”
实际情况:Quinn 明确记录了这一模式:Apple Developer Forums 上报告的大多数钥匙串问题,根源都在封装库,而不是钥匙串本身。封装库通常会硬编码 kSecMatchLimit、假设返回类型、混淆可访问性与访问控制,或采用先删除再添加而不是更新的方式。封装库出问题时,你既要调试它的抽象层,也要调试底层的 SecItem 调用。此外,你还会受制于封装库对访问组、可访问性和错误处理的取舍,而这些可能不符合你的需求。
正确做法:针对应用的需求编写一个精简的扩展或函数。安全的添加或更新模式、一个读取函数和一个删除函数,就能以不到 50 行代码覆盖 95% 的使用场景。
反对意见模板:“Apple 论坛上大多数钥匙串错误都源于封装库,而不是钥匙串本身。我们只需要 3 个函数——保存、读取、删除。这只需 50 行我们完全理解的代码,而不是引入一个会自行决定访问组和错误处理方式的依赖。”
检查清单
发布钥匙串代码前:
模型:
- 直接使用 SecItem 函数(而不是自己并不了解的封装)
- 所有查询都包含全部主键属性(通用密码需包含 service + account)
- 使用先更新后添加的模式,而不是先删除后添加
- 在需要的地方显式设置 kSecMatchLimit(绝不依赖默认值)
数据保护:
- 每次添加操作都显式设置 kSecAttrAccessible
- 在后台访问的项目使用 AfterFirstUnlock(而不是 WhenUnlocked)
- 处理 errSecInteractionNotAllowed 时不删除项目
- 在已锁定的实体设备上测试(不只是 Simulator)
访问控制:
- 生物识别标志符合安全要求(currentSet 与 any)
- LAContext 的复用时长合适(不过长)
- 生物识别不可用或失败时能平稳降级
共享:
- 钥匙串访问组授权与代码一致(包括 Team ID 前缀)
- 所有共享目标的授权中都包含相同的访问组
- 在查询中显式指定访问组(不依赖默认值)
Mac 兼容性(如果支持多平台):
- 在所有 macOS 操作中将 kSecUseDataProtectionKeychain 设为 true
- 在 macOS 上测试(不只是 iOS)
错误处理:
- 每次调用 SecItem 都检查 OSStatus 返回值
- 处理 errSecDuplicateItem(不忽略,也不只是记录日志后置之不理)
- 将 errSecItemNotFound 视为正常情况(在读取流程中不当作错误)
- 错误上下文包含 service、account 和尝试执行的操作
资源
WWDC:2019-709
文档:/security/keychain_services、/security/certificate_key_and_trust_services、/technotes/tn3137-on-mac-keychains
Apple 论坛:thread/724023(SecItem 基础知识)、thread/724013(SecItem 常见陷阱)
技能:axiom-keychain-diag、axiom-keychain-ref、axiom-cryptokit、axiom-code-signing、axiom-app-attest