sigstore class to sign buildkit provenance blobs
Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com>
This commit is contained in:
156
src/sigstore/sigstore.ts
Normal file
156
src/sigstore/sigstore.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Copyright 2025 actions-toolkit authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {X509Certificate} from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import {signingEndpoints, SigstoreInstance} from '@actions/attest/lib/endpoints';
|
||||
import * as core from '@actions/core';
|
||||
import {signPayload} from '@actions/attest/lib/sign';
|
||||
import {bundleToJSON} from '@sigstore/bundle';
|
||||
import {Attestation} from '@actions/attest';
|
||||
import {Bundle} from '@sigstore/sign';
|
||||
|
||||
import {Subject} from '../types/intoto/intoto';
|
||||
|
||||
export interface SignProvenanceBlobsOpts {
|
||||
localExportDir: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface SignProvenanceBlobsResult extends Attestation {
|
||||
bundlePath: string;
|
||||
subjects: Array<Subject>;
|
||||
}
|
||||
|
||||
export class Sigstore {
|
||||
private intotoPayloadType = 'application/vnd.in-toto+json';
|
||||
private searchSigstoreURL = 'https://search.sigstore.dev';
|
||||
|
||||
public async signProvenanceBlobs(opts: SignProvenanceBlobsOpts): Promise<Record<string, SignProvenanceBlobsResult>> {
|
||||
const result: Record<string, SignProvenanceBlobsResult> = {};
|
||||
try {
|
||||
if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
|
||||
throw new Error('missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.');
|
||||
}
|
||||
|
||||
const sigstoreInstance: SigstoreInstance = 'public-good';
|
||||
const endpoints = signingEndpoints(sigstoreInstance);
|
||||
core.info(`Using Sigstore signing endpoint: ${endpoints.fulcioURL}`);
|
||||
|
||||
const provenanceBlobs = Sigstore.getProvenanceBlobs(opts);
|
||||
for (const p of Object.keys(provenanceBlobs)) {
|
||||
await core.group(`Signing ${p}`, async () => {
|
||||
const blob = provenanceBlobs[p];
|
||||
const bundlePath = path.join(path.dirname(p), `${opts.name ?? 'provenance'}.sigstore.json`);
|
||||
const subjects = Sigstore.getProvenanceSubjects(blob);
|
||||
if (subjects.length === 0) {
|
||||
core.warning(`No subjects found in provenance ${p}, skip signing.`);
|
||||
return;
|
||||
}
|
||||
const bundle = await signPayload(
|
||||
{
|
||||
body: blob,
|
||||
type: this.intotoPayloadType
|
||||
},
|
||||
endpoints
|
||||
);
|
||||
const attest = Sigstore.toAttestation(bundle);
|
||||
core.info(`Provenance blob signed for:`);
|
||||
for (const subject of subjects) {
|
||||
const [digestAlg, digestValue] = Object.entries(subject.digest)[0] || [];
|
||||
core.info(` - ${subject.name} (${digestAlg}:${digestValue})`);
|
||||
}
|
||||
if (attest.tlogID) {
|
||||
core.info(`Attestation signature uploaded to Rekor transparency log: ${this.searchSigstoreURL}?logIndex=${attest.tlogID}`);
|
||||
}
|
||||
core.info(`Writing Sigstore bundle to: ${bundlePath}`);
|
||||
fs.writeFileSync(bundlePath, JSON.stringify(attest.bundle, null, 2), {
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
result[p] = {
|
||||
...attest,
|
||||
bundlePath: bundlePath,
|
||||
subjects: subjects
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(`Signing BuildKit provenance blobs failed: ${(err as Error).message}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static getProvenanceBlobs(opts: SignProvenanceBlobsOpts): Record<string, Buffer> {
|
||||
// For single platform build
|
||||
const singleProvenance = path.join(opts.localExportDir, 'provenance.json');
|
||||
if (fs.existsSync(singleProvenance)) {
|
||||
return {[singleProvenance]: fs.readFileSync(singleProvenance)};
|
||||
}
|
||||
|
||||
// For multi-platform build
|
||||
const dirents = fs.readdirSync(opts.localExportDir, {withFileTypes: true});
|
||||
const platformFolders = dirents.filter(dirent => dirent.isDirectory());
|
||||
if (platformFolders.length > 0 && platformFolders.length === dirents.length && platformFolders.every(platformFolder => fs.existsSync(path.join(opts.localExportDir, platformFolder.name, 'provenance.json')))) {
|
||||
const result: Record<string, Buffer> = {};
|
||||
for (const platformFolder of platformFolders) {
|
||||
const p = path.join(opts.localExportDir, platformFolder.name, 'provenance.json');
|
||||
result[p] = fs.readFileSync(p);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new Error(`No valid provenance.json found in ${opts.localExportDir}`);
|
||||
}
|
||||
|
||||
private static getProvenanceSubjects(body: Buffer): Array<Subject> {
|
||||
const statement = JSON.parse(body.toString()) as {
|
||||
subject: Array<{name: string; digest: Record<string, string>}>;
|
||||
};
|
||||
return statement.subject.map(s => ({
|
||||
name: s.name,
|
||||
digest: s.digest
|
||||
}));
|
||||
}
|
||||
|
||||
// https://github.com/actions/toolkit/blob/d3ab50471b4ff1d1274dffb90ef9c5d9949b4886/packages/attest/src/attest.ts#L90
|
||||
private static toAttestation(bundle: Bundle): Attestation {
|
||||
let certBytes: Buffer;
|
||||
switch (bundle.verificationMaterial.content.$case) {
|
||||
case 'x509CertificateChain':
|
||||
certBytes = bundle.verificationMaterial.content.x509CertificateChain.certificates[0].rawBytes;
|
||||
break;
|
||||
case 'certificate':
|
||||
certBytes = bundle.verificationMaterial.content.certificate.rawBytes;
|
||||
break;
|
||||
default:
|
||||
throw new Error('Bundle must contain an x509 certificate');
|
||||
}
|
||||
|
||||
const signingCert = new X509Certificate(certBytes);
|
||||
|
||||
// Collect transparency log ID if available
|
||||
const tlogEntries = bundle.verificationMaterial.tlogEntries;
|
||||
const tlogID = tlogEntries.length > 0 ? tlogEntries[0].logIndex : undefined;
|
||||
|
||||
return {
|
||||
bundle: bundleToJSON(bundle),
|
||||
certificate: signingCert.toString(),
|
||||
tlogID: tlogID
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -18,3 +18,9 @@
|
||||
export const MEDIATYPE_PAYLOAD = 'application/vnd.in-toto+json';
|
||||
|
||||
export const MEDIATYPE_PREDICATE = 'in-toto.io/predicate-type';
|
||||
|
||||
// https://github.com/in-toto/in-toto-golang/blob/0a34c087cedcc36de065b4fccb7cf7c9bc16e29f/in_toto/attestations.go#L30-L42
|
||||
export interface Subject {
|
||||
name: string;
|
||||
digest: Record<string, string>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user