ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Valet:让iOS/macOS Keychain敏感数据存储告别原生API的繁琐

Valet:让iOS/macOS Keychain敏感数据存储告别原生API的繁琐

做 iOS 和 macOS 开发这些年,凡是涉及登录态、令牌、凭证这类敏感数据的存储,我第一反应就是 Keychain。但直接去撸 Keychain 的 C API 写一次之后,我就知道自己彻底不想再碰了——参数又长又绕,返回的错误码全靠查表,模拟器和真机行为还不一样。直到后来接触了 Valet 这个封装库,才感觉像是有人把 Keychain 这头野象驯服了,牵过来递到你手里说:你只管存取,剩下的我来。

Valet 本质上就是一个基于 Keychain 的 Swift 封装层,它做的事就是让你用几行类型安全、语义清晰的代码,去完成本该由几十行 C 代码完成的钥匙串读写。本文我会先聊清楚为什么直接操作原生 Keychain 这么痛苦,再拆解 Valet 的几种关键类型和设计逻辑,接着给出完整的集成与迁移实操,最后把这些年踩过的坑整理成一份排查清单。无论你是第一次接触 Keychain,还是已经在项目里维护着一堆钥匙串代码,应该都能从这里拿到一些能用得上的东西。

1. 为什么绕不开 Keychain,又为什么建议用 Valet

先别急着学 Valet,你得先搞清楚它到底替你扛下了什么。Keychain 是 iOS/macOS 系统提供的一套加密存储机制,数据不是放在普通沙盒文件里,而是由系统级的安全模块管理,即使 App 被删了,数据也不一定会被清掉。这套机制的安全下限很高,但开发体验非常原始。

1.1 Keychain 原生 API 的痛点在哪里

原生 Keychain 接口是以 Security.framework 里的 C 函数为核心的,比如 SecItemAdd、SecItemCopyMatching、SecItemUpdate、SecItemDelete。这些函数的签名不是“传入 key 和 value”,而是“传一个 CFDictionary 查询条件”,你要把 kSecClass、kSecAttrService、kSecAttrAccount、kSecValueData、kSecAttrAccessible 这些个常量反复组合。写的时候感觉像是在拼 SQL,但拼错一个字段类型或对象生命周期管理不当,轻则 item 查不到,重则整段代码崩掉。

错误处理也是一个让人血压飙升的点。系统返回的是一个 OSStatus 错误码,比如 errSecItemNotFound、errSecDuplicateItem,你得先把这些码映射成中文或英文日志,才能判断到底发生了什么。而且有些错误码在真机和模拟器上的表现还不一样,调试时特别容易怀疑人生。更麻烦的是,同一个 App 里多个模块各写各的 Keychain 逻辑,service 命名不统一,accessibility 配得不一致,最后会出现“这个模块存进去的数据,那个模块读不到”这种诡异的线上问题。

还有一个隐性坑:Keychain 的底层行为是“每个 item 有 service 和 account 两个维度”,如果你不做统一抽象,代码里就会散落大量 magic string。一旦某个 key 写错,数据不会报错,只会安静地读不到,这种 bug 排查成本极高。

1.2 Valet 替你做了什么,以及为什么敢叫“终极”

Valet 把上述所有麻烦都藏在了背后。它内部仍然调用的还是系统 Keychain API,只是把所有重复、易错、碎片化的部分收敛成了一套 Swift 接口。你不再需要关心 kSecAttrService 怎么拼,因为每个 Valet 实例本身就代表了一个命名空间;你也不再需要手工管理 CFDictionary 的内存,因为传进去和吐出来的都是 String、Data 这类标准类型。

我把它称作“终极”,并不是说它在加密强度上比原生 Keychain 更强,它在密码学层面没有魔法,最后的加密底子还是系统提供的。它真正的贡献在于把 Keychain 的复杂度“封装到死”,同时在常见的安全场景里给出了合理的默认值。你只要选对 Valet 的类型,剩下的存取、迁移、错误清理都能稳定复现。对团队来说,这相当于把“每个人都要精通 Keychain”的隐性要求,降级成了“每个人只要会用库”的显性要求,长期维护成本差距还是很大的。

2. Valet 的类型设计与安全机制本质

Valet 并不是一个单一类,它按照不同的安全模式和同步需求,拆成了几种类型。理解这几类之间的差异,其实就是在理解 Keychain 本身的几种使用姿势。选错了类型,你的 App 可能在用户手机重启后就读不出数据,或者在你以为同步的时候发现只有本机有数据。

2.1 Valet 的四种核心类型,怎么理解和选择

  • Valet:默认类型,适用于普通凭证、登录 token、用户偏好中的敏感字段。它使用 kSecClassGenericPassword 作为底层 item 类型,数据会在设备重启后第一次解锁时变为可读。大多数业务场景选它就够,安全性和便利性之间的平衡最好。

  • SynchronizableValet:专门用于跨设备同步的钥匙串。它对应系统的 iCloud Keychain 同步能力。适合做“用户在 iPhone 上登录后,iPad 上也能自动拿到登录态”这样的需求。要注意的是,这种行为强依赖用户是否开启了 iCloud 钥匙串同步,不是开发者单方面能控制的。

  • SecureEnclaveValet:基于 Secure Enclave 的硬件级保护。使用这种类型时,Keychain 里的对称密钥实际上是包裹在安全区硬件之下的,即使设备被物理拆解,直接从闪存提数据也很难解出明文。代价是你必须保证设备处于解锁状态,而且代码逻辑里不能假设“后台静默读取”一定成功。

  • SinglePromptSecureEnclaveValet:和 SecureEnclaveValet 类似,但它在 App 生命周期内只需要完成一次解锁验证,后续读取不再反复弹生物识别或者密码。适合需要频繁读敏感数据,又不希望每次打断用户的场景。

这四种类型不是让你随意挑一个用的,它们背后对应的是不同的访问控制级别。选型的时候我一般会问自己几个问题:数据丢了重登可不可接受?数据是否需要跨设备?用户手机被物理拿走的威胁模型是否明显?如果只是登录态,普通 Valet 足够;如果涉及支付凭证或私钥,SecureEnclave 系列更合适。

2.2 访问控制和 Accessible 参数,到底在控制什么

Keychain 的 kSecAttrAccessible 参数控制的是“密钥在什么条件下可以被读取”,常见的几个选项包括:设备解锁后可读、设备第一次解锁后可读、仅本设备可读、需要设置密码等。Valet 在初始化时已经把这些参数固化进了对应类型,你在使用时不需要再手工指定。

很多从原生 Keychain 迁移过来的项目,最容易踩的坑就在这儿:之前手工指定了不当的 Accessible 值,比如用了旧的 kSecAttrAccessibleAlways,那在较新的系统版本上会被直接拒绝,或者在 App 进入后台后读取失败。Valet 的做法是直接接管这个决策,默认值都偏向“设备第一次解锁之后可读”,既不打扰前台高频操作,又不会让数据留在锁屏前的可访问窗口里。这个细节我觉得是它设计里很聪明的地方,普通开发者几乎想不到这一层。

2.3 Valet 内部一个“服务”是多宽的一个概念

Valet 初始化时需要传一个 identifier,比如Valet.valet(with: Identifier("com.example.login")!, accessibility: .afterFirstUnlock)。这个 identifier 最终会体现在 Keychain item 的 service 字段上。如果你业务里有多个独立模块,比如登录模块、支付模块、网络层证书存储,我建议给每个模块建独立的 Valet 实例,而不是全部塞到一个实例里。

这么做的好处不仅仅是职责清晰,更关键的是删除和迁移时不会误伤别的模块数据。我就见过有项目把所有东西存进同一个 Keychain 服务里,结果一次清理逻辑写错,把用户的所有凭证全删了,那场面属实惨烈。按模块拆分之后,每个 Valet 实例就像是独立的保险柜,里面放的什么只有对应模块自己清楚。

3. 从零集成 Valet,手把手跑通存取链路

这一部分直接进入实操。我会按“安装依赖 → 初始化实例 → 执行写入读取 → 处理 Codable 对象 → 验证删除”的顺序走一遍。在你自己的工程里,照这个步骤走完,基本就能把敏感数据的安全存储链路搭起来了。

3.1 安装和基础初始化

Valet 支持通过 Swift Package Manager 和 Cocoapods 集成。现在新项目我基本都用 SPM,在 Package.swift 或 Xcode 的 “Add Package Dependency” 里填仓库地址就行。引入后,在文件顶部import Valet即可使用。

初始化代码大致是这样一个模式:

import Valet let myValet = Valet.valet( with: Identifier("com.example.app.login")!, accessibility: .afterFirstUnlock )

这里的Identifier就是 service 层的命名空间,可以理解成保险柜上的编号牌。accessibility参数根据前面类型选择的建议来定就行。初始化完之后,这个实例就可以在多个模块间共享了,不需要每次存取都重新指一个 service。

值得注意的是,如果同一套代码要同时跑 iOS 和 macOS,Valet 的 API 是通用的,你在 mac 平台只需要额外留意一下是否要开启 App Sandbox 的能力访问。macOS 上如果沙盒隔离设置不对,钥匙串 item 会静默创建失败,这是跨平台开发最容易忽略的点。

3.2 字符串、密码和数据存取

基础读写接口非常简单,字符串和密码分别是这样:

// 写入 try myValet.setString("guest_token_abc123", forKey: "currentUserId") // 读取 let token = try myValet.string(forKey: "currentUserId")

这里forKey会映射到底层 Keychain item 的 account 字段,所以不需要你手动再把 service 和 account 拼接起来。读取接口返回的是可失败的可选项,如果 item 不存在,它返回 nil,而不是抛错,这对于业务层判断“用户是否已登录”特别方便。

Data 类型的存取也完全对称:

let data = Data(base64Encoded: "...") try myValet.setObject(data, forKey: "profileData") let loadedData = try myValet.object(forKey: "profileData")

需要提醒的是,虽然接口看起来像是字典存储,但 Keychain 并不是一个大字典,它的读写有 IO 开销和大小限制。单个 item 的体积上限通常被限制在几百 KB 到 1 MB 之间,视系统版本和类型而定,你不能真的把它当成 NSUserDefaults 或本地数据库来用。我一般只往里放小体积敏感信息,像用户头像这种动辄几百 KB 的资源,仍然放沙盒目录,钥匙串里只放加密后的密钥。

对于现代 Swift 项目,我更推荐把登录信息包成 Codable 结构体再实现协议的存取方式,比如一组UserCredential,内部包含 token、刷新时间、用户标识。Valet 在文档里公开支持 Codable 对象存储,你只要让对象遵循Codable即可。但注意,因为这些对象最终要序列化成 Data,所以存储体积仍然遵循 Data 的限制。

下面是一个 Codable 存取的参考模式:

struct UserCredential: Codable { let accessToken: String let refreshToken: String let expiresAt: Date } let credential = UserCredential( accessToken: "token_a", refreshToken: "token_b", expiresAt: Date().addingTimeInterval(3600) ) try myValet.setObject(credential, forKey: "loginCredential") let restored: UserCredential? = try myValet.object(forKey: "loginCredential")

用JSONEncoder自己编码再存入 Data 也能达到同样效果,但 Valet 的泛型封装省掉了这些样板代码。我在实际项目里,凡是登录响应带上 token 就会动态生成这个结构体,并且把过期时间也一并存进去,这样读取时可以先比对过期时间来决定是否自动刷新,避免每次启动都要走一遍网络请求。

3.3 删除与更新,以及它的行为细节

删除接口是try myValet.removeObject(forKey:),更新不需要单独 API,直接再次setString或setObject覆盖即可。Keychain 的底层逻辑要求更新 item 时,如果你用SecItemUpdate,query 和 update 字段必须同时传对;Valet 替你把“先查后更新”的逻辑包了进去,实际使用中已经完全感知不到这种细节。

有一点必须在团队内部讲清楚:Keychain 数据不随 App 卸载而消失。如果你在开发调试时写入了数据,删除 App 再重装,旧数据可能还在。这既是优点也是隐患。优点是升级版本后用户登录态不丢;隐患是如果你的 key 结构变了,旧数据很容易变成“读不出来又删不掉”的僵尸 item,这时候只能手工写清理逻辑,或者在版本升级脚本里统一 remove。Valet 没有魔法帮你自动清理历史 key,因为这是业务语义问题,不是存储层问题。

3.4 行为细节:锁屏前后和后台读取

我把这条单独拎出来,是因为它在生产环境里特别容易出事故。当你使用默认的 Valet 类型,即 accessibility 为.afterFirstUnlock时,设备一旦在开机后完成首次解锁,App 在后台或者锁屏状态下依然可以读取钥匙串内容。但如果选了 SecureEnclave 系列,锁屏之后读取可能会触发用户解锁验证,后台静默读取大概率失败。

有一次我做后台同步功能,把一个 SecureEnclaveValet 用在“后台刷新 token”的流程里,结果用户在锁屏状态下 App 尝试刷新时就一直拿不到旧 token,最后只能降级处理:等用户下一次解锁之后再做同步。后来我把访问控制调整为.afterFirstUnlock才解决。所以选型前一定要想清楚:你的读取场景有没有后台执行的可能,如果没有,SecureEnclave 的高安全性才值得坚持。

4. 从原生 Keychain 和其他封装迁移到 Valet

老项目不会天然长成 Valet 的形状。我从原生 Keychain 代码迁到 Valet,以及从其他类似封装库迁到 Valet,都梳理过一套流程。这里分享出来的迁移思路,多数操作是通用的,核心原则只有一个:先建立新的读写通道,再做旧数据搬迁,最后删除旧通道。

4.1 从原生 Keychain API 迁移的步骤拆分

假设你原来的代码是这样往 Keychain 里写数据的:

let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "com.example.legacy", kSecAttrAccount as String: "userId", kSecValueData as String: Data("old_token".utf8), kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock ] SecItemAdd(query as CFDictionary, nil)

这段代码的问题不在于“能不能跑”,而在于“换个人维护就抓瞎”。迁移时,第一步先用 Valet 新建一个实例:

let legacyValet = Valet.valet( with: Identifier("com.example.legacy")!, accessibility: .afterFirstUnlock )

然后执行迁移校验,把旧 item 读出来:

if let legacyString = try? legacyValet.string(forKey: "userId") { try newValet.setString(legacyString, forKey: "userId") try legacyValet.removeObject(forKey: "userId") }

这个“读旧 → 写新 → 删旧”的三段式模式,和数据库迁移的思维很像。不要试图在原地上改 service 值,因为一旦写错,旧数据可能直接丢。只有新通道完整写入成功后,才允许删除,这也是保证迁移失败可回滚的基本盘。

4.2 从其他封装库迁移时的坑

有段时间我接过一个维护项目,里面用的是另一套钥匙串封装。它的底层其实还是 Keychain,但它把 service 命名成了带前缀的字符串,并且把多个字段塞在一个 JSON 对象里。迁移这种事最怕的其实是“你以为字段对了,但粒度和对方不一致”。

我的习惯是先写一个“读取探针”的小工具代码,在测试环境下把旧库暴露的 key 列表全量遍历出来,再映射到新 Valet 的 key 集合里,确认没有遗漏后,才进入真实迁移。如果旧库数据是复合结构,比如一个 item 里存了整个用户模型 JSON,那迁移到新库时最好拆成多个独立字段,后续按需读取,而不是继续抱着一坨 JSON 字符串。

4.3 Accessibility 提升时的兼容性维护

很多团队在某一时刻会觉得“我们 App 的安全等级应该更高”,于是想着把 accessibility 从.afterFirstUnlock升级到.whenUnlocked或 Secure Enclave 系列。这个出发点是好的,但你要知道,Keychain item 的访问控制属性是“钉死”在 item 上的,不是换个参数重新读取就能升级的。

如果你在初始化时改了 Valet 的 accessibility,你会发现原来能读到的 item 全部读不到了。因为底层匹配规则里,accessibility 是查询条件的一部分。正确做法是新建一个不同 identifier 的 Valet 实例,用新实例读取老数据并迁移。我在一次真实升级里差点把用户登录态全部“搞丢”,后来才发现是 Accessibility 不一致导致的,这个教训让我养成了一个习惯:任何访问控制级别的变更,都必须先在测试机上做完整迁移演练。

5. 常见问题与排查技巧实录

到了这节,几乎每个都是真金白银换来的经验。我把实际开发中遇到频率最高的几类问题整理成速查表和相应的处理思路,方便你遇到对应现象时快速定位。

5.1 问题速查表

现象可能原因处理建议
写入后立刻读取返回 nilidentifier 不同,或 accessibility 不一致核对 Valet 实例初始化参数是否全局统一
App 后台读取出错选了 SecureEnclave 类型,锁屏下不可读降级为 afterFirstUnlock,或延后到解锁后再读
同一数据 iPhone 有,iPad 没有用了默认 Valet 而不是 SynchronizableValet切换类型,并注意用户是否开启 iCloud 钥匙串
重装 App 后旧数据还在Keychain 不随卸载清除版本迭代时提供手动清理开关或迁移逻辑
模拟器能读,真机不能读模拟器和真机 Keychain 行为差异统一以真机测试为准,模拟器只做开发验证
写入抛出重复 item 错误key 已存在但代码走的是 add 路径正常覆盖写即可,Valet 已处理,检查是否用了旧库路径
某些 item 在系统更新后消失accessibility 或 item 类型在新系统被清理避免使用 deprecated 访问常量,保留迁移通道

这张表不能覆盖所有情况,但它能帮你建立几个大方向:出现“读不到”先看 identifier 和 accessibility,出现“不同步”先看类型,出现“历史上诡异丢失”先反思是否改过访问控制。Keychain 的诡异之处在于,很多错误不是立刻报出来的,而是数据默默消失,所以排查思路比具体错误码更重要。

5.2 模拟器和真机的行为差异,这个坑必须单独说

模拟器在钥匙串上的行为和真机差异非常大。模拟器的 Keychain 更像是“映射到 macOS 钥匙串”,你可以在 Mac 的“钥匙串访问”里看到模拟器里存的项。而真机上,每个 App 的钥匙串 item 还受到 provisioning profile、entitlements 以及设备锁定状态的影响。

我之前有一次开发中在模拟器上测得好好的,一上真机就死活读不到数据。排查了整整半天,最后发现是开发签名里没有包含必需的 Keychain Access Groups entitlement。Valet 初始化时如果使用了共享访问组,比如需要在同一 App 组内和扩展共享数据,就必须在 entitlements 里配上。因为基础配置是静默失败的,你不会收到明显报错,只有 item 写入和读取双双失败。这个问题如果不提前在项目配置阶段检查好,后期排查会极其痛苦。

5.3 处理生物识别和用户取消的细节

如果你在 SecureEnclaveValet 上加上访问控制,iOS 会弹出 Face ID 或 Touch ID 验证框。这个流程和系统 UI 绑定,意味着如果用户连续几次验证失败并退出,对应操作会返回用户取消错误。业务层不能把这种取消当成普通失败处理,最好做静默降级:比如读不到就提示用户重新解锁,而不是立刻弹一个“网络错误”之类误导性的报错。

另外一个容易被忽视的点是:生物识别验证属于前台交互式操作,不能在后台线程随机发起。你必须在主线程或者合适的前台时机触发,且尽量合并多次读取请求,避免连续弹出验证框。用户对频繁弹 Face ID 的容忍度很低,我在产品上线后就因为这个被用户反馈过“太烦人”,后来把验证频率控制在会话周期内只触发一次,体验才恢复正常。

5.4 测试时的钥匙串残留问题,怎么根治

由于 Keychain 数据不随 App 卸载,自动化测试里很容易出现“上一轮用例写入的 item 污染了下一轮用例结果”。我的经验是专门注册一个tearDown清理方法,把当前用例用到的 identifier 对应的 key 全部 remove 干净,并且在清理时不要依赖被测代码的存储路径,直接创建相同 identifier 的 Valet 实例去 remove。

还可以根据设备类型做区分:模拟器上可以直接用系统钥匙串访问工具清除;真机上没有这么方便,但只要测试代码主动清理,基本也能保证用例隔离。这个清理逻辑建议放进测试基类里,而不是散落在每个用例里,否则时间长了就会有人漏写,出现随机挂用例的现象。

6. 一些更深的经验:安全边界与设计取舍

Valet 确实好用,但它不是万能密钥。把钥匙串封装好了,只是把“安全存储”这件事做得更顺手,并不代表整个 App 就固若金汤。

6.1 Valet 不能防什么,得心里有数

Keychain 保护的是静态数据,也就是数据在存储状态下的保密性和完整性。它不能防止攻击者对你的 App 做运行时注入,比如通过越狱环境 hook 动态库来读取你在内存里解密后的明文;也不能防止你在代码里把密钥直接硬编码进二进制文件,然后被逆向工具提取。Valet 只是把“存储”这一环的坑填平了,你这边的密钥分发、解密链路、网络传输安全仍需要自己负责。

所以在设计敏感数据链路时,我的原则是分层:Keychain 里存第一层保护密钥,真正的业务敏感内容先用这个密钥加密后放进沙盒数据库,换取性能;同时内存里不长期驻留密钥,用完即销毁。Valet 保证的是密钥本身的存放,后续使用方式仍依赖你自身的工程素养。

6.2 多线程读写和团队协作层面的注意点

Keychain API 本身是线程安全的,但 Valet 的实例在多线程同时读写同一个 key 时,仍然可能出现你预料不到的时序问题。我建议在 App 内对 Valet 实例做单例化管理,比如一个登录用户凭证管理器,所有模块都通过这个管理器来操作,而不是各自初始化一堆实例。这样既统一了 identifier,也方便以后换存储方式时只改一个类。

团队协作时,每个需要新增敏感字段的成员往往会下意识地往同一个 key 空间里加。人一多,命名就容易乱。我的做法是维护一张“钥匙串 key 注册表”,每次新增字段都要先登记,避免两个模块无意间写到同一个 key 上。这个看起来有点老派,但确实能救团队于水火中。

6.3 备份和迁移策略的兜底思考

需要考虑数据的生命周期,不只是“存进去”。比如,用户的登录凭证在 token 失效后要不要自动删?用户注销账号后要不要立刻清理?App 升级到新版后,旧的钥匙串结构要不要保留兼容读取?我见过很多项目在早期只写不删,到了后期 Keychain 里堆积了大量过期 token,还占用同步容量。

我一般会在用户登录态切换的地方统一封一层“认证会话管理器”,它负责在登录、刷新、登出时执行对应的 Valet 读写和清理。这样登录逻辑无论怎么改版,钥匙串的状态始终跟随会话状态。与其说这是 Valet 的特性,不如说这是用好它的前提条件。

最后再分享一个细节

在实际项目里跑了一段时间之后,我最深的一个体会是:Valet 这类封装的价值,更多体现在“降低团队犯错的概率”,而不是“提升单次加密操作的速度”。你现在花半小时读完这些内容,可能觉得 API 很简单,但从长期维护的角度看,它帮你避开的那些坑——比如 accessibility 不一致导致的静默丢失、key 命名混乱导致的互相覆盖、锁屏后台读取失败导致的同步异常——每一个都够你熬夜好几次。

如果你正打算把项目里的敏感数据迁移到 Keychain,或者想重构现有的钥匙串代码,我的建议是:先别急着把全部数据都搬进去,挑一个最不敏感的字段(比如用户偏好里的一个标志位)作为试点,跑通整个存取、读取、删除链路之后,确认在真机、后台、锁屏、升级这些场景下都正常,再逐步铺开。等这套通道稳定了,你再去处理旧数据迁移,风险会小很多。

Valet 不是一个能解决所有安全问题的银弹,但如果你需要在一个大规模 App 里管理登录态、令牌、加密密钥这类东西,它确实是我目前遇到过的、把 Keychain 复杂度和安全性平衡得最好的方案。如果你也在项目里踩过钥匙串的坑,或者发现了一些我没提到的诡异现象,欢迎在评论区一起交流——这种要和系统安全边界打交道的经验,多聊一次,大家就能少踩一次。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进