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..953a4c9 --- /dev/null +++ b/npm/code-cli/scripts/pin-checksums.mjs @@ -0,0 +1,48 @@ +#!/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..c2dcf76 --- /dev/null +++ b/npm/code-cli/tests/pinned-checksums.test.mjs @@ -0,0 +1,100 @@ +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/, + ); +});