@pnpm/deps.security.signatures 1101.1.6 → 1101.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ export declare const NPM_SIGNING_KEYS: readonly [{
2
+ readonly expires: "2025-01-29T00:00:00.000Z";
3
+ readonly keyid: "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA";
4
+ readonly keytype: "ecdsa-sha2-nistp256";
5
+ readonly scheme: "ecdsa-sha2-nistp256";
6
+ readonly key: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==";
7
+ }, {
8
+ readonly expires: null;
9
+ readonly keyid: "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U";
10
+ readonly keytype: "ecdsa-sha2-nistp256";
11
+ readonly scheme: "ecdsa-sha2-nistp256";
12
+ readonly key: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY6Ya7W++7aUPzvMTrezH6Ycx3c+HOKYCcNGybJZSCJq/fd7Qa8uuAKtdIkUQtQiEKERhAmE5lMMJhP8OkDOa2g==";
13
+ }];
@@ -0,0 +1,24 @@
1
+ /* eslint-disable */
2
+ // GENERATED — npm's public registry signing keys, mirrored from
3
+ // https://registry.npmjs.org/-/npm/v1/keys
4
+ //
5
+ // Refresh with: node deps/security/signatures/scripts/update-npm-signing-keys.mjs --update
6
+ // The release workflow runs `--check` and fails if these drift from npm, so a
7
+ // rotated key cannot silently break (or weaken) signature verification.
8
+ export const NPM_SIGNING_KEYS = [
9
+ {
10
+ "expires": "2025-01-29T00:00:00.000Z",
11
+ "keyid": "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA",
12
+ "keytype": "ecdsa-sha2-nistp256",
13
+ "scheme": "ecdsa-sha2-nistp256",
14
+ "key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg=="
15
+ },
16
+ {
17
+ "expires": null,
18
+ "keyid": "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U",
19
+ "keytype": "ecdsa-sha2-nistp256",
20
+ "scheme": "ecdsa-sha2-nistp256",
21
+ "key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY6Ya7W++7aUPzvMTrezH6Ycx3c+HOKYCcNGybJZSCJq/fd7Qa8uuAKtdIkUQtQiEKERhAmE5lMMJhP8OkDOa2g=="
22
+ }
23
+ ];
24
+ //# sourceMappingURL=npmSigningKeys.js.map
@@ -21,4 +21,69 @@ export interface VerifySignaturesOptions extends CreateFetchFromRegistryOptions
21
21
  retry?: RetryTimeoutOptions;
22
22
  timeout?: number;
23
23
  }
24
+ interface RegistryKey {
25
+ expires: string | null;
26
+ key: string;
27
+ keyid: string;
28
+ keytype: string;
29
+ scheme: string;
30
+ }
24
31
  export declare function verifySignatures(packages: SignaturePackage[], getAuthHeader: GetAuthHeader, opts: VerifySignaturesOptions): Promise<SignatureVerificationResult>;
32
+ export type { RegistryKey };
33
+ /**
34
+ * The trusted npm signing keys used to verify package-manager binaries before
35
+ * pnpm spawns them — npm's public keys embedded in the CLI. There is
36
+ * deliberately no way to override or disable them at runtime: a verification
37
+ * off-switch would be a footgun, and npm mirrors work without one (they proxy
38
+ * the same signed packument, which is verified against these keys). The keys
39
+ * are refreshed at release time by the update-npm-signing-keys script.
40
+ */
41
+ export declare function getNpmSigningKeys(): RegistryKey[];
42
+ export interface InstalledPackageToVerify {
43
+ name: string;
44
+ /** The registry the package was installed from — the packument (and its signatures) is fetched from here. */
45
+ registry: string;
46
+ version: string;
47
+ /** Integrity of the bytes actually installed on disk (from the lockfile). */
48
+ integrity: string;
49
+ }
50
+ /**
51
+ * Why a package failed signature verification:
52
+ * - `invalid`: a registry signature is present but does not validate over the
53
+ * installed bytes — a strong tamper signal.
54
+ * - `absent`: the package/version is not on the (canonical) registry, or carries
55
+ * no signature — suspicious for a package that is expected to be signed.
56
+ * - `unreachable`: the trust root could not be consulted (registry advertised no
57
+ * signing keys, or the network request failed) — typically transient/offline,
58
+ * not evidence of tampering.
59
+ */
60
+ export type SignatureFailureCategory = 'invalid' | 'absent' | 'unreachable';
61
+ export interface InstalledSignatureFailure {
62
+ name: string;
63
+ version: string;
64
+ reason: string;
65
+ category: SignatureFailureCategory;
66
+ }
67
+ export interface InstalledSignatureVerificationResult {
68
+ verified: boolean;
69
+ failures: InstalledSignatureFailure[];
70
+ }
71
+ /**
72
+ * Verifies that the bytes installed on disk are exactly what the registry
73
+ * signed for `name@version`. The signed message is built from the
74
+ * caller-supplied installed {@link InstalledPackageToVerify.integrity}, not
75
+ * from the integrity in the freshly-fetched packument — so if the integrity
76
+ * on disk was tampered with (or fetched from a different registry), the
77
+ * registry's signature will not validate over it.
78
+ *
79
+ * Signatures are verified against the caller-supplied `trustedKeys` (npm's
80
+ * embedded public keys, see {@link getNpmSigningKeys}) rather than keys fetched
81
+ * from a registry — so a registry the caller cannot vouch for cannot answer with
82
+ * its own key pair. The packument (which carries the signatures) is fetched from
83
+ * each package's own registry; an npm mirror works transparently because it
84
+ * proxies the same signed packument.
85
+ *
86
+ * A package counts as a failure when the package is unsigned/unpublished, or
87
+ * when a signature is present but does not validate over the installed bytes.
88
+ */
89
+ export declare function verifyInstalledPackageSignatures(packages: InstalledPackageToVerify[], trustedKeys: RegistryKey[], getAuthHeader: GetAuthHeader, opts: VerifySignaturesOptions): Promise<InstalledSignatureVerificationResult>;
@@ -4,6 +4,7 @@ import util from 'node:util';
4
4
  import { PnpmError } from '@pnpm/error';
5
5
  import { createFetchFromRegistry } from '@pnpm/network.fetch';
6
6
  import pLimit from 'p-limit';
7
+ import { NPM_SIGNING_KEYS } from './npmSigningKeys.js';
7
8
  export async function verifySignatures(packages, getAuthHeader, opts) {
8
9
  const registries = new Set(packages.map(({ registry }) => registry));
9
10
  const keysByRegistry = await getKeysByRegistry(registries, getAuthHeader, opts);
@@ -147,38 +148,60 @@ function verifyPackageSignatures(pkg, keys) {
147
148
  // Registry signatures cover the package identity and content integrity.
148
149
  const message = `${pkg.name}@${pkg.version}:${pkg.integrity}`;
149
150
  const publishedTime = pkg.publishedAt ? Date.parse(pkg.publishedAt) : undefined;
151
+ // A package is accepted as soon as ONE signature made by a trusted key
152
+ // validates. Signatures from unknown/expired/invalid keys are recorded but do
153
+ // not on their own fail the package — otherwise a key rotation (a packument
154
+ // carrying multiple signatures) breaks, and a mirror could force a failure
155
+ // just by appending a junk signature. We fail only when no signature validates
156
+ // against a trusted key.
157
+ const failures = [];
150
158
  for (const signature of pkg.signatures) {
151
159
  const key = keys.find(({ keyid }) => keyid === signature.keyid);
152
160
  if (!key) {
153
- const reason = `${pkg.name}@${pkg.version} has a registry signature with keyid ${signature.keyid} but no corresponding public key can be found`;
154
- return toSignatureIssue(pkg, reason);
161
+ failures.push(`${pkg.name}@${pkg.version} has a registry signature with keyid ${signature.keyid} but no corresponding public key can be found`);
162
+ continue;
155
163
  }
156
- // Without publish time metadata we cannot safely compare against key expiry,
157
- // so keep verifying with the key instead of failing closed on incomplete metadata.
164
+ // Key expiry is a consistency check, not a security boundary: the publish
165
+ // time comes from the same unauthenticated packument as the signatures, so
166
+ // a forger holding an expired trusted key could backdate it anyway. The
167
+ // signature verification below is what gates acceptance. That is why a
168
+ // missing publish time keeps the key usable instead of failing closed —
169
+ // the same trade-off npm's pacote makes by substituting a pre-expiry date.
158
170
  if (key.expires && publishedTime != null && publishedTime >= Date.parse(key.expires)) {
159
- const reason = `${pkg.name}@${pkg.version} has a registry signature with keyid ${signature.keyid} but the corresponding public key has expired ${key.expires}`;
160
- return toSignatureIssue(pkg, reason);
171
+ failures.push(`${pkg.name}@${pkg.version} has a registry signature with keyid ${signature.keyid} but the corresponding public key has expired ${key.expires}`);
172
+ continue;
161
173
  }
162
- const verifier = crypto.createVerify('SHA256');
163
- verifier.write(message);
164
- verifier.end();
165
174
  const pem = `-----BEGIN PUBLIC KEY-----\n${key.key}\n-----END PUBLIC KEY-----`;
166
175
  // crypto.verify can throw on malformed PEM key material or signature bytes
167
176
  // returned by the registry; treat any failure as an invalid signature so
168
177
  // one bad key doesn't crash the whole audit.
169
178
  let verified;
170
179
  try {
180
+ const verifier = crypto.createVerify('SHA256');
181
+ verifier.write(message);
182
+ verifier.end();
171
183
  verified = verifier.verify(pem, signature.sig, 'base64');
172
184
  }
173
185
  catch {
174
186
  verified = false;
175
187
  }
176
- if (!verified) {
177
- const reason = `${pkg.name}@${pkg.version} has an invalid registry signature with keyid ${signature.keyid}`;
178
- return toSignatureIssue(pkg, reason);
179
- }
188
+ if (verified)
189
+ return undefined;
190
+ failures.push(`${pkg.name}@${pkg.version} has an invalid registry signature with keyid ${signature.keyid}`);
191
+ }
192
+ return toSignatureIssue(pkg, pickMostTellingFailure(pkg, failures));
193
+ }
194
+ /**
195
+ * The reason to surface when no signature validated against a trusted key.
196
+ * Prefer an invalid signature from a known key (a tamper signal) over an
197
+ * unknown-key or expiry reason, since unknown keys may just be junk a mirror
198
+ * appended.
199
+ */
200
+ function pickMostTellingFailure(pkg, failures) {
201
+ if (failures.length === 0) {
202
+ return `${pkg.name}@${pkg.version} has no registry signature from a trusted key`;
180
203
  }
181
- return undefined;
204
+ return failures.find((reason) => reason.includes('invalid registry signature')) ?? failures[0];
182
205
  }
183
206
  function toSignatureIssue(pkg, reason) {
184
207
  return {
@@ -231,4 +254,75 @@ function isPackageSignature(signature) {
231
254
  function sortIssue(a, b) {
232
255
  return `${a.name}@${a.version}`.localeCompare(`${b.name}@${b.version}`);
233
256
  }
257
+ /**
258
+ * The trusted npm signing keys used to verify package-manager binaries before
259
+ * pnpm spawns them — npm's public keys embedded in the CLI. There is
260
+ * deliberately no way to override or disable them at runtime: a verification
261
+ * off-switch would be a footgun, and npm mirrors work without one (they proxy
262
+ * the same signed packument, which is verified against these keys). The keys
263
+ * are refreshed at release time by the update-npm-signing-keys script.
264
+ */
265
+ export function getNpmSigningKeys() {
266
+ return NPM_SIGNING_KEYS.map((k) => ({ ...k }));
267
+ }
268
+ /**
269
+ * Verifies that the bytes installed on disk are exactly what the registry
270
+ * signed for `name@version`. The signed message is built from the
271
+ * caller-supplied installed {@link InstalledPackageToVerify.integrity}, not
272
+ * from the integrity in the freshly-fetched packument — so if the integrity
273
+ * on disk was tampered with (or fetched from a different registry), the
274
+ * registry's signature will not validate over it.
275
+ *
276
+ * Signatures are verified against the caller-supplied `trustedKeys` (npm's
277
+ * embedded public keys, see {@link getNpmSigningKeys}) rather than keys fetched
278
+ * from a registry — so a registry the caller cannot vouch for cannot answer with
279
+ * its own key pair. The packument (which carries the signatures) is fetched from
280
+ * each package's own registry; an npm mirror works transparently because it
281
+ * proxies the same signed packument.
282
+ *
283
+ * A package counts as a failure when the package is unsigned/unpublished, or
284
+ * when a signature is present but does not validate over the installed bytes.
285
+ */
286
+ export async function verifyInstalledPackageSignatures(packages, trustedKeys, getAuthHeader, opts) {
287
+ const packumentCache = new Map();
288
+ const limit = pLimit(opts.networkConcurrency ?? 16);
289
+ const failures = [];
290
+ await Promise.all(packages.map((pkg) => limit(async () => {
291
+ const failure = await findSignatureFailure(pkg, trustedKeys, getAuthHeader, opts, packumentCache);
292
+ if (failure != null) {
293
+ failures.push({ name: pkg.name, version: pkg.version, ...failure });
294
+ }
295
+ })));
296
+ failures.sort((a, b) => `${a.name}@${a.version}`.localeCompare(`${b.name}@${b.version}`));
297
+ return { verified: failures.length === 0, failures };
298
+ }
299
+ async function findSignatureFailure(pkg, trustedKeys, getAuthHeader, opts, packumentCache) {
300
+ let packument;
301
+ try {
302
+ packument = await getPackument(pkg, getAuthHeader, opts, packumentCache);
303
+ }
304
+ catch (err) {
305
+ return { reason: util.types.isNativeError(err) ? err.message : String(err), category: 'unreachable' };
306
+ }
307
+ if (!packument)
308
+ return { reason: `${pkg.name} is not published on ${pkg.registry}`, category: 'absent' };
309
+ const version = packument.versions?.[pkg.version];
310
+ if (!version)
311
+ return { reason: `${pkg.name}@${pkg.version} was not found on ${pkg.registry}`, category: 'absent' };
312
+ const rawSignatures = version.dist?.signatures;
313
+ if (rawSignatures != null && !Array.isArray(rawSignatures)) {
314
+ return { reason: `malformed registry signatures metadata for ${pkg.name}@${pkg.version}`, category: 'absent' };
315
+ }
316
+ const signatures = rawSignatures ?? [];
317
+ if (!signatures.every(isPackageSignature)) {
318
+ return { reason: `malformed registry signatures metadata for ${pkg.name}@${pkg.version}`, category: 'absent' };
319
+ }
320
+ if (signatures.length === 0) {
321
+ return { reason: `${pkg.name}@${pkg.version} has no registry signature`, category: 'absent' };
322
+ }
323
+ // The message is built from the installed integrity, so a signature only
324
+ // validates when the installed bytes match what the registry signed.
325
+ const issue = verifyPackageSignatures({ ...pkg, integrity: pkg.integrity, publishedAt: packument.time?.[pkg.version], signatures }, trustedKeys);
326
+ return issue == null ? undefined : { reason: issue.reason ?? 'invalid registry signature', category: 'invalid' };
327
+ }
234
328
  //# sourceMappingURL=verifySignatures.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/deps.security.signatures",
3
- "version": "1101.1.6",
3
+ "version": "1101.2.1",
4
4
  "description": "Verify package signatures from npm registries",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -31,17 +31,17 @@
31
31
  "dependencies": {
32
32
  "p-limit": "^7.3.0",
33
33
  "@pnpm/error": "1100.0.0",
34
- "@pnpm/fetching.types": "1100.0.1",
35
- "@pnpm/network.fetch": "1100.1.0"
34
+ "@pnpm/network.fetch": "1100.1.2",
35
+ "@pnpm/fetching.types": "1100.0.1"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@pnpm/logger": "^1001.0.1"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@jest/globals": "30.3.0",
42
- "@pnpm/deps.security.signatures": "1101.1.6",
43
- "@pnpm/testing.mock-agent": "1101.0.0",
44
- "@pnpm/logger": "1100.0.0"
42
+ "@pnpm/logger": "1100.0.0",
43
+ "@pnpm/testing.mock-agent": "1101.0.2",
44
+ "@pnpm/deps.security.signatures": "1101.2.1"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=22.13"