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

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