Transparency
Signed C2SP checkpoints, stock witnesses, policies, the auditor's Verifier, tiles and offline proof bundles.
On this page
What to call
The log side (the operator):
transparency::LogKey—from_seed,vkey,sign.Journal::checkpoint/Ledger::checkpoint→SignedCheckpoint.WitnessSession,AddCheckpoint,WitnessReply::read,SignedCheckpoint::with_cosignature.Journal::tile,Journal::tile_entries,TileId.Journal::bundle_entry,Journal::bundle_balance,Journal::bundle_record→ProofBundle.
The auditor side:
Policy—k_of_n,builder,parse,verify.Verifier—update,verify,pin,heads.ProofBundle::parse,ProofBundle::verify→Proven.transparency::Error— every refusal, by name.
Signing and signature verification need the witness feature (Ed25519).
Without it every text format still parses and renders, and bundles verify by
hashes (ProofBundle::verify_inclusion).
The gap proofs cannot close
Every proof verifies against a tree head. An operator who shows one head to the tax authority and another to the bank shows two internally consistent histories: a split view. Only a party that is not the operator, and remembers what it signed, can refuse it.
So the crate ships no witness. It speaks the C2SP
formats used by the Go checksum database, Sigsum and the transparency-dev
witness network, and lets their stock tlog-witness servers do the
remembering.
testing::TestWitness
(features testing and witness) is an in-memory test double.
| C2SP text | In the crate |
|---|---|
signed-note v1.1.0 | Note, KeyName, Vkey |
tlog-checkpoint v1.1.0 | Checkpoint, SignedCheckpoint, LogKey (Ed25519, 0x01) |
tlog-cosignature v1.1.0 | Cosignature (Ed25519, 0x04) |
tlog-witness v1.1.0 add-checkpoint | AddCheckpoint, WitnessReply, WitnessSession |
tlog-policy | Policy, PolicyBuilder |
tlog-proof v1 | ProofBundle |
tlog-tiles v1.0.0 | TileId; tile, tile_entries |
Everything is sans-I/O: the crate builds requests and reads replies, your HTTP client posts them. Keys come from caller seeds and timestamps from witnesses; the crate reads no clock.
Sign the head
use doubleentry::prelude::*;
use doubleentry::transparency::{Checkpoint, KeyName, LogKey, SignedCheckpoint, Vkey};
let id = LedgerId::new("example.com/ledgers/acme")?;
let books = Journal::new(id.clone());
let key = LogKey::from_seed(&id, [7; 32]); // the seed comes from your KMS
// Publish the verification key; auditors and witnesses configure it.
let published = key.vkey().to_string();
let vkey: Vkey = published.parse()?;
// The checkpoint is the head, signed as a C2SP signed note.
let signed = books.checkpoint(&key)?;
assert_eq!(signed.checkpoint().head(), books.head());
assert_eq!(signed.checkpoint().origin(), &KeyName::from(&id));
// Deterministic: signing the same head again gives the same bytes.
let again = key.sign(&Checkpoint::new(KeyName::from(&id), books.head()))?;
assert_eq!(again.to_bytes(), signed.to_bytes());
// Anyone with the vkey verifies it.
let parsed = SignedCheckpoint::parse(&signed.to_bytes())?;
assert_eq!(parsed.verify(&[vkey])?.len(), 1);
The origin is the ledger id; another ledger's key cannot sign
(KeyNameMismatch). Ed25519 signatures are verified strictly: non-canonical
S, small-order R and small-order keys are refused.
Collect cosignatures
A witness receives an add-checkpoint request (the size it holds, a
consistency proof from there, the signed checkpoint), checks the proof against
what it signed before, and cosigns. A
WitnessSession
runs one checkpoint to one witness in at most three requests: on a 409 with the
size the witness really holds (text/x.tlog.size) it asks for a proof from
there; a witness whose size goes backwards ends the session with
WitnessRegressed.
use doubleentry::prelude::*;
use doubleentry::testing::TestWitness;
use doubleentry::transparency::{AddCheckpoint, LogKey, Next, Policy, WitnessReply, WitnessSession};
let id = LedgerId::new("example.com/ledgers/acme")?;
let mut books = Journal::new(id.clone());
let key = LogKey::from_seed(&id, [7; 32]);
// In production: somebody else's stock tlog-witness, configured with your vkey.
let mut witness = TestWitness::new("witness.example/w1", [9; 32])?.log(key.vkey());
// First contact: the witness holds nothing for this log (old = 0, no proof).
let first = books.checkpoint(&key)?;
let request = AddCheckpoint::new(&first, 0, None)?;
let reply = witness.post(&request.to_bytes()); // your HTTP POST to <prefix>/add-checkpoint
let cosignature =
WitnessReply::read(reply.status, reply.content_type, &reply.body, &first, &witness.vkey())?;
let first = first.with_cosignature(&cosignature)?;
let mut held = first.checkpoint().head().size; // persist per witness
// Later: the session asks for the consistency proof the witness needs.
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
let next = books.checkpoint(&key)?;
let mut session = WitnessSession::new(next.clone(), witness.vkey(), held)?;
let cosigned = loop {
let proof = match session.needed_proof() {
Some((old, new)) => Some(books.prove_consistency(old, new)?),
None => None,
};
let reply = witness.post(&session.request(proof.as_ref())?);
match session.receive(reply.status, reply.content_type, &reply.body)? {
Next::Cosigned { cosignature, witness_size } => {
held = witness_size;
break next.with_cosignature(&cosignature)?;
}
Next::Retry => continue, // 409: the session adopted the witness's size
other => return Err(format!("unexpected: {other:?}").into()),
}
};
assert_eq!(held, books.head().size);
// Publish only what your policy's quorum cosigned.
let policy = Policy::k_of_n(key.vkey(), 1, [(String::from("w1"), witness.vkey())])?;
policy.verify(&cosigned)?;
Every cosignature is verified before it is returned; its timestamp is the
witness's, returned and never judged. Every other outcome (400, 403, 404, 409,
422, 429, a malformed body, a witness ahead of the checkpoint) is a distinct
WitnessError.
Persist the size each witness holds (0 when unknown); a wrong guess is
corrected through the 409.
Policies
A policy is the tlog-policy text auditors and witness networks publish: known
logs, known witnesses, groups with thresholds, and a quorum.
log example.com/ledgers/acme+c3b6d994+AepKbGPinFIKvvVQexMuxfmVR3auvr57kkIe6mkURtIs
witness w1 witness.example/w1+24b5d9fe+BP0XJDhaoMdbZPt4zWAvodmR/ev3axPFjtcC6sg16fYY
group witnesses 1 w1
quorum witnesses
Policy::k_of_n
builds the common case, Policy::builder() any other, Policy::parse reads the
text; Display writes the canonical form.
Policy::verify evaluates as transparency-dev/formats does: the checkpoint must
be signed by a policy log for its origin, every signature from a known key
must verify, unknown keys are ignored, and the quorum counts verified witness
cosignatures. Where the crate differs it is stricter, and the Go vectors record
both verdicts: ML-DSA-44 witnesses are kept but never counted; a witness key
that is also a log key is refused (PolicyRule::WitnessIsLog); two keys with
one (key name, key ID) are refused (AmbiguousKey).
The auditor's Verifier
A Verifier
keeps one trusted head per origin: first a pin or the first validly signed
checkpoint, then only forward, only through a consistency proof, and, when
built from a policy, only to checkpoints its quorum cosigned. A smaller size is a
rollback; a second root at a trusted size is a fork, refused with both signed
checkpoints as evidence. A proof that does not verify is Error::Proof (no
evidence: anyone in between can corrupt one); a valid proof of a different
prefix is Error::Inconsistent.
use doubleentry::prelude::*;
use doubleentry::transparency::{KeyName, LogKey, Update, Verifier};
let id = LedgerId::new("example.com/ledgers/acme")?;
let key = LogKey::from_seed(&id, [7; 32]);
let mut books = Journal::new(id.clone());
let mut auditor = Verifier::new().log(key.vkey())?;
let first = books.checkpoint(&key)?;
assert_eq!(auditor.update(&first, None)?, Update::First);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
let next = books.checkpoint(&key)?;
let (old, new) = (first.checkpoint().head(), next.checkpoint().head());
// Without the consistency proof the head does not move.
assert!(auditor.update(&next, None).is_err());
let proof = books.prove_consistency(old.size, new.size)?;
assert_eq!(auditor.update(&next, Some(&proof))?, Update::Advanced { from: old });
assert_eq!(auditor.head(&KeyName::from(&id)), Some(new));
// Going back is a rollback.
assert!(auditor.update(&first, None).is_err());
Persist heads
and restore them with pin; the verifier holds no other state.
Proof bundles
A ProofBundle
is one tlog-proof file: a signed checkpoint, the record's leaf bytes and its
inclusion path, verifiable offline. bundle_record proves a record at a
position, bundle_entry an entry by id, bundle_balance one account's
sealed balance.
use doubleentry::prelude::*;
use doubleentry::transparency::{LogKey, Policy, ProofBundle, Proven, Verifier};
let id = LedgerId::new("example.com/ledgers/acme")?;
let mut books = Journal::new(id.clone());
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.open("Assets:Cash", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01"))?;
books.define_months(2026, 9..=9)?;
let invoice = books.record(
Entry::new("invoice-2026-0001", date!("2026-09-15"))
.debit("Assets:Cash", amount!("1190.00"), EUR)
.credit("Income:Sales", amount!("1190.00"), EUR),
)?;
books.seal_period("2026-09")?;
let key = LogKey::from_seed(&id, [7; 32]);
let signed = books.checkpoint(&key)?;
// The operator hands out two files.
let entry_file = books.bundle_entry(invoice.id, &signed)?.to_bytes();
let cash_file = books
.bundle_balance("Assets:Cash", EUR, Layer::Settled, "2026-09", &signed)?
.to_bytes();
// The auditor verifies them offline, against its policy alone…
let policy = Policy::log_only(key.vkey());
let Proven::Record(entry) = ProofBundle::parse(&entry_file)?.verify(&policy)? else {
return Err("an entry bundle proves a record".into());
};
assert_eq!(entry.entry_id, Some(invoice.id));
assert_eq!(entry.position, invoice.position);
// …or against the head its Verifier trusts.
let mut auditor = Verifier::from_policy(policy);
auditor.update(&signed, None)?;
let Proven::Balance(cash) = auditor.verify(&ProofBundle::parse(&cash_file)?)? else {
return Err("a balance bundle proves a balance".into());
};
assert_eq!(cash.account.to_string(), "Assets:Cash");
assert_eq!(cash.balance.net(), amount!("1190.00"));
Policy::log_only needs no witness; in production the policy names the
witnesses required. A Verifier checks a bundle only at its trusted head
(NotAtTrustedHead otherwise).
Tiles
TileId::changed
names the tlog-tiles tiles that changed between two sizes; tile renders a
hash tile from stored nodes, so monitors can mirror the tree with stock tooling.
use doubleentry::prelude::*;
use doubleentry::transparency::TileId;
let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
let mut published = Vec::new();
for tile in TileId::changed(0, books.head().size) {
let bytes = books.tile(tile)?; // write to <prefix>/<tile.path()>
assert_eq!(bytes.len(), 32 * usize::from(tile.width()));
published.push(tile.path());
}
assert_eq!(published, ["tile/0/000.p/2"]);
Hash tiles reveal only hashes. Entry bundles
(tile_entries)
disclose every record in their range in full (memo text excepted): publish them
only where the books may be read.
Tested against Go, both ways
- Go → crate.
interop/c2sp/genwrites vectors from fixed seeds withgolang.org/x/mod/sumdb/{note,tlog},transparency-dev/formats,transparency-dev/merkleand the stocktransparency-dev/witness. They are committed undertests/vectors/c2sp/and read on every test run, without Go. - Crate → Go.
just interophasinterop/c2sp/checkopen the crate's checkpoints, requests, bundles, policies and tiles with the Go libraries, run the stock witness on ten consecutive requests (plus a stale size, a foreign key and a corrupted proof, refused with 409, 403 and 422), and hand its cosignatures back to the crate to verify.
Full programs: examples/transparency.rs
(two witnesses, a two-of-two policy, tiles, bundles, an auditor) and
examples/witness.rs
(a witness cosigning each new head while an auditor follows).
Limits
- No witness, no HTTP, no retry timing: the caller posts, backs off and persists each witness's size.
- A cosignature timestamp is the witness's claim, not a qualified time stamp.
- No ML-DSA signing, subtree cosignatures, COSE receipts or RFC 3161 tokens.
- A party that accepts an unwitnessed checkpoint is not protected from a split view.