mcpField
A
axiom-keychain

axiom-keychain

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

关于 axiom-keychain

面向 iOS 和 macOS 开发者的 Apple Keychain Services 学习与故障排查技能,讲解 SecItem 操作、唯一性、数据保护、生物识别访问控制、共享及常见错误。

主要功能

  1. 01讲解四种 SecItem 操作
  2. 02排查唯一性和重复项目错误
  3. 03按执行场景选择可访问性级别
  4. 04配置生物识别与密码访问控制
  5. 05介绍共享及 macOS 钥匙串差异

适用场景

  1. 01安全存储令牌或 API 密钥
  2. 02调查钥匙串读写失败
  3. 03选择后台访问所需的保护级别
  4. 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 对应项用途
SecItemAddINSERT创建新项目
SecItemCopyMatchingSELECT读取一个或多个项目
SecItemUpdateUPDATE修改现有项目
SecItemDeleteDELETE删除项目
按类别划分的表

每种项目类别都是一个独立的表,具有不同的列(属性):

类别kSecClass 值常见用途
通用密码kSecClassGenericPassword应用凭据、令牌、机密
互联网密码kSecClassInternetPassword与 URL 关联的凭据
证书kSecClassCertificateX.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 调用 SecItemCopyMatchingkSecMatchLimitOne返回一项
使用 kSecReturnAttributes 调用 SecItemCopyMatchingkSecMatchLimitOne返回一项
使用 kSecReturnRef 调用 SecItemCopyMatchingkSecMatchLimitOne返回一项
SecItemDelete无限制概念删除所有匹配项

SecItemDelete 没有 kSecMatchLimit 参数。它会删除所有符合查询条件的项目。如果查询范围过宽(只有 kSecClass 和 kSecAttrService),就会删除该服务在所有账户下的每个项目。

返回类型决定输出类型
kSecReturn* 标志输出类型
仅 kSecReturnDataCFData(原始机密)
仅 kSecReturnAttributesCFDictionary(项目元数据)
仅 kSecReturnRefSecKey / SecCertificate / SecIdentity
kSecReturnData + kSecReturnAttributesCFDictionary(元数据 + kSecValueData 键)
kSecReturnPersistentRefCFData(持久引用,可在钥匙串重置后保留)

将结果强制转换为错误类型是常见的崩溃原因。请始终确保转换类型与返回标志相匹配。

可访问性与数据保护

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
Entitlementkeychain-access-groupscom.apple.security.application-groups
前缀Team ID(自动添加)无前缀(完整字符串由你控制)
范围仅钥匙串项目文件、UserDefaults 和钥匙串
设置位置Signing & Capabilities → Keychain SharingSigning & Capabilities → App Groups
访问组的工作方式

每个钥匙串项目都恰好属于一个访问组。如果未指定 kSecAttrAccessGroup,项目将存入应用的默认访问组(TEAMID.your.bundle.id)。

要在应用或扩展之间共享:

  1. 为两个目标添加相同的钥匙串访问组(Signing & Capabilities → Keychain Sharing)
  2. 写入时指定该组:
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

评论

支持 Markdown