Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/computerd-emudhra-roots.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions .github/workflows/next-computerd-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
56 changes: 54 additions & 2 deletions packages/computerd/scripts/build-bin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 = [
{
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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"));
Comment on lines +184 to +189

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Cross-target root checks lack tests

The new checkRootStore extraction has no tests for either target or for rejecting distrusted roots. This violates the repository's requirement to test new behavior.

Learn more

The build validates bundled certificates for Linux and macOS by reading each target's executable. Its new extraction and parsing branch is not exercised by any test. A passing build today does not protect this safeguard against a later regression. COLLABORATORS.md requires tests for new behavior.

Example: A fixture with 116 valid certificates plus CN=AAA Certificate Services must make checkRootStore reject the build; a fixture with fewer than 100 certificates must also fail. Neither case currently has a regression test.

Recommended fix: Extract checkRootStore into a testable module and add fixtures or generated test binaries covering clean stores, both prohibited subjects, malformed PEM blocks, and a store below minimumBundledRoots. Verify that buildTarget invokes it for both targets.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

} 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);
Expand Down
Loading