下面给出一个可直接拿来使用的完整方案与代码示例,满足你要求的目标:主 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 操作截图/文本步骤发给你吗?

 

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐