mcpField
A
axiom-keychain-diag

axiom-keychain-diag

github.com/charleswiltgen安全1.2k36 次安装今天更新
代码仓库

关于 axiom-keychain-diag

面向排查 Apple Security framework 和 Keychain 故障的开发者指南。它将常见错误症状整理为检查步骤与决策路径,涵盖权限、数据保护和 macOS 行为差异。

主要功能

  1. 01诊断常见 Keychain 错误码
  2. 02检查项目属性与查询不匹配
  3. 03排查可访问性和后台时序
  4. 04验证访问组权限
  5. 05比较 macOS Keychain 行为

适用场景

  1. 01追查 errSecDuplicateItem 或 errSecItemNotFound
  2. 02调查后台执行期间的 Keychain 故障
  3. 03查明应用更新后项目消失的原因

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-keychain-diag

安装后在 Claude Code 中输入/axiom-keychain-diag确认已加载。

详细介绍

钥匙串诊断

针对 Security 框架故障的系统化排查:唯一性约束冲突、查询不匹配、数据保护时机、访问组权限、更新后项目消失,以及 Mac 兼容层行为差异。

概述

核心原则:钥匙串操作失败时,问题通常是:

  1. 唯一性约束不匹配(errSecDuplicateItem)——25%
  2. 查询属性混淆(errSecItemNotFound)——25%
  3. 数据保护/后台时机(errSecInteractionNotAllowed)——20%
  4. 访问组/权限不匹配(errSecMissingEntitlement)——15%
  5. Mac 兼容层行为差异——10%
  6. 应用更新后项目丢失(权限或 App ID 前缀变更)——5%

更改钥匙串代码前,务必先转储现有项目并比较属性。

危险信号

表明存在钥匙串特定问题的症状:

症状可能原因
查询返回未找到,但出现 errSecDuplicateItem添加查询中的属性不具唯一性——唯一性由类别和主键属性决定,而不是由完整查询决定
errSecItemNotFound,但刚刚添加了项目kSecClass 错误、查询属性范围过窄,或访问组不匹配
后台运行时出现 errSecInteractionNotAllowedkSecAttrAccessibleWhenUnlocked(默认值)+设备锁定+后台执行
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"];
}

各类别的唯一性约束:

类别主键属性
kSecClassGenericPasswordkSecAttrAccount + kSecAttrService + kSecAttrAccessGroup
kSecClassInternetPasswordkSecAttrAccount + kSecAttrSecurityDomain + kSecAttrServer + kSecAttrProtocol + kSecAttrAuthenticationType + kSecAttrPort + kSecAttrPath
kSecClassCertificatekSecAttrCertificateType + kSecAttrIssuer + kSecAttrSerialNumber
kSecClassKeykSecAttrKeyClass + 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

评论

支持 Markdown