From 8181d81b713eda047f9260d4d34dc70dfa19b80f Mon Sep 17 00:00:00 2001 From: Ronen Slavin Date: Mon, 26 Oct 2026 14:23:23 +0200 Subject: [PATCH 1/3] attest: add a kms input for KMS signing Pass a KMS key URI (HashiCorp Vault transit: vault://) to sign the provenance statement with a key held in Vault. The input is forwarded as --kms, include-timestamp and timestamp-server-url are forwarded with it when include-timestamp is true (as with sign-key), and the signed provenance is uploaded as an artifact. Requires cimon v1.0.28 or later. Co-Authored-By: Claude Opus 5.5 --- attest/README.md | 36 ++++++++++++++++++++++++++++++------ attest/action.yml | 8 ++++++-- attest/index.js | 10 +++++++--- 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/attest/README.md b/attest/README.md index cf59fbe..224cd59 100644 --- a/attest/README.md +++ b/attest/README.md @@ -47,18 +47,41 @@ If your environment cannot submit to the public Sigstore transparency log (e.g. customers in the USA / EU with data-residency requirements), use one of: -- **KMS signing**: `--kms vault://` (HashiCorp Vault transit). - The signature stays in your control. Vault transit is the only KMS - backend implemented today. +- **KMS signing**: `kms: vault://` (HashiCorp Vault transit). + The private key never leaves Vault; cimon sends the digest and receives + the signature. Vault transit is the only KMS backend implemented today, + and it requires cimon v1.0.28 or later. - **Keyed signing with checked-in private key**: `sign-key: private-key.pem`. - **Private Sigstore**: deploy your own Fulcio + Rekor and pass `fulcio-server-url` + `rekor-server-url`. See the inputs section below for the full list. +### KMS signing with HashiCorp Vault transit + +cimon reads the Vault connection from the standard Vault environment +variables (`VAULT_ADDR`, `VAULT_TOKEN`, and optionally `VAULT_NAMESPACE` +and `VAULT_CACERT`). The transit engine must be mounted at `transit/`, and +the key must support signing (for example `ecdsa-p256` or `rsa-3072`): + +```yaml +- uses: cycodelabs/cimon-action/attest@v1 + env: + VAULT_ADDR: https://vault.example.com:8200 + VAULT_TOKEN: ${{ secrets.VAULT_TOKEN }} + with: + subjects: dist\my-app.msi + kms: vault://cimon-signing + fail-on-error: true +``` + +Verifiers need the key's public half, which Vault returns from +`transit/keys/`: +`cimon attest verify --key public-key.pem --signed-prov provenance.intoto.jsonl.sig`. + ### Timestamping key-based signatures -A signature made with `sign-key` carries no time anchor unless you add an +A signature made with `sign-key` or `kms` carries no time anchor unless you add an RFC 3161 timestamp, which lets a verifier show it was made before the key was rotated or revoked. Request one explicitly; `timestamp-server-url` accepts a Sigstore timestamp authority or a standard RFC 3161 service: @@ -89,12 +112,13 @@ releases accept these inputs and produce no timestamp. | `source-path` | Path to the source checkout recorded in the attestation. Unset means automatic discovery (subject paths, run-id directory, workspace). Requires cimon v1.0.24 or later | — | | `skip-source-tree-check` | Skip verifying the source tree matches the recorded commit | `false` | | `sign-key` | Path to a private ECDSA/RSA/ED25519 PEM key | — | +| `kms` | KMS key URI for signing. HashiCorp Vault transit: `vault://`, configured through `VAULT_ADDR` and `VAULT_TOKEN`. Requires cimon v1.0.28 or later | — | | `keyless` | Use keyless (Sigstore) signing | `false` | | `tlog-upload` | **Keyless only.** Upload signature to Rekor transparency log | `true` | -| `include-timestamp` | Request an RFC 3161 timestamp and embed it in the signature. Applies to `keyless` and `sign-key` signing | `false` | +| `include-timestamp` | Request an RFC 3161 timestamp and embed it in the signature. Applies to `keyless`, `sign-key` and `kms` signing | `false` | | `fulcio-server-url` | **Keyless only.** Fulcio server URL | `https://fulcio.sigstore.dev` | | `rekor-server-url` | **Keyless only.** Rekor server URL | `https://rekor.sigstore.dev` | -| `timestamp-server-url` | RFC 3161 timestamp server URL: a Sigstore timestamp authority or a standard RFC 3161 service. With `sign-key`, forwarded only when `include-timestamp: true` | — | +| `timestamp-server-url` | RFC 3161 timestamp server URL: a Sigstore timestamp authority or a standard RFC 3161 service. With `sign-key` or `kms`, forwarded only when `include-timestamp: true` | — | | `allow-submit-data-to-public-sigstore` | **Keyless only.** Required when using public Sigstore | `false` | | `provenance-output` | Path for unsigned provenance | `provenance.intoto.jsonl` | | `signed-provenance-output` | Path for signed provenance | `provenance.intoto.jsonl.sig` | diff --git a/attest/action.yml b/attest/action.yml index 387fe0a..87383f9 100644 --- a/attest/action.yml +++ b/attest/action.yml @@ -43,6 +43,10 @@ inputs: description: Input path to a private ECDSA/RSA/ED25519 key used to sign provenance statement required: false default: '' + kms: + description: 'KMS key used to sign provenance statement, as a provider URI. HashiCorp Vault transit: vault://, with the Vault address and token in the VAULT_ADDR and VAULT_TOKEN environment variables. Requires cimon v1.0.28 or later.' + required: false + default: '' keyless: description: Allow keyless signature if no signing key is provided required: false @@ -52,7 +56,7 @@ inputs: required: false default: 'true' include-timestamp: - description: 'Request an RFC 3161 timestamp of the artifact signature from a timestamping authority. Applies to keyless and sign-key signing.' + description: 'Request an RFC 3161 timestamp of the artifact signature from a timestamping authority. Applies to keyless, sign-key and kms signing.' required: false default: 'false' fulcio-server-url: @@ -64,7 +68,7 @@ inputs: required: false default: 'https://rekor.sigstore.dev' timestamp-server-url: - description: 'RFC 3161 timestamp server URL: a Sigstore timestamp authority or a standard RFC 3161 service. With sign-key, forwarded only when include-timestamp is true.' + description: 'RFC 3161 timestamp server URL: a Sigstore timestamp authority or a standard RFC 3161 service. With sign-key or kms, forwarded only when include-timestamp is true.' required: false allow-submit-data-to-public-sigstore: description: 'Keyless signing only: agree to submit data to an immutable public transparency log (needed for public Sigstore). Ignored unless keyless is true.' diff --git a/attest/index.js b/attest/index.js index a98d0df..ccfc0b2 100644 --- a/attest/index.js +++ b/attest/index.js @@ -131,6 +131,7 @@ function getActionConfig() { subjects: core.getInput('subjects'), imageRef: core.getInput('image-ref'), signKey: core.getInput('sign-key'), + kms: core.getInput('kms'), keyless: core.getBooleanInput('keyless'), tlogUpload: core.getBooleanInput('tlog-upload'), includeTimestamp: core.getBooleanInput('include-timestamp'), @@ -233,6 +234,7 @@ async function run(config) { if (config.attest.signedProvenanceOutput !== '') args.push('--output-signed-prov', config.attest.signedProvenanceOutput); if (config.attest.signKey !== '') args.push('--key', config.attest.signKey); + if (config.attest.kms !== '') args.push('--kms', config.attest.kms); if (config.attest.sourcePath !== '') args.push('--source-path', config.attest.sourcePath); if (config.attest.skipSourceTreeCheck) @@ -244,6 +246,8 @@ async function run(config) { if (config.cimon.logLevel !== '') args.push('--log-level', config.cimon.logLevel); if (config.report.reportJobSummary) args.push('--report-job-summary'); + const keyBasedSigning = + config.attest.signKey !== '' || config.attest.kms !== ''; if (config.attest.keyless) { args.push('--keyless'); @@ -264,8 +268,8 @@ async function run(config) { `--timestamp-server-url=${config.attest.timestampServerUrl}` ); } - } else if (config.attest.signKey !== '' && config.attest.includeTimestamp) { - // Key-based signing only timestamps on an explicit request, so the + } else if (keyBasedSigning && config.attest.includeTimestamp) { + // Key and KMS signing only timestamp on an explicit request, so the // inputs are forwarded only when include-timestamp is true. Older // cimon releases accept and ignore them. args.push('--include-timestamp=true'); @@ -295,7 +299,7 @@ async function run(config) { path.dirname(config.attest.provenanceOutput), { continueOnError: true } ); - if (config.attest.signKey !== '') { + if (keyBasedSigning) { client.uploadArtifact( `signed-provenance-${jobId}-${randomSuffix}`, [config.attest.signedProvenanceOutput], From 14cf67b043cb23fe9cdf478a145fe91642f5d5f1 Mon Sep 17 00:00:00 2001 From: Ronen Slavin Date: Mon, 26 Oct 2026 14:25:18 +0200 Subject: [PATCH 2/3] chore(attest): rebuild dist/index.js Co-Authored-By: Claude Opus 5.5 --- attest/dist/index.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/attest/dist/index.js b/attest/dist/index.js index 0769217..72cba5a 100644 --- a/attest/dist/index.js +++ b/attest/dist/index.js @@ -127691,6 +127691,7 @@ function getActionConfig() { subjects: _actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput('subjects'), imageRef: _actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput('image-ref'), signKey: _actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput('sign-key'), + kms: _actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput('kms'), keyless: _actions_core__WEBPACK_IMPORTED_MODULE_0__.getBooleanInput('keyless'), tlogUpload: _actions_core__WEBPACK_IMPORTED_MODULE_0__.getBooleanInput('tlog-upload'), includeTimestamp: _actions_core__WEBPACK_IMPORTED_MODULE_0__.getBooleanInput('include-timestamp'), @@ -127793,6 +127794,7 @@ async function run(config) { if (config.attest.signedProvenanceOutput !== '') args.push('--output-signed-prov', config.attest.signedProvenanceOutput); if (config.attest.signKey !== '') args.push('--key', config.attest.signKey); + if (config.attest.kms !== '') args.push('--kms', config.attest.kms); if (config.attest.sourcePath !== '') args.push('--source-path', config.attest.sourcePath); if (config.attest.skipSourceTreeCheck) @@ -127804,6 +127806,8 @@ async function run(config) { if (config.cimon.logLevel !== '') args.push('--log-level', config.cimon.logLevel); if (config.report.reportJobSummary) args.push('--report-job-summary'); + const keyBasedSigning = + config.attest.signKey !== '' || config.attest.kms !== ''; if (config.attest.keyless) { args.push('--keyless'); @@ -127824,8 +127828,8 @@ async function run(config) { `--timestamp-server-url=${config.attest.timestampServerUrl}` ); } - } else if (config.attest.signKey !== '' && config.attest.includeTimestamp) { - // Key-based signing only timestamps on an explicit request, so the + } else if (keyBasedSigning && config.attest.includeTimestamp) { + // Key and KMS signing only timestamp on an explicit request, so the // inputs are forwarded only when include-timestamp is true. Older // cimon releases accept and ignore them. args.push('--include-timestamp=true'); @@ -127855,7 +127859,7 @@ async function run(config) { path__WEBPACK_IMPORTED_MODULE_5__.dirname(config.attest.provenanceOutput), { continueOnError: true } ); - if (config.attest.signKey !== '') { + if (keyBasedSigning) { client.uploadArtifact( `signed-provenance-${jobId}-${randomSuffix}`, [config.attest.signedProvenanceOutput], From 9208aa03dc015e409f2035127089aad83e20b901 Mon Sep 17 00:00:00 2001 From: Ronen Slavin Date: Mon, 26 Oct 2026 18:45:44 +0200 Subject: [PATCH 3/3] ci(attest): push the rebuilt dist to the PR branch, leased on the PR head The rebuild-dist job checks out the PR head SHA, a detached HEAD, so the bare `git push` in the auto-commit step failed with "You are not currently on a branch". Push to the PR branch by name instead, leased on the PR head SHA the run built from, so the push lands only while the branch is unchanged. The workflow also runs when its own file changes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build-attest-dist.yaml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-attest-dist.yaml b/.github/workflows/build-attest-dist.yaml index da9b179..ac3a328 100644 --- a/.github/workflows/build-attest-dist.yaml +++ b/.github/workflows/build-attest-dist.yaml @@ -9,6 +9,7 @@ on: - 'attest/index.js' - 'attest/package.json' - 'attest/package-lock.json' + - '.github/workflows/build-attest-dist.yaml' workflow_dispatch: permissions: @@ -50,6 +51,8 @@ jobs: if: github.event_name == 'pull_request' env: PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} BASE_REPO: ${{ github.repository }} run: | # SECURITY: only act if the rebuild produced exactly one @@ -88,4 +91,9 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add attest/dist/index.js git commit -m "chore(attest): rebuild dist/index.js" - git push + # The checkout is the PR head SHA, a detached HEAD, so name the + # PR branch. The lease makes the push land only while the branch + # still points at the SHA this run built from; if it moved in any + # direction or was deleted, the push is rejected. + git push --force-with-lease="refs/heads/$PR_HEAD_REF:$PR_HEAD_SHA" \ + origin "HEAD:refs/heads/$PR_HEAD_REF"