sigstore: verifyArtifact func to verify arbitrary artifact

Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com>
This commit is contained in:
CrazyMax
2026-01-14 23:57:18 +01:00
parent 89e14b0d85
commit 17e08b98a8
4 changed files with 109 additions and 66 deletions

View File

@@ -14,15 +14,12 @@
* limitations under the License.
*/
import {X509Certificate} from 'crypto';
import fs from 'fs';
import os from 'os';
import path from 'path';
import * as core from '@actions/core';
import * as httpm from '@actions/http-client';
import * as tc from '@actions/tool-cache';
import {bundleFromJSON, SerializedBundle} from '@sigstore/bundle';
import * as tuf from '@sigstore/tuf';
import {toSignedEntity, toTrustMaterial, Verifier} from '@sigstore/verify';
import * as semver from 'semver';
import * as util from 'util';
@@ -33,10 +30,12 @@ import {Exec} from '../exec.js';
import {Docker} from '../docker/docker.js';
import {Git} from '../git.js';
import {GitHub} from '../github.js';
import {Sigstore} from '../sigstore/sigstore.js';
import {Util} from '../util.js';
import {DownloadVersion} from '../types/buildx/buildx.js';
import {GitHubRelease} from '../types/github.js';
import {SEARCH_URL} from '../types/sigstore/sigstore.js';
export interface DownloadOpts {
version: string;
@@ -49,15 +48,18 @@ export interface DownloadOpts {
export interface InstallOpts {
standalone?: boolean;
githubToken?: string;
sigstore?: Sigstore;
}
export class Install {
private readonly standalone: boolean | undefined;
private readonly githubToken: string | undefined;
private readonly sigstore: Sigstore;
constructor(opts?: InstallOpts) {
this.standalone = opts?.standalone;
this.githubToken = opts?.githubToken || process.env.GITHUB_TOKEN;
this.sigstore = opts?.sigstore || new Sigstore();
}
/*
@@ -232,35 +234,26 @@ export class Install {
private async verifySignature(binPath: string, downloadURL: string): Promise<void> {
const bundleURL = `${downloadURL.replace(/\.exe$/, '')}.sigstore.json`;
core.info(`Downloading keyless verification bundle at ${bundleURL}`);
const bundlePath = await tc.downloadTool(bundleURL, undefined, this.githubToken);
core.debug(`Install.verifySignature bundlePath: ${bundlePath}`);
core.info(`Verifying keyless verification bundle signature`);
const parsedBundle = JSON.parse(fs.readFileSync(bundlePath, 'utf-8')) as SerializedBundle;
const bundle = bundleFromJSON(parsedBundle);
core.info(`Fetching Sigstore TUF trusted root metadata`);
const trustedRoot = await tuf.getTrustedRoot();
const trustMaterial = toTrustMaterial(trustedRoot);
let bundlePath: string;
try {
core.info(`Verifying Buildx binary signature`);
const signedEntity = toSignedEntity(bundle, fs.readFileSync(binPath));
const signingCert = new X509Certificate(signedEntity.signature.signature);
if (!signingCert.subjectAltName?.match(/^https:\/\/github\.com\/docker\/(github-builder-experimental|github-builder)\/\.github\/workflows\/bake\.yml.*$/)) {
throw new Error(`Signing certificate subjectAlternativeName "${signingCert.subjectAltName}" does not match expected pattern`);
bundlePath = await tc.downloadTool(bundleURL, undefined, this.githubToken);
core.debug(`Install.verifySignature bundlePath: ${bundlePath}`);
} catch (e) {
if (e.message && e.message.statusCode === httpm.HttpCodes.NotFound) {
core.info(`No signature bundle found at ${bundleURL}, skipping verification`);
return;
}
const verifier = new Verifier(trustMaterial);
const signer = verifier.verify(signedEntity, {
// FIXME: uncomment when subjectAlternativeName check with regex is supported: https://github.com/docker/actions-toolkit/pull/929#discussion_r2682150413
//subjectAlternativeName: /^https:\/\/github\.com\/docker\/(github-builder-experimental|github-builder)\/\.github\/workflows\/bake\.yml.*$/,
extensions: {issuer: 'https://token.actions.githubusercontent.com'}
});
core.debug(`Install.verifySignature signer: ${JSON.stringify(signer)}`);
core.info(`Buildx binary signature verified!`);
} catch (err) {
throw new Error(`Failed to verify Buildx binary signature: ${err}`);
throw e;
}
const verifyResult = await this.sigstore.verifyArtifact(binPath, bundlePath, {
// TODO: add githubWorkflowRepository , runnerEnvironment and sourceRepositoryURI extensions when supported by sigstore module
subjectAlternativeName: /^https:\/\/github\.com\/docker\/(github-builder-experimental|github-builder)\/\.github\/workflows\/bake\.yml.*$/,
issuer: 'https://token.actions.githubusercontent.com'
});
core.info(`Buildx binary signature verified! ${verifyResult.tlogID ? `${SEARCH_URL}?logIndex=${verifyResult.tlogID}` : ''}`);
}
private filename(version: string): string {

View File

@@ -19,9 +19,6 @@ import os from 'os';
import path from 'path';
import * as core from '@actions/core';
import * as tc from '@actions/tool-cache';
import {bundleFromJSON, SerializedBundle} from '@sigstore/bundle';
import * as tuf from '@sigstore/tuf';
import {toSignedEntity, toTrustMaterial, Verifier} from '@sigstore/verify';
import * as semver from 'semver';
import * as util from 'util';
@@ -31,11 +28,13 @@ import {Context} from '../context.js';
import {Exec} from '../exec.js';
import {Git} from '../git.js';
import {GitHub} from '../github.js';
import {Sigstore} from '../sigstore/sigstore.js';
import {Util} from '../util.js';
import {DownloadVersion} from '../types/cosign/cosign.js';
import {GitHubRelease} from '../types/github.js';
import {dockerfileContent} from './dockerfile.js';
import {SEARCH_URL} from '../types/sigstore/sigstore.js';
export interface DownloadOpts {
version: string;
@@ -47,15 +46,18 @@ export interface DownloadOpts {
export interface InstallOpts {
githubToken?: string;
buildx?: Buildx;
sigstore?: Sigstore;
}
export class Install {
private readonly githubToken: string | undefined;
private readonly buildx: Buildx;
private readonly sigstore: Sigstore;
constructor(opts?: InstallOpts) {
this.githubToken = opts?.githubToken || process.env.GITHUB_TOKEN;
this.buildx = opts?.buildx || new Buildx();
this.sigstore = opts?.sigstore || new Sigstore();
}
public async download(opts: DownloadOpts): Promise<string> {
@@ -196,27 +198,12 @@ export class Install {
const bundlePath = await tc.downloadTool(bundleURL, undefined, this.githubToken);
core.debug(`Install.verifySignature bundlePath: ${bundlePath}`);
core.info(`Verifying keyless verification bundle signature`);
const parsedBundle = JSON.parse(fs.readFileSync(bundlePath, 'utf-8')) as SerializedBundle;
const bundle = bundleFromJSON(parsedBundle);
const verifyResult = await this.sigstore.verifyArtifact(cosignBinPath, bundlePath, {
subjectAlternativeName: 'keyless@projectsigstore.iam.gserviceaccount.com',
issuer: 'https://accounts.google.com'
});
core.info(`Fetching Sigstore TUF trusted root metadata`);
const trustedRoot = await tuf.getTrustedRoot();
const trustMaterial = toTrustMaterial(trustedRoot);
try {
core.info(`Verifying cosign binary signature`);
const signedEntity = toSignedEntity(bundle, fs.readFileSync(cosignBinPath));
const verifier = new Verifier(trustMaterial);
const signer = verifier.verify(signedEntity, {
subjectAlternativeName: 'keyless@projectsigstore.iam.gserviceaccount.com',
extensions: {issuer: 'https://accounts.google.com'}
});
core.debug(`Install.verifySignature signer: ${JSON.stringify(signer)}`);
core.info(`Cosign binary signature verified!`);
} catch (err) {
throw new Error(`Failed to verify cosign binary signature: ${err}`);
}
core.info(`Cosign binary signature verified! ${verifyResult.tlogID ? `${SEARCH_URL}?logIndex=${verifyResult.tlogID}` : ''}`);
}
private filename(): string {

View File

@@ -19,8 +19,10 @@ import fs from 'fs';
import path from 'path';
import * as core from '@actions/core';
import {bundleFromJSON, bundleToJSON} from '@sigstore/bundle';
import {bundleFromJSON, bundleToJSON, SerializedBundle} from '@sigstore/bundle';
import {Artifact, Bundle, CIContextProvider, DSSEBundleBuilder, FulcioSigner, RekorWitness, TSAWitness, Witness} from '@sigstore/sign';
import * as tuf from '@sigstore/tuf';
import {toSignedEntity, toTrustMaterial, Verifier} from '@sigstore/verify';
import {Context} from '../context.js';
import {Cosign} from '../cosign/cosign.js';
@@ -40,6 +42,8 @@ import {
SignProvenanceBlobsOpts,
SignProvenanceBlobsResult,
TSASERVER_URL,
VerifyArtifactOpts,
VerifyArtifactResult,
VerifySignedArtifactsOpts,
VerifySignedArtifactsResult,
VerifySignedManifestsOpts,
@@ -361,6 +365,51 @@ export class Sigstore {
return result;
}
public async verifyArtifact(artifactPath: string, bundlePath: string, opts?: VerifyArtifactOpts): Promise<VerifyArtifactResult> {
core.info(`Verifying keyless verification bundle signature`);
const parsedBundle = JSON.parse(fs.readFileSync(bundlePath, 'utf-8')) as SerializedBundle;
const bundle = bundleFromJSON(parsedBundle);
core.info(`Fetching Sigstore TUF trusted root metadata`);
const trustedRoot = await tuf.getTrustedRoot();
const trustMaterial = toTrustMaterial(trustedRoot);
try {
core.info(`Verifying artifact signature`);
const signedEntity = toSignedEntity(bundle, fs.readFileSync(artifactPath));
const signingCert = Sigstore.parseCertificate(bundle);
// collect transparency log ID if available
const tlogEntries = bundle.verificationMaterial.tlogEntries;
const tlogID = tlogEntries.length > 0 ? tlogEntries[0].logIndex : undefined;
// TODO: remove when subjectAlternativeName check with regex is supported: https://github.com/sigstore/sigstore-js/pull/1556
if (opts?.subjectAlternativeName && opts?.subjectAlternativeName instanceof RegExp) {
const subjectAltName = signingCert.subjectAltName?.replace(/^uri:/i, '');
if (!subjectAltName) {
throw new Error('Signing certificate does not contain subjectAltName');
} else if (!subjectAltName.match(opts.subjectAlternativeName)) {
throw new Error(`Signing certificate subjectAlternativeName "${subjectAltName}" does not match expected pattern`);
}
}
const verifier = new Verifier(trustMaterial);
const signer = verifier.verify(signedEntity, {
subjectAlternativeName: opts?.subjectAlternativeName && typeof opts.subjectAlternativeName === 'string' ? opts.subjectAlternativeName : undefined,
extensions: opts?.issuer ? {issuer: opts.issuer} : undefined
});
core.debug(`Sigstore.verifyArtifact signer: ${JSON.stringify(signer)}`);
return {
payload: parsedBundle,
certificate: signingCert.toString(),
tlogID: tlogID
};
} catch (err) {
throw new Error(`Failed to verify artifact signature: ${err}`);
}
}
private signingEndpoints(noTransparencyLog?: boolean): Endpoints {
noTransparencyLog = Sigstore.noTransparencyLog(noTransparencyLog);
core.info(`Upload to transparency log: ${noTransparencyLog ? 'disabled' : 'enabled'}`);
@@ -442,19 +491,7 @@ export class Sigstore {
}
private static parseBundle(bundle: Bundle): ParsedBundle {
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);
const signingCert = Sigstore.parseCertificate(bundle);
// collect transparency log ID if available
const tlogEntries = bundle.verificationMaterial.tlogEntries;
@@ -466,4 +503,19 @@ export class Sigstore {
tlogID: tlogID
};
}
private static parseCertificate(bundle: Bundle): X509Certificate {
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');
}
return new X509Certificate(certBytes);
}
}

View File

@@ -78,3 +78,14 @@ export interface VerifySignedArtifactsResult {
bundlePath: string;
cosignArgs: Array<string>;
}
export interface VerifyArtifactOpts {
subjectAlternativeName: string | RegExp;
issuer?: string;
}
export interface VerifyArtifactResult {
payload: SerializedBundle;
certificate: string;
tlogID?: string;
}