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.
108 lines
4.7 KiB
Swift
108 lines
4.7 KiB
Swift
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")
|
|
}
|