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>What signing writes
Section titled “What signing writes”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 againstattestation/ 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.
The signature format is cosign’s
Section titled “The signature format is cosign’s”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
cosign verify-blob --key attestation/cosign.pub \ --signature attestation/sigs/corpus.sqlite.sig corpus.sqliteverifies 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.
Which cosign
Section titled “Which cosign”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.
The provenance chain
Section titled “The provenance chain”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 snapshotsha256 set in SOURCES.lock → the regulator payloads themselvesThe 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.
The transparency log
Section titled “The transparency log”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,integratedTimeand averification.inclusionProof, exactly Rekor’sLogEntry; - 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.
Verifying
Section titled “Verifying”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:
| check | what passing means |
|---|---|
signature | every artifact’s ECDSA-P-256 signature checks out under the key the release names |
trust | under 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 |
digests | every artifact hashes to what release-meta.yaml and the signature manifest declare |
provenance | tag → commit → CI run → snapshots, with every hash the corpus cites attested |
log inclusion | every 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.
The trust anchor
Section titled “The trust anchor”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:
--key <cosign.pub>(repeatable), and--log-key <log.pub>;- otherwise
config/trust.yaml— reviewed, committed configuration besidesources.yamlandembeddings.yaml, validated byopenregs validate trust config/trust.yaml; - 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.
The signing identity
Section titled “The signing identity”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) | |
|---|---|---|
| covers | fixreg@* | *@* except !fixreg@* |
| anchor | a public key in config/trust.yaml | an OIDC issuer plus a certificate identity |
| what signs | the key derived from a published seed | a ten-minute Fulcio certificate for the workflow |
| held secret | none — the seed is published, so the key vouches for nothing | none — the key is generated in the job and deleted in it |
| checkable offline | yes, with nothing installed | yes, 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.
The identity, exactly
Section titled “The identity, exactly”issuer https://token.actions.githubusercontent.comcertificate identity https://github.com/openregs/openregs/.github/workflows/release.yml@refs/tags/*CA https://fulcio.sigstore.devtransparency log https://rekor.sigstore.devThe 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 foropenregs/websitedoes not match..github/workflows/release.yml— this workflow file. A pull request that addsattacker.ymlcannot sign; changingrelease.ymlmeans changing a file on a protected branch through a reviewed pull request.@refs/tags/— a tag push only. Every merge tomainis signed@refs/heads/mainand 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.
tooling/ci/signing_posture.py fixreg@2025.04 # -> keyedtooling/ci/signing_posture.py eu@2026.01 # -> keylesstooling/ci/signing_posture.py eu@2026.01 identity-regexp # -> ^https://github\.com/…$What a keyless release looks like
Section titled “What a keyless release looks like”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.
- 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; cosign sign-blob --yes --bundle attestation/keyless.sigstore.jsonsignsattestation/signature-manifest.yamlunder 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;- 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 signatureWalking the bundle offline
Section titled “Walking the bundle offline”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 | |
|---|---|
| chain | every 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) |
| identity | the 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 |
| signature | the 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 |
| timestamp | Rekor’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 |
| inclusion | the 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 signatureThe 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.
The trust material, and who supplied it
Section titled “The trust material, and who supplied it”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.3Sigstore 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/rootCertis 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:
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.
The fixture keys
Section titled “The fixture keys”.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 dwith 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.
In the pipeline
Section titled “In the pipeline”.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:
openregs release sign dist --fixture-key --signer auto --run-id "$GITHUB_RUN_ID"openregs verify --release "$TAG" --path distkeyless, which is what every real regime gets:
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.yamlshred -u …/cosign.keyopenregs verify --release "$TAG" --path dist --key …/cosign.pub --log-key …/cosign.pubcosign 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.
Related
Section titled “Related”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 thesource_hashin each unit’smetathat the completeness check reads.CONTRIBUTING.md§8 — where this sits in the lifecycle.
Rendered from openregs/openregs@f3a2d10:docs/runbooks/release-sign.md