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,302 @@
/**
* A1 acceptance (FIX C-1) — the single X.509 issuance primitive. Proves that a leaf assembled by
* DER-encoding the TBS ourselves and signing the SERIALIZED TBS via `CaSigner.sign` (the KMS
* boundary) is a REAL, tool-parseable, signature-verifiable X.509 v3 certificate for BOTH the
* Ed25519 and ECDSA-P256 CA families — and that the dNSName+URI SAN the A3 nginx njs will parse
* round-trips byte-correct.
*/
import 'reflect-metadata'
import { describe, test, expect } from 'vitest'
import * as x509 from '@peculiar/x509'
import { AsnConvert } from '@peculiar/asn1-schema'
import { Certificate } from '@peculiar/asn1-x509'
import { webcrypto, generateKeyPairSync, randomBytes } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
assembleCertificate,
normalizeEcdsaSignatureToDer,
type AssembleCertificateInput,
} from '../src/ca/x509-assembler.js'
import { inProcessCaSigner, inProcessP256CaSigner, type CaSigner } from '../src/boot/ca-wiring.js'
x509.cryptoProvider.set(webcrypto)
const DAY_MS = 24 * 60 * 60 * 1000
const SAN_DNS = 'alice.terminal.yaojia.wang'
const SAN_URI = 'spiffe://relay.example.com/account/a1/host/alice'
/** WebCrypto key pair (the `CryptoKeyPair` global is not in this project's TS lib set). */
type KeyPair = { readonly publicKey: CryptoKey; readonly privateKey: CryptoKey }
async function genP256(): Promise<KeyPair> {
return (await webcrypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])) as unknown as KeyPair
}
const ED25519_SPKI_PREFIX = Uint8Array.from([
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
])
function derToPem(der: Uint8Array, label = 'CERTIFICATE'): string {
const b64 = Buffer.from(der).toString('base64').match(/.{1,64}/g)?.join('\n') ?? ''
return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----\n`
}
function leafExtensions(): x509.Extension[] {
return [
new x509.SubjectAlternativeNameExtension([
{ type: 'dns', value: SAN_DNS },
{ type: 'url', value: SAN_URI },
]),
new x509.BasicConstraintsExtension(false, undefined, true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]),
]
}
async function importEd25519Public(raw: Uint8Array): Promise<CryptoKey> {
const spki = new Uint8Array(ED25519_SPKI_PREFIX.length + raw.length)
spki.set(ED25519_SPKI_PREFIX, 0)
spki.set(raw, ED25519_SPKI_PREFIX.length)
return webcrypto.subtle.importKey('spki', spki, { name: 'Ed25519' }, true, ['verify'])
}
async function importP256Public(spkiDer: Uint8Array): Promise<CryptoKey> {
// Copy into a fresh ArrayBuffer-backed view so it satisfies BufferSource (not ArrayBufferLike).
return webcrypto.subtle.importKey('spki', new Uint8Array(spkiDer), { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'])
}
async function ed25519SubjectSpki(): Promise<Uint8Array> {
const { publicKey } = generateKeyPairSync('ed25519')
return new Uint8Array(publicKey.export({ format: 'der', type: 'spki' }))
}
function baseInput(overrides: Partial<AssembleCertificateInput>): AssembleCertificateInput {
const now = Date.now()
return {
subjectPublicKey: new Uint8Array(0),
subject: 'CN=alice',
issuer: 'CN=test-CA',
serialNumber: randomBytes(16),
notBefore: new Date(now - 60_000),
notAfter: new Date(now + DAY_MS),
extensions: leafExtensions(),
signer: inProcessCaSigner(),
sigAlg: 'ed25519',
...overrides,
}
}
describe('x509-assembler — gate (a): assembled leaves re-parse as X.509', () => {
test('Ed25519 leaf re-parses via new x509.X509Certificate without throwing', async () => {
const der = await assembleCertificate(baseInput({ subjectPublicKey: await ed25519SubjectSpki() }))
expect(() => new x509.X509Certificate(der)).not.toThrow()
})
test('ECDSA-P256 leaf re-parses via new x509.X509Certificate without throwing', async () => {
const subject = await genP256()
const der = await assembleCertificate(baseInput({ subjectPublicKey: subject.publicKey, signer: inProcessP256CaSigner(), sigAlg: 'ecdsa-p256' }))
expect(() => new x509.X509Certificate(der)).not.toThrow()
expect(new x509.X509Certificate(der).subject).toBe('CN=alice')
})
})
describe('x509-assembler — gate (b): re-parsed leaf signature verifies against the CA public key', () => {
test('Ed25519 leaf verifies against the Ed25519 CA public key', async () => {
const ca = inProcessCaSigner()
const der = await assembleCertificate(baseInput({ subjectPublicKey: await ed25519SubjectSpki(), signer: ca, sigAlg: 'ed25519' }))
const leaf = new x509.X509Certificate(der)
const caPub = await importEd25519Public(ca.publicKeyRaw)
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
})
test('ECDSA-P256 leaf verifies against the P-256 CA public key', async () => {
const ca = inProcessP256CaSigner()
const subject = await genP256()
const der = await assembleCertificate(baseInput({ subjectPublicKey: subject.publicKey, signer: ca, sigAlg: 'ecdsa-p256' }))
const leaf = new x509.X509Certificate(der)
const caPub = await importP256Public(ca.publicKeyRaw)
expect(await leaf.verify({ publicKey: caPub, signatureOnly: true })).toBe(true)
})
test('a leaf does NOT verify against a different CA key (negative)', async () => {
const ca = inProcessP256CaSigner()
const subject = await genP256()
const der = await assembleCertificate(baseInput({ subjectPublicKey: subject.publicKey, signer: ca, sigAlg: 'ecdsa-p256' }))
const leaf = new x509.X509Certificate(der)
const otherCaPub = await importP256Public(inProcessP256CaSigner().publicKeyRaw)
expect(await leaf.verify({ publicKey: otherCaPub, signatureOnly: true })).toBe(false)
})
})
describe('x509-assembler — gate (c): dNSName + URI SAN round-trips byte-correct', () => {
test('Ed25519 leaf SAN carries both the dNSName and the URI', async () => {
const der = await assembleCertificate(baseInput({ subjectPublicKey: await ed25519SubjectSpki() }))
const san = new x509.X509Certificate(der).getExtension(x509.SubjectAlternativeNameExtension)
const names = san!.names.toJSON()
expect(names).toContainEqual({ type: 'dns', value: SAN_DNS })
expect(names).toContainEqual({ type: 'url', value: SAN_URI })
})
test('ECDSA-P256 leaf SAN carries both the dNSName and the URI', async () => {
const subject = await genP256()
const der = await assembleCertificate(baseInput({ subjectPublicKey: subject.publicKey, signer: inProcessP256CaSigner(), sigAlg: 'ecdsa-p256' }))
const san = new x509.X509Certificate(der).getExtension(x509.SubjectAlternativeNameExtension)
const names = san!.names.toJSON()
expect(names).toContainEqual({ type: 'dns', value: SAN_DNS })
expect(names).toContainEqual({ type: 'url', value: SAN_URI })
})
})
describe('x509-assembler — X.509 structural invariants', () => {
test('tbsCertificate.signature OID equals certificate.signatureAlgorithm OID (identical)', async () => {
const der = await assembleCertificate(baseInput({ subjectPublicKey: await ed25519SubjectSpki() }))
const cert = AsnConvert.parse(der.buffer.slice(der.byteOffset, der.byteOffset + der.byteLength) as ArrayBuffer, Certificate)
expect(cert.tbsCertificate.signature.algorithm).toBe(cert.signatureAlgorithm.algorithm)
expect(cert.signatureAlgorithm.algorithm).toBe('1.3.101.112')
})
test('ECDSA-P256 signatureAlgorithm OID is ecdsa-with-SHA256', async () => {
const subject = await genP256()
const der = await assembleCertificate(baseInput({ subjectPublicKey: subject.publicKey, signer: inProcessP256CaSigner(), sigAlg: 'ecdsa-p256' }))
const cert = AsnConvert.parse(der.buffer.slice(der.byteOffset, der.byteOffset + der.byteLength) as ArrayBuffer, Certificate)
expect(cert.tbsCertificate.signature.algorithm).toBe('1.2.840.10045.4.3.2')
expect(cert.signatureAlgorithm.algorithm).toBe('1.2.840.10045.4.3.2')
})
test('a high-bit serial is emitted as a positive INTEGER (0x00-prefixed content octets)', async () => {
const highBit = Uint8Array.from([0x80, 0x01, 0x02, 0x03])
const der = await assembleCertificate(baseInput({ subjectPublicKey: await ed25519SubjectSpki(), serialNumber: highBit }))
// Inspect the raw INTEGER content octets: the assembler MUST prepend 0x00 so a leading 0x80 is
// never read as a negative integer. (@peculiar's serialNumber getter strips it for display.)
const cert = AsnConvert.parse(der.buffer.slice(der.byteOffset, der.byteOffset + der.byteLength) as ArrayBuffer, Certificate)
const serialBytes = new Uint8Array(cert.tbsCertificate.serialNumber)
expect(Array.from(serialBytes)).toEqual([0x00, 0x80, 0x01, 0x02, 0x03])
})
test('subject public key accepted as BOTH a CryptoKey and as SPKI DER', async () => {
const kp = await genP256()
const spkiDer = new Uint8Array(await webcrypto.subtle.exportKey('spki', kp.publicKey))
const ca = inProcessP256CaSigner()
const fromKey = await assembleCertificate(baseInput({ subjectPublicKey: kp.publicKey, signer: ca, sigAlg: 'ecdsa-p256' }))
const fromDer = await assembleCertificate(baseInput({ subjectPublicKey: spkiDer, signer: ca, sigAlg: 'ecdsa-p256' }))
// Both embed the SAME subjectPublicKeyInfo.
expect(new x509.X509Certificate(fromKey).publicKey.rawData.byteLength).toBe(new x509.X509Certificate(fromDer).publicKey.rawData.byteLength)
expect(Buffer.from(new x509.X509Certificate(fromKey).publicKey.rawData).equals(Buffer.from(new x509.X509Certificate(fromDer).publicKey.rawData))).toBe(true)
})
})
describe('normalizeEcdsaSignatureToDer — both signer output shapes', () => {
test('raw P1363 (64 bytes) is converted to a valid DER ECDSA-Sig-Value', async () => {
const kp = generateKeyPairSync('ec', { namedCurve: 'P-256' })
const { sign } = await import('node:crypto')
const raw = new Uint8Array(sign('sha256', Buffer.from('hello'), { key: kp.privateKey, dsaEncoding: 'ieee-p1363' }))
expect(raw.length).toBe(64)
const der = normalizeEcdsaSignatureToDer(raw)
expect(der[0]).toBe(0x30) // SEQUENCE
// openssl-shape signature verifies against the same key (proves the r,s survived intact).
const { verify } = await import('node:crypto')
expect(verify('sha256', Buffer.from('hello'), { key: kp.publicKey, dsaEncoding: 'der' }, Buffer.from(der))).toBe(true)
})
test('an already-DER ECDSA-Sig-Value passes through unchanged', async () => {
const kp = generateKeyPairSync('ec', { namedCurve: 'P-256' })
const { sign } = await import('node:crypto')
const der = new Uint8Array(sign('sha256', Buffer.from('world'), { key: kp.privateKey, dsaEncoding: 'der' }))
const out = normalizeEcdsaSignatureToDer(der)
expect(Buffer.from(out).equals(Buffer.from(der))).toBe(true)
})
test('garbage that is neither P1363 nor DER throws (fail loud at issuance)', () => {
expect(() => normalizeEcdsaSignatureToDer(Uint8Array.from([1, 2, 3, 4, 5]))).toThrow()
})
})
describe('x509-assembler — Ed25519 signatureValue length guard (CP2)', () => {
/** A CaSigner whose `sign` returns a wrong-length "Ed25519" signature (truncated / malformed). */
function fixedLenEd25519Signer(len: number): CaSigner {
return { publicKeyRaw: new Uint8Array(32), async sign() { return new Uint8Array(len) } }
}
test('a signer returning fewer than 64 bytes is rejected at issuance (not embedded)', async () => {
await expect(
assembleCertificate(
baseInput({ subjectPublicKey: await ed25519SubjectSpki(), signer: fixedLenEd25519Signer(63), sigAlg: 'ed25519' }),
),
).rejects.toThrow(/64 bytes/)
})
test('a signer returning more than 64 bytes is rejected too', async () => {
await expect(
assembleCertificate(
baseInput({ subjectPublicKey: await ed25519SubjectSpki(), signer: fixedLenEd25519Signer(65), sigAlg: 'ed25519' }),
),
).rejects.toThrow(/64 bytes/)
})
test('a real 64-byte Ed25519 signature still issues fine (no false positive)', async () => {
const der = await assembleCertificate(baseInput({ subjectPublicKey: await ed25519SubjectSpki() }))
expect(() => new x509.X509Certificate(der)).not.toThrow()
})
})
describe('x509-assembler — gate (e): openssl parses & verifies the chain', () => {
function hasOpenssl(): boolean {
try {
execFileSync('openssl', ['version'], { stdio: 'ignore' })
return true
} catch {
return false
}
}
test('openssl x509 -text parses the P-256 leaf and openssl verify OKs it against the CA', async () => {
if (!hasOpenssl()) {
// openssl absent — gates (a)-(c) already prove real X.509; note and skip the tool check.
expect(true).toBe(true)
return
}
// Self-signed P-256 CA (subject key == signer key) so openssl can build a full chain.
const caKeys = generateKeyPairSync('ec', { namedCurve: 'P-256' })
const caSpki = new Uint8Array(caKeys.publicKey.export({ format: 'der', type: 'spki' }))
const caSigner: CaSigner = inProcessP256CaSigner(caKeys.privateKey)
const now = Date.now()
const caDer = await assembleCertificate({
subjectPublicKey: caSpki,
subject: 'CN=Test P256 CA',
issuer: 'CN=Test P256 CA',
serialNumber: Uint8Array.from([0x01]),
notBefore: new Date(now - DAY_MS),
notAfter: new Date(now + 365 * DAY_MS),
extensions: [
new x509.BasicConstraintsExtension(true, undefined, true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true),
],
signer: caSigner,
sigAlg: 'ecdsa-p256',
})
const subject = await genP256()
const leafDer = await assembleCertificate(baseInput({ subjectPublicKey: subject.publicKey, issuer: 'CN=Test P256 CA', signer: caSigner, sigAlg: 'ecdsa-p256' }))
const dir = mkdtempSync(join(tmpdir(), 'x509asm-'))
try {
const caPem = join(dir, 'ca.pem')
const leafPem = join(dir, 'leaf.pem')
writeFileSync(caPem, derToPem(caDer))
writeFileSync(leafPem, derToPem(leafDer))
const text = execFileSync('openssl', ['x509', '-in', leafPem, '-noout', '-text'], { encoding: 'utf8' })
expect(text).toContain('Signature Algorithm: ecdsa-with-SHA256')
expect(text).toContain(SAN_DNS)
expect(text).toContain(SAN_URI)
const verifyOut = execFileSync('openssl', ['verify', '-CAfile', caPem, leafPem], { encoding: 'utf8' })
expect(verifyOut).toContain('OK')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})