@pnpm/deps.security.signatures 1101.2.6 → 1101.2.7
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 +11 -0
- package/lib/index.d.ts +1 -0
- package/lib/npmSigningKeys.d.ts +13 -0
- package/lib/npmSigningKeys.js +24 -0
- package/lib/verifySignatures.d.ts +89 -0
- package/lib/verifySignatures.js +328 -0
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# @pnpm/deps.security.signatures
|
|
2
2
|
|
|
3
|
+
## 1101.2.7
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Republished every package: the tarballs published by the v11.13.1 through v11.16.0 releases were missing most of their compiled files due to a packing bug [#13164](https://github.com/pnpm/pnpm/issues/13164).
|
|
8
|
+
|
|
9
|
+
- Updated dependencies:
|
|
10
|
+
- @pnpm/error@1100.1.0
|
|
11
|
+
- @pnpm/fetching.types@1100.0.3
|
|
12
|
+
- @pnpm/network.fetch@1100.1.8
|
|
13
|
+
|
|
3
14
|
## 1101.2.6
|
|
4
15
|
|
|
5
16
|
### Patch Changes
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './verifySignatures.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const NPM_SIGNING_KEYS: readonly [{
|
|
2
|
+
readonly expires: null;
|
|
3
|
+
readonly keyid: "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U";
|
|
4
|
+
readonly keytype: "ecdsa-sha2-nistp256";
|
|
5
|
+
readonly scheme: "ecdsa-sha2-nistp256";
|
|
6
|
+
readonly key: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY6Ya7W++7aUPzvMTrezH6Ycx3c+HOKYCcNGybJZSCJq/fd7Qa8uuAKtdIkUQtQiEKERhAmE5lMMJhP8OkDOa2g==";
|
|
7
|
+
}, {
|
|
8
|
+
readonly expires: "2025-01-29T00:00:00.000Z";
|
|
9
|
+
readonly keyid: "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA";
|
|
10
|
+
readonly keytype: "ecdsa-sha2-nistp256";
|
|
11
|
+
readonly scheme: "ecdsa-sha2-nistp256";
|
|
12
|
+
readonly key: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==";
|
|
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 create-release-pr workflow refreshes these automatically, so a rotated
|
|
7
|
+
// key cannot silently break (or weaken) signature verification.
|
|
8
|
+
export const NPM_SIGNING_KEYS = [
|
|
9
|
+
{
|
|
10
|
+
"expires": null,
|
|
11
|
+
"keyid": "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U",
|
|
12
|
+
"keytype": "ecdsa-sha2-nistp256",
|
|
13
|
+
"scheme": "ecdsa-sha2-nistp256",
|
|
14
|
+
"key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY6Ya7W++7aUPzvMTrezH6Ycx3c+HOKYCcNGybJZSCJq/fd7Qa8uuAKtdIkUQtQiEKERhAmE5lMMJhP8OkDOa2g=="
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"expires": "2025-01-29T00:00:00.000Z",
|
|
18
|
+
"keyid": "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA",
|
|
19
|
+
"keytype": "ecdsa-sha2-nistp256",
|
|
20
|
+
"scheme": "ecdsa-sha2-nistp256",
|
|
21
|
+
"key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg=="
|
|
22
|
+
}
|
|
23
|
+
];
|
|
24
|
+
//# sourceMappingURL=npmSigningKeys.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
interface RegistryKey {
|
|
25
|
+
expires: string | null;
|
|
26
|
+
key: string;
|
|
27
|
+
keyid: string;
|
|
28
|
+
keytype: string;
|
|
29
|
+
scheme: string;
|
|
30
|
+
}
|
|
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>;
|
|
@@ -0,0 +1,328 @@
|
|
|
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
|
+
import { NPM_SIGNING_KEYS } from './npmSigningKeys.js';
|
|
8
|
+
export async function verifySignatures(packages, getAuthHeader, opts) {
|
|
9
|
+
const registries = new Set(packages.map(({ registry }) => registry));
|
|
10
|
+
const keysByRegistry = await getKeysByRegistry(registries, getAuthHeader, opts);
|
|
11
|
+
const result = {
|
|
12
|
+
audited: 0,
|
|
13
|
+
invalid: [],
|
|
14
|
+
missing: [],
|
|
15
|
+
verified: 0,
|
|
16
|
+
};
|
|
17
|
+
// Registries without signing keys are not counted as audited: there is no
|
|
18
|
+
// registry trust root to verify against.
|
|
19
|
+
const packumentCache = new Map();
|
|
20
|
+
const limit = pLimit(opts.networkConcurrency ?? 16);
|
|
21
|
+
await Promise.all(packages.map((pkg) => limit(async () => {
|
|
22
|
+
const keys = keysByRegistry.get(pkg.registry) ?? [];
|
|
23
|
+
if (keys.length === 0)
|
|
24
|
+
return;
|
|
25
|
+
let version;
|
|
26
|
+
let publishedAt;
|
|
27
|
+
try {
|
|
28
|
+
const packument = await getPackument(pkg, getAuthHeader, opts, packumentCache);
|
|
29
|
+
if (!packument)
|
|
30
|
+
return;
|
|
31
|
+
result.audited++;
|
|
32
|
+
version = packument.versions?.[pkg.version];
|
|
33
|
+
publishedAt = packument.time?.[pkg.version];
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
result.invalid.push({ ...pkg, reason: util.types.isNativeError(err) ? err.message : String(err) });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const integrity = version?.dist?.integrity;
|
|
40
|
+
const resolved = version?.dist?.tarball;
|
|
41
|
+
const rawSignatures = version?.dist?.signatures;
|
|
42
|
+
if (rawSignatures != null && !Array.isArray(rawSignatures)) {
|
|
43
|
+
result.invalid.push({ ...pkg, integrity, resolved, reason: `Malformed registry signatures metadata for ${pkg.name}@${pkg.version}` });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const signatures = rawSignatures ?? [];
|
|
47
|
+
if (!signatures.every(isPackageSignature)) {
|
|
48
|
+
result.invalid.push({ ...pkg, integrity, resolved, reason: `Malformed registry signatures metadata for ${pkg.name}@${pkg.version}` });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!version) {
|
|
52
|
+
result.invalid.push({ ...pkg, reason: `Missing registry metadata for ${pkg.name}@${pkg.version}` });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!integrity) {
|
|
56
|
+
result.missing.push({ ...pkg, resolved });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (signatures.length === 0) {
|
|
60
|
+
result.missing.push({ ...pkg, integrity, resolved });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const issue = verifyPackageSignatures({ ...pkg, integrity, publishedAt, resolved, signatures }, keys);
|
|
64
|
+
if (issue) {
|
|
65
|
+
result.invalid.push(issue);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
result.verified++;
|
|
69
|
+
})));
|
|
70
|
+
result.invalid.sort(sortIssue);
|
|
71
|
+
result.missing.sort(sortIssue);
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
async function getKeysByRegistry(registries, getAuthHeader, opts) {
|
|
75
|
+
const keysByRegistry = new Map();
|
|
76
|
+
await Promise.all(Array.from(registries, async (registry) => {
|
|
77
|
+
const keys = await fetchRegistryKeys(registry, getAuthHeader, opts);
|
|
78
|
+
keysByRegistry.set(registry, keys);
|
|
79
|
+
}));
|
|
80
|
+
return keysByRegistry;
|
|
81
|
+
}
|
|
82
|
+
async function fetchRegistryKeys(registry, getAuthHeader, opts) {
|
|
83
|
+
const registryUrl = registry.endsWith('/') ? registry : `${registry}/`;
|
|
84
|
+
const keysUrl = new url.URL('-/npm/v1/keys', registryUrl).toString();
|
|
85
|
+
const fetchFromRegistry = createFetchFromRegistry(opts);
|
|
86
|
+
const response = await fetchFromRegistry(keysUrl, {
|
|
87
|
+
authHeaderValue: getAuthHeader(registryUrl),
|
|
88
|
+
method: 'GET',
|
|
89
|
+
retry: opts.retry,
|
|
90
|
+
timeout: opts.timeout,
|
|
91
|
+
});
|
|
92
|
+
if (response.status === 404 || response.status === 400) {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
if (response.status !== 200) {
|
|
96
|
+
const code = 'AUDIT_SIGNATURE_KEYS_FETCH_FAIL';
|
|
97
|
+
const message = `The registry keys endpoint (at ${response.url}) responded with ${response.status}: ${await response.text()}`;
|
|
98
|
+
throw new PnpmError(code, message);
|
|
99
|
+
}
|
|
100
|
+
const body = await parseJsonResponse(response, 'AUDIT_SIGNATURE_KEYS_FETCH_FAIL', 'The registry keys endpoint');
|
|
101
|
+
if (!isRegistryKeysResponse(body)) {
|
|
102
|
+
const code = 'AUDIT_SIGNATURE_KEYS_FETCH_FAIL';
|
|
103
|
+
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)}`;
|
|
104
|
+
throw new PnpmError(code, message);
|
|
105
|
+
}
|
|
106
|
+
// npm registry signing currently uses ECDSA P-256 keys. Sigstore provenance
|
|
107
|
+
// attestations are intentionally handled separately from this registry check.
|
|
108
|
+
return body.keys.filter(({ keytype, scheme }) => keytype === 'ecdsa-sha2-nistp256' && scheme === 'ecdsa-sha2-nistp256');
|
|
109
|
+
}
|
|
110
|
+
async function getPackument(pkg, getAuthHeader, opts, packumentCache) {
|
|
111
|
+
const cacheKey = `${pkg.registry}:${pkg.name}`;
|
|
112
|
+
let packument = packumentCache.get(cacheKey);
|
|
113
|
+
if (!packument) {
|
|
114
|
+
// Multiple installed versions share one full packument fetch.
|
|
115
|
+
packument = fetchPackument(pkg, getAuthHeader, opts);
|
|
116
|
+
packumentCache.set(cacheKey, packument);
|
|
117
|
+
}
|
|
118
|
+
return packument;
|
|
119
|
+
}
|
|
120
|
+
async function fetchPackument(pkg, getAuthHeader, opts) {
|
|
121
|
+
const registryUrl = pkg.registry.endsWith('/') ? pkg.registry : `${pkg.registry}/`;
|
|
122
|
+
const packumentUrl = toUri(pkg.name, registryUrl);
|
|
123
|
+
const fetchFromRegistry = createFetchFromRegistry(opts);
|
|
124
|
+
const response = await fetchFromRegistry(packumentUrl, {
|
|
125
|
+
authHeaderValue: getAuthHeader(registryUrl),
|
|
126
|
+
fullMetadata: true,
|
|
127
|
+
method: 'GET',
|
|
128
|
+
retry: opts.retry,
|
|
129
|
+
timeout: opts.timeout,
|
|
130
|
+
});
|
|
131
|
+
if (response.status === 404) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
if (response.status !== 200) {
|
|
135
|
+
const code = 'AUDIT_SIGNATURE_PACKUMENT_FETCH_FAIL';
|
|
136
|
+
const message = `The packument endpoint (at ${response.url}) responded with ${response.status}: ${await response.text()}`;
|
|
137
|
+
throw new PnpmError(code, message);
|
|
138
|
+
}
|
|
139
|
+
const body = await parseJsonResponse(response, 'AUDIT_SIGNATURE_PACKUMENT_FETCH_FAIL', 'The packument endpoint');
|
|
140
|
+
if (!isPackument(body)) {
|
|
141
|
+
const code = 'AUDIT_SIGNATURE_PACKUMENT_FETCH_FAIL';
|
|
142
|
+
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)}`;
|
|
143
|
+
throw new PnpmError(code, message);
|
|
144
|
+
}
|
|
145
|
+
return body;
|
|
146
|
+
}
|
|
147
|
+
function verifyPackageSignatures(pkg, keys) {
|
|
148
|
+
// Registry signatures cover the package identity and content integrity.
|
|
149
|
+
const message = `${pkg.name}@${pkg.version}:${pkg.integrity}`;
|
|
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 = [];
|
|
158
|
+
for (const signature of pkg.signatures) {
|
|
159
|
+
const key = keys.find(({ keyid }) => keyid === signature.keyid);
|
|
160
|
+
if (!key) {
|
|
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;
|
|
163
|
+
}
|
|
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.
|
|
170
|
+
if (key.expires && publishedTime != null && publishedTime >= Date.parse(key.expires)) {
|
|
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;
|
|
173
|
+
}
|
|
174
|
+
const pem = `-----BEGIN PUBLIC KEY-----\n${key.key}\n-----END PUBLIC KEY-----`;
|
|
175
|
+
// crypto.verify can throw on malformed PEM key material or signature bytes
|
|
176
|
+
// returned by the registry; treat any failure as an invalid signature so
|
|
177
|
+
// one bad key doesn't crash the whole audit.
|
|
178
|
+
let verified;
|
|
179
|
+
try {
|
|
180
|
+
const verifier = crypto.createVerify('SHA256');
|
|
181
|
+
verifier.write(message);
|
|
182
|
+
verifier.end();
|
|
183
|
+
verified = verifier.verify(pem, signature.sig, 'base64');
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
verified = false;
|
|
187
|
+
}
|
|
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`;
|
|
203
|
+
}
|
|
204
|
+
return failures.find((reason) => reason.includes('invalid registry signature')) ?? failures[0];
|
|
205
|
+
}
|
|
206
|
+
function toSignatureIssue(pkg, reason) {
|
|
207
|
+
return {
|
|
208
|
+
integrity: pkg.integrity,
|
|
209
|
+
name: pkg.name,
|
|
210
|
+
reason,
|
|
211
|
+
registry: pkg.registry,
|
|
212
|
+
resolved: pkg.resolved,
|
|
213
|
+
version: pkg.version,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
async function parseJsonResponse(response, errorCode, endpointDescription) {
|
|
217
|
+
const rawBody = await response.text();
|
|
218
|
+
try {
|
|
219
|
+
return JSON.parse(rawBody);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
const reason = util.types.isNativeError(err) ? err.message : String(err);
|
|
223
|
+
throw new PnpmError(errorCode, `${endpointDescription} (at ${response.url}) returned invalid JSON: ${reason}. Response body: ${rawBody.slice(0, 500)}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function toUri(pkgName, registry) {
|
|
227
|
+
let encodedName;
|
|
228
|
+
if (pkgName[0] === '@') {
|
|
229
|
+
encodedName = `@${encodeURIComponent(pkgName.slice(1))}`;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
encodedName = encodeURIComponent(pkgName);
|
|
233
|
+
}
|
|
234
|
+
return new url.URL(encodedName, registry.endsWith('/') ? registry : `${registry}/`).toString();
|
|
235
|
+
}
|
|
236
|
+
function isRegistryKeysResponse(body) {
|
|
237
|
+
return typeof body === 'object' && body != null &&
|
|
238
|
+
Array.isArray(body.keys) &&
|
|
239
|
+
body.keys.every((key) => typeof key === 'object' && key != null &&
|
|
240
|
+
typeof key.keyid === 'string' &&
|
|
241
|
+
typeof key.keytype === 'string' &&
|
|
242
|
+
typeof key.scheme === 'string' &&
|
|
243
|
+
typeof key.key === 'string' &&
|
|
244
|
+
(key.expires == null || typeof key.expires === 'string'));
|
|
245
|
+
}
|
|
246
|
+
function isPackument(body) {
|
|
247
|
+
return typeof body === 'object' && body != null && typeof body.versions === 'object' && body.versions != null;
|
|
248
|
+
}
|
|
249
|
+
function isPackageSignature(signature) {
|
|
250
|
+
return typeof signature === 'object' && signature != null &&
|
|
251
|
+
typeof signature.keyid === 'string' &&
|
|
252
|
+
typeof signature.sig === 'string';
|
|
253
|
+
}
|
|
254
|
+
function sortIssue(a, b) {
|
|
255
|
+
return `${a.name}@${a.version}`.localeCompare(`${b.name}@${b.version}`);
|
|
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
|
+
}
|
|
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.2.
|
|
3
|
+
"version": "1101.2.7",
|
|
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.0
|
|
33
|
-
"@pnpm/fetching.types": "1100.0.
|
|
34
|
-
"@pnpm/network.fetch": "1100.1.
|
|
32
|
+
"@pnpm/error": "1100.1.0",
|
|
33
|
+
"@pnpm/fetching.types": "1100.0.3",
|
|
34
|
+
"@pnpm/network.fetch": "1100.1.8",
|
|
35
35
|
"p-limit": "^7.3.0"
|
|
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.
|
|
42
|
+
"@pnpm/deps.security.signatures": "1101.2.7",
|
|
43
43
|
"@pnpm/logger": "1100.0.0",
|
|
44
44
|
"@pnpm/testing.mock-agent": "1101.0.7"
|
|
45
45
|
},
|