- 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.
56 lines
2.5 KiB
Swift
56 lines
2.5 KiB
Swift
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:))
|
|
}
|
|
}
|