diff --git a/workspaces/libnpmpublish/README.md b/workspaces/libnpmpublish/README.md index 9c9c61d4b5965..90b1f7c68ab4f 100644 --- a/workspaces/libnpmpublish/README.md +++ b/workspaces/libnpmpublish/README.md @@ -51,6 +51,17 @@ A couple of options of note: token for the registry. For other ways to pass in auth details, see the n-r-f docs. +* `opts.provenance` - when running in a supported CI environment, will trigger + the generation of a signed provenance statement to be published alongside + the package. Mutually exclusive with the `provenanceFile` option. + +* `opts.provenanceFile` - specifies the path to an externally-generated + provenance statement to be published alongside the package. Mutually + exclusive with the `provenance` option. The specified file should be a + [Sigstore Bundle](https://github.com/sigstore/protobuf-specs/blob/main/protos/sigstore_bundle.proto) + containing a [DSSE](https://github.com/secure-systems-lab/dsse)-packaged + provenance statement. + #### `> libpub.publish(manifest, tarData, [opts]) -> Promise` Sends the package represented by the `manifest` and `tarData` to the diff --git a/workspaces/libnpmpublish/lib/provenance.js b/workspaces/libnpmpublish/lib/provenance.js index 1eb870da5f24f..ebe4a24475331 100644 --- a/workspaces/libnpmpublish/lib/provenance.js +++ b/workspaces/libnpmpublish/lib/provenance.js @@ -1,4 +1,5 @@ const { sigstore } = require('sigstore') +const { readFile } = require('fs/promises') const INTOTO_PAYLOAD_TYPE = 'application/vnd.in-toto+json' const INTOTO_STATEMENT_TYPE = 'https://in-toto.io/Statement/v0.1' @@ -66,6 +67,50 @@ const generateProvenance = async (subject, opts) => { return sigstore.attest(Buffer.from(JSON.stringify(payload)), INTOTO_PAYLOAD_TYPE, opts) } +const verifyProvenance = async (subject, provenancePath) => { + let provenanceBundle + try { + provenanceBundle = JSON.parse(await readFile(provenancePath)) + } catch (err) { + err.message = `Invalid provenance provided: ${err.message}` + throw err + } + + const payload = extractProvenance(provenanceBundle) + if (!payload.subject || !payload.subject.length) { + throw new Error('No subject found in sigstore bundle payload') + } + if (payload.subject.length > 1) { + throw new Error('Found more than one subject in the sigstore bundle payload') + } + + const bundleSubject = payload.subject[0] + if (subject.name !== bundleSubject.name) { + throw new Error( + `Provenance subject ${bundleSubject.name} does not match the package: ${subject.name}` + ) + } + if (subject.digest.sha512 !== bundleSubject.digest.sha512) { + throw new Error('Provenance subject digest does not match the package') + } + + await sigstore.verify(provenanceBundle) + return provenanceBundle +} + +const extractProvenance = (bundle) => { + if (!bundle?.dsseEnvelope?.payload) { + throw new Error('No dsseEnvelope with payload found in sigstore bundle') + } + try { + return JSON.parse(Buffer.from(bundle.dsseEnvelope.payload, 'base64').toString('utf8')) + } catch (err) { + err.message = `Failed to parse payload from dsseEnvelope: ${err.message}` + throw err + } +} + module.exports = { generateProvenance, + verifyProvenance, } diff --git a/workspaces/libnpmpublish/lib/publish.js b/workspaces/libnpmpublish/lib/publish.js index 79c00eb68ad0c..3749c3cebfdc8 100644 --- a/workspaces/libnpmpublish/lib/publish.js +++ b/workspaces/libnpmpublish/lib/publish.js @@ -7,7 +7,7 @@ const { URL } = require('url') const ssri = require('ssri') const ciInfo = require('ci-info') -const { generateProvenance } = require('./provenance') +const { generateProvenance, verifyProvenance } = require('./provenance') const TLOG_BASE_URL = 'https://search.sigstore.dev/' @@ -111,7 +111,7 @@ const patchManifest = (_manifest, opts) => { } const buildMetadata = async (registry, manifest, tarballData, spec, opts) => { - const { access, defaultTag, algorithms, provenance } = opts + const { access, defaultTag, algorithms, provenance, provenanceFile } = opts const root = { _id: manifest.name, name: manifest.name, @@ -154,66 +154,31 @@ const buildMetadata = async (registry, manifest, tarballData, spec, opts) => { // Handle case where --provenance flag was set to true let transparencyLogUrl - if (provenance === true) { + if (provenance === true || provenanceFile) { + let provenanceBundle const subject = { name: npa.toPurl(spec), digest: { sha512: integrity.sha512[0].hexDigest() }, } - // Ensure that we're running in GHA, currently the only supported build environment - if (ciInfo.name !== 'GitHub Actions') { - throw Object.assign( - new Error('Automatic provenance generation not supported outside of GitHub Actions'), - { code: 'EUSAGE' } - ) - } - - // Ensure that the GHA OIDC token is available - if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) { - throw Object.assign( - /* eslint-disable-next-line max-len */ - new Error('Provenance generation in GitHub Actions requires "write" access to the "id-token" permission'), - { code: 'EUSAGE' } - ) - } - - // Some registries (e.g. GH packages) require auth to check visibility, - // and always return 404 when no auth is supplied. In this case we assume - // the package is always private and require `--access public` to publish - // with provenance. - let visibility = { public: false } - if (opts.provenance === true && opts.access !== 'public') { - try { - const res = await npmFetch - .json(`${registry}/-/package/${spec.escapedName}/visibility`, opts) - visibility = res - } catch (err) { - if (err.code !== 'E404') { - throw err - } + if (provenance === true) { + await ensureProvenanceGeneration(registry, spec, opts) + provenanceBundle = await generateProvenance([subject], opts) + + /* eslint-disable-next-line max-len */ + log.notice('publish', 'Signed provenance statement with source and build information from GitHub Actions') + + const tlogEntry = provenanceBundle?.verificationMaterial?.tlogEntries[0] + /* istanbul ignore else */ + if (tlogEntry) { + transparencyLogUrl = `${TLOG_BASE_URL}?logIndex=${tlogEntry.logIndex}` + log.notice( + 'publish', + `Provenance statement published to transparency log: ${transparencyLogUrl}` + ) } - } - - if (!visibility.public && opts.provenance === true && opts.access !== 'public') { - throw Object.assign( - /* eslint-disable-next-line max-len */ - new Error("Can't generate provenance for new or private package, you must set `access` to public."), - { code: 'EUSAGE' } - ) - } - const provenanceBundle = await generateProvenance([subject], opts) - - /* eslint-disable-next-line max-len */ - log.notice('publish', 'Signed provenance statement with source and build information from GitHub Actions') - - const tlogEntry = provenanceBundle?.verificationMaterial?.tlogEntries[0] - /* istanbul ignore else */ - if (tlogEntry) { - transparencyLogUrl = `${TLOG_BASE_URL}?logIndex=${tlogEntry.logIndex}` - log.notice( - 'publish', - `Provenance statement published to transparency log: ${transparencyLogUrl}` - ) + } else { + provenanceBundle = await verifyProvenance(subject, provenanceFile) } const serializedBundle = JSON.stringify(provenanceBundle) @@ -275,4 +240,49 @@ const patchMetadata = (current, newData) => { return current } +// Check that all the prereqs are met for provenance generation +const ensureProvenanceGeneration = async (registry, spec, opts) => { + // Ensure that we're running in GHA, currently the only supported build environment + if (ciInfo.name !== 'GitHub Actions') { + throw Object.assign( + new Error('Automatic provenance generation not supported outside of GitHub Actions'), + { code: 'EUSAGE' } + ) + } + + // Ensure that the GHA OIDC token is available + if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) { + throw Object.assign( + /* eslint-disable-next-line max-len */ + new Error('Provenance generation in GitHub Actions requires "write" access to the "id-token" permission'), + { code: 'EUSAGE' } + ) + } + + // Some registries (e.g. GH packages) require auth to check visibility, + // and always return 404 when no auth is supplied. In this case we assume + // the package is always private and require `--access public` to publish + // with provenance. + let visibility = { public: false } + if (true && opts.access !== 'public') { + try { + const res = await npmFetch + .json(`${registry}/-/package/${spec.escapedName}/visibility`, opts) + visibility = res + } catch (err) { + if (err.code !== 'E404') { + throw err + } + } + } + + if (!visibility.public && opts.provenance === true && opts.access !== 'public') { + throw Object.assign( + /* eslint-disable-next-line max-len */ + new Error("Can't generate provenance for new or private package, you must set `access` to public."), + { code: 'EUSAGE' } + ) + } +} + module.exports = publish diff --git a/workspaces/libnpmpublish/test/publish.js b/workspaces/libnpmpublish/test/publish.js index 4cc1199a0d6a8..5c4aa0b1742bd 100644 --- a/workspaces/libnpmpublish/test/publish.js +++ b/workspaces/libnpmpublish/test/publish.js @@ -980,3 +980,177 @@ t.test('automatic provenance with incorrect permissions', async t => { } ) }) + +t.test('user-supplied provenance - success', async t => { + const { publish } = t.mock('..', { + '../lib/provenance': t.mock('../lib/provenance', { + sigstore: { sigstore: { verify: () => {} } }, + }), + }) + + const registry = new MockRegistry({ + tap: t, + registry: opts.registry, + authorization: token, + }) + const manifest = { + name: '@npmcli/libnpmpublish-test', + version: '1.0.0', + description: 'test libnpmpublish package', + } + const spec = npa(manifest.name) + const packument = { + _id: manifest.name, + name: manifest.name, + description: manifest.description, + 'dist-tags': { + latest: '1.0.0', + }, + versions: { + '1.0.0': { + _id: `${manifest.name}@${manifest.version}`, + _nodeVersion: process.versions.node, + ...manifest, + dist: { + shasum, + integrity: integrity.sha512[0].toString(), + /* eslint-disable-next-line max-len */ + tarball: 'http://mock.reg/@npmcli/libnpmpublish-test/-/@npmcli/libnpmpublish-test-1.0.0.tgz', + }, + }, + }, + access: 'public', + _attachments: { + '@npmcli/libnpmpublish-test-1.0.0.tgz': { + content_type: 'application/octet-stream', + data: tarData.toString('base64'), + length: tarData.length, + }, + '@npmcli/libnpmpublish-test-1.0.0.sigstore': { + content_type: 'application/vnd.dev.sigstore.bundle+json;version=0.1', + data: /.*/, // Can't match against static value as signature is always different + length: 7927, + }, + }, + } + registry.nock.put(`/${spec.escapedName}`, body => { + return t.match(body, packument, 'posted packument matches expectations') + }).reply(201, {}) + const ret = await publish(manifest, tarData, { + ...opts, + provenanceFile: './test/fixtures/valid-bundle.json', + }) + t.ok(ret, 'publish succeeded') +}) + +t.test('user-supplied provenance - failure', async t => { + const { publish } = t.mock('..') + const manifest = { + name: '@npmcli/libnpmpublish-test', + version: '1.0.0', + description: 'test libnpmpublish package', + } + await t.rejects( + publish(manifest, Buffer.from(''), { + ...opts, + provenanceFile: './test/fixtures/bad-bundle.json', + }), + { message: /Invalid provenance provided/ } + ) +}) + +t.test('user-supplied provenance - bundle missing DSSE envelope', async t => { + const { publish } = t.mock('..') + const manifest = { + name: '@npmcli/libnpmpublish-test', + version: '1.0.0', + description: 'test libnpmpublish package', + } + await t.rejects( + publish(manifest, Buffer.from(''), { + ...opts, + provenanceFile: './test/fixtures/no-provenance-envelope-bundle.json', + }), + { message: /No dsseEnvelope with payload found/ } + ) +}) + +t.test('user-supplied provenance - bundle with invalid DSSE payload', async t => { + const { publish } = t.mock('..') + const manifest = { + name: '@npmcli/libnpmpublish-test', + version: '1.0.0', + description: 'test libnpmpublish package', + } + await t.rejects( + publish(manifest, Buffer.from(''), { + ...opts, + provenanceFile: './test/fixtures/bad-dsse-payload-bundle.json', + }), + { message: /Failed to parse payload/ } + ) +}) + +t.test('user-supplied provenance - provenance with missing subject', async t => { + const { publish } = t.mock('..') + const manifest = { + name: '@npmcli/libnpmpublish-test', + version: '1.0.0', + description: 'test libnpmpublish package', + } + await t.rejects( + publish(manifest, Buffer.from(''), { + ...opts, + provenanceFile: './test/fixtures/no-provenance-subject-bundle.json', + }), + { message: /No subject found/ } + ) +}) + +t.test('user-supplied provenance - provenance w/ multiple subjects', async t => { + const { publish } = t.mock('..') + const manifest = { + name: '@npmcli/libnpmpublish-test', + version: '1.0.0', + description: 'test libnpmpublish package', + } + await t.rejects( + publish(manifest, Buffer.from(''), { + ...opts, + provenanceFile: './test/fixtures/multi-subject-provenance-bundle.json', + }), + { message: /Found more than one subject/ } + ) +}) + +t.test('user-supplied provenance - provenance w/ mismatched subject name', async t => { + const { publish } = t.mock('..') + const manifest = { + name: '@npmcli/libnpmpublish-fail-test', + version: '1.0.0', + description: 'test libnpmpublish package', + } + await t.rejects( + publish(manifest, Buffer.from(''), { + ...opts, + provenanceFile: './test/fixtures/valid-bundle.json', + }), + { message: /Provenance subject/ } + ) +}) + +t.test('user-supplied provenance - provenance w/ mismatched package digest', async t => { + const { publish } = t.mock('..') + const manifest = { + name: '@npmcli/libnpmpublish-test', + version: '1.0.0', + description: 'test libnpmpublish package', + } + await t.rejects( + publish(manifest, Buffer.from(''), { + ...opts, + provenanceFile: './test/fixtures/digest-mismatch-provenance-bundle.json', + }), + { message: /Provenance subject digest does not match/ } + ) +})