Files
pnpm/crypto/shasums-file/scripts/update-node-release-keys.mjs
Zoltan Kochan 3d50680eda fix(security): verify Node.js runtime SHASUMS OpenPGP signature (#12295)
Follow-up to #12292 (which verifies the **package-manager** binary). This closes the same class of gap for the **Node.js runtime**.

When a repository requests a Node.js runtime — `devEngines.runtime: node@X` (with `onFail: download`, the default) or `useNodeVersion` — pnpm downloads and then executes a Node binary (it's used to run lifecycle / `run` / `exec` scripts). The download **mirror is repository-configurable** via `node-mirror:<channel>` (`nodeDownloadMirrors`) in project `.npmrc`, and the integrity comes from `SHASUMS256.txt` fetched **from that same mirror**.

That's a circular check: a malicious mirror serves a tampered `node` tarball **and** a matching `SHASUMS256.txt`, the sha256 check passes, and pnpm runs the binary. Drive-by on a normal command in a cloned repo.

## Fix

pnpm now fetches `SHASUMS256.txt.sig` and verifies its **detached OpenPGP signature** against the **Node.js release team's public keys, embedded in the pnpm CLI**, before trusting the hashes. A mirror that serves a tampered binary cannot also produce a valid signature, so verification fails. Any faithful mirror (one that proxies the real signed SHASUMS) keeps working.

- `@pnpm/crypto.shasums-file`: new `fetchVerifiedNodeShasums` / `fetchVerifiedNodeShasumsFile` verify the signature via `openpgp` against the embedded keys.
- The keys live in a generated file (`src/nodeReleaseKeys.ts`, 28 keys) mirrored from the canonical `nodejs/release-keys` list. `crypto/shasums-file/scripts/update-node-release-keys.mjs` keeps them current (`pnpm check:node-release-keys` / `--update`), and the **create-release-pr** workflow runs the check as a gate so a new release signer can't silently break verification.
- `@pnpm/engine.runtime.node-resolver` verifies the **configurable-mirror** SHASUMS. The hardcoded `unofficial-builds.nodejs.org` musl mirror is **not** repo-configurable and is signed by a different key, so it stays trusted over TLS.

## Scope

- **Pre-release channels (rc, nightly, …) are not verified** — Node only signs the `release` channel (no `SHASUMS256.txt.sig` exists for them, even on nodejs.org), so they remain unverifiable. Verification is gated on the `release` channel.
- **Bun / Deno are unaffected** — their download/SHASUMS URLs are hardcoded to canonical GitHub (`github.com/oven-sh/bun`, `api.github.com/repos/denoland/deno`), not mirror-configurable, so a repo can't redirect them.
- **Pacquet parity:** `pacquet/crates/engine-runtime-node-resolver` has the same mirror-configurable SHASUMS logic and needs the equivalent Rust port — tracked as a follow-up (per the repo's parity rule, opening the TS side first).
2026-06-10 00:33:31 +02:00

118 lines
4.9 KiB
JavaScript

#!/usr/bin/env node
// Mirrors the Node.js release team's OpenPGP public keys (used to verify the
// signature of SHASUMS256.txt) from the canonical nodejs/release-keys repo into
// src/nodeReleaseKeys.ts and pacquet's matching Rust key module.
//
// node update-node-release-keys.mjs # check (CI / release gate)
// node update-node-release-keys.mjs --update # rewrite the embedded keys
//
// `--check` fails when the authoritative keys.list contains a fingerprint that
// is not embedded, so a newly added release signer cannot silently break Node
// runtime verification.
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const RAW = 'https://raw.githubusercontent.com/nodejs/release-keys/main'
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const TS_KEYS_FILE = path.join(ROOT, 'crypto', 'shasums-file', 'src', 'nodeReleaseKeys.ts')
const RUST_KEYS_FILE = path.join(ROOT, 'pacquet', 'crates', 'crypto-shasums-file', 'src', 'node_release_keys.rs')
async function main () {
const update = process.argv.includes('--update')
const fingerprints = (await (await fetchOk(`${RAW}/keys.list`)).text())
.split('\n').map((l) => l.trim()).filter(Boolean)
const embedded = [
readEmbeddedFingerprints(TS_KEYS_FILE, /fingerprint: '([0-9A-F]+)'/g),
readEmbeddedFingerprints(RUST_KEYS_FILE, /fingerprint: "([0-9A-F]+)"/g),
]
const missing = embedded.flatMap(({ label, fingerprints: embeddedFingerprints }) =>
fingerprints.filter((fp) => !embeddedFingerprints.includes(fp)).map((fp) => ({ label, fp }))
)
// Keys embedded here but no longer in the canonical list (e.g. revoked/rotated)
// must NOT stay in the trust set, so they fail the check too.
const extra = embedded.flatMap(({ label, fingerprints: embeddedFingerprints }) =>
embeddedFingerprints.filter((fp) => !fingerprints.includes(fp)).map((fp) => ({ label, fp }))
)
if (!update) {
if (missing.length === 0 && extra.length === 0) {
console.log(`✓ Embedded Node.js release keys are up to date (${fingerprints.length} key(s)).`)
return
}
console.error('✗ Embedded Node.js release keys are out of sync with nodejs/release-keys.')
if (missing.length > 0) console.error(` Missing (add): ${formatDrift(missing)}`)
if (extra.length > 0) console.error(` No longer canonical (remove — possibly revoked): ${formatDrift(extra)}`)
console.error(`Run: node ${path.relative(process.cwd(), fileURLToPath(import.meta.url))} --update`)
process.exit(1)
}
const keys = []
for (const fp of fingerprints) {
const armored = (await (await fetchOk(`${RAW}/keys/${fp}.asc`)).text()).trim()
keys.push({ fingerprint: fp, armored })
}
fs.writeFileSync(TS_KEYS_FILE, renderTypeScript(keys))
fs.writeFileSync(RUST_KEYS_FILE, renderRust(keys))
console.log(`✓ Wrote ${keys.length} Node.js release key(s).`)
}
async function fetchOk (url) {
const res = await fetch(url)
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`)
return res
}
function readEmbeddedFingerprints (file, pattern) {
const label = path.relative(ROOT, file)
if (!fs.existsSync(file)) return { label, fingerprints: [] }
return {
label,
fingerprints: [...fs.readFileSync(file, 'utf8').matchAll(pattern)].map((m) => m[1]),
}
}
function formatDrift (items) {
return items.map(({ label, fp }) => `${label}: ${fp}`).join(', ')
}
function renderTypeScript (keys) {
const entries = keys.map(({ fingerprint, armored }) =>
` {\n fingerprint: '${fingerprint}',\n armoredKey: ${JSON.stringify(`${armored}\n`)},\n },`).join('\n')
return `/* eslint-disable */
// cspell:disable
// GENERATED — the Node.js release team's OpenPGP public keys, mirrored from
// <https://github.com/nodejs/release-keys> (keys.list + keys/<fingerprint>.asc).
//
// Used to verify the signature of a Node.js release's SHASUMS256.txt before
// trusting its hashes. Refresh with:
// node crypto/shasums-file/scripts/update-node-release-keys.mjs --update
export const NODE_RELEASE_KEYS = [
${entries}
] as const satisfies ReadonlyArray<{ fingerprint: string, armoredKey: string }>
`
}
function renderRust (keys) {
const entries = keys.map(({ fingerprint, armored }) =>
` NodeReleaseKey {\n fingerprint: "${fingerprint}",\n armored_key: r#"${armored}\n"#,\n },`).join('\n')
return `// GENERATED - the Node.js release team's OpenPGP public keys, mirrored from
// https://github.com/nodejs/release-keys (keys.list + keys/<fingerprint>.asc).
//
// Used to verify the signature of a Node.js release's SHASUMS256.txt before
// trusting its hashes. Refresh with:
// node crypto/shasums-file/scripts/update-node-release-keys.mjs --update
pub(crate) struct NodeReleaseKey {
pub(crate) fingerprint: &'static str,
pub(crate) armored_key: &'static str,
}
pub(crate) const NODE_RELEASE_KEYS: &[NodeReleaseKey] = &[
${entries}
];
`
}
main().catch((err) => { console.error(err); process.exit(1) })