From 5ee4c7e28b1a7c76d8f1f4ba7cd1b167cbfeabc8 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 01:42:45 -0400 Subject: [PATCH] wai: typed receipt classes and the soft binding class, reading OER through the OER module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A JWP receipt says what was delivered, by whom and at what energy cost, and binds to its object one way. Other statements about the same objects had nowhere to go, and energy-measurement §2 requires an acquisition class beside every non-zero figure, which the object receipt has no member for. jwp-receipts §2.4.3-§2.4.7 adds a `claim` kind with one envelope, one canonical form (RFC 8785) and one signature rule for the classes energy, lawful-basis and log-inclusion. §2.6 and §3.2 add a second, deliberately weaker binding class: a publisher-signed soft binding with a resolution endpoint, under the ancestor rule. §3.1 step 3 fixes a strict Ed25519 rule. A declaration that a decision or a recovery record is checked against is verified before anything is compared with it (`context_invalid`). OER as settled on main is authoritative: this adds no second OER reader and changes no OER rule, vector, verifier or document. An OER receipt an energy claim names is read by oer::load under the document profile, verified by oer::verify_receipt, and its figure read by the exact quantity reader the composition checks use, through a new crate-private oer::rounds_to_micro. claim_verify.py does the same with the OER reference's load, verify_receipt and dec. The claim envelope keeps its own step-1 reader (claim_text.rs), which reads claims only. - Acquisition classes are the refined ones. The energy body carries the class as energy-measurement §2.7 writes it in JSON (energy_provenance, energy_uncertainty, energy_counter_filtering, energy_calibration_ref), present and null where a class has none, so a claim keeps one canonical form. Legacy HwShunt is refused, since no claim predates the refinement (energy_legacy_acquisition); missing, misplaced or sub-floor evidence is energy_acquisition_evidence. The grade correspondence is oer-receipts §2's, an exhaustive match over all five variants. - §3.1 step 2's member tables give exactly the type step 2 checks, and step 2 lists what the class rules add to a type as step 4's, never field_type. Both verifiers read a declaration in its table's order (they had read purposes before vocabulary and fragment, and every encoding after every member, so a declaration with two defects gave another reason than the text). - oer::py_float_repr writes a double at a tie between two shortest decimals as CPython's repr does (round half to even at the shortest length). No content address or verdict depended on it: main's number profile keeps floats out of every receipt body. It changed the printed name of a vector named by such a number, which both OER verifiers print and the OER fuzzer compares. Fourteen doubles CPython 3.14.7 wrote are pinned. - claim-conformance/: 154 vectors covering all 40 reasons, the canonical fixture recomputed by claim_verify.py and pinned, a second verifier (claim_verify.py) that shares no code with wai-rs, and differential.py, which runs both on inputs the corpus does not hold. receipt_claims joins the conformance library tests and both corpus loops. Checked on this machine (macOS arm64) on the stream's tree: - library tests with receipt_claims (dev; oer + receipt_claims also release); - wai_claim_vectors and wai_oer_vectors verify their corpora; - claim_verify.py reports every vector's declared outcome; - differential.py: its default families and 20,000 random inputs (seed 7): no verdict differs and nothing crashes; - oer2_verify.py passes every OER vector file and refuses every unreadable document, the C kernel's output equals firmware_vectors.json, and fuzz_differential.py (seed 1, 20,000 documents) finds no divergence. And on this commit's tree, the per-link gate: build, library tests, carriage, every corpus in the conformance loop, byte-exact, wasm32. Not run here: the Linux and Windows legs (CI runs Linux; the Windows runner is offline). The Python scripts are run by hand, not in CI. Not done: key discovery (every claim signature presumes the verifier holds the key), an authority class for who may sign which class, region- or time-scoped soft bindings, and checking a log's receipt under its profile. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 8 +- wai/CHANGELOG.md | 77 + wai/SPEC.md | 6 +- wai/claim-conformance/.gitattributes | 3 + wai/claim-conformance/README.md | 239 ++ wai/claim-conformance/claim_verify.py | 795 +++++ wai/claim-conformance/differential.py | 789 ++++ wai/claim-conformance/vectors.json | 3054 ++++++++++++++++ wai/extensions/energy-measurement.md | 33 +- wai/extensions/jwp-receipts.md | 827 ++++- wai/extensions/oer-receipts.md | 13 + wai/extensions/transparency.md | 5 + wai/extensions/unidirectional-delivery.md | 8 +- wai/wai-rs/Cargo.lock | 1 + wai/wai-rs/Cargo.toml | 17 + wai/wai-rs/src/bin/wai_claim_vectors.rs | 128 + wai/wai-rs/src/claim_receipt.rs | 3969 +++++++++++++++++++++ wai/wai-rs/src/claim_text.rs | 304 ++ wai/wai-rs/src/lib.rs | 15 + wai/wai-rs/src/oer.rs | 150 +- 20 files changed, 10419 insertions(+), 22 deletions(-) create mode 100644 wai/claim-conformance/.gitattributes create mode 100644 wai/claim-conformance/README.md create mode 100644 wai/claim-conformance/claim_verify.py create mode 100644 wai/claim-conformance/differential.py create mode 100644 wai/claim-conformance/vectors.json create mode 100644 wai/wai-rs/src/bin/wai_claim_vectors.rs create mode 100644 wai/wai-rs/src/claim_receipt.rs create mode 100644 wai/wai-rs/src/claim_text.rs 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=