Skip to content

Get NPM signing keys from @sigstore/tuf #647

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
31 changes: 31 additions & 0 deletions .yarn/patches/tuf-js-npm-3.0.1-9135d15fbd.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
diff --git a/dist/fetcher.js b/dist/fetcher.js
index f966ce1bb0cdc6c785ce1263f1faea15d3fe764c..111588c64ba0cc049cabeb471d39f0fdc78bbb7e 100644
--- a/dist/fetcher.js
+++ b/dist/fetcher.js
@@ -6,7 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.DefaultFetcher = exports.BaseFetcher = void 0;
const debug_1 = __importDefault(require("debug"));
const fs_1 = __importDefault(require("fs"));
-const make_fetch_happen_1 = __importDefault(require("make-fetch-happen"));
+const stream = require("node:stream")
const util_1 = __importDefault(require("util"));
const error_1 = require("./error");
const tmpfile_1 = require("./utils/tmpfile");
@@ -61,14 +61,12 @@ class DefaultFetcher extends BaseFetcher {
}
async fetch(url) {
log('GET %s', url);
- const response = await (0, make_fetch_happen_1.default)(url, {
- timeout: this.timeout,
- retry: this.retry,
- });
+ const response = await globalThis.tufJsFetch(url);
+
if (!response.ok || !response?.body) {
throw new error_1.DownloadHTTPError('Failed to download', response.status);
}
- return response.body;
+ return stream.Readable.fromWeb(response.body);
}
}
exports.DefaultFetcher = DefaultFetcher;
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,8 +355,17 @@ same major line. Should you need to upgrade to a new major, use an explicit
[`proxy-from-env`](https://github.com/Rob--W/proxy-from-env).

- `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.
instruct Corepack to skip signature verification, or to a JSON string
containing custom keys. The format based on the response of the
`GET /-/npm/v1/keys` endpoint of npm registry under the `npm` key. That is,

```bash
curl https://registry.npmjs.org/-/npm/v1/keys | jq -c '{npm: .keys}'
```

See the [npm documentation on
signatures](https://docs.npmjs.com/about-registry-signatures) for more
information.

## Troubleshooting

Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"license": "MIT",
"packageManager": "[email protected]+sha512.5383cc12567a95f1d668fbe762dfe0075c595b4bfff433be478dbbe24e05251a8e8c3eb992a986667c1d53b6c3a9c85b8398c35a960587fbd9fa3a0915406728",
"devDependencies": {
"@sigstore/tuf": "^3.1.0",
"@sinonjs/fake-timers": "^14.0.0",
"@types/debug": "^4.1.5",
"@types/node": "^20.4.6",
"@types/proxy-from-env": "^1",
Expand All @@ -43,7 +45,8 @@
"which": "^5.0.0"
},
"resolutions": {
"undici-types": "6.x"
"undici-types": "6.x",
"tuf-js@npm:^3.0.1": "patch:tuf-js@npm%3A3.0.1#~/.yarn/patches/tuf-js-npm-3.0.1-9135d15fbd.patch"
},
"scripts": {
"build": "run clean && run build:bundle && tsx ./mkshims.ts",
Expand Down
2 changes: 1 addition & 1 deletion sources/corepackUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,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
2 changes: 1 addition & 1 deletion sources/httpUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export async function fetchUrlStream(input: string | URL, init?: RequestInit) {

let ProxyAgent: typeof import('undici').ProxyAgent;

async function getProxyAgent(input: string | URL) {
export async function getProxyAgent(input: string | URL) {
const {getProxyForUrl} = await import(`proxy-from-env`);

// @ts-expect-error - The internal implementation is compatible with a WHATWG URL instance
Expand Down
131 changes: 105 additions & 26 deletions sources/npmRegistryUtils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import * as sigstoreTuf from '@sigstore/tuf';
import {UsageError} from 'clipanion';
import {createVerify} from 'crypto';
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
Expand Down Expand Up @@ -32,38 +37,112 @@ export async function fetchAsJson(packageName: string, version?: string) {
return httpUtils.fetchAsJson(`${npmRegistryUrl}/${packageName}${version ? `/${version}` : ``}`, {headers});
}

export function verifySignature({signatures, integrity, packageName, version}: {
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`);

const {npm: trustedKeys} = process.env.COREPACK_INTEGRITY_KEYS ?
JSON.parse(process.env.COREPACK_INTEGRITY_KEYS) as typeof defaultConfig.keys :
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;
}
}
if (signature?.sig == null) throw new UsageError(`The package was not signed by any trusted keys: ${JSON.stringify({signatures, trustedKeys}, 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,
`base64`,
);
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 does not match`);
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.`,
);
}
}

Expand All @@ -74,7 +153,7 @@ export async function fetchLatestStableVersion(packageName: string) {

if (!shouldSkipIntegrityCheck()) {
try {
verifySignature({
await verifySignature({
packageName, version,
integrity, signatures,
});
Expand Down
28 changes: 14 additions & 14 deletions tests/_registryServer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ import {gzipSync} from 'node:zlib';
let privateKey, keyid;

switch (process.env.TEST_INTEGRITY) {
case `invalid_npm_signature`: {
// Claim to use a known NPM signing key but provide a signature from a different key
keyid = `SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U`;
({privateKey} = generateKeyPairSync(`ec`, {
namedCurve: `sect239k1`,
}));
break;
}
case `invalid_signature`: {
({privateKey} = generateKeyPairSync(`ec`, {
namedCurve: `sect239k1`,
Expand Down Expand Up @@ -195,6 +203,7 @@ if (process.env.AUTH_TYPE === `PROXY`) {
}

server.listen(0, `localhost`);
server.unref();
await once(server, `listening`);

const {address, port} = server.address();
Expand Down Expand Up @@ -222,17 +231,8 @@ switch (process.env.AUTH_TYPE) {
default: throw new Error(`Invalid AUTH_TYPE in env`, {cause: process.env.AUTH_TYPE});
}

if (process.env.NOCK_ENV === `replay`) {
const originalFetch = globalThis.fetch;
globalThis.fetch = function fetch(i) {
if (!`${i}`.startsWith(
process.env.AUTH_TYPE === `PROXY` ?
`http://example.com` :
`http://${address.includes(`:`) ? `[${address}]` : address}:${port}`))
throw new Error(`Unexpected request to ${i}`);

return Reflect.apply(originalFetch, this, arguments);
};
}

server.unref();
globalThis.fetch.passthroughUrls.push(
process.env.AUTH_TYPE === `PROXY` ?
`http://example.com` :
`http://${address.includes(`:`) ? `[${address}]` : address}:${port}`,
);
8 changes: 7 additions & 1 deletion tests/_runCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ export async function runCli(cwd: PortablePath, argv: Array<string>, withCustomR
const err: Array<Buffer> = [];

return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [`--no-warnings`, ...(withCustomRegistry ? [`--import`, pathToFileURL(path.join(__dirname, `_registryServer.mjs`)) as any as string] : [`-r`, require.resolve(`./recordRequests.js`)]), require.resolve(`../dist/corepack.js`), ...argv], {
const child = spawn(process.execPath, [
`--no-warnings`,
...(withCustomRegistry ? [`--import`, pathToFileURL(path.join(__dirname, `_registryServer.mjs`)).toString()] : []),
`--require`, require.resolve(`./recordRequests.js`),
require.resolve(`../dist/corepack.js`),
...argv,
], {
cwd: npath.fromPortablePath(cwd),
env: process.env,
stdio: `pipe`,
Expand Down
Loading