Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plenty-tigers-speak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sigstore/mock': patch
---

Update Fulcio mock server to support all GH OIDC claims
84 changes: 84 additions & 0 deletions packages/mock/src/fulcio/__snapshots__/handler.test.ts.snap
Original file line number Diff line number Diff line change
@@ -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'",
]
`;
2 changes: 1 addition & 1 deletion packages/mock/src/fulcio/ca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export interface CertificateRequestOptions {
extensions?: ExtensionValue[];
}

interface ExtensionValue {
export interface ExtensionValue {
oid: string;
value: string;
legacy?: boolean;
Expand Down
28 changes: 28 additions & 0 deletions packages/mock/src/fulcio/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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', () => {
Expand Down
171 changes: 159 additions & 12 deletions packages/mock/src/fulcio/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -52,18 +71,17 @@ function createSigningCertHandler(
return async (body: string): Promise<HandlerFnResult> => {
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
Expand All @@ -80,31 +98,34 @@ function createSigningCertHandler(
function parseBody(
body: string,
subjectClaim: string
): { subject: string; issuer: string; publicKey: string } {
): { subject: string; publicKey: string; claims: Record<string, string> } {
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<string, string>;

/* 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<string, string>;
} {
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 },
};
}

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

function extensionFromClaims(claims: Record<string, string>): 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(
Expand Down