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 happened | what it is called here | what to do |
|---|---|---|
| the workflow file is renamed or moved | identity change | §1 |
| the repository is renamed, or releases move to another repository | identity change | §1 |
a workflow other than release.yml gained id-token: write | over-broad identity | §2 |
| a release was signed that should not have been | distrust, not revocation | §3 |
| the fixture seed or its derivation changes | key rotation, the ordinary kind | §4 |
| Sigstore’s own roots rotate | nothing — see §5 | §5 |
1. The identity changes
Section titled “1. The identity changes”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.
-
Do not delete the old entry. Add the new one beside it, each scoped with
applies_toto 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-releasecertificate_identity: …/workflows/release.yml@refs/tags/*applies_to: ['eu@2026.0*', 'uk@2026.0*'] # what it did sign- id: openregs-release-2certificate_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.
-
Regenerate the file rather than hand-editing it —
config/trust.yamlis generated fromopenregs.release.signing.shipped_roster()and a test asserts the shipped bytes equal the renderer’s output, so a hand edit fails CI. -
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.
-
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.
2. The identity turns out to be too broad
Section titled “2. The identity turns out to be too broad”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_tokenfails the build if any job other thanrelease.yml’ssigndeclares 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.
-
Narrow the identity’s
applies_toto exclude the bad release. A!-prefixed pattern is exactly this operation:applies_to: ['*@*', '!fixreg@*', '!eu@2026.03']openregs verifythen refuseseu@2026.03with “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. -
Remove the release from
index/combinationsso no pinned set resolves to it. -
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.
-
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_tocut off at the last known-good release.
4. Rotating the fixture key
Section titled “4. Rotating the fixture key”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.
- Change the seed or the derivation;
fixture_roster()follows automatically. - Regenerate
config/trust.yamlfromshipped_roster(). - Rebuild and re-sign the standing releases and
fixtures/registryper../../fixtures/registry/README.md. make test && make conformance && make e2e.
5. Sigstore’s own roots
Section titled “5. Sigstore’s own roots”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:
| what | sha256 | valid until |
|---|---|---|
CN=sigstore-intermediate,O=sigstore.dev (DER cert) | 15d795348226b4649f750f5802592c393bee7cc53c3b86982175b7ad087efe47 | 2031-10-05 |
CN=sigstore,O=sigstore.dev, self-signed root (DER cert) | 3ba7b6cc4e95469d4d334b49cb257ad8537076fa84b0ca87ff4ecfe6a54680c1 | 2031-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.
How you find out
Section titled “How you find out”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 verifyprints 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.
Rotating the pin
Section titled “Rotating the pin”- 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 initializeand read~/.sigstore/root/targets/, or a TUF client walkinghttps://tuf-repo-cdn.sigstore.devfrom its bootstrap root to thetrusted_root.jsontarget. Record the metadata versions and expiry you walked through; they go in the pull request. - Cross-check against
https://fulcio.sigstore.dev/api/v2/trustBundleandhttps://rekor.sigstore.dev/api/v1/log/publicKey. Two channels agreeing is not proof, but two channels disagreeing is the end of the procedure. - Update
SIGSTORE_FULCIO_CA_CERTIFICATES,SIGSTORE_FULCIO_CA_FINGERPRINTS,SIGSTORE_REKOR_LOG_PUBLIC_KEYandSIGSTORE_REKOR_LOG_KEY_IDinopenregs.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. - Regenerate the roster, then
openregs validate trust config/trust.yaml && make test. - 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.
- 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_certificatesis 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.
The dry run
Section titled “The dry run”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.
What was rehearsed, and what happened
Section titled “What was rehearsed, and what happened”A full keyed rotation, cutting both ways — §4’s shape, executed:
-
openregs release keygenproduced a fresh signer (4aae86ba23f440c7); -
a roster naming it instead of the fixture key was written, scoped
applies_to: ['fixreg@*']; -
a copy of
fixreg@2025.04was re-signed under the new key; -
it verified under the new roster — all five checks, offline:
trust anchored signing key openregs-rotated-drill (4aae86ba23f440c7) … are in trust-new.yamlverify: OK — fixreg@2025.04 verified entirely offline -
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.04verify: FAIL — fixreg@2025.04: 1 problem(s); this release must not be unpacked or served -
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).
What could not be dry-run, and why
Section titled “What could not be dry-run, and why”- 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-blobagainst a genuine bundle, the publish stage’s identity check, and the shape of a realkeyless.sigstore.json. openregs verifyaccepting 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 undertooling/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, becausetranslog.note_bodyputs 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 changingnote_bodyand 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.
Related
Section titled “Related”release-sign.md— the identity, the two postures, and what the pipeline runs.../../config/trust.yaml— the roster itself.../../fixtures/registry/README.md— how the standing releases are rebuilt and re-signed.disaster-recovery.md— restoring state, as distinct from re-establishing trust.
Rendered from openregs/openregs@f3a2d10:docs/runbooks/key-rotation.md