App layer, four sequential slices (a shared .xcodeproj means adding files regenerates it, so these could not run in parallel): - token UX end to end: pairing prompts for a token when a host 401s, POST /auth validates it, and 204-without-Set-Cookie is correctly read as "this server has auth disabled" rather than "authenticated". A host paired before the token was turned on recovers by re-pairing in place. Remove-host now exists and finally gives PushRegistrar.handleHostRemoved a caller. - project git panel + worktree lifecycle (T-iOS-32) + claude --resume history — the parity gap with Android and the web front end. - terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a session switch between dictation and confirm cannot inject into the wrong session. - theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no longer hard-locks .preferredColorScheme(.dark). Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that collided with the upstream one, verified green from a fresh derivedDataPath. Includes the two HIGH fixes the security review found: - iOS resolved the WS token host-independently, so a token-gated host sitting next to an open one could never open a terminal and no on-screen remedy could fix it. Now one transport per host; cross-host leakage is structurally impossible since both read paths return only that host's own value. - Android reported the host's own git-credential 401 (git-ops.ts:108, "Push authentication required on the host.") as "your access token is wrong", because a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now ROUTE_DEFINED and keep the server's message. And the doc sync: README/ios README no longer claim the client is unmerged on feat/ios-client, the Clients section finally lists Android, and the plan checkboxes reflect what is actually built. iOS 534 app tests + 452 package tests; Android 687 tests.
164 lines
8.5 KiB
Swift
164 lines
8.5 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 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=<t>` 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)
|
|
}
|
|
}
|