Skip to content

Commit c2ca121

Browse files
bdehamersegiddins
andauthored
fulcio mock support for all GH claims (#1319)
Signed-off-by: Brian DeHamer <[email protected]> Co-authored-by: Samuel Giddins <[email protected]>
1 parent 8279f49 commit c2ca121

File tree

5 files changed

+277
-13
lines changed

5 files changed

+277
-13
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 & 12 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,34 @@ 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
89-
/* eslint-disable @typescript-eslint/no-explicit-any */
90-
const claims = jose.decodeJwt(oidc) as any;
107+
const claims = jose.decodeJwt(oidc) as Record<string, string>;
91108

92109
/* istanbul ignore next */
93110
return {
94111
subject: claims[subjectClaim] || DEFAULT_SUBJECT,
95-
issuer: claims['iss'] || DEFAULT_ISSUER,
96112
publicKey: pem,
113+
claims: { iss: DEFAULT_ISSUER, ...claims },
97114
};
98115
}
99116

100-
function stubBody(): { subject: string; issuer: string; publicKey: string } {
117+
function stubBody(): {
118+
subject: string;
119+
publicKey: string;
120+
claims: Record<string, string>;
121+
} {
101122
const { publicKey } = generateKeyPairSync('ec', {
102123
namedCurve: 'P-256',
103124
});
104125
return {
105126
subject: DEFAULT_SUBJECT,
106-
issuer: DEFAULT_ISSUER,
107127
publicKey: publicKey.export({ format: 'pem', type: 'spki' }).toString(),
128+
claims: { iss: DEFAULT_ISSUER },
108129
};
109130
}
110131

@@ -119,6 +140,132 @@ function buildResponse(leaf: Buffer, root: Buffer): string {
119140
return JSON.stringify(body);
120141
}
121142

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

0 commit comments

Comments
 (0)