小组件共享数据
我会分步骤说明必要的 Xcode 配置(必须完成),再给出主 App 写入 Keychain、Widget 读取并从 p12 构建 SecIdentity 的完整 Swift 实现,以及如何在 Widget 中基于该身份做客户端证书的网络请求示例(注意 Widget 的执行限制)。(可选但推荐)同时启用 App Groups(如果你还想在共享目录中放置文件/日志/缓存),并使用同一组为主 App
下面给出一个可直接拿来使用的完整方案与代码示例,满足你要求的目标:主 App 将双向认证的 .p12(二进制 data)与密码 pwd 安全地共享给 Widget(小组件),并保证数据在传输与存储阶段的安全性与合规性。
我会分步骤说明必要的 Xcode 配置(必须完成),再给出主 App 写入 Keychain、Widget 读取并从 p12 构建 SecIdentity 的完整 Swift 实现,以及如何在 Widget 中基于该身份做客户端证书的网络请求示例(注意 Widget 的执行限制)。请按说明替换占位符(例如 team id / access group 名称)。
1) 必要的 Xcode 配置(
必须
)
在主 App target 和 Widget target 中都打开 Signing & Capabilities。
添加 Keychain Sharing(Keychain Access Groups),并添加相同的 access group。access group 形式通常是 $(AppIdentifierPrefix)com.yourcompany.shared($(AppIdentifierPrefix) 在 Xcode 中等于 Team ID + .)。最终形如:<TEAMID>.com.yourcompany.shared。
示例(占位):9A1B2C3D4E.com.example.sharedkeychain —— 请用你自己的 Team ID 与标识替换。
(可选但推荐)同时启用 App Groups(如果你还想在共享目录中放置文件/日志/缓存),并使用同一组为主 App 与 Widget 添加 group,例如 group.com.example.app。这不是必须用于证书,但常见的结合方式。
确保 App 和 Widget 的 Bundle ID 与 Apple Developer 帐号中的配置一致,并且已经为 Keychain Sharing 能力做了相应的 Apple 开发者侧配置。
重要:Keychain Access Group 必须在 Apple Developer Portal 中注册(在 Xcode 启用 capability 时通常会自动处理),且 widget 与 app 必须使用相同的 access group 才能共享 keychain 项目。
2) 安全策略(设计要点)
不要把证书密码或 p12 明文存到 UserDefaults 或共享文件夹(除非对数据做强加密)。使用 Keychain + Access Group 将二进制数据/密码安全存储并在主 App 与 Widget 之间共享。
Keychain 条目使用 kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly:保证设备首次解锁后可被扩展读取,同时标记为 ThisDeviceOnly,避免通过 iCloud Keychain 在设备间同步(通常证书不需要跨设备同步)。
绝对避免日志打印明文密码或证书内容。
当不再需要时,删除 Keychain 项目以降低风险(示例包含删除函数)。
3) 代码实现(Swift)
下面是一套完整代码,包含:
SharedKeychain.swift:Keychain 管理器(add/update/delete/get Data / String)
主 App 示例:如何写入 p12 data 与密码
Widget 示例:如何读取 p12 & 密码,导入 SecIdentity(SecPKCS12Import),并做基于客户端证书的 URLSession 请求(示例 URLSessionDelegate 提供 credential)
将 <YOUR_KEYCHAIN_ACCESS_GROUP> 替换为你在 Xcode/AppleDev 上配置的 access group(形如 <TEAMID>.com.example.sharedkeychain)。
SharedKeychain.swift(通用,主App 与 Widget 都可用)
import Foundation
import Security
enum KeychainError: Error {
case unexpectedStatus(OSStatus)
case itemNotFound
case invalidData
}
final class SharedKeychain {
/// 替换为你的 access group,例如 "9A1B2C3D4E.com.example.sharedkeychain"
private static let accessGroup = "<YOUR_KEYCHAIN_ACCESS_GROUP>"
/// 保存 Data(add or update)
@discardableResult
static func set(data: Data, service: String, account: String) throws -> Bool {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
query[kSecAttrAccessGroup as String] = accessGroup
// Try update first
let attributesToUpdate = [kSecValueData as String: data]
let statusUpdate = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary)
if statusUpdate == errSecSuccess {
return true
} else if statusUpdate == errSecItemNotFound {
// Add
let statusAdd = SecItemAdd(query as CFDictionary, nil)
guard statusAdd == errSecSuccess else {
throw KeychainError.unexpectedStatus(statusAdd)
}
return true
} else {
throw KeychainError.unexpectedStatus(statusUpdate)
}
}
/// 读取 Data
static func getData(service: String, account: String) throws -> Data {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
query[kSecAttrAccessGroup as String] = accessGroup
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecSuccess {
guard let data = item as? Data else { throw KeychainError.invalidData }
return data
} else if status == errSecItemNotFound {
throw KeychainError.itemNotFound
} else {
throw KeychainError.unexpectedStatus(status)
}
}
/// 删除项
@discardableResult
static func delete(service: String, account: String) throws -> Bool {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
query[kSecAttrAccessGroup as String] = accessGroup
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess || status == errSecItemNotFound {
return true
} else {
throw KeychainError.unexpectedStatus(status)
}
}
// Helper for String
static func set(string: String, service: String, account: String) throws {
guard let d = string.data(using: .utf8) else { throw KeychainError.invalidData }
try set(data: d, service: service, account: account)
}
static func getString(service: String, account: String) throws -> String {
let d = try getData(service: service, account: account)
guard let s = String(data: d, encoding: .utf8) else { throw KeychainError.invalidData }
return s
}
}
主 App:保存 .p12 与密码到 Keychain(示例)
import Foundation
// 用于 Keychain 的 service/account 常量
fileprivate enum SharedKeys {
static let p12Service = "com.example.shared.ssl"
static let p12Account = "client_p12" // 标识 p12 二进制数据
static let pwdService = "com.example.shared.ssl"
static let pwdAccount = "client_p12_pwd" // 标识 p12 密码
}
final class CertificateRepository {
/// 保存 p12 Data 与密码到 Keychain
static func storeP12(p12Data: Data, password: String) throws {
// 写入 p12 二进制
try SharedKeychain.set(data: p12Data, service: SharedKeys.p12Service, account: SharedKeys.p12Account)
// 写入密码(字符串)
try SharedKeychain.set(string: password, service: SharedKeys.pwdService, account: SharedKeys.pwdAccount)
}
/// 删除
static func removeP12() throws {
try SharedKeychain.delete(service: SharedKeys.p12Service, account: SharedKeys.p12Account)
try SharedKeychain.delete(service: SharedKeys.pwdService, account: SharedKeys.pwdAccount)
}
}
主 App 使用示例(例如从文件读取并存入):
// 假设你已经通过文件选择或者其它方式拿到了 p12Data 与 password
func saveClientCertToSharedKeychain(p12Data: Data, password: String) {
do {
try CertificateRepository.storeP12(p12Data: p12Data, password: password)
// 通知 Widget 更新 timeline(可选)
import WidgetKit
WidgetCenter.shared.reloadAllTimelines()
print("saved to shared keychain")
} catch {
print("save failed: \(error)")
}
}
Widget(或任何 Extension)读取证书并导入为 SecIdentity,然后建立带客户端证书的 URLSession
import Foundation
import Security
import WidgetKit
fileprivate enum SharedKeys {
static let p12Service = "com.example.shared.ssl"
static let p12Account = "client_p12"
static let pwdService = "com.example.shared.ssl"
static let pwdAccount = "client_p12_pwd"
}
final class CertificateLoader {
/// 从 Keychain 读取 p12 data 和密码,并导入 SecIdentity
/// 返回 (identity, certificateArray) 其中 identity 用于 URLCredential(useWith:), certs 可作为 server trust chain 的一部分
static func loadIdentityFromSharedKeychain() throws -> (SecIdentity, [SecCertificate]?) {
// 读取 data 与 pwd
let p12Data = try SharedKeychain.getData(service: SharedKeys.p12Service, account: SharedKeys.p12Account)
let pwd = try SharedKeychain.getString(service: SharedKeys.pwdService, account: SharedKeys.pwdAccount)
// PKCS12 import
let importPasswordOption = kSecImportExportPassphrase as String
let options = [importPasswordOption: pwd]
var items: CFArray?
let status = SecPKCS12Import(p12Data as CFData, options as CFDictionary, &items)
guard status == errSecSuccess, let itemsArr = items as? [[String: Any]], itemsArr.count > 0 else {
throw KeychainError.unexpectedStatus(status)
}
let first = itemsArr[0]
guard let identity = first[kSecImportItemIdentity as String] as? SecIdentity else {
throw KeychainError.invalidData
}
// optionally get cert chain
var certs: [SecCertificate]?
if let certArray = first[kSecImportItemCertChain as String] as? [SecCertificate] {
certs = certArray
}
return (identity, certs)
}
/// 使用 identity 创建 URLCredential
static func makeCredential(from identity: SecIdentity, certChain: [SecCertificate]?) -> URLCredential {
// URLCredential 中可以包含 identity + certificate chain
let credential = URLCredential(identity: identity, certificates: certChain, persistence: .forSession)
return credential
}
}
URLSessionDelegate 示例(在 Widget 中如果需要发起需要客户端证书的请求)
import Foundation
final class ClientCertURLSessionDelegate: NSObject, URLSessionDelegate {
private let credential: URLCredential
init(credential: URLCredential) {
self.credential = credential
}
// Called when server requests client credential
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
switch challenge.protectionSpace.authenticationMethod {
case NSURLAuthenticationMethodClientCertificate:
completionHandler(.useCredential, credential)
case NSURLAuthenticationMethodServerTrust:
// 默认使用系统验证 server trust
if let serverTrust = challenge.protectionSpace.serverTrust {
let cred = URLCredential(trust: serverTrust)
completionHandler(.useCredential, cred)
} else {
completionHandler(.performDefaultHandling, nil)
}
default:
completionHandler(.performDefaultHandling, nil)
}
}
}
Widget 中发起请求(示例 — 注意 Widget 执行时间限制)
func performRequestThatNeedsClientCert(url: URL, completion: @escaping (Result<Data, Error>) -> Void) {
do {
let (identity, certs) = try CertificateLoader.loadIdentityFromSharedKeychain()
let credential = CertificateLoader.makeCredential(from: identity, certChain: certs)
let delegate = ClientCertURLSessionDelegate(credential: credential)
let config = URLSessionConfiguration.ephemeral
let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
var req = URLRequest(url: url)
req.httpMethod = "GET"
let task = session.dataTask(with: req) { data, response, error in
if let err = error {
completion(.failure(err))
} else {
completion(.success(data ?? Data()))
}
}
task.resume()
} catch {
completion(.failure(error))
}
}
注意(Widget 限制):WidgetKit 的 timeline provider(getSnapshot / getTimeline)允许执行短时异步网络请求,但系统会限制时间和频率。不要在 Widget 中做长时间阻塞的网络任务。若请求经常超时或失败,考虑在主 App 中进行请求并把结果缓存到 App Group 文件,再由 Widget 读取展示。
4) 额外细节与安全建议
Keychain access group 字符串要精确,无前后空格;如使用 Xcode 的 $(AppIdentifierPrefix),确保在运行时产生正确值;生产环境中手动替换为 <TEAMID>.com... 可以避免出错。
不要把密码写在源码里或打包到资源里。密码应在运行时由用户输入或在受控环境生产。
使用 kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly:允许 Widget 在设备初次解锁后访问,但禁止 iCloud 同步(ThisDeviceOnly)。
日志中不要记录证书原文或密码。
实际部署时考虑:证书轮换策略(过期时如何替换)、删除旧证书,以及最小化存留时间(如果 Widget 临时使用)。
5) 常见问题(FAQ)
Q:为什么不用 App Group 文件直接存 p12?
A:可以,但文件如果未经加密仍暴露风险;Keychain 专为秘密存储设计,并且配合 Keychain Access Group 可安全在 App 与 Extension 间共享。若在共享文件中保存 p12,必须再用强对称加密,且密钥存放仍需要 Keychain。
Q:Widget 可以直接访问 Keychain 吗?
A:可以,只要 Keychain Access Group 在 App 和 Widget 的 entitlements 中都列出,并且使用相同的 access group。
Q:需不需要同时启用 App Groups?
A:不必要,但启用 App Groups 对于缓存、状态共享等很有用。关键是 Keychain Access Group 必须配置好。
结尾与注意事项
把上面代码中的 "<YOUR_KEYCHAIN_ACCESS_GROUP>" 替换为你实际的 access group(形如 <TEAMID>.com.example.sharedkeychain)。
在真实生产环境中,务必做过安全评审:限制证书权限、做好轮换、最小化存放时间,以及必要时对 p12 做额外的加密层(Keychain 已提供强保护)。
如果你愿意,我可以:
帮你生成 Entitlements 的示例片段(.entitlements 文件 JSON/XML)供直接拷贝;
或者把上面代码整理成一个小模块(Swift package)给你下载。
需要我把 entitlements 示例和更完整的 Xcode 操作截图/文本步骤发给你吗?
更多推荐
所有评论(0)