From fea67806b7d39c980c5338e2c9c764fddb76c08b Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Mon, 12 Jan 2026 13:48:46 +0100 Subject: [PATCH 1/4] buildx(install): use sigstore module to verify signature Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- __tests__/buildx/install.test.itg.ts | 7 +++- __tests__/buildx/install.test.ts | 8 ++-- src/buildx/install.ts | 55 +++++++++++++++++++++++++--- 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/__tests__/buildx/install.test.itg.ts b/__tests__/buildx/install.test.itg.ts index 3041f2de..baa19125 100644 --- a/__tests__/buildx/install.test.itg.ts +++ b/__tests__/buildx/install.test.itg.ts @@ -29,7 +29,12 @@ maybe('download', () => { 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..ec65e54f 100644 --- a/src/buildx/install.ts +++ b/src/buildx/install.ts @@ -19,6 +19,9 @@ 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'; @@ -34,6 +37,14 @@ import {Util} from '../util'; import {DownloadVersion} from '../types/buildx/buildx'; import {GitHubRelease} from '../types/github'; +export interface DownloadOpts { + version: string; + ghaNoCache?: boolean; + disableHtc?: boolean; + skipState?: boolean; + verifySignature?: boolean; +} + export interface InstallOpts { standalone?: boolean; githubToken?: string; @@ -54,8 +65,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 +85,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 +100,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 +228,36 @@ 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}`); + 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 Buildx binary signature`); + const signedEntity = toSignedEntity(bundle, fs.readFileSync(binPath)); + 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\/build\.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}`); + } + } + private filename(version: string): string { let arch: string; switch (os.arch()) { From f912da78550eb1931f8fea3d11edef4865a2f653 Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Tue, 13 Jan 2026 13:39:43 +0100 Subject: [PATCH 2/4] buildx(install): workaround to check subjectAlternativeName Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- src/buildx/install.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/buildx/install.ts b/src/buildx/install.ts index ec65e54f..dd45d3cf 100644 --- a/src/buildx/install.ts +++ b/src/buildx/install.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import {X509Certificate} from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -245,10 +246,14 @@ export class Install { 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`); + } 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\/build\.yml.*$/, + //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)}`); From d8bf6a7b710862d660988a6ae55f48f30ff86948 Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Wed, 14 Jan 2026 23:57:18 +0100 Subject: [PATCH 3/4] sigstore: verifyArtifact func to verify arbitrary artifact Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- src/buildx/install.ts | 50 ++++++++++------------- src/cosign/install.ts | 35 +++++----------- src/sigstore/sigstore.ts | 75 ++++++++++++++++++++++++++++------ src/types/sigstore/sigstore.ts | 11 +++++ 4 files changed, 105 insertions(+), 66 deletions(-) diff --git a/src/buildx/install.ts b/src/buildx/install.ts index dd45d3cf..aa233ffa 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'; 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; @@ -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,25 @@ 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, { + 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 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; +} From e58e309452b2caec143e35d2fde16e010055097f Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Thu, 15 Jan 2026 09:22:44 +0100 Subject: [PATCH 4/4] buildx: test artifact signatures with v0.31.0-rc1 Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- __tests__/buildx/install.test.itg.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/buildx/install.test.itg.ts b/__tests__/buildx/install.test.itg.ts index baa19125..e0f4b397 100644 --- a/__tests__/buildx/install.test.itg.ts +++ b/__tests__/buildx/install.test.itg.ts @@ -23,7 +23,7 @@ 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({