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,4 +1,5 @@
import APIClient import APIClient
import ClientTLS
import Foundation import Foundation
import os import os
import SwiftUI import SwiftUI
@@ -225,12 +226,19 @@ final class SessionThumbnailPipeline {
cache = SessionThumbnailCache(maxEntries: maxCacheEntries) cache = SessionThumbnailCache(maxEntries: maxCacheEntries)
} }
/// ephemeral URLSessionpreview /// ephemeral URLSessionpreview
/// only T-iOS-19 RO GET /// only T-iOS-19 RO GET C-iOS-2
/// mTLS nginx
/// provider keychain MEDIUM
/// =nil
static func live() -> SessionThumbnailPipeline { static func live() -> SessionThumbnailPipeline {
SessionThumbnailPipeline( let identityStore = KeychainClientIdentityStore()
let transport = URLSessionHTTPTransport(identityProvider: {
identityStore.loadedIdentityOrNil()
})
return SessionThumbnailPipeline(
loader: { request in loader: { request in
try await APIClient(endpoint: request.endpoint, http: liveTransport) try await APIClient(endpoint: request.endpoint, http: transport)
.preview(id: request.sessionId) .preview(id: request.sessionId)
}, },
renderer: { data, cols, rows in renderer: { data, cols, rows in
@@ -239,8 +247,6 @@ final class SessionThumbnailPipeline {
) )
} }
private static let liveTransport = URLSessionHTTPTransport()
/// `.placeholder` /// `.placeholder`
/// UI /// UI
func thumbnail(for request: SessionThumbnailRequest) async -> SessionThumbnailImage { func thumbnail(for request: SessionThumbnailRequest) async -> SessionThumbnailImage {

View File

@@ -0,0 +1,243 @@
import ClientTLS
import Foundation
import Observation
import SwiftUI
import UniformTypeIdentifiers
/// C-iOS-3 · Device-certificate install / rotation screen.
///
/// Flow: `.fileImporter([.pkcs12])` secure passphrase import (validates via
/// `SecPKCS12Import`) persist in the keychain show issuer CN + expiry, with
/// replace/rotate + remove. This is the in-app, app-sandboxed install path (a
/// `.p12` delivered via AirDrop/Files), NOT a configuration profile.
///
/// INTEGRATION NOTE: presenting this screen needs a one-line nav/Settings entry
/// ("Device certificate") in the app chrome (RootView / PairingScreen), which
/// live outside this task's ownership. The screen is fully self-contained so
/// that wiring is a single `NavigationLink { ClientCertScreen() }`.
struct ClientCertScreen: View {
@State private var model: ClientCertViewModel
init(model: ClientCertViewModel = ClientCertViewModel()) {
_model = State(initialValue: model)
}
var body: some View {
Form {
installedSection
importSection
if model.summary != nil {
removeSection
}
}
.navigationTitle(ClientCertCopy.title)
.fileImporter(
isPresented: $model.isPickingFile,
allowedContentTypes: [.pkcs12],
allowsMultipleSelection: false
) { result in
model.handleFileSelection(result)
}
.task { model.refresh() }
}
// MARK: - Sections
@ViewBuilder private var installedSection: some View {
Section(ClientCertCopy.installedHeader) {
if let summary = model.summary {
LabeledContent(ClientCertCopy.subject, value: summary.subjectCommonName ?? "")
LabeledContent(ClientCertCopy.issuer, value: summary.issuerCommonName ?? "")
LabeledContent(ClientCertCopy.expiry, value: model.expiryText(summary))
if summary.isExpired() {
Label(ClientCertCopy.expiredWarning, systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
} else {
Text(ClientCertCopy.noneInstalled).foregroundStyle(.secondary)
}
}
}
@ViewBuilder private var importSection: some View {
Section {
if let name = model.pendingFileName {
LabeledContent(ClientCertCopy.selectedFile, value: name)
SecureField(ClientCertCopy.passphrase, text: $model.passphrase)
.textContentType(.password)
.autocorrectionDisabled()
Button(ClientCertCopy.importAction) { model.importPending() }
.disabled(!model.canImport)
Button(ClientCertCopy.cancelAction, role: .cancel) { model.clearPending() }
} else {
Button {
model.beginPicking()
} label: {
Label(
model.summary == nil
? ClientCertCopy.chooseFile
: ClientCertCopy.replaceFile,
systemImage: "doc.badge.plus"
)
}
}
if let error = model.errorMessage {
Text(error).foregroundStyle(.red).font(.footnote)
}
} header: {
Text(model.summary == nil ? ClientCertCopy.importHeader : ClientCertCopy.rotateHeader)
} footer: {
Text(ClientCertCopy.importFooter)
}
}
@ViewBuilder private var removeSection: some View {
Section {
Button(ClientCertCopy.removeAction, role: .destructive) { model.remove() }
}
}
}
/// State machine for the install screen. Errors are mapped to actionable copy
/// and never swallowed; a failed import leaves any prior identity intact.
@MainActor
@Observable
final class ClientCertViewModel {
private(set) var summary: ClientCertificateSummary?
var isPickingFile = false
var passphrase = ""
private(set) var pendingData: Data?
private(set) var pendingFileName: String?
private(set) var errorMessage: String?
@ObservationIgnored private let store: any ClientIdentityStore
init(store: any ClientIdentityStore = KeychainClientIdentityStore()) {
self.store = store
}
var canImport: Bool { pendingData != nil && !passphrase.isEmpty }
func refresh() {
do {
summary = try store.loadSummary()
} catch {
summary = nil
errorMessage = ClientCertCopy.loadFailed
}
}
func beginPicking() {
errorMessage = nil
isPickingFile = true
}
func handleFileSelection(_ result: Result<[URL], Error>) {
switch result {
case .failure:
errorMessage = ClientCertCopy.fileReadFailed
case .success(let urls):
guard let url = urls.first else { return }
readFile(at: url)
}
}
private func readFile(at url: URL) {
let didAccess = url.startAccessingSecurityScopedResource()
defer { if didAccess { url.stopAccessingSecurityScopedResource() } }
do {
pendingData = try Data(contentsOf: url)
pendingFileName = url.lastPathComponent
passphrase = ""
errorMessage = nil
} catch {
pendingData = nil
pendingFileName = nil
errorMessage = ClientCertCopy.fileReadFailed
}
}
func importPending() {
guard let data = pendingData else { return }
do {
try store.save(p12Data: data, passphrase: passphrase)
clearPending()
refresh()
} catch {
errorMessage = Self.copy(for: error)
}
}
func clearPending() {
pendingData = nil
pendingFileName = nil
passphrase = ""
errorMessage = nil
}
func remove() {
do {
try store.remove()
summary = nil
errorMessage = nil
} catch {
errorMessage = ClientCertCopy.removeFailed
}
}
func expiryText(_ summary: ClientCertificateSummary) -> String {
guard let notAfter = summary.notAfter else { return "" }
return notAfter.formatted(date: .abbreviated, time: .omitted)
}
/// Map an import/storage error to install-UX copy (never swallowed).
private static func copy(for error: any Error) -> String {
switch error {
case PKCS12ImportError.wrongPassphrase:
return ClientCertCopy.errWrongPassphrase
case PKCS12ImportError.corruptFile:
return ClientCertCopy.errCorruptFile
case PKCS12ImportError.unsupported:
return ClientCertCopy.errUnsupported
case PKCS12ImportError.noIdentity:
return ClientCertCopy.errNoIdentity
case ClientIdentityStoreError.keychain, ClientIdentityStoreError.corruptStoredBlob:
return ClientCertCopy.errKeychain
default:
return ClientCertCopy.errUnknown
}
}
}
/// User-facing copy (Chinese, matching the rest of the app).
enum ClientCertCopy {
static let title = "设备证书"
static let installedHeader = "已安装证书"
static let subject = "设备 (CN)"
static let issuer = "签发 CA"
static let expiry = "有效期至"
static let expiredWarning = "证书已过期,请重新导入。"
static let noneInstalled = "尚未安装设备证书。"
static let importHeader = "导入证书"
static let rotateHeader = "替换证书"
static let chooseFile = "选择 .p12 文件"
static let replaceFile = "选择新的 .p12 文件"
static let selectedFile = "已选文件"
static let passphrase = "证书密码"
static let importAction = "导入"
static let cancelAction = "取消"
static let removeAction = "移除证书"
static let importFooter =
"证书用于连接你自己的隧道主机 (mTLS)。通过 AirDrop / 文件 把 .p12 传到本机后在此导入;证书与密码只保存在本设备钥匙串。"
static let errWrongPassphrase = "密码错误,请重试。"
static let errCorruptFile = "文件无法识别,请确认是有效的 .p12 证书。"
static let errUnsupported = "证书格式不受支持(请用 openssl pkcs12 -export -legacy 生成)。"
static let errNoIdentity = "该 .p12 不含身份(缺少私钥),无法用于客户端认证。"
static let errKeychain = "保存到钥匙串失败,请重试。"
static let errUnknown = "导入失败,请重试。"
static let loadFailed = "读取已安装证书失败。"
static let fileReadFailed = "无法读取所选文件。"
static let removeFailed = "移除证书失败,请重试。"
}

View File

@@ -23,6 +23,12 @@ struct SessionListScreen: View {
var onOpen: (SessionListViewModel.OpenRequest) -> Void = { _ in } var onOpen: (SessionListViewModel.OpenRequest) -> Void = { _ in }
/// Host-switch header hook: "" entry (pairing sheet, T-iOS-15). /// Host-switch header hook: "" entry (pairing sheet, T-iOS-15).
var onAddHost: () -> Void = {} var onAddHost: () -> Void = {}
/// C-iOS-3 (HIGH reachability fix) · "" entry presents
/// `ClientCertScreen` (import / rotate the mTLS device cert). The device
/// cert is a global concern, but the toolbar host-menu is the app's only
/// settings-like surface and is present in every empty state, so this is the
/// nav idiom that makes the orphaned screen reachable.
var onDeviceCert: () -> Void = {}
/// T-iOS-28 (additive slot) · shared thumbnail pipeline: one cache + one /// T-iOS-28 (additive slot) · shared thumbnail pipeline: one cache + one
/// render-concurrency gate across ALL rows (scrolling must never spawn /// render-concurrency gate across ALL rows (scrolling must never spawn
/// unbounded offscreen terminals). `@State` keeps it stable across body /// unbounded offscreen terminals). `@State` keeps it stable across body
@@ -213,6 +219,12 @@ struct SessionListScreen: View {
} label: { } label: {
Label(ScreenCopy.addHost, systemImage: "plus") Label(ScreenCopy.addHost, systemImage: "plus")
} }
Button {
onDeviceCert()
} label: {
Label(ScreenCopy.deviceCert, systemImage: "lock.shield")
}
.accessibilityIdentifier("sessions.deviceCert")
} label: { } label: {
Label( Label(
viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback, viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback,
@@ -342,6 +354,7 @@ private enum ScreenCopy {
static let newSession = "新建会话" static let newSession = "新建会话"
static let kill = "结束" static let kill = "结束"
static let addHost = "配对新主机" static let addHost = "配对新主机"
static let deviceCert = "设备证书"
static let hostMenuFallback = "主机" static let hostMenuFallback = "主机"
static let notPairedTitle = "还没有配对的主机" static let notPairedTitle = "还没有配对的主机"
static let notPairedHint = "先配对你电脑上的 web-terminal扫码或手输地址会话会出现在这里。" static let notPairedHint = "先配对你电脑上的 web-terminal扫码或手输地址会话会出现在这里。"

View File

@@ -1,4 +1,5 @@
import APIClient import APIClient
import ClientTLS
import Foundation import Foundation
import HostRegistry import HostRegistry
import Observation import Observation
@@ -116,10 +117,21 @@ final class PairingViewModel {
@ObservationIgnored private let store: any HostStore @ObservationIgnored private let store: any HostStore
@ObservationIgnored private let probe: Probe @ObservationIgnored private let probe: Probe
/// C-iOS-3 · Whether a device client certificate is installed. Used to gate
/// the probe for tunnel hosts (mTLS-only). Injected so tests control it;
/// production reads the keychain. Defaulted so existing call sites compile.
@ObservationIgnored private let isDeviceCertInstalled: @Sendable () -> Bool
init(store: any HostStore, probe: @escaping Probe) { init(
store: any HostStore,
probe: @escaping Probe,
isDeviceCertInstalled: @escaping @Sendable () -> Bool = {
KeychainClientIdentityStore().hasInstalledIdentity()
}
) {
self.store = store self.store = store
self.probe = probe self.probe = probe
self.isDeviceCertInstalled = isDeviceCertInstalled
} }
// MARK: - Input boundaries (untrusted, validated via HostEndpoint) // MARK: - Input boundaries (untrusted, validated via HostEndpoint)
@@ -205,10 +217,20 @@ final class PairingViewModel {
private func runProbe(for pending: PendingHost) async { private func runProbe(for pending: PendingHost) async {
needsPublicRiskAcknowledgement = false needsPublicRiskAcknowledgement = false
// C-iOS-3 · Tunnel hosts are mTLS-only: refuse to probe (which would
// fail at the TLS handshake) until a device certificate is installed.
// This is the single choke point both confirmConnect and retry funnel
// through, so the gate can't be bypassed via retry().
if Self.isTunnelHost(pending.endpoint), !isDeviceCertInstalled() {
phase = .failed(pending, FailureDisplay(
message: PairingCopy.deviceCertRequired, action: .retry
))
return
}
phase = .probing(pending) phase = .probing(pending)
switch await probe(pending.endpoint) { switch await probe(pending.endpoint) {
case .failure(let error): case .failure(let error):
phase = .failed(pending, Self.display(for: error)) phase = .failed(pending, Self.display(for: error, endpoint: pending.endpoint))
case .success(let endpoint): case .success(let endpoint):
await storePairedHost(endpoint: endpoint, pending: pending) await storePairedHost(endpoint: endpoint, pending: pending)
} }
@@ -239,6 +261,19 @@ final class PairingViewModel {
// MARK: - PairingError copy + action (task RED list, one case each) // MARK: - PairingError copy + action (task RED list, one case each)
/// C-iOS-3 · Host-aware display. nginx rejects an invalid/absent/revoked
/// client cert at the TLS layer; URLSession surfaces that as
/// secureConnectionFailed / connection-reset `PairingError.classify` maps
/// it to `.tlsFailure` ("server cert invalid"), which is the WRONG diagnosis
/// for an mTLS tunnel host. Re-map that one case to the device-cert copy;
/// everything else falls through to the per-case mapping.
static func display(for error: PairingError, endpoint: HostEndpoint) -> FailureDisplay {
if case .tlsFailure = error, isTunnelHost(endpoint) {
return FailureDisplay(message: PairingCopy.clientCertRejected, action: .retry)
}
return display(for: error)
}
static func display(for error: PairingError) -> FailureDisplay { static func display(for error: PairingError) -> FailureDisplay {
switch error { switch error {
case .localNetworkDenied: case .localNetworkDenied:
@@ -270,6 +305,14 @@ final class PairingViewModel {
/// block regardless of scheme (§5.4 table: https is included in the /// block regardless of scheme (§5.4 table: https is included in the
/// public-host confirm warning); otherwise https clears every notice. /// public-host confirm warning); otherwise https clears every notice.
static func warning(for endpoint: HostEndpoint) -> SecurityWarning { static func warning(for endpoint: HostEndpoint) -> SecurityWarning {
// C-iOS-3 · A *.terminal.yaojia.wang tunnel host is gated by the device
// client certificate (mTLS), so the "anyone who can reach the port gets
// a shell" blocking warning is FALSE here and would only deter the
// intended flow. The real gate is the cert-install check in runProbe.
// Genuinely public NON-tunnel hosts still hit .publicHostBlocking below.
if isTunnelHost(endpoint) {
return .none
}
let hostClass = classifyHost(endpoint.baseURL.host ?? "") let hostClass = classifyHost(endpoint.baseURL.host ?? "")
if hostClass == .publicHost { if hostClass == .publicHost {
return .publicHostBlocking return .publicHostBlocking
@@ -353,8 +396,17 @@ final class PairingViewModel {
return octets return octets
} }
/// C-iOS-3 · A native mTLS reverse-tunnel host (`<name>.terminal.yaojia.wang`).
/// These reach a loopback base app through the VPS and are protected ONLY by
/// the device client certificate hence the softened warning + the
/// cert-install gate + the client-cert-rejected re-classification.
static func isTunnelHost(_ endpoint: HostEndpoint) -> Bool {
(endpoint.baseURL.host ?? "").lowercased().hasSuffix(tunnelZoneSuffix)
}
// MARK: - Named constants (no magic values, plan §4) // MARK: - Named constants (no magic values, plan §4)
private static let tunnelZoneSuffix = ".terminal.yaojia.wang"
private static let schemeSeparator = "://" private static let schemeSeparator = "://"
private static let defaultManualScheme = "http://" private static let defaultManualScheme = "http://"
private static let httpsScheme = "https" private static let httpsScheme = "https"
@@ -385,6 +437,13 @@ enum PairingCopy {
"TLS 连接失败:证书无效或不受信任。" "TLS 连接失败:证书无效或不受信任。"
static let timeout = static let timeout =
"连接超时。请确认主机在线、与手机在同一网络后重试。" "连接超时。请确认主机在线、与手机在同一网络后重试。"
/// C-iOS-3 · Tunnel host reached without a device certificate installed.
static let deviceCertRequired =
"请先安装本设备证书:到 设置 →「设备证书」导入 .p12 后,再连接该隧道主机。"
/// C-iOS-3 · nginx rejected the presented client certificate (invalid /
/// revoked). Surfaced in place of the mis-classified "server cert invalid".
static let clientCertRejected =
"本设备证书无效或已吊销,请重新导入。"
static func hostUnreachable(_ underlying: String) -> String { static func hostUnreachable(_ underlying: String) -> String {
"无法连接主机:\(underlying)" "无法连接主机:\(underlying)"

View File

@@ -51,6 +51,9 @@ struct AdaptiveRootView: View {
) { ) {
projectsSheet projectsSheet
} }
.sheet(isPresented: $coordinator.isDeviceCertPresented) {
deviceCertSheet
}
} }
// MARK: - Layout branch (the SOLE size-class consumer) // MARK: - Layout branch (the SOLE size-class consumer)
@@ -107,4 +110,20 @@ struct AdaptiveRootView: View {
} }
} }
} }
// MARK: - Device certificate sheet (C-iOS-3, HIGH reachability fix)
/// NavigationStack`ClientCertScreen` `.navigationTitle`+
/// .p12 `ClientCertScreen`
/// `ClientCertViewModel`keychain store
@ViewBuilder private var deviceCertSheet: some View {
NavigationStack {
ClientCertScreen()
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(RootCopy.done) { coordinator.isDeviceCertPresented = false }
}
}
}
}
} }

View File

@@ -31,6 +31,11 @@ final class AppCoordinator {
/// prefs /// prefs
private(set) var projectsViewModel: ProjectsViewModel? private(set) var projectsViewModel: ProjectsViewModel?
var isProjectsPresented = false var isProjectsPresented = false
/// C-iOS-3 (HIGH reachability fix) · "" sheet (import / rotate the
/// mTLS device cert via `ClientCertScreen`). No VM state the screen owns
/// its own `ClientCertViewModel` over the keychain store; dismissal needs no
/// refresh because the transports resolve the identity lazily per connection.
var isDeviceCertPresented = false
let sessionList: SessionListViewModel let sessionList: SessionListViewModel
@ObservationIgnored let environment: AppEnvironment @ObservationIgnored let environment: AppEnvironment
@@ -108,6 +113,13 @@ final class AppCoordinator {
projectsViewModel = nil projectsViewModel = nil
} }
// MARK: - Device certificate (C-iOS-3)
/// Toolbar host-menu / sheet
func presentDeviceCert() {
isDeviceCertPresented = true
}
/// "" sheet fresh spawn`attach(null, cwd)`+ /// "" sheet fresh spawn`attach(null, cwd)`+
/// attach `claude\r` engine attach-first /// attach `claude\r` engine attach-first
func openProject(_ request: ProjectOpenRequest) { func openProject(_ request: ProjectOpenRequest) {

View File

@@ -1,4 +1,5 @@
import APIClient import APIClient
import ClientTLS
import Foundation import Foundation
import HostRegistry import HostRegistry
import SessionCore import SessionCore
@@ -37,8 +38,20 @@ struct AppEnvironment: Sendable {
var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore() var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore()
static func production() -> AppEnvironment { static func production() -> AppEnvironment {
let http = URLSessionHTTPTransport() // C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the installed device client
let termTransport = URLSessionTermTransport() // 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( return AppEnvironment(
hostStore: KeychainHostStore(), hostStore: KeychainHostStore(),
lastSessionStore: UserDefaultsLastSessionStore(), lastSessionStore: UserDefaultsLastSessionStore(),

View File

@@ -74,7 +74,8 @@ struct StackRootView: View {
SessionListScreen( SessionListScreen(
viewModel: coordinator.sessionList, viewModel: coordinator.sessionList,
onOpen: { coordinator.open($0) }, onOpen: { coordinator.open($0) },
onAddHost: { coordinator.presentAddHost() } onAddHost: { coordinator.presentAddHost() },
onDeviceCert: { coordinator.presentDeviceCert() }
) )
.safeAreaInset(edge: .bottom) { continueLastBanner } .safeAreaInset(edge: .bottom) { continueLastBanner }
// / DS reduceMotion // / DS reduceMotion
@@ -178,4 +179,5 @@ struct ProjectsToolbarItem: ToolbarContent {
enum RootCopy { enum RootCopy {
static let continueLast = "继续上次会话" static let continueLast = "继续上次会话"
static let projects = "项目" static let projects = "项目"
static let done = "完成"
} }

View File

@@ -39,7 +39,8 @@ struct SplitRootView: View {
// selectSidebarItem // selectSidebarItem
coordinator.selectSidebarItem(sidebarItem(for: request)) coordinator.selectSidebarItem(sidebarItem(for: request))
}, },
onAddHost: { coordinator.presentAddHost() } onAddHost: { coordinator.presentAddHost() },
onDeviceCert: { coordinator.presentDeviceCert() }
) )
.safeAreaInset(edge: .bottom) { continueLastBanner } .safeAreaInset(edge: .bottom) { continueLastBanner }
.animation( .animation(

View File

@@ -1,3 +1,4 @@
import ClientTLS
import Foundation import Foundation
import WireProtocol import WireProtocol
@@ -9,14 +10,39 @@ import WireProtocol
/// bypass that single audited point (review CRITICAL). /// bypass that single audited point (review CRITICAL).
struct URLSessionHTTPTransport: HTTPTransport { struct URLSessionHTTPTransport: HTTPTransport {
private let session: URLSession private let session: URLSession
/// 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
/// Default is EPHEMERAL, not `.shared` (T-iOS-19 finding): RO GET bodies /// Fixed-identity convenience (snapshot callers / tests): wraps a constant
/// include `/live-sessions/:id/preview` raw terminal ring-buffer bytes /// provider, so behaviour is identical to capturing the identity directly.
/// that may contain printed secrets and `.shared`'s default URLCache init(identity: ClientIdentity? = nil) {
/// writes responses to disk. Ephemeral keeps them memory-only, matching self.init(identityProvider: { identity })
/// the WS transport and the privacy-shade posture. }
init(session: URLSession = URLSession(configuration: .ephemeral)) {
self.session = session /// 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?) {
let delegate = LazyClientTLSSessionDelegate(identityProvider: identityProvider)
self.tlsDelegate = delegate
self.session = URLSession(
configuration: .ephemeral, delegate: delegate, delegateQueue: nil
)
} }
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
@@ -29,3 +55,40 @@ struct URLSessionHTTPTransport: HTTPTransport {
return (data, httpResponse) return (data, httpResponse)
} }
} }
/// 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)
}
}

View File

@@ -0,0 +1,22 @@
// swift-tools-version: 6.0
// C-iOS-1 · Device client-certificate (mutual-TLS) leaf package.
//
// Standalone, no local-package dependencies imports ONLY Security/Foundation.
// It is a downward leaf: SessionCore and the App target may depend on it, it
// depends on nothing in this workspace. This keeps the pure, unit-testable
// mTLS logic (identity wrapper, PKCS#12 import, challenge responder) isolated
// from the WS/HTTP transport plumbing that consumes it.
import PackageDescription
let package = Package(
name: "ClientTLS",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [
.library(name: "ClientTLS", targets: ["ClientTLS"]),
],
targets: [
.target(name: "ClientTLS"),
.testTarget(name: "ClientTLSTests", dependencies: ["ClientTLS"]),
],
swiftLanguageModes: [.v6]
)

View File

@@ -0,0 +1,188 @@
import Foundation
import Security
/// C-iOS-3 · Display summary of a device certificate, shown on the install /
/// rotation screen so the user can confirm *which* cert is active and *when*
/// it expires before relying on it.
public struct ClientCertificateSummary: Equatable, Sendable {
/// Subject common name the device/leaf CN (e.g. `t1-iphone`).
public let subjectCommonName: String?
/// Issuer common name the device-CA CN (e.g. `webterm-device-ca`).
public let issuerCommonName: String?
/// Not-after date; `nil` if it could not be parsed.
public let notAfter: Date?
public init(subjectCommonName: String?, issuerCommonName: String?, notAfter: Date?) {
self.subjectCommonName = subjectCommonName
self.issuerCommonName = issuerCommonName
self.notAfter = notAfter
}
/// Expired relative to `now` (defaults to the current instant). Unknown
/// expiry is treated as NOT expired (fail-open for display only the TLS
/// stack, not this label, is the real gate).
public func isExpired(asOf now: Date = Date()) -> Bool {
guard let notAfter else { return false }
return notAfter < now
}
}
/// Reads the display fields off a `SecCertificate`.
///
/// Implemented as a minimal X.509 DER walk over `SecCertificateCopyData` rather
/// than `SecCertificateCopyValues`, because that API and its `kSecOID*` /
/// `kSecPropertyKey*` constants are **macOS-only** unavailable on iOS, which
/// is the real deployment target. The DER walk is identical on both platforms
/// and unit-testable against the fixture. Any parse miss degrades to `nil`
/// fields (the summary is display-only; the TLS stack is the gate).
enum CertificateInspector {
static func summary(of certificate: SecCertificate) -> ClientCertificateSummary {
let der = [UInt8](SecCertificateCopyData(certificate) as Data)
let fields = X509Fields.parse(der: der)
return ClientCertificateSummary(
subjectCommonName: fields?.subjectCommonName,
issuerCommonName: fields?.issuerCommonName,
notAfter: fields?.notAfter
)
}
}
// MARK: - Minimal ASN.1 DER reader
/// One tag-length-value element, as byte ranges into the source buffer.
private struct DERElement {
let tag: UInt8
let valueStart: Int
let valueEnd: Int
}
private enum DER {
/// DER tags used here.
static let sequence: UInt8 = 0x30
static let set: UInt8 = 0x31
static let oid: UInt8 = 0x06
static let contextTag0: UInt8 = 0xA0 // [0] EXPLICIT (X.509 version)
static let utcTime: UInt8 = 0x17
static let generalizedTime: UInt8 = 0x18
/// Read a single TLV at `start`. Returns the element and the index just
/// past it, or `nil` on any malformed length/overrun (defensive: the cert
/// bytes come from the system but are still parsed as untrusted input).
static func read(_ bytes: [UInt8], at start: Int) -> DERElement? {
guard start >= 0, start + 1 < bytes.count else { return nil }
let tag = bytes[start]
var index = start + 1
let firstLengthByte = bytes[index]
index += 1
var length = 0
if firstLengthByte & 0x80 == 0 {
length = Int(firstLengthByte)
} else {
let byteCount = Int(firstLengthByte & 0x7F)
guard byteCount > 0, byteCount <= 4, index + byteCount <= bytes.count else {
return nil
}
for _ in 0..<byteCount {
length = (length << 8) | Int(bytes[index])
index += 1
}
}
let valueEnd = index + length
guard length >= 0, valueEnd <= bytes.count else { return nil }
return DERElement(tag: tag, valueStart: index, valueEnd: valueEnd)
}
/// Split a constructed value `[start, end)` into its immediate children.
static func children(_ bytes: [UInt8], from start: Int, to end: Int) -> [DERElement] {
var elements: [DERElement] = []
var index = start
while index < end, let element = read(bytes, at: index) {
elements.append(element)
index = element.valueEnd
}
return elements
}
}
// MARK: - X.509 field extraction
private struct X509Fields {
let subjectCommonName: String?
let issuerCommonName: String?
let notAfter: Date?
/// CommonName attribute OID `2.5.4.3` DER content bytes `55 04 03`.
private static let commonNameOID: [UInt8] = [0x55, 0x04, 0x03]
/// Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signature }
/// tbsCertificate ::= SEQUENCE { [0] version?, serialNumber, signature,
/// issuer Name, validity, subject Name, subjectPublicKeyInfo, ... }
static func parse(der bytes: [UInt8]) -> X509Fields? {
guard let certificate = DER.read(bytes, at: 0), certificate.tag == DER.sequence else {
return nil
}
let certChildren = DER.children(
bytes, from: certificate.valueStart, to: certificate.valueEnd
)
guard let tbs = certChildren.first, tbs.tag == DER.sequence else { return nil }
var tbsChildren = DER.children(bytes, from: tbs.valueStart, to: tbs.valueEnd)
// Optional EXPLICIT [0] version drop it so the fixed fields align.
if let first = tbsChildren.first, first.tag == DER.contextTag0 {
tbsChildren.removeFirst()
}
// Fixed order after (optional) version: serialNumber, signature,
// issuer, validity, subject, ...
guard tbsChildren.count >= 5 else { return nil }
let issuer = tbsChildren[2]
let validity = tbsChildren[3]
let subject = tbsChildren[4]
return X509Fields(
subjectCommonName: commonName(bytes, in: subject),
issuerCommonName: commonName(bytes, in: issuer),
notAfter: notAfterDate(bytes, in: validity)
)
}
/// Name ::= SEQUENCE OF RDN(SET) OF AttributeTypeAndValue(SEQ{OID, value}).
/// Returns the first CN value found.
private static func commonName(_ bytes: [UInt8], in name: DERElement) -> String? {
for rdn in DER.children(bytes, from: name.valueStart, to: name.valueEnd)
where rdn.tag == DER.set {
for atv in DER.children(bytes, from: rdn.valueStart, to: rdn.valueEnd)
where atv.tag == DER.sequence {
let parts = DER.children(bytes, from: atv.valueStart, to: atv.valueEnd)
guard parts.count >= 2, parts[0].tag == DER.oid else { continue }
let oid = Array(bytes[parts[0].valueStart..<parts[0].valueEnd])
if oid == commonNameOID {
let value = Array(bytes[parts[1].valueStart..<parts[1].valueEnd])
// PrintableString / UTF8String both decode as UTF-8.
return String(bytes: value, encoding: .utf8)
}
}
}
return nil
}
/// Validity ::= SEQUENCE { notBefore Time, notAfter Time } the 2nd child.
private static func notAfterDate(_ bytes: [UInt8], in validity: DERElement) -> Date? {
let times = DER.children(bytes, from: validity.valueStart, to: validity.valueEnd)
guard times.count >= 2 else { return nil }
let notAfter = times[1]
let value = Array(bytes[notAfter.valueStart..<notAfter.valueEnd])
guard let text = String(bytes: value, encoding: .ascii) else { return nil }
return parseTime(text, tag: notAfter.tag)
}
private static func parseTime(_ text: String, tag: UInt8) -> Date? {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "UTC")
// UTCTime: YYMMDDHHMMSSZ (used for years < 2050); GeneralizedTime:
// YYYYMMDDHHMMSSZ. The 2-digit-year window covers the relevant range
// (device certs live in the 2020s-2040s).
formatter.dateFormat = tag == DER.utcTime ? "yyMMddHHmmss'Z'" : "yyyyMMddHHmmss'Z'"
return formatter.date(from: text)
}
}

View File

@@ -0,0 +1,55 @@
import Foundation
import Security
/// C-iOS-1 · An imported device client identity: the `SecIdentity` (leaf
/// certificate + its private key) plus the issuer chain that accompanies it in
/// the TLS handshake.
///
/// `@unchecked Sendable`: `SecIdentity` / `SecCertificate` are CoreFoundation
/// handles that are immutable once imported and thread-safe to read; this value
/// only ever holds finished imports and never mutates them. Marking it lets the
/// identity flow into the `@unchecked Sendable` WS connection and the URLSession
/// delegates that answer client-certificate challenges.
public struct ClientIdentity: @unchecked Sendable {
/// The leaf certificate + private key used to authenticate to the server.
public let secIdentity: SecIdentity
/// Issuer certificates to present alongside the leaf (the CA chain, leaf
/// excluded). May be empty when the trust anchor is already pinned server
/// side (nginx `ssl_client_certificate` = the device-CA) the leaf alone
/// then verifies at `ssl_verify_depth 1`.
public let issuerCertificates: [SecCertificate]
public init(secIdentity: SecIdentity, issuerCertificates: [SecCertificate] = []) {
self.secIdentity = secIdentity
self.issuerCertificates = issuerCertificates
}
/// The `URLCredential` handed back to a `ClientCertificate` auth challenge.
///
/// `.forSession` (not `.permanent`) per plan §C-iOS-1: the identity already
/// lives in the app's keychain item persisting the credential in the
/// shared URL credential store would be a second, unmanaged copy.
public func urlCredential(
persistence: URLCredential.Persistence = .forSession
) -> URLCredential {
URLCredential(
identity: secIdentity,
certificates: issuerCertificates.isEmpty ? nil : issuerCertificates,
persistence: persistence
)
}
/// The leaf `SecCertificate` backing this identity (for display / summary).
public func leafCertificate() -> SecCertificate? {
var certificate: SecCertificate?
let status = SecIdentityCopyCertificate(secIdentity, &certificate)
return status == errSecSuccess ? certificate : nil
}
/// Human-readable summary of the leaf certificate (subject CN, issuer CN,
/// expiry) for the install/rotation UI. `nil` only if the leaf can't be
/// read (should never happen for a valid import).
public func summary() -> ClientCertificateSummary? {
leafCertificate().map(CertificateInspector.summary(of:))
}
}

View File

@@ -0,0 +1,36 @@
import Foundation
/// C-iOS-2 · Reusable `NSObject` URLSession delegate that answers
/// **session-level** auth challenges by delegating to `MutualTLSChallengeResponder`.
///
/// This is the seam for the HTTP transport (`session.data(for:)` surfaces
/// challenges through `urlSession(_:didReceive:completionHandler:)`). The WS
/// transport can't reuse it its connection object is already the session's
/// delegate and needs the **task-level** callback so that one implements the
/// task-level method itself against the same responder.
///
/// `@unchecked Sendable`: URLSession retains its delegate and invokes it from
/// arbitrary queues; every stored field is an immutable `let` over thread-safe
/// values (`ClientIdentity` is `@unchecked Sendable`, the responder is stateless).
public final class ClientTLSSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
private let identity: ClientIdentity?
private let responder: MutualTLSChallengeResponder
public init(
identity: ClientIdentity?,
responder: MutualTLSChallengeResponder = MutualTLSChallengeResponder()
) {
self.identity = identity
self.responder = responder
super.init()
}
public func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
let resolution = responder.resolve(challenge, identity: identity)
completionHandler(resolution.disposition, resolution.credential)
}
}

View File

@@ -0,0 +1,200 @@
import Foundation
import os
import Security
/// Persists the device identity so it survives relaunch. The raw `.p12` bytes
/// **and** its passphrase are stored together (the passphrase is required to
/// re-import via `SecPKCS12Import` at every launch), then re-imported on load.
public protocol ClientIdentityStore: Sendable {
/// Validate (`SecPKCS12Import`) then persist the `.p12` + passphrase.
/// Throws `PKCS12ImportError` on a bad passphrase / corrupt file (nothing is
/// persisted in that case) and `ClientIdentityStoreError` on a storage fault.
func save(p12Data: Data, passphrase: String) throws
/// Re-import and return the stored identity; `nil` if none is installed.
func loadIdentity() throws -> ClientIdentity?
/// Display summary of the stored certificate; `nil` if none is installed.
func loadSummary() throws -> ClientCertificateSummary?
/// Delete the stored identity (rotation / removal). Idempotent.
func remove() throws
/// Cheap existence check for gating (does NOT re-import).
func hasInstalledIdentity() -> Bool
}
public enum ClientIdentityStoreError: Error, Equatable, Sendable {
/// A Keychain `SecItem*` call failed with this `OSStatus`.
case keychain(OSStatus)
/// The stored blob was present but could not be decoded.
case corruptStoredBlob
}
public extension ClientIdentityStore {
/// Convenience for composition roots: load the identity, logging and
/// swallowing errors into `nil`. A missing cert is the normal pre-install
/// state; a genuine fault must not crash launch, but is logged (never
/// silently dropped).
func loadedIdentityOrNil() -> ClientIdentity? {
do {
return try loadIdentity()
} catch {
ClientTLSLog.identity.error(
"loadIdentity failed: \(String(describing: error), privacy: .public)"
)
return nil
}
}
}
/// The stored payload `.p12` bytes plus the passphrase needed to re-import.
private struct StoredP12Blob: Codable {
let p12: Data
let passphrase: String
}
/// Keychain-backed store: one `kSecClassGenericPassword` item holding the
/// JSON-encoded `StoredP12Blob` in `kSecValueData`, protected with
/// `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (available after the first
/// unlock post-boot, never migrates off this device).
public struct KeychainClientIdentityStore: ClientIdentityStore {
public static let defaultService = "com.yaojia.webterm.clienttls"
public static let defaultAccount = "device-identity"
private let service: String
private let account: String
public init(
service: String = defaultService, account: String = defaultAccount
) {
self.service = service
self.account = account
}
public func save(p12Data: Data, passphrase: String) throws {
// Validate BEFORE persisting a wrong passphrase / corrupt file must
// surface to the install UI and leave any prior identity untouched.
_ = try PKCS12Importer.importIdentity(data: p12Data, passphrase: passphrase)
let blob = try encode(StoredP12Blob(p12: p12Data, passphrase: passphrase))
try writeItem(blob)
}
public func loadIdentity() throws -> ClientIdentity? {
guard let blob = try readBlob() else { return nil }
return try PKCS12Importer.importIdentity(
data: blob.p12, passphrase: blob.passphrase
)
}
public func loadSummary() throws -> ClientCertificateSummary? {
try loadIdentity()?.summary()
}
public func remove() throws {
let status = SecItemDelete(baseQuery() as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(status)
}
}
public func hasInstalledIdentity() -> Bool {
var query = baseQuery()
query[kSecReturnData as String] = false
query[kSecMatchLimit as String] = kSecMatchLimitOne
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
}
// MARK: - Keychain plumbing
private func baseQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
}
private func writeItem(_ data: Data) throws {
// Delete-then-add keeps the item's protection class deterministic
// (SecItemUpdate can't change kSecAttrAccessible in place).
let deleteStatus = SecItemDelete(baseQuery() as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(deleteStatus)
}
var attributes = baseQuery()
attributes[kSecValueData as String] = data
attributes[kSecAttrAccessible as String] =
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(attributes as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw ClientIdentityStoreError.keychain(addStatus)
}
}
private func readBlob() throws -> StoredP12Blob? {
var query = baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = result as? Data else {
throw ClientIdentityStoreError.keychain(status)
}
do {
return try JSONDecoder().decode(StoredP12Blob.self, from: data)
} catch {
throw ClientIdentityStoreError.corruptStoredBlob
}
}
private func encode(_ blob: StoredP12Blob) throws -> Data {
do {
return try JSONEncoder().encode(blob)
} catch {
throw ClientIdentityStoreError.corruptStoredBlob
}
}
}
/// In-memory store for previews and unit tests: same import/summary code path as
/// the keychain store (so the roundtrip is exercised) without any keychain
/// entitlement. `@unchecked Sendable` mutable blob guarded by a lock.
public final class InMemoryClientIdentityStore: ClientIdentityStore, @unchecked Sendable {
private let lock = NSLock()
private var blob: StoredP12BlobBox?
/// Boxed so the private `StoredP12Blob` type stays file-private above; this
/// mirror keeps the two bytes+passphrase without exposing the Codable type.
private struct StoredP12BlobBox {
let p12: Data
let passphrase: String
}
public init() {}
public func save(p12Data: Data, passphrase: String) throws {
_ = try PKCS12Importer.importIdentity(data: p12Data, passphrase: passphrase)
lock.withLock { blob = StoredP12BlobBox(p12: p12Data, passphrase: passphrase) }
}
public func loadIdentity() throws -> ClientIdentity? {
guard let stored = lock.withLock({ blob }) else { return nil }
return try PKCS12Importer.importIdentity(
data: stored.p12, passphrase: stored.passphrase
)
}
public func loadSummary() throws -> ClientCertificateSummary? {
try loadIdentity()?.summary()
}
public func remove() throws {
lock.withLock { blob = nil }
}
public func hasInstalledIdentity() -> Bool {
lock.withLock { blob != nil }
}
}
enum ClientTLSLog {
static let identity = Logger(subsystem: "com.yaojia.webterm", category: "client-tls")
}

View File

@@ -0,0 +1,73 @@
import Foundation
/// C-iOS-1 · The pure, synchronous decision at the heart of mutual TLS.
///
/// It maps a URLSession auth challenge (+ the optionally-installed device
/// identity) to a disposition and credential. Deliberately free of any
/// URLSession / socket state so the full truth table is unit-testable without a
/// live connection the transports (WS task delegate, HTTP session delegate)
/// are thin adapters that call `resolve` and forward its result to the
/// completion handler.
///
/// Truth table (plan §C-iOS-1):
/// a. `ClientCertificate` + identity present `.useCredential` with the
/// identity's `URLCredential`.
/// b. `ClientCertificate` + no identity `.cancelAuthenticationChallenge`
/// (a clean, classifiable failure never a silent no-cert continue).
/// c. `ServerTrust` `.performDefaultHandling`
/// (the LE wildcard is validated by the system trust store).
/// d. anything else `.performDefaultHandling`.
public struct MutualTLSChallengeResponder: Sendable {
/// The responder's decision. Not `Sendable` (it may carry a `URLCredential`,
/// which isn't) it is produced and consumed synchronously on the delegate
/// queue, never sent across isolation domains.
public struct Resolution {
public let disposition: URLSession.AuthChallengeDisposition
public let credential: URLCredential?
public init(
disposition: URLSession.AuthChallengeDisposition,
credential: URLCredential? = nil
) {
self.disposition = disposition
self.credential = credential
}
}
public init() {}
/// Resolve a full `URLAuthenticationChallenge` by dispatching on its
/// authentication method.
public func resolve(
_ challenge: URLAuthenticationChallenge, identity: ClientIdentity?
) -> Resolution {
resolve(
authenticationMethod: challenge.protectionSpace.authenticationMethod,
identity: identity
)
}
/// Method-keyed core (challenge-free) the directly-tested pure function.
public func resolve(
authenticationMethod: String, identity: ClientIdentity?
) -> Resolution {
switch authenticationMethod {
case NSURLAuthenticationMethodClientCertificate:
guard let identity else {
// (b) No installed identity: cancel so the failure surfaces as a
// classifiable client-cert error rather than a bare TLS reset.
return Resolution(disposition: .cancelAuthenticationChallenge)
}
// (a) Present the device certificate for this session.
return Resolution(
disposition: .useCredential, credential: identity.urlCredential()
)
case NSURLAuthenticationMethodServerTrust:
// (c) System-trusted LE wildcard default evaluation.
return Resolution(disposition: .performDefaultHandling)
default:
// (d) Basic/Digest/NTLM/etc. not used by this app; default.
return Resolution(disposition: .performDefaultHandling)
}
}
}

View File

@@ -0,0 +1,89 @@
import Foundation
import Security
/// Failure modes of a PKCS#12 import, each mapped to actionable install-UX copy
/// (C-iOS-3). The three plan-named `OSStatus` mappings are honored:
/// `errSecAuthFailed .wrongPassphrase`, `errSecDecode .corruptFile`, and any
/// other non-success status (unsupported/unknown format the plan's "errSecPkg"
/// bucket) `.unsupported`.
public enum PKCS12ImportError: Error, Equatable, Sendable {
/// Wrong passphrase (`errSecAuthFailed`).
case wrongPassphrase
/// Not a decodable PKCS#12 blob (`errSecDecode`) truncated / not a `.p12`.
case corruptFile
/// Decoded, but the format/algorithms aren't importable on this OS
/// (any other non-success `OSStatus`). Retains the raw status for logs.
case unsupported(OSStatus)
/// Import succeeded but carried no `SecIdentity` (e.g. a certs-only `.p12`).
case noIdentity
public static func == (lhs: PKCS12ImportError, rhs: PKCS12ImportError) -> Bool {
switch (lhs, rhs) {
case (.wrongPassphrase, .wrongPassphrase),
(.corruptFile, .corruptFile),
(.noIdentity, .noIdentity):
return true
case let (.unsupported(a), .unsupported(b)):
return a == b
default:
return false
}
}
}
/// Imports a `.p12` into a `ClientIdentity` via `SecPKCS12Import`.
///
/// Memory-only: `SecPKCS12Import` returns the identity/chain as CoreFoundation
/// handles without writing to the keychain, so callers control persistence
/// (see `KeychainClientIdentityStore`).
public enum PKCS12Importer {
public static func importIdentity(
data: Data, passphrase: String
) throws -> ClientIdentity {
let options = [kSecImportExportPassphrase as String: passphrase] as CFDictionary
var rawItems: CFArray?
let status = SecPKCS12Import(data as CFData, options, &rawItems)
switch status {
case errSecSuccess:
break
case errSecAuthFailed:
throw PKCS12ImportError.wrongPassphrase
case errSecDecode:
throw PKCS12ImportError.corruptFile
default:
// Covers unsupported formats/algorithms and any other failure
// the plan's `errSecPkg .unsupported` mapping (that symbol does
// not exist in the SDK; the status is preserved for diagnostics).
throw PKCS12ImportError.unsupported(status)
}
guard let items = rawItems as? [[String: Any]], let first = items.first,
let identityValue = first[kSecImportItemIdentity as String]
else {
throw PKCS12ImportError.noIdentity
}
// Force-cast is safe: `kSecImportItemIdentity` is always a SecIdentity.
let secIdentity = identityValue as! SecIdentity
let chain = (first[kSecImportItemCertChain as String] as? [SecCertificate]) ?? []
return ClientIdentity(
secIdentity: secIdentity,
issuerCertificates: issuerChain(from: chain, identity: secIdentity)
)
}
/// The chain from `SecPKCS12Import` includes the leaf at index 0; the
/// `URLCredential` must carry only the *issuer* certs (Apple: do not repeat
/// the identity's own cert). Drops whichever chain entry DER-matches the
/// identity's leaf.
private static func issuerChain(
from chain: [SecCertificate], identity: SecIdentity
) -> [SecCertificate] {
var leaf: SecCertificate?
SecIdentityCopyCertificate(identity, &leaf)
guard let leafData = leaf.map({ SecCertificateCopyData($0) as Data }) else {
return chain
}
return chain.filter { SecCertificateCopyData($0) as Data != leafData }
}
}

View File

@@ -0,0 +1,62 @@
import Foundation
import Testing
@testable import ClientTLS
// C-iOS-1 · Store roundtrip via the InMemory store (same import/summary code
// path as the keychain store, minus the entitlement the keychain variant is
// covered by on-device/simulator tests, mirroring KeychainHostStoreLiveTests).
@Test("save → loadIdentity roundtrips and hasInstalledIdentity flips")
func storeRoundtrip() throws {
// Arrange
let store = InMemoryClientIdentityStore()
#expect(store.hasInstalledIdentity() == false)
#expect(try store.loadIdentity() == nil)
// Act
try store.save(
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
)
// Assert
#expect(store.hasInstalledIdentity() == true)
let identity = try #require(try store.loadIdentity())
#expect(identity.summary()?.subjectCommonName == ClientTLSFixtures.leafCommonName)
let summary = try #require(try store.loadSummary())
#expect(summary.issuerCommonName == ClientTLSFixtures.issuerCommonName)
}
@Test("save validates the passphrase and leaves prior state untouched on failure")
func storeSaveValidatesBeforePersisting() {
// Arrange
let store = InMemoryClientIdentityStore()
// Act / Assert a wrong passphrase must surface, not persist.
#expect(throws: PKCS12ImportError.wrongPassphrase) {
try store.save(p12Data: ClientTLSFixtures.deviceP12Data, passphrase: "wrong")
}
#expect(store.hasInstalledIdentity() == false)
}
@Test("remove clears the stored identity")
func storeRemove() throws {
// Arrange
let store = InMemoryClientIdentityStore()
try store.save(
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
)
#expect(store.hasInstalledIdentity() == true)
// Act
try store.remove()
// Assert
#expect(store.hasInstalledIdentity() == false)
#expect(try store.loadIdentity() == nil)
}
@Test("loadedIdentityOrNil returns nil (not a throw) when nothing is installed")
func loadedIdentityOrNilEmpty() {
let store = InMemoryClientIdentityStore()
#expect(store.loadedIdentityOrNil() == nil)
}

View File

@@ -0,0 +1,33 @@
import Foundation
/// Embedded test fixture: a real PKCS#12 generated at authoring time with
/// OpenSSL (EC P-256 self-signed device-CA EC P-256 leaf, `EKU=clientAuth`,
/// exported `-legacy` for iOS/Android import parity mirrors
/// `deploy/scripts/gen-device-ca.sh` / `issue-device-cert.sh`).
///
/// Embedded as base64 rather than an SPM resource so the MUST-PASS package test
/// runs headless with no `Bundle.module` resource wiring. Regenerate with:
/// openssl ecparam -name prime256v1 -genkey -noout -out ca.key.pem
/// openssl req -x509 -new -key ca.key.pem -sha256 -days 3650 \
/// -subj "/CN=webterm-device-ca-fixture" \
/// -addext "basicConstraints=critical,CA:TRUE" \
/// -addext "keyUsage=critical,keyCertSign,cRLSign" -out ca.cert.pem
/// openssl ecparam -name prime256v1 -genkey -noout -out leaf.key.pem
/// openssl req -new -key leaf.key.pem -subj "/CN=device-fixture" -out leaf.csr.pem
/// openssl x509 -req -in leaf.csr.pem -CA ca.cert.pem -CAkey ca.key.pem \
/// -CAcreateserial -days 825 -sha256 -extfile leaf.ext -out leaf.cert.pem
/// openssl pkcs12 -export -legacy -inkey leaf.key.pem -in leaf.cert.pem \
/// -certfile ca.cert.pem -passout pass:clienttls-test-pass \
/// -name device-fixture -out device.p12 ; base64 device.p12
enum ClientTLSFixtures {
static let passphrase = "clienttls-test-pass"
static let leafCommonName = "device-fixture"
static let issuerCommonName = "webterm-device-ca-fixture"
static var deviceP12Data: Data {
Data(base64Encoded: deviceP12Base64)!
}
// swiftlint:disable:next line_length
static let deviceP12Base64 = "MIIF8wIBAzCCBbkGCSqGSIb3DQEHAaCCBaoEggWmMIIFojCCBGcGCSqGSIb3DQEHBqCCBFgwggRUAgEAMIIETQYJKoZIhvcNAQcBMBwGCiqGSIb3DQEMAQYwDgQIKBUqQZQxk0wCAggAgIIEIBIkpttDOWO6edQjmr9V3ASfsuc0m+Wdl8//Yrt5FPgz5HOhYZKhOUXhBLWyGXPnTx8fBrC1Yk4YmXwQisHT/4PsfvwIAgF9PBFo3UNwsQLATWHrplUF1rTO9uB5Luju306Ox9QYB+VP64BOFxdixtvhjZF8LemfJ6bV/9DWmXOk5UoGdd0c5/6WeYUTfr1aFG/TJo+GOHa5CD5fW5Mr4SejYKNcvvT2Fki5kCKAQRbhJb8W7+RzcmYoF6ECzCNeSnDNenpZm2xowpdGP/6d3U7MbrXj4bmAtQYljAnA391atw/bm2T9d8Q6CPx5QD+WqMNdKN6tYV9OvDB6XB+VPimVMd0GCNYaIKazHj0ohNYqvEYDjSaYgbBvLHJyJRqnumyjwMz4o26NoTGm1m/U28pUGUJL3njYhuyxKilat85oXT3YO4x16bCeF7fmuGVkakeZjrxvxAKap8rD5yPaDd533Va9+xg1byIj7h01Q5tiOXO3sqkEYB1XI9/n78Et5eZItMYEH53v4uE9NRWZmetA7TQ5uRon4EK1ajKIJaQi/6lGdCAzLf5tysZ7A8vfJPJeBRFI2a9QYQzcyL8sZWEfAhJszRo59g6LWZn87DXL6EFEP/UvV0tDy6kPz/OzUmDGMrWNbeCX/5zI/xaFaf+2OpiXkJYwMUybi9JkTmCfNeOLdpPjHI0lJ47iG2cmiiMNYWJ5hXKqmdxigQt5W/5WShjD4iYh6PcjzdD6IBD5UT8d3fTKWa4cvJs2bnILjElXOWo+s8o5NGAL359+QbCQfiWYha1P4SdSNhX98SXy0K6Dla0+Ny+miEVIf5N0jXvNlsJnnjjGgK+9gPGlU9mEbpF+4EqV2XgGp+Tg8ZGO489kK2S37gJTKMUhU+haMhAF0eoj5FSMN30LcXnHTjMjsps4hpeGeUu8gb0dm96+vvmAAibt9wnCULgMFKIEpP10IY5l4D8puLTi+smvf0MMvvrGRC3+aAq+jfemQEmJZgfhlhtr1O5DdUoTvcWazmNdNScwoJ68M/D/45pplBUglECub/Ib34HKNGmEMX5+tbDEHxe589A2Jwxvq1meycCnj3IoL/lXSprB7jWt3ZInURTJItaS5GSw9D4vLboe4OISAHZftdgGJea2rXJYPZk6N5L0EbLfftEh+zzOTb3Zr3MsIiCdxpWLshaDfiijRgVYsAd0rsx0LG+8+Icb33KvN3MK/ol8PjavM+T7F2qUIiKyaA5eZ9B5CAkm7YGvrE1TBYHeEpsgvaGnRyl3PcPYSCoOQ5ckZ67briJlf6OpZM+5P6u/MP/BWhQa8Ksox2SO4aZEtDzceWAHI4ccJSbD8h/jpoLZbSm2Z+CqNUmqr1atZnZgpgArKdcnxErljkCOfkp+k6nRkhg5RkbOCjCCATMGCSqGSIb3DQEHAaCCASQEggEgMIIBHDCCARgGCyqGSIb3DQEMCgECoIG0MIGxMBwGCiqGSIb3DQEMAQMwDgQIybmfLdxQ2ZUCAggABIGQQm1iiwDNnPOMO1rBboRmVPjVzV8OLeEVLNz2qhOhSR7nEBMSw289dZzdgbY/PZh2GrO0duJSHIzjBj7W9ShHVB6zdl0kZx1JRh5aJie3eEZ76l9zVOZadp2T0wCd7HD7c4rOYJFjuTwPVB4/GWzlA3r2HVGiyuQR8N8Z855M97/KrUCByR6HG1Nl8e1+EYyrMVIwIwYJKoZIhvcNAQkVMRYEFBZc8x6cgn9EB/NImL993PHArfe6MCsGCSqGSIb3DQEJFDEeHhwAZABlAHYAaQBjAGUALQBmAGkAeAB0AHUAcgBlMDEwITAJBgUrDgMCGgUABBQGw7bWopAP93FLjXc4LIiReHWd5AQISUwQzLqUrPECAggA"
}

View File

@@ -0,0 +1,114 @@
import Foundation
import Testing
@testable import ClientTLS
// C-iOS-1 · The responder truth table: 3 challenge types × identity present /
// absent. Exercised through the pure method-keyed core AND through a real
// `URLAuthenticationChallenge` (proving the method is extracted correctly),
// with no live socket.
private let responder = MutualTLSChallengeResponder()
private func makeIdentity() throws -> ClientIdentity {
try PKCS12Importer.importIdentity(
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
)
}
// MARK: - ClientCertificate
@Test("ClientCertificate + identity → .useCredential with a credential")
func clientCertWithIdentityUsesCredential() throws {
// Arrange
let identity = try makeIdentity()
// Act
let resolution = responder.resolve(
authenticationMethod: NSURLAuthenticationMethodClientCertificate, identity: identity
)
// Assert
#expect(resolution.disposition == .useCredential)
#expect(resolution.credential != nil)
#expect(resolution.credential?.identity != nil)
}
@Test("ClientCertificate + no identity → .cancelAuthenticationChallenge, no credential")
func clientCertWithoutIdentityCancels() {
// Act
let resolution = responder.resolve(
authenticationMethod: NSURLAuthenticationMethodClientCertificate, identity: nil
)
// Assert
#expect(resolution.disposition == .cancelAuthenticationChallenge)
#expect(resolution.credential == nil)
}
// MARK: - ServerTrust
@Test("ServerTrust → .performDefaultHandling regardless of identity")
func serverTrustDefaultHandling() throws {
let identity = try makeIdentity()
for id in [identity, nil] as [ClientIdentity?] {
let resolution = responder.resolve(
authenticationMethod: NSURLAuthenticationMethodServerTrust, identity: id
)
#expect(resolution.disposition == .performDefaultHandling)
#expect(resolution.credential == nil)
}
}
// MARK: - Other method (e.g. Basic)
@Test("an unrelated method → .performDefaultHandling regardless of identity")
func otherMethodDefaultHandling() throws {
let identity = try makeIdentity()
for id in [identity, nil] as [ClientIdentity?] {
let resolution = responder.resolve(
authenticationMethod: NSURLAuthenticationMethodHTTPBasic, identity: id
)
#expect(resolution.disposition == .performDefaultHandling)
#expect(resolution.credential == nil)
}
}
// MARK: - Full URLAuthenticationChallenge extraction
@Test("resolve(challenge:) extracts the method from the protection space")
func resolveFromRealChallenge() throws {
// Arrange a real challenge for the ClientCertificate method.
let identity = try makeIdentity()
let challenge = makeChallenge(method: NSURLAuthenticationMethodClientCertificate)
// Act
let withIdentity = responder.resolve(challenge, identity: identity)
let withoutIdentity = responder.resolve(challenge, identity: nil)
// Assert
#expect(withIdentity.disposition == .useCredential)
#expect(withIdentity.credential != nil)
#expect(withoutIdentity.disposition == .cancelAuthenticationChallenge)
}
// MARK: - Helpers
/// Minimal sender so `URLAuthenticationChallenge` can be constructed in-process
/// (its designated init requires a non-optional sender). The responder never
/// calls back into the sender it only reads `protectionSpace`.
private final class NoopChallengeSender: NSObject, URLAuthenticationChallengeSender {
func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {}
func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {}
func cancel(_ challenge: URLAuthenticationChallenge) {}
}
private func makeChallenge(method: String) -> URLAuthenticationChallenge {
let space = URLProtectionSpace(
host: "t1.terminal.yaojia.wang", port: 443, protocol: "https",
realm: nil, authenticationMethod: method
)
return URLAuthenticationChallenge(
protectionSpace: space, proposedCredential: nil, previousFailureCount: 0,
failureResponse: nil, error: nil, sender: NoopChallengeSender()
)
}

View File

@@ -0,0 +1,69 @@
import Foundation
import Security
import Testing
@testable import ClientTLS
// C-iOS-1 · PKCS#12 import against a real OpenSSL-generated fixture `.p12`,
// including the wrong-passphrase and corrupt-file error mappings.
@Test("import with the correct passphrase yields an identity whose leaf CN matches")
func importSucceedsAndExposesLeaf() throws {
// Arrange
let data = ClientTLSFixtures.deviceP12Data
// Act
let identity = try PKCS12Importer.importIdentity(
data: data, passphrase: ClientTLSFixtures.passphrase
)
// Assert
let summary = try #require(identity.summary())
#expect(summary.subjectCommonName == ClientTLSFixtures.leafCommonName)
#expect(summary.issuerCommonName == ClientTLSFixtures.issuerCommonName)
// 825-day leaf minted at authoring time not yet expired.
#expect(summary.isExpired() == false)
}
@Test("import drops the leaf from the issuer chain (credential must not repeat it)")
func importIssuerChainExcludesLeaf() throws {
// Arrange / Act
let identity = try PKCS12Importer.importIdentity(
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
)
// Assert fixture chain is [leaf, CA]; issuerCertificates keeps only the CA.
let leaf = try #require(identity.leafCertificate())
let leafData = SecCertificateCopyData(leaf) as Data
#expect(identity.issuerCertificates.count == 1)
for issuer in identity.issuerCertificates {
#expect((SecCertificateCopyData(issuer) as Data) != leafData)
}
}
@Test("wrong passphrase maps errSecAuthFailed → .wrongPassphrase")
func importWrongPassphrase() {
// Arrange / Act / Assert
#expect(throws: PKCS12ImportError.wrongPassphrase) {
_ = try PKCS12Importer.importIdentity(
data: ClientTLSFixtures.deviceP12Data, passphrase: "not-the-passphrase"
)
}
}
@Test("garbage bytes map errSecDecode → .corruptFile")
func importCorruptFile() {
// Arrange
let garbage = Data([0x00, 0x01, 0x02, 0x03, 0x99, 0xAB, 0xCD, 0xEF])
// Act / Assert
#expect(throws: PKCS12ImportError.corruptFile) {
_ = try PKCS12Importer.importIdentity(data: garbage, passphrase: "x")
}
}
@Test("empty data is treated as a corrupt file, not a crash")
func importEmptyData() {
#expect(throws: PKCS12ImportError.corruptFile) {
_ = try PKCS12Importer.importIdentity(data: Data(), passphrase: "x")
}
}

View File

@@ -1,7 +1,9 @@
// swift-tools-version: 6.0 // swift-tools-version: 6.0
// T-iOS-1 scaffold shell. SessionEngine / ReconnectMachine / PingScheduler / // T-iOS-1 scaffold shell. SessionEngine / ReconnectMachine / PingScheduler /
// GateState / AwayDigest / URLSessionTermTransport land in W1W2 tasks. // 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 import PackageDescription
let package = Package( let package = Package(
@@ -12,12 +14,16 @@ let package = Package(
], ],
dependencies: [ dependencies: [
.package(path: "../WireProtocol"), .package(path: "../WireProtocol"),
.package(path: "../ClientTLS"),
.package(path: "../TestSupport"), .package(path: "../TestSupport"),
], ],
targets: [ targets: [
.target( .target(
name: "SessionCore", name: "SessionCore",
dependencies: [.product(name: "WireProtocol", package: "WireProtocol")] dependencies: [
.product(name: "WireProtocol", package: "WireProtocol"),
.product(name: "ClientTLS", package: "ClientTLS"),
]
), ),
.testTarget( .testTarget(
name: "SessionCoreTests", name: "SessionCoreTests",

View File

@@ -1,3 +1,4 @@
import ClientTLS
import Darwin import Darwin
import Foundation import Foundation
import WireProtocol import WireProtocol
@@ -76,16 +77,45 @@ protocol ConnectionPinger: Sendable {
public struct URLSessionTermTransport: TermTransport { public struct URLSessionTermTransport: TermTransport {
/// `URLSessionWebSocketTask.maximumMessageSize` applied to every task. /// `URLSessionWebSocketTask.maximumMessageSize` applied to every task.
private let maxMessageBytes: Int 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() { /// Fixed-identity convenience (snapshot callers / tests): wraps a constant
self.init(maxMessageBytes: Tunables.maxWSMessageBytes) /// 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 /// Internal TEST seam: a shrunken cap makes the oversizeEMSGSIZE path
/// deterministic without 16 MiB fixtures. Production code paths always go /// deterministic without 16 MiB fixtures. Production code paths always go
/// through `init()` and the frozen `Tunables.maxWSMessageBytes`. /// through `init()` / `init(identityProvider:)` and the frozen
init(maxMessageBytes: Int) { /// `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.maxMessageBytes = maxMessageBytes
self.identityProvider = identityProvider
} }
public func connect(to endpoint: HostEndpoint) async throws -> TransportConnection { 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; /// 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 { 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 frames: AsyncThrowingStream<String, any Error>
private let framesContinuation: AsyncThrowingStream<String, any Error>.Continuation 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() { private override init() {
(frames, framesContinuation) = AsyncThrowingStream<String, any Error>.makeStream() (frames, framesContinuation) = AsyncThrowingStream<String, any Error>.makeStream()
super.init() super.init()
@@ -143,9 +184,13 @@ final class WSConnection: NSObject, @unchecked Sendable {
/// Connect: build session+task, resume, await the delegate-driven /// Connect: build session+task, resume, await the delegate-driven
/// handshake (didOpen / didCompleteWithError no receive-error guessing), /// handshake (didOpen / didCompleteWithError no receive-error guessing),
/// then start the re-arming receive loop. /// 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() let connection = WSConnection()
connection.configure(endpoint: endpoint, maxMessageBytes: maxMessageBytes) connection.configure(
endpoint: endpoint, maxMessageBytes: maxMessageBytes, identity: identity
)
try await connection.performHandshake() try await connection.performHandshake()
connection.startReceiveLoop() connection.startReceiveLoop()
return connection return connection
@@ -163,7 +208,12 @@ final class WSConnection: NSObject, @unchecked Sendable {
// MARK: Setup // 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). // Origin: SINGLE source of truth = endpoint.originHeader (plan §5.1).
var request = URLRequest(url: endpoint.wsURL) var request = URLRequest(url: endpoint.wsURL)
request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin") request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin")
@@ -311,6 +361,22 @@ extension WSConnection: URLSessionWebSocketDelegate {
takeHandshakeContinuation()?.resume() 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 /// Server close frame processed CLEAN close: the stream FINISHES
/// (distinguishable from the transport-error THROW path). /// (distinguishable from the transport-error THROW path).
func urlSession( func urlSession(

View File

@@ -243,6 +243,29 @@ struct URLSessionTermTransportTests {
await connection.close() 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)") @Test("connect to a dead port throws (handshake failure path, no hang)")
func connectToDeadPortThrows() async throws { func connectToDeadPortThrows() async throws {
let server = ScriptedWSServer() let server = ScriptedWSServer()
@@ -254,5 +277,14 @@ struct URLSessionTermTransportTests {
_ = try await URLSessionTermTransport().connect(to: endpoint) _ = 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 #endif

View File

@@ -31,6 +31,8 @@ packages:
path: Packages/HostRegistry path: Packages/HostRegistry
APIClient: APIClient:
path: Packages/APIClient path: Packages/APIClient
ClientTLS:
path: Packages/ClientTLS # C-iOS · device client-cert (mTLS) leaf package
TestSupport: TestSupport:
path: Packages/TestSupport # test doubles — WebTermTests only, never the app target path: Packages/TestSupport # test doubles — WebTermTests only, never the app target
# SwiftTerm is the ONLY third-party dependency, attached to the App target # SwiftTerm is the ONLY third-party dependency, attached to the App target
@@ -50,6 +52,7 @@ targets:
- package: SessionCore - package: SessionCore
- package: HostRegistry - package: HostRegistry
- package: APIClient - package: APIClient
- package: ClientTLS
- package: SwiftTerm - package: SwiftTerm
settings: settings:
base: base:
@@ -119,6 +122,7 @@ targets:
- package: SessionCore - package: SessionCore
- package: HostRegistry - package: HostRegistry
- package: APIClient - package: APIClient
- package: ClientTLS
- package: TestSupport - package: TestSupport
settings: settings:
base: base: