# 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](https://certificate.transparency.dev/) (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](https://www.rfc-editor.org/rfc/rfc6962) transparency log, served as [C2SP `tlog-tiles`](https://c2sp.org/tlog-tiles) with [C2SP signed-note](https://c2sp.org/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](https://c2sp.org/signed-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.

| Generation | Origin | Key id | Status | Final size |
| --- | --- | --- | --- | --- |
| g1 | `whisper.online/ledger` | `8a3a5df0` | retired 30 Jul 2026 | 18994 |
| g2 | `whisper.online/ledger/g2` | `d40e573a` | current | growing |

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](https://opentimestamps.org/) 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](/docs/opentimestamps) runs that check end to end against the live log, and [`/verify` step 5(c)](https://whisper.online/verify) 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`](https://c2sp.org/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](https://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:

```bash
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.

```bash
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:

```bash
# 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](https://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:

```bash
# 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](https://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`](/docs/register-a-statement) registers a COSE Signed Statement you signed, and [`POST /attest`](/docs/compliance#5-attest-a-control-in-force-typed-compliance-attestations) 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](https://www.rfc-editor.org/rfc/rfc9942) COSE receipt under the [RFC 9943](https://www.rfc-editor.org/rfc/rfc9943) 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](/docs/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](/docs/point-in-time-rdap).

## Next

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