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 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.
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 | that key, and the log’s key, are ones the anchor accepts |
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.
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 runs
openregs release sign dist --fixture-key --signer auto --run-id "$GITHUB_RUN_ID"openregs verify --release "$TAG" --path distagainst 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.
Related
Section titled “Related”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@cbe9615:docs/runbooks/release-sign.md