Files
web-terminal/ios/App/WebTerm/Wiring/DeviceEnrollmentWiring.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

91 lines
3.9 KiB
Swift

import ClientTLS
import Foundation
import WireProtocol
/// B3 · Production wiring that connects the ClientTLS enrollment library to the
/// app's real transports. Kept in the assembly layer (not the leaf package) so
/// `ClientTLS` stays dependency-free and unit-testable; here we only adapt types
/// and read persisted config no crypto, no keychain of our own.
/// Adapts the app's `HTTPTransport` (WireProtocol) to `ClientTLS`'s
/// `EnrollmentTransport`. The two protocols have an identical `send` shape; this
/// one-line bridge lets the SAME `URLSessionHTTPTransport` which already
/// presents the device identity on a client-cert challenge (mTLS) serve login,
/// enroll AND the silent renew, so renew authenticates with the current cert.
struct EnrollmentTransportAdapter: EnrollmentTransport {
private let http: any HTTPTransport
init(http: any HTTPTransport) {
self.http = http
}
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
try await http.send(request)
}
}
/// B3 · Non-secret enrollment config persisted for the silent rotation scheduler
/// (the control-plane URL to renew against) and to prefill the re-enroll form.
/// Namespaced statics over `UserDefaults.standard` nothing secret is stored
/// here (the bearer is never persisted; the private key stays in the Secure
/// Enclave; the leaf/chain live in the keychain via `KeychainClientIdentityStore`).
enum EnrollmentSettings {
private static let controlPlaneURLKey = "webterm.enroll.controlPlaneURL"
private static let subdomainKey = "webterm.enroll.subdomain"
static var controlPlaneURL: String? {
get { UserDefaults.standard.string(forKey: controlPlaneURLKey) }
set { UserDefaults.standard.set(newValue, forKey: controlPlaneURLKey) }
}
static var subdomain: String? {
get { UserDefaults.standard.string(forKey: subdomainKey) }
set { UserDefaults.standard.set(newValue, forKey: subdomainKey) }
}
}
enum DeviceEnrollmentWiringError: Error, Equatable, Sendable {
/// The control-plane URL is missing/invalid cannot build a renew client.
case missingControlPlaneURL
/// The typed control-plane URL is not a valid absolute URL.
case invalidControlPlaneURL
}
/// Build the enrollment flow (login SE keygen+CSR enroll store) against the
/// given control-plane URL, reusing the app's mTLS-capable transport.
func makeDeviceEnrollmentFlow(
controlPlaneURL: URL,
http: any HTTPTransport,
store: KeychainClientIdentityStore = KeychainClientIdentityStore()
) -> DeviceEnrollmentFlow {
DeviceEnrollmentFlow(
baseURL: controlPlaneURL,
transport: EnrollmentTransportAdapter(http: http),
store: store
)
}
/// Build the silent rotation scheduler. `renewalState` reads the installed leaf's
/// timing; `performRenew` re-CSRs from the SAME Secure-Enclave key and POSTs
/// `/device/:id/renew` over mTLS (nil bearer the current cert authenticates).
/// The control-plane URL is read fresh from `EnrollmentSettings` at renew time.
func makeCertificateRotationScheduler(
http: any HTTPTransport,
store: KeychainClientIdentityStore = KeychainClientIdentityStore()
) -> CertificateRotationScheduler {
let transport = EnrollmentTransportAdapter(http: http)
return CertificateRotationScheduler(
renewalState: { try store.renewalState() },
performRenew: {
guard let urlString = EnrollmentSettings.controlPlaneURL,
let url = URL(string: urlString) else {
throw DeviceEnrollmentWiringError.missingControlPlaneURL
}
// nil bearer: /device/:id/renew authenticates by the presented
// client cert (mTLS), which the adapter's transport supplies.
let client = DeviceEnrollmentClient(baseURL: url, bearerToken: nil, transport: transport)
return try await store.renew(using: client)
}
)
}