corepack/sources/npmRegistryUtils.ts

191 lines
7.5 KiB
TypeScript

import * as sigstoreTuf from '@sigstore/tuf';
import {UsageError} from 'clipanion';
import assert from 'node:assert';
import * as crypto from 'node:crypto';
import * as path from 'node:path';
import defaultConfig from '../config.json';
import {shouldSkipIntegrityCheck} from './corepackUtils';
import * as debugUtils from './debugUtils';
import * as folderUtils from './folderUtils';
import * as httpUtils from './httpUtils';
// load abbreviated metadata as that's all we need for these calls
// see: https://github.com/npm/registry/blob/cfe04736f34db9274a780184d1cdb2fb3e4ead2a/docs/responses/package-metadata.md
export const DEFAULT_HEADERS: Record<string, string> = {
[`Accept`]: `application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8`,
};
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;
if (process.env.COREPACK_ENABLE_NETWORK === `0`)
throw new UsageError(`Network access disabled by the environment; can't reach npm repository ${npmRegistryUrl}`);
const headers = {...DEFAULT_HEADERS};
if (`COREPACK_NPM_TOKEN` in process.env) {
headers.authorization = `Bearer ${process.env.COREPACK_NPM_TOKEN}`;
} else if (`COREPACK_NPM_USERNAME` in process.env
&& `COREPACK_NPM_PASSWORD` in process.env) {
const encodedCreds = Buffer.from(`${process.env.COREPACK_NPM_USERNAME}:${process.env.COREPACK_NPM_PASSWORD}`, `utf8`).toString(`base64`);
headers.authorization = `Basic ${encodedCreds}`;
}
return httpUtils.fetchAsJson(`${npmRegistryUrl}/${packageName}${version ? `/${version}` : ``}`, {headers});
}
interface KeyInfo {
keyid: string;
// base64 encoded DER SPKI
keyData: string;
}
async function fetchSigstoreTufKeys(): Promise<Array<KeyInfo> | null> {
// This follows the implementation for npm.
// See https://github.com/npm/cli/blob/3a80a7b7d168c23b5e297cba7b47ba5b9875934d/lib/utils/verify-signatures.js#L174
let keysRaw: string;
try {
// @ts-expect-error inject custom fetch into monkey-patched `tuf-js` module.
globalThis.tufJsFetch = async (input: string) => {
const agent = await httpUtils.getProxyAgent(input);
return await globalThis.fetch(input, {
dispatcher: agent,
});
};
const sigstoreTufClient = await sigstoreTuf.initTUF({
cachePath: path.join(folderUtils.getCorepackHomeFolder(), `_tuf`),
});
keysRaw = await sigstoreTufClient.getTarget(`registry.npmjs.org/keys.json`);
} catch (error) {
console.warn(`Warning: Failed to get signing keys from Sigstore TUF repo`, error);
return null;
}
// The format of the key file is undocumented but follows `PublicKey` from
// sigstore/protobuf-specs.
// See https://github.com/sigstore/protobuf-specs/blob/main/gen/pb-typescript/src/__generated__/sigstore_common.ts
const keysFromSigstore = JSON.parse(keysRaw) as {keys: Array<{keyId: string, publicKey: {rawBytes: string, keyDetails: string}}>};
return keysFromSigstore.keys.filter(key => {
if (key.publicKey.keyDetails === `PKIX_ECDSA_P256_SHA_256`) {
return true;
} else {
debugUtils.log(`Unsupported verification key type ${key.publicKey.keyDetails}`);
return false;
}
}).map(k => ({
keyid: k.keyId,
keyData: k.publicKey.rawBytes,
}));
}
async function getVerificationKeys(): Promise<Array<KeyInfo>> {
let keys: Array<{keyid: string, key: string}>;
if (process.env.COREPACK_INTEGRITY_KEYS) {
// We use the format of the `GET /-/npm/v1/keys` endpoint with `npm` instead
// of `keys` as the wrapping key.
const keysFromEnv = JSON.parse(process.env.COREPACK_INTEGRITY_KEYS) as {npm: Array<{keyid: string, key: string}>};
keys = keysFromEnv.npm;
debugUtils.log(`Using COREPACK_INTEGRITY_KEYS to verify signatures: ${keys.map(k => k.keyid).join(`, `)}`);
return keys.map(k => ({
keyid: k.keyid,
keyData: k.key,
}));
}
const sigstoreKeys = await fetchSigstoreTufKeys();
if (sigstoreKeys) {
debugUtils.log(`Using NPM keys from @sigstore/tuf to verify signatures: ${sigstoreKeys.map(k => k.keyid).join(`, `)}`);
return sigstoreKeys;
}
debugUtils.log(`Falling back to built-in npm verification keys`);
return defaultConfig.keys.npm.map(k => ({
keyid: k.keyid,
keyData: k.key,
}));
}
let verificationKeysCache: Promise<Array<KeyInfo>> | null = null;
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) throw new Error(`No compatible signature found in package metadata`);
if (!verificationKeysCache)
verificationKeysCache = getVerificationKeys();
const keys = await verificationKeysCache;
const keyInfo = keys.find(({keyid}) => signatures.some(s => s.keyid === keyid));
if (keyInfo == null)
throw new Error(`Cannot find key to verify signature. signature keys: ${signatures.map(s => s.keyid)}, verification keys: ${keys.map(k => k.keyid)}`);
const signature = signatures.find(({keyid}) => keyid === keyInfo.keyid);
assert(signature);
const verifier = crypto.createVerify(`SHA256`);
const payload = `${packageName}@${version}:${integrity}`;
verifier.end(payload);
const key = crypto.createPublicKey({key: Buffer.from(keyInfo.keyData, `base64`), format: `der`, type: `spki`});
const valid = verifier.verify(key, signature.sig, `base64`);
if (!valid) {
throw new Error(
`Signature verification failed for ${payload} with key ${keyInfo.keyid}\n` +
`If you are using a custom registry you can set COREPACK_INTEGRITY_KEYS.`,
);
}
}
export async function fetchLatestStableVersion(packageName: string) {
const metadata = await fetchAsJson(packageName, `latest`);
const {version, dist: {integrity, signatures, shasum}} = metadata;
if (!shouldSkipIntegrityCheck()) {
try {
await verifySignature({
packageName, version,
integrity, signatures,
});
} catch (cause) {
// TODO: consider switching to `UsageError` when https://github.com/arcanis/clipanion/issues/157 is fixed
throw new Error(`Corepack cannot download the latest stable version of ${packageName}; you can disable signature verification by setting COREPACK_INTEGRITY_CHECK to 0 in your env, or instruct Corepack to use the latest stable release known by this version of Corepack by setting COREPACK_USE_LATEST to 0`, {cause});
}
}
return `${version}+${
integrity ?
`sha512.${Buffer.from(integrity.slice(7), `base64`).toString(`hex`)}` :
`sha1.${shasum}`
}`;
}
export async function fetchAvailableTags(packageName: string) {
const metadata = await fetchAsJson(packageName);
return metadata[`dist-tags`];
}
export async function fetchAvailableVersions(packageName: string) {
const metadata = await fetchAsJson(packageName);
return Object.keys(metadata.versions);
}
export async function fetchTarballURLAndSignature(packageName: string, version: string) {
const versionMetadata = await fetchAsJson(packageName, version);
const {tarball, signatures, integrity} = versionMetadata.dist;
if (tarball === undefined || !tarball.startsWith(`http`))
throw new Error(`${packageName}@${version} does not have a valid tarball.`);
return {tarball, signatures, integrity};
}