mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 18:05:29 -04:00
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).
118 lines
4.9 KiB
JavaScript
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) })
|