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

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