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:
@@ -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:))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user