Files
web-terminal/deploy/nginx/njs/getCertSub.js
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

242 lines
10 KiB
JavaScript

/*
* A3 / FIX C-native-3 — the SINGLE load-bearing tenant-isolation control.
*
* Runs INSIDE nginx as njs (an ECMAScript subset). `js_set $cert_sub certsub.getCertSub` exposes the
* leftmost dNSName SAN label of the (already CA-verified) client certificate to an nginx `map`, which
* compares it against the requested `$ssl_server_name` and `return 403`s on mismatch. A cert stamped
* `dNSName alice.terminal.yaojia.wang` may ONLY reach the `alice.*` subdomain; a cert for tenant A
* presented to tenant B's host is denied. This binding runs AFTER `ssl_verify_client` — by the time
* this code sees the cert, nginx has already proven it was signed by the device-CA.
*
* DESIGN: `extractLeftmostDnsLabel` is a PURE function using ONLY APIs common to njs AND Node, so the
* SAME source is unit-tested under Node/vitest (control-plane/test/getcertsub.test.ts against real
* A1-minted P-256 leaves) and runs unchanged under njs. No `Buffer`, no `atob`, no typed-array-only
* APIs: base64 decode and X.509/ASN.1 (DER) walking are hand-rolled over plain arrays of byte numbers.
*
* FAIL-CLOSED: ANY malformed input, missing SAN, missing dNSName, non-hostname bytes, or truncated DER
* returns '' (empty). An empty `$cert_sub` can never satisfy the map regex `^(?<s>[^:]+):(?P=s)\.`
* (which requires a non-empty label), so an empty result always DENIES. Never throws; never allows on
* doubt. Conservative by construction — this is the one control between tenants.
*/
/* -------------------------------------------------------------------------- */
/* PEM → base64 body */
/* -------------------------------------------------------------------------- */
/**
* Extract the base64 body between the FIRST `-----BEGIN ... CERTIFICATE-----` header and the next
* `-----END`. We anchor on `CERTIFICATE-----` (the tail of the BEGIN line) so the header letters
* (B,E,G,I,N,C — all valid base64 chars) can never leak into the decoded body. '' on absence.
*/
function pemBody(pem) {
var marker = 'CERTIFICATE-----'
var b = pem.indexOf(marker)
if (b < 0) return ''
b += marker.length
var e = pem.indexOf('-----END', b)
if (e < 0) return ''
return pem.substring(b, e)
}
/** Map one base64 alphabet char code to its 6-bit value; -1 for anything else (whitespace, '=', …). */
function b64Val(c) {
if (c >= 65 && c <= 90) return c - 65 // A-Z → 0..25
if (c >= 97 && c <= 122) return c - 71 // a-z → 26..51
if (c >= 48 && c <= 57) return c + 4 // 0-9 → 52..61
if (c === 43) return 62 // '+'
if (c === 47) return 63 // '/'
return -1
}
/**
* Decode base64 text to a plain array of byte numbers. Non-alphabet characters (newlines, the TAB that
* nginx `$ssl_client_cert` prepends to continuation lines, spaces, '=' padding) are skipped. The bit
* accumulator is masked to 24 bits every step so it never grows past double precision.
*/
function base64ToBytes(s) {
var out = []
var buffer = 0
var bits = 0
for (var i = 0; i < s.length; i++) {
var v = b64Val(s.charCodeAt(i))
if (v < 0) continue
buffer = ((buffer << 6) | v) & 0xffffff
bits += 6
if (bits >= 8) {
bits -= 8
out.push((buffer >> bits) & 0xff)
}
}
return out
}
/* -------------------------------------------------------------------------- */
/* Minimal DER (definite-length) reader */
/* -------------------------------------------------------------------------- */
/** Read a DER length starting at `pos`. Returns { len, next } or null (indefinite / overlong / OOB). */
function readLen(bytes, pos) {
var first = bytes[pos]
if (first === undefined) return null
if (first < 0x80) return { len: first, next: pos + 1 }
var numBytes = first & 0x7f
if (numBytes === 0 || numBytes > 4) return null // 0 = indefinite (illegal in DER); >4 = absurd
var len = 0
for (var i = 0; i < numBytes; i++) {
var b = bytes[pos + 1 + i]
if (b === undefined) return null
len = len * 256 + b
}
return { len: len, next: pos + 1 + numBytes }
}
/**
* Read one TLV at `pos`, bounded by `parentEnd` (the contentEnd of the immediate container; callers
* pass it so a child can never claim to extend past its parent even while still fitting the overall
* buffer — a crafted nested-length-inconsistent cert must not let the walk read sibling/trailing bytes
* as a child's content). `parentEnd` defaults to the total buffer length for top-level reads. Returns
* { tag, contentStart, contentEnd, next } or null on any inconsistency (out of bounds, high-tag-number
* form, length overrun past the parent OR the buffer). Single-byte tags only — every tag this walk
* cares about (SEQUENCE 0x30, [3] 0xA3, OID 0x06, BOOLEAN 0x01, OCTET STRING 0x04, dNSName 0x82) is
* single-byte, so a high-tag-number tag is treated as malformed (fail-closed).
*/
function readTlv(bytes, pos, parentEnd) {
var limit = parentEnd === undefined ? bytes.length : parentEnd
if (limit > bytes.length) limit = bytes.length // never trust a parent bound wider than the buffer
if (pos < 0 || pos >= limit) return null
var tag = bytes[pos]
if ((tag & 0x1f) === 0x1f) return null // high-tag-number form — unexpected → fail-closed
var l = readLen(bytes, pos + 1)
if (l === null) return null
var contentStart = l.next
var contentEnd = contentStart + l.len
if (contentEnd > limit) return null // overruns the parent container (or buffer) → fail-closed
return { tag: tag, contentStart: contentStart, contentEnd: contentEnd, next: contentEnd }
}
/** True iff this OID TLV's content is exactly `55 1d 11` = OID 2.5.29.17 (subjectAltName). */
function isSanOid(der, oid) {
if (oid.contentEnd - oid.contentStart !== 3) return false
return (
der[oid.contentStart] === 0x55 && der[oid.contentStart + 1] === 0x1d && der[oid.contentStart + 2] === 0x11
)
}
/** Valid hostname byte? [0-9A-Za-z.-] only — anything else rejects the whole label (fail-closed). */
function isHostByte(c) {
return (
(c >= 0x30 && c <= 0x39) || // 0-9
(c >= 0x41 && c <= 0x5a) || // A-Z
(c >= 0x61 && c <= 0x7a) || // a-z
c === 0x2d || // '-'
c === 0x2e // '.'
)
}
/** Decode the dNSName value bytes to a hostname string, or '' if any byte is outside [0-9A-Za-z.-]. */
function hostString(der, start, end) {
var s = ''
for (var i = start; i < end; i++) {
var c = der[i]
if (!isHostByte(c)) return ''
s += String.fromCharCode(c)
}
return s
}
/**
* Walk the certificate DER structurally to the SubjectAlternativeName extension and return the FIRST
* dNSName value. Structure:
* Certificate ::= SEQUENCE { tbsCertificate SEQUENCE { ..., extensions [3] EXPLICIT }, ... }
* Extensions ::= SEQUENCE OF Extension { extnID OID, critical BOOLEAN OPTIONAL, extnValue OCTET STRING }
* extnValue (for SAN) = GeneralNames ::= SEQUENCE OF GeneralName; dNSName is [2] IA5String (tag 0x82)
* Returns '' on any structural deviation, missing SAN, or SAN-without-dNSName.
*/
function firstDnsSan(der) {
var cert = readTlv(der, 0)
if (cert === null || cert.tag !== 0x30) return ''
var tbs = readTlv(der, cert.contentStart, cert.contentEnd)
if (tbs === null || tbs.tag !== 0x30) return ''
// Walk the DIRECT children of TBSCertificate for the [3] (0xA3) explicit extensions container.
var pos = tbs.contentStart
var extsContainer = null
while (pos < tbs.contentEnd) {
var child = readTlv(der, pos, tbs.contentEnd)
if (child === null) return ''
if (child.tag === 0xa3) {
extsContainer = child
break
}
pos = child.next
}
if (extsContainer === null) return '' // no extensions → no SAN
var exts = readTlv(der, extsContainer.contentStart, extsContainer.contentEnd)
if (exts === null || exts.tag !== 0x30) return ''
// Iterate each Extension looking for the SAN OID.
var epos = exts.contentStart
while (epos < exts.contentEnd) {
var ext = readTlv(der, epos, exts.contentEnd)
if (ext === null || ext.tag !== 0x30) return ''
var oid = readTlv(der, ext.contentStart, ext.contentEnd)
if (oid === null || oid.tag !== 0x06) {
epos = ext.next
continue
}
if (isSanOid(der, oid)) {
// Skip an optional BOOLEAN `critical`, then require the OCTET STRING `extnValue` — both bounded
// to THIS Extension's content so a lying extnValue length can't reach into a sibling extension.
var vpos = oid.next
var val = readTlv(der, vpos, ext.contentEnd)
if (val !== null && val.tag === 0x01) {
vpos = val.next
val = readTlv(der, vpos, ext.contentEnd)
}
if (val === null || val.tag !== 0x04) return ''
// The GeneralNames SEQUENCE and every GeneralName are bounded to the OCTET STRING's content:
// a GeneralNames length that overruns extnValue (but still fits the buffer) must NOT be read.
var gnames = readTlv(der, val.contentStart, val.contentEnd)
if (gnames === null || gnames.tag !== 0x30) return ''
var gpos = gnames.contentStart
while (gpos < gnames.contentEnd) {
var gn = readTlv(der, gpos, gnames.contentEnd)
if (gn === null) return ''
if (gn.tag === 0x82) return hostString(der, gn.contentStart, gn.contentEnd) // dNSName [2]
gpos = gn.next
}
return '' // SAN present but carries no dNSName (e.g. URI-only)
}
epos = ext.next
}
return '' // no SAN extension
}
/* -------------------------------------------------------------------------- */
/* Public surface */
/* -------------------------------------------------------------------------- */
/**
* Decode a PEM client certificate and return the LEFTMOST label of its FIRST dNSName SAN
* (e.g. 'alice.terminal.yaojia.wang' → 'alice'). '' on ANY failure (fail-closed → the map denies).
*/
function extractLeftmostDnsLabel(pemClientCert) {
if (!pemClientCert || typeof pemClientCert !== 'string') return ''
var body = pemBody(pemClientCert)
if (body === '') return ''
var der = base64ToBytes(body)
if (der.length === 0) return ''
var dns = firstDnsSan(der)
if (dns === '') return ''
var dot = dns.indexOf('.')
return dot < 0 ? dns : dns.substring(0, dot)
}
/** njs entrypoint referenced by `js_set $cert_sub certsub.getCertSub`. */
function getCertSub(r) {
return extractLeftmostDnsLabel(r.variables.ssl_client_cert || '')
}
export default { getCertSub: getCertSub, extractLeftmostDnsLabel: extractLeftmostDnsLabel }