Files
web-terminal/ios/Packages/ClientTLS/Sources/ClientTLS/DeviceEnrollmentFlow.swift
Yaojia Wang 07bcbf0c08 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.
2026-07-18 13:32:05 +02:00

64 lines
3.2 KiB
Swift

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