From f9bccbba81c5032d873f219ce696c5dfbf64f8c1 Mon Sep 17 00:00:00 2001 From: DanielC000 <91308079+DanielC000@users.noreply.github.com> Date: Sun, 12 Jul 2026 02:38:08 +0200 Subject: [PATCH] fix: fall back to package-root metadata when dist.signatures is missing on the version endpoint --- sources/corepackUtils.ts | 2 +- sources/npmRegistryUtils.ts | 10 ++++-- tests/npmRegistryUtils.test.ts | 65 +++++++++++++++++++++++++++++++--- 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/sources/corepackUtils.ts b/sources/corepackUtils.ts index c02571ab6..10fef4a9c 100644 --- a/sources/corepackUtils.ts +++ b/sources/corepackUtils.ts @@ -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`); } diff --git a/sources/npmRegistryUtils.ts b/sources/npmRegistryUtils.ts index a7d110e95..2755a1149 100644 --- a/sources/npmRegistryUtils.ts +++ b/sources/npmRegistryUtils.ts @@ -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 ? @@ -74,7 +80,7 @@ export async function fetchLatestStableVersion(packageName: string) { if (!shouldSkipIntegrityCheck()) { try { - verifySignature({ + await verifySignature({ packageName, version, integrity, signatures, }); diff --git a/tests/npmRegistryUtils.test.ts b/tests/npmRegistryUtils.test.ts index 728d6f977..6deabb8d6 100644 --- a/tests/npmRegistryUtils.test.ts +++ b/tests/npmRegistryUtils.test.ts @@ -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`); @@ -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`); + }); +});