/* * 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 `^(?[^:]+):(?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 }