diff --git a/.changeset/tiny-comics-sleep.md b/.changeset/tiny-comics-sleep.md new file mode 100644 index 00000000..ec82548d --- /dev/null +++ b/.changeset/tiny-comics-sleep.md @@ -0,0 +1,5 @@ +--- +'@sigstore/mock': minor +--- + +Update Fulcio mock with support for CSRs diff --git a/packages/mock/src/fulcio/__snapshots__/handler.test.ts.snap b/packages/mock/src/fulcio/__snapshots__/handler.test.ts.snap index 14512cc5..02ef6194 100644 --- a/packages/mock/src/fulcio/__snapshots__/handler.test.ts.snap +++ b/packages/mock/src/fulcio/__snapshots__/handler.test.ts.snap @@ -1,6 +1,89 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`fulcioHandler #fn when invoked returns a certificate chain 1`] = ` +exports[`fulcioHandler #fn when invoked w/ a CSR 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'", +] +`; + +exports[`fulcioHandler #fn when invoked w/ a public key returns a certificate chain 1`] = ` Extensions [ "SEQUENCE : OBJECT IDENTIFIER : 1.3.6.1.4.1.57264.1.1 diff --git a/packages/mock/src/fulcio/handler.test.ts b/packages/mock/src/fulcio/handler.test.ts index ef66aede..56a15e83 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 { Crypto } from '@peculiar/webcrypto'; import x509 from '@peculiar/x509'; import { generateKeyPairSync } from 'crypto'; import { generateKeyPair } from '../util/key'; @@ -32,39 +33,39 @@ describe('fulcioHandler', () => { }); describe('#fn', () => { + 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); + it('returns a function', async () => { const ca = await initializeCA(keyPair); const handler = fulcioHandler(ca); expect(handler.fn).toBeInstanceOf(Function); }); - describe('when invoked', () => { + describe('when invoked w/ a public key', () => { const { publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); - 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); - const certRequest = { credentials: { oidcIdentityToken: jwt, @@ -100,9 +101,14 @@ describe('fulcioHandler', () => { certs.signedCertificateEmbeddedSct.chain.certificates ).toHaveLength(2); - const { extensions } = new x509.X509Certificate( + const { extensions, publicKey } = new x509.X509Certificate( certs.signedCertificateEmbeddedSct.chain.certificates[0] ); + + // Ensure public key matches input + expect(publicKey.toString('pem')).toEqual( + certRequest.publicKeyRequest.publicKey.content.trimEnd() + ); expect( extensions .filter((e) => e.type.startsWith('1.3.6.1.4.1.57264')) @@ -125,6 +131,69 @@ describe('fulcioHandler', () => { }); }); }); + + describe('when invoked w/ a CSR', () => { + it('returns a certificate chain', async () => { + const crypto = new Crypto(); + const kp = await crypto.subtle.generateKey( + { name: 'ecdsa', namedCurve: 'P-256' }, + true, + ['sign', 'verify'] + ); + const csr = await x509.Pkcs10CertificateRequestGenerator.create( + { + signingAlgorithm: { + name: 'ECDSA', + hash: 'SHA-256', + }, + keys: kp, + }, + crypto + ); + + const certRequest = { + credentials: { + oidcIdentityToken: jwt, + }, + certificateSigningRequest: csr.toString('pem'), + }; + + const ca = await initializeCA(keyPair); + const { fn } = fulcioHandler(ca); + + // Make a request + const resp = await fn(JSON.stringify(certRequest)); + expect(resp.statusCode).toBe(201); + + // Check the response + const certs = JSON.parse(resp.response.toString()); + expect(certs).toBeDefined(); + expect(certs.signedCertificateEmbeddedSct).toBeDefined(); + expect(certs.signedCertificateEmbeddedSct.chain).toBeDefined(); + expect( + certs.signedCertificateEmbeddedSct.chain.certificates + ).toBeDefined(); + expect( + certs.signedCertificateEmbeddedSct.chain.certificates + ).toHaveLength(2); + + const { extensions, publicKey } = new x509.X509Certificate( + certs.signedCertificateEmbeddedSct.chain.certificates[0] + ); + + // Ensure public key matches the CSR + const expectedKey = await crypto.subtle.exportKey('spki', kp.publicKey); + expect(publicKey.toString('base64')).toEqual( + Buffer.from(expectedKey).toString('base64') + ); + + expect( + extensions + .filter((e) => e.type.startsWith('1.3.6.1.4.1.57264')) + .map((e) => e.toString('asn')) + ).toMatchSnapshot(); + }); + }); }); }); diff --git a/packages/mock/src/fulcio/handler.ts b/packages/mock/src/fulcio/handler.ts index b16d6d7a..4d69f635 100644 --- a/packages/mock/src/fulcio/handler.ts +++ b/packages/mock/src/fulcio/handler.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 assert from 'assert'; import { generateKeyPairSync } from 'crypto'; import * as jose from 'jose'; @@ -101,7 +102,9 @@ function parseBody( ): { subject: string; publicKey: string; claims: Record } { const json = JSON.parse(body.toString()); const oidc = json.credentials.oidcIdentityToken; - const pem = json.publicKeyRequest.publicKey.content; + const pem = json.publicKeyRequest + ? json.publicKeyRequest.publicKey.content + : extractCSRKey(json.certificateSigningRequest); // Decode the JWT const claims = jose.decodeJwt(oidc) as Record; @@ -266,6 +269,11 @@ function extensionFromClaims(claims: Record): ExtensionValue[] { return extensions; } +function extractCSRKey(pem: string): string { + const csr = new x509.Pkcs10CertificateRequest(pem); + return csr.publicKey.toString('pem'); +} + // PEM string to DER-encoded byte buffer conversion function fromPEM(pem: string): Buffer { return Buffer.from(