Skip to content

Commit f55cce1

Browse files
committed
feat: oidc default provenance
1 parent cb60ef4 commit f55cce1

File tree

7 files changed

+445
-56
lines changed

7 files changed

+445
-56
lines changed

lib/utils/oidc.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,29 @@ async function oidc ({ packageName, registry, opts, config }) {
116116
return undefined
117117
}
118118

119+
const [headerB64, payloadB64] = idToken.split('.')
120+
let isPublicRepo = false
121+
if (headerB64 && payloadB64) {
122+
const payloadJson = Buffer.from(payloadB64, 'base64').toString('utf8')
123+
try {
124+
const payload = JSON.parse(payloadJson)
125+
if (ciInfo.GITHUB_ACTIONS && payload.repository_visibility === 'public') {
126+
isPublicRepo = true
127+
}
128+
if (ciInfo.GITLAB && payload.project_visibility === 'public') {
129+
isPublicRepo = true
130+
}
131+
} catch (e) {
132+
log.silly('oidc', 'Failed to parse idToken payload as JSON')
133+
}
134+
}
135+
136+
if (isPublicRepo) {
137+
log.silly('oidc', 'Repository is public, setting provenance')
138+
opts.provenance = true
139+
config.set('provenance', true, 'user')
140+
}
141+
119142
log.silly('oidc', `id_token has a length of ${idToken.length} characters`)
120143

121144
const parsedRegistry = new URL(registry)

mock-registry/lib/index.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,11 @@ class MockRegistry {
8080
// XXX: this is opt-in currently because it breaks some existing CLI
8181
// tests. We should work towards making this the default for all tests.
8282
t.comment(logReq(req, 'interceptors', 'socket', 'response', '_events'))
83-
t.fail(`Unmatched request: ${req.method} ${req.path}`)
83+
const protocol = req?.options?.protocol || 'http:'
84+
const hostname = req?.options?.hostname || req?.hostname || 'localhost'
85+
const p = req?.path || '/'
86+
const url = new URL(p, `${protocol}//${hostname}`).toString()
87+
t.fail(`Unmatched request: ${req.method} ${url}`)
8488
}
8589
}
8690

mock-registry/lib/provenance.js

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
'use strict'
2+
const t = require('tap')
3+
const mockGlobals = require('@npmcli/mock-globals')
4+
const nock = require('nock')
5+
6+
class MockProvenance {
7+
static sigstoreIdToken () {
8+
return `.${Buffer.from(JSON.stringify({
9+
iss: 'https://oauth2.sigstore.dev/auth',
10+
email: 'foo@bar.com',
11+
})).toString('base64')}.`
12+
}
13+
14+
static successfulNock ({
15+
oidcURL,
16+
requestToken,
17+
workflowPath,
18+
repository,
19+
serverUrl,
20+
ref,
21+
sha,
22+
runID,
23+
runAttempt,
24+
runnerEnv,
25+
}) {
26+
mockGlobals(t, {
27+
'process.env': {
28+
CI: true,
29+
GITHUB_ACTIONS: true,
30+
ACTIONS_ID_TOKEN_REQUEST_URL: oidcURL,
31+
ACTIONS_ID_TOKEN_REQUEST_TOKEN: requestToken,
32+
GITHUB_WORKFLOW_REF: `${repository}/${workflowPath}@${ref}`,
33+
GITHUB_REPOSITORY: repository,
34+
GITHUB_SERVER_URL: serverUrl,
35+
GITHUB_REF: ref,
36+
GITHUB_SHA: sha,
37+
GITHUB_RUN_ID: runID,
38+
GITHUB_RUN_ATTEMPT: runAttempt,
39+
RUNNER_ENVIRONMENT: runnerEnv,
40+
},
41+
})
42+
43+
const idToken = this.sigstoreIdToken()
44+
45+
const url = new URL(oidcURL)
46+
nock(url.origin)
47+
.get(url.pathname)
48+
.query({ audience: 'sigstore' })
49+
.matchHeader('authorization', `Bearer ${requestToken}`)
50+
.matchHeader('accept', 'application/json')
51+
.reply(200, { value: idToken })
52+
53+
const leafCertificate = `-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n`
54+
55+
// Mock the Fulcio signing certificate endpoint
56+
nock('https://fulcio.sigstore.dev')
57+
.post('/api/v2/signingCert')
58+
.reply(200, {
59+
signedCertificateEmbeddedSct: {
60+
chain: {
61+
certificates: [
62+
leafCertificate,
63+
`-----BEGIN CERTIFICATE-----\nxyz\n-----END CERTIFICATE-----\n`,
64+
],
65+
},
66+
},
67+
})
68+
69+
nock('https://rekor.sigstore.dev')
70+
.post('/api/v1/log/entries')
71+
.reply(201, {
72+
'69e5a0c1663ee4452674a5c9d5050d866c2ee31e2faaf79913aea7cc27293cf6': {
73+
body: Buffer.from(JSON.stringify({
74+
kind: 'hashedrekord',
75+
apiVersion: '0.0.1',
76+
spec: {
77+
signature: {
78+
content: 'ABC123',
79+
publicKey: { content: Buffer.from(leafCertificate).toString('base64') },
80+
},
81+
},
82+
})).toString(
83+
'base64'
84+
),
85+
integratedTime: 1654015743,
86+
logID:
87+
'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d',
88+
logIndex: 2513258,
89+
verification: {
90+
signedEntryTimestamp: 'MEUCIQD6CD7ZNLUipFoxzmSL/L8Ewic4SRkXN77UjfJZ7d/wAAIgatokSuX9Rg0iWxAgSfHMtcsagtDCQalU5IvXdQ+yLEA=',
91+
},
92+
},
93+
})
94+
}
95+
}
96+
97+
module.exports = {
98+
MockProvenance,
99+
}

test/fixtures/mock-oidc.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,45 @@
11
const nock = require('nock')
22
const ciInfo = require('ci-info')
33

4+
function tnock (t, host, opts) {
5+
nock.disableNetConnect()
6+
const server = nock(host, opts)
7+
t.teardown(function () {
8+
nock.enableNetConnect()
9+
server.done()
10+
})
11+
return server
12+
}
13+
14+
// this is an effort to not add a dependency to the cli just for testing
15+
function makeJwt (payload) {
16+
const header = { alg: 'none', typ: 'JWT' }
17+
const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64')
18+
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64')
19+
// empty signature section
20+
return `${headerB64}.${payloadB64}.`
21+
}
22+
23+
function gitlabIdToken ({ visibility = 'public' } = { visibility: 'public' }) {
24+
const now = Math.floor(Date.now() / 1000)
25+
const payload = {
26+
project_visibility: visibility,
27+
iat: now,
28+
exp: now + 3600, // 1 hour expiration
29+
}
30+
return makeJwt(payload)
31+
}
32+
33+
function githubIdToken ({ visibility = 'public' } = { visibility: 'public' }) {
34+
const now = Math.floor(Date.now() / 1000)
35+
const payload = {
36+
repository_visibility: visibility,
37+
iat: now,
38+
exp: now + 3600, // 1 hour expiration
39+
}
40+
return makeJwt(payload)
41+
}
42+
443
class MockOidc {
544
constructor (opts) {
645
const defaultOpts = {
@@ -17,6 +56,8 @@ class MockOidc {
1756
this.gitlab = options.gitlab
1857
this.ACTIONS_ID_TOKEN_REQUEST_URL = options.ACTIONS_ID_TOKEN_REQUEST_URL
1958
this.ACTIONS_ID_TOKEN_REQUEST_TOKEN = options.ACTIONS_ID_TOKEN_REQUEST_TOKEN
59+
this.SIGSTORE_ID_TOKEN = options.SIGSTORE_ID_TOKEN
60+
2061
this.NPM_ID_TOKEN = options.NPM_ID_TOKEN
2162
this.GITHUB_ID_TOKEN = options.GITHUB_ID_TOKEN
2263

@@ -28,6 +69,7 @@ class MockOidc {
2869
ACTIONS_ID_TOKEN_REQUEST_TOKEN: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN,
2970
GITLAB_CI: process.env.GITLAB_CI,
3071
NPM_ID_TOKEN: process.env.NPM_ID_TOKEN,
72+
SIGSTORE_ID_TOKEN: process.env.SIGSTORE_ID_TOKEN,
3173
}
3274
this.originalCiInfo = {
3375
GITLAB: ciInfo.GITLAB,
@@ -53,6 +95,7 @@ class MockOidc {
5395
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
5496
delete process.env.GITLAB_CI
5597
delete process.env.NPM_ID_TOKEN
98+
delete process.env.SIGSTORE_ID_TOKEN
5699

57100
ciInfo.GITHUB_ACTIONS = false
58101
ciInfo.GITLAB = false
@@ -65,6 +108,7 @@ class MockOidc {
65108

66109
if (this.gitlab) {
67110
process.env.NPM_ID_TOKEN = this.NPM_ID_TOKEN
111+
process.env.SIGSTORE_ID_TOKEN = this.SIGSTORE_ID_TOKEN
68112
ciInfo.GITLAB = true
69113
}
70114
}
@@ -128,4 +172,7 @@ class MockOidc {
128172

129173
module.exports = {
130174
MockOidc,
175+
gitlabIdToken,
176+
githubIdToken,
177+
tnock,
131178
}

test/fixtures/mock-provenance.js

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
'use strict'
2+
const t = require('tap')
3+
const MockRegistry = require('./index')
4+
const mockGlobals = require('@npmcli/mock-globals')
5+
6+
class MockProvenance {
7+
static successfulNock ({
8+
oidcURL,
9+
requestToken,
10+
workflowPath,
11+
repository,
12+
serverUrl,
13+
ref,
14+
sha,
15+
runID,
16+
runAttempt,
17+
runnerEnv,
18+
}) {
19+
mockGlobals(t, {
20+
'process.env': {
21+
CI: true,
22+
GITHUB_ACTIONS: true,
23+
ACTIONS_ID_TOKEN_REQUEST_URL: oidcURL,
24+
ACTIONS_ID_TOKEN_REQUEST_TOKEN: requestToken,
25+
GITHUB_WORKFLOW_REF: `${repository}/${workflowPath}@${ref}`,
26+
GITHUB_REPOSITORY: repository,
27+
GITHUB_SERVER_URL: serverUrl,
28+
GITHUB_REF: ref,
29+
GITHUB_SHA: sha,
30+
GITHUB_RUN_ID: runID,
31+
GITHUB_RUN_ATTEMPT: runAttempt,
32+
RUNNER_ENVIRONMENT: runnerEnv,
33+
},
34+
})
35+
36+
// Data for mocking the OIDC token request
37+
const oidcClaims = {
38+
iss: 'https://oauth2.sigstore.dev/auth',
39+
email: 'foo@bar.com',
40+
}
41+
const idToken = `.${Buffer.from(JSON.stringify(oidcClaims)).toString('base64')}.`
42+
43+
// Data for mocking Fulcio certifcate request
44+
const fulcioURL = 'https://mock.fulcio'
45+
const leafCertificate = `-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n`
46+
const rootCertificate = `-----BEGIN CERTIFICATE-----\nxyz\n-----END CERTIFICATE-----\n`
47+
const certificateResponse = {
48+
signedCertificateEmbeddedSct: {
49+
chain: {
50+
certificates: [leafCertificate, rootCertificate],
51+
},
52+
},
53+
}
54+
55+
// Data for mocking Rekor upload
56+
const rekorURL = 'https://mock.rekor'
57+
const signature = 'ABC123'
58+
const b64Cert = Buffer.from(leafCertificate).toString('base64')
59+
const logIndex = 2513258
60+
const uuid =
61+
'69e5a0c1663ee4452674a5c9d5050d866c2ee31e2faaf79913aea7cc27293cf6'
62+
63+
const signatureBundle = {
64+
kind: 'hashedrekord',
65+
apiVersion: '0.0.1',
66+
spec: {
67+
signature: {
68+
content: signature,
69+
publicKey: { content: b64Cert },
70+
},
71+
},
72+
}
73+
74+
const rekorEntry = {
75+
[uuid]: {
76+
body: Buffer.from(JSON.stringify(signatureBundle)).toString(
77+
'base64'
78+
),
79+
integratedTime: 1654015743,
80+
logID:
81+
'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d',
82+
logIndex,
83+
verification: {
84+
/* eslint-disable-next-line max-len */
85+
signedEntryTimestamp: 'MEUCIQD6CD7ZNLUipFoxzmSL/L8Ewic4SRkXN77UjfJZ7d/wAAIgatokSuX9Rg0iWxAgSfHMtcsagtDCQalU5IvXdQ+yLEA=',
86+
},
87+
},
88+
}
89+
90+
const oidcSrv = MockRegistry.tnock(t, oidcURL)
91+
oidcSrv.get('/?audience=sigstore', undefined, {
92+
authorization: `Bearer ${requestToken}`,
93+
}).reply(200, { value: idToken })
94+
95+
const fulcioSrv = MockRegistry.tnock(t, fulcioURL)
96+
fulcioSrv.matchHeader('Content-Type', 'application/json')
97+
.post('/api/v2/signingCert', {
98+
credentials: { oidcIdentityToken: idToken },
99+
publicKeyRequest: {
100+
publicKey: {
101+
algorithm: 'ECDSA',
102+
content: /.+/i,
103+
},
104+
proofOfPossession: /.+/i,
105+
},
106+
})
107+
.reply(200, certificateResponse)
108+
109+
const rekorSrv = MockRegistry.tnock(t, rekorURL)
110+
rekorSrv
111+
.matchHeader('Accept', 'application/json')
112+
.matchHeader('Content-Type', 'application/json')
113+
.post('/api/v1/log/entries')
114+
.reply(201, rekorEntry)
115+
116+
return {
117+
fulcioURL: fulcioURL,
118+
rekorURL: rekorURL,
119+
}
120+
}
121+
}
122+
123+
module.exports = {
124+
MockProvenance,
125+
}

0 commit comments

Comments
 (0)