@pnpm/deps.security.signatures 1101.1.5 → 1101.2.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/lib/npmSigningKeys.d.ts +13 -0
- package/lib/npmSigningKeys.js +24 -0
- package/lib/verifySignatures.d.ts +65 -0
- package/lib/verifySignatures.js +108 -14
- package/package.json +9 -6
|
@@ -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>;
|
package/lib/verifySignatures.js
CHANGED
|
@@ -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
|
-
|
|
154
|
-
|
|
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
|
-
//
|
|
157
|
-
//
|
|
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
|
-
|
|
160
|
-
|
|
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 (
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "1101.2.0",
|
|
4
4
|
"description": "Verify package signatures from npm registries",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pnpm",
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
],
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"funding": "https://opencollective.com/pnpm",
|
|
13
|
-
"repository":
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/pnpm/pnpm/tree/main/deps/security/signatures"
|
|
16
|
+
},
|
|
14
17
|
"homepage": "https://github.com/pnpm/pnpm/tree/main/deps/security/signatures#readme",
|
|
15
18
|
"bugs": {
|
|
16
19
|
"url": "https://github.com/pnpm/pnpm/issues"
|
|
@@ -29,16 +32,16 @@
|
|
|
29
32
|
"p-limit": "^7.3.0",
|
|
30
33
|
"@pnpm/error": "1100.0.0",
|
|
31
34
|
"@pnpm/fetching.types": "1100.0.1",
|
|
32
|
-
"@pnpm/network.fetch": "1100.
|
|
35
|
+
"@pnpm/network.fetch": "1100.1.1"
|
|
33
36
|
},
|
|
34
37
|
"peerDependencies": {
|
|
35
38
|
"@pnpm/logger": "^1001.0.1"
|
|
36
39
|
},
|
|
37
40
|
"devDependencies": {
|
|
38
41
|
"@jest/globals": "30.3.0",
|
|
39
|
-
"@pnpm/deps.security.signatures": "1101.
|
|
40
|
-
"@pnpm/
|
|
41
|
-
"@pnpm/
|
|
42
|
+
"@pnpm/deps.security.signatures": "1101.2.0",
|
|
43
|
+
"@pnpm/logger": "1100.0.0",
|
|
44
|
+
"@pnpm/testing.mock-agent": "1101.0.1"
|
|
42
45
|
},
|
|
43
46
|
"engines": {
|
|
44
47
|
"node": ">=22.13"
|