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:
@@ -1,7 +1,9 @@
|
||||
// swift-tools-version: 6.0
|
||||
// T-iOS-1 scaffold shell. SessionEngine / ReconnectMachine / PingScheduler /
|
||||
// GateState / AwayDigest / URLSessionTermTransport land in W1–W2 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",
|
||||
|
||||
@@ -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 nil→installed
|
||||
/// 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 oversize→EMSGSIZE 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user