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