Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,11 @@ same major line. Should you need to upgrade to a new major, use an explicit

- `COREPACK_INTEGRITY_KEYS` can be set to an empty string or `0` to
instruct Corepack to skip integrity checks, or to a JSON string containing
custom keys.
custom keys. When it is not set and `COREPACK_NPM_REGISTRY` points to a
registry other than the default one, Corepack additionally trusts the signing
keys that registry publishes at `/-/npm/v1/keys`, so registries which re-sign
the packages they serve can still be verified. Those keys are only requested
when the bundled npm keys don't already cover the signature.

## Troubleshooting

Expand Down
2 changes: 1 addition & 1 deletion sources/corepackUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ export async function installVersion(installTarget: string, locator: Locator, {s
if (signatures! == null || integrity! == null)
({signatures, integrity} = (await npmRegistryUtils.fetchTarballURLAndSignature(registry.package, version)));

npmRegistryUtils.verifySignature({signatures, integrity, packageName: registry.package, version});
await npmRegistryUtils.verifySignature({signatures, integrity, packageName: registry.package, version});
// @ts-expect-error ignore readonly
build[1] = Buffer.from(integrity.slice(`sha512-`.length), `base64`).toString(`hex`);
}
Expand Down
125 changes: 107 additions & 18 deletions sources/npmRegistryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {createVerify} from 'crypto';
import defaultConfig from '../config.json';

import {shouldSkipIntegrityCheck} from './corepackUtils';
import * as debugUtils from './debugUtils';
import * as httpUtils from './httpUtils';

// load abbreviated metadata as that's all we need for these calls
Expand All @@ -13,12 +14,23 @@ export const DEFAULT_HEADERS: Record<string, string> = {
};
export const DEFAULT_NPM_REGISTRY_URL = `https://registry.npmjs.org`;

export async function fetchAsJson(packageName: string, version?: string) {
const npmRegistryUrl = process.env.COREPACK_NPM_REGISTRY || DEFAULT_NPM_REGISTRY_URL;
// Standard endpoint at which an npm registry publishes its signing keys.
// see: https://docs.npmjs.com/about-registry-signatures
const KEYS_ENDPOINT_PATH = `/-/npm/v1/keys`;

if (process.env.COREPACK_ENABLE_NETWORK === `0`)
throw new UsageError(`Network access disabled by the environment; can't reach npm repository ${npmRegistryUrl}`);
interface TrustedKey {
keyid: string;
key: string;
}

// A configured registry may carry a trailing slash, which would turn the keys
// path into `//-/npm/v1/keys`; not every registry resolves that to the same
// route, so it gets trimmed before the path is appended.
function trimTrailingSlashes(url: string) {
return url.replace(/\/+$/, ``);
}

function getRegistryHeaders() {
const headers = {...DEFAULT_HEADERS};

if (`COREPACK_NPM_TOKEN` in process.env) {
Expand All @@ -29,10 +41,70 @@ export async function fetchAsJson(packageName: string, version?: string) {
headers.authorization = `Basic ${encodedCreds}`;
}

return httpUtils.fetchAsJson(`${npmRegistryUrl}/${packageName}${version ? `/${version}` : ``}`, {headers});
return headers;
}

export function verifySignature({signatures, integrity, packageName, version}: {
export async function fetchAsJson(packageName: string, version?: string) {
const npmRegistryUrl = process.env.COREPACK_NPM_REGISTRY || DEFAULT_NPM_REGISTRY_URL;

if (process.env.COREPACK_ENABLE_NETWORK === `0`)
throw new UsageError(`Network access disabled by the environment; can't reach npm repository ${npmRegistryUrl}`);

return httpUtils.fetchAsJson(`${npmRegistryUrl}/${packageName}${version ? `/${version}` : ``}`, {headers: getRegistryHeaders()});
}

const registryKeysCache = new Map<string, Promise<Array<TrustedKey> | null>>();

async function fetchRegistryKeys(registryUrl: string): Promise<Array<TrustedKey> | null> {
const url = `${registryUrl}${KEYS_ENDPOINT_PATH}`;

try {
const data = await httpUtils.fetchAsJson(url, {headers: getRegistryHeaders()});

// Only keep well-formed entries; anything else is unusable for verification.
const keys = Array.isArray(data?.keys) ?
(data.keys as Array<unknown>).filter((key): key is TrustedKey =>
typeof (key as TrustedKey)?.keyid === `string` && typeof (key as TrustedKey)?.key === `string`) :
[];

if (!keys.length) {
debugUtils.log(`No usable signing keys returned by ${url}`);
return null;
}

return keys;
} catch (error) {
debugUtils.log(`Failed to fetch signing keys from ${url}: ${error}`);
return null;
}
}

function getRegistryKeys(registryUrl: string) {
let keys = registryKeysCache.get(registryUrl);
if (keys == null) {
keys = fetchRegistryKeys(registryUrl);
registryKeysCache.set(registryUrl, keys);
}

return keys;
}

function findTrustedSignature(signatures: Array<{keyid: string, sig: string}>, trustedKeys: Array<TrustedKey>) {
let signature: typeof signatures[0] | undefined;
let key!: string;
for (const k of trustedKeys) {
signature = signatures.find(({keyid}) => keyid === k.keyid);
if (signature != null) {
key = k.key;
break;
}
}
if (signature?.sig == null) return null;

return {signature, key};
}

export async function verifySignature({signatures, integrity, packageName, version}: {
signatures: Array<{keyid: string, sig: string}>;
integrity: string;
packageName: string;
Expand All @@ -41,25 +113,42 @@ export function verifySignature({signatures, integrity, packageName, version}: {
if (!Array.isArray(signatures) || !signatures.length) throw new Error(`No compatible signature found in package metadata`);

const {npm: trustedKeys} = process.env.COREPACK_INTEGRITY_KEYS ?
JSON.parse(process.env.COREPACK_INTEGRITY_KEYS) as typeof defaultConfig.keys :
JSON.parse(process.env.COREPACK_INTEGRITY_KEYS) as {npm: Array<TrustedKey>} :
defaultConfig.keys;

let signature: typeof signatures[0] | undefined;
let key!: string;
for (const k of trustedKeys) {
signature = signatures.find(({keyid}) => keyid === k.keyid);
if (signature != null) {
key = k.key;
break;
let match = findTrustedSignature(signatures, trustedKeys);

// The bundled keys only describe the public npm registry. When a custom
// registry is configured, the package it serves may legitimately be signed by
// that registry's own key (some registries re-sign what they serve), so fall
// back to the keys it publishes rather than rejecting the package outright.
// Those keys come from the very registry that serves the tarball we are about
// to execute, so consulting them doesn't widen the trust boundary. An explicit
// COREPACK_INTEGRITY_KEYS always wins, and the keys are only fetched when the
// bundled ones don't already cover the signature.
let registryKeys: Array<TrustedKey> | null = null;
const customRegistryUrl = process.env.COREPACK_NPM_REGISTRY ?
trimTrailingSlashes(process.env.COREPACK_NPM_REGISTRY) :
undefined;
if (match == null && !process.env.COREPACK_INTEGRITY_KEYS && customRegistryUrl && customRegistryUrl !== DEFAULT_NPM_REGISTRY_URL) {
registryKeys = await getRegistryKeys(customRegistryUrl);
if (registryKeys != null) {
match = findTrustedSignature(signatures, registryKeys);
}
}
if (signature?.sig == null) throw new UsageError(`The package was not signed by any trusted keys: ${JSON.stringify({signatures, trustedKeys}, undefined, 2)}`);

if (match == null) {
throw new UsageError(`The package was not signed by any trusted keys: ${JSON.stringify({
signatures,
trustedKeys: registryKeys == null ? trustedKeys : [...trustedKeys, ...registryKeys],
}, undefined, 2)}`);
}

const verifier = createVerify(`SHA256`);
verifier.end(`${packageName}@${version}:${integrity}`);
const valid = verifier.verify(
`-----BEGIN PUBLIC KEY-----\n${key}\n-----END PUBLIC KEY-----`,
signature.sig,
`-----BEGIN PUBLIC KEY-----\n${match.key}\n-----END PUBLIC KEY-----`,
match.signature.sig,
`base64`,
);
if (!valid) {
Expand All @@ -74,7 +163,7 @@ export async function fetchLatestStableVersion(packageName: string) {

if (!shouldSkipIntegrityCheck()) {
try {
verifySignature({
await verifySignature({
packageName, version,
integrity, signatures,
});
Expand Down
179 changes: 174 additions & 5 deletions tests/npmRegistryUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {Buffer} from 'node:buffer';
import process from 'node:process';
import {describe, beforeEach, it, expect, vi} from 'vitest';
import {Buffer} from 'node:buffer';
import {createHash, createSign, generateKeyPairSync} from 'node:crypto';
import process from 'node:process';
import {describe, beforeEach, it, expect, vi} from 'vitest';

import {fetchAsJson as httpFetchAsJson} from '../sources/httpUtils';
import {DEFAULT_HEADERS, DEFAULT_NPM_REGISTRY_URL, fetchAsJson} from '../sources/npmRegistryUtils';
import {fetchAsJson as httpFetchAsJson} from '../sources/httpUtils';
import {DEFAULT_HEADERS, DEFAULT_NPM_REGISTRY_URL, fetchAsJson, verifySignature} from '../sources/npmRegistryUtils';

vi.mock(`../sources/httpUtils`);

Expand Down Expand Up @@ -90,3 +91,171 @@ describe(`npm registry utils fetchAsJson`, () => {
expect(httpFetchAsJson).lastCalledWith(`${DEFAULT_NPM_REGISTRY_URL}/package-name`, {headers: DEFAULT_HEADERS});
});
});

describe(`npm registry utils verifySignature`, () => {
const packageName = `package-name`;
const version = `1.0.0`;
const KEYS_PATH = `/-/npm/v1/keys`;

// Fetched keys are cached per registry for the lifetime of the process, so
// every test needs its own registry URL to stay independent.
let registryCount = 0;
function uniqueRegistryUrl() {
return `https://registry-${++registryCount}.example.org`;
}

function signPackage() {
const integrity = `sha512-${Buffer.from(`${packageName}@${version}`).toString(`base64`)}`;
const {privateKey, publicKey} = generateKeyPairSync(`ec`, {
namedCurve: `prime256v1`,
publicKeyEncoding: {type: `spki`, format: `pem`},
privateKeyEncoding: {type: `pkcs8`, format: `pem`},
});
const keyid = `SHA256:${createHash(`SHA256`).end(publicKey).digest(`base64`)}`;
const sig = createSign(`SHA256`).end(`${packageName}@${version}:${integrity}`).sign(privateKey, `base64`);

return {
integrity,
signatures: [{keyid, sig}],
publishedKey: {
expires: null,
keyid,
keytype: `ecdsa-sha2-nistp256`,
scheme: `ecdsa-sha2-nistp256`,
key: publicKey.split(`\n`).slice(1, -2).join(``),
},
};
}

function mockKeysEndpoint(payload: any) {
vi.mocked(httpFetchAsJson).mockImplementation(async (input: string | URL) => {
if (`${input}`.endsWith(KEYS_PATH)) return payload;
throw new Error(`Unexpected request to ${input}`);
});
}

beforeEach(() => {
vi.resetAllMocks();
});

it(`verifies a signature made with a key published by the custom registry`, async () => {
const {integrity, signatures, publishedKey} = signPackage();
const registryUrl = uniqueRegistryUrl();
process.env.COREPACK_NPM_REGISTRY = registryUrl;
mockKeysEndpoint({keys: [publishedKey]});

await expect(verifySignature({signatures, integrity, packageName, version})).resolves.toBeUndefined();

expect(httpFetchAsJson).lastCalledWith(`${registryUrl}${KEYS_PATH}`, {headers: DEFAULT_HEADERS});
});

it(`does not double the slash when the configured registry ends with one`, async () => {
const {integrity, signatures, publishedKey} = signPackage();
const registryUrl = uniqueRegistryUrl();
process.env.COREPACK_NPM_REGISTRY = `${registryUrl}/`;
mockKeysEndpoint({keys: [publishedKey]});

await expect(verifySignature({signatures, integrity, packageName, version})).resolves.toBeUndefined();

expect(httpFetchAsJson).lastCalledWith(`${registryUrl}${KEYS_PATH}`, {headers: DEFAULT_HEADERS});
});

it(`does not fetch the published keys when the default registry is set explicitly with a trailing slash`, async () => {
const {integrity, signatures} = signPackage();
process.env.COREPACK_NPM_REGISTRY = `${DEFAULT_NPM_REGISTRY_URL}/`;

await expect(verifySignature({signatures, integrity, packageName, version})).rejects.toThrowError(/not signed by any trusted keys/);

expect(httpFetchAsJson).not.toBeCalled();
});

it(`sends the registry credentials when fetching the keys`, async () => {
const {integrity, signatures, publishedKey} = signPackage();
const registryUrl = uniqueRegistryUrl();
process.env.COREPACK_NPM_REGISTRY = registryUrl;
process.env.COREPACK_NPM_TOKEN = `foo`;
mockKeysEndpoint({keys: [publishedKey]});

await verifySignature({signatures, integrity, packageName, version});

expect(httpFetchAsJson).lastCalledWith(`${registryUrl}${KEYS_PATH}`, {headers: {
...DEFAULT_HEADERS,
authorization: `Bearer foo`,
}});
});

it(`ignores malformed entries in the published keys`, async () => {
const {integrity, signatures, publishedKey} = signPackage();
process.env.COREPACK_NPM_REGISTRY = uniqueRegistryUrl();
mockKeysEndpoint({keys: [`invalid`, {keyid: `SHA256:no-key`}, publishedKey]});

await expect(verifySignature({signatures, integrity, packageName, version})).resolves.toBeUndefined();
});

it(`only fetches the published keys once per registry`, async () => {
const {integrity, signatures, publishedKey} = signPackage();
process.env.COREPACK_NPM_REGISTRY = uniqueRegistryUrl();
mockKeysEndpoint({keys: [publishedKey]});

await verifySignature({signatures, integrity, packageName, version});
await verifySignature({signatures, integrity, packageName, version});

expect(httpFetchAsJson).toHaveBeenCalledTimes(1);
});

it(`does not fetch the published keys when no custom registry is configured`, async () => {
const {integrity, signatures} = signPackage();

await expect(verifySignature({signatures, integrity, packageName, version})).rejects.toThrowError(/not signed by any trusted keys/);

expect(httpFetchAsJson).not.toBeCalled();
});

it(`does not fetch the published keys when COREPACK_INTEGRITY_KEYS is set`, async () => {
const {integrity, signatures, publishedKey} = signPackage();
process.env.COREPACK_NPM_REGISTRY = uniqueRegistryUrl();
process.env.COREPACK_INTEGRITY_KEYS = JSON.stringify({npm: [{keyid: `SHA256:other`, key: `other`}]});
mockKeysEndpoint({keys: [publishedKey]});

await expect(verifySignature({signatures, integrity, packageName, version})).rejects.toThrowError(/not signed by any trusted keys/);

expect(httpFetchAsJson).not.toBeCalled();
});

it(`does not fetch the published keys when the bundled keys already cover the signature`, async () => {
const {integrity, signatures, publishedKey} = signPackage();
process.env.COREPACK_NPM_REGISTRY = uniqueRegistryUrl();
process.env.COREPACK_INTEGRITY_KEYS = JSON.stringify({npm: [publishedKey]});
mockKeysEndpoint({keys: []});

await expect(verifySignature({signatures, integrity, packageName, version})).resolves.toBeUndefined();

expect(httpFetchAsJson).not.toBeCalled();
});

it(`throws the usual error when the keys endpoint is unreachable`, async () => {
const {integrity, signatures} = signPackage();
process.env.COREPACK_NPM_REGISTRY = uniqueRegistryUrl();
vi.mocked(httpFetchAsJson).mockRejectedValue(new Error(`HTTP 404`));

await expect(verifySignature({signatures, integrity, packageName, version})).rejects.toThrowError(/not signed by any trusted keys/);
});

it(`throws the usual error when the registry publishes no usable keys`, async () => {
const {integrity, signatures} = signPackage();
process.env.COREPACK_NPM_REGISTRY = uniqueRegistryUrl();
mockKeysEndpoint({keys: []});

await expect(verifySignature({signatures, integrity, packageName, version})).rejects.toThrowError(/not signed by any trusted keys/);
});

it(`throws when the signature does not match the published key`, async () => {
const {integrity, signatures} = signPackage();
const {publishedKey: otherKey} = signPackage();
process.env.COREPACK_NPM_REGISTRY = uniqueRegistryUrl();
// Same keyid as the signature, but a key the signature was not made with.
mockKeysEndpoint({keys: [{...otherKey, keyid: signatures[0].keyid}]});

await expect(verifySignature({signatures, integrity, packageName, version})).rejects.toThrowError(/Signature does not match/);
});
});