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:
@@ -0,0 +1,188 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// C-iOS · Proves the manual PKCS#10 encoder produces a well-formed, self-signed
|
||||
// P-256 CSR that the control-plane `verifyCsrPoPEc` (id-ecPublicKey + prime256v1
|
||||
// SPKI, ecdsa-with-SHA256 self-signature) would accept. Runs headless with a
|
||||
// SOFTWARE P-256 SecKey (SecKeyCreateRandomKey WITHOUT the Secure-Enclave token)
|
||||
// so no hardware/entitlement is needed — the signing path is byte-identical to
|
||||
// the on-device SE key. Real SE keygen + SecIdentity roundtrip are device-only.
|
||||
|
||||
/// Software P-256 key via the SAME SecKey API used on-device (no SE token).
|
||||
private func makeSoftwareKey() throws -> SecureEnclaveKey {
|
||||
try SecureEnclaveKeyFactory.generateSoftware(tag: nil, permanent: false)
|
||||
}
|
||||
|
||||
@Test("CSR is a canonical PKCS#10 SEQUENCE of exactly three elements")
|
||||
func csrOuterStructure() throws {
|
||||
// Arrange
|
||||
let signer = try makeSoftwareKey()
|
||||
|
||||
// Act
|
||||
let der = try CertificateSigningRequest.der(
|
||||
subjectCommonName: "web-terminal-device", signer: signer
|
||||
)
|
||||
|
||||
// Assert — outer CertificationRequest ::= SEQUENCE { info, algId, sig }.
|
||||
let bytes = [UInt8](der)
|
||||
let outer = try #require(TestDER.read(bytes, at: 0))
|
||||
#expect(outer.tag == 0x30)
|
||||
#expect(outer.end == bytes.count) // no trailing garbage
|
||||
let parts = TestDER.children(bytes, outer)
|
||||
#expect(parts.count == 3)
|
||||
#expect(parts[0].tag == 0x30) // certificationRequestInfo
|
||||
#expect(parts[1].tag == 0x30) // signatureAlgorithm
|
||||
#expect(parts[2].tag == 0x03) // signature BIT STRING
|
||||
}
|
||||
|
||||
@Test("CSR self-signature verifies against the embedded P-256 public key")
|
||||
func csrSelfSignatureVerifies() throws {
|
||||
// Arrange
|
||||
let signer = try makeSoftwareKey()
|
||||
let expectedPoint = try signer.publicKeyX963()
|
||||
|
||||
// Act
|
||||
let der = try CertificateSigningRequest.der(
|
||||
subjectCommonName: "web-terminal-device", signer: signer
|
||||
)
|
||||
let bytes = [UInt8](der)
|
||||
|
||||
// Extract the exact CertificationRequestInfo bytes that were signed and the
|
||||
// ECDSA signature (the same crypto check `verifyCsrPoPEc`'s req.verify() runs).
|
||||
let outer = try #require(TestDER.read(bytes, at: 0))
|
||||
let parts = TestDER.children(bytes, outer)
|
||||
let infoBytes = Data(bytes[parts[0].start..<parts[0].end])
|
||||
let sigContent = parts[2] // BIT STRING: first content byte is unused-bits (0x00)
|
||||
let signature = Data(bytes[(sigContent.valueStart + 1)..<sigContent.valueEnd])
|
||||
|
||||
// Rebuild the public SecKey from the X9.63 point and verify.
|
||||
let publicKey = try #require(makePublicKey(fromX963: expectedPoint))
|
||||
var error: Unmanaged<CFError>?
|
||||
let ok = SecKeyVerifySignature(
|
||||
publicKey,
|
||||
.ecdsaSignatureMessageX962SHA256,
|
||||
infoBytes as CFData,
|
||||
signature as CFData,
|
||||
&error
|
||||
)
|
||||
#expect(ok, "self-signature must verify: \(String(describing: error?.takeRetainedValue()))")
|
||||
}
|
||||
|
||||
@Test("CSR embeds a P-256 SubjectPublicKeyInfo the server verifier accepts")
|
||||
func csrEmbedsP256Spki() throws {
|
||||
// Arrange
|
||||
let signer = try makeSoftwareKey()
|
||||
let point = [UInt8](try signer.publicKeyX963())
|
||||
|
||||
// Act
|
||||
let der = try CertificateSigningRequest.der(
|
||||
subjectCommonName: "web-terminal-device", signer: signer
|
||||
)
|
||||
let bytes = [UInt8](der)
|
||||
|
||||
// certificationRequestInfo → { version, subject, subjectPKInfo, [0] attrs }
|
||||
let outer = try #require(TestDER.read(bytes, at: 0))
|
||||
let info = TestDER.children(bytes, outer)[0]
|
||||
let infoChildren = TestDER.children(bytes, info)
|
||||
#expect(infoChildren.count == 4)
|
||||
#expect(Array(bytes[infoChildren[0].start..<infoChildren[0].end]) == [0x02, 0x01, 0x00]) // v1(0)
|
||||
#expect(infoChildren[3].tag == 0xA0) // [0] IMPLICIT attributes
|
||||
#expect(infoChildren[3].valueEnd - infoChildren[3].valueStart == 0) // empty SET
|
||||
|
||||
// subjectPublicKeyInfo ::= SEQUENCE { AlgorithmIdentifier, BIT STRING point }
|
||||
let spki = infoChildren[2]
|
||||
let spkiChildren = TestDER.children(bytes, spki)
|
||||
#expect(spkiChildren.count == 2)
|
||||
let algIdChildren = TestDER.children(bytes, spkiChildren[0])
|
||||
// AlgorithmIdentifier { id-ecPublicKey, prime256v1 } — the exact OIDs
|
||||
// verifyCsrPoPEc pins.
|
||||
#expect(Array(bytes[algIdChildren[0].start..<algIdChildren[0].end])
|
||||
== [0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01])
|
||||
#expect(Array(bytes[algIdChildren[1].start..<algIdChildren[1].end])
|
||||
== [0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07])
|
||||
// BIT STRING content = 0x00 unused-bits + the exact 65-byte point.
|
||||
let bitString = spkiChildren[1]
|
||||
#expect(bitString.tag == 0x03)
|
||||
#expect(bytes[bitString.valueStart] == 0x00)
|
||||
#expect(Array(bytes[(bitString.valueStart + 1)..<bitString.valueEnd]) == point)
|
||||
}
|
||||
|
||||
@Test("signatureAlgorithm is ecdsa-with-SHA256")
|
||||
func csrSignatureAlgorithm() throws {
|
||||
let signer = try makeSoftwareKey()
|
||||
let der = try CertificateSigningRequest.der(
|
||||
subjectCommonName: "web-terminal-device", signer: signer
|
||||
)
|
||||
let bytes = [UInt8](der)
|
||||
let outer = try #require(TestDER.read(bytes, at: 0))
|
||||
let algId = TestDER.children(bytes, outer)[1]
|
||||
let oid = TestDER.children(bytes, algId)[0]
|
||||
#expect(Array(bytes[oid.start..<oid.end])
|
||||
== [0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x02])
|
||||
}
|
||||
|
||||
@Test("empty subject CN is rejected")
|
||||
func csrRejectsEmptySubject() throws {
|
||||
let signer = try makeSoftwareKey()
|
||||
#expect(throws: CertificateSigningRequest.CSRError.invalidSubject) {
|
||||
_ = try CertificateSigningRequest.der(subjectCommonName: "", signer: signer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - helpers
|
||||
|
||||
/// Reconstruct a public SecKey from an X9.63 uncompressed point for verification.
|
||||
private func makePublicKey(fromX963 point: Data) -> SecKey? {
|
||||
let attributes: [String: Any] = [
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
|
||||
kSecAttrKeySizeInBits as String: 256,
|
||||
]
|
||||
return SecKeyCreateWithData(point as CFData, attributes as CFDictionary, nil)
|
||||
}
|
||||
|
||||
/// A throwaway DER reader for assertions (production parsing lives in
|
||||
/// CertificateSummary's X509 walk; this mirrors it for tests).
|
||||
enum TestDER {
|
||||
struct Element {
|
||||
let tag: UInt8
|
||||
let start: Int // index of the tag byte
|
||||
let valueStart: Int
|
||||
let valueEnd: Int
|
||||
var end: Int { valueEnd }
|
||||
}
|
||||
|
||||
static func read(_ bytes: [UInt8], at start: Int) -> Element? {
|
||||
guard start >= 0, start + 1 < bytes.count else { return nil }
|
||||
let tag = bytes[start]
|
||||
var index = start + 1
|
||||
let first = bytes[index]
|
||||
index += 1
|
||||
var length = 0
|
||||
if first & 0x80 == 0 {
|
||||
length = Int(first)
|
||||
} else {
|
||||
let count = Int(first & 0x7F)
|
||||
guard count > 0, count <= 4, index + count <= bytes.count else { return nil }
|
||||
for _ in 0..<count {
|
||||
length = (length << 8) | Int(bytes[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
let valueEnd = index + length
|
||||
guard valueEnd <= bytes.count else { return nil }
|
||||
return Element(tag: tag, start: start, valueStart: index, valueEnd: valueEnd)
|
||||
}
|
||||
|
||||
static func children(_ bytes: [UInt8], _ parent: Element) -> [Element] {
|
||||
var elements: [Element] = []
|
||||
var index = parent.valueStart
|
||||
while index < parent.valueEnd, let element = read(bytes, at: index) {
|
||||
elements.append(element)
|
||||
index = element.valueEnd
|
||||
}
|
||||
return elements
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user