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:
Zoltan Kochan
2026-06-10 00:33:31 +02:00
committed by GitHub
parent 822beb5fa0
commit 3d50680eda
26 changed files with 4157 additions and 27 deletions

View File

@@ -1,3 +1,3 @@
[alias]
# Do not append `--` or it will break IDEs
codecov = "llvm-cov nextest --workspace --ignore-filename-regex tasks"
codecov = "llvm-cov nextest --workspace --exclude pacquet-integrated-benchmark --exclude pacquet-micro-benchmark --exclude pacquet-registry-mock --ignore-filename-regex tasks"

View File

@@ -0,0 +1,13 @@
---
"@pnpm/crypto.shasums-file": minor
"@pnpm/engine.runtime.node-resolver": patch
"pnpm": patch
---
Security: pnpm now verifies the OpenPGP signature of a downloaded Node.js runtime's `SHASUMS256.txt` before trusting its integrity hashes.
When a repository requests a Node.js runtime (e.g. via `devEngines.runtime` / `useNodeVersion`), the download mirror is repository-configurable through `node-mirror:<channel>`. The integrity of the downloaded binary was only checked against `SHASUMS256.txt` fetched from that same mirror — a circular check that a malicious mirror could satisfy by serving a tampered binary together with a matching `SHASUMS256.txt`. pnpm then executes the binary (for example to run lifecycle scripts).
pnpm now fetches `SHASUMS256.txt.sig` and verifies the detached OpenPGP signature against the Node.js release team's public keys, which ship embedded in the pnpm CLI. A mirror that serves a tampered binary cannot also produce a valid signature, so the download fails to verify. The embedded keys are kept current by a release-time check against the canonical `nodejs/release-keys` list.
The musl variants from the hardcoded `unofficial-builds.nodejs.org` mirror are not repository-configurable and are signed by a different key, so they continue to be trusted over TLS.

View File

@@ -57,6 +57,13 @@ jobs:
- name: Check embedded npm signing keys are up to date
run: node deps/security/signatures/scripts/update-npm-signing-keys.mjs
# Fail the release if the embedded Node.js release keys (used to verify the
# signature of a downloaded runtime's SHASUMS256.txt) have drifted from the
# canonical nodejs/release-keys list, so a new release signer cannot silently
# break Node.js runtime verification.
- name: Check embedded Node.js release keys are up to date
run: node crypto/shasums-file/scripts/update-node-release-keys.mjs
# Consumes the pending changesets: bumps versions, writes changelogs, updates
# the ledger, and syncs manifests. A no-op (no pending changesets) leaves the
# tree clean and the steps below skip.

View File

@@ -3,7 +3,9 @@
# typos
[files]
extend-exclude = []
extend-exclude = [
"pacquet/crates/crypto-shasums-file/src/node_release_keys.rs",
]
[default.extend-words]
cafs = "cafs"

1078
Cargo.lock generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -130,6 +130,7 @@ reqwest = { version = "0.13", default-features = false, features = [
node-semver = { version = "2.2.0" }
pathdiff = { version = "0.2.3" }
pipe-trait = { version = "0.4.0" }
pgp = { version = "0.19.0", default-features = false }
rayon = { version = "1.12.0" }
rmp-serde = { version = "1.3.0" }
rusqlite = { version = "0.39.0", features = ["bundled"] }

View File

@@ -35,10 +35,12 @@
"dependencies": {
"@pnpm/crypto.hash": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/fetching.types": "workspace:*"
"@pnpm/fetching.types": "workspace:*",
"openpgp": "catalog:"
},
"devDependencies": {
"@jest/globals": "catalog:",
"@openpgp/web-stream-tools": "catalog:",
"@pnpm/crypto.shasums-file": "workspace:*"
},
"engines": {

View File

@@ -0,0 +1,117 @@
#!/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) })

View File

@@ -3,6 +3,10 @@ import type {
FetchFromRegistry,
} from '@pnpm/fetching.types'
import { fetchVerifiedNodeShasums } from './verifyNodeShasums.js'
export { fetchVerifiedNodeShasums }
export interface ShasumsFileItem {
integrity: string
fileName: string
@@ -12,7 +16,23 @@ export async function fetchShasumsFile (
fetch: FetchFromRegistry,
shasumsUrl: string
): Promise<ShasumsFileItem[]> {
const shasumsFileContent = await fetchShasumsFileRaw(fetch, shasumsUrl)
return parseShasumsFile(await fetchShasumsFileRaw(fetch, shasumsUrl))
}
/**
* Like {@link fetchShasumsFile}, but first verifies the SHASUMS file's detached
* OpenPGP signature against the Node.js release keys (see
* {@link fetchVerifiedNodeShasums}). Use this whenever the SHASUMS file is
* fetched from a repository-configurable Node.js mirror.
*/
export async function fetchVerifiedNodeShasumsFile (
fetch: FetchFromRegistry,
shasumsUrl: string
): Promise<ShasumsFileItem[]> {
return parseShasumsFile(await fetchVerifiedNodeShasums(fetch, shasumsUrl))
}
export function parseShasumsFile (shasumsFileContent: string): ShasumsFileItem[] {
const lines = shasumsFileContent.split('\n')
const items: ShasumsFileItem[] = []
for (const line of lines) {

View File

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,120 @@
import { PnpmError } from '@pnpm/error'
import type { FetchFromRegistry } from '@pnpm/fetching.types'
import * as openpgp from 'openpgp'
import { NODE_RELEASE_KEYS } from './nodeReleaseKeys.js'
export interface ArmoredKey { armoredKey: string }
let bundledKeyPacketsPromise: Promise<openpgp.AnyKeyPacket[]> | undefined
async function loadSigningKeyPackets (trustedKeys: readonly ArmoredKey[]): Promise<openpgp.AnyKeyPacket[]> {
if (trustedKeys === NODE_RELEASE_KEYS) {
bundledKeyPacketsPromise ??= readSigningKeyPackets(NODE_RELEASE_KEYS)
return bundledKeyPacketsPromise
}
return readSigningKeyPackets(trustedKeys)
}
async function readSigningKeyPackets (trustedKeys: readonly ArmoredKey[]): Promise<openpgp.AnyKeyPacket[]> {
const keys = await Promise.all(trustedKeys.map(({ armoredKey }) => openpgp.readKey({ armoredKey })))
// A signature may be made by the primary key or any subkey; collect both.
return keys.flatMap((key) => [key.keyPacket, ...key.subkeys.map((subkey) => subkey.keyPacket)])
}
/**
* Fetches a Node.js release's `SHASUMS256.txt` and verifies its detached
* OpenPGP signature (`SHASUMS256.txt.sig`) against the Node.js release team's
* embedded public keys before returning its content.
*
* The download mirror is repository-configurable (`node-mirror:<channel>`), so
* the SHASUMS file — and the integrity hashes it carries — cannot be trusted on
* their own. Verifying the signature against keys embedded in pnpm anchors the
* download to the real Node.js release team: a mirror serving a tampered binary
* with a matching SHASUMS cannot also produce a valid signature.
*
* The signature is verified at the packet level (the cryptographic check),
* deliberately bypassing OpenPGP key-validity-window checks: the trusted keys
* are pinned (mirrored from `nodejs/release-keys`), and the Node.js release keys
* are re-certified over time, which would otherwise make signatures on older
* releases fail to validate against the current key material.
*
* Throws when the signature is missing or does not verify against a trusted key.
*/
export async function fetchVerifiedNodeShasums (
fetch: FetchFromRegistry,
shasumsUrl: string,
trustedKeys: readonly ArmoredKey[] = NODE_RELEASE_KEYS
): Promise<string> {
const [shasumsBytes, signatureBytes] = await Promise.all([
fetchBytes(fetch, shasumsUrl, 'SHASUMS256.txt'),
fetchBytes(fetch, `${shasumsUrl}.sig`, 'SHASUMS256.txt.sig'),
])
if (!(await isSignedByTrustedKey(shasumsBytes, signatureBytes, trustedKeys))) {
throw new PnpmError(
'NODE_SHASUMS_SIGNATURE_INVALID',
`The OpenPGP signature of ${shasumsUrl} does not match any trusted Node.js release key. ` +
'The downloaded Node.js runtime cannot be verified as a genuine release.'
)
}
return Buffer.from(shasumsBytes).toString('utf8')
}
async function isSignedByTrustedKey (
content: Uint8Array,
signatureBytes: Uint8Array,
trustedKeys: readonly ArmoredKey[]
): Promise<boolean> {
let signature: openpgp.Signature
let keyPackets: openpgp.AnyKeyPacket[]
try {
;[signature, keyPackets] = await Promise.all([
openpgp.readSignature({ binarySignature: signatureBytes }),
loadSigningKeyPackets(trustedKeys),
])
} catch (err: unknown) {
throw new PnpmError('NODE_SHASUMS_SIGNATURE_INVALID', `Could not read the Node.js SHASUMS signature: ${String(err)}`)
}
const message = await openpgp.createMessage({ binary: content })
const literalDataPacket = message.packets[0]
const perSignature = await Promise.all(
signature.packets.map((signaturePacket) =>
signaturePacketVerifies(signaturePacket, keyPackets, literalDataPacket))
)
return perSignature.some(Boolean)
}
async function signaturePacketVerifies (
signaturePacket: openpgp.SignaturePacket,
keyPackets: openpgp.AnyKeyPacket[],
literalDataPacket: object
): Promise<boolean> {
const issuerKeyID = signaturePacket.issuerKeyID
if (issuerKeyID == null) return false
const keyPacket = keyPackets.find((packet) => packet.getKeyID().equals(issuerKeyID))
if (keyPacket == null) return false
try {
// Resolves on a valid signature, rejects otherwise. This is the raw
// cryptographic check against a pinned key — no web-of-trust / key-expiry
// evaluation, which is what `openpgp.verify` would (incorrectly here) apply.
await signaturePacket.verify(keyPacket, signaturePacket.signatureType!, literalDataPacket, signaturePacket.created ?? undefined, true)
return true
} catch {
// Not valid under this key/packet.
return false
}
}
async function fetchBytes (fetch: FetchFromRegistry, url: string, what: string): Promise<Uint8Array> {
const res = await fetch(url)
if (!res.ok) {
throw new PnpmError(
'NODE_SHASUMS_FETCH_FAIL',
`Failed to fetch ${what} (${url}) to verify the Node.js download (status: ${res.status})`
)
}
return new Uint8Array(await res.arrayBuffer())
}

View File

@@ -0,0 +1,67 @@
import { expect, test } from '@jest/globals'
import { fetchVerifiedNodeShasums } from '@pnpm/crypto.shasums-file'
import * as openpgp from 'openpgp'
const SHASUMS_URL = 'https://nodejs.example.test/download/release/v22.11.0/SHASUMS256.txt'
const SHASUMS = 'deadbeef'.repeat(8) + ' node-v22.11.0-darwin-arm64.tar.gz\n'
async function makeKey () {
const { privateKey, publicKey } = await openpgp.generateKey({
userIDs: [{ name: 'Test Node Releaser', email: 'test@nodejs.example' }],
format: 'armored',
})
return { privateKey: await openpgp.readPrivateKey({ armoredKey: privateKey }), armoredKey: publicKey }
}
async function detachedSig (privateKey: openpgp.PrivateKey, content: string): Promise<Uint8Array> {
const message = await openpgp.createMessage({ binary: new TextEncoder().encode(content) })
return openpgp.sign({ message, signingKeys: privateKey, detached: true, format: 'binary' }) as Promise<Uint8Array>
}
function mockFetch (responses: Record<string, { ok: boolean, body?: Uint8Array }>) {
return (async (url: string) => {
const res = responses[url]
if (!res) return { ok: false, status: 404 }
return { ok: res.ok, status: res.ok ? 200 : 404, arrayBuffer: async () => res.body!.buffer }
}) as never
}
test('returns the SHASUMS content when the detached signature verifies against a trusted key', async () => {
const key = await makeKey()
const fetch = mockFetch({
[SHASUMS_URL]: { ok: true, body: new TextEncoder().encode(SHASUMS) },
[`${SHASUMS_URL}.sig`]: { ok: true, body: await detachedSig(key.privateKey, SHASUMS) },
})
await expect(fetchVerifiedNodeShasums(fetch, SHASUMS_URL, [key])).resolves.toBe(SHASUMS)
})
test('throws when the signature was made by an untrusted key', async () => {
const signer = await makeKey()
const trusted = await makeKey()
const fetch = mockFetch({
[SHASUMS_URL]: { ok: true, body: new TextEncoder().encode(SHASUMS) },
[`${SHASUMS_URL}.sig`]: { ok: true, body: await detachedSig(signer.privateKey, SHASUMS) },
})
await expect(fetchVerifiedNodeShasums(fetch, SHASUMS_URL, [trusted])).rejects.toThrow(/signature/i)
})
test('throws when the SHASUMS content was tampered with after signing', async () => {
const key = await makeKey()
const fetch = mockFetch({
[SHASUMS_URL]: { ok: true, body: new TextEncoder().encode(SHASUMS.replace('deadbeef', 'tampered0')) },
[`${SHASUMS_URL}.sig`]: { ok: true, body: await detachedSig(key.privateKey, SHASUMS) },
})
await expect(fetchVerifiedNodeShasums(fetch, SHASUMS_URL, [key])).rejects.toThrow(/signature/i)
})
test('throws when the signature file is missing', async () => {
const key = await makeKey()
const fetch = mockFetch({
[SHASUMS_URL]: { ok: true, body: new TextEncoder().encode(SHASUMS) },
})
await expect(fetchVerifiedNodeShasums(fetch, SHASUMS_URL, [key])).rejects.toThrow(/SHASUMS256.txt.sig/)
})

View File

@@ -1,4 +1,9 @@
{
"ignorePaths": [
"**/nodeReleaseKeys.ts",
"**/nodeReleaseKeys.d.ts",
"**/node_release_keys.rs"
],
"words": [
"adduser",
"adipiscing",
@@ -217,6 +222,7 @@
"ofjergrg",
"onclickoutside",
"oomol",
"openpgp",
"ossl",
"outfile",
"overrider",
@@ -345,6 +351,8 @@
"subdeps",
"subdir",
"subdirs",
"subkey",
"subkeys",
"subpkg",
"subresource",
"supercede",

View File

@@ -22,6 +22,11 @@ unmaintained = "workspace"
# yanked-crates check: "deny" | "warn" | "allow"
yanked = "warn"
ignore = [
# `pgp` pulls in `rsa` to verify OpenPGP signatures made by RSA Node.js
# release keys. Pacquet only performs public-key signature verification here;
# it never handles RSA private keys, so the private-key timing side channel
# described by this advisory is not reachable through this use.
{ id = "RUSTSEC-2023-0071", reason = "Only public-key OpenPGP signature verification is performed for pinned Node.js release keys; no RSA private-key operation is exposed." },
# hickory-proto 0.25.2 is pulled in transitively through reqwest 0.13.x ->
# hickory-resolver 0.25.x. The reqwest 0.13 line has not migrated to
# hickory-proto 0.26, so `cargo update` cannot resolve either advisory; the

View File

@@ -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').

View File

@@ -28,6 +28,8 @@
"make-release-description": "pn --filter=@pnpm/get-release-text run write-release-text",
"check:npm-signing-keys": "node deps/security/signatures/scripts/update-npm-signing-keys.mjs",
"update:npm-signing-keys": "node deps/security/signatures/scripts/update-npm-signing-keys.mjs --update",
"check:node-release-keys": "node crypto/shasums-file/scripts/update-node-release-keys.mjs",
"update:node-release-keys": "node crypto/shasums-file/scripts/update-node-release-keys.mjs --update",
"release": "pn --filter=@pnpm/exe run build-artifacts && pn --filter=@pnpm/exe publish --tag=next-11 --access=public --provenance && pn publish --filter=!pnpm --filter=!@pnpm/exe --access=public --provenance && pn publish --filter=pnpm --tag=next-11 --access=public --provenance",
"dev-setup": "pn -C=./pnpm/dev link -g"
},

View File

@@ -38,7 +38,7 @@
use assert_cmd::prelude::*;
use command_extra::CommandExtra;
use pacquet_testing_utils::bin::{AddMockedRegistry, CommandTempCwd};
use std::{fs, path::Path, process::Command};
use std::{fs, path::Path, process::Command, thread::sleep, time::Duration};
fn pacquet_at(workspace: &Path) -> Command {
Command::cargo_bin("pacquet").expect("find the pacquet binary").with_current_dir(workspace)
@@ -127,6 +127,10 @@ fn remote_tarball_integrity_survives_unrelated_install() {
// Install an unrelated package. This rewrites the lockfile while the
// tarball dependency is re-resolved — the exact
// <https://github.com/pnpm/pnpm/issues/12001> trigger.
// Ensure the manifest mtime is observably newer than the first
// install's workspace-state validation timestamp; otherwise the
// optimistic repeat-install shortcut can legitimately skip resolution.
sleep(Duration::from_millis(20));
fs::write(
&manifest_path,
serde_json::json!({

View File

@@ -16,9 +16,11 @@ pacquet-network = { workspace = true }
base64 = { workspace = true }
derive_more = { workspace = true }
miette = { workspace = true }
pgp = { workspace = true }
reqwest = { workspace = true }
[dev-dependencies]
mockito = { workspace = true }
pretty_assertions = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] }

View File

@@ -8,21 +8,32 @@
//! `sha256-<base64>` integrity string the lockfile records on the
//! emitted `BinaryResolution`.
//!
//! Two surfaces:
//! Three surfaces:
//!
//! - [`fetch_shasums_file`] — download and parse every row at once.
//! The node-resolver and bun-resolver fan the parsed rows out across
//! every artifact a release ships.
//! - [`fetch_verified_node_shasums_file`] — download a Node.js release
//! SHASUMS file, verify its detached OpenPGP signature against the
//! embedded Node.js release keys, then parse the trusted body.
//! - [`pick_file_checksum_from_shasums_file`] — re-parse a previously
//! downloaded body to extract the integrity of a single file. The
//! verifier path uses it when only one variant's hash is needed.
use std::sync::Arc;
mod node_release_keys;
use std::{io::Cursor, string::FromUtf8Error, sync::Arc};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
use derive_more::{Display, Error};
use miette::Diagnostic;
use pacquet_network::ThrottledClient;
use pgp::{
composed::{Deserializable, DetachedSignature, SignedPublicKey},
types::KeyDetails,
};
use node_release_keys::{NODE_RELEASE_KEYS, NodeReleaseKey};
/// One row parsed out of a `SHASUMS256.txt` body.
///
@@ -55,6 +66,74 @@ pub enum FetchShasumsFileError {
},
}
/// Errors raised by [`fetch_verified_node_shasums`] and
/// [`fetch_verified_node_shasums_file`].
///
/// Mirrors pnpm's `NODE_SHASUMS_FETCH_FAIL` and
/// `NODE_SHASUMS_SIGNATURE_INVALID` codes. These are specific to
/// Node.js runtime verification, where a repository-configurable
/// mirror cannot be trusted to supply both the binary and the hash
/// list unchecked.
#[derive(Debug, Display, Error, Diagnostic)]
pub enum FetchVerifiedNodeShasumsError {
#[display("Failed to fetch {what} ({url}) to verify the Node.js download (status: {status})")]
#[diagnostic(code(NODE_SHASUMS_FETCH_FAIL))]
StatusNotOk {
#[error(not(source))]
what: &'static str,
#[error(not(source))]
url: String,
status: u16,
},
#[display("Failed to fetch {what} ({url}) to verify the Node.js download")]
#[diagnostic(code(NODE_SHASUMS_FETCH_FAIL))]
Network {
#[error(not(source))]
what: &'static str,
#[error(not(source))]
url: String,
#[error(source)]
error: Arc<reqwest::Error>,
},
#[display("Could not read the Node.js SHASUMS signature: {error}")]
#[diagnostic(code(NODE_SHASUMS_SIGNATURE_INVALID))]
SignatureUnreadable {
#[error(source)]
error: Arc<pgp::errors::Error>,
},
#[display("The verified Node.js SHASUMS file at {url} is not valid UTF-8")]
#[diagnostic(code(NODE_SHASUMS_SIGNATURE_INVALID))]
InvalidUtf8 {
#[error(not(source))]
url: String,
#[error(source)]
error: Arc<FromUtf8Error>,
},
#[display(
"Embedded Node.js release key fingerprint mismatch: expected {expected}, got {actual}"
)]
#[diagnostic(code(NODE_SHASUMS_SIGNATURE_INVALID))]
EmbeddedKeyFingerprintMismatch {
#[error(not(source))]
expected: &'static str,
#[error(not(source))]
actual: String,
},
#[display(
"The OpenPGP signature of {url} does not match any trusted Node.js release key. The downloaded Node.js runtime cannot be verified as a genuine release."
)]
#[diagnostic(code(NODE_SHASUMS_SIGNATURE_INVALID))]
SignatureInvalid {
#[error(not(source))]
url: String,
},
}
/// Errors raised by [`pick_file_checksum_from_shasums_file`].
///
/// Two upstream codes survive the port verbatim — they are the
@@ -90,6 +169,41 @@ pub async fn fetch_shasums_file(
Ok(parse_shasums_file(&body))
}
/// Fetch a Node.js release's `SHASUMS256.txt` and verify its
/// detached OpenPGP signature (`SHASUMS256.txt.sig`) against the
/// embedded Node.js release keys before returning the body.
pub async fn fetch_verified_node_shasums(
http_client: &ThrottledClient,
shasums_url: &str,
) -> Result<String, FetchVerifiedNodeShasumsError> {
let shasums_bytes =
fetch_node_shasums_bytes(http_client, shasums_url, "SHASUMS256.txt").await?;
let signature_url = format!("{shasums_url}.sig");
let signature_bytes =
fetch_node_shasums_bytes(http_client, &signature_url, "SHASUMS256.txt.sig").await?;
if !is_signed_by_trusted_node_release_key(&shasums_bytes, &signature_bytes)? {
return Err(FetchVerifiedNodeShasumsError::SignatureInvalid {
url: shasums_url.to_string(),
});
}
String::from_utf8(shasums_bytes).map_err(|error| FetchVerifiedNodeShasumsError::InvalidUtf8 {
url: shasums_url.to_string(),
error: Arc::new(error),
})
}
/// Like [`fetch_shasums_file`], but first verifies the SHASUMS file's
/// detached OpenPGP signature against the Node.js release keys.
pub async fn fetch_verified_node_shasums_file(
http_client: &ThrottledClient,
shasums_url: &str,
) -> Result<Vec<ShasumsFileItem>, FetchVerifiedNodeShasumsError> {
let body = fetch_verified_node_shasums(http_client, shasums_url).await?;
Ok(parse_shasums_file(&body))
}
/// Companion to [`fetch_shasums_file`] that returns the raw body so
/// callers can later pick a single row out with
/// [`pick_file_checksum_from_shasums_file`].
@@ -119,6 +233,78 @@ pub async fn fetch_shasums_file_raw(
})
}
async fn fetch_node_shasums_bytes(
http_client: &ThrottledClient,
url: &str,
what: &'static str,
) -> Result<Vec<u8>, FetchVerifiedNodeShasumsError> {
let response =
http_client.acquire_for_url(url).await.get(url).send().await.map_err(|error| {
FetchVerifiedNodeShasumsError::Network {
what,
url: url.to_string(),
error: Arc::new(error),
}
})?;
if !response.status().is_success() {
return Err(FetchVerifiedNodeShasumsError::StatusNotOk {
what,
url: url.to_string(),
status: response.status().as_u16(),
});
}
response.bytes().await.map(|bytes| bytes.to_vec()).map_err(|error| {
FetchVerifiedNodeShasumsError::Network {
what,
url: url.to_string(),
error: Arc::new(error),
}
})
}
fn is_signed_by_trusted_node_release_key(
content: &[u8],
signature_bytes: &[u8],
) -> Result<bool, FetchVerifiedNodeShasumsError> {
let signature = DetachedSignature::from_bytes(Cursor::new(signature_bytes))
.map_err(signature_unreadable)?;
for key in trusted_node_release_keys()? {
if signature.verify(&key.primary_key, content).is_ok() {
return Ok(true);
}
for subkey in &key.public_subkeys {
if signature.verify(subkey, content).is_ok() {
return Ok(true);
}
}
}
Ok(false)
}
fn trusted_node_release_keys() -> Result<Vec<SignedPublicKey>, FetchVerifiedNodeShasumsError> {
NODE_RELEASE_KEYS.iter().map(read_node_release_key).collect()
}
fn read_node_release_key(
trusted_key: &NodeReleaseKey,
) -> Result<SignedPublicKey, FetchVerifiedNodeShasumsError> {
let (key, _headers) = SignedPublicKey::from_armor_single(trusted_key.armored_key.as_bytes())
.map_err(signature_unreadable)?;
let actual_fingerprint = key.fingerprint().to_string();
let fingerprint_matches = actual_fingerprint.eq_ignore_ascii_case(trusted_key.fingerprint);
if !fingerprint_matches {
return Err(FetchVerifiedNodeShasumsError::EmbeddedKeyFingerprintMismatch {
expected: trusted_key.fingerprint,
actual: actual_fingerprint,
});
}
Ok(key)
}
fn signature_unreadable(error: pgp::errors::Error) -> FetchVerifiedNodeShasumsError {
FetchVerifiedNodeShasumsError::SignatureUnreadable { error: Arc::new(error) }
}
/// Parse a `SHASUMS256.txt` body into rows.
///
/// Split out from [`fetch_shasums_file`] so verifier-side code that

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
use pretty_assertions::assert_eq;
use super::{
PickFileChecksumError, ShasumsFileItem, parse_shasums_file,
FetchVerifiedNodeShasumsError, PickFileChecksumError, ShasumsFileItem,
fetch_verified_node_shasums, is_signed_by_trusted_node_release_key, parse_shasums_file,
pick_file_checksum_from_shasums_file,
};
@@ -96,3 +97,136 @@ be127be1d98cad94c56f46245d0f2de89934d300028694456861a6d5ac558bf3 foo.msi";
other => panic!("expected Malformed, got {other:?}"),
}
}
#[tokio::test]
async fn fetches_node_shasums_when_signature_verifies() {
let mut server = mockito::Server::new_async().await;
let signature = node_22_11_0_signature();
let _shasums = server
.mock("GET", "/download/release/v22.11.0/SHASUMS256.txt")
.with_status(200)
.with_body(NODE_22_11_0_SHASUMS)
.create_async()
.await;
let _signature = server
.mock("GET", "/download/release/v22.11.0/SHASUMS256.txt.sig")
.with_status(200)
.with_body(signature)
.create_async()
.await;
let client = pacquet_network::ThrottledClient::new_for_installs();
let body = fetch_verified_node_shasums(
&client,
&format!("{}/download/release/v22.11.0/SHASUMS256.txt", server.url()),
)
.await
.expect("signature verifies");
assert_eq!(body, NODE_22_11_0_SHASUMS);
}
#[test]
fn rejects_tampered_node_shasums() {
let signature = node_22_11_0_signature();
let tampered = NODE_22_11_0_SHASUMS.replacen("1bbf7e63", "00000000", 1);
assert!(
!is_signed_by_trusted_node_release_key(tampered.as_bytes(), &signature)
.expect("signature parsed"),
);
}
#[tokio::test]
async fn missing_node_shasums_signature_fails() {
let mut server = mockito::Server::new_async().await;
let _shasums = server
.mock("GET", "/download/release/v22.11.0/SHASUMS256.txt")
.with_status(200)
.with_body(NODE_22_11_0_SHASUMS)
.create_async()
.await;
let _signature = server
.mock("GET", "/download/release/v22.11.0/SHASUMS256.txt.sig")
.with_status(404)
.create_async()
.await;
let client = pacquet_network::ThrottledClient::new_for_installs();
let err = fetch_verified_node_shasums(
&client,
&format!("{}/download/release/v22.11.0/SHASUMS256.txt", server.url()),
)
.await
.expect_err("missing signature must fail");
assert!(matches!(
err,
FetchVerifiedNodeShasumsError::StatusNotOk { what: "SHASUMS256.txt.sig", status: 404, .. },
));
}
fn node_22_11_0_signature() -> Vec<u8> {
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
BASE64_STANDARD.decode(NODE_22_11_0_SIGNATURE_B64).expect("valid base64")
}
// cspell:disable
const NODE_22_11_0_SHASUMS: &str = "\
1bbf7e632ea55eabf920e8e27bb3e73ca4923eca78a300e5767635e9b2c0c603 node-v22.11.0-aix-ppc64.tar.gz
de6cd4db461b6dc3b3eab31a36b58e30d8af074183bcb13ceca6fd162a579ba6 node-v22.11.0-arm64.msi
2e89afe6f4e3aa6c7e21c560d8a0453d84807e97850bbb819b998531a22bdfde node-v22.11.0-darwin-arm64.tar.gz
c379a90c6aa605b74042a233ddcda4247b347ba5732007d280e44422cc8f9ecb node-v22.11.0-darwin-arm64.tar.xz
668d30b9512137b5f5baeef6c1bb4c46efff9a761ba990a034fb6b28b9da2465 node-v22.11.0-darwin-x64.tar.gz
ab28d1784625d151e3f608a9412a009118f376118ed842ae643f8c2efdfb0af6 node-v22.11.0-darwin-x64.tar.xz
0d42dc3b3377f49e495976dc0e4f5c3a7ffb1d714050d2f247afdbbc0898dae5 node-v22.11.0-headers.tar.gz
7eddf759cd3d1a0113c1a0ac7c080e5c0e458bca34a064c62dc8ce613ff5efdd node-v22.11.0-headers.tar.xz
27453f7a0dd6b9e6738f1f6ea6a09b102ec7aa484de1e39d6a1c3608ad47aa6a node-v22.11.0-linux-arm64.tar.gz
6031d04b98f59ff0f7cb98566f65b115ecd893d3b7870821171708cdbaf7ae6e node-v22.11.0-linux-arm64.tar.xz
f85ced095b17e2535859fd2a5641370c3fca12dd72147f93d2696e2909fe1e9d node-v22.11.0-linux-armv7l.tar.gz
9de0fdcfb1cccbe03f72f939e4e6f03867aef3da8223f90606cd93757704dae0 node-v22.11.0-linux-armv7l.tar.xz
0532965a717d3996302a111703c007dac2763e01795730d488dadbc2fcfac2fa node-v22.11.0-linux-ppc64le.tar.gz
d1d49d7d611b104b6d616e18ac439479d8296aa20e3741432de0e85f4735a81e node-v22.11.0-linux-ppc64le.tar.xz
64f691400ffe3a84be930e0cb03607d0b95bef122a679f7893d8e2972e90c521 node-v22.11.0-linux-s390x.tar.gz
f474ed77d6b13d66d07589aee1c2b9175be4c1b165483e608ac1674643064a99 node-v22.11.0-linux-s390x.tar.xz
4f862bab52039835efbe613b532238b6e4dde98d139a34e6923193e073438b13 node-v22.11.0-linux-x64.tar.gz
83bf07dd343002a26211cf1fcd46a9d9534219aad42ee02847816940bf610a72 node-v22.11.0-linux-x64.tar.xz
8d658eda7699d580ccc268ca8a40ced5aeecef5bb4d19c4187e92eebac5d68ec node-v22.11.0.pkg
24e5130fa7bc1eaab218a0c9cb05e03168fa381bb9e3babddc6a11f655799222 node-v22.11.0.tar.gz
bbf0297761d53aefda9d7855c57c7d2c272b83a7b5bad4fea9cb29006d8e1d35 node-v22.11.0.tar.xz
55b491f3d73fdacf8cf43a2199e824abadda2c43a94780310baa526dc1d679e2 node-v22.11.0-win-arm64.7z
b9ff5a6b6ffb68a0ffec82cc5664ed48247dabbd25ee6d129facd2f65a8ca80d node-v22.11.0-win-arm64.zip
d2a4fadb1f5e4abc634b6ac16c44cae7c73ffc3dbfe8b92b011d85f2df90f6c1 node-v22.11.0-win-x64.7z
905373a059aecaf7f48c1ce10ffbd5334457ca00f678747f19db5ea7d256c236 node-v22.11.0-win-x64.zip
ca0a274f1edc90005b1dc7ec22ec55dad1acc21320bc0be853065d69db2a5152 node-v22.11.0-win-x86.7z
700e0b1bcaca8b1a04c929ce29b0f07e099b4a34a7facab74fda71764d16f71c node-v22.11.0-win-x86.zip
9eea480bd30c98ae11a97cb89a9278235cbbbd03c171ee5e5198bd86b7965b4b node-v22.11.0-x64.msi
ab19f02c4b0d9f578928b67d2a652496aa31729a8cc9771ffc9cc6d3b8afe7e3 node-v22.11.0-x86.msi
b4e5e2821aeb518c0c55f02d4fcd9182c57f97bcce50341998333dba38e34ea4 win-arm64/node.exe
ad65afe5b192644fec9d599c77f0e38a8421d0d7ad2389679882a288c8df444b win-arm64/node.lib
0861cf0f1ff6135a21eb26279fc6a6f7dc9d9c0ac926a17553f387c32945eea5 win-arm64/node_pdb.7z
f35c2d1a967080b0a1e288b891cb9300a04d0b90042bac8c965c9ebcfc3749bf win-arm64/node_pdb.zip
7447c4ece014aa41fb2ff866c993c708e5a8213a00913cc2ac5049ea3ffc230d win-x64/node.exe
3581a06b68c4584d146372113eaa8c4d102127222e5041195ba38f185eef419c win-x64/node.lib
171d80aeedbe43bd70b3539de6f845a359d8dd97a684df2cbb4f49d8946f4991 win-x64/node_pdb.7z
7c3fa0149b17d9ff4b5af2f3e19e768b6ab684a9dd8dcf35ea204a90d3f56903 win-x64/node_pdb.zip
e54a4559dafd56562a45b50000831d28ee2f7f1ac4ff98b38165871f31f64ab8 win-x86/node.exe
45399070d1d247cf223d12e80d3e638635af24d2f7a4714bc8e38a6a918f162a win-x86/node.lib
a78040dbb0e7296eebe90c235091ee46a8a01587a226bf4e5a01f5b399e153d7 win-x86/node_pdb.7z
9fb300178536e8243ad55207ee85990731e77299c9e670cec0b54e10dc971713 win-x86/node_pdb.zip
";
const NODE_22_11_0_SIGNATURE_B64: &str = concat!(
"iQIzBAABCAAdFiEEyC+jrhy+3Gvka5NgxDzsRcF6uTwFAmcg6xUACgkQxDzsRcF6",
"uTyvgg/+J2t1XkjQGIKUiZVKbdWcct6x4F9RblpmlVV4ZMG9F6yMyM4i1znFPCok",
"35gCi/gQcF0wR2pE2cpmUEqYFObPf7x7/CEcraZEEqvdve/0r3tBjwUr7MjQXm3",
"B3TtPjo6S2f5CfzU+WhvdQrly/Bzlv7bPeGLDp+IKYN/MBDqDqBePs5i5l/Ka2z",
"mEwxKu1dAt8/0n+3xNoTtlWSO5zURRZeztn0cx7pHFc4emsLJe7HKgswAaWmF0N",
"N+8Rj6efbn//TxLm9LGlLkQGcqwfUmnjwsN7u+4CWRH8Bku2FmnmZLYLJVMjJa4",
"mgODI/AtcbuhYxtC2lN/FIZ+/inKWbmh7j2VwqBkz7yc9yATcqWrLbXVlsD/+E4",
"4vXtJUPMQCB9M05m1i28isxWEYf6UssKEgB2lHCXsDyYYtH7X6GwznKybnkhWvF",
"tKloNwHBgw0ox1wI+uNF5UpI63HlVK3vADFhgwBQ1aOoPPMacefw0xAKwex40Vf",
"sntaLBDaGoY454t2xYn7qPCJNtq4fi6CScJqHK3RRH13E17Tk7lfc8IxUY4UwIr",
"eIR0oPv1dAmmuCJqLvCJ/mQ0fDzTKPB+1f5d1BPXXBetcHdsZHsRiEbz1R5xzOT",
"+meX1EoRTSZliUd/NGx7F9MwzpHrxfEDTJCHbtGwwyfbc6WuzCfRR14riE/g=",
);
// cspell:enable

View File

@@ -26,6 +26,7 @@ ssri = { workspace = true }
tokio = { workspace = true }
[dev-dependencies]
mockito = { workspace = true }
pretty_assertions = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] }

View File

@@ -13,7 +13,10 @@ use std::{
use derive_more::{Display, Error};
use miette::Diagnostic;
use pacquet_crypto_shasums_file::{FetchShasumsFileError, fetch_shasums_file};
use pacquet_crypto_shasums_file::{
FetchShasumsFileError, FetchVerifiedNodeShasumsError, fetch_shasums_file,
fetch_verified_node_shasums_file,
};
use pacquet_lockfile::{
BinaryArchive, BinaryResolution, BinarySpec, LockfileResolution, PlatformAssetResolution,
PlatformAssetTarget, VariationsResolution,
@@ -65,6 +68,9 @@ pub enum NodeResolverError {
#[diagnostic(transparent)]
FetchShasumsFile(#[error(source)] FetchShasumsFileError),
#[diagnostic(transparent)]
FetchVerifiedNodeShasums(#[error(source)] FetchVerifiedNodeShasumsError),
#[display("Failed to parse integrity {integrity} for {file_name}")]
#[diagnostic(code(NODE_INTEGRITY_PARSE_FAILED))]
ParseIntegrity {
@@ -145,7 +151,7 @@ impl NodeResolver {
Box::new(NodeResolverError::VersionNotFound { spec: version_spec.to_string() })
as ResolveError
})?;
let variants = self.read_node_assets(&mirror, &version).await?;
let variants = self.read_node_assets(&mirror, &version, &parsed.release_channel).await?;
let range = if version == version_spec { version.clone() } else { format!("^{version}") };
let resolution = LockfileResolution::Variations(VariationsResolution { variants });
let manifest = serde_json::json!({
@@ -217,12 +223,14 @@ impl NodeResolver {
&self,
mirror: &str,
version: &str,
release_channel: &str,
) -> Result<Vec<PlatformAssetResolution>, ResolveError> {
let mut assets = read_node_assets_from_mirror(
&self.http_client,
mirror,
version,
/* musl_only */ false,
/* verify_signature */ release_channel == "release",
)
.await?;
if mirror == DEFAULT_NODE_MIRROR_BASE_URL
@@ -231,6 +239,7 @@ impl NodeResolver {
UNOFFICIAL_NODE_MIRROR_BASE_URL,
version,
/* musl_only */ true,
/* verify_signature */ false,
)
.await
{
@@ -263,11 +272,18 @@ async fn read_node_assets_from_mirror(
node_mirror_base_url: &str,
version: &str,
musl_only: bool,
verify_signature: bool,
) -> Result<Vec<PlatformAssetResolution>, ResolveError> {
let integrities_url = format!("{node_mirror_base_url}v{version}/SHASUMS256.txt");
let items = fetch_shasums_file(http_client, &integrities_url)
.await
.map_err(|err| Box::new(NodeResolverError::FetchShasumsFile(err)) as ResolveError)?;
let items = if verify_signature {
fetch_verified_node_shasums_file(http_client, &integrities_url).await.map_err(|err| {
Box::new(NodeResolverError::FetchVerifiedNodeShasums(err)) as ResolveError
})?
} else {
fetch_shasums_file(http_client, &integrities_url)
.await
.map_err(|err| Box::new(NodeResolverError::FetchShasumsFile(err)) as ResolveError)?
};
let mut assets = Vec::new();
for item in items {
let Some(parsed) = parse_node_file_name(&item.file_name, version) else { continue };

View File

@@ -4,7 +4,10 @@ use pacquet_network::ThrottledClient;
use pacquet_resolving_resolver_base::{ResolveOptions, Resolver, WantedDependency};
use pretty_assertions::assert_eq;
use super::{NodeResolver, bin_spec_for_platform, parse_node_file_name};
use super::{
NodeResolver, NodeResolverError, bin_spec_for_platform, parse_node_file_name,
read_node_assets_from_mirror,
};
fn resolver() -> NodeResolver {
NodeResolver::new(Arc::new(ThrottledClient::new_for_installs()))
@@ -101,3 +104,57 @@ fn bin_spec_is_a_named_map() {
BinarySpec::Map(BTreeMap::from([("node".to_string(), "node.exe".to_string())])),
);
}
#[tokio::test]
async fn release_asset_reader_requires_signature_when_requested() {
let mut server = mockito::Server::new_async().await;
let _shasums = server
.mock("GET", "/download/release/v22.11.0/SHASUMS256.txt")
.with_status(200)
.with_body(SHASUMS_WITH_ONE_NODE_ASSET)
.create_async()
.await;
let _signature = server
.mock("GET", "/download/release/v22.11.0/SHASUMS256.txt.sig")
.with_status(404)
.create_async()
.await;
let err = read_node_assets_from_mirror(
&ThrottledClient::new_for_installs(),
&format!("{}/download/release/", server.url()),
"22.11.0",
false,
true,
)
.await
.expect_err("stable release assets must require a SHASUMS signature");
let err = err.downcast_ref::<NodeResolverError>().expect("NodeResolverError");
assert!(matches!(err, NodeResolverError::FetchVerifiedNodeShasums(_)));
}
#[tokio::test]
async fn prerelease_asset_reader_does_not_require_signature() {
let mut server = mockito::Server::new_async().await;
let _shasums = server
.mock("GET", "/download/rc/v22.11.0/SHASUMS256.txt")
.with_status(200)
.with_body(SHASUMS_WITH_ONE_NODE_ASSET)
.create_async()
.await;
let assets = read_node_assets_from_mirror(
&ThrottledClient::new_for_installs(),
&format!("{}/download/rc/", server.url()),
"22.11.0",
false,
false,
)
.await
.expect("unsigned channels use the raw SHASUMS file");
assert_eq!(assets.len(), 1);
}
const SHASUMS_WITH_ONE_NODE_ASSET: &str = "\
ed52239294ad517fbe91a268146d5d2aa8a17d2d62d64873e43219078ba71c4e node-v22.11.0-linux-x64.tar.gz
";

42
pnpm-lock.yaml generated
View File

@@ -301,6 +301,9 @@ catalogs:
'@npm/types':
specifier: ^2.1.0
version: 2.1.0
'@openpgp/web-stream-tools':
specifier: 0.3.1
version: 0.3.1
'@pnpm/byline':
specifier: ^1.0.0
version: 1.0.0
@@ -772,6 +775,9 @@ catalogs:
open:
specifier: ^7.4.2
version: 7.4.2
openpgp:
specifier: ^6.3.1
version: 6.3.1
p-defer:
specifier: ^4.0.1
version: 4.0.1
@@ -2873,10 +2879,16 @@ importers:
'@pnpm/fetching.types':
specifier: workspace:*
version: link:../../fetching/types
openpgp:
specifier: 'catalog:'
version: 6.3.1(@openpgp/web-stream-tools@0.3.1(@types/node@25.9.1)(typescript@5.9.3))
devDependencies:
'@jest/globals':
specifier: 'catalog:'
version: 30.3.0
'@openpgp/web-stream-tools':
specifier: 'catalog:'
version: 0.3.1(@types/node@25.9.1)(typescript@5.9.3)
'@pnpm/crypto.shasums-file':
specifier: workspace:*
version: 'link:'
@@ -11288,6 +11300,18 @@ packages:
resolution: {integrity: sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==}
engines: {node: ^20.17.0 || >=22.9.0}
'@openpgp/web-stream-tools@0.3.1':
resolution: {integrity: sha512-EV+VQ4Dr8b+JmlGnc74FLgx7EhLyydOr4j6s6Hp+2scQh6sLQMs2h+1oEYUIslXcQPicWKG5ZQx+/dua0dgPWA==}
engines: {node: '>= 18.0.0'}
peerDependencies:
'@types/node': '>=18.0.0'
typescript: '>=4.7'
peerDependenciesMeta:
'@types/node':
optional: true
typescript:
optional: true
'@package-json/types@0.0.12':
resolution: {integrity: sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==}
@@ -15512,6 +15536,15 @@ packages:
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
engines: {node: '>=8'}
openpgp@6.3.1:
resolution: {integrity: sha512-7oSPvmlKPojxFoyelT5DWPIAVmqWZh4qU/5pO6bdoShEtRpCw9Sye9IXUQj6EFM3XpgGssqccAr705YtTcLNQw==}
engines: {node: '>= 18.0.0', typescript: '>= 5.0.0'}
peerDependencies:
'@openpgp/web-stream-tools': ~0.3.0
peerDependenciesMeta:
'@openpgp/web-stream-tools':
optional: true
opt-cli@1.5.1:
resolution: {integrity: sha512-iRFQBiQjXZ+LX/8pis04prUhS6FOYcJiZRouofN3rUJEB282b/e0s3jp9vT7aHgXY6TUpgPwu12f0i+qF40Kjw==}
hasBin: true
@@ -18581,6 +18614,11 @@ snapshots:
'@npmcli/redact@4.0.0': {}
'@openpgp/web-stream-tools@0.3.1(@types/node@25.9.1)(typescript@5.9.3)':
optionalDependencies:
'@types/node': 25.9.1
typescript: 5.9.3
'@package-json/types@0.0.12': {}
'@pkgr/core@0.3.6': {}
@@ -23936,6 +23974,10 @@ snapshots:
is-docker: 2.2.1
is-wsl: 2.2.0
openpgp@6.3.1(@openpgp/web-stream-tools@0.3.1(@types/node@25.9.1)(typescript@5.9.3)):
optionalDependencies:
'@openpgp/web-stream-tools': 0.3.1(@types/node@25.9.1)(typescript@5.9.3)
opt-cli@1.5.1:
dependencies:
commander: 2.9.0

View File

@@ -241,6 +241,8 @@ catalog:
npm-packlist: 10.0.4
object-hash: 3.0.0
open: ^7.4.2
openpgp: ^6.3.1
'@openpgp/web-stream-tools': 0.3.1
p-defer: ^4.0.1
p-every: ^2.0.0
p-filter: ^4.1.0