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