Metadata-Version: 2.4
Name: tanilo-receipt-verify
Version: 0.2.1
Summary: Verifier for composed verification-state envelopes (RFC 8785 JCS + EdDSA/ES256 JWS). Canonicalization matches the Node reference on the shared fixtures and supported receipt-number ranges. Supersedes agentoracle-receipt-verify 0.1.0.
Author-email: Joe Krausz <joe@tanilo.io>
License-Expression: MIT
Project-URL: Homepage, https://tanilo.io
Project-URL: Repository, https://github.com/TKCollective/tanilo-receipt-verify
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: cryptography>=42.0.0
Dynamic: license-file

# 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

```bash
pip install tanilo-receipt-verify
```

## Usage

**Key material is required to reach a verdict.** Pass `jwks_by_issuer`:

```python
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](https://github.com/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 `kid` not found in any supplied JWKS** → that signer's entry reports `verified: None`, `issuer: "unresolved"`. The envelope's overall status becomes `indeterminate` (not `invalid`) when every other check passes, with `indeterminate_reason` naming which `kid`(s) could not be resolved.
- **Signer's `kid` found, signature verification actually run and fails** → that signer's entry reports `verified: False`. The envelope's overall status is `invalid`.

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:

1. **"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"`.
2. **"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:

```python
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](https://github.com/robertolocatelli81-dev) proposed the same distinction for JWKS lookups as `keys_are_complete` in `cryptovalid-opencore`, and [babyblueviper1](https://github.com/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](https://github.com/babyblueviper1/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](https://tanilo.io/docs/anchoring):
a test receipt issued on 2026-10-06, anchored on GOAT Network (`eip155:2345`).

```python
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`, not
  `anchored`: 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 is `indeterminate`. A trusted contract that does not hold
  the root is `not_anchored`.
- `verify_anchor` makes no network call itself; the lookup makes two JSON-RPC
  calls (`eth_chainId`, `eth_call` of `anchoredAt(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:

```python
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.

```python
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](https://github.com/babyblueviper1/preaction-governance-conformance/tree/3e54ee2/examples/decision-ref-recompute), byte-identical to her Python and our Node.
- `test_conformance_sample_canonical_hash` — reproduces the canonical hash from the production `/v1/conformance/sample` endpoint.
- `tests/test_anchor.py` — the Python anchor check reaches the same answer as the Node implementation in [TKCollective/tanilo-anchor](https://github.com/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`.
