iOS Keychain 实战:安全存储、访问控制与多应用共享
1. 从一个真实需求说起为什么我要认真聊聊 Keychain做 iOS 开发这些年被问得最多的问题里账号密码存哪儿绝对排得上前三。很多刚入行的朋友第一反应是UserDefaults稍微懂点的会说存沙盒文件里再讲究一点的会提到加密后写进数据库。这些方案我都用过也都踩过坑最后绕来绕去还是回到了系统自带的Keychain上。Keychain 是 iOS 系统提供的一套安全存储机制专门用来保存密码、密钥、证书、令牌这类敏感信息。它不是一个简单的键值对容器而是一个由系统统一管理、带访问控制、支持加密的凭据数据库。你调用SecItemAdd、SecItemCopyMatching、SecItemUpdate、SecItemDelete这几个 C 函数就能把数据交给系统保管取的时候再按条件查回来。听起来有点原始但正是这种底层接口给了我们最大的控制权。这篇文章适合谁看如果你正在做登录态持久化、Token 管理、自动填充、设备唯一标识或者单纯想搞清楚“为什么我的密码存 UserDefaults 被安全审计打回来了”那这篇就是写给你的。我会从设计思路讲到具体代码从参数含义讲到实际踩坑尽量把每个“为什么”都说透。Keychain 这套东西文档不算少但真正把坑讲明白的中文资料不多我把自己这几年攒下来的经验一次性摊开讲。需要先说明一点Keychain 的接口是 C 语言风格的参数是CFDictionary错误码是一堆OSStatus第一次看确实劝退。但只要你理解了它的“查询字典”模型后面就是套模板的事。我会用生活化的类比帮你建立直觉再给可直接抄的代码。2. Keychain 的整体设计与核心思路拆解2.1 Keychain 到底是个什么东西你可以把 Keychain 想象成系统帮你管着的一个带索引的保险箱。保险箱里有很多格子每个格子放一条数据每条数据都贴着一堆标签这条数据属于哪个应用、属于哪个访问组、是什么类型密码还是密钥、对应哪个账号、哪台设备。你想取东西的时候不是拿一个唯一的钥匙去开某个格子而是描述你要找的东西长什么样系统把所有匹配的格子找出来给你。这个“描述条件”就是查询字典query dictionary。这是 Keychain 最核心的设计理念也是它和普通字典存储最大的区别。UserDefaults是你给一个 key它返回一个 value一对一。Keychain 是你给一组属性它返回所有满足条件的条目可能一条可能多条也可能零条。理解这一点非常关键因为后面所有的 API 调用本质上都是在构造这个“描述条件”。SecItemAdd是“按这些属性新建一条”SecItemCopyMatching是“按这些属性找”SecItemUpdate是“把满足这些属性的条目改成那样”SecItemDelete是“把满足这些属性的都删掉”。2.2 为什么不用 UserDefaults 或文件存储我见过太多项目把 token 直接塞进UserDefaults。UserDefaults存的是一个 plist 文件位于应用沙盒的Library/Preferences目录下。这个文件没有加密只要拿到设备备份或者越狱环境直接就能读出来。就算是普通用户用一些备份分析工具也能看到明文。安全审计一扫描直接标红。文件存储的问题类似你写进沙盒的 Documents 或 Caches 目录默认都是明文。有人会说“我自己 AES 加密再存”这确实比明文强但密钥放哪儿又成了新问题——密钥硬编码在代码里可以被逆向放文件里又回到原点。这是个鸡生蛋的问题。Keychain 的价值就在于加密和密钥管理由系统负责。数据在落盘时由系统加密密钥由安全隔区Secure Enclave或系统密钥链管理应用层拿不到原始密钥。而且 Keychain 支持访问控制比如“必须设备解锁后才能读”“必须用户在场Face ID / Touch ID才能读”。这些能力自己实现成本极高用系统现成的才是正解。2.3 几个必须先搞懂的核心概念在动手写代码前有几个概念必须先理清否则后面参数怎么填都是懵的。kSecClass条目类型Keychain 里的数据是分类的常见的有kSecClassGenericPassword通用密码最常用、kSecClassInternetPassword网络密码带服务器、端口等属性、kSecClassCertificate证书、kSecClassKey密钥。日常存 token、存账号密码99% 的情况用kSecClassGenericPassword就够了。Service 和 Account对于kSecClassGenericPassword系统用kSecAttrService和kSecAttrAccount这两个属性来定位一条记录。你可以把 Service 理解成“哪个业务”Account 理解成“哪个用户”。比如 Service 填com.myapp.loginAccount 填用户 ID这样就能区分不同业务、不同用户的凭据。这两个字段是逻辑主键重复添加会报errSecDuplicateItem。AccessGroup访问组默认情况下一个应用只能访问自己写入的 Keychain 条目。但同一个开发团队下的多个应用可以通过配置相同的 AccessGroup 来共享数据。这在做应用矩阵、主 App 和扩展如 Widget、Share Extension共享登录态时非常有用。配置 AccessGroup 需要在 Xcode 的 Capabilities 里开启 Keychain Sharing并填写组名格式一般是团队ID.组名。kSecAttrAccessible可访问性这个属性决定了数据什么时候能被读到是安全性的关键。常见取值有kSecAttrAccessibleWhenUnlocked设备解锁后可读默认推荐、kSecAttrAccessibleAfterFirstUnlock首次解锁后一直可读适合后台需要访问的场景、kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly必须设置了密码且只在本机不随备份迁移。选错这个属性要么后台读不到数据要么安全性打折扣。3. 核心 API 详解与参数实操要点3.1 SecItemAdd把数据放进保险箱SecItemAdd的签名是SecItemAdd(CFDictionaryRef attributes, CFTypeRef *result)。第一个参数是属性字典描述你要存什么第二个参数是输出一般传 NULL 就行除非你需要拿到系统分配的持久化引用。构造属性字典时必填的几项是kSecClass、kSecAttrService、kSecAttrAccount、kSecValueData。kSecValueData是NSData类型所以字符串要先转成 Data。下面是一段可以直接用的 Swift 代码func savePassword(service: String, account: String, password: String) - Bool { guard let data password.data(using: .utf8) else { return false } let query: [String: Any] [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked ] SecItemDelete(query as CFDictionary) // 先删后加避免重复 let status SecItemAdd(query as CFDictionary, nil) return status errSecSuccess }这里有个实操细节先删后加。因为如果同样的 Service Account 已经存在SecItemAdd会返回errSecDuplicateItem不会覆盖。很多人第一次用会踩这个坑以为存进去了其实没更新。先删后加是最省事的做法虽然理论上有一瞬间数据不存在但对绝大多数场景无所谓。如果你追求原子性可以用SecItemUpdate先尝试更新失败再添加。注意kSecAttrAccessible如果不显式指定系统会用默认值但不同 iOS 版本默认值可能不同。强烈建议每次都显式写清楚别依赖默认。3.2 SecItemCopyMatching按条件把数据取出来查询比添加稍微复杂一点因为你要控制返回什么。SecItemCopyMatching(CFDictionaryRef query, CFTypeRef *result)的查询字典里除了定位条件还可以加两个控制项kSecReturnData是否返回数据本身和kSecMatchLimit返回几条。kSecMatchLimit常用kSecMatchLimitOne只返回一条和kSecMatchLimitAll返回全部。如果你只想要一条一定要设成 One否则返回的是数组解析起来麻烦。kSecReturnData设成true系统才会把kSecValueData给你否则只返回属性。func readPassword(service: String, account: String) - String? { let query: [String: Any] [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var result: AnyObject? let status SecItemCopyMatching(query as CFDictionary, result) guard status errSecSuccess, let data result as? Data, let password String(data: data, encoding: .utf8) else { return nil } return password }这里有个容易忽略的点result的类型取决于你查询时设的返回选项。如果你同时设了kSecReturnData和kSecReturnAttributes为 true返回的会是一个字典数据在kSecValueData键下。如果只设了kSecReturnData返回的直接就是 Data。类型判断写错就会拿到 nil然后怀疑人生。3.3 SecItemUpdate更新已有条目SecItemUpdate(CFDictionaryRef query, CFDictionaryRef attributesToUpdate)接收两个字典第一个描述“要更新哪些条目”第二个描述“改成什么”。注意第二个字典里不能包含定位属性如 Service、Account只能包含要修改的属性比如kSecValueData。func updatePassword(service: String, account: String, newPassword: String) - Bool { guard let data newPassword.data(using: .utf8) else { return false } let query: [String: Any] [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account ] let attributes: [String: Any] [ kSecValueData as String: data ] let status SecItemUpdate(query as CFDictionary, attributes as CFDictionary) return status errSecSuccess }如果条目不存在SecItemUpdate会返回errSecItemNotFound。所以一个健壮的写入逻辑通常是先 Update如果返回 not found再 Add。这样比“先删后加”更高效也避免了数据短暂丢失。3.4 SecItemDelete删除条目删除最简单给定位条件就行。但要注意删除是按条件批量删的。如果你的条件写得太宽泛比如只写了kSecClass那会把该类型下所有条目全删掉。所以定位条件一定要写全 Service 和 Account。func deletePassword(service: String, account: String) - Bool { let query: [String: Any] [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account ] let status SecItemDelete(query as CFDictionary) return status errSecSuccess || status errSecItemNotFound }删除时把errSecItemNotFound也当成成功是个实用技巧。因为“删一个本来就不存在的东西”从业务角度看就是“现在它不在了”没必要报错。3.5 错误码速查与含义Keychain 的 API 返回OSStatus是个 Int32。常见错误码如下表建议收藏错误码常量数值含义与常见原因errSecSuccess0成功errSecDuplicateItem-25299条目已存在Add 时未先删或未 UpdateerrSecItemNotFound-25300没找到查询条件不对或数据已被删errSecParam-50参数错误字典缺必填项或类型不对errSecMissingEntitlement-34018缺少权限AccessGroup 配置不对或签名问题errSecAuthFailed-25293认证失败访问控制要求用户验证但未通过errSecInteractionNotAllowed-25308当前状态不允许交互如锁屏时读了 WhenUnlocked 的数据errSecMissingEntitlement这个坑特别值得说。它经常出现在真机调试、扩展共享、或者证书配置混乱的时候。模拟器上跑得好好的一上真机就报 -34018八成是 AccessGroup 的 entitlement 没配对。解决办法是检查 Xcode 的 Signing Capabilities确认 Keychain Sharing 开启且组名和代码里一致。4. 完整实操流程与关键环节实现4.1 封装一个可复用的 Keychain 工具类每次写业务都手搓字典太累也容易出错。我习惯封装一个工具类把增删改查包成方法。下面这个版本是我在多个项目里用过的稳定可靠import Foundation import Security final class KeychainHelper { static let shared KeychainHelper() private init() {} discardableResult func save(_ data: Data, service: String, account: String) - Bool { let query: [String: Any] [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account ] let attributes: [String: Any] [ kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked ] let updateStatus SecItemUpdate(query as CFDictionary, attributes as CFDictionary) if updateStatus errSecSuccess { return true } var addQuery query addQuery[kSecValueData as String] data addQuery[kSecAttrAccessible as String] kSecAttrAccessibleWhenUnlocked return SecItemAdd(addQuery as CFDictionary, nil) errSecSuccess } func read(service: String, account: String) - Data? { let query: [String: Any] [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var result: AnyObject? guard SecItemCopyMatching(query as CFDictionary, result) errSecSuccess else { return nil } return result as? Data } discardableResult func delete(service: String, account: String) - Bool { let query: [String: Any] [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account ] let status SecItemDelete(query as CFDictionary) return status errSecSuccess || status errSecItemNotFound } }这个封装的核心思路是Update 优先Add 兜底。这样既避免了重复添加报错又保证了数据能写入。save方法接收 Data字符串、Codable 对象都可以先转 Data 再存通用性更强。4.2 存一个 Codable 对象Token 模型的实战实际项目里我们往往不是存一个字符串而是存一个包含 token、过期时间、刷新令牌的对象。用Codable编码成 JSON 再存是最顺手的做法struct AuthToken: Codable { let accessToken: String let refreshToken: String let expiresAt: TimeInterval } extension KeychainHelper { func saveToken(_ token: AuthToken, account: String) - Bool { guard let data try? JSONEncoder().encode(token) else { return false } return save(data, service: com.myapp.auth, account: account) } func readToken(account: String) - AuthToken? { guard let data read(service: com.myapp.auth, account: account) else { return nil } return try? JSONDecoder().decode(AuthToken.self, from: data) } }这里 Service 用com.myapp.auth这种反向域名风格是行业惯例能有效避免不同业务之间的键冲突。Account 用用户 ID 或设备标识这样多账号切换时互不干扰。4.3 访问控制让 Face ID 保护你的敏感数据Keychain 最强大的能力之一是结合生物识别做访问控制。你可以要求“读取这条数据时必须通过 Face ID 或 Touch ID 验证”。实现方式是给条目加上SecAccessControlfunc saveWithBiometricProtection(data: Data, service: String, account: String) - Bool { guard let access SecAccessControlCreateWithFlags( nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .biometryCurrentSet, nil ) else { return false } let query: [String: Any] [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecValueData as String: data, kSecAttrAccessControl as String: access ] SecItemDelete(query as CFDictionary) return SecItemAdd(query as CFDictionary, nil) errSecSuccess }.biometryCurrentSet的含义是只有当前录入的生物特征集合能解锁。如果用户新增或删除了指纹/面容这条数据就失效了。这对高敏感场景如支付密码是合适的但对普通登录态可能太严格用户换个指纹就登不上了。所以选哪个 flag 要根据业务权衡。提示使用生物识别保护的 Keychain 条目读取时系统会自动弹出验证界面不需要你手动调LAContext。但要注意读取操作必须在主线程发起否则可能因为无法展示 UI 而返回errSecInteractionNotAllowed。4.4 多应用共享AccessGroup 的正确配置姿势主 App 和 Share Extension 共享登录态是 AccessGroup 最典型的用法。配置分三步第一步在 Xcode 里选中 Target进入 Signing Capabilities点加号添加 Keychain Sharing填写一个组名比如com.mycompany.shared。主 App 和扩展都要加且组名一致。第二步代码里在查询字典中加上kSecAttrAccessGroup值就是团队ID.com.mycompany.shared。团队 ID 在开发者账号里能查到是 10 位字符。第三步确认两个 Target 的 entitlement 文件里都有keychain-access-groups这一项且包含该组名。let query: [String: Any] [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: com.myapp.auth, kSecAttrAccount as String: account, kSecAttrAccessGroup as String: ABCDE12345.com.mycompany.shared, kSecValueData as String: data ]这里最常见的坑是模拟器不校验 AccessGroup真机严格校验。所以一定要在真机上测。另外如果扩展和主 App 的签名证书不一致也会报errSecMissingEntitlement。4.5 数据迁移从 UserDefaults 平滑搬到 Keychain老项目升级时往往已经有一批数据存在 UserDefaults 里。直接切换会导致老用户登录态丢失。我的做法是写一个迁移逻辑App 启动时检查 Keychain 里有没有数据没有的话从 UserDefaults 读出来写进 Keychain然后删掉 UserDefaults 里的旧数据。func migrateTokenIfNeeded() { let account current_user if KeychainHelper.shared.read(service: com.myapp.auth, account: account) ! nil { return // 已迁移 } if let oldToken UserDefaults.standard.string(forKey: auth_token) { if let data oldToken.data(using: .utf8) { KeychainHelper.shared.save(data, service: com.myapp.auth, account: account) } UserDefaults.standard.removeObject(forKey: auth_token) } }迁移逻辑要幂等跑多少次结果都一样。用“Keychain 里有没有”作为判断依据比用版本号标记更可靠因为用户可能重装、可能清数据状态不一定按你预期走。5. 常见问题与排查技巧实录5.1 那些年我踩过的 Keychain 坑坑一模拟器上好好的真机报 -34018。前面提过这是 entitlement 问题。但还有一种情况你的 App 用了自动签名Xcode 自动生成的 entitlement 里 AccessGroup 和你代码里写的不一致。解决办法是手动检查.entitlements文件或者干脆代码里不指定 AccessGroup用默认的。坑二卸载重装后数据还在。这是 Keychain 的“特性”而非 bug。iOS 卸载 App 时不会清除 Keychain 数据这是系统设计如此为了让用户重装后还能保留凭据。但如果你希望卸载即清空需要在首次启动时判断并清理。判断方法是用UserDefaults存一个标记卸载后UserDefaults会清空而 Keychain 不会两者对比就能知道是不是重装。func clearKeychainIfReinstalled() { let hasLaunchedKey has_launched_before if !UserDefaults.standard.bool(forKey: hasLaunchedKey) { // 首次启动可能是新装也可能是重装清空 Keychain KeychainHelper.shared.delete(service: com.myapp.auth, account: current_user) UserDefaults.standard.set(true, forKey: hasLaunchedKey) } }坑三锁屏状态下读不到数据。如果你存的时候用了kSecAttrAccessibleWhenUnlocked那设备锁屏时读取会返回errSecInteractionNotAllowed。后台任务、推送处理、蓝牙通信这些场景要用kSecAttrAccessibleAfterFirstUnlock。这个属性表示设备首次解锁后即使再锁屏也能读。选属性时要考虑你的数据在什么时机被访问。坑四查询返回多条数据。如果你没设kSecMatchLimitOne而恰好有多条匹配返回的是数组。有人直接as? Data就拿到 nil然后以为没数据。养成习惯单条查询永远带上kSecMatchLimitOne。5.2 常见问题速查表现象可能原因排查方向Add 返回 -25299条目已存在改用 Update 或先 DeleteCopy 返回 -25300条件不匹配检查 Service/Account/AccessGroup 是否一致真机返回 -34018entitlement 缺失检查 Keychain Sharing 配置和签名锁屏读取失败Accessible 属性太严改用 AfterFirstUnlock返回 nil 但状态是成功类型转换错误确认返回是 Data 还是 Dictionary生物识别读取无响应非主线程调用切到主线程再读多设备数据不同步Keychain 默认不跨设备需要 iCloud Keychain 同步则用 kSecAttrSynchronizable5.3 几个提升健壮性的实操心得第一永远检查 OSStatus。我见过太多代码调完SecItemAdd就不管返回值了出了问题完全不知道哪一步错。每个调用都判断状态失败时打日志能省下大量排查时间。第二Service 命名要规范。用反向域名加业务模块比如com.company.app.login、com.company.app.payment。别用login、token这种太泛的名字多个模块容易撞车。第三敏感数据加一层业务加密。虽然 Keychain 本身加密但对于特别敏感的数据如支付密码我习惯在存入前再用业务密钥加密一次。这样即使 Keychain 被某种方式读取拿到的也是密文。当然业务密钥的管理又是另一个话题可以用白盒加密或分片存储来增强。第四写单元测试。Keychain 的操作是可以测试的用不同的 Service 前缀隔离测试数据测完清理。这样重构时心里有底不会改坏核心逻辑。第五注意线程安全。Keychain 的 API 本身是线程安全的但你的封装如果用了共享的可变状态就要加锁。我一般用串行队列或者NSLock保护写入操作避免并发写导致状态混乱。6. 关于 Keychain 的一些延伸思考聊到这里Keychain 的核心用法基本覆盖了。最后分享几个我在实际项目中总结的延伸经验算是给不同阶段的读者一点参考。对于刚接触的朋友我的建议是先用起来再深究。把上面那个KeychainHelper抄进项目跑通增删改查建立直觉。等你遇到 AccessGroup、访问控制这些需求时再回头研究参数含义会顺畅很多。一上来就啃SecAccessControl的文档很容易劝退。对于有一定经验的开发者可以关注iCloud Keychain 同步。通过设置kSecAttrSynchronizable为 true数据可以在用户的多个设备间同步。这对做多端产品的团队很有价值用户换设备不用重新登录。但要注意同步的数据不能包含设备绑定的信息否则换设备就失效了。还有一个容易被忽视的点Keychain 的性能。单次读写很快但如果你在列表滚动时频繁读取或者一次性查询大量条目还是会有开销。我的做法是启动时把需要的凭据读进内存缓存后续从内存取只在写入时同步到 Keychain。这样兼顾了性能和安全。另外随着 iOS 版本迭代Keychain 的行为也在微调。比如某些版本对 AccessGroup 的校验更严格某些版本对生物识别的超时时间有变化。建议在升级 iOS 大版本后回归测试一下 Keychain 相关功能别等线上出问题才发现。我个人在实际操作中的体会是Keychain 这套接口虽然长得丑但它是 iOS 安全体系里最值得信赖的一环。把它用对了账号安全、多端共享、生物识别保护这些需求都能优雅解决。用错了轻则数据丢失重则安全漏洞。希望这篇内容能帮你少走点弯路把这块硬骨头啃下来。