Skip to content

Runbook: rotation and revocation of a signing identity

Read the signing runbook first. This runbook is about what to do when the thing a consumer trusts has to change.

Rotation is a different shape here, and saying so is the point

Section titled “Rotation is a different shape here, and saying so is the point”

The habit a reader arrives with is: a signing key exists, it is held somewhere, it is replaced every N months, and old signatures made under it stay valid because the old public half stays published. Almost none of that applies to a release this project signs in CI.

Production releases are signed keylessly. The signing key is generated inside the release job, used once, and deleted before the step ends; the certificate that vouches for it is issued by Fulcio and expires about ten minutes later. There is nothing durable to rotate. What a consumer pins is not a key but an identity — an issuer plus the workflow ref a certificate must name — and the operations on an identity are change and distrust, not rotate.

The fixture regime is the exception, and it is an exception in the other direction: it is signed with a key derived from a published seed, so that key is not secret either and rotating it protects nobody. It is rotated only if the derivation itself has to change.

So the table of what can actually go wrong is short, and none of the rows is “the key leaked”.

what happenedwhat it is called herewhat to do
the workflow file is renamed or movedidentity change§1
the repository is renamed, or releases move to another repositoryidentity change§1
a workflow other than release.yml gained id-token: writeover-broad identity§2
a release was signed that should not have beendistrust, not revocation§3
the fixture seed or its derivation changeskey rotation, the ordinary kind§4
Sigstore’s own roots rotatenothing — see §5§5

An identity change is a breaking change to every consumer’s trust root. Releases signed under the old identity do not become invalid; they become verifiable only by somebody whose roster still carries the old entry.

  1. Do not delete the old entry. Add the new one beside it, each scoped with applies_to to the releases it governs. An identity is a claim about a range of releases, and the range the old one covered does not stop existing:

    identities:
    - id: openregs-release
    certificate_identity: …/workflows/release.yml@refs/tags/*
    applies_to: ['eu@2026.0*', 'uk@2026.0*'] # what it did sign
    - id: openregs-release-2
    certificate_identity: …/workflows/release-v2.yml@refs/tags/*
    applies_to: ['*@*', '!fixreg@*', '!eu@2026.0*', '!uk@2026.0*']

    The exclusions are the work. Two entries covering one release is an error, by design: the roster refuses to guess which of two anchors a reader meant.

  2. Regenerate the file rather than hand-editing it — config/trust.yaml is generated from openregs.release.signing.shipped_roster() and a test asserts the shipped bytes equal the renderer’s output, so a hand edit fails CI.

  3. Announce it. Every consumer who pinned the old identity has to take the new roster before they can verify a new release, and they will find out either from an announcement or from a failed verification.

  4. Cut one release under the new identity and verify it end to end before deleting anything.

A trust root is only as good as its distribution. Today config/trust.yaml travels in the repository, which means “update your trust root” means “take a newer commit”. No separate distribution channel for release artifacts or trust roots exists yet; until one does, an identity change is a commit and a release note, and nothing else.

The certificate identity names one workflow file in one repository on tag refs only, and each of those three narrows who may sign. The way it silently widens is not an edit to the roster — it is id-token: write appearing on another job or another workflow, because any job that can mint the OIDC token can obtain a certificate for that repository.

  • test_only_the_signing_job_may_mint_an_oidc_token fails the build if any job other than release.yml’s sign declares it, and if any workflow declares it at the top level (which would grant it to every job in the file).
  • If it happened anyway and something was signed: treat it as §3.
  • Tightening after the fact is an identity change (§1). The identity string cannot be narrowed retroactively — old certificates already exist.

3. Something was signed that should not have been

Section titled “3. Something was signed that should not have been”

There is no revocation. Fulcio issues no CRL a verifier consults, the certificate has already expired, and the Rekor entry is append-only and cannot be deleted — that is what a transparency log is for, and wanting to remove an entry is wanting the property the log exists to provide.

What exists instead is distrust, and it is expressed in the same place everything else is: the roster, plus the release index.

  1. Narrow the identity’s applies_to to exclude the bad release. A !-prefixed pattern is exactly this operation:

    applies_to: ['*@*', '!fixreg@*', '!eu@2026.03']

    openregs verify then refuses eu@2026.03 with “has no entry that covers eu@2026.03” rather than passing it, because a release nobody’s anchor covers is refused, never anchored to whatever else is in the file.

  2. Remove the release from index/combinations so no pinned set resolves to it.

  3. Publish a superseding release, and say in the release notes what was wrong. The Rekor entry stays; the public record of a mistake is not a leak.

  4. If the compromise is of the identity rather than of one release — someone could sign arbitrarily as the release workflow — then every release signed under it after the compromise is suspect, and the answer is §1 with the old entry’s applies_to cut off at the last known-good release.

Only relevant if the seed or the derivation in openregs.release.signing changes. It is an ordinary keyed rotation, and it is the one this repository can rehearse end to end — see the drill below.

  1. Change the seed or the derivation; fixture_roster() follows automatically.
  2. Regenerate config/trust.yaml from shipped_roster().
  3. Rebuild and re-sign the standing releases and fixtures/registry per ../../fixtures/registry/README.md.
  4. make test && make conformance && make e2e.

Fulcio’s CA and Rekor’s log keys rotate on Sigstore’s schedule, distributed through the Sigstore TUF repository. The identity entry in config/trust.yaml pins them — ca_certificates and log_public_key — and that is what lets openregs verify walk a keyless bundle with no network.

What is pinned today, and the fingerprints to compare against:

whatsha256valid until
CN=sigstore-intermediate,O=sigstore.dev (DER cert)15d795348226b4649f750f5802592c393bee7cc53c3b86982175b7ad087efe472031-10-05
CN=sigstore,O=sigstore.dev, self-signed root (DER cert)3ba7b6cc4e95469d4d334b49cb257ad8537076fa84b0ca87ff4ecfe6a54680c12031-10-05
rekor.sigstore.dev log key (DER SubjectPublicKeyInfo)c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d—

The intermediate is pinned as well as the root, and that is load-bearing rather than tidy: a Sigstore bundle at media type v0.3 — what current cosign writes — carries the leaf certificate and nothing else, so a roster holding only the root cannot build a path at all.

The Rekor line is checkable without going anywhere: it is also the log id Rekor publishes, wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=, which every bundle carries in tlogEntries[].logId. Two derivations of the same 32 bytes.

Pinning is a trade, and both halves are real. Pinned, verification is offline and a rotation upstream is a pull request here — a stale pin is a verification outage, and somebody has to notice. Unpinned, cosign resolves the roots from the TUF repository and a verifier’s Sigstore trust root comes from the network the first time.

Nothing here tells you on its own, which is the cost of pinning. The three signals, soonest first:

  • A keyless release stops verifying, and the trust check says the chain “stops at” a certificate nothing issued, or the checkpoint “is not signed by log key c0d23d6a…”. That is the pin being stale, not the release being bad — check the Sigstore TUF repository before suspecting the release.
  • Sigstore announces it, on https://blog.sigstore.dev and in sigstore/root-signing. This is the signal you want to be watching; the others are the ones that find you.
  • The certificates expire 2031-10-05, both of them, and after that date no chain validates however correct it is. There is no code that warns about this. openregs verify prints the chain length, not the expiry.

There is a fourth thing already true and it is not a rotation: Sigstore now runs a second, tile-backed log, log2025-1.rekor.sigstore.dev, whose key is Ed25519. This verifier reads neither Ed25519 nor the tile format, so that key is deliberately not pinned — pinning it would claim a check the code cannot make. What that means practically: the release pipeline must keep writing to Rekor v1. If cosign’s default moves to v2, openregs verify will fail on a release that is perfectly well signed, and the fix is a change to the verifier, not to this table.

  1. Read the new material out of the Sigstore TUF repository, not off the hosts that serve it — a root taken on the word of its own server is not a root. Either cosign initialize and read ~/.sigstore/root/targets/, or a TUF client walking https://tuf-repo-cdn.sigstore.dev from its bootstrap root to the trusted_root.json target. Record the metadata versions and expiry you walked through; they go in the pull request.
  2. Cross-check against https://fulcio.sigstore.dev/api/v2/trustBundle and https://rekor.sigstore.dev/api/v1/log/publicKey. Two channels agreeing is not proof, but two channels disagreeing is the end of the procedure.
  3. Update SIGSTORE_FULCIO_CA_CERTIFICATES, SIGSTORE_FULCIO_CA_FINGERPRINTS, SIGSTORE_REKOR_LOG_PUBLIC_KEY and SIGSTORE_REKOR_LOG_KEY_ID in openregs.release.signing. The roster is generated, not hand-edited (§1 step 2), and the fingerprints are checked against the PEMs at build time — an edit to one half that misses the other is an error, by design.
  4. Regenerate the roster, then openregs validate trust config/trust.yaml && make test.
  5. Put the fingerprints in the pull request body and say that merging is the act of trusting them. A reviewer who cannot compare a fingerprint against Sigstore’s published value has not reviewed anything.
  6. Merge. Releases signed under the old root stop verifying at that moment, so keep the retiring certificates in the bundle alongside the new ones — ca_certificates is a PEM bundle and may hold several — until nothing standing was signed under them.

None of this touches the fixture regime, which keeps the keyed posture precisely so it verifies with no network and no roster maintenance at all, forever.


Run 2026-08-02, on the branch that introduced keyless signing. Nothing was committed by it and no release in the repository was touched: every drill worked on copies in a temporary directory.

A full keyed rotation, cutting both ways — §4’s shape, executed:

  1. openregs release keygen produced a fresh signer (4aae86ba23f440c7);

  2. a roster naming it instead of the fixture key was written, scoped applies_to: ['fixreg@*'];

  3. a copy of fixreg@2025.04 was re-signed under the new key;

  4. it verified under the new roster — all five checks, offline:

    trust anchored signing key openregs-rotated-drill (4aae86ba23f440c7) … are in trust-new.yaml
    verify: OK — fixreg@2025.04 verified entirely offline
  5. and the same bytes were refused by the shipped roster — which is the half of a rotation drill that is usually skipped:

    trust FAILED signing key 4aae86ba23f440c7 is not in config/trust.yaml for fixreg@2025.04
    verify: FAIL — fixreg@2025.04: 1 problem(s); this release must not be unpacked or served
  6. and symmetrically, the committed registry release was refused by the new roster (signing key 24f0b959505940ce is not in trust-new.yaml). Both refusals exit non-zero, and in both the other four checks still pass and say so — a rotation makes a release un-anchored, not corrupt, and the report says which.

An identity change — §1’s mechanics, executed: a roster whose certificate_identity names release-v2.yml instead of release.yml was written, and the pipeline’s reader picked it up with no code change:

$ tooling/ci/signing_posture.py eu@2026.01 identity-regexp --trust trust-identity.yaml
^https://github\.com/openregs/openregs/\.github/workflows/release\-v2\.yml@refs/tags/[^/]*$

and fixreg@2025.04 still verified OK under that roster — an identity change does not disturb the keyed posture.

Distrust by exclusion — §3 step 1, executed as a test rather than by hand: widening the identity to ['*@*'] so it also covers the fixture releases is refused (“covers fixreg@2025.04 twice”), an identity with no applies_to at all is refused, and a release no entry covers is refused with “has no entry that covers eu@2026.01”. These live in tooling/tests/test_release_signing.py, so they are rehearsed on every run rather than once.

The pipeline’s refusals — the keyless branch of release.yml was executed twice offline, once in a fork context and once in the canonical organisation’s with no OIDC token, and it failed both times naming what was missing without reaching cosign (tooling/tests/test_ci_workflows.py).

  • A real keyless signature. It needs an OIDC token GitHub only mints inside a workflow run, a certificate from Fulcio and an entry in public Rekor — all network, all forbidden to this test suite, and none of it reproducible on a laptop. The first real exercise is the first release of a real regime.
  • Everything downstream of that: cosign verify-blob against a genuine bundle, the publish stage’s identity check, and the shape of a real keyless.sigstore.json.
  • openregs verify accepting a release signed against public Sigstore. It walks a bundle — chain, identity, issuer, signed entry timestamp and inclusion proof, all offline — against a Fulcio-shaped CA and Rekor-shaped log derived from published seeds (tooling/tests/sigstore_ca.py), and, since the roster gained public Sigstore’s material, against a real public-Sigstore signature committed under tooling/tests/sigstore_public/. That last one establishes the pinned bytes are the right bytes. What it also establishes is the gap: a genuine Rekor checkpoint fails its signature check here, because translog.note_body puts the note’s trailing blank line inside the signed bytes and C2SP’s note format — which Rekor implements — does not. So a keyless release signed against public Sigstore still fails, on that and nothing else. Closing it means changing note_body and re-signing every standing release, which is its own change.
  • A real keyless release end to end, for the reason above and because the identity that would sign one has never signed anything.
  • Propagating a changed trust root to consumers. There are none yet, and no distribution channel for one exists.

Rendered from openregs/openregs@f3a2d10:docs/runbooks/key-rotation.md