Trust Without Keys: Why Our Receipts Verify Without Knowing the Signer
Date: April 21, 2026 · Author: Dmitrii Zatona
Suppose someone emails you a receipt. A signed PDF, an X.509 certificate, a PGP-signed email, a DKIM-signed message — pick any traditional signature system. Before you can verify anything, you need one thing: the signer’s public key. If you don’t have it, you cannot proceed. The verification algorithm is structurally incapable of returning an answer until you look up the key in some directory, a trust store, a CA hierarchy, a keyserver, a DNS record, a previously-agreed-out-of-band artifact.
This requirement is so universal that most people think of it as part of the definition of “signature verification.” No key, no verification. Period.
ATL Protocol v2.0 rejects this assumption. The recommended way to verify an ATL receipt is to call ReceiptVerifier::anchor_only() — a constructor that takes no arguments, no public key, no certificate, no trust store. It verifies the receipt anyway. And that is the entire point.
I want to walk through why we designed it this way, what the verifier actually does without a key, and the five rules that decide whether a receipt is valid.
The PKI Problem, Stated Honestly
In a PKI-style trust model, the signature is the trust root. “I trust this document because I trust the key that signed it.” This shifts the entire problem to key management: whose key is legitimate, how do you learn about it, how do you revoke it when compromised, how do you handle key rotation without invalidating historical signatures.
PGP answered this with a web of trust that nobody outside a small community actually uses. X.509 answered it with certificate authorities, which has produced a decade of incidents: DigiNotar, Symantec, the endless CA/Browser Forum fights. DKIM answered it with DNS TXT records, which works until someone rotates a selector without telling downstream verifiers. Every system that anchors trust in a key eventually has a key distribution problem, a key rotation problem, and a key compromise problem.
For a transparency log, these problems are particularly bad. A log is meant to outlive any particular operator key. The whole point of anchoring document existence is that the proof remains valid ten years from now — after the signing key has been rotated, the issuing CA has been untrusted, and the operator’s organization has possibly ceased to exist. If the trust root is a key, the proof has a shelf life equal to the key’s lifetime.
So I built ATL Protocol around a different question: what if the trust root is not a key?
What “Anchor-Only Verification” Actually Means
An ATL receipt carries three kinds of evidence:
- A Merkle inclusion proof showing the entry is in some tree.
- A checkpoint: the tree’s root hash at a specific size and timestamp, with an Ed25519 signature over that checkpoint by the log operator.
- External anchors: RFC 3161 TSA timestamps (from an independent timestamping authority) and/or Bitcoin OTS proofs (the checkpoint hash anchored in a Bitcoin transaction).
The signature and the anchors answer different questions.
The signature says: “the log operator asserts these root hashes at this size.” It is the operator’s word, cryptographically bound to a private key they hold. If the key is compromised later, a signature produced before the compromise is no more trustworthy than one produced after — the signature alone does not tell you when it was issued.
The RFC 3161 anchor carries a signed timestamping token from a TSA, binding the data tree root to a specific time. atl-core currently parses the token and checks that the MessageImprint matches the expected data tree root; full CMS signature and certificate chain validation is a verification layer intended to live on top of this structural check. The design point is that the TSA is a party independent of the log operator, so the trust is delegated outward regardless of how much of the chain the verifier chooses to walk.
The Bitcoin OTS anchor carries a .ots proof whose initial digest commits to the super root. atl-core verifies that the proof starts from the expected digest and contains a Bitcoin attestation referencing a specific block height. Walking from that attestation to a validated Bitcoin block header — confirming the transaction was actually mined — is again a layer on top, and is what turns the structural commitment into a proof-of-work-anchored timestamp.
The insight: neither anchor is bound to the operator’s identity. A verifier who wants a stronger guarantee than structural binding can validate the TSA’s certificate chain and Bitcoin block headers independently; none of that depends on operator key material.
If both anchors verify, the receipt has a trust story that does not route through the operator at all: external parties committed to this log state at this point in time. That is a different shape of trust than “the operator signed this” — and it is obtainable without the operator’s key.
To be honest about the framing: “trust without keys” does not mean trust without roots. RFC 3161 TSAs and the Bitcoin network are trust roots. The shift is that they are independent of the log operator, and the verifier usually already trusts them for reasons that have nothing to do with this protocol — standard TSA certificates ship in OS and browser trust stores, and Bitcoin is a public network anyone can audit. The operator’s key is replaced by trust roots the verifier does not have to specifically set up to accept this log.
Two Constructors, One Recommendation
The ReceiptVerifier struct in src/core/verify/verifier.rs exposes exactly two primary constructors:
#[must_use]
pub fn anchor_only() -> Self {
Self { checkpoint_verifier: None, options: VerifyOptions::default() }
}
#[must_use]
pub fn with_key(verifier: CheckpointVerifier) -> Self {
Self { checkpoint_verifier: Some(verifier), options: VerifyOptions::default() }
}anchor_only() takes nothing. The internal checkpoint_verifier: Option<CheckpointVerifier> is None. The verifier will skip signature verification entirely and rely on the anchors.
with_key(...) takes a CheckpointVerifier containing the log operator’s public key. The verifier will additionally verify the Ed25519 signature on the checkpoint, as an extra integrity check layered on top of anchor verification.
Both are first-class. Neither is a degraded mode. But anchor_only() is the recommended constructor for first-time verification of receipts from unknown operators — the common case in a transparency log ecosystem.
There used to be a new() constructor. It still exists, marked deprecated:
#[deprecated(
since = "0.5.0",
note = "Use `ReceiptVerifier::anchor_only()` or `ReceiptVerifier::with_key()` instead"
)]
#[must_use]
pub fn new(verifier: CheckpointVerifier) -> Self {
Self::with_key(verifier)
}The deprecation is not about functionality — new() works identically to with_key(). It is about naming. A new() that requires a public key implies that keys are mandatory. They are not. The new names make the choice explicit: are you verifying with anchors only, or are you also checking the signature? The API forces the decision into the constructor name rather than hiding it behind a default.
SignatureMode: Require, Optional, Skip
Once you choose a constructor, you can further control signature behavior via VerifyOptions::signature_mode. There are three modes, defined in src/core/verify/types.rs:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SignatureMode {
/// Signature MUST verify successfully.
Require,
/// Verify signature if key is available, skip otherwise.
#[default]
Optional,
/// Never verify signature, rely only on anchors.
Skip,
}Require says the signature must verify or the whole receipt is rejected. Useful when you have a specific operator key you trust and you want to enforce that any receipt you accept came from exactly that operator.
Optional is the default. If a key is available, attempt signature verification and record the result. If no key is available, skip signature verification without counting it against the receipt. This is the mode that pairs naturally with anchor_only(): no key, no attempt, no failure.
Skip says never verify the signature even if a key is available. This is for maximum-performance scenarios where you trust anchors completely and do not want to spend Ed25519 verification cycles.
The #[default] attribute is on Optional deliberately. If a developer instantiates VerifyOptions::default() without thinking about signatures at all, they get the protocol-recommended behavior: signature is a nice-to-have, anchors are the real proof.
There is also a companion SignatureStatus enum for reporting what actually happened:
pub enum SignatureStatus {
Verified, // Signature cryptographically valid, key matches
Failed, // Signature was checked and did not verify
Skipped, // No attempt made (no key provided, or Skip mode)
KeyMismatch, // Provided key's key_id does not match checkpoint's key_id
}Verified is the success case. Failed is the “someone has a key for this operator and the checkpoint did not match it” case — cause for alarm. Skipped is the normal outcome of anchor-only verification. KeyMismatch is a design slot today: the enum reserves a variant for distinguishing “wrong key supplied” from “right key, bad signature,” but the current helper collapses both into Failed. If you see Failed, understand that it covers both shapes of failure until a future helper refinement surfaces the distinction.
The Five Rules of compute_validity
After all the individual verification steps run, one function decides whether the receipt is valid overall. It lives at the bottom of verifier.rs:
fn compute_validity(
result: &VerificationResult,
options: &VerifyOptions,
has_super_proof: bool,
) -> bool {
use super::types::{SignatureMode, SignatureStatus};
// Rule 1: Inclusion must pass
if !result.inclusion_valid {
return false;
}
// Rule 2: Super-Tree must pass (if present)
if has_super_proof && (!result.super_inclusion_valid || !result.super_consistency_valid) {
return false;
}
// Rule 3: Check signature based on mode
if options.signature_mode == SignatureMode::Require
&& result.signature_status != SignatureStatus::Verified
{
return false;
}
// Rule 4: Trust anchor required
let has_signature_trust = result.signature_status == SignatureStatus::Verified;
let has_anchor_trust = result.anchor_results.iter().any(|a| a.is_valid);
if !has_signature_trust && !has_anchor_trust {
return false;
}
// Rule 5: No errors
result.errors.is_empty()
}Five rules, evaluated in order. Each one encodes a specific claim about what a valid receipt must prove.
Rule 1: Inclusion must pass. The Merkle inclusion proof has to reconstruct the data tree’s root hash correctly. If this fails, the entry is not in the tree the receipt claims. Nothing else matters.
Rule 2: Super-Tree must pass, when present. For Receipt-Full, which carries a super_proof, the data tree’s root must be included in the Super-Tree and the Super-Tree must be consistent with its genesis. This protects against an operator silently dropping entire data trees from history.
Rule 3: Signature requirement met. This is the only place signature mode directly affects overall validity. In Require mode, signature must be Verified. In Optional and Skip, any signature status is acceptable — verification can succeed even when the signature was not checked at all.
Rule 4: Trust anchor required. The receipt must have at least one trust source — either a verified signature or a valid external anchor. A receipt with no anchors and no verified signature is rejected.
Rule 4 is a guard against a specific failure mode. Imagine a receipt arrives with no RFC 3161 timestamp, no Bitcoin OTS, and no signature check (because the caller used anchor_only() on a receipt from an operator they do not know). The Merkle proofs might all verify — the entry is in the tree, the tree is consistent with itself — but there is no external evidence that this tree represents anything real. An attacker could have constructed a completely synthetic log in seconds. Rule 4 refuses to call that “valid.”
Rule 5: No errors. During verification, individual steps push typed errors into result.errors. If any step produced an error, overall validity is false. This is a belt-and-suspenders check — the earlier rules should already catch the error conditions, but if some future verification step adds a new error category and forgets to fail an earlier rule, rule 5 catches it.
Where the Code is More Permissive Than the Protocol
Rule 4 is also where the implementation quietly admits it is permissive about trust models. The protocol’s design intent is that anchors are the trust root and the signature is an integrity check. The code enforces a weaker invariant: some trust source must exist.
In anchor-only verification there is no signature, so Rule 4 reduces to “at least one valid anchor” — exactly the guarantee this post argues for. In with_key() + Require mode a verified signature alone can satisfy Rule 4 even if every anchor fails, which is useful when the operator’s key is already a known trusted root for the caller. The verifier accommodates both models; anchor_only() is how you opt into the protocol’s recommended one.
Verifying a Receipt from a Stranger
I send you a receipt today. You have never heard of me. You do not know my signing key. You have no prior relationship with my log instance.
You run:
let verifier = ReceiptVerifier::anchor_only();
let result = verifier.verify(&receipt);
if result.is_valid {
// Verified. Trust came from external anchors, not the operator's key.
}That works. No out-of-band key exchange. No call to a keyserver. No CA validation. The anchors speak for themselves, and if they check out, the receipt is valid.
If you later obtain my signing key and want a stronger guarantee — “I want to confirm this specific operator signed this specific checkpoint, not just that the tree state was anchored” — you upgrade to with_key():
let ck_verifier = CheckpointVerifier::from_bytes(&my_public_key)?;
let verifier = ReceiptVerifier::with_key(ck_verifier);
let result = verifier.verify(&receipt);Same receipt. Same method. The only change is that signature verification is now attempted and the result.signature_status reflects what happened. Whether a Failed status invalidates the receipt depends on SignatureMode: in Require it is a hard error, in the default Optional it is recorded but does not fail validity on its own — Rule 4 can still be satisfied by a valid anchor. If you want the strongest statement — “this specific operator’s key signed this specific checkpoint, full stop” — use SignatureMode::Require alongside with_key().
The receipt format itself did not change. The verifier is stateless with respect to trust — whether you trust via anchors, signature, or both is a property of how you construct the verifier, not a property of the receipt. The receipt carries enough evidence for the strictest verification, and each caller decides how much of that evidence to demand.
What Breaks Under Key Compromise
Consider a scenario: the log operator’s private key is stolen next month. An attacker uses it to sign forged checkpoints.
In a PKI-style system, every receipt signed by that key is now suspect, including receipts issued before the compromise. You have no way to tell, from the signature alone, which side of the timeline you are on.
In ATL Protocol v2.0, the anchors save you. A receipt issued before the compromise has an RFC 3161 timestamp predating the key theft, signed by a TSA whose key was not compromised. It also has (for Receipt-Full) a Bitcoin OTS anchor embedded in a Bitcoin block that existed before the key theft. Both anchors continue to verify. The forged signature on a checkpoint published after the compromise is caught by the inconsistency between the checkpoint the operator now claims and the super root that the pre-compromise anchors already committed to.
The key compromise invalidates future signatures. It does not invalidate past proofs. Because the past proofs were never grounded in the key to begin with.
Why Not Just Omit the Signature?
A reasonable question: if anchors are the trust root, why sign checkpoints at all?
The signature is an integrity check between the operator and their own infrastructure — proof that a checkpoint came from the configured signing key, not from a corrupted cache, a bad disk write, or a compromised path inside the operator’s pipeline. It is defense-in-depth for the operator’s operational integrity, not a trust mechanism for external verifiers. An external verifier can use it as an additional check if they want; a verifier who only wants the protocol-required trust gets everything they need from the anchors.
The Broader Design Principle
Most cryptographic protocols assume that identity is the trust root. You trust a signer, a certificate, a public key. The identity is primary; the data is what the identity attests to.
A transparency log inverts this. The primary object is the log state — a Merkle tree, growing append-only, rooted in a hash. Identity of the operator is secondary. What matters is that the log state, at any given moment, was observed by independent parties (timestamping authorities, blockchains) and cannot be retroactively changed without breaking those observations.
Once you accept that framing, key-based trust stops being necessary. The log speaks for itself, through its anchors. The operator’s signature is incidental — useful as an integrity check, not load-bearing for trust.
ReceiptVerifier::anchor_only() is the API expression of that design principle. A constructor with no parameters that still produces a correct verification. The protocol does not need the operator’s key in your trust store. It never did.
The full implementation is open source: github.com/evidentum-io/atl-core (Apache-2.0)
The files discussed in this post:
src/core/verify/verifier.rs—ReceiptVerifier,anchor_only(),with_key(),compute_validity()src/core/verify/types.rs—SignatureMode,SignatureStatus,VerifyOptionssrc/core/verify/helpers.rs—verify_anchor(),verify_checkpoint_signature()