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.
This commit is contained in:
275
ios/App/WebTerm/Screens/EnrollmentScreen.swift
Normal file
275
ios/App/WebTerm/Screens/EnrollmentScreen.swift
Normal file
@@ -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 = "注册失败,请重试。"
|
||||
}
|
||||
@@ -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(扫码或手输地址),会话会出现在这里。"
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
90
ios/App/WebTerm/Wiring/DeviceEnrollmentWiring.swift
Normal file
90
ios/App/WebTerm/Wiring/DeviceEnrollmentWiring.swift
Normal file
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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<Bool> {
|
||||
@@ -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 = "设备证书自动续期失败,将在下次前台重试"
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
142
ios/App/WebTermTests/EnrollmentViewModelTests.swift
Normal file
142
ios/App/WebTermTests/EnrollmentViewModelTests.swift
Normal file
@@ -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<ClientCertificateSummary?, any Error>
|
||||
|
||||
init(result: Result<ClientCertificateSummary?, any Error>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user