关于 axiom-keychain-diag
面向排查 Apple Security framework 和 Keychain 故障的开发者指南。它将常见错误症状整理为检查步骤与决策路径,涵盖权限、数据保护和 macOS 行为差异。
主要功能
- 01诊断常见 Keychain 错误码
- 02检查项目属性与查询不匹配
- 03排查可访问性和后台时序
- 04验证访问组权限
- 05比较 macOS Keychain 行为
适用场景
- 01追查 errSecDuplicateItem 或 errSecItemNotFound
- 02调查后台执行期间的 Keychain 故障
- 03查明应用更新后项目消失的原因
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-keychain-diag安装后在 Claude Code 中输入/axiom-keychain-diag确认已加载。
详细介绍
钥匙串诊断
针对 Security 框架故障的系统化排查:唯一性约束冲突、查询不匹配、数据保护时机、访问组权限、更新后项目消失,以及 Mac 兼容层行为差异。
概述
核心原则:钥匙串操作失败时,问题通常是:
- 唯一性约束不匹配(errSecDuplicateItem)——25%
- 查询属性混淆(errSecItemNotFound)——25%
- 数据保护/后台时机(errSecInteractionNotAllowed)——20%
- 访问组/权限不匹配(errSecMissingEntitlement)——15%
- Mac 兼容层行为差异——10%
- 应用更新后项目丢失(权限或 App ID 前缀变更)——5%
更改钥匙串代码前,务必先转储现有项目并比较属性。
危险信号
表明存在钥匙串特定问题的症状:
| 症状 | 可能原因 |
|---|---|
| 查询返回未找到,但出现 errSecDuplicateItem | 添加查询中的属性不具唯一性——唯一性由类别和主键属性决定,而不是由完整查询决定 |
| errSecItemNotFound,但刚刚添加了项目 | kSecClass 错误、查询属性范围过窄,或访问组不匹配 |
| 后台运行时出现 errSecInteractionNotAllowed | kSecAttrAccessibleWhenUnlocked(默认值)+设备锁定+后台执行 |
| errSecMissingEntitlement | 访问组未列入 keychain-access-groups 权限中 |
| errSecNoSuchAttr | 属性不受该项目类别支持(例如,在 kSecClassGenericPassword 上使用 kSecAttrApplicationTag) |
| Mac 上出现 errSecAuthFailed | 基于文件的钥匙串已锁定或超时 |
| 应用更新后项目消失 | 版本之间的访问组或权限发生了变化 |
| 团队变更后项目消失 | App ID 前缀发生变化——无法访问以旧前缀为键的项目 |
| SecItemDelete 删除了所有内容 | kSecMatchLimit 对删除无效——它会删除所有匹配的项目 |
| 钥匙串在模拟器中正常,在设备上失败 | 模拟器不强制执行数据保护,而设备会 |
避免自我安慰
| 自我安慰 | 失败原因 | 时间成本 |
|---|---|---|
| “封装层会处理好” | 封装层会隐藏唯一性约束。出现 errSecDuplicateItem 时,你无法调试自己看不到的内容,最后还是得去读封装层源码。 | 拆解封装层耗时 30 分钟以上 |
| “我直接删除再添加就行” | 这会丢失项目元数据、破坏 iCloud 钥匙串同步状态;而且如果删除查询范围过宽,还会悄悄删除其他项目。 | 调试凭据丢失耗时 1–2 小时 |
| “这个令牌用 UserDefaults 存就行” | UserDefaults 未加密、会备份到 iCloud、对 MDM 配置文件可见,也能通过设备备份提取读取。一次安全审计就会发现。 | 审核不通过后迁移到钥匙串要花数小时 |
| “errSecItemNotFound 就说明项目不存在” | 这表示你的查询没有匹配到项目。项目可能存在,只是属性与你搜索时使用的不同。转储所有项目进行检查。 | 项目实际已存在,却重写添加逻辑,要花 30–60 分钟 |
| “上线后再修钥匙串代码” | 钥匙串错误会导致无声的数据丢失。用户更新后会丢失凭据、无法登录,也没有恢复途径。你只能从 1 星评价中得知问题。 | 紧急修复耗时数天,并损害用户信任 |
必须执行的首要步骤
更改钥匙串代码前,先运行以下诊断:
步骤 1:转储相关类别的所有项目
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true,
kSecReturnRef as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let items = result as? [[String: Any]] {
for item in items {
print(item)
}
}
这会显示应用能够看到的该类别所有项目——包括你已经忘记的项目。
步骤 2:将属性与查询进行比较
将添加/更新/搜索查询中的每个属性与转储输出进行核对。常见的不匹配包括:
kSecAttrAccount与kSecAttrService——你用哪一个作为键?kSecAttrAccessGroup——你指定的访问组是否与默认值不同?- 缩小搜索范围的额外属性(例如,添加时设置了
kSecAttrLabel,搜索时却省略了它)
步骤 3:检查可访问性类别与设备锁定状态
// In your dump, look for:
// kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked (default — fails when locked)
// kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock (survives background)
如果应用在后台访问钥匙串(推送通知处理程序、后台提取),设备锁定时,WhenUnlocked 会导致访问失败。
步骤 4:验证访问组授权
codesign -d --entitlements - /path/to/YourApp.app 2>&1 | grep keychain-access-groups
查询中的访问组必须出现在此列表中。默认组为 $(AppIdentifierPrefix)$(CFBundleIdentifier)。
决策树
树 1:errSecDuplicateItem
digraph tree1 {
"errSecDuplicateItem?" [shape=diamond];
"Dump all items (Step 1)" [shape=box];
"Item with same primary keys exists?" [shape=diamond];
"Same kSecAttrAccount + kSecAttrService?" [shape=diamond];
"Use SecItemUpdate" [shape=box, label="Use SecItemUpdate instead.\nQuery with primary key attrs only.\nPass new values in attributesToUpdate."];
"Query-before-add" [shape=box, label="Search first, update if found:\nSecItemCopyMatching → exists?\n yes → SecItemUpdate\n no → SecItemAdd"];
"Different account/service" [shape=box, label="Your add query matches an existing\nitem on primary key attributes.\nkSecClassGenericPassword uniqueness:\n kSecAttrAccount + kSecAttrService\n + kSecAttrAccessGroup"];
"Check access group" [shape=box, label="Item exists in a different access\ngroup. Your search missed it but\nadd sees it. Specify kSecAttrAccessGroup\nexplicitly in both operations."];
"errSecDuplicateItem?" -> "Dump all items (Step 1)";
"Dump all items (Step 1)" -> "Item with same primary keys exists?" [label="inspect"];
"Item with same primary keys exists?" -> "Same kSecAttrAccount + kSecAttrService?" [label="yes"];
"Item with same primary keys exists?" -> "Check access group" [label="no visible match"];
"Same kSecAttrAccount + kSecAttrService?" -> "Use SecItemUpdate" [label="yes, want to overwrite"];
"Same kSecAttrAccount + kSecAttrService?" -> "Different account/service" [label="no, different values"];
"Use SecItemUpdate" -> "Query-before-add" [label="prevent future duplicates"];
}
各类别的唯一性约束:
| 类别 | 主键属性 |
|---|---|
| kSecClassGenericPassword | kSecAttrAccount + kSecAttrService + kSecAttrAccessGroup |
| kSecClassInternetPassword | kSecAttrAccount + kSecAttrSecurityDomain + kSecAttrServer + kSecAttrProtocol + kSecAttrAuthenticationType + kSecAttrPort + kSecAttrPath |
| kSecClassCertificate | kSecAttrCertificateType + kSecAttrIssuer + kSecAttrSerialNumber |
| kSecClassKey | kSecAttrKeyClass + kSecAttrKeyType + kSecAttrApplicationLabel + kSecAttrApplicationTag + kSecAttrEffectiveKeySize |
树 2:errSecItemNotFound
digraph tree2 {
"errSecItemNotFound?" [shape=diamond];
"Dump all items (Step 1)" [shape=box];
"Any items returned?" [shape=diamond];
"Correct kSecClass?" [shape=diamond];
"Erroneous attribute?" [shape=diamond];
"Class mismatch" [shape=box, label="Wrong kSecClass in query.\nGenericPassword vs InternetPassword\nis the most common confusion.\nKeys use kSecClassKey."];
"Narrow query" [shape=box, label="Erroneous attribute narrows\nquery to match nothing.\nRemove attributes one at a time\nuntil item is found.\nCommon: kSecAttrLabel, kSecAttrType"];
"Access group" [shape=box, label="Item exists in different\naccess group than query.\nCheck kSecAttrAccessGroup\nor omit it to use default."];
"Data protection" [shape=box, label="Item exists but device is locked\nand item has WhenUnlocked accessibility.\nSee Tree 3."];
"Not added yet" [shape=box, label="Item was never successfully added.\nCheck return value of SecItemAdd\n— was it errSecSuccess?"];
"errSecItemNotFound?" -> "Dump all items (Step 1)";
"Dump all items (Step 1)" -> "Any items returned?" [label="check"];
"Any items returned?" -> "Not added yet" [label="no items at all"];
"Any items returned?" -> "Correct kSecClass?" [label="yes, items exist"];
"Correct kSecClass?" -> "Class mismatch" [label="no"];
"Correct kSecClass?" -> "Erroneous attribute?" [label="yes"];
"Erroneous attribute?" -> "Narrow query" [label="yes, extra attrs"];
"Erroneous attribute?" -> "Access group" [label="no, attrs match"];
"Access group" -> "Data protection" [label="access group matches too"];
}
树 3:errSecInteractionNotAllowed
digraph tree3 {
"errSecInteractionNotAllowed?" [shape=diamond];
"Background execution?" [shape=diamond];
"Device locked?" [shape=diamond];
"Check accessibility" [shape=diamond];
"Change accessibility" [shape=box, label="Migrate item to\nkSecAttrAccessibleAfterFirstUnlock\nor AfterFirstUnlockThisDeviceOnly.\nRequires delete + re-add."];
"Timing issue" [shape=box, label="App launched in background\nbefore first unlock after reboot.\nDefer keychain access until\nUIApplication.protectedDataDidBecomeAvailable"];
"Delete trap" [shape=octagon, label="DANGER: Do NOT delete and re-add\njust to change accessibility.\nIf device is locked, the delete\nwill succeed but the add will FAIL\n— you lose the credential."];
"Not data protection" [shape=box, label="On Mac: file-based keychain\nmay be locked. Check\nsecurity unlock-keychain.\nOr keychain requires user\ninteraction (SecAccessControl)."];
"Check SecAccessControl" [shape=box, label="If using biometric protection\n(SecAccessControlCreateWithFlags),\nbackground access is impossible.\nStore a separate non-biometric\ncopy for background use."];
"errSecInteractionNotAllowed?" -> "Background execution?" [label="check context"];
"Background execution?" -> "Device locked?" [label="yes"];
"Background execution?" -> "Not data protection" [label="no, foreground"];
"Device locked?" -> "Check accessibility" [label="yes"];
"Device locked?" -> "Check SecAccessControl" [label="no, unlocked but still fails"];
"Check accessibility" -> "Timing issue" [label="WhenUnlocked + after reboot"];
"Check accessibility" -> "Change accessibility" [label="WhenUnlocked + normal lock"];
"Change accessibility" -> "Delete trap" [label="WARNING"];
}
树 4:errSecMissingEntitlement
digraph tree4 {
"errSecMissingEntitlement?" [shape=diamond];
"Using explicit access group?" [shape=diamond];
"Check entitlements (Step 4)" [shape=box];
"Group in entitlements?" [shape=diamond];
"Add to entitlements" [shape=box, label="Xcode > Target >\nSigning & Capabilities >\nKeychain Sharing >\nAdd access group"];
"Prefix mismatch" [shape=box, label="Access group must use\nApp ID prefix (Team ID or\nApp ID prefix from portal).\n$(AppIdentifierPrefix)com.your.group\nNOT just com.your.group"];
"Shared group config" [shape=box, label="For shared keychain between apps:\n1. Same Team ID\n2. Same access group string\n3. Both apps list group in\n Keychain Sharing capability"];
"Default group" [shape=box, label="If not specifying access group,\ndefault is AppIdentifierPrefix +\nbundle ID. Verify your app's\nprefix hasn't changed."];
"errSecMissingEntitlement?" -> "Using explicit access group?" [label="check query"];
"Using explicit access group?" -> "Check entitlements (Step 4)" [label="yes"];
"Using explicit access group?" -> "Default group" [label="no"];
"Check entitlements (Step 4)" -> "Group in entitlements?" [label="inspect"];
"Group in entitlements?" -> "Prefix mismatch" [label="no, group missing"];
"Group in entitlements?" -> "Shared group config" [label="yes but still fails"];
"Prefix mismatch" -> "Add to entitlements" [label="fix"];
}
树 5:应用更新后钥匙串项目丢失
digraph tree5 {
"Items gone after update?" [shape=diamond];
"Access group changed?" [shape=diamond];
"App ID prefix changed?" [shape=diamond];
"Entitlements file changed?" [shape=diamond];
"Restore access group" [shape=box, label="Add the OLD access group back\nto Keychain Sharing entitlement.\nItems are keyed to the group\nthey were created with."];
"Prefix migration" [shape=box, label="App ID prefix change means\nnew items are under new prefix.\nOld items are under old prefix.\nAdd both prefixes to entitlements\nor migrate items at first launch."];
"Entitlement restore" [shape=box, label="If Keychain Sharing was removed,\nthe default access group changed.\nRe-add Keychain Sharing with\nthe original group name."];
"Query change" [shape=box, label="Check if the query attributes\nchanged between versions.\nDump items (Step 1) to verify\nitems still exist under old attrs."];
"Items gone after update?" -> "Access group changed?" [label="check entitlements diff"];
"Access group changed?" -> "Restore access group" [label="yes"];
"Access group changed?" -> "App ID prefix changed?" [label="no"];
"App ID prefix changed?" -> "Prefix migration" [label="yes, team transfer"];
"App ID prefix changed?" -> "Entitlements file changed?" [label="no"];
"Entitlements file changed?" -> "Entitlement restore" [label="yes"];
"Entitlements file changed?" -> "Query change" [label="no, entitlements identical"];
}
树 6:Mac 特有问题
digraph tree6 {
"Mac keychain issue?" [shape=diamond];
"Catalyst or native?" [shape=diamond];
"File-based keychain?" [shape=diamond];
"Shim behavior" [shape=box, label="Mac Catalyst uses iOS-style\ndata-protection keychain by default.\nkSecUseDataProtectionKeychain = true\nis automatic on Catalyst.\nFile-based keychain quirks don't apply."];
"Native Mac" [shape=box, label="Native macOS apps default to\nfile-based keychain unless you set\nkSecUseDataProtectionKeychain = true.\nFile-based has different:\n- kSecMatchLimit defaults\n- Locking behavior\n- Access control prompts"];
"Match limit" [shape=box, label="File-based keychain default:\nkSecMatchLimit = kSecMatchLimitAll\nData-protection keychain default:\nkSecMatchLimit = kSecMatchLimitOne\nAlways set explicitly."];
"Lock timeout" [shape=box, label="File-based keychain locks after\ntimeout (default: sleep + 5 min idle).\nerrSecAuthFailed = locked keychain.\nsecurity unlock-keychain to test."];
"Use data protection" [shape=box, label="For cross-platform code,\nset kSecUseDataProtectionKeychain = true\non macOS. This gives iOS-identical\nbehavior on macOS 10.15+."];
"Mac keychain issue?" -> "Catalyst or native?" [label="check target"];
"Catalyst or native?" -> "Shim behavior" [label="Catalyst"];
"Catalyst or native?" -> "File-based keychain?" [label="native macOS"];
"File-based keychain?" -> "Match limit" [label="unexpected result count"];
"File-based keychain?" -> "Lock timeout" [label="errSecAuthFailed"];
"File-based keychain?" -> "Use data protection" [label="want iOS-identical behavior"];
"Shim behavior" -> "Native Mac" [label="opted out of shim"];
}
树 7:errSecNoSuchAttr
digraph tree7 {
"errSecNoSuchAttr?" [shape=diamond];
"Check attr vs class" [shape=box, label="Not all attributes work\nwith all item classes.\nDump item to see which\nattributes it actually has."];
"Common mistakes" [shape=diamond];
"Tag on password" [shape=box, label="kSecAttrApplicationTag is for\nkSecClassKey only.\nFor passwords, use\nkSecAttrAccount or kSecAttrService."];
"Label mismatch" [shape=box, label="kSecAttrLabel behavior differs:\n- Passwords: free-form string\n- Keys: computed from key data\n- Certs: computed from subject\nSetting it may be silently ignored."];
"Description on key" [shape=box, label="kSecAttrDescription is for\nkSecClassGenericPassword and\nkSecClassInternetPassword only.\nNot available on keys or certs."];
"errSecNoSuchAttr?" -> "Check attr vs class" [label="first"];
"Check attr vs class" -> "Common mistakes" [label="identify"];
"Common mistakes" -> "Tag on password" [label="kSecAttrApplicationTag + password"];
"Common mistakes" -> "Label mismatch" [label="kSecAttrLabel unexpected behavior"];
"Common mistakes" -> "Description on key" [label="kSecAttrDescription + key/cert"];
}
快速参考表
| 症状 | 检查项 | 修复方法 |
|---|---|---|
| errSecDuplicateItem | 转储项目(步骤 1),比较主键属性 | 使用 SecItemUpdate 或先查询再添加的模式 |
| errSecItemNotFound | 转储项目,确认 kSecClass 和属性匹配 | 移除错误属性,修正类别 |
| 后台出现 errSecInteractionNotAllowed | 检查 kSecAttrAccessible 值 | 迁移到 AfterFirstUnlock(解锁时删除并重新添加) |
| 重启后出现 errSecInteractionNotAllowed | 检查是否已首次解锁 | 等待 protectedDataDidBecomeAvailable 后再访问 |
| errSecMissingEntitlement | 使用 codesign -d --entitlements - 检查访问组 | 将组添加到 Keychain Sharing 功能 |
| errSecNoSuchAttr | 检查属性是否与项目类别兼容 | 使用该类别对应的正确属性 |
| Mac 上出现 errSecAuthFailed | 检查基于文件的钥匙串是否已锁定 | 使用 security unlock-keychain 或数据保护钥匙串 |
| 更新后项目消失 | 对比不同版本的授权 | 恢复旧访问组,迁移项目 |
| 团队变更后项目消失 | 检查 App ID 前缀是否变更 | 在授权中添加两个前缀 |
| 删除操作删除了过多项目 | 检查删除查询的精确度 | 删除查询中始终指定所有主键属性 |
| 在模拟器上正常,在设备上失败 | 检查可访问性类别 | 模拟器会忽略数据保护——请在设备上测试 |
| Mac 与 iOS 行为不一致 | 检查 kSecUseDataProtectionKeychain | 将其设为 true,以确保跨平台行为一致 |
| 查询返回了错误项目 | 检查 kSecMatchLimit | 始终显式设置——不同钥匙串类型的默认值不同 |
| 后台生物识别项目访问失败 | 检查 SecAccessControl 标志 | 单独存储一个不使用生物识别的副本供后台使用 |
| SecItemAdd 返回 errSecSuccess,但搜索失败 | 检查添加和搜索时的访问组是否不同 | 在两者中都显式指定 kSecAttrAccessGroup |
压力场景
场景 1:“更新后用户无法登录——只要清除并重新存储令牌”
背景:2.1 版更改了 Keychain Sharing 权限。用户从 2.0 版升级后,会丢失身份验证令牌。支持工单大量涌入。
压力:“只要删除旧条目,并在首次启动时存储一个新条目。”
实际情况:由于访问组已更改,旧条目无法访问,SecItemDelete 也找不到它。“删除并重新添加”的做法实际上什么也没做,而且不会报告错误。与此同时,真正的修复方法是在权限中恢复旧访问组,让现有条目重新变得可读,然后迁移到新组。
正确做法:将旧访问组重新添加到 Keychain Sharing 权限中。首次启动时,从旧组读取,写入新组,然后从旧组删除。以 2.1.1 版发布。
反驳模板:“删除也行不通——旧条目位于我们现在无法读取的旧访问组中。我们需要在权限中重新添加旧访问组,才能读取并迁移这些条目。这是一个 30 分钟就能完成的修复,不需要重新设计。”
场景 2:“推送处理程序中出现 errSecInteractionNotAllowed——只要改成 AfterFirstUnlock”
背景:后台推送通知处理程序从钥匙串读取身份验证令牌以调用 API。设备锁定时会失败,并出现 errSecInteractionNotAllowed。
压力:“只要把可访问性改成 AfterFirstUnlock。这是个快速修复。”
实际情况:更改可访问性需要先删除旧条目,再添加一个具有新可访问性类别的新条目。如果你在设备锁定时于推送处理程序中执行此操作,删除会成功(它不读取数据),但添加会失败(AfterFirstUnlock 仍要求设备首次解锁;如果设备刚刚重启,就还没有首次解锁)。这样你就删除了用户的凭据。
正确做法:在前台代码中更改可访问性(应用启动时、protectedDataDidBecomeAvailable 时)。绝不要在后台执行路径中迁移钥匙串条目。
反驳模板:“我们不能在推送处理程序中更改可访问性——删除会成功,但如果设备重启后还没解锁,重新添加就可能失败。我们需要在下次应用启动时于前台完成迁移,并在此之前妥善处理推送处理程序的失败。”
场景 3:“钥匙串包装库会处理这些问题——直接用它就行”
背景:团队使用第三方钥匙串包装库(KeychainAccess、Valet 等)。尽管包装库提供了“upsert”方法,errSecDuplicateItem 仍不断出现。
压力:“包装库文档说它会处理重复项。肯定是包装库的 bug。”
实际情况:包装库的 upsert 会先查询再添加,或先查询再更新。但如果查询属性与该条目类别的唯一性约束不匹配,搜索就会返回未找到,而添加操作却会命中现有条目的主键。包装库无法修复使用了错误属性的查询。你需要了解条目的唯一性由什么决定,并确保包装库配置与之匹配。
正确做法:导出所有条目(步骤 1),查看现有内容。将包装库的查询属性与条目类别唯一性约束表进行比较。修正包装库配置,使其使用主键属性进行查询。
反驳模板:“包装库本身工作正常——是我们的配置与钥匙串的唯一性约束不匹配。让我导出现有条目,并与我们的查询进行比较。这只需 10 分钟就能查明原因。”
检查清单
在宣布钥匙串问题已修复前:
- 转储相关类别的所有项目,了解现有内容
- 确认 kSecClass 与项目类型匹配(GenericPassword、InternetPassword 或 Key)
- 检查主键属性的唯一性约束
- 确认 kSecAttrAccessible 适用于执行上下文(前台或后台)
- 确认授权文件中的访问组与查询匹配
- 在设备上测试(不只是模拟器——模拟器会忽略数据保护)
- 针对后台场景,在设备重启并锁定后进行测试
- 如果要迁移可访问性:仅在前台执行迁移,绝不要在后台执行
- 如果要在应用之间共享:两个应用在 Keychain Sharing 中使用相同的访问组
资源
文档:/security/keychain_services、/security/keychain_services/keychain_items、/security/errSecDuplicateItem、/security/errSecItemNotFound、/security/errSecInteractionNotAllowed
参考资料:Quinn “The Eskimo”——SecItem Pitfalls and Best Practices(Apple Developer Forums)、Keychain Items Fundamentals(Apple TN3137)
技能:axiom-keychain、axiom-keychain-ref