Files
web-terminal/ios/Packages/ClientTLS/Sources/ClientTLS/PKCS12Importer.swift
Yaojia Wang e38e6d1689 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.
2026-07-07 09:42:12 +02:00

90 lines
3.7 KiB
Swift

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 }
}
}