@pnpm/deps.security.signatures 1101.2.9 → 1101.3.0

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @pnpm/deps.security.signatures
2
2
 
3
+ ## 1101.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Registries that serve no npm signature metadata (private mirrors and feed proxies commonly strip `dist.signatures`) no longer break the automatic `packageManager` version switch and `pnpm self-update` [#13147](https://github.com/pnpm/pnpm/issues/13147). When the configured registry cannot provide a verifiable signature, pnpm now fetches the signature from `registry.npmjs.org` and verifies it against the same embedded npm keys over the installed integrity — which proves exactly the same thing. If no signature can be obtained from either source (for example, both are unreachable, or the registry publishes only a `shasum`), pnpm proceeds with a warning instead of failing, but only when the packages resolve through a registry configured in the user's own (non-project) configuration; the download stays pinned by the lockfile integrity, and a signature that exists but does not validate still fails the switch.
8
+
9
+ ## 1101.2.10
10
+
11
+ ### Patch Changes
12
+
13
+ - Updated dependencies:
14
+ - @pnpm/error@1100.1.1
15
+ - @pnpm/network.fetch@1100.1.11
16
+
3
17
  ## 1101.2.9
4
18
 
5
19
  ### Patch Changes
@@ -56,11 +56,21 @@ export interface InstalledPackageToVerify {
56
56
  * - `unreachable`: the trust root could not be consulted (registry advertised no
57
57
  * signing keys, or the network request failed) — typically transient/offline,
58
58
  * not evidence of tampering.
59
+ * - `uncovered`: the installed integrity is not a sha512 hash (e.g. a sha1 pin
60
+ * from a registry that only publishes `shasum`), so no npm registry signature
61
+ * can ever validate over it — verification is impossible by construction,
62
+ * not evidence of tampering.
59
63
  */
60
- export type SignatureFailureCategory = 'invalid' | 'absent' | 'unreachable';
64
+ export type SignatureFailureCategory = 'invalid' | 'absent' | 'unreachable' | 'uncovered';
61
65
  export interface InstalledSignatureFailure {
62
66
  name: string;
63
67
  version: string;
68
+ /**
69
+ * The registry the package was installed from (see
70
+ * {@link InstalledPackageToVerify.registry}), with inline `user:pass@`
71
+ * credentials and control characters stripped so the failure is safe to print or log.
72
+ */
73
+ registry: string;
64
74
  reason: string;
65
75
  category: SignatureFailureCategory;
66
76
  }
@@ -86,4 +96,27 @@ export interface InstalledSignatureVerificationResult {
86
96
  * A package counts as a failure when the package is unsigned/unpublished, or
87
97
  * when a signature is present but does not validate over the installed bytes.
88
98
  */
89
- export declare function verifyInstalledPackageSignatures(packages: InstalledPackageToVerify[], trustedKeys: RegistryKey[], getAuthHeader: GetAuthHeader, opts: VerifySignaturesOptions): Promise<InstalledSignatureVerificationResult>;
99
+ export interface VerifyInstalledSignaturesOptions extends VerifySignaturesOptions {
100
+ /**
101
+ * A registry to consult for signature metadata when a package's own registry
102
+ * cannot provide a verifiable signature (its packument omits `dist.signatures`,
103
+ * as private mirrors commonly do, or carries only a stale/broken one).
104
+ * Signatures are verified against the caller's trusted keys over the installed
105
+ * integrity, so where the signature bytes come from does not affect what they
106
+ * prove — a package passes only when some genuine signature validates over the
107
+ * bytes actually installed. See https://github.com/pnpm/pnpm/issues/13147.
108
+ */
109
+ fallbackRegistry?: string;
110
+ }
111
+ export declare function verifyInstalledPackageSignatures(packages: InstalledPackageToVerify[], trustedKeys: RegistryKey[], getAuthHeader: GetAuthHeader, opts: VerifyInstalledSignaturesOptions): Promise<InstalledSignatureVerificationResult>;
112
+ /**
113
+ * Whether two registry URLs address the same registry. URL-equivalent forms
114
+ * must compare equal — hosts are case-insensitive and default ports are
115
+ * implied — or a canonical registry written as e.g.
116
+ * `https://Registry.NPMJS.org:443/` would be misclassified as a different,
117
+ * non-canonical one, weakening fail-closed decisions keyed on whether the
118
+ * registry is the canonical one. Inline `user:pass@` credentials are auth
119
+ * material, not identity, so they are stripped before comparing for the same
120
+ * reason.
121
+ */
122
+ export declare function equalRegistries(a: string, b: string): boolean;
@@ -1,7 +1,7 @@
1
1
  import crypto from 'node:crypto';
2
2
  import url from 'node:url';
3
3
  import util from 'node:util';
4
- import { PnpmError } from '@pnpm/error';
4
+ import { PnpmError, redactAndSanitize } from '@pnpm/error';
5
5
  import { createFetchFromRegistry } from '@pnpm/network.fetch';
6
6
  import pLimit from 'p-limit';
7
7
  import { NPM_SIGNING_KEYS } from './npmSigningKeys.js';
@@ -265,50 +265,84 @@ function sortIssue(a, b) {
265
265
  export function getNpmSigningKeys() {
266
266
  return NPM_SIGNING_KEYS.map((k) => ({ ...k }));
267
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
268
  export async function verifyInstalledPackageSignatures(packages, trustedKeys, getAuthHeader, opts) {
287
- const packumentCache = new Map();
269
+ const ctx = {
270
+ trustedKeys,
271
+ getAuthHeader,
272
+ opts,
273
+ packumentCache: new Map(),
274
+ };
288
275
  const limit = pLimit(opts.networkConcurrency ?? 16);
289
276
  const failures = [];
290
277
  await Promise.all(packages.map((pkg) => limit(async () => {
291
- const failure = await findSignatureFailure(pkg, trustedKeys, getAuthHeader, opts, packumentCache);
278
+ const failure = await findSignatureFailure(pkg, ctx);
292
279
  if (failure != null) {
293
- failures.push({ name: pkg.name, version: pkg.version, ...failure });
280
+ failures.push({ name: pkg.name, version: pkg.version, registry: redactAndSanitize(pkg.registry), ...failure });
294
281
  }
295
282
  })));
296
283
  failures.sort((a, b) => `${a.name}@${a.version}`.localeCompare(`${b.name}@${b.version}`));
297
284
  return { verified: failures.length === 0, failures };
298
285
  }
299
- async function findSignatureFailure(pkg, trustedKeys, getAuthHeader, opts, packumentCache) {
286
+ async function findSignatureFailure(pkg, ctx) {
287
+ // npm registry signatures sign `name@version:integrity` with the sha512
288
+ // integrity the registry published. An installed integrity in any other form
289
+ // (a sha1 pin converted from `shasum` by a registry that publishes no
290
+ // `integrity`) can never validate against a genuine signature, so verifying
291
+ // it would misreport an authentic release as tampered with.
292
+ if (!pkg.integrity.startsWith('sha512-')) {
293
+ return {
294
+ reason: `${pkg.name}@${pkg.version} is pinned by a non-sha512 integrity, which npm registry signatures cannot cover`,
295
+ category: 'uncovered',
296
+ };
297
+ }
298
+ const primary = await attemptSignatureVerification(pkg, pkg.registry, ctx);
299
+ if (primary == null)
300
+ return undefined;
301
+ const { fallbackRegistry } = ctx.opts;
302
+ if (fallbackRegistry == null || equalRegistries(pkg.registry, fallbackRegistry))
303
+ return primary;
304
+ const secondary = await attemptSignatureVerification(pkg, fallbackRegistry, ctx);
305
+ // A genuine signature validating over the installed integrity proves the
306
+ // installed bytes regardless of which registry the primary attempt hit or
307
+ // what it answered (e.g. a mirror serving stale signatures from a rotated
308
+ // key), so a fallback pass is a pass.
309
+ if (secondary == null)
310
+ return undefined;
311
+ // A well-formed signature that fails to validate is a tamper signal from
312
+ // either source; surface it over the softer categories.
313
+ if (primary.category === 'invalid')
314
+ return primary;
315
+ if (secondary.category !== 'unreachable')
316
+ return secondary;
317
+ // The primary registry had no usable signature (a mirror commonly serves
318
+ // none) and the fallback could not be consulted — nothing suspicious was
319
+ // observed, the signature was simply unobtainable.
320
+ return {
321
+ reason: `${primary.reason}; the fallback registry (${redactAndSanitize(fallbackRegistry)}) could not be consulted either: ${secondary.reason}`,
322
+ category: 'unreachable',
323
+ };
324
+ }
325
+ /**
326
+ * Verifies `pkg`'s installed integrity against the signatures the packument on
327
+ * `registry` carries. Returns `undefined` on success, otherwise the failure.
328
+ */
329
+ async function attemptSignatureVerification(pkg, registry, ctx) {
330
+ // Registry URLs may carry inline `user:pass@` credentials, and the reasons
331
+ // built here end up in error messages and warnings.
332
+ const displayRegistry = redactAndSanitize(registry);
300
333
  let packument;
301
334
  try {
302
- packument = await getPackument(pkg, getAuthHeader, opts, packumentCache);
335
+ packument = await getPackument({ ...pkg, registry }, ctx.getAuthHeader, ctx.opts, ctx.packumentCache);
303
336
  }
304
337
  catch (err) {
305
- return { reason: util.types.isNativeError(err) ? err.message : String(err), category: 'unreachable' };
338
+ // The fetch error may echo the request URL, credentials included.
339
+ return { reason: redactAndSanitize(util.types.isNativeError(err) ? err.message : String(err)), category: 'unreachable' };
306
340
  }
307
341
  if (!packument)
308
- return { reason: `${pkg.name} is not published on ${pkg.registry}`, category: 'absent' };
342
+ return { reason: `${pkg.name} is not published on ${displayRegistry}`, category: 'absent' };
309
343
  const version = packument.versions?.[pkg.version];
310
344
  if (!version)
311
- return { reason: `${pkg.name}@${pkg.version} was not found on ${pkg.registry}`, category: 'absent' };
345
+ return { reason: `${pkg.name}@${pkg.version} was not found on ${displayRegistry}`, category: 'absent' };
312
346
  const rawSignatures = version.dist?.signatures;
313
347
  if (rawSignatures != null && !Array.isArray(rawSignatures)) {
314
348
  return { reason: `malformed registry signatures metadata for ${pkg.name}@${pkg.version}`, category: 'absent' };
@@ -318,11 +352,34 @@ async function findSignatureFailure(pkg, trustedKeys, getAuthHeader, opts, packu
318
352
  return { reason: `malformed registry signatures metadata for ${pkg.name}@${pkg.version}`, category: 'absent' };
319
353
  }
320
354
  if (signatures.length === 0) {
321
- return { reason: `${pkg.name}@${pkg.version} has no registry signature`, category: 'absent' };
355
+ return { reason: `${pkg.name}@${pkg.version} has no registry signature on ${displayRegistry}`, category: 'absent' };
322
356
  }
323
357
  // The message is built from the installed integrity, so a signature only
324
358
  // 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);
359
+ const issue = verifyPackageSignatures({ ...pkg, integrity: pkg.integrity, publishedAt: packument.time?.[pkg.version], signatures }, ctx.trustedKeys);
326
360
  return issue == null ? undefined : { reason: issue.reason ?? 'invalid registry signature', category: 'invalid' };
327
361
  }
362
+ /**
363
+ * Whether two registry URLs address the same registry. URL-equivalent forms
364
+ * must compare equal — hosts are case-insensitive and default ports are
365
+ * implied — or a canonical registry written as e.g.
366
+ * `https://Registry.NPMJS.org:443/` would be misclassified as a different,
367
+ * non-canonical one, weakening fail-closed decisions keyed on whether the
368
+ * registry is the canonical one. Inline `user:pass@` credentials are auth
369
+ * material, not identity, so they are stripped before comparing for the same
370
+ * reason.
371
+ */
372
+ export function equalRegistries(a, b) {
373
+ return normalizeRegistryUrl(a) === normalizeRegistryUrl(b);
374
+ }
375
+ function normalizeRegistryUrl(registry) {
376
+ const withSlash = redactAndSanitize(registry.endsWith('/') ? registry : `${registry}/`);
377
+ try {
378
+ // URL normalization lowercases the host and drops a default port.
379
+ return new url.URL(withSlash).toString().toLowerCase();
380
+ }
381
+ catch {
382
+ return withSlash.toLowerCase();
383
+ }
384
+ }
328
385
  //# sourceMappingURL=verifySignatures.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/deps.security.signatures",
3
- "version": "1101.2.9",
3
+ "version": "1101.3.0",
4
4
  "description": "Verify package signatures from npm registries",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -29,9 +29,9 @@
29
29
  "!*.map"
30
30
  ],
31
31
  "dependencies": {
32
- "@pnpm/error": "1100.1.0",
32
+ "@pnpm/error": "1100.1.1",
33
33
  "@pnpm/fetching.types": "1100.0.3",
34
- "@pnpm/network.fetch": "1100.1.10",
34
+ "@pnpm/network.fetch": "1100.1.11",
35
35
  "p-limit": "^7.3.1"
36
36
  },
37
37
  "peerDependencies": {
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "devDependencies": {
41
41
  "@jest/globals": "30.4.1",
42
- "@pnpm/deps.security.signatures": "1101.2.9",
42
+ "@pnpm/deps.security.signatures": "1101.3.0",
43
43
  "@pnpm/logger": "1100.0.0",
44
44
  "@pnpm/testing.mock-agent": "1101.0.7"
45
45
  },