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 about ROUTING: /// `APIClient` builds every request — including the Origin-iff-G rule (plan §3.4 /// 铁律) — and this type only performs the exchange. Adding URL or Origin logic /// here would bypass that single audited point (review CRITICAL). /// /// C1 · The ONE exception is the access-token `Cookie` (ios-completion §1.1), /// and it is deliberate: /// - the token is **unconditional** — every request, RO and G alike — so it has /// no interaction with the conditional Origin rule it must never replace; /// - it is **per host**, resolved from the request's own origin, whereas /// `APIClient` instances are built ad hoc all over the App layer (list poll, /// previews, projects, diffs, push registration) with no access to the /// Keychain. Stamping at the shared transport is what makes "every request /// carries the token" true by construction instead of per-call-site; /// - it is resolved LAZILY per request from `tokenForOrigin` — the same /// no-relaunch pattern as the mTLS `identityProvider` below — so a token typed /// mid-run applies to the very next request. /// /// A request that ALREADY carries a `Cookie` is left untouched: the pairing probe /// authenticates with a *candidate* token that is not in the store yet, and /// overwriting it here would silently unauthenticate the probe. struct URLSessionHTTPTransport: HTTPTransport { private let session: URLSession /// Resolves the stored access token for a request's origin (see type doc). /// `@Sendable`, async: the store is an actor over the Keychain. private let tokenForOrigin: @Sendable (String) async -> String? /// 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. /// No token source ⇒ no `Cookie` is ever added (LAN zero-config default). 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?, tokenForOrigin: @escaping @Sendable (String) async -> String? = { _ in nil } ) { let delegate = LazyClientTLSSessionDelegate(identityProvider: identityProvider) self.tlsDelegate = delegate self.tokenForOrigin = tokenForOrigin 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: await authenticated(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) } /// Stamp `Cookie: webterm_auth=` for the request's own origin (C1). /// Immutable style: returns a NEW request, never mutates the caller's. /// /// `internal`, not private: this is the whole behaviour `send` adds, and it /// is testable with zero network — the alternative would be leaving the one /// line that carries a credential unverified. func authenticated(_ request: URLRequest) async -> URLRequest { guard let origin = AccessTokenCookie.origin(of: request), let token = await tokenForOrigin(origin) else { return request } return AccessTokenCookie.stamped(request, token: token) } } /// The single point where an access token becomes a request header (App layer). /// Pure and static so the rules are unit-testable without any network. enum AccessTokenCookie { /// `AUTH_COOKIE_NAME` (src/http/auth.ts:30) — the same literal the packages /// pin; both of their `AuthCookie` helpers are package-internal, so the App /// layer needs its own single definition rather than a fourth ad-hoc string. static let name = "webterm_auth" static let header = "Cookie" /// The request's origin in `HostEndpoint.originHeader` form, via the frozen /// single derivation point (default ports omitted, scheme/host lowercased) — /// never hand-assembled here. nil for a non-http(s) or host-less URL. static func origin(of request: URLRequest) -> String? { guard let url = request.url, let endpoint = HostEndpoint(baseURL: url) else { return nil } return endpoint.originHeader } /// A copy of `request` carrying the token cookie — unless it already carries /// a `Cookie`, in which case the existing one wins (the pairing probe's /// candidate token must not be overwritten by the stored one). static func stamped(_ request: URLRequest, token: String) -> URLRequest { guard request.value(forHTTPHeaderField: header) == nil else { return request } var authenticated = request authenticated.setValue("\(name)=\(token)", forHTTPHeaderField: header) return authenticated } } /// 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) } }