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:
90
ios/App/WebTerm/Wiring/DeviceEnrollmentWiring.swift
Normal file
90
ios/App/WebTerm/Wiring/DeviceEnrollmentWiring.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user