Witness onboarding
Who this is for: an operator or auditor who wants Bucker's evidence log to be hard for Bucker to rewrite.
Why a witness is the whole point
Every proof-ledger entry is signed, and the log's heads are published as signed checkpoints. A signature proves who wrote the bytes. It proves nothing about history.
An issuer who controls every byte can produce a perfectly consistent log for a
verification that never ran. Re-signing a rewritten log costs nothing when you hold the
key. That is not a hypothetical about Bucker specifically; it is the reason transparency
logs exist as a genre, and it is stated in the specification itself
(docs/proof-ledger-spec, §10.2) rather than hidden.
The fix is that somebody who is not us holds a copy of a head. Once a third party has
recorded "at tree size 4,182 the root was abc…", we can no longer produce a different
history for those 4,182 entries without that party's copy contradicting us. The log stops
being a promise and becomes a commitment.
So the honest reading of an un-witnessed log is:
This log proves internal consistency and authorship, and nothing about history.
That sentence is what GET /orgs/:orgSlug/proof-ledger/witnesses returns in its
disclosure field when independentlyWitnessed is false, and it is what the
Verification screen prints at the top of the witness panel. It is meant to be
uncomfortable until you fix it.
What counts as a witness, and what does not
| Independent? | What it buys you | |
|---|---|---|
| A bucket you control, with Object Lock | yes (declared) | A rewrite is contradicted by an object we cannot delete |
| An endpoint you run (SIEM, notary, your own store) | yes (declared) | Same, plus alerting on your side |
| A store Bucker operates | no | Durability. Nothing else |
A copy the issuer holds cannot corroborate the issuer. The API computes independent
from the declared custody rather than storing it as a flag, precisely so nobody can
register our own bucket and call the log witnessed:
independent: witness.custody === 'CUSTOMER'
Custody is declared, not verified
We cannot verify that a URL or a bucket is really yours. An endpoint is an endpoint.
Every witness response therefore carries custodyUnverified: true, always, and the UI
repeats it. What is verifiable is the receipt trail, and — where the witness
countersigns — its signature over the exact checkpoint bytes.
Do not read "CUSTOMER custody" as a Bucker attestation. Read it as your claim, recorded with an author and a timestamp.
Onboarding, end to end
1. Choose a transport
OBJECT_STORE — checkpoints are written as immutable objects under a key prefix.
The intended production shape is S3 with Object Lock in COMPLIANCE mode.
WEBHOOK — checkpoints are POSTed to an endpoint you run.
Object store is the stronger default: retention is enforced by the bucket, so a witness that is merely offline for a week loses nothing, and there is no service for you to keep up.
2. Register it
curl -X POST https://api.bucker.io/orgs/<org>/proof-ledger/witnesses \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{
"name": "acme-s3-objectlock",
"kind": "OBJECT_STORE",
"custody": "CUSTOMER",
"endpoint": "bucker-checkpoints/",
"config": { "bucket": "acme-audit", "region": "us-east-1",
"objectLockMode": "COMPLIANCE", "retainDays": 2555 },
"expectsCountersignature": false
}'
Requires admin on the org. For a webhook, add a secret of at least 16 characters —
that is the HMAC key we sign deliveries with, and it is stored sealed and never returned.
config is non-secret provider settings only. Note what the receipt will say about
objectLockMode: we record the write, not the retention. Retention is enforced by
your bucket, and if you have not actually turned Object Lock on, nothing in our receipt
will notice.
3. Verify a delivery signature (webhook only)
Each POST carries:
x-bucker-timestamp: 2026-08-29T19:00:00.000Z
x-bucker-signature: t=<unix seconds>,v1=<hex>
The signature header is a comma-separated key=value list. Take t and v1 from it and
check:
expected = hex(hmac_sha256(secret, t + "." + rawBody))
valid = constant_time_equals(expected, v1) and abs(now - t) <= your_tolerance
Four things that are easy to get wrong, and that we get asked about:
tis the timestamp that was signed — notx-bucker-timestamp. That header is a readable convenience for your logs. It is not covered by the MAC, and signing its contents will fail every delivery.- Take the FIRST
t=if the value somehow carries two. Last-wins lets anyone who can append to the header shadow the timestamp we actually signed. - Sign over the raw body bytes, not a re-serialized parse.
- Compare in constant time, and reject a
toutside your tolerance — 300 seconds is what our own reference verifier uses. The timestamp is inside the signed string specifically so a captured delivery cannot be replayed at your endpoint later.
A future algorithm will arrive as an additional v2= beside v1= in the same header, so
a verifier that keeps reading v1 keeps working while one that has migrated can prefer
the newer. Ignore keys you do not recognise rather than refusing the delivery.
Changed 2026-09-10. Deliveries previously carried
x-bucker-signature: sha256=<hex>over<isoTimestamp>.<body>, with the time inx-bucker-timestamp. The material signed is the same in substance — the timestamp was always inside the MAC — but it is now spelled in unix seconds undert=, and the value is thet=,v1=list above rather than asha256=prefix. This is the same grammar every other Bucker webhook uses; one name now means one thing. A verifier written against the old shape will reject every delivery rather than accept a forgery.
4. Countersign, if you can
Set expectsCountersignature: true and supply your Ed25519 public key as SPKI PEM. Then
answer a delivery with:
{ "countersignature": "<base64 Ed25519 over the exact checkpoint bytes>",
"algorithm": "Ed25519" }
This is the difference between two very different claims, and the receipt records which one you made:
- stored — you received a head. A delivery record.
- attested — you signed that you received these exact bytes. Evidence.
GET /orgs/:orgSlug/proof-ledger/witnesses/:witnessId/receipts reports both counts
separately (attestedHeads, storedHeads) rather than adding them together, because
adding them together would launder the first into the second.
5. Deliver, and check
# Publish a checkpoint now, then push it to every enabled witness.
curl -X POST .../orgs/<org>/proof-ledger/checkpoints -H "authorization: Bearer $TOKEN"
curl -X POST .../orgs/<org>/proof-ledger/checkpoints/<id>/deliver -H "authorization: Bearer $TOKEN"
# What came back.
curl .../orgs/<org>/proof-ledger/witnesses/<witnessId>/receipts -H "authorization: Bearer $TOKEN"
A receipt is PENDING, CONFIRMED or FAILED, with attempts and the raw
receipt blob (HTTP status and response size, or object key and version id). Delivery
retries up to five attempts; a witness that is down does not block issuance, and the
backlog is retried by retryPendingDeliveries.
6. Confirm it took
In the app: Verification → Who co-signs our log heads. The panel prints the
independentlyWitnessed headline first, then each witness with its custody, whether it
countersigns, and its last receipt.
The headline flips to positive only when a witness is enabled, has CUSTOMER
custody, and has actually received a head (deliveredCount > 0). Registering a
witness is not witnessing.
What you should do with the copies
Holding checkpoints is only half of it. To actually detect a rewrite:
Keep them. Object Lock, or append-only storage on your side. A copy we could ask you to delete is a copy we control.
Compare them. Periodically fetch
GET /orgs/:orgSlug/proof-ledger/consistency-proof?oldSize=<a size you hold>and check that the tree you were shown then is a prefix of the tree we show now.bucker-verifydoes this offline, with no account and no network:npx bucker-verify consistency --old checkpoint-4182.json --new checkpoint-9001.jsonAlert on a gap. If deliveries stop, that is a signal. A witness nobody reads is a witness that cannot testify.
Step 2 is the one people skip, and skipping it makes the whole arrangement decorative.
Failure modes worth knowing
A checkpoint at a given tree size is immutable. A second write at the same object key with different contents is refused rather than overwritten, and the receipt records the refusal. If you see that error, something is wrong — investigate rather than clearing it.
Object Lock is yours to configure. Our receipt says a write happened. It cannot say retention was enforced.
A non-JSON 200 still counts as received. A witness that acknowledges without a
parseable body has the head; it simply has not attested to it. The receipt distinguishes
the two.
Disabling a witness does not delete its receipts. History is not editable from the witness roster, deliberately.
Related
docs/proof-ledger-spec/— the published format, its versioning policy, and worked examplescode/tooling/bucker-verify/README.md— the offline verifier: no account, no network, no runtime dependencies/verify— the same rule set in a browser tab, for a certificate or a share link/transparency— the public miss-rate register and its own Merkle log