- 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.
90 lines
3.7 KiB
Swift
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 }
|
|
}
|
|
}
|