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
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
10 changes: 8 additions & 2 deletions sources/npmRegistryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,18 @@ export async function fetchAsJson(packageName: string, version?: string) {
return httpUtils.fetchAsJson(`${npmRegistryUrl}/${packageName}${version ? `/${version}` : ``}`, {headers});
}

export function verifySignature({signatures, integrity, packageName, version}: {
export async function verifySignature({signatures, integrity, packageName, version}: {
signatures: Array<{keyid: string, sig: string}>;
integrity: string;
packageName: string;
version: string;
}) {
if (!Array.isArray(signatures) || !signatures.length) {
// Some registry proxies omit `dist.signatures` on the per-version endpoint but keep it on the package root.
const rootMetadata = await fetchAsJson(packageName);
signatures = rootMetadata.versions?.[version]?.dist?.signatures;
}

if (!Array.isArray(signatures) || !signatures.length) throw new Error(`No compatible signature found in package metadata`);

const {npm: trustedKeys} = process.env.COREPACK_INTEGRITY_KEYS ?
Expand Down Expand Up @@ -74,7 +80,7 @@ export async function fetchLatestStableVersion(packageName: string) {

if (!shouldSkipIntegrityCheck()) {
try {
verifySignature({
await verifySignature({
packageName, version,
integrity, signatures,
});
Expand Down
65 changes: 60 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 {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,57 @@ 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 integrity = `sha512-abcdef`;
const keyid = `SHA256:test-key`;

let signatures: Array<{keyid: string, sig: string}>;

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

const {publicKey, privateKey} = generateKeyPairSync(`ec`, {
namedCurve: `prime256v1`,
publicKeyEncoding: {type: `spki`, format: `der`},
privateKeyEncoding: {type: `pkcs8`, format: `pem`},
});

const signer = createSign(`SHA256`);
signer.end(`${packageName}@${version}:${integrity}`);
signatures = [{keyid, sig: signer.sign(privateKey, `base64`)}];

process.env.COREPACK_INTEGRITY_KEYS = JSON.stringify({npm: [{keyid, key: publicKey.toString(`base64`)}]});
});

it(`verifies using the version endpoint's signatures without a fallback fetch`, async () => {
await expect(verifySignature({signatures, integrity, packageName, version})).resolves.toBeUndefined();

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

it(`falls back to the package-root endpoint when signatures are missing on the version endpoint`, async () => {
vi.mocked(httpFetchAsJson).mockResolvedValue({
versions: {
[version]: {dist: {signatures}},
},
});

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

expect(httpFetchAsJson).toBeCalledTimes(1);
expect(httpFetchAsJson).lastCalledWith(`${DEFAULT_NPM_REGISTRY_URL}/${packageName}`, {headers: DEFAULT_HEADERS});
});

it(`throws when signatures are missing on both the version and package-root endpoints`, async () => {
vi.mocked(httpFetchAsJson).mockResolvedValue({
versions: {
[version]: {dist: {}},
},
});

await expect(verifySignature({signatures: [], integrity, packageName, version})).rejects.toThrowError(`No compatible signature found in package metadata`);
});
});