diff --git a/__tests__/buildx/install.test.itg.ts b/__tests__/buildx/install.test.itg.ts index 3041f2de..e0f4b397 100644 --- a/__tests__/buildx/install.test.itg.ts +++ b/__tests__/buildx/install.test.itg.ts @@ -23,13 +23,18 @@ const maybe = !process.env.GITHUB_ACTIONS || (process.env.GITHUB_ACTIONS === 'tr maybe('download', () => { // prettier-ignore - test.each(['latest'])( + test.each(['v0.31.0-rc1'])( 'install buildx %s', async (version) => { await expect((async () => { const install = new Install({ standalone: true }); - const toolPath = await install.download(version); + const toolPath = await install.download({ + version: version, + verifySignature: true, + ghaNoCache: true, + disableHtc: true + }); if (!fs.existsSync(toolPath)) { throw new Error('toolPath does not exist'); } diff --git a/__tests__/buildx/install.test.ts b/__tests__/buildx/install.test.ts index 9defe183..8c8a90d1 100644 --- a/__tests__/buildx/install.test.ts +++ b/__tests__/buildx/install.test.ts @@ -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) { @@ -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); @@ -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); @@ -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); }); diff --git a/src/buildx/install.ts b/src/buildx/install.ts index 2fc61f84..aa233ffa 100644 --- a/src/buildx/install.ts +++ b/src/buildx/install.ts @@ -18,6 +18,7 @@ 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 * as semver from 'semver'; import * as util from 'util'; @@ -29,23 +30,36 @@ 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; + disableHtc?: 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(); } /* @@ -54,8 +68,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 { - const version: DownloadVersion = await Install.getDownloadVersion(v); + public async download(opts: DownloadOpts): Promise { + const version: DownloadVersion = await Install.getDownloadVersion(opts.version); core.debug(`Install.download version: ${version.version}`); const release: GitHubRelease = await Install.getRelease(version, this.githubToken); @@ -74,11 +88,11 @@ 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(); - if (cacheFoundPath) { + if (!opts.disableHtc && cacheFoundPath) { core.info(`Buildx binary found in ${cacheFoundPath}`); return cacheFoundPath; } @@ -89,7 +103,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', {includePrerelease: true})) { + await this.verifySignature(htcDownloadPath, downloadURL); + } + + const cacheSavePath = await installCache.save(htcDownloadPath, opts.skipState); core.info(`Cached to ${cacheSavePath}`); return cacheSavePath; } @@ -213,6 +231,30 @@ export class Install { return standalone; } + private async verifySignature(binPath: string, downloadURL: string): Promise { + const bundleURL = `${downloadURL.replace(/\.exe$/, '')}.sigstore.json`; + core.info(`Downloading keyless verification bundle at ${bundleURL}`); + + let bundlePath: string; + try { + 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; + } + throw e; + } + + 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()) { diff --git a/src/cosign/install.ts b/src/cosign/install.ts index 4a512e2e..fe6f5857 100644 --- a/src/cosign/install.ts +++ b/src/cosign/install.ts @@ -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'; 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; @@ -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 { @@ -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 { diff --git a/src/sigstore/sigstore.ts b/src/sigstore/sigstore.ts index 3ad9600b..3913493c 100644 --- a/src/sigstore/sigstore.ts +++ b/src/sigstore/sigstore.ts @@ -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'; @@ -39,6 +41,8 @@ import { SignProvenanceBlobsOpts, SignProvenanceBlobsResult, TSASERVER_URL, + VerifyArtifactOpts, + VerifyArtifactResult, VerifySignedArtifactsOpts, VerifySignedArtifactsResult, VerifySignedManifestsOpts, @@ -329,6 +333,48 @@ export class Sigstore { return result; } + public async verifyArtifact(artifactPath: string, bundlePath: string, opts?: VerifyArtifactOpts): Promise { + 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'}`); @@ -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': @@ -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); } } diff --git a/src/types/sigstore/sigstore.ts b/src/types/sigstore/sigstore.ts index b08e1ffe..de2866cc 100644 --- a/src/types/sigstore/sigstore.ts +++ b/src/types/sigstore/sigstore.ts @@ -78,3 +78,14 @@ export interface VerifySignedArtifactsResult { bundlePath: string; cosignArgs: Array; } + +export interface VerifyArtifactOpts { + subjectAlternativeName: string | RegExp; + issuer?: string; +} + +export interface VerifyArtifactResult { + payload: SerializedBundle; + certificate: string; + tlogID?: string; +}