From 7a21e2c7e0f90fa52b987719df7a812b7225fa82 Mon Sep 17 00:00:00 2001 From: Aron <263346377+aron-cf@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:39:50 +0000 Subject: [PATCH 1/2] computerd: Build the binary on Node 22.23.4 Eight eMudhra intermediate CAs were issued with keys anyone can recover from the certificates. They chain to emSign ECC Root CA - G3 and, through Sectigo cross-signs, to AAA Certificate Services. Node 22.22.3, which the computerd binary was built on, still ships emSign ECC Root CA - G3 in its bundled root store, and the binary verifies TLS against that store. A client in computerd would therefore accept a certificate forged with one of the compromised keys. Node 22.23.4 drops both roots and rejects the intermediates. Pin the binary build and the next-image workflow to it. build-bin now runs the target Node binary before injecting the SEA blob and fails when its bundled store still contains either root, so a later pin to an unpatched release cannot reintroduce them silently. The check runs only for the target matching the build host; the other target shares the same Node version. --- .changeset/computerd-emudhra-roots.md | 5 +++ .github/workflows/next-computerd-image.yml | 4 +-- packages/computerd/scripts/build-bin.mjs | 40 +++++++++++++++++++++- 3 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 .changeset/computerd-emudhra-roots.md 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..1c4ee10eb 100644 --- a/packages/computerd/scripts/build-bin.mjs +++ b/packages/computerd/scripts/build-bin.mjs @@ -30,7 +30,16 @@ 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 the target's bundled store still contains one. +const distrustedRootSubjects = ["CN=emSign ECC Root CA - G3", "CN=AAA Certificate Services"]; const targets = [ { @@ -107,6 +116,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 +166,34 @@ async function ensureTargetNodeBinary(target) { return extractedPath; } +// Run the target Node binary and fail if its bundled root store contains a +// distrusted root. Only a target matching the build host can be executed, so +// other targets are skipped; they share nodeVersion and so the same store. +async function checkRootStore(target, nodeBinary) { + const runnable = + process.arch === "x64" && process.platform === (target.isMac ? "darwin" : "linux"); + if (!runnable) { + console.warn(`[computerd-bin] cannot run ${target.name} Node here; root store not checked`); + return; + } + const script = ` + const { rootCertificates } = require("node:tls"); + const { X509Certificate } = require("node:crypto"); + const subjects = rootCertificates.map((pem) => new X509Certificate(pem).subject); + process.stdout.write(JSON.stringify(subjects)); + `; + const { stdout } = await execFileP(nodeBinary, ["-e", script]); + const subjects = JSON.parse(stdout); + const found = distrustedRootSubjects.filter((needle) => + subjects.some((subject) => subject.split("\n").includes(needle)), + ); + if (found.length > 0) { + throw new Error( + `Node ${nodeVersion} for ${target.name} still trusts distrusted roots: ${found.join(", ")}`, + ); + } +} + async function exists(p) { try { await stat(p); From 99dbaf34bad67f90e610771f00dd229789927add Mon Sep 17 00:00:00 2001 From: Aron <263346377+aron-cf@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:01:40 +0000 Subject: [PATCH 2/2] computerd: Check every target's root store, not only the host's build-bin checked a target's bundled root store by running its Node binary, so it could only check the target matching the build host. On Linux the macOS binary shipped unchecked, and on macOS the Linux one did. Node compiles its root store into the executable as PEM text, so read the store from the file instead. Every target is now checked on any build host. Finding fewer than 100 certificates also fails the build, so a change in how Node embeds the store cannot pass as a clean store. --- packages/computerd/scripts/build-bin.mjs | 52 +++++++++++++++--------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/packages/computerd/scripts/build-bin.mjs b/packages/computerd/scripts/build-bin.mjs index 1c4ee10eb..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"; @@ -38,9 +39,16 @@ const nodeCacheDir = resolve(repoRoot, ".devbox/node-binaries"); const nodeVersion = "v22.23.4"; // Subjects of roots the target Node binary must not trust. checkRootStore -// fails the build when the target's bundled store still contains one. +// 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 = [ { name: "linux-x64", @@ -166,32 +174,38 @@ async function ensureTargetNodeBinary(target) { return extractedPath; } -// Run the target Node binary and fail if its bundled root store contains a -// distrusted root. Only a target matching the build host can be executed, so -// other targets are skipped; they share nodeVersion and so the same store. +// 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 runnable = - process.arch === "x64" && process.platform === (target.isMac ? "darwin" : "linux"); - if (!runnable) { - console.warn(`[computerd-bin] cannot run ${target.name} Node here; root store not checked`); - return; + 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 script = ` - const { rootCertificates } = require("node:tls"); - const { X509Certificate } = require("node:crypto"); - const subjects = rootCertificates.map((pem) => new X509Certificate(pem).subject); - process.stdout.write(JSON.stringify(subjects)); - `; - const { stdout } = await execFileP(nodeBinary, ["-e", script]); - const subjects = JSON.parse(stdout); const found = distrustedRootSubjects.filter((needle) => - subjects.some((subject) => subject.split("\n").includes(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) {