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:
@@ -169,3 +169,46 @@ func renewTargetsRenewEndpoint() async throws {
|
||||
#expect(stub.lastRequest?.url?.absoluteString
|
||||
== "https://cp.terminal.yaojia.wang/device/dev-9/renew")
|
||||
}
|
||||
|
||||
@Test("renew body is {csr}-ONLY (server's strict schema rejects any extra field)")
|
||||
func renewSendsCsrOnlyBody() async throws {
|
||||
// The control-plane /device/:id/renew schema authenticates by the presented
|
||||
// mTLS cert and accepts a body of exactly { csr } — a stray `keyAlg` (or any
|
||||
// non-csr field) trips its strict validation and 400s every silent renewal.
|
||||
let stub = StubTransport(status: 201, body: enrollBody())
|
||||
let client = DeviceEnrollmentClient(baseURL: baseURL, transport: stub)
|
||||
let csr = Data([0x0A, 0x0B, 0x0C])
|
||||
|
||||
_ = try await client.renew(deviceId: "dev-9", csrDER: csr)
|
||||
|
||||
let request = try #require(stub.lastRequest)
|
||||
let sent = try #require(request.httpBody)
|
||||
let object = try #require(try JSONSerialization.jsonObject(with: sent) as? [String: Any])
|
||||
#expect(object["csr"] as? String == csr.base64EncodedString())
|
||||
#expect(object["keyAlg"] == nil) // NOT sent — the enroll-only field must be dropped
|
||||
#expect(object.keys.sorted() == ["csr"]) // exactly one field
|
||||
}
|
||||
|
||||
@Test("renew with no bearer sends NO Authorization header (mTLS-authenticated)")
|
||||
func renewMTLSOmitsBearer() async throws {
|
||||
// /device/:id/renew authenticates by the CURRENT device client cert (mTLS),
|
||||
// NOT a bearer — the silent rotation scheduler has no fresh bearer weeks
|
||||
// later, so it constructs the client with a nil bearer and relies on the
|
||||
// transport presenting the installed identity. The Authorization header
|
||||
// must therefore be ABSENT (a stale/empty "Bearer " would be wrong).
|
||||
let stub = StubTransport(status: 201, body: enrollBody())
|
||||
let client = DeviceEnrollmentClient(baseURL: baseURL, transport: stub) // bearer defaults to nil
|
||||
_ = try await client.renew(deviceId: "dev-9", csrDER: Data([0x02]))
|
||||
let request = try #require(stub.lastRequest)
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
|
||||
// Content-Type is still set — it's a JSON POST regardless of auth mechanism.
|
||||
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||
}
|
||||
|
||||
@Test("enroll still sets the bearer when one is supplied (unchanged contract)")
|
||||
func enrollKeepsBearerWhenSupplied() async throws {
|
||||
let stub = StubTransport(status: 201, body: enrollBody())
|
||||
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
|
||||
_ = try await client.enroll(csrDER: Data([0x01]), subdomain: "a", deviceName: "d")
|
||||
#expect(stub.lastRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer \(bearer)")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user