Pure cryptographic library for ATL Protocol v2.0.
Full documentation is available at:
metadata_hash is SHA-256(JCS(metadata)), so every byte of the canonical
form is protocol-visible — and so is the parse that produced the values
being canonicalized. Both halves are requirements, and only one of them lives
in this crate:
-
Rendering. RFC 8785 §3.2.2.3 requires the ECMA-262 §7.1.12.1 number-to-string algorithm verbatim, "including the Note 2 enhancement". Rust's
f64Display is not that algorithm — it never switches to exponential notation (1e+21becomes 22 digits) and it breaks shortest-digit ties away from zero rather than to even. This crate takes the formatter fromryu-js, whose documented contract is the ECMAScript algorithm. -
Parsing. RFC 8785 §3.1 requires number data to be expressible as IEEE 754 doubles, which makes "which double does this literal denote" part of the hash.
serde_json's default number parser is a best-effort fast path that misses the correctly rounded double by one ULP for a large share of inputs — including RFC 8785's own example,333333333.33333329. This crate therefore depends onserde_jsonwithfloat_roundtrip, and Cargo's feature unification carries that to every build linkingatl-core. -
Input constraints. RFC 8785 §3.1 states three MUSTs on the data being canonicalized. They bind the data's producer, not the serializer, and this crate treats them accordingly.
Numbers are normalised, never refused. Appendix B notes (1) and (2) say the algorithm ignores how numbers are used, and Table 1 requires the double 2^68 to serialize as
295147905179352830000rather than be rejected. So9007199254740993canonicalizes to9007199254740992. The one refusal §3.2.2.3 mandates isNaN/Infinity, which no JSON text can produce.Duplicate property names are refused, because neither RFC 8785 nor RFC 8259 §4 says which occurrence wins — two conformant readers can compute two different hashes from identical bytes and both be right, and refusal is the only correct answer where the specification supplies no single one.
Receipt::from_jsonandReceipt::from_slicescan the raw text and refuse.That constraint is a property of a byte stream, so no API taking a parsed structure can check it —
serde_json::from_str::<Receipt>collapses the duplicate before this crate sees it, andfrom_valueis handed a value where it is already gone. Such a receipt is declined, not rejected: it carries no record that its bytes were examined, and the verifier reports "not evaluated" rather than "invalid". Nothing was compared, so nothing was disproved. A producer that assembles receipts itself usesReceiptBuilderand states that provenance at.build(...). There is no way to pass another receipt's marker through: the only public reader answers abool, everyReceiptfield is private behind a shared-reference accessor, and no public method takes aReceiptand returns aReceipt— so a receipt cannot be edited, or rebuilt by method, into holding content its marker never covered.
Any independent implementation must do the same on both sides. Two
verifiers that read the same receipt bytes into different doubles compute
different metadata_hash values, different leaf hashes and different roots,
and nothing downstream can repair it. In Rust, str::parse::<f64> is already
correctly rounded; in Python and in any ECMAScript runtime, the built-in parser
is. It is the JSON library's fast path that is not.
Up to and including 0.27.0 this crate met neither half of the serialization
requirement, so a receipt whose metadata contains a floating-point number
outside roughly 1e-6..1e21, an exact shortest-digit tie, or a
17-significant-digit literal got a metadata_hash no conformant verifier
reproduces. That is fixed rather than grandfathered; see CHANGELOG.md for why
no receipt is affected.
ReceiptVerifier::verify answers "is this receipt acceptable" and collapses
each anchor to is_valid: bool. That boolean cannot tell you whether an anchor
was checked and found false or not checked at all — a missing trust
root, an unimplemented algorithm, a Bitcoin block nobody fetched. Those call
for opposite reactions, and a verifier that reports the second as the first is
publishing an accusation it cannot support.
Use verify_receipt_anchors when you need to say why:
use atl_core::{verify_receipt_anchors, Receipt, VerifyOptions};
let receipt = Receipt::from_json(&json)?;
for anchor in verify_receipt_anchors(&receipt, &VerifyOptions::default()) {
if anchor.is_refuted() {
// A fact about THIS ANCHOR was checked and is false. Report it: an
// anchor is something anybody who relayed the receipt could have
// appended, so a refuted one is evidence that somebody interfered.
// It is NOT evidence against the receipt — see below.
for finding in anchor.refutations() {
eprintln!("{}: {finding}", anchor.anchor_type());
}
} else if anchor.is_indeterminate() {
// Nothing was refuted; this check could not be finished. Do NOT
// present it as a broken anchor either.
for finding in anchor.inabilities() {
eprintln!("{}: {finding}", anchor.anchor_type());
}
}
}
// The receipt's own verdict is a separate question, and no anchor finding
// takes part in it: `ReceiptVerifier::verify`, read through
// `VerificationResult::receipt_errors()`.The three predicates partition, every finding is a VerificationError that
answers is_refutation() for itself, and the full RFC 3161 / OpenTimestamps
fact sets ride along in AnchorFacts::evidence() so nothing has to be
recomputed. Any refutation outranks every inability: is_refuted() is a
question about the whole finding set, never about the first one met.
VerificationResult::is_valid is acceptance, and since 0.29 it means what it
says. ATL v2.0 §5.5 — "At least one anchor MUST be verified to establish trust
in the receipt" — is enforced literally:
- enough verified anchors —
max(1, min_valid_anchors)of them; §5.5's floor is one and a caller may raise it but never lower it. The log operator's own checkpoint signature is not an anchor: §5.4 calls it "an integrity check, not a trust establishment" and §1.2 derives trust "exclusively from external, independent anchors". A shortfall is reported once, asNoTrustAnchor { required, verified }, and is an inability — not enough was proved, nothing was disproved; - nothing may be wrong with the receipt itself — its inclusion proof, its
Super-Tree proofs,
metadata_hash, the provenance of its bytes. "The receipt itself" is exact: see below; - a receipt with no anchors is not accepted — and not refuted either. It is
is_indeterminate()withNoTrustAnchor: nothing about it was shown false, it was simply never attested to by anything outside the log. §5.6's own table calls that tier "internal consistency only".
§5.5 sets a threshold — at least one verified anchor — and says nothing about
the others. That is not a detail to paper over, because a receipt does not
authenticate its own anchors: the leaf hash is SHA256(0x00 || payload_hash || metadata_hash) and the checkpoint blob is 98 bytes of origin, tree size,
timestamp and root hash. The anchors array is in neither. Anybody who relays
a receipt can append an anchor to it, with no key at all.
If an appended anchor that fails verification could veto acceptance, one malformed token would destroy the verification of a receipt carrying a flawless independent anchor — a denial of verification available to every relay, for free. An anchor nobody could verify reports on itself and on nothing else; it cannot undo an existence in time that another anchor established.
So an anchor that fails verification changes no status this crate reports
about the receipt — not is_valid, and not is_indeterminate either.
Scoping only is_valid would have left the same defect one storey up: a
stranger could not make a receipt invalid, but could still flip it from trust
could not be established to this evidence is disproved, which is an
accusation manufactured for free against a document nothing had disproved.
The guarantee is one-sided, and only the one side holds. An anchor that
passes verification raises the verified count and can carry a receipt over
the threshold — at min_valid_anchors: 2, a receipt with one verified anchor
reports NoTrustAnchor { required: 2, verified: 1 } and a second verified
anchor clears it. That is not a gap: it is what anchors are for, and a verifier
in which anchors changed nothing would be worthless. The asymmetry mirrors the
capability. Appending rubbish costs nothing; producing an anchor that verifies
needs a timestamp token over this receipt's root, chaining to a trust root
you supplied.
A receipt whose only anchor is refuted is therefore reported as unattested, not as refuted. Its own facts are untouched; what was found false is an attachment anybody could have added. And that is the only answer a stranger cannot steer.
It is never hidden. The finding stays in VerificationResult::errors,
wrapped in VerificationError::AnchorFinding { index, anchor_type, finding },
still answering is_refutation() as a refutation of the anchor. An appended
anchor is evidence that somebody interfered with the receipt, and a consumer
that drops it because it did not change the verdict is concealing the very
thing it reveals.
VerificationError::is_about_the_receipt() draws the line, and
VerificationResult gives both sides of it directly:
| what you want | what to read |
|---|---|
| decide anything | receipt_errors() — the errors every status is computed from |
| show the tampering | anchor_findings() — never empty-handed, never load-bearing |
| show everything | errors() — both, in the order found |
Nothing derived from the full errors() list is safe to branch on: an anchor
anybody could have appended adds to it, and where in the anchors array it sits
decides where its finding lands.
Because a bitcoin_ots anchor is never verified without a block header, a
receipt whose only anchor is a Bitcoin one is never accepted by this crate
alone. That is not a limitation to route around: it is the honest report of
what an offline verifier established.
Two things this crate cannot answer, by design — it performs no I/O:
- a
bitcoin_otsanchor is neveris_verified(), because the block header whose Merkle root would confirm the OTS proof was never obtained (BitcoinBlockNotObtained), and neither was the block time ATL v2.0 §5.5.2 step 5 asks about; - revocation is never checked (
Revocation::NotChecked).
PathStatus gained a variant, Indeterminate, and the meaning of the
existing ones narrowed. A match with a wildcard arm keeps compiling and
silently changes behaviour, so this needs an explicit audit rather than a
rebuild. TerminalAnchor::Assumed also gained a field, and
Rfc3161AnchorFacts gained two.
Rfc3161AnchorFacts::cms_signature_valid and imprint_matches_root are also
gone, replaced by the three-valued cms_signature: CmsSignature and
message_imprint: MessageImprint. Those do fail to compile, on purpose.
One rule governs the whole change, in both directions: a refutation means
something was checked and is false, and an inability means nothing at all.
Do not let an inability suppress a refutation either — aggregate every fact
before forming a verdict rather than returning at the first non-Verified
one, or an unevaluatable imprint will quietly conceal a refuted signature.
The short version: a refutation now means something was checked and is
false, and nothing else. PathStatus::Incomplete,
PathStatus::Indeterminate, CmsSignature::Indeterminate and
MessageImprint::Indeterminate all describe this verifier's own limits — a
missing certificate, or cryptography this crate does not implement — and must
never be reported as refuted evidence. Route them explicitly and fail closed.
See CHANGELOG.md for the full before/after table and the reasoning.
Apache-2.0