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.
This commit is contained in:
Yaojia Wang
2026-07-07 09:42:12 +02:00
parent 5337281e85
commit e38e6d1689
25 changed files with 1510 additions and 30 deletions

View File

@@ -1,7 +1,9 @@
// swift-tools-version: 6.0
// T-iOS-1 scaffold shell. SessionEngine / ReconnectMachine / PingScheduler /
// GateState / AwayDigest / URLSessionTermTransport land in W1W2 tasks.
// Dependency direction is strictly downward: SessionCore WireProtocol only.
// Dependency direction is strictly downward: SessionCore WireProtocol, plus
// ClientTLS (C-iOS-2) for the device client-cert mTLS responder the WS transport
// answers connection-level challenges with. Both are downward leaves.
import PackageDescription
let package = Package(
@@ -12,12 +14,16 @@ let package = Package(
],
dependencies: [
.package(path: "../WireProtocol"),
.package(path: "../ClientTLS"),
.package(path: "../TestSupport"),
],
targets: [
.target(
name: "SessionCore",
dependencies: [.product(name: "WireProtocol", package: "WireProtocol")]
dependencies: [
.product(name: "WireProtocol", package: "WireProtocol"),
.product(name: "ClientTLS", package: "ClientTLS"),
]
),
.testTarget(
name: "SessionCoreTests",

View File

@@ -1,3 +1,4 @@
import ClientTLS
import Darwin
import Foundation
import WireProtocol
@@ -76,16 +77,45 @@ protocol ConnectionPinger: Sendable {
public struct URLSessionTermTransport: TermTransport {
/// `URLSessionWebSocketTask.maximumMessageSize` applied to every task.
private let maxMessageBytes: Int
/// C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the optional device client
/// identity LAZILY, once per `connect`, so a certificate imported mid-run
/// takes effect on the NEXT connection without an app relaunch (a snapshot
/// captured at composition would stay stale). When the resolved identity is
/// present, the connection answers a `ClientCertificate` challenge (mTLS to a
/// tunneled host) with its credential; when `nil`, the challenge is cancelled
/// cleanly. Local (non-mTLS) hosts never issue the challenge, so a `nil`
/// result is inert there.
private let identityProvider: @Sendable () -> ClientIdentity?
public init() {
self.init(maxMessageBytes: Tunables.maxWSMessageBytes)
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
/// provider, so behaviour is identical to capturing the identity directly.
public init(identity: ClientIdentity? = nil) {
self.init(identityProvider: { identity })
}
/// C-iOS-2 (MEDIUM no-relaunch fix) · per-connect identity resolution: the
/// provider is re-consulted on every `connect`, so a nilinstalled
/// transition is picked up without relaunch.
public init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
self.init(
maxMessageBytes: Tunables.maxWSMessageBytes, identityProvider: identityProvider
)
}
/// Internal TEST seam: a shrunken cap makes the oversizeEMSGSIZE path
/// deterministic without 16 MiB fixtures. Production code paths always go
/// through `init()` and the frozen `Tunables.maxWSMessageBytes`.
init(maxMessageBytes: Int) {
/// through `init()` / `init(identityProvider:)` and the frozen
/// `Tunables.maxWSMessageBytes`.
init(maxMessageBytes: Int, identity: ClientIdentity? = nil) {
self.init(maxMessageBytes: maxMessageBytes, identityProvider: { identity })
}
init(
maxMessageBytes: Int,
identityProvider: @escaping @Sendable () -> ClientIdentity?
) {
self.maxMessageBytes = maxMessageBytes
self.identityProvider = identityProvider
}
public func connect(to endpoint: HostEndpoint) async throws -> TransportConnection {
@@ -93,9 +123,13 @@ public struct URLSessionTermTransport: TermTransport {
}
/// Internal: concrete-typed connect (tests assert task configuration;
/// `connectPingable` builds on it).
/// `connectPingable` builds on it). The identity is resolved HERE, per
/// connect, from `identityProvider` (no-relaunch pickup).
func openConnection(to endpoint: HostEndpoint) async throws -> WSConnection {
try await WSConnection.open(endpoint: endpoint, maxMessageBytes: maxMessageBytes)
try await WSConnection.open(
endpoint: endpoint, maxMessageBytes: maxMessageBytes,
identity: identityProvider()
)
}
}
@@ -135,6 +169,13 @@ final class WSConnection: NSObject, @unchecked Sendable {
private let frames: AsyncThrowingStream<String, any Error>
private let framesContinuation: AsyncThrowingStream<String, any Error>.Continuation
/// C-iOS-2 mTLS. Set once in `configure` BEFORE `task.resume()`, so the
/// connection-level challenge (which can only arrive after resume) always
/// reads a stable value without locking same set-once discipline as
/// `task`/`session`.
private var identity: ClientIdentity?
private let challengeResponder = MutualTLSChallengeResponder()
private override init() {
(frames, framesContinuation) = AsyncThrowingStream<String, any Error>.makeStream()
super.init()
@@ -143,9 +184,13 @@ final class WSConnection: NSObject, @unchecked Sendable {
/// Connect: build session+task, resume, await the delegate-driven
/// handshake (didOpen / didCompleteWithError no receive-error guessing),
/// then start the re-arming receive loop.
static func open(endpoint: HostEndpoint, maxMessageBytes: Int) async throws -> WSConnection {
static func open(
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity? = nil
) async throws -> WSConnection {
let connection = WSConnection()
connection.configure(endpoint: endpoint, maxMessageBytes: maxMessageBytes)
connection.configure(
endpoint: endpoint, maxMessageBytes: maxMessageBytes, identity: identity
)
try await connection.performHandshake()
connection.startReceiveLoop()
return connection
@@ -163,7 +208,12 @@ final class WSConnection: NSObject, @unchecked Sendable {
// MARK: Setup
private func configure(endpoint: HostEndpoint, maxMessageBytes: Int) {
private func configure(
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity?
) {
// Set BEFORE building the task/resume: the mTLS challenge can only fire
// after `task.resume()`, so this write happens-before any read of it.
self.identity = identity
// Origin: SINGLE source of truth = endpoint.originHeader (plan §5.1).
var request = URLRequest(url: endpoint.wsURL)
request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin")
@@ -311,6 +361,22 @@ extension WSConnection: URLSessionWebSocketDelegate {
takeHandshakeContinuation()?.resume()
}
/// C-iOS-2 · Connection-level auth challenge (server trust + client cert)
/// arrives task-level for a WS task. Delegate the decision to the pure
/// `MutualTLSChallengeResponder`: present the device identity for an mTLS
/// tunnel host, default-handle the LE server trust, cancel cleanly if a
/// client cert is demanded but none is installed. `identity` is set-once
/// before `resume()` (see `configure`), so this read needs no lock.
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
let resolution = challengeResponder.resolve(challenge, identity: identity)
completionHandler(resolution.disposition, resolution.credential)
}
/// Server close frame processed CLEAN close: the stream FINISHES
/// (distinguishable from the transport-error THROW path).
func urlSession(

View File

@@ -243,6 +243,29 @@ struct URLSessionTermTransportTests {
await connection.close()
}
@Test("identity provider is resolved PER CONNECT (no-relaunch pickup, not captured once)")
func identityProviderResolvedPerConnect() async throws {
// A cert imported mid-run must take effect on the NEXT connection. The
// transport therefore re-consults its provider on every `connect` rather
// than capturing a snapshot proven here by a provider whose invocation
// count grows once per connect (nil identity: ScriptedWSServer is plain
// ws, so the connection succeeds regardless).
let (server, endpoint) = try await Self.startServer()
defer { server.stop() }
let calls = CallCounter()
let transport = URLSessionTermTransport(identityProvider: {
calls.increment()
return nil
})
let first = try await transport.connect(to: endpoint)
await first.close()
let second = try await transport.connect(to: endpoint)
await second.close()
#expect(calls.value == 2)
}
@Test("connect to a dead port throws (handshake failure path, no hang)")
func connectToDeadPortThrows() async throws {
let server = ScriptedWSServer()
@@ -254,5 +277,14 @@ struct URLSessionTermTransportTests {
_ = try await URLSessionTermTransport().connect(to: endpoint)
}
}
/// Thread-safe invocation counter for the `@Sendable` identity provider
/// (URLSession may consult it off the test's isolation domain).
private final class CallCounter: @unchecked Sendable {
private let lock = NSLock()
private var count = 0
func increment() { lock.withLock { count += 1 } }
var value: Int { lock.withLock { count } }
}
}
#endif