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.
64 lines
3.2 KiB
Swift
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)
|
|
}
|
|
}
|