Whisper · independent compliance verification

Verify a Whisper compliance event yourself - from the IANA DNSSEC root down

A compliance event is a minted per-operator identity plus a signed event record committed to an append-only transparency ledger. Every check below runs with stock tooling - dig/delv, openssl, curl, python3 - against public keyless endpoints, with one named exception: the Bitcoin leg in 5(c) needs the reference OpenTimestamps client, which is third-party open source and still keyless. No Whisper API key or account is ever in the trust path. Our API is a convenience for provisioning; it is never needed to verify.

Operator identity (name)a47f143b897e097f6.tf204d2f06f7187a005f79967a2e5b62e.agents.whisper.online
Routable identity (/128)2a04:2a01:aea2:cd12:47f1:43b8:97e0:97f6
Eventissuance record whose event chain hashes to the ES256-signed root_hash of step 4. Every tree size, leaf index and block number on this page is a snapshot of a live, growing log, and the kid depends on which node signed for you, so yours will differ; what has to match is the fold and the signature check, not the count.

1 · 2

Root→name DNSSEC - authenticated by the IANA root, not by us

delv validates the whole chain against its own built-in IANA root trust anchor. A ; fully validated line means every link - root → onlinewhisper.onlineagents.whisper.online → the name - was cryptographically checked. Whisper is nowhere in the trust decision.

shell
delv @1.1.1.1 AAAA "$FQDN"
; fully validated
a47f143b897e097f6.…agents.whisper.online. 60 IN AAAA 2a04:2a01:aea2:cd12:47f1:43b8:97e0:97f6
a47f143b897e097f6.…agents.whisper.online. 60 IN RRSIG AAAA 13 5 60 20260909110000 20260907100000 54705 agents.whisper.online. n9YXJCb2IiEo…
3

Key pin (DANE) - the operator's signing key, pinned by DNSSEC, no public CA

shell
dig +dnssec +noall +answer TLSA "_443._tcp.$FQDN" @1.1.1.1
delv @1.1.1.1 TLSA "_443._tcp.$FQDN" | grep validated
_443._tcp.a47f…agents.whisper.online. 60 IN TLSA 3 1 1 FD017415F6802A34756A3C5FFD3B6573F0946F410F4710311CF8BD230A96FC53
; fully validated
shell
echo | openssl s_client -connect "[$ADDR]:443" -servername "$FQDN" 2>/dev/null \
  | openssl x509 -pubkey -noout | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256      # → matches the TLSA 3 1 1 hex above
# leaf issuer: CN = Whisper Agent Identity Issuing CA, O = viaGraph B.V. (DANE-EE - not WebPKI-trusted)
4

Event signature - verify the ledger's signature over the event chain, offline

The event is committed to the ledger, and the ledger signs this address's own event history: an ES256 JWS over root_hash, the SHA-256 chain across the address's events. It verifies offline against the ledger's keyless-published JWKS (/.well-known/jwks.json, which serves all current ledger signing keys so any kid resolves in one fetch). This is not the signed tree head: the Merkle tree root is signed separately, in Ed25519, on the C2SP checkpoint of step 5.

shell
curl -s "https://whisper.online/ip/$ADDR/transparency" > tp.json

A JWS ES256 signature is raw r||s, 64 bytes, and openssl dgst -verify reads DER. Converting it is the step every hand-written recipe forgets, so here it is in full. Save this as jws_prepare.py and run python3 jws_prepare.py tp.json:

python
import base64, json, sys, urllib.request

doc = json.load(open(sys.argv[1] if len(sys.argv) > 1 else "tp.json"))
header, payload, sig = doc["root_signature"].split(".")
b64u = lambda s: base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

# 1. The signing input is exactly the two base64url segments joined by a dot.
open("sth.signing_input", "wb").write((header + "." + payload).encode())

# 2. A JWS ES256 signature is RAW r||s, 64 bytes. openssl wants DER, so convert.
raw = b64u(sig)
def der_int(b):
    b = b.lstrip(b"\x00") or b"\x00"
    return b"\x02" + bytes([len(b) + (b[0] >= 0x80)]) + (b"\x00" if b[0] >= 0x80 else b"") + b
body = der_int(raw[:32]) + der_int(raw[32:])
open("sth.der", "wb").write(b"\x30" + bytes([len(body)]) + body)

# 3. Pick the JWKS key this JWS names, and wrap its x/y as a P-256 SPKI PEM.
kid = json.loads(b64u(header))["kid"]
jwks = json.load(urllib.request.urlopen("https://whisper.online/.well-known/jwks.json"))
k = next(k for k in jwks["keys"] if k["kid"] == kid)
spki = bytes.fromhex("3059301306072a8648ce3d020106082a8648ce3d030107034200")
spki += b"\x04" + b64u(k["x"]) + b64u(k["y"])
b64 = base64.b64encode(spki).decode()
open("ledger_pub.pem", "w").write(
    "-----BEGIN PUBLIC KEY-----\n"
    + "\n".join(b64[i:i + 64] for i in range(0, len(b64), 64))
    + "\n-----END PUBLIC KEY-----\n")
print("kid", kid[:16], "| payload", json.loads(b64u(payload)))

It writes the three files openssl needs, then:

shell
openssl dgst -sha256 -verify ledger_pub.pem -signature sth.der sth.signing_input
kid 17b67fbdf403e4b2 | payload {'object': 'identity-transparency-root', 'address': '2a04:2a01:aea2:cd12:47f1:43b8:97e0:97f6', 'count': 1, 'root_hash': '5b8aad2e59e795a1bfbb348769c89712e94c67dd36cd68dd5bfc6f087f1fc217'}
Verified OK

The signed payload is {object, address, count, root_hash}, so what this ES256 signature covers is root_hash, the SHA-256 chain over this address's own events that step 5(a) recomputes. The Merkle root your inclusion proof folds to is a different value under a different key: Ed25519, on the checkpoint. Compare the root_hash in the payload with line 3 of /checkpoint and you will see two different hashes, which is exactly right: two signed objects, two jobs. The operator's key (step 3, DANE-pinned) and the ledger's key (this step, JWKS-published) are both keyless.

5

Ledger inclusion + tamper-evidence - Merkle proof, append-only, Bitcoin-anchored, Article 17

(a) Inclusion - the event is in the signed tree (stock python3 + hashlib, RFC 6962):

shell
curl -s "https://whisper.online/ip/$ADDR/transparency" > tp.json   # event chain + the ledger arm
curl -s "https://whisper.online/inclusion?leaf=166" > inc.json     # any leaf, by index, keyless

Save this as rfc6962_inclusion.py and run python3 rfc6962_inclusion.py inc.json:

python
import base64, hashlib, json, sys

# Reads either shape the log serves: the "ledger" arm of /ip/<addr>/transparency, or the flat
# answer from /inclusion?leaf=N. Both carry a leaf hash, an audit path and the signed checkpoint.
doc = json.load(open(sys.argv[1] if len(sys.argv) > 1 else "tp.json"))
led = doc.get("ledger", doc)
ok = True

# 1. The C2SP signed note: line 1 origin, line 2 tree size, line 3 base64 root. What /checkpoint serves.
note = led.get("checkpoint") or (open(sys.argv[2]).read() if len(sys.argv) > 2 else "")
if not note:
    sys.exit("no checkpoint here. curl -s https://whisper.online/checkpoint > cp.txt, then pass it as arg 2")
origin, size, root = note.split("\n")[0], int(note.split("\n")[1]), base64.b64decode(note.split("\n")[2])
print("log %s, tree size %d" % (origin, size))

# 2. Fold each leaf hash up its audit path to the root (RFC 9162 section 2.1.3.2).
leaves = [(l["index"], l["leaf_hash"], l["inclusion_proof"]) for l in led["leaves"]] \
    if "leaves" in led else [(led["leaf"], led["leaf_hash"], led["proof"])]
for index, leaf_hex, path in leaves:
    node, fn, sn = bytes.fromhex(leaf_hex), index, size - 1
    for sib in (bytes.fromhex(h) for h in path):
        if fn & 1 or fn == sn:
            node = hashlib.sha256(b"\x01" + sib + node).digest()
            while fn & 1 == 0 and fn != 0:
                fn, sn = fn >> 1, sn >> 1
        else:
            node = hashlib.sha256(b"\x01" + node + sib).digest()
        fn, sn = fn >> 1, sn >> 1
    ok &= node == root
    print("leaf %d of %d, %d siblings, folds to the signed root: %s" % (index, size, len(path), node == root))
if not leaves:
    print("this tree carries no leaf for this address, so NOTHING about inclusion was proved here.")
    print("Ask the log for a leaf directly, which is the form that always answers:")
    print("  curl -s 'https://whisper.online/inclusion?leaf=<N>' > inc.json && python3 %s inc.json" % sys.argv[0])

# 3. The per-event hash chain, when this is a /transparency answer: proof[i] = SHA-256(proof[i-1] || event[i]).
if "events" in doc:
    chain = b""
    for ev in doc["events"]:
        chain = hashlib.sha256(chain + json.dumps(ev["event"], separators=(",", ":")).encode()).digest()
    ok &= chain.hex() == doc["root_hash"]
    print("event chain over %d event(s) reproduces root_hash: %s"
          % (len(doc["events"]), chain.hex() == doc["root_hash"]))

# Folding no proof at all is not a pass. A verifier printed under the heading "the event is in the
# signed tree" that exits 0 having checked nothing is worse than no verifier: someone wires it into CI.
sys.exit(0 if ok and leaves else 1)
log whisper.online/ledger/g2, tree size 252100
leaf 166 of 252100, 18 siblings, folds to the signed root: True

That is the fold: the leaf hash and its 18 siblings recompute the exact root the log signed. Change one sibling byte and the last word becomes False and the script exits 1.

Now the same script against tp.json, which is the honest half:

log whisper.online/ledger/g2, tree size 252100
this tree carries no leaf for this address, so NOTHING about inclusion was proved here.
Ask the log for a leaf directly, which is the form that always answers:
  curl -s 'https://whisper.online/inclusion?leaf=<N>' > inc.json && python3 rfc6962_inclusion.py inc.json
event chain over 1 event(s) reproduces root_hash: True

It exits 1, deliberately. A verifier printed under the heading "the event is in the signed tree" that returned success having folded no proof would be worse than no verifier at all, because somebody would wire it into CI and get a green light for a claim nobody checked.

An empty ledger.leaves means this tree does not carry a leaf for that address. The log runs on its second genesis (origin whisper.online/ledger/g2, see Transparency) and carries only what was minted into it, and a registration from the last few moments may not be inside the proving checkpoint yet either. The event chain is unaffected and still verifies: those two lines say the address's own event history hashes to the root_hash that step 4's ES256 signature covers.

(b) Append-only - the log never rewrote history (RFC 6962 consistency proof, keyless):

shell
curl -s "https://whisper.online/checkpoint"                    # C2SP signed note: origin, size, root, Ed25519 sig
curl -s "https://whisper.online/consistency?from=251620&to=252275" \
  | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["object"], d["from"], "->", d["to"], len(d["proof"]), "nodes")'
ledger-consistency 251620 -> 252275 16 nodes

Size 251620 is a size the log stamped, so that one line says the older tree is a prefix of the tree the log serves now: nothing behind it was rewritten. /checkpoint/ots/latest-confirmed names the most recent stamped size at any moment, and it moves as the log grows.

(c) External anchor - OpenTimestamps, settled in Bitcoin - what we submit, and what you can check:

shell
curl -s "https://whisper.online/checkpoint/ots/latest-confirmed"
#   {"object":"whisper-ledger-ots-latest-confirmed","tree_size":257164,
#    "root":"oG5hGCWIYYhFx89Gv+kE49vCFvVL6VkP3RoS3EnJ9fU=","bitcoin_block":966009,
#    "stamped_at_millis":1788835574668,"confirmed_at_millis":1788838821442,
#    "ots":"/ots/257164","consistency":"/consistency?from=257164"}
curl -sD - "https://whisper.online/ots/257164" -o cp257164.ots
#   X-Whisper-OTS-Status: confirmed
#   X-Whisper-Ledger-Tree-Size: 257164
#   X-Whisper-OTS-Bitcoin-Block: 966009

The proof is detached over the checkpoint root. The first thing to check, with nothing but python3, is that it commits to the root we published and not to some other bytes. The stamped digest sits at a fixed offset: after the 31-byte magic, the version byte and the hash-op byte, so 33 bytes in.

python
import base64, json, urllib.request
lc = json.load(urllib.request.urlopen("https://whisper.online/checkpoint/ots/latest-confirmed"))
assert lc["ots"].startswith("/ots/")          # never let the server steer this fetch off-origin
d  = urllib.request.urlopen("https://whisper.online" + lc["ots"]).read()
print(d[33:65].hex() == base64.b64decode(lc["root"]).hex())
True

So the root we serve is the root we submitted to OpenTimestamps.

The settlement leg - the root is in a Bitcoin block, and here is how you confirm it. Until recently this section carried a retraction, and the retraction was right: we framed each upgraded proof over the pending digest instead of splicing it onto the calendar operations that produced that digest, so what we served folded to a merkle root no Bitcoin block has ever had. That is fixed and the anchors were rebuilt. The leg verifies now, and you should not take that from us either, so here is the check.

This is the one step on this page that stock tools cannot do. An .ots file is a binary chain of operations and the reference OpenTimestamps client is what reads it correctly, so install the client rather than writing your own walker. It is third-party open source, it is not ours, and it still needs no Whisper API key. Save this as check-anchor.sh and run bash check-anchor.sh in a directory of its own: it writes lc.json and cp.ots there and puts the client in an otsenv/ beside them, and it refuses rather than checking nothing at three points, which is an exit and belongs in a script rather than pasted into your shell.

shell
python3 -m venv otsenv && ./otsenv/bin/pip install -q "opentimestamps-client==0.7.2"   # the reference client

curl -s https://whisper.online/checkpoint/ots/latest-confirmed > lc.json
SIZE=$(python3 -c 'import json;print(json.load(open("lc.json")).get("tree_size",""))')
[ -n "$SIZE" ] || { echo "no confirmed anchor served, so there is nothing to check" >&2; exit 1; }
curl -s "https://whisper.online/ots/$SIZE" -o cp.ots
[ -s cp.ots ] || { echo "no proof for size $SIZE" >&2; exit 1; }

# what the proof says about itself: the digest it stamps, the block it names, and the
# merkle root its whole operation chain folds to for that block
./otsenv/bin/ots info cp.ots | grep -E 'File sha256|Attestation|merkle root'

# the block number comes from the PROOF, never from the JSON we handed you. A proof still
# waiting on its calendar names no block, so this refuses rather than checking nothing.
BLOCK=$(./otsenv/bin/ots info cp.ots | sed -n 's/.*BitcoinBlockHeaderAttestation(\([0-9]*\)).*/\1/p')

if [ -z "$BLOCK" ]; then
  echo "still a calendar promise, not a Bitcoin fact: ask for /ots/$SIZE again"
else
  # what two unrelated explorers say that block's hash and merkle root are
  for API in https://mempool.space/api https://blockstream.info/api; do
    HASH=$(curl -s "$API/block-height/$BLOCK")
    [ -n "$HASH" ] || { echo "$API has no block $BLOCK" >&2; continue; }
    echo "$HASH  $(curl -s "$API/block/$HASH" | python3 -c 'import json,sys;print(json.load(sys.stdin)["merkle_root"])')"
  done
fi
File sha256 hash: a06e61182588618845c7cf46bfe904e3dbc216f54be9590fdd1a12dc49c9f5f5
verify BitcoinBlockHeaderAttestation(966009)
# Bitcoin block merkle root d79e38962b2d7db4bea9b4eb78cf4d1ed0aeacd224fac7a2fe711ad3adf16625
0000000000000000000103d28488d77d2d3fd3514614d26bfc29551bb1b51df6  d79e38962b2d7db4bea9b4eb78cf4d1ed0aeacd224fac7a2fe711ad3adf16625
0000000000000000000103d28488d77d2d3fd3514614d26bfc29551bb1b51df6  d79e38962b2d7db4bea9b4eb78cf4d1ed0aeacd224fac7a2fe711ad3adf16625

Line 1 is the digest the proof stamps, the same 32 bytes the python3 snippet above matched against the root we serve. Line 3 is what the proof's operation chain computes for block 966009, and it is what two explorers with no relationship to us, or to each other, report for that block. That closes the chain: the checkpoint root we publish was inside block 966009.

If your run prints still a calendar promise, not a Bitcoin fact where the explorer lines should be, the proof for that size had not finished upgrading from a calendar promise into a Bitcoin fact when you fetched it. Ask again, or use a size that settled earlier. The refusal is deliberate: without it the check would hand an empty block number to an explorer and print something that looks like a broken anchor.

Two traps, if you decide to walk the proof by hand anyway:

What the anchor proves, and what it does not. It proves the checkpoint root existed by the time of block 966009, and that nobody, us included, can move it earlier without re-mining Bitcoin from that height. It says nothing about the contents of the tree. What is inside is what 5(a)'s inclusion proof and 5(b)'s consistency proof establish, and the anchor neither adds to them nor stands in for them. It is no defence against a split view either; that is what witnessing is for, a separate claim with a separate status, stated plainly at the end of this page.

/ots/<n> answers 200 with X-Whisper-OTS-Status: pending for a stamped root that has not settled, and 404 with no OpenTimestamps proof for this checkpoint yet for a size the log never stamped at all. Stamping happens at intervals, so most sizes have no proof and never will.

(d) Article 17 erasure - erasure and immutable audit coexist. The ledger leaf is only an opaque commitment = SHA-256(salt ‖ event); the cleartext (salt, event) live in a separate erasable store. Two distinct actions, honestly separated. op:release / op:revoke are lifecycle actions - they tear the identity down and crypto-shred the leaf's salt (the opaque leaf can no longer be opened) but by design retain the append-only allocation history (the audit), so after a plain release the transparency feed still reconstructs who held the address. The Article 17 subject erasure is its own call - whisper.agents({op:'identity', args:{op:'erase', address:'<your /128>'}}), authenticated with your API key, confined to your own identity (a foreign address returns 404). It releases the identity, crypto-shreds the salt, and removes the subject from every keyless served surface - the RDAP object, the transparency events[], the historical ownership trail, the /ip/<addr>/lookups feed - immediately, via a read-filter keyed on the erasure record (an erased address with no current holder serves none of its history - a pure presence check, no cross-node timestamps compared). Demonstrated live:

BEFORE erase: RDAP shows the identity; /ip/<addr>/transparency lists the issuance event; the issuance leaf inclusion-verifies to the signed checkpoint (True).
ERASE whisper.agents({op:'identity', args:{op:'erase', address:<addr>}})
AFTER erase: curl …/ip/<addr>404 (identity erased); …/ip/<addr>/transparency"events": [] (no cleartext) - yet the SAME response still serves the opaque leaf hash + inclusion proof + signed checkpoint, and a consistency proof across the erasure still verifies (True): the audit leaf is immutably retained; only the subject's plaintext is unrecoverable.

The honest physical-erasure window. The keyless served surfaces above show nothing for the subject from the moment the call returns. That immediacy comes from the read-filter, which stays in force regardless of what happens physically underneath. Under the hood the subject's operational activity records (allocation trail, inbound lookups, DNS/connection activity) are best-effort physically deleted at erase time, and where an immediate physical delete cannot complete, the read-filter is the authoritative backstop and any residual copies physically age out within a bounded, documented retention window (up to 45 days). Crypto-shredded salts age out of backups within 7 days, the same window the DPIA documents. Erasure is scoped to the past holder: if the same address is later re-allocated, the new holding's own activity is visible. We erase a subject's history; we do not blind an address forever.

6

Whisper's API is never in the trust path

Every command above uses only dig/delv, openssl, curl, python3, plus the third-party OpenTimestamps client for the Bitcoin leg, against public, keyless surfaces: DNS/DNSSEC (any validating resolver), TLSA/DANE, RDAP /ip/<addr>, /.well-known/jwks.json, /checkpoint, /checkpoint/ots/latest-confirmed, /witness/keys, /consistency, /inclusion, /ip/<addr>/transparency, /ots/<n>. No X-API-Key, no account, no login. A Whisper API key is required only to mint or erase an identity - never to verify one.


What this proves today - and the honest scope

verified · open tooling The operator identity is authenticated from the IANA DNSSEC root (steps 1–3); its signing key is DANE-pinned with no public CA; the compliance event is committed to an append-only Merkle ledger whose root is signed, cosigned by one independent witness, and anchored in Bitcoin through OpenTimestamps, which 5(c) walks you through checking against two independent explorers (steps 4–5); and GDPR Article 17 erasure coexists with the immutable audit trail (5d).

The ledger tells you its own claim level - check the header:

shell
curl -sD - https://whisper.online/checkpoint -o /dev/null | grep X-Whisper-Ledger-Claim
X-Whisper-Ledger-Claim: tamper-evident, signed transparency log

one independent witness cosigning The header reports its own level; today it reads tamper-evident, signed transparency log. It 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 cosigns today as one such witness, and because the log speaks the open C2SP tlog-witness protocol, any additional independent witness brings the quorum. The witness policy is published keyless at /witness/keys; if a cosignature ages past its freshness window the claim steps down on its own. A verifier never takes our word for the claim level - it is a property of the signed artifact you fetched, so check the live header yourself.