fix(security): verify npm registry signature before spawning a package-manager binary (#12292)

pnpm can be made to download and execute a native binary through two **repository-controlled** inputs, neither of which was authenticated before this change:

1. **pacquet install engine** — declaring `pacquet` (or `@pnpm/pacquet`) in `configDependencies` (in `pnpm-workspace.yaml`) opts in to pnpm's Rust install engine, and pnpm spawns the platform binary `@pacquet/<platform>-<arch>` during `pnpm install`.
2. **package-manager version switch** — the `packageManager` / `devEngines.packageManager` field makes pnpm download and run a specific pnpm version. This is **on by default** (`onFail` defaults to `download`) and also covers `pnpm self-update` and `pnpm with`.

In both cases the repository also controls the lockfile integrity and the registry the bytes are fetched from (via `.npmrc`), so matching the lockfile integrity proves nothing — it matches the hash the attacker wrote. A cloned, untrusted repository could therefore execute an arbitrary native binary just by running a normal pnpm command.

## Fix (corepack-style registry-signature verification)

pnpm now verifies the **npm registry signature** of the bytes it is about to spawn, **over the installed integrity**, against npm's public signing keys that **ship embedded in the pnpm CLI** (exactly as corepack does). If the bytes on disk were substituted or tampered with, npm's real signature does not validate over them.

- New reusable `verifyInstalledPackageSignatures()` in `@pnpm/deps.security.signatures` verifies `name@version:integrity` against `dist.signatures` using the embedded keys.
- Because the keys are **embedded** (not fetched), a registry the user did not vouch for cannot supply its own keypair to forge a signature. The signed packument is fetched from the **configured** registry, so an **npm mirror works transparently** — it proxies the same signed packument, with no configuration. There is intentionally **no runtime override or off-switch** for the keys.
- **pacquet** (`installing/commands`): verifies the `pacquet` shim and the host platform binary. It **fails the command** if the signature does not verify or cannot be checked (e.g. registry unreachable); the only graceful fallback to pnpm's own engine is when pacquet has no binary for the current platform.
- **pnpm engine** (`engine/pm/commands`): verifies `pnpm`, `@pnpm/exe`, and the host platform binary, **only on a store cache miss** (an actual download), so it adds no network round trip to every command. It **fails closed** — any verification failure, including an unreachable registry, refuses the version switch rather than running an unverified binary.

## Keeping the embedded keys fresh

The embedded keys live in a generated file. `deps/security/signatures/scripts/update-npm-signing-keys.mjs` keeps them in sync with npm's keys endpoint (`pnpm check:npm-signing-keys` / `--update`), and the **create-release-pr** workflow runs the check as a gate, so a key rotation cannot silently break verification — a stale key set blocks the release until refreshed.

## Pacquet parity

pacquet gained `configDependencies` support on `main` (#12285), but it has **no install-engine-spawn sink** — pacquet *is* the engine, and it does not select/spawn an alternate engine from `configDependencies` (its only config-dependency code-execution path is `updateConfig` plugin pnpmfiles, which it shares with pnpm and which this advisory does not cover). So CAND-PNPM-097 has no pacquet-side analog; no pacquet code change is needed.
This commit is contained in:
Zoltan Kochan
2026-06-09 23:37:20 +02:00
committed by GitHub
parent 1017c36776
commit 5f2bb9f5ba
21 changed files with 991 additions and 17 deletions

View File

@@ -0,0 +1,15 @@
---
"@pnpm/deps.security.signatures": minor
"@pnpm/installing.commands": patch
"@pnpm/engine.pm.commands": patch
"pnpm": patch
---
Security: pnpm now verifies the npm registry signature of a package-manager binary before spawning it, so a cloned repository cannot make pnpm download and execute an arbitrary native binary.
This covers two paths that select an executable from repository-controlled input:
- **pacquet install engine** — declaring `pacquet` (or `@pnpm/pacquet`) in `configDependencies` opts in to pnpm's Rust install engine. pnpm now verifies that the installed `pacquet` shim and the host's `@pacquet/<platform>-<arch>` binary carry a valid npm registry signature for their exact `name@version`, and refuses to run pacquet (failing the command) if the signature does not verify or cannot be checked. The only graceful fallback to pnpm's own engine is when pacquet has no binary for the current platform.
- **automatic version switch / `self-update`** — the `packageManager` / `devEngines.packageManager` field makes pnpm download and run a specific pnpm version. pnpm now verifies the registry signature of `pnpm`, `@pnpm/exe`, and the host platform binary before installing/spawning them, and refuses to run an engine whose signature does not match a published, signed release. The check runs only on an actual download (store cache miss), so it does not add a network round trip to every command.
In both cases the signature is verified over the *installed* integrity, against npm's public signing keys that ship embedded in the pnpm CLI (like corepack), so bytes substituted via a tampered lockfile or a repository-controlled registry fail verification — and a registry the user did not vouch for cannot supply its own signing keys. The signed packument is fetched from the configured registry, so an npm mirror works transparently. Verification fails closed: if it cannot be completed (for example, the registry is unreachable), the command fails rather than running an unverified binary. The embedded keys are kept current by a release-time check against npm's signing-keys endpoint.

View File

@@ -50,6 +50,13 @@ jobs:
git fetch origin "$TARGET"
git checkout -B "release-pr/$TARGET" FETCH_HEAD
# Fail the release if the npm registry signing keys embedded in
# @pnpm/deps.security.signatures have drifted from what npm advertises. pnpm
# verifies package-manager binaries (pacquet, the version-switch pnpm) against
# these keys, so a stale set could break verification after a key rotation.
- name: Check embedded npm signing keys are up to date
run: node deps/security/signatures/scripts/update-npm-signing-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

@@ -0,0 +1,105 @@
#!/usr/bin/env node
// Keeps the embedded npm registry signing keys (src/npmSigningKeys.ts) in sync
// with https://registry.npmjs.org/-/npm/v1/keys.
//
// node update-npm-signing-keys.mjs # check (CI / release gate)
// node update-npm-signing-keys.mjs --update # rewrite the embedded keys
//
// `--check` fails when npm advertises a signing key that is not embedded
// verbatim, so a key rotation cannot silently break (or weaken) pnpm's
// signature verification. `--update` writes the union of npm's keys and any
// embedded keys npm no longer lists (older keys are kept so packages published
// before a rotation still verify).
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const KEYS_URL = 'https://registry.npmjs.org/-/npm/v1/keys'
const KEYS_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'npmSigningKeys.ts')
const KEY_FIELDS = ['expires', 'keyid', 'keytype', 'scheme', 'key']
async function main () {
const update = process.argv.includes('--update')
const npmKeys = await fetchNpmKeys()
const embedded = readEmbeddedKeys()
const missing = npmKeys.filter((npmKey) => !embedded.some((e) => keysEqual(e, npmKey)))
if (!update) {
if (missing.length === 0) {
console.log(`✓ Embedded npm signing keys are up to date (${embedded.length} key(s)).`)
return
}
console.error(`✗ Embedded npm signing keys are out of date. ${missing.length} key(s) advertised by npm are not embedded:`)
for (const key of missing) console.error(` - ${key.keyid}`)
console.error(`\nRun: node ${path.relative(process.cwd(), fileURLToPath(import.meta.url))} --update`)
process.exit(1)
}
// Union: every npm key, plus embedded keys npm no longer lists (kept for
// verifying packages published before a rotation).
const merged = [...npmKeys]
for (const e of embedded) {
if (!merged.some((m) => m.keyid === e.keyid)) merged.push(e)
}
fs.writeFileSync(KEYS_FILE, render(merged))
console.log(missing.length === 0
? '✓ Embedded npm signing keys already current; rewrote file.'
: `✓ Updated embedded npm signing keys (added ${missing.length}).`)
}
async function fetchNpmKeys () {
const res = await fetch(KEYS_URL)
if (!res.ok) throw new Error(`Failed to fetch ${KEYS_URL}: ${res.status}`)
const body = await res.json()
if (!Array.isArray(body?.keys)) throw new Error(`Unexpected response from ${KEYS_URL}`)
return body.keys.map(pickFields)
}
function readEmbeddedKeys () {
const source = fs.readFileSync(KEYS_FILE, 'utf8')
const start = source.indexOf('[', source.indexOf('NPM_SIGNING_KEYS'))
if (start === -1) throw new Error(`Could not find NPM_SIGNING_KEYS array in ${KEYS_FILE}`)
let depth = 0
let end = -1
for (let i = start; i < source.length; i++) {
if (source[i] === '[') depth++
else if (source[i] === ']' && --depth === 0) { end = i + 1; break }
}
if (end === -1) throw new Error(`Unterminated NPM_SIGNING_KEYS array in ${KEYS_FILE}`)
return JSON.parse(source.slice(start, end)).map(pickFields)
}
function pickFields (key) {
const out = {}
for (const f of KEY_FIELDS) out[f] = key[f] ?? null
return out
}
function keysEqual (a, b) {
return KEY_FIELDS.every((f) => (a[f] ?? null) === (b[f] ?? null))
}
function render (keys) {
const body = JSON.stringify(keys, KEY_FIELDS, 2)
return `/* eslint-disable */
// GENERATED — npm's public registry signing keys, mirrored from
// ${KEYS_URL}
//
// Refresh with: node deps/security/signatures/scripts/update-npm-signing-keys.mjs --update
// The release workflow runs \`--check\` and fails if these drift from npm, so a
// rotated key cannot silently break (or weaken) signature verification.
export const NPM_SIGNING_KEYS = ${body} as const satisfies ReadonlyArray<{
expires: string | null
keyid: string
keytype: string
scheme: string
key: string
}>
`
}
main().catch((err) => {
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,29 @@
/* eslint-disable */
// GENERATED — npm's public registry signing keys, mirrored from
// https://registry.npmjs.org/-/npm/v1/keys
//
// Refresh with: node deps/security/signatures/scripts/update-npm-signing-keys.mjs --update
// The release workflow runs `--check` and fails if these drift from npm, so a
// rotated key cannot silently break (or weaken) signature verification.
export const NPM_SIGNING_KEYS = [
{
"expires": "2025-01-29T00:00:00.000Z",
"keyid": "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA",
"keytype": "ecdsa-sha2-nistp256",
"scheme": "ecdsa-sha2-nistp256",
"key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg=="
},
{
"expires": null,
"keyid": "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U",
"keytype": "ecdsa-sha2-nistp256",
"scheme": "ecdsa-sha2-nistp256",
"key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY6Ya7W++7aUPzvMTrezH6Ycx3c+HOKYCcNGybJZSCJq/fd7Qa8uuAKtdIkUQtQiEKERhAmE5lMMJhP8OkDOa2g=="
}
] as const satisfies ReadonlyArray<{
expires: string | null
keyid: string
keytype: string
scheme: string
key: string
}>

View File

@@ -7,6 +7,8 @@ import type { GetAuthHeader } from '@pnpm/fetching.types'
import { createFetchFromRegistry, type CreateFetchFromRegistryOptions, type RetryTimeoutOptions } from '@pnpm/network.fetch'
import pLimit from 'p-limit'
import { NPM_SIGNING_KEYS } from './npmSigningKeys.js'
export interface SignaturePackage {
name: string
registry: string
@@ -257,37 +259,62 @@ function verifyPackageSignatures (
const message = `${pkg.name}@${pkg.version}:${pkg.integrity}`
const publishedTime = pkg.publishedAt ? Date.parse(pkg.publishedAt) : undefined
// A package is accepted as soon as ONE signature made by a trusted key
// validates. Signatures from unknown/expired/invalid keys are recorded but do
// not on their own fail the package — otherwise a key rotation (a packument
// carrying multiple signatures) breaks, and a mirror could force a failure
// just by appending a junk signature. We fail only when no signature validates
// against a trusted key.
const failures: string[] = []
for (const signature of pkg.signatures) {
const key = keys.find(({ keyid }) => keyid === signature.keyid)
if (!key) {
const reason = `${pkg.name}@${pkg.version} has a registry signature with keyid ${signature.keyid} but no corresponding public key can be found`
return toSignatureIssue(pkg, reason)
failures.push(`${pkg.name}@${pkg.version} has a registry signature with keyid ${signature.keyid} but no corresponding public key can be found`)
continue
}
// Without publish time metadata we cannot safely compare against key expiry,
// so keep verifying with the key instead of failing closed on incomplete metadata.
// Key expiry is a consistency check, not a security boundary: the publish
// time comes from the same unauthenticated packument as the signatures, so
// a forger holding an expired trusted key could backdate it anyway. The
// signature verification below is what gates acceptance. That is why a
// missing publish time keeps the key usable instead of failing closed —
// the same trade-off npm's pacote makes by substituting a pre-expiry date.
if (key.expires && publishedTime != null && publishedTime >= Date.parse(key.expires)) {
const reason = `${pkg.name}@${pkg.version} has a registry signature with keyid ${signature.keyid} but the corresponding public key has expired ${key.expires}`
return toSignatureIssue(pkg, reason)
failures.push(`${pkg.name}@${pkg.version} has a registry signature with keyid ${signature.keyid} but the corresponding public key has expired ${key.expires}`)
continue
}
const verifier = crypto.createVerify('SHA256')
verifier.write(message)
verifier.end()
const pem = `-----BEGIN PUBLIC KEY-----\n${key.key}\n-----END PUBLIC KEY-----`
// crypto.verify can throw on malformed PEM key material or signature bytes
// returned by the registry; treat any failure as an invalid signature so
// one bad key doesn't crash the whole audit.
let verified: boolean
try {
const verifier = crypto.createVerify('SHA256')
verifier.write(message)
verifier.end()
verified = verifier.verify(pem, signature.sig, 'base64')
} catch {
verified = false
}
if (!verified) {
const reason = `${pkg.name}@${pkg.version} has an invalid registry signature with keyid ${signature.keyid}`
return toSignatureIssue(pkg, reason)
}
if (verified) return undefined
failures.push(`${pkg.name}@${pkg.version} has an invalid registry signature with keyid ${signature.keyid}`)
}
return undefined
return toSignatureIssue(pkg, pickMostTellingFailure(pkg, failures))
}
/**
* The reason to surface when no signature validated against a trusted key.
* Prefer an invalid signature from a known key (a tamper signal) over an
* unknown-key or expiry reason, since unknown keys may just be junk a mirror
* appended.
*/
function pickMostTellingFailure (
pkg: SignaturePackage,
failures: string[]
): string {
if (failures.length === 0) {
return `${pkg.name}@${pkg.version} has no registry signature from a trusted key`
}
return failures.find((reason) => reason.includes('invalid registry signature')) ?? failures[0]
}
function toSignatureIssue (
@@ -352,3 +379,128 @@ function isPackageSignature (signature: unknown): signature is PackageSignature
function sortIssue (a: SignatureIssue, b: SignatureIssue): number {
return `${a.name}@${a.version}`.localeCompare(`${b.name}@${b.version}`)
}
export type { RegistryKey }
/**
* The trusted npm signing keys used to verify package-manager binaries before
* pnpm spawns them — npm's public keys embedded in the CLI. There is
* deliberately no way to override or disable them at runtime: a verification
* off-switch would be a footgun, and npm mirrors work without one (they proxy
* the same signed packument, which is verified against these keys). The keys
* are refreshed at release time by the update-npm-signing-keys script.
*/
export function getNpmSigningKeys (): RegistryKey[] {
return NPM_SIGNING_KEYS.map((k) => ({ ...k }))
}
export interface InstalledPackageToVerify {
name: string
/** The registry the package was installed from — the packument (and its signatures) is fetched from here. */
registry: string
version: string
/** Integrity of the bytes actually installed on disk (from the lockfile). */
integrity: string
}
/**
* Why a package failed signature verification:
* - `invalid`: a registry signature is present but does not validate over the
* installed bytes — a strong tamper signal.
* - `absent`: the package/version is not on the (canonical) registry, or carries
* no signature — suspicious for a package that is expected to be signed.
* - `unreachable`: the trust root could not be consulted (registry advertised no
* signing keys, or the network request failed) — typically transient/offline,
* not evidence of tampering.
*/
export type SignatureFailureCategory = 'invalid' | 'absent' | 'unreachable'
export interface InstalledSignatureFailure {
name: string
version: string
reason: string
category: SignatureFailureCategory
}
export interface InstalledSignatureVerificationResult {
verified: boolean
failures: InstalledSignatureFailure[]
}
/**
* Verifies that the bytes installed on disk are exactly what the registry
* signed for `name@version`. The signed message is built from the
* caller-supplied installed {@link InstalledPackageToVerify.integrity}, not
* from the integrity in the freshly-fetched packument — so if the integrity
* on disk was tampered with (or fetched from a different registry), the
* registry's signature will not validate over it.
*
* Signatures are verified against the caller-supplied `trustedKeys` (npm's
* embedded public keys, see {@link getNpmSigningKeys}) rather than keys fetched
* from a registry — so a registry the caller cannot vouch for cannot answer with
* its own key pair. The packument (which carries the signatures) is fetched from
* each package's own registry; an npm mirror works transparently because it
* proxies the same signed packument.
*
* A package counts as a failure when the package is unsigned/unpublished, or
* when a signature is present but does not validate over the installed bytes.
*/
export async function verifyInstalledPackageSignatures (
packages: InstalledPackageToVerify[],
trustedKeys: RegistryKey[],
getAuthHeader: GetAuthHeader,
opts: VerifySignaturesOptions
): Promise<InstalledSignatureVerificationResult> {
const packumentCache = new Map<string, Promise<Packument | undefined>>()
const limit = pLimit(opts.networkConcurrency ?? 16)
const failures: InstalledSignatureFailure[] = []
await Promise.all(packages.map((pkg) => limit(async () => {
const failure = await findSignatureFailure(pkg, trustedKeys, getAuthHeader, opts, packumentCache)
if (failure != null) {
failures.push({ name: pkg.name, version: pkg.version, ...failure })
}
})))
failures.sort((a, b) => `${a.name}@${a.version}`.localeCompare(`${b.name}@${b.version}`))
return { verified: failures.length === 0, failures }
}
async function findSignatureFailure (
pkg: InstalledPackageToVerify,
trustedKeys: RegistryKey[],
getAuthHeader: GetAuthHeader,
opts: VerifySignaturesOptions,
packumentCache: Map<string, Promise<Packument | undefined>>
): Promise<{ reason: string, category: SignatureFailureCategory } | undefined> {
let packument: Packument | undefined
try {
packument = await getPackument(pkg, getAuthHeader, opts, packumentCache)
} catch (err: unknown) {
return { reason: util.types.isNativeError(err) ? err.message : String(err), category: 'unreachable' }
}
if (!packument) return { reason: `${pkg.name} is not published on ${pkg.registry}`, category: 'absent' }
const version = packument.versions?.[pkg.version]
if (!version) return { reason: `${pkg.name}@${pkg.version} was not found on ${pkg.registry}`, category: 'absent' }
const rawSignatures = version.dist?.signatures
if (rawSignatures != null && !Array.isArray(rawSignatures)) {
return { reason: `malformed registry signatures metadata for ${pkg.name}@${pkg.version}`, category: 'absent' }
}
const signatures = rawSignatures ?? []
if (!signatures.every(isPackageSignature)) {
return { reason: `malformed registry signatures metadata for ${pkg.name}@${pkg.version}`, category: 'absent' }
}
if (signatures.length === 0) {
return { reason: `${pkg.name}@${pkg.version} has no registry signature`, category: 'absent' }
}
// The message is built from the installed integrity, so a signature only
// validates when the installed bytes match what the registry signed.
const issue = verifyPackageSignatures(
{ ...pkg, integrity: pkg.integrity, publishedAt: packument.time?.[pkg.version], signatures },
trustedKeys
)
return issue == null ? undefined : { reason: issue.reason ?? 'invalid registry signature', category: 'invalid' }
}

View File

@@ -1,7 +1,7 @@
import crypto from 'node:crypto'
import { afterEach, beforeEach, describe, expect, test } from '@jest/globals'
import { verifySignatures } from '@pnpm/deps.security.signatures'
import { getNpmSigningKeys, verifyInstalledPackageSignatures, verifySignatures } from '@pnpm/deps.security.signatures'
import { getMockAgent, setupMockAgent, teardownMockAgent } from '@pnpm/testing.mock-agent'
const REGISTRY = 'https://registry.example.test/'
@@ -136,6 +136,24 @@ describe('verifySignatures', () => {
expect(result.invalid[0].reason).toBe('signed-pkg@1.0.0 has a registry signature with keyid SHA256:unknown-key but no corresponding public key can be found')
})
test('accepts a package when one valid signature is present alongside a junk one', async () => {
const key = createSigningKey()
mockRegistryKey(key)
// A mirror could append a junk signature; a valid trusted one must still pass.
mockPackument({
signatures: [
{ keyid: 'SHA256:junk-key', sig: 'not-a-real-signature' },
{ keyid: key.keyid, sig: key.sign('signed-pkg@1.0.0', INTEGRITY) },
],
})
const result = await verifySignatures([
{ name: 'signed-pkg', registry: REGISTRY, version: '1.0.0' },
], () => undefined, {})
expect(result).toMatchObject({ audited: 1, invalid: [], missing: [], verified: 1 })
})
test('skips registries without signing keys', async () => {
getMockAgent().get(REGISTRY.replace(/\/$/, ''))
.intercept({ path: '/-/npm/v1/keys', method: 'GET' })
@@ -224,6 +242,112 @@ describe('verifySignatures', () => {
})
})
describe('verifyInstalledPackageSignatures', () => {
beforeEach(async () => {
await setupMockAgent()
})
afterEach(async () => {
await teardownMockAgent()
})
test('verifies when the installed integrity matches what the registry signed', async () => {
const key = createSigningKey()
mockPackument({ signatures: [{ keyid: key.keyid, sig: key.sign('signed-pkg@1.0.0', INTEGRITY) }] })
const result = await verifyInstalledPackageSignatures([
{ name: 'signed-pkg', registry: REGISTRY, version: '1.0.0', integrity: INTEGRITY },
], [toRegistryKey(key)], () => undefined, {})
expect(result).toEqual({ verified: true, failures: [] })
})
test('fails when the installed bytes differ from what the registry signed (tamper)', async () => {
const key = createSigningKey()
// The registry signed INTEGRITY, but a tampered lockfile claims a different one.
mockPackument({ signatures: [{ keyid: key.keyid, sig: key.sign('signed-pkg@1.0.0', INTEGRITY) }] })
const result = await verifyInstalledPackageSignatures([
{ name: 'signed-pkg', registry: REGISTRY, version: '1.0.0', integrity: 'sha512-tampered-bytes' },
], [toRegistryKey(key)], () => undefined, {})
expect(result.verified).toBe(false)
expect(result.failures).toHaveLength(1)
expect(result.failures[0]).toMatchObject({ name: 'signed-pkg', version: '1.0.0', category: 'invalid' })
expect(result.failures[0].reason).toContain('invalid registry signature')
})
test('fails when the signature was made by a key that is not trusted', async () => {
const signingKey = createSigningKey()
const trustedButDifferentKey = createSigningKey()
mockPackument({ signatures: [{ keyid: signingKey.keyid, sig: signingKey.sign('signed-pkg@1.0.0', INTEGRITY) }] })
const result = await verifyInstalledPackageSignatures([
{ name: 'signed-pkg', registry: REGISTRY, version: '1.0.0', integrity: INTEGRITY },
], [{ ...toRegistryKey(trustedButDifferentKey), keyid: 'SHA256:not-the-signing-key' }], () => undefined, {})
expect(result.verified).toBe(false)
expect(result.failures[0]).toMatchObject({ category: 'invalid' })
})
test('verifies when a valid trusted signature is present alongside an untrusted/junk one', async () => {
const key = createSigningKey()
// A malicious mirror cannot force a failure by appending a junk signature.
mockPackument({
signatures: [
{ keyid: 'SHA256:junk-key', sig: 'not-a-real-signature' },
{ keyid: key.keyid, sig: key.sign('signed-pkg@1.0.0', INTEGRITY) },
],
})
const result = await verifyInstalledPackageSignatures([
{ name: 'signed-pkg', registry: REGISTRY, version: '1.0.0', integrity: INTEGRITY },
], [toRegistryKey(key)], () => undefined, {})
expect(result).toEqual({ verified: true, failures: [] })
})
test('fails when the registry has no signature for the package', async () => {
const key = createSigningKey()
mockPackument({ signatures: [] })
const result = await verifyInstalledPackageSignatures([
{ name: 'signed-pkg', registry: REGISTRY, version: '1.0.0', integrity: INTEGRITY },
], [toRegistryKey(key)], () => undefined, {})
expect(result.verified).toBe(false)
expect(result.failures[0]).toMatchObject({ category: 'absent' })
expect(result.failures[0].reason).toContain('no registry signature')
})
test('fails when the package is not published on the registry', async () => {
const key = createSigningKey()
getMockAgent().get(REGISTRY.replace(/\/$/, ''))
.intercept({ path: '/signed-pkg', method: 'GET' })
.reply(404, {})
const result = await verifyInstalledPackageSignatures([
{ name: 'signed-pkg', registry: REGISTRY, version: '1.0.0', integrity: INTEGRITY },
], [toRegistryKey(key)], () => undefined, {})
expect(result.verified).toBe(false)
expect(result.failures[0]).toMatchObject({ category: 'absent' })
expect(result.failures[0].reason).toContain('not published')
})
})
function toRegistryKey (key: ReturnType<typeof createSigningKey>) {
return { expires: null, key: key.publicKey, keyid: key.keyid, keytype: 'ecdsa-sha2-nistp256', scheme: 'ecdsa-sha2-nistp256' }
}
describe('getNpmSigningKeys', () => {
test('returns npm\'s embedded public signing keys', () => {
const keys = getNpmSigningKeys()
expect(keys.some((k) => k.keyid === 'SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U')).toBe(true)
expect(keys.every((k) => k.keytype === 'ecdsa-sha2-nistp256')).toBe(true)
})
})
function mockRegistryKey (key: ReturnType<typeof createSigningKey>): void {
getMockAgent().get(REGISTRY.replace(/\/$/, ''))
.intercept({ path: '/-/npm/v1/keys', method: 'GET' })

View File

@@ -35,9 +35,11 @@
"@pnpm/building.policy": "workspace:*",
"@pnpm/cli.meta": "workspace:*",
"@pnpm/cli.utils": "workspace:*",
"@pnpm/config.pick-registry-for-package": "workspace:*",
"@pnpm/config.reader": "workspace:*",
"@pnpm/config.version-policy": "workspace:*",
"@pnpm/deps.graph-hasher": "workspace:*",
"@pnpm/deps.security.signatures": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/global.commands": "workspace:*",
"@pnpm/global.packages": "workspace:*",
@@ -46,6 +48,7 @@
"@pnpm/installing.env-installer": "workspace:*",
"@pnpm/lockfile.fs": "workspace:*",
"@pnpm/lockfile.types": "workspace:*",
"@pnpm/network.auth-header": "workspace:*",
"@pnpm/os.env.path-extender": "catalog:",
"@pnpm/resolving.npm-resolver": "workspace:*",
"@pnpm/shell.path": "workspace:*",

View File

@@ -1,4 +1,5 @@
export { selfUpdate } from './self-updater/index.js'
export { exePlatformPkgDirName, exePlatformPkgDirNameNext, installPnpm, installPnpmToStore, linkExePlatformBinary } from './self-updater/installPnpm.js'
export { verifyPnpmEngineIdentity, type VerifyPnpmEngineIdentityOptions } from './self-updater/verifyPnpmEngineIdentity.js'
export { setup } from './setup/index.js'
export { withCmd } from './with/index.js'

View File

@@ -25,6 +25,8 @@ import type { DepPath, ProjectId, ProjectRootDir, Registries } from '@pnpm/types
import { familySync } from 'detect-libc'
import { symlinkDir } from 'symlink-dir'
import { verifyPnpmEngineIdentity, type VerifyPnpmEngineIdentityOptions } from './verifyPnpmEngineIdentity.js'
// @pnpm/exe has platform-specific binaries, so its GVS hash must
// include ENGINE_NAME for correct per-platform resolution.
const PNPM_ALLOW_BUILDS: Record<string, boolean> = { '@pnpm/exe': true }
@@ -40,6 +42,8 @@ export interface InstallPnpmOptions extends GlobalAddOptions {
storeController?: StoreController
storeDir?: string
packageManager?: { name: string, version: string }
/** See {@link VerifyPnpmEngineIdentityOptions.trustedKeys} — a test seam. */
trustedKeys?: VerifyPnpmEngineIdentityOptions['trustedKeys']
}
/**
@@ -81,7 +85,7 @@ export async function installPnpmToStore (
registries: Registries
virtualStoreDirMaxLength: number
packageManager?: { name: string, version: string }
}
} & VerifyPnpmEngineIdentityOptions
): Promise<{ binDir: string }> {
const currentPkgName = getCurrentPackageName()
const wantedLockfile = buildLockfileFromEnvLockfile(opts.envLockfile, currentPkgName, pnpmVersion)
@@ -100,6 +104,10 @@ export async function installPnpmToStore (
return { binDir }
}
// Reached only on a store cache miss (a genuine download), so verifying the
// pnpm engine's registry signature here does not slow down repeated commands.
await verifyPnpmEngineIdentity(opts.envLockfile, pnpmVersion, opts)
// Install to a temporary directory — headless install with GVS enabled
// will populate the global virtual store
const tmpInstallDir = path.join(opts.storeDir, '.tmp', `pnpm-${pnpmVersion}-${Date.now()}`)
@@ -188,6 +196,11 @@ async function installPnpmToGlobalDir (
try {
if (wantedLockfile != null && opts.storeController != null && opts.storeDir != null) {
if (opts.envLockfile != null) {
// Reached only when actually downloading (no matching global install),
// so the signature check does not run on every invocation.
await verifyPnpmEngineIdentity(opts.envLockfile, version, opts)
}
await installFromLockfile(installDir, binDir, {
wantedLockfile,
allowBuilds: PNPM_ALLOW_BUILDS,

View File

@@ -0,0 +1,151 @@
import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package'
import {
getNpmSigningKeys,
type InstalledPackageToVerify,
type RegistryKey,
type SignatureFailureCategory,
verifyInstalledPackageSignatures,
type VerifySignaturesOptions,
} from '@pnpm/deps.security.signatures'
import { PnpmError } from '@pnpm/error'
import type { EnvLockfile } from '@pnpm/lockfile.types'
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
import type { Registries, RegistryConfig } from '@pnpm/types'
import { familySync } from 'detect-libc'
import { exePlatformPkgDirName, exePlatformPkgDirNameNext } from './installPnpm.js'
export type VerifyPnpmEngineIdentityOptions = VerifySignaturesOptions & {
registries: Registries
configByUri?: Record<string, RegistryConfig>
/**
* The npm signing keys to trust. Defaults to {@link getNpmSigningKeys} (npm's
* embedded public keys). A test seam only — passing an empty array skips
* verification. Not reachable from project config, so it cannot be used to
* weaken verification for a real install.
*/
trustedKeys?: RegistryKey[]
}
/**
* Verifies that the pnpm engine about to be installed (and then executed) for an
* automatic version switch or self-update is genuinely the published `pnpm` —
* i.e. the bytes recorded in the env lockfile carry a valid npm registry
* signature for their exact `name@version`.
*
* The wanted pnpm version comes from a repository's `packageManager` /
* `devEngines.packageManager` field, and the project controls the lockfile
* integrity and the registry the bytes are fetched from — so without this
* check, a cloned repository could make pnpm download and run an arbitrary
* native binary. Signatures are verified against npm's embedded public keys
* (see `getNpmSigningKeys`), so a project-controlled registry cannot answer with
* its own key pair; the signed packument is fetched from the configured registry,
* which an npm mirror proxies transparently.
*
* Throws when verification detects tampering (an invalid signature), when a
* package/version is absent from the registry, or when an engine component
* present in the lockfile carries no integrity metadata — pnpm can install a
* tarball without integrity, so a missing integrity must fail closed rather
* than silently exempt that component from verification. Even an unreachable
* registry fails closed (with `PNPM_ENGINE_IDENTITY_UNVERIFIABLE`): the
* lockfile integrity is project-controlled, so it is not a safe fallback.
* This runs only when the engine is actually being installed (a store cache
* miss), so it does not add a network round trip to every command.
*/
export async function verifyPnpmEngineIdentity (
envLockfile: EnvLockfile,
pnpmVersion: string,
opts: VerifyPnpmEngineIdentityOptions
): Promise<void> {
const trustedKeys = opts.trustedKeys ?? getNpmSigningKeys()
if (trustedKeys.length === 0) return // test seam: no trusted keys means skip
const toVerify = collectEnginePackagesToVerify(envLockfile, opts.registries)
if (toVerify.length === 0) {
throw new PnpmError(
'PNPM_ENGINE_IDENTITY_UNVERIFIABLE',
`Cannot verify the identity of pnpm@${pnpmVersion}: its integrity metadata is missing from pnpm-lock.yaml.`
)
}
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {})
let result
try {
result = await verifyInstalledPackageSignatures(toVerify, trustedKeys, getAuthHeader, opts)
} catch (err: unknown) {
// Fail closed: we will not run a downloaded pnpm we could not verify, even
// when the failure is "could not reach the registry". The lockfile integrity
// is project-controlled, so it is not a safe fallback.
throw new PnpmError(
'PNPM_ENGINE_IDENTITY_UNVERIFIABLE',
`Refusing to run pnpm@${pnpmVersion}: its npm registry signature could not be verified (${String(err)}).`,
{ hint: 'The registry signing keys / packument must be reachable to verify the pnpm release. Set `pmOnFail` to `ignore` to skip the version switch.' }
)
}
if (result.verified) return
const onlyUnreachable = result.failures.every((f) => f.category === 'unreachable')
throw new PnpmError(
onlyUnreachable ? 'PNPM_ENGINE_IDENTITY_UNVERIFIABLE' : 'PNPM_ENGINE_IDENTITY_MISMATCH',
`Refusing to run pnpm@${pnpmVersion}: its npm registry signature could not be verified ` +
`(${describe(result.failures)}). The bytes selected by this project's lockfile/registry do not match a published, signed pnpm release.`,
{ hint: 'This can indicate a tampered lockfile or a malicious/unreachable registry. Set `pmOnFail` to `ignore` to skip the version switch if this is unexpected.' }
)
}
function collectEnginePackagesToVerify (envLockfile: EnvLockfile, registries: Registries): InstalledPackageToVerify[] {
const pmDeps = envLockfile.importers['.']?.packageManagerDependencies ?? {}
const toVerify: InstalledPackageToVerify[] = []
for (const name of ['pnpm', '@pnpm/exe']) {
const version = pmDeps[name]?.version
if (version == null) continue
toVerify.push(engineComponentToVerify(envLockfile, registries, { name, version }))
}
// The bytes actually executed are the host's `@pnpm/exe` platform binary,
// listed as an optional dependency of `@pnpm/exe`.
const exeVersion = pmDeps['@pnpm/exe']?.version
if (exeVersion != null) {
const optionalDeps = envLockfile.snapshots[`@pnpm/exe@${exeVersion}`]?.optionalDependencies ?? {}
const libcFamily = familySync()
const candidateNames = [
`@pnpm/${exePlatformPkgDirName(process.platform, process.arch, libcFamily)}`,
`@pnpm/${exePlatformPkgDirNameNext(process.platform, process.arch, libcFamily)}`,
]
for (const platformName of candidateNames) {
const platformVersion = optionalDeps[platformName]
if (platformVersion == null) continue
// The first candidate present in the lockfile is the binary the install
// will link and execute, so it is the one that must be verifiable.
toVerify.push(engineComponentToVerify(envLockfile, registries, { name: platformName, version: platformVersion }))
break
}
}
return toVerify
}
function engineComponentToVerify (
envLockfile: EnvLockfile,
registries: Registries,
{ name, version }: { name: string, version: string }
): InstalledPackageToVerify {
const integrity = registryIntegrity(envLockfile.packages[`${name}@${version}`]?.resolution)
if (integrity == null) {
throw new PnpmError(
'PNPM_ENGINE_IDENTITY_UNVERIFIABLE',
`Cannot verify the identity of ${name}@${version}: its integrity metadata is missing from pnpm-lock.yaml.`
)
}
return { name, version, registry: pickRegistryForPackage(registries, name), integrity }
}
function registryIntegrity (resolution: unknown): string | undefined {
const integrity = (resolution as { integrity?: unknown } | undefined)?.integrity
return typeof integrity === 'string' && integrity ? integrity : undefined
}
function describe (failures: Array<{ name: string, version: string, reason: string, category: SignatureFailureCategory }>): string {
return failures.map(({ name, version, reason }) => `${name}@${version}: ${reason}`).join('; ')
}

View File

@@ -84,6 +84,19 @@ export async function handler (
registries: opts.registries,
virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength,
packageManager: { name: packageManager.name, version: packageManager.version },
// Network settings so the engine identity check can reach the canonical
// npm registry through the user's proxy / TLS configuration.
ca: opts.ca,
cert: opts.cert,
key: opts.key,
httpProxy: opts.httpProxy,
httpsProxy: opts.httpsProxy,
noProxy: opts.noProxy,
strictSsl: opts.strictSsl,
localAddress: opts.localAddress,
maxSockets: opts.maxSockets,
configByUri: opts.configByUri,
timeout: opts.fetchTimeout,
}))
} finally {
await store.ctrl.close()

View File

@@ -66,6 +66,9 @@ function prepareOptions (dir: string) {
cacheDir: path.join(dir, '.cache'),
virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120,
dir,
// The fixture pnpm installed here is not signed with npm's real keys, so
// skip the engine identity signature check (empty trusted-keys = skip).
trustedKeys: [],
}
}

View File

@@ -0,0 +1,161 @@
import crypto from 'node:crypto'
import { afterEach, beforeEach, describe, expect, test } from '@jest/globals'
import type { EnvLockfile } from '@pnpm/lockfile.types'
import { getMockAgent, setupMockAgent, teardownMockAgent } from '@pnpm/testing.mock-agent'
import { familySync } from 'detect-libc'
const { exePlatformPkgDirName, verifyPnpmEngineIdentity } = await import('@pnpm/engine.pm.commands')
const REGISTRY = 'https://registry.example.test/'
const PNPM_INTEGRITY = 'sha512-pnpm-integrity'
const EXE_INTEGRITY = 'sha512-exe-integrity'
const PLATFORM_INTEGRITY = 'sha512-platform-integrity'
const PLATFORM_PKG_NAME = `@pnpm/${exePlatformPkgDirName(process.platform, process.arch, familySync())}`
beforeEach(async () => {
await setupMockAgent()
})
afterEach(async () => {
await teardownMockAgent()
})
describe('verifyPnpmEngineIdentity', () => {
test('resolves when both pnpm and @pnpm/exe carry a valid registry signature over the installed bytes', async () => {
const key = createSigningKey()
mockPackument('pnpm', PNPM_INTEGRITY, [{ keyid: key.keyid, sig: key.sign('pnpm@9.1.0', PNPM_INTEGRITY) }])
mockPackument('@pnpm/exe', EXE_INTEGRITY, [{ keyid: key.keyid, sig: key.sign('@pnpm/exe@9.1.0', EXE_INTEGRITY) }])
await expect(verifyPnpmEngineIdentity(envLockfile(), '9.1.0', optsTrusting(key))).resolves.toBeUndefined()
})
test('throws when the installed bytes do not match what the registry signed (tamper)', async () => {
const key = createSigningKey()
// The registry signed the genuine integrity, but the lockfile pins a different one.
mockPackument('pnpm', PNPM_INTEGRITY, [{ keyid: key.keyid, sig: key.sign('pnpm@9.1.0', 'sha512-genuine-pnpm') }])
mockPackument('@pnpm/exe', EXE_INTEGRITY, [{ keyid: key.keyid, sig: key.sign('@pnpm/exe@9.1.0', 'sha512-genuine-exe') }])
await expect(verifyPnpmEngineIdentity(envLockfile(), '9.1.0', optsTrusting(key))).rejects.toThrow(/Refusing to run pnpm/)
})
test('throws when the engine is signed by a key pnpm does not trust', async () => {
const signingKey = createSigningKey()
mockPackument('pnpm', PNPM_INTEGRITY, [{ keyid: signingKey.keyid, sig: signingKey.sign('pnpm@9.1.0', PNPM_INTEGRITY) }])
mockPackument('@pnpm/exe', EXE_INTEGRITY, [{ keyid: signingKey.keyid, sig: signingKey.sign('@pnpm/exe@9.1.0', EXE_INTEGRITY) }])
// Trust a different key than the one that signed.
await expect(verifyPnpmEngineIdentity(envLockfile(), '9.1.0', optsTrusting(createSigningKey()))).rejects.toThrow(/Refusing to run pnpm/)
})
test('throws when the engine version is absent from the registry', async () => {
getMockAgent().get(REGISTRY.replace(/\/$/, ''))
.intercept({ path: '/pnpm', method: 'GET' }).reply(404, {})
getMockAgent().get(REGISTRY.replace(/\/$/, ''))
.intercept({ path: '/@pnpm%2Fexe', method: 'GET' }).reply(404, {}) // cspell:disable-line
await expect(verifyPnpmEngineIdentity(envLockfile(), '9.1.0', optsTrusting(createSigningKey()))).rejects.toThrow(/Refusing to run pnpm/)
})
test('throws (fails closed) when the registry is unreachable', async () => {
// No intercept registered and net connect disabled, so the packument fetch fails.
await expect(verifyPnpmEngineIdentity(envLockfile(), '9.1.0', optsTrusting(createSigningKey()))).rejects.toThrow(/Refusing to run pnpm/)
})
test('skips (no throw) when no trusted keys are provided', async () => {
await expect(verifyPnpmEngineIdentity(envLockfile(), '9.1.0', { registries: { default: REGISTRY }, trustedKeys: [] })).resolves.toBeUndefined()
})
test('throws when an engine component in the lockfile has no integrity metadata', async () => {
const key = createSigningKey()
mockPackument('pnpm', PNPM_INTEGRITY, [{ keyid: key.keyid, sig: key.sign('pnpm@9.1.0', PNPM_INTEGRITY) }])
const lockfile = envLockfile()
;(lockfile.packages as Record<string, unknown>)['@pnpm/exe@9.1.0'] = { resolution: { tarball: `${REGISTRY}@pnpm/exe/-/exe-9.1.0.tgz` } }
await expect(verifyPnpmEngineIdentity(lockfile, '9.1.0', optsTrusting(key))).rejects.toThrow(/integrity metadata is missing/)
})
test('throws when the platform binary in the lockfile has no integrity metadata', async () => {
const key = createSigningKey()
mockPackument('pnpm', PNPM_INTEGRITY, [{ keyid: key.keyid, sig: key.sign('pnpm@9.1.0', PNPM_INTEGRITY) }])
mockPackument('@pnpm/exe', EXE_INTEGRITY, [{ keyid: key.keyid, sig: key.sign('@pnpm/exe@9.1.0', EXE_INTEGRITY) }])
const lockfile = envLockfile()
;(lockfile.snapshots as Record<string, unknown>)['@pnpm/exe@9.1.0'] = { optionalDependencies: { [PLATFORM_PKG_NAME]: '9.1.0' } }
;(lockfile.packages as Record<string, unknown>)[`${PLATFORM_PKG_NAME}@9.1.0`] = { resolution: { tarball: `${REGISTRY}${PLATFORM_PKG_NAME}/-/x-9.1.0.tgz` } }
await expect(verifyPnpmEngineIdentity(lockfile, '9.1.0', optsTrusting(key))).rejects.toThrow(/integrity metadata is missing/)
})
test('resolves when the platform binary carries a valid registry signature', async () => {
const key = createSigningKey()
mockPackument('pnpm', PNPM_INTEGRITY, [{ keyid: key.keyid, sig: key.sign('pnpm@9.1.0', PNPM_INTEGRITY) }])
mockPackument('@pnpm/exe', EXE_INTEGRITY, [{ keyid: key.keyid, sig: key.sign('@pnpm/exe@9.1.0', EXE_INTEGRITY) }])
mockPackument(PLATFORM_PKG_NAME, PLATFORM_INTEGRITY, [{ keyid: key.keyid, sig: key.sign(`${PLATFORM_PKG_NAME}@9.1.0`, PLATFORM_INTEGRITY) }])
const lockfile = envLockfile()
;(lockfile.snapshots as Record<string, unknown>)['@pnpm/exe@9.1.0'] = { optionalDependencies: { [PLATFORM_PKG_NAME]: '9.1.0' } }
;(lockfile.packages as Record<string, unknown>)[`${PLATFORM_PKG_NAME}@9.1.0`] = { resolution: { integrity: PLATFORM_INTEGRITY } }
await expect(verifyPnpmEngineIdentity(lockfile, '9.1.0', optsTrusting(key))).resolves.toBeUndefined()
})
})
function optsTrusting (key: ReturnType<typeof createSigningKey>) {
return {
registries: { default: REGISTRY },
trustedKeys: [{ expires: null, key: key.publicKey, keyid: key.keyid, keytype: 'ecdsa-sha2-nistp256', scheme: 'ecdsa-sha2-nistp256' }],
}
}
function envLockfile (): EnvLockfile {
return {
lockfileVersion: '9.0',
importers: {
'.': {
configDependencies: {},
packageManagerDependencies: {
pnpm: { specifier: '9.1.0', version: '9.1.0' },
'@pnpm/exe': { specifier: '9.1.0', version: '9.1.0' },
},
},
},
packages: {
'pnpm@9.1.0': { resolution: { integrity: PNPM_INTEGRITY } },
'@pnpm/exe@9.1.0': { resolution: { integrity: EXE_INTEGRITY } },
},
snapshots: {
'pnpm@9.1.0': {},
'@pnpm/exe@9.1.0': {},
},
} as unknown as EnvLockfile
}
function mockPackument (name: string, integrity: string, signatures: unknown): void {
const encodedPath = name[0] === '@' ? `/${name.replace(/\//g, '%2F')}` : `/${name}`
getMockAgent().get(REGISTRY.replace(/\/$/, ''))
.intercept({ path: encodedPath, method: 'GET' })
.reply(200, {
name,
time: { '9.1.0': '2024-01-01T00:00:00.000Z' },
versions: {
'9.1.0': { name, version: '9.1.0', dist: { integrity, signatures, tarball: `${REGISTRY}${name}/-/x-9.1.0.tgz` } },
},
}).persist()
}
function createSigningKey (): { keyid: string, publicKey: string, sign: (id: string, integrity: string) => string } {
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' })
const publicKeyPem = publicKey.export({ format: 'pem', type: 'spki' }).toString()
return {
keyid: `SHA256:test-key-${crypto.randomBytes(4).toString('hex')}`,
publicKey: publicKeyPem.replace(/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s/g, ''),
sign: (id, integrity) => {
const signer = crypto.createSign('SHA256')
signer.write(`${id}:${integrity}`)
signer.end()
return signer.sign(privateKey, 'base64')
},
}
}

View File

@@ -25,6 +25,9 @@
{
"path": "../../../cli/utils"
},
{
"path": "../../../config/pick-registry-for-package"
},
{
"path": "../../../config/reader"
},
@@ -46,6 +49,9 @@
{
"path": "../../../deps/graph-hasher"
},
{
"path": "../../../deps/security/signatures"
},
{
"path": "../../../global/commands"
},
@@ -67,6 +73,9 @@
{
"path": "../../../lockfile/types"
},
{
"path": "../../../network/auth-header"
},
{
"path": "../../../resolving/npm-resolver"
},

View File

@@ -46,6 +46,7 @@
"@pnpm/constants": "workspace:*",
"@pnpm/deps.inspection.outdated": "workspace:*",
"@pnpm/deps.path": "workspace:*",
"@pnpm/deps.security.signatures": "workspace:*",
"@pnpm/deps.status": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/fs.graceful-fs": "workspace:*",
@@ -56,7 +57,10 @@
"@pnpm/installing.dedupe.check": "workspace:*",
"@pnpm/installing.deps-installer": "workspace:*",
"@pnpm/installing.env-installer": "workspace:*",
"@pnpm/lockfile.fs": "workspace:*",
"@pnpm/lockfile.types": "workspace:*",
"@pnpm/network.auth-header": "workspace:*",
"@pnpm/network.fetch": "workspace:*",
"@pnpm/pkg-manifest.reader": "workspace:*",
"@pnpm/pkg-manifest.utils": "workspace:*",
"@pnpm/resolving.npm-resolver": "workspace:*",

View File

@@ -52,6 +52,7 @@ import {
} from './recursive.js'
import { makeRunPacquet } from './runPacquet.js'
import { createWorkspaceSpecs, updateToWorkspacePackagesFromManifest } from './updateWorkspaceDependencies.js'
import { verifyPacquetIdentity } from './verifyPacquetIdentity.js'
const OVERWRITE_UPDATE_OPTIONS = {
allowNew: true,
@@ -217,11 +218,26 @@ export async function installDeps (
// optional `@pacquet/<plat>-<arch>` binary sub-packages, so the
// resolved \`node_modules/.pnpm-config/<name>\` layout pacquet's
// wrapper expects is identical either way.
const pacquetConfigDepName = opts.configDependencies?.['@pnpm/pacquet'] != null
//
// `configDependencies` come from the repository's `pnpm-workspace.yaml`, so
// the declaration cannot be trusted to authorize spawning a native binary on
// its own. `verifyPacquetIdentity` confirms, against the canonical npm
// registry, that the installed bytes carry a valid registry signature for
// that `name@version` before we delegate; otherwise we fall back to pnpm's
// own engine.
const declaredPacquetConfigDepName = opts.configDependencies?.['@pnpm/pacquet'] != null
? '@pnpm/pacquet'
: opts.configDependencies?.pacquet != null
? 'pacquet'
: undefined
const pacquetConfigDepName = declaredPacquetConfigDepName != null &&
await verifyPacquetIdentity(declaredPacquetConfigDepName, {
...opts,
lockfileDir: opts.lockfileDir ?? opts.dir,
rootDir: opts.lockfileDir ?? opts.dir,
})
? declaredPacquetConfigDepName
: undefined
const runPacquet = pacquetConfigDepName != null
? makeRunPacquet({
lockfileDir: opts.lockfileDir ?? opts.dir,

View File

@@ -0,0 +1,120 @@
import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package'
import {
getNpmSigningKeys,
type InstalledPackageToVerify,
verifyInstalledPackageSignatures,
} from '@pnpm/deps.security.signatures'
import { PnpmError } from '@pnpm/error'
import { readEnvLockfile } from '@pnpm/lockfile.fs'
import { logger } from '@pnpm/logger'
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
import type { CreateFetchFromRegistryOptions, RetryTimeoutOptions } from '@pnpm/network.fetch'
import type { Registries, RegistryConfig } from '@pnpm/types'
export interface VerifyPacquetIdentityOptions extends CreateFetchFromRegistryOptions {
lockfileDir: string
rootDir: string
registries: Registries
configByUri?: Record<string, RegistryConfig>
retry?: RetryTimeoutOptions
timeout?: number
networkConcurrency?: number
}
/**
* Decides whether pnpm may spawn the pacquet binary installed under
* `node_modules/.pnpm-config/<packageName>` as an install engine.
*
* A repository declares pacquet in its `pnpm-workspace.yaml`
* `configDependencies` and controls the lockfile integrity and the registry
* the bytes came from — so the declaration alone cannot authorize running a
* native binary. This verifies that the exact bytes installed on disk (the
* `pacquet` shim and the host's `@pacquet/<platform>-<arch>` binary, which is
* what actually executes) carry a valid npm registry signature for that
* `name@version`, checked against npm's embedded public keys. The signature is
* verified over the *installed* integrity, so substituted or tampered bytes
* fail — and because the keys are embedded rather than fetched, a repository
* pointing the registry at a server it controls cannot supply its own key pair.
*
* Fails closed: if a declared pacquet's signature does not verify, or it cannot
* be verified (e.g. the registry is unreachable), this throws rather than
* silently running pnpm's own engine — a verification failure should be loud.
* The one exception is when pacquet simply has no binary for this platform: that
* is unavailability, not tampering, so it returns `false` and the caller uses
* pnpm's own install engine.
*/
export async function verifyPacquetIdentity (
packageName: 'pacquet' | '@pnpm/pacquet',
opts: VerifyPacquetIdentityOptions
): Promise<boolean> {
const trustedKeys = getNpmSigningKeys()
const toVerify = await collectPacquetPackagesToVerify(packageName, opts.rootDir, opts.registries)
if (toVerify == null) {
// pacquet has no installed binary for this platform — use pnpm's own engine.
return skip(opts.lockfileDir)
}
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {})
let result
try {
result = await verifyInstalledPackageSignatures(toVerify, trustedKeys, getAuthHeader, opts)
} catch (err: unknown) {
throw new PnpmError(
'PACQUET_IDENTITY_UNVERIFIABLE',
`Refusing to use pacquet as the install engine: its npm registry signature could not be verified (${String(err)}).`,
{ hint: 'The registry must be reachable to verify the pacquet release declared in configDependencies. Remove pacquet from configDependencies to use pnpm\'s own install engine.' }
)
}
if (!result.verified) {
const detail = result.failures.map(({ name, version, reason }) => `${name}@${version}: ${reason}`).join('; ')
throw new PnpmError(
'PACQUET_IDENTITY_MISMATCH',
`Refusing to use pacquet as the install engine: the bytes installed for "${packageName}" do not match a published, signed release (${detail}).`,
{ hint: 'This can indicate a tampered lockfile or a malicious registry. Remove pacquet from configDependencies if this is unexpected.' }
)
}
return true
}
async function collectPacquetPackagesToVerify (
packageName: string,
rootDir: string,
registries: Registries
): Promise<InstalledPackageToVerify[] | undefined> {
const envLockfile = await readEnvLockfile(rootDir)
if (envLockfile == null) return undefined
const shim = envLockfile.importers['.']?.configDependencies?.[packageName]
if (shim == null) return undefined
const shimKey = `${packageName}@${shim.version}`
const shimIntegrity = registryIntegrity(envLockfile.packages[shimKey]?.resolution)
if (shimIntegrity == null) return undefined
// Only the host's platform binary is ever spawned, so that's the one whose
// identity matters. If it isn't in the lockfile, pacquet couldn't run here.
const platformPkgName = `@pacquet/${process.platform}-${process.arch}`
const platformVersion = envLockfile.snapshots[shimKey]?.optionalDependencies?.[platformPkgName]
if (platformVersion == null) return undefined
const platformKey = `${platformPkgName}@${platformVersion}`
const platformIntegrity = registryIntegrity(envLockfile.packages[platformKey]?.resolution)
if (platformIntegrity == null) return undefined
return [
{ name: packageName, version: shim.version, registry: pickRegistryForPackage(registries, packageName), integrity: shimIntegrity },
{ name: platformPkgName, version: platformVersion, registry: pickRegistryForPackage(registries, platformPkgName), integrity: platformIntegrity },
]
}
function registryIntegrity (resolution: unknown): string | undefined {
const integrity = (resolution as { integrity?: unknown } | undefined)?.integrity
return typeof integrity === 'string' && integrity ? integrity : undefined
}
function skip (prefix: string): false {
logger.warn({
message: "Not using pacquet as the install engine: no pacquet binary is installed for this platform. Using pnpm's own install engine.",
prefix,
})
return false
}

View File

@@ -66,6 +66,9 @@
{
"path": "../../deps/path"
},
{
"path": "../../deps/security/signatures"
},
{
"path": "../../deps/status"
},
@@ -81,9 +84,18 @@
{
"path": "../../hooks/pnpmfile"
},
{
"path": "../../lockfile/fs"
},
{
"path": "../../lockfile/types"
},
{
"path": "../../network/auth-header"
},
{
"path": "../../network/fetch"
},
{
"path": "../../pkg-manifest/reader"
},

View File

@@ -26,6 +26,8 @@
"lint:meta": "pn meta-updater --test",
"copy-artifacts": "node __utils__/scripts/src/copy-artifacts.ts",
"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",
"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"
},

21
pnpm-lock.yaml generated
View File

@@ -3880,6 +3880,9 @@ importers:
'@pnpm/cli.utils':
specifier: workspace:*
version: link:../../../cli/utils
'@pnpm/config.pick-registry-for-package':
specifier: workspace:*
version: link:../../../config/pick-registry-for-package
'@pnpm/config.reader':
specifier: workspace:*
version: link:../../../config/reader
@@ -3889,6 +3892,9 @@ importers:
'@pnpm/deps.graph-hasher':
specifier: workspace:*
version: link:../../../deps/graph-hasher
'@pnpm/deps.security.signatures':
specifier: workspace:*
version: link:../../../deps/security/signatures
'@pnpm/error':
specifier: workspace:*
version: link:../../../core/error
@@ -3913,6 +3919,9 @@ importers:
'@pnpm/lockfile.types':
specifier: workspace:*
version: link:../../../lockfile/types
'@pnpm/network.auth-header':
specifier: workspace:*
version: link:../../../network/auth-header
'@pnpm/os.env.path-extender':
specifier: 'catalog:'
version: 3.0.1
@@ -5229,6 +5238,9 @@ importers:
'@pnpm/deps.path':
specifier: workspace:*
version: link:../../deps/path
'@pnpm/deps.security.signatures':
specifier: workspace:*
version: link:../../deps/security/signatures
'@pnpm/deps.status':
specifier: workspace:*
version: link:../../deps/status
@@ -5259,9 +5271,18 @@ importers:
'@pnpm/installing.env-installer':
specifier: workspace:*
version: link:../env-installer
'@pnpm/lockfile.fs':
specifier: workspace:*
version: link:../../lockfile/fs
'@pnpm/lockfile.types':
specifier: workspace:*
version: link:../../lockfile/types
'@pnpm/network.auth-header':
specifier: workspace:*
version: link:../../network/auth-header
'@pnpm/network.fetch':
specifier: workspace:*
version: link:../../network/fetch
'@pnpm/pkg-manifest.reader':
specifier: workspace:*
version: link:../../pkg-manifest/reader

View File

@@ -89,6 +89,19 @@ export async function switchCliVersion (config: Config, context: ConfigContext):
registries: config.registries,
virtualStoreDirMaxLength: config.virtualStoreDirMaxLength,
packageManager: { name: packageManager.name, version: packageManager.version },
// Network settings so the engine identity check can reach the canonical
// npm registry through the user's proxy / TLS configuration.
ca: config.ca,
cert: config.cert,
key: config.key,
httpProxy: config.httpProxy,
httpsProxy: config.httpsProxy,
noProxy: config.noProxy,
strictSsl: config.strictSsl,
localAddress: config.localAddress,
maxSockets: config.maxSockets,
configByUri: config.configByUri,
timeout: config.fetchTimeout,
}))
} finally {
await storeToUse.ctrl.close()