feat(ios): device client-cert mTLS — ClientTLS package, transport wiring, install UX (C-iOS)

- ios/Packages/ClientTLS: SecIdentity wrapper, PKCS12 importer (typed errors), keychain store
  (AfterFirstUnlockThisDeviceOnly), pure MutualTLSChallengeResponder truth table, cross-platform
  X.509 DER summary. 14/14 tests.
- Both transports (SessionCore URLSessionTermTransport, App URLSessionHTTPTransport) + SessionThumbnail
  take a lazy @Sendable ()->ClientIdentity? provider: WS resolves per-connect, HTTP per client-cert
  challenge, so a freshly-imported cert applies without an app relaunch. AppEnvironment injects
  { store.loadedIdentityOrNil() }.
- ClientCertScreen (.fileImporter([.pkcs12]) + passphrase -> import -> keychain), reachable via a
  设备证书 entry in SessionListScreen.hostMenu. PairingViewModel gates tunnel-host probes on cert
  presence and re-maps mTLS-reject to a clientCertRejected message.
Verified: ClientTLS 14/14, SessionCore 93/93, xcodegen + xcodebuild BUILD SUCCEEDED.
This commit is contained in:
Yaojia Wang
2026-07-07 09:42:12 +02:00
parent 5337281e85
commit e38e6d1689
25 changed files with 1510 additions and 30 deletions

View File

@@ -0,0 +1,188 @@
import Foundation
import Security
/// C-iOS-3 · Display summary of a device certificate, shown on the install /
/// rotation screen so the user can confirm *which* cert is active and *when*
/// it expires before relying on it.
public struct ClientCertificateSummary: Equatable, Sendable {
/// Subject common name the device/leaf CN (e.g. `t1-iphone`).
public let subjectCommonName: String?
/// Issuer common name the device-CA CN (e.g. `webterm-device-ca`).
public let issuerCommonName: String?
/// Not-after date; `nil` if it could not be parsed.
public let notAfter: Date?
public init(subjectCommonName: String?, issuerCommonName: String?, notAfter: Date?) {
self.subjectCommonName = subjectCommonName
self.issuerCommonName = issuerCommonName
self.notAfter = notAfter
}
/// Expired relative to `now` (defaults to the current instant). Unknown
/// expiry is treated as NOT expired (fail-open for display only the TLS
/// stack, not this label, is the real gate).
public func isExpired(asOf now: Date = Date()) -> Bool {
guard let notAfter else { return false }
return notAfter < now
}
}
/// Reads the display fields off a `SecCertificate`.
///
/// Implemented as a minimal X.509 DER walk over `SecCertificateCopyData` rather
/// than `SecCertificateCopyValues`, because that API and its `kSecOID*` /
/// `kSecPropertyKey*` constants are **macOS-only** unavailable on iOS, which
/// is the real deployment target. The DER walk is identical on both platforms
/// and unit-testable against the fixture. Any parse miss degrades to `nil`
/// fields (the summary is display-only; the TLS stack is the gate).
enum CertificateInspector {
static func summary(of certificate: SecCertificate) -> ClientCertificateSummary {
let der = [UInt8](SecCertificateCopyData(certificate) as Data)
let fields = X509Fields.parse(der: der)
return ClientCertificateSummary(
subjectCommonName: fields?.subjectCommonName,
issuerCommonName: fields?.issuerCommonName,
notAfter: fields?.notAfter
)
}
}
// MARK: - Minimal ASN.1 DER reader
/// One tag-length-value element, as byte ranges into the source buffer.
private struct DERElement {
let tag: UInt8
let valueStart: Int
let valueEnd: Int
}
private enum DER {
/// DER tags used here.
static let sequence: UInt8 = 0x30
static let set: UInt8 = 0x31
static let oid: UInt8 = 0x06
static let contextTag0: UInt8 = 0xA0 // [0] EXPLICIT (X.509 version)
static let utcTime: UInt8 = 0x17
static let generalizedTime: UInt8 = 0x18
/// Read a single TLV at `start`. Returns the element and the index just
/// past it, or `nil` on any malformed length/overrun (defensive: the cert
/// bytes come from the system but are still parsed as untrusted input).
static func read(_ bytes: [UInt8], at start: Int) -> DERElement? {
guard start >= 0, start + 1 < bytes.count else { return nil }
let tag = bytes[start]
var index = start + 1
let firstLengthByte = bytes[index]
index += 1
var length = 0
if firstLengthByte & 0x80 == 0 {
length = Int(firstLengthByte)
} else {
let byteCount = Int(firstLengthByte & 0x7F)
guard byteCount > 0, byteCount <= 4, index + byteCount <= bytes.count else {
return nil
}
for _ in 0..<byteCount {
length = (length << 8) | Int(bytes[index])
index += 1
}
}
let valueEnd = index + length
guard length >= 0, valueEnd <= bytes.count else { return nil }
return DERElement(tag: tag, valueStart: index, valueEnd: valueEnd)
}
/// Split a constructed value `[start, end)` into its immediate children.
static func children(_ bytes: [UInt8], from start: Int, to end: Int) -> [DERElement] {
var elements: [DERElement] = []
var index = start
while index < end, let element = read(bytes, at: index) {
elements.append(element)
index = element.valueEnd
}
return elements
}
}
// MARK: - X.509 field extraction
private struct X509Fields {
let subjectCommonName: String?
let issuerCommonName: String?
let notAfter: Date?
/// CommonName attribute OID `2.5.4.3` DER content bytes `55 04 03`.
private static let commonNameOID: [UInt8] = [0x55, 0x04, 0x03]
/// Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signature }
/// tbsCertificate ::= SEQUENCE { [0] version?, serialNumber, signature,
/// issuer Name, validity, subject Name, subjectPublicKeyInfo, ... }
static func parse(der bytes: [UInt8]) -> X509Fields? {
guard let certificate = DER.read(bytes, at: 0), certificate.tag == DER.sequence else {
return nil
}
let certChildren = DER.children(
bytes, from: certificate.valueStart, to: certificate.valueEnd
)
guard let tbs = certChildren.first, tbs.tag == DER.sequence else { return nil }
var tbsChildren = DER.children(bytes, from: tbs.valueStart, to: tbs.valueEnd)
// Optional EXPLICIT [0] version drop it so the fixed fields align.
if let first = tbsChildren.first, first.tag == DER.contextTag0 {
tbsChildren.removeFirst()
}
// Fixed order after (optional) version: serialNumber, signature,
// issuer, validity, subject, ...
guard tbsChildren.count >= 5 else { return nil }
let issuer = tbsChildren[2]
let validity = tbsChildren[3]
let subject = tbsChildren[4]
return X509Fields(
subjectCommonName: commonName(bytes, in: subject),
issuerCommonName: commonName(bytes, in: issuer),
notAfter: notAfterDate(bytes, in: validity)
)
}
/// Name ::= SEQUENCE OF RDN(SET) OF AttributeTypeAndValue(SEQ{OID, value}).
/// Returns the first CN value found.
private static func commonName(_ bytes: [UInt8], in name: DERElement) -> String? {
for rdn in DER.children(bytes, from: name.valueStart, to: name.valueEnd)
where rdn.tag == DER.set {
for atv in DER.children(bytes, from: rdn.valueStart, to: rdn.valueEnd)
where atv.tag == DER.sequence {
let parts = DER.children(bytes, from: atv.valueStart, to: atv.valueEnd)
guard parts.count >= 2, parts[0].tag == DER.oid else { continue }
let oid = Array(bytes[parts[0].valueStart..<parts[0].valueEnd])
if oid == commonNameOID {
let value = Array(bytes[parts[1].valueStart..<parts[1].valueEnd])
// PrintableString / UTF8String both decode as UTF-8.
return String(bytes: value, encoding: .utf8)
}
}
}
return nil
}
/// Validity ::= SEQUENCE { notBefore Time, notAfter Time } the 2nd child.
private static func notAfterDate(_ bytes: [UInt8], in validity: DERElement) -> Date? {
let times = DER.children(bytes, from: validity.valueStart, to: validity.valueEnd)
guard times.count >= 2 else { return nil }
let notAfter = times[1]
let value = Array(bytes[notAfter.valueStart..<notAfter.valueEnd])
guard let text = String(bytes: value, encoding: .ascii) else { return nil }
return parseTime(text, tag: notAfter.tag)
}
private static func parseTime(_ text: String, tag: UInt8) -> Date? {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "UTC")
// UTCTime: YYMMDDHHMMSSZ (used for years < 2050); GeneralizedTime:
// YYYYMMDDHHMMSSZ. The 2-digit-year window covers the relevant range
// (device certs live in the 2020s-2040s).
formatter.dateFormat = tag == DER.utcTime ? "yyMMddHHmmss'Z'" : "yyyyMMddHHmmss'Z'"
return formatter.date(from: text)
}
}

View File

@@ -0,0 +1,55 @@
import Foundation
import Security
/// C-iOS-1 · An imported device client identity: the `SecIdentity` (leaf
/// certificate + its private key) plus the issuer chain that accompanies it in
/// the TLS handshake.
///
/// `@unchecked Sendable`: `SecIdentity` / `SecCertificate` are CoreFoundation
/// handles that are immutable once imported and thread-safe to read; this value
/// only ever holds finished imports and never mutates them. Marking it lets the
/// identity flow into the `@unchecked Sendable` WS connection and the URLSession
/// delegates that answer client-certificate challenges.
public struct ClientIdentity: @unchecked Sendable {
/// The leaf certificate + private key used to authenticate to the server.
public let secIdentity: SecIdentity
/// Issuer certificates to present alongside the leaf (the CA chain, leaf
/// excluded). May be empty when the trust anchor is already pinned server
/// side (nginx `ssl_client_certificate` = the device-CA) the leaf alone
/// then verifies at `ssl_verify_depth 1`.
public let issuerCertificates: [SecCertificate]
public init(secIdentity: SecIdentity, issuerCertificates: [SecCertificate] = []) {
self.secIdentity = secIdentity
self.issuerCertificates = issuerCertificates
}
/// The `URLCredential` handed back to a `ClientCertificate` auth challenge.
///
/// `.forSession` (not `.permanent`) per plan §C-iOS-1: the identity already
/// lives in the app's keychain item persisting the credential in the
/// shared URL credential store would be a second, unmanaged copy.
public func urlCredential(
persistence: URLCredential.Persistence = .forSession
) -> URLCredential {
URLCredential(
identity: secIdentity,
certificates: issuerCertificates.isEmpty ? nil : issuerCertificates,
persistence: persistence
)
}
/// The leaf `SecCertificate` backing this identity (for display / summary).
public func leafCertificate() -> SecCertificate? {
var certificate: SecCertificate?
let status = SecIdentityCopyCertificate(secIdentity, &certificate)
return status == errSecSuccess ? certificate : nil
}
/// Human-readable summary of the leaf certificate (subject CN, issuer CN,
/// expiry) for the install/rotation UI. `nil` only if the leaf can't be
/// read (should never happen for a valid import).
public func summary() -> ClientCertificateSummary? {
leafCertificate().map(CertificateInspector.summary(of:))
}
}

View File

@@ -0,0 +1,36 @@
import Foundation
/// C-iOS-2 · Reusable `NSObject` URLSession delegate that answers
/// **session-level** auth challenges by delegating to `MutualTLSChallengeResponder`.
///
/// This is the seam for the HTTP transport (`session.data(for:)` surfaces
/// challenges through `urlSession(_:didReceive:completionHandler:)`). The WS
/// transport can't reuse it its connection object is already the session's
/// delegate and needs the **task-level** callback so that one implements the
/// task-level method itself against the same responder.
///
/// `@unchecked Sendable`: URLSession retains its delegate and invokes it from
/// arbitrary queues; every stored field is an immutable `let` over thread-safe
/// values (`ClientIdentity` is `@unchecked Sendable`, the responder is stateless).
public final class ClientTLSSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
private let identity: ClientIdentity?
private let responder: MutualTLSChallengeResponder
public init(
identity: ClientIdentity?,
responder: MutualTLSChallengeResponder = MutualTLSChallengeResponder()
) {
self.identity = identity
self.responder = responder
super.init()
}
public func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
let resolution = responder.resolve(challenge, identity: identity)
completionHandler(resolution.disposition, resolution.credential)
}
}

View File

@@ -0,0 +1,200 @@
import Foundation
import os
import Security
/// Persists the device identity so it survives relaunch. The raw `.p12` bytes
/// **and** its passphrase are stored together (the passphrase is required to
/// re-import via `SecPKCS12Import` at every launch), then re-imported on load.
public protocol ClientIdentityStore: Sendable {
/// Validate (`SecPKCS12Import`) then persist the `.p12` + passphrase.
/// Throws `PKCS12ImportError` on a bad passphrase / corrupt file (nothing is
/// persisted in that case) and `ClientIdentityStoreError` on a storage fault.
func save(p12Data: Data, passphrase: String) throws
/// Re-import and return the stored identity; `nil` if none is installed.
func loadIdentity() throws -> ClientIdentity?
/// Display summary of the stored certificate; `nil` if none is installed.
func loadSummary() throws -> ClientCertificateSummary?
/// Delete the stored identity (rotation / removal). Idempotent.
func remove() throws
/// Cheap existence check for gating (does NOT re-import).
func hasInstalledIdentity() -> Bool
}
public enum ClientIdentityStoreError: Error, Equatable, Sendable {
/// A Keychain `SecItem*` call failed with this `OSStatus`.
case keychain(OSStatus)
/// The stored blob was present but could not be decoded.
case corruptStoredBlob
}
public extension ClientIdentityStore {
/// Convenience for composition roots: load the identity, logging and
/// swallowing errors into `nil`. A missing cert is the normal pre-install
/// state; a genuine fault must not crash launch, but is logged (never
/// silently dropped).
func loadedIdentityOrNil() -> ClientIdentity? {
do {
return try loadIdentity()
} catch {
ClientTLSLog.identity.error(
"loadIdentity failed: \(String(describing: error), privacy: .public)"
)
return nil
}
}
}
/// The stored payload `.p12` bytes plus the passphrase needed to re-import.
private struct StoredP12Blob: Codable {
let p12: Data
let passphrase: String
}
/// Keychain-backed store: one `kSecClassGenericPassword` item holding the
/// JSON-encoded `StoredP12Blob` in `kSecValueData`, protected with
/// `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (available after the first
/// unlock post-boot, never migrates off this device).
public struct KeychainClientIdentityStore: ClientIdentityStore {
public static let defaultService = "com.yaojia.webterm.clienttls"
public static let defaultAccount = "device-identity"
private let service: String
private let account: String
public init(
service: String = defaultService, account: String = defaultAccount
) {
self.service = service
self.account = account
}
public func save(p12Data: Data, passphrase: String) throws {
// Validate BEFORE persisting a wrong passphrase / corrupt file must
// surface to the install UI and leave any prior identity untouched.
_ = try PKCS12Importer.importIdentity(data: p12Data, passphrase: passphrase)
let blob = try encode(StoredP12Blob(p12: p12Data, passphrase: passphrase))
try writeItem(blob)
}
public func loadIdentity() throws -> ClientIdentity? {
guard let blob = try readBlob() else { return nil }
return try PKCS12Importer.importIdentity(
data: blob.p12, passphrase: blob.passphrase
)
}
public func loadSummary() throws -> ClientCertificateSummary? {
try loadIdentity()?.summary()
}
public func remove() throws {
let status = SecItemDelete(baseQuery() as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(status)
}
}
public func hasInstalledIdentity() -> Bool {
var query = baseQuery()
query[kSecReturnData as String] = false
query[kSecMatchLimit as String] = kSecMatchLimitOne
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
}
// MARK: - Keychain plumbing
private func baseQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
}
private func writeItem(_ data: Data) throws {
// Delete-then-add keeps the item's protection class deterministic
// (SecItemUpdate can't change kSecAttrAccessible in place).
let deleteStatus = SecItemDelete(baseQuery() as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(deleteStatus)
}
var attributes = baseQuery()
attributes[kSecValueData as String] = data
attributes[kSecAttrAccessible as String] =
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(attributes as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw ClientIdentityStoreError.keychain(addStatus)
}
}
private func readBlob() throws -> StoredP12Blob? {
var query = baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = result as? Data else {
throw ClientIdentityStoreError.keychain(status)
}
do {
return try JSONDecoder().decode(StoredP12Blob.self, from: data)
} catch {
throw ClientIdentityStoreError.corruptStoredBlob
}
}
private func encode(_ blob: StoredP12Blob) throws -> Data {
do {
return try JSONEncoder().encode(blob)
} catch {
throw ClientIdentityStoreError.corruptStoredBlob
}
}
}
/// In-memory store for previews and unit tests: same import/summary code path as
/// the keychain store (so the roundtrip is exercised) without any keychain
/// entitlement. `@unchecked Sendable` mutable blob guarded by a lock.
public final class InMemoryClientIdentityStore: ClientIdentityStore, @unchecked Sendable {
private let lock = NSLock()
private var blob: StoredP12BlobBox?
/// Boxed so the private `StoredP12Blob` type stays file-private above; this
/// mirror keeps the two bytes+passphrase without exposing the Codable type.
private struct StoredP12BlobBox {
let p12: Data
let passphrase: String
}
public init() {}
public func save(p12Data: Data, passphrase: String) throws {
_ = try PKCS12Importer.importIdentity(data: p12Data, passphrase: passphrase)
lock.withLock { blob = StoredP12BlobBox(p12: p12Data, passphrase: passphrase) }
}
public func loadIdentity() throws -> ClientIdentity? {
guard let stored = lock.withLock({ blob }) else { return nil }
return try PKCS12Importer.importIdentity(
data: stored.p12, passphrase: stored.passphrase
)
}
public func loadSummary() throws -> ClientCertificateSummary? {
try loadIdentity()?.summary()
}
public func remove() throws {
lock.withLock { blob = nil }
}
public func hasInstalledIdentity() -> Bool {
lock.withLock { blob != nil }
}
}
enum ClientTLSLog {
static let identity = Logger(subsystem: "com.yaojia.webterm", category: "client-tls")
}

View File

@@ -0,0 +1,73 @@
import Foundation
/// C-iOS-1 · The pure, synchronous decision at the heart of mutual TLS.
///
/// It maps a URLSession auth challenge (+ the optionally-installed device
/// identity) to a disposition and credential. Deliberately free of any
/// URLSession / socket state so the full truth table is unit-testable without a
/// live connection the transports (WS task delegate, HTTP session delegate)
/// are thin adapters that call `resolve` and forward its result to the
/// completion handler.
///
/// Truth table (plan §C-iOS-1):
/// a. `ClientCertificate` + identity present `.useCredential` with the
/// identity's `URLCredential`.
/// b. `ClientCertificate` + no identity `.cancelAuthenticationChallenge`
/// (a clean, classifiable failure never a silent no-cert continue).
/// c. `ServerTrust` `.performDefaultHandling`
/// (the LE wildcard is validated by the system trust store).
/// d. anything else `.performDefaultHandling`.
public struct MutualTLSChallengeResponder: Sendable {
/// The responder's decision. Not `Sendable` (it may carry a `URLCredential`,
/// which isn't) it is produced and consumed synchronously on the delegate
/// queue, never sent across isolation domains.
public struct Resolution {
public let disposition: URLSession.AuthChallengeDisposition
public let credential: URLCredential?
public init(
disposition: URLSession.AuthChallengeDisposition,
credential: URLCredential? = nil
) {
self.disposition = disposition
self.credential = credential
}
}
public init() {}
/// Resolve a full `URLAuthenticationChallenge` by dispatching on its
/// authentication method.
public func resolve(
_ challenge: URLAuthenticationChallenge, identity: ClientIdentity?
) -> Resolution {
resolve(
authenticationMethod: challenge.protectionSpace.authenticationMethod,
identity: identity
)
}
/// Method-keyed core (challenge-free) the directly-tested pure function.
public func resolve(
authenticationMethod: String, identity: ClientIdentity?
) -> Resolution {
switch authenticationMethod {
case NSURLAuthenticationMethodClientCertificate:
guard let identity else {
// (b) No installed identity: cancel so the failure surfaces as a
// classifiable client-cert error rather than a bare TLS reset.
return Resolution(disposition: .cancelAuthenticationChallenge)
}
// (a) Present the device certificate for this session.
return Resolution(
disposition: .useCredential, credential: identity.urlCredential()
)
case NSURLAuthenticationMethodServerTrust:
// (c) System-trusted LE wildcard default evaluation.
return Resolution(disposition: .performDefaultHandling)
default:
// (d) Basic/Digest/NTLM/etc. not used by this app; default.
return Resolution(disposition: .performDefaultHandling)
}
}
}

View File

@@ -0,0 +1,89 @@
import Foundation
import Security
/// Failure modes of a PKCS#12 import, each mapped to actionable install-UX copy
/// (C-iOS-3). The three plan-named `OSStatus` mappings are honored:
/// `errSecAuthFailed .wrongPassphrase`, `errSecDecode .corruptFile`, and any
/// other non-success status (unsupported/unknown format the plan's "errSecPkg"
/// bucket) `.unsupported`.
public enum PKCS12ImportError: Error, Equatable, Sendable {
/// Wrong passphrase (`errSecAuthFailed`).
case wrongPassphrase
/// Not a decodable PKCS#12 blob (`errSecDecode`) truncated / not a `.p12`.
case corruptFile
/// Decoded, but the format/algorithms aren't importable on this OS
/// (any other non-success `OSStatus`). Retains the raw status for logs.
case unsupported(OSStatus)
/// Import succeeded but carried no `SecIdentity` (e.g. a certs-only `.p12`).
case noIdentity
public static func == (lhs: PKCS12ImportError, rhs: PKCS12ImportError) -> Bool {
switch (lhs, rhs) {
case (.wrongPassphrase, .wrongPassphrase),
(.corruptFile, .corruptFile),
(.noIdentity, .noIdentity):
return true
case let (.unsupported(a), .unsupported(b)):
return a == b
default:
return false
}
}
}
/// Imports a `.p12` into a `ClientIdentity` via `SecPKCS12Import`.
///
/// Memory-only: `SecPKCS12Import` returns the identity/chain as CoreFoundation
/// handles without writing to the keychain, so callers control persistence
/// (see `KeychainClientIdentityStore`).
public enum PKCS12Importer {
public static func importIdentity(
data: Data, passphrase: String
) throws -> ClientIdentity {
let options = [kSecImportExportPassphrase as String: passphrase] as CFDictionary
var rawItems: CFArray?
let status = SecPKCS12Import(data as CFData, options, &rawItems)
switch status {
case errSecSuccess:
break
case errSecAuthFailed:
throw PKCS12ImportError.wrongPassphrase
case errSecDecode:
throw PKCS12ImportError.corruptFile
default:
// Covers unsupported formats/algorithms and any other failure
// the plan's `errSecPkg .unsupported` mapping (that symbol does
// not exist in the SDK; the status is preserved for diagnostics).
throw PKCS12ImportError.unsupported(status)
}
guard let items = rawItems as? [[String: Any]], let first = items.first,
let identityValue = first[kSecImportItemIdentity as String]
else {
throw PKCS12ImportError.noIdentity
}
// Force-cast is safe: `kSecImportItemIdentity` is always a SecIdentity.
let secIdentity = identityValue as! SecIdentity
let chain = (first[kSecImportItemCertChain as String] as? [SecCertificate]) ?? []
return ClientIdentity(
secIdentity: secIdentity,
issuerCertificates: issuerChain(from: chain, identity: secIdentity)
)
}
/// The chain from `SecPKCS12Import` includes the leaf at index 0; the
/// `URLCredential` must carry only the *issuer* certs (Apple: do not repeat
/// the identity's own cert). Drops whichever chain entry DER-matches the
/// identity's leaf.
private static func issuerChain(
from chain: [SecCertificate], identity: SecIdentity
) -> [SecCertificate] {
var leaf: SecCertificate?
SecIdentityCopyCertificate(identity, &leaf)
guard let leafData = leaf.map({ SecCertificateCopyData($0) as Data }) else {
return chain
}
return chain.filter { SecCertificateCopyData($0) as Data != leafData }
}
}