diff --git a/.changeset/plenty-tigers-speak.md b/.changeset/plenty-tigers-speak.md new file mode 100644 index 00000000..2817824b --- /dev/null +++ b/.changeset/plenty-tigers-speak.md @@ -0,0 +1,5 @@ +--- +'@sigstore/mock': patch +--- + +Update Fulcio mock server to support all GH OIDC claims diff --git a/packages/mock/src/fulcio/__snapshots__/handler.test.ts.snap b/packages/mock/src/fulcio/__snapshots__/handler.test.ts.snap new file mode 100644 index 00000000..14512cc5 --- /dev/null +++ b/packages/mock/src/fulcio/__snapshots__/handler.test.ts.snap @@ -0,0 +1,84 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`fulcioHandler #fn when invoked returns a certificate chain 1`] = ` +Extensions [ + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.1 + OCTET STRING : 687474703a2f2f666f6f2e636f6d", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.8 + OCTET STRING : + UTF8String : 'http://foo.com'", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.2 + OCTET STRING : 776f726b666c6f775f6469737061746368", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.20 + OCTET STRING : + UTF8String : 'workflow_dispatch'", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.9 + OCTET STRING : + UTF8String : 'https://github.com/foo/attest-demo/.github/workflows/oidc.yml@refs/heads/main'", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.10 + OCTET STRING : + UTF8String : 'ba214227977e57973d87219493ad93eeda9c7d6c'", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.6 + OCTET STRING : 726566732f68656164732f6d61696e", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.14 + OCTET STRING : + UTF8String : 'refs/heads/main'", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.5 + OCTET STRING : 666f6f2f6174746573742d64656d6f", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.12 + OCTET STRING : + UTF8String : 'https://github.com/foo/attest-demo'", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.15 + OCTET STRING : + UTF8String : '792829709'", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.16 + OCTET STRING : + UTF8String : 'https://github.com/foo'", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.17 + OCTET STRING : + UTF8String : '398027'", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.22 + OCTET STRING : + UTF8String : 'public'", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.11 + OCTET STRING : + UTF8String : 'github-hosted'", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.3 + OCTET STRING : 62613231343232373937376535373937336438373231393439336164393365656461396337643663", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.13 + OCTET STRING : + UTF8String : 'ba214227977e57973d87219493ad93eeda9c7d6c'", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.4 + OCTET STRING : 4f494443", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.18 + OCTET STRING : + UTF8String : 'https://github.com/foo/attest-demo/.github/workflows/oidc.yml@refs/heads/main'", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.19 + OCTET STRING : + UTF8String : 'ba214227977e57973d87219493ad93eeda9c7d6c'", + "SEQUENCE : + OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.21 + OCTET STRING : + UTF8String : 'https://github.com/foo/attest-demo/actions/runs/11997537386/attempts/3'", +] +`; diff --git a/packages/mock/src/fulcio/ca.ts b/packages/mock/src/fulcio/ca.ts index b5c74c91..778a105e 100644 --- a/packages/mock/src/fulcio/ca.ts +++ b/packages/mock/src/fulcio/ca.ts @@ -44,7 +44,7 @@ export interface CertificateRequestOptions { extensions?: ExtensionValue[]; } -interface ExtensionValue { +export interface ExtensionValue { oid: string; value: string; legacy?: boolean; diff --git a/packages/mock/src/fulcio/handler.test.ts b/packages/mock/src/fulcio/handler.test.ts index d65ec194..ef66aede 100644 --- a/packages/mock/src/fulcio/handler.test.ts +++ b/packages/mock/src/fulcio/handler.test.ts @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import x509 from '@peculiar/x509'; import { generateKeyPairSync } from 'crypto'; import { generateKeyPair } from '../util/key'; import { CA, initializeCA } from './ca'; @@ -43,6 +44,24 @@ describe('fulcioHandler', () => { const claims = { sub: 'http://github.com/foo/workflow.yml@refs/heads/main', iss: 'http://foo.com', + event_name: 'workflow_dispatch', + job_workflow_ref: + 'foo/attest-demo/.github/workflows/oidc.yml@refs/heads/main', + job_workflow_sha: 'ba214227977e57973d87219493ad93eeda9c7d6c', + ref: 'refs/heads/main', + repository: 'foo/attest-demo', + repository_id: '792829709', + repository_owner: 'foo', + repository_owner_id: '398027', + repository_visibility: 'public', + run_attempt: '3', + run_id: '11997537386', + runner_environment: 'github-hosted', + sha: 'ba214227977e57973d87219493ad93eeda9c7d6c', + workflow: 'OIDC', + workflow_ref: + 'foo/attest-demo/.github/workflows/oidc.yml@refs/heads/main', + workflow_sha: 'ba214227977e57973d87219493ad93eeda9c7d6c', }; const jwt = jwtify(claims); @@ -80,6 +99,15 @@ describe('fulcioHandler', () => { expect( certs.signedCertificateEmbeddedSct.chain.certificates ).toHaveLength(2); + + const { extensions } = new x509.X509Certificate( + certs.signedCertificateEmbeddedSct.chain.certificates[0] + ); + expect( + extensions + .filter((e) => e.type.startsWith('1.3.6.1.4.1.57264')) + .map((e) => e.toString('asn')) + ).toMatchSnapshot(); }); describe('when the CA raises an error', () => { diff --git a/packages/mock/src/fulcio/handler.ts b/packages/mock/src/fulcio/handler.ts index 45be3edf..ed3bf5d3 100644 --- a/packages/mock/src/fulcio/handler.ts +++ b/packages/mock/src/fulcio/handler.ts @@ -18,14 +18,33 @@ import assert from 'assert'; import { generateKeyPairSync } from 'crypto'; import * as jose from 'jose'; import type { Handler, HandlerFn, HandlerFnResult } from '../shared.types'; -import type { CA } from './ca'; +import type { CA, ExtensionValue } from './ca'; const CREATE_SIGNING_CERT_PATH = '/api/v2/signingCert'; const DEFAULT_SUBJECT = 'NO-SUBJECT'; const DEFAULT_ISSUER = 'https://fake.oidcissuer.com'; const ISSUER_EXT_OID_V1 = '1.3.6.1.4.1.57264.1.1'; +const GH_WORKFLOW_TRIGGER_EXT_OID = '1.3.6.1.4.1.57264.1.2'; +const GH_WORKFLOW_SHA_EXT_OID = '1.3.6.1.4.1.57264.1.3'; +const GH_WORKFLOW_NAME_EXT_OID = '1.3.6.1.4.1.57264.1.4'; +const GH_WORKFLOW_REPO_EXT_OID = '1.3.6.1.4.1.57264.1.5'; +const GH_WORKFLOW_REF_EXT_OID = '1.3.6.1.4.1.57264.1.6'; const ISSUER_EXT_OID_V2 = '1.3.6.1.4.1.57264.1.8'; +const BUILD_SIGNER_URI_EXT_OID = '1.3.6.1.4.1.57264.1.9'; +const BUILD_SIGNER_DIGEST_EXT_OID = '1.3.6.1.4.1.57264.1.10'; +const RUNNER_ENVIRONMENT_EXT_OID = '1.3.6.1.4.1.57264.1.11'; +const SOURCE_REPO_URI_EXT_OID = '1.3.6.1.4.1.57264.1.12'; +const SOURCE_REPO_DIGEST_EXT_OID = '1.3.6.1.4.1.57264.1.13'; +const SOURCE_REPO_REF_EXT_OID = '1.3.6.1.4.1.57264.1.14'; +const SOURCE_REPO_ID_EXT_OID = '1.3.6.1.4.1.57264.1.15'; +const SOURCE_REPO_OWNER_URI_EXT_OID = '1.3.6.1.4.1.57264.1.16'; +const SOURCE_REPO_OWNER_ID_EXT_OID = '1.3.6.1.4.1.57264.1.17'; +const BUILD_CONFIG_URI_EXT_OID = '1.3.6.1.4.1.57264.1.18'; +const BUILD_CONFIG_DIGEST_EXT_OID = '1.3.6.1.4.1.57264.1.19'; +const BUILD_TRIGGER_EXT_OID = '1.3.6.1.4.1.57264.1.20'; +const RUN_INVOCATION_URI_EXT_OID = '1.3.6.1.4.1.57264.1.21'; +const SOURCE_REPO_VISIBILITY_EXT_OID = '1.3.6.1.4.1.57264.1.22'; interface FulcioHandlerOptions { strict?: boolean; @@ -52,18 +71,17 @@ function createSigningCertHandler( return async (body: string): Promise => { try { // Extract relevant fields from the request - const { subject, issuer, publicKey } = strict + const { subject, publicKey, claims } = strict ? parseBody(body, subjectClaim) : stubBody(); + const extensions = extensionFromClaims(claims); + // Request certificate from CA const cert = await ca.issueCertificate({ publicKey: fromPEM(publicKey), subjectAltName: subject, - extensions: [ - { oid: ISSUER_EXT_OID_V1, value: issuer, legacy: true }, - { oid: ISSUER_EXT_OID_V2, value: issuer }, - ], + extensions: extensions, }); // Format the response @@ -80,31 +98,35 @@ function createSigningCertHandler( function parseBody( body: string, subjectClaim: string -): { subject: string; issuer: string; publicKey: string } { +): { subject: string; publicKey: string; claims: Record } { const json = JSON.parse(body.toString()); const oidc = json.credentials.oidcIdentityToken; const pem = json.publicKeyRequest.publicKey.content; // Decode the JWT /* eslint-disable @typescript-eslint/no-explicit-any */ - const claims = jose.decodeJwt(oidc) as any; + const claims = jose.decodeJwt(oidc) as Record; /* istanbul ignore next */ return { subject: claims[subjectClaim] || DEFAULT_SUBJECT, - issuer: claims['iss'] || DEFAULT_ISSUER, publicKey: pem, + claims: { iss: DEFAULT_ISSUER, ...claims }, }; } -function stubBody(): { subject: string; issuer: string; publicKey: string } { +function stubBody(): { + subject: string; + publicKey: string; + claims: Record; +} { const { publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256', }); return { subject: DEFAULT_SUBJECT, - issuer: DEFAULT_ISSUER, publicKey: publicKey.export({ format: 'pem', type: 'spki' }).toString(), + claims: { iss: DEFAULT_ISSUER }, }; } @@ -119,6 +141,132 @@ function buildResponse(leaf: Buffer, root: Buffer): string { return JSON.stringify(body); } +function extensionFromClaims(claims: Record): ExtensionValue[] { + const extensions: ExtensionValue[] = []; + const baseURL = 'https://github.com'; + + for (const [key, value] of Object.entries(claims)) { + switch (key) { + case 'iss': + extensions.push({ + oid: ISSUER_EXT_OID_V1, + value: value, + legacy: true, + }); + extensions.push({ oid: ISSUER_EXT_OID_V2, value: value }); + break; + case 'event_name': + extensions.push({ + oid: GH_WORKFLOW_TRIGGER_EXT_OID, + value: value, + legacy: true, + }); + extensions.push({ oid: BUILD_TRIGGER_EXT_OID, value: value }); + break; + case 'sha': + extensions.push({ + oid: GH_WORKFLOW_SHA_EXT_OID, + value: value, + legacy: true, + }); + extensions.push({ oid: SOURCE_REPO_DIGEST_EXT_OID, value: value }); + break; + case 'workflow': + extensions.push({ + oid: GH_WORKFLOW_NAME_EXT_OID, + value: value, + legacy: true, + }); + break; + case 'repository': + extensions.push({ + oid: GH_WORKFLOW_REPO_EXT_OID, + value: value, + legacy: true, + }); + extensions.push({ + oid: SOURCE_REPO_URI_EXT_OID, + value: `${baseURL}/${value}`, + }); + break; + case 'ref': + extensions.push({ + oid: GH_WORKFLOW_REF_EXT_OID, + value: value, + legacy: true, + }); + extensions.push({ + oid: SOURCE_REPO_REF_EXT_OID, + value: value, + }); + break; + case 'job_workflow_ref': + extensions.push({ + oid: BUILD_SIGNER_URI_EXT_OID, + value: `${baseURL}/${value}`, + }); + break; + case 'job_workflow_sha': + extensions.push({ + oid: BUILD_SIGNER_DIGEST_EXT_OID, + value: value, + }); + break; + case 'runner_environment': + extensions.push({ + oid: RUNNER_ENVIRONMENT_EXT_OID, + value: value, + }); + break; + case 'repository_id': + extensions.push({ + oid: SOURCE_REPO_ID_EXT_OID, + value: value, + }); + break; + case 'repository_owner': + extensions.push({ + oid: SOURCE_REPO_OWNER_URI_EXT_OID, + value: `${baseURL}/${value}`, + }); + break; + case 'repository_owner_id': + extensions.push({ + oid: SOURCE_REPO_OWNER_ID_EXT_OID, + value: value, + }); + break; + case 'workflow_ref': + extensions.push({ + oid: BUILD_CONFIG_URI_EXT_OID, + value: `${baseURL}/${value}`, + }); + break; + case 'workflow_sha': + extensions.push({ + oid: BUILD_CONFIG_DIGEST_EXT_OID, + value: value, + }); + break; + case 'repository_visibility': + extensions.push({ + oid: SOURCE_REPO_VISIBILITY_EXT_OID, + value: value, + }); + break; + } + } + + if (claims['repository'] && claims['run_id'] && claims['run_attempt']) { + extensions.push({ + oid: RUN_INVOCATION_URI_EXT_OID, + value: `${baseURL}/${claims['repository']}/actions/runs/${claims['run_id']}/attempts/${claims['run_attempt']}`, + }); + } + + return extensions; +} + // PEM string to DER-encoded byte buffer conversion function fromPEM(pem: string): Buffer { return Buffer.from(