iOS Keychain 实战:安全存储、访问控制与多应用共享
2026/9/23 6:40:39 网站建设 项目流程

1. 从一个真实需求说起:为什么我要认真聊聊 Keychain

做 iOS 开发这些年,被问得最多的问题里,账号密码存哪儿绝对排得上前三。很多刚入行的朋友第一反应是UserDefaults,稍微懂点的会说存沙盒文件里,再讲究一点的会提到加密后写进数据库。这些方案我都用过,也都踩过坑,最后绕来绕去,还是回到了系统自带的Keychain上。

Keychain 是 iOS 系统提供的一套安全存储机制,专门用来保存密码、密钥、证书、令牌这类敏感信息。它不是一个简单的键值对容器,而是一个由系统统一管理、带访问控制、支持加密的凭据数据库。你调用SecItemAddSecItemCopyMatchingSecItemUpdateSecItemDelete这几个 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 直接塞进UserDefaultsUserDefaults存的是一个 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,系统用kSecAttrServicekSecAttrAccount这两个属性来定位一条记录。你可以把 Service 理解成“哪个业务”,Account 理解成“哪个用户”。比如 Service 填com.myapp.login,Account 填用户 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 就行,除非你需要拿到系统分配的持久化引用。

构造属性字典时,必填的几项是:kSecClasskSecAttrServicekSecAttrAccountkSecValueDatakSecValueDataNSData类型,所以字符串要先转成 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的类型取决于你查询时设的返回选项。如果你同时设了kSecReturnDatakSecReturnAttributes为 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 时未先删或未 Update
errSecItemNotFound-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 验证”。实现方式是给条目加上SecAccessControl

func 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 或先 Delete
Copy 返回 -25300条件不匹配检查 Service/Account/AccessGroup 是否一致
真机返回 -34018entitlement 缺失检查 Keychain Sharing 配置和签名
锁屏读取失败Accessible 属性太严改用 AfterFirstUnlock
返回 nil 但状态是成功类型转换错误确认返回是 Data 还是 Dictionary
生物识别读取无响应非主线程调用切到主线程再读
多设备数据不同步Keychain 默认不跨设备需要 iCloud Keychain 同步则用 kSecAttrSynchronizable

5.3 几个提升健壮性的实操心得

第一,永远检查 OSStatus。我见过太多代码调完SecItemAdd就不管返回值了,出了问题完全不知道哪一步错。每个调用都判断状态,失败时打日志,能省下大量排查时间。

第二,Service 命名要规范。用反向域名加业务模块,比如com.company.app.logincom.company.app.payment。别用logintoken这种太泛的名字,多个模块容易撞车。

第三,敏感数据加一层业务加密。虽然 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 安全体系里最值得信赖的一环。把它用对了,账号安全、多端共享、生物识别保护这些需求都能优雅解决。用错了,轻则数据丢失,重则安全漏洞。希望这篇内容能帮你少走点弯路,把这块硬骨头啃下来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询