Whisper · independent compliance verification
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.
delv validates the whole chain against its own built-in IANA root trust anchor. A ; fully validated line means every link - root → online → whisper.online → agents.whisper.online → the name - was cryptographically checked. Whisper is nowhere in the trust decision.
delv @1.1.1.1 AAAA "$FQDN"dig +dnssec +noall +answer TLSA "_443._tcp.$FQDN" @1.1.1.1 delv @1.1.1.1 TLSA "_443._tcp.$FQDN" | grep validated
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)
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.
curl -s "https://whisper.online/ip/$ADDR/transparency" > tp.jsonA 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:
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:
openssl dgst -sha256 -verify ledger_pub.pem -signature sth.der sth.signing_inputThe 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.
(a) Inclusion - the event is in the signed tree (stock python3 + hashlib, RFC 6962):
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:
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)
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:
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):
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")'
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:
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.
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())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.
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
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:
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.
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.
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:
curl -sD - https://whisper.online/checkpoint -o /dev/null | grep X-Whisper-Ledger-Claimone 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.