@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 +14 -0
- package/lib/verifySignatures.d.ts +35 -2
- package/lib/verifySignatures.js +86 -29
- package/package.json +4 -4
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
|
|
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;
|
package/lib/verifySignatures.js
CHANGED
|
@@ -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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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 ${
|
|
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 ${
|
|
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.
|
|
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.
|
|
32
|
+
"@pnpm/error": "1100.1.1",
|
|
33
33
|
"@pnpm/fetching.types": "1100.0.3",
|
|
34
|
-
"@pnpm/network.fetch": "1100.1.
|
|
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.
|
|
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
|
},
|