Skip to content
Draft
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: 4 additions & 1 deletion __tests__/buildx/install.test.itg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ maybe('download', () => {
const install = new Install({
standalone: true
});
const toolPath = await install.download(version);
const toolPath = await install.download({
version: version,
verifySignature: true
});
if (!fs.existsSync(toolPath)) {
throw new Error('toolPath does not exist');
}
Expand Down
8 changes: 4 additions & 4 deletions __tests__/buildx/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('download', () => {
])(
'acquires %p of buildx (standalone: %p)', async (version, standalone) => {
const install = new Install({standalone: standalone});
const toolPath = await install.download(version);
const toolPath = await install.download({version});
expect(fs.existsSync(toolPath)).toBe(true);
let buildxBin: string;
if (standalone) {
Expand All @@ -57,7 +57,7 @@ describe('download', () => {
])(
'acquires %p of buildx with cache', async (version) => {
const install = new Install({standalone: false});
const toolPath = await install.download(version);
const toolPath = await install.download({version});
expect(fs.existsSync(toolPath)).toBe(true);
}, 100000);

Expand All @@ -68,7 +68,7 @@ describe('download', () => {
])(
'acquires %p of buildx without cache', async (version) => {
const install = new Install({standalone: false});
const toolPath = await install.download(version, true);
const toolPath = await install.download({version: version, ghaNoCache: true});
expect(fs.existsSync(toolPath)).toBe(true);
}, 100000);

Expand All @@ -88,7 +88,7 @@ describe('download', () => {
jest.spyOn(osm, 'platform').mockImplementation(() => os as NodeJS.Platform);
jest.spyOn(osm, 'arch').mockImplementation(() => arch);
const install = new Install();
const buildxBin = await install.download('latest');
const buildxBin = await install.download({version: 'latest'});
expect(fs.existsSync(buildxBin)).toBe(true);
}, 100000);
});
Expand Down
38 changes: 34 additions & 4 deletions src/buildx/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,35 @@ import {Exec} from '../exec';
import {Docker} from '../docker/docker';
import {Git} from '../git';
import {GitHub} from '../github';
import {Sigstore} from '../sigstore/sigstore';
import {Util} from '../util';

import {DownloadVersion} from '../types/buildx/buildx';
import {GitHubRelease} from '../types/github';
import {SEARCH_URL} from '../types/sigstore/sigstore';

export interface DownloadOpts {
version: string;
ghaNoCache?: boolean;
skipState?: boolean;
verifySignature?: boolean;
}

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();
}

/*
Expand All @@ -54,8 +66,8 @@ export class Install {
* @param ghaNoCache: disable binary caching in GitHub Actions cache backend
* @returns path to the buildx binary
*/
public async download(v: string, ghaNoCache?: boolean): Promise<string> {
const version: DownloadVersion = await Install.getDownloadVersion(v);
public async download(opts: DownloadOpts): Promise<string> {
const version: DownloadVersion = await Install.getDownloadVersion(opts.version);
core.debug(`Install.download version: ${version.version}`);

const release: GitHubRelease = await Install.getRelease(version, this.githubToken);
Expand All @@ -74,7 +86,7 @@ export class Install {
htcVersion: vspec,
baseCacheDir: path.join(Buildx.configDir, '.bin'),
cacheFile: os.platform() == 'win32' ? 'docker-buildx.exe' : 'docker-buildx',
ghaNoCache: ghaNoCache
ghaNoCache: opts.ghaNoCache
});

const cacheFoundPath = await installCache.find();
Expand All @@ -89,7 +101,11 @@ export class Install {
const htcDownloadPath = await tc.downloadTool(downloadURL, undefined, this.githubToken);
core.debug(`Install.download htcDownloadPath: ${htcDownloadPath}`);

const cacheSavePath = await installCache.save(htcDownloadPath);
if (opts.verifySignature && semver.satisfies(vspec, '>=0.31.0')) {
await this.verifySignature(htcDownloadPath, downloadURL);
}

const cacheSavePath = await installCache.save(htcDownloadPath, opts.skipState);
core.info(`Cached to ${cacheSavePath}`);
return cacheSavePath;
}
Expand Down Expand Up @@ -213,6 +229,20 @@ export class Install {
return standalone;
}

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}`);

const verifyResult = await this.sigstore.verifyArtifact(binPath, bundlePath, {
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 {
let arch: string;
switch (os.arch()) {
Expand Down
35 changes: 11 additions & 24 deletions src/cosign/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -31,11 +28,13 @@ import {Context} from '../context';
import {Exec} from '../exec';
import {Git} from '../git';
import {GitHub} from '../github';
import {Sigstore} from '../sigstore/sigstore';
import {Util} from '../util';

import {DownloadVersion} from '../types/cosign/cosign';
import {GitHubRelease} from '../types/github';
import {dockerfileContent} from './dockerfile';
import {SEARCH_URL} from '../types/sigstore/sigstore';

export interface DownloadOpts {
version: string;
Expand All @@ -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> {
Expand Down Expand Up @@ -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);

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}`);
}
const verifyResult = await this.sigstore.verifyArtifact(cosignBinPath, bundlePath, {
subjectAlternativeName: 'keyless@projectsigstore.iam.gserviceaccount.com',
issuer: 'https://accounts.google.com'
});

core.info(`Cosign binary signature verified! ${verifyResult.tlogID ? `${SEARCH_URL}?logIndex=${verifyResult.tlogID}` : ''}`);
}

private filename(): string {
Expand Down
75 changes: 62 additions & 13 deletions src/sigstore/sigstore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {Cosign} from '../cosign/cosign';
import {Exec} from '../exec';
Expand All @@ -39,6 +41,8 @@ import {
SignProvenanceBlobsOpts,
SignProvenanceBlobsResult,
TSASERVER_URL,
VerifyArtifactOpts,
VerifyArtifactResult,
VerifySignedArtifactsOpts,
VerifySignedArtifactsResult,
VerifySignedManifestsOpts,
Expand Down Expand Up @@ -329,6 +333,48 @@ 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) {
if (!signingCert.subjectAltName?.match(opts.subjectAlternativeName)) {
throw new Error(`Signing certificate subjectAlternativeName "${signingCert.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'}`);
Expand Down Expand Up @@ -410,6 +456,20 @@ export class Sigstore {
}

private static parseBundle(bundle: Bundle): ParsedBundle {
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;

return {
payload: bundleToJSON(bundle),
certificate: signingCert.toString(),
tlogID: tlogID
};
}

private static parseCertificate(bundle: Bundle): X509Certificate {
let certBytes: Buffer;
switch (bundle.verificationMaterial.content.$case) {
case 'x509CertificateChain':
Expand All @@ -421,17 +481,6 @@ export class Sigstore {
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 {
payload: bundleToJSON(bundle),
certificate: signingCert.toString(),
tlogID: tlogID
};
return new X509Certificate(certBytes);
}
}
11 changes: 11 additions & 0 deletions src/types/sigstore/sigstore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading