Skip to content

Commit e561dd0

Browse files
authored
feat: verify integrity signature when downloading from npm registry (#432)
When the user has not provided any hash (so when running `corepack up`/`corepack use …`), and the package manager is downloaded from the npm registry, we can verify the signature. BREAKING CHANGE: attempting to download a version from the npm registry (or a mirror) that was published using the now deprecated PGP signature without providing a hash will trigger an error. Users can disable the signature verification using a environment variable.
1 parent 2d63536 commit e561dd0

12 files changed

+336
-20
lines changed

README.md

+3
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,9 @@ same major line. Should you need to upgrade to a new major, use an explicit
294294
- `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` are supported through
295295
[`node-proxy-agent`](https://github.com/TooTallNate/node-proxy-agent).
296296

297+
- `COREPACK_INTEGRITY_KEYS` can be set to an empty string to instruct Corepack
298+
to skip integrity checks, or a JSON string containing custom keys.
299+
297300
## Troubleshooting
298301

299302
### Networking

config.json

+11
Original file line numberDiff line numberDiff line change
@@ -161,5 +161,16 @@
161161
}
162162
}
163163
}
164+
},
165+
"keys": {
166+
"npm": [
167+
{
168+
"expires": null,
169+
"keyid": "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA",
170+
"keytype": "ecdsa-sha2-nistp256",
171+
"scheme": "ecdsa-sha2-nistp256",
172+
"key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg=="
173+
}
174+
]
164175
}
165176
}

sources/Engine.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ export class Engine {
296296

297297
let isTransparentCommand = false;
298298
if (packageManager != null) {
299-
const defaultVersion = await this.getDefaultVersion(packageManager);
299+
const defaultVersion = binaryVersion || await this.getDefaultVersion(packageManager);
300300
const definition = this.config.definitions[packageManager]!;
301301

302302
// If all leading segments match one of the patterns defined in the `transparent`

sources/corepackUtils.ts

+15-2
Original file line numberDiff line numberDiff line change
@@ -219,13 +219,15 @@ export async function installVersion(installTarget: string, locator: Locator, {s
219219
}
220220

221221
let url: string;
222+
let signatures: Array<{keyid: string, sig: string}>;
223+
let integrity: string;
222224
let binPath: string | null = null;
223225
if (locatorIsASupportedPackageManager) {
224226
url = spec.url.replace(`{}`, version);
225227
if (process.env.COREPACK_NPM_REGISTRY) {
226228
const registry = getRegistryFromPackageManagerSpec(spec);
227229
if (registry.type === `npm`) {
228-
url = await npmRegistryUtils.fetchTarballUrl(registry.package, version);
230+
({tarball: url, signatures, integrity} = await npmRegistryUtils.fetchTarballURLAndSignature(registry.package, version));
229231
if (registry.bin) {
230232
binPath = registry.bin;
231233
}
@@ -247,7 +249,7 @@ export async function installVersion(installTarget: string, locator: Locator, {s
247249
}
248250

249251
debugUtils.log(`Installing ${locator.name}@${version} from ${url}`);
250-
const algo = build[0] ?? `sha256`;
252+
const algo = build[0] ?? `sha512`;
251253
const {tmpFolder, outputFile, hash: actualHash} = await download(installTarget, url, algo, binPath);
252254

253255
let bin: BinSpec | BinList;
@@ -280,6 +282,17 @@ export async function installVersion(installTarget: string, locator: Locator, {s
280282
}
281283
}
282284

285+
if (!build[1]) {
286+
const registry = getRegistryFromPackageManagerSpec(spec);
287+
if (registry.type === `npm` && !registry.bin && process.env.COREPACK_INTEGRITY_KEYS !== ``) {
288+
if (signatures! == null || integrity! == null)
289+
({signatures, integrity} = (await npmRegistryUtils.fetchTarballURLAndSignature(registry.package, version)));
290+
291+
npmRegistryUtils.verifySignature({signatures, integrity, packageName: registry.package, version});
292+
// @ts-expect-error ignore readonly
293+
build[1] = Buffer.from(integrity.slice(`sha512-`.length), `base64`).toString(`hex`);
294+
}
295+
}
283296
if (build[1] && actualHash !== build[1])
284297
throw new Error(`Mismatch hashes. Expected ${build[1]}, got ${actualHash}`);
285298

sources/npmRegistryUtils.ts

+43-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import {UsageError} from 'clipanion';
2+
import {createVerify} from 'crypto';
3+
4+
import defaultConfig from '../config.json';
25

36
import * as httpUtils from './httpUtils';
47

@@ -28,11 +31,46 @@ export async function fetchAsJson(packageName: string, version?: string) {
2831
return httpUtils.fetchAsJson(`${npmRegistryUrl}/${packageName}${version ? `/${version}` : ``}`, {headers});
2932
}
3033

34+
export function verifySignature({signatures, integrity, packageName, version}: {
35+
signatures: Array<{keyid: string, sig: string}>;
36+
integrity: string;
37+
packageName: string;
38+
version: string;
39+
}) {
40+
const {npm: keys} = process.env.COREPACK_INTEGRITY_KEYS ?
41+
JSON.parse(process.env.COREPACK_INTEGRITY_KEYS) as typeof defaultConfig.keys :
42+
defaultConfig.keys;
43+
44+
const key = keys.find(({keyid}) => signatures.some(s => s.keyid === keyid));
45+
const signature = signatures.find(({keyid}) => keyid === key?.keyid);
46+
47+
if (key == null || signature == null) throw new Error(`Cannot find matching keyid: ${JSON.stringify({signatures, keys})}`);
48+
49+
const verifier = createVerify(`SHA256`);
50+
verifier.end(`${packageName}@${version}:${integrity}`);
51+
const valid = verifier.verify(
52+
`-----BEGIN PUBLIC KEY-----\n${key.key}\n-----END PUBLIC KEY-----`,
53+
signature.sig,
54+
`base64`,
55+
);
56+
if (!valid) {
57+
throw new Error(`Signature does not match`);
58+
}
59+
}
60+
3161
export async function fetchLatestStableVersion(packageName: string) {
3262
const metadata = await fetchAsJson(packageName, `latest`);
3363

34-
const {shasum} = metadata.dist;
35-
return `${metadata.version}+sha1.${shasum}`;
64+
const {version, dist: {integrity, signatures}} = metadata;
65+
66+
if (process.env.COREPACK_INTEGRITY_KEYS !== ``) {
67+
verifySignature({
68+
packageName, version,
69+
integrity, signatures,
70+
});
71+
}
72+
73+
return `${version}+sha512.${Buffer.from(integrity.slice(7), `base64`).toString(`hex`)}`;
3674
}
3775

3876
export async function fetchAvailableTags(packageName: string) {
@@ -45,11 +83,11 @@ export async function fetchAvailableVersions(packageName: string) {
4583
return Object.keys(metadata.versions);
4684
}
4785

48-
export async function fetchTarballUrl(packageName: string, version: string) {
86+
export async function fetchTarballURLAndSignature(packageName: string, version: string) {
4987
const versionMetadata = await fetchAsJson(packageName, version);
50-
const {tarball} = versionMetadata.dist;
88+
const {tarball, signatures, integrity} = versionMetadata.dist;
5189
if (tarball === undefined || !tarball.startsWith(`http`))
5290
throw new Error(`${packageName}@${version} does not have a valid tarball.`);
5391

54-
return tarball;
92+
return {tarball, signatures, integrity};
5593
}

sources/types.ts

+10
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,16 @@ export interface Config {
103103
};
104104
};
105105
};
106+
107+
keys: {
108+
[registry: string]: Array<{
109+
expires: null;
110+
keyid: string;
111+
keytype: string;
112+
scheme: string;
113+
key: string;
114+
}>;
115+
};
106116
}
107117

108118
/**

tests/Up.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe(`UpCommand`, () => {
2323
});
2424

2525
await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({
26-
packageManager: `yarn@2.4.3+sha256.8c1575156cfa42112242cc5cfbbd1049da9448ffcdb5c55ce996883610ea983f`,
26+
packageManager: `yarn@2.4.3+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`,
2727
});
2828

2929
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({

tests/Use.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ describe(`UseCommand`, () => {
2222
});
2323

2424
await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({
25-
packageManager: `yarn@1.22.4+sha256.bc5316aa110b2f564a71a3d6e235be55b98714660870c5b6b2d2d3f12587fb58`,
25+
packageManager: `yarn@1.22.4+sha512.a1833b862fe52169bd6c2a033045a07df5bc6a23595c259e675fed1b2d035ab37abe6ce309720abb6636d68f03615054b6292dc0a70da31c8697fda228b50d18`,
2626
});
2727

2828
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
@@ -40,7 +40,7 @@ describe(`UseCommand`, () => {
4040
});
4141

4242
await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({
43-
packageManager: `yarn@1.22.4+sha256.bc5316aa110b2f564a71a3d6e235be55b98714660870c5b6b2d2d3f12587fb58`,
43+
packageManager: `yarn@1.22.4+sha512.a1833b862fe52169bd6c2a033045a07df5bc6a23595c259e675fed1b2d035ab37abe6ce309720abb6636d68f03615054b6292dc0a70da31c8697fda228b50d18`,
4444
});
4545

4646
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({

tests/_registryServer.mjs

+59-9
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,40 @@
1-
import {createHash} from 'node:crypto';
2-
import {once} from 'node:events';
3-
import {createServer} from 'node:http';
4-
import {connect} from 'node:net';
5-
import {gzipSync} from 'node:zlib';
1+
import {createHash, createSign, generateKeyPairSync} from 'node:crypto';
2+
import {once} from 'node:events';
3+
import {createServer} from 'node:http';
4+
import {connect} from 'node:net';
5+
import {gzipSync} from 'node:zlib';
6+
7+
let privateKey, keyid;
8+
9+
switch (process.env.TEST_INTEGRITY) {
10+
case `invalid_signature`: {
11+
({privateKey} = generateKeyPairSync(`ec`, {
12+
namedCurve: `sect239k1`,
13+
}));
14+
}
15+
// eslint-disable-next-line no-fallthrough
16+
case `invalid_integrity`:
17+
case `valid`: {
18+
const {privateKey: p, publicKey} = generateKeyPairSync(`ec`, {
19+
namedCurve: `sect239k1`,
20+
publicKeyEncoding: {
21+
type: `spki`,
22+
format: `pem`,
23+
},
24+
});
25+
privateKey ??= p;
26+
keyid = `SHA256:${createHash(`SHA256`).end(publicKey).digest(`base64`)}`;
27+
process.env.COREPACK_INTEGRITY_KEYS = JSON.stringify({npm: [{
28+
expires: null,
29+
keyid,
30+
keytype: `ecdsa-sha2-sect239k1`,
31+
scheme: `ecdsa-sha2-sect239k1`,
32+
key: publicKey.split(`\n`).slice(1, -2).join(``),
33+
}]});
34+
break;
35+
}
36+
}
37+
638

739
function createSimpleTarArchive(fileName, fileContent, mode = 0o644) {
840
const contentBuffer = Buffer.from(fileContent);
@@ -13,7 +45,7 @@ function createSimpleTarArchive(fileName, fileContent, mode = 0o644) {
1345
header.write(`0001750 `, 108, 8, `utf-8`); // Owner's numeric user ID (octal) followed by a space
1446
header.write(`0001750 `, 116, 8, `utf-8`); // Group's numeric user ID (octal) followed by a space
1547
header.write(`${contentBuffer.length.toString(8)} `, 124, 12, `utf-8`); // File size in bytes (octal) followed by a space
16-
header.write(`${Math.floor(Date.now() / 1000).toString(8)} `, 136, 12, `utf-8`); // Last modification time in numeric Unix time format (octal) followed by a space
48+
header.write(`${Math.floor(new Date(2000, 1, 1) / 1000).toString(8)} `, 136, 12, `utf-8`); // Last modification time in numeric Unix time format (octal) followed by a space
1749
header.fill(` `, 148, 156); // Fill checksum area with spaces for calculation
1850
header.write(`ustar `, 257, 8, `utf-8`); // UStar indicator
1951

@@ -37,7 +69,11 @@ const mockPackageTarGz = gzipSync(Buffer.concat([
3769
Buffer.alloc(1024),
3870
]));
3971
const shasum = createHash(`sha1`).update(mockPackageTarGz).digest(`hex`);
40-
const integrity = `sha512-${createHash(`sha512`).update(mockPackageTarGz).digest(`base64`)}`;
72+
const integrity = `sha512-${createHash(`sha512`).update(
73+
process.env.TEST_INTEGRITY === `invalid_integrity` ?
74+
mockPackageTarGz.subarray(1) :
75+
mockPackageTarGz,
76+
).digest(`base64`)}`;
4177

4278
const registry = {
4379
__proto__: null,
@@ -48,6 +84,14 @@ const registry = {
4884
customPkgManager: [`1.0.0`],
4985
};
5086

87+
function generateSignature(packageName, version) {
88+
if (privateKey == null) return undefined;
89+
const sign = createSign(`SHA256`).end(`${packageName}@${version}:${integrity}`);
90+
return {signatures: [{
91+
keyid,
92+
sig: sign.sign(privateKey, `base64`),
93+
}]};
94+
}
5195
function generateVersionMetadata(packageName, version) {
5296
return {
5397
name: packageName,
@@ -61,14 +105,20 @@ function generateVersionMetadata(packageName, version) {
61105
size: mockPackageTarGz.length,
62106
noattachment: false,
63107
tarball: `${process.env.COREPACK_NPM_REGISTRY}/${packageName}/-/${packageName}-${version}.tgz`,
108+
...generateSignature(packageName, version),
64109
},
65110
};
66111
}
67112

113+
const TOKEN_MOCK = `SOME_DUMMY_VALUE`;
114+
68115
const server = createServer((req, res) => {
69116
const auth = req.headers.authorization;
70117

71-
if (auth?.startsWith(`Basic `) && Buffer.from(auth.slice(`Basic `.length), `base64`).toString() !== `user:pass`) {
118+
if (
119+
(auth?.startsWith(`Bearer `) && auth.slice(`Bearer `.length) !== TOKEN_MOCK) ||
120+
(auth?.startsWith(`Basic `) && Buffer.from(auth.slice(`Basic `.length), `base64`).toString() !== `user:pass`)
121+
) {
72122
res.writeHead(401).end(`Unauthorized`);
73123
return;
74124
}
@@ -159,7 +209,7 @@ switch (process.env.AUTH_TYPE) {
159209

160210
case `COREPACK_NPM_TOKEN`:
161211
process.env.COREPACK_NPM_REGISTRY = `http://${address.includes(`:`) ? `[${address}]` : address}:${port}`;
162-
process.env.COREPACK_NPM_TOKEN = Buffer.from(`user:pass`).toString(`base64`);
212+
process.env.COREPACK_NPM_TOKEN = TOKEN_MOCK;
163213
break;
164214

165215
case `COREPACK_NPM_PASSWORD`:

tests/config.test.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {jest, describe, it, expect} from '@jest/globals';
2+
3+
import defaultConfig from '../config.json';
4+
import {DEFAULT_NPM_REGISTRY_URL} from '../sources/npmRegistryUtils';
5+
6+
jest.mock(`../sources/httpUtils`);
7+
8+
describe(`key store should be up-to-date`, () => {
9+
it(`should contain up-to-date npm keys`, async () => {
10+
const r = await globalThis.fetch(new URL(`/-/npm/v1/keys`, DEFAULT_NPM_REGISTRY_URL));
11+
expect(r.ok).toBe(true);
12+
expect(r.json()).resolves.toMatchObject({keys: defaultConfig.keys.npm});
13+
});
14+
});

0 commit comments

Comments
 (0)