- 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.
74 lines
3.8 KiB
Swift
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) }
|
|
}
|
|
}
|