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:
Yaojia Wang
2026-07-18 13:32:05 +02:00
parent 9a5909f672
commit 07bcbf0c08
19 changed files with 1549 additions and 32 deletions

View File

@@ -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 }
}
}
}
}
}
}

View File

@@ -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 sheetVM
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:

View 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)
}
)
}

View File

@@ -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 = "设备证书自动续期失败,将在下次前台重试"
}

View File

@@ -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(