|
| 1 | +/** |
| 2 | + * Copyright 2025 actions-toolkit authors |
| 3 | + * |
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | + * you may not use this file except in compliance with the License. |
| 6 | + * You may obtain a copy of the License at |
| 7 | + * |
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | + * |
| 10 | + * Unless required by applicable law or agreed to in writing, software |
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | + * See the License for the specific language governing permissions and |
| 14 | + * limitations under the License. |
| 15 | + */ |
| 16 | + |
| 17 | +import {X509Certificate} from 'crypto'; |
| 18 | +import fs from 'fs'; |
| 19 | +import path from 'path'; |
| 20 | + |
| 21 | +import {Endpoints} from '@actions/attest/lib/endpoints'; |
| 22 | +import * as core from '@actions/core'; |
| 23 | +import {signPayload} from '@actions/attest/lib/sign'; |
| 24 | +import {bundleToJSON} from '@sigstore/bundle'; |
| 25 | +import {Attestation} from '@actions/attest'; |
| 26 | +import {Bundle} from '@sigstore/sign'; |
| 27 | + |
| 28 | +import {Cosign} from '../cosign/cosign'; |
| 29 | +import {Exec} from '../exec'; |
| 30 | +import {GitHub} from '../github'; |
| 31 | + |
| 32 | +import {MEDIATYPE_PAYLOAD as intotoMediatypePayload, Subject} from '../types/intoto/intoto'; |
| 33 | +import {FULCIO_URL, REKOR_URL, SEARCH_URL, TSASERVER_URL} from '../types/sigstore/sigstore'; |
| 34 | + |
| 35 | +export interface SignProvenanceBlobsOpts { |
| 36 | + localExportDir: string; |
| 37 | + name?: string; |
| 38 | + noTransparencyLog?: boolean; |
| 39 | +} |
| 40 | + |
| 41 | +export interface SignProvenanceBlobsResult extends Attestation { |
| 42 | + bundlePath: string; |
| 43 | + subjects: Array<Subject>; |
| 44 | +} |
| 45 | + |
| 46 | +export interface VerifySignedArtifactsOpts { |
| 47 | + certificateIdentityRegexp: string; |
| 48 | +} |
| 49 | + |
| 50 | +export interface VerifySignedArtifactsResult { |
| 51 | + bundlePath: string; |
| 52 | + cosignArgs: Array<string>; |
| 53 | +} |
| 54 | + |
| 55 | +export interface SigstoreOpts { |
| 56 | + cosign?: Cosign; |
| 57 | +} |
| 58 | + |
| 59 | +export class Sigstore { |
| 60 | + private readonly cosign: Cosign; |
| 61 | + |
| 62 | + constructor(opts?: SigstoreOpts) { |
| 63 | + this.cosign = opts?.cosign || new Cosign(); |
| 64 | + } |
| 65 | + |
| 66 | + public async signProvenanceBlobs(opts: SignProvenanceBlobsOpts): Promise<Record<string, SignProvenanceBlobsResult>> { |
| 67 | + const result: Record<string, SignProvenanceBlobsResult> = {}; |
| 68 | + try { |
| 69 | + if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) { |
| 70 | + throw new Error('missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.'); |
| 71 | + } |
| 72 | + |
| 73 | + const endpoints = this.signingEndpoints(opts); |
| 74 | + core.info(`Using Sigstore signing endpoint: ${endpoints.fulcioURL}`); |
| 75 | + |
| 76 | + const provenanceBlobs = Sigstore.getProvenanceBlobs(opts); |
| 77 | + for (const p of Object.keys(provenanceBlobs)) { |
| 78 | + await core.group(`Signing ${p}`, async () => { |
| 79 | + const blob = provenanceBlobs[p]; |
| 80 | + const bundlePath = path.join(path.dirname(p), `${opts.name ?? 'provenance'}.sigstore.json`); |
| 81 | + const subjects = Sigstore.getProvenanceSubjects(blob); |
| 82 | + if (subjects.length === 0) { |
| 83 | + core.warning(`No subjects found in provenance ${p}, skip signing.`); |
| 84 | + return; |
| 85 | + } |
| 86 | + const bundle = await signPayload( |
| 87 | + { |
| 88 | + body: blob, |
| 89 | + type: intotoMediatypePayload |
| 90 | + }, |
| 91 | + endpoints |
| 92 | + ); |
| 93 | + const attest = Sigstore.toAttestation(bundle); |
| 94 | + core.info(`Provenance blob signed for:`); |
| 95 | + for (const subject of subjects) { |
| 96 | + const [digestAlg, digestValue] = Object.entries(subject.digest)[0] || []; |
| 97 | + core.info(` - ${subject.name} (${digestAlg}:${digestValue})`); |
| 98 | + } |
| 99 | + if (attest.tlogID) { |
| 100 | + core.info(`Attestation signature uploaded to Rekor transparency log: ${SEARCH_URL}?logIndex=${attest.tlogID}`); |
| 101 | + } |
| 102 | + core.info(`Writing Sigstore bundle to: ${bundlePath}`); |
| 103 | + fs.writeFileSync(bundlePath, JSON.stringify(attest.bundle, null, 2), { |
| 104 | + encoding: 'utf-8' |
| 105 | + }); |
| 106 | + result[p] = { |
| 107 | + ...attest, |
| 108 | + bundlePath: bundlePath, |
| 109 | + subjects: subjects |
| 110 | + }; |
| 111 | + }); |
| 112 | + } |
| 113 | + } catch (err) { |
| 114 | + throw new Error(`Signing BuildKit provenance blobs failed: ${(err as Error).message}`); |
| 115 | + } |
| 116 | + return result; |
| 117 | + } |
| 118 | + |
| 119 | + public async verifySignedArtifacts(opts: VerifySignedArtifactsOpts, signed: Record<string, SignProvenanceBlobsResult>): Promise<Record<string, VerifySignedArtifactsResult>> { |
| 120 | + const result: Record<string, VerifySignedArtifactsResult> = {}; |
| 121 | + if (!(await this.cosign.isAvailable())) { |
| 122 | + throw new Error('Cosign is required to verify signed artifacts'); |
| 123 | + } |
| 124 | + for (const [provenancePath, signedRes] of Object.entries(signed)) { |
| 125 | + const baseDir = path.dirname(provenancePath); |
| 126 | + await core.group(`Verifying ${signedRes.bundlePath}`, async () => { |
| 127 | + for (const subject of signedRes.subjects) { |
| 128 | + const artifactPath = path.join(baseDir, subject.name); |
| 129 | + core.info(`Verifying signed artifact ${artifactPath}`); |
| 130 | + // prettier-ignore |
| 131 | + const cosignArgs = [ |
| 132 | + 'verify-blob-attestation', |
| 133 | + '--new-bundle-format', |
| 134 | + '--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com', |
| 135 | + '--certificate-identity-regexp', opts.certificateIdentityRegexp |
| 136 | + ] |
| 137 | + if (!signedRes.bundle.verificationMaterial || !Array.isArray(signedRes.bundle.verificationMaterial.tlogEntries) || signedRes.bundle.verificationMaterial.tlogEntries.length === 0) { |
| 138 | + // if there is no tlog entry, we skip tlog verification but still verify the signed timestamp |
| 139 | + cosignArgs.push('--use-signed-timestamps', '--insecure-ignore-tlog'); |
| 140 | + } |
| 141 | + const execRes = await Exec.getExecOutput('cosign', [...cosignArgs, '--bundle', signedRes.bundlePath, artifactPath], { |
| 142 | + ignoreReturnCode: true |
| 143 | + }); |
| 144 | + if (execRes.stderr.length > 0 && execRes.exitCode != 0) { |
| 145 | + throw new Error(execRes.stderr); |
| 146 | + } |
| 147 | + result[artifactPath] = { |
| 148 | + bundlePath: signedRes.bundlePath, |
| 149 | + cosignArgs: cosignArgs |
| 150 | + }; |
| 151 | + } |
| 152 | + }); |
| 153 | + } |
| 154 | + return result; |
| 155 | + } |
| 156 | + |
| 157 | + private signingEndpoints(opts: SignProvenanceBlobsOpts): Endpoints { |
| 158 | + const noTransparencyLog = opts.noTransparencyLog ?? GitHub.context.payload.repository?.private; |
| 159 | + core.info(`Upload to transparency log: ${noTransparencyLog ? 'disabled' : 'enabled'}`); |
| 160 | + return { |
| 161 | + fulcioURL: FULCIO_URL, |
| 162 | + rekorURL: noTransparencyLog ? undefined : REKOR_URL, |
| 163 | + tsaServerURL: TSASERVER_URL |
| 164 | + }; |
| 165 | + } |
| 166 | + |
| 167 | + private static getProvenanceBlobs(opts: SignProvenanceBlobsOpts): Record<string, Buffer> { |
| 168 | + // For single platform build |
| 169 | + const singleProvenance = path.join(opts.localExportDir, 'provenance.json'); |
| 170 | + if (fs.existsSync(singleProvenance)) { |
| 171 | + return {[singleProvenance]: fs.readFileSync(singleProvenance)}; |
| 172 | + } |
| 173 | + |
| 174 | + // For multi-platform build |
| 175 | + const dirents = fs.readdirSync(opts.localExportDir, {withFileTypes: true}); |
| 176 | + const platformFolders = dirents.filter(dirent => dirent.isDirectory()); |
| 177 | + if (platformFolders.length > 0 && platformFolders.length === dirents.length && platformFolders.every(platformFolder => fs.existsSync(path.join(opts.localExportDir, platformFolder.name, 'provenance.json')))) { |
| 178 | + const result: Record<string, Buffer> = {}; |
| 179 | + for (const platformFolder of platformFolders) { |
| 180 | + const p = path.join(opts.localExportDir, platformFolder.name, 'provenance.json'); |
| 181 | + result[p] = fs.readFileSync(p); |
| 182 | + } |
| 183 | + return result; |
| 184 | + } |
| 185 | + |
| 186 | + throw new Error(`No valid provenance.json found in ${opts.localExportDir}`); |
| 187 | + } |
| 188 | + |
| 189 | + private static getProvenanceSubjects(body: Buffer): Array<Subject> { |
| 190 | + const statement = JSON.parse(body.toString()) as { |
| 191 | + subject: Array<{name: string; digest: Record<string, string>}>; |
| 192 | + }; |
| 193 | + return statement.subject.map(s => ({ |
| 194 | + name: s.name, |
| 195 | + digest: s.digest |
| 196 | + })); |
| 197 | + } |
| 198 | + |
| 199 | + // https://github.com/actions/toolkit/blob/d3ab50471b4ff1d1274dffb90ef9c5d9949b4886/packages/attest/src/attest.ts#L90 |
| 200 | + private static toAttestation(bundle: Bundle): Attestation { |
| 201 | + let certBytes: Buffer; |
| 202 | + switch (bundle.verificationMaterial.content.$case) { |
| 203 | + case 'x509CertificateChain': |
| 204 | + certBytes = bundle.verificationMaterial.content.x509CertificateChain.certificates[0].rawBytes; |
| 205 | + break; |
| 206 | + case 'certificate': |
| 207 | + certBytes = bundle.verificationMaterial.content.certificate.rawBytes; |
| 208 | + break; |
| 209 | + default: |
| 210 | + throw new Error('Bundle must contain an x509 certificate'); |
| 211 | + } |
| 212 | + |
| 213 | + const signingCert = new X509Certificate(certBytes); |
| 214 | + |
| 215 | + // Collect transparency log ID if available |
| 216 | + const tlogEntries = bundle.verificationMaterial.tlogEntries; |
| 217 | + const tlogID = tlogEntries.length > 0 ? tlogEntries[0].logIndex : undefined; |
| 218 | + |
| 219 | + return { |
| 220 | + bundle: bundleToJSON(bundle), |
| 221 | + certificate: signingCert.toString(), |
| 222 | + tlogID: tlogID |
| 223 | + }; |
| 224 | + } |
| 225 | +} |
0 commit comments