From e9d8b45a3f40e01b44d2eafc7980a59af447ac9e Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Tue, 27 Oct 2026 17:40:10 +0000 Subject: [PATCH 1/2] fix(npm): verify launcher downloads against checksums pinned at publish The launcher only checked each binary against a .sha256 sidecar served from the same GitHub release. A release replaced wholesale would pass that check. At prepublish, scripts/pin-checksums.mjs now hashes every release binary, checks it against its sidecar, and writes checksums.json into the package. The launcher requires the downloaded bytes to match the pinned hash too, and refuses when the pinned file is missing or for a different version. Co-Authored-By: Claude Haiku 5.5 --- npm/code-cli/README.md | 4 +- npm/code-cli/lib/download.mjs | 22 ++++- npm/code-cli/lib/pinned-checksums.mjs | 43 +++++++++ npm/code-cli/lib/platform.mjs | 6 ++ npm/code-cli/package.json | 1 + npm/code-cli/scripts/pin-checksums.mjs | 43 +++++++++ npm/code-cli/scripts/prepublish.sh | 4 + npm/code-cli/tests/pinned-checksums.test.mjs | 96 ++++++++++++++++++++ 8 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 npm/code-cli/lib/pinned-checksums.mjs create mode 100755 npm/code-cli/scripts/pin-checksums.mjs create mode 100644 npm/code-cli/tests/pinned-checksums.test.mjs diff --git a/npm/code-cli/README.md b/npm/code-cli/README.md index ed88f94..ad2bd3a 100644 --- a/npm/code-cli/README.md +++ b/npm/code-cli/README.md @@ -149,7 +149,9 @@ npm test # Dry-run `npm pack` and inspect the published file list npm run pack:dry -# Pre-publish guard: version check + tests +# Pre-publish guard: version check, pin release checksums, tests. +# Publish only after the GitHub release for v exists: the pin step downloads +# each release binary and writes checksums.json into the package. npm run prepublishOnly # Full CI/presubmit checks diff --git a/npm/code-cli/lib/download.mjs b/npm/code-cli/lib/download.mjs index 1611f58..4e7f7ee 100644 --- a/npm/code-cli/lib/download.mjs +++ b/npm/code-cli/lib/download.mjs @@ -10,6 +10,7 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { loadPinnedChecksums, pinnedSha256 } from "./pinned-checksums.mjs"; import { detectTarget, UnsupportedPlatformError } from "./platform.mjs"; export const RELEASE_BASE = "https://github.com/frontal-labs/code/releases/download"; @@ -51,7 +52,7 @@ export async function fetchSha256(version, assetName) { } } -async function downloadTo({ version, destDir, log = () => undefined }) { +async function downloadTo({ version, destDir, log = () => undefined, pinned }) { const { target, assetName, binName } = detectTarget(); const url = releaseAssetUrl(version, assetName); log(`Downloading ${assetName} (${target}) from ${url}`); @@ -72,7 +73,15 @@ async function downloadTo({ version, destDir, log = () => undefined }) { if (!verifySha256(buf, expected)) { throw new Error(`SHA-256 mismatch for ${assetName}`); } - log(`Verified SHA-256 for ${assetName}`); + // The sidecar comes from the same release as the binary, so it cannot catch a release + // that was replaced wholesale. The checksum pinned at publish time can. + if (!verifySha256(buf, pinnedSha256(pinned, version, assetName))) { + throw new Error( + `SHA-256 for ${assetName} does not match the checksum pinned in this npm package. ` + + `The release may have been altered after publish; refusing to install it.`, + ); + } + log(`Verified SHA-256 for ${assetName} against the pinned checksum`); const binDir = join(destDir, "vendor", target, "bin"); mkdirSync(binDir, { recursive: true }); @@ -82,7 +91,12 @@ async function downloadTo({ version, destDir, log = () => undefined }) { } // Public entry point. Returns the resolved binary path under vendor/. -export async function downloadRelease({ version, destDir, log = () => undefined }) { +export async function downloadRelease({ + version, + destDir, + log = () => undefined, + pinned = loadPinnedChecksums(), +}) { if (!version || /dev$/.test(version) || version === "0.0.0") { throw new Error( `Refusing to download for dev version "${version}". Use a tagged release version.`, @@ -90,7 +104,7 @@ export async function downloadRelease({ version, destDir, log = () => undefined } let binPath; try { - binPath = await downloadTo({ version, destDir, log }); + binPath = await downloadTo({ version, destDir, log, pinned }); } catch (e) { if (e instanceof UnsupportedPlatformError) throw e; throw e; diff --git a/npm/code-cli/lib/pinned-checksums.mjs b/npm/code-cli/lib/pinned-checksums.mjs new file mode 100644 index 0000000..649c51e --- /dev/null +++ b/npm/code-cli/lib/pinned-checksums.mjs @@ -0,0 +1,43 @@ +// Checksums pinned into the npm package at publish time. +// +// `checksums.json` is written by `scripts/pin-checksums.mjs` from the release binaries +// during `prepublishOnly`, so it ships inside the npm tarball. The launcher checks a +// downloaded binary against these hashes as well as the release's `.sha256` sidecar. +// A tampered GitHub release that replaces a binary and its sidecar still fails here, +// because the npm package was published from the genuine release. + +import { readFileSync } from "node:fs"; + +export const CHECKSUMS_URL = new URL("../checksums.json", import.meta.url); + +// Load the pinned file. Returns null when it is absent (for example, a source checkout +// that has not been through the publish step). +export function loadPinnedChecksums(url = CHECKSUMS_URL) { + try { + return JSON.parse(readFileSync(url, "utf8")); + } catch (e) { + if (e.code === "ENOENT") return null; + throw e; + } +} + +// Return the pinned SHA-256 for one asset of one version. Throws when the pinned file is +// for another version or has no entry for the asset. +export function pinnedSha256(pinned, version, assetName) { + if (!pinned) { + throw new Error( + `No pinned checksums are bundled with this package, so ${assetName} cannot be verified. ` + + `Reinstall a published release, or build from source with \`cargo build --release -p cli\`.`, + ); + } + if (pinned.version !== version) { + throw new Error( + `Bundled checksums are for v${pinned.version}, not v${version}. Reinstall the package for this version.`, + ); + } + const hex = pinned.assets?.[assetName]; + if (!hex) { + throw new Error(`No pinned SHA-256 for ${assetName} in v${version}.`); + } + return hex; +} diff --git a/npm/code-cli/lib/platform.mjs b/npm/code-cli/lib/platform.mjs index 90d50bf..b00bb6f 100644 --- a/npm/code-cli/lib/platform.mjs +++ b/npm/code-cli/lib/platform.mjs @@ -28,6 +28,12 @@ const TARGETS = { "win32/x64": { target: "x86_64-pc-windows-msvc", ext: ".exe" }, }; +// Every release asset name the launcher can download. The checksum pin script uses this +// list so the packaged checksums cover every platform, not just the one that publishes. +export function allAssetNames() { + return Object.values(TARGETS).map((entry) => `code-cli-${entry.target}${entry.ext}`); +} + // Pure helper: resolve a descriptor from explicit platform/arch. export function targetFor(platform, arch) { const entry = TARGETS[`${platform}/${arch}`]; diff --git a/npm/code-cli/package.json b/npm/code-cli/package.json index 6733066..ad85267 100644 --- a/npm/code-cli/package.json +++ b/npm/code-cli/package.json @@ -26,6 +26,7 @@ "files": [ "bin/index.js", "lib/**/*.mjs", + "checksums.json", "scripts/postinstall.sh", "README.md" ], diff --git a/npm/code-cli/scripts/pin-checksums.mjs b/npm/code-cli/scripts/pin-checksums.mjs new file mode 100755 index 0000000..5a89926 --- /dev/null +++ b/npm/code-cli/scripts/pin-checksums.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +// Writes checksums.json for one release version. Run from prepublishOnly, after the +// GitHub release for `v` exists. +// +// It downloads every platform binary and hashes the bytes. It also checks each hash +// against the release's `.sha256` sidecar, so a mismatch fails the publish instead of +// pinning a bad value. + +import { createHash } from "node:crypto"; +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { fetchSha256, fetchUrl, releaseAssetUrl } from "../lib/download.mjs"; +import { allAssetNames } from "../lib/platform.mjs"; + +export async function pinChecksums(version, { out, fetchBinary = fetchUrl, fetchSidecar = fetchSha256 } = {}) { + const assets = {}; + for (const assetName of allAssetNames()) { + const bytes = await fetchBinary(releaseAssetUrl(version, assetName)); + const actual = createHash("sha256").update(bytes).digest("hex"); + const sidecar = await fetchSidecar(version, assetName); + if (!sidecar) { + throw new Error(`No .sha256 sidecar published for ${assetName} in v${version}.`); + } + if (sidecar.toLowerCase() !== actual) { + throw new Error(`Sidecar for ${assetName} does not match the binary (sidecar ${sidecar}, binary ${actual}).`); + } + assets[assetName] = actual; + } + const pinned = { version, assets }; + if (out) writeFileSync(out, `${JSON.stringify(pinned, null, 2)}\n`); + return pinned; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const version = process.argv[2]; + if (!version || /dev$/.test(version) || version === "0.0.0") { + console.error("usage: pin-checksums.mjs (refuses dev versions)"); + process.exit(1); + } + const out = fileURLToPath(new URL("../checksums.json", import.meta.url)); + await pinChecksums(version, { out }); + console.log(`Pinned ${allAssetNames().length} checksums for v${version} -> checksums.json`); +} diff --git a/npm/code-cli/scripts/prepublish.sh b/npm/code-cli/scripts/prepublish.sh index 9d7026d..4fa16e8 100755 --- a/npm/code-cli/scripts/prepublish.sh +++ b/npm/code-cli/scripts/prepublish.sh @@ -18,5 +18,9 @@ for f in bin/index.js lib/platform.mjs lib/resolve-binary.mjs lib/download.mjs s fi done +# Pin the release binaries' checksums into the package. Requires the GitHub release +# for v$VERSION to exist, so publish after the release workflow has finished. +node ./scripts/pin-checksums.mjs "$VERSION" + echo "==> Pre-publish checks passed (v$VERSION). Running tests." exec ./scripts/test.sh diff --git a/npm/code-cli/tests/pinned-checksums.test.mjs b/npm/code-cli/tests/pinned-checksums.test.mjs new file mode 100644 index 0000000..5632e62 --- /dev/null +++ b/npm/code-cli/tests/pinned-checksums.test.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { downloadRelease } from "../lib/download.mjs"; +import { pinnedSha256 } from "../lib/pinned-checksums.mjs"; +import { allAssetNames, detectTarget } from "../lib/platform.mjs"; +import { pinChecksums } from "../scripts/pin-checksums.mjs"; + +const sha = (buf) => createHash("sha256").update(buf).digest("hex"); +const VERSION = "9.9.9"; + +test("pinnedSha256 refuses when no checksums are bundled", () => { + assert.throws(() => pinnedSha256(null, VERSION, "code-cli-x"), /No pinned checksums/); +}); + +test("pinnedSha256 refuses checksums for a different version", () => { + const pinned = { version: "1.0.0", assets: { "code-cli-x": "aa" } }; + assert.throws(() => pinnedSha256(pinned, VERSION, "code-cli-x"), /are for v1\.0\.0/); +}); + +test("pinnedSha256 refuses an asset with no entry and returns a matching one", () => { + const pinned = { version: VERSION, assets: { "code-cli-x": "abc" } }; + assert.equal(pinnedSha256(pinned, VERSION, "code-cli-x"), "abc"); + assert.throws(() => pinnedSha256(pinned, VERSION, "code-cli-y"), /No pinned SHA-256/); +}); + +test("allAssetNames lists every platform asset once", () => { + const names = allAssetNames(); + assert.equal(new Set(names).size, names.length); + assert.equal(names.length, 5); + assert.ok(names.includes(detectTarget().assetName)); +}); + +test("downloadRelease refuses a binary that matches its sidecar but not the pinned checksum", async () => { + // A release replaced wholesale: the binary and its sidecar agree with each other, but + // not with the checksum pinned when the npm package was published. + const { assetName } = detectTarget(); + const tampered = Buffer.from("tampered binary"); + const realFetch = globalThis.fetch; + globalThis.fetch = async (url) => + String(url).endsWith(".sha256") + ? new Response(`${sha(tampered)} ${assetName}`, { status: 200 }) + : new Response(tampered, { status: 200 }); + try { + const pinned = { version: VERSION, assets: { [assetName]: sha(Buffer.from("genuine")) } }; + await assert.rejects( + downloadRelease({ version: VERSION, destDir: mkdtempSync(join(tmpdir(), "fc-pin-")), pinned }), + /does not match the checksum pinned/, + ); + } finally { + globalThis.fetch = realFetch; + } +}); + +test("downloadRelease installs a binary that matches both the sidecar and the pinned checksum", async () => { + const { assetName } = detectTarget(); + const genuine = Buffer.from("genuine binary"); + const realFetch = globalThis.fetch; + globalThis.fetch = async (url) => + String(url).endsWith(".sha256") + ? new Response(`${sha(genuine)} ${assetName}`, { status: 200 }) + : new Response(genuine, { status: 200 }); + try { + const pinned = { version: VERSION, assets: { [assetName]: sha(genuine) } }; + const binPath = await downloadRelease({ + version: VERSION, + destDir: mkdtempSync(join(tmpdir(), "fc-pin-")), + pinned, + }); + assert.ok(binPath.endsWith(detectTarget().binName)); + } finally { + globalThis.fetch = realFetch; + } +}); + +test("pinChecksums hashes the binaries and rejects a sidecar that disagrees", async () => { + const bytes = Buffer.from("release bytes"); + const ok = await pinChecksums(VERSION, { + fetchBinary: async () => bytes, + fetchSidecar: async () => sha(bytes), + }); + assert.equal(ok.version, VERSION); + assert.equal(Object.keys(ok.assets).length, allAssetNames().length); + for (const hex of Object.values(ok.assets)) assert.equal(hex, sha(bytes)); + + await assert.rejects( + pinChecksums(VERSION, { + fetchBinary: async () => bytes, + fetchSidecar: async () => sha(Buffer.from("other")), + }), + /does not match the binary/, + ); +}); From e90db352666d90e57924b7b8bd980429804a5d86 Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Tue, 27 Oct 2026 17:54:12 +0000 Subject: [PATCH 2/2] style(npm): format pin-checksums script and tests with biome Co-Authored-By: Claude Haiku 5.5 --- npm/code-cli/scripts/pin-checksums.mjs | 9 +++++++-- npm/code-cli/tests/pinned-checksums.test.mjs | 6 +++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/npm/code-cli/scripts/pin-checksums.mjs b/npm/code-cli/scripts/pin-checksums.mjs index 5a89926..953a4c9 100755 --- a/npm/code-cli/scripts/pin-checksums.mjs +++ b/npm/code-cli/scripts/pin-checksums.mjs @@ -12,7 +12,10 @@ import { fileURLToPath } from "node:url"; import { fetchSha256, fetchUrl, releaseAssetUrl } from "../lib/download.mjs"; import { allAssetNames } from "../lib/platform.mjs"; -export async function pinChecksums(version, { out, fetchBinary = fetchUrl, fetchSidecar = fetchSha256 } = {}) { +export async function pinChecksums( + version, + { out, fetchBinary = fetchUrl, fetchSidecar = fetchSha256 } = {}, +) { const assets = {}; for (const assetName of allAssetNames()) { const bytes = await fetchBinary(releaseAssetUrl(version, assetName)); @@ -22,7 +25,9 @@ export async function pinChecksums(version, { out, fetchBinary = fetchUrl, fetch throw new Error(`No .sha256 sidecar published for ${assetName} in v${version}.`); } if (sidecar.toLowerCase() !== actual) { - throw new Error(`Sidecar for ${assetName} does not match the binary (sidecar ${sidecar}, binary ${actual}).`); + throw new Error( + `Sidecar for ${assetName} does not match the binary (sidecar ${sidecar}, binary ${actual}).`, + ); } assets[assetName] = actual; } diff --git a/npm/code-cli/tests/pinned-checksums.test.mjs b/npm/code-cli/tests/pinned-checksums.test.mjs index 5632e62..c2dcf76 100644 --- a/npm/code-cli/tests/pinned-checksums.test.mjs +++ b/npm/code-cli/tests/pinned-checksums.test.mjs @@ -47,7 +47,11 @@ test("downloadRelease refuses a binary that matches its sidecar but not the pinn try { const pinned = { version: VERSION, assets: { [assetName]: sha(Buffer.from("genuine")) } }; await assert.rejects( - downloadRelease({ version: VERSION, destDir: mkdtempSync(join(tmpdir(), "fc-pin-")), pinned }), + downloadRelease({ + version: VERSION, + destDir: mkdtempSync(join(tmpdir(), "fc-pin-")), + pinned, + }), /does not match the checksum pinned/, ); } finally {