feat(tunnel): zero-touch tunnel enrollment — control-plane PKI, host agent, iOS, nginx isolation

Customers install one command / log in once; hardware-generated keys never leave the
device; CSRs return certs + subdomain; frpc + base-app run as durable services. No .p12,
no manual cert import. Implements the MVP fast-path of docs/PLAN_TUNNEL_AUTOMATION.md.

Control-plane / PKI (control-plane/):
- ca/x509-assembler.ts: single KMS-signed real X.509 issuance primitive (Ed25519 + P-256)
- ca/csr-ec.ts: P-256 PKCS#10 proof-of-possession (verifyCsrPoPEc) + CSR-key routing
- ca/frpclient-issue.ts, ca/device-issue.ts: P-256 frp-client + device leaf signers
- ca/rotate.ts + api/renew.ts: real-X.509 /renew + /device/:id/renew (mTLS current cert)
- registry/devices.ts: device registry + per-account cap/rate-limit
- auth/session.ts: device:enroll capability token mint/verify
- api/device-enroll.ts: POST /device/enroll (ownership-gated, deny-by-default)
- pairing/native-redeem.ts + shared gateAndConsumePairingCode; api/provision.ts native arm
- boot/native-ca.ts + main.ts: wire two P-256 CAs + issuers + routers (dev / KMS fail-fast)

Contracts: relay-contracts enroll right; relay-auth SPIFFE /device/ arm + spiffeIdFor(kind)

Host agent (agent/):
- transport/frpcToml.ts; provision/frpcBinary.ts + untar.ts (verify-download + traversal-safe extract)
- keys P-256 keygen/CSR/loadIdentity; service two-unit install + BIND_HOST loopback S-GATE
- net/loopbackLiteral.ts strict guard; health/probe.ts + transport/frpSupervise.ts; cli pair --install

iOS (ios/Packages/ClientTLS): SecureEnclaveKey + CertificateSigningRequest + DeviceEnrollmentClient
+ Keychain enroll refactor (SecKey/Security.framework end-to-end, avoids the -25300 trap)

Isolation (deploy/nginx): njs/getCertSub.js SAN parser + zone-anchored map -> 403

Verified: 758 tests green (control-plane 246, agent 267, relay-auth 133, relay-contracts 85,
iOS ClientTLS 27), all tsc clean; real nginx+njs docker 403/200/400; Swift CSR accepted by
the real control-plane verifier; frpc extract byte-identical to `tar -xO`. Cross-validation
caught + fixed 5 real defects (1 critical, 4 high). Remaining = infra (KMS, nginx deploy,
VPS frps, physical iPhone) per PROGRESS_LOG runbook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-07-10 16:11:13 +02:00
parent 31054450fc
commit e7f3bd05f0
79 changed files with 9920 additions and 385 deletions

View File

@@ -0,0 +1,171 @@
import Foundation
import Testing
@testable import ClientTLS
// C-iOS · DeviceEnrollmentClient request-building + response-mapping, driven by a
// stub transport (no network). Mirrors the A4 contract exactly.
private let baseURL = URL(string: "https://cp.terminal.yaojia.wang")!
private let bearer = "device-enroll-token-abc"
/// Records the last request and replays a canned response. `@unchecked Sendable`:
/// the mutable capture is guarded by a lock.
private final class StubTransport: EnrollmentTransport, @unchecked Sendable {
private let lock = NSLock()
private var _lastRequest: URLRequest?
private let status: Int
private let body: Data
init(status: Int, body: Data) {
self.status = status
self.body = body
}
var lastRequest: URLRequest? { lock.withLock { _lastRequest } }
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
lock.withLock { _lastRequest = request }
let response = HTTPURLResponse(
url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil
)!
return (body, response)
}
}
private func enrollBody(
deviceId: String = "dev-1",
cert: Data = Data([0x30, 0x01, 0x02]),
caChain: [Data] = [Data([0x30, 0xAA])],
notBefore: String = "2026-07-08T00:00:00.000Z",
notAfter: String = "2026-10-06T00:00:00.000Z",
renewAfter: String = "2026-09-05T00:00:00.000Z"
) -> Data {
let json: [String: Any] = [
"deviceId": deviceId,
"cert": cert.base64EncodedString(),
"caChain": caChain.map { $0.base64EncodedString() },
"notBefore": notBefore,
"notAfter": notAfter,
"renewAfter": renewAfter,
]
return try! JSONSerialization.data(withJSONObject: json)
}
@Test("enroll builds a bearer-authenticated POST /device/enroll with the A4 body")
func enrollBuildsRequest() async throws {
// Arrange
let stub = StubTransport(status: 201, body: enrollBody())
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
let csr = Data([0xDE, 0xAD, 0xBE, 0xEF])
// Act
_ = try await client.enroll(csrDER: csr, subdomain: "alice", deviceName: "Alice iPhone")
// Assert
let request = try #require(stub.lastRequest)
#expect(request.httpMethod == "POST")
#expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/enroll")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(bearer)")
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
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"] as? String == "ec-p256")
#expect(object["subdomain"] as? String == "alice")
#expect(object["deviceName"] as? String == "Alice iPhone")
#expect(object["attestation"] == nil) // omitted when not provided
}
@Test("enroll maps a 201 response into a typed EnrollmentResult")
func enrollMapsResponse() async throws {
// Arrange
let cert = Data([0x30, 0x82, 0x01, 0x23])
let ca = Data([0x30, 0x82, 0x02, 0x00])
let stub = StubTransport(status: 201, body: enrollBody(deviceId: "dev-xyz", cert: cert, caChain: [ca]))
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
// Act
let result = try await client.enroll(csrDER: Data([0x01]), subdomain: "alice", deviceName: "iPhone")
// Assert
#expect(result.deviceId == "dev-xyz")
#expect(result.certificate == cert)
#expect(result.caChain == [ca])
#expect(result.notAfter != nil)
#expect(result.renewAfter != nil)
// renewAfter (2026-09-05) is before notAfter (2026-10-06).
#expect(result.renewAfter! < result.notAfter!)
}
@Test("isRenewalDue flips at renewAfter")
func renewalDueSeam() async throws {
let stub = StubTransport(status: 201, body: enrollBody())
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
let result = try await client.enroll(csrDER: Data([0x01]), subdomain: "a", deviceName: "d")
let before = ISO8601DateFormatter().date(from: "2026-09-04T00:00:00Z")!
let after = ISO8601DateFormatter().date(from: "2026-09-06T00:00:00Z")!
#expect(result.isRenewalDue(asOf: before) == false)
#expect(result.isRenewalDue(asOf: after) == true)
}
@Test("attestation passphrase is forwarded when provided")
func enrollForwardsAttestation() 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", attestation: "attest-blob"
)
let object = try JSONSerialization.jsonObject(
with: stub.lastRequest!.httpBody!
) as! [String: Any]
#expect(object["attestation"] as? String == "attest-blob")
}
@Test("a non-201 response throws http with the server error code")
func enrollRejectSurfacesStatus() async throws {
// 403 subdomain-not-owned { error: "rejected" }.
let body = try! JSONSerialization.data(withJSONObject: ["error": "rejected"])
let stub = StubTransport(status: 403, body: body)
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
await #expect(throws: DeviceEnrollmentError.http(status: 403, code: "rejected")) {
_ = try await client.enroll(csrDER: Data([0x01]), subdomain: "bob", deviceName: "d")
}
}
@Test("a malformed 201 body throws malformedResponse")
func enrollMalformedBody() async throws {
let stub = StubTransport(status: 201, body: Data("not json".utf8))
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
await #expect(throws: DeviceEnrollmentError.malformedResponse) {
_ = try await client.enroll(csrDER: Data([0x01]), subdomain: "a", deviceName: "d")
}
}
@Test("attestChallenge maps a 200 challenge response")
func attestChallengeMaps() async throws {
let body = try! JSONSerialization.data(
withJSONObject: ["challenge": "Y2hhbGxlbmdl", "expires_in": 120]
)
let stub = StubTransport(status: 200, body: body)
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
let challenge = try await client.attestChallenge()
#expect(challenge.challenge == "Y2hhbGxlbmdl")
#expect(challenge.expiresIn == 120)
#expect(stub.lastRequest?.url?.absoluteString
== "https://cp.terminal.yaojia.wang/device/attest/challenge")
}
@Test("renew targets POST /device/:id/renew (A6 seam)")
func renewTargetsRenewEndpoint() async throws {
let stub = StubTransport(status: 201, body: enrollBody())
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
_ = try await client.renew(deviceId: "dev-9", csrDER: Data([0x02]))
#expect(stub.lastRequest?.url?.absoluteString
== "https://cp.terminal.yaojia.wang/device/dev-9/renew")
}