WhisperDocs
Trust & cryptography

Transparency log

Nothing issued in the dark.

Every agent identity Whisper mints - and every revocation - lands in a public, append-only Merkle log the moment it happens. If we ever tried to show two different histories to two different people, the math catches it and says so.

The problem this solves

A certificate authority, a registry, or an identity provider is, by default, a black box: it tells you an identity is valid and you take its word for it.

That word can be wrong by accident (a mis-issuance) or on purpose - a compromised operator quietly minting a second, conflicting identity for the same address, or showing an auditor a clean history while showing everyone else a different one (the "split-view" attack).

The web PKI learned this the hard way. After the 2011 DigiNotar breach, browsers forced every public CA onto Certificate Transparency (RFC 6962): a public log every issued certificate must appear in, so mis-issuance is detectable, not just forbidden by policy.

Agent identity has the same failure mode and deserves the same fix. A /128 and its DANE pin are exactly the kind of high-value binding you don't want to take on trust - so we don't ask you to.

The mechanism: an RFC 6962 Merkle tree

The ledger is a standard RFC 6962 transparency log, served as C2SP tlog-tiles with C2SP signed-note checkpoints - the same log shape as Certificate Transparency and Sigsum, not a bespoke format. Two hash rules, domain-separated by a leading byte so a leaf can never be mistaken for an interior node:

leaf     = SHA-256(0x00 ‖ commitment)
interior = SHA-256(0x01 ‖ left ‖ right)

Adding a leaf changes the root; any leaf can prove its own membership with a short inclusion proof (the sibling hashes on the path to the root), and any two tree sizes can prove one is a strict extension of the other with a consistency proof - the log can only grow, never rewrite.

What's actually in a leaf. Not the /128, not the FQDN, not the owner - a bare, opaque commitment: commitment = SHA-256(salt ‖ canonical-event), where salt is a fresh 256-bit secret minted per event and the event is a deterministic encoding of "this /128+name was issued to this owner at time T" (or revoked, or rotated). The tree itself is unreadable - that's deliberate. It reconciles two things that normally fight: public verifiability and GDPR Art. 17 erasure. The (salt, event) pair lives outside the append-only tree, in a store that can delete. Verifying what a commitment means requires the subject (or an auditor they've authorized) to disclose that pair; you then recompute the hash and check it folds to the signed root - selective disclosure, not global readability. Erase the salt and the leaf's meaning becomes unrecoverable forever, while every prior inclusion proof stays mathematically valid - the log doesn't have to lie about its own history to honor a right to be forgotten.

Signed checkpoints

The current state of the tree is published as a signed checkpoint - a plain-text C2SP note: line one is the log origin, line two the tree size, line three the base64 root hash, then one Ed25519 signature line per cosigner (the log itself, and each witness). No key required to fetch it, ever. A copy pinned in a doc goes stale the moment the tree grows, so fetch the live one and the key it is signed under rather than trust a printed sample:

# the current signed checkpoint: origin, size, root, then the signature lines
curl -s https://whisper.online/checkpoint

# the dedicated log key that signs it
curl -s https://whisper.online/checkpoint/key
# {"object":"whisper-ledger-key","origin":"whisper.online/ledger/g2","alg":"Ed25519",
#  "key_id":"d40e573a","public_key":"7pSYYInKXm2TMjwAqEsoKon/d3E8SDq34V1VUrgTAm0=",
#  "public_key_spki":"MCowBQYDK2VwAyEA7pSYYInKXm2TMjwAqEsoKon/d3E8SDq34V1VUrgTAm0="}

The signing key is dedicated - it signs ledger checkpoints and nothing else (not RDAP, not the identity document), so a compromise or rotation of one never touches the others. The log runs on its second genesis today, origin whisper.online/ledger/g2, signed by key id d40e573a. That key, and the full set the log may sign under, are published and DNSSEC-anchored as _whisper-ledger.whisper.online TXT records, so you pin the set from signed DNS once and verify forever without re-trusting a web fetch each time.

Log generations

The log has run under two origins. A checkpoint names its own generation on its first line, and a client derives its URLs from that origin, so each generation answers at the path its origin implies.

GenerationOriginKey idStatusFinal size
g1whisper.online/ledger8a3a5df0retired 30 Jul 202618994
g2whisper.online/ledger/g2d40e573acurrentgrowing

g2 began at a fresh genesis, so it is not an extension of g1 and no consistency proof links the two. That is exactly why the generation matters: a g1 receipt verifies against g1's head and a g2 receipt against g2's, and a verifier that mixes them fails closed, which is the correct outcome.

g1's final head is published permanently, and it is served even when the current log is unavailable, because a retired head is immutable history rather than a claim about today's tree:

curl -s https://whisper.online/ledger/g1/checkpoint
# whisper.online/ledger
# 18994
# a0vbxKwvjYBapH/OXPBUmSMB0dp+ILRey2+EFEsTAZo=
#
# ...then a blank line and three C2SP signature lines: ours under key id 8a3a5df0,
# and two cosignatures from markovianprotocol.com/witness. Fetch it to see them.

It verifies under key id 8a3a5df0 and carries two cosignatures from our cross-witness. Being straight about its provenance: we no longer hold g1's tree, so that head is the last one the witness cosigned. We retrieved it from them and verified the Ed25519 signature against the pinned g1 key before publishing it.

What a g1 receipt holder can and cannot check, permanently. They can verify the final head: its Ed25519 signature under key id 8a3a5df0, the two witness cosignatures on it, and the Bitcoin anchor below. They cannot recompute an inclusion proof for their own individual receipt. g1's tiles went with the tree when g2 began at a fresh genesis, and we asked the witness directly: they hold the final head at 18994 and nothing else, no intermediate heads and no leaves. So there is no copy anywhere, ours or theirs, from which a g1 audit path could be rebuilt. Saying "not currently" would imply a recovery that is coming. There isn't one. A pre-rotation receipt is checkable against a signed, witnessed, Bitcoin-anchored head, and that is the whole of it.

And it cannot happen the same way twice. The head was lost because a rotation simply started a new log and nothing captured the old one; it took an outside witness, seven weeks later, to notice. A box now detects that state on boot - configured for one origin, holding the committed head of another - and writes that terminal head to durable storage before anything else runs, which is the last moment it is still readable. It is then served at /ledger/<generation>/checkpoint with no code change and no redeploy, and the rotation raises a metric so it is observed as it happens rather than months later. The limit is worth stating plainly, because the code cannot cover it: a rotation that wipes the store before booting leaves nothing to capture. Leaves have to be exported before that point, by hand, and that is a runbook rule rather than something a guard can enforce.

That head is also anchored in Bitcoin, and the anchor is not ours. We never stamped g1 ourselves, because its tree was already gone when g2 took over. The proof published here was produced by the same independent witness, over the exact 471 bytes we serve, and stamped into Bitcoin block 968085. It is a confirmed attestation rather than a calendar promise, so it checks out offline against that block's header with nothing of ours in the loop:

curl -s https://whisper.online/ledger/g1/checkpoint     -o g1-head.txt
curl -s https://whisper.online/ledger/g1/checkpoint.ots -o g1-head.txt.ots
ots info g1-head.txt.ots
# File sha256 hash: 6cbd60eef9ffa912d59cc53cff8cf5033b60e1cc8555fc371a9f563d820fe88e
#   ...which is the sha256 of g1-head.txt, so the proof is about that exact file
# verify BitcoinBlockHeaderAttestation(968085)

curl -s https://whisper.online/ledger/g1/checkpoint/ots/latest-confirmed
# {"object":"whisper-ledger-anchor","generation":"g1","bitcoin_block":968085,
#  "attestor":"markovianprotocol.com","attestor_independent":true,"self_attested":false, ...}

Read that descriptor for what it declines to claim. An independent party timestamped our published head; we did not timestamp ourselves and then cite it back. self_attested is false and the attestor is named, in the body and in the proof's response headers, so nothing here can be mistaken for our own stamp. Serving it quietly under our own name would make it worth nothing, because the whole value of a third-party anchor is the third party. The proof path accepts both spellings, and /ledger/g1/checkpoint.ots is the one to reach for first.

The current generation answers under its origin path too, which is what a client that derives URLs from the origin line will ask for:

curl -s https://whisper.online/ledger/g2/checkpoint

Anchored to Bitcoin, honestly

A signature proves Whisper attests to a root; it doesn't prove when, and it doesn't stop us from moving our own clock. So every signed checkpoint's 32-byte root is submitted to public OpenTimestamps calendars, which batch many commitments into one aggregate and periodically timestamp that aggregate into a real Bitcoin transaction. Once the transaction is buried a few blocks deep, the proof upgrades from a calendar promise ("pending") to a Bitcoin fact ("confirmed"). An upgraded proof then checks out against the Bitcoin block header alone, with no calendar server contacted and no trust in Whisper's clock: what it does need is the reference OpenTimestamps client to read the .ots operation chain, which is third-party open source and still keyless, plus the block header itself from a node you run or a public explorer. OpenTimestamps & Bitcoin anchoring runs that check end to end against the live log, and /verify step 5(c) walks the same one with its two byte-order traps spelled out. Only the 32-byte root ever touches the chain - no event count, no identity data, nothing to link.

Fetch the proof. The .ots for the latest anchored checkpoint is served keylessly, and so is a small descriptor naming the Bitcoin block it landed in. Both spellings of the proof path work:

curl -s https://whisper.online/checkpoint.ots -o checkpoint.ots   # conventional
curl -s https://whisper.online/checkpoint/ots -o checkpoint.ots   # canonical, same bytes
curl -s https://whisper.online/checkpoint/ots/latest-confirmed    # which block, and through what size

At the time of writing that descriptor read bitcoin_block 968130 for tree_size 339869, anchored through 340312. Those numbers move every time a new aggregate confirms, which is the point - read the descriptor rather than this sentence, exactly as with the claim header above. A specific checkpoint's proof is at /ots/<tree_size>.

›

Current status. The ledger is tamper-evident and Ed25519-signed, with checkpoints anchored to Bitcoin, and it is independently witnessed by MarkovianProtocol (C2SP tlog-witness): a genuinely separate party, not an availability check between our own boxes. The served claim reaches publicly verifiable / split-view-resistant once a quorum of at least two genuinely independent witnesses is cosigning the served checkpoint (gated in code, self-reverting); MarkovianProtocol is one such witness today, so the served claim is tamper-evident, signed transparency log, one cosigner short of quorum. The live X-Whisper-Ledger-Claim header is the source of truth for the current level - fetch /checkpoint and read it yourself rather than trust this paragraph. We speak the open C2SP tlog-witness protocol, so any Sigsum/transparency.dev-style witness (a $5 VPS is plenty) can co-sign our checkpoints for free and bring the quorum. Full policy: nic.whisper.online/policy#transparency.

The freshest confirmed anchor

The newest checkpoint's OpenTimestamps proof sits calendar-pending for a few hours before it upgrades to a Bitcoin confirmation, so the freshest root you can anchor a continuity check against is the newest confirmed one, which lags the head. GET /checkpoint/ots/latest-confirmed returns exactly that, keyless:

# the newest Bitcoin-confirmed anchored root
curl -s https://whisper.online/checkpoint/ots/latest-confirmed
{
  "object": "whisper-ledger-ots-latest-confirmed",
  "tree_size": 260301,
  "root": "q9Qc45nBkOYbcBhWPx/44YyHgECXkDn39gYf053oGg8=",
  "bitcoin_block": 966095,
  "stamped_at_millis": 1788881464627,
  "confirmed_at_millis": 1788893113172,
  "ots": "/ots/260301",
  ...
}

The consistency field is elided above rather than reprinted: the block is a capture from before the pointer carried both bounds, and showing a value the paragraph below tells you is rejected would be worse than showing fewer fields of a real answer. Fetch it yourself and you will get the current form.

The answer names its own follow-up, which is the point: take the size from that JSON rather than from this page, because the log grows and /ots/<size> answers a clean 404 once a size has rolled past. Asking for the size printed here months from now proves nothing about the tree you are looking at.

CONS=$(curl -s https://whisper.online/checkpoint/ots/latest-confirmed \
  | python3 -c 'import json,sys;print(json.load(sys.stdin).get("consistency",""))')
if [ -z "$CONS" ]; then echo "no confirmed anchor served" >&2; else
  curl -s "https://whisper.online$CONS" \
    | python3 -c 'import json,sys;d=json.load(sys.stdin);print(d.get("detail") or "%s %d -> %d, %d nodes" % (d["object"], d["from"], d["to"], len(d["proof"])))'
fi

Follow the pointer the answer gives you rather than rebuilding the URL: the endpoint requires both bounds and has no default, so a hand-built ?from= alone gets a 400 telling you so.

ledger-consistency 369069 -> 369349, 15 nodes

Both snippets read the answer rather than assume it. A node that cannot serve a proof right now says so in its own detail, and the line above prints that sentence instead of a traceback, which is what you want at three in the morning when the question is whether the log is broken or one box is. You may well see it: at the time of writing a node behind the shared address can answer that way, so if you get the detail line rather than a proof, ask again.

Those nineteen nodes are the RFC 6962 consistency proof from the newest Bitcoin-confirmed size up to the current head: with no trust in our clock, the live tree is an append-only extension of a root that is in block 966095. Response headers on /ots/<size> carry the same facts (X-Whisper-OTS-Status: confirmed, X-Whisper-OTS-Bitcoin-Block, X-Whisper-Ledger-Tree-Size); an empty store answers a clean 404, never a 500.

Try it: fetch and verify a checkpoint by hand

With stock tools - curl, openssl, jq, python3. No Whisper software anywhere in this path:

# 1. the signed checkpoint (a C2SP note: origin / size / root / signature)
curl -s https://whisper.online/checkpoint

# 2. the log's public key + key id, to verify against - pin it from signed DNS,
#    which is DNSSEC-anchored, so you trust the chain and not this fetch
dig +short TXT _whisper-ledger.whisper.online
# "v=whisper1; k=ed25519; n=whisper.online/ledger/g2; p=MCowBQYDK2VwAyEA7pSYYInKXm2TMjwAqEsoKon/d3E8SDq34V1VUrgTAm0=" …
curl -s https://whisper.online/checkpoint/key
# {"origin":"whisper.online/ledger/g2","alg":"Ed25519","key_id":"d40e573a",
#  "public_key":"7pSYYInKXm2TMjwAqEsoKon/d3E8SDq34V1VUrgTAm0=", …}

# 3. verify the Ed25519 signature (OpenSSL ≥3.0 verifies Ed25519 with -rawin)
openssl pkeyutl -verify -rawin -in checkpoint.body \
  -pubin -inkey ledger.pub -sigfile checkpoint.sig

# 4. any leaf's inclusion proof, by index: leaf_hash + sibling path + the signed
#    checkpoint it folds to, one keyless call (a bad leaf is a clear 400, never a 500)
curl -s "https://whisper.online/inclusion?leaf=0"
# {"object":"ledger-inclusion","leaf":0,"tree_size":429,
#  "leaf_hash":"9e47e79ecfecbce8babb888fc7f7e3481b45fb5b1d5c4492671e69a0042e119a",
#  "proof":["82f522c46fcaad3f3aa070b468be09dbf1b532e98aa1615b5b1141bf81ca281f",
#           "b19e49843367422013a66b799da22f495683d6c9a78e7087e5a5119071dc9647", …],
#  "checkpoint":"…"}

# 5. one agent's inclusion proof - no key needed, RFC 9083 RDAP arm
curl -s https://rdap.whisper.online/ip/2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4/transparency | jq .
# {"object":"identity-transparency","address":"2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4",
#  "count":1,"events":[{"event":{"action":"issuance", …},"proof":"46cf7eb9…","prev_proof":""}],
#  "root_hash":"46cf7eb9…","root_signature":"eyJhbGci…","root_signature_alg":"ES256"}
# a fresh registration reads the same shape - the issuance event and its root
# signature - not yet a populated ledger.leaves inclusion path; fold the proof
# by hand with steps 3-4 above (a live checkpoint + /inclusion?leaf=N) instead

# 6. fold the proof to the signed root yourself
python3 - <<'PY'
import hashlib
def node(l, r): return hashlib.sha256(b"\x01" + l + r).digest()
leaf = bytes.fromhex("b21f...")          # from step 4 or 5
proof = [bytes.fromhex(h) for h in ["9ac3...", "5e10..."]]
h, idx, size = leaf, 57, 39858        # size = the tree_size from your own fetch
for sib in proof:
    h = node(sib, h) if idx % 2 else node(h, sib)
    idx //= 2
assert h.hex() == "{root-from-step-1}"    # matches the signed checkpoint
PY

The Merkle-fold logic is public domain math (RFC 6962 §2.1.1) - nothing here needs a client library. The same checkpoint's witness cosignature is verifiable independently at witness.markovianprotocol.com/checkpoints - a separate party vouching for the very root you just folded to.

With Whisper - one command per step, same math, done for you:

# fetch + verify the latest checkpoint under the published key
whisper ledger checkpoint

# prove a disclosed (salt, event) is included in the signed tree
whisper ledger verify 2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4 \
  --salt 9f1e4c2a... --event-file event.bin

# the full trustless verdict - DNSSEC root → DANE pin → this ledger → identity
# document, chained, with Whisper's API trusted for nothing:
whisper verify --trustless 2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4

whisper ledger checkpoint fetches the checkpoint and key, verifies the Ed25519 signature, and prints VERIFIED. whisper ledger verify additionally fetches the inclusion proof and folds it to the root for you - exit code 0 means cryptographically included, exit 1 means it is not. Full source for both: github.com/whisper-sec/whisper-cli, internal/cli/ledger.go.

On the write side, identity events never call the ledger directly - every whisper.agents({op:'register'|'identity'|'revoke'}) call appends the corresponding event automatically as part of issuing or tearing down a /128. The log is a side effect of using the control plane, not a separate thing you have to remember to do. The tree does have two deliberate, API-key-gated write doors for your own material: POST /entries registers a COSE Signed Statement you signed, and POST /attest commits a typed compliance attestation. Both land as the same opaque, keyless-verifiable leaves as everything else.

SCITT receipts (RFC 9943 / RFC 9942)

This same log supports SCITT: every leaf can be pulled, keyless, as an RFC 9942 COSE receipt under the RFC 9943 architecture (GET /entries/{leaf}, key discovery at /.well-known/scitt-keys), signed by the same Ed25519 key that signs the checkpoint above. Fold the receipt's inclusion proof and it byte-equals the checkpoint root at the same tree size: one tree, two envelopes, verifiable with stock COSE tooling and no Whisper code. The receipt inherits this page's claim level, log-signed inclusion, and now independent witnessing. Full walkthrough: SCITT receipts.

Point-in-time RDAP, anchored in this same log

The same tree also holds a second leaf kind: not identity events, an observation, "we saw subject X have operator-of-record Y at time T." A keyless "operator of record as of T" endpoint returns an RFC 9083 object plus a SCITT receipt for that observation, so the same fold that proves an identity leaf or a receipt is in this tree proves an RDAP observation is too: one tree, three surfaces. The ?at= query parameter is Whisper's own extension, not a standardized RDAP param, and the ceiling on the claim is an observation of what we saw, never a claim about the registry's own ground truth. Full walkthrough: Point-in-time RDAP.

Next

DANE & DNSSEC covers the cryptographic pin the ledger's inclusion proofs sit alongside; Compliance & audit covers how crypto-shredding a ledger entry satisfies GDPR Art. 17 without breaking any prior proof.