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 installed. The artifact is identical either way, which is why nothing records which one ran.

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
trustthat key, and the log’s key, are ones the anchor accepts
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.

.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 runs

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

against the artifacts the build stage uploaded — you sign the bytes you shipped, not a rebuild of them — and the verification is an ordinary step, so a signature that does not check out fails the job and nothing downstream of it runs. --fixture-key is deliberate there: this repository releases the FIXREG fixture regime and holds no organisation key. A deployment swaps in --key.

  • 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@cbe9615:docs/runbooks/release-sign.md