Skip to content

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

Merged
zkochan merged 9 commits into
mainfrom
verify-install-engine-registry-signature
Jun 9, 2026
Merged

fix(security): verify npm registry signature before spawning a package-manager binary#12292
zkochan merged 9 commits into
mainfrom
verify-install-engine-registry-signature

Conversation

@zkochan

@zkochan zkochan commented Jun 9, 2026

Copy link
Copy Markdown
Member

Summary

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.

Testing

  • @pnpm/deps.security.signatures: 17 (signature verification incl. tamper / untrusted-key cases; getNpmSigningKeys returns the embedded keys).
  • engine unit/integration: selfUpdate 46 + verifyPnpmEngineIdentity 6 (valid, tamper, untrusted-key, absent, unreachable-fails-closed, skip).
  • e2e (rebuilt bundle): switchingVersions 12, install/selfUpdate, packageManagerCheck, withCommand, install/pacquet — all passing, verifying the real signed pnpm/pacquet packages against the embedded keys (Verdaccio preserves the upstream dist.signatures).
  • Key sync script verified against live npm; builds + lint + meta-updater + cspell clean.

Written by an agent (Claude Code, claude-opus-4-8).

Summary by CodeRabbit

  • New Features

    • Added npm registry signature verification for package-manager binaries to ensure authenticity and prevent execution of tampered or unauthorized executables.
    • Verification is applied automatically during pnpm updates and when using alternative install engines.
  • Chores

    • Added npm signing key management scripts for maintaining embedded registry verification keys.

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7eb00d97-b657-41a7-bba6-4d4177af9550

📥 Commits

Reviewing files that changed from the base of the PR and between 91d72bb and 50ac1f3.

📒 Files selected for processing (2)
  • engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts
  • engine/pm/commands/test/self-updater/verifyPnpmEngineIdentity.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • engine/pm/commands/test/self-updater/verifyPnpmEngineIdentity.test.ts
  • engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts
📜 Recent review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Compile & Lint

📝 Walkthrough

Walkthrough

This PR implements npm registry signature verification for pnpm and pacquet binaries before execution. It adds npm signing key infrastructure, embedded key data, installed-package verification APIs, and integrates verification into pnpm self-update and pacquet delegation flows.

Changes

npm Registry Signature Verification for Package Managers

Layer / File(s) Summary
npm Signing Keys Infrastructure and Core Signature Verification APIs
deps/security/signatures/scripts/update-npm-signing-keys.mjs, deps/security/signatures/src/npmSigningKeys.ts, deps/security/signatures/src/verifySignatures.ts, deps/security/signatures/test/verifySignatures.ts
npm key sync script with check/update modes; embedded npm signing keys; signature verification reworked to accept any valid trusted signature; installed-package verification APIs with failure categorization (invalid, absent, unreachable); test coverage for all APIs.
pnpm Engine Verification Logic and Collection Helpers
engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts
verifyPnpmEngineIdentity verifies pnpm engine packages (pnpm, @pnpm/exe, platform-specific @pnpm/) match npm-published signatures; collects lockfile integrities and platform binaries; distinguishes unreachable vs. integrity-mismatch failures.
pnpm Engine Verification Integration into Install Flows
engine/pm/commands/src/self-updater/installPnpm.ts, engine/pm/commands/src/index.ts, engine/pm/commands/test/self-updater/verifyPnpmEngineIdentity.test.ts, engine/pm/commands/test/self-updater/selfUpdate.test.ts
Integrates verification into installPnpmToStore (cache-miss path) and installPnpmToGlobalDir; adds trustedKeys test seam; exports verifyPnpmEngineIdentity API; comprehensive test suite with mock registry fixtures and ECDSA key generation.
pacquet Engine Verification Logic and Installation Gating
installing/commands/src/verifyPacquetIdentity.ts, installing/commands/src/installDeps.ts
verifyPacquetIdentity verifies pacquet shim and platform-specific @pacquet/- binary signatures; gates pacquet delegation in installDeps on successful verification; returns false when platform unavailable, throws on signature failures.
Network Configuration Forwarding and Dependency Wiring
pnpm/src/switchCliVersion.ts, engine/pm/commands/src/with/with.ts, engine/pm/commands/package.json, engine/pm/commands/tsconfig.json, installing/commands/package.json, installing/commands/tsconfig.json, package.json, .github/workflows/create-release-pr.yml, .changeset/pacquet-install-engine-identity.md
Forwards network/TLS/proxy config to pnpm install calls; adds workspace dependencies for signature verification modules; updates TypeScript references; adds npm-signing-keys check/update scripts; release workflow step to refresh embedded keys; changeset documenting security feature and version bumps.

Sequence Diagram(s)

sequenceDiagram
  participant KeySync as update-npm-signing-keys.mjs
  participant NpmKeys as npm /-/npm/v1/keys
  participant EmbeddedKeys as npmSigningKeys.ts
  participant InstallPnpm as installPnpmToStore/installPnpmToGlobalDir
  participant VerifyEngine as verifyPnpmEngineIdentity
  participant VerifyInstalled as verifyInstalledPackageSignatures
  participant Registry as npm registry

  KeySync->>NpmKeys: fetch advertised signing keys
  KeySync->>EmbeddedKeys: merge and refresh NPM_SIGNING_KEYS
  InstallPnpm->>VerifyEngine: verify engine packages
  VerifyEngine->>VerifyInstalled: verify installed signatures
  VerifyInstalled->>Registry: fetch packuments for pnpm/@pnpm/exe/@pnpm/platform
  Registry-->>VerifyInstalled: dist.integrity and dist.signatures
  VerifyInstalled-->>VerifyEngine: categorized failures or verified: true
  VerifyEngine-->>InstallPnpm: continue or throw error
Loading
sequenceDiagram
  participant InstallDeps as installDeps
  participant VerifyPacquet as verifyPacquetIdentity
  participant Collect as collectPacquetPackagesToVerify
  participant VerifyInstalled as verifyInstalledPackageSignatures
  participant Registry as npm registry

  InstallDeps->>VerifyPacquet: verify pacquet before delegation
  VerifyPacquet->>Collect: collect pacquet shim and platform binary
  VerifyPacquet->>VerifyInstalled: verify installed signatures
  VerifyInstalled->>Registry: fetch packuments
  Registry-->>VerifyInstalled: packument data
  VerifyInstalled-->>VerifyPacquet: verification result
  VerifyPacquet-->>InstallDeps: return true/false or throw
  InstallDeps->>InstallDeps: enable/disable pacquet delegation
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • pnpm/pnpm#11734: Modifies pacquet delegation initialization in installing/commands/src/installDeps.ts; this PR gates the same delegation point with identity verification.
  • pnpm/pnpm#11781: Changes how command-line flags are forwarded to pacquet in the delegation flow; this PR adds a verification checkpoint before that delegation path.

Poem

🐰 Hopping down the registry trail,
Where signatures vouch: "This is real!"
Pacquet and pnpm both check their mail—
Tampered binaries won't steal the deal.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.59% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main security enhancement: adding npm registry signature verification for package-manager binaries before execution.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch verify-install-engine-registry-signature

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

zkochan added 4 commits June 9, 2026 20:27
…e-manager binary

A repository can make pnpm download and execute a native binary through two
repository-controlled inputs, neither of which was authenticated:

- `configDependencies` naming `pacquet`/`@pnpm/pacquet`, which opts in to the
  pacquet install engine and spawns `@pacquet/<platform>-<arch>`.
- the `packageManager` / `devEngines.packageManager` field, which makes pnpm
  download and run a specific pnpm version (on by default; also `self-update`
  and `pnpm with`).

In both cases the repository also controls the lockfile integrity and the
registry the bytes come from, so integrity-matching alone proves nothing.

pnpm now verifies the npm registry signature (`dist.signatures` against the
registry's ECDSA-P256 signing keys) of the bytes it is about to spawn, over the
*installed* integrity, against the canonical `registry.npmjs.org`. Substituted
or tampered bytes fail verification.

- New `verifyInstalledPackageSignatures()` in `@pnpm/deps.security.signatures`.
- pacquet: verify the shim and the host platform binary; on failure fall back
  to pnpm's own install engine.
- pnpm engine: verify `pnpm`, `@pnpm/exe`, and the host platform binary, only on
  a store cache miss (no per-command network cost); throw on tamper, warn and
  proceed when the trust root is merely unreachable (offline / mirror). The
  trust root is overridable for npm mirrors via `PNPM_ENGINE_IDENTITY_REGISTRY`.
Instead of fetching signing keys from a hardcoded canonical registry (and
overriding it via PNPM_ENGINE_IDENTITY_REGISTRY for mirrors/tests), embed npm's
public signing keys in the pnpm CLI, like corepack does. The registry-served
`dist.signatures` are verified against the embedded keys, so:

- An npm mirror works transparently: it proxies the same signed packument, with
  no override needed.
- A repository pointing the registry at a server it controls cannot supply its
  own keypair to forge a signature.

The signed packument is fetched from the configured registry. Signature
verification can be disabled, or the trusted keys overridden, with the
PNPM_NPM_SIGNING_KEYS environment variable (`0` disables; a `{"keys":[...]}`
document overrides) — a process-level setting, not project config.

The embedded keys live in a generated file. A script keeps them in sync with
npm's `-/npm/v1/keys` endpoint, and the create-release-pr workflow runs it as a
gate so a key rotation cannot silently break verification:

    node deps/security/signatures/scripts/update-npm-signing-keys.mjs [--update]
@zkochan zkochan force-pushed the verify-install-engine-registry-signature branch from 632b72c to 82ce79c Compare June 9, 2026 18:43
Address review feedback:

- Remove the `PNPM_NPM_SIGNING_KEYS` environment variable. It was an
  unnecessary "turn off signature verification" footgun: with the keys embedded
  in the CLI, npm mirrors already work without any override (they proxy the
  signed packument), and the keys are kept fresh by the release-time check. The
  embedded keys are now the only trust root; tests inject keys through a code
  parameter rather than the environment.

- Fail closed. The pnpm-engine version switch now throws when verification
  cannot be completed (e.g. the registry is unreachable) instead of proceeding
  on the project-controlled lockfile integrity. Likewise pacquet now refuses to
  run (failing the command) when its signature does not verify or cannot be
  checked, rather than silently falling back to pnpm's own engine — the only
  graceful fallback left is when pacquet has no binary for the current platform.
@zkochan zkochan marked this pull request as ready for review June 9, 2026 19:11
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (5) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0)

Grey Divider


Action required

1. Partial engine verification allowed ✓ Resolved 🐞 Bug ⛨ Security
Description
collectEnginePackagesToVerify() silently skips engine components whose lockfile entry lacks
resolution.integrity (including the @pnpm/* platform binary), and for the platform binary it breaks
after the first matching candidate even if that candidate wasn’t verifiable. This can allow a
version switch/self-update to proceed after verifying only a subset of components, despite pnpm
still being able to download/install packages without integrity metadata.
Code

engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts[R92-123]

+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
+    const integrity = registryIntegrity(envLockfile.packages[`${name}@${version}`]?.resolution)
+    if (integrity != null) {
+      toVerify.push({ name, version, registry: pickRegistryForPackage(registries, name), integrity })
+    }
+  }
+
+  // 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
+      const integrity = registryIntegrity(envLockfile.packages[`${platformName}@${platformVersion}`]?.resolution)
+      if (integrity != null) {
+        toVerify.push({ name: platformName, version: platformVersion, registry: pickRegistryForPackage(registries, platformName), integrity })
+      }
+      break
+    }
Evidence
The engine verifier only adds packages to the verification set when it can read a non-empty
integrity string, and the platform-binary selection loop breaks even if no verifiable entry was
added. Separately, the fetch/install path explicitly supports downloading without integrity, so
skipping verification due to missing integrity can still result in downloaded bytes being executed.

engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts[92-123]
fetching/tarball-fetcher/src/index.ts[71-104]
store/index/src/index.ts[79-94]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`collectEnginePackagesToVerify()` currently *skips* entries when `resolution.integrity` is missing, and the platform-binary loop `break`s even when the first matching candidate cannot be verified (e.g., missing integrity). This allows **partial verification** and can skip verification of the **actual executed** platform binary.
### Issue Context
pnpm can still download/install tarballs even when `integrity` is missing, so “missing integrity” is not a safe reason to proceed without verification.
### Fix Focus Areas
- engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts[92-127]
### What to change
- Treat missing integrity for any *required* engine component (pnpm, @pnpm/exe, and the resolved platform binary) as **unverifiable** and throw `PNPM_ENGINE_IDENTITY_UNVERIFIABLE` (fail closed).
- In the `candidateNames` loop, only `break` after you have successfully collected a verifiable platform package (i.e., version exists *and* integrity exists). If a candidate name exists in `optionalDependencies` but its integrity is missing, continue to the next candidate name; if none are verifiable, fail closed with a clear error.
- Consider returning a structured result from `collectEnginePackagesToVerify()` (e.g., `{toVerify, missing: [...]}`) so the caller can emit a precise error indicating which component(s) lacked integrity metadata.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. With drops auth/proxy opts ✓ Resolved 🐞 Bug ☼ Reliability
Description
installPnpmToStore() now performs a packument fetch via verifyPnpmEngineIdentity(opts) on cache
misses, but the pnpm with command calls installPnpmToStore() without forwarding
configByUri/proxy/TLS-related fetch options, so signature verification can fail in
authenticated/proxied environments even if normal installs would work.
Code

engine/pm/commands/src/self-updater/installPnpm.ts[R107-110]

+  // 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)
+
Evidence
installPnpmToStore() now triggers network verification using the passed opts, while pnpm with
passes a reduced set of options that omits configByUri and other network settings needed for
packument fetch/auth.

engine/pm/commands/src/self-updater/installPnpm.ts[91-110]
engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts[67-71]
engine/pm/commands/src/with/with.ts[64-88]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`installPnpmToStore()` now calls `verifyPnpmEngineIdentity(opts)` which uses `createFetchFromRegistry(opts)` and `createGetAuthHeaderByURI(opts.configByUri ?? {})` to fetch packuments and authenticate. The `pnpm with` command currently passes only a minimal option object into `installPnpmToStore()`, dropping `configByUri` and proxy/TLS settings, which can break verification behind proxies or when registries require auth.
### Issue Context
Unlike `switchCliVersion()` (which explicitly forwards proxy/TLS options), `with.ts` constructs a narrow options object when calling `installPnpmToStore()`. After this PR, that call path performs additional network requests outside the store controller, so it must receive the same network/auth settings.
### Fix Focus Areas
- engine/pm/commands/src/with/with.ts[64-88]
- engine/pm/commands/src/self-updater/installPnpm.ts[99-125]
- engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts[67-71]
### Suggested fix
Update the `installPnpmToStore()` call in `engine/pm/commands/src/with/with.ts` to forward relevant network/auth options (at minimum `configByUri`, and proxy/TLS dispatcher options like `ca/cert/key/httpProxy/httpsProxy/noProxy/strictSsl/localAddress/maxSockets`, plus `timeout` if `fetchTimeout` is available). A simple approach is to spread `opts` into the call and then override `envLockfile/storeController/storeDir/...` fields last, and map `timeout: opts.fetchTimeout` if needed.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Retries not forwarded 🐞 Bug ☼ Reliability
Description
The new pnpm-engine identity verification uses opts.retry for packument fetches, but the
version-switch entrypoints only forward timeout (and proxy/TLS fields) and never construct/pass a
retry policy from fetchRetries/fetchRetry*. Because verification fails closed, transient
registry hiccups that pnpm would normally retry can now abort packageManager version switching /
pnpm with unnecessarily.
Code

pnpm/src/switchCliVersion.ts[R92-105]

+      // 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,
   }))
Evidence
switchCliVersion and pnpm with pass timeout but no retry into installPnpmToStore, while
fetchPackument only applies retries via retry: opts.retry. pnpm’s config model supports
fetchRetries/fetchRetry*, and the store controller demonstrates the intended translation into a
retry object—this translation is missing specifically in these new verification call paths.

pnpm/src/switchCliVersion.ts[83-105]
engine/pm/commands/src/with/with.ts[80-100]
deps/security/signatures/src/verifySignatures.ts[211-226]
config/reader/src/Config.ts[99-105]
store/connection-manager/src/createNewStoreController.ts[100-109]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The engine identity signature verification fetch path (`verifyInstalledPackageSignatures` → `fetchPackument`) only retries when `VerifySignaturesOptions.retry` is provided. The version-switch entrypoints (`switchCliVersion`, `pnpm with`) currently pass `timeout` and proxy/TLS settings but omit `retry`, so these verification requests don’t honor pnpm’s configured retry policy (`fetchRetries`, `fetchRetryFactor`, `fetchRetryMintimeout`, `fetchRetryMaxtimeout`). Since verification fails closed, this can cause avoidable version-switch failures on transient registry/network errors.
### Issue Context
pnpm already has a standard mapping from `fetchRetry*` config to the network client `retry` object (see store controller creation). The signature verification calls should use the same mapping.
### Fix Focus Areas
- pnpm/src/switchCliVersion.ts[83-105]
- engine/pm/commands/src/with/with.ts[80-100]
### Proposed fix
1. In both call sites, add a `retry` field when calling `installPnpmToStore`, e.g.:

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Failures always marked invalid ✓ Resolved 🐞 Bug ≡ Correctness
Description
findSignatureFailure() returns category: 'invalid' for any signature verification issue
(including “unknown key” / “expired key”), contradicting the documented semantics where invalid is
specifically about a signature not validating over installed bytes (tamper signal).
Code

deps/security/signatures/src/verifySignatures.ts[R499-505]

+  // 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' }
Evidence
The type doc defines invalid narrowly, but the implementation always returns invalid for any
non-success outcome from verifyPackageSignatures().

deps/security/signatures/src/verifySignatures.ts[406-415]
deps/security/signatures/src/verifySignatures.ts[469-506]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`SignatureFailureCategory` documentation distinguishes `invalid` (signature present but does not validate over installed bytes) from `absent` (no trusted signature / not published) and `unreachable`. However, `findSignatureFailure()` currently maps *any* `verifyPackageSignatures()` issue to `category: 'invalid'`, even when the reason is “no corresponding public key can be found” or “key expired”.
### Issue Context
This impacts downstream error classification/messaging (and any branching logic based on `category`), making untrusted-key cases look like byte-tampering.
### Fix Focus Areas
- deps/security/signatures/src/verifySignatures.ts[406-416]
- deps/security/signatures/src/verifySignatures.ts[469-506]
### Suggested fix
Derive `category` based on the specific failure reason:
- Keep `invalid` only for actual cryptographic verification failures (e.g. reason contains `invalid registry signature`).
- Treat “no corresponding public key” and “public key has expired” as `absent` (or introduce a new explicit category like `untrusted` if you want to preserve more detail).
Add/adjust unit tests in `deps/security/signatures/test/verifySignatures.ts` to assert the expected categories for unknown-key/expired-key scenarios.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Pacquet check skips silently 🐞 Bug ≡ Correctness
Description
verifyPacquetIdentity() returns false (fallback to pnpm engine) whenever
collectPacquetPackagesToVerify() returns undefined, but that undefined currently covers many “cannot
verify / metadata missing” cases (e.g. env lockfile unreadable, missing integrity), not just “no
platform binary”. This contradicts the documented “fail closed unless pacquet has no binary”
behavior and emits a misleading warning that can hide real verification/unverifiable states.
Code

installing/commands/src/verifyPacquetIdentity.ts[R51-55]

+  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)
+  }
Evidence
verifyPacquetIdentity() converts toVerify == null into a platform-unavailable skip, but
collectPacquetPackagesToVerify() returns undefined for many non-platform reasons (env lockfile
missing/unreadable, shim/integrity missing). readEnvLockfile() can return null for missing/invalid
lockfile content, meaning these cases are reachable and will be mislabeled as “no pacquet binary”.

installing/commands/src/verifyPacquetIdentity.ts[46-56]
installing/commands/src/verifyPacquetIdentity.ts[80-107]
installing/commands/src/verifyPacquetIdentity.ts[114-119]
lockfile/fs/src/envLockfile.ts[27-56]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`verifyPacquetIdentity()` treats any `undefined` result from `collectPacquetPackagesToVerify()` as “no pacquet binary installed” and returns `false`, which makes `installDeps()` silently fall back to pnpm’s JS engine. However, `collectPacquetPackagesToVerify()` returns `undefined` for multiple other reasons (missing/unreadable env lockfile, missing integrity metadata, etc.), which are *verification-impossible* states and should not be labeled as “platform binary unavailable”.
## Issue Context
The function’s docstring explicitly states it should **fail closed** when signature verification cannot be performed, with the **only** graceful fallback being “pacquet has no binary for this platform”. The current implementation collapses many cases into the fallback path and logs an inaccurate warning.
## Fix Focus Areas
- installing/commands/src/verifyPacquetIdentity.ts[46-107]
- lockfile/fs/src/envLockfile.ts[27-56]
### Suggested implementation approach
1. Change `collectPacquetPackagesToVerify()` to return a richer result, e.g.:
- `{ kind: 'unsupported_platform' }` (only when platform optional dep is missing)
- `{ kind: 'unverifiable', reason: '...' }` (missing env lockfile, missing shim entry, missing integrity, etc.)
- `{ kind: 'verify', packages: InstalledPackageToVerify[] }`
2. In `verifyPacquetIdentity()`:
- Return `false` only for `unsupported_platform`.
- Throw `PnpmError('PACQUET_IDENTITY_UNVERIFIABLE', ...)` for `unverifiable` cases (so the “cannot be checked” path is loud as intended).
3. Update the warning message in `skip()` to match the actual fallback condition (platform unsupported), or remove it if you switch to throwing in all other cases.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (2)
6. Pacquet fetch policy mismatch 🐞 Bug ☼ Reliability
Description
installDeps() calls verifyPacquetIdentity() without mapping pnpm’s configured fetch timeout/retry
settings (fetchTimeout/fetchRetry*) into the timeout/retry options that @pnpm/network.fetch
actually consumes for these signature-packument requests. Because pacquet identity verification
failures are fatal, this can make installs less resilient (different retries) and ignore user
timeout expectations for registry access.
Code

installing/commands/src/installDeps.ts[R233-238]

+  const pacquetConfigDepName = declaredPacquetConfigDepName != null &&
+    await verifyPacquetIdentity(declaredPacquetConfigDepName, {
+      ...opts,
+      lockfileDir: opts.lockfileDir ?? opts.dir,
+      rootDir: opts.lockfileDir ?? opts.dir,
+    })
Evidence
The signature-verification fetch path reads opts.timeout/opts.retry, and the underlying fetch
layer defaults timeout to 60000ms if unset. pnpm’s standard network wiring uses fetchTimeout and
fetchRetry* to configure timeout/retry, but the new pacquet call-site does not translate these
into timeout/retry, so the verifier requests use defaults instead of user-configured policy.

installing/commands/src/installDeps.ts[228-240]
deps/security/signatures/src/verifySignatures.ts[220-226]
deps/security/signatures/src/verifySignatures.ts[164-169]
network/fetch/src/fetchFromRegistry.ts[91-113]
store/connection-manager/src/createNewStoreController.ts[20-30]
store/connection-manager/src/createNewStoreController.ts[101-109]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The pacquet identity verification path uses `verifyInstalledPackageSignatures()` → `fetchPackument()` which passes `opts.retry` and `opts.timeout` into `createFetchFromRegistry()`. In `installDeps()`, the options passed to `verifyPacquetIdentity()` are `...opts` from CLI/config, but pnpm’s networking config uses `fetchTimeout` and `fetchRetry*` fields rather than `timeout`/`retry`, so the verification fetches fall back to defaults.
## Issue Context
This is a new network path that can hard-fail installs (verification is fail-closed). It should behave consistently with pnpm’s other registry traffic, including honoring configured timeouts and retry policy.
## Fix Focus Areas
- installing/commands/src/installDeps.ts[228-240]
- installing/commands/src/verifyPacquetIdentity.ts[46-61]
- deps/security/signatures/src/verifySignatures.ts[211-226]
- network/fetch/src/fetchFromRegistry.ts[91-113]
- store/connection-manager/src/createNewStoreController.ts[20-30]
- store/connection-manager/src/createNewStoreController.ts[101-109]
### Suggested implementation approach
When calling `verifyPacquetIdentity()` from `installDeps()`, explicitly provide `timeout` and `retry` derived from pnpm config:

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Key sync fetch lacks timeout 🐞 Bug ☼ Reliability
Description
update-npm-signing-keys.mjs performs an unbounded fetch() with no timeout/retry and is run as a
release-gating workflow step, so transient network stalls can hang or flake the release PR workflow.
Code

deps/security/signatures/scripts/update-npm-signing-keys.mjs[R51-57]

+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)
+}
Evidence
The workflow runs the script as a release gate, and the script’s fetch has no timeout, so network
stalls can block the job.

deps/security/signatures/scripts/update-npm-signing-keys.mjs[51-57]
.github/workflows/create-release-pr.yml[53-59]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The release-gating key sync script uses `fetch(KEYS_URL)` without an explicit timeout or retry logic. A stalled connection can block the workflow step indefinitely (or for a long time), and transient failures can cause flaky release gating.
### Issue Context
The `create-release-pr` workflow runs this script unconditionally as a gate.
### Fix Focus Areas
- deps/security/signatures/scripts/update-npm-signing-keys.mjs[51-57]
- .github/workflows/create-release-pr.yml[53-59]
### Suggested change
- Implement an `AbortController` timeout (e.g. 15–30s) for the fetch.
- Add a small retry loop (e.g. 2–3 attempts with backoff) for transient network errors.
- Ensure errors are surfaced clearly (include attempt count / timeout hit) so the workflow failure is diagnosable.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

8. Engine identity doc wrong ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
The JSDoc for verifyPnpmEngineIdentity() states that if the registry is unreachable it “warns and
returns”, but the implementation throws and refuses to run the downloaded pnpm in all
unreachable/offline cases.
Code

engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts[R45-49]

+ * Throws when verification detects tampering (an invalid signature) or that a
+ * package/version is absent from the registry. When the registry simply cannot
+ * be reached (offline), it warns and returns: that is not evidence of tampering.
+ * 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.
Evidence
The comment explicitly says “warns and returns” for offline, but the function throws on verification
network errors and also throws when failures are present.

engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts[45-49]
engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts[69-89]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`verifyPnpmEngineIdentity()` documentation claims offline/unreachable registry scenarios “warn and return”, but the implementation throws a `PnpmError` and fails closed.
### Issue Context
This is an API-contract/documentation mismatch that can mislead future maintainers and callers.
### Fix Focus Areas
- engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts[45-89]
### Suggested fix
Update the JSDoc to reflect the actual behavior (fail closed / throw on unreachable registry), or change the implementation if the intended behavior truly is to warn+return (which would conflict with the PR description).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Unreachable reported as mismatch 🐞 Bug ≡ Correctness
Description
verifyPnpmEngineIdentity()/verifyPacquetIdentity() currently treats all verification
failures—including purely unreachable (registry/packument fetch) scenarios—as an identity
mismatch, emitting messaging that the installed bytes “do not match a published, signed release”
even when verification could not actually be performed. This misclassifies
offline/proxy/registry-outage cases and misleads triage by implying a confirmed mismatch rather than
an unverifiable result.
Code

engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts[R83-89]

+  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.' }
+  )
Evidence
The code path explicitly computes/encounters an “only unreachable” condition (e.g.,
onlyUnreachable) while still appending mismatch wording, indicating the message is not conditioned
on failure categories. At the same time, verifyInstalledPackageSignatures() maps network/packument
fetch errors (e.g., getPackument() failures) into result.failures entries with `category:
'unreachable'` rather than throwing, meaning unreachable-only results occur in real
offline/registry-unavailable situations; yet verifyPacquetIdentity() (and the pnpm engine identity
verifier) does not branch on these categories and always throws a mismatch-style error/message.

engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts[45-49]
engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts[83-89]
deps/security/signatures/src/verifySignatures.ts[444-456]
installing/commands/src/verifyPacquetIdentity.ts[57-76]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The identity verification wrappers (`verifyPnpmEngineIdentity()` and `verifyPacquetIdentity()`) currently emit “mismatch” errors and wording (e.g., asserting the installed bytes do not match a published, signed release) even when all verification failures are categorized as `unreachable` due to registry/packument fetch problems. This is misleading because in unreachable-only scenarios the verification could not be completed, so the error should be “unverifiable” (or at minimum avoid asserting a byte mismatch).
## Issue Context
`verifyInstalledPackageSignatures()` reports network/packument fetch problems as failures in `result.failures` with `category: 'unreachable'` (e.g., when `getPackument()` throws), not as thrown exceptions; therefore unreachable-only outcomes are expected in offline/proxy-failure/registry-outage situations. Although the pnpm engine verifier already computes an `onlyUnreachable` condition, it still appends mismatch language, and `verifyPacquetIdentity()` treats any `result.verified === false` as a mismatch without inspecting failure categories.
## Fix Focus Areas
- engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts[45-49]
- engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts[83-89]
- installing/commands/src/verifyPacquetIdentity.ts[57-76]
- deps/security/signatures/src/verifySignatures.ts[444-457]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

fix(security): verify npm registry signature before spawning a package-manager binary
🐞 Bug fix ✨ Enhancement 🕐 40+ Minutes

Grey Divider

Walkthroughs

Description
• Adds verifyInstalledPackageSignatures() to @pnpm/deps.security.signatures, verifying
  name@version:integrity against dist.signatures using npm's ECDSA-P256 public keys embedded in
  the CLI (corepack-style), so a project-controlled registry cannot supply its own keypair.
• Blocks pacquet engine execution (verifyPacquetIdentity) unless the installed pacquet shim and
  host platform binary carry a valid npm registry signature; fails closed on tamper or unreachable
  registry.
• Blocks pnpm version-switch / self-update (verifyPnpmEngineIdentity) unless pnpm, @pnpm/exe,
  and the host platform binary are registry-signed; check runs only on a store cache miss to avoid
  per-command network cost.
• Embeds npm's current ECDSA signing keys in a generated npmSigningKeys.ts; a create-release-pr
  CI gate and check:npm-signing-keys script fail the release if the embedded keys drift from npm's
  live endpoint.
Diagram
graph TD
    A["pnpm install / self-update"] --> B["installDeps\n(installing/commands)"]
    A --> C["switchCliVersion\n(pnpm/src)"]

    B --> D["verifyPacquetIdentity\n(installing/commands)"]
    C --> E["installPnpmToStore\n(engine/pm/commands)"]
    E --> F["verifyPnpmEngineIdentity\n(engine/pm/commands)"]

    D --> G["verifyInstalledPackageSignatures\n(deps.security.signatures)"]
    F --> G

    G --> H[("npmSigningKeys.ts\nEmbedded ECDSA Keys")]
    G --> I["npm Registry\n(packument fetch)"]  

    J["update-npm-signing-keys.mjs"] -->|"CI gate / --update"| H

    subgraph Legend
      direction LR
      _proc["Process / Module"] ~~~ _db[("Embedded Data")] ~~~ _ext["External Service"]
    end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Fetch signing keys from registry at runtime
  • ➕ No embedded key maintenance
  • ➕ Automatically picks up key rotations
  • ➖ A project-controlled registry can supply its own keypair, defeating the trust model
  • ➖ Adds a network round trip on every verification
  • ➖ Was the initial approach and was correctly replaced
2. Sigstore / TUF-based verification
  • ➕ Cryptographically stronger trust chain
  • ➕ Handles key compromise scenarios better
  • ➕ Industry direction for supply-chain security
  • ➖ npm packages are not yet universally signed via Sigstore
  • ➖ Significant implementation complexity
  • ➖ Would require a separate trust root infrastructure

Recommendation: The embedded-keys approach (corepack-style) is the right choice here. The main alternative — fetching signing keys from the registry at verification time — was the approach in an earlier commit but was correctly replaced: it allows a project-controlled registry to supply its own keypair, defeating the trust model entirely. A third option (Sigstore/TUF-based verification) would be more robust against key compromise but requires significant infrastructure and is not yet standard for npm packages. The current approach is the pragmatic, proven solution.

Grey Divider

File Changes

Enhancement (2)
verifySignatures.ts Add verifyInstalledPackageSignatures() and getNpmSigningKeys() exports +127/-0

Add verifyInstalledPackageSignatures() and getNpmSigningKeys() exports

• Adds 'getNpmSigningKeys()' returning the embedded npm keys, and 'verifyInstalledPackageSignatures()' which verifies installed-on-disk integrity against registry 'dist.signatures' using caller-supplied trusted keys. Failure categories ('invalid', 'absent', 'unreachable') allow callers to distinguish tamper from transient failures.

deps/security/signatures/src/verifySignatures.ts


index.ts Export verifyPnpmEngineIdentity from engine/pm/commands +1/-0

Export verifyPnpmEngineIdentity from engine/pm/commands

• Exports the new 'verifyPnpmEngineIdentity' function and its options type from the package's public API.

engine/pm/commands/src/index.ts


Bug fix (5)
installPnpm.ts Call verifyPnpmEngineIdentity on store cache miss before installing pnpm +14/-1

Call verifyPnpmEngineIdentity on store cache miss before installing pnpm

• Invokes 'verifyPnpmEngineIdentity' in both 'installPnpmToStore' and 'installPnpmToGlobalDir' only when an actual download occurs (cache miss), so signature verification adds no network cost to repeated commands. Adds 'trustedKeys' as a test seam on 'InstallPnpmOptions'.

engine/pm/commands/src/self-updater/installPnpm.ts


verifyPnpmEngineIdentity.ts New module: verify pnpm/exe registry signatures before version switch +136/-0

New module: verify pnpm/exe registry signatures before version switch

• Implements 'verifyPnpmEngineIdentity()' which collects 'pnpm', '@pnpm/exe', and the host platform binary from the env lockfile and verifies their installed integrity against npm's embedded signing keys. Throws 'PNPM_ENGINE_IDENTITY_MISMATCH' on tamper and 'PNPM_ENGINE_IDENTITY_UNVERIFIABLE' when the registry is unreachable (fails closed).

engine/pm/commands/src/self-updater/verifyPnpmEngineIdentity.ts


installDeps.ts Gate pacquet engine delegation on verifyPacquetIdentity result +17/-1

Gate pacquet engine delegation on verifyPacquetIdentity result

• Wraps the pacquet config-dependency name resolution with a call to 'verifyPacquetIdentity'; only sets 'pacquetConfigDepName' (and thus delegates to pacquet) if verification passes. Adds inline comment explaining the trust model.

installing/commands/src/installDeps.ts


verifyPacquetIdentity.ts New module: verify pacquet shim and platform binary before spawning +120/-0

New module: verify pacquet shim and platform binary before spawning

• Implements 'verifyPacquetIdentity()' which reads the env lockfile, collects the pacquet shim and host '@pacquet/<platform>-<arch>' binary, and verifies their installed integrity against npm's embedded keys. Returns 'false' (graceful fallback) when pacquet has no binary for the current platform; throws 'PACQUET_IDENTITY_MISMATCH' or 'PACQUET_IDENTITY_UNVERIFIABLE' otherwise.

installing/commands/src/verifyPacquetIdentity.ts


switchCliVersion.ts Pass network/TLS config to installPnpm so engine identity check can reach the registry +13/-0

Pass network/TLS config to installPnpm so engine identity check can reach the registry

• Forwards 'ca', 'cert', 'key', proxy settings, 'strictSsl', 'localAddress', 'maxSockets', 'configByUri', and 'fetchTimeout' from the user's config into 'installPnpm' options, enabling the new engine identity check to honour the user's proxy and TLS configuration.

pnpm/src/switchCliVersion.ts


Tests (3)
verifySignatures.ts Add tests for verifyInstalledPackageSignatures and getNpmSigningKeys +90/-1

Add tests for verifyInstalledPackageSignatures and getNpmSigningKeys

• Adds 5 tests for 'verifyInstalledPackageSignatures' covering valid signature, tampered bytes, untrusted key, absent signature, and unpublished package. Adds 1 test for 'getNpmSigningKeys' confirming the embedded keys are present.

deps/security/signatures/test/verifySignatures.ts


selfUpdate.test.ts Pass empty trustedKeys to skip signature check in selfUpdate fixture tests +3/-0

Pass empty trustedKeys to skip signature check in selfUpdate fixture tests

• Adds 'trustedKeys: []' to the fixture options so existing selfUpdate tests bypass the new engine identity check (fixture binaries are not signed with npm's real keys).

engine/pm/commands/test/self-updater/selfUpdate.test.ts


verifyPnpmEngineIdentity.test.ts New test suite for verifyPnpmEngineIdentity (6 cases) +123/-0

New test suite for verifyPnpmEngineIdentity (6 cases)

• Tests valid signature, tampered bytes, untrusted key, absent version, unreachable registry (fails closed), and empty trusted-keys skip. Uses mock packuments with in-process ECDSA key generation.

engine/pm/commands/test/self-updater/verifyPnpmEngineIdentity.test.ts


Documentation (1)
pacquet-install-engine-identity.md Add changeset for security fix across 4 packages +15/-0

Add changeset for security fix across 4 packages

• Documents the security fix as a minor release for '@pnpm/deps.security.signatures' and patch releases for '@pnpm/installing.commands', '@pnpm/engine.pm.commands', and 'pnpm'.

.changeset/pacquet-install-engine-identity.md


Other (9)
create-release-pr.yml Add CI gate to fail release if embedded npm signing keys are stale +7/-0

Add CI gate to fail release if embedded npm signing keys are stale

• Adds a step to the release PR workflow that runs 'update-npm-signing-keys.mjs' in check mode, blocking the release if npm has rotated keys that are not yet embedded in the CLI.

.github/workflows/create-release-pr.yml


update-npm-signing-keys.mjs Add script to sync embedded npm signing keys with npm's live endpoint +105/-0

Add script to sync embedded npm signing keys with npm's live endpoint

• New Node.js script that fetches npm's current signing keys and either checks (CI mode) or rewrites the embedded 'npmSigningKeys.ts' file. Preserves retired keys so packages published before a rotation still verify.

deps/security/signatures/scripts/update-npm-signing-keys.mjs


npmSigningKeys.ts Add generated file embedding npm's ECDSA-P256 public signing keys +29/-0

Add generated file embedding npm's ECDSA-P256 public signing keys

• Generated TypeScript constant 'NPM_SIGNING_KEYS' containing npm's two current ECDSA-P256 public keys, used as the trust root for verifying package-manager binaries without a runtime network fetch.

deps/security/signatures/src/npmSigningKeys.ts


package.json Add @pnpm/deps.security.signatures and auth-header dependencies +3/-0

Add @pnpm/deps.security.signatures and auth-header dependencies

• Adds '@pnpm/deps.security.signatures', '@pnpm/config.pick-registry-for-package', and '@pnpm/network.auth-header' as workspace dependencies to support the new engine identity verification.

engine/pm/commands/package.json


tsconfig.json Add TypeScript project references for new security dependencies +9/-0

Add TypeScript project references for new security dependencies

• Adds project references for '@pnpm/config.pick-registry-for-package', '@pnpm/deps.security.signatures', and '@pnpm/network.auth-header'.

engine/pm/commands/tsconfig.json


package.json Add security, lockfile, and network dependencies for pacquet identity check +4/-0

Add security, lockfile, and network dependencies for pacquet identity check

• Adds '@pnpm/deps.security.signatures', '@pnpm/lockfile.fs', '@pnpm/network.auth-header', and '@pnpm/network.fetch' as workspace dependencies.

installing/commands/package.json


tsconfig.json Add TypeScript project references for security and network packages +12/-0

Add TypeScript project references for security and network packages

• Adds project references for '@pnpm/deps.security.signatures', '@pnpm/lockfile.fs', '@pnpm/network.auth-header', and '@pnpm/network.fetch'.

installing/commands/tsconfig.json


package.json Add check:npm-signing-keys and update:npm-signing-keys root scripts +2/-0

Add check:npm-signing-keys and update:npm-signing-keys root scripts

• Exposes two convenience scripts at the monorepo root: 'check:npm-signing-keys' (CI check mode) and 'update:npm-signing-keys' (rewrite mode) wrapping the key-sync script.

package.json


pnpm-lock.yaml Update lockfile with new workspace dependency links +21/-0

Update lockfile with new workspace dependency links

• Records the new workspace dependency links for '@pnpm/deps.security.signatures', '@pnpm/config.pick-registry-for-package', '@pnpm/network.auth-header', '@pnpm/network.fetch', and '@pnpm/lockfile.fs' in both 'engine/pm/commands' and 'installing/commands' importers.

pnpm-lock.yaml


Grey Divider

Qodo Logo

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Integrated-Benchmark Report (Linux)

Each scenario reports direct installs and pnpr installs. Bencher consumes pacquet@HEAD and pnpr@HEAD.

Scenario: Isolated linker: fresh restore, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 9.930 ± 0.190 9.705 10.299 1.90 ± 0.06
pacquet@main 9.873 ± 0.186 9.704 10.224 1.89 ± 0.06
pnpr@HEAD 5.280 ± 0.179 5.148 5.658 1.01 ± 0.04
pnpr@main 5.218 ± 0.135 5.108 5.511 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 9.92959932068,
      "stddev": 0.18990523651653632,
      "median": 9.95937050358,
      "user": 3.38135644,
      "system": 2.11382872,
      "min": 9.70487612258,
      "max": 10.29894270058,
      "times": [
        10.29894270058,
        9.92538397958,
        10.06806966458,
        10.00853838658,
        9.99335702758,
        9.76465056358,
        9.70487612258,
        9.732149292579999,
        9.758189998579999,
        10.041835470579999
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 9.873392527979998,
      "stddev": 0.18595622440038376,
      "median": 9.82711260908,
      "user": 3.3712210399999996,
      "system": 2.0907908199999996,
      "min": 9.704479320579999,
      "max": 10.22428693258,
      "times": [
        9.909769214579999,
        10.149182271579999,
        9.90422907058,
        9.910080431579999,
        9.704479320579999,
        9.74709800158,
        10.22428693258,
        9.74999614758,
        9.713179221579999,
        9.72162466758
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 5.28028532078,
      "stddev": 0.17911275746059885,
      "median": 5.19118323708,
      "user": 2.7649089399999993,
      "system": 1.8796187199999999,
      "min": 5.14843402058,
      "max": 5.658005988579999,
      "times": [
        5.17243861358,
        5.32461452958,
        5.15839631158,
        5.14843402058,
        5.20992786058,
        5.1644459955799995,
        5.25226856458,
        5.17108496058,
        5.54323636258,
        5.658005988579999
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 5.21779199278,
      "stddev": 0.1347961206788497,
      "median": 5.150420663079999,
      "user": 2.7623341399999997,
      "system": 1.86775852,
      "min": 5.108465218579999,
      "max": 5.51086433158,
      "times": [
        5.374342963579999,
        5.26317115858,
        5.11899315258,
        5.51086433158,
        5.142425945579999,
        5.126756289579999,
        5.158415380579999,
        5.11653953858,
        5.108465218579999,
        5.25794594858
      ]
    }
  ]
}

Scenario: Isolated linker: fresh restore, hot cache + hot store

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 473.9 ± 5.8 467.1 487.3 1.00
pacquet@main 485.3 ± 15.4 467.1 519.8 1.02 ± 0.03
pnpr@HEAD 573.2 ± 9.4 558.2 589.0 1.21 ± 0.02
pnpr@main 586.6 ± 20.3 567.0 638.5 1.24 ± 0.05
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.47391842084000013,
      "stddev": 0.005758313353975136,
      "median": 0.4715501457400001,
      "user": 0.37149286,
      "system": 0.7686322599999998,
      "min": 0.46711804474000007,
      "max": 0.4873024697400001,
      "times": [
        0.4712330457400001,
        0.4873024697400001,
        0.47064454574000003,
        0.47088877374000004,
        0.4764381737400001,
        0.47911396974000003,
        0.47352933074000003,
        0.47186724574000005,
        0.47104860874000004,
        0.46711804474000007
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.4852709936400001,
      "stddev": 0.015361002484673753,
      "median": 0.4835085142400001,
      "user": 0.37480856,
      "system": 0.7744741599999999,
      "min": 0.46712452874000004,
      "max": 0.51978290374,
      "times": [
        0.49042462374000007,
        0.46712452874000004,
        0.49266550974000006,
        0.48811140574000006,
        0.47344092674000005,
        0.47515440974000006,
        0.49416210474000005,
        0.4729379007400001,
        0.47890562274000004,
        0.51978290374
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.57319868424,
      "stddev": 0.009372839249852931,
      "median": 0.57190899874,
      "user": 0.38727796000000003,
      "system": 0.7732513599999999,
      "min": 0.5582021767400001,
      "max": 0.58900590274,
      "times": [
        0.56677189374,
        0.58647183074,
        0.56749039174,
        0.57743961874,
        0.56986364274,
        0.58900590274,
        0.5752423237400001,
        0.56754470674,
        0.57395435474,
        0.5582021767400001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.58663080814,
      "stddev": 0.0202723251476856,
      "median": 0.58321171074,
      "user": 0.39566436000000005,
      "system": 0.7872450599999998,
      "min": 0.56703866474,
      "max": 0.63848802374,
      "times": [
        0.5972864907400001,
        0.58318206174,
        0.63848802374,
        0.58952175874,
        0.58324135974,
        0.57322786274,
        0.58518428774,
        0.57260892674,
        0.57652864474,
        0.56703866474
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 9.129 ± 0.051 9.064 9.243 1.84 ± 0.03
pacquet@main 9.132 ± 0.047 9.072 9.219 1.84 ± 0.03
pnpr@HEAD 5.011 ± 0.196 4.859 5.396 1.01 ± 0.04
pnpr@main 4.960 ± 0.087 4.893 5.125 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 9.129464426139998,
      "stddev": 0.05123755892440537,
      "median": 9.12824994224,
      "user": 3.9557925399999996,
      "system": 2.14501798,
      "min": 9.06384741924,
      "max": 9.243274571239999,
      "times": [
        9.09212635224,
        9.13460436424,
        9.243274571239999,
        9.14087648124,
        9.06384741924,
        9.121895520239999,
        9.08169846224,
        9.10327010524,
        9.14370286024,
        9.169348125239999
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 9.131696329239999,
      "stddev": 0.0474971547794126,
      "median": 9.12323046074,
      "user": 3.9196120399999996,
      "system": 2.1439975799999997,
      "min": 9.072265205239999,
      "max": 9.21925442024,
      "times": [
        9.21925442024,
        9.10341977824,
        9.15102279224,
        9.072265205239999,
        9.09135338524,
        9.11104466824,
        9.135416253239999,
        9.16619974324,
        9.18180209724,
        9.08518494924
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 5.010799501139999,
      "stddev": 0.19580720947465932,
      "median": 4.93484761274,
      "user": 2.58507074,
      "system": 1.8000431799999999,
      "min": 4.85916625524,
      "max": 5.39563218024,
      "times": [
        4.93129255524,
        4.959429388239999,
        4.8927316872399995,
        4.887196448239999,
        4.9384026702399995,
        4.85916625524,
        4.9762068812399995,
        4.91146203524,
        5.356474910239999,
        5.39563218024
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 4.96035271774,
      "stddev": 0.0866040551235765,
      "median": 4.92381444674,
      "user": 2.5678590399999996,
      "system": 1.8214850799999998,
      "min": 4.89311147624,
      "max": 5.12467221924,
      "times": [
        4.916652281239999,
        5.11695050924,
        4.91169496424,
        4.89991708124,
        4.95583362724,
        4.941008934239999,
        4.930976612239999,
        4.9127094722399995,
        5.12467221924,
        4.89311147624
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, hot cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 1.221 ± 0.023 1.196 1.278 2.39 ± 0.08
pacquet@main 1.228 ± 0.040 1.190 1.333 2.41 ± 0.10
pnpr@HEAD 0.530 ± 0.035 0.504 0.619 1.04 ± 0.07
pnpr@main 0.510 ± 0.013 0.488 0.535 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 1.22051170824,
      "stddev": 0.02310221643264936,
      "median": 1.21727642424,
      "user": 1.55672042,
      "system": 1.07769016,
      "min": 1.19590528974,
      "max": 1.27794829374,
      "times": [
        1.22340151074,
        1.21398615274,
        1.22518465074,
        1.2023696017399998,
        1.20502873874,
        1.2317054427399998,
        1.2205666957399999,
        1.27794829374,
        1.20902070574,
        1.19590528974
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 1.2279693324399998,
      "stddev": 0.03989235185985859,
      "median": 1.21674361174,
      "user": 1.5741168199999998,
      "system": 1.0791248599999999,
      "min": 1.1896383637399999,
      "max": 1.33277101774,
      "times": [
        1.2423331257399999,
        1.1896383637399999,
        1.33277101774,
        1.20994458274,
        1.23782370574,
        1.20551669174,
        1.20708458974,
        1.2210940237399999,
        1.2177499327399999,
        1.21573729074
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.5299981225400001,
      "stddev": 0.03459156221632053,
      "median": 0.51556496974,
      "user": 0.34380921999999997,
      "system": 0.7463500599999999,
      "min": 0.5036270397400001,
      "max": 0.6188850957400001,
      "times": [
        0.54842678174,
        0.6188850957400001,
        0.50897606874,
        0.51561075174,
        0.5036270397400001,
        0.5135617947400001,
        0.51551918774,
        0.50457769374,
        0.53543134374,
        0.53536546774
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.5100178482400001,
      "stddev": 0.013254369676822815,
      "median": 0.5106324257400001,
      "user": 0.34189901999999994,
      "system": 0.7532418599999999,
      "min": 0.48765897774,
      "max": 0.53452876774,
      "times": [
        0.53452876774,
        0.5176742257400001,
        0.51307746874,
        0.5049862377400001,
        0.5166935567400001,
        0.5183620267400001,
        0.48765897774,
        0.49457959074,
        0.5044302477400001,
        0.5081873827400001
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.891 ± 0.017 4.865 4.919 9.35 ± 0.31
pacquet@main 4.906 ± 0.034 4.855 4.963 9.38 ± 0.32
pnpr@HEAD 0.527 ± 0.021 0.496 0.567 1.01 ± 0.05
pnpr@main 0.523 ± 0.017 0.501 0.562 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.89051385452,
      "stddev": 0.017439031893901443,
      "median": 4.88900769052,
      "user": 1.8260538399999995,
      "system": 1.2321849999999999,
      "min": 4.86475046152,
      "max": 4.919281048519999,
      "times": [
        4.887572609519999,
        4.890442771519999,
        4.90332402352,
        4.875577208519999,
        4.86979055252,
        4.90821054852,
        4.919281048519999,
        4.89994905652,
        4.86475046152,
        4.88624026452
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.90573540392,
      "stddev": 0.03422020143296685,
      "median": 4.90168160402,
      "user": 1.83759044,
      "system": 1.2325152999999998,
      "min": 4.855040722519999,
      "max": 4.962910250519999,
      "times": [
        4.91028201352,
        4.890575858519999,
        4.91120912552,
        4.89308119452,
        4.95257203852,
        4.962910250519999,
        4.926941407519999,
        4.855040722519999,
        4.87546866852,
        4.87927275952
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.52707806072,
      "stddev": 0.021205099710090856,
      "median": 0.5230801730200001,
      "user": 0.33706824,
      "system": 0.7716267999999998,
      "min": 0.4961401435200001,
      "max": 0.5665933415200001,
      "times": [
        0.5665933415200001,
        0.5178705615200001,
        0.5375462415200001,
        0.51971015852,
        0.5093448795200001,
        0.5264501875200001,
        0.4961401435200001,
        0.5531418785200001,
        0.51118140952,
        0.5328018055200001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.52292222462,
      "stddev": 0.01734719631268414,
      "median": 0.5221662165200001,
      "user": 0.33778734000000005,
      "system": 0.7562089999999999,
      "min": 0.5013084855200001,
      "max": 0.56226413452,
      "times": [
        0.56226413452,
        0.5114435245200001,
        0.5013084855200001,
        0.52343346152,
        0.5235011005200001,
        0.5392247625200001,
        0.5208989715200001,
        0.5241850105200001,
        0.51520862152,
        0.5077541735200001
      ]
    }
  ]
}

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12292
Testbedpacquet

🚨 1 Alert

BenchmarkMeasure
Units
ViewBenchmark Result
(Result Δ%)
Upper Boundary
(Limit %)
isolated-linker.fresh-install.cold-cache.cold-storeLatency
seconds (s)
📈 plot
🚷 threshold
🚨 alert (🔔)
9.13 s
(+27.52%)Baseline: 7.16 s
8.59 s
(106.27%)

Click to view all benchmark results
BenchmarkLatencyBenchmark Result
milliseconds (ms)
(Result Δ%)
Upper Boundary
milliseconds (ms)
(Limit %)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
🚷 view threshold
🚨 view alert (🔔)
9,129.46 ms
(+27.52%)Baseline: 7,159.34 ms
8,591.21 ms
(106.27%)

isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
🚷 view threshold
4,890.51 ms
(-2.54%)Baseline: 5,018.19 ms
6,021.83 ms
(81.21%)
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
🚷 view threshold
1,220.51 ms
(-13.42%)Baseline: 1,409.71 ms
1,691.65 ms
(72.15%)
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
🚷 view threshold
9,929.60 ms
(+8.58%)Baseline: 9,145.30 ms
10,974.36 ms
(90.48%)
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
🚷 view threshold
473.92 ms
(-27.20%)Baseline: 651.03 ms
781.23 ms
(60.66%)
🐰 View full continuous benchmarking report in Bencher

verifyPackageSignatures() failed on the first unknown, expired, or invalid
signature instead of accepting any signature that validates against a trusted
key. That breaks key-rotation / multi-signature packuments and lets a mirror
force a verification failure by appending a junk signature. Now a package is
accepted as soon as one trusted signature validates; it fails only when none do.
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 30667be

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@deps/security/signatures/src/verifySignatures.ts`:
- Around line 262-277: The expiry check currently trusts publishedTime (from
packument.time[pkg.version]) which is untrusted; update the verification in the
loop over pkg.signatures so that when key.expires is present you must not
consider an unverified/packument-derived publishedTime as valid — either require
a cryptographically verified timestamp/provenance before using publishedTime or
treat missing/unverified publishedTime as failing the expiry check (i.e., treat
the signature as expired). Modify the logic around publishedTime and key.expires
in the signature verification loop (references: pkg.signatures, keys.find,
publishedTime, key.expires) to fail-closed for unverified timestamps until a
proper authenticated timestamp mechanism is implemented.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7fee4316-5d09-448d-801b-3a2334d14880

📥 Commits

Reviewing files that changed from the base of the PR and between b10565d and 30667be.

📒 Files selected for processing (2)
  • deps/security/signatures/src/verifySignatures.ts
  • deps/security/signatures/test/verifySignatures.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Analyze (javascript)
  • GitHub Check: Compile & Lint
  • GitHub Check: Run benchmark on ubuntu-latest
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: Use Standard Style with trailing commas, prefer functions over classes, declare functions after they are used (relying on hoisting), limit functions to no more than two or three arguments, and use a single options object for functions needing more parameters
Follow import order: standard libraries first, then external dependencies (sorted alphabetically), then relative imports
Do not write comments that restate what the code already says; rename variables, split helpers, or move checks to more obvious places instead
Do not repeat documentation at call sites that already lives on the callee; update the JSDoc once and let every call site benefit
Use JSDoc for the function's contract (preconditions, postconditions, edge cases, why the function exists), not for re-narrating the function body
Do not record past implementation shape, refactor history, or removed code in comments; use git log and git blame for that information instead
Write comments only when the reason for code is non-obvious, a hidden invariant exists, a workaround for a known bug is needed, or an exception to surrounding pattern is deliberate

Files:

  • deps/security/signatures/test/verifySignatures.ts
  • deps/security/signatures/src/verifySignatures.ts
🧠 Learnings (3)
📚 Learning: 2026-05-14T09:04:00.133Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11622
File: resolving/npm-resolver/test/publishedBy.test.ts:350-354
Timestamp: 2026-05-14T09:04:00.133Z
Learning: In the pnpm/pnpm repository, ESLint is the authoritative style linter. Do not raise review findings for missing trailing commas in multiline function calls (e.g., `fs.writeFileSync(...)`) when this repo’s ESLint configuration does not report them and lint passes. Prefer deferring to the ESLint results for this specific trailing-comma rule rather than enforcing it manually in code review.

Applied to files:

  • deps/security/signatures/test/verifySignatures.ts
  • deps/security/signatures/src/verifySignatures.ts
📚 Learning: 2026-06-05T13:47:05.929Z
Learnt from: vsumner
Repo: pnpm/pnpm PR: 12190
File: installing/deps-installer/test/install/frozenStore.ts:2-17
Timestamp: 2026-06-05T13:47:05.929Z
Learning: In the pnpm/pnpm repository, the shared Jest preset keeps `injectGlobals` at its default (`true`), so `test` and `expect` are available as Jest globals. Therefore, reviewers should not flag (or treat as TypeScript/compilation errors) missing `import { test, expect } from 'jest/globals'` when a test file uses `test`/`expect` without importing them. Importing from `jest/globals` may still be used for consistency with sibling files, but it is not required for execution in this repo unless a Jest preset is explicitly configured with `injectGlobals: false`.

Applied to files:

  • deps/security/signatures/test/verifySignatures.ts
📚 Learning: 2026-06-05T13:47:26.046Z
Learnt from: vsumner
Repo: pnpm/pnpm PR: 12190
File: installing/deps-installer/src/install/index.ts:2337-2343
Timestamp: 2026-06-05T13:47:26.046Z
Learning: In the pnpm/pnpm codebase, `PnpmError` automatically prefixes `err.code` with `ERR_PNPM_` when you pass a code that does not already start with `ERR_PNPM_` (it normalizes `this.code` via `code.startsWith('ERR_PNPM_') ? code : `ERR_PNPM_${code}``). Therefore, during code review you should NOT flag `new PnpmError(...)` call sites for passing a bare error code (e.g., `new PnpmError('FROZEN_STORE_INCOMPATIBLE_WITH_PNPR', ...)`); the resulting `err.code` will still be `ERR_PNPM_FROZEN_STORE_INCOMPATIBLE_WITH_PNPR`.

Applied to files:

  • deps/security/signatures/test/verifySignatures.ts
  • deps/security/signatures/src/verifySignatures.ts
🔇 Additional comments (1)
deps/security/signatures/test/verifySignatures.ts (1)

139-155: LGTM!

Also applies to: 293-308

Comment thread deps/security/signatures/src/verifySignatures.ts
The expiry comparison is a consistency check, not a security boundary:
the publish time comes from the same unauthenticated packument as the
signatures, so failing closed on a missing timestamp would not stop a
forger (who could backdate it) while rejecting expired keys outright
would break every npm package published before the 2025-01-29 key
rotation. Matches the trade-off npm's pacote makes.
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 5fd3e39

Comment thread engine/pm/commands/src/self-updater/installPnpm.ts
installPnpmToStore() fetches the packument to verify the pnpm engine's
registry signature on a store cache miss, so the with command must pass
the proxy/TLS/auth settings the same way switchCliVersion does.
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 91d72bb

…etadata

collectEnginePackagesToVerify() silently skipped lockfile entries without
resolution.integrity, so a crafted env lockfile could exempt a component
(including the @pnpm/exe platform binary actually executed) from signature
verification while the remaining components verified. pnpm can install a
tarball without integrity, so a missing integrity now throws
PNPM_ENGINE_IDENTITY_UNVERIFIABLE instead of narrowing the verified set.
@zkochan zkochan force-pushed the verify-install-engine-registry-signature branch from 91d72bb to 50ac1f3 Compare June 9, 2026 20:57
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 50ac1f3

@zkochan zkochan merged commit 5f2bb9f into main Jun 9, 2026
15 of 16 checks passed
@zkochan zkochan deleted the verify-install-engine-registry-signature branch June 9, 2026 21:37
zkochan added a commit that referenced this pull request Jun 9, 2026
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).
zkochan added a commit that referenced this pull request Jun 10, 2026
* fix(package-bins): reject reserved manifest bin names

Manifest bin keys "", ".", "..", and scoped forms such as "@scope/.."
passed the bin-name guard because encodeURIComponent leaves them
unchanged. When joined to the global bin directory during global
remove/update/add operations, "." resolves to the bin directory itself
and ".." to its parent, which removeBin then recursively deletes.

Reject empty, ".", and ".." bin names after scope stripping.

Backport of #12289 to v10.

* fix: block untrusted request destination env expansion

Makes environment expansion trust-aware for registry/auth config and
request destinations:

- Stops project and workspace .npmrc files from expanding ${...}
  placeholders in registry/proxy request destinations, URL-scoped keys,
  and registry credential values.
- Stops repository-controlled pnpm-workspace.yaml from expanding
  ${...} placeholders in the registry setting.
- Preserves env expansion for trusted user/global/CLI/env config so
  existing token and registry setup flows continue to work.

Backport of #12291 (CAND-PNPM-122 / GHSA-3qhv-2rgh-x77r) to v10.

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

The packageManager field (and pnpm self-update) makes pnpm download and
run a specific pnpm version. The staged install's bytes were trusted
based on lockfile integrity alone, which proves nothing when the inputs
are repository-controlled.

pnpm now verifies the npm registry signature of the engine it is about
to spawn, over the installed integrity, against npm's public signing
keys embedded in the pnpm CLI (exactly as corepack does):

- verifyPnpmEngineIdentity() checks pnpm/@pnpm/exe and the materialized
  platform binaries of the staged install before it is linked into the
  tools directory.
- Fails closed: any verification failure, including an unreachable
  registry, refuses the version switch rather than running an unverified
  binary. Runs only on a tools-directory cache miss (an actual
  download).
- The embedded keys live in a generated file kept in sync with npm's
  keys endpoint by scripts/update-npm-signing-keys.mjs; the release
  workflow runs the check as a gate so a key rotation cannot silently
  break verification.

Backport of #12292 (CAND-PNPM-097) to v10.

* fix: harden package-manager bootstrap metadata

Resolve package-manager bootstrap traffic through trusted user/CLI
registries and trusted network config, defaulting to the public npm
registry instead of project/workspace registry settings:

- getConfig() now computes packageManagerRegistries and
  packageManagerNetworkConfig from trusted config sources only (CLI
  options, env config, user and global .npmrc) — never the repository's
  project/workspace .npmrc or pnpm-workspace.yaml.
- switchCliVersion() applies that bootstrap config when installing and
  verifying the wanted pnpm version, so repository .npmrc
  proxy/TLS/registry values cannot steer package-manager bootstrap
  traffic.

Backport of #12296 to v10. The v11 env-lockfile validation
parts do not apply: v10 bootstraps the wanted version through a staged
child install instead of an env lockfile.

* fix(security): verify Node.js runtime SHASUMS OpenPGP signature

When a repository requests a Node.js runtime (useNodeVersion or an
execution env), pnpm downloads and then executes a Node binary. The
download mirror is repository-configurable via node-mirror:<channel> in
project .npmrc, and the integrity came from SHASUMS256.txt fetched from
that same mirror — a circular check a malicious mirror can satisfy with
a tampered binary and matching hashes.

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:

- @pnpm/crypto.shasums-file: new fetchVerifiedNodeShasums /
  fetchVerifiedNodeShasumsFile verify the signature via openpgp against
  the embedded keys (generated src/nodeReleaseKeys.ts, mirrored from
  the canonical nodejs/release-keys list).
- @pnpm/node.fetcher verifies the configurable-mirror SHASUMS for the
  release channel; pre-release channels (rc, nightly, ...) are unsigned
  by Node and remain unverified.
- scripts/update-node-release-keys.mjs keeps the keys current
  (pnpm run check:node-release-keys / update:node-release-keys), and
  the release workflow runs the check as a gate.

Backport of #12295 to v10 (without the pacquet Rust port,
which does not exist on this branch).

* test(env): sign the SHASUMS fixture for Node.js download tests

The Node.js download tests exercise the release channel, whose
SHASUMS256.txt is now signature-verified. Sign the fixture with a
generated OpenPGP key and trust it through the new
trustedNodeReleaseKeys test seam (threaded from plugin-commands-env via
@pnpm/node.fetcher to fetchVerifiedNodeShasums), so the tests keep
exercising the verification path instead of bypassing it.

* fix(self-updater): redact registry credentials from engine identity errors

Registry URLs may legally embed basic-auth credentials
(https://user:pass@host/). verifyPnpmEngineIdentity() interpolated the
packument URL and registry URL into PnpmError messages, and the
unreachable-registry path surfaced fetch-layer error messages that embed
the request URL — all of which land in terminal output and CI logs.
Strip URL credentials from every error message and truncate the non-200
response body.

* fix: update vulnerable transitive dependencies

Override shell-quote to >=1.8.4 (GHSA-w7jw-789q-3m8p, critical, pulled
in via concurrently) so the audit workflow passes again. The advisory
was published after the last release/10 audit run; it is unrelated to
the security backports on this branch.
@zkochan

zkochan commented Jun 10, 2026

Copy link
Copy Markdown
Member Author

🚢 v11.5.3

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants