|
| 1 | +import { X509Certificate } from "node:crypto"; |
| 2 | + |
| 3 | +// Is this merged CA bundle safe to hand claude as NODE_EXTRA_CA_CERTS? |
| 4 | +// |
| 5 | +// Lives in its own module for one reason: the launcher is a top-level script, |
| 6 | +// so a test could not import this decision and the previous version kept a |
| 7 | +// hand-copied duplicate in test/proxy-forward-ca.test.mjs under a "change one, |
| 8 | +// change both" comment. Measured: mutating the launcher's copy left the whole |
| 9 | +// suite green, so the trust path had no regression cover at all. Two call sites |
| 10 | +// is below this repo's bar for a new abstraction; the justification here is not |
| 11 | +// reuse, it is that the test must drive the shipped code rather than an |
| 12 | +// adjacent copy of it. |
| 13 | +// |
| 14 | +// The rule this implements is node's, not openssl's-in-general, and every |
| 15 | +// clause below was measured against a real TLS handshake (node v24.11.1, |
| 16 | +// openssl 3.6.1) rather than reasoned from the spec. See |
| 17 | +// test/proxy-forward-ca.test.mjs, which re-runs that comparison on each shape. |
| 18 | +// |
| 19 | +// It stays a pre-flight guard, never proof: it establishes the file parses and |
| 20 | +// carries us, never that node will verify a given leaf with it. |
| 21 | +export function bundleCarriesOurCA(merged, ourCaPem) { |
| 22 | + const text = merged.replace(/\r\n/g, "\n"); |
| 23 | + const ourDer = new X509Certificate(ourCaPem).raw; |
| 24 | + // Line-anchored. openssl only honours a marker that begins a line, so a |
| 25 | + // marker quoted inside prose is not a block. The previous count-based check |
| 26 | + // read the raw file and rejected any bundle whose provenance header happened |
| 27 | + // to name the marker — measured: `# see -----BEGIN CERTIFICATE-----` ahead of |
| 28 | + // a healthy CA was refused while node authorized the same bytes. |
| 29 | + const marker = /^-----BEGIN ([A-Z0-9 ]*)-----$/gm; |
| 30 | + let carriesUs = false; |
| 31 | + for (let m; (m = marker.exec(text)); ) { |
| 32 | + const label = m[1]; |
| 33 | + const endMarker = `\n-----END ${label}-----`; |
| 34 | + const end = text.indexOf(endMarker, m.index); |
| 35 | + // Unterminated, or closed by a different label. Fatal whatever the label |
| 36 | + // is, and deliberately not analyzed further: openssl's base64 decoder |
| 37 | + // treats the next '-' as end-of-data instead of an error, so a torn block |
| 38 | + // yields a valid entry when its truncated body happens to be a complete |
| 39 | + // DER and garbage when it does not. Measured both outcomes from the same |
| 40 | + // tear position with only the body length changed. Since the result is not |
| 41 | + // knowable from out here, a damaged file is refused rather than guessed at. |
| 42 | + if (end === -1) return { ok: false, reason: `unterminated ${label} block` }; |
| 43 | + // Everything else in the file is node's business, not ours. A merged bundle |
| 44 | + // legitimately carries CRLs, public keys and key material alongside the |
| 45 | + // roots; node's loader skips them and verifies fine. Parsing every block as |
| 46 | + // a certificate is what made those bundles unusable — and rejecting the |
| 47 | + // bundle does not fail safe, it drops every sibling and corporate CA for |
| 48 | + // the whole session, which is the failure this contract exists to prevent. |
| 49 | + if (label !== "CERTIFICATE") continue; |
| 50 | + const block = text.slice(m.index, end + endMarker.length); |
| 51 | + let der; |
| 52 | + try { der = new X509Certificate(block).raw; } |
| 53 | + catch { return { ok: false, reason: "undecodable CERTIFICATE block" }; } |
| 54 | + if (der.equals(ourDer)) carriesUs = true; |
| 55 | + } |
| 56 | + // A bundle that exists but predates our publish is WORSE than no bundle: |
| 57 | + // handing it to claude makes the client distrust the very proxy it is routed |
| 58 | + // through, so every request fails TLS rather than merely losing some other |
| 59 | + // component's CA. |
| 60 | + // |
| 61 | + // Matched on DER, and only on a CERTIFICATE block, because neither weaker |
| 62 | + // check is sound. Measured: relabelling our own CA to TRUSTED CERTIFICATE |
| 63 | + // leaves X509Certificate parsing it into byte-identical DER while node's CA |
| 64 | + // loader skips it entirely — the old guard accepted that bundle and the |
| 65 | + // handshake then failed with UNABLE_TO_VERIFY_LEAF_SIGNATURE. That is the |
| 66 | + // exact outcome this check exists to prevent, so the label is load-bearing. |
| 67 | + return carriesUs ? { ok: true } : { ok: false, reason: "bundle does not carry our CA" }; |
| 68 | +} |
0 commit comments