From e38e6d16896f025a369aa5a8cb2d581d4c02d7e1 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 7 Jul 2026 09:42:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(ios):=20device=20client-cert=20mTLS=20?= =?UTF-8?q?=E2=80=94=20ClientTLS=20package,=20transport=20wiring,=20instal?= =?UTF-8?q?l=20UX=20(C-iOS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../WebTerm/Components/SessionThumbnail.swift | 18 +- .../WebTerm/Screens/ClientCertScreen.swift | 243 ++++++++++++++++++ .../WebTerm/Screens/SessionListScreen.swift | 13 + .../WebTerm/ViewModels/PairingViewModel.swift | 63 ++++- ios/App/WebTerm/Wiring/AdaptiveRootView.swift | 19 ++ ios/App/WebTerm/Wiring/AppCoordinator.swift | 12 + ios/App/WebTerm/Wiring/AppEnvironment.swift | 17 +- ios/App/WebTerm/Wiring/RootView.swift | 4 +- ios/App/WebTerm/Wiring/SplitRootView.swift | 3 +- .../Wiring/URLSessionHTTPTransport.swift | 77 +++++- ios/Packages/ClientTLS/Package.swift | 22 ++ .../ClientTLS/CertificateSummary.swift | 188 ++++++++++++++ .../Sources/ClientTLS/ClientIdentity.swift | 55 ++++ .../ClientTLS/ClientTLSSessionDelegate.swift | 36 +++ .../KeychainClientIdentityStore.swift | 200 ++++++++++++++ .../MutualTLSChallengeResponder.swift | 73 ++++++ .../Sources/ClientTLS/PKCS12Importer.swift | 89 +++++++ .../ClientIdentityStoreTests.swift | 62 +++++ .../Tests/ClientTLSTests/Fixtures.swift | 33 +++ .../MutualTLSChallengeResponderTests.swift | 114 ++++++++ .../ClientTLSTests/PKCS12ImporterTests.swift | 69 +++++ ios/Packages/SessionCore/Package.swift | 10 +- .../SessionCore/URLSessionTermTransport.swift | 84 +++++- .../URLSessionTermTransportTests.swift | 32 +++ ios/project.yml | 4 + 25 files changed, 1510 insertions(+), 30 deletions(-) create mode 100644 ios/App/WebTerm/Screens/ClientCertScreen.swift create mode 100644 ios/Packages/ClientTLS/Package.swift create mode 100644 ios/Packages/ClientTLS/Sources/ClientTLS/CertificateSummary.swift create mode 100644 ios/Packages/ClientTLS/Sources/ClientTLS/ClientIdentity.swift create mode 100644 ios/Packages/ClientTLS/Sources/ClientTLS/ClientTLSSessionDelegate.swift create mode 100644 ios/Packages/ClientTLS/Sources/ClientTLS/KeychainClientIdentityStore.swift create mode 100644 ios/Packages/ClientTLS/Sources/ClientTLS/MutualTLSChallengeResponder.swift create mode 100644 ios/Packages/ClientTLS/Sources/ClientTLS/PKCS12Importer.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/ClientIdentityStoreTests.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/Fixtures.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/MutualTLSChallengeResponderTests.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/PKCS12ImporterTests.swift diff --git a/ios/App/WebTerm/Components/SessionThumbnail.swift b/ios/App/WebTerm/Components/SessionThumbnail.swift index 4619f5d..4b05e45 100644 --- a/ios/App/WebTerm/Components/SessionThumbnail.swift +++ b/ios/App/WebTerm/Components/SessionThumbnail.swift @@ -1,4 +1,5 @@ import APIClient +import ClientTLS import Foundation import os import SwiftUI @@ -225,12 +226,19 @@ final class SessionThumbnailPipeline { cache = SessionThumbnailCache(maxEntries: maxCacheEntries) } - /// 生产装配:共享一个 ephemeral URLSession(preview 字节可能含屏上密钥, - /// 内存缓存 only——同 T-iOS-19 对 RO GET 的裁定)。 + /// 生产装配:ephemeral URLSession(preview 字节可能含屏上密钥,内存缓存 + /// only——同 T-iOS-19 对 RO GET 的裁定),并携带 C-iOS-2 的设备证书身份, + /// 这样对隧道主机的预览取数也能通过 mTLS(否则缩略图会被 nginx 拒握手)。 + /// 身份经 provider 每次握手时按需从 keychain 载入(MEDIUM 无需重启修复: + /// 中途导入的证书下次取数即生效;缺证=nil,对本地主机无副作用)。 static func live() -> SessionThumbnailPipeline { - SessionThumbnailPipeline( + let identityStore = KeychainClientIdentityStore() + let transport = URLSessionHTTPTransport(identityProvider: { + identityStore.loadedIdentityOrNil() + }) + return SessionThumbnailPipeline( loader: { request in - try await APIClient(endpoint: request.endpoint, http: liveTransport) + try await APIClient(endpoint: request.endpoint, http: transport) .preview(id: request.sessionId) }, renderer: { data, cols, rows in @@ -239,8 +247,6 @@ final class SessionThumbnailPipeline { ) } - private static let liveTransport = URLSessionHTTPTransport() - /// 取(或渲染)一张缩略图。永不抛错:任何失败显式降级为 `.placeholder` /// (占位图就是缩略图的错误 UI;细节进内部日志,绝不静默无痕)。 func thumbnail(for request: SessionThumbnailRequest) async -> SessionThumbnailImage { diff --git a/ios/App/WebTerm/Screens/ClientCertScreen.swift b/ios/App/WebTerm/Screens/ClientCertScreen.swift new file mode 100644 index 0000000..679dd47 --- /dev/null +++ b/ios/App/WebTerm/Screens/ClientCertScreen.swift @@ -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 = "移除证书失败,请重试。" +} diff --git a/ios/App/WebTerm/Screens/SessionListScreen.swift b/ios/App/WebTerm/Screens/SessionListScreen.swift index 39a3a3f..7113323 100644 --- a/ios/App/WebTerm/Screens/SessionListScreen.swift +++ b/ios/App/WebTerm/Screens/SessionListScreen.swift @@ -23,6 +23,12 @@ struct SessionListScreen: View { var onOpen: (SessionListViewModel.OpenRequest) -> Void = { _ in } /// Host-switch header hook: "添加主机" entry (pairing sheet, T-iOS-15). 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 /// render-concurrency gate across ALL rows (scrolling must never spawn /// unbounded offscreen terminals). `@State` keeps it stable across body @@ -213,6 +219,12 @@ struct SessionListScreen: View { } label: { Label(ScreenCopy.addHost, systemImage: "plus") } + Button { + onDeviceCert() + } label: { + Label(ScreenCopy.deviceCert, systemImage: "lock.shield") + } + .accessibilityIdentifier("sessions.deviceCert") } label: { Label( viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback, @@ -342,6 +354,7 @@ private enum ScreenCopy { static let newSession = "新建会话" static let kill = "结束" static let addHost = "配对新主机" + static let deviceCert = "设备证书" static let hostMenuFallback = "主机" static let notPairedTitle = "还没有配对的主机" static let notPairedHint = "先配对你电脑上的 web-terminal(扫码或手输地址),会话会出现在这里。" diff --git a/ios/App/WebTerm/ViewModels/PairingViewModel.swift b/ios/App/WebTerm/ViewModels/PairingViewModel.swift index a96dacd..7db901f 100644 --- a/ios/App/WebTerm/ViewModels/PairingViewModel.swift +++ b/ios/App/WebTerm/ViewModels/PairingViewModel.swift @@ -1,4 +1,5 @@ import APIClient +import ClientTLS import Foundation import HostRegistry import Observation @@ -116,10 +117,21 @@ final class PairingViewModel { @ObservationIgnored private let store: any HostStore @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.probe = probe + self.isDeviceCertInstalled = isDeviceCertInstalled } // MARK: - Input boundaries (untrusted, validated via HostEndpoint) @@ -205,10 +217,20 @@ final class PairingViewModel { private func runProbe(for pending: PendingHost) async { 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) switch await probe(pending.endpoint) { 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): await storePairedHost(endpoint: endpoint, pending: pending) } @@ -239,6 +261,19 @@ final class PairingViewModel { // 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 { switch error { case .localNetworkDenied: @@ -270,6 +305,14 @@ final class PairingViewModel { /// block regardless of scheme (§5.4 table: https is included in the /// public-host confirm warning); otherwise https clears every notice. 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 ?? "") if hostClass == .publicHost { return .publicHostBlocking @@ -353,8 +396,17 @@ final class PairingViewModel { return octets } + /// C-iOS-3 · A native mTLS reverse-tunnel host (`.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) + private static let tunnelZoneSuffix = ".terminal.yaojia.wang" private static let schemeSeparator = "://" private static let defaultManualScheme = "http://" private static let httpsScheme = "https" @@ -385,6 +437,13 @@ enum PairingCopy { "TLS 连接失败:证书无效或不受信任。" 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 { "无法连接主机:\(underlying)" diff --git a/ios/App/WebTerm/Wiring/AdaptiveRootView.swift b/ios/App/WebTerm/Wiring/AdaptiveRootView.swift index fb25f0b..716e380 100644 --- a/ios/App/WebTerm/Wiring/AdaptiveRootView.swift +++ b/ios/App/WebTerm/Wiring/AdaptiveRootView.swift @@ -51,6 +51,9 @@ struct AdaptiveRootView: View { ) { projectsSheet } + .sheet(isPresented: $coordinator.isDeviceCertPresented) { + deviceCertSheet + } } // 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 } + } + } + } + } } diff --git a/ios/App/WebTerm/Wiring/AppCoordinator.swift b/ios/App/WebTerm/Wiring/AppCoordinator.swift index 4ba86bc..0f1b30e 100644 --- a/ios/App/WebTerm/Wiring/AppCoordinator.swift +++ b/ios/App/WebTerm/Wiring/AppCoordinator.swift @@ -31,6 +31,11 @@ final class AppCoordinator { /// prefs 每次进入都重新拉取。 private(set) var projectsViewModel: ProjectsViewModel? 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 @ObservationIgnored let environment: AppEnvironment @@ -108,6 +113,13 @@ final class AppCoordinator { projectsViewModel = nil } + // MARK: - Device certificate (C-iOS-3) + + /// Toolbar host-menu 入口:呈现设备证书导入/轮换 sheet。 + func presentDeviceCert() { + isDeviceCertPresented = true + } + /// "在此仓库开新会话":关 sheet → fresh spawn(`attach(null, cwd)`)+ /// attach 后注入 `claude\r`(帧序由 engine 的 attach-first 队列保证)。 func openProject(_ request: ProjectOpenRequest) { diff --git a/ios/App/WebTerm/Wiring/AppEnvironment.swift b/ios/App/WebTerm/Wiring/AppEnvironment.swift index db6fe0a..f7a5b35 100644 --- a/ios/App/WebTerm/Wiring/AppEnvironment.swift +++ b/ios/App/WebTerm/Wiring/AppEnvironment.swift @@ -1,4 +1,5 @@ import APIClient +import ClientTLS import Foundation import HostRegistry import SessionCore @@ -37,8 +38,20 @@ struct AppEnvironment: Sendable { var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore() static func production() -> AppEnvironment { - let http = URLSessionHTTPTransport() - let termTransport = URLSessionTermTransport() + // C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the installed device client + // identity LAZILY from the keychain on each connect/challenge, injected + // into BOTH transports + the probe as a provider. This way a certificate + // imported from the "设备证书" screen takes effect on the NEXT connection + // without relaunching — a snapshot captured here would stay stale. A + // missing cert is the normal pre-install state (→ nil); genuine faults + // are logged, not fatal (`loadedIdentityOrNil`). mTLS challenges only + // fire for tunnel hosts, so a `nil` result is inert for local hosts. + let identityStore = KeychainClientIdentityStore() + let identityProvider: @Sendable () -> ClientIdentity? = { + identityStore.loadedIdentityOrNil() + } + let http = URLSessionHTTPTransport(identityProvider: identityProvider) + let termTransport = URLSessionTermTransport(identityProvider: identityProvider) return AppEnvironment( hostStore: KeychainHostStore(), lastSessionStore: UserDefaultsLastSessionStore(), diff --git a/ios/App/WebTerm/Wiring/RootView.swift b/ios/App/WebTerm/Wiring/RootView.swift index eaa32eb..cd22681 100644 --- a/ios/App/WebTerm/Wiring/RootView.swift +++ b/ios/App/WebTerm/Wiring/RootView.swift @@ -74,7 +74,8 @@ struct StackRootView: View { SessionListScreen( viewModel: coordinator.sessionList, onOpen: { coordinator.open($0) }, - onAddHost: { coordinator.presentAddHost() } + onAddHost: { coordinator.presentAddHost() }, + onDeviceCert: { coordinator.presentDeviceCert() } ) .safeAreaInset(edge: .bottom) { continueLastBanner } // 横幅出现/消失走 DS 动效(reduceMotion 时塌成瞬切,无位移)。 @@ -178,4 +179,5 @@ struct ProjectsToolbarItem: ToolbarContent { enum RootCopy { static let continueLast = "继续上次会话" static let projects = "项目" + static let done = "完成" } diff --git a/ios/App/WebTerm/Wiring/SplitRootView.swift b/ios/App/WebTerm/Wiring/SplitRootView.swift index 6a9a678..97bb24d 100644 --- a/ios/App/WebTerm/Wiring/SplitRootView.swift +++ b/ios/App/WebTerm/Wiring/SplitRootView.swift @@ -39,7 +39,8 @@ struct SplitRootView: View { // 分栏只是又一个触发面:映射到同一 selectSidebarItem 路由。 coordinator.selectSidebarItem(sidebarItem(for: request)) }, - onAddHost: { coordinator.presentAddHost() } + onAddHost: { coordinator.presentAddHost() }, + onDeviceCert: { coordinator.presentDeviceCert() } ) .safeAreaInset(edge: .bottom) { continueLastBanner } .animation( diff --git a/ios/App/WebTerm/Wiring/URLSessionHTTPTransport.swift b/ios/App/WebTerm/Wiring/URLSessionHTTPTransport.swift index 4b7f1cc..0bdab89 100644 --- a/ios/App/WebTerm/Wiring/URLSessionHTTPTransport.swift +++ b/ios/App/WebTerm/Wiring/URLSessionHTTPTransport.swift @@ -1,3 +1,4 @@ +import ClientTLS import Foundation import WireProtocol @@ -9,14 +10,39 @@ import WireProtocol /// bypass that single audited point (review CRITICAL). struct URLSessionHTTPTransport: HTTPTransport { 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 - /// 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(session: URLSession = URLSession(configuration: .ephemeral)) { - self.session = session + /// Fixed-identity convenience (snapshot callers / tests): wraps a constant + /// provider, so behaviour is identical to capturing the identity directly. + init(identity: ClientIdentity? = nil) { + self.init(identityProvider: { identity }) + } + + /// 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) { @@ -29,3 +55,40 @@ struct URLSessionHTTPTransport: HTTPTransport { 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) + } +} diff --git a/ios/Packages/ClientTLS/Package.swift b/ios/Packages/ClientTLS/Package.swift new file mode 100644 index 0000000..6b5f2c2 --- /dev/null +++ b/ios/Packages/ClientTLS/Package.swift @@ -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] +) diff --git a/ios/Packages/ClientTLS/Sources/ClientTLS/CertificateSummary.swift b/ios/Packages/ClientTLS/Sources/ClientTLS/CertificateSummary.swift new file mode 100644 index 0000000..190ab87 --- /dev/null +++ b/ios/Packages/ClientTLS/Sources/ClientTLS/CertificateSummary.swift @@ -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..= 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.. 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.. 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) + } +} diff --git a/ios/Packages/ClientTLS/Sources/ClientTLS/ClientIdentity.swift b/ios/Packages/ClientTLS/Sources/ClientTLS/ClientIdentity.swift new file mode 100644 index 0000000..faaab84 --- /dev/null +++ b/ios/Packages/ClientTLS/Sources/ClientTLS/ClientIdentity.swift @@ -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:)) + } +} diff --git a/ios/Packages/ClientTLS/Sources/ClientTLS/ClientTLSSessionDelegate.swift b/ios/Packages/ClientTLS/Sources/ClientTLS/ClientTLSSessionDelegate.swift new file mode 100644 index 0000000..e84a7ae --- /dev/null +++ b/ios/Packages/ClientTLS/Sources/ClientTLS/ClientTLSSessionDelegate.swift @@ -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) + } +} diff --git a/ios/Packages/ClientTLS/Sources/ClientTLS/KeychainClientIdentityStore.swift b/ios/Packages/ClientTLS/Sources/ClientTLS/KeychainClientIdentityStore.swift new file mode 100644 index 0000000..7fe081c --- /dev/null +++ b/ios/Packages/ClientTLS/Sources/ClientTLS/KeychainClientIdentityStore.swift @@ -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") +} diff --git a/ios/Packages/ClientTLS/Sources/ClientTLS/MutualTLSChallengeResponder.swift b/ios/Packages/ClientTLS/Sources/ClientTLS/MutualTLSChallengeResponder.swift new file mode 100644 index 0000000..441ad27 --- /dev/null +++ b/ios/Packages/ClientTLS/Sources/ClientTLS/MutualTLSChallengeResponder.swift @@ -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) + } + } +} diff --git a/ios/Packages/ClientTLS/Sources/ClientTLS/PKCS12Importer.swift b/ios/Packages/ClientTLS/Sources/ClientTLS/PKCS12Importer.swift new file mode 100644 index 0000000..82872ab --- /dev/null +++ b/ios/Packages/ClientTLS/Sources/ClientTLS/PKCS12Importer.swift @@ -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 } + } +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/ClientIdentityStoreTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/ClientIdentityStoreTests.swift new file mode 100644 index 0000000..78de7b4 --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/ClientIdentityStoreTests.swift @@ -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) +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/Fixtures.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/Fixtures.swift new file mode 100644 index 0000000..1706397 --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/Fixtures.swift @@ -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" +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/MutualTLSChallengeResponderTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/MutualTLSChallengeResponderTests.swift new file mode 100644 index 0000000..5523acf --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/MutualTLSChallengeResponderTests.swift @@ -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() + ) +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/PKCS12ImporterTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/PKCS12ImporterTests.swift new file mode 100644 index 0000000..cfeabf0 --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/PKCS12ImporterTests.swift @@ -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") + } +} diff --git a/ios/Packages/SessionCore/Package.swift b/ios/Packages/SessionCore/Package.swift index 5ff7ddf..b472f38 100644 --- a/ios/Packages/SessionCore/Package.swift +++ b/ios/Packages/SessionCore/Package.swift @@ -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", diff --git a/ios/Packages/SessionCore/Sources/SessionCore/URLSessionTermTransport.swift b/ios/Packages/SessionCore/Sources/SessionCore/URLSessionTermTransport.swift index bc2e164..638b1ff 100644 --- a/ios/Packages/SessionCore/Sources/SessionCore/URLSessionTermTransport.swift +++ b/ios/Packages/SessionCore/Sources/SessionCore/URLSessionTermTransport.swift @@ -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 private let framesContinuation: AsyncThrowingStream.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.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( diff --git a/ios/Packages/SessionCore/Tests/SessionCoreTests/URLSessionTermTransportTests.swift b/ios/Packages/SessionCore/Tests/SessionCoreTests/URLSessionTermTransportTests.swift index 0acfbaf..df2ddf3 100644 --- a/ios/Packages/SessionCore/Tests/SessionCoreTests/URLSessionTermTransportTests.swift +++ b/ios/Packages/SessionCore/Tests/SessionCoreTests/URLSessionTermTransportTests.swift @@ -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 diff --git a/ios/project.yml b/ios/project.yml index e0a5c53..0ec6c85 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -31,6 +31,8 @@ packages: path: Packages/HostRegistry APIClient: path: Packages/APIClient + ClientTLS: + path: Packages/ClientTLS # C-iOS · device client-cert (mTLS) leaf package TestSupport: path: Packages/TestSupport # test doubles — WebTermTests only, never the app target # SwiftTerm is the ONLY third-party dependency, attached to the App target @@ -50,6 +52,7 @@ targets: - package: SessionCore - package: HostRegistry - package: APIClient + - package: ClientTLS - package: SwiftTerm settings: base: @@ -119,6 +122,7 @@ targets: - package: SessionCore - package: HostRegistry - package: APIClient + - package: ClientTLS - package: TestSupport settings: base: