Skip to content

Commit b4f8e5f

Browse files
authored
Merge pull request #820 from crazy-max/signing
sigstore class to sign and verify buildkit provenance blobs
2 parents 3ed33f6 + 364d8e8 commit b4f8e5f

File tree

13 files changed

+2438
-9
lines changed

13 files changed

+2438
-9
lines changed

.github/workflows/test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@ jobs:
146146
fail-fast: false
147147
matrix:
148148
include: ${{ fromJson(needs.prepare-itg.outputs.includes) }}
149+
permissions:
150+
contents: read
151+
id-token: write # needed for signing with GitHub OIDC Token
149152
steps:
150153
-
151154
name: Checkout
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Hello, World! This is linux/amd64

__tests__/.fixtures/sigstore/multi/linux_amd64/provenance.json

Lines changed: 462 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Hello, World! This is linux/arm64

__tests__/.fixtures/sigstore/multi/linux_arm64/provenance.json

Lines changed: 462 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Hello, World! This is linux/amd64

__tests__/.fixtures/sigstore/single/provenance.json

Lines changed: 462 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Copyright 2025 actions-toolkit authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {describe, expect, jest, it, beforeAll} from '@jest/globals';
18+
import fs from 'fs';
19+
import * as path from 'path';
20+
21+
import {Install as CosignInstall} from '../../src/cosign/install';
22+
import {Sigstore} from '../../src/sigstore/sigstore';
23+
24+
const fixturesDir = path.join(__dirname, '..', '.fixtures');
25+
26+
const maybe = process.env.GITHUB_ACTIONS && process.env.GITHUB_ACTIONS === 'true' && process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ImageOS && process.env.ImageOS.startsWith('ubuntu') ? describe : describe.skip;
27+
28+
// needs current GitHub repo info
29+
jest.unmock('@actions/github');
30+
31+
beforeAll(async () => {
32+
const cosignInstall = new CosignInstall();
33+
const cosignBinPath = await cosignInstall.download('v3.0.2', true);
34+
await cosignInstall.install(cosignBinPath);
35+
}, 100000);
36+
37+
maybe('signProvenanceBlobs', () => {
38+
it('single platform', async () => {
39+
const sigstore = new Sigstore();
40+
const results = await sigstore.signProvenanceBlobs({
41+
localExportDir: path.join(fixturesDir, 'sigstore', 'single')
42+
});
43+
expect(Object.keys(results).length).toEqual(1);
44+
const provenancePath = Object.keys(results)[0];
45+
expect(provenancePath).toEqual(path.join(fixturesDir, 'sigstore', 'single', 'provenance.json'));
46+
expect(fs.existsSync(results[provenancePath].bundlePath)).toBe(true);
47+
expect(results[provenancePath].bundle).toBeDefined();
48+
expect(results[provenancePath].certificate).toBeDefined();
49+
expect(results[provenancePath].tlogID).toBeDefined();
50+
expect(results[provenancePath].attestationID).not.toBeDefined();
51+
console.log(provenancePath, JSON.stringify(results[provenancePath].bundle, null, 2));
52+
});
53+
it('multi-platform', async () => {
54+
const sigstore = new Sigstore();
55+
const results = await sigstore.signProvenanceBlobs({
56+
localExportDir: path.join(fixturesDir, 'sigstore', 'multi')
57+
});
58+
expect(Object.keys(results).length).toEqual(2);
59+
for (const [provenancePath, res] of Object.entries(results)) {
60+
expect(provenancePath).toMatch(/linux_(amd64|arm64)\/provenance.json/);
61+
expect(fs.existsSync(res.bundlePath)).toBe(true);
62+
expect(res.bundle).toBeDefined();
63+
expect(res.certificate).toBeDefined();
64+
expect(res.tlogID).toBeDefined();
65+
expect(res.attestationID).not.toBeDefined();
66+
console.log(provenancePath, JSON.stringify(res.bundle, null, 2));
67+
}
68+
});
69+
});
70+
71+
maybe('verifySignedArtifacts', () => {
72+
it('sign and verify', async () => {
73+
const sigstore = new Sigstore();
74+
const signResults = await sigstore.signProvenanceBlobs({
75+
localExportDir: path.join(fixturesDir, 'sigstore', 'multi')
76+
});
77+
expect(Object.keys(signResults).length).toEqual(2);
78+
79+
const verifyResults = await sigstore.verifySignedArtifacts(
80+
{
81+
certificateIdentityRegexp: `^https://github.com/docker/actions-toolkit/.github/workflows/test.yml.*$`
82+
},
83+
signResults
84+
);
85+
expect(Object.keys(verifyResults).length).toEqual(2);
86+
for (const [artifactPath, res] of Object.entries(verifyResults)) {
87+
expect(fs.existsSync(artifactPath)).toBe(true);
88+
expect(res.bundlePath).toBeDefined();
89+
expect(res.cosignArgs).toBeDefined();
90+
}
91+
});
92+
});

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
},
4747
"dependencies": {
4848
"@actions/artifact": "^4.0.0",
49+
"@actions/attest": "^2.0.0",
4950
"@actions/cache": "^4.1.0",
5051
"@actions/core": "^1.11.1",
5152
"@actions/exec": "^1.1.1",
@@ -56,6 +57,8 @@
5657
"@azure/storage-blob": "^12.15.0",
5758
"@octokit/core": "^5.2.2",
5859
"@octokit/plugin-rest-endpoint-methods": "^10.4.1",
60+
"@sigstore/bundle": "^3.1.0",
61+
"@sigstore/sign": "^3.1.0",
5962
"async-retry": "^1.3.3",
6063
"csv-parse": "^6.1.0",
6164
"gunzip-maybe": "^1.4.2",
@@ -68,6 +71,8 @@
6871
"tmp": "^0.2.5"
6972
},
7073
"devDependencies": {
74+
"@sigstore/mock": "^0.10.0",
75+
"@sigstore/rekor-types": "^3.0.0",
7176
"@types/gunzip-maybe": "^1.4.2",
7277
"@types/he": "^1.2.3",
7378
"@types/js-yaml": "^4.0.9",

src/sigstore/sigstore.ts

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
/**
2+
* Copyright 2025 actions-toolkit authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {X509Certificate} from 'crypto';
18+
import fs from 'fs';
19+
import path from 'path';
20+
21+
import {Endpoints} from '@actions/attest/lib/endpoints';
22+
import * as core from '@actions/core';
23+
import {signPayload} from '@actions/attest/lib/sign';
24+
import {bundleToJSON} from '@sigstore/bundle';
25+
import {Attestation} from '@actions/attest';
26+
import {Bundle} from '@sigstore/sign';
27+
28+
import {Cosign} from '../cosign/cosign';
29+
import {Exec} from '../exec';
30+
import {GitHub} from '../github';
31+
32+
import {MEDIATYPE_PAYLOAD as intotoMediatypePayload, Subject} from '../types/intoto/intoto';
33+
import {FULCIO_URL, REKOR_URL, SEARCH_URL, TSASERVER_URL} from '../types/sigstore/sigstore';
34+
35+
export interface SignProvenanceBlobsOpts {
36+
localExportDir: string;
37+
name?: string;
38+
noTransparencyLog?: boolean;
39+
}
40+
41+
export interface SignProvenanceBlobsResult extends Attestation {
42+
bundlePath: string;
43+
subjects: Array<Subject>;
44+
}
45+
46+
export interface VerifySignedArtifactsOpts {
47+
certificateIdentityRegexp: string;
48+
}
49+
50+
export interface VerifySignedArtifactsResult {
51+
bundlePath: string;
52+
cosignArgs: Array<string>;
53+
}
54+
55+
export interface SigstoreOpts {
56+
cosign?: Cosign;
57+
}
58+
59+
export class Sigstore {
60+
private readonly cosign: Cosign;
61+
62+
constructor(opts?: SigstoreOpts) {
63+
this.cosign = opts?.cosign || new Cosign();
64+
}
65+
66+
public async signProvenanceBlobs(opts: SignProvenanceBlobsOpts): Promise<Record<string, SignProvenanceBlobsResult>> {
67+
const result: Record<string, SignProvenanceBlobsResult> = {};
68+
try {
69+
if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
70+
throw new Error('missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.');
71+
}
72+
73+
const endpoints = this.signingEndpoints(opts);
74+
core.info(`Using Sigstore signing endpoint: ${endpoints.fulcioURL}`);
75+
76+
const provenanceBlobs = Sigstore.getProvenanceBlobs(opts);
77+
for (const p of Object.keys(provenanceBlobs)) {
78+
await core.group(`Signing ${p}`, async () => {
79+
const blob = provenanceBlobs[p];
80+
const bundlePath = path.join(path.dirname(p), `${opts.name ?? 'provenance'}.sigstore.json`);
81+
const subjects = Sigstore.getProvenanceSubjects(blob);
82+
if (subjects.length === 0) {
83+
core.warning(`No subjects found in provenance ${p}, skip signing.`);
84+
return;
85+
}
86+
const bundle = await signPayload(
87+
{
88+
body: blob,
89+
type: intotoMediatypePayload
90+
},
91+
endpoints
92+
);
93+
const attest = Sigstore.toAttestation(bundle);
94+
core.info(`Provenance blob signed for:`);
95+
for (const subject of subjects) {
96+
const [digestAlg, digestValue] = Object.entries(subject.digest)[0] || [];
97+
core.info(` - ${subject.name} (${digestAlg}:${digestValue})`);
98+
}
99+
if (attest.tlogID) {
100+
core.info(`Attestation signature uploaded to Rekor transparency log: ${SEARCH_URL}?logIndex=${attest.tlogID}`);
101+
}
102+
core.info(`Writing Sigstore bundle to: ${bundlePath}`);
103+
fs.writeFileSync(bundlePath, JSON.stringify(attest.bundle, null, 2), {
104+
encoding: 'utf-8'
105+
});
106+
result[p] = {
107+
...attest,
108+
bundlePath: bundlePath,
109+
subjects: subjects
110+
};
111+
});
112+
}
113+
} catch (err) {
114+
throw new Error(`Signing BuildKit provenance blobs failed: ${(err as Error).message}`);
115+
}
116+
return result;
117+
}
118+
119+
public async verifySignedArtifacts(opts: VerifySignedArtifactsOpts, signed: Record<string, SignProvenanceBlobsResult>): Promise<Record<string, VerifySignedArtifactsResult>> {
120+
const result: Record<string, VerifySignedArtifactsResult> = {};
121+
if (!(await this.cosign.isAvailable())) {
122+
throw new Error('Cosign is required to verify signed artifacts');
123+
}
124+
for (const [provenancePath, signedRes] of Object.entries(signed)) {
125+
const baseDir = path.dirname(provenancePath);
126+
await core.group(`Verifying ${signedRes.bundlePath}`, async () => {
127+
for (const subject of signedRes.subjects) {
128+
const artifactPath = path.join(baseDir, subject.name);
129+
core.info(`Verifying signed artifact ${artifactPath}`);
130+
// prettier-ignore
131+
const cosignArgs = [
132+
'verify-blob-attestation',
133+
'--new-bundle-format',
134+
'--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com',
135+
'--certificate-identity-regexp', opts.certificateIdentityRegexp
136+
]
137+
if (!signedRes.bundle.verificationMaterial || !Array.isArray(signedRes.bundle.verificationMaterial.tlogEntries) || signedRes.bundle.verificationMaterial.tlogEntries.length === 0) {
138+
// if there is no tlog entry, we skip tlog verification but still verify the signed timestamp
139+
cosignArgs.push('--use-signed-timestamps', '--insecure-ignore-tlog');
140+
}
141+
const execRes = await Exec.getExecOutput('cosign', [...cosignArgs, '--bundle', signedRes.bundlePath, artifactPath], {
142+
ignoreReturnCode: true
143+
});
144+
if (execRes.stderr.length > 0 && execRes.exitCode != 0) {
145+
throw new Error(execRes.stderr);
146+
}
147+
result[artifactPath] = {
148+
bundlePath: signedRes.bundlePath,
149+
cosignArgs: cosignArgs
150+
};
151+
}
152+
});
153+
}
154+
return result;
155+
}
156+
157+
private signingEndpoints(opts: SignProvenanceBlobsOpts): Endpoints {
158+
const noTransparencyLog = opts.noTransparencyLog ?? GitHub.context.payload.repository?.private;
159+
core.info(`Upload to transparency log: ${noTransparencyLog ? 'disabled' : 'enabled'}`);
160+
return {
161+
fulcioURL: FULCIO_URL,
162+
rekorURL: noTransparencyLog ? undefined : REKOR_URL,
163+
tsaServerURL: TSASERVER_URL
164+
};
165+
}
166+
167+
private static getProvenanceBlobs(opts: SignProvenanceBlobsOpts): Record<string, Buffer> {
168+
// For single platform build
169+
const singleProvenance = path.join(opts.localExportDir, 'provenance.json');
170+
if (fs.existsSync(singleProvenance)) {
171+
return {[singleProvenance]: fs.readFileSync(singleProvenance)};
172+
}
173+
174+
// For multi-platform build
175+
const dirents = fs.readdirSync(opts.localExportDir, {withFileTypes: true});
176+
const platformFolders = dirents.filter(dirent => dirent.isDirectory());
177+
if (platformFolders.length > 0 && platformFolders.length === dirents.length && platformFolders.every(platformFolder => fs.existsSync(path.join(opts.localExportDir, platformFolder.name, 'provenance.json')))) {
178+
const result: Record<string, Buffer> = {};
179+
for (const platformFolder of platformFolders) {
180+
const p = path.join(opts.localExportDir, platformFolder.name, 'provenance.json');
181+
result[p] = fs.readFileSync(p);
182+
}
183+
return result;
184+
}
185+
186+
throw new Error(`No valid provenance.json found in ${opts.localExportDir}`);
187+
}
188+
189+
private static getProvenanceSubjects(body: Buffer): Array<Subject> {
190+
const statement = JSON.parse(body.toString()) as {
191+
subject: Array<{name: string; digest: Record<string, string>}>;
192+
};
193+
return statement.subject.map(s => ({
194+
name: s.name,
195+
digest: s.digest
196+
}));
197+
}
198+
199+
// https://github.com/actions/toolkit/blob/d3ab50471b4ff1d1274dffb90ef9c5d9949b4886/packages/attest/src/attest.ts#L90
200+
private static toAttestation(bundle: Bundle): Attestation {
201+
let certBytes: Buffer;
202+
switch (bundle.verificationMaterial.content.$case) {
203+
case 'x509CertificateChain':
204+
certBytes = bundle.verificationMaterial.content.x509CertificateChain.certificates[0].rawBytes;
205+
break;
206+
case 'certificate':
207+
certBytes = bundle.verificationMaterial.content.certificate.rawBytes;
208+
break;
209+
default:
210+
throw new Error('Bundle must contain an x509 certificate');
211+
}
212+
213+
const signingCert = new X509Certificate(certBytes);
214+
215+
// Collect transparency log ID if available
216+
const tlogEntries = bundle.verificationMaterial.tlogEntries;
217+
const tlogID = tlogEntries.length > 0 ? tlogEntries[0].logIndex : undefined;
218+
219+
return {
220+
bundle: bundleToJSON(bundle),
221+
certificate: signingCert.toString(),
222+
tlogID: tlogID
223+
};
224+
}
225+
}

0 commit comments

Comments
 (0)