3、Keychain安全存储:Keychain基础、增删改查操作、AccessGroup与共享、生物识别保护
说到iOS数据安全,Keychain绝对是个绕不开的话题。我个人习惯把Keychain看作iOS系统里的「保险柜」——它和普通的UserDefaults、文件存储完全是两个世界的东西。你想想看,用户密码、Token、密钥这些敏感数据,如果直接扔到沙盒里,那跟把钥匙放在门口垫子底下有什么区别?
Keychain的好处在于,它是由系统内核直接管理的加密存储区域。就算App被逆向、被砸壳,攻击者也很难直接从Keychain里掏出数据。嗯,这里要注意,我说的是「很难」,不是「不可能」——后面我会讲到一些坑。
3.1 Keychain基础
Keychain本质上是一个加密的数据库。每个条目(Item)都包含一组属性,比如服务名、账号、访问权限等。系统用这些属性来定位和管理数据。
我记得刚接触Keychain时,最让我困惑的是它的查询机制。它不像SQL那样直接写SELECT,而是用一堆字典参数来构造查询。说白了,就是通过CFDictionary来描述「我要找什么」。
一个典型的Keychain条目包含以下关键属性:
| 属性 | 说明 | 示例值 |
|---|---|---|
| kSecClass | 条目类型 | kSecClassGenericPassword |
| kSecAttrService | 服务标识 | com.myapp.login |
| kSecAttrAccount | 账号名称 | user@example.com |
| kSecValueData | 实际存储的数据 | 加密后的密码 |
| kSecAttrAccessible | 访问策略 | kSecAttrAccessibleWhenUnlocked |
核心要点:Keychain存储的数据在设备锁定时是不可读的(除非你特意设置为Always)。系统会在设备解锁后自动解密Keychain内容。这个机制天然防止了冷启动攻击。
3.2 增删改查操作
实际操作中,Keychain的API用起来有点啰嗦。我封装过一个工具类,把增删改查包装成几行代码。下面直接看代码:
3.2.1 添加数据
func saveToKeychain(service: String, account: String, data: Data) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
// 先删除旧数据,避免重复添加
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
这里有个细节:kSecAttrAccessibleWhenUnlockedThisDeviceOnly 表示数据只在设备解锁时可访问,而且不会备份到iCloud。我个人习惯用这个级别,安全性和便利性比较平衡。
3.2.2 查询数据
func loadFromKeychain(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?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
return nil
}
return result as? Data
}
为什么查询时要加 kSecMatchLimitOne?因为Keychain允许同一个服务名下存多条记录。如果不限制,返回的可能是一个数组。我曾经踩过这个坑,查了半天才发现返回的是CFArray而不是Data。
3.2.3 更新和删除
func updateKeychain(service: String, account: String, newData: Data) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let attributes: [String: Any] = [
kSecValueData as String: newData
]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
return status == errSecSuccess
}
func deleteFromKeychain(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
}
小技巧:更新操作其实可以拆成「先删后加」。但用SecItemUpdate更高效,因为它只修改数据字段,不会动其他属性。如果你的访问策略、标签等属性需要一起改,那就用删除+添加的方式。
3.3 AccessGroup与共享
同一个开发者账号下的多个App,可以通过AccessGroup共享Keychain数据。这个功能在「主App + 扩展」或者「主App + Watch App」的场景下特别有用。
配置共享需要两步:
- 在Capabilities里开启Keychain Sharing,并指定相同的Group ID(比如
com.yourcompany.shared) - 在查询字典里加上
kSecAttrAccessGroup属性
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: "com.yourcompany.shared",
kSecReturnData as String: true
]
这里有个容易忽略的点:AccessGroup的格式必须是 TeamID.BundleID 或者 TeamID.*。我见过有人直接写 com.mycompany.shared,结果死活读不到数据。嗯,TeamID是苹果分配的那个10位字符串,在开发者中心能看到。
避坑指南:我曾经在App Extension里尝试读取主App的Keychain数据,发现总是返回nil。排查了半天,原来是Extension的Entitlements里没有加上对应的AccessGroup。记住:每个Target都要单独配置Keychain Sharing。
3.4 生物识别保护
Keychain最强大的功能之一,就是可以和生物识别(Face ID / Touch ID)绑定。数据只有在用户通过生物验证后才能读取。这个机制比单纯存个密码要安全得多——因为生物特征没法被钓鱼。
实现方式很简单,添加一个访问控制策略:
// 创建访问控制对象
var error: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet,
&error
) else {
print("创建访问控制失败: \(error!.takeRetainedValue())")
return
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessControl as String: accessControl,
kSecUseAuthenticationContext as String: LAContext()
]
let status = SecItemAdd(query as CFDictionary, nil)
读取时也需要传入 LAContext:
let context = LAContext()
context.localizedReason = "验证身份以访问您的登录信息"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecUseAuthenticationContext as String: context,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
为什么这里要用 LAContext?因为系统需要知道谁来发起验证、验证失败后的处理逻辑。你可以设置 localizedReason 来告诉用户为什么要验证——这个字符串会显示在Face ID的弹窗上。
关键点:生物识别保护不是「加密数据」,而是「控制访问」。数据本身仍然用Keychain的加密机制保护,但读取时必须通过生物验证。换句话说,就算攻击者拿到了设备的文件系统,没有用户的生物特征,他也读不到这些数据。
我个人建议,对于登录Token、支付密码这类高敏感数据,一定要加上生物识别保护。虽然每次读取都要弹Face ID有点烦,但安全性和用户体验之间总得有个取舍。你想想看,用户宁愿多刷一次脸,也不希望自己的钱被盗刷吧?
最后说一句:Keychain不是万能的。如果设备越狱了,攻击者可以通过hook系统调用来拦截Keychain的读写。但话说回来,在非越狱设备上,Keychain依然是iOS上最安全的本地存储方案。用好它,你的App安全防护就成功了一半。