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

@@ -0,0 +1,107 @@
import Foundation
import os
/// B3 · Rotation timing of an installed enrolled (Secure-Enclave) identity. The
/// scheduler reads this to decide whether to pre-empt expiry. The private key
/// itself never leaves the Secure Enclave, so only advisory timing surfaces.
public struct DeviceRenewalState: Equatable, Sendable {
/// The enrolled device id drives `POST /device/:id/renew`.
public let deviceId: String
/// Leaf `notAfter` (hard expiry the TLS stack rejects past it).
public let notAfter: Date?
/// When to renew from the same hardware key (~2/3 of the lifetime).
public let renewAfter: Date?
public init(deviceId: String, notAfter: Date?, renewAfter: Date?) {
self.deviceId = deviceId
self.notAfter = notAfter
self.renewAfter = renewAfter
}
/// Is the leaf due for renewal as of `now`? A missing `renewAfter` NEVER
/// triggers (fail-safe the TLS stack is the real gate; the scheduler only
/// pre-empts expiry). Mirrors `EnrollmentResult.isRenewalDue`.
public func isRenewalDue(asOf now: Date) -> Bool {
guard let renewAfter else { return false }
return now >= renewAfter
}
}
/// B3 · The silent rotation scheduler: on a trigger (app foreground / launch) it
/// checks the installed identity's rotation timing and, if the renew window has
/// opened, drives an mTLS renew against the SAME Secure-Enclave key. This is the
/// "one bootstrap tap, then never again" half of zero-touch after the first
/// login+enroll, the cert renews itself with no human action.
///
/// Deliberately pure and transport-free: `renewalState` and `performRenew` are
/// injected closures, so the whole decision table is unit-testable with fakes
/// (no keychain, no network). Production wires `renewalState` to the keychain
/// store and `performRenew` to an mTLS `DeviceEnrollmentClient` renew (nil
/// bearer the current cert authenticates).
///
/// Failure posture: a keychain read fault or a renew error is surfaced as
/// `.failed` and logged (never a secret), NEVER a crash the existing cert
/// stays valid until `notAfter`, so a transient renew failure is recoverable on
/// the next trigger.
public struct CertificateRotationScheduler: Sendable {
/// Current rotation timing; `nil` = nothing enrolled to renew.
private let renewalState: @Sendable () throws -> DeviceRenewalState?
/// The mTLS renew operation (re-CSR from the SE key replace the leaf).
private let performRenew: @Sendable () async throws -> ClientCertificateSummary?
private let now: @Sendable () -> Date
public init(
renewalState: @escaping @Sendable () throws -> DeviceRenewalState?,
performRenew: @escaping @Sendable () async throws -> ClientCertificateSummary?,
now: @escaping @Sendable () -> Date = { Date() }
) {
self.renewalState = renewalState
self.performRenew = performRenew
self.now = now
}
/// The outcome of a single scheduler pass total over the decision table.
public enum Outcome: Equatable, Sendable {
/// No enrolled identity (fresh install / legacy `.p12`): nothing to do.
case notEnrolled
/// Enrolled but the renew window has not opened yet.
case notDue(renewAfter: Date?)
/// The renew succeeded; the new leaf is installed for the next handshake.
case renewed
/// A read/renew error occurred; logged, cert still valid until expiry.
case failed
}
/// Run one pass: read timing decide renew if due. Idempotent and safe to
/// call on every foreground; `.notDue` is the common (cheap) case.
public func runIfDue() async -> Outcome {
let state: DeviceRenewalState?
do {
state = try renewalState()
} catch {
RotationLog.log.error(
"rotation: renewal-state read failed: \(String(describing: error), privacy: .public)"
)
return .failed
}
guard let state else { return .notEnrolled }
guard state.isRenewalDue(asOf: now()) else {
return .notDue(renewAfter: state.renewAfter)
}
do {
_ = try await performRenew()
RotationLog.log.info("rotation: device certificate renewed silently")
return .renewed
} catch {
// No secrets in the log only the error shape (never token/cert bytes).
RotationLog.log.error(
"rotation: silent renew failed: \(String(describing: error), privacy: .public)"
)
return .failed
}
}
}
private enum RotationLog {
static let log = Logger(subsystem: "com.yaojia.webterm", category: "cert-rotation")
}