Files
web-terminal/ios/Packages/ClientTLS/Tests/ClientTLSTests/CertificateSigningRequestTests.swift
Yaojia Wang e7f3bd05f0 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>
2026-07-10 16:11:13 +02:00

189 lines
7.4 KiB
Swift

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
}
}