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

View File

@@ -0,0 +1,108 @@
import Foundation
/// B3 · The phone-track bootstrap: exchanges the operator's one-time credential
/// for a short-lived `device:enroll` bearer at the control-plane.
///
/// PINNED login contract (all pieces MUST agree iOS / Android / server B1):
///
/// `POST /auth/login { "password": "<operator secret>" }`
/// 201 { "enrollToken": "<jwt/capability>", "accountId": "<uuid>", "expiresIn": <seconds> }
///
/// The returned `enrollToken` is then presented as `Authorization: Bearer ` to
/// `DeviceEnrollmentClient.enroll`. This client is deliberately logic-free about
/// TLS and reuses the SAME `EnrollmentTransport` seam as `DeviceEnrollmentClient`,
/// so production injects `URLSessionHTTPTransport` and tests inject a stub.
///
/// Security posture:
/// - The password is validated at the boundary (non-empty) and sent ONCE; it is
/// never persisted, never logged, and no other field rides along in the body.
/// - The `enrollToken` is a secret bearer: it is never logged here either. Only
/// the transport (which sees it in a header) and the caller hold it, briefly.
public struct ControlPlaneLoginClient: Sendable {
private let baseURL: URL
private let transport: any EnrollmentTransport
public init(baseURL: URL, transport: any EnrollmentTransport) {
self.baseURL = baseURL
self.transport = transport
}
/// Exchange the operator credential for a `device:enroll` bearer.
///
/// - Throws: `ControlPlaneLoginError.emptyPassword` (before any network),
/// `.http(status:code:)` on a non-201 (401 = bad/missing credential),
/// `.malformedResponse` on a 201 body that does not decode.
public func login(password: String) async throws -> LoginResult {
// Boundary validation fail fast, never send an empty credential.
guard !password.isEmpty else { throw ControlPlaneLoginError.emptyPassword }
guard let url = URL(string: "/auth/login", relativeTo: baseURL) else {
throw ControlPlaneLoginError.malformedResponse
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// Password ONLY no accountId / device fields leak from the client.
request.httpBody = try JSONSerialization.data(withJSONObject: ["password": password])
let (data, response) = try await transport.send(request)
guard response.statusCode == 201 else {
throw ControlPlaneLoginError.http(status: response.statusCode, code: errorCode(in: data))
}
guard let dto = try? JSONDecoder().decode(LoginResponseDTO.self, from: data) else {
throw ControlPlaneLoginError.malformedResponse
}
return dto.toResult()
}
private func errorCode(in data: Data) -> String? {
(try? JSONDecoder().decode(LoginErrorDTO.self, from: data))?.error
}
}
// MARK: - Result & errors
/// The login exchange result. The `enrollToken` is a short-lived secret bearer;
/// hold it only as long as needed to drive `DeviceEnrollmentClient.enroll`.
public struct LoginResult: Equatable, Sendable {
/// The `device:enroll` capability bearer to present to `/device/enroll`.
public let enrollToken: String
/// The authenticated account (the token `sub`); the device enrolls under a
/// subdomain this account owns (server enforces ownership, deny-by-default).
public let accountId: String
/// Token lifetime in seconds (minutes-scale). Advisory: the enrollment must
/// complete within this window or the bearer is re-minted via another login.
public let expiresIn: Int
public init(enrollToken: String, accountId: String, expiresIn: Int) {
self.enrollToken = enrollToken
self.accountId = accountId
self.expiresIn = expiresIn
}
}
public enum ControlPlaneLoginError: Error, Equatable, Sendable {
/// The password was empty rejected before any network call.
case emptyPassword
/// Non-success HTTP status with the server's uniform `{ error }` code, if any
/// (401 = bad/missing credential, 429 = rate_limited).
case http(status: Int, code: String?)
/// A 2xx body that did not decode to the expected shape.
case malformedResponse
}
// MARK: - Wire DTOs
private struct LoginResponseDTO: Decodable {
let enrollToken: String
let accountId: String
let expiresIn: Int
func toResult() -> LoginResult {
LoginResult(enrollToken: enrollToken, accountId: accountId, expiresIn: expiresIn)
}
}
private struct LoginErrorDTO: Decodable {
let error: String
}

View File

@@ -16,11 +16,15 @@ import Foundation
/// server's `decodeCsrWire` accepts directly.
public struct DeviceEnrollmentClient: Sendable {
private let baseURL: URL
/// The one-time account `device:enroll` bearer obtained at login.
private let bearerToken: String
/// The one-time account `device:enroll` bearer obtained at login. `nil` on
/// the renew path, which authenticates by the CURRENT device client cert
/// (mTLS) the silent rotation scheduler has no fresh bearer weeks later, so
/// it constructs the client with `nil` and relies on the transport presenting
/// the installed identity. When `nil`, no `Authorization` header is sent.
private let bearerToken: String?
private let transport: any EnrollmentTransport
public init(baseURL: URL, bearerToken: String, transport: any EnrollmentTransport) {
public init(baseURL: URL, bearerToken: String? = nil, transport: any EnrollmentTransport) {
self.baseURL = baseURL
self.bearerToken = bearerToken
self.transport = transport
@@ -42,10 +46,12 @@ public struct DeviceEnrollmentClient: Sendable {
}
/// Renew against the SAME hardware key (A6 seam): a fresh CSR to
/// `/device/:id/renew`. Server support lands in A6; the client shape is here
/// so the rotation scheduler has an endpoint to drive.
/// `/device/:id/renew`. The endpoint authenticates by the presented mTLS cert
/// and its schema is strict the body is `{ csr }` ONLY (NOT the enroll body).
/// A stray `keyAlg` (or any non-`csr` field) trips server validation and 400s
/// every silent renewal, so it is deliberately absent here.
public func renew(deviceId: String, csrDER: Data) async throws -> EnrollmentResult {
let body = ["csr": csrDER.base64EncodedString(), "keyAlg": "ec-p256"]
let body = ["csr": csrDER.base64EncodedString()]
let path = "/device/\(deviceId)/renew"
let request = try makeJSONRequest(path: path, jsonObject: body)
return try await sendExpectingLeaf(request)
@@ -73,7 +79,11 @@ public struct DeviceEnrollmentClient: Sendable {
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
// Bearer only when present (enroll). The renew path passes nil and
// authenticates via the presented client certificate (mTLS).
if let bearerToken {
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
}
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: jsonObject)
return request

View File

@@ -0,0 +1,63 @@
import Foundation
/// B3 · The phone-track enrollment FLOW the composition that turns the pinned
/// bootstrap contract into an installed, hardware-bound device identity:
///
/// 1. `POST /auth/login { password }` short-lived `device:enroll` bearer.
/// 2. Build a `DeviceEnrollmentClient` authenticated with THAT bearer.
/// 3. `install` = generate a NON-EXPORTABLE Secure-Enclave key, self-sign a
/// PKCS#10 CSR, `POST /device/enroll`, and store the returned leaf against
/// the SE key so it presents automatically on the existing mTLS path.
///
/// It reuses the enrollment library wholesale (`ControlPlaneLoginClient`,
/// `DeviceEnrollmentClient`, `KeychainClientIdentityStore.enroll`) and adds NO
/// crypto/keychain of its own it is pure orchestration, so it is unit-testable
/// with a stub transport + a fake installer (no network, no keychain).
///
/// The `install` step is injected (rather than a hard dependency on the keychain
/// store) so the composition is testable off-device; production supplies the
/// `KeychainClientIdentityStore` convenience initializer below.
public struct DeviceEnrollmentFlow: Sendable {
/// The install step: given the bearer-authenticated client + the requested
/// subdomain/name, generate the SE key + CSR, enroll, and persist the leaf.
/// Returns the installed cert summary (nil only if the leaf can't be read).
public typealias Install = @Sendable (
_ client: DeviceEnrollmentClient, _ subdomain: String, _ deviceName: String
) async throws -> ClientCertificateSummary?
private let baseURL: URL
private let transport: any EnrollmentTransport
private let install: Install
public init(baseURL: URL, transport: any EnrollmentTransport, install: @escaping Install) {
self.baseURL = baseURL
self.transport = transport
self.install = install
}
/// Production convenience: wire `install` to the keychain store's
/// Secure-Enclave enrollment (the `.p12`-free path). The SE key is generated
/// inside `store.enroll` and never leaves hardware.
public init(
baseURL: URL, transport: any EnrollmentTransport, store: KeychainClientIdentityStore
) {
self.init(baseURL: baseURL, transport: transport) { client, subdomain, deviceName in
try await store.enroll(using: client, subdomain: subdomain, deviceName: deviceName)
}
}
/// Run the full bootstrap. Errors propagate unchanged so the UI can map them:
/// `ControlPlaneLoginError` (login step), `DeviceEnrollmentError` (enroll
/// step, e.g. 403 subdomain-not-owned), or a keychain/CSR error from install.
/// A login failure short-circuits the enroll step never runs.
public func run(
password: String, subdomain: String, deviceName: String
) async throws -> ClientCertificateSummary? {
let login = try await ControlPlaneLoginClient(baseURL: baseURL, transport: transport)
.login(password: password)
let client = DeviceEnrollmentClient(
baseURL: baseURL, bearerToken: login.enrollToken, transport: transport
)
return try await install(client, subdomain, deviceName)
}
}

View File

@@ -172,6 +172,20 @@ public struct KeychainClientIdentityStore: ClientIdentityStore {
return try loadSummary()
}
/// B3 · Rotation timing of the installed enrolled (Secure-Enclave) identity,
/// read for the silent rotation scheduler. `nil` when there is no enrolled
/// identity (a fresh install, or a legacy `.p12`-only device neither of
/// which the scheduler can silently renew). The private key never leaves the
/// Secure Enclave, so only the advisory timing + deviceId surface here.
public func renewalState() throws -> DeviceRenewalState? {
guard let record = try readEnrollment() else { return nil }
return DeviceRenewalState(
deviceId: record.deviceId,
notAfter: record.notAfter,
renewAfter: record.renewAfter
)
}
/// The Secure-Enclave-backed identity: the assembled `SecIdentity` (leaf bound
/// to the SE key) plus the stored issuer chain. `nil` when not enrolled.
func loadDeviceIdentity() throws -> ClientIdentity? {
@@ -205,31 +219,62 @@ public struct KeychainClientIdentityStore: ClientIdentityStore {
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
}
/// Persist the enrolled leaf (delete-then-add so rotation replaces the prior
/// one) plus the enrollment record. The leaf binds to the permanent SE key,
/// letting the keychain form the identity on load.
/// Persist the enrolled leaf plus the enrollment record. The leaf binds to the
/// permanent SE key, letting the keychain form the identity on load.
///
/// ATOMIC replace (reached automatically by the rotation scheduler): ADD the
/// new leaf BEFORE deleting the prior one the inverse of delete-then-add,
/// whose window has zero leaves installed. An interrupted write therefore
/// never strands the device without an identity. The prior leaf is deleted by
/// its captured persistent ref (both share `leafLabel`, so a label-only delete
/// would also remove the just-added replacement).
private func storeEnrolledLeaf(_ result: EnrollmentResult, deviceName: String) throws {
guard let certificate = SecCertificateCreateWithData(nil, result.certificate as CFData)
else {
throw ClientIdentityStoreError.corruptStoredBlob // not a valid DER cert
}
let deleteLeaf: [String: Any] = [
kSecClass as String: kSecClassCertificate,
kSecAttrLabel as String: leafLabel,
]
let deleteStatus = SecItemDelete(deleteLeaf as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(deleteStatus)
}
let addLeaf: [String: Any] = [
kSecClass as String: kSecClassCertificate,
kSecValueRef as String: certificate,
kSecAttrLabel as String: leafLabel,
]
let addStatus = SecItemAdd(addLeaf as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw ClientIdentityStoreError.keychain(addStatus)
}
// Capture the prior leaf BEFORE the add so it can be deleted by identity
// afterwards. `nil` on first enroll (nothing to replace).
let priorLeafRef = existingLeafPersistentRef()
var addedDistinctLeaf = false
try replaceKeychainItemAtomically(
addNew: {
let addLeaf: [String: Any] = [
kSecClass as String: kSecClassCertificate,
kSecValueRef as String: certificate,
kSecAttrLabel as String: leafLabel,
]
let addStatus = SecItemAdd(addLeaf as CFDictionary, nil)
switch addStatus {
case errSecSuccess:
addedDistinctLeaf = true // a new, distinct leaf now coexists with the old
case errSecDuplicateItem:
// Byte-identical re-store (same serial): the leaf is already
// installed. Keep it and DO NOT delete the prior ref it is
// the very item we just tried to add.
addedDistinctLeaf = false
default:
throw ClientIdentityStoreError.keychain(addStatus)
}
},
deleteOld: {
// Only sweep the prior leaf once a distinct replacement is in place.
guard addedDistinctLeaf, let priorLeafRef else { return }
let deleteStatus = SecItemDelete(
[kSecValuePersistentRef as String: priorLeafRef] as CFDictionary
)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(deleteStatus)
}
},
reportStaleDeleteFailure: { error in
// Non-fatal: the new leaf is installed (renewal succeeded). A stale
// leftover binds to the same SE key and is swept on the next write.
ClientTLSLog.identity.error(
"storeEnrolledLeaf: stale leaf delete failed (new leaf installed): \(String(describing: error), privacy: .public)"
)
}
)
try writeEnrollment(
StoredEnrollment(
deviceId: result.deviceId,
@@ -241,6 +286,22 @@ public struct KeychainClientIdentityStore: ClientIdentityStore {
)
}
/// Persistent reference of the currently-installed enrolled leaf (by label),
/// captured before an atomic replace so the prior copy can be deleted by
/// identity. `nil` when none is installed or the lookup fails (first enroll).
private func existingLeafPersistentRef() -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassCertificate,
kSecAttrLabel as String: leafLabel,
kSecReturnPersistentRef as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else { return nil }
return data
}
/// Delete the SE key, the enrolled leaf, and the enrollment record. Idempotent.
private func removeEnrolled() throws {
let deleteLeaf: [String: Any] = [
@@ -273,9 +334,17 @@ public struct KeychainClientIdentityStore: ClientIdentityStore {
} catch {
throw ClientIdentityStoreError.corruptStoredBlob
}
let deleteStatus = SecItemDelete(enrollmentQuery() as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(deleteStatus)
// ATOMIC upsert (reached automatically by the rotation scheduler): update
// the DATA in place the (service, account) primary key and protection
// class are unchanged on renewal, so no delete-then-add window can strand
// the device without its enrollment record. Fall back to add on first write.
let updateStatus = SecItemUpdate(
enrollmentQuery() as CFDictionary,
[kSecValueData as String: data] as CFDictionary
)
if updateStatus == errSecSuccess { return }
guard updateStatus == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(updateStatus)
}
var attributes = enrollmentQuery()
attributes[kSecValueData as String] = data

View File

@@ -0,0 +1,30 @@
import Foundation
/// Replace a keychain-backed item so a crash/failure mid-write can NEVER leave
/// zero installed copies which, for the device identity, would lock the device
/// out of its own mTLS cert until a manual re-enroll. This is the inverse of a
/// delete-then-add sequence, whose window between the two calls has zero items:
///
/// 1. `addNew` FIRST. Once it succeeds the replacement is installed, so at
/// least one valid item exists from here on. If `addNew` throws, the prior
/// item is left untouched (the device keeps a working identity) and the
/// error propagates.
/// 2. `deleteOld` SECOND, removing the prior copy. A failure here is NON-fatal:
/// the new item is already installed, so the write is a success. The stale
/// leftover is reported via `reportStaleDeleteFailure` (never a lockout) and
/// swept on the next write throwing here would strand a working new cert.
///
/// Kept pure (closures, no `Security` types) so the ordering invariant is unit-
/// testable with fakes; the keychain plumbing lives at the call sites.
func replaceKeychainItemAtomically(
addNew: () throws -> Void,
deleteOld: () throws -> Void,
reportStaleDeleteFailure: (Error) -> Void = { _ in }
) throws {
try addNew()
do {
try deleteOld()
} catch {
reportStaleDeleteFailure(error)
}
}

View File

@@ -0,0 +1,107 @@
import Foundation
import Testing
@testable import ClientTLS
// B3 · The silent rotation scheduler decides from the installed identity's
// rotation timing whether to renew before expiry, then drives an injected
// mTLS renew. Pure and transport-free: fully unit-testable with fakes.
private let t0 = ISO8601DateFormatter().date(from: "2026-09-05T00:00:00Z")!
private let renewAfter = ISO8601DateFormatter().date(from: "2026-09-05T00:00:00Z")!
private let notAfter = ISO8601DateFormatter().date(from: "2026-10-06T00:00:00Z")!
private let before = ISO8601DateFormatter().date(from: "2026-09-04T00:00:00Z")!
private let after = ISO8601DateFormatter().date(from: "2026-09-06T00:00:00Z")!
private func state(renewAfter: Date? = renewAfter) -> DeviceRenewalState {
DeviceRenewalState(deviceId: "dev-1", notAfter: notAfter, renewAfter: renewAfter)
}
/// Records how many times the renew operation ran. `@unchecked Sendable`: guarded
/// by a lock.
private final class RenewSpy: @unchecked Sendable {
private let lock = NSLock()
private var _count = 0
var count: Int { lock.withLock { _count } }
func bump() { lock.withLock { _count += 1 } }
}
@Test("no enrolled identity → notEnrolled, and renew never runs")
func schedulerNotEnrolled() async {
let spy = RenewSpy()
let scheduler = CertificateRotationScheduler(
renewalState: { nil },
performRenew: { spy.bump(); return nil },
now: { after }
)
let outcome = await scheduler.runIfDue()
#expect(outcome == .notEnrolled)
#expect(spy.count == 0)
}
@Test("cert not yet due → notDue, and renew never runs")
func schedulerNotDue() async {
let spy = RenewSpy()
let scheduler = CertificateRotationScheduler(
renewalState: { state() },
performRenew: { spy.bump(); return nil },
now: { before }
)
let outcome = await scheduler.runIfDue()
#expect(outcome == .notDue(renewAfter: renewAfter))
#expect(spy.count == 0)
}
@Test("past renewAfter → renews exactly once")
func schedulerRenews() async {
let spy = RenewSpy()
let scheduler = CertificateRotationScheduler(
renewalState: { state() },
performRenew: { spy.bump(); return nil },
now: { after }
)
let outcome = await scheduler.runIfDue()
#expect(outcome == .renewed)
#expect(spy.count == 1)
}
@Test("due exactly at renewAfter (>= boundary) renews")
func schedulerRenewsAtBoundary() async {
let scheduler = CertificateRotationScheduler(
renewalState: { state() },
performRenew: { nil },
now: { renewAfter } // now == renewAfter
)
#expect(await scheduler.runIfDue() == .renewed)
}
@Test("a missing renewAfter never triggers (fail-safe — TLS stack is the gate)")
func schedulerMissingRenewAfterNeverDue() async {
let scheduler = CertificateRotationScheduler(
renewalState: { state(renewAfter: nil) },
performRenew: { nil },
now: { after }
)
#expect(await scheduler.runIfDue() == .notDue(renewAfter: nil))
}
@Test("a renew that throws is surfaced as .failed (never crashes, cert still valid)")
func schedulerRenewFailure() async {
struct Boom: Error {}
let scheduler = CertificateRotationScheduler(
renewalState: { state() },
performRenew: { throw Boom() },
now: { after }
)
#expect(await scheduler.runIfDue() == .failed)
}
@Test("a keychain read fault is surfaced as .failed, not a crash")
func schedulerReadFailure() async {
struct Boom: Error {}
let scheduler = CertificateRotationScheduler(
renewalState: { throw Boom() },
performRenew: { nil },
now: { after }
)
#expect(await scheduler.runIfDue() == .failed)
}

View File

@@ -0,0 +1,123 @@
import Foundation
import Testing
@testable import ClientTLS
// B3 · ControlPlaneLoginClient request-building + response-mapping, driven by a
// stub transport (no network). Mirrors the PINNED login contract exactly:
//
// POST /auth/login { "password": "<operator secret>" }
// 201 { "enrollToken": "<jwt/capability>", "accountId": "<uuid>", "expiresIn": <seconds> }
private let cpBaseURL = URL(string: "https://cp.terminal.yaojia.wang")!
/// Records the last request and replays a canned response. `@unchecked Sendable`:
/// the mutable capture is guarded by a lock.
private final class LoginStubTransport: EnrollmentTransport, @unchecked Sendable {
private let lock = NSLock()
private var _lastRequest: URLRequest?
private let status: Int
private let body: Data
init(status: Int, body: Data) {
self.status = status
self.body = body
}
var lastRequest: URLRequest? { lock.withLock { _lastRequest } }
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
lock.withLock { _lastRequest = request }
let response = HTTPURLResponse(
url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil
)!
return (body, response)
}
}
private func loginBody(
enrollToken: String = "v4.public.enroll-token",
accountId: String = "11111111-2222-3333-4444-555555555555",
expiresIn: Int = 600
) -> Data {
let json: [String: Any] = [
"enrollToken": enrollToken,
"accountId": accountId,
"expiresIn": expiresIn,
]
return try! JSONSerialization.data(withJSONObject: json)
}
@Test("login builds a POST /auth/login carrying only the password (never bearer)")
func loginBuildsRequest() async throws {
// Arrange
let stub = LoginStubTransport(status: 201, body: loginBody())
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
// Act
_ = try await client.login(password: "operator-secret")
// Assert
let request = try #require(stub.lastRequest)
#expect(request.httpMethod == "POST")
#expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/auth/login")
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
// Login is credential-based, NOT bearer-authed no Authorization header.
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
let sent = try #require(request.httpBody)
let object = try #require(try JSONSerialization.jsonObject(with: sent) as? [String: Any])
#expect(object["password"] as? String == "operator-secret")
#expect(object.keys.count == 1) // password ONLY no extra fields leak
}
@Test("login maps a 201 response into a typed LoginResult")
func loginMapsResponse() async throws {
// Arrange
let stub = LoginStubTransport(status: 201, body: loginBody(
enrollToken: "v4.public.abc", accountId: "acct-uuid", expiresIn: 900
))
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
// Act
let result = try await client.login(password: "pw")
// Assert
#expect(result.enrollToken == "v4.public.abc")
#expect(result.accountId == "acct-uuid")
#expect(result.expiresIn == 900)
}
@Test("an empty password fails fast without any network call")
func loginEmptyPasswordRejected() async throws {
// Arrange
let stub = LoginStubTransport(status: 201, body: loginBody())
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
// Act / Assert boundary validation: reject before sending.
await #expect(throws: ControlPlaneLoginError.emptyPassword) {
_ = try await client.login(password: "")
}
#expect(stub.lastRequest == nil) // proves zero network happened
}
@Test("a 401 login response surfaces http with the server error code")
func loginRejectSurfacesStatus() async throws {
// The login stub seam denies-by-default { error: "login rejected" }.
let body = try! JSONSerialization.data(withJSONObject: ["error": "login rejected"])
let stub = LoginStubTransport(status: 401, body: body)
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
await #expect(throws: ControlPlaneLoginError.http(status: 401, code: "login rejected")) {
_ = try await client.login(password: "wrong")
}
}
@Test("a malformed 201 body throws malformedResponse")
func loginMalformedBody() async throws {
let stub = LoginStubTransport(status: 201, body: Data("not json".utf8))
let client = ControlPlaneLoginClient(baseURL: cpBaseURL, transport: stub)
await #expect(throws: ControlPlaneLoginError.malformedResponse) {
_ = try await client.login(password: "pw")
}
}

View File

@@ -169,3 +169,46 @@ func renewTargetsRenewEndpoint() async throws {
#expect(stub.lastRequest?.url?.absoluteString
== "https://cp.terminal.yaojia.wang/device/dev-9/renew")
}
@Test("renew body is {csr}-ONLY (server's strict schema rejects any extra field)")
func renewSendsCsrOnlyBody() async throws {
// The control-plane /device/:id/renew schema authenticates by the presented
// mTLS cert and accepts a body of exactly { csr } a stray `keyAlg` (or any
// non-csr field) trips its strict validation and 400s every silent renewal.
let stub = StubTransport(status: 201, body: enrollBody())
let client = DeviceEnrollmentClient(baseURL: baseURL, transport: stub)
let csr = Data([0x0A, 0x0B, 0x0C])
_ = try await client.renew(deviceId: "dev-9", csrDER: csr)
let request = try #require(stub.lastRequest)
let sent = try #require(request.httpBody)
let object = try #require(try JSONSerialization.jsonObject(with: sent) as? [String: Any])
#expect(object["csr"] as? String == csr.base64EncodedString())
#expect(object["keyAlg"] == nil) // NOT sent the enroll-only field must be dropped
#expect(object.keys.sorted() == ["csr"]) // exactly one field
}
@Test("renew with no bearer sends NO Authorization header (mTLS-authenticated)")
func renewMTLSOmitsBearer() async throws {
// /device/:id/renew authenticates by the CURRENT device client cert (mTLS),
// NOT a bearer the silent rotation scheduler has no fresh bearer weeks
// later, so it constructs the client with a nil bearer and relies on the
// transport presenting the installed identity. The Authorization header
// must therefore be ABSENT (a stale/empty "Bearer " would be wrong).
let stub = StubTransport(status: 201, body: enrollBody())
let client = DeviceEnrollmentClient(baseURL: baseURL, transport: stub) // bearer defaults to nil
_ = try await client.renew(deviceId: "dev-9", csrDER: Data([0x02]))
let request = try #require(stub.lastRequest)
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
// Content-Type is still set it's a JSON POST regardless of auth mechanism.
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
}
@Test("enroll still sets the bearer when one is supplied (unchanged contract)")
func enrollKeepsBearerWhenSupplied() async throws {
let stub = StubTransport(status: 201, body: enrollBody())
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
_ = try await client.enroll(csrDER: Data([0x01]), subdomain: "a", deviceName: "d")
#expect(stub.lastRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer \(bearer)")
}

View File

@@ -0,0 +1,136 @@
import Foundation
import Testing
@testable import ClientTLS
// B3 · DeviceEnrollmentFlow composes the pinned bootstrap end-to-end:
// login(password) bearer enroll(CSR, subdomain) with that bearer leaf.
// Driven by a path-routing stub transport (no network, no keychain): asserts the
// bearer minted at login is exactly what authenticates the enroll call.
private let flowBaseURL = URL(string: "https://cp.terminal.yaojia.wang")!
/// Routes by path: `/auth/login` login body, `/device/enroll` enroll body.
/// Records every request so the test can assert the enroll bearer. `@unchecked
/// Sendable`: mutable captures guarded by a lock.
private final class RoutingStubTransport: EnrollmentTransport, @unchecked Sendable {
private let lock = NSLock()
private var _requests: [URLRequest] = []
private let loginStatus: Int
private let loginBody: Data
init(loginStatus: Int = 201, loginBody: Data) {
self.loginStatus = loginStatus
self.loginBody = loginBody
}
var requests: [URLRequest] { lock.withLock { _requests } }
func request(forPathSuffix suffix: String) -> URLRequest? {
lock.withLock { _requests.first { $0.url?.path.hasSuffix(suffix) == true } }
}
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
lock.withLock { _requests.append(request) }
let path = request.url?.path ?? ""
let (status, body): (Int, Data)
if path.hasSuffix("/auth/login") {
(status, body) = (loginStatus, loginBody)
} else {
// /device/enroll a minimal 201 leaf.
(status, body) = (201, enrollLeafBody())
}
let response = HTTPURLResponse(
url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil
)!
return (body, response)
}
}
private func loginResponseBody(enrollToken: String) -> Data {
try! JSONSerialization.data(withJSONObject: [
"enrollToken": enrollToken,
"accountId": "acct-123",
"expiresIn": 600,
])
}
private func enrollLeafBody() -> Data {
try! JSONSerialization.data(withJSONObject: [
"deviceId": "dev-1",
"cert": Data([0x30, 0x01]).base64EncodedString(),
"caChain": [Data([0x30, 0x02]).base64EncodedString()],
"notBefore": "2026-07-18T00:00:00Z",
"notAfter": "2026-10-16T00:00:00Z",
"renewAfter": "2026-09-15T00:00:00Z",
])
}
/// A fake installer that invokes the real `client.enroll` (so the transport sees
/// the enroll request with the login-minted bearer), then returns a summary.
/// Records the subdomain/deviceName it was handed. `@unchecked Sendable`: lock.
private final class SpyInstaller: @unchecked Sendable {
private let lock = NSLock()
private(set) var subdomain: String?
private(set) var deviceName: String?
func install(
_ client: DeviceEnrollmentClient, _ subdomain: String, _ deviceName: String
) async throws -> ClientCertificateSummary? {
lock.withLock { self.subdomain = subdomain; self.deviceName = deviceName }
_ = try await client.enroll(csrDER: Data([0xAB]), subdomain: subdomain, deviceName: deviceName)
return ClientCertificateSummary(
subjectCommonName: deviceName, issuerCommonName: "webterm-device-ca", notAfter: nil
)
}
}
@Test("flow logs in then enrolls with the login-minted bearer (end to end)")
func flowEndToEnd() async throws {
// Arrange
let transport = RoutingStubTransport(loginBody: loginResponseBody(enrollToken: "TOKEN-XYZ"))
let installer = SpyInstaller()
let flow = DeviceEnrollmentFlow(
baseURL: flowBaseURL,
transport: transport,
install: installer.install
)
// Act
let summary = try await flow.run(password: "pw", subdomain: "alice", deviceName: "Alice iPhone")
// Assert login happened, THEN enroll with the exact bearer from login.
#expect(transport.request(forPathSuffix: "/auth/login") != nil)
let enroll = try #require(transport.request(forPathSuffix: "/device/enroll"))
#expect(enroll.value(forHTTPHeaderField: "Authorization") == "Bearer TOKEN-XYZ")
#expect(installer.subdomain == "alice")
#expect(installer.deviceName == "Alice iPhone")
#expect(summary?.subjectCommonName == "Alice iPhone")
}
@Test("a login failure short-circuits: enroll is never attempted")
func flowLoginFailureShortCircuits() async {
// 401 login the flow must NOT reach /device/enroll.
let body = try! JSONSerialization.data(withJSONObject: ["error": "login rejected"])
let transport = RoutingStubTransport(loginStatus: 401, loginBody: body)
let installer = SpyInstaller()
let flow = DeviceEnrollmentFlow(
baseURL: flowBaseURL, transport: transport, install: installer.install
)
await #expect(throws: ControlPlaneLoginError.http(status: 401, code: "login rejected")) {
_ = try await flow.run(password: "wrong", subdomain: "alice", deviceName: "d")
}
#expect(transport.request(forPathSuffix: "/device/enroll") == nil)
#expect(installer.subdomain == nil) // installer never ran
}
@Test("an empty password is rejected before any network")
func flowEmptyPassword() async {
let transport = RoutingStubTransport(loginBody: loginResponseBody(enrollToken: "t"))
let flow = DeviceEnrollmentFlow(
baseURL: flowBaseURL, transport: transport, install: { _, _, _ in nil }
)
await #expect(throws: ControlPlaneLoginError.emptyPassword) {
_ = try await flow.run(password: "", subdomain: "alice", deviceName: "d")
}
#expect(transport.requests.isEmpty)
}

View File

@@ -0,0 +1,76 @@
import Foundation
import Testing
@testable import ClientTLS
// C-iOS · The keychain-replace helper backs the rotation scheduler's identity
// updates. Its one job: a crash/failure mid-write must NEVER leave zero installed
// items (which would lock the device out of its own mTLS identity until a manual
// re-enroll). It enforces ADD-new-BEFORE-DELETE-old ordering the inverse of the
// delete-then-add sequence whose window has zero items installed.
/// A tiny in-memory stand-in for the keychain: the current set of installed item
/// ids plus the smallest count ever observed, so a test can assert the invariant
/// that the installed set is NEVER empty at any step of the replace.
private final class FakeKeychain {
private(set) var items: Set<String>
private(set) var log: [String] = []
private(set) var minCount: Int
init(seed: Set<String>) {
items = seed
minCount = seed.count
}
func add(_ id: String) {
items.insert(id)
log.append("add(\(id))")
minCount = min(minCount, items.count)
}
func delete(_ id: String) {
items.remove(id)
log.append("delete(\(id))")
minCount = min(minCount, items.count)
}
}
@Test("replace ADDS the new item before DELETING the old — never a zero-item window")
func replaceAddsBeforeDeletes() throws {
let keychain = FakeKeychain(seed: ["old"])
try replaceKeychainItemAtomically(
addNew: { keychain.add("new") },
deleteOld: { keychain.delete("old") }
)
#expect(keychain.items == ["new"])
#expect(keychain.log == ["add(new)", "delete(old)"]) // add strictly precedes delete
#expect(keychain.minCount >= 1) // at least one identity installed at every step
}
@Test("a failed add leaves the prior item intact and never runs the delete")
func replaceAddFailureKeepsOld() {
struct Boom: Error {}
let keychain = FakeKeychain(seed: ["old"])
#expect(throws: Boom.self) {
try replaceKeychainItemAtomically(
addNew: { throw Boom() },
deleteOld: { keychain.delete("old") }
)
}
#expect(keychain.items == ["old"]) // old preserved a working identity remains
#expect(keychain.log.contains("delete(old)") == false)
#expect(keychain.minCount >= 1)
}
@Test("a failed delete-of-old is non-fatal — the new item stays installed and the fault is surfaced")
func replaceDeleteFailureKeepsNew() throws {
struct Boom: Error {}
let keychain = FakeKeychain(seed: ["old"])
var reported: Error?
try replaceKeychainItemAtomically(
addNew: { keychain.add("new") },
deleteOld: { throw Boom() },
reportStaleDeleteFailure: { reported = $0 }
)
#expect(keychain.items.contains("new")) // renewal succeeded despite the stale-delete fault
#expect(reported is Boom) // surfaced (not silently swallowed)
#expect(keychain.minCount >= 1)
}