From 07bcbf0c08a0b2246f9ae0c17c25b195fbca4ce3 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sat, 18 Jul 2026 13:32:05 +0200 Subject: [PATCH] feat(ios): device enrollment flow + silent cert rotation (B3) Wire the SecureEnclave enroll library into a real flow (login->bearer->CSR-> /device/enroll->keychain identity), presented on the existing mTLS path; add a rotation scheduler. Atomic keychain replace (add-before-delete); renew body is {csr}-only; renewal-failing surfaced in the UI. ClientTLS 48 tests pass. --- .../WebTerm/Screens/EnrollmentScreen.swift | 275 ++++++++++++++++++ .../WebTerm/Screens/SessionListScreen.swift | 12 + ios/App/WebTerm/Wiring/AdaptiveRootView.swift | 23 ++ ios/App/WebTerm/Wiring/AppCoordinator.swift | 61 ++++ .../Wiring/DeviceEnrollmentWiring.swift | 90 ++++++ ios/App/WebTerm/Wiring/RootView.swift | 43 ++- ios/App/WebTerm/Wiring/SplitRootView.swift | 3 +- .../EnrollmentViewModelTests.swift | 142 +++++++++ .../CertificateRotationScheduler.swift | 107 +++++++ .../ClientTLS/ControlPlaneLoginClient.swift | 108 +++++++ .../ClientTLS/DeviceEnrollmentClient.swift | 24 +- .../ClientTLS/DeviceEnrollmentFlow.swift | 63 ++++ .../KeychainClientIdentityStore.swift | 115 ++++++-- .../ClientTLS/KeychainItemReplace.swift | 30 ++ .../CertificateRotationSchedulerTests.swift | 107 +++++++ .../ControlPlaneLoginClientTests.swift | 123 ++++++++ .../DeviceEnrollmentClientTests.swift | 43 +++ .../DeviceEnrollmentFlowTests.swift | 136 +++++++++ .../KeychainItemReplaceTests.swift | 76 +++++ 19 files changed, 1549 insertions(+), 32 deletions(-) create mode 100644 ios/App/WebTerm/Screens/EnrollmentScreen.swift create mode 100644 ios/App/WebTerm/Wiring/DeviceEnrollmentWiring.swift create mode 100644 ios/App/WebTermTests/EnrollmentViewModelTests.swift create mode 100644 ios/Packages/ClientTLS/Sources/ClientTLS/CertificateRotationScheduler.swift create mode 100644 ios/Packages/ClientTLS/Sources/ClientTLS/ControlPlaneLoginClient.swift create mode 100644 ios/Packages/ClientTLS/Sources/ClientTLS/DeviceEnrollmentFlow.swift create mode 100644 ios/Packages/ClientTLS/Sources/ClientTLS/KeychainItemReplace.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/CertificateRotationSchedulerTests.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/ControlPlaneLoginClientTests.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/DeviceEnrollmentFlowTests.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainItemReplaceTests.swift diff --git a/ios/App/WebTerm/Screens/EnrollmentScreen.swift b/ios/App/WebTerm/Screens/EnrollmentScreen.swift new file mode 100644 index 0000000..068f6d1 --- /dev/null +++ b/ios/App/WebTerm/Screens/EnrollmentScreen.swift @@ -0,0 +1,275 @@ +import ClientTLS +import Foundation +import Observation +import SwiftUI +import UIKit +import WireProtocol + +/// B3 · Zero-`.p12` device-enrollment screen — the phone half of zero-touch. +/// +/// Flow: one operator login → a short-lived `device:enroll` bearer → a +/// NON-EXPORTABLE Secure-Enclave key + PKCS#10 CSR → `POST /device/enroll` → +/// the returned leaf is stored against the SE key and presents AUTOMATICALLY on +/// the existing mTLS path (no manual certificate import, ever). After this one +/// login the cert renews itself silently (`CertificateRotationScheduler`). +/// +/// Pure presentation over `EnrollmentViewModel`; validation, the flow, and error +/// mapping live in the VM. Presented from the host-menu ("自动获取证书"). +struct EnrollmentScreen: View { + @State private var model: EnrollmentViewModel + + init(model: EnrollmentViewModel) { + _model = State(initialValue: model) + } + + var body: some View { + Form { + installedSection + enrollSection + } + .navigationTitle(EnrollmentCopy.title) + .task { model.refresh() } + } + + // MARK: - Sections + + @ViewBuilder private var installedSection: some View { + Section(EnrollmentCopy.installedHeader) { + if let summary = model.summary { + LabeledContent(EnrollmentCopy.subject, value: summary.subjectCommonName ?? "—") + LabeledContent(EnrollmentCopy.issuer, value: summary.issuerCommonName ?? "—") + LabeledContent(EnrollmentCopy.expiry, value: model.expiryText(summary)) + if summary.isExpired() { + Label(EnrollmentCopy.expiredWarning, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } + } else { + Text(EnrollmentCopy.noneInstalled).foregroundStyle(.secondary) + } + } + } + + @ViewBuilder private var enrollSection: some View { + Section { + TextField(EnrollmentCopy.controlPlaneURL, text: $model.controlPlaneURLText) + .textContentType(.URL) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField(EnrollmentCopy.subdomain, text: $model.subdomain) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField(EnrollmentCopy.deviceName, text: $model.deviceName) + SecureField(EnrollmentCopy.password, text: $model.password) + .textContentType(.password) + .autocorrectionDisabled() + Button { + Task { await model.enroll() } + } label: { + if model.isEnrolling { + ProgressView() + } else { + Label( + model.summary == nil + ? EnrollmentCopy.enrollAction + : EnrollmentCopy.rotateAction, + systemImage: "checkmark.shield" + ) + } + } + .disabled(!model.canEnroll) + .accessibilityIdentifier("enroll.submit") + + if let error = model.errorMessage { + Text(error).foregroundStyle(.red).font(.footnote) + } + if model.didSucceed { + Label(EnrollmentCopy.success, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green).font(.footnote) + } + } header: { + Text(model.summary == nil ? EnrollmentCopy.enrollHeader : EnrollmentCopy.rotateHeader) + } footer: { + Text(EnrollmentCopy.footer) + } + } +} + +/// B3 · Enrollment state machine. Validates inputs at the boundary, drives +/// `DeviceEnrollmentFlow`, and maps every failure to actionable copy — never +/// swallowed, never leaking the password or bearer. +@MainActor +@Observable +final class EnrollmentViewModel { + /// The enroll operation: login → SE keygen+CSR → enroll → store. Injected so + /// the VM is unit-testable without a keychain / network (production wires it + /// to `DeviceEnrollmentFlow`). + typealias EnrollOperation = @Sendable ( + _ password: String, _ subdomain: String, _ deviceName: String, _ controlPlaneURL: URL + ) async throws -> ClientCertificateSummary? + + var controlPlaneURLText: String + var subdomain: String + var deviceName: String + var password = "" + + private(set) var summary: ClientCertificateSummary? + private(set) var isEnrolling = false + private(set) var didSucceed = false + private(set) var errorMessage: String? + + @ObservationIgnored private let enrollOperation: EnrollOperation + @ObservationIgnored private let loadSummary: @Sendable () -> ClientCertificateSummary? + @ObservationIgnored private let persistSettings: @Sendable (_ url: String, _ subdomain: String) -> Void + + init( + controlPlaneURLText: String = EnrollmentSettings.controlPlaneURL ?? EnrollmentCopy.defaultControlPlaneURL, + subdomain: String = EnrollmentSettings.subdomain ?? "", + deviceName: String = UIDevice.current.name, + enrollOperation: @escaping EnrollOperation, + loadSummary: @escaping @Sendable () -> ClientCertificateSummary?, + persistSettings: @escaping @Sendable (_ url: String, _ subdomain: String) -> Void = { url, sub in + EnrollmentSettings.controlPlaneURL = url + EnrollmentSettings.subdomain = sub + } + ) { + self.controlPlaneURLText = controlPlaneURLText + self.subdomain = subdomain + self.deviceName = deviceName + self.enrollOperation = enrollOperation + self.loadSummary = loadSummary + self.persistSettings = persistSettings + } + + /// Fields all present → the button is enabled (deeper URL validation on tap). + var canEnroll: Bool { + !isEnrolling + && !controlPlaneURLText.trimmed.isEmpty + && !subdomain.trimmed.isEmpty + && !deviceName.trimmed.isEmpty + && !password.isEmpty + } + + func refresh() { + summary = loadSummary() + } + + func expiryText(_ summary: ClientCertificateSummary) -> String { + guard let notAfter = summary.notAfter else { return "—" } + return notAfter.formatted(date: .abbreviated, time: .omitted) + } + + /// Run enrollment. Validates the control-plane URL (must be https with a + /// host) before any network, then drives the flow. The password is cleared + /// after every attempt (success OR failure) so it never lingers in memory. + func enroll() async { + errorMessage = nil + didSucceed = false + let trimmedURL = controlPlaneURLText.trimmed + let trimmedSubdomain = subdomain.trimmed + let trimmedName = deviceName.trimmed + guard let url = URL(string: trimmedURL), + url.scheme?.lowercased() == "https", + let host = url.host, !host.isEmpty else { + errorMessage = EnrollmentCopy.errInvalidURL + return + } + guard !trimmedSubdomain.isEmpty, !trimmedName.isEmpty, !password.isEmpty else { + errorMessage = EnrollmentCopy.errMissingFields + return + } + isEnrolling = true + defer { isEnrolling = false } + let secret = password + do { + summary = try await enrollOperation(secret, trimmedSubdomain, trimmedName, url) + persistSettings(trimmedURL, trimmedSubdomain) + password = "" // never linger after success + didSucceed = true + refresh() + } catch { + password = "" // never linger after failure either + errorMessage = Self.copy(for: error) + } + } + + /// Map a login/enroll/keychain error to enrollment-UX copy (never swallowed, + /// never containing the password/token). + private static func copy(for error: any Error) -> String { + switch error { + case ControlPlaneLoginError.emptyPassword: + return EnrollmentCopy.errEmptyPassword + case ControlPlaneLoginError.http(let status, _) where status == 401: + return EnrollmentCopy.errBadCredential + case ControlPlaneLoginError.http: + return EnrollmentCopy.errLoginFailed + case ControlPlaneLoginError.malformedResponse: + return EnrollmentCopy.errServer + case DeviceEnrollmentError.http(let status, _) where status == 403: + return EnrollmentCopy.errSubdomainNotOwned + case DeviceEnrollmentError.http(let status, _) where status == 429: + return EnrollmentCopy.errRateLimited + case DeviceEnrollmentError.http(let status, _) where status == 400: + return EnrollmentCopy.errRejected + case DeviceEnrollmentError.http: + return EnrollmentCopy.errEnrollFailed + case DeviceEnrollmentError.malformedResponse: + return EnrollmentCopy.errServer + case SecureEnclaveKeyError.secureEnclaveUnavailable: + return EnrollmentCopy.errNoSecureEnclave + case is SecureEnclaveKeyError: + return EnrollmentCopy.errKeygen + case ClientIdentityStoreError.keychain, ClientIdentityStoreError.corruptStoredBlob: + return EnrollmentCopy.errKeychain + case DeviceEnrollmentWiringError.missingControlPlaneURL, + DeviceEnrollmentWiringError.invalidControlPlaneURL: + return EnrollmentCopy.errInvalidURL + default: + return EnrollmentCopy.errUnknown + } + } +} + +private extension String { + var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } +} + +/// User-facing copy (Chinese, matching the rest of the app). +enum EnrollmentCopy { + static let defaultControlPlaneURL = "https://cp.terminal.yaojia.wang" + + 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 enrollHeader = "注册本设备" + static let rotateHeader = "重新注册" + static let controlPlaneURL = "控制面地址" + static let subdomain = "子域名 (你拥有的隧道名)" + static let deviceName = "设备名称" + static let password = "操作口令" + static let enrollAction = "注册本设备" + static let rotateAction = "重新注册" + static let success = "已注册,证书已保存到本设备安全区。" + static let footer = + "首次登录一次即可:本设备在安全区 (Secure Enclave) 生成不可导出的私钥,向控制面申请证书并自动保存;之后连接隧道主机时自动出示,证书到期前会自动续期,无需再手动导入 .p12。" + + static let errInvalidURL = "控制面地址无效,请输入 https:// 开头的完整地址。" + static let errMissingFields = "请填写子域名、设备名称和操作口令。" + static let errEmptyPassword = "请输入操作口令。" + static let errBadCredential = "操作口令错误,请重试。" + static let errLoginFailed = "登录失败,请稍后重试。" + static let errSubdomainNotOwned = "该账号未拥有此子域名,无法签发证书。" + static let errRateLimited = "请求过于频繁,请稍后再试。" + static let errRejected = "请求被拒绝:子域名或证书请求无效。" + static let errEnrollFailed = "注册失败,请稍后重试。" + static let errNoSecureEnclave = "本设备不支持安全区 (Secure Enclave)(模拟器?),无法生成硬件密钥。" + static let errKeygen = "生成硬件密钥失败,请重试。" + static let errKeychain = "保存到钥匙串失败,请重试。" + static let errServer = "服务器返回异常,请稍后重试。" + static let errUnknown = "注册失败,请重试。" +} diff --git a/ios/App/WebTerm/Screens/SessionListScreen.swift b/ios/App/WebTerm/Screens/SessionListScreen.swift index 7113323..d04ebe5 100644 --- a/ios/App/WebTerm/Screens/SessionListScreen.swift +++ b/ios/App/WebTerm/Screens/SessionListScreen.swift @@ -29,6 +29,11 @@ struct SessionListScreen: View { /// 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 = {} + /// B3 · "自动获取证书" entry — presents `EnrollmentScreen` (the zero-`.p12` + /// enrollment flow: login → Secure-Enclave key + CSR → device cert). Same + /// host-menu surface as `onDeviceCert`; that screen imports a `.p12`, this + /// one obtains a hardware-bound cert with no file at all. + var onEnroll: () -> 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 @@ -219,6 +224,12 @@ struct SessionListScreen: View { } label: { Label(ScreenCopy.addHost, systemImage: "plus") } + Button { + onEnroll() + } label: { + Label(ScreenCopy.enroll, systemImage: "checkmark.shield") + } + .accessibilityIdentifier("sessions.enroll") Button { onDeviceCert() } label: { @@ -355,6 +366,7 @@ private enum ScreenCopy { static let kill = "结束" static let addHost = "配对新主机" static let deviceCert = "设备证书" + static let enroll = "自动获取证书" static let hostMenuFallback = "主机" static let notPairedTitle = "还没有配对的主机" static let notPairedHint = "先配对你电脑上的 web-terminal(扫码或手输地址),会话会出现在这里。" diff --git a/ios/App/WebTerm/Wiring/AdaptiveRootView.swift b/ios/App/WebTerm/Wiring/AdaptiveRootView.swift index 716e380..b91f9da 100644 --- a/ios/App/WebTerm/Wiring/AdaptiveRootView.swift +++ b/ios/App/WebTerm/Wiring/AdaptiveRootView.swift @@ -54,6 +54,12 @@ struct AdaptiveRootView: View { .sheet(isPresented: $coordinator.isDeviceCertPresented) { deviceCertSheet } + .sheet( + isPresented: $coordinator.isEnrollmentPresented, + onDismiss: { coordinator.enrollmentDismissed() } + ) { + enrollmentSheet + } } // MARK: - Layout branch (the SOLE size-class consumer) @@ -126,4 +132,21 @@ struct AdaptiveRootView: View { } } } + + // MARK: - Device enrollment sheet (B3, zero-.p12 auto-cert) + + /// 自带 NavigationStack + 右上「完成」。VM 由 coordinator 每次进入构建 + /// (`presentEnrollment`);关闭后 `enrollmentDismissed` 丢弃 VM 并跑一次轮换。 + @ViewBuilder private var enrollmentSheet: some View { + if let viewModel = coordinator.enrollmentViewModel { + NavigationStack { + EnrollmentScreen(model: viewModel) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(RootCopy.done) { coordinator.isEnrollmentPresented = false } + } + } + } + } + } } diff --git a/ios/App/WebTerm/Wiring/AppCoordinator.swift b/ios/App/WebTerm/Wiring/AppCoordinator.swift index 0f1b30e..ed457e0 100644 --- a/ios/App/WebTerm/Wiring/AppCoordinator.swift +++ b/ios/App/WebTerm/Wiring/AppCoordinator.swift @@ -1,3 +1,4 @@ +import ClientTLS import Foundation import HostRegistry import Observation @@ -36,6 +37,20 @@ final class AppCoordinator { /// its own `ClientCertViewModel` over the keychain store; dismissal needs no /// refresh because the transports resolve the identity lazily per connection. var isDeviceCertPresented = false + /// B3 · "自动获取证书" sheet — the zero-`.p12` enrollment flow + /// (`EnrollmentScreen`): one login → Secure-Enclave key + CSR → device cert, + /// presented automatically on the existing mTLS path. VM built per entry. + var isEnrollmentPresented = false + private(set) var enrollmentViewModel: EnrollmentViewModel? + /// B3 · Re-entrancy guard so overlapping foregrounds never launch two silent + /// renews at once (a shared PTY-style single-flight for the rotation pass). + @ObservationIgnored private var isRotating = false + /// B3 (HIGH observability fix) · Persistent, OBSERVABLE flag: the last silent + /// rotation pass ended in `.failed` (keychain read fault or renew error). Set + /// alongside the os.Logger line so a silently-failing renewal surfaces to the + /// UI (a warning banner) instead of vanishing into the log. Cleared by the + /// next non-failed pass — a recovered renewal drops the warning automatically. + private(set) var isCertificateRenewalFailing = false let sessionList: SessionListViewModel @ObservationIgnored let environment: AppEnvironment @@ -69,6 +84,7 @@ final class AppCoordinator { if route == .pairing { rootPairingViewModel = makePairingViewModel() } + runCertificateRotationIfDue() // B3 · renew a due device cert on cold launch await deepLink.markReady() // flush a cold-launch deep link (T-iOS-22) } @@ -120,6 +136,50 @@ final class AppCoordinator { isDeviceCertPresented = true } + // MARK: - Device enrollment (B3, zero-.p12 auto-cert) + + /// Toolbar host-menu 入口:呈现「自动获取证书」注册 sheet。VM 每次进入重建。 + func presentEnrollment() { + enrollmentViewModel = makeEnrollmentViewModel() + isEnrollmentPresented = true + } + + /// Sheet 消失:丢弃 VM,并立刻跑一次轮换检查(刚注册完的证书据此登记续期)。 + func enrollmentDismissed() { + enrollmentViewModel = nil + runCertificateRotationIfDue() + } + + /// 生产 VM:登录 → 安全区生成密钥+CSR → `/device/enroll` → 存钥匙串,全部经 + /// `DeviceEnrollmentFlow`(复用 ClientTLS 库,本层零加密/钥匙串逻辑)。 + private func makeEnrollmentViewModel() -> EnrollmentViewModel { + let http = environment.http + return EnrollmentViewModel( + enrollOperation: { password, subdomain, deviceName, url in + try await makeDeviceEnrollmentFlow(controlPlaneURL: url, http: http) + .run(password: password, subdomain: subdomain, deviceName: deviceName) + }, + loadSummary: { (try? KeychainClientIdentityStore().loadSummary()) ?? nil } + ) + } + + /// B3 · Silent rotation: check the installed leaf's timing and, if the renew + /// window has opened, renew over mTLS against the SAME Secure-Enclave key. + /// Fire-and-forget on launch + every foreground; single-flight via + /// `isRotating`. A `.notEnrolled` / `.notDue` pass is cheap and common. + func runCertificateRotationIfDue() { + guard !isRotating else { return } + isRotating = true + let scheduler = makeCertificateRotationScheduler(http: environment.http) + Task { [weak self] in + let outcome = await scheduler.runIfDue() + self?.isRotating = false + // Surface a silently-failing renewal as observable state (not only a + // log line); a later successful/not-due pass clears it. + self?.isCertificateRenewalFailing = (outcome == .failed) + } + } + /// "在此仓库开新会话":关 sheet → fresh spawn(`attach(null, cwd)`)+ /// attach 后注入 `claude\r`(帧序由 engine 的 attach-first 队列保证)。 func openProject(_ request: ProjectOpenRequest) { @@ -290,6 +350,7 @@ final class AppCoordinator { terminalController?.suspend() case .active: terminalController?.resumeIfNeeded() + runCertificateRotationIfDue() // B3 · check for a due renewal each foreground case .inactive: break // transient; shade covers it at the view layer @unknown default: diff --git a/ios/App/WebTerm/Wiring/DeviceEnrollmentWiring.swift b/ios/App/WebTerm/Wiring/DeviceEnrollmentWiring.swift new file mode 100644 index 0000000..da15941 --- /dev/null +++ b/ios/App/WebTerm/Wiring/DeviceEnrollmentWiring.swift @@ -0,0 +1,90 @@ +import ClientTLS +import Foundation +import WireProtocol + +/// B3 · Production wiring that connects the ClientTLS enrollment library to the +/// app's real transports. Kept in the assembly layer (not the leaf package) so +/// `ClientTLS` stays dependency-free and unit-testable; here we only adapt types +/// and read persisted config — no crypto, no keychain of our own. + +/// Adapts the app's `HTTPTransport` (WireProtocol) to `ClientTLS`'s +/// `EnrollmentTransport`. The two protocols have an identical `send` shape; this +/// one-line bridge lets the SAME `URLSessionHTTPTransport` — which already +/// presents the device identity on a client-cert challenge (mTLS) — serve login, +/// enroll AND the silent renew, so renew authenticates with the current cert. +struct EnrollmentTransportAdapter: EnrollmentTransport { + private let http: any HTTPTransport + + init(http: any HTTPTransport) { + self.http = http + } + + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + try await http.send(request) + } +} + +/// B3 · Non-secret enrollment config persisted for the silent rotation scheduler +/// (the control-plane URL to renew against) and to prefill the re-enroll form. +/// Namespaced statics over `UserDefaults.standard` — nothing secret is stored +/// here (the bearer is never persisted; the private key stays in the Secure +/// Enclave; the leaf/chain live in the keychain via `KeychainClientIdentityStore`). +enum EnrollmentSettings { + private static let controlPlaneURLKey = "webterm.enroll.controlPlaneURL" + private static let subdomainKey = "webterm.enroll.subdomain" + + static var controlPlaneURL: String? { + get { UserDefaults.standard.string(forKey: controlPlaneURLKey) } + set { UserDefaults.standard.set(newValue, forKey: controlPlaneURLKey) } + } + + static var subdomain: String? { + get { UserDefaults.standard.string(forKey: subdomainKey) } + set { UserDefaults.standard.set(newValue, forKey: subdomainKey) } + } +} + +enum DeviceEnrollmentWiringError: Error, Equatable, Sendable { + /// The control-plane URL is missing/invalid — cannot build a renew client. + case missingControlPlaneURL + /// The typed control-plane URL is not a valid absolute URL. + case invalidControlPlaneURL +} + +/// Build the enrollment flow (login → SE keygen+CSR → enroll → store) against the +/// given control-plane URL, reusing the app's mTLS-capable transport. +func makeDeviceEnrollmentFlow( + controlPlaneURL: URL, + http: any HTTPTransport, + store: KeychainClientIdentityStore = KeychainClientIdentityStore() +) -> DeviceEnrollmentFlow { + DeviceEnrollmentFlow( + baseURL: controlPlaneURL, + transport: EnrollmentTransportAdapter(http: http), + store: store + ) +} + +/// Build the silent rotation scheduler. `renewalState` reads the installed leaf's +/// timing; `performRenew` re-CSRs from the SAME Secure-Enclave key and POSTs +/// `/device/:id/renew` over mTLS (nil bearer — the current cert authenticates). +/// The control-plane URL is read fresh from `EnrollmentSettings` at renew time. +func makeCertificateRotationScheduler( + http: any HTTPTransport, + store: KeychainClientIdentityStore = KeychainClientIdentityStore() +) -> CertificateRotationScheduler { + let transport = EnrollmentTransportAdapter(http: http) + return CertificateRotationScheduler( + renewalState: { try store.renewalState() }, + performRenew: { + guard let urlString = EnrollmentSettings.controlPlaneURL, + let url = URL(string: urlString) else { + throw DeviceEnrollmentWiringError.missingControlPlaneURL + } + // nil bearer: /device/:id/renew authenticates by the presented + // client cert (mTLS), which the adapter's transport supplies. + let client = DeviceEnrollmentClient(baseURL: url, bearerToken: nil, transport: transport) + return try await store.renew(using: client) + } + ) +} diff --git a/ios/App/WebTerm/Wiring/RootView.swift b/ios/App/WebTerm/Wiring/RootView.swift index cd22681..a2f7255 100644 --- a/ios/App/WebTerm/Wiring/RootView.swift +++ b/ios/App/WebTerm/Wiring/RootView.swift @@ -75,9 +75,13 @@ struct StackRootView: View { viewModel: coordinator.sessionList, onOpen: { coordinator.open($0) }, onAddHost: { coordinator.presentAddHost() }, - onDeviceCert: { coordinator.presentDeviceCert() } + onDeviceCert: { coordinator.presentDeviceCert() }, + onEnroll: { coordinator.presentEnrollment() } ) .safeAreaInset(edge: .bottom) { continueLastBanner } + // B3 (HIGH) · A silently-failing device-cert renewal is surfaced here + // (top inset), so it is observable instead of buried in os.Logger. + .safeAreaInset(edge: .top) { certRenewalWarningBanner } // 横幅出现/消失走 DS 动效(reduceMotion 时塌成瞬切,无位移)。 .animation( DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion), @@ -98,6 +102,15 @@ struct StackRootView: View { } } + // MARK: - Device-cert renewal warning (B3 HIGH observability fix) + + @ViewBuilder private var certRenewalWarningBanner: some View { + if coordinator.isCertificateRenewalFailing { + CertRenewalWarningBanner() + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + // MARK: - Terminal push private var terminalBinding: Binding { @@ -155,6 +168,32 @@ struct ContinueLastBanner: View { } } +// MARK: - Device-cert renewal warning (B3 HIGH observability fix) + +/// A quiet, non-blocking warning that the silent device-certificate renewal is +/// failing — surfaced from `AppCoordinator.isCertificateRenewalFailing` so a +/// failing renewal is observable in the UI instead of only in os.Logger. Amber +/// (the `waiting`/needs-me status color) + an SF Symbol so meaning is never +/// carried by color alone. Non-interactive: the existing cert stays valid until +/// expiry, and the next foreground retries automatically. +struct CertRenewalWarningBanner: View { + var body: some View { + Label(RootCopy.certRenewalFailing, systemImage: "exclamationmark.shield") + .font(DS.Typography.caption) + .foregroundStyle(DS.Palette.statusWaiting) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, DS.Space.lg16) + .padding(.vertical, DS.Space.sm8) + .background(.regularMaterial) + .overlay(alignment: .bottom) { + Rectangle() + .fill(DS.Palette.hairline) + .frame(height: DS.Stroke.hairline) + } + .accessibilityIdentifier("root.certRenewalWarning") + } +} + // MARK: - Projects toolbar item (shared stack + split, DRY) /// 「项目」leading 工具栏入口 —— stack 与 split 共用(同 disabled 条件、同 @@ -180,4 +219,6 @@ enum RootCopy { static let continueLast = "继续上次会话" static let projects = "项目" static let done = "完成" + /// B3 (HIGH) · Shown when the silent device-cert renewal keeps failing. + static let certRenewalFailing = "设备证书自动续期失败,将在下次前台重试" } diff --git a/ios/App/WebTerm/Wiring/SplitRootView.swift b/ios/App/WebTerm/Wiring/SplitRootView.swift index 97bb24d..362d8eb 100644 --- a/ios/App/WebTerm/Wiring/SplitRootView.swift +++ b/ios/App/WebTerm/Wiring/SplitRootView.swift @@ -40,7 +40,8 @@ struct SplitRootView: View { coordinator.selectSidebarItem(sidebarItem(for: request)) }, onAddHost: { coordinator.presentAddHost() }, - onDeviceCert: { coordinator.presentDeviceCert() } + onDeviceCert: { coordinator.presentDeviceCert() }, + onEnroll: { coordinator.presentEnrollment() } ) .safeAreaInset(edge: .bottom) { continueLastBanner } .animation( diff --git a/ios/App/WebTermTests/EnrollmentViewModelTests.swift b/ios/App/WebTermTests/EnrollmentViewModelTests.swift new file mode 100644 index 0000000..39aa85b --- /dev/null +++ b/ios/App/WebTermTests/EnrollmentViewModelTests.swift @@ -0,0 +1,142 @@ +import ClientTLS +import Foundation +import Testing +@testable import WebTerm + +/// B3 · EnrollmentViewModel — the zero-`.p12` enrollment flow's state machine. +/// The enroll operation is injected as a closure (the flow logic itself is +/// covered headlessly in ClientTLS's DeviceEnrollmentFlowTests); these cover the +/// VM's boundary validation, success bookkeeping, and error→copy mapping — +/// including that the password never lingers after an attempt. +@MainActor +@Suite("EnrollmentViewModel") +struct EnrollmentViewModelTests { + /// Records enroll invocations + replays a scripted result/error. + private actor EnrollScript { + private(set) var calls: [(password: String, subdomain: String, deviceName: String, url: URL)] = [] + private let result: Result + + init(result: Result) { + self.result = result + } + + func run(_ p: String, _ s: String, _ d: String, _ u: URL) throws -> ClientCertificateSummary? { + calls.append((p, s, d, u)) + return try result.get() + } + + var callCount: Int { calls.count } + var lastPassword: String? { calls.last?.password } + } + + /// Records persisted settings so a test can assert them without real defaults. + private final class PersistSpy: @unchecked Sendable { + private let lock = NSLock() + private(set) var url: String? + private(set) var subdomain: String? + func persist(_ url: String, _ subdomain: String) { + lock.withLock { self.url = url; self.subdomain = subdomain } + } + } + + private func makeVM( + script: EnrollScript, + persist: PersistSpy = PersistSpy(), + url: String = "https://cp.terminal.yaojia.wang", + subdomain: String = "alice", + summary: ClientCertificateSummary? = nil + ) -> EnrollmentViewModel { + EnrollmentViewModel( + controlPlaneURLText: url, + subdomain: subdomain, + deviceName: "Test iPhone", + enrollOperation: { p, s, d, u in try await script.run(p, s, d, u) }, + loadSummary: { summary }, + persistSettings: { persist.persist($0, $1) } + ) + } + + @Test("a successful enroll sets the summary, flags success, clears the password, persists settings") + func successPath() async { + let sum = ClientCertificateSummary( + subjectCommonName: "Test iPhone", issuerCommonName: "webterm-device-ca", notAfter: nil + ) + let script = EnrollScript(result: .success(sum)) + let persist = PersistSpy() + let vm = makeVM(script: script, persist: persist, summary: sum) + vm.password = "operator-secret" + + await vm.enroll() + + #expect(await script.callCount == 1) + #expect(vm.didSucceed == true) + #expect(vm.errorMessage == nil) + #expect(vm.password == "") // never lingers + #expect(vm.summary?.subjectCommonName == "Test iPhone") + #expect(persist.url == "https://cp.terminal.yaojia.wang") + #expect(persist.subdomain == "alice") + } + + @Test("a non-https control-plane URL is rejected before any network") + func rejectsInsecureURL() async { + let script = EnrollScript(result: .success(nil)) + let vm = makeVM(script: script, url: "http://cp.terminal.yaojia.wang") + vm.password = "pw" + + await vm.enroll() + + #expect(await script.callCount == 0) // zero network + #expect(vm.errorMessage == EnrollmentCopy.errInvalidURL) + #expect(vm.didSucceed == false) + } + + @Test("a 403 subdomain-not-owned maps to actionable copy; password still cleared") + func mapsSubdomainNotOwned() async { + let script = EnrollScript( + result: .failure(DeviceEnrollmentError.http(status: 403, code: "rejected")) + ) + let vm = makeVM(script: script) + vm.password = "operator-secret" + + await vm.enroll() + + #expect(vm.errorMessage == EnrollmentCopy.errSubdomainNotOwned) + #expect(vm.didSucceed == false) + #expect(vm.password == "") // cleared even on failure + } + + @Test("a 401 login maps to the bad-credential copy") + func mapsBadCredential() async { + let script = EnrollScript( + result: .failure(ControlPlaneLoginError.http(status: 401, code: "login rejected")) + ) + let vm = makeVM(script: script) + vm.password = "wrong" + + await vm.enroll() + #expect(vm.errorMessage == EnrollmentCopy.errBadCredential) + } + + @Test("Secure Enclave unavailable (simulator) maps to a clear message") + func mapsNoSecureEnclave() async { + let script = EnrollScript( + result: .failure(SecureEnclaveKeyError.secureEnclaveUnavailable("no entitlement")) + ) + let vm = makeVM(script: script) + vm.password = "operator-secret" + + await vm.enroll() + #expect(vm.errorMessage == EnrollmentCopy.errNoSecureEnclave) + } + + @Test("canEnroll requires every field including a non-empty password") + func canEnrollGating() { + let script = EnrollScript(result: .success(nil)) + let vm = makeVM(script: script) + #expect(vm.canEnroll == false) // password empty + vm.password = "pw" + #expect(vm.canEnroll == true) + vm.subdomain = " " + #expect(vm.canEnroll == false) // whitespace-only subdomain + } +} diff --git a/ios/Packages/ClientTLS/Sources/ClientTLS/CertificateRotationScheduler.swift b/ios/Packages/ClientTLS/Sources/ClientTLS/CertificateRotationScheduler.swift new file mode 100644 index 0000000..a4ee797 --- /dev/null +++ b/ios/Packages/ClientTLS/Sources/ClientTLS/CertificateRotationScheduler.swift @@ -0,0 +1,107 @@ +import Foundation +import os + +/// B3 · Rotation timing of an installed enrolled (Secure-Enclave) identity. The +/// scheduler reads this to decide whether to pre-empt expiry. The private key +/// itself never leaves the Secure Enclave, so only advisory timing surfaces. +public struct DeviceRenewalState: Equatable, Sendable { + /// The enrolled device id — drives `POST /device/:id/renew`. + public let deviceId: String + /// Leaf `notAfter` (hard expiry — the TLS stack rejects past it). + public let notAfter: Date? + /// When to renew from the same hardware key (~2/3 of the lifetime). + public let renewAfter: Date? + + public init(deviceId: String, notAfter: Date?, renewAfter: Date?) { + self.deviceId = deviceId + self.notAfter = notAfter + self.renewAfter = renewAfter + } + + /// Is the leaf due for renewal as of `now`? A missing `renewAfter` NEVER + /// triggers (fail-safe — the TLS stack is the real gate; the scheduler only + /// pre-empts expiry). Mirrors `EnrollmentResult.isRenewalDue`. + public func isRenewalDue(asOf now: Date) -> Bool { + guard let renewAfter else { return false } + return now >= renewAfter + } +} + +/// B3 · The silent rotation scheduler: on a trigger (app foreground / launch) it +/// checks the installed identity's rotation timing and, if the renew window has +/// opened, drives an mTLS renew against the SAME Secure-Enclave key. This is the +/// "one bootstrap tap, then never again" half of zero-touch — after the first +/// login+enroll, the cert renews itself with no human action. +/// +/// Deliberately pure and transport-free: `renewalState` and `performRenew` are +/// injected closures, so the whole decision table is unit-testable with fakes +/// (no keychain, no network). Production wires `renewalState` to the keychain +/// store and `performRenew` to an mTLS `DeviceEnrollmentClient` renew (nil +/// bearer — the current cert authenticates). +/// +/// Failure posture: a keychain read fault or a renew error is surfaced as +/// `.failed` and logged (never a secret), NEVER a crash — the existing cert +/// stays valid until `notAfter`, so a transient renew failure is recoverable on +/// the next trigger. +public struct CertificateRotationScheduler: Sendable { + /// Current rotation timing; `nil` = nothing enrolled to renew. + private let renewalState: @Sendable () throws -> DeviceRenewalState? + /// The mTLS renew operation (re-CSR from the SE key → replace the leaf). + private let performRenew: @Sendable () async throws -> ClientCertificateSummary? + private let now: @Sendable () -> Date + + public init( + renewalState: @escaping @Sendable () throws -> DeviceRenewalState?, + performRenew: @escaping @Sendable () async throws -> ClientCertificateSummary?, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.renewalState = renewalState + self.performRenew = performRenew + self.now = now + } + + /// The outcome of a single scheduler pass — total over the decision table. + public enum Outcome: Equatable, Sendable { + /// No enrolled identity (fresh install / legacy `.p12`): nothing to do. + case notEnrolled + /// Enrolled but the renew window has not opened yet. + case notDue(renewAfter: Date?) + /// The renew succeeded; the new leaf is installed for the next handshake. + case renewed + /// A read/renew error occurred; logged, cert still valid until expiry. + case failed + } + + /// Run one pass: read timing → decide → renew if due. Idempotent and safe to + /// call on every foreground; `.notDue` is the common (cheap) case. + public func runIfDue() async -> Outcome { + let state: DeviceRenewalState? + do { + state = try renewalState() + } catch { + RotationLog.log.error( + "rotation: renewal-state read failed: \(String(describing: error), privacy: .public)" + ) + return .failed + } + guard let state else { return .notEnrolled } + guard state.isRenewalDue(asOf: now()) else { + return .notDue(renewAfter: state.renewAfter) + } + do { + _ = try await performRenew() + RotationLog.log.info("rotation: device certificate renewed silently") + return .renewed + } catch { + // No secrets in the log — only the error shape (never token/cert bytes). + RotationLog.log.error( + "rotation: silent renew failed: \(String(describing: error), privacy: .public)" + ) + return .failed + } + } +} + +private enum RotationLog { + static let log = Logger(subsystem: "com.yaojia.webterm", category: "cert-rotation") +} diff --git a/ios/Packages/ClientTLS/Sources/ClientTLS/ControlPlaneLoginClient.swift b/ios/Packages/ClientTLS/Sources/ClientTLS/ControlPlaneLoginClient.swift new file mode 100644 index 0000000..afe4d9a --- /dev/null +++ b/ios/Packages/ClientTLS/Sources/ClientTLS/ControlPlaneLoginClient.swift @@ -0,0 +1,108 @@ +import Foundation + +/// B3 · The phone-track bootstrap: exchanges the operator's one-time credential +/// for a short-lived `device:enroll` bearer at the control-plane. +/// +/// PINNED login contract (all pieces MUST agree — iOS / Android / server B1): +/// +/// `POST /auth/login { "password": "" }` +/// → 201 { "enrollToken": "", "accountId": "", "expiresIn": } +/// +/// The returned `enrollToken` is then presented as `Authorization: Bearer …` to +/// `DeviceEnrollmentClient.enroll`. This client is deliberately logic-free about +/// TLS and reuses the SAME `EnrollmentTransport` seam as `DeviceEnrollmentClient`, +/// so production injects `URLSessionHTTPTransport` and tests inject a stub. +/// +/// Security posture: +/// - The password is validated at the boundary (non-empty) and sent ONCE; it is +/// never persisted, never logged, and no other field rides along in the body. +/// - The `enrollToken` is a secret bearer: it is never logged here either. Only +/// the transport (which sees it in a header) and the caller hold it, briefly. +public struct ControlPlaneLoginClient: Sendable { + private let baseURL: URL + private let transport: any EnrollmentTransport + + public init(baseURL: URL, transport: any EnrollmentTransport) { + self.baseURL = baseURL + self.transport = transport + } + + /// Exchange the operator credential for a `device:enroll` bearer. + /// + /// - Throws: `ControlPlaneLoginError.emptyPassword` (before any network), + /// `.http(status:code:)` on a non-201 (401 = bad/missing credential), + /// `.malformedResponse` on a 201 body that does not decode. + public func login(password: String) async throws -> LoginResult { + // Boundary validation — fail fast, never send an empty credential. + guard !password.isEmpty else { throw ControlPlaneLoginError.emptyPassword } + + guard let url = URL(string: "/auth/login", relativeTo: baseURL) else { + throw ControlPlaneLoginError.malformedResponse + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + // Password ONLY — no accountId / device fields leak from the client. + request.httpBody = try JSONSerialization.data(withJSONObject: ["password": password]) + + let (data, response) = try await transport.send(request) + guard response.statusCode == 201 else { + throw ControlPlaneLoginError.http(status: response.statusCode, code: errorCode(in: data)) + } + guard let dto = try? JSONDecoder().decode(LoginResponseDTO.self, from: data) else { + throw ControlPlaneLoginError.malformedResponse + } + return dto.toResult() + } + + private func errorCode(in data: Data) -> String? { + (try? JSONDecoder().decode(LoginErrorDTO.self, from: data))?.error + } +} + +// MARK: - Result & errors + +/// The login exchange result. The `enrollToken` is a short-lived secret bearer; +/// hold it only as long as needed to drive `DeviceEnrollmentClient.enroll`. +public struct LoginResult: Equatable, Sendable { + /// The `device:enroll` capability bearer to present to `/device/enroll`. + public let enrollToken: String + /// The authenticated account (the token `sub`); the device enrolls under a + /// subdomain this account owns (server enforces ownership, deny-by-default). + public let accountId: String + /// Token lifetime in seconds (minutes-scale). Advisory: the enrollment must + /// complete within this window or the bearer is re-minted via another login. + public let expiresIn: Int + + public init(enrollToken: String, accountId: String, expiresIn: Int) { + self.enrollToken = enrollToken + self.accountId = accountId + self.expiresIn = expiresIn + } +} + +public enum ControlPlaneLoginError: Error, Equatable, Sendable { + /// The password was empty — rejected before any network call. + case emptyPassword + /// Non-success HTTP status with the server's uniform `{ error }` code, if any + /// (401 = bad/missing credential, 429 = rate_limited). + case http(status: Int, code: String?) + /// A 2xx body that did not decode to the expected shape. + case malformedResponse +} + +// MARK: - Wire DTOs + +private struct LoginResponseDTO: Decodable { + let enrollToken: String + let accountId: String + let expiresIn: Int + + func toResult() -> LoginResult { + LoginResult(enrollToken: enrollToken, accountId: accountId, expiresIn: expiresIn) + } +} + +private struct LoginErrorDTO: Decodable { + let error: String +} diff --git a/ios/Packages/ClientTLS/Sources/ClientTLS/DeviceEnrollmentClient.swift b/ios/Packages/ClientTLS/Sources/ClientTLS/DeviceEnrollmentClient.swift index a0f533c..c283900 100644 --- a/ios/Packages/ClientTLS/Sources/ClientTLS/DeviceEnrollmentClient.swift +++ b/ios/Packages/ClientTLS/Sources/ClientTLS/DeviceEnrollmentClient.swift @@ -16,11 +16,15 @@ import Foundation /// server's `decodeCsrWire` accepts directly. public struct DeviceEnrollmentClient: Sendable { private let baseURL: URL - /// The one-time account `device:enroll` bearer obtained at login. - private let bearerToken: String + /// The one-time account `device:enroll` bearer obtained at login. `nil` on + /// the renew path, which authenticates by the CURRENT device client cert + /// (mTLS) — the silent rotation scheduler has no fresh bearer weeks later, so + /// it constructs the client with `nil` and relies on the transport presenting + /// the installed identity. When `nil`, no `Authorization` header is sent. + private let bearerToken: String? private let transport: any EnrollmentTransport - public init(baseURL: URL, bearerToken: String, transport: any EnrollmentTransport) { + public init(baseURL: URL, bearerToken: String? = nil, transport: any EnrollmentTransport) { self.baseURL = baseURL self.bearerToken = bearerToken self.transport = transport @@ -42,10 +46,12 @@ public struct DeviceEnrollmentClient: Sendable { } /// Renew against the SAME hardware key (A6 seam): a fresh CSR to - /// `/device/:id/renew`. Server support lands in A6; the client shape is here - /// so the rotation scheduler has an endpoint to drive. + /// `/device/:id/renew`. The endpoint authenticates by the presented mTLS cert + /// and its schema is strict — the body is `{ csr }` ONLY (NOT the enroll body). + /// A stray `keyAlg` (or any non-`csr` field) trips server validation and 400s + /// every silent renewal, so it is deliberately absent here. public func renew(deviceId: String, csrDER: Data) async throws -> EnrollmentResult { - let body = ["csr": csrDER.base64EncodedString(), "keyAlg": "ec-p256"] + let body = ["csr": csrDER.base64EncodedString()] let path = "/device/\(deviceId)/renew" let request = try makeJSONRequest(path: path, jsonObject: body) return try await sendExpectingLeaf(request) @@ -73,7 +79,11 @@ public struct DeviceEnrollmentClient: Sendable { } var request = URLRequest(url: url) request.httpMethod = "POST" - request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") + // Bearer only when present (enroll). The renew path passes nil and + // authenticates via the presented client certificate (mTLS). + if let bearerToken { + request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") + } request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: jsonObject) return request diff --git a/ios/Packages/ClientTLS/Sources/ClientTLS/DeviceEnrollmentFlow.swift b/ios/Packages/ClientTLS/Sources/ClientTLS/DeviceEnrollmentFlow.swift new file mode 100644 index 0000000..fbcf706 --- /dev/null +++ b/ios/Packages/ClientTLS/Sources/ClientTLS/DeviceEnrollmentFlow.swift @@ -0,0 +1,63 @@ +import Foundation + +/// B3 · The phone-track enrollment FLOW — the composition that turns the pinned +/// bootstrap contract into an installed, hardware-bound device identity: +/// +/// 1. `POST /auth/login { password }` → short-lived `device:enroll` bearer. +/// 2. Build a `DeviceEnrollmentClient` authenticated with THAT bearer. +/// 3. `install` = generate a NON-EXPORTABLE Secure-Enclave key, self-sign a +/// PKCS#10 CSR, `POST /device/enroll`, and store the returned leaf against +/// the SE key — so it presents automatically on the existing mTLS path. +/// +/// It reuses the enrollment library wholesale (`ControlPlaneLoginClient`, +/// `DeviceEnrollmentClient`, `KeychainClientIdentityStore.enroll`) and adds NO +/// crypto/keychain of its own — it is pure orchestration, so it is unit-testable +/// with a stub transport + a fake installer (no network, no keychain). +/// +/// The `install` step is injected (rather than a hard dependency on the keychain +/// store) so the composition is testable off-device; production supplies the +/// `KeychainClientIdentityStore` convenience initializer below. +public struct DeviceEnrollmentFlow: Sendable { + /// The install step: given the bearer-authenticated client + the requested + /// subdomain/name, generate the SE key + CSR, enroll, and persist the leaf. + /// Returns the installed cert summary (nil only if the leaf can't be read). + public typealias Install = @Sendable ( + _ client: DeviceEnrollmentClient, _ subdomain: String, _ deviceName: String + ) async throws -> ClientCertificateSummary? + + private let baseURL: URL + private let transport: any EnrollmentTransport + private let install: Install + + public init(baseURL: URL, transport: any EnrollmentTransport, install: @escaping Install) { + self.baseURL = baseURL + self.transport = transport + self.install = install + } + + /// Production convenience: wire `install` to the keychain store's + /// Secure-Enclave enrollment (the `.p12`-free path). The SE key is generated + /// inside `store.enroll` and never leaves hardware. + public init( + baseURL: URL, transport: any EnrollmentTransport, store: KeychainClientIdentityStore + ) { + self.init(baseURL: baseURL, transport: transport) { client, subdomain, deviceName in + try await store.enroll(using: client, subdomain: subdomain, deviceName: deviceName) + } + } + + /// Run the full bootstrap. Errors propagate unchanged so the UI can map them: + /// `ControlPlaneLoginError` (login step), `DeviceEnrollmentError` (enroll + /// step, e.g. 403 subdomain-not-owned), or a keychain/CSR error from install. + /// A login failure short-circuits — the enroll step never runs. + public func run( + password: String, subdomain: String, deviceName: String + ) async throws -> ClientCertificateSummary? { + let login = try await ControlPlaneLoginClient(baseURL: baseURL, transport: transport) + .login(password: password) + let client = DeviceEnrollmentClient( + baseURL: baseURL, bearerToken: login.enrollToken, transport: transport + ) + return try await install(client, subdomain, deviceName) + } +} diff --git a/ios/Packages/ClientTLS/Sources/ClientTLS/KeychainClientIdentityStore.swift b/ios/Packages/ClientTLS/Sources/ClientTLS/KeychainClientIdentityStore.swift index ab21fd6..ec0cb71 100644 --- a/ios/Packages/ClientTLS/Sources/ClientTLS/KeychainClientIdentityStore.swift +++ b/ios/Packages/ClientTLS/Sources/ClientTLS/KeychainClientIdentityStore.swift @@ -172,6 +172,20 @@ public struct KeychainClientIdentityStore: ClientIdentityStore { return try loadSummary() } + /// B3 · Rotation timing of the installed enrolled (Secure-Enclave) identity, + /// read for the silent rotation scheduler. `nil` when there is no enrolled + /// identity (a fresh install, or a legacy `.p12`-only device — neither of + /// which the scheduler can silently renew). The private key never leaves the + /// Secure Enclave, so only the advisory timing + deviceId surface here. + public func renewalState() throws -> DeviceRenewalState? { + guard let record = try readEnrollment() else { return nil } + return DeviceRenewalState( + deviceId: record.deviceId, + notAfter: record.notAfter, + renewAfter: record.renewAfter + ) + } + /// The Secure-Enclave-backed identity: the assembled `SecIdentity` (leaf bound /// to the SE key) plus the stored issuer chain. `nil` when not enrolled. func loadDeviceIdentity() throws -> ClientIdentity? { @@ -205,31 +219,62 @@ public struct KeychainClientIdentityStore: ClientIdentityStore { return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess } - /// Persist the enrolled leaf (delete-then-add so rotation replaces the prior - /// one) plus the enrollment record. The leaf binds to the permanent SE key, - /// letting the keychain form the identity on load. + /// Persist the enrolled leaf plus the enrollment record. The leaf binds to the + /// permanent SE key, letting the keychain form the identity on load. + /// + /// ATOMIC replace (reached automatically by the rotation scheduler): ADD the + /// new leaf BEFORE deleting the prior one — the inverse of delete-then-add, + /// whose window has zero leaves installed. An interrupted write therefore + /// never strands the device without an identity. The prior leaf is deleted by + /// its captured persistent ref (both share `leafLabel`, so a label-only delete + /// would also remove the just-added replacement). private func storeEnrolledLeaf(_ result: EnrollmentResult, deviceName: String) throws { guard let certificate = SecCertificateCreateWithData(nil, result.certificate as CFData) else { throw ClientIdentityStoreError.corruptStoredBlob // not a valid DER cert } - let deleteLeaf: [String: Any] = [ - kSecClass as String: kSecClassCertificate, - kSecAttrLabel as String: leafLabel, - ] - let deleteStatus = SecItemDelete(deleteLeaf as CFDictionary) - guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else { - throw ClientIdentityStoreError.keychain(deleteStatus) - } - let addLeaf: [String: Any] = [ - kSecClass as String: kSecClassCertificate, - kSecValueRef as String: certificate, - kSecAttrLabel as String: leafLabel, - ] - let addStatus = SecItemAdd(addLeaf as CFDictionary, nil) - guard addStatus == errSecSuccess else { - throw ClientIdentityStoreError.keychain(addStatus) - } + // Capture the prior leaf BEFORE the add so it can be deleted by identity + // afterwards. `nil` on first enroll (nothing to replace). + let priorLeafRef = existingLeafPersistentRef() + var addedDistinctLeaf = false + try replaceKeychainItemAtomically( + addNew: { + let addLeaf: [String: Any] = [ + kSecClass as String: kSecClassCertificate, + kSecValueRef as String: certificate, + kSecAttrLabel as String: leafLabel, + ] + let addStatus = SecItemAdd(addLeaf as CFDictionary, nil) + switch addStatus { + case errSecSuccess: + addedDistinctLeaf = true // a new, distinct leaf now coexists with the old + case errSecDuplicateItem: + // Byte-identical re-store (same serial): the leaf is already + // installed. Keep it and DO NOT delete the prior ref — it is + // the very item we just tried to add. + addedDistinctLeaf = false + default: + throw ClientIdentityStoreError.keychain(addStatus) + } + }, + deleteOld: { + // Only sweep the prior leaf once a distinct replacement is in place. + guard addedDistinctLeaf, let priorLeafRef else { return } + let deleteStatus = SecItemDelete( + [kSecValuePersistentRef as String: priorLeafRef] as CFDictionary + ) + guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else { + throw ClientIdentityStoreError.keychain(deleteStatus) + } + }, + reportStaleDeleteFailure: { error in + // Non-fatal: the new leaf is installed (renewal succeeded). A stale + // leftover binds to the same SE key and is swept on the next write. + ClientTLSLog.identity.error( + "storeEnrolledLeaf: stale leaf delete failed (new leaf installed): \(String(describing: error), privacy: .public)" + ) + } + ) try writeEnrollment( StoredEnrollment( deviceId: result.deviceId, @@ -241,6 +286,22 @@ public struct KeychainClientIdentityStore: ClientIdentityStore { ) } + /// Persistent reference of the currently-installed enrolled leaf (by label), + /// captured before an atomic replace so the prior copy can be deleted by + /// identity. `nil` when none is installed or the lookup fails (first enroll). + private func existingLeafPersistentRef() -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassCertificate, + kSecAttrLabel as String: leafLabel, + kSecReturnPersistentRef as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return data + } + /// Delete the SE key, the enrolled leaf, and the enrollment record. Idempotent. private func removeEnrolled() throws { let deleteLeaf: [String: Any] = [ @@ -273,9 +334,17 @@ public struct KeychainClientIdentityStore: ClientIdentityStore { } catch { throw ClientIdentityStoreError.corruptStoredBlob } - let deleteStatus = SecItemDelete(enrollmentQuery() as CFDictionary) - guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else { - throw ClientIdentityStoreError.keychain(deleteStatus) + // ATOMIC upsert (reached automatically by the rotation scheduler): update + // the DATA in place — the (service, account) primary key and protection + // class are unchanged on renewal, so no delete-then-add window can strand + // the device without its enrollment record. Fall back to add on first write. + let updateStatus = SecItemUpdate( + enrollmentQuery() as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { + throw ClientIdentityStoreError.keychain(updateStatus) } var attributes = enrollmentQuery() attributes[kSecValueData as String] = data diff --git a/ios/Packages/ClientTLS/Sources/ClientTLS/KeychainItemReplace.swift b/ios/Packages/ClientTLS/Sources/ClientTLS/KeychainItemReplace.swift new file mode 100644 index 0000000..dbed93c --- /dev/null +++ b/ios/Packages/ClientTLS/Sources/ClientTLS/KeychainItemReplace.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Replace a keychain-backed item so a crash/failure mid-write can NEVER leave +/// zero installed copies — which, for the device identity, would lock the device +/// out of its own mTLS cert until a manual re-enroll. This is the inverse of a +/// delete-then-add sequence, whose window between the two calls has zero items: +/// +/// 1. `addNew` FIRST. Once it succeeds the replacement is installed, so at +/// least one valid item exists from here on. If `addNew` throws, the prior +/// item is left untouched (the device keeps a working identity) and the +/// error propagates. +/// 2. `deleteOld` SECOND, removing the prior copy. A failure here is NON-fatal: +/// the new item is already installed, so the write is a success. The stale +/// leftover is reported via `reportStaleDeleteFailure` (never a lockout) and +/// swept on the next write — throwing here would strand a working new cert. +/// +/// Kept pure (closures, no `Security` types) so the ordering invariant is unit- +/// testable with fakes; the keychain plumbing lives at the call sites. +func replaceKeychainItemAtomically( + addNew: () throws -> Void, + deleteOld: () throws -> Void, + reportStaleDeleteFailure: (Error) -> Void = { _ in } +) throws { + try addNew() + do { + try deleteOld() + } catch { + reportStaleDeleteFailure(error) + } +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/CertificateRotationSchedulerTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/CertificateRotationSchedulerTests.swift new file mode 100644 index 0000000..af58086 --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/CertificateRotationSchedulerTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing +@testable import ClientTLS + +// B3 · The silent rotation scheduler decides — from the installed identity's +// rotation timing — whether to renew before expiry, then drives an injected +// mTLS renew. Pure and transport-free: fully unit-testable with fakes. + +private let t0 = ISO8601DateFormatter().date(from: "2026-09-05T00:00:00Z")! +private let renewAfter = ISO8601DateFormatter().date(from: "2026-09-05T00:00:00Z")! +private let notAfter = ISO8601DateFormatter().date(from: "2026-10-06T00:00:00Z")! +private let before = ISO8601DateFormatter().date(from: "2026-09-04T00:00:00Z")! +private let after = ISO8601DateFormatter().date(from: "2026-09-06T00:00:00Z")! + +private func state(renewAfter: Date? = renewAfter) -> DeviceRenewalState { + DeviceRenewalState(deviceId: "dev-1", notAfter: notAfter, renewAfter: renewAfter) +} + +/// Records how many times the renew operation ran. `@unchecked Sendable`: guarded +/// by a lock. +private final class RenewSpy: @unchecked Sendable { + private let lock = NSLock() + private var _count = 0 + var count: Int { lock.withLock { _count } } + func bump() { lock.withLock { _count += 1 } } +} + +@Test("no enrolled identity → notEnrolled, and renew never runs") +func schedulerNotEnrolled() async { + let spy = RenewSpy() + let scheduler = CertificateRotationScheduler( + renewalState: { nil }, + performRenew: { spy.bump(); return nil }, + now: { after } + ) + let outcome = await scheduler.runIfDue() + #expect(outcome == .notEnrolled) + #expect(spy.count == 0) +} + +@Test("cert not yet due → notDue, and renew never runs") +func schedulerNotDue() async { + let spy = RenewSpy() + let scheduler = CertificateRotationScheduler( + renewalState: { state() }, + performRenew: { spy.bump(); return nil }, + now: { before } + ) + let outcome = await scheduler.runIfDue() + #expect(outcome == .notDue(renewAfter: renewAfter)) + #expect(spy.count == 0) +} + +@Test("past renewAfter → renews exactly once") +func schedulerRenews() async { + let spy = RenewSpy() + let scheduler = CertificateRotationScheduler( + renewalState: { state() }, + performRenew: { spy.bump(); return nil }, + now: { after } + ) + let outcome = await scheduler.runIfDue() + #expect(outcome == .renewed) + #expect(spy.count == 1) +} + +@Test("due exactly at renewAfter (>= boundary) renews") +func schedulerRenewsAtBoundary() async { + let scheduler = CertificateRotationScheduler( + renewalState: { state() }, + performRenew: { nil }, + now: { renewAfter } // now == renewAfter + ) + #expect(await scheduler.runIfDue() == .renewed) +} + +@Test("a missing renewAfter never triggers (fail-safe — TLS stack is the gate)") +func schedulerMissingRenewAfterNeverDue() async { + let scheduler = CertificateRotationScheduler( + renewalState: { state(renewAfter: nil) }, + performRenew: { nil }, + now: { after } + ) + #expect(await scheduler.runIfDue() == .notDue(renewAfter: nil)) +} + +@Test("a renew that throws is surfaced as .failed (never crashes, cert still valid)") +func schedulerRenewFailure() async { + struct Boom: Error {} + let scheduler = CertificateRotationScheduler( + renewalState: { state() }, + performRenew: { throw Boom() }, + now: { after } + ) + #expect(await scheduler.runIfDue() == .failed) +} + +@Test("a keychain read fault is surfaced as .failed, not a crash") +func schedulerReadFailure() async { + struct Boom: Error {} + let scheduler = CertificateRotationScheduler( + renewalState: { throw Boom() }, + performRenew: { nil }, + now: { after } + ) + #expect(await scheduler.runIfDue() == .failed) +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/ControlPlaneLoginClientTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/ControlPlaneLoginClientTests.swift new file mode 100644 index 0000000..febabad --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/ControlPlaneLoginClientTests.swift @@ -0,0 +1,123 @@ +import Foundation +import Testing +@testable import ClientTLS + +// B3 · ControlPlaneLoginClient request-building + response-mapping, driven by a +// stub transport (no network). Mirrors the PINNED login contract exactly: +// +// POST /auth/login { "password": "" } +// → 201 { "enrollToken": "", "accountId": "", "expiresIn": } + +private let cpBaseURL = URL(string: "https://cp.terminal.yaojia.wang")! + +/// Records the last request and replays a canned response. `@unchecked Sendable`: +/// the mutable capture is guarded by a lock. +private final class LoginStubTransport: EnrollmentTransport, @unchecked Sendable { + private let lock = NSLock() + private var _lastRequest: URLRequest? + private let status: Int + private let body: Data + + init(status: Int, body: Data) { + self.status = status + self.body = body + } + + var lastRequest: URLRequest? { lock.withLock { _lastRequest } } + + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + lock.withLock { _lastRequest = request } + let response = HTTPURLResponse( + url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil + )! + return (body, response) + } +} + +private func loginBody( + enrollToken: String = "v4.public.enroll-token", + accountId: String = "11111111-2222-3333-4444-555555555555", + expiresIn: Int = 600 +) -> Data { + let json: [String: Any] = [ + "enrollToken": enrollToken, + "accountId": accountId, + "expiresIn": expiresIn, + ] + return try! JSONSerialization.data(withJSONObject: json) +} + +@Test("login builds a POST /auth/login carrying only the password (never bearer)") +func loginBuildsRequest() async throws { + // Arrange + let stub = LoginStubTransport(status: 201, body: loginBody()) + let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub) + + // Act + _ = try await client.login(password: "operator-secret") + + // Assert + let request = try #require(stub.lastRequest) + #expect(request.httpMethod == "POST") + #expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/auth/login") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + // Login is credential-based, NOT bearer-authed — no Authorization header. + #expect(request.value(forHTTPHeaderField: "Authorization") == nil) + + let sent = try #require(request.httpBody) + let object = try #require(try JSONSerialization.jsonObject(with: sent) as? [String: Any]) + #expect(object["password"] as? String == "operator-secret") + #expect(object.keys.count == 1) // password ONLY — no extra fields leak +} + +@Test("login maps a 201 response into a typed LoginResult") +func loginMapsResponse() async throws { + // Arrange + let stub = LoginStubTransport(status: 201, body: loginBody( + enrollToken: "v4.public.abc", accountId: "acct-uuid", expiresIn: 900 + )) + let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub) + + // Act + let result = try await client.login(password: "pw") + + // Assert + #expect(result.enrollToken == "v4.public.abc") + #expect(result.accountId == "acct-uuid") + #expect(result.expiresIn == 900) +} + +@Test("an empty password fails fast without any network call") +func loginEmptyPasswordRejected() async throws { + // Arrange + let stub = LoginStubTransport(status: 201, body: loginBody()) + let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub) + + // Act / Assert — boundary validation: reject before sending. + await #expect(throws: ControlPlaneLoginError.emptyPassword) { + _ = try await client.login(password: "") + } + #expect(stub.lastRequest == nil) // proves zero network happened +} + +@Test("a 401 login response surfaces http with the server error code") +func loginRejectSurfacesStatus() async throws { + // The login stub seam denies-by-default → { error: "login rejected" }. + let body = try! JSONSerialization.data(withJSONObject: ["error": "login rejected"]) + let stub = LoginStubTransport(status: 401, body: body) + let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub) + + await #expect(throws: ControlPlaneLoginError.http(status: 401, code: "login rejected")) { + _ = try await client.login(password: "wrong") + } +} + +@Test("a malformed 201 body throws malformedResponse") +func loginMalformedBody() async throws { + let stub = LoginStubTransport(status: 201, body: Data("not json".utf8)) + let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub) + + await #expect(throws: ControlPlaneLoginError.malformedResponse) { + _ = try await client.login(password: "pw") + } +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/DeviceEnrollmentClientTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/DeviceEnrollmentClientTests.swift index f2f1426..fb23287 100644 --- a/ios/Packages/ClientTLS/Tests/ClientTLSTests/DeviceEnrollmentClientTests.swift +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/DeviceEnrollmentClientTests.swift @@ -169,3 +169,46 @@ func renewTargetsRenewEndpoint() async throws { #expect(stub.lastRequest?.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/dev-9/renew") } + +@Test("renew body is {csr}-ONLY (server's strict schema rejects any extra field)") +func renewSendsCsrOnlyBody() async throws { + // The control-plane /device/:id/renew schema authenticates by the presented + // mTLS cert and accepts a body of exactly { csr } — a stray `keyAlg` (or any + // non-csr field) trips its strict validation and 400s every silent renewal. + let stub = StubTransport(status: 201, body: enrollBody()) + let client = DeviceEnrollmentClient(baseURL: baseURL, transport: stub) + let csr = Data([0x0A, 0x0B, 0x0C]) + + _ = try await client.renew(deviceId: "dev-9", csrDER: csr) + + let request = try #require(stub.lastRequest) + let sent = try #require(request.httpBody) + let object = try #require(try JSONSerialization.jsonObject(with: sent) as? [String: Any]) + #expect(object["csr"] as? String == csr.base64EncodedString()) + #expect(object["keyAlg"] == nil) // NOT sent — the enroll-only field must be dropped + #expect(object.keys.sorted() == ["csr"]) // exactly one field +} + +@Test("renew with no bearer sends NO Authorization header (mTLS-authenticated)") +func renewMTLSOmitsBearer() async throws { + // /device/:id/renew authenticates by the CURRENT device client cert (mTLS), + // NOT a bearer — the silent rotation scheduler has no fresh bearer weeks + // later, so it constructs the client with a nil bearer and relies on the + // transport presenting the installed identity. The Authorization header + // must therefore be ABSENT (a stale/empty "Bearer " would be wrong). + let stub = StubTransport(status: 201, body: enrollBody()) + let client = DeviceEnrollmentClient(baseURL: baseURL, transport: stub) // bearer defaults to nil + _ = try await client.renew(deviceId: "dev-9", csrDER: Data([0x02])) + let request = try #require(stub.lastRequest) + #expect(request.value(forHTTPHeaderField: "Authorization") == nil) + // Content-Type is still set — it's a JSON POST regardless of auth mechanism. + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") +} + +@Test("enroll still sets the bearer when one is supplied (unchanged contract)") +func enrollKeepsBearerWhenSupplied() async throws { + let stub = StubTransport(status: 201, body: enrollBody()) + let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub) + _ = try await client.enroll(csrDER: Data([0x01]), subdomain: "a", deviceName: "d") + #expect(stub.lastRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer \(bearer)") +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/DeviceEnrollmentFlowTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/DeviceEnrollmentFlowTests.swift new file mode 100644 index 0000000..7a0bd31 --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/DeviceEnrollmentFlowTests.swift @@ -0,0 +1,136 @@ +import Foundation +import Testing +@testable import ClientTLS + +// B3 · DeviceEnrollmentFlow composes the pinned bootstrap end-to-end: +// login(password) → bearer → enroll(CSR, subdomain) with that bearer → leaf. +// Driven by a path-routing stub transport (no network, no keychain): asserts the +// bearer minted at login is exactly what authenticates the enroll call. + +private let flowBaseURL = URL(string: "https://cp.terminal.yaojia.wang")! + +/// Routes by path: `/auth/login` → login body, `/device/enroll` → enroll body. +/// Records every request so the test can assert the enroll bearer. `@unchecked +/// Sendable`: mutable captures guarded by a lock. +private final class RoutingStubTransport: EnrollmentTransport, @unchecked Sendable { + private let lock = NSLock() + private var _requests: [URLRequest] = [] + private let loginStatus: Int + private let loginBody: Data + + init(loginStatus: Int = 201, loginBody: Data) { + self.loginStatus = loginStatus + self.loginBody = loginBody + } + + var requests: [URLRequest] { lock.withLock { _requests } } + func request(forPathSuffix suffix: String) -> URLRequest? { + lock.withLock { _requests.first { $0.url?.path.hasSuffix(suffix) == true } } + } + + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + lock.withLock { _requests.append(request) } + let path = request.url?.path ?? "" + let (status, body): (Int, Data) + if path.hasSuffix("/auth/login") { + (status, body) = (loginStatus, loginBody) + } else { + // /device/enroll → a minimal 201 leaf. + (status, body) = (201, enrollLeafBody()) + } + let response = HTTPURLResponse( + url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil + )! + return (body, response) + } +} + +private func loginResponseBody(enrollToken: String) -> Data { + try! JSONSerialization.data(withJSONObject: [ + "enrollToken": enrollToken, + "accountId": "acct-123", + "expiresIn": 600, + ]) +} + +private func enrollLeafBody() -> Data { + try! JSONSerialization.data(withJSONObject: [ + "deviceId": "dev-1", + "cert": Data([0x30, 0x01]).base64EncodedString(), + "caChain": [Data([0x30, 0x02]).base64EncodedString()], + "notBefore": "2026-07-18T00:00:00Z", + "notAfter": "2026-10-16T00:00:00Z", + "renewAfter": "2026-09-15T00:00:00Z", + ]) +} + +/// A fake installer that invokes the real `client.enroll` (so the transport sees +/// the enroll request with the login-minted bearer), then returns a summary. +/// Records the subdomain/deviceName it was handed. `@unchecked Sendable`: lock. +private final class SpyInstaller: @unchecked Sendable { + private let lock = NSLock() + private(set) var subdomain: String? + private(set) var deviceName: String? + + func install( + _ client: DeviceEnrollmentClient, _ subdomain: String, _ deviceName: String + ) async throws -> ClientCertificateSummary? { + lock.withLock { self.subdomain = subdomain; self.deviceName = deviceName } + _ = try await client.enroll(csrDER: Data([0xAB]), subdomain: subdomain, deviceName: deviceName) + return ClientCertificateSummary( + subjectCommonName: deviceName, issuerCommonName: "webterm-device-ca", notAfter: nil + ) + } +} + +@Test("flow logs in then enrolls with the login-minted bearer (end to end)") +func flowEndToEnd() async throws { + // Arrange + let transport = RoutingStubTransport(loginBody: loginResponseBody(enrollToken: "TOKEN-XYZ")) + let installer = SpyInstaller() + let flow = DeviceEnrollmentFlow( + baseURL: flowBaseURL, + transport: transport, + install: installer.install + ) + + // Act + let summary = try await flow.run(password: "pw", subdomain: "alice", deviceName: "Alice iPhone") + + // Assert — login happened, THEN enroll with the exact bearer from login. + #expect(transport.request(forPathSuffix: "/auth/login") != nil) + let enroll = try #require(transport.request(forPathSuffix: "/device/enroll")) + #expect(enroll.value(forHTTPHeaderField: "Authorization") == "Bearer TOKEN-XYZ") + #expect(installer.subdomain == "alice") + #expect(installer.deviceName == "Alice iPhone") + #expect(summary?.subjectCommonName == "Alice iPhone") +} + +@Test("a login failure short-circuits: enroll is never attempted") +func flowLoginFailureShortCircuits() async { + // 401 login → the flow must NOT reach /device/enroll. + let body = try! JSONSerialization.data(withJSONObject: ["error": "login rejected"]) + let transport = RoutingStubTransport(loginStatus: 401, loginBody: body) + let installer = SpyInstaller() + let flow = DeviceEnrollmentFlow( + baseURL: flowBaseURL, transport: transport, install: installer.install + ) + + await #expect(throws: ControlPlaneLoginError.http(status: 401, code: "login rejected")) { + _ = try await flow.run(password: "wrong", subdomain: "alice", deviceName: "d") + } + #expect(transport.request(forPathSuffix: "/device/enroll") == nil) + #expect(installer.subdomain == nil) // installer never ran +} + +@Test("an empty password is rejected before any network") +func flowEmptyPassword() async { + let transport = RoutingStubTransport(loginBody: loginResponseBody(enrollToken: "t")) + let flow = DeviceEnrollmentFlow( + baseURL: flowBaseURL, transport: transport, install: { _, _, _ in nil } + ) + await #expect(throws: ControlPlaneLoginError.emptyPassword) { + _ = try await flow.run(password: "", subdomain: "alice", deviceName: "d") + } + #expect(transport.requests.isEmpty) +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainItemReplaceTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainItemReplaceTests.swift new file mode 100644 index 0000000..300186e --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainItemReplaceTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import ClientTLS + +// C-iOS · The keychain-replace helper backs the rotation scheduler's identity +// updates. Its one job: a crash/failure mid-write must NEVER leave zero installed +// items (which would lock the device out of its own mTLS identity until a manual +// re-enroll). It enforces ADD-new-BEFORE-DELETE-old ordering — the inverse of the +// delete-then-add sequence whose window has zero items installed. + +/// A tiny in-memory stand-in for the keychain: the current set of installed item +/// ids plus the smallest count ever observed, so a test can assert the invariant +/// that the installed set is NEVER empty at any step of the replace. +private final class FakeKeychain { + private(set) var items: Set + private(set) var log: [String] = [] + private(set) var minCount: Int + init(seed: Set) { + items = seed + minCount = seed.count + } + + func add(_ id: String) { + items.insert(id) + log.append("add(\(id))") + minCount = min(minCount, items.count) + } + + func delete(_ id: String) { + items.remove(id) + log.append("delete(\(id))") + minCount = min(minCount, items.count) + } +} + +@Test("replace ADDS the new item before DELETING the old — never a zero-item window") +func replaceAddsBeforeDeletes() throws { + let keychain = FakeKeychain(seed: ["old"]) + try replaceKeychainItemAtomically( + addNew: { keychain.add("new") }, + deleteOld: { keychain.delete("old") } + ) + #expect(keychain.items == ["new"]) + #expect(keychain.log == ["add(new)", "delete(old)"]) // add strictly precedes delete + #expect(keychain.minCount >= 1) // at least one identity installed at every step +} + +@Test("a failed add leaves the prior item intact and never runs the delete") +func replaceAddFailureKeepsOld() { + struct Boom: Error {} + let keychain = FakeKeychain(seed: ["old"]) + #expect(throws: Boom.self) { + try replaceKeychainItemAtomically( + addNew: { throw Boom() }, + deleteOld: { keychain.delete("old") } + ) + } + #expect(keychain.items == ["old"]) // old preserved — a working identity remains + #expect(keychain.log.contains("delete(old)") == false) + #expect(keychain.minCount >= 1) +} + +@Test("a failed delete-of-old is non-fatal — the new item stays installed and the fault is surfaced") +func replaceDeleteFailureKeepsNew() throws { + struct Boom: Error {} + let keychain = FakeKeychain(seed: ["old"]) + var reported: Error? + try replaceKeychainItemAtomically( + addNew: { keychain.add("new") }, + deleteOld: { throw Boom() }, + reportStaleDeleteFailure: { reported = $0 } + ) + #expect(keychain.items.contains("new")) // renewal succeeded despite the stale-delete fault + #expect(reported is Boom) // surfaced (not silently swallowed) + #expect(keychain.minCount >= 1) +}