Skip to content

Commit

Permalink
feat: add provenanceFile option for libnpmpublish
Browse files Browse the repository at this point in the history
Signed-off-by: Brian DeHamer <bdehamer@github.com>
  • Loading branch information
bdehamer committed May 23, 2023
1 parent 483327c commit 98e288f
Show file tree
Hide file tree
Showing 3 changed files with 285 additions and 56 deletions.
45 changes: 45 additions & 0 deletions workspaces/libnpmpublish/lib/provenance.js
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
}
122 changes: 66 additions & 56 deletions workspaces/libnpmpublish/lib/publish.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/'

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
174 changes: 174 additions & 0 deletions workspaces/libnpmpublish/test/publish.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/ }
)
})

0 comments on commit 98e288f

Please sign in to comment.