tanilo-receipt-verify
Verifier for composed verification-state envelopes: RFC 8785 JCS canonicalization plus EdDSA/ES256 JWS signature verification. Canonicalization output matches the Node reference on the shared fixtures and supported receipt-number ranges; see the number-formatting limitation below.
JCS number formatting: this implementation's number serialization matches RFC 8785 for the value ranges receipt fields actually use (small integers and simple decimals) — it does not implement the full RFC 8785 §3.2.2.3 ECMAScript-compatible number-to-string algorithm across every double (extreme exponents, -0, etc.). If a future receipt field ever carries a number outside that range, re-verify canonicalization against the Node reference before trusting a byte-identical claim for it.
Supersedes agentoracle-receipt-verify 0.1.0. Same author, same verifier, corrected defect (AC-11) described below. If you have agentoracle-receipt-verify installed, switch to this package; agentoracle-receipt-verify will not receive further fixes.
Design goal
Three language bindings, one canonicalization. A receipt canonicalized in Node, Python, or the browser must produce the byte-identical string and byte-identical SHA-256. No language-specific behavior. No trusted issuer round-trip.
Install
pip install tanilo-receipt-verify
Usage
Key material is required to reach a verdict. Pass jwks_by_issuer:
from tanilo_receipt_verify import verify
result = verify(envelope, jwks_by_issuer={
"https://agentoracle.co/.well-known/jwks.json": ao_jwks,
"https://agenttrust.uk/.well-known/jwks.json": at_jwks,
})
if result.status == "valid":
print("verified — canonical:", result.canonical_sha256)
Three outcomes, not two
status |
valid |
Meaning |
|---|---|---|
"valid" |
True |
Every check ran and passed |
"invalid" |
False |
A check ran and failed; see .errors |
"indeterminate" |
None |
A check could not run; see .indeterminate_reason |
Calling verify(envelope) without jwks_by_issuer on a signed envelope returns indeterminate, not valid. Canonicalization recompute proves the payload matches its claimed hash; it binds the payload to no issuer. Only the signature does that. A verifier that reported valid there would assert a property it never tested.
None is falsy, so if result.valid: fails closed. Branch on .status when you need to distinguish "failed" from "could not check".
What it checks
| Invariant | Description |
|---|---|
canonical_recomputes |
JCS(payload) → SHA-256 recomputes byte-identical to claimed |
decision_ref_recomputes |
sha256(JCS(preimage)) matches published decision_ref (per invinoveritas/babyblueviper1 spec) |
decision_signer_ne_runtime |
Decision signer issuer ≠ runtime issuer (fail-closed: self-approval is void). None when either issuer is absent from the payload — the check could not run, which is not the same fact as "it ran and they matched" |
all_signatures_verified |
Every JWS signature (EdDSA or ES256, per draft-krausz-verification-state-02 §5.1) verifies against a resolvable JWK by kid. None when a signer's key material could not be resolved from the supplied JWKS, its key material was malformed, or its algorithm isn't one of the two this verifier implements — unevaluated, not failed. False for an unresolved kid instead of None when jwks_is_complete=True — see below |
Corrects a defect in agentoracle-receipt-verify 0.1.0 (AC-11)
"AC-11" names this bug class — a checker's own incomplete input reported as a finding about the artifact it's checking, rather than a fact about the checker — as case AC-11 in stillmarcus24/assurance-run, the independent conformance suite this class of defect was first named in. This package's own instance of it (below) is credited under that name, not coined here.
agentoracle-receipt-verify 0.1.0 correctly returned indeterminate when called with no jwks_by_issuer at all. It did not correctly handle the partial case: a composed, multi-signer envelope where the caller supplies JWKS for some but not all signers.
For a signer whose kid could not be matched against any supplied JWKS, 0.1.0 recorded {"verified": False} — the same value a genuine cryptographic signature failure produces. all_signatures_verified then read that as a real failure, and the adjudication logic ranked it above "unevaluated," returning status: "invalid" for an envelope that was never actually disproven — only partially checked.
This package resolves the two cases distinctly:
- Signer's
kidnot found in any supplied JWKS → that signer's entry reportsverified: None,issuer: "unresolved". The envelope's overall status becomesindeterminate(notinvalid) when every other check passes, withindeterminate_reasonnaming whichkid(s) could not be resolved. - Signer's
kidfound, signature verification actually run and fails → that signer's entry reportsverified: False. The envelope's overall status isinvalid.
An unresolved key lookup and a cryptographic failure are different facts. Reporting both as invalid collapsed "we never checked this" into "this was checked and failed" — the same failure class documented in the 0.1.0 CHANGELOG for the fully-omitted-JWKS case, here on the partial-JWKS path instead.
The two meanings of a missing key — jwks_is_complete
An unresolved kid can mean two different things, and this verifier cannot tell them apart on its own:
- "This is whatever keys I happened to have on hand." A missing kid is a gap in what the caller fetched, not a statement about the receipt. That's the default above:
verified: None,status: "indeterminate". - "This IS my complete trust list." The caller is asserting every issuer they will ever accept is already in
jwks_by_issuer, so a kid that isn't there is a deliberate refusal, not a gap.
Both readings are legitimate; the verifier has no way to guess which one the caller means, so it's an explicit argument instead of a guess:
result = verify(
envelope,
jwks_by_issuer={"https://agentoracle.co/.well-known/jwks.json": ao_jwks},
jwks_is_complete=True, # this IS the whole trust list
)
# an unresolved kid now reports verified=False, is named in .errors,
# and the envelope's status is "invalid" — a policy refusal, not a gap
jwks_is_complete defaults to False, matching every example above with no behavior change. Setting it to True only changes what happens when a kid can't be resolved against the supplied JWKS — it does not change how a signature that resolves and genuinely fails cryptographic verification is reported; that's always invalid either way.
Credit where it's due: this split converged out of a public thread rather than being invented here. robertolocatelli81-dev proposed the same distinction for JWKS lookups as keys_are_complete in cryptovalid-opencore, and babyblueviper1 shipped the same distinction for a different artifact — a referenced-proof set, not a key set — as --referenced-set-is-complete in preaction-governance-conformance. Both land on: absent-by-default is an absence, not a judgment; the caller has to declare completeness before a missing entry becomes a refusal. jwks_is_complete here is that same shape applied to this package's own JWKS lookup.
verify() never raises
Any input -- not just a well-formed envelope with the wrong content, but a
non-dict envelope, a non-dict jws, a non-string payload, or a
signatures value that isn't a list of objects -- returns a VerifyResult.
It never raises.
Structurally malformed input (wrong shape, checkable directly) returns
status="invalid" with a specific error. Anything unanticipated deeper in
the verification logic is caught and returned as status="indeterminate"
with indeterminate_reason beginning "instrument_failure: " --
draft-krausz-verification-state-02 Section 3.1's reason code for a check that
could not run to completion. Either way, a caller receives a VerifyResult with status valid, invalid or
indeterminate; these are verifier statuses, not the draft's four claim states;
never a traceback -- per stillmarcus24's report (tsc#4,
2026-09-24) that 0.1.0 raised on 22 of 30 malformed envelopes he tested, from
unguarded call sites at the top of verify() and inside its signature loop.
See CHANGELOG.md for the full account.
Anchor check: a time bound on the payload (0.2.0)
A receipt's signature shows which key signed it. An anchor adds one thing: the
receipt's canonical payload existed no later than the timestamp of a public
block. verify_anchor checks a tanilo.anchor.v1 proof for a receipt. It is
separate from verify() and never changes a signature result.
What an anchor shows: the canonical payload existed by the anchoring block's timestamp, assuming SHA-256 behaves as designed and the chain's record of that block and its timestamp is what it appears to be. What it does not show: when the signature was made or the receipt issued (the payload may be older than the block, and a signature over it can be made at any time); which key signed it (the signature shows that, and associating the key with an issuer requires a key set you have authenticated as the issuer's); or that the claim in the receipt is true. Anchor status is reported beside signature validity, never folded into it.
This is the live example from tanilo.io/docs/anchoring:
a test receipt issued on 2026-10-06, anchored on GOAT Network (eip155:2345).
import json
from tanilo_receipt_verify import verify, verify_anchor, evm_contract_lookup
CONTRACT = "0xddCC4eb18b39a520b874046b91b748B5E8cE7C54" # the contract you trust
receipt = json.load(open("receipt.json"))
jwks = json.load(open("jwks.json")) # a key set you have authenticated as the issuer's
proof = json.load(open("proof.json"))["proof"]
# 1. The signature, and the hash recomputed from the signed payload.
r = verify(receipt, jwks_by_issuer={"https://tanilo.io/.well-known/jwks.json": jwks})
assert r.status == "valid"
# 2. Offline: the path from the verifier's hash (never the proof's own leaf) to the root.
print(verify_anchor(r.canonical_sha256, proof).merkle_ok) # True
# 3. Against the chain: when the trusted contract says that root was anchored.
lookup = evm_contract_lookup("https://rpc.goat.network", trusted_contracts=[CONTRACT], chain_id=2345)
a = verify_anchor(r.canonical_sha256, proof, {"evm-contract": lookup})
print(a.status, a.anchored_at) # anchored 2026-10-06T00:36:56Z
The receipt, the proof and the commands that fetch them are on the documentation
page; the same files are in tests/fixtures/anchor/mainnet-2026-10-06/.
- Three outcomes, never a raise:
anchored,not_anchored,indeterminate. - Without a lookup, a proof whose path verifies is
indeterminate, notanchored: the path alone does not show the root was published anywhere. - You list the contract addresses you trust. A proof that names another
contract is
indeterminate, because an unknown contract could report any time it likes. An RPC on a different chain, an unreachable RPC or an unexpected answer isindeterminate. A trusted contract that does not hold the root isnot_anchored. verify_anchormakes no network call itself; the lookup makes two JSON-RPC calls (eth_chainId,eth_callofanchoredAt(bytes32)) when you pass it in.- Pass the hash your verifier recomputed, not the proof's own
leaf. The anchor check does not check the signature or that a hash belongs to the receipt you hold. - The lookup reads the contract's current
anchoredAt(root)as the RPC endpoint reports it. It does not verify the transaction details carried in the proof and does not establish finality. - A proof that points at a testnet which was since reset can no longer be confirmed; the three proofs of the first testnet3 batch are kept as test fixtures with their retired contract for that reason.
Ed25519 small-order guard (0.2.1)
With the identity public key, the signature R = identity, S = 0 satisfies the verification equation for every message. For other small-order keys, acceptance depends on the verifier's equation and the message. This release rejects all small-order public keys and R values.
Acceptance of these edge cases varies by library, backend and version. In our measurements, cryptography 50.0.1 (OpenSSL 4.0.2) and Node 18 (OpenSSL 3.0.16) accepted Taming cases 0, 1, 2 and 11 and the identity-key signature; Node 24 (OpenSSL 3.5.7) rejected them. This release applies its input restrictions independently of the backend.
Since 0.2.1 verify() checks the raw key and signature bytes before the
library is called (tanilo_receipt_verify.ed25519_guard, pure Python, no new
dependency):
- the public key must decode per RFC 8032 section 5.1.3 (x = 0 with the sign bit set is invalid), must be canonically encoded (y < p), and must not be a small-order point (order 1, 2, 4 or 8), whatever its encoding;
- the signature's R must decode and must not be a small-order point;
- the signature's S must satisfy 0 <= S < L.
A rejection is a failed verification with a named reason, never an exception:
r = verify(envelope, jwks_by_issuer=...)
r.status # "invalid"
r.signers[0]["verified"] # False
r.signers[0]["reason"] # "small_order_key: the public key encodes a small-order point ..."
The reason starts with one of small_order_key, small_order_r,
noncanonical_key_encoding, noncanonical_r_encoding, key_not_on_curve,
r_not_on_curve, signature_s_out_of_range, signature_length, key_type
or signature_type. An ordinary cryptographic failure carries no reason,
as before.
All five Tanilo published public keys tested pass the new public-key checks. A scan of nine Tanilo repositories and review exports, covering 670 files, identified 13 distinct signatures under those keys; all 13 pass the new guard. These results cover the keys and signatures found in that scan, not every historical receipt or full receipt verification. Under normal RFC 8032 signing, small-order R is negligibly unlikely but not impossible. Version 0.2.1 intentionally rejects equation-valid signatures with small-order R, including ones a key holder constructs deliberately. The identity-key acceptance issue concerns a supplied key set containing a crafted key.
Not checked: a mixed-order key or R (outside the prime-order subgroup but not itself small-order). RFC 8032 does not require rejecting those.
The identity-key behavior was reported by Noûs through @robertolocatelli81-dev
in x402-foundation/tsc#4. @babyblueviper1 reproduced it and added small-order
checks to preaction-governance-conformance at 90b5032. This release adds
corresponding input restrictions to Tanilo's verifier.
Vectors from "Taming the many EdDSAs" (Chalkias, Garillot, Nikolaenko, 2020;
novifinancial/ed25519-speccheck). What the installed backend itself accepts is
reported by python tests/diagnostic_ed25519_backend.py.
Which mapping document a receipt binds to (0.2.1)
mapping_policy() inspects unverified payload claims. A known result means only that the supplied mapping identifier and hash match the configured registry; it does not authenticate the receipt, issuer, signing time or mapping application. Verify the same envelope separately using authenticated trust material.
TANILO_MAPPING_CUTOVER_AT is a configured policy threshold,
2026-10-13T00:07:22.431Z: the first instant at which the deployment serving
tanilo-v0.3-2026-10-13 was observed answering on api.tanilo.io. The previous
deployment's last observed answer was at 2026-10-13T00:07:21.433Z; the
planned start had been 2026-10-13T00:00:00Z.
When the time rule applies (a legacy id with cutover_at set) and signed_at is
missing or cannot be parsed, the status is unknown with the note "time policy
not evaluated". A malformed payload gives absent; a bad configuration argument
gives unknown with a configuration-error note. Nothing raises.
from tanilo_receipt_verify import mapping_policy
p = mapping_policy(receipt) # Tanilo's two published documents by default
print(p.status, p.mapping_id, p.mapping_hash, p.signed_at, p.notes)
known, unknown, legacy_after_cutover or absent. Informative only: it never raises and does not change verify()'s status. Receipts signed before the cutover on 13 October 2026 (UTC) carry agentoracle-v0.3-2026-05-30; receipts signed after it carry tanilo-v0.3-2026-10-13 (same rules, Tanilo identifier). Rely on mapping_id and mapping_hash rather than the signing date to tell them apart.
Cross-language guarantees
The tests/ suite includes byte-identical fixtures shared with the Node reference implementation:
test_jcs_byte_identical_to_node— Python JCS output byte-matches Node output for a payload with nested objects, arrays, unicode, booleans, and integers.test_decision_ref_recompute_babyblueviper1— Python recomputes the shipped invinoveritas fixture, byte-identical to her Python and our Node.test_conformance_sample_canonical_hash— reproduces the canonical hash from the production/v1/conformance/sampleendpoint.tests/test_anchor.py— the Python anchor check reaches the same answer as the Node implementation in TKCollective/tanilo-anchor on all 78 valid and 15 invalid RFC 6962 vectors (tests/fixtures/anchor/merkle-proofs.json, byte-identical copy, CC0-1.0).
License
MIT — see LICENSE.
Metadata
Release files for tanilo-receipt-verify 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| tanilo_receipt_verify-0.2.1.tar.gz | 88.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tanilo_receipt_verify-0.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 120.5 kB
Release files / tanilo_receipt_verify-0.2.1.tar.gz
| Download URL | tanilo_receipt_verify-0.2.1.tar.gz |
|---|---|
| Size | 88.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
67bedbbde1a95ca3fb521f36f096d7f8fd56fab69e68ddc723dce84b338ee3b7
|
|
BLAKE2b-256 checksum How to use checksums |
99e9dc91ca355885d5424d8b616e3d4243da2d74b8a6ff711d0fd97df8b5f55e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Release files / tanilo_receipt_verify-0.2.1-py3-none-any.whl
| Download URL | tanilo_receipt_verify-0.2.1-py3-none-any.whl |
|---|---|
| Size | 32.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2acc8939b7a2890bee70354426f0de880a9f1b22ccde89ac921db84392b32ab2
|
|
BLAKE2b-256 checksum How to use checksums |
4bec5964894a55379f2859ed89ddbca95938048612eccefeba04b93aeb32edd1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|