diff --git a/.changeset/computerd-emudhra-roots.md b/.changeset/computerd-emudhra-roots.md new file mode 100644 index 000000000..24b2fd1d2 --- /dev/null +++ b/.changeset/computerd-emudhra-roots.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computerd": patch +--- + +Build the computerd binary on Node 22.23.4, whose bundled root store no longer trusts the emSign ECC Root CA - G3 and AAA Certificate Services roots behind the compromised eMudhra intermediates. diff --git a/.github/workflows/next-computerd-image.yml b/.github/workflows/next-computerd-image.yml index 87f574965..391d44aac 100644 --- a/.github/workflows/next-computerd-image.yml +++ b/.github/workflows/next-computerd-image.yml @@ -61,11 +61,11 @@ jobs: sudo apt-get install -y --no-install-recommends libfuse2t64 fuse3 # build-bin generates the SEA blob with the active Node executable and - # injects it into the pinned Node 22.22.3 target binary. + # injects it into the pinned Node 22.23.4 target binary. - name: Use the target Node version for the binary build uses: actions/setup-node@v6 with: - node-version: 22.22.3 + node-version: 22.23.4 # computerd's binary build bundles the sibling packages, so their dist/ # directories must exist before build:bin runs. diff --git a/packages/computerd/scripts/build-bin.mjs b/packages/computerd/scripts/build-bin.mjs index 34f561485..ad96464d5 100644 --- a/packages/computerd/scripts/build-bin.mjs +++ b/packages/computerd/scripts/build-bin.mjs @@ -13,8 +13,9 @@ // Everything used at runtime is embedded; the resulting artifact has no // sidecar files. import { execFile } from "node:child_process"; +import { X509Certificate } from "node:crypto"; import { createWriteStream } from "node:fs"; -import { chmod, copyFile, mkdir, rm, stat, writeFile } from "node:fs/promises"; +import { chmod, copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; @@ -30,7 +31,23 @@ const repoRoot = resolve(computerdRoot, "../.."); const outputDir = resolve(repoRoot, "artifacts/computerd"); const seaWorkDir = resolve(computerdRoot, "dist/sea"); const nodeCacheDir = resolve(repoRoot, ".devbox/node-binaries"); -const nodeVersion = "v22.22.3"; +// v22.23.4 is the first 22.x release whose bundled root store drops the +// emSign ECC Root CA - G3 and AAA Certificate Services roots, through which +// eight compromised eMudhra intermediates chain. The SEA binary verifies TLS +// against this bundled store, so an older target would trust certificates +// forged with those intermediates' keys. +const nodeVersion = "v22.23.4"; + +// Subjects of roots the target Node binary must not trust. checkRootStore +// fails the build when a target's bundled store still contains one. +const distrustedRootSubjects = ["CN=emSign ECC Root CA - G3", "CN=AAA Certificate Services"]; + +// Node 22.22.3 bundles 145 roots and 22.23.4 bundles 116. The floor leaves room +// for further removals while failing an extraction that finds no store, or +// only stray PEM text. +const minimumBundledRoots = 100; +const EMBEDDED_PEM_PATTERN = + /-----BEGIN CERTIFICATE-----[A-Za-z0-9+/=\r\n]+?-----END CERTIFICATE-----/g; const targets = [ { @@ -107,6 +124,7 @@ async function buildTarget(target) { await execFileP(process.execPath, ["--experimental-sea-config", configPath]); const nodeBinary = await ensureTargetNodeBinary(target); + await checkRootStore(target, nodeBinary); await copyFile(nodeBinary, outBin); await chmod(outBin, 0o755); @@ -156,6 +174,40 @@ async function ensureTargetNodeBinary(target) { return extractedPath; } +// Fail if the target Node binary's bundled root store contains a distrusted +// root. Node compiles its root store into the executable as PEM text, so the +// store is read from the file rather than by running the binary, which lets +// every target be checked on any build host. A store that cannot be found, or +// that has fewer than minimumBundledRoots certificates, fails the build too: a +// change in how Node embeds the store must not pass as a clean store. +async function checkRootStore(target, nodeBinary) { + const text = (await readFile(nodeBinary)).toString("latin1"); + const blocks = text.match(EMBEDDED_PEM_PATTERN) ?? []; + const subjects = []; + for (const pem of blocks) { + try { + subjects.push(new X509Certificate(pem).subject.split("\n")); + } catch { + // PEM-shaped bytes that are not a certificate are not part of the store. + } + } + if (subjects.length < minimumBundledRoots) { + throw new Error( + `found ${subjects.length} root certificates in Node ${nodeVersion} for ${target.name}; ` + + `expected at least ${minimumBundledRoots}, so its root store could not be checked`, + ); + } + const found = distrustedRootSubjects.filter((needle) => + subjects.some((names) => names.includes(needle)), + ); + if (found.length > 0) { + throw new Error( + `Node ${nodeVersion} for ${target.name} still trusts distrusted roots: ${found.join(", ")}`, + ); + } + console.log(`[computerd-bin] ${target.name}: ${subjects.length} bundled roots, none distrusted`); +} + async function exists(p) { try { await stat(p);