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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user