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:
302
control-plane/test/x509-assembler.test.ts
Normal file
302
control-plane/test/x509-assembler.test.ts
Normal 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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user