Files
web-terminal/ios/App/WebTerm/Wiring/AppEnvironment.swift
Yaojia Wang e38e6d1689 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.
2026-07-07 09:42:12 +02:00

74 lines
3.8 KiB
Swift

import APIClient
import ClientTLS
import Foundation
import HostRegistry
import SessionCore
import WireProtocol
/// T-iOS-15 · Production dependency graph (composition root). One immutable
/// value assembled at launch; every screen/VM receives its dependencies from
/// here no ad-hoc URLSession/keychain access anywhere else in the App layer.
///
/// Assembly security audit (task , verified at this single point):
/// - All `G` (state-changing) HTTP goes through `APIClient` (kill via
/// SessionListViewModel's client, probe's kill round-trip inside
/// `runPairingProbe`); `URLSessionHTTPTransport` adds no headers of its own.
/// - Origin is derived solely by `HostEndpoint` (WS: URLSessionTermTransport;
/// HTTP: APIClient's route builder) nothing here hand-assembles one.
/// - Secrets: hosts in `KeychainHostStore`
/// (AfterFirstUnlockThisDeviceOnly, hosted keychain test asserts it);
/// UserDefaults carries only the non-secret per-host lastSessionId.
/// - No debug ATS overrides exist (project.yml declares NO
/// NSAllowsArbitraryLoads in any configuration only the five §5.2 CIDR
/// exceptions), and no isSecureTextEntry-style screenshot hacks are used;
/// `UIScreen.isCaptured` detection is SKIPPED per plan (accepted residual
/// risk, local trust model).
struct AppEnvironment: Sendable {
let hostStore: any HostStore
let lastSessionStore: any LastSessionStore
let http: any HTTPTransport
let termTransport: any TermTransport
/// Injected into `PairingViewModel` production is `runPairingProbe`
/// over the real transports (two-step: RO GET, then WS attach + guarded
/// kill; only runs after the user's explicit confirm, T-iOS-12).
let probe: PairingViewModel.Probe
/// T-iOS-23 · unread last-seen watermarks (non-secret; UserDefaults).
/// `var` + default so the memberwise init stays source-compatible for
/// pre-P1 call sites while tests can inject an in-memory fake.
var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore()
static func production() -> AppEnvironment {
// C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the installed device client
// identity LAZILY from the keychain on each connect/challenge, injected
// into BOTH transports + the probe as a provider. This way a certificate
// imported from the "" screen takes effect on the NEXT connection
// without relaunching a snapshot captured here would stay stale. A
// missing cert is the normal pre-install state ( nil); genuine faults
// are logged, not fatal (`loadedIdentityOrNil`). mTLS challenges only
// fire for tunnel hosts, so a `nil` result is inert for local hosts.
let identityStore = KeychainClientIdentityStore()
let identityProvider: @Sendable () -> ClientIdentity? = {
identityStore.loadedIdentityOrNil()
}
let http = URLSessionHTTPTransport(identityProvider: identityProvider)
let termTransport = URLSessionTermTransport(identityProvider: identityProvider)
return AppEnvironment(
hostStore: KeychainHostStore(),
lastSessionStore: UserDefaultsLastSessionStore(),
http: http,
termTransport: termTransport,
probe: { endpoint in
await runPairingProbe(endpoint: endpoint, http: http, ws: termTransport)
}
)
}
/// Away-digest source for a session engine: wraps `APIClient.events` per
/// host (the engine never holds an HTTP client plan §3.2).
func makeEventsSource(endpoint: HostEndpoint)
-> @Sendable (UUID) async throws -> [TimelineEvent] {
let client = APIClient(endpoint: endpoint, http: http)
return { id in try await client.events(id: id) }
}
}