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 authored and lukekarrys committed May 31, 2023
1 parent 2a8f4f2 commit a63a6d8
Show file tree
Hide file tree
Showing 4 changed files with 296 additions and 56 deletions.
11 changes: 11 additions & 0 deletions workspaces/libnpmpublish/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

#### <a name="publish"></a> `> libpub.publish(manifest, tarData, [opts]) -> Promise`

Sends the package represented by the `manifest` and `tarData` to the
Expand Down
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
Loading

0 comments on commit a63a6d8

Please sign in to comment.