1. 项目概述为什么我们需要Valet在iOS和macOS开发中数据安全存储一直是个既基础又棘手的问题。你可能遇到过这样的场景用户登录后你需要安全地保存他的访问令牌Access Token或者你的应用需要记住一个高敏感度的加密密钥。直接存到UserDefaults或者一个明文文件里这无异于把家门钥匙放在门口的脚垫下面。这时候苹果提供的Keychain服务似乎是理所当然的选择——它被设计用来安全地存储密码、密钥等敏感信息。但当你真正开始使用Keychain的APISecurity.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的同时也加入了自己的安全实践强制使用正确的访问级别AccessibilityKeychain的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...。在搜索框中输入仓库URLhttps://github.com/square/Valet。将“Dependency Rule”设置为“Up to Next Major Version”例如4.0.0。添加到你的应用Target中。导入模块在任何需要使用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和扩展中使用相同的代码 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实例的描述信息确保它们完全相同。模拟器上正常真机上报错特别注意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) // 迁移后清理旧数据 } } }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无法满足的定制需求。ValetAPI优雅直观覆盖绝大多数场景社区活跃文档完善由大厂维护。作为第三方库增加依赖。对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为你的应用数据保驾护航。在项目初期就引入它你会发现为安全而编码也可以是一件很愉快的事。