diff --git a/src/buildx/install.ts b/src/buildx/install.ts index 29f15b7..2b49c1c 100644 --- a/src/buildx/install.ts +++ b/src/buildx/install.ts @@ -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 { 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 { diff --git a/src/cosign/install.ts b/src/cosign/install.ts index 56a0152..5891cba 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.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 { @@ -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 { diff --git a/src/sigstore/sigstore.ts b/src/sigstore/sigstore.ts index 6fa8966..f48847d 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 {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 { + 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); + } } diff --git a/src/types/sigstore/sigstore.ts b/src/types/sigstore/sigstore.ts index d6a2d70..e1cd7b8 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; +}