Skip to content

Commit cb45177

Browse files
bdehamersegiddins
andcommitted
fulcio mock support for all GH claims
Co-authored-by: Samuel Giddins <[email protected]> Signed-off-by: Brian DeHamer <[email protected]>
1 parent e529e31 commit cb45177

File tree

5 files changed

+277
-12
lines changed

5 files changed

+277
-12
lines changed

.changeset/plenty-tigers-speak.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sigstore/mock': patch
3+
---
4+
5+
Update Fulcio mock server to support all GH OIDC claims
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`fulcioHandler #fn when invoked returns a certificate chain 1`] = `
4+
Extensions [
5+
"SEQUENCE :
6+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.1
7+
OCTET STRING : 687474703a2f2f666f6f2e636f6d",
8+
"SEQUENCE :
9+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.8
10+
OCTET STRING :
11+
UTF8String : 'http://foo.com'",
12+
"SEQUENCE :
13+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.2
14+
OCTET STRING : 776f726b666c6f775f6469737061746368",
15+
"SEQUENCE :
16+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.20
17+
OCTET STRING :
18+
UTF8String : 'workflow_dispatch'",
19+
"SEQUENCE :
20+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.9
21+
OCTET STRING :
22+
UTF8String : 'https://github.com/foo/attest-demo/.github/workflows/oidc.yml@refs/heads/main'",
23+
"SEQUENCE :
24+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.10
25+
OCTET STRING :
26+
UTF8String : 'ba214227977e57973d87219493ad93eeda9c7d6c'",
27+
"SEQUENCE :
28+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.6
29+
OCTET STRING : 726566732f68656164732f6d61696e",
30+
"SEQUENCE :
31+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.14
32+
OCTET STRING :
33+
UTF8String : 'refs/heads/main'",
34+
"SEQUENCE :
35+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.5
36+
OCTET STRING : 666f6f2f6174746573742d64656d6f",
37+
"SEQUENCE :
38+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.12
39+
OCTET STRING :
40+
UTF8String : 'https://github.com/foo/attest-demo'",
41+
"SEQUENCE :
42+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.15
43+
OCTET STRING :
44+
UTF8String : '792829709'",
45+
"SEQUENCE :
46+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.16
47+
OCTET STRING :
48+
UTF8String : 'https://github.com/foo'",
49+
"SEQUENCE :
50+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.17
51+
OCTET STRING :
52+
UTF8String : '398027'",
53+
"SEQUENCE :
54+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.22
55+
OCTET STRING :
56+
UTF8String : 'public'",
57+
"SEQUENCE :
58+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.11
59+
OCTET STRING :
60+
UTF8String : 'github-hosted'",
61+
"SEQUENCE :
62+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.3
63+
OCTET STRING : 62613231343232373937376535373937336438373231393439336164393365656461396337643663",
64+
"SEQUENCE :
65+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.13
66+
OCTET STRING :
67+
UTF8String : 'ba214227977e57973d87219493ad93eeda9c7d6c'",
68+
"SEQUENCE :
69+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.4
70+
OCTET STRING : 4f494443",
71+
"SEQUENCE :
72+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.18
73+
OCTET STRING :
74+
UTF8String : 'https://github.com/foo/attest-demo/.github/workflows/oidc.yml@refs/heads/main'",
75+
"SEQUENCE :
76+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.19
77+
OCTET STRING :
78+
UTF8String : 'ba214227977e57973d87219493ad93eeda9c7d6c'",
79+
"SEQUENCE :
80+
OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.21
81+
OCTET STRING :
82+
UTF8String : 'https://github.com/foo/attest-demo/actions/runs/11997537386/attempts/3'",
83+
]
84+
`;

packages/mock/src/fulcio/ca.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export interface CertificateRequestOptions {
4444
extensions?: ExtensionValue[];
4545
}
4646

47-
interface ExtensionValue {
47+
export interface ExtensionValue {
4848
oid: string;
4949
value: string;
5050
legacy?: boolean;

packages/mock/src/fulcio/handler.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17+
import x509 from '@peculiar/x509';
1718
import { generateKeyPairSync } from 'crypto';
1819
import { generateKeyPair } from '../util/key';
1920
import { CA, initializeCA } from './ca';
@@ -43,6 +44,24 @@ describe('fulcioHandler', () => {
4344
const claims = {
4445
sub: 'http://github.com/foo/workflow.yml@refs/heads/main',
4546
iss: 'http://foo.com',
47+
event_name: 'workflow_dispatch',
48+
job_workflow_ref:
49+
'foo/attest-demo/.github/workflows/oidc.yml@refs/heads/main',
50+
job_workflow_sha: 'ba214227977e57973d87219493ad93eeda9c7d6c',
51+
ref: 'refs/heads/main',
52+
repository: 'foo/attest-demo',
53+
repository_id: '792829709',
54+
repository_owner: 'foo',
55+
repository_owner_id: '398027',
56+
repository_visibility: 'public',
57+
run_attempt: '3',
58+
run_id: '11997537386',
59+
runner_environment: 'github-hosted',
60+
sha: 'ba214227977e57973d87219493ad93eeda9c7d6c',
61+
workflow: 'OIDC',
62+
workflow_ref:
63+
'foo/attest-demo/.github/workflows/oidc.yml@refs/heads/main',
64+
workflow_sha: 'ba214227977e57973d87219493ad93eeda9c7d6c',
4665
};
4766
const jwt = jwtify(claims);
4867

@@ -80,6 +99,15 @@ describe('fulcioHandler', () => {
8099
expect(
81100
certs.signedCertificateEmbeddedSct.chain.certificates
82101
).toHaveLength(2);
102+
103+
const { extensions } = new x509.X509Certificate(
104+
certs.signedCertificateEmbeddedSct.chain.certificates[0]
105+
);
106+
expect(
107+
extensions
108+
.filter((e) => e.type.startsWith('1.3.6.1.4.1.57264'))
109+
.map((e) => e.toString('asn'))
110+
).toMatchSnapshot();
83111
});
84112

85113
describe('when the CA raises an error', () => {

packages/mock/src/fulcio/handler.ts

Lines changed: 159 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,33 @@ import assert from 'assert';
1818
import { generateKeyPairSync } from 'crypto';
1919
import * as jose from 'jose';
2020
import type { Handler, HandlerFn, HandlerFnResult } from '../shared.types';
21-
import type { CA } from './ca';
21+
import type { CA, ExtensionValue } from './ca';
2222

2323
const CREATE_SIGNING_CERT_PATH = '/api/v2/signingCert';
2424
const DEFAULT_SUBJECT = 'NO-SUBJECT';
2525
const DEFAULT_ISSUER = 'https://fake.oidcissuer.com';
2626

2727
const ISSUER_EXT_OID_V1 = '1.3.6.1.4.1.57264.1.1';
28+
const GH_WORKFLOW_TRIGGER_EXT_OID = '1.3.6.1.4.1.57264.1.2';
29+
const GH_WORKFLOW_SHA_EXT_OID = '1.3.6.1.4.1.57264.1.3';
30+
const GH_WORKFLOW_NAME_EXT_OID = '1.3.6.1.4.1.57264.1.4';
31+
const GH_WORKFLOW_REPO_EXT_OID = '1.3.6.1.4.1.57264.1.5';
32+
const GH_WORKFLOW_REF_EXT_OID = '1.3.6.1.4.1.57264.1.6';
2833
const ISSUER_EXT_OID_V2 = '1.3.6.1.4.1.57264.1.8';
34+
const BUILD_SIGNER_URI_EXT_OID = '1.3.6.1.4.1.57264.1.9';
35+
const BUILD_SIGNER_DIGEST_EXT_OID = '1.3.6.1.4.1.57264.1.10';
36+
const RUNNER_ENVIRONMENT_EXT_OID = '1.3.6.1.4.1.57264.1.11';
37+
const SOURCE_REPO_URI_EXT_OID = '1.3.6.1.4.1.57264.1.12';
38+
const SOURCE_REPO_DIGEST_EXT_OID = '1.3.6.1.4.1.57264.1.13';
39+
const SOURCE_REPO_REF_EXT_OID = '1.3.6.1.4.1.57264.1.14';
40+
const SOURCE_REPO_ID_EXT_OID = '1.3.6.1.4.1.57264.1.15';
41+
const SOURCE_REPO_OWNER_URI_EXT_OID = '1.3.6.1.4.1.57264.1.16';
42+
const SOURCE_REPO_OWNER_ID_EXT_OID = '1.3.6.1.4.1.57264.1.17';
43+
const BUILD_CONFIG_URI_EXT_OID = '1.3.6.1.4.1.57264.1.18';
44+
const BUILD_CONFIG_DIGEST_EXT_OID = '1.3.6.1.4.1.57264.1.19';
45+
const BUILD_TRIGGER_EXT_OID = '1.3.6.1.4.1.57264.1.20';
46+
const RUN_INVOCATION_URI_EXT_OID = '1.3.6.1.4.1.57264.1.21';
47+
const SOURCE_REPO_VISIBILITY_EXT_OID = '1.3.6.1.4.1.57264.1.22';
2948

3049
interface FulcioHandlerOptions {
3150
strict?: boolean;
@@ -52,18 +71,17 @@ function createSigningCertHandler(
5271
return async (body: string): Promise<HandlerFnResult> => {
5372
try {
5473
// Extract relevant fields from the request
55-
const { subject, issuer, publicKey } = strict
74+
const { subject, publicKey, claims } = strict
5675
? parseBody(body, subjectClaim)
5776
: stubBody();
5877

78+
const extensions = extensionFromClaims(claims);
79+
5980
// Request certificate from CA
6081
const cert = await ca.issueCertificate({
6182
publicKey: fromPEM(publicKey),
6283
subjectAltName: subject,
63-
extensions: [
64-
{ oid: ISSUER_EXT_OID_V1, value: issuer, legacy: true },
65-
{ oid: ISSUER_EXT_OID_V2, value: issuer },
66-
],
84+
extensions: extensions,
6785
});
6886

6987
// Format the response
@@ -80,31 +98,35 @@ function createSigningCertHandler(
8098
function parseBody(
8199
body: string,
82100
subjectClaim: string
83-
): { subject: string; issuer: string; publicKey: string } {
101+
): { subject: string; publicKey: string; claims: Record<string, string> } {
84102
const json = JSON.parse(body.toString());
85103
const oidc = json.credentials.oidcIdentityToken;
86104
const pem = json.publicKeyRequest.publicKey.content;
87105

88106
// Decode the JWT
89107
/* eslint-disable @typescript-eslint/no-explicit-any */
90-
const claims = jose.decodeJwt(oidc) as any;
108+
const claims = jose.decodeJwt(oidc) as Record<string, string>;
91109

92110
/* istanbul ignore next */
93111
return {
94112
subject: claims[subjectClaim] || DEFAULT_SUBJECT,
95-
issuer: claims['iss'] || DEFAULT_ISSUER,
96113
publicKey: pem,
114+
claims: { iss: DEFAULT_ISSUER, ...claims },
97115
};
98116
}
99117

100-
function stubBody(): { subject: string; issuer: string; publicKey: string } {
118+
function stubBody(): {
119+
subject: string;
120+
publicKey: string;
121+
claims: Record<string, string>;
122+
} {
101123
const { publicKey } = generateKeyPairSync('ec', {
102124
namedCurve: 'P-256',
103125
});
104126
return {
105127
subject: DEFAULT_SUBJECT,
106-
issuer: DEFAULT_ISSUER,
107128
publicKey: publicKey.export({ format: 'pem', type: 'spki' }).toString(),
129+
claims: { iss: DEFAULT_ISSUER },
108130
};
109131
}
110132

@@ -119,6 +141,132 @@ function buildResponse(leaf: Buffer, root: Buffer): string {
119141
return JSON.stringify(body);
120142
}
121143

144+
function extensionFromClaims(claims: Record<string, string>): ExtensionValue[] {
145+
const extensions: ExtensionValue[] = [];
146+
const baseURL = 'https://github.com';
147+
148+
for (const [key, value] of Object.entries(claims)) {
149+
switch (key) {
150+
case 'iss':
151+
extensions.push({
152+
oid: ISSUER_EXT_OID_V1,
153+
value: value,
154+
legacy: true,
155+
});
156+
extensions.push({ oid: ISSUER_EXT_OID_V2, value: value });
157+
break;
158+
case 'event_name':
159+
extensions.push({
160+
oid: GH_WORKFLOW_TRIGGER_EXT_OID,
161+
value: value,
162+
legacy: true,
163+
});
164+
extensions.push({ oid: BUILD_TRIGGER_EXT_OID, value: value });
165+
break;
166+
case 'sha':
167+
extensions.push({
168+
oid: GH_WORKFLOW_SHA_EXT_OID,
169+
value: value,
170+
legacy: true,
171+
});
172+
extensions.push({ oid: SOURCE_REPO_DIGEST_EXT_OID, value: value });
173+
break;
174+
case 'workflow':
175+
extensions.push({
176+
oid: GH_WORKFLOW_NAME_EXT_OID,
177+
value: value,
178+
legacy: true,
179+
});
180+
break;
181+
case 'repository':
182+
extensions.push({
183+
oid: GH_WORKFLOW_REPO_EXT_OID,
184+
value: value,
185+
legacy: true,
186+
});
187+
extensions.push({
188+
oid: SOURCE_REPO_URI_EXT_OID,
189+
value: `${baseURL}/${value}`,
190+
});
191+
break;
192+
case 'ref':
193+
extensions.push({
194+
oid: GH_WORKFLOW_REF_EXT_OID,
195+
value: value,
196+
legacy: true,
197+
});
198+
extensions.push({
199+
oid: SOURCE_REPO_REF_EXT_OID,
200+
value: value,
201+
});
202+
break;
203+
case 'job_workflow_ref':
204+
extensions.push({
205+
oid: BUILD_SIGNER_URI_EXT_OID,
206+
value: `${baseURL}/${value}`,
207+
});
208+
break;
209+
case 'job_workflow_sha':
210+
extensions.push({
211+
oid: BUILD_SIGNER_DIGEST_EXT_OID,
212+
value: value,
213+
});
214+
break;
215+
case 'runner_environment':
216+
extensions.push({
217+
oid: RUNNER_ENVIRONMENT_EXT_OID,
218+
value: value,
219+
});
220+
break;
221+
case 'repository_id':
222+
extensions.push({
223+
oid: SOURCE_REPO_ID_EXT_OID,
224+
value: value,
225+
});
226+
break;
227+
case 'repository_owner':
228+
extensions.push({
229+
oid: SOURCE_REPO_OWNER_URI_EXT_OID,
230+
value: `${baseURL}/${value}`,
231+
});
232+
break;
233+
case 'repository_owner_id':
234+
extensions.push({
235+
oid: SOURCE_REPO_OWNER_ID_EXT_OID,
236+
value: value,
237+
});
238+
break;
239+
case 'workflow_ref':
240+
extensions.push({
241+
oid: BUILD_CONFIG_URI_EXT_OID,
242+
value: `${baseURL}/${value}`,
243+
});
244+
break;
245+
case 'workflow_sha':
246+
extensions.push({
247+
oid: BUILD_CONFIG_DIGEST_EXT_OID,
248+
value: value,
249+
});
250+
break;
251+
case 'repository_visibility':
252+
extensions.push({
253+
oid: SOURCE_REPO_VISIBILITY_EXT_OID,
254+
value: value,
255+
});
256+
break;
257+
}
258+
}
259+
260+
if (claims['repository'] && claims['run_id'] && claims['run_attempt']) {
261+
extensions.push({
262+
oid: RUN_INVOCATION_URI_EXT_OID,
263+
value: `${baseURL}/${claims['repository']}/actions/runs/${claims['run_id']}/attempts/${claims['run_attempt']}`,
264+
});
265+
}
266+
267+
return extensions;
268+
}
269+
122270
// PEM string to DER-encoded byte buffer conversion
123271
function fromPEM(pem: string): Buffer {
124272
return Buffer.from(

0 commit comments

Comments
 (0)