mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 09:55:39 -04:00
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).
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { fetchShasumsFile } from '@pnpm/crypto.shasums-file'
|
||||
import { fetchShasumsFile, fetchVerifiedNodeShasumsFile } from '@pnpm/crypto.shasums-file'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import type { FetchFromRegistry } from '@pnpm/fetching.types'
|
||||
import type {
|
||||
@@ -63,7 +63,7 @@ export async function resolveNodeRuntime (
|
||||
if (!version) {
|
||||
throw new PnpmError('NODEJS_VERSION_NOT_FOUND', `Could not find a Node.js version that satisfies ${versionSpec}`)
|
||||
}
|
||||
const variants = await readNodeAssets(ctx.fetchFromRegistry, nodeMirrorBaseUrl, version)
|
||||
const variants = await readNodeAssets(ctx.fetchFromRegistry, nodeMirrorBaseUrl, version, releaseChannel)
|
||||
const range = version === versionSpec ? version : `^${version}`
|
||||
return {
|
||||
id: `node@runtime:${version}` as PkgResolutionId,
|
||||
@@ -96,14 +96,21 @@ export async function resolveLatestNodeRuntime (
|
||||
return { latestManifest: { name: 'node', version } }
|
||||
}
|
||||
|
||||
async function readNodeAssets (fetch: FetchFromRegistry, nodeMirrorBaseUrl: string, version: string): Promise<PlatformAssetResolution[]> {
|
||||
const assets = await readNodeAssetsFromMirror(fetch, { nodeMirrorBaseUrl, version, muslOnly: false })
|
||||
async function readNodeAssets (fetch: FetchFromRegistry, nodeMirrorBaseUrl: string, version: string, releaseChannel: string): Promise<PlatformAssetResolution[]> {
|
||||
// The mirror is repository-configurable, so the SHASUMS file's hashes are only
|
||||
// trustworthy once its OpenPGP signature is verified against the Node.js
|
||||
// release keys embedded in pnpm. Only the `release` channel publishes a signed
|
||||
// SHASUMS256.txt; pre-release channels (rc, nightly, …) are unsigned by Node,
|
||||
// so they cannot be verified this way.
|
||||
const assets = await readNodeAssetsFromMirror(fetch, { nodeMirrorBaseUrl, version, muslOnly: false, verifySignature: releaseChannel === 'release' })
|
||||
|
||||
// When using the default mirror, also fetch musl variants from unofficial-builds.nodejs.org,
|
||||
// since musl builds are not available on the official mirror.
|
||||
// since musl builds are not available on the official mirror. That URL is hardcoded (not
|
||||
// repository-configurable) and signed by a different (unofficial-builds) key, so it is trusted
|
||||
// over TLS rather than verified against the official release keys.
|
||||
if (nodeMirrorBaseUrl === DEFAULT_NODE_MIRROR_BASE_URL) {
|
||||
try {
|
||||
const muslAssets = await readNodeAssetsFromMirror(fetch, { nodeMirrorBaseUrl: UNOFFICIAL_NODE_MIRROR_BASE_URL, version, muslOnly: true })
|
||||
const muslAssets = await readNodeAssetsFromMirror(fetch, { nodeMirrorBaseUrl: UNOFFICIAL_NODE_MIRROR_BASE_URL, version, muslOnly: true, verifySignature: false })
|
||||
assets.push(...muslAssets)
|
||||
} catch {
|
||||
// Musl variants may not be available for all Node.js versions (e.g. very old ones)
|
||||
@@ -119,11 +126,14 @@ async function readNodeAssetsFromMirror (
|
||||
nodeMirrorBaseUrl: string
|
||||
version: string
|
||||
muslOnly: boolean
|
||||
verifySignature: boolean
|
||||
}
|
||||
): Promise<PlatformAssetResolution[]> {
|
||||
const { nodeMirrorBaseUrl, version, muslOnly } = opts
|
||||
const { nodeMirrorBaseUrl, version, muslOnly, verifySignature } = opts
|
||||
const integritiesFileUrl = `${nodeMirrorBaseUrl}v${version}/SHASUMS256.txt`
|
||||
const shasumsFileItems = await fetchShasumsFile(fetch, integritiesFileUrl)
|
||||
const shasumsFileItems = verifySignature
|
||||
? await fetchVerifiedNodeShasumsFile(fetch, integritiesFileUrl)
|
||||
: await fetchShasumsFile(fetch, integritiesFileUrl)
|
||||
const escaped = version.replace(/\\/g, '\\\\').replace(/\./g, '\\.')
|
||||
// The second capture group uses [^.-]+ to stop at a dash, so that the optional
|
||||
// third group can capture the '-musl' suffix separately (e.g. 'x64' + '-musl').
|
||||
|
||||
Reference in New Issue
Block a user