1. 项目概述:为什么我们需要Valet?
在iOS和macOS开发中,数据安全存储一直是个既基础又棘手的问题。你可能遇到过这样的场景:用户登录后,你需要安全地保存他的访问令牌(Access Token);或者你的应用需要记住一个高敏感度的加密密钥。直接存到UserDefaults或者一个明文文件里?这无异于把家门钥匙放在门口的脚垫下面。这时候,苹果提供的Keychain服务似乎是理所当然的选择——它被设计用来安全地存储密码、密钥等敏感信息。
但当你真正开始使用Keychain的API(Security.framework)时,很快就会发现,它的原始接口堪称“开发者体验的灾难”。繁琐的CFDictionary配置、令人困惑的错误码(errSecItemNotFoundvserrSecParam)、以及线程安全等细节,足以让一个简单的存储操作代码膨胀到几十行,并且极易出错。更别提在不同设备(iOS, macOS)甚至不同共享策略(同一App组、同一开发者账号下的不同App)间保持一致行为所带来的额外复杂度。
这就是Valet诞生的背景。它不是苹果官方的框架,而是由Square公司(现Block)开源的一个Swift库。你可以把它理解为Keychain的一个“现代化、人性化的外壳”。它没有重新发明轮子,底层依然坚实地依赖着苹果的Keychain服务,这意味着它继承了Keychain硬件级的安全特性(如Secure Enclave)。同时,它用一套简洁、直观、Swifty的API,将开发者从Keychain的复杂性中彻底解放出来。当你看到项目标题“iOS/macOS Keychain安全存储的终极解决方案”时,“终极”二字或许有些绝对,但就封装程度、易用性和社区认可度而言,Valet确实配得上这个称号。它让安全存储变得像使用UserDefaults一样简单,却丝毫不牺牲安全性。
2. Valet的核心设计哲学与架构拆解
Valet的成功并非偶然,其设计紧密围绕两个核心目标:极致的开发者体验和无妥协的安全性。理解它的架构,能帮助我们在正确场景下选择正确的工具。
2.1 抽象层:统一的接口,差异化的实现
Valet最巧妙的设计在于,它定义了一个名为Valet的协议(Protocol),以及一个核心类Valet(遵循该协议)。这个协议声明了所有Valet类型都必须实现的基本操作:canAccessKeychain()、setObject(_:forKey:)、object(forKey:)、removeObject(forKey:)等。对于开发者来说,无论底层是使用iCloud同步的Keychain,还是仅限本设备的Keychain,亦或是硬件级的Secure Enclave,调用的API都是一样的。
在这个统一协议之下,Valet提供了多个具体实现类,每个类对应一种特定的Keychain访问配置和功能:
- Valet: 最通用的实现,用于在单个App内存储数据。你可以指定标识符(identifier)和访问级别(Accessibility)。
- SharedAccessGroupValet: 用于在同一个开发者账号下的多个App之间共享Keychain数据。你需要配置一个共享的访问组标识符(Access Group)。
- SecureEnclaveValet: 这是安全性的巅峰。它将数据存储在设备的Secure Enclave安全芯片中。存储在这里的密钥永远无法被完整导出,所有加解密操作都在Secure Enclave内部完成。它通常用于存储应用自身的根加密密钥。
- SinglePromptSecureEnclaveValet:
SecureEnclaveValet的增强版,也是网络资料中提到的“实现iOS生物认证的终极方案”的核心。它最大的特点是“单次提示”(Single Prompt)。对于需要生物认证(Touch ID/Face ID)才能访问的数据,通常每次读取都需要用户验证一次。而这个类允许你在一次生物认证通过后,在短时间内(可配置)多次访问数据而无需重复验证,极大提升了需要频繁访问安全数据场景下的用户体验。
这种设计意味着,作为开发者,你只需要根据业务需求(数据是否共享、是否需要最高等级硬件安全、是否需要优化生物认证体验)来实例化对应的Valet子类,之后所有的存储、读取、删除操作都使用同一套接口,学习成本和维护成本极低。
2.2 安全性设计:不仅仅是封装
Valet在安全性上并非简单的“传话筒”。它在封装Keychain的同时,也加入了自己的安全实践:
- 强制使用正确的访问级别(Accessibility):Keychain的Accessibility决定了数据何时可被访问(例如,设备解锁后、首次解锁后、始终)。Valet的API强制你在初始化时就必须选择一个明确的级别(如
.whenUnlocked),避免了开发者因疏忽而使用不安全默认值的风险。 - 对象序列化安全:Valet的
setObject(_:forKey:)方法接受的是Data类型。这迫使开发者必须显式地将要存储的对象(如String)转换为Data。Valet推荐使用安全的序列化方法(如data(using: .utf8)),并隐式地引导开发者避免不安全的序列化方式。 - 优雅的错误处理:Keychain的原生错误是
OSStatus(一个Int32)。Valet将其转换为强类型的SwiftError枚举(如KeychainError.itemNotFound),使得错误处理更加清晰和安全。
3. 从入门到精通:Valet的完整实操指南
理论说再多,不如一行代码。让我们从安装开始,一步步掌握Valet的所有核心操作。
3.1 环境准备与安装
Valet支持Swift Package Manager (SPM)、CocoaPods和Carthage。目前最主流和推荐的方式是SPM。
在Xcode项目中集成:
- 打开你的Xcode项目,导航到
File -> Add Packages...。 - 在搜索框中输入仓库URL:
https://github.com/square/Valet。 - 将“Dependency Rule”设置为“Up to Next Major Version”,例如
4.0.0。 - 添加到你的应用Target中。
- 打开你的Xcode项目,导航到
导入模块:在任何需要使用Valet的Swift文件中,添加
import Valet。
3.2 基础操作:存储、读取与删除
假设我们要存储用户的会话令牌。
import Valet // 1. 创建Valet实例 // 使用App的唯一标识符和一个访问级别 let myValet = Valet.valet(with: Identifier(nonEmpty: "com.yourapp.userdata")!, accessibility: .whenUnlocked) // 2. 存储一个字符串令牌 let authToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." let tokenData = authToken.data(using: .utf8)! do { try myValet.setObject(tokenData, forKey: "userAuthToken") print("令牌存储成功!") } catch { print("存储失败:\(error)") } // 3. 读取令牌 do { let retrievedData = try myValet.object(forKey: "userAuthToken") let retrievedToken = String(data: retrievedData, encoding: .utf8) print("读取到的令牌:\(retrievedToken ?? "nil")") } catch KeychainError.itemNotFound { print("未找到该令牌。") } catch { print("读取失败:\(error)") } // 4. 删除令牌 do { try myValet.removeObject(forKey: "userAuthToken") print("令牌已删除。") } catch { print("删除失败:\(error)") }注意:
Identifier必须是非空字符串。通常使用你的App Bundle ID的反向域名格式,可以加上后缀以区分不同的Valet用途(如com.yourapp.userdata,com.yourapp.settings)。
3.3 高级场景实战
3.3.1 场景一:在App扩展与主App间共享数据
你需要使用SharedAccessGroupValet,并确保主App和扩展的Target都配置了相同的Keychain访问组(Keychain Sharing)。
配置Xcode:
- 在主App和Extension的Target中,打开
Signing & Capabilities。 - 点击
+ Capability,添加Keychain Sharing。 - 在两个Target中,添加完全相同的Keychain Groups,例如
group.com.yourapp.shared。
- 在主App和Extension的Target中,打开
代码实现:
// 在主App和扩展中使用相同的代码 let sharedValet = SharedAccessGroupValet.sharedAccessGroupValet( with: Identifier(nonEmpty: "sharedStorage")!, accessGroup: "group.com.yourapp.shared", // 必须与Capability中配置的一致 accessibility: .whenUnlocked ) // 之后使用 sharedValet 进行 setObject/object 操作,数据即可互通。3.3.2 场景二:使用Secure Enclave存储顶级密钥
这是存储应用“命门”密钥的正确方式,例如用于加密本地数据库的密钥。
// 创建SecureEnclaveValet实例 // 注意:`.whenPasscodeSetThisDeviceOnly`是Secure Enclave最常用的访问策略, // 它要求设备已设置密码,且数据仅限本设备,不与iCloud同步。 let secureValet = SecureEnclaveValet.valet( with: Identifier(nonEmpty: "com.yourapp.masterKey")!, accessControl: .whenPasscodeSetThisDeviceOnly ) // 生成一个随机的加密密钥(256位,32字节) var masterKey = [UInt8](repeating: 0, count: 32) let status = SecRandomCopyBytes(kSecRandomDefault, masterKey.count, &masterKey) guard status == errSecSuccess else { fatalError("无法生成安全随机数") } let masterKeyData = Data(masterKey) // 存储到Secure Enclave do { try secureValet.setObject(masterKeyData, forKey: "databaseEncryptionKey") } catch { // 处理错误,例如提示用户设备不支持Secure Enclave(旧设备) print("无法存储到Secure Enclave: \(error)") } // 后续使用时,从Secure Enclave读取 do { let retrievedKeyData = try secureValet.object(forKey: "databaseEncryptionKey") // 使用 retrievedKeyData 进行解密操作... } catch KeychainError.itemNotFound { // 首次运行,需要生成并存储新密钥 } catch { // 其他错误,如用户未设置密码、生物认证失败等 }重要提示:存储在Secure Enclave中的是密钥,而不是大量数据。Secure Enclave空间有限,且操作相对较慢。最佳实践是:用Secure Enclave保护一个对称密钥(如AES密钥),再用这个密钥去加密你的大量用户数据。
3.3.3 场景三:优化生物认证体验(SinglePrompt)
当你的应用需要频繁访问一个受生物认证保护的数据时(例如,一个金融类App每次交易都需要签名密钥),反复弹出Touch ID/Face ID对话框会严重影响体验。SinglePromptSecureEnclaveValet解决了这个问题。
// 初始化SinglePromptSecureEnclaveValet // 需要指定一个“提示”(prompt),这个文字会显示在生物认证的弹窗上。 let singlePromptValet = SinglePromptSecureEnclaveValet.valet( with: Identifier(nonEmpty: "com.yourapp.paymentKey")!, accessControl: .userPresence, // 需要用户存在(密码或生物识别) prompt: "授权支付" // 显示给用户的提示文字 ) // 存储一个支付签名密钥 let signatureKeyData = generateSignatureKey() try? singlePromptValet.setObject(signatureKeyData, forKey: "paymentSignatureKey") // --- 关键部分:在需要使用的场景 --- // 例如,在用户点击“确认支付”时 func authorizeAndPay() { // 这个方法会触发生物认证弹窗。 // 用户首次成功认证后,在接下来的30秒内(默认),再次调用`object(forKey:)`将不再弹窗。 singlePromptValet.object(forKey: "paymentSignatureKey") { result in DispatchQueue.main.async { switch result { case .success(let keyData): // 认证成功(可能是本次,也可能是30秒内的缓存),使用密钥进行支付签名 self.processPayment(with: keyData) case .failure(let error): // 认证失败(用户取消、生物识别不匹配等) self.showAlert(message: "支付授权失败:\(error.localizedDescription)") } } } }它的工作原理是:在首次生物认证成功后,Valet会在内存中缓存一个用于解密Secure Enclave中数据的“令牌”,并在短时间内(可配置)使其有效。这样,在缓存有效期内,后续的数据访问就绕过了重复的生物认证步骤。
4. 深入原理:Valet如何与Keychain交互
理解Valet背后的原理,能帮助我们在调试和遇到边界情况时更有把握。本质上,Valet的每个setObject和object调用,最终都被翻译成了对Security.framework中SecItemAdd、SecItemCopyMatching、SecItemUpdate和SecItemDelete函数的调用。
当你调用myValet.setObject(someData, forKey: “myKey”)时,Valet内部大致做了以下事情:
构建查询字典(CFDictionary):这是Keychain API的核心。Valet会根据初始化参数,构建一个包含以下关键信息的字典:
kSecClass: 设置为kSecClassGenericPassword(表示存储的是通用密码)。kSecAttrService: 通常使用你提供的Identifier,用于区分不同服务。kSecAttrAccount: 就是你传入的forKey参数(“myKey”),作为这条记录的唯一标识。kSecAttrAccessible: 对应初始化时的accessibility(如kSecAttrAccessibleWhenUnlocked)。kSecAttrAccessGroup: 如果使用的是SharedAccessGroupValet,这里会填入共享组ID。kSecValueData: 要存储的二进制数据(someData)。- 对于
SecureEnclaveValet,还会设置kSecAttrTokenID为kSecAttrTokenIDSecureEnclave,并配置更复杂的kSecAttrAccessControl。
执行Keychain操作:Valet会先尝试用这个字典去
SecItemCopyMatching,看是否已存在该键值。如果存在,则调用SecItemUpdate进行更新;如果不存在,则调用SecItemAdd进行添加。所有的错误OSStatus都会被捕获并转换为Valet自定义的Error类型抛出。线程安全:Valet内部使用锁(如
NSLock)来确保对Keychain的访问是线程安全的。这意味着你可以从任何线程调用Valet的方法,而不必担心竞争条件。
5. 实战避坑指南与性能优化
即使有了Valet这样优秀的封装,在实际项目中依然会遇到一些坑。以下是我从多个项目中总结出的经验。
5.1 常见问题与排查
错误
KeychainError.itemNotFound:- 原因:最常见的原因是你用来读取的Valet实例,与当初存储时使用的实例配置不一致。检查以下几点:
Identifier字符串是否完全一致(大小写敏感)。accessibility或accessControl是否相同。- 如果是共享数据,
accessGroup是否配置正确且在Xcode中已启用。 - 你是在模拟器还是真机?模拟器的Keychain在每次App卸载或系统重启后可能会被清空,真机行为更稳定。
- 排查:在存储和读取的地方打印Valet实例的描述信息,确保它们完全相同。
- 原因:最常见的原因是你用来读取的Valet实例,与当初存储时使用的实例配置不一致。检查以下几点:
模拟器上正常,真机上报错:
- 特别注意:
SecureEnclaveValet在iOS模拟器上不可用。模拟器没有Secure Enclave硬件。Valet在模拟器上会使用一个降级方案(通常是在普通Keychain中模拟),但这可能与真机行为有差异。所有涉及Secure Enclave的功能,必须在真机上进行测试。
- 特别注意:
生物认证(Touch ID/Face ID)相关错误:
LAError.userCancel: 用户手动取消了认证。应友好提示,允许用户重试。LAError.passcodeNotSet: 设备未设置密码。对于要求.whenPasscodeSet的策略,这是必然错误。你的App需要有一个降级处理流程,例如提示用户去设置密码,或者使用一个安全性较低的存储方案。LAError.biometryNotAvailable: 设备不支持生物识别。做好兼容性判断,在初始化SinglePromptSecureEnclaveValet前,先用LAContext的canEvaluatePolicy方法检测。
数据迁移与版本升级:
- 问题:如果你在App新版本中改变了Valet的
Identifier或accessGroup,旧版本存储的数据将无法被新版本读取,导致用户数据“丢失”。 - 方案:在App启动时,编写数据迁移逻辑。先用旧配置的Valet实例尝试读取,如果成功,再用新配置的Valet实例存储一份,最后删除旧数据。
func migrateKeychainDataIfNeeded() { let oldValet = Valet.valet(with: Identifier(nonEmpty: “old_identifier”)!, ...) let newValet = Valet.valet(with: Identifier(nonEmpty: “new_identifier”)!, ...) let allKeys = oldValet.allKeys() for key in allKeys { if let oldData = try? oldValet.object(forKey: key) { try? newValet.setObject(oldData, forKey: key) try? oldValet.removeObject(forKey: key) // 迁移后清理旧数据 } } }- 问题:如果你在App新版本中改变了Valet的
5.2 性能考量与最佳实践
- 批量操作:Keychain不是为高频、大批量数据操作设计的。避免在短时间内进行成千上万次的
setObject或removeObject调用。如果需要存储大量结构化数据,应考虑使用SQLite或CoreData进行加密存储,而只将解密密钥放在Keychain中。 - 主线程警告:虽然Valet内部是线程安全的,但涉及生物认证的
object(forKey:)调用(尤其是带闭包的回调版本)可能会阻塞。务必确保不在主线程上同步调用可能触发生物认证的读取操作,否则会导致界面卡顿甚至被系统看门狗终止。使用异步回调或将其放在后台线程。 - 数据类型:始终存储
Data类型。对于String、Int、Bool等,提供便捷的扩展方法固然方便,但理解底层是Data能让你更清楚自己在做什么。Valet官方提供了Valet扩展来支持String,但其本质仍是转换。 - 清理工作:当用户退出登录时,记得清理对应的Keychain数据。但要注意区分:哪些是用户级别的数据(如令牌),哪些是设备级别的设置(如生物认证偏好)。只清理该清理的。
6. 超越基础:Valet在现代化架构中的应用
在现代SwiftUI + Swift Concurrency的架构中,Valet可以很好地融入。
6.1 与SwiftUI状态绑定
你可以创建一个@Observable类或使用@AppStorage的替代方案,将Keychain数据与SwiftUI视图绑定。但要注意,Keychain访问是异步且可能失败的。
import SwiftUI import Valet @MainActor class AuthViewModel: ObservableObject { @Published var authToken: String? @Published var isLoading = false @Published var error: Error? private let valet: Valet init() { self.valet = Valet.valet(with: Identifier(nonEmpty: “com.yourapp.auth”)!, accessibility: .whenUnlocked) loadToken() } func loadToken() { isLoading = true Task { do { let tokenData = try valet.object(forKey: “authToken”) await MainActor.run { self.authToken = String(data: tokenData, encoding: .utf8) self.isLoading = false } } catch { await MainActor.run { self.error = error self.isLoading = false // itemNotFound 是正常情况,表示用户未登录 if !(error is KeychainError) { self.authToken = nil } } } } } func saveToken(_ token: String) async throws { let tokenData = token.data(using: .utf8)! try valet.setObject(tokenData, forKey: “authToken”) await MainActor.run { self.authToken = token } } func logout() { try? valet.removeObject(forKey: “authToken”) authToken = nil } }6.2 与Swift Concurrency结合
Valet的API目前主要是同步和基于闭包回调的。在异步上下文中使用同步API可能会阻塞线程。一个常见的模式是将其封装到actor中,或者使用withCheckedThrowingContinuation将其转换为async/await接口。
actor SecureStorageActor { private let valet: SecureEnclaveValet init() { self.valet = SecureEnclaveValet.valet( with: Identifier(nonEmpty: “com.yourapp.secure”)!, accessControl: .whenPasscodeSetThisDeviceOnly ) } func saveSecret(_ data: Data, forKey key: String) throws { try valet.setObject(data, forKey: key) } func loadSecret(forKey key: String) throws -> Data { return try valet.object(forKey: key) } } // 在异步上下文中使用 Task { let storage = SecureStorageActor() do { let secretKey = try await storage.loadSecret(forKey: “masterKey”) // 使用密钥... } catch { // 处理错误 } }对于SinglePromptSecureEnclaveValet的异步回调API,可以很方便地封装成AsyncThrowingStream或直接使用闭包。
7. 横向对比:Valet与其他方案的抉择
在iOS/macOS生态中,安全存储并非只有Valet一个选择。了解其他方案有助于做出最适合的决策。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 原生 Keychain API | 无任何依赖,功能最全面、最底层。 | API极其晦涩难用,错误处理复杂,易出错。 | 对Keychain有极其特殊、Valet无法满足的定制需求。 |
| Valet | API优雅直观,覆盖绝大多数场景,社区活跃,文档完善,由大厂维护。 | 作为第三方库,增加依赖。对Secure Enclave和生物认证的封装极好,但极端定制化能力不如原生API。 | 绝大多数应用的首选。需要安全存储密码、令牌、密钥,尤其是涉及生物认证和Secure Enclave时。 |
| UserDefaults | 极其简单,API友好。 | 完全不安全,数据以明文形式存储在plist文件中,可被轻易读取。 | 存储非敏感的用户偏好设置,如界面主题、排序方式等。 |
| 文件系统加密 | 自行使用CommonCrypto等库加密后存储到文件。 | 实现复杂,容易出错(如密钥管理不当、模式选择错误)。需要自己处理加密、解密、密钥存储(而密钥存储又回到Keychain问题)。 | 存储大量的敏感结构化数据(如加密的本地数据库),且对性能有较高要求。通常结合Valet(存储加密主密钥)使用。 |
| 其他第三方库 | 如KeychainAccess等,也提供了对Keychain的封装。 | 功能与Valet类似,但API设计、活跃度和社区支持可能有所不同。Valet在Swifty和安全性抽象上通常被认为更胜一筹。 | 如果你已经深度使用并满意其他库,可以继续使用。对于新项目,Valet是更主流的选择。 |
结论:对于90%以上的iOS/macOS应用,需要安全存储敏感数据时,Valet都是最优解。它在易用性和安全性之间取得了最佳平衡。
8. 安全审计与上线前检查清单
在将使用Valet的App提交到App Store前,进行一次安全检查是必要的。
- [ ]密钥管理:是否所有密钥/令牌都通过Valet存储?是否还有敏感信息残留在
UserDefaults、plist或代码字符串中?(可使用strings命令扫描二进制文件)。 - [ ]访问级别审查:每个Valet实例使用的
accessibility是否恰当?例如,需要后台刷新的令牌是否用了.whenUnlocked导致刷新失败?高度敏感的密钥是否用了过于宽松的级别? - [ ]Secure Enclave使用:是否将最顶层的加密密钥存入了
SecureEnclaveValet?对于不支持Secure Enclave的设备(如旧款iPhone),是否有降级方案和友好提示? - [ ]生物认证流程:使用
SinglePromptSecureEnclaveValet时,提示语(prompt)是否清晰、友好?是否处理了所有可能的LAError(用户取消、密码未设置、生物识别不可用等)? - [ ]数据共享配置:如果使用了
SharedAccessGroupValet,是否在所有Target(主App、扩展、Widget)的Signing & Capabilities中正确配置了相同的Keychain Group?是否在代码中使用了完全相同的字符串? - [ ]数据清理:用户退出登录或删除账户时,相关的Keychain数据是否被正确清理?是否误删了设备级别的设置?
- [ ]模拟器与真机测试:是否在真机上全面测试了所有Keychain相关功能?特别是生物认证和Secure Enclave功能。
- [ ]错误日志:Keychain操作失败时,是否有适当的错误日志记录(注意不要将敏感数据记录到日志中)?是否对用户展示了友好的错误信息而非原生错误码?
Valet不仅仅是一个工具库,它代表了一种对安全存储的正确态度:安全不应该以牺牲开发效率为代价。通过将复杂、易错的Keychain API封装成简洁、可靠的Swift接口,它让开发者能够更轻松地做出正确的安全选择。从今天开始,告别那些脆弱且不安全的存储方式,让Valet为你的应用数据保驾护航。在项目初期就引入它,你会发现,为安全而编码,也可以是一件很愉快的事。