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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user