@pnpm/deps.security.signatures 1101.1.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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015-2016 Rico Sta. Cruz and other contributors
4
+ Copyright (c) 2016-2026 Zoltan Kochan and other contributors
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # @pnpm/deps.security.signatures
2
+
3
+ > Verify package signatures from npm registries
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@pnpm/deps.security.signatures.svg)](https://npmx.dev/package/@pnpm/deps.security.signatures)
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ pnpm add @pnpm/deps.security.signatures
11
+ ```
12
+
13
+ ## Signature Verification
14
+
15
+ `verifySignatures()` verifies ECDSA registry signatures for installed package versions. It fetches public keys from each package's registry at `/-/npm/v1/keys`, fetches full package metadata, and verifies each signature over `${name}@${version}:${integrity}`.
16
+
17
+ Registries that do not expose signing keys are skipped. Sigstore provenance attestations are not yet verified by this package; they are tracked as future scope.
18
+
19
+ ## License
20
+
21
+ MIT
package/lib/index.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './verifySignatures.js';
package/lib/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from './verifySignatures.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,24 @@
1
+ import type { GetAuthHeader } from '@pnpm/fetching.types';
2
+ import { type CreateFetchFromRegistryOptions, type RetryTimeoutOptions } from '@pnpm/network.fetch';
3
+ export interface SignaturePackage {
4
+ name: string;
5
+ registry: string;
6
+ version: string;
7
+ }
8
+ export interface SignatureIssue extends SignaturePackage {
9
+ integrity?: string;
10
+ reason?: string;
11
+ resolved?: string;
12
+ }
13
+ export interface SignatureVerificationResult {
14
+ audited: number;
15
+ invalid: SignatureIssue[];
16
+ missing: SignatureIssue[];
17
+ verified: number;
18
+ }
19
+ export interface VerifySignaturesOptions extends CreateFetchFromRegistryOptions {
20
+ networkConcurrency?: number;
21
+ retry?: RetryTimeoutOptions;
22
+ timeout?: number;
23
+ }
24
+ export declare function verifySignatures(packages: SignaturePackage[], getAuthHeader: GetAuthHeader, opts: VerifySignaturesOptions): Promise<SignatureVerificationResult>;
@@ -0,0 +1,234 @@
1
+ import crypto from 'node:crypto';
2
+ import url from 'node:url';
3
+ import util from 'node:util';
4
+ import { PnpmError } from '@pnpm/error';
5
+ import { createFetchFromRegistry } from '@pnpm/network.fetch';
6
+ import pLimit from 'p-limit';
7
+ export async function verifySignatures(packages, getAuthHeader, opts) {
8
+ const registries = new Set(packages.map(({ registry }) => registry));
9
+ const keysByRegistry = await getKeysByRegistry(registries, getAuthHeader, opts);
10
+ const result = {
11
+ audited: 0,
12
+ invalid: [],
13
+ missing: [],
14
+ verified: 0,
15
+ };
16
+ // Registries without signing keys are not counted as audited: there is no
17
+ // registry trust root to verify against.
18
+ const packumentCache = new Map();
19
+ const limit = pLimit(opts.networkConcurrency ?? 16);
20
+ await Promise.all(packages.map((pkg) => limit(async () => {
21
+ const keys = keysByRegistry.get(pkg.registry) ?? [];
22
+ if (keys.length === 0)
23
+ return;
24
+ let version;
25
+ let publishedAt;
26
+ try {
27
+ const packument = await getPackument(pkg, getAuthHeader, opts, packumentCache);
28
+ if (!packument)
29
+ return;
30
+ result.audited++;
31
+ version = packument.versions?.[pkg.version];
32
+ publishedAt = packument.time?.[pkg.version];
33
+ }
34
+ catch (err) {
35
+ result.invalid.push({ ...pkg, reason: util.types.isNativeError(err) ? err.message : String(err) });
36
+ return;
37
+ }
38
+ const integrity = version?.dist?.integrity;
39
+ const resolved = version?.dist?.tarball;
40
+ const rawSignatures = version?.dist?.signatures;
41
+ if (rawSignatures != null && !Array.isArray(rawSignatures)) {
42
+ result.invalid.push({ ...pkg, integrity, resolved, reason: `Malformed registry signatures metadata for ${pkg.name}@${pkg.version}` });
43
+ return;
44
+ }
45
+ const signatures = rawSignatures ?? [];
46
+ if (!signatures.every(isPackageSignature)) {
47
+ result.invalid.push({ ...pkg, integrity, resolved, reason: `Malformed registry signatures metadata for ${pkg.name}@${pkg.version}` });
48
+ return;
49
+ }
50
+ if (!version) {
51
+ result.invalid.push({ ...pkg, reason: `Missing registry metadata for ${pkg.name}@${pkg.version}` });
52
+ return;
53
+ }
54
+ if (!integrity) {
55
+ result.missing.push({ ...pkg, resolved });
56
+ return;
57
+ }
58
+ if (signatures.length === 0) {
59
+ result.missing.push({ ...pkg, integrity, resolved });
60
+ return;
61
+ }
62
+ const issue = verifyPackageSignatures({ ...pkg, integrity, publishedAt, resolved, signatures }, keys);
63
+ if (issue) {
64
+ result.invalid.push(issue);
65
+ return;
66
+ }
67
+ result.verified++;
68
+ })));
69
+ result.invalid.sort(sortIssue);
70
+ result.missing.sort(sortIssue);
71
+ return result;
72
+ }
73
+ async function getKeysByRegistry(registries, getAuthHeader, opts) {
74
+ const keysByRegistry = new Map();
75
+ await Promise.all(Array.from(registries, async (registry) => {
76
+ const keys = await fetchRegistryKeys(registry, getAuthHeader, opts);
77
+ keysByRegistry.set(registry, keys);
78
+ }));
79
+ return keysByRegistry;
80
+ }
81
+ async function fetchRegistryKeys(registry, getAuthHeader, opts) {
82
+ const registryUrl = registry.endsWith('/') ? registry : `${registry}/`;
83
+ const keysUrl = new url.URL('-/npm/v1/keys', registryUrl).toString();
84
+ const fetchFromRegistry = createFetchFromRegistry(opts);
85
+ const response = await fetchFromRegistry(keysUrl, {
86
+ authHeaderValue: getAuthHeader(registryUrl),
87
+ method: 'GET',
88
+ retry: opts.retry,
89
+ timeout: opts.timeout,
90
+ });
91
+ if (response.status === 404 || response.status === 400) {
92
+ return [];
93
+ }
94
+ if (response.status !== 200) {
95
+ const code = 'AUDIT_SIGNATURE_KEYS_FETCH_FAIL';
96
+ const message = `The registry keys endpoint (at ${response.url}) responded with ${response.status}: ${await response.text()}`;
97
+ throw new PnpmError(code, message);
98
+ }
99
+ const body = await parseJsonResponse(response, 'AUDIT_SIGNATURE_KEYS_FETCH_FAIL', 'The registry keys endpoint');
100
+ if (!isRegistryKeysResponse(body)) {
101
+ const code = 'AUDIT_SIGNATURE_KEYS_FETCH_FAIL';
102
+ const message = `The registry keys endpoint (at ${response.url}) returned an unexpected body. Expected an object with a keys array; got: ${JSON.stringify(body)?.slice(0, 500) ?? String(body)}`;
103
+ throw new PnpmError(code, message);
104
+ }
105
+ // npm registry signing currently uses ECDSA P-256 keys. Sigstore provenance
106
+ // attestations are intentionally handled separately from this registry check.
107
+ return body.keys.filter(({ keytype, scheme }) => keytype === 'ecdsa-sha2-nistp256' && scheme === 'ecdsa-sha2-nistp256');
108
+ }
109
+ async function getPackument(pkg, getAuthHeader, opts, packumentCache) {
110
+ const cacheKey = `${pkg.registry}:${pkg.name}`;
111
+ let packument = packumentCache.get(cacheKey);
112
+ if (!packument) {
113
+ // Multiple installed versions share one full packument fetch.
114
+ packument = fetchPackument(pkg, getAuthHeader, opts);
115
+ packumentCache.set(cacheKey, packument);
116
+ }
117
+ return packument;
118
+ }
119
+ async function fetchPackument(pkg, getAuthHeader, opts) {
120
+ const registryUrl = pkg.registry.endsWith('/') ? pkg.registry : `${pkg.registry}/`;
121
+ const packumentUrl = toUri(pkg.name, registryUrl);
122
+ const fetchFromRegistry = createFetchFromRegistry(opts);
123
+ const response = await fetchFromRegistry(packumentUrl, {
124
+ authHeaderValue: getAuthHeader(registryUrl),
125
+ fullMetadata: true,
126
+ method: 'GET',
127
+ retry: opts.retry,
128
+ timeout: opts.timeout,
129
+ });
130
+ if (response.status === 404) {
131
+ return undefined;
132
+ }
133
+ if (response.status !== 200) {
134
+ const code = 'AUDIT_SIGNATURE_PACKUMENT_FETCH_FAIL';
135
+ const message = `The packument endpoint (at ${response.url}) responded with ${response.status}: ${await response.text()}`;
136
+ throw new PnpmError(code, message);
137
+ }
138
+ const body = await parseJsonResponse(response, 'AUDIT_SIGNATURE_PACKUMENT_FETCH_FAIL', 'The packument endpoint');
139
+ if (!isPackument(body)) {
140
+ const code = 'AUDIT_SIGNATURE_PACKUMENT_FETCH_FAIL';
141
+ const message = `The packument endpoint (at ${response.url}) returned an unexpected body. Expected an object with versions; got: ${JSON.stringify(body)?.slice(0, 500) ?? String(body)}`;
142
+ throw new PnpmError(code, message);
143
+ }
144
+ return body;
145
+ }
146
+ function verifyPackageSignatures(pkg, keys) {
147
+ // Registry signatures cover the package identity and content integrity.
148
+ const message = `${pkg.name}@${pkg.version}:${pkg.integrity}`;
149
+ const publishedTime = pkg.publishedAt ? Date.parse(pkg.publishedAt) : undefined;
150
+ for (const signature of pkg.signatures) {
151
+ const key = keys.find(({ keyid }) => keyid === signature.keyid);
152
+ 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);
155
+ }
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.
158
+ 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);
161
+ }
162
+ const verifier = crypto.createVerify('SHA256');
163
+ verifier.write(message);
164
+ verifier.end();
165
+ const pem = `-----BEGIN PUBLIC KEY-----\n${key.key}\n-----END PUBLIC KEY-----`;
166
+ // crypto.verify can throw on malformed PEM key material or signature bytes
167
+ // returned by the registry; treat any failure as an invalid signature so
168
+ // one bad key doesn't crash the whole audit.
169
+ let verified;
170
+ try {
171
+ verified = verifier.verify(pem, signature.sig, 'base64');
172
+ }
173
+ catch {
174
+ verified = false;
175
+ }
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
+ }
180
+ }
181
+ return undefined;
182
+ }
183
+ function toSignatureIssue(pkg, reason) {
184
+ return {
185
+ integrity: pkg.integrity,
186
+ name: pkg.name,
187
+ reason,
188
+ registry: pkg.registry,
189
+ resolved: pkg.resolved,
190
+ version: pkg.version,
191
+ };
192
+ }
193
+ async function parseJsonResponse(response, errorCode, endpointDescription) {
194
+ const rawBody = await response.text();
195
+ try {
196
+ return JSON.parse(rawBody);
197
+ }
198
+ catch (err) {
199
+ const reason = util.types.isNativeError(err) ? err.message : String(err);
200
+ throw new PnpmError(errorCode, `${endpointDescription} (at ${response.url}) returned invalid JSON: ${reason}. Response body: ${rawBody.slice(0, 500)}`);
201
+ }
202
+ }
203
+ function toUri(pkgName, registry) {
204
+ let encodedName;
205
+ if (pkgName[0] === '@') {
206
+ encodedName = `@${encodeURIComponent(pkgName.slice(1))}`;
207
+ }
208
+ else {
209
+ encodedName = encodeURIComponent(pkgName);
210
+ }
211
+ return new url.URL(encodedName, registry.endsWith('/') ? registry : `${registry}/`).toString();
212
+ }
213
+ function isRegistryKeysResponse(body) {
214
+ return typeof body === 'object' && body != null &&
215
+ Array.isArray(body.keys) &&
216
+ body.keys.every((key) => typeof key === 'object' && key != null &&
217
+ typeof key.keyid === 'string' &&
218
+ typeof key.keytype === 'string' &&
219
+ typeof key.scheme === 'string' &&
220
+ typeof key.key === 'string' &&
221
+ (key.expires == null || typeof key.expires === 'string'));
222
+ }
223
+ function isPackument(body) {
224
+ return typeof body === 'object' && body != null && typeof body.versions === 'object' && body.versions != null;
225
+ }
226
+ function isPackageSignature(signature) {
227
+ return typeof signature === 'object' && signature != null &&
228
+ typeof signature.keyid === 'string' &&
229
+ typeof signature.sig === 'string';
230
+ }
231
+ function sortIssue(a, b) {
232
+ return `${a.name}@${a.version}`.localeCompare(`${b.name}@${b.version}`);
233
+ }
234
+ //# sourceMappingURL=verifySignatures.js.map
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@pnpm/deps.security.signatures",
3
+ "version": "1101.1.0",
4
+ "description": "Verify package signatures from npm registries",
5
+ "keywords": [
6
+ "pnpm",
7
+ "pnpm11",
8
+ "audit",
9
+ "signatures"
10
+ ],
11
+ "license": "MIT",
12
+ "funding": "https://opencollective.com/pnpm",
13
+ "repository": "https://github.com/pnpm/pnpm/tree/main/deps/security/signatures",
14
+ "homepage": "https://github.com/pnpm/pnpm/tree/main/deps/security/signatures#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/pnpm/pnpm/issues"
17
+ },
18
+ "type": "module",
19
+ "main": "lib/index.js",
20
+ "types": "lib/index.d.ts",
21
+ "exports": {
22
+ ".": "./lib/index.js"
23
+ },
24
+ "files": [
25
+ "lib",
26
+ "!*.map"
27
+ ],
28
+ "dependencies": {
29
+ "p-limit": "^7.1.0",
30
+ "@pnpm/network.fetch": "1100.0.3",
31
+ "@pnpm/fetching.types": "1100.0.1",
32
+ "@pnpm/error": "1100.0.0"
33
+ },
34
+ "peerDependencies": {
35
+ "@pnpm/logger": ">=1001.0.0 <1002.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@jest/globals": "30.3.0",
39
+ "@pnpm/deps.security.signatures": "1101.1.0",
40
+ "@pnpm/logger": "1100.0.0",
41
+ "@pnpm/testing.mock-agent": "1100.0.3"
42
+ },
43
+ "engines": {
44
+ "node": ">=22.13"
45
+ },
46
+ "jest": {
47
+ "preset": "@pnpm/jest-config"
48
+ },
49
+ "scripts": {
50
+ "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
51
+ "test": "pn compile && pn .test",
52
+ "compile": "tsgo --build && pn lint --fix",
53
+ ".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest"
54
+ }
55
+ }