|
| 1 | +import { X509Certificate } from "node:crypto"; |
| 2 | + |
| 3 | +// Does a non-certificate block's armor decode? Node only needs that much from a |
| 4 | +// CRL or key block, so this is deliberately weaker than parsing it as whatever |
| 5 | +// it claims to be — the guard's job is to predict node's loader, not to |
| 6 | +// validate the block's contents. |
| 7 | +// |
| 8 | +// Base64 is checked as whole 4-character quanta, not merely as an alphabet. An |
| 9 | +// alphabet-only test accepted a one-character body: measured, `A` in a |
| 10 | +// PUBLIC KEY block ahead of our CA gave guard=accept while node reported |
| 11 | +// `bad base64 decode` and loaded zero extra CAs. Padding is equally positional — |
| 12 | +// `AAA=` and `AA==` load, `A===`, `=AAA` and `AA=A` do not. Measured 16/16 |
| 13 | +// agreement with a real handshake on the rule below. |
| 14 | +// |
| 15 | +// A body containing `-` reads as damaged here even though node accepts it |
| 16 | +// (openssl stops at the dash), which is the conservative direction and the only |
| 17 | +// measured disagreement. |
| 18 | +function isBase64Body(body) { |
| 19 | + const b = body.replace(/\s+/g, ""); |
| 20 | + return b.length > 0 && b.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(b); |
| 21 | +} |
| 22 | + |
| 23 | +// Is this merged CA bundle safe to hand claude as NODE_EXTRA_CA_CERTS? |
| 24 | +// |
| 25 | +// Lives in its own module for one reason: the launcher is a top-level script, |
| 26 | +// so a test could not import this decision and the previous version kept a |
| 27 | +// hand-copied duplicate in test/proxy-forward-ca.test.mjs under a "change one, |
| 28 | +// change both" comment. Measured: mutating the launcher's copy left the whole |
| 29 | +// suite green, so the trust path had no regression cover at all. Two call sites |
| 30 | +// is below this repo's bar for a new abstraction; the justification here is not |
| 31 | +// reuse, it is that the test must drive the shipped code rather than an |
| 32 | +// adjacent copy of it. |
| 33 | +// |
| 34 | +// The rule this implements is node's, not openssl's-in-general, and every |
| 35 | +// clause below was measured against a real TLS handshake (node v24.11.1, |
| 36 | +// openssl 3.6.1) rather than reasoned from the spec. See |
| 37 | +// test/proxy-forward-ca.test.mjs, which re-runs that comparison on each shape. |
| 38 | +// |
| 39 | +// It stays a pre-flight guard, never proof: it establishes the file parses and |
| 40 | +// carries us, never that node will verify a given leaf with it. |
| 41 | +export function bundleCarriesOurCA(text, ourCaPem) { |
| 42 | + const ourDer = new X509Certificate(ourCaPem).raw; |
| 43 | + // Line-anchored. openssl only honours a marker that begins a line, so a |
| 44 | + // marker quoted inside prose is not a block. The previous count-based check |
| 45 | + // read the raw file and rejected any bundle whose provenance header happened |
| 46 | + // to name the marker — measured: `# see -----BEGIN CERTIFICATE-----` ahead of |
| 47 | + // a healthy CA was refused while node authorized the same bytes. |
| 48 | + // |
| 49 | + // No CRLF normalization: `$` in a /m regex matches before a `\r`, and the END |
| 50 | + // search below is anchored on the leading `\n`, so both halves already read a |
| 51 | + // CRLF file the same as an LF one. Measured across 102 shapes (34 bundle |
| 52 | + // layouts x LF/CRLF/mixed): identical verdicts with and without the replace. |
| 53 | + // Trailing whitespace is tolerated on the marker line. openssl still reacts |
| 54 | + // to `-----BEGIN CERTIFICATE----- ` (one trailing space), so a `$`-anchored |
| 55 | + // pattern made that block invisible to us while node still tried to load it |
| 56 | + // — measured: a corrupt block wearing a trailing space was skipped by the |
| 57 | + // guard and failed the handshake. |
| 58 | + // The label pattern is permissive on purpose. Restricting it to uppercase, |
| 59 | + // digits and spaces made every other legal label invisible to us while |
| 60 | + // openssl still treated the block as real — measured: a malformed `X-FOO` |
| 61 | + // block ahead of our CA gave guard=accept while node loaded zero extra CAs. |
| 62 | + // Every label tried behaved as a real block (hyphenated, lowercase, |
| 63 | + // underscored, dotted, punctuated, even empty), so the label decides only |
| 64 | + // WHICH check a block gets, never whether it is one. |
| 65 | + // `-` is legal INSIDE a label, so the stop condition is the `-----` run, not |
| 66 | + // the first hyphen: `[^-]*` failed to match `X-FOO` at all, which is the same |
| 67 | + // blind spot in a new place. |
| 68 | + const marker = /^-----BEGIN ((?:(?!-----).)*)-----[ \t]*$/gm; |
| 69 | + let carriesUs = false; |
| 70 | + for (let m; (m = marker.exec(text)); ) { |
| 71 | + const label = m[1]; |
| 72 | + // Bounded by the NEXT marker, not by a search to end-of-file. An unbounded |
| 73 | + // indexOf lets a torn block borrow the END line of a later one, so the |
| 74 | + // unterminated check never fires and the slice spans two entries. |
| 75 | + // The END marker must also END ITS LINE, bar trailing whitespace. indexOf |
| 76 | + // alone ignored whatever followed it, so `-----END CERTIFICATE-----garbage` |
| 77 | + // and `-----END CERTIFICATE-------` both read as terminators here while |
| 78 | + // openssl rejected the block and node loaded zero CAs — measured, both as |
| 79 | + // false accepts on an otherwise healthy bundle. Whitespace is fine (13/13 |
| 80 | + // agreement with a real handshake on what may follow), anything else is not. |
| 81 | + const endMarker = `\n-----END ${label}-----`; |
| 82 | + const nextBegin = text.indexOf("\n-----BEGIN ", m.index + 1); |
| 83 | + let end = -1; |
| 84 | + for (let at = text.indexOf(endMarker, m.index); at !== -1; |
| 85 | + at = text.indexOf(endMarker, at + 1)) { |
| 86 | + const lineEnd = text.indexOf("\n", at + 1); |
| 87 | + const tail = text.slice(at + endMarker.length, lineEnd === -1 ? undefined : lineEnd); |
| 88 | + if (/^[ \t\r]*$/.test(tail)) { end = at; break; } |
| 89 | + } |
| 90 | + if (end !== -1 && nextBegin !== -1 && end > nextBegin) end = -1; |
| 91 | + // Unterminated, or closed by a different label. Fatal whatever the label |
| 92 | + // is, and deliberately not analyzed further: openssl's base64 decoder |
| 93 | + // treats the next '-' as end-of-data instead of an error, so a torn block |
| 94 | + // yields a valid entry when its truncated body happens to be a complete |
| 95 | + // DER and garbage when it does not. Measured both outcomes from the same |
| 96 | + // tear position with only the body length changed. Since the result is not |
| 97 | + // knowable from out here, a damaged file is refused rather than guessed at. |
| 98 | + if (end === -1) return { ok: false, reason: `unterminated ${label} block` }; |
| 99 | + const block = text.slice(m.index, end + endMarker.length); |
| 100 | + // EVERY block must decode, whatever its label. Node's PEM reader aborts the |
| 101 | + // whole extras load on any block it cannot decode — a truncated CRL or key |
| 102 | + // block ahead of our CA takes the entire file down with it, our own entry |
| 103 | + // included. Skipping non-certificate blocks outright (as this did) waved |
| 104 | + // those bundles through: measured, a corrupt PUBLIC KEY and a corrupt |
| 105 | + // X509 CRL each gave guard=accept while the handshake failed |
| 106 | + // UNABLE_TO_VERIFY_LEAF_SIGNATURE. |
| 107 | + // |
| 108 | + // What "decodes" means differs by label, and both halves were measured |
| 109 | + // against a real handshake rather than reasoned from the spec. For a |
| 110 | + // CERTIFICATE, base64 validity is not enough — a well-formed base64 body |
| 111 | + // that is not a certificate still kills the load, so it must parse as X509. |
| 112 | + // For everything else node only needs the armor to decode, so valid base64 |
| 113 | + // is the whole bar; demanding more would reject the CRLs and key blocks a |
| 114 | + // real corporate bundle legitimately carries, and rejecting does not fail |
| 115 | + // safe here — it drops every sibling and corporate CA for the session, |
| 116 | + // which is the failure this contract exists to prevent. |
| 117 | + if (label === "CERTIFICATE") { |
| 118 | + let der; |
| 119 | + try { der = new X509Certificate(block).raw; } |
| 120 | + catch { return { ok: false, reason: "undecodable CERTIFICATE block" }; } |
| 121 | + if (der.equals(ourDer)) carriesUs = true; |
| 122 | + } else if (!isBase64Body(text.slice(m.index + m[0].length, end))) { |
| 123 | + return { ok: false, reason: `undecodable ${label} block` }; |
| 124 | + } |
| 125 | + } |
| 126 | + // A bundle that exists but predates our publish is WORSE than no bundle: |
| 127 | + // handing it to claude makes the client distrust the very proxy it is routed |
| 128 | + // through, so every request fails TLS rather than merely losing some other |
| 129 | + // component's CA. |
| 130 | + // |
| 131 | + // Matched on DER, and only on a CERTIFICATE block, because neither weaker |
| 132 | + // check is sound. Measured: relabelling our own CA to TRUSTED CERTIFICATE |
| 133 | + // leaves X509Certificate parsing it into byte-identical DER while node's CA |
| 134 | + // loader skips it entirely — the old guard accepted that bundle and the |
| 135 | + // handshake then failed with UNABLE_TO_VERIFY_LEAF_SIGNATURE. That is the |
| 136 | + // exact outcome this check exists to prevent, so the label is load-bearing. |
| 137 | + return carriesUs ? { ok: true } : { ok: false, reason: "bundle does not carry our CA" }; |
| 138 | +} |
0 commit comments