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") }