diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 3e34ba3b9..881f06745 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -86,7 +86,7 @@ jobs: run: cargo build --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film - name: Library unit tests (the fixed-point determinism floors) working-directory: wai/wai-rs - run: cargo test --lib --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,oer + run: cargo test --lib --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,oer,receipt_claims # The JPEG AI exact decoder reproduces across platforms only if no platform math # library is on its path. Two guards, each failing the step: clippy's # disallowed-methods (wai-rs/clippy.toml), which each module on the decode path @@ -144,7 +144,7 @@ jobs: working-directory: wai/wai-rs run: | set -euo pipefail - F=world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,quantum,quantum_ops,oer + F=world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,quantum,quantum_ops,oer,receipt_claims v() { echo "::group::$1"; cargo run -q --no-default-features --features "$F" --bin "$2" -- verify "$3"; echo "::endgroup::"; } v worlds wai_world_vectors ../world-conformance v spatial-audio wai_audio_vectors ../audio-conformance @@ -158,6 +158,7 @@ jobs: v quantum wai_quantum_vectors ../quantum-conformance v quantum-ops wai_quantum_ops_vectors ../quantum-ops-conformance v oer wai_oer_vectors ../oer-conformance + v claims wai_claim_vectors ../claim-conformance # OER's second implementation is portable C, not Rust. Rebuild it from source and # require its output to match the pinned corpus byte for byte: the kernel is # deterministic, and wai_oer_vectors above has already verified the pinned copy, @@ -178,7 +179,7 @@ jobs: shell: pwsh working-directory: wai/wai-rs run: | - $F = "world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,quantum,quantum_ops,oer" + $F = "world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,quantum,quantum_ops,oer,receipt_claims" function v($grp, $bin, $path) { Write-Output "::group::$grp" cargo run -q --no-default-features --features $F --bin $bin -- verify $path @@ -197,6 +198,7 @@ jobs: v quantum wai_quantum_vectors ../quantum-conformance v quantum-ops wai_quantum_ops_vectors ../quantum-ops-conformance v oer wai_oer_vectors ../oer-conformance + v claims wai_claim_vectors ../claim-conformance byte-exact: name: cross-arch byte-equality · ${{ matrix.os }} diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 9ed97c1f8..21b565f0c 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,83 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### Typed receipt classes, and a second (soft) binding class + +- [`extensions/jwp-receipts.md`](extensions/jwp-receipts.md) §2.4.3–§2.4.7: every + receipt now belongs to one **class**. `group`/`object` are the `delivery` class, with + their wire form unchanged; a new `claim` kind carries the others on one envelope (JCS, + RFC 8785), one Ed25519 signing rule and a per-signer chain kept separate from the + group chain. Classes: `energy`, `lawful-basis` (a declaration of purposes each with + its own basis, or a decision recorded against one) and `log-inclusion` (the log's + evidence carried verbatim — accepting the claim is not verifying the inclusion). An + unknown class is rejected, never skipped; `delivery` is reserved to `group`/`object`. +- **`energy`** gives an object's figure a signed place for its acquisition class, which + the object receipt has no member for + ([`extensions/energy-measurement.md`](extensions/energy-measurement.md) §1 now says + so, and that a group total still has none). The claim restates the figure and carries the + class with its evidence in energy-measurement §2.7's JSON fields — `energy_provenance`, + `energy_uncertainty`, `energy_counter_filtering`, `energy_calibration_ref`, present and + `null` where a class has none — admitted through `crate::energy_class`: a refined + hardware class needs its declared uncertainty above the resolution floor, a counter + its filtering state and an instrument its calibration reference, and legacy `HwShunt` + is refused (`energy_legacy_acquisition`, `energy_acquisition_evidence`). Its grade may + not rank above what the class supports under + [`extensions/oer-receipts.md`](extensions/oer-receipts.md) §2 — both refined + hardware classes `measured`, `ModelBased` `derived`, `Estimator` `projected` — so a + verifier that checks an accepted claim against the OER receipt it names holds one + graded within the class the claim states. A named OER receipt + is read, verified and its figure read by the OER module under oer-receipts' own rules + (a vector document read under §4's profile, a figure read as §4 reads a quantity and + compared exactly); the claim layer has no reading of OER of its own. +- §2.6 and §3.2: a **second binding class**. A publisher-signed `soft-binding` + declaration names a registry algorithm (reverse-DNS form, aligned with the C2PA + soft-binding algorithm list), its type, the value and an `https` resolution endpoint; + a `binding-recovery` record states `attests: "ancestor"`. **The ancestor rule**: a + receipt reached through a soft binding attests the ancestor, never the object in + hand, and the transformation's energy is unaccounted rather than zero. A declaration + counts only if signed by the publisher of an already-verified delivery receipt for its + subject, and only once its own signature is verified — when a recovery is performed, + and when a recorded one, or a lawful-basis decision, is checked against the + declaration it names (`context_invalid` otherwise). +- §3.1 step 3 restates RFC 8032's own decoding rule (a key has one canonical encoding) + and range rule (`S` < L), and fixes the two choices on which RFC 8032 verifiers + differ: keys and `R` of small order are refused, and the equation is the cofactorless + one. Step 1 states what JSON is `malformed` where parsers part — a byte-order mark, a + repeated member name, an escape that leaves a surrogate unpaired, nesting deeper than + 32 — and that a number never is (`1e400`, `-0` and a 400-digit integer are + `field_type`); `issued_at` must name a day its month has; `resolution_endpoint`'s host + must be non-empty and its port digits. Step 2 checks exactly the type each member + table gives, a member's type and encoding together in the table's order (a + lawful-basis body's `record` first, a nested object's members where the table reaches + it); what a class's rules add to a type — a non-zero figure or work count, a non-empty + basis, an `alg` in registry form, an `https` endpoint — is step 4's, with the rule's + own reason. +- `wai-rs --features receipt_claims` (`crate::claim_receipt`, `wai_claim_vectors`) and + `claim-conformance/`: vectors covering every reason, run by CI in both corpus loops, + with the feature added to the library tests. A canonical fixture computed by + a separate implementation (`claim_verify.py`: Python JSON with UTF-16 member order, + `cryptography` Ed25519 plus step 3's key, `R` and `S` checks written from the curve's + definition, BLAKE3 from its specification) is pinned in `wai-rs`; that script also + reproduces every vector's outcome, reading OER receipts with the OER reference + verifier. `claim-conformance/differential.py` runs both verifiers on generated inputs + the corpus does not hold and fails on any differing verdict. Both are run by hand, + not in CI. +- OER: `py_float_repr` wrote a double half-way between two shortest decimals with the + upper one (`75.036163330078125` as `75.03616333007813`), where the reference's `repr` + writes the even one (`…812`). §4's number profile keeps floats out of every receipt + body, so no content address or verdict depended on it. `str()` of a number no check + reads did — a vector's name, which the reference and `wai-rs` (`wai_oer_vectors`, + `wai_oer_diff`) print — so the two printed different names for such a vector. The + function now takes the length from the shortest round-trip formatter and the digits + from the exact one, keeping the shortest digits where the nearest decimal of that + length does not read back (next to a power of two); a table CPython generated pins + fourteen such doubles. +- Not done here: key discovery (every claim signature presumes the verifier holds the + key), an authority class for who may sign which class (a lawful-basis decision is + checked against its declaration whoever signed it: nothing supplies an operator's key + the way a verified delivery receipt supplies a publisher's), region- or time-scoped + soft bindings, and checking a log's receipt under its profile. + ### `wai.neural.int_mlicpp` — the MLIC++ entropy topology at decode-equivalence - New capability `wai.neural.int_mlicpp` (`wai-rs/src/int_mlicpp.rs`, behind diff --git a/wai/SPEC.md b/wai/SPEC.md index f5e714355..3c0a3a750 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -265,7 +265,11 @@ capabilities are added by registration; the WAI envelope is unchanged. See [`extensions/jwp-receipts.md`](extensions/jwp-receipts.md) for the receipt-chain sub-spec covering per-object content hashes, per-group -Merkle roots, Ed25519 group signatures, and cross-hop preservation. +Merkle roots, Ed25519 group signatures, and cross-hop preservation; the +typed receipt classes beside delivery (`energy`, `lawful-basis`, +`log-inclusion`) carried as `claim` receipts; and a second, weaker +binding class (a registry-named soft binding with a resolution endpoint) +whose recovered receipts attest the ancestor, never the object in hand. See [`extensions/energy-measurement.md`](extensions/energy-measurement.md) for how a receipt's `joules_micro` is acquired: the required acquisition class (`CalibratedInstrument` / `OnChipCounter` / `ModelBased` / `Estimator`, with the diff --git a/wai/claim-conformance/.gitattributes b/wai/claim-conformance/.gitattributes new file mode 100644 index 000000000..165556bc9 --- /dev/null +++ b/wai/claim-conformance/.gitattributes @@ -0,0 +1,3 @@ +# The pinned corpus is compared byte-for-byte with what the generator writes (LF), on +# every platform of the CI matrix. Keep a CRLF checkout from turning that into a failure. +* text eol=lf diff --git a/wai/claim-conformance/README.md b/wai/claim-conformance/README.md new file mode 100644 index 000000000..252df22af --- /dev/null +++ b/wai/claim-conformance/README.md @@ -0,0 +1,239 @@ +# claim-conformance + +Conformance for [typed receipt classes and the soft binding class](../extensions/jwp-receipts.md) +(§2.4.3–§2.4.7, §2.6, §3.1–§3.2): `claim` receipts of class `energy`, +`lawful-basis`, `log-inclusion`, `soft-binding` and `binding-recovery`, and recovery +through a soft binding under the ancestor rule. + +Each vector declares **one outcome**: `accept` — with the claim's `link_hash`, which +pins the canonical form, or for a recovery the ancestor it attests — or `reject` at a +named §3.1 step with a named reason. A verifier that rejects for another reason, or at +another step, does not conform. + +## Files + +| file | what it is | +|---|---| +| `vectors.json` | 154 vectors: 27 accepted claims, 119 rejected claims, and 8 recoveries (2 accepted, 6 rejected). Every reason of §3.1 is exercised at least once. | +| `claim_verify.py` | A second verifier, written from the extension text, that shares no code with `wai-rs`. | +| `differential.py` | Generates inputs the corpus does not hold, runs both verifiers on them, and reports every input on which their verdicts differ. | + +Two procedures appear. `claim` verifies one receipt (§3.1), with optional context the +verifier holds: `previous` (this signer's previous claim, step 5), `object` (the +delivery receipt's `content_hash` and `joules_micro`), `oer` (an OER receipt and its +signer keys), `declaration` (the claim a decision or a recovery record names), +`publisher` (for a recovery record: the key of a verified delivery receipt for the +declaration's subject — §2.6.3 makes the declaration context only together with it, so +a vector that gives a recovery record one without the other is malformed), and +`exact_match_algs` (the soft-binding algorithms the verifier has a matcher for; the +corpus's matcher is byte equality). `recover` runs §3.2 from a `declaration`, the +`publisher` key taken from a verified delivery receipt, the `observed_value`, and the +object in hand's `observed_hash` (or `null`). Both procedures report +`recovery_signer_mismatch` for a declaration the ancestor's publisher did not sign: +`recover_signer_mismatch` and `binding_recovery_declaration_not_publishers` are the same +defect, in a recovery performed and in one recorded. + +Everything is synthetic. Keys are Ed25519 from fixed seeds (0x11 publisher, 0x22 meter, +0x33 operator, 0x44 sink, 0x55 resolver) and attest nothing; figures, uncertainties and +calibration references are invented; algorithm identifiers, registries, endpoints, logs +and calibration certificates use reserved example domains; vocabulary terms are +illustrative and no verifier here checks them against a vocabulary. + +## The acquisition class + +An `energy` claim carries its class as energy-measurement §2.7 writes a class in JSON: +`energy_provenance`, with `energy_uncertainty`, `energy_counter_filtering` and +`energy_calibration_ref` present and `null` where the class has no such evidence. +Twenty-eight vectors are about the class and its evidence: + +- **Accepted:** an `OnChipCounter` figure with a declared uncertainty (2 % over 1 s) and + its filtering state `Unknown`, and with `Off`; a `CalibratedInstrument` figure with a + declared uncertainty (0.5 % over 1 s) and a calibration reference; a counter graded + `derived`, below what it supports; a 1 µJ figure declared at exactly the resolution + floor (288,676 ppm); and a counter graded `measured` that names an OER receipt grading + the same figure `measured`. +- **Rejected at step 4:** legacy `HwShunt`, alone and with evidence beside it + (`energy_legacy_acquisition`); a label in another case (`energy_unknown_acquisition`); + and, as `energy_acquisition_evidence`, a counter without its uncertainty or without its + filtering state (`null` is not `Unknown`), with a state outside `On`/`Off`/`Unknown`, or + with a calibration reference; an instrument without its reference, with a reference of + white space only (a space and U+3000), or with a filtering state; a `ModelBased` figure + with an uncertainty; an uncertainty of zero ppm, of a zero window, or of 2³² ppm; and a + 1 µJ figure at 288,675 ppm, one below the floor. An `Estimator` graded `simulated` is + `energy_grade_exceeds_acquisition`. +- **Rejected at step 2:** an uncertainty without its window (`missing_required_field`), + with a coverage factor beside it (`unknown_member`), with its relative part as a + string, written as an array, and a filtering state written as a number (`field_type`). +- **Rejected at step 3:** a `ModelBased` claim rewritten in transit to `OnChipCounter` + with an uncertainty and a filtering state: the class and its evidence are signed. + +## Steps 1 and 2: types, and the order a body is read in + +§3.1 step 2 checks exactly the type a member table gives, member by member in the +table's order; whatever a class's rules add to a type is step 4's, with the rule's own +reason. Four step-4 vectors pin that boundary from the rules' side: a zero figure +(`energy_zero_figure`), a zero work count (`energy_zero_work`), an empty basis +(`lawful_basis_purpose_without_basis`) and an `http` endpoint +(`soft_binding_endpoint_not_https`). Eight more pin what the text settles on the other +side, each signed again after its edit so the edit is its only defect: + +- **Step 1:** a byte-order mark before the text is `malformed` (RFC 8259 lets a parser + ignore one); a `class` written as a number is `field_type`, not `unknown_class`. +- **Step 2, types:** an empty `alg` is not a term; an endpoint with no scheme is not an + absolute URI; a work unit holding U+3000, a White_Space character outside ASCII, is not + a term. Each is `field_type` at step 2: `alg` is a term, not a string left whole to + step 4's registry form; an endpoint's type is an absolute URI, and only `https` is step + 4's; a term's white space is Unicode's White_Space, not ASCII's. +- **Step 2, order:** a lawful-basis body whose `record` names neither record is + `field_type`, since `record` is read first; a declaration whose vocabulary has no scheme + and whose purpose has no basis is `field_type`, since the table reaches `vocabulary` + first and its encoding is checked as it is read; and one whose first purpose's term + holds a space and whose second carries an extra member is `field_type`, since each + purpose is read whole before the next. A reader that checks every purpose's members + before the other members, or every encoding after every member, reports these two as + `missing_required_field` and `unknown_member`. + +## The OER receipts + +The OER receipts in `oer` contexts are sealed with `wai-rs`'s OER module (its canonical +form and content address), and read and verified by each verifier's own OER verifier +under [oer-receipts](../extensions/oer-receipts.md): the reference verifier +(`../oer-conformance/oer2_verify.py`) reports the valid ones valid, the one altered after +sealing as `receipt_id_mismatch` and `receipt_hash_mismatch`, and as `malformed` the six +whose figure is not a quantity oer-receipts §4 reads or whose body holds a number §4 +forbids: `joules_total` as a decimal string of 35 significand digits, as the JSON number +`0.0120005`, as `-0.0`, as the integer 2⁵³, spelt `+0.012000`, and as `true`. Two valid +ones state the claim's figure and grade but are not about its object (an unrelated +object, and the object's hash spelt in uppercase); step 6 rejects both. + +Seven vectors state a figure step 6 reads at its edges, as oer-receipts §4 reads a +quantity and compares it exactly: `0.0120005`, exactly half-way, which admits 12,000 and +12,001 µJ; the same decimal written `1.20005e-2`; the integer `1`, one joule; and two +figures written with 34 significand digits, the most a decimal string holds — one exactly +half-way, and one (`0.0120004` followed by twenty-six nines) below half-way only in its +last digits, which is 12,000 µJ and not 12,001, although rounding it to 28 significant +digits would make it a tie. + +## Signatures + +Seven vectors test §3.1 step 3 beyond an ordinary bad signature. + +- **Six satisfy the verification equation** — the cofactorless one, and so the + cofactored one — with the key decoded leniently (`y` reduced mod p, the sign bit + ignored when x = 0) and `S` reduced mod L. The generator asserts this for each. Step 3 + refuses them on two different grounds: + - *RFC 8032's own rules*, which step 3 restates: the identity key written with its sign + bit set, and written with `y` = p + 1, each with a signature made without a private + key (§5.1.3 decoding refuses both encodings; the point they spell is also of small + order, so step 3's choice refuses them too); and `S` replaced by `S` + L (§5.1.7 + requires `S` < L). + - *The choices step 3 fixes*: the identity key and a key of order 8, each "signed" with + no private key, and a genuine key signing with `R` the identity. An RFC 8032 verifier + that accepts keys and `R` of small order accepts these three. +- **One does not** (`signer_key_y_not_reduced`): a key of large order written with + `y` = p + j. Nobody can sign for that point, so the vector carries another key's + signature, which fails the equation under any decoding (the generator asserts that + too). It pins only the reason — `bad_signer_key`, not `signature_verify_failed` — and + does not show that a lenient verifier would accept a forgery. + +The `cryptography` package's Ed25519 on its own is more lenient than RFC 8032's +decoding. Called on each vector's key, signature and signing input (`cryptography` +50.0.1 over OpenSSL 3.6.4, Python 3.14.7, macOS on arm64), it accepted five of the six +that satisfy the equation — both non-canonical spellings of the identity and the three +vectors of the second group — and of the seven refused only `S` + L and the `y` = p + j +vector. `claim_verify.py` therefore makes step 3's decoding, order and range checks +itself, and leaves only the equation to `cryptography`. + +## Running + +```sh +# wai-rs: the canonical fixture, byte-identical regeneration, and every vector +cargo run --no-default-features --features receipt_claims --bin wai_claim_vectors -- verify ../claim-conformance + +# the second verifier (needs Python 3 and `cryptography`) +python3 claim_verify.py + +# the two verifiers' verdicts on generated inputs the corpus does not hold +cargo build --no-default-features --features receipt_claims --bin wai_claim_vectors +python3 differential.py /debug/wai_claim_vectors [--random N] [--seed S] [--keep FILE] +``` + +CI runs the first on the four-platform conformance matrix. The second and third are +run by hand; they are not CI steps. + +`differential.py` writes its inputs in the corpus's format with no declared outcome +and runs each verifier's `verdicts` mode (`wai_claim_vectors verdicts `, +`claim_verify.py verdicts `), which prints one verdict per input. Its families: +JSON numbers and strings at the limits of what parsers accept, nesting depth, calendar +dates, URI authorities, acquisition classes with every combination of label, +uncertainty, filtering state and calibration reference around energy-measurement +§2.3–§2.5's rules (calibration references of every White_Space character, and of +U+001C, U+001F, U+200B and U+FEFF, which are not White_Space), OER figures near a +half-microjoule and at the edges of oer-receipts §4's quantities, OER receipts whose sums +and floors sit at §6's tolerances, OER receipts holding numbers §4 forbids in a body +(`-0`, fractions, exponents, integers beyond I-JSON's range) in members of every kind, a +delivered object's microjoules spelt as the corpus never gives them, context receipts +that do not themselves verify, bodies of every class with one defect from a fixed list +and with every pair of those at two different positions (a member, or a member of a +nested object), where the order §3.1 step 2 reads a body in decides the reason, and, with +`--random N`, a seeded random sample of dates, endpoints, figures, sums and class +combinations. Separately, each value that makes a whole file unreadable under +oer-receipts §4 — a number beyond the double range, `NaN`, an unpaired surrogate escape, +a repeated member name, nesting past 64 levels — goes in a file of its own beside +readable twins, and the two verifiers must both refuse the file or give it the same +verdicts. It exits 0 only if the two verdict lists are identical, every file is read or +refused alike, and neither verifier crashed; any difference exits 1, at whatever step. + +Both verifiers read a corpus-format file as oer-receipts §4 reads a vector document, +since it holds OER receipts: `wai-rs` with `oer::load`, `claim_verify.py` with the OER +reference's `load`. A difference where one side reports `energy_oer_invalid` is marked, +since its cause is in the two OER verifiers rather than the claim rules, but it counts +the same. Those two are compared on their own by `../oer-conformance/fuzz_differential.py`. + +## What each check can and cannot show + +The expected outcomes are declared per vector in the generator (`wai-rs`, +`claim_receipt::corpus`), not copied from a verifier's output. The signed bytes, +though, are produced by the same module that verifies them, so a canonicalisation +defect would be baked into every signature and link hash and then confirmed. Two +checks close that: + +- **The canonical fixture.** An `OnChipCounter` claim given with members out of order at + three levels, whitespace, and `\u` escapes in the input text. Its canonical bytes, + signature and link hash were computed by `claim_verify.py fixture` — Python's JSON + serialiser with members ordered by UTF-16 code units, `cryptography`'s Ed25519, and + BLAKE3 written from its specification — and are pinned as literals in `wai-rs` + (`check_canonical_form`), which `wai_claim_vectors verify` checks first. Reversing + the member order, or changing the signing domain, fails it. +- **The second verifier.** `claim_verify.py` reports every vector's declared outcome and + every pinned link hash, including a claim whose canonical form spans three BLAKE3 + chunks, and checks that the fixture it computes equals the literals in `wai-rs`. Its + §3.1 step 3 checks other than the equation — RFC 8032 key decoding, the order of the + key and of `R`, and `S` < L (which `cryptography` also checks) — are written from the + curve's definition; the equation is `cryptography`'s. Its acquisition-class rule is + written from energy-measurement §2.3–§2.5, where `wai-rs` admits the class through + `crate::energy_class`. + +The corpus shows the two verifiers agree on its vectors and nothing more. +`differential.py` checks agreement on inputs no vector declares an outcome for: that +the two read the same inputs the same way. It does not show that either reading is the +extension's, and it covers only the families it generates. + +The second verifier and the differential were written by the same author as `wai-rs`, +in a different language with no shared code. They catch disagreements in canonical +form, hashing, signatures and the reading of JSON, dates, hosts, classes and figures; +they are weaker evidence against a misreading of the extension that both verifiers share. +Two of the order vectors above (`lawful_basis_vocabulary_before_purposes`, +`lawful_basis_purposes_in_order`) exist because of one: both verifiers once read a +declaration's purposes before its other members, which §3.1 step 2 does not say, and +agreed with each other throughout. It was found by reading the text against the code; +neither check could have shown it. + +## Regenerating + +```sh +cargo run --no-default-features --features receipt_claims --bin wai_claim_vectors -- gen ../claim-conformance +``` + +Ed25519 signing is deterministic and the seeds are fixed, so the output is +byte-identical on every machine; `verify` fails if the pinned file differs from it. diff --git a/wai/claim-conformance/claim_verify.py b/wai/claim-conformance/claim_verify.py new file mode 100644 index 000000000..b684172b0 --- /dev/null +++ b/wai/claim-conformance/claim_verify.py @@ -0,0 +1,795 @@ +#!/usr/bin/env python3 +"""Second verifier for claim-conformance/ — typed claim receipts, the soft binding class, +and recovery under the ancestor rule (extensions/jwp-receipts.md §2.4.3-§2.4.7, §2.6, +§3.1-§3.2). + +Written from the extension text. It shares no code with wai-rs (`crate::claim_receipt`), +which generates the corpus: the canonical form is Python's JSON serialiser with members +ordered by UTF-16 code units, BLAKE3 is written below from its specification, Ed25519's +equation is the `cryptography` package's and step 3's other checks (key decoding, the +order of the key and of R, S < L) are written below from the curve's definition, and OER +receipts named by an `energy` claim are read, verified and their figures read by the OER +reference verifier (`../oer-conformance/oer2_verify.py`), under oer-receipts' own rules. + + python3 claim_verify.py [vectors.json] verify every vector and the canonical fixture + python3 claim_verify.py fixture print the fixture's canonical bytes, + signature and link hash + python3 claim_verify.py verdicts print `nameverdict` for every vector of a + corpus-format file, ignoring any declared + outcome (the input to differential.py) + +Exit status 0 only if every vector reports its declared outcome and the fixture this +script computes equals the literals pinned in wai-rs. + +A corpus-format file is read as the OER reference reads a vector document (`OER.load`, +oer-receipts §4 "The document"), since it holds OER receipts: a file that reading refuses +has no verdicts, and `verdicts` exits 1 with no output. No integer in a claim is converted +from more than twenty digits, so no verdict depends on an interpreter's limit on integer +digits. +""" +import json, os, re, sys, unicodedata +from decimal import localcontext + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..", "oer-conformance")) +import oer2_verify as OER # noqa: E402 (the OER reference verifier: grades + receipt checks) + +from cryptography.exceptions import InvalidSignature # noqa: E402 +from cryptography.hazmat.primitives import serialization # noqa: E402 +from cryptography.hazmat.primitives.asymmetric import ed25519 # noqa: E402 + +# ---------------------------------------------------------------- BLAKE3 (hash mode) + +_IV = [0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19] +_PERM = [2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8] +_CHUNK_START, _CHUNK_END, _PARENT, _ROOT = 1, 2, 4, 8 +_M32 = 0xFFFFFFFF + + +def _rotr(x, n): + return ((x >> n) | (x << (32 - n))) & _M32 + + +def _g(s, a, b, c, d, x, y): + s[a] = (s[a] + s[b] + x) & _M32 + s[d] = _rotr(s[d] ^ s[a], 16) + s[c] = (s[c] + s[d]) & _M32 + s[b] = _rotr(s[b] ^ s[c], 12) + s[a] = (s[a] + s[b] + y) & _M32 + s[d] = _rotr(s[d] ^ s[a], 8) + s[c] = (s[c] + s[d]) & _M32 + s[b] = _rotr(s[b] ^ s[c], 7) + + +def _compress(cv, m, counter, blen, flags): + s = list(cv) + _IV[:4] + [counter & _M32, (counter >> 32) & _M32, blen, flags] + m = list(m) + for r in range(7): + _g(s, 0, 4, 8, 12, m[0], m[1]); _g(s, 1, 5, 9, 13, m[2], m[3]) + _g(s, 2, 6, 10, 14, m[4], m[5]); _g(s, 3, 7, 11, 15, m[6], m[7]) + _g(s, 0, 5, 10, 15, m[8], m[9]); _g(s, 1, 6, 11, 12, m[10], m[11]) + _g(s, 2, 7, 8, 13, m[12], m[13]); _g(s, 3, 4, 9, 14, m[14], m[15]) + if r < 6: + m = [m[i] for i in _PERM] + return [s[i] ^ s[i + 8] for i in range(8)] + [s[i + 8] ^ cv[i] for i in range(8)] + + +def _words(block): + block = block + b"\0" * (64 - len(block)) + return [int.from_bytes(block[i:i + 4], "little") for i in range(0, 64, 4)] + + +def _chunk_output(chunk, counter): + blocks = [chunk[i:i + 64] for i in range(0, len(chunk), 64)] or [b""] + cv = _IV + for j, b in enumerate(blocks[:-1]): + cv = _compress(cv, _words(b), counter, 64, _CHUNK_START if j == 0 else 0)[:8] + last = blocks[-1] + return (cv, _words(last), counter, len(last), _CHUNK_END | (_CHUNK_START if len(blocks) == 1 else 0)) + + +def _cv(out): + cv, w, c, bl, f = out + return _compress(cv, w, c, bl, f)[:8] + + +def blake3(data: bytes) -> bytes: + chunks = [data[i:i + 1024] for i in range(0, len(data), 1024)] or [b""] + stack = [] + for idx, ch in enumerate(chunks[:-1]): + cv = _cv(_chunk_output(ch, idx)) + total = idx + 1 + while total & 1 == 0: + cv = _cv((_IV, stack.pop() + cv, 0, 64, _PARENT)) + total >>= 1 + stack.append(cv) + out = _chunk_output(chunks[-1], len(chunks) - 1) + while stack: + out = (_IV, stack.pop() + _cv(out), 0, 64, _PARENT) + cv, w, _, bl, f = out + return b"".join(x.to_bytes(4, "little") for x in _compress(cv, w, 0, bl, f | _ROOT)[:8]) + + +# ------------------------------------------------ Ed25519 (RFC 8032), §3.1 step 3's rule +# +# RFC 8032 verifiers differ on two choices that decide whether a signature verifies: +# whether keys and R of small order are refused (RFC 8032's procedure does not refuse +# them), and whether the cofactored or the cofactorless equation is checked (§5.1.7 states +# the first and allows the second). §3.1 step 3 fixes both, and restates RFC 8032's own decoding rule +# (one encoding per point) and range rule (S < L). Every check but the equation is made +# here, from the curve's definition: the `cryptography` package decodes keys more +# leniently than RFC 8032 §5.1.3 and accepts keys and R of small order. It does check +# S < L, which is made here as well. The equation itself is left to it. + +_P = 2 ** 255 - 19 +_L = 2 ** 252 + 27742317777372353535851937790883648493 +_D = (-121665 * pow(121666, _P - 2, _P)) % _P +_SQRT_M1 = pow(2, (_P - 1) // 4, _P) + + +def point_decode(b: bytes): + """RFC 8032 §5.1.3: the affine point (x, y), or None where decoding fails — including + y >= p and x = 0 with the sign bit set, so a point has exactly one encoding.""" + y = int.from_bytes(b, "little") + sign, y = y >> 255, y & ((1 << 255) - 1) + if y >= _P: + return None + u, v = (y * y - 1) % _P, (_D * y * y + 1) % _P + x2 = u * pow(v, _P - 2, _P) % _P + if x2 == 0: + return (0, y) if sign == 0 else None + x = pow(x2, (_P + 3) // 8, _P) + if (x * x - x2) % _P: + x = x * _SQRT_M1 % _P + if (x * x - x2) % _P: + return None + if x & 1 != sign: + x = _P - x + return (x, y) + + +def _add(p, q): + """Twisted Edwards addition (a = -1), complete on this curve.""" + (x1, y1), (x2, y2) = p, q + t = _D * x1 * x2 * y1 * y2 % _P + x3 = (x1 * y2 + y1 * x2) * pow(1 + t, _P - 2, _P) % _P + y3 = (y1 * y2 + x1 * x2) * pow(1 - t, _P - 2, _P) % _P + return (x3, y3) + + +def small_order(pt) -> bool: + """Whether [8]pt is the identity: the point's order divides 8.""" + for _ in range(3): + pt = _add(pt, pt) + return pt == (0, 1) + + +def check_signature(pk: bytes, sig: bytes, msg: bytes): + """§3.1 step 3, in its order: the key decodes and is not of small order + (`bad_signer_key`); then S < L, R is not a point of small order, and the cofactorless + equation holds with R compared byte for byte (`signature_verify_failed`).""" + a = point_decode(pk) + need(a is not None and not small_order(a), 3, "bad_signer_key") + r = point_decode(sig[:32]) + need(int.from_bytes(sig[32:], "little") < _L, 3, "signature_verify_failed") + need(r is None or not small_order(r), 3, "signature_verify_failed") + try: + ed25519.Ed25519PublicKey.from_public_bytes(pk).verify(sig, msg) + except (InvalidSignature, ValueError): + raise Reject(3, "signature_verify_failed") + + +# ------------------------------------------------------------- canonical form (RFC 8785) + +MAX_INT = 2 ** 53 - 1 + + +def jcs(v): + if v is None: + return "null" + if v is True or v is False: + return "true" if v else "false" + if isinstance(v, int): + if not 0 <= v <= MAX_INT: + raise ValueError("integer outside the canonical subset") + return str(v) + if isinstance(v, str): + return json.dumps(v, ensure_ascii=False) + if isinstance(v, list): + return "[" + ",".join(jcs(x) for x in v) + "]" + if isinstance(v, dict): + keys = sorted(v, key=lambda k: k.encode("utf-16-be")) + return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + jcs(v[k]) for k in keys) + "}" + raise ValueError("value outside the canonical subset") + + +def link_hash(claim: dict) -> str: + return blake3(b"moq-jwp:claim-link\x01" + jcs(claim).encode()).hex() + + +def signing_input(claim: dict) -> bytes: + return b"moq-jwp:claim\x01" + jcs({k: v for k, v in claim.items() if k != "sig"}).encode() + + +MAX_DEPTH = 32 # §3.1 step 1: arrays and objects nested deeper, the receipt being level 1 + + +class OutsideSubset: + """A JSON number that is not an unsigned integer written without a sign, fraction or + exponent: whatever its value, not one a claim may carry (`field_type`, never + `malformed`). Kept as a marker rather than a float so that no reading depends on how a + float is rounded, or on whether it overflows.""" + + +OUTSIDE_SUBSET = OutsideSubset() + + +def _scalar_text(s): + """Whether `s` is Unicode text: no surrogate code point, which is what an escape that + leaves a surrogate unpaired decodes to here.""" + return not any("\ud800" <= ch <= "\udfff" for ch in s) + + +def _check_tree(v, level): + if isinstance(v, str): + if not _scalar_text(v): + raise ValueError("an escape that leaves a surrogate unpaired") + elif isinstance(v, (list, dict)): + if level + 1 > MAX_DEPTH: + raise ValueError("nested too deeply") + for x in (v.values() if isinstance(v, dict) else v): + _check_tree(x, level + 1) + + +def parse_strict(text): + """§3.1 step 1's reading of a JSON text; ValueError is `malformed`. Refuses what RFC + 8259 does, a duplicate member name, a string or member name holding an unpaired + surrogate, and nesting deeper than MAX_DEPTH. Never refuses a number: one outside the + claim subset is OUTSIDE_SUBSET.""" + def pairs(ps): + d = {} + for k, v in ps: + if not _scalar_text(k): + raise ValueError("an escape that leaves a surrogate unpaired") + if k in d: + raise ValueError("duplicate member " + k) + d[k] = v + return d + + def no_constant(c): + raise ValueError("not JSON: " + c) + + def integer(s): + # A plain unsigned integer beyond 20 digits exceeds every u64, as wai-rs reads it. + return OUTSIDE_SUBSET if s.startswith("-") or len(s) > 20 else int(s) + + if not isinstance(text, str): + raise TypeError("a claim is given as its wire form") + try: + v = json.loads(text, object_pairs_hook=pairs, parse_constant=no_constant, + parse_float=lambda s: OUTSIDE_SUBSET, parse_int=integer) + except RecursionError: + raise ValueError("nested too deeply") + _check_tree(v, 0) + return v + + +# -------------------------------------------------------------------- the extension + +ENVELOPE = ["kind", "class", "subject_type", "subject", "seq", "issued_at", "body", + "signer_pubkey", "signer_id", "parent_receipt_hash", "sig"] +ADMITS = {"energy": {"object"}, "lawful-basis": {"object", "receipt"}, "log-inclusion": {"receipt"}, + "soft-binding": {"object"}, "binding-recovery": {"object"}} +BODY = { + "energy": ["joules_micro", "energy_provenance", "energy_uncertainty", "energy_counter_filtering", + "energy_calibration_ref", "coverage", "grade", "method", "work_units", "work_unit", "oer_receipt"], + "log-inclusion": ["log", "profile", "entry", "log_receipt"], + "soft-binding": ["alg", "registry", "binding_type", "value", "resolution_endpoint"], + "binding-recovery": ["alg", "binding_type", "value", "declaration", "resolution_endpoint", "observed", "attests"], +} +DECLARATION = ["record", "vocabulary", "fragment", "purposes"] +PURPOSE = ["purpose", "basis", "data"] +DECISION = ["record", "vocabulary", "declaration", "purpose", "outcome"] +UNCERTAINTY = ["relative_ppm", "window_us"] +# energy-measurement.md §2's acquisition classes, each with the strongest oer-receipts.md +# §2 grade it supports. Grades and their ranking come from the OER reference verifier. +STRONGEST = {"CalibratedInstrument": "measured", "OnChipCounter": "measured", "HwShunt": "measured", + "ModelBased": "derived", "Estimator": "projected"} +FILTERING = {"On", "Off", "Unknown"} # energy-measurement.md §2.4 +RESOLUTION_FLOOR = 288676 # energy-measurement.md §2.3: joules_micro x relative_ppm, at least +# Unicode's White_Space property: what a calibration reference must hold something besides +# (energy-measurement.md §2.5, "non-empty"). Written out, because str.isspace() also +# counts U+001C to U+001F, which are not White_Space. +WHITE_SPACE = ("\t\n\x0b\x0c\r \x85\xa0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007" + "\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000") +COVERAGE = {"Whole", "Partial"} +GRADES = OER.GRADES + + +class Reject(Exception): + def __init__(self, step, reason): + super().__init__(f"reject step={step} reason={reason}") + self.step, self.reason = step, reason + + +def is_int(v): + return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= MAX_INT + + +def is_hex(v, nbytes=None): + if not isinstance(v, str) or len(v) % 2 or not re.fullmatch(r"[0-9a-f]*", v): + return False + return nbytes is None or len(v) == 2 * nbytes + + +def is_token(s): + return (isinstance(s, str) and s != "" and len(s.encode()) <= 2048 + and not any(c.isspace() or unicodedata.category(c) == "Cc" for c in s)) + + +def is_uri(s): + return is_token(s) and re.fullmatch(r"[A-Za-z][A-Za-z0-9+.\-]*:.+", s, re.S) is not None + + +def is_https(s): + """§2.6.2: `https://`, then an authority (up to the first `/`, `?` or `#`) with no `@`, + that is a non-empty host — the text before the first `:`, or what an IP literal's + brackets enclose — optionally followed by `:` and a port of ASCII digits.""" + if not isinstance(s, str) or not s.startswith("https://"): + return False + authority = re.split(r"[/?#]", s[len("https://"):], maxsplit=1)[0] + if not is_uri(s) or "@" in authority: + return False + if authority.startswith("["): + host, closed, after = authority[1:].partition("]") + if not closed: + return False + else: + host, colon, port = authority.partition(":") + after = colon + port + return host != "" and (after == "" or (after[0] == ":" and all(c in "0123456789" for c in after[1:]))) + + +def registry_form(alg): + labels = alg.split(".") + return len(labels) >= 2 and all(re.fullmatch(r"[A-Za-z0-9_+\-]+", l) for l in labels) + + +def days_in(year, month): + """Days in a month of the proleptic Gregorian calendar (RFC 3339 Appendix C).""" + if month == 2: + return 29 if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) else 28 + return 30 if month in (4, 6, 9, 11) else 31 + + +def is_rfc3339(s): + """RFC 3339 §5.6 date-time with §5.7's day-of-month rule; ASCII digits only (a regex + `\\d` would also take any Unicode decimal digit). A second of 60 is accepted at any + time of day: whether a leap second occurred then is not a property of the syntax.""" + m = re.fullmatch(r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?" + r"([Zz]|[+-]([0-9]{2}):([0-9]{2}))", s) + if not m: + return False + yr, mo, da, hh, mi, ss = (int(m.group(i)) for i in (1, 2, 3, 4, 5, 6)) + if not (1 <= mo <= 12 and 1 <= da <= days_in(yr, mo) and hh <= 23 and mi <= 59 and ss <= 60): + return False + return m.group(9) is None or (int(m.group(9)) <= 23 and int(m.group(10)) <= 59) + + +def is_oer_id(s): + return isinstance(s, str) and re.fullmatch(r"(sha256|blake3):[0-9a-f]{64}", s) is not None + + +def members(obj, required, step): + if any(k not in obj for k in required): + raise Reject(step, "missing_required_field") + if any(k not in required for k in obj): + raise Reject(step, "unknown_member") + + +def need(ok, step, reason="field_type"): + if not ok: + raise Reject(step, reason) + + +def check_body(cls, b): + """§3.1 step 2: the members, then each member's type and encoding together, in the + member table's order, a nested object's members checked where the table reaches it. + Where every failure left is `field_type` the checks are grouped; the order shows only + where a nested object's missing or unknown member could be reported instead.""" + need(isinstance(b, dict), 2) + if cls == "lawful-basis": + # §2.4.6: `record` is read before anything else, and names the table. + if "record" not in b: + raise Reject(2, "missing_required_field") + need(isinstance(b["record"], str), 2) + if b["record"] == "declaration": + members(b, DECLARATION, 2) + need(is_uri(b["vocabulary"]), 2) + need(b["fragment"] is None or is_hex(b["fragment"], 32), 2) + need(isinstance(b["purposes"], list), 2) + for p in b["purposes"]: + need(isinstance(p, dict), 2) + members(p, PURPOSE, 2) + need(is_token(p["purpose"]), 2) + need(p["basis"] == "" or is_token(p["basis"]), 2) # an empty basis is step 4's + need(isinstance(p["data"], list) and all(is_token(d) for d in p["data"]), 2) + elif b["record"] == "decision": + members(b, DECISION, 2) + need(all(isinstance(b[k], str) for k in ("vocabulary", "purpose", "outcome")), 2) + need(is_hex(b["declaration"], 32), 2) + need(is_uri(b["vocabulary"]) and is_token(b["purpose"]) and is_token(b["outcome"]), 2) + else: + raise Reject(2, "field_type") + return + members(b, BODY[cls], 2) + if cls == "energy": + # In the member table's order: a nested object's member check can report another + # reason than a type check, so the order is the one every verifier uses. + need(is_int(b["joules_micro"]), 2) + need(isinstance(b["energy_provenance"], str), 2) + u = b["energy_uncertainty"] + if u is not None: + need(isinstance(u, dict), 2) + members(u, UNCERTAINTY, 2) + need(is_int(u["relative_ppm"]) and is_int(u["window_us"]), 2) + need(b["energy_counter_filtering"] is None or isinstance(b["energy_counter_filtering"], str), 2) + need(b["energy_calibration_ref"] is None or isinstance(b["energy_calibration_ref"], str), 2) + need(all(isinstance(b[k], str) for k in ("coverage", "grade", "method")), 2) + need(is_int(b["work_units"]), 2) + need(isinstance(b["work_unit"], str), 2) + need(b["oer_receipt"] is None or isinstance(b["oer_receipt"], str), 2) + need(is_uri(b["method"]) and is_token(b["work_unit"]), 2) + need(b["oer_receipt"] is None or is_oer_id(b["oer_receipt"]), 2) + elif cls == "log-inclusion": + need(all(isinstance(b[k], str) for k in ("log", "profile", "entry")), 2) + need(is_hex(b["log_receipt"]), 2) + need(is_uri(b["log"]) and is_uri(b["profile"]) and is_token(b["entry"]), 2) + else: # soft-binding, binding-recovery + need(isinstance(b["alg"], str) and b["binding_type"] in ("watermark", "fingerprint"), 2) + need(is_hex(b["value"]) and isinstance(b["resolution_endpoint"], str), 2) + if cls == "soft-binding": + need(isinstance(b["registry"], str), 2) + need(is_token(b["alg"]) and is_uri(b["registry"]) and is_uri(b["resolution_endpoint"]), 2) + else: + need(is_hex(b["declaration"], 32), 2) + need(b["observed"] is None or is_hex(b["observed"], 32), 2) + need(isinstance(b["attests"], str), 2) + need(is_token(b["alg"]) and is_uri(b["resolution_endpoint"]), 2) + + +def acquisition(b): + """§2.4.5 rule 2: the class is one energy-measurement.md §2 names, not the legacy + HwShunt, and carries exactly the evidence §2.3-§2.5 give it: a refined hardware class + a non-zero declared uncertainty whose relative part fits in 32 bits (§2.7) and which + reaches the resolution floor, an on-chip counter a filtering state and no calibration + reference, a calibrated instrument a calibration reference that is not all white space + and no filtering state; every other class none of the three.""" + label = b["energy_provenance"] + need(label in STRONGEST, 4, "energy_unknown_acquisition") + need(label != "HwShunt", 4, "energy_legacy_acquisition") + u, f, ref = b["energy_uncertainty"], b["energy_counter_filtering"], b["energy_calibration_ref"] + ev = "energy_acquisition_evidence" + if label in ("OnChipCounter", "CalibratedInstrument"): + need(u is not None and 0 < u["relative_ppm"] <= 2 ** 32 - 1 and u["window_us"] > 0, 4, ev) + if label == "OnChipCounter": + need(f in FILTERING and ref is None, 4, ev) + else: + need(f is None and ref is not None and ref.strip(WHITE_SPACE) != "", 4, ev) + need(b["joules_micro"] * u["relative_ppm"] >= RESOLUTION_FLOOR, 4, ev) + else: + need(u is None and f is None and ref is None, 4, ev) + + +def class_rules(c): + """§3.1 step 4 — §2.4.5-§2.4.7 and §2.6.""" + cls, b = c["class"], c["body"] + if cls == "energy": + need(b["joules_micro"] != 0, 4, "energy_zero_figure") + acquisition(b) + need(b["coverage"] in COVERAGE, 4, "energy_unknown_coverage") + need(b["grade"] in GRADES, 4, "energy_unknown_grade") + need(GRADES.index(b["grade"]) <= GRADES.index(STRONGEST[b["energy_provenance"]]), 4, + "energy_grade_exceeds_acquisition") + need(b["work_units"] != 0, 4, "energy_zero_work") + elif cls == "lawful-basis" and b["record"] == "declaration": + need(len(b["purposes"]) > 0, 4, "lawful_basis_no_purposes") + seen = set() + for p in b["purposes"]: + need(p["basis"] != "", 4, "lawful_basis_purpose_without_basis") + need(p["purpose"] not in seen, 4, "lawful_basis_duplicate_purpose") + seen.add(p["purpose"]) + elif cls == "log-inclusion": + need(b["log_receipt"] != "", 4, "log_inclusion_no_evidence") + elif cls in ("soft-binding", "binding-recovery"): + need(registry_form(b["alg"]), 4, "soft_binding_alg_not_registry_form") + need(b["value"] != "", 4, "soft_binding_empty_value") + need(is_https(b["resolution_endpoint"]), 4, "soft_binding_endpoint_not_https") + if cls == "binding-recovery": + need(b["attests"] == "ancestor", 4, "recovery_not_ancestor") + need(b["observed"] != c["subject"], 4, "recovery_of_hard_bound_object") + + +def verify_claim(text): + """§3.1 steps 1-4. Returns the claim; raises Reject.""" + try: + c = parse_strict(text) + except ValueError: + raise Reject(1, "malformed") + need(isinstance(c, dict), 1, "malformed") + if "kind" not in c: + raise Reject(1, "missing_required_field") + need(c["kind"] == "claim", 1, "wrong_kind") + members(c, ENVELOPE, 1) + need(isinstance(c["class"], str), 1) + need(c["class"] != "delivery", 1, "class_reserved") + need(c["class"] in ADMITS, 1, "unknown_class") + need(c["subject_type"] in ("object", "receipt"), 1) + need(is_hex(c["subject"], 32), 1) + need(is_int(c["seq"]), 1) + need(isinstance(c["issued_at"], str) and is_rfc3339(c["issued_at"]), 1) + need(is_hex(c["signer_pubkey"], 32), 1) + need(isinstance(c["signer_id"], str), 1) + need(c["parent_receipt_hash"] is None or is_hex(c["parent_receipt_hash"], 32), 1) + need(is_hex(c["sig"], 64), 1) + need(c["subject_type"] in ADMITS[c["class"]], 1, "subject_type_not_admitted") + check_body(c["class"], c["body"]) + check_signature(bytes.fromhex(c["signer_pubkey"]), bytes.fromhex(c["sig"]), signing_input(c)) + class_rules(c) + return c + + +def check_chain(c, previous): + """§3.1 step 5.""" + parent = c["parent_receipt_hash"] + if previous is None: + need(parent is None, 5, "parent_not_held") + return + need(parent is not None, 5, "chain_break") + need(previous["signer_pubkey"] == c["signer_pubkey"] and parent == link_hash(previous) + and c["seq"] > previous["seq"], 5, "chain_break") + + +def match(alg, declared, observed, exact_algs): + need(alg in exact_algs, 6, "recovery_no_matcher") + need(declared == observed, 6, "recovery_value_mismatch") + + +def dig(v, *path): + """The member at `path` through JSON objects, or None.""" + for k in path: + v = v.get(k) if isinstance(v, dict) else None + return v + + +def figure_rounds_to_micro(figure, uj): + """§2.4.5 check 4: whether an OER receipt's joules_total is `uj` microjoules to the + nearest microjoule, a figure half-way between two admitting either. + + The figure is read as the OER reference reads a quantity for its own composition + checks (`OER.dec`: a decimal string of at most 34 significand digits, or an integer + within I-JSON's range) and compared in its exact context, so no digit is rounded + away: 0.0120004 followed by twenty-five nines is below half-way, although rounding it + to 28 digits would make it a tie. Check 2 has already refused a receipt whose figure + is anything else; such a value is no figure here either.""" + try: + with localcontext(OER.EXACT): + return abs(OER.dec(figure) * 1000000 - uj) * 2 <= 1 + except TypeError: + return False + + +def check_context(c, ctx): + """§3.1 step 6.""" + b = c["body"] + if "object" in ctx: + o = ctx["object"] + # The corpus gives a delivered object's microjoules as an integer within 64 bits, as + # wai-rs's harness requires; anything else (`-0`, which is read as a double, among + # them) is a malformed corpus, not a verdict. + j = o.get("joules_micro") + if not (isinstance(j, int) and not isinstance(j, bool) and 0 <= j < 2 ** 64): + raise ValueError("bad object.joules_micro") + need(c["class"] == "energy" and o["content_hash"] == c["subject"], 6, "context_mismatch") + need(o["joules_micro"] == b["joules_micro"], 6, "energy_disagrees_with_delivery") + if "oer" in ctx: + r, keys = ctx["oer"]["receipt"], ctx["oer"]["keys"] + need(c["class"] == "energy" and b["oer_receipt"] is not None + and dig(r, "receipt_id") == b["oer_receipt"], 6, "context_mismatch") + # The reference reports every failure as a reason (oer-receipts §7); an exception + # here is a defect in it, and is left to surface as one rather than read as a verdict. + need(OER.verify_receipt(r, keys) == [], 6, "energy_oer_invalid") + need(dig(r, "subject", "output") == c["subject"], 6, "context_mismatch") + need(dig(r, "provenance", "how") == b["grade"], 6, "energy_disagrees_with_oer") + need(figure_rounds_to_micro(dig(r, "energy", "joules_total"), b["joules_micro"]), 6, "energy_disagrees_with_oer") + if "declaration" in ctx: + # In §3.1 step 6's order: the claim must be one that names a declaration; the + # declaration must itself pass steps 1-4 (context_invalid) — its signer_pubkey is + # only its signer's once that is established; then it must be of the class named. + recovery = c["class"] == "binding-recovery" + decision = c["class"] == "lawful-basis" and b["record"] == "decision" + # §2.6.3: the declaration is context only together with the publisher key of a + # verified delivery receipt for its subject. A corpus that gives one without the + # other is malformed, not a verdict. + if recovery and "publisher" not in ctx: + raise ValueError("a recovery record's declaration is context only with a publisher key") + need(recovery or decision, 6, "context_mismatch") + if not isinstance(ctx["declaration"], str): + raise TypeError("a declaration is given as its wire form") + try: + d = verify_claim(ctx["declaration"]) + except Reject: + raise Reject(6, "context_invalid") + db = d["body"] + if recovery: + need(d["class"] == "soft-binding", 6, "context_mismatch") + need(b["declaration"] == link_hash(d) and c["subject"] == d["subject"] + and b["alg"] == db["alg"] and b["binding_type"] == db["binding_type"], + 6, "recovery_declaration_mismatch") + need(d["signer_pubkey"] == ctx["publisher"], 6, "recovery_signer_mismatch") + match(db["alg"], db["value"], b["value"], ctx.get("exact_match_algs", [])) + else: + need(d["class"] == "lawful-basis" and db["record"] == "declaration", 6, "context_mismatch") + need(b["declaration"] == link_hash(d) and c["subject"] == d["subject"] + and c["subject_type"] == d["subject_type"] and b["vocabulary"] == db["vocabulary"], + 6, "decision_declaration_mismatch") + need(any(p["purpose"] == b["purpose"] for p in db["purposes"]), 6, "decision_purpose_not_declared") + + +def held_previous(ctx): + """§3.1 step 5's previous claim: one that does not itself pass steps 1-4 is not held — + failing steps 1-3 it is not known to be its signer's, failing step 4 it breaks its + class's rules.""" + if "previous" not in ctx: + return None + if not isinstance(ctx["previous"], str): + raise TypeError("a previous claim is given as its wire form") + try: + return verify_claim(ctx["previous"]) + except Reject: + return None + + +def recover(ctx): + """§3.2. Returns the ancestor; raises Reject.""" + d = verify_claim(ctx["declaration"]) + need(d["class"] == "soft-binding", 6, "context_mismatch") + need(d["signer_pubkey"] == ctx["publisher"], 6, "recovery_signer_mismatch") + need(ctx.get("observed_hash") != d["subject"], 6, "recovery_of_hard_bound_object") + match(d["body"]["alg"], d["body"]["value"], ctx["observed_value"], ctx.get("exact_match_algs", [])) + return d["subject"] + + +def verdict(v): + """This verifier's verdict on one vector: `accept link_hash=` for an accepted + claim, `accept ancestor=` for a recovery, or `reject step= reason=