Skip to content

Runbook: signing a release, and verifying one offline

A built release is a directory of bytes and a manifest of digests. The manifest proves the bytes have not rotted. It proves nothing about who produced them, because whoever changed the bytes could rewrite the manifest in the same edit. Signing is what closes that gap, and openregs verify --release is what a consumer runs before relying on anything.

openregs release keygen --out <dir>
openregs release sign <release-dir> --key <cosign.key> --run-id <ci-run-id>
openregs verify --release <regime>@<tag>

Everything lands in one directory, attestation/, beside the artifacts:

regimes/fixreg/releases/2025.04/
corpus.sqlite … release-meta.yaml what the builder wrote
attestation/
cosign.pub the public half of the signing key
log.pub the public half of the transparency log's key
provenance.json the in-toto / SLSA statement
provenance.json.sig its signature
sigs/<path>.sig one per file the builder wrote
signature-manifest.yaml what was covered, by whom, and where logged
transparency-log.json the entries and their inclusion proofs
checkpoint.txt the signed head those proofs are against

attestation/ is not committed, and .gitignore refuses it. A release is a function of a commit and rebuilds byte for byte; a signature is not — ECDSA draws a fresh nonce every time — so a committed attestation would make the shipped release differ from a rebuild of it. Signing happens in the pipeline, over the artifacts the build stage handed on.

Signing verifies before it signs. Every digest in release-meta.yaml is recomputed first, and a release that fails is refused. A valid signature over bytes that already disagree with their own manifest is worse than no signature.

Key-based cosign sign-blob produces an ECDSA-P-256 signature over the SHA-256 of the blob, DER-encoded then base64-encoded into a .sig, with the public half a PEM SubjectPublicKeyInfo. That is exactly what attestation/sigs/ holds, so

Terminal window
cosign verify-blob --key attestation/cosign.pub \
--signature attestation/sigs/corpus.sqlite.sig corpus.sqlite

verifies a release signed here, and a release signed by cosign verifies here. --signer picks which produces it: cosign demands the binary and fails if it is absent, local uses the in-process signer, auto (the default) prefers cosign when it is one this project can drive. The artifact is identical either way; signature-manifest.yaml records which ran, and nothing downstream reads it.

cosign 2 only, for signing. sign-blob there writes a detached signature to --output-signature and can be told --tlog-upload=false. cosign 3 deprecates the first in favour of a bundle and rejects the second outright, because it drives signing through a config that always names a transparency log. Both are reasonable for cosign; neither is this artifact, whose shape — attestation/sigs/<path>.sig, a bare base64 DER signature, inclusion proved against a log bundled beside it — is what openregs verify and every document here describe. Moving to a bundle changes what every consumer verifies, so it is a decision somebody takes, not a version bump.

openregs.release.signing.COSIGN_SIGN_BLOB_MAJORS is that list. auto asks the installed cosign what version it is rather than merely whether it exists, and signs in process when the answer is a major it cannot drive. --signer cosign is a demand, so it refuses instead, naming the version it found.

This is not hypothetical. The first release tag this repository ever pushed failed in the sign stage for exactly this reason: the runner installs the newest cosign, auto chose it for being present, and cosign rejected the flags. Nothing local caught it because cosign is installed on none of the machines the suite runs on, so auto always chose the in-process signer there. The tests now put stub cosigns on PATH that report a version, which is all the choice turns on — see test_release_signing.py, “which cosign”.

The keyless step wants the opposite: OIDC, Fulcio and Rekor are what recent cosign is for. So the pipeline installs the newest cosign, uses it for the identity binding, and lets auto fall back for the keyed signature.

Verification never shells out. Signing may delegate to a binary; checking a standard ECDSA signature against a standard PEM key does not need one, and openregs verify has to work on a machine with no cosign, no network and no clock it trusts.

attestation/provenance.json is an in-toto v1 Statement with a slsa.dev/provenance/v1 predicate. It links four things, and verify walks all four:

tag → git commit (and the regime subtree sha) → CI run id → the snapshot
sha256 set in SOURCES.lock → the regulator payloads themselves

The whole SOURCES.lock ledger travels inside the document — uri, sha256, adapter, capture instant, plus the digest of the lock file itself. A consumer who pulled a bundle has no checkout, so a provenance document that merely referred to SOURCES.lock would be unverifiable exactly where verification matters.

The completeness rule is checked from the release alone: each unit in corpus.sqlite records the source_hash of the payload it was normalized from, so the verifier collects every hash the corpus cites and requires the provenance to attest all of them. Delete a snapshot from the document and that check fails, naming the digest.

The lock is read with git show <commit>:regimes/<regime>/SOURCES.lock, never from the working tree, so provenance describes the tree the artifacts were compiled from. --run-id names the CI run; without it (and without $GITHUB_RUN_ID) signing refuses, because a chain with an unnamed run is broken in the middle.

Rekor-compatible, locally implemented — the spec allows a local log for self-hosted deployments. What it is compatible with is the format:

  • the tree is RFC 6962’s: leaf = sha256(0x00 ‖ entry), node = sha256(0x01 ‖ left ‖ right), an odd node promoted unchanged;
  • an entry body is hashedrekord/0.0.1 — the blob’s sha256, the signature and the public key, so an entry is checkable without the blob;
  • an entry record carries logIndex, logID, integratedTime and a verification.inclusionProof, exactly Rekor’s LogEntry;
  • the checkpoint is a C2SP signed note: origin, tree size, base64 root hash, a blank line, then a signature line beginning — .

The log lives in state/translog/ by default (--log <dir> moves it) and is runtime state, not an artifact. Opening it recomputes the root over every entry on disk and compares it with the checkpoint it last wrote, so an entry somebody edited or deleted is caught there, before anything is appended on top of it.

integratedTime is the commit’s committer date, never the clock.

The bundled checkpoint is what makes verification offline. Each entry’s proof is against a specific root at a specific tree size; the checkpoint states that root and is signed by the log’s key. A verifier holding the release and the log’s public key needs nothing else.

openregs verify --release fixreg@2025.04 [--path <dir>] [--key <pub>] [--trust <file>]

Five checks, all of which run — the report is their union, because a tampered release usually breaks several at once and a verifier that stops at the first one tells its operator less than it knows:

checkwhat passing means
signatureevery artifact’s ECDSA-P-256 signature checks out under the key the release names
trustunder the keyed posture: that key, and the log’s key, are ones the anchor accepts. Under keyless: the bundled certificate chains to a root the anchor names, carries the identity and issuer it pins, and signs this release’s signature manifest — which is what names the key the other checks used
digestsevery artifact hashes to what release-meta.yaml and the signature manifest declare
provenancetag → commit → CI run → snapshots, with every hash the corpus cites attested
log inclusionevery audit path rebuilds the bundled checkpoint’s root, and that checkpoint is signed by the log’s key

A single problem is a non-zero exit and a verify: FAIL line saying the release must not be unpacked or served.

A release does not get to nominate the key it is trusted under. It carries attestation/cosign.pub, and that file is useful and proves nothing: whoever re-signed a tampered release replaced it in the same edit. The anchor comes from somewhere else:

  1. --key <cosign.pub> (repeatable), and --log-key <log.pub>;
  2. otherwise config/trust.yaml — reviewed, committed configuration beside sources.yaml and embeddings.yaml, validated by openregs validate trust config/trust.yaml;
  3. otherwise an error. A verifier with no anchor can check that a release is internally consistent and nothing more, and reporting that as success is exactly the silent pass this command exists to prevent.

Each roster entry states a key_id — sha256 over the key’s DER SubjectPublicKeyInfo, hex — and the PEM it hashes. The two are checked against each other on load, so an entry whose halves disagree is an error rather than a silent preference for one of them.

Under the keyless posture the anchor is not a key but the identity entry’s ca_certificates and log_public_key — the same rule wearing different clothes, since the certificate and the log entry both travel inside the release and the root they are measured against must not.

Production releases are signed keylessly: GitHub OIDC, Fulcio, Rekor. There is no organisation key in a secret manager and none on anybody’s laptop, so there is none to steal, to rotate on a schedule, or to lose with a departing maintainer. What signs a release is a workflow, and what a consumer pins is that workflow’s identity.

Two postures therefore live in one roster, and applies_to says which governs which release. Exactly one entry must cover a release: two is an ambiguity in reviewed configuration and none is a release nobody vouches for, and both are errors rather than a quiet choice between them.

keyed (signers / logs)keyless (identities)
coversfixreg@**@* except !fixreg@*
anchora public key in config/trust.yamlan OIDC issuer plus a certificate identity
what signsthe key derived from a published seeda ten-minute Fulcio certificate for the workflow
held secretnone — the seed is published, so the key vouches for nothingnone — the key is generated in the job and deleted in it
checkable offlineyes, with nothing installedyes, with nothing installed, once the roster carries the CA root and log key — see below

Why the fixture regime keeps the keyed posture. regimes/fixreg is a fixture, not data. Its releases exist so that a fresh checkout — anybody’s, offline, forever — can run the whole verification chain end to end. A keyless signature over them would take that away and give nothing back: the fixture key is published and vouches for nothing anyway, which is the honest thing for a fixture to say about itself.

issuer https://token.actions.githubusercontent.com
certificate identity https://github.com/openregs/openregs/.github/workflows/release.yml@refs/tags/*
CA https://fulcio.sigstore.dev
transparency log https://rekor.sigstore.dev

The certificate identity is the job workflow ref, which is what Fulcio puts in the certificate’s SAN for a GitHub Actions signer. It is not the OIDC sub claim (repo:openregs/openregs:ref:refs/tags/…), which is spelled differently and would not match.

Every component of it is a constraint, and the narrowness is the entire value:

  • openregs/openregs — this repository, not the organisation. A certificate for openregs/website does not match.
  • .github/workflows/release.yml — this workflow file. A pull request that adds attacker.yml cannot sign; changing release.yml means changing a file on a protected branch through a reviewed pull request.
  • @refs/tags/ — a tag push only. Every merge to main is signed @refs/heads/main and does not match, and the repository’s tag ruleset forbids moving or deleting a release tag, so the tag a certificate names cannot later be pointed at other bytes.

The one wildcard is the tag, because narrowing it to a literal would mean a reviewed edit to every consumer’s trust root for every release — an anchor that moves monthly is not an anchor. In the regexp handed to cosign, * becomes [^/]* rather than .*: it stands for a tag, and tags carry no slashes.

tooling/ci/signing_posture.py is the single reader of all this. The pipeline asks it which posture a tag falls under and, when keyless, for the issuer and the anchored identity regexp — so the identity is written once, in the roster, and the pipeline follows an edit to it rather than needing the same edit twice.

Terminal window
tooling/ci/signing_posture.py fixreg@2025.04 # -> keyed
tooling/ci/signing_posture.py eu@2026.01 # -> keyless
tooling/ci/signing_posture.py eu@2026.01 identity-regexp # -> ^https://github\.com/…$

The release format is unchanged: the same per-blob ECDSA signatures, the same local transparency log, the same provenance document. What changes is where the signing key comes from and what vouches for it.

  1. the job generates a P-256 key pair under $RUNNER_TEMP, signs the release with it — blobs and log checkpoints both, so the release has one key — and deletes the private half before the step ends;
  2. cosign sign-blob --yes --bundle attestation/keyless.sigstore.json signs attestation/signature-manifest.yaml under the workflow’s Fulcio certificate. The manifest names the signer’s key id and every artifact’s digest, so one keyless signature covers the whole release;
  3. the bundle — certificate, signature, Rekor entry and inclusion proof — ships inside attestation/.

So the chain a consumer walks is:

workflow identity → Fulcio certificate → signature over signature-manifest.yaml
→ the signer key id the manifest names → every artifact's own signature

openregs verify walks the chain itself. openregs/release/sigstore.py is that walk, and it opens no socket. Five things are checked and all five must hold:

what passing means
chainevery certificate from the leaf to a root in config/trust.yaml is signed by the next, in date, and permitted to issue what it issued (basicConstraints, keyUsage, path length)
identitythe leaf’s subject alternative name matches the anchored pattern the roster pins, and Fulcio’s OIDC-issuer extension (1.3.6.1.4.1.57264.1.8, or the older 1.1) is the issuer it pins
signaturethe bundle’s signature over signature-manifest.yaml verifies under the leaf’s public key, and the digest the bundle states is that file’s digest
timestampRekor’s signed entry timestamp is a signature by the log’s key over this entry — which is what makes integratedTime a fact rather than a claim
inclusionthe audit path rebuilds the root of the checkpoint bundled with the entry, that checkpoint is signed by the log’s key, and the entry’s body names the same digest, the same signature and the same certificate as the bundle

So the chain a consumer walks closes:

identity → Fulcio certificate → signature over signature-manifest.yaml
→ the signer key id the manifest names → attestation/cosign.pub
→ every artifact's own signature

The certificate’s validity is checked against Rekor’s clock, not the machine’s. A Fulcio certificate lives ten minutes. Checked against now, every release ever published would fail; checked against a time the bundle merely asserted, an expired certificate could be revived by editing a number. It is checked against integratedTime, which the log signed — which is why the signed entry timestamp is not optional.

The release’s own log key is anchored the same way. A keyless release signs its transparency-log checkpoints with the same throwaway key, so no roster holds that either. What vouches for it is the certificate: the manifest names the log’s key_id, and the certificate vouches for the manifest, so attestation/log.pub is bound to the workflow identity exactly as the signing key is. A deployment running a named log puts its key in logs: and that entry wins.

Never, in any of this, does the verifier fall back to the key the release names. That key is generated per run and is in no roster, so “is it in the roster” is the wrong question, and answering it with the release’s own cosign.pub is precisely the mistake the trust check exists to refuse.

Walking a chain offline needs two pieces of material that cannot come from the release and must not come from a fetch — a trust root learned at verification time is a trust root an attacker can supply. They live on the identity entry:

identities:
- id: openregs-release
issuer: https://token.actions.githubusercontent.com
certificate_identity: https://github.com/openregs/openregs/.github/workflows/release.yml@refs/tags/*
ca_certificates: |
-----BEGIN CERTIFICATE-----
... # Fulcio's intermediate
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
... # Fulcio's root
-----END CERTIFICATE-----
log_public_key: |
-----BEGIN PUBLIC KEY-----
... # Rekor's log key
-----END PUBLIC KEY-----

Both or neither: a chain validated against a root whose log entry nobody could check is a signature nobody witnessed, and the loader refuses half of it by name.

The shipped roster states both, and they are public Sigstore’s own — read out of the Sigstore TUF repository and reviewed into the roster by a human, which is what obtaining a trust root has to be. The fingerprints and the rotation procedure are in key-rotation.md §5.

Two things about that entry are worth knowing before you copy it:

  • The intermediate is pinned as well as the root. A v0.3 Sigstore bundle — what current cosign writes — carries the leaf certificate and nothing else, so the intermediate is not inside the release to be borrowed. Pin the root alone and every keyless release fails on a chain that is in fact intact.
  • curl https://fulcio.sigstore.dev/api/v1/rootCert is not enough, for the same reason: it returns the root. Take the whole chain, out of the TUF repository, and cross-check it against /api/v2/trustBundle.

Where a deployment’s roster states neither, a release signed against public Sigstore fails its trust check naming both fields and printing the cosign verify-blob that answers the question meanwhile:

Terminal window
cosign verify-blob \
--bundle attestation/keyless.sigstore.json \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github\.com/openregs/openregs/\.github/workflows/release\.yml@refs/tags/[^/]*$' \
attestation/signature-manifest.yaml

⚠️ One thing between here and a real keyless release verifying. translog.note_body puts a note’s trailing blank line inside the bytes a checkpoint signature covers; C2SP’s note format, which Rekor implements, signs the note text alone. A genuine Rekor checkpoint therefore fails its signature check by one byte, and a release signed against public Sigstore fails its trust check on that and on nothing else — chain, identity, issuer, signed entry timestamp and inclusion proof all check out against the pinned material. test_a_real_rekor_checkpoint_is_the_one_thing_this_reader_still_refuses holds this open. Fixing it means changing note_body and re-signing every standing release, because this repository’s own log signs the same way.

tooling/tests/sigstore_ca.py is how the walk is tested without a network: a Fulcio-shaped CA and a Rekor-shaped log derived from four published seeds, issuing certificates dated from fixed instants. Nine rejections are asserted against it — wrong issuer, wrong identity, an identity matching a prefix but not the anchored pattern, a chain to an untrusted root, a certificate outside its window, a tampered digest, an invalid signed entry timestamp, an inclusion proof that does not reconcile, and a bundle for another release swapped in.

tooling/tests/sigstore_public/ is how the material is tested: cosign’s own release blob and the bundle public Fulcio and public Rekor gave it, committed so the check can be made offline. A CA this repository made up can only show the walk is right; somebody else’s real signature is what shows the pins are.

.gitignore says it plainly: the private key is never committed, cosign.pub is. So the key this repository’s own tests and standing releases use is derived from a published seed rather than stored:

d = sha256(seed) mod (n − 1) + 1, key = P-256 private key with scalar d

with n the P-256 group order. Two seeds are published in openregs.release.signing: openregs/fixture/release-signing-key/1 and openregs/fixture/transparency-log-key/1. It is the same idea as fixture-hash/1.0.0, the weightless embedder — reproducible everywhere, offline, claiming nothing.

A fixture key is not a secret and vouches for nothing: anybody can sign with it. config/trust.yaml marks it fixture: true, and openregs release sign --fixture-key says so on stderr. A deployment runs openregs release keygen, keeps the private half in a secret, and replaces the roster entry with its own.

.github/workflows/release.yml’s sign stage works on the artifacts the build stage uploaded — you sign the bytes you shipped, not a rebuild of them — and asks config/trust.yaml which posture the tag falls under rather than deciding for itself. Verification is part of the same step, so a signature that does not check out fails the job and nothing downstream of it runs.

keyed, which is what fixreg@* gets:

Terminal window
openregs release sign dist --fixture-key --signer auto --run-id "$GITHUB_RUN_ID"
openregs verify --release "$TAG" --path dist

keyless, which is what every real regime gets:

Terminal window
openregs release keygen --out "$RUNNER_TEMP/openregs-signing"
openregs release sign dist --key …/cosign.key --log-key …/cosign.key \
--signer auto --run-id "$GITHUB_RUN_ID"
cosign sign-blob --yes --bundle dist/attestation/keyless.sigstore.json \
dist/attestation/signature-manifest.yaml
shred -u …/cosign.key
openregs verify --release "$TAG" --path dist --key …/cosign.pub --log-key …/cosign.pub
cosign verify-blob --bundle … --certificate-oidc-issuer … --certificate-identity-regexp …

permissions: id-token: write is on the sign job and on no other job in this repository. That permission is the private key: any job holding it can mint the OIDC token and sign as this repository’s release pipeline. build, eval, publish and attach do not have it, and a test asserts that they do not.

The keyless branch refuses rather than improvises. Outside the canonical organisation there is no such identity to have, and with no OIDC token there is nothing to present; both are ordinary job failures naming what was missing, never a quiet downgrade to a key that would anchor the release to nobody. Both refusals are executed by the offline test suite, which is how a branch that would otherwise call Fulcio can be tested at all.

publish re-verifies before it uploads, and under the keyless posture it asks the identity question first — cosign verify-blob against the roster’s issuer and identity, over this release’s signature manifest — and only then anchors the five offline checks with --key attestation/cosign.pub. The order matters: a manifest cosign has vouched for is a manifest whose signer.key_id can be trusted, and passing that key without the cosign step first would be the “the release nominates its own anchor” mistake wearing a different hat.

  • release-tag.md — pushing the tag that sets all of this off, which commit it points at, and why that is not the commit the release records.
  • key-rotation.md — what rotation and revocation mean when there is no key, and what to do when the identity itself has to change.
  • release-build.md — what signing is signing.
  • ../sqlite-queries.md — the bundle’s schema, including the source_hash in each unit’s meta that the completeness check reads.
  • CONTRIBUTING.md §8 — where this sits in the lifecycle.

Rendered from openregs/openregs@f3a2d10:docs/runbooks/release-sign.md