Security
Verifying receipts offline
A hash chain proves internal consistency: no row was altered after it was written, given this chain. It does not prove authenticity — that this chain is ours and not one somebody generated. The product’s own verifier says it plainly on every healthy unsigned verdict: these records are internally consistent; anyone able to rewrite the whole receipt log could recompute every hash in it and produce a result that looks exactly like this one. Signing is what closes that gap.
The public key endpoint
https://<your-instance>/.well-known/garboard-receipts.json
Unauthenticated, deliberately — an auditor verifying your receipts must not need your permission to do it. Each key is published as {key_id, alg: "ed25519", public_key, retired}, and the key_id is the first 16 hex characters of the SHA-256 of the public key, so it is derivable from the key alone. An exported bundle carries the same document as public-key.json, so the archive stays checkable after the instance is gone.
Verifying
garboard receipt verify receipts.json --keys keys.json
No call to us, and no call to your instance if you fetched the keys once. That is the property worth having: an auditor who must ask the vendor whether the vendor’s records are genuine has learned nothing.
Without --keys the command checks internal consistency only, and says so. The exit codes are made for CI:
| Exit | Verdict |
|---|---|
0 |
verified, or unsigned with the chain intact |
1 |
chain_broken — a hash, link, or signature failed |
2 |
no_records — an empty file is not a pass, so it is not 0 |
What the chain proves, precisely
Each receipt’s hash is the SHA-256 of its canonical JSON with the hash and signature fields excluded, and the previous receipt’s hash is part of that digest. Verification recomputes every hash and returns the index and id of the first bad row, not just a boolean — a tampered record is located, not merely detected.
A file is treated as a subset of the chain: a missing link between two receipts in a filtered export counts as a gap (the receipt in between was excluded), while the same non-link inside a whole-chain check is a break. Verifying one receipt on its own proves it was written by the key holder and unaltered since — it says nothing about the chain around it, and the result says so.
The signature, and the four rules
The signature is ed25519 over the receipt’s hash — which already commits to every field and to the receipt’s position in the chain via prev_hash. Signature assessment follows four rules, in order:
- A structurally broken chain stays
chain_broken; signatures are not even consulted. - A signature that fails to verify, or that names an unpublished key, is
chain_broken— the tamper signal, never softened to “unsigned”. - Partial signing is
unsigned. If any receipt in the range is unsigned, the range is not signed evidence. A chain older than its signing key reads “consistent, not signed” for any period covering the pre-key receipts — forever, by design, because re-signing history is backdating. - Only when every receipt is signed and every signature verifies:
verified.
Signed and unsigned are never blurred. An instance with no signing key still writes receipts, and they are labelled unsigned rather than quietly presented as checked. There is no state in which an unsigned receipt is reported as verified.
Keys
| Variable | Purpose |
|---|---|
GARBOARD_RECEIPT_KEY |
A base64 32-byte Ed25519 seed. Generate with garboard receipt keygen. |
GARBOARD_RECEIPT_RETIRED_KEYS |
Previously used public keys, kept published so receipts signed with them still verify after a rotation. |
Ed25519 held in a secret store rather than a cloud KMS asymmetric key, deliberately: a self-hosted customer can sign without any cloud call, which keeps the offline property real rather than nominal.
Rotating does not invalidate history. Move the old key into the retired list and past receipts keep verifying — a retired key that is unpublished instead is what breaks them. And under rule 3 above, rotation never backdates: receipts written before any key existed stay honestly unsigned.
What a receipt contains and when one is written is covered in signed receipts.