- 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.
95 lines
4.9 KiB
Swift
95 lines
4.9 KiB
Swift
import ClientTLS
|
|
import Foundation
|
|
import WireProtocol
|
|
|
|
/// T-iOS-15 · Production `HTTPTransport` (the WireProtocol seam's doc reserves
|
|
/// the URLSession wrapper for the production side; no package owns it, so the
|
|
/// assembly layer provides it). Deliberately logic-free: `APIClient` builds
|
|
/// every request — including the Origin-iff-G rule (plan §3.4 铁律) — and this
|
|
/// type only performs the exchange. Adding ANY header/URL logic here would
|
|
/// bypass that single audited point (review CRITICAL).
|
|
struct URLSessionHTTPTransport: HTTPTransport {
|
|
private let session: URLSession
|
|
/// Strong reference to the mTLS delegate. URLSession retains its delegate
|
|
/// until invalidated, but this ephemeral session is never explicitly
|
|
/// invalidated, so holding it here documents the ownership and keeps the
|
|
/// transport a self-contained value.
|
|
private let tlsDelegate: LazyClientTLSSessionDelegate
|
|
|
|
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
|
|
/// provider, so behaviour is identical to capturing the identity directly.
|
|
init(identity: ClientIdentity? = nil) {
|
|
self.init(identityProvider: { identity })
|
|
}
|
|
|
|
/// C-iOS-2 (MEDIUM no-relaunch fix) · The session's mTLS delegate resolves
|
|
/// the device identity LAZILY, per client-certificate challenge, from
|
|
/// `identityProvider`. A `ClientCertificate` challenge from a tunneled host
|
|
/// is thus answered with whatever cert is installed AT CHALLENGE TIME — so a
|
|
/// certificate imported mid-run is presented on the NEXT TLS handshake
|
|
/// without an app relaunch (a snapshot captured here would stay stale). The
|
|
/// session itself stays a single long-lived value (no per-request churn).
|
|
/// Local http/https hosts never issue that challenge, so the provider is
|
|
/// never even consulted for them (and a `nil` result is inert regardless).
|
|
///
|
|
/// EPHEMERAL, not `.shared` (T-iOS-19 finding): RO GET bodies include
|
|
/// `/live-sessions/:id/preview` — raw terminal ring-buffer bytes that may
|
|
/// contain printed secrets — and `.shared`'s default URLCache writes
|
|
/// responses to disk. Ephemeral keeps them memory-only, matching the WS
|
|
/// transport and the privacy-shade posture.
|
|
init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
|
|
let delegate = LazyClientTLSSessionDelegate(identityProvider: identityProvider)
|
|
self.tlsDelegate = delegate
|
|
self.session = URLSession(
|
|
configuration: .ephemeral, delegate: delegate, delegateQueue: nil
|
|
)
|
|
}
|
|
|
|
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
|
let (data, response) = try await session.data(for: request)
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
// http(s)-only endpoints (HostEndpoint validates) always produce
|
|
// an HTTPURLResponse; anything else is a transport-level anomaly.
|
|
throw URLError(.badServerResponse)
|
|
}
|
|
return (data, httpResponse)
|
|
}
|
|
}
|
|
|
|
/// C-iOS-2 (MEDIUM no-relaunch fix) · Session-level mTLS delegate that resolves
|
|
/// the device identity FRESH per client-certificate challenge — the App-layer
|
|
/// twin of ClientTLS's fixed-identity `ClientTLSSessionDelegate` (which captures
|
|
/// the identity once). The provider is only consulted for a `ClientCertificate`
|
|
/// challenge, so a `ServerTrust` challenge never triggers a keychain read; the
|
|
/// pure decision itself is delegated to the shared `MutualTLSChallengeResponder`
|
|
/// (single truth table, unit-tested in ClientTLS).
|
|
///
|
|
/// `@unchecked Sendable`: URLSession retains its delegate and invokes it from
|
|
/// arbitrary queues; every stored field is an immutable `let` over a `@Sendable`
|
|
/// value (the provider is `@Sendable`, the responder is stateless).
|
|
private final class LazyClientTLSSessionDelegate:
|
|
NSObject, URLSessionDelegate, @unchecked Sendable {
|
|
private let identityProvider: @Sendable () -> ClientIdentity?
|
|
private let responder = MutualTLSChallengeResponder()
|
|
|
|
init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
|
|
self.identityProvider = identityProvider
|
|
super.init()
|
|
}
|
|
|
|
func urlSession(
|
|
_ session: URLSession,
|
|
didReceive challenge: URLAuthenticationChallenge,
|
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
|
) {
|
|
// Only a client-certificate challenge needs the identity; resolving it
|
|
// for a server-trust challenge would do a needless keychain read/import
|
|
// on every HTTPS handshake.
|
|
let isClientCert = challenge.protectionSpace.authenticationMethod
|
|
== NSURLAuthenticationMethodClientCertificate
|
|
let identity = isClientCert ? identityProvider() : nil
|
|
let resolution = responder.resolve(challenge, identity: identity)
|
|
completionHandler(resolution.disposition, resolution.credential)
|
|
}
|
|
}
|