Skip to content

Commit 876ff32

Browse files
committed
feat: add provenanceFile option for libnpmpublish
Signed-off-by: Brian DeHamer <[email protected]>
1 parent 483327c commit 876ff32

File tree

4 files changed

+293
-56
lines changed

4 files changed

+293
-56
lines changed

workspaces/libnpmpublish/README.md

+8
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ A couple of options of note:
5151
token for the registry. For other ways to pass in auth details, see the
5252
n-r-f docs.
5353

54+
* `opts.provenance` - when running in a suppoted CI environment, will trigger
55+
the generation of a signed provenance statement to be published alongside
56+
the package. Mutually exclusive with the `provenanceFile` option.
57+
58+
* `opts.provenanceFile` - specifies the path to an externally-generated
59+
provenance statement to be published alongside the package. Mutually
60+
exclusive with the `provenance` option.
61+
5462
#### <a name="publish"></a> `> libpub.publish(manifest, tarData, [opts]) -> Promise`
5563

5664
Sends the package represented by the `manifest` and `tarData` to the

workspaces/libnpmpublish/lib/provenance.js

+45
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const { sigstore } = require('sigstore')
2+
const { readFile } = require('fs/promises')
23

34
const INTOTO_PAYLOAD_TYPE = 'application/vnd.in-toto+json'
45
const INTOTO_STATEMENT_TYPE = 'https://in-toto.io/Statement/v0.1'
@@ -66,6 +67,50 @@ const generateProvenance = async (subject, opts) => {
6667
return sigstore.attest(Buffer.from(JSON.stringify(payload)), INTOTO_PAYLOAD_TYPE, opts)
6768
}
6869

70+
const verifyProvenance = async (subject, provenancePath) => {
71+
let provenanceBundle
72+
try {
73+
provenanceBundle = JSON.parse(await readFile(provenancePath))
74+
} catch (err) {
75+
err.message = `Invalid provenance provided: ${err.message}`
76+
throw err
77+
}
78+
79+
const payload = extractProvenance(provenanceBundle)
80+
if (!payload.subject || !payload.subject.length) {
81+
throw new Error('No subject found in sigstore bundle payload')
82+
}
83+
if (payload.subject.length > 1) {
84+
throw new Error('Found more than one subject in the sigstore bundle payload')
85+
}
86+
87+
const bundleSubject = payload.subject[0]
88+
if (subject.name !== bundleSubject.name) {
89+
throw new Error(
90+
`Provenance subject ${bundleSubject.name} does not match the package: ${subject.name}`
91+
)
92+
}
93+
if (subject.digest.sha512 !== bundleSubject.digest.sha512) {
94+
throw new Error('Provenance subject digest does not match the package')
95+
}
96+
97+
await sigstore.verify(provenanceBundle)
98+
return provenanceBundle
99+
}
100+
101+
const extractProvenance = (bundle) => {
102+
if (!bundle?.dsseEnvelope?.payload) {
103+
throw new Error('No dsseEnvelope with payload found in sigstore bundle')
104+
}
105+
try {
106+
return JSON.parse(Buffer.from(bundle.dsseEnvelope.payload, 'base64').toString('utf8'))
107+
} catch (err) {
108+
err.message = `Failed to parse payload from dsseEnvelope: ${err.message}`
109+
throw err
110+
}
111+
}
112+
69113
module.exports = {
70114
generateProvenance,
115+
verifyProvenance,
71116
}

workspaces/libnpmpublish/lib/publish.js

+66-56
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const { URL } = require('url')
77
const ssri = require('ssri')
88
const ciInfo = require('ci-info')
99

10-
const { generateProvenance } = require('./provenance')
10+
const { generateProvenance, verifyProvenance } = require('./provenance')
1111

1212
const TLOG_BASE_URL = 'https://search.sigstore.dev/'
1313

@@ -111,7 +111,7 @@ const patchManifest = (_manifest, opts) => {
111111
}
112112

113113
const buildMetadata = async (registry, manifest, tarballData, spec, opts) => {
114-
const { access, defaultTag, algorithms, provenance } = opts
114+
const { access, defaultTag, algorithms, provenance, provenanceFile } = opts
115115
const root = {
116116
_id: manifest.name,
117117
name: manifest.name,
@@ -154,66 +154,31 @@ const buildMetadata = async (registry, manifest, tarballData, spec, opts) => {
154154

155155
// Handle case where --provenance flag was set to true
156156
let transparencyLogUrl
157-
if (provenance === true) {
157+
if (provenance === true || provenanceFile) {
158+
let provenanceBundle
158159
const subject = {
159160
name: npa.toPurl(spec),
160161
digest: { sha512: integrity.sha512[0].hexDigest() },
161162
}
162163

163-
// Ensure that we're running in GHA, currently the only supported build environment
164-
if (ciInfo.name !== 'GitHub Actions') {
165-
throw Object.assign(
166-
new Error('Automatic provenance generation not supported outside of GitHub Actions'),
167-
{ code: 'EUSAGE' }
168-
)
169-
}
170-
171-
// Ensure that the GHA OIDC token is available
172-
if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
173-
throw Object.assign(
174-
/* eslint-disable-next-line max-len */
175-
new Error('Provenance generation in GitHub Actions requires "write" access to the "id-token" permission'),
176-
{ code: 'EUSAGE' }
177-
)
178-
}
179-
180-
// Some registries (e.g. GH packages) require auth to check visibility,
181-
// and always return 404 when no auth is supplied. In this case we assume
182-
// the package is always private and require `--access public` to publish
183-
// with provenance.
184-
let visibility = { public: false }
185-
if (opts.provenance === true && opts.access !== 'public') {
186-
try {
187-
const res = await npmFetch
188-
.json(`${registry}/-/package/${spec.escapedName}/visibility`, opts)
189-
visibility = res
190-
} catch (err) {
191-
if (err.code !== 'E404') {
192-
throw err
193-
}
164+
if (provenance === true) {
165+
await ensureProvenanceGeneration(registry, spec, opts)
166+
provenanceBundle = await generateProvenance([subject], opts)
167+
168+
/* eslint-disable-next-line max-len */
169+
log.notice('publish', 'Signed provenance statement with source and build information from GitHub Actions')
170+
171+
const tlogEntry = provenanceBundle?.verificationMaterial?.tlogEntries[0]
172+
/* istanbul ignore else */
173+
if (tlogEntry) {
174+
transparencyLogUrl = `${TLOG_BASE_URL}?logIndex=${tlogEntry.logIndex}`
175+
log.notice(
176+
'publish',
177+
`Provenance statement published to transparency log: ${transparencyLogUrl}`
178+
)
194179
}
195-
}
196-
197-
if (!visibility.public && opts.provenance === true && opts.access !== 'public') {
198-
throw Object.assign(
199-
/* eslint-disable-next-line max-len */
200-
new Error("Can't generate provenance for new or private package, you must set `access` to public."),
201-
{ code: 'EUSAGE' }
202-
)
203-
}
204-
const provenanceBundle = await generateProvenance([subject], opts)
205-
206-
/* eslint-disable-next-line max-len */
207-
log.notice('publish', 'Signed provenance statement with source and build information from GitHub Actions')
208-
209-
const tlogEntry = provenanceBundle?.verificationMaterial?.tlogEntries[0]
210-
/* istanbul ignore else */
211-
if (tlogEntry) {
212-
transparencyLogUrl = `${TLOG_BASE_URL}?logIndex=${tlogEntry.logIndex}`
213-
log.notice(
214-
'publish',
215-
`Provenance statement published to transparency log: ${transparencyLogUrl}`
216-
)
180+
} else {
181+
provenanceBundle = await verifyProvenance(subject, provenanceFile)
217182
}
218183

219184
const serializedBundle = JSON.stringify(provenanceBundle)
@@ -275,4 +240,49 @@ const patchMetadata = (current, newData) => {
275240
return current
276241
}
277242

243+
// Check that all the prereqs are met for provenance generation
244+
const ensureProvenanceGeneration = async (registry, spec, opts) => {
245+
// Ensure that we're running in GHA, currently the only supported build environment
246+
if (ciInfo.name !== 'GitHub Actions') {
247+
throw Object.assign(
248+
new Error('Automatic provenance generation not supported outside of GitHub Actions'),
249+
{ code: 'EUSAGE' }
250+
)
251+
}
252+
253+
// Ensure that the GHA OIDC token is available
254+
if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
255+
throw Object.assign(
256+
/* eslint-disable-next-line max-len */
257+
new Error('Provenance generation in GitHub Actions requires "write" access to the "id-token" permission'),
258+
{ code: 'EUSAGE' }
259+
)
260+
}
261+
262+
// Some registries (e.g. GH packages) require auth to check visibility,
263+
// and always return 404 when no auth is supplied. In this case we assume
264+
// the package is always private and require `--access public` to publish
265+
// with provenance.
266+
let visibility = { public: false }
267+
if (true && opts.access !== 'public') {
268+
try {
269+
const res = await npmFetch
270+
.json(`${registry}/-/package/${spec.escapedName}/visibility`, opts)
271+
visibility = res
272+
} catch (err) {
273+
if (err.code !== 'E404') {
274+
throw err
275+
}
276+
}
277+
}
278+
279+
if (!visibility.public && opts.provenance === true && opts.access !== 'public') {
280+
throw Object.assign(
281+
/* eslint-disable-next-line max-len */
282+
new Error("Can't generate provenance for new or private package, you must set `access` to public."),
283+
{ code: 'EUSAGE' }
284+
)
285+
}
286+
}
287+
278288
module.exports = publish

0 commit comments

Comments
 (0)