Files
web-terminal/control-plane/test/getcertsub.test.ts
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

239 lines
11 KiB
TypeScript

/**
* A3 (FIX C-native-3) — the SINGLE load-bearing tenant-isolation control, unit-tested under Node.
*
* `deploy/nginx/njs/getCertSub.js` runs inside nginx (njs) via `js_set $cert_sub getCertSub`. This
* suite exercises the SAME source under Node against REAL P-256 leaf certs minted by the A1 issuance
* primitive (`assembleCertificate` + `inProcessP256CaSigner`) — the exact structure the B1/A4 issuers
* stamp — so a parsing regression is caught in CI, not in production where it would be a skeleton-key.
*
* Two layers, both here:
* 1. `extractLeftmostDnsLabel` — the njs SAN extractor: leftmost dNSName label, fail-closed to ''.
* 2. `certHostOk` — a JS mirror of the nginx `map "$cert_sub:$ssl_server_name"` PCRE rule, so the
* allow/deny semantics (incl. the cross-tenant NEGATIVE case) are asserted deterministically.
*/
import 'reflect-metadata'
import { describe, test, expect } from 'vitest'
import * as x509 from '@peculiar/x509'
import { webcrypto, randomBytes } from 'node:crypto'
import certsub from '../../deploy/nginx/njs/getCertSub.js'
import { assembleCertificate } from '../src/ca/x509-assembler.js'
import { inProcessP256CaSigner } from '../src/boot/ca-wiring.js'
x509.cryptoProvider.set(webcrypto)
const { extractLeftmostDnsLabel, getCertSub } = certsub
const DAY_MS = 24 * 60 * 60 * 1000
/** 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
}
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`
}
type SanEntry = { readonly type: 'dns' | 'url'; readonly value: string }
/**
* Mint a REAL P-256 leaf PEM off the dev P-256 CA (stand-in for the B1/A4 issuers), carrying exactly
* the supplied SAN entries. Empty `san` omits the SAN extension entirely (the no-SAN case).
*/
async function mintLeafPem(san: readonly SanEntry[]): Promise<string> {
const subject = await genP256()
const extensions: x509.Extension[] = []
if (san.length > 0) {
extensions.push(new x509.SubjectAlternativeNameExtension(san as { type: string; value: string }[]))
}
extensions.push(new x509.BasicConstraintsExtension(false, undefined, true))
extensions.push(new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]))
const now = Date.now()
const der = await assembleCertificate({
subjectPublicKey: subject.publicKey,
subject: 'CN=leaf',
issuer: 'CN=device-CA',
serialNumber: randomBytes(16),
notBefore: new Date(now - 60_000),
notAfter: new Date(now + DAY_MS),
extensions,
signer: inProcessP256CaSigner(),
sigAlg: 'ecdsa-p256',
})
return derToPem(der)
}
const SPIFFE_ALICE = 'spiffe://terminal.yaojia.wang/account/a1/host/alice'
describe('extractLeftmostDnsLabel — POSITIVE (real A1-minted leaves, FIX C-native-3)', () => {
test("SAN dNSName 'alice.terminal.yaojia.wang' (+ spiffe URI) → 'alice'", async () => {
const pem = await mintLeafPem([
{ type: 'dns', value: 'alice.terminal.yaojia.wang' },
{ type: 'url', value: SPIFFE_ALICE },
])
expect(extractLeftmostDnsLabel(pem)).toBe('alice')
})
test("SAN dNSName 'bob.terminal.yaojia.wang' → 'bob'", async () => {
const pem = await mintLeafPem([{ type: 'dns', value: 'bob.terminal.yaojia.wang' }])
expect(extractLeftmostDnsLabel(pem)).toBe('bob')
})
test('picks the dNSName even when a URI SAN is listed FIRST (order-independent)', async () => {
const pem = await mintLeafPem([
{ type: 'url', value: 'spiffe://terminal.yaojia.wang/account/a1/host/charlie' },
{ type: 'dns', value: 'charlie.terminal.yaojia.wang' },
])
expect(extractLeftmostDnsLabel(pem)).toBe('charlie')
})
test('takes the FIRST dNSName when multiple are present', async () => {
const pem = await mintLeafPem([
{ type: 'dns', value: 'dave.terminal.yaojia.wang' },
{ type: 'dns', value: 'erin.terminal.yaojia.wang' },
])
expect(extractLeftmostDnsLabel(pem)).toBe('dave')
})
})
describe('extractLeftmostDnsLabel — FAIL-CLOSED (any parse failure / missing SAN → "")', () => {
test('a leaf with ONLY a URI SAN (no dNSName) → ""', async () => {
const pem = await mintLeafPem([{ type: 'url', value: SPIFFE_ALICE }])
expect(extractLeftmostDnsLabel(pem)).toBe('')
})
test('a leaf with NO SAN extension at all → ""', async () => {
const pem = await mintLeafPem([])
expect(extractLeftmostDnsLabel(pem)).toBe('')
})
test('empty string → ""', () => {
expect(extractLeftmostDnsLabel('')).toBe('')
})
test('non-PEM garbage → ""', () => {
expect(extractLeftmostDnsLabel('this is not a certificate')).toBe('')
})
test('valid PEM markers but garbage base64 body → ""', () => {
const junk = '-----BEGIN CERTIFICATE-----\nZ m 9 v Y m F y\n-----END CERTIFICATE-----\n'
expect(extractLeftmostDnsLabel(junk)).toBe('')
})
test('truncated DER inside valid PEM markers → ""', async () => {
const pem = await mintLeafPem([{ type: 'dns', value: 'alice.terminal.yaojia.wang' }])
const body = pem.split('\n').slice(1, 3).join('') // keep only the first ~2 base64 lines
const truncated = `-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----\n`
expect(extractLeftmostDnsLabel(truncated)).toBe('')
})
test('CP5: a nested-length-inconsistent cert cannot leak a dNSName past its parent bound → ""', () => {
// Hand-crafted DER: Certificate→TBS→[3]→Extensions→Extension(SAN)→extnValue OCTET STRING whose
// GeneralNames SEQUENCE declares length 6 — overrunning the 2-byte extnValue content — so its
// "content" reaches into a dNSName ('evil') that actually sits AFTER the OCTET STRING as a sibling
// (but still inside the buffer). Without the parent-bound check readTlv would accept the inflated
// GeneralNames and return 'evil' (a cross-tenant skeleton-key); bounding each child to its parent's
// contentEnd makes the extractor fail-closed to ''.
const der = [
0x30, 0x17, // Certificate SEQUENCE, len 23
0x30, 0x15, // TBSCertificate SEQUENCE, len 21
0xa3, 0x13, // [3] extensions container, len 19
0x30, 0x11, // Extensions SEQUENCE, len 17
0x30, 0x09, // Extension SEQUENCE, len 9
0x06, 0x03, 0x55, 0x1d, 0x11, // OID 2.5.29.17 (subjectAltName)
0x04, 0x02, 0x30, 0x06, // extnValue OCTET STRING (len 2) = GeneralNames header claiming len 6
0x82, 0x04, 0x65, 0x76, 0x69, 0x6c, // dNSName [2] "evil" — sibling, reached only by the lie
]
const b64 = Buffer.from(Uint8Array.from(der)).toString('base64')
const pem = `-----BEGIN CERTIFICATE-----\n${b64}\n-----END CERTIFICATE-----\n`
expect(extractLeftmostDnsLabel(pem)).toBe('')
})
})
describe('getCertSub — njs entrypoint glue over r.variables.ssl_client_cert', () => {
test('reads ssl_client_cert and returns the leftmost label', async () => {
const pem = await mintLeafPem([{ type: 'dns', value: 'alice.terminal.yaojia.wang' }])
expect(getCertSub({ variables: { ssl_client_cert: pem } })).toBe('alice')
})
test('missing ssl_client_cert (no client cert) → "" (fail-closed → map denies)', () => {
expect(getCertSub({ variables: {} })).toBe('')
})
test('tab-mangled PEM (nginx $ssl_client_cert continuation form) still parses', async () => {
const pem = await mintLeafPem([{ type: 'dns', value: 'alice.terminal.yaojia.wang' }])
// nginx prepends a TAB to every line except the first in $ssl_client_cert.
const tabbed = pem
.split('\n')
.map((line, i) => (i === 0 ? line : `\t${line}`))
.join('\n')
expect(getCertSub({ variables: { ssl_client_cert: tabbed } })).toBe('alice')
})
})
/**
* JS mirror of the nginx map (zone-anchored, self-sufficient):
* map "$cert_sub:$ssl_server_name" $cert_host_ok { "~^(?<s>[^:]+):(?P=s)\\.terminal\\.yaojia\\.wang$" 1; default 0; }
* PCRE `(?P=s)` is the named backreference; JS spells the same backreference `\k<s>`. Both engines
* require: a non-empty leftmost label before ':', then the SAME text after ':' followed by EXACTLY
* `.terminal.yaojia.wang` to the end. Anchoring the full zone (not just `<label>.`) means the map does
* not rely on the server_name regex / stream SNI routing to constrain the zone. An empty $cert_sub
* (fail-closed extractor output) can never satisfy `[^:]+` → always denies.
*/
const CERT_HOST_RE = /^(?<s>[^:]+):\k<s>\.terminal\.yaojia\.wang$/
function certHostOk(certSub: string, serverName: string): 0 | 1 {
return CERT_HOST_RE.test(`${certSub}:${serverName}`) ? 1 : 0
}
describe('certHostOk — map "$cert_sub:$ssl_server_name" allow/deny (mirrors nginx PCRE)', () => {
test("own host ALLOWS: ('alice','alice.terminal.yaojia.wang') → 1", () => {
expect(certHostOk('alice', 'alice.terminal.yaojia.wang')).toBe(1)
})
test("cross-tenant DENIES: ('alice','bob.terminal.yaojia.wang') → 0 [THE NEGATIVE]", () => {
expect(certHostOk('alice', 'bob.terminal.yaojia.wang')).toBe(0)
})
test("empty cert_sub DENIES: ('','alice.terminal.yaojia.wang') → 0", () => {
expect(certHostOk('', 'alice.terminal.yaojia.wang')).toBe(0)
})
test("prefix attack DENIES: ('alice','alicex.terminal.yaojia.wang') → 0", () => {
expect(certHostOk('alice', 'alicex.terminal.yaojia.wang')).toBe(0)
})
test("no-dot server_name DENIES: ('alice','alice') → 0", () => {
expect(certHostOk('alice', 'alice')).toBe(0)
})
test("empty server_name DENIES: ('alice','') → 0", () => {
expect(certHostOk('alice', '')).toBe(0)
})
test("a second tenant ALLOWS its own host: ('bob','bob.terminal.yaojia.wang') → 1", () => {
expect(certHostOk('bob', 'bob.terminal.yaojia.wang')).toBe(1)
})
test("cross-ZONE bypass DENIES: ('alice','alice.evil.com') → 0 [zone-anchor hardening]", () => {
// A label-only map (`(?P=s)\.`) would wrongly ALLOW this; the full-zone anchor denies it, so the
// map stays sound even if the server_name regex / stream SNI routing were ever loosened.
expect(certHostOk('alice', 'alice.evil.com')).toBe(0)
expect(certHostOk('alice', 'alice.terminal.yaojia.wang.evil.com')).toBe(0)
})
test('end-to-end: extractor output feeds the map (alice cert on bob host → deny)', async () => {
const pem = await mintLeafPem([{ type: 'dns', value: 'alice.terminal.yaojia.wang' }])
const sub = extractLeftmostDnsLabel(pem)
expect(sub).toBe('alice')
expect(certHostOk(sub, 'alice.terminal.yaojia.wang')).toBe(1)
expect(certHostOk(sub, 'bob.terminal.yaojia.wang')).toBe(0)
})
})