Register a statement: sign your own objects
Whisper's transparency log is an append-only registry you can write to. Sign a statement about any object you hold (a document, a build artifact, a model, a dataset), register it, and get back a COSE receipt that proves it was logged, verifiable by anyone with stock CBOR and COSE tooling and no Whisper code.
The log behind /checkpoint is a SCITT Transparency Service (RFC 9943). This page is the producer walkthrough: sign, register, receipt, verify, in four copy-paste steps. If you want the receipt wire format and the Merkle fold in depth, that reference is SCITT receipts; this page is the "how do I get MY object in there" recipe.
It is a close cousin of Sign agent outputs, with one addition. That recipe binds a signature to a key you publish in DNS, so a recipient checks it against your name; nothing is logged. Here you also drop the signed statement into a shared, append-only log and get an inclusion proof back, so a third party can later show your statement existed and has not been altered since, without ever contacting you. The two compose: publish the key with one, prove the record with the other.
Keyless to read, one key to write
Two tiers, Postel style. Reading and verifying any entry is keyless, and always will be. Writing into the shared tree takes an API key, and that is the only thing the key is for here: an anti-spam gate on a single log everyone shares. One tree, one root, one signing key. The receipt you get back is the same envelope SCITT receipts folds, and it resolves to the same root as the checkpoint.
What you register is a claim about your object, not the object itself. The tree is a log, not a blob store (statements cap at 64 KiB), so you sign a small statement over your object's hash and leave the object wherever it already lives.
The shape of a Signed Statement
A Signed Statement is a COSE_Sign1 (CBOR tag 18): [protected, unprotected, payload, signature], signed by YOUR key. The registration policy is small, standard (RFC 9943 §6), and it is everything the log checks on the way in:
- the
protectedheader carriesalg (1), a key identifierkid (4)(required unless anx5chain (33)orx5t (34)names your key by certificate), and, mandatory,CWT_Claims (15): a map withiss (1), who is saying it, andsub (2), what it is about.CWT_Claimsmust sit in the protected header so your signature covers it. - the
payloadis your object's hash, or a small manifest about it. Sign the hash, not the file. - the
signatureis over the COSESig_structure, which is exactly["Signature1", protected, h'', payload](RFC 9052 §4.4).
The log stores your bytes exactly as received and never re-encodes them, because your signature covers those exact protected-header bytes. It does not verify your signature and does not resolve your key, and that is deliberate: the log proves your statement was registered, while whether the key named in kid is genuinely yours is proven separately, by publishing that key at your DNSSEC-anchored name so any verifier can resolve it. Naming the key is your job; checking it is the relying party's.
1. Sign your object
Stock Python, cbor2 plus cryptography, no Whisper code. This uses Ed25519 (alg -8), whose signatures are raw 64-byte values, so there is no DER-versus-raw footgun:
pip install cbor2 cryptography
import cbor2, hashlib
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
# 1. Your object, reduced to its SHA-256. Sign the hash; the file stays where it is.
digest = hashlib.sha256(open("model.safetensors", "rb").read()).digest()
# 2. Your own signing key. Generate once, keep the private half; publish the public
# half at your name so a verifier can resolve the kid (see /docs/sign-outputs).
sk = Ed25519PrivateKey.generate()
name = "acef2002a323d40d4.demo.agents.whisper.online"
protected = {
1: -8, # alg = EdDSA (RFC 9053)
4: name.encode(), # kid = names your verification key
15: {1: name, 2: "model.safetensors"}, # CWT_Claims: iss (who), sub (what)
}
protected_bstr = cbor2.dumps(protected)
# 3. Sign the COSE Sig_structure: ["Signature1", protected, external_aad=b"", payload]
sig_structure = cbor2.dumps(["Signature1", protected_bstr, b"", digest])
signature = sk.sign(sig_structure)
# 4. The COSE_Sign1 Signed Statement (tag 18): [protected, unprotected, payload, signature]
statement = cbor2.dumps(cbor2.CBORTag(18, [protected_bstr, {}, digest, signature]))
open("statement.cose", "wb").write(statement)
# The entry id is the SHA-256 of these exact bytes; the log commits to them.
print("entry id:", hashlib.sha256(statement).hexdigest())
model.safetensors is just an example; substitute a document, a container image digest, an SBOM, or a dataset manifest. name is any identifier a verifier can tie back to a key. A Whisper agent already has a DNSSEC-anchored name that resolves to a key, which is what makes it an ideal iss and kid.
2. Register it
POST /entries with your API key and the raw COSE bytes:
curl -sS -X POST "https://whisper.online/entries" \
-H "X-API-Key: whisper_live_xxx" \
-H "Content-Type: application/cose" \
--data-binary @statement.cose -D -
# HTTP/1.1 202 Accepted
# Location: /entries/9f2b7c...e41a (the entry id: SHA-256 of your statement bytes)
202 Accepted means the statement is committed and the sequencer is assigning it a Merkle leaf. The Location id is the 64-hex SHA-256 of the exact bytes you sent, the same value the sign script printed. Registration is idempotent: re-POST the identical statement and you get 201 Created with the final leaf index, never a second leaf. Anything malformed comes back as an RFC 9290 problem document that says exactly what is wrong (a missing CWT_Claims, an absent kid, a body over 64 KiB), never an opaque 500.
3. Get your receipt
GET the id from the Location. This is keyless, so anyone you hand the id to can do it too:
curl -sD headers.txt "https://whisper.online/entries/9f2b7c...e41a" -o receipt.cose
grep -i '^x-whisper-scitt-entry:' headers.txt | tr -d '\r' | awk '{print $2}' > entry.hex
While the leaf is being sequenced you get 204 No Content with a Retry-After; poll until 200. The 200 body is application/cose. Because the server holds the bytes you registered, it serves the full Transparent Statement (RFC 9943 §7): your original envelope, byte for byte, with the log's Receipt appended to its unprotected receipts (394) header. Both signatures, yours over the object and the log's over the tree, keep verifying on that one file. If the server does not hold your bytes it serves the bare Receipt instead; either way the proof is identical.
The response headers carry the pieces a verifier folds:
| Header | Meaning |
|---|---|
X-Whisper-Scitt-Entry-Id |
the decimal Merkle leaf index |
X-Whisper-Scitt-Entry |
the 32-byte opaque commitment, the SCITT "entry": leaf_hash = SHA-256(0x00 ‖ entry) |
X-Whisper-Ledger-Claim |
the honest claim level of the served checkpoint (see the last section) |
4. Verify it yourself, offline
Two independent checks fall out of that one file. The Receipt proves inclusion (the log logged it); your own signature proves authorship (your key made it). Both use stock cbor2 plus cryptography; the inclusion fold is identical to the one on SCITT receipts.
Inclusion, against the log's key and the checkpoint root:
curl -s https://whisper.online/.well-known/scitt-keys -o scitt-keys.cbor
curl -s https://whisper.online/checkpoint > checkpoint.txt
import base64, cbor2, hashlib
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
ts = cbor2.loads(open("receipt.cose", "rb").read()) # your Transparent Statement (tag 18)
s_protected, s_unprot, s_payload, s_sig = ts.value
receipt = cbor2.loads(s_unprot[394][0]) # the log's Receipt, out of receipts(394)
r_protected, r_unprot, r_payload, r_sig = receipt.value
rhdr = cbor2.loads(r_protected)
assert rhdr[1] == -8 and rhdr[395] == 1, "EdDSA + RFC9162_SHA256 receipt"
assert r_payload is None, "detached: the verifier recomputes the signed root"
# the inclusion proof rides vdp(396){-1}: [tree_size, leaf_index, path]
tree_size, leaf_index, path = cbor2.loads(r_unprot[396][-1][0])
# leaf hash from the entry commitment (the X-Whisper-Scitt-Entry response header)
entry = bytes.fromhex(open("entry.hex").read().strip())
node = hashlib.sha256(b"\x00" + entry).digest()
# fold the path to the root (RFC 9162 section 2.1.3.2)
fn, sn = leaf_index, tree_size - 1
for sib in path:
if fn & 1 or fn == sn:
node = hashlib.sha256(b"\x01" + sib + node).digest()
if not fn & 1:
while fn & 1 == 0 and fn != 0:
fn >>= 1; sn >>= 1
else:
node = hashlib.sha256(b"\x01" + node + sib).digest()
fn >>= 1; sn >>= 1
root = node
# the log's EdDSA signature under the published COSE_Key (kty OKP, crv Ed25519)
keys = cbor2.loads(open("scitt-keys.cbor", "rb").read())
k = next(x for x in keys if x[1] == 1 and x[-1] == 6)
Ed25519PublicKey.from_public_bytes(k[-2]).verify(
r_sig, cbor2.dumps(["Signature1", r_protected, b"", root]))
print("inclusion: OK (log-signed against the checkpoint root)")
# and the root byte-matches the C2SP checkpoint at this tree size
lines = open("checkpoint.txt").read().splitlines()
assert int(lines[1]) == tree_size and base64.b64decode(lines[2]) == root
print("one tree, one root:", lines[2], "at size", tree_size)
Authorship, against the key you named. The receipt says nothing about who made the statement; that is what your signature is for. A relying party resolves the public key from the kid and iss you set (published at your name, see Sign agent outputs), then:
# resolve agent_pub from the kid/iss name in the wild; here it is the key from step 1
Ed25519PublicKey.from_public_bytes(agent_pub).verify(
s_sig, cbor2.dumps(["Signature1", s_protected, b"", s_payload]))
assert s_payload == hashlib.sha256(open("model.safetensors", "rb").read()).digest()
print("authorship: OK (your key signed this exact object hash)")
Three facts now stand on their own, no Whisper account in the trust path: the statement is in the log (inclusion), you made it (authorship), and the object is the exact bytes the statement names (the hash compare). Any one failing means reject.
What a receipt proves, and what it does not
A receipt asserts one precise thing: log-signed inclusion. Your exact signed statement is a leaf in the tree whose root the log's key signed, and, while the X-Whisper-Ledger-Claim header reads publicly verifiable / split-view-resistant, an independent witness is cosigning that checkpoint, so the log cannot show you one history and someone else another. That claim level is a property of the artifact you fetched and self-revokes if the witness goes away, so a verifier never takes our word for it; it reads the live header.
What a receipt does not do: it does not certify that your object's contents are true, correct, complete, or lawful. The log never reads your object, and it does not vouch for your key or your claim. Authorship is what your own signature proves; the truth of the claim is between you and your relying party. A receipt is evidence that a specific signed statement existed and has not been altered since it was logged, no more and no less. It also records registration order, not wall-clock time: if you need to prove WHEN, anchor the checkpoint to Bitcoin with OpenTimestamps.
Next: SCITT receipts for the full receipt wire format and the fold in depth, Transparency log for the checkpoint chain your receipt anchors to, Sign agent outputs to publish the key that signs your statements, or OpenTimestamps to add a Bitcoin-anchored proof of time.