wai: typed receipt classes and the soft binding class, reading OER through the OER module #112

Merged
dcharlot merged 2 commits from wai/land-10-receipt-classes into main 2026-09-30 19:53:01 -04:00
20 changed files with 10419 additions and 22 deletions

View file

@ -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 }}

View file

@ -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

View file

@ -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

3
wai/claim-conformance/.gitattributes vendored Normal file
View file

@ -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

View file

@ -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 <target-dir>/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 <file>`,
`claim_verify.py verdicts <file>`), 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.

View file

@ -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 <file> print `name<TAB>verdict` 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=<hex>` for an accepted
claim, `accept ancestor=<hex>` for a recovery, or `reject step=<n> reason=<label>`."""
ctx = v.get("context", {})
try:
if v["procedure"] == "recover":
return "accept ancestor=" + recover(ctx)
c = verify_claim(v["receipt"])
check_chain(c, held_previous(ctx))
check_context(c, ctx)
return "accept link_hash=" + link_hash(c)
except Reject as r:
return f"reject step={r.step} reason={r.reason}"
def run_vector(v):
got = verdict(v)
if got.startswith("accept link_hash="):
# An accepted claim must also reproduce its pinned link hash, when one is pinned.
pinned = v["expected"].get("link_hash")
return "accept" if pinned in (None, got[len("accept link_hash="):]) else got
return got
def verdicts(path):
"""`name<TAB>verdict` for every vector in a corpus-format file, ignoring any declared
outcome: the input to a differential comparison with wai-rs (`differential.py`).
A vector this script cannot interpret is `error`; an exception of any other kind is
reported as `crash: <type>`, which a conforming run never prints."""
try:
doc = OER.load(open(path, encoding="utf-8"))
except ValueError as e:
print(f"{path}: not read: {e}", file=sys.stderr)
return 1
for v in doc["vectors"]:
try:
got = verdict(v)
except UnicodeError as e:
got = "crash: " + type(e).__name__
except (ValueError, KeyError, TypeError):
got = "error"
except Exception as e: # noqa: BLE001 -- reported, never swallowed
got = "crash: " + type(e).__name__
print(f"{v['name']}\t{got}")
return 0
def expected_of(e):
if e["outcome"] == "accept":
return "accept ancestor=" + e["ancestor"] if "ancestor" in e else "accept"
return f"reject step={e['step']} reason={e['reason']}"
# ------------------------------------------------------------------ canonical fixture
FIXTURE_SEED = bytes([0x22] * 32)
def fixture():
"""The canonical fixture wai-rs pins: computed here, from the members and the seed."""
sk = ed25519.Ed25519PrivateKey.from_private_bytes(FIXTURE_SEED)
pk = sk.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw).hex()
c = {
"signer_id": "did:example:météo\u001f",
"body": {"work_unit": "subpixel", "oer_receipt": None, "energy_calibration_ref": None, "joules_micro": 4096,
"grade": "measured", "energy_uncertainty": {"window_us": 1000000, "relative_ppm": 20000},
"method": "https://transaction.science/wai/extensions/energy-measurement",
"coverage": "Whole", "energy_counter_filtering": "Unknown", "work_units": 49152,
"energy_provenance": "OnChipCounter"},
"seq": 7, "parent_receipt_hash": None, "kind": "claim",
"subject": blake3(b"moq-jwp:object\x01" + b"claim-conformance: canonical fixture object").hex(),
"issued_at": "2026-09-27T12:00:00.5+02:00", "class": "energy", "subject_type": "object",
"signer_pubkey": pk,
}
c["sig"] = sk.sign(signing_input(c)).hex()
return c, jcs(c), link_hash(c)
def fixture_matches_wai_rs():
"""The literals `check_canonical_form` pins in wai-rs equal what this script computes."""
src_path = os.path.join(HERE, "..", "wai-rs", "src", "claim_receipt.rs")
try:
src = open(src_path, encoding="utf-8").read()
except OSError as e:
return [f"cannot read {src_path}: {e}"]
c, canon, link = fixture()
fails = []
m = re.search(r'const CANONICAL: &str = "((?:[^"\\]|\\.)*)";', src)
pinned = re.sub(r'\\(["\\])', r"\1", m.group(1)) if m else None
if pinned != canon:
fails.append("canonical bytes differ from wai-rs")
m = re.search(r'const LINK: &str = "([0-9a-f]{64})";', src)
if not m or m.group(1) != link:
fails.append("link hash differs from wai-rs")
if f'"sig": "{c["sig"]}"' not in src:
fails.append("signature differs from wai-rs")
return fails
def main(argv):
if argv[1:2] == ["verdicts"] and len(argv) == 3:
return verdicts(argv[2])
if argv[1:2] == ["fixture"]:
c, canon, link = fixture()
print("sig ", c["sig"])
print("canonical", canon)
print("link_hash", link)
return 0
fail = 0
# BLAKE3's published values for the empty input and "abc", before anything relies on it.
b3 = blake3(b"").hex() == "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262" and \
blake3(b"abc").hex() == "6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85"
print(("PASS" if b3 else "FAIL") + " BLAKE3 reproduces its published values")
fail += not b3
ff = fixture_matches_wai_rs()
print(("PASS" if not ff else "FAIL") + " canonical fixture equals the literals pinned in wai-rs " + ",".join(ff))
fail += bool(ff)
rd = OER.check_canonical_form() # the OER reference's own fixtures, its reading of -0 among them
print(("PASS" if not rd else "FAIL") + " the OER reference's canonical form and number reading hold " + ",".join(rd))
fail += bool(rd)
path = argv[1] if len(argv) > 1 else os.path.join(HERE, "vectors.json")
doc = OER.load(open(path, encoding="utf-8"))
for v in doc["vectors"]:
want, got = expected_of(v["expected"]), run_vector(v)
ok = want == got
fail += not ok
print(f"{'PASS' if ok else 'FAIL'} {v['name']} expected={want} got={got}")
print(f"{len(doc['vectors'])} vectors, {fail} failure(s)")
return 1 if fail else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))

View file

@ -0,0 +1,789 @@
#!/usr/bin/env python3
"""Differential check: wai-rs and claim_verify.py give the same verdict on inputs the corpus
does not hold.
The corpus pins one declared outcome per vector, which shows both verifiers agree on those
vectors and nothing more. This script generates inputs around the edges where two readings
of jwp-receipts §3.1-§3.2 could part — JSON numbers and strings at the limits of what a
parser accepts, nesting, calendar dates, URI authorities, acquisition classes with every
combination of evidence around energy-measurement §2.3-§2.5's rules, 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, integers beyond I-JSON's range) in members of every kind, context
receipts that do not themselves verify, and bodies of every class with one defect, or two
at different positions, where the order §3.1 step 2 reads a body in decides the reason —
writes them in the corpus's format with no declared outcome, runs both verifiers'
`verdicts` mode, and reports every input on which they differ.
Some inputs cannot share a file with the rest: a number whose nearest double is infinite,
`NaN`, an unpaired surrogate, a repeated member name or nesting past 64 levels makes a
whole file unreadable (oer-receipts §4 "The document"), for both verifiers or — if they
differ — for one. Each of those is written to a file of its own with a readable twin beside
it, and the two verifiers must either both refuse the file or give the same verdicts on it.
python3 differential.py <wai_claim_vectors binary> [--random N] [--seed S] [--keep FILE]
Keys are the corpus's fixed seeds, so every signed input is reproducible. Exit status 0
only if the two verdict lists are identical, every file of its own is read or refused
alike, and neither verifier printed `crash`; any difference, whatever step it is at,
exits 1.
An energy claim that names an OER receipt reaches the OER verifier of its own language
(§2.4.5 check 2): wai-rs's `crate::oer`, and the reference `oer2_verify.py`. A difference
there is still a difference in the claim verdict, and is reported like any other; the
listing marks it, since its cause is in the two OER verifiers rather than the claim rules.
Those two are compared on their own by `../oer-conformance/fuzz_differential.py`.
"""
import argparse, copy, io, json, os, random, subprocess, sys, tempfile
from decimal import Decimal, localcontext
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import claim_verify as CV # noqa: E402
from cryptography.hazmat.primitives import serialization # noqa: E402
from cryptography.hazmat.primitives.asymmetric import ed25519 # noqa: E402
OER = CV.OER
def key(seed):
return ed25519.Ed25519PrivateKey.from_private_bytes(bytes([seed] * 32))
def pk(k):
return k.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw).hex()
PUBLISHER, METER, OPERATOR, VIEWER, RESOLVER = (key(s) for s in (0x11, 0x22, 0x33, 0x44, 0x55))
OER_SIGNER = "ed25519:claim-conformance-meter"
WM_ALG = "org.example.watermark.1"
def sign(c, k):
"""`c` with its signature made under `k` over §2.4.3's signing input."""
c = copy.deepcopy(c)
c.pop("sig", None)
c["signer_pubkey"] = pk(k)
c["sig"] = k.sign(CV.signing_input(c)).hex()
return c
RAW = {}
def raw(spelling):
"""A JSON value written into the input file exactly as `spelling` — `-0`, an integer
beyond 64 bits, a string with an unpaired surrogate escape — where json.dumps would
write another spelling or none. A placeholder string until text_of() writes it."""
k = f"\u0000raw{len(RAW)}\u0000"
RAW[k] = spelling
return k
def text_of(v, **kw):
"""json.dumps(v, ensure_ascii=True), every raw() placeholder written as its spelling."""
t = json.dumps(v, ensure_ascii=True, **kw)
if "\\u0000raw" in t:
for k, spelling in RAW.items():
t = t.replace(json.dumps(k), spelling)
return t
def seal_oer(template, joules_total, terms=None, floor=None, edit=None):
"""`template` recording `joules_total`, re-sealed and signed as oer-receipts §4-§5
define. The breakdown is `terms` (compute_j first) or, by default, `joules_total` as
compute_j with the other terms as the template has them (zero); `floor`, if given, is
(bits_committed, temperature_k, floor_j); `edit`, if given, changes the receipt last.
The content address is computed over the receipt as the file will be read: its text,
read as §4 reads a document (`OER.load`). A receipt whose body holds a number §4
forbids is sealed all the same; both verifiers must refuse it before its address."""
r = copy.deepcopy(template)
r.pop("chain", None)
r.pop("receipt_id", None)
r["energy"]["joules_total"] = joules_total
b = r["energy"]["breakdown"]
for k, t in zip(list(b), terms if terms is not None else [joules_total]):
b[k] = t
if floor is not None:
r["floor"]["bits_committed"], r["energy"]["temperature_k"], r["floor"]["floor_j"] = floor
if edit is not None:
edit(r)
body = OER.canonical_bytes(OER.load(io.StringIO(text_of(r))))
prev = "sha256:" + "0" * 64
rh = OER.h("sha-256", prev.encode() + body)
r["chain"] = {"prev_hash": prev, "receipt_hash": rh, "signer": OER_SIGNER,
"signature": METER.sign(rh.encode()).hex()}
r["receipt_id"] = OER.h("sha-256", body)
return r
class Inputs:
def __init__(self, corpus):
self.by_name = {v["name"]: v for v in corpus["vectors"]}
self.out = []
def receipt(self, name):
return json.loads(self.by_name[name]["receipt"])
def wire(self, name):
return self.by_name[name]["receipt"]
def add(self, name, receipt=None, context=None, procedure="claim"):
v = {"name": f"{len(self.out):05d}_{name}", "procedure": procedure}
if receipt is not None:
v["receipt"] = receipt
if context:
v["context"] = context
self.out.append(v)
# ------------------------------------------------------------------ input families
NUMBER_TOKENS = [
"0", "-0", "1", "-1", "9007199254740991", "9007199254740992", "18446744073709551615",
"18446744073709551616", "1" + "0" * 30, "1" + "0" * 308, "1" + "0" * 309, "1" + "0" * 400,
"-" + "1" + "0" * 400, "0.0", "-0.0", "1.0", "1e0", "1E2", "1e-2", "1e308", "1e309", "1e400",
"-1e400", "1E400", "1e-400", "1.7976931348623157e308", "1.7976931348623158e308",
"1.7976931348623159e308", "1.8e308", "2e308", "1e99999999999999999999",
"179769313486231580793728971405303415079934132710037826936173778980444968292764750946649017977587207096330286416692887910946555547851940402630657488671505820681908902000708383676273854845817711531764475730270069855571366959622842914819860834936475292719074168444365510704342711559699508093042880177904174497791",
"01", "+1", ".5", "1.", "1e", "1e+", "--1", "0x10", "NaN", "Infinity", "-Infinity", "1_0",
"1", "١",
]
STRING_BODIES = [
"\\ud800", "\\udc00", "\\udbff\\udfff", "\\ud800\\u0041", "\\ud83d\\ude00", "\\ud83d", "a\\u0000b",
"\\u00e9", "\\u00E9", "\\/", "\x01", "\x7f", "
", "", "￿", "﷐", "\\ud800x",
"\\uDFFF", "\\u", "\\u12", "\\x41", "\\'", "tab\there",
]
def numbers(ins):
wire = ins.wire("energy_accept")
for t in NUMBER_TOKENS:
ins.add("seq_" + t[:24], wire.replace('"seq":0', '"seq":' + t, 1))
ins.add("work_units_" + t[:24], wire.replace('"work_units":49152', '"work_units":' + t, 1))
def strings(ins):
wire = ins.wire("energy_accept")
for s in STRING_BODIES:
ins.add("signer_id_" + s.encode("unicode_escape").decode()[:24],
wire.replace('"did:example:meter"', '"' + s + '"', 1))
ins.add("member_name_" + s.encode("unicode_escape").decode()[:24],
wire.replace('{"body":', '{"' + s + '":1,"body":', 1))
# A validly signed claim whose signer_id holds characters outside the BMP and a control
# character, sent with every such character escaped: canonicalisation undoes escapes.
c = sign(dict(ins.receipt("energy_accept"), signer_id="did:example:m\U0001F600é\u001f"), METER)
escaped = json.dumps(c, ensure_ascii=True, separators=(", ", ": "))
ins.add("escaped_astral_signer_id", escaped)
ins.add("canonical_astral_signer_id", CV.jcs(c))
ins.add("bom_prefixed", "" + wire)
ins.add("trailing_garbage", wire + " x")
ins.add("trailing_whitespace", wire + " \n\t\r")
ins.add("nbsp_whitespace", " " + wire)
ins.add("empty", "")
def nesting(ins):
wire = ins.wire("energy_accept")
for d in list(range(1, 40)) + [63, 64, 65, 126, 127, 128, 129, 200, 999, 1001, 5000]:
ins.add(f"nesting_{d}", wire.replace('"did:example:meter"', "[" * d + "]" * d, 1))
ins.add("nesting_obj_40", wire.replace('"did:example:meter"', '{"a":' * 40 + "1" + "}" * 40, 1))
def dates(ins, rng, n):
base = ins.receipt("energy_accept")
fixed = ["2026-02-28T00:00:00Z", "2026-02-29T00:00:00Z", "2028-02-29T00:00:00Z", "2100-02-29T00:00:00Z",
"2000-02-29T00:00:00Z", "1900-02-29T00:00:00Z", "0000-02-29T00:00:00Z", "0004-02-29T00:00:00Z",
"2026-04-31T00:00:00Z", "2026-04-30T23:59:60Z", "2026-12-31T23:59:60+14:00", "2026-01-00T00:00:00Z",
"2026-00-10T00:00:00Z", "2026-13-01T00:00:00Z", "2026-09-27T24:00:00Z", "2026-09-27T12:60:00Z",
"2026-09-27T12:00:61Z", "2026-09-27T12:00:00.Z", "2026-09-27T12:00:00.000000001Z",
"2026-09-27t12:00:00z", "2026-09-27T12:00:00-00:00", "2026-09-27T12:00:00+24:00",
"2026-09-27T12:00:00+23:60", "2026-09-27 12:00:00Z", "2026-9-27T12:00:00Z",
"2026-09-27T12:00:00Z", "2026-09-2٧T12:00:00Z", "2026-09-27T12:00:00Z",
"+2026-09-27T12:00:00Z", "2026-09-27T12:00:00Z\n", "2026-09-31T12:00:00Z", "2026-06-31T12:00:00Z",
"2026-11-31T12:00:00Z", "2026-01-31T12:00:00Z", "2026-03-31T12:00:00Z"]
cases = list(fixed)
for _ in range(n):
y, mo, d = rng.randrange(0, 10000), rng.randrange(0, 14), rng.randrange(0, 33)
cases.append(f"{y:04d}-{mo:02d}-{d:02d}T{rng.randrange(0, 25):02d}:{rng.randrange(0, 61):02d}:"
f"{rng.randrange(0, 62):02d}{rng.choice(['Z', '+05:30', '-12:00', '.5Z', 'z'])}")
for t in cases:
ins.add("issued_at_" + t.encode("unicode_escape").decode()[:32], CV.jcs(sign(dict(base, issued_at=t), METER)))
def endpoints(ins, rng, n):
base = ins.receipt("soft_binding_accept")
fixed = ["https://resolver.example.org/matches", "https://:443/m", "https://:/m", "https://resolver.example.org:/m",
"https://resolver.example.org:8443/m", "https://resolver.example.org:x/m", "https://resolver.example.org:1:2/m",
"https://[2001:db8::1]:8443/m", "https://[2001:db8::1]/m", "https://[]/m", "https://[/m", "https://[::1/m",
"https://::1]/m", "https://[::1]x/m", "https://[::1]:/m", "https://[::1]:x/m", "https://h]:1/m",
"https://[email protected]/m", "https://@/m", "https://resolver.example.org",
"https://resolver.example.org?q", "https://resolver.example.org#f", "https://?q", "https://#f", "https://",
"https:///m", "HTTPS://resolver.example.org/m", "http://resolver.example.org/m", "https:resolver.example.org",
"https://résolver.example.org/m", "https://resolver.example.org:8/m", "https://1.2.3.4:5/m",
"https://%72esolver.example.org/m", "https://a/b@c"]
alphabet = "ab:[]@/?#1%."
cases = list(fixed) + ["https://" + "".join(rng.choice(alphabet) for _ in range(rng.randrange(0, 9))) for _ in range(n)]
for e in cases:
body = dict(base["body"], resolution_endpoint=e)
ins.add("endpoint_" + e.encode("unicode_escape").decode()[:32], CV.jcs(sign(dict(base, body=body), PUBLISHER)))
FIGURES = ["0.0120005", "0.012000500000000000000000000000000", "0.0120004999999999999999999999999999",
"0.0120005000000000000000000000000000", "0.012000500000000000000000000000000000000000000",
"0.0120004999999999999999999999999999999999999999", "0.012000", "0.0120004", "0.0120006", "1.20005e-2",
"120005e-7", "1.20005E-2", "1.20005e-02", "1.20005e-002", "1.20005e-0002", "12000.5e-6",
"0.0000000000000000000000000000000000000000000000120005e43",
"+0.0120005", " 0.0120005", "0.0120005 ", "00.0120005", ".0120005", "0.0120005e", "0_0120005", "1_2",
"0.0120005", "NaN", "Infinity", "-0.0120005", "0", "-0", "0e0", "1e-999", "1e999", "1e-1000",
"12000500000000000000000000000000000000000000000e-51", "0.00000050", "0.0000005", "0.0000015", "0.0000025",
"18446744073709.5516155", "18446744073709.551615", "9007199254.740991", "9007199254.7409915"]
# JSON numbers: §4 admits integers within I-JSON's range in a body and nothing else, so every
# fraction, `-0` and integer beyond the range here must make the receipt malformed. Among
# them, doubles half-way between two shortest decimals, which a formatter could write two
# ways — no receipt body may carry one, so no content address may depend on it.
NUMERIC_FIGURES = [0.0120005, 0.012, 0.0120004, 0.0120006, 0.0000025, 1.5e-6, 2.5e-6, 12, 0, -0.0120005, 1e-300,
9007199254.740991, 1.2000500000000001e-2, True, False, None, -0.0,
1e37, 9.999999999999999e37, 1e38, 1e-38, 9.9e-39, 1.7976931348623157e308, 5e-324,
2 ** 53 - 1, 2 ** 53, -(2 ** 53 - 1), -(2 ** 53), 2 ** 63 - 1, 2 ** 64 - 1, 2 ** 64, 10 ** 30,
75.036163330078125, 2365.15167236328125, 5.9604644775390625e-8, 983123238.6575775]
# §4's decimal strings at their limits: 34 significand digits and a three-digit exponent.
RANGE_FIGURES = ["9" * 34, "9" * 35, "0." + "0" * 32 + "1", "0." + "0" * 33 + "1", "-" + "9" * 34, "1e999", "1e-999",
"9.999999999999999999999999999999999e999", "9.9999999999999999999999999999999999e999",
"0.0120004" + "9" * 26, "0.0120004" + "9" * 27, "0.0120005" + "0" * 26, "0.0120005" + "0" * 26 + "1"]
LABELS = ["OnChipCounter", "CalibratedInstrument", "ModelBased", "Estimator", "HwShunt", "onchipcounter",
"HWSHUNT", "Measured", "", " OnChipCounter", "OnChipCounter\u200b"]
UNCERTAINTIES = [None, {"relative_ppm": 20000, "window_us": 1000000}, {"relative_ppm": 0, "window_us": 1},
{"relative_ppm": 1, "window_us": 0}, {"relative_ppm": 2 ** 32 - 1, "window_us": 1},
{"relative_ppm": 2 ** 32, "window_us": 1}, {"relative_ppm": 2 ** 53 - 1, "window_us": 2 ** 53 - 1},
{"relative_ppm": 288676, "window_us": 1}, {"relative_ppm": 288675, "window_us": 1},
{"relative_ppm": 144338, "window_us": 1}, {"relative_ppm": 144337, "window_us": 1},
{"relative_ppm": 20000}, {"window_us": 1}, {}, {"relative_ppm": 20000, "window_us": 1, "k": 2},
{"relative_ppm": "20000", "window_us": 1}, [20000, 1], "20000", 20000, True]
FILTERINGS = [None, "On", "Off", "Unknown", "unknown", "", "Maybe", 0, False]
# A calibration reference is blank when it is White_Space only (energy-measurement §2.5, as
# the reference implementation trims it); U+001C to U+001F, U+200B and U+FEFF are not.
CALIBRATION_REFS = [None, "cert 7", "", " ", "\t", "\u3000", "\u0085", "\u00a0", "\u2028", "\u001c", "\u001f",
"\u200b", "\ufeff", " x ", "https://calibration.example.org/certificates/0001", 7]
CLASS_FIGURES = [1, 2, 14, 15, 12000, 2 ** 53 - 1, 0]
def classes(ins, rng, n):
"""§2.4.5 rule 2 and its interaction with rule 5: energy claims with every acquisition
label, uncertainty, filtering state and calibration reference above in fixed
combinations — each field varied alone from a valid counter, a valid instrument and a
ModelBased figure — and `n` random combinations of all four with a figure and a grade.
Each is signed, so its verdict is decided at step 2, 4 or later, never by the signature."""
base = ins.receipt("energy_accept")
bases = {
"OnChipCounter": dict(base["body"], energy_provenance="OnChipCounter",
energy_uncertainty={"relative_ppm": 20000, "window_us": 1000000},
energy_counter_filtering="Unknown", grade="measured"),
"CalibratedInstrument": dict(base["body"], energy_provenance="CalibratedInstrument",
energy_uncertainty={"relative_ppm": 5000, "window_us": 1000000},
energy_calibration_ref="cert 7", grade="measured"),
"ModelBased": dict(base["body"], oer_receipt=None),
}
cases = []
for bname, b in bases.items():
b = dict(b, oer_receipt=None)
cases += [(f"{bname}_label_{i}", dict(b, energy_provenance=x)) for i, x in enumerate(LABELS)]
cases += [(f"{bname}_uncertainty_{i}", dict(b, energy_uncertainty=x)) for i, x in enumerate(UNCERTAINTIES)]
cases += [(f"{bname}_filtering_{i}", dict(b, energy_counter_filtering=x)) for i, x in enumerate(FILTERINGS)]
cases += [(f"{bname}_calibration_{i}", dict(b, energy_calibration_ref=x)) for i, x in enumerate(CALIBRATION_REFS)]
cases += [(f"{bname}_figure_{i}", dict(b, joules_micro=x)) for i, x in enumerate(CLASS_FIGURES)]
cases += [(f"{bname}_grade_{g}", dict(b, grade=g)) for g in OER.GRADES + ["certified"]]
for i in range(n):
cases.append((f"random_{i}", dict(base["body"], oer_receipt=None, energy_provenance=rng.choice(LABELS),
energy_uncertainty=rng.choice(UNCERTAINTIES),
energy_counter_filtering=rng.choice(FILTERINGS),
energy_calibration_ref=rng.choice(CALIBRATION_REFS),
joules_micro=rng.choice(CLASS_FIGURES), grade=rng.choice(OER.GRADES))))
for name, body in cases:
ins.add("class_" + name, CV.jcs(sign(dict(base, body=body), METER)))
# §3.1 step 2 reads a body's members, then each member's type and encoding together in its
# table's order, a nested object's members where the table reaches it and a lawful-basis
# body's `record` first. One defect shows where each is caught; two at different positions
# show the order. Per accepted corpus claim: its signer and, in table order, each position
# (a member, or a member of a nested object) with the values put there. DELETE removes the
# member; a position ending in EXTRA adds a member the table does not have. Values stay in
# what the canonical form can write, so every input is signed — numbers at the edges of
# what a claim may hold are the `numbers` family's.
DELETE = object()
EXTRA = "+"
BODY_DEFECTS = {
"lawful_basis_declaration_accept": ("OPERATOR", [
(("record",), ["consent", "decision", 5, DELETE]),
(("vocabulary",), [5, None, "w3id.org/dpv", "", "https://w3id.org/ dpv", DELETE]),
(("fragment",), [5, "zz", "AB" * 32, "ab", DELETE]),
(("purposes",), ["x", [], [5], {}]),
(("purposes", 0, "purpose"), [5, "dpv:A B", "", "dpv:\u3000", DELETE]),
(("purposes", 0, "basis"), [5, None, "", "a b", DELETE]),
(("purposes", 0, "data"), ["x", [5], [""], ["a b"], [], DELETE]),
(("purposes", 0, EXTRA), [1]),
(("purposes", 1, "purpose"), [5, "dpv:A B", "dpv:ServiceProvision", DELETE]),
(("purposes", 1, "basis"), [5, "", "a\tb", DELETE]),
(("purposes", 1, "data"), [[5], ["x y"], DELETE]),
(("purposes", 1, EXTRA), [1]),
((EXTRA,), [1]),
]),
"lawful_basis_decision_accept": ("VIEWER", [
(("record",), ["declaration", "x", 5, DELETE]),
(("vocabulary",), [5, "dpv", DELETE]),
(("declaration",), [5, "zz", "AB" * 32, DELETE]),
(("purpose",), [5, "", "a b", DELETE]),
(("outcome",), [5, "", DELETE]),
((EXTRA,), [1]),
]),
"energy_on_chip_counter_accept": ("METER", [
(("joules_micro",), ["1", 0, True, None, DELETE]),
(("energy_provenance",), [5, "Measured", "HwShunt", DELETE]),
(("energy_uncertainty",), ["x", [1, 1], None, {}, DELETE]),
(("energy_uncertainty", "relative_ppm"), ["1", 0, DELETE]),
(("energy_uncertainty", "window_us"), [None, 0, DELETE]),
(("energy_uncertainty", EXTRA), [2]),
(("energy_counter_filtering",), [5, None, "Maybe", DELETE]),
(("energy_calibration_ref",), [5, "cert", DELETE]),
(("coverage",), [5, "Most", DELETE]),
(("grade",), [5, "certified", DELETE]),
(("method",), [5, "x", "", DELETE]),
(("work_units",), ["1", 0, DELETE]),
(("work_unit",), [5, "", "a b", DELETE]),
(("oer_receipt",), [5, "md5:1", DELETE]),
((EXTRA,), [1]),
]),
"soft_binding_accept": ("PUBLISHER", [
(("alg",), [5, "", "a b", "bare", DELETE]),
(("registry",), [5, "x", DELETE]),
(("binding_type",), [5, "hash", DELETE]),
(("value",), [5, "zz", "ABCD", "", DELETE]),
(("resolution_endpoint",), [5, "x", "http://x/", "https://u@x/", DELETE]),
((EXTRA,), [1]),
]),
"binding_recovery_accept": ("RESOLVER", [
(("alg",), [5, "", "bare", DELETE]),
(("binding_type",), [5, "hash", DELETE]),
(("value",), [5, "zz", "", DELETE]),
(("declaration",), [5, "zz", DELETE]),
(("resolution_endpoint",), [5, "x", "http://x/", DELETE]),
(("observed",), [5, "zz", DELETE]),
(("attests",), [5, "object", DELETE]),
((EXTRA,), [1]),
]),
"log_inclusion_accept": ("PUBLISHER", [
(("log",), [5, "x", DELETE]),
(("profile",), [5, "", DELETE]),
(("entry",), [5, "", "a b", DELETE]),
(("log_receipt",), [5, "zz", "", DELETE]),
((EXTRA,), [1]),
]),
}
def edit_at(body, path, value):
"""Put `value` at `path` in `body` (DELETE removes the member there; a path ending in
EXTRA adds the member `note`). False if an earlier edit left no object on the path."""
*parents, last = path
node = body
for p in parents:
if isinstance(p, int):
if not (isinstance(node, list) and p < len(node)):
return False
elif not (isinstance(node, dict) and p in node):
return False
node = node[p]
if not isinstance(node, dict):
return False
if last == EXTRA:
node["note"] = value
elif value is DELETE:
node.pop(last, None)
else:
node[last] = value
return True
def bodies(ins):
keys = {"OPERATOR": OPERATOR, "VIEWER": VIEWER, "METER": METER, "PUBLISHER": PUBLISHER, "RESOLVER": RESOLVER}
for name, (signer, positions) in BODY_DEFECTS.items():
base = ins.receipt(name)
singles = [(path, i, value) for path, values in positions for i, value in enumerate(values)]
cases = [[s] for s in singles]
for a, first in enumerate(singles):
for second in singles[a + 1:]:
p, q = first[0], second[0]
if p[:len(q)] != q and q[:len(p)] != p: # different positions, neither inside the other
cases.append([first, second])
for case in cases:
body = copy.deepcopy(base["body"])
if not all(edit_at(body, path, value) for path, _, value in case):
continue
label = "+".join(".".join(map(str, path)) + f"#{i}" for path, i, _ in case)
ins.add(f"body_{name[:-len('_accept')]}_{label}", CV.jcs(sign(dict(base, body=body), keys[signer])))
def figures(ins, rng, n):
oer_t = ins.by_name["energy_matches_oer_accept"]["context"]["oer"]["receipt"]
claim_t = ins.receipt("energy_matches_oer_accept")
keys = {OER_SIGNER: pk(METER)}
cases = [(f, uj) for f in FIGURES + NUMERIC_FIGURES + RANGE_FIGURES for uj in (1, 12000, 12001, 11999, 2, 3)]
for _ in range(n):
uj = rng.randrange(1, 10 ** rng.randrange(1, 16)) # a claim's integers stop at 2^53 - 1
frac = "".join(rng.choice("0123456789") for _ in range(rng.randrange(0, 50)))
pick = rng.randrange(4)
if pick == 0: # exactly half-way, with trailing zeros
f = f"{uj // 10**6}.{uj % 10**6:06d}5" + "0" * rng.randrange(0, 40)
elif pick == 1: # just either side of half-way
f = f"{uj // 10**6}.{uj % 10**6:06d}{rng.choice(['49', '50', '51'])}{frac}"
elif pick == 2: # exponent form of the same digits
s = f"{uj}5{frac}"
f = f"{s}e-{len(s) - len(str(uj)) + 6}"
else: # a double near the figure
f = (uj + rng.choice([0.5, 0.4999999, 0.5000001, 0.25])) / 1e6
cases.append((f, rng.choice([uj, uj + 1, uj - 1 if uj > 1 else uj])))
for f, uj in cases:
r = seal_oer(oer_t, f)
body = dict(claim_t["body"], joules_micro=uj, oer_receipt=r["receipt_id"])
c = sign(dict(claim_t, body=body), METER)
ins.add(f"figure_{str(f)[:40]}_uj{uj}", CV.jcs(c), {"oer": {"receipt": r, "keys": keys}})
def sums(ins, rng, n):
"""OER receipts whose breakdown or floor exercises §6's exact arithmetic: large terms
that cancel, differences at and one unit past the 1e-9 tolerance, floors exactly on
the 1% bound, and quantities at §4's limits. The claim names each receipt with the
figure's microjoules, so the verdict turns on whether each OER verifier accepts the
receipt."""
oer_t = ins.by_name["energy_matches_oer_accept"]["context"]["oer"]["receipt"]
claim_t = ins.receipt("energy_matches_oer_accept")
keys = {OER_SIGNER: pk(METER)}
e37 = "1" + "0" * 37
cases = [
("cancel_1e24", "0.012", ["1000000000000000000000000.012", "-1000000000000000000000000", "0", "0", "0"], None),
("cancel_1e30", "0.012", ["1000000000000000000000000000000.012", "-1000000000000000000000000000000", "0", "0", "0"], None),
("cancel_1e37", "0.012", ["10000000000000000000000000000000000000.012", "-" + e37, "0", "0", "0"], None),
("wide_1e37_1e-38", e37, [e37, "0." + "0" * 37 + "1", "0", "0", "0"], None),
("at_tolerance", "9999999990000000000000000000000000000", [e37, "0", "0", "0", "0"], None),
("past_tolerance", "9999999989999999999999999999999999999", [e37, "0", "0", "0", "0"], None),
("at_tolerance_unit", "1.000000001", ["2", "-1", "0", "0", "0"], None),
("past_tolerance_unit", "1.000000001000000000000000000000000001", ["2", "-1", "0", "0", "0"], None),
("negative_total", "-0.012", ["-0.012", "0", "0", "0", "0"], None),
("floor_tiny_temperature", "0.012", None, (256, "1e-38", "0")),
("floor_huge_temperature", "0.012", None, (256, "9e37", "1.6e+17")),
("floor_zero_bits", "0.012", None, (0, "300", "0")),
("floor_zero_bits_tiny_floor_j", "0.012", None, (0, "300", "1e-38")),
("floor_bits_beyond_64", "0.012", None, (2 ** 64, "300", "1e-18")),
("floor_bits_float", "0.012", None, (256.0, "300", "1.838e-18")),
]
# floor_j exactly on each 1% bound, and one unit of its last digit either side: §6's
# check is exact, so a difference exactly at the tolerance passes and one past it does
# not, in both verifiers or neither.
with localcontext(OER.EXACT):
for bits in (1, 256, 510, 4096, 2 ** 40):
for temp in ("300", "3.000000e+02", "0.001", "77.5"):
exact = Decimal(bits) * Decimal("1.380649e-23") * Decimal(temp) * Decimal("0.6931471805599453")
for side in ("0.99", "1.01"):
on = exact * Decimal(side)
unit = Decimal((0, (1,), on.as_tuple().exponent))
for name, fj in (("on", on), ("below", on - unit), ("above", on + unit)):
cases.append((f"floor_{name}_bound_{bits}_{temp}_{side}", "0.012", None, (bits, temp, format(fj, "e"))))
def term():
whole = "".join(rng.choice("0123456789") for _ in range(rng.randrange(1, 20))).lstrip("0") or "0"
frac = "".join(rng.choice("0123456789") for _ in range(rng.randrange(1, 25)))
return ("-" if rng.random() < 0.3 else "") + whole + "." + frac
for _ in range(n):
# Five random terms, some negative, up to 43 digits (so some exceed §4's 34 and make
# the receipt malformed), and a total exactly at, inside or outside the tolerance of
# their sum.
terms = [term() for _ in range(5)]
with localcontext(OER.EXACT):
total = sum(Decimal(t) for t in terms)
tol = Decimal("1e-9") * max(Decimal(1), abs(total))
jt = total + rng.choice([Decimal(0), tol, -tol, tol * 2, Decimal("1e-30")])
cases.append((f"random_sum_{len(cases)}", format(jt, "f"), terms, None))
for name, jt, terms, floor in cases:
r = seal_oer(oer_t, jt, terms, floor)
uj = Decimal(jt) * 1000000 if OER.DECIMAL.fullmatch(jt) else 1
uj = max(1, min(int(abs(uj).to_integral_value()), 2 ** 53 - 1))
body = dict(claim_t["body"], joules_micro=uj, oer_receipt=r["receipt_id"])
ins.add("sum_" + name, CV.jcs(sign(dict(claim_t, body=body), METER)), {"oer": {"receipt": r, "keys": keys}})
def readings(ins):
"""oer-receipts §4 "Numbers": numbers a receipt body must not carry — `-0` however
spelt, fractions, exponents, integers beyond I-JSON's range — beside the range's own
bounds, in figures and in members no check reads. A body holding a forbidden number is
`malformed` before its content address is computed, so the verdict is the same however
a reader would have rounded it. And a delivered object's microjoules spelt as numbers
the corpus does not give them as."""
oer_t = ins.by_name["energy_matches_oer_accept"]["context"]["oer"]["receipt"]
claim_t = ins.receipt("energy_matches_oer_accept")
keys = {OER_SIGNER: pk(METER)}
def k_floor(bits): # temperature_k "300", computed as §6 fixes it
with localcontext(OER.EXACT):
return format(Decimal(bits) * OER.KBOLTZ * Decimal(300) * OER.LN2, "e")
def at(path, spelling):
def edit(r):
*outer, last = path
for p in outer:
r = r[p]
r[last] = raw(spelling)
return edit
def both(*edits):
return lambda r: [e(r) for e in edits]
zero_terms = ["0.012000", "0", "0", "0", "0"]
cases = [ # (name, joules_total, breakdown, floor, edit, microjoules)
("term_neg_zero", "0.012000", zero_terms, None, at(("energy", "breakdown", "movement_j"), "-0"), 12000),
("term_neg_zero_float", "0.012000", zero_terms, None, at(("energy", "breakdown", "movement_j"), "-0.0"), 12000),
("term_neg_zero_exp", "0.012000", zero_terms, None, at(("energy", "breakdown", "movement_j"), "-0e7"), 12000),
("total_neg_zero", "0", ["0", "0", "0", "0", "0"], None,
both(at(("energy", "joules_total"), "-0"), at(("energy", "breakdown", "compute_j"), "-0")), 1),
("latency_neg_zero", "0.012000", None, None, at(("work", "latency_s"), "-0"), 12000),
("status_neg_zero", "0.012000", None, None, at(("work", "status"), "-0"), 12000),
("status_i64_min", "0.012000", None, None, at(("work", "status"), "-9223372036854775808"), 12000),
("status_below_i64", "0.012000", None, None, at(("work", "status"), "-9223372036854775809"), 12000),
("status_u64_max", "0.012000", None, None, at(("work", "status"), "18446744073709551615"), 12000),
("status_i_json_max", "0.012000", None, None, at(("work", "status"), "9007199254740991"), 12000),
("status_i_json_min", "0.012000", None, None, at(("work", "status"), "-9007199254740991"), 12000),
("status_beyond_i_json", "0.012000", None, None, at(("work", "status"), "9007199254740992"), 12000),
("status_below_i_json", "0.012000", None, None, at(("work", "status"), "-9007199254740992"), 12000),
("status_one_point_zero", "0.012000", None, None, at(("work", "status"), "1.0"), 12000),
("status_exponent", "0.012000", None, None, at(("work", "status"), "1e0"), 12000),
("status_beyond_u64", "0.012000", None, None, at(("work", "status"), "18446744073709551616"), 12000),
("status_1e30_integer", "0.012000", None, None, at(("work", "status"), "1" + "0" * 30), 12000),
("status_308_digits", "0.012000", None, None, at(("work", "status"), "1" + "0" * 308), 12000),
("status_exponent_zeros", "0.012000", None, None, at(("work", "status"), "1e" + "0" * 400 + "5"), 12000),
("status_underflow", "0.012000", None, None, at(("work", "status"), "-1e-400"), 12000),
("bits_beyond_u64", "0.012000", None, (2 ** 64, "300", k_floor(float(2 ** 64))),
at(("floor", "bits_committed"), "18446744073709551616"), 12000),
("bits_1e30_integer", "0.012000", None, (10 ** 30, "300", k_floor(1e30)),
at(("floor", "bits_committed"), "1" + "0" * 30), 12000),
("bits_neg_zero", "0.012000", None, (0, "300", "0"), at(("floor", "bits_committed"), "-0"), 12000),
("total_beyond_u64_micro", "18446744073709.551616", ["18446744073709.551616", "0", "0", "0", "0"], None,
at(("energy", "joules_total"), "18446744073709551616e-6"), 2 ** 53 - 1),
("total_number_spelt_long", "0.012000", zero_terms, None,
at(("energy", "joules_total"), "0.01200000000000000000000000000000000000000000000001"), 12000),
]
for name, jt, terms, floor, edit, uj in cases:
r = seal_oer(oer_t, jt, terms, floor, edit)
body = dict(claim_t["body"], joules_micro=uj, oer_receipt=r["receipt_id"])
c = CV.jcs(sign(dict(claim_t, body=body), METER))
ins.add(f"reading_{name}", c, {"oer": {"receipt": r, "keys": keys}})
delivery = ins.by_name["energy_matches_delivery_accept"]
for spelling in ["12000", "-0", "12000.0", "1.2e4", "18446744073709551616", "-12000"]:
ctx = dict(delivery["context"], object=dict(delivery["context"]["object"], joules_micro=raw(spelling)))
ins.add("delivered_joules_" + spelling[:24], delivery["receipt"], ctx)
# (name, spelling) of a value placed in an OER receipt's `work.outcome`, each in a file of
# its own: whether the file is read at all is the first thing compared.
DOCUMENTS = [
("control", '"encoded"'), ("max_double", "1.7976931348623157e308"), ("below_max_half_ulp", "1.7976931348623158e308"),
("past_max_half_ulp", "1.7976931348623159e308"), ("e400", "1e400"), ("neg_e400", "-1e400"), ("e308_8", "1.8e308"),
("digits_400", "1" + "0" * 400), ("neg_digits_400", "-1" + "0" * 400), ("zero_e_huge", "0e99999999999999999999"),
("one_e_huge", "1e99999999999999999999"), ("one_e_neg_huge", "1e-99999999999999999999"),
("nan", "NaN"), ("infinity", "Infinity"), ("neg_infinity", "-Infinity"),
("lone_high_surrogate", '"\\ud800"'), ("lone_low_surrogate", '"\\udc00"'), ("paired_surrogates", '"\\ud83d\\ude00"'),
("high_surrogate_then_letter", '"\\ud800\\u0041"'),
# A repeated member name, in an object no check reads, and a repeated figure.
("repeated_member", '{"a":1,"a":2}'), ("repeated_member_same_value", '{"a":1,"a":1}'),
("distinct_members", '{"a":1,"b":2}'),
]
# work.outcome sits at level 7 of a corpus file ({vectors:[{context:{oer:{receipt:{work:
# ...}}}}]}), so arrays nested d deep inside it reach level 7 + d; §4 reads 64 levels.
DOCUMENTS += [(f"nesting_to_level_{7 + d}", "[" * d + "]" * d) for d in (55, 56, 57, 58, 121, 200, 1100, 5000)]
def documents(corpus):
"""One corpus-format file per DOCUMENTS entry: an energy claim naming an OER receipt that
holds the value, sealed over §4's reading where the text is one §4 reads."""
ins = Inputs(corpus)
oer_t = ins.by_name["energy_matches_oer_accept"]["context"]["oer"]["receipt"]
claim_t = ins.receipt("energy_matches_oer_accept")
keys = {OER_SIGNER: pk(METER)}
out = []
for name, spelling in DOCUMENTS:
def edit(r, spelling=spelling):
r["work"]["outcome"] = raw(spelling)
try:
r = seal_oer(oer_t, "0.012000", None, None, edit)
except (ValueError, RecursionError):
# §4 does not read the receipt's own text. Sealed with a placeholder, which only
# matters if a verifier reads the file anyway — and then the verdicts differ.
r = seal_oer(oer_t, "0.012000")
edit(r)
body = dict(claim_t["body"], joules_micro=12000, oer_receipt=r["receipt_id"])
one = Inputs(corpus)
one.add("document_" + name, CV.jcs(sign(dict(claim_t, body=body), METER)), {"oer": {"receipt": r, "keys": keys}})
out.append((name, text_of({"about": "one differential input", "vectors": one.out})))
return out
def contexts(ins):
decl = ins.receipt("soft_binding_accept")
record = ins.receipt("binding_recovery_accept")
lawful = ins.receipt("lawful_basis_declaration_accept")
decision = ins.receipt("lawful_basis_decision_accept")
e0 = ins.receipt("energy_accept")
e1 = ins.receipt("energy_chain_accept")
ppk = pk(PUBLISHER)
def recovery_case(name, declaration_wire, rec, algs=(WM_ALG,), publisher=ppk):
ctx = {"declaration": declaration_wire, "publisher": publisher, "exact_match_algs": list(algs)}
ins.add(name, CV.jcs(rec), ctx)
def naming(rec_template, declaration, k, **body):
b = dict(rec_template["body"], declaration=CV.link_hash(declaration), **body)
return sign(dict(rec_template, body=b), k)
# A resolver-signed declaration with its signer_pubkey rewritten to the publisher's
# key, and a record naming that forgery: a declaration parsed but not verified.
forged = dict(sign(decl, RESOLVER), signer_pubkey=ppk)
recovery_case("recovery_forged_declaration", CV.jcs(forged), naming(record, forged, RESOLVER))
forged_pub = dict(decl, seq=decl["seq"] + 1) # the publisher's declaration, altered after signing
recovery_case("recovery_altered_declaration", CV.jcs(forged_pub), naming(record, forged_pub, RESOLVER))
recovery_case("recovery_declaration_not_json", "not json", record)
recovery_case("recovery_declaration_not_a_claim", CV.jcs(dict(decl, kind="group")), record)
recovery_case("recovery_declaration_lawful", CV.jcs(lawful), naming(record, lawful, RESOLVER))
recovery_case("recovery_declaration_energy_forged", CV.jcs(dict(e0, seq=5)), naming(record, dict(e0, seq=5), RESOLVER))
recovery_case("recovery_record_alg_differs", CV.jcs(decl), naming(record, decl, RESOLVER, alg="org.example.watermark.2"))
recovery_case("recovery_record_type_differs", CV.jcs(decl), naming(record, decl, RESOLVER, binding_type="fingerprint"))
recovery_case("recovery_record_no_matcher", CV.jcs(decl), record, algs=())
recovery_case("recovery_record_ok", CV.jcs(decl), record)
recovery_case("recovery_record_rogue_publisher", CV.jcs(decl), record, publisher=pk(RESOLVER))
# A decision naming a declaration altered after the operator signed it (a purpose added).
altered = copy.deepcopy(lawful)
altered["body"]["purposes"].append({"purpose": "dpv:PersonalisedAdvertising", "basis": "dpv:Consent", "data": []})
dec_b = dict(decision["body"], declaration=CV.link_hash(altered), purpose="dpv:PersonalisedAdvertising")
ins.add("decision_altered_declaration", CV.jcs(sign(dict(decision, body=dec_b), VIEWER)), {"declaration": CV.jcs(altered)})
ins.add("decision_declaration_not_json", CV.jcs(decision), {"declaration": "{"})
ins.add("decision_declaration_is_decision", CV.jcs(sign(dict(decision, body=dict(decision["body"], declaration=CV.link_hash(decision))), VIEWER)),
{"declaration": CV.jcs(decision)})
ins.add("decision_declaration_is_soft_binding", CV.jcs(sign(dict(decision, body=dict(decision["body"], declaration=CV.link_hash(decl))), VIEWER)),
{"declaration": CV.jcs(decl)})
st = sign(dict(decision, subject_type="receipt"), VIEWER)
ins.add("decision_subject_type_differs", CV.jcs(st), {"declaration": CV.jcs(lawful)})
ins.add("declaration_given_to_a_declaration", CV.jcs(lawful), {"declaration": CV.jcs(lawful)})
ins.add("declaration_given_to_energy", CV.jcs(e0), {"declaration": "not json"})
# Step 5 against a previous claim that does not itself verify.
bad_prev = dict(e0, seq=0, issued_at="2026-09-27T12:00:01Z") # altered after signing
e1_bad = sign(dict(e1, parent_receipt_hash=CV.link_hash(bad_prev)), METER)
ins.add("previous_altered_named", CV.jcs(e1_bad), {"previous": CV.jcs(bad_prev)})
ins.add("previous_altered_head", CV.jcs(sign(dict(e1, parent_receipt_hash=None), METER)), {"previous": CV.jcs(bad_prev)})
ins.add("previous_not_json", CV.jcs(e1), {"previous": "[]"})
ins.add("previous_ok", CV.jcs(e1), {"previous": CV.jcs(e0)})
# §3.2 with the same declarations.
for name, d in [("forged", forged), ("altered", forged_pub), ("ok", decl)]:
ins.add("recover_" + name, None, {"declaration": CV.jcs(d), "publisher": ppk, "observed_value": decl["body"]["value"],
"observed_hash": None, "exact_match_algs": [WM_ALG]}, procedure="recover")
# ------------------------------------------------------------------ run and compare
def run_raw(cmd):
env = dict(os.environ, PYTHONIOENCODING="utf-8", PYTHONDONTWRITEBYTECODE="1")
return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", env=env)
def run(cmd):
p = run_raw(cmd)
if p.returncode != 0:
sys.exit(f"{' '.join(cmd)} exited {p.returncode}: {p.stderr.strip()[:400]}")
return [line.split("\t", 1) for line in p.stdout.splitlines()]
def document_outcome(p, python):
"""`refused`, the verdict lines, or `crash: ...`. The reference refuses a file with exit
status 1, no output and `not read:` on stderr; anything else that exits non-zero is a
crash. wai-rs exits 1 with its reader's error on stderr."""
if p.returncode == 0:
return "\n".join(p.stdout.splitlines())
if p.stdout or p.returncode != 1 or (python and "not read:" not in p.stderr):
return "crash: exit %d: %s" % (p.returncode, p.stderr.strip().splitlines()[-1:] or "")
return "refused"
def main(argv):
ap = argparse.ArgumentParser()
ap.add_argument("rust_bin")
ap.add_argument("--random", type=int, default=200)
ap.add_argument("--seed", type=int, default=1)
ap.add_argument("--keep")
a = ap.parse_args(argv[1:])
rng = random.Random(a.seed)
corpus = OER.load(open(os.path.join(HERE, "vectors.json"), encoding="utf-8"))
ins = Inputs(corpus)
numbers(ins)
strings(ins)
nesting(ins)
dates(ins, rng, a.random)
endpoints(ins, rng, a.random)
figures(ins, rng, a.random)
sums(ins, rng, a.random)
classes(ins, random.Random(f"classes {a.seed}"), a.random) # its own stream: other families' draws are unchanged
readings(ins)
contexts(ins)
bodies(ins)
doc = text_of({"about": "differential inputs; no outcome is declared", "vectors": ins.out})
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f:
f.write(doc)
path = f.name
try:
rust = run([a.rust_bin, "verdicts", path])
py = run([sys.executable, os.path.join(HERE, "claim_verify.py"), "verdicts", path])
finally:
if a.keep:
os.replace(path, a.keep)
else:
os.unlink(path)
if len(rust) != len(ins.out) or len(py) != len(ins.out):
sys.exit(f"verdict counts differ: {len(ins.out)} inputs, wai-rs {len(rust)}, python {len(py)}")
differ = [(n, r, p) for (n, r), (_, p) in zip(rust, py) if r != p]
crashes = [(n, v) for n, v in rust + py if v.startswith("crash")]
tally = {}
for _, v in rust:
k = v.split(" link_hash=")[0].split(" ancestor=")[0]
tally[k] = tally.get(k, 0) + 1
oer = "reject step=6 reason=energy_oer_invalid"
for n, r, p in differ:
# Marked, never set apart: the verdicts differ either way.
mark = " (one side's OER verifier refused the receipt)" if oer in (r, p) else ""
print(f"DIFFER {n}{mark}\n wai-rs: {r}\n python: {p}")
for n, v in crashes:
print(f"CRASH {n}: {v}")
print(f"{len(ins.out)} inputs; verdicts: " + ", ".join(f"{k} x{c}" for k, c in sorted(tally.items())))
# Files of their own: read by both or refused by both, and the same verdicts if read.
doc_differ, doc_crash, doc_tally = 0, 0, {}
with tempfile.TemporaryDirectory() as d:
for name, text in documents(corpus):
path = os.path.join(d, name + ".json")
with open(path, "w", encoding="utf-8") as f:
f.write(text)
r = document_outcome(run_raw([a.rust_bin, "verdicts", path]), False)
p = document_outcome(run_raw([sys.executable, os.path.join(HERE, "claim_verify.py"), "verdicts", path]), True)
key = r.split("\t", 1)[-1].split(" link_hash=")[0]
doc_tally[key] = doc_tally.get(key, 0) + 1
if r.startswith("crash") or p.startswith("crash"):
doc_crash += 1
print(f"CRASH document {name}\n wai-rs: {r}\n python: {p}")
elif r != p:
doc_differ += 1
print(f"DIFFER document {name}\n wai-rs: {r}\n python: {p}")
print(f"{len(DOCUMENTS)} documents; wai-rs: " + ", ".join(f"{k} x{c}" for k, c in sorted(doc_tally.items())))
print(f"{len(differ)} differ, {len(crashes)} crash; documents: {doc_differ} differ, {doc_crash} crash")
return 1 if differ or crashes or doc_differ or doc_crash else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))

File diff suppressed because it is too large Load diff

View file

@ -70,15 +70,26 @@ however it is carried:
whose parts are labelled inside the same receipt, and a group receipt's
parts are its objects, labelled, if at all, outside it.
An `energy` claim ([jwp-receipts](jwp-receipts.md) §2.4.5) gives an
object's figure a signed place for its class without changing the object
receipt. It is a receipt of its own, signed by whoever acquired the
figure, that restates the object's `joules_micro` beside its class and the
class's evidence in §2.7's JSON fields, and a verifier holding both
rejects the pair if the two figures differ. The claim meets §2: its figure,
class and evidence are inside its signature. The object receipt still has
no member for the class, and on its own still falls short. Nothing like the
claim exists for a group total.
The interactive-worlds session receipt is a profile of the group receipt that
carries the class as an optional signed label (interactive-worlds §8, and
§2.8 here). It is signed under its own domain, `wai:world-receipt\x01`, not
the base group receipt's, so it is not among these. A JWP group or object
receipt that carries a non-zero figure falls short of §2. A `WAI_RECEIPT`
property falls short too, because its class is outside any signature. This
extension records that rather than exempting them. Giving them a signed place
for the class remains open. It changes the jwp-receipts wire format and what
its producers seal.
extension records that rather than exempting them. Giving the group total a
signed place for its class, and giving either receipt a member of its own for
it, remains open: it changes the jwp-receipts wire format and what its
producers seal.
## 2. The acquisition class (REQUIRED)
@ -150,7 +161,8 @@ reference implementation seals carries its figure in one of two ways:
- **Always present**, in the receipts defined with a class from the start: the
energy binding and its C2PA companion assertion
([energy-binding](energy-binding.md)), the `wai.video.int_motion` receipt,
and the `wai.video.*` OTT receipts.
the `wai.video.*` OTT receipts, and the `energy` claim
([jwp-receipts](jwp-receipts.md) §2.4.5).
- **As an optional label** (§2.8), in the receipt families that sealed a figure
before the class existed and whose issued receipts must keep verifying: the
world-session, film and reel, haptic, score, 4D-splat, audio-scene,
@ -458,7 +470,8 @@ Receipts sealed before this revision carry `HwShunt`. Its meaning is fixed as
sealed, so it accepts `HwShunt` there as above. The reference implementation
meets the rule in its own producers, none of which emits `HwShunt`, and
holds its callers to it in part: its meters cannot produce `HwShunt`, a new
energy binding and an optional label (§2.8) refuse it, and the `wai-rs`
energy binding, an optional label (§2.8) and an `energy` claim
([jwp-receipts](jwp-receipts.md) §2.4.5) refuse it, and the `wai-rs`
variant is deprecated, so code that names it draws a compiler warning. The
constructors of the `wai.video.int_motion` receipt and the `wai.video.*` OTT
receipts accept `HwShunt`, so that an issued receipt can be recreated byte
@ -499,9 +512,13 @@ byte-identical to one written before this revision. The names are fixed:
| filtering state | `energy_counter_filtering` | `counterFiltering` |
| calibration reference | `energy_calibration_ref` | `calibrationRef` |
The snake_case carriers are the rendered `wai.video.int_motion` receipt and
every receipt carrying an optional label (§2.8); in a labelled bill of
materials the fields sit inside each labelled entry. Every snake_case field is
The snake_case carriers are the rendered `wai.video.int_motion` receipt,
every receipt carrying an optional label (§2.8), and the body of an `energy`
claim ([jwp-receipts](jwp-receipts.md) §2.4.5); in a labelled bill of
materials the fields sit inside each labelled entry. A claim has no optional
members and no legacy form, so an `energy` claim writes the fields a class does
not carry as `null` rather than omitting them, and writes an `OnChipCounter`'s
filtering state even when it is `Unknown`. Every snake_case field is
`energy_`-prefixed so that it cannot collide with a field a receipt already
renders — a quantum job receipt's `calibration_ref` names the calibration
receipt the job ran under, not a certificate. A reader **MUST** refuse evidence

View file

@ -27,6 +27,17 @@ In scope:
parent-receipt linkage for cross-hop preservation.
- Verifier behaviour, relay preservation requirements, and conformance
test vectors.
- **Typed receipt classes** (§2.4): every receipt belongs to exactly one
class. `group` and `object` receipts are the **delivery** class; a
`claim` receipt (§2.4.3) carries one of the others — `energy`,
`lawful-basis`, `log-inclusion` (§2.4.5–§2.4.7) — on one envelope, one
canonical form and one signature rule, so a statement other than
delivery does not need a field set of its own.
- **A second binding class** (§2.6): a soft binding, declared by the
publisher with a registry-named algorithm and a resolution endpoint,
for objects whose bytes changed in normal distribution — and the
**ancestor rule** (§3.2): a receipt reached through it attests the
object the one in hand was derived from, never the object in hand.
Out of scope:
@ -41,6 +52,12 @@ Out of scope:
sits **above** it. A receipt's content hash is computed over the
full WAI envelope (`WAI1` magic + manifest + payload), not over the
payload alone.
- Key discovery. Every signature here, `claim` signatures included,
presumes the verifier already holds the signer's key by some means
this extension does not specify.
- A soft-binding algorithm, a resolution service, or a transparency
log. This extension names them by identifier and locator (§2.4.7,
§2.6) and defines none of them.
Relationship to WAI SPEC.md:
@ -115,10 +132,15 @@ group receipt whose `signature` length is not 64.
### 2.4. Receipt envelope (typed)
Two receipt kinds are defined: `group` (one per group) and `object`
(one per object). Both are typed JSON; equivalent CBOR is permitted
when the carrier mandates it, provided the field set and semantics
are identical.
Three receipt kinds are defined: `group` (one per group), `object`
(one per object) and `claim` (§2.4.3). Every receipt belongs to
exactly one **class** (§2.4.4). `group` and `object` are the
`delivery` class; for them the class is implied by the kind and is
not carried, so their wire form is exactly as below. A `claim` names
its class in a `class` member. All are typed JSON; equivalent CBOR is
permitted when the carrier mandates it, provided the field set and
semantics are identical (for a `claim`, see §2.4.3 on which bytes are
signed).
#### 2.4.1. `kind: "group"`
@ -131,7 +153,7 @@ Required fields:
| `group_id` | u64 | Monotonic group counter under `publisher_key`. |
| `root_hash` | 32 B hex | §2.2 Merkle root over object content hashes. |
| `leaf_count` | u64 | Number of objects covered by `root_hash`. |
| `joules_total` | u64 | Sum of microjoules across the group's objects. No member carries its acquisition class, which [energy-measurement](energy-measurement.md) §2 requires beside a non-zero figure; §1 there records this as open, for this receipt and the object receipt alike. |
| `joules_total` | u64 | Sum of microjoules across the group's objects. No member carries its acquisition class, which [energy-measurement](energy-measurement.md) §2 requires beside a non-zero figure, and no claim class carries one for a group total; §1 there records this as open. |
| `group_start` | string | RFC 3339 timestamp of the first object. |
| `group_end` | string | RFC 3339 timestamp of the last object. |
| `signer_pubkey` | 32 B hex | Ed25519 verifying key per RFC 8032. |
@ -150,10 +172,329 @@ Required fields:
| `group_id` | u64 | Mirrors the parent group receipt. |
| `object_id` | u64 | Monotonic from 0 within the group. |
| `content_hash` | 32 B hex | §2.1 BLAKE3 over the WAI envelope bytes. |
| `joules_micro` | u64 | Microjoules consumed producing this object. This field fixes the unit and the scope of attribution, not how the number is obtained — see [energy-measurement](energy-measurement.md), which requires an acquisition class beside it and forbids publishing a figure from a measurement that failed. |
| `joules_micro` | u64 | Microjoules consumed producing this object. This field fixes the unit and the scope of attribution, not how the number is obtained — see [energy-measurement](energy-measurement.md), which requires an acquisition class beside it and forbids publishing a figure from a measurement that failed. This receipt has no member for the class; an `energy` claim (§2.4.5) restates the figure with its class under a signature. |
| `origin` | string | Datacenter / origin label. |
| `merkle_proof` | object | Inclusion proof: `{ "siblings": [32 B hex, …], "direction_bits": u64, "leaf_index": u64 }`. `direction_bits` bit `i` is `1` iff the current node at level `i` is the right child. |
#### 2.4.3. `kind: "claim"`
`group` and `object` answer one question — *what was delivered, by
whom, at what energy cost*. Other statements are made about the same
objects: how an energy figure was acquired and how much weight it
bears, on what basis what a sink observes may be processed, that a
receipt was entered in a log, that an object carries a binding that
survives re-encoding. None is a delivery statement, and several are
signed by someone other than the publisher. A `claim` receipt carries
one such statement — a class-scoped `body` under a `class`
discriminator — on one envelope.
Required members. A claim has no optional members: a nullable member
is present and `null`, so one claim has one canonical form.
| field | type | meaning |
|---|---|---|
| `kind` | string | MUST equal `"claim"`. |
| `class` | string | A class of §2.4.4 other than `delivery`. |
| `subject_type` | string | `"object"` or `"receipt"`; which a class admits is fixed by §2.4.4. |
| `subject` | 32 B hex | For `object`: the §2.1 content hash of the object the claim is about. For `receipt`: the link hash of a receipt — §3 step 3's for a group receipt, `link_hash` below for a claim. |
| `seq` | integer | Strictly increasing across the claims of one `signer_pubkey`. |
| `issued_at` | string | A date-time (below) at which the signer issued the claim. Self-asserted, like `group_end`. |
| `body` | object | Class-scoped; its members are fixed by `class` and by nothing else (§2.4.5–§2.4.7, §2.6). |
| `signer_pubkey` | 32 B hex | Ed25519 verifying key per RFC 8032. |
| `signer_id` | string | Bound identity string. |
| `parent_receipt_hash` | 32 B hex or `null` | `link_hash` of this signer's previous claim; `null` for its first. |
| `sig` | 64 B hex | Ed25519 signature, below. |
**Encodings.** Byte strings are lowercase hex — one byte string, one
signed encoding. Integers are unsigned and at most 2^53 − 1, written in
plain decimal: a number written with a sign, a fraction or an exponent
— `-0`, `1.0`, `1e0` — is not an integer here, whatever its value. A
claim carries no other number, and no boolean. A **term** is a compact
IRI (`dpv:Consent`) or an IRI: a non-empty string of at most 2048 bytes
in UTF-8 holding no character with the Unicode White_Space property and
no control character (general category Cc). An **absolute URI** is a
term with a scheme (RFC 3986 §3.1), a colon, and a non-empty
remainder. A **date-time** is RFC 3339 §5.6's `date-time` with ASCII
digits, `T` and `Z` in either case: a month of 1–12 and a day its month
has in its year (§5.7, in the proleptic Gregorian calendar of RFC 3339
Appendix C — `2026-02-29` is not a date, `2028-02-29` is); an hour of at
most 23, a minute of at most 59 and a second of at most 60; any number
of fractional-second digits, at least one if the point is written; and
an offset of `Z` or `±hh:mm` with `hh` at most 23 and `mm` at most 59.
A second of 60 is accepted at any time of day: whether a leap second
was inserted then is a fact about the calendar, not the syntax, and
`issued_at` is self-asserted.
**Canonical form, signature, identity.** A claim's canonical form is
its JSON Canonicalization Scheme serialisation (JCS, RFC 8785). On the
values a claim may carry — strings, unsigned integers no larger than
2^53 − 1, `null`, arrays and objects — JCS reduces to: member names
sorted by their UTF-16 code units; no insignificant whitespace;
strings escaped with `\"` `\\` `\b` `\f` `\n` `\r` `\t` and `\u00xx`
(lowercase hex) for the remaining characters below U+0020, every other
character written as itself in UTF-8; integers in plain decimal. No
number formatting is involved.
```
sig = Ed25519(signer_key, "moq-jwp:claim\x01" || JCS(claim without "sig"))
link_hash = BLAKE3("moq-jwp:claim-link\x01" || JCS(claim))
```
A verifier canonicalises before it verifies: whitespace and member
order on the wire are not signed. Duplicate member names are rejected,
since RFC 8785 is defined over I-JSON (RFC 7493), which forbids them,
and two parsers resolving a duplicate differently would canonicalise
one claim two ways. The signed bytes are this JSON form whatever the
carriage: a carrier that moves claims as CBOR MUST reproduce it to
verify. A claim SHOULD be sent in canonical form, and relays forward
it byte-for-byte (§4.1).
**Chain.** A signer's claims form one chain through
`parent_receipt_hash` and `seq`, **separate from the group chain**: a
claim's parent is never a group receipt and a group receipt's parent
is never a claim, so every existing group-chain verifier is unaffected
by claims. A publisher that issues both keeps two chains under one
key.
#### 2.4.4. Receipt classes
| `class` | carried by | `subject_type` | body | signed by |
|---|---|---|---|---|
| `delivery` | `group`, `object` (implied, not carried) | — | §2.4.1, §2.4.2 | the publisher |
| `energy` | `claim` | `object` | §2.4.5 | whoever acquired the figure |
| `lawful-basis` | `claim` | `object` or `receipt` | §2.4.6 | the service operator (a declaration); the party recording it (a decision) |
| `log-inclusion` | `claim` | `receipt` | §2.4.7 | whoever attaches the log's evidence |
| `soft-binding` | `claim` | `object` | §2.6.2 | the object's publisher |
| `binding-recovery` | `claim` | `object` (the ancestor) | §2.6.3 | the party that performed the recovery |
Rules:
- A verifier **MUST** reject a `claim` whose `class` it does not know,
and **MUST** report the rejection rather than skip the claim.
Silently discarding a class a signer thought load-bearing is the
failure this rule exists to prevent. The rejection is of that claim
only: a delivery receipt for the same subject verifies or fails on
its own.
- `delivery` is reserved to `group` and `object`. A `claim` naming it
**MUST** be rejected; delivery is stated only in the §2.3 signed
payload.
- The class is read from `class` and **MUST NOT** be inferred from the
body's shape. A body **MUST** carry exactly its class's members; a
missing or additional member is a rejection, not an extension point.
A new class, or a new member, is a new registration here.
- A `claim` carries the authority of its signer and no more. `class`
says what is asserted, never that it is true. A claim signature is
not evidence about delivery, and a delivery signature is not
evidence about a claim.
- A `claim` that repeats a figure a delivery receipt carries for the
same subject — the `energy` class does, deliberately, to bind its
labels to the figure — **MUST** agree with it. Where they disagree a
verifier **MUST** reject rather than choose.
#### 2.4.5. `class: "energy"`
§2.4.2's `joules_micro` fixes the unit and the scope of attribution;
[energy-measurement](energy-measurement.md) §2 requires an acquisition
class beside any non-zero figure, with the class's evidence inside the
signature, and the `object` receipt has no member for either. An
`energy` claim supplies them without changing the signed `object`
format: it restates the figure and binds to it, under the claim's
signature, the labels other extensions define. It defines none of those
vocabularies. The class and its evidence are written as
energy-measurement §2.7 writes them in a JSON receipt, under the same
member names; a class that carries no evidence has those members
present and `null`.
| member | type | meaning |
|---|---|---|
| `joules_micro` | integer | The figure. Equal to the `object` receipt's `joules_micro` for the same subject. A zero is an integer here and is refused by rule 1, at step 4. |
| `energy_provenance` | string | An acquisition class of [energy-measurement](energy-measurement.md) §2, verbatim. |
| `energy_uncertainty` | object or `null` | The declared uncertainty (energy-measurement §2.3): `{ "relative_ppm": integer, "window_us": integer }`, exactly those two members. |
| `energy_counter_filtering` | string or `null` | An `OnChipCounter`'s filtering state (energy-measurement §2.4). |
| `energy_calibration_ref` | string or `null` | A `CalibratedInstrument`'s calibration reference (energy-measurement §2.5). |
| `coverage` | string | `Whole` or `Partial`, per [energy-binding](energy-binding.md) §4. |
| `grade` | string | An evidential grade of [oer-receipts](oer-receipts.md) §2, verbatim. |
| `method` | absolute URI | The published protocol the figure was obtained under. |
| `work_units` | integer | The recomputable work the figure is expressed against ([energy-measurement](energy-measurement.md) §5). A zero is refused by rule 6, at step 4. |
| `work_unit` | term | What one work unit is (e.g. `subpixel`). |
| `oer_receipt` | string or `null` | The `receipt_id` (`sha256:<hex>` or `blake3:<hex>`, lowercase) of an [OER receipt](oer-receipts.md) recording the same figure, or `null`. |
§3.1 step 2 reads these members in the table's order, and
`energy_uncertainty`'s two members, where it is an object, as it is
reached; it checks their types and encodings, and nothing more. Which
class, evidence and figure they may hold together, and whether the
figure or the work count is zero, is step 4's.
Rules, checked at §3.1 step 4 in this order:
1. `joules_micro` **MUST NOT** be zero. A zero is a failed measurement,
and an unmetered object gets no `energy` claim at all
([energy-measurement](energy-measurement.md) §2).
2. The acquisition class, in this order:
- `energy_provenance` **MUST** name a class of energy-measurement §2
(`energy_unknown_acquisition`);
- it **MUST NOT** be `HwShunt` (`energy_legacy_acquisition`).
energy-measurement §2.6 forbids the legacy label in a new receipt,
and no claim predates the refinement, so a verifier can hold every
claim to it — as energy-measurement §2.8 holds the optional labels;
- the evidence **MUST** be the class's, whole
(`energy_acquisition_evidence`). `CalibratedInstrument` and
`OnChipCounter` carry a declared uncertainty whose two parts are
non-zero and whose `relative_ppm` is at most 2³² − 1, the width of
its signed encoding (energy-measurement §2.7), and which reaches
the resolution floor, `joules_micro × relative_ppm ≥ 288 676`
(energy-measurement §2.3). `OnChipCounter` carries a filtering state
of `On`, `Off` or `Unknown` and no calibration reference; a filtering
state of `null` is not `Unknown` here, so that one class has one
spelling. `CalibratedInstrument` carries a calibration reference
that is not empty once Unicode White_Space is trimmed from both
ends, and no filtering state. `ModelBased` and `Estimator` carry
none of the three.
3. `coverage` **MUST** be `Whole` or `Partial`.
4. `grade` **MUST** name a grade of oer-receipts §2.
5. `grade` **MUST NOT** rank above the grade the class supports. The
correspondence is oer-receipts §2's — `CalibratedInstrument` and
`OnChipCounter` support up to `measured`, `ModelBased` up to
`derived`, `Estimator` up to `projected` — with grades ranked as
oer-receipts lists them (`projected` < `simulated` < `derived` <
`measured`). Grading a figure *below* what its class supports is
permitted. The grade does not say which hardware class is behind a
`measured` figure; the class and its declared uncertainty do.
6. `work_units` **MUST NOT** be zero.
A relying party holding an `energy` claim with `coverage: "Partial"`
for a subject **MUST NOT** report that subject's `joules_micro` as the
whole cost of producing it.
**Against the delivery receipt** (§3.1 step 6): the claim's
`joules_micro` **MUST** equal the `object` receipt's for the same
subject.
**Against the OER receipt it names** (§3.1 step 6): a verifier holding
that receipt checks, in this order. The receipt is read as oer-receipts
§4 reads a document: a text it refuses whole is no receipt, so a
verifier holding only such a text holds none.
1. that it is the one named — its `receipt_id` equals `oer_receipt`
(`context_mismatch`);
2. that it verifies under [oer-receipts](oer-receipts.md) with no
reason (`energy_oer_invalid`);
3. that it is about the claim's subject — its `subject.output` equals
the claim's `subject` exactly as the claim writes it, 64 lowercase
hex digits (`context_mismatch`). oer-receipts does not fix what
`subject.output` holds; an OER receipt an `energy` claim can name
holds there the object's §2.1 content hash in this form. Without
this check a claim about one object could name a valid OER receipt
about another that happens to carry the same figure and grade;
4. that its `provenance.how` equals `grade`, and that its
`energy.joules_total`, rounded to the nearest microjoule, equals
`joules_micro` (a figure exactly half-way between two microjoules
admits either) (`energy_disagrees_with_oer`). Check 2 has
established that the figure is a quantity oer-receipts §4 reads — a
decimal string of at most 34 significand digits, or an integer
within I-JSON's range — since a receipt whose figure is anything
else, or whose body holds a fraction, `-0` or an integer beyond that
range anywhere, does not verify. The figure is compared exactly, as
oer-receipts §6 compares figures: `0.0120005` is half-way and admits
12,000 and 12,001 µJ, and `0.0120004` followed by twenty-six nines is
below half-way, although rounding it to 28 significant digits would
make it a tie.
This checks that two signed statements of one figure about one object
agree. It does not check that the OER receipt's `energy.boundary` is
the scope §2.4.2 attributes to `joules_micro`; that remains the claim
signer's assertion.
The claim does not make the figure verifiable — re-execution
reproduces the work, never the joules
([energy-measurement](energy-measurement.md) §5). It makes the
figure's labels signed, and a contradiction between them detectable.
#### 2.4.6. `class: "lawful-basis"`
The afferent dual of a delivery statement: on what basis what a sink
observes may be processed, and the record that a decision was taken
against that basis. A body is one of two records, discriminated by
`record`, which §3.1 step 2 reads before anything else in the body:
`record` absent is `missing_required_field`, and a `record` other than
`"declaration"` or `"decision"` is `field_type`. The body is then
checked against the table of the record it names.
A **declaration** (`record: "declaration"`), signed by the service
operator:
| member | type | meaning |
|---|---|---|
| `record` | string | `"declaration"`. |
| `vocabulary` | absolute URI | The vocabulary the terms are drawn from (for example the W3C Data Privacy Vocabulary). |
| `fragment` | 32 B hex or `null` | §2.1 content hash of the envelope carrying the declaration fragment beside the manifest, when one is carried. |
| `purposes` | array | One entry per purpose: `{ "purpose": term, "basis": term or "", "data": [term, …] }`, `data` possibly empty. The array may be empty and a `basis` may be `""` as far as step 2 is concerned: both are refused by the rules below, at step 4, each with its own reason. |
A **decision** (`record: "decision"`), signed by the party recording
it:
| member | type | meaning |
|---|---|---|
| `record` | string | `"decision"`. |
| `vocabulary` | absolute URI | As the declaration's. |
| `declaration` | 32 B hex | `link_hash` of the declaration claim the decision is expressed against. |
| `purpose` | term | The purpose decided on. |
| `outcome` | term | The decision, as a vocabulary term. |
Rules (§3.1 step 4): a declaration **MUST** list at least one purpose;
purposes are then checked in order, and each **MUST** carry its own
non-empty basis — a basis is per purpose, never per service — and
**MUST NOT** repeat an earlier purpose. Against the declaration it
names (§3.1 step 6): the declaration **MUST** itself pass §3.1 steps
1–4 (`context_invalid`) and be a lawful-basis declaration
(`context_mismatch`); a decision **MUST** then name it by `link_hash`,
share its `subject`, `subject_type` and `vocabulary`, and decide on a
purpose the declaration lists. That check does not establish that the
declaration was the service operator's: a verified delivery receipt
supplies a publisher's key (§3.2), but this extension names no source
for an operator's, so the check establishes only that the decision
matches a declaration, signed by whoever signed it.
What this class does not do. It records what was declared and what
was decided. It is not a consent mechanism: a declaration is agnostic
across bases, and each purpose carries its own. It makes no statement
that a basis is sufficient for any purpose under any regime, and a
verifier here does not check terms against the vocabulary. The shape
— purposes, each with its own basis and data categories, from a
declared vocabulary — is the one ABNT NBR 25608:2025 uses for its
broadcast privacy declaration, as described in that standard's public
operational guidelines (see the [2030 landscape survey](../research/2030-transport-landscape.md)
§7.2), whose records follow ISO/IEC TS 27560. A `lawful-basis` claim
is signed, like every claim.
#### 2.4.7. `class: "log-inclusion"`
A statement that a receipt was entered in an append-only log,
carrying the log's own evidence. Its subject is always a receipt: a
log registers receipts, never objects, and per
[transparency](transparency.md) §3 per-object receipts are not
registered individually — the subject is a group receipt's link hash,
or a claim's.
| member | type | meaning |
|---|---|---|
| `log` | absolute URI | The log. |
| `profile` | absolute URI | The registration profile the log's evidence is checked under (for example `urn:ietf:rfc:9942`, COSE Receipts). |
| `entry` | term | The log's identifier for the entry. |
| `log_receipt` | hex | The log's own receipt for the entry, verbatim. |
Rule (§3.1 step 4): `log_receipt` **MUST NOT** be empty; an inclusion
claim without the log's evidence is only its signer's say-so.
**Accepting a `log-inclusion` claim establishes that its signer
asserted inclusion and carried the log's evidence. It does not
establish inclusion.** Inclusion is established only by checking
`log_receipt` under `profile` against the log, which this extension
does not specify; a verifier that has not done so **MUST NOT** report
the subject as logged. This claim's signature never stands in for the
log's, and [transparency](transparency.md) §6 applies: registration is
a witness, not a verification.
### 2.5. Worked example
Manifest JSON (group receipt, formatted for readability — wire form
@ -201,6 +542,153 @@ Object receipt for the second object in that group:
The signature hex shown is illustrative; conformance is checked
against the test vectors in §5.
An `energy` claim (corpus vector `energy_accept`, §5.6), formatted for
readability — its wire form is its JCS serialisation. This signature
is real, under the corpus's published test seed, which attests
nothing; the figure is invented:
```json
{
"body": {
"coverage": "Whole",
"energy_calibration_ref": null,
"energy_counter_filtering": null,
"energy_provenance": "ModelBased",
"energy_uncertainty": null,
"grade": "derived",
"joules_micro": 12000,
"method": "https://transaction.science/wai/extensions/energy-measurement",
"oer_receipt": "sha256:970afc417e8a310929f3cdcd65ac989a477f240b5a7f27435139261da73288ea",
"work_unit": "subpixel",
"work_units": 49152
},
"class": "energy",
"issued_at": "2026-09-27T12:00:00Z",
"kind": "claim",
"parent_receipt_hash": null,
"seq": 0,
"sig": "58ab60212898afda4315a8f864769aa3378f8b6afd3ddaa77ea4d0061e64a447f298f935aaa26e878e94a0a50fa4f5e76b83cb48376e081c4f761596d94c4f0a",
"signer_id": "did:example:meter",
"signer_pubkey": "a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0",
"subject": "5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e",
"subject_type": "object"
}
```
Its `link_hash` is
`797a6d60712bb66270be3683546ed9d92c7134d3a3cfa34f304e6fdad886046f`.
### 2.6. Binding classes
A receipt binds to an object in one of two ways.
#### 2.6.1. Hard binding
§2.1: BLAKE3 over the envelope bytes. It holds or it fails, with no
tolerance, and it is destroyed by any change to those bytes — a
transcode, a remux, a platform re-encode. Those are not attacks; they
are what normal distribution does. Reconstruct-class objects have no
transcode hop by construction, and a transcoder that participates is a
lineage step ([provenance](provenance.md) §2). What the hard binding
cannot survive is a re-encode by a party that does not participate:
the receipt stays intact, the object no longer matches it, and with
one binding class nothing connects them.
#### 2.6.2. Soft binding (`class: "soft-binding"`)
A second, deliberately weaker class. A soft binding is a value
computed from the samples rather than the bytes — a watermark decoded
back out of them, or a fingerprint computed over them — by a
registered algorithm, and it survives re-encodes the hard binding does
not. The publisher declares it with a `claim` of class `soft-binding`
whose subject is the object (its §2.1 content hash):
| member | type | meaning |
|---|---|---|
| `alg` | term | The algorithm identifier, verbatim from the registry `registry` names, in that registry's reverse-DNS form (a rule below, at step 4). |
| `registry` | absolute URI | The public algorithm registry `alg` is listed in. |
| `binding_type` | string | `"watermark"` or `"fingerprint"`, as the registry types the algorithm. |
| `value` | hex | The value the algorithm produced for the object. |
| `resolution_endpoint` | absolute URI | Where this declaration — and through it the publisher's receipts for the object — may be recovered from `value`. That it is an `https` URI with a host and no userinfo is a rule below, at step 4. |
Rules (§3.1 step 4): `alg` **MUST** be in reverse-DNS registry form
(two or more dot-separated labels of ASCII letters, digits, `_`, `+`
and `-`) and so **MUST NOT** be a bare name; `value` **MUST NOT** be
empty; `resolution_endpoint` **MUST** be an `https` URI with a host
and no userinfo. That is: it begins with the characters `https://`,
and its authority — what follows, up to the first `/`, `?` or `#` —
holds no `@` and is a host, optionally followed by `:` and a port of
ASCII digits (RFC 3986 §3.2.2–§3.2.3; the port may be empty). The host
is the text before the first `:`, or, for an IP literal, the text
between `[` and `]`, and it **MUST NOT** be empty. Nothing further
about the host is checked.
The fields align with the C2PA soft-binding algorithm list — a public,
schema-validated registry whose entries carry an algorithm identifier,
its type (`watermark` | `fingerprint`) and optional resolution APIs. A
declaration naming that list in `registry` names its algorithm by the
list's identifier in `alg`. WAI defines no soft-binding algorithm, no
resolution service and no transport for one: `resolution_endpoint` is
a locator, referenced the way the carrier protocol is, and a verifier
here checks that `alg` has registry form, not that it is listed. A
soft binding here covers the whole object; a binding scoped to a
region or a time range is not expressible.
#### 2.6.3. Recovery record (`class: "binding-recovery"`)
A recovery (§3.2) **MAY** be recorded as a `claim` of class
`binding-recovery`, signed by the party that performed it. Its subject
is **the ancestor** — the declaration's subject — never the object in
hand.
| member | type | meaning |
|---|---|---|
| `alg` | term | As the declaration's. |
| `binding_type` | string | As the declaration's. |
| `value` | hex | The value computed from the object in hand. |
| `declaration` | 32 B hex | `link_hash` of the `soft-binding` claim the value matched. |
| `resolution_endpoint` | absolute URI | The endpoint the recovery was made through; an `https` URI with a host and no userinfo, as §2.6.2's rule requires at step 4. |
| `observed` | 32 B hex or `null` | §2.1 content hash of the object in hand, when it is still a WAI envelope. |
| `attests` | string | **MUST** be `"ancestor"` — the ancestor rule, stated in-band. Any string passes step 2; another value is refused at step 4 (`recovery_not_ancestor`). |
Rules (§3.1 step 4): §2.6.2's rules on `alg`, `value` and
`resolution_endpoint`; `attests` **MUST** be `"ancestor"`; `observed`
**MUST NOT** equal `subject` — an object whose hard binding holds is
verified by §3, not recovered, and recording it as a recovery would
downgrade a hard binding to a soft one.
**Against the declaration it names** (§3.1 step 6). The context is the
declaration **together with** the publisher key *P* of a delivery
receipt for the declaration's subject that the verifier has verified
under §3 — *P* taken from that receipt, never from the declaration or
from the record, as in §3.2 step 3. A verifier that holds the
declaration but no such receipt holds no context for the record, and
**MUST NOT** report the record as checked against its declaration.
The checks, in order:
1. the declaration passes §3.1 steps 1–4 (`context_invalid`). Its
members, `signer_pubkey` among them, are its signer's only once its
signature is checked: a declaration that was only parsed can carry
the publisher's key over anyone's signature;
2. the declaration is a `soft-binding` claim (`context_mismatch`);
3. `declaration` is its `link_hash`, and `subject`, `alg` and
`binding_type` equal the declaration's
(`recovery_declaration_mismatch`);
4. the declaration's `signer_pubkey` is *P*
(`recovery_signer_mismatch`) — §3.2 step 3's check, without which a
record could carry a recovery that §3.2 itself refuses;
5. `value` matches the declaration's `value` under the algorithm's own
rule (`recovery_no_matcher`, `recovery_value_mismatch`).
**What an accepted record establishes.** Checked against that context:
that its signer recovered through a declaration made by the
ancestor's publisher, with a value that matches it under the
algorithm — and no more, since §3.2's ancestor rule and its bound on
what a match is worth apply unchanged. Accepted without that context
(steps 1–5 only): that its signer reports a recovery, and nothing
about who made the declaration it names or whether the value matches
it.
---
## 3. Verifier algorithm
@ -257,6 +745,215 @@ A verifier MUST treat any rejection in steps 1–5 as a hard failure
for the object in question. A verifier MUST NOT accept a partial
verification (e.g. valid Merkle proof against an unverified root).
### 3.1. Claim receipts
A verifier checks a `claim` in six steps and reports the first check
that fails, as a step and a reason. Within a step, checks run in the
order listed.
1. **Envelope.** A value that is not JSON (RFC 8259), is not an
object, or has a duplicate member name is `malformed`. A text that
begins with a byte-order mark is not JSON here: RFC 8259 §8.1 lets a
parser ignore one, and this step does not. So is `malformed` a value
holding a `\u` escape that leaves a surrogate unpaired — RFC 8259's
grammar admits it, but it denotes no Unicode text, and parsers
differ on what they make of it (RFC 8259 §8.2) — or arrays and
objects nested more than 32 deep, the claim itself being the first
level. A number is never `malformed`: every JSON number is JSON,
whatever its size, and one a member may not hold (`1e400`, `-0`, an
integer of four hundred digits) is `field_type` below. `kind`
absent is `missing_required_field`; `kind` other than `"claim"` is
`wrong_kind`. Any §2.4.3 member absent is `missing_required_field`;
any other member present is `unknown_member`. `class` that is not a
string is `field_type`; equal to `delivery` it is `class_reserved`,
and a string naming no class of §2.4.4 is `unknown_class`. Then, in
table order with `class` done and `body` left to step 2, a
member of the wrong type or encoding — `subject_type` outside its
enumeration, a hash that is not 64 lowercase hex digits, `seq` not
an integer of §2.4.3, `issued_at` not a date-time of §2.4.3, `sig`
not 128 lowercase hex digits — is `field_type`. A `subject_type` the
class does not admit is `subject_type_not_admitted`.
2. **Body.** A `body` that is not an object is `field_type`. Otherwise
it is checked against the class's member table (for a lawful-basis
body, the table of the record that `record`, read first, names; see
§2.4.6): a member absent is `missing_required_field`, an additional
member is `unknown_member`, and then, in the table's order, a member
that is not of the type the table gives it is `field_type`. That is
a wrong JSON type; an integer that is not one of §2.4.3; a byte
string that is not lowercase hex, two digits a byte, or a hash that
is not 64 of them; a value outside an enumeration this extension
defines here (`record`, `binding_type`); a URI that is not absolute;
a term that is not one of §2.4.3 (empty, longer than 2048 bytes, or
holding a White_Space or control character); or a malformed
`oer_receipt`. A member's type and encoding are checked together,
before the next member is read. An object a class fixes the members
of — a lawful-basis purpose, an `energy_uncertainty` — is checked
the same way, its members and then their types, where the table
reaches it, and the entries of an array in the array's order. Of a
member's value, this step checks the type column and nothing else.
What a class's rules require beyond it is step 4's, each with its
own reason, and is never `field_type` here: labels and evidence
from vocabularies another extension defines (acquisition class and
its evidence, coverage, grade); a `joules_micro` or `work_units` of
zero; an empty `purposes`; an empty `basis`, which is why its type
is `term or ""`; an empty `log_receipt` or `value`; an `alg` that is
a term but not in registry form; a `resolution_endpoint` that is an
absolute URI but not an `https` URI with a host and no userinfo; and
an `attests` other than `"ancestor"`.
3. **Signature.** Ed25519 (RFC 8032). This step restates RFC 8032's
own decoding and range rules, and fixes the two choices on which
RFC 8032 verifiers differ — whether a key or `R` of small order is
refused (RFC 8032's procedure does not refuse them; this step does),
and which verification equation is checked — so that every
conforming verifier accepts the same signatures. `signer_pubkey` is
`bad_signer_key` if it does not decode under RFC 8032 §5.1.3 —
which refuses a `y` coordinate ≥ p, and `x` = 0 with the sign bit
set, so every point has exactly one encoding — or if it decodes to a
point of small order (one whose order divides 8). `sig` is
`R` ‖ `S`, and it is `signature_verify_failed` unless `S` < L (the
order of the base point B, as RFC 8032 §5.1.7 requires), `R` is not
the encoding of a point of small order, and the encoding of
[S]B − [k]A equals `R` byte for byte, where A is the key's point and
k = SHA-512(`R` ‖ `signer_pubkey` ‖ §2.4.3's signing input) mod L.
That is the cofactorless equation. RFC 8032 §5.1.7
states the cofactored one and allows the cofactorless one; they
disagree on some signatures whose points have a small-order
component, and this step uses the cofactorless one only. The key
checks run first. Under a key of small order the equation can hold
for a signature made without any private key — under the identity,
`R` = [s]B with `S` = s verifies for every message — so such a key
is refused before its signature is examined.
4. **Class rules**, per §2.4.5–§2.4.7 and §2.6, each with its own
reason (below). They run after the signature because a rule about
what a signer said only means something once it is established who
said it.
5. **Chain.** Against the previous claim the verifier holds for
`signer_pubkey`. A claim is held only if it passes steps 1–4 itself.
One that fails steps 1–3 is not known to be its signer's, and one
that fails step 4 breaks its class's rules; neither is held, so an
altered copy can neither satisfy a link nor break one in the
signer's name. A non-null `parent_receipt_hash` with no previous
claim held is `parent_not_held` — recoverable by fetching that claim,
if it verifies, and **MUST NOT** be accepted silently. A parent that is
not the held claim's `link_hash`, a `seq` that does not increase, or
a `null` parent while a previous claim is held, is `chain_break`.
6. **Context**: checks against other receipts the verifier holds,
each verified first — an `energy` claim against the `object` receipt
for its subject, verified under §3, and against the OER receipt it
names, verified under oer-receipts as §2.4.5's check 2
(`energy_oer_invalid`); a decision against its declaration (§2.4.6);
a recovery record against its declaration together with the
publisher key of a verified delivery receipt for the declaration's
subject (§2.6.3), and never against the declaration alone. A
declaration is verified here under steps 1–4, and one that fails
them — or is not JSON — is `context_invalid`: its members,
`signer_pubkey` among them, are its signer's only once its signature
is checked. For a decision or a recovery record the order is: the
claim is of a class that names a declaration (`context_mismatch`),
the declaration passes steps 1–4 (`context_invalid`), it is of the
class the claim names (`context_mismatch`), then the class's own
checks. A context receipt of another class is `context_mismatch`.
For an `energy` claim, an `object` receipt about another subject,
and an OER receipt other than the one named or about another
subject, are `context_mismatch`, in §2.4.5's order. For a decision
or a recovery record, a declaration other than the one named or
about another subject is the class's own reason,
`decision_declaration_mismatch` or `recovery_declaration_mismatch`;
for a recovery record, a declaration signed by anyone but that
publisher is then `recovery_signer_mismatch`, in §2.6.3's order.
A claim that passes every step it was checked at is **accepted**: its
signer made this statement about this subject. §2.4.4's rules bound
what that means, and §2.4.7 bounds it further for `log-inclusion`.
| step | reason | raised when |
|---|---|---|
| 4 | `energy_zero_figure` | `joules_micro` is 0 |
| 4 | `energy_unknown_acquisition` | `energy_provenance` names no class of energy-measurement §2 |
| 4 | `energy_legacy_acquisition` | `energy_provenance` is the legacy `HwShunt` |
| 4 | `energy_acquisition_evidence` | the class's evidence is missing, carried by a class that has none, or below the resolution floor (§2.4.5 rule 2) |
| 4 | `energy_unknown_coverage` | `coverage` is not `Whole` or `Partial` |
| 4 | `energy_unknown_grade` | `grade` names no grade of oer-receipts §2 |
| 4 | `energy_grade_exceeds_acquisition` | `grade` ranks above what the acquisition class supports |
| 4 | `energy_zero_work` | `work_units` is 0 |
| 4 | `lawful_basis_no_purposes` | a declaration lists no purpose |
| 4 | `lawful_basis_purpose_without_basis` | a purpose has an empty basis |
| 4 | `lawful_basis_duplicate_purpose` | a purpose repeats an earlier one |
| 4 | `log_inclusion_no_evidence` | `log_receipt` is empty |
| 4 | `soft_binding_alg_not_registry_form` | `alg` is not in registry form |
| 4 | `soft_binding_empty_value` | `value` is empty |
| 4 | `soft_binding_endpoint_not_https` | `resolution_endpoint` is not an `https` URI with a host and no userinfo |
| 4 | `recovery_not_ancestor` | `attests` is not `"ancestor"` |
| 4 | `recovery_of_hard_bound_object` | `observed` equals `subject` |
| 6 | `energy_disagrees_with_delivery` | the figure differs from the `object` receipt's |
| 6 | `energy_oer_invalid` | the named OER receipt reports any reason under oer-receipts |
| 6 | `energy_disagrees_with_oer` | the named OER receipt states another grade or figure |
| 6 | `context_invalid` | a declaration a decision or a recovery record is checked against does not pass steps 1–4 |
| 6 | `decision_declaration_mismatch` | a decision does not match the declaration it names |
| 6 | `decision_purpose_not_declared` | a decision is about a purpose its declaration does not list |
| 6 | `recovery_declaration_mismatch` | a recovery record does not match the declaration it names |
| 6 | `recovery_signer_mismatch` | the declaration a recovery record names (§2.6.3), or a recovery runs through (§3.2), is not signed by the publisher of a verified delivery receipt for its subject |
| 6 | `recovery_value_mismatch` | the value does not match under the algorithm's rule |
| 6 | `recovery_no_matcher` | the verifier has no matcher for the algorithm |
### 3.2. Recovery through a soft binding, and the ancestor rule
For an object in hand that fails §3 step 4 — or is no longer a WAI
envelope at all — a verifier **MAY** attempt recovery. It needs: a
`soft-binding` claim *D*, typically returned by a resolution endpoint;
the publisher key *P* of a delivery receipt for *D*'s subject that it
has verified under §3; the value *V* the registered algorithm computes
from the object in hand; the object in hand's §2.1 content hash *H*,
if it is still a WAI envelope; and a matcher for *D*'s algorithm.
1. Verify *D* under §3.1 steps 1–4. A rejection of *D* is the
recovery's rejection, reported unchanged.
2. *D* **MUST** be a `soft-binding` claim (`context_mismatch`).
3. *D*'s `signer_pubkey` **MUST** equal *P*
(`recovery_signer_mismatch`). *P* comes from the verified delivery
receipt, never from *D*: a soft binding declared by anyone but the
publisher links the object in hand to that party's statement, not
to the publisher's receipts. The resolution endpoint is a locator,
not an authority; what it returns counts only if it passes this
step.
4. If *H* equals *D*'s subject the hard binding holds and there is
nothing to recover (`recovery_of_hard_bound_object`): verify under
§3 instead.
5. Evaluate *V* against *D*'s `value` under the algorithm's own rule.
A verifier with no matcher for the algorithm **MUST NOT** report a
recovery (`recovery_no_matcher`); no match is
`recovery_value_mismatch`.
Steps 2–5 report at step 6. On success the result is a **recovery**,
and it attests *D*'s subject: the ancestor.
**The ancestor rule (REQUIRED).** A receipt reached through a soft
binding attests the ancestor the object in hand was derived from, not
the object in hand. A verifier **MUST** report a recovery as such,
naming the ancestor, and **MUST NOT** report the object in hand as
verified, as bound, or as the subject of the ancestor's receipts. The
energy consequence is part of the rule: the ancestor's figures cover
producing the ancestor; the transformation from the ancestor to the
object in hand is **unaccounted, which is not the same as zero**, and
a verifier **MUST NOT** report the ancestor's figures as the energy of
the object in hand. An implementation that cannot express this
distinction **MUST NOT** implement recovery: a recovery presented as a
hard binding is worse than no recovery, because it launders a
transformation nobody attested.
**What a match is worth.** A soft-binding match is evidence at the
algorithm's own error rates, which WAI does not state. A watermark can
be copied onto other content, and unrelated content can share a
fingerprint. A recovery says the object in hand matched the
publisher's declared binding under the named algorithm — nothing about
how it was derived, by whom, or with what changes.
A recovery **MAY** be recorded as a `binding-recovery` claim (§2.6.3).
The reference implementation (`wai-rs`, feature `receipt_claims`,
`claim_receipt::recover`) returns a value whose only attestation is the
ancestor; there is no path from it to a statement about the object in
hand.
---
## 4. Relay preservation requirements
@ -281,6 +978,11 @@ chain across hops, a conforming relay:
- Preserve the byte-exact WAI envelope (`WAI1` magic, manifest bytes,
payload bytes) end-to-end. Any rewriting at the envelope layer
invalidates §2.1 content hashes.
- Forward `claim` receipts (§2.4.3) the same way — unaltered, whatever
their class. A relay **MUST NOT** drop a claim because it does not
know its class: rejecting an unknown class is a verifier's rule
(§2.4.4), and a relay that filters claims decides for every
downstream verifier what it may see.
### 4.2. MAY
@ -373,6 +1075,68 @@ declared step with the declared reason. The Rust reference verifier
in `inv-moq::receipt::ReceiptVerifier` exercises all five vectors
under `cargo test --lib -p inv-moq`.
### 5.6. Claim receipts and recovery (`claim-conformance/`)
The typed classes of §2.4.3–§2.4.7, the binding classes of §2.6 and
the procedures of §3.1–§3.2 are checked by the corpus in
[`claim-conformance/`](../claim-conformance/), generated and verified
by `wai_claim_vectors` (`wai-rs`, feature `receipt_claims`) and run by
the WAI conformance workflow on every platform of its matrix. 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 step with a named reason. A verifier
that rejects for another reason, or at another step, does not
conform. The corpus exercises every reason of §3.1; the ancestor rule;
each acquisition class with its evidence, each way the evidence can be
missing, misplaced or below the resolution floor, and the legacy
`HwShunt`; OER receipts sealed with and checked by the OER module,
including figures at the 34 digits a decimal string may hold and
figures and numbers oer-receipts §4 does not admit in a receipt body;
signatures that satisfy the verification equation and that step 3
still refuses, by RFC 8032's own decoding and range rules
(non-canonical encodings of a key, `S` ≥ L) or by the choices step 3
fixes (keys of small order with signatures made without a private key,
an `R` of small order); a non-canonical encoding of a key of large
order, which pins the reason step 3 gives; a recovery record naming a
declaration that the ancestor's publisher did not sign, and one naming
a declaration whose `signer_pubkey` was rewritten to the publisher's
after another party signed it; a previous claim that does not itself
verify; JSON that parsers read differently — a byte-order mark, an
unpaired surrogate escape, deep nesting, numbers out of every range —
and dates that do not exist; the boundary between a member's type
(step 2) and what a class's rules add to it (step 4), from both sides,
and the order step 2 reads a body in, where a body has two defects;
and a claim whose canonical form spans three BLAKE3 chunks.
The generator and the verifier in `wai-rs` are one implementation, so
two checks are made against code that shares nothing with it. A
**canonical fixture** — a claim given with its members out of order at
three levels and escapes in the input text — has its canonical bytes,
signature and `link_hash` computed by
`claim-conformance/claim_verify.py` (Python's JSON serialiser with
UTF-16 member order, an Ed25519 implementation other than the one
`wai-rs` uses, and BLAKE3 written from its specification), and pinned
as literals that `wai_claim_vectors` checks first. And that script, a
second verifier written from this section, reports every vector's
declared outcome; it reads and verifies OER receipts with the OER
reference verifier, as `wai-rs` does with its own OER module. The
corpus pins one outcome per vector, which shows the two verifiers agree
on those vectors and nothing more, so
`claim-conformance/differential.py` also generates inputs the corpus
does not hold — JSON at the edges of what parsers accept, calendar
dates, URI authorities, acquisition classes with every combination of
evidence, figures near a half-microjoule and at the edges of
oer-receipts §4 and §6, OER receipts holding numbers §4 forbids in a
body, files §4 does not read at all, context receipts that do not
verify, bodies of every class with one defect and with two at
different positions — runs both verifiers' `verdicts` mode on them,
and reports every input on which their verdicts differ. Agreement
shows that the two read alike, not that either reads §3 as written;
only the vectors' declared outcomes, set from the text, speak to that,
and only for the inputs they hold. The fixture runs in CI;
the script and the differential need Python with an Ed25519 library
and are run by hand, not by CI.
---
## 6. Security considerations
@ -465,3 +1229,54 @@ multi-signer container; the v1.0 wire format remains a strict subset.
`joules_total` are unit-of-energy fields, not unit-of-account
fields. Conversion to currency, carbon, or grid-mix CO₂ is
performed by downstream aggregators and is not normative here.
### 6.7. Claims
A `claim` is a signed statement, and §6.1–§6.3 apply to it as to a
group receipt: `seq` and `parent_receipt_hash` bound replay per
signer, and every member — `class`, `subject`, the whole body — is
inside the signature, so a label cannot be promoted in transit (an
`energy` claim whose `energy_provenance` is rewritten from `ModelBased`
to `OnChipCounter`, or whose declared uncertainty is rewritten smaller,
fails at §3.1 step 3). Step 3 also refuses keys of small
order, under which a signature can be made without any private key,
and restates RFC 8032's rule that a key has only its canonical
encoding, so that verifiers following it accept the same signatures.
What a signature does not settle is whether its signer was entitled
to say it: this extension defines no authority class and no key
discovery, and a claim is worth exactly as much as the verifier's
reason to trust the key that signed it.
### 6.8. Soft binding
- **A copied watermark.** A watermark decoded from an object shows
that the object carries the payload, not that it descends from the
ancestor: a payload can be embedded into unrelated content. The
ancestor rule (§3.2) bounds the damage — a recovery attests the
ancestor's receipts, never the object in hand — and a relying party
**MUST NOT** read a recovery as the provenance of the object in hand.
- **Fingerprint collisions.** Unrelated objects can share a
fingerprint value. How often is a property of the algorithm: WAI
states no rate, a `soft-binding` declaration carries none, and a
verifier here does not assume one. A relying party that needs a rate
takes it from the algorithm's own specification, where one is
published; a match is evidence at that rate, never proof of
derivation.
- **A hostile resolution endpoint.** An endpoint can return any
declaration it likes. What it returns counts only if it passes two
checks, made wherever a declaration is relied on — §3.2 steps 1 and 3
when a recovery is performed, and §2.6.3's checks 1 and 4 when a
recorded one is verified: the declaration verifies under §3.1 steps
1–4, and its signer is the publisher of a delivery receipt for its
subject that the verifier has already verified. Neither suffices
alone. Without the first, the signer check compares a key the
declaration's author wrote in, over a signature that may be anyone's;
without the second, a valid declaration binds the object in hand to
whoever made it. A recovery record accepted without that context
establishes nothing about who made the declaration it names
(§2.6.3). What passing both establishes is bounded by the ancestor
rule and by the algorithm's own error rates (§3.2).
- **Downgrade.** An object whose hard binding holds is never
recovered (§3.2 step 4; for a recovery record, `observed` ≠
`subject`), so a soft binding cannot stand in for a hard one that
still verifies.

View file

@ -95,6 +95,19 @@ A producer **MUST NOT** grade `measured` a figure whose acquisition class is
`provenance.weakest_input` names the lowest-graded input the figure rests on, so a
`measured` total built on a `projected` constant is visible as such.
The `energy` claim class of [`jwp-receipts.md`](jwp-receipts.md) §2.4.5 carries both
axes for a delivery receipt's figure: the acquisition class with its evidence, and a
grade, which it rejects when it ranks above what the class supports under the
correspondence above. It may name the OER receipt that records the figure. A verifier
holding that receipt then checks that it verifies under this document, is about the
same object (its `subject.output` is the object's jwp-receipts §2.1 content hash, as
the claim writes it), and states the same grade and, to the microjoule, the same
figure, read as §4 reads a quantity. That is where the producer rule above becomes
checkable, against the class the claim's signer states: a verifier that checks an
accepted `energy` claim against the OER receipt it names holds a receipt graded no
higher than that class supports. It changes nothing about the OER receipt itself, which
still carries no class.
## 3. Receipt members
Members are fixed per path, and **only** these members may appear:

View file

@ -56,6 +56,11 @@ forcing them together would put a registration round-trip in the per-object path
- Registration **MUST NOT** be a precondition for a receipt being valid. An
unregistered receipt is a receipt whose existence is unwitnessed, not a receipt
that is wrong.
- A registration **MAY** be recorded on the receipt chain as a `log-inclusion`
claim ([`jwp-receipts`](jwp-receipts.md) §2.4.7): its subject is the registered
receipt's link hash, and its body carries the Transparency Service's receipt
verbatim with the profile it was registered against. Accepting that claim is
not verifying the inclusion (§6).
The checking party is the **Relying Party** in the terminology of the SCITT
architecture; this extension uses that term rather than coining another.

View file

@ -74,9 +74,11 @@ proof:
- Such a receipt **MUST** declare the bearer as unidirectional, so a relying
party cannot mistake transmission for delivery.
- A sink **MAY** retain object commitments and reconcile them out of band later.
A reconciliation **MUST** be reported as a `claim` of class
`binding-recovery`-style provenance, never folded into the publisher's chain
as though it had been a hop.
A reconciliation **MUST** be reported apart from the publisher's chain — as a
`claim` ([`jwp-receipts`](jwp-receipts.md) §2.4.3), under a class registered for
it in jwp-receipts §2.4.4, which none yet is — and never folded into the
publisher's chain as though it had been a hop. Like a `binding-recovery` record
(jwp-receipts §2.6.3), it attests less than a hop would, and says so.
**No receipt can prove rendering over a bearer with no return path.** This
section does not attempt it. What it prevents is the quieter error: a receipt

1
wai/wai-rs/Cargo.lock generated
View file

@ -3874,6 +3874,7 @@ dependencies = [
"c2pa",
"cbindgen",
"claxon",
"curve25519-dalek",
"dav1d",
"ed25519-dalek",
"flac-bound",

View file

@ -81,6 +81,11 @@ c2pa = { version = "0.90", optional = true, default-features = false,
blake3 = { version = "1", optional = true } # state hashes, node minting, op/seal commitments
ed25519-dalek = { version = "2", optional = true } # op + seal signatures
num-bigint = { version = "0.4", optional = true } # oer: exact decimal arithmetic in the composition checks
# The curve ed25519-dalek is built on, named directly only by `receipt_claims`: its
# conformance corpus builds the signatures jwp-receipts §3.1 step 3 must refuse (keys of
# small order, non-canonical encodings), which the signing API cannot produce, and the
# verifier checks S < L itself (§3.1 step 3).
curve25519-dalek = { version = "4", optional = true }
# ---- quantum (optional, behind the `quantum*` features) ----
# The quantum stack now lives in its own dependency-light crate so it can be used
@ -163,6 +168,13 @@ provenance = ["world", "dep:sha2"]
# content-address and chain it, and sign it. Conformance is reason-set equality with
# the reference verifier and the portable C kernel over oer-conformance/.
oer = ["dep:sha2", "dep:blake3", "dep:ed25519-dalek", "dep:num-bigint", "serde_json/float_roundtrip"]
# Typed receipt classes on the JWP chain (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,
# the soft-binding class, and recovery under the ancestor rule. Needs `provenance`
# for the coverage vocabulary and `oer` for the evidential grades and the OER receipt
# an energy claim may name, which it reads and verifies through `crate::oer`. Its one
# direct dependency, curve25519-dalek, is already in the graph under ed25519-dalek.
receipt_claims = ["provenance", "oer", "dep:curve25519-dalek"]
# Emit a REAL C2PA manifest (not just the assertion shapes). Off by default:
# the SDK is a large dependency chain and most WAI consumers only need the
# binding types, which `provenance` already gives them.
@ -490,6 +502,11 @@ name = "wai_oer_diff"
path = "src/bin/wai_oer_diff.rs"
required-features = ["oer"]
[[bin]]
name = "wai_claim_vectors"
path = "src/bin/wai_claim_vectors.rs"
required-features = ["receipt_claims"]
[[bin]]
name = "wai_quantum_vectors"
path = "src/bin/wai_quantum_vectors.rs"

View file

@ -0,0 +1,128 @@
//! Conformance corpus for typed receipt classes and the soft binding class
//! (`claim-conformance/`, extensions/jwp-receipts.md §2.4.3–§2.4.7, §2.6, §3.1–§3.2).
//!
//! Every 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 a different reason,
//! or at a different step, does not conform.
//!
//! wai_claim_vectors gen <corpus-dir> write vectors.json
//! wai_claim_vectors verify <corpus-dir> the canonical fixture holds, the pinned file
//! regenerates byte-identically, and every
//! vector gets its declared outcome
//! wai_claim_vectors verdicts <file> one `name<TAB>verdict` line per vector of a
//! corpus-format file, any declared outcome
//! ignored — the input to
//! `claim-conformance/differential.py`
//!
//! The generator and this verifier are one implementation, so the corpus alone cannot
//! show that this one reads the specification correctly. Three things are checked
//! against code that shares nothing with it: the canonical fixture (bytes, signature and
//! link hash computed by `claim-conformance/claim_verify.py`), and — run by hand, not in
//! CI — that script's own verification of every vector, and `differential.py`, which
//! compares the two verifiers' verdicts on generated inputs the corpus does not hold.
use std::path::Path;
use wai::claim_receipt::{check_canonical_form, conformance_vectors, corpus_verdicts, corpus_vector_count, run_corpus};
fn main() {
let a: Vec<String> = std::env::args().collect();
match (a.get(1).map(String::as_str), a.get(2)) {
(Some("gen"), Some(d)) => {
let p = Path::new(d).join("vectors.json");
if let Err(e) = std::fs::create_dir_all(d).and_then(|_| std::fs::write(&p, conformance_vectors())) {
eprintln!("write {}: {e}", p.display());
std::process::exit(1);
}
println!("wrote {}", p.display());
}
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
(Some("verdicts"), Some(f)) => std::process::exit(verdicts(Path::new(f))),
_ => {
eprintln!("usage: wai_claim_vectors gen|verify <corpus-dir> | verdicts <file>");
std::process::exit(2);
}
}
}
/// Print `name<TAB>verdict` for every vector in a corpus-format file, ignoring any
/// declared outcome — the input to a differential comparison with another verifier.
fn verdicts(file: &Path) -> i32 {
let doc = match std::fs::read_to_string(file) {
Ok(s) => s,
Err(e) => {
eprintln!("read {}: {e}", file.display());
return 1;
}
};
match corpus_verdicts(&doc) {
Ok(vs) => {
for (name, got) in vs {
println!("{name}\t{got}");
}
0
}
Err(e) => {
eprintln!("{}: {e}", file.display());
1
}
}
}
fn verify(dir: &Path) -> i32 {
let mut fail = 0usize;
let cf = check_canonical_form();
println!(
"{} canonical form pinned to its independently computed fixture got={}",
if cf.is_empty() { "PASS" } else { "FAIL" },
cf.join(",")
);
fail += (!cf.is_empty()) as usize;
let path = dir.join("vectors.json");
// `.gitattributes` pins LF; a checkout that ignores it (a CRLF working tree) changes
// only the pretty-print line breaks, never a receipt, so it is undone rather than
// reported as a corpus change.
let pinned = match std::fs::read_to_string(&path) {
Ok(s) => s.replace("\r\n", "\n"),
Err(e) => {
println!("FAIL {}: {e}", path.display());
return 1;
}
};
let same = pinned == conformance_vectors();
println!("{} pinned corpus regenerates byte-identically", if same { "PASS" } else { "FAIL" });
fail += (!same) as usize;
match run_corpus(&pinned) {
Ok(results) => {
if results.len() != corpus_vector_count() {
println!("FAIL {}: {} vectors, expected {}", path.display(), results.len(), corpus_vector_count());
fail += 1;
}
for r in results {
println!(
"{} {} expected={} got={}",
if r.pass { "PASS" } else { "FAIL" },
r.name,
r.expected,
r.got
);
fail += (!r.pass) as usize;
}
}
Err(e) => {
println!("FAIL {}: {e}", path.display());
fail += 1;
}
}
if fail == 0 {
println!("all vectors pass");
0
} else {
println!("{fail} failure(s)");
1
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,304 @@
//! §3.1 step 1's reading of a claim's JSON text (`extensions/jwp-receipts.md`).
//!
//! RFC 8259's grammar, whitespace and string escapes, with the claim's own limits. What
//! it refuses is `malformed`:
//!
//! - anything outside RFC 8259's grammar, including a byte-order mark, a control
//! character inside a string, and trailing data;
//! - a member name repeated in one object. RFC 8785 is defined over I-JSON (RFC 7493),
//! which forbids them, and two parsers that resolve a duplicate differently would
//! canonicalise one receipt two ways;
//! - a `\u` escape that leaves a surrogate unpaired, which denotes no Unicode text
//! (RFC 8259 §8.2);
//! - arrays and objects nested more than [`MAX_DEPTH`] levels deep, the claim itself
//! being the first.
//!
//! A number is never refused here, whatever its size: every number is valid JSON, and
//! whether it is one a claim may carry is a type question (`field_type`), answered from
//! its spelling alone. An integer with no sign, fraction or exponent that fits in a
//! `u64` is kept as that integer; any other number — `-0`, `1.0`, `1e400`, an integer of
//! four hundred digits — is outside the claim subset and is read as `-1`, which is all
//! the claim reading needs to know about it. No number goes through a float parser, so
//! no reading depends on how one rounds.
//!
//! This reads claims and nothing else. An OER receipt an `energy` claim names is read
//! under oer-receipts' own document profile, by `crate::oer::load`.
use serde_json::{Map, Value};
/// The deepest nesting of arrays and objects a claim may have, counting the receipt
/// itself as the first level. A claim needs five; a parser needs a bound.
pub(crate) const MAX_DEPTH: usize = 32;
/// Read `s` as one claim text. The error says what was refused and where.
pub(crate) fn read(s: &str) -> Result<Value, String> {
let mut p = Text { s, b: s.as_bytes(), i: 0 };
p.ws();
let v = p.value(1)?;
p.ws();
if p.i != p.b.len() {
return Err(p.err("trailing data"));
}
Ok(v)
}
struct Text<'a> {
s: &'a str,
b: &'a [u8],
i: usize,
}
impl Text<'_> {
fn err(&self, what: &str) -> String {
format!("{what} at byte {}", self.i)
}
fn ws(&mut self) {
while matches!(self.b.get(self.i), Some(b' ' | b'\t' | b'\n' | b'\r')) {
self.i += 1;
}
}
fn eat(&mut self, c: u8) -> bool {
let hit = self.b.get(self.i) == Some(&c);
self.i += usize::from(hit);
hit
}
fn literal(&mut self, word: &str, v: Value) -> Result<Value, String> {
if self.b[self.i..].starts_with(word.as_bytes()) {
self.i += word.len();
Ok(v)
} else {
Err(self.err("not a JSON value"))
}
}
/// A value at nesting level `depth` (the level it occupies if it is an array or an
/// object).
fn value(&mut self, depth: usize) -> Result<Value, String> {
match self.b.get(self.i) {
Some(b'{' | b'[') if depth > MAX_DEPTH => Err(self.err("nested too deeply")),
Some(b'{') => {
self.i += 1;
self.object(depth)
}
Some(b'[') => {
self.i += 1;
self.array(depth)
}
Some(b'"') => self.string().map(Value::String),
Some(b't') => self.literal("true", Value::Bool(true)),
Some(b'f') => self.literal("false", Value::Bool(false)),
Some(b'n') => self.literal("null", Value::Null),
Some(b'-' | b'0'..=b'9') => self.number(),
_ => Err(self.err("not a JSON value")),
}
}
fn object(&mut self, depth: usize) -> Result<Value, String> {
let mut m = Map::new();
self.ws();
if self.eat(b'}') {
return Ok(Value::Object(m));
}
loop {
self.ws();
if self.b.get(self.i) != Some(&b'"') {
return Err(self.err("expected a member name"));
}
let k = self.string()?;
self.ws();
if !self.eat(b':') {
return Err(self.err("expected ':'"));
}
self.ws();
let v = self.value(depth + 1)?;
if m.insert(k, v).is_some() {
return Err(self.err("duplicate member name"));
}
self.ws();
if self.eat(b'}') {
return Ok(Value::Object(m));
}
if !self.eat(b',') {
return Err(self.err("expected ',' or '}'"));
}
}
}
fn array(&mut self, depth: usize) -> Result<Value, String> {
let mut a = Vec::new();
self.ws();
if self.eat(b']') {
return Ok(Value::Array(a));
}
loop {
self.ws();
a.push(self.value(depth + 1)?);
self.ws();
if self.eat(b']') {
return Ok(Value::Array(a));
}
if !self.eat(b',') {
return Err(self.err("expected ',' or ']'"));
}
}
}
fn string(&mut self) -> Result<String, String> {
self.i += 1; // the opening quote
let mut out = String::new();
loop {
// A run of characters written as themselves. It can only end at an ASCII
// byte, so it never splits a UTF-8 sequence.
let start = self.i;
while self.b.get(self.i).is_some_and(|&c| c != b'"' && c != b'\\' && c >= 0x20) {
self.i += 1;
}
out.push_str(&self.s[start..self.i]);
match self.b.get(self.i) {
Some(b'"') => {
self.i += 1;
return Ok(out);
}
Some(b'\\') => {
self.i += 1;
let c = self.escape()?;
out.push(c);
}
Some(_) => return Err(self.err("control character in a string")),
None => return Err(self.err("unterminated string")),
}
}
}
fn escape(&mut self) -> Result<char, String> {
let c = *self.b.get(self.i).ok_or_else(|| self.err("unterminated escape"))?;
self.i += 1;
Ok(match c {
b'"' => '"',
b'\\' => '\\',
b'/' => '/',
b'b' => '\u{8}',
b'f' => '\u{c}',
b'n' => '\n',
b'r' => '\r',
b't' => '\t',
b'u' => {
let unpaired = "an escape that leaves a surrogate unpaired";
match self.hex4()? {
hi @ 0xd800..=0xdbff => {
if !(self.eat(b'\\') && self.eat(b'u')) {
return Err(self.err(unpaired));
}
let lo = self.hex4()?;
if !(0xdc00..=0xdfff).contains(&lo) {
return Err(self.err(unpaired));
}
char::from_u32(0x10000 + ((hi - 0xd800) << 10) + (lo - 0xdc00))
.expect("a supplementary code point")
}
0xdc00..=0xdfff => return Err(self.err(unpaired)),
u => char::from_u32(u).expect("a scalar value of the BMP"),
}
}
_ => return Err(self.err("not a JSON escape")),
})
}
fn hex4(&mut self) -> Result<u32, String> {
let h = self.b.get(self.i..self.i + 4).filter(|h| h.iter().all(u8::is_ascii_hexdigit));
let h = h.ok_or_else(|| self.err("\\u needs four hex digits"))?;
self.i += 4;
Ok(h.iter().fold(0, |a, &c| a * 16 + (c as char).to_digit(16).expect("a hex digit")))
}
fn digits(&mut self) -> Result<(), String> {
let start = self.i;
while self.b.get(self.i).is_some_and(u8::is_ascii_digit) {
self.i += 1;
}
if self.i == start { Err(self.err("expected a digit")) } else { Ok(()) }
}
/// RFC 8259 §6's grammar, checked in full; the value is the integer only for a plain
/// unsigned integer that fits in a `u64`, and `-1` (outside the claim subset) for
/// every other number.
fn number(&mut self) -> Result<Value, String> {
let signed = self.eat(b'-');
let int_start = self.i;
match self.b.get(self.i) {
Some(b'0') => self.i += 1,
Some(b'1'..=b'9') => self.digits()?,
_ => return Err(self.err("not a JSON number")),
}
let int_end = self.i;
let mut integer = true;
if self.eat(b'.') {
integer = false;
self.digits()?;
}
if matches!(self.b.get(self.i), Some(b'e' | b'E')) {
integer = false;
self.i += 1;
if matches!(self.b.get(self.i), Some(b'+' | b'-')) {
self.i += 1;
}
self.digits()?;
}
let unsigned = if integer && !signed { self.s[int_start..int_end].parse::<u64>().ok() } else { None };
Ok(unsigned.map_or(Value::from(-1i64), Value::from))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn duplicate_members_are_refused_at_any_depth() {
assert!(read("{\"a\":1,\"a\":1}").is_err());
assert!(read("{\"a\":{\"b\":1,\"b\":2}}").is_err());
assert!(read("[{\"b\":1},{\"b\":2}]").is_ok());
assert!(read("{\"a\":1} x").is_err(), "trailing data is not one value");
}
#[test]
fn a_claim_text_is_read_one_way() {
// Surrogates: a pair is one character; anything unpaired is not text.
assert_eq!(read(r#""\ud83d\ude00""#), Ok(Value::String("\u{1F600}".into())));
assert_eq!(read(r#""\uD83D\uDE00""#), Ok(Value::String("\u{1F600}".into())));
for t in [r#""\ud800""#, r#""\udc00""#, r#""\ud800\u0041""#, r#""\ud800\ud800""#, r#""\ud800\n""#, r#"{"\udfff":1}"#] {
assert!(read(t).is_err(), "{t}");
}
// Nesting: the receipt is level 1, and 32 levels are read.
let deep = |n: usize| format!("{}{}", "[".repeat(n), "]".repeat(n));
assert!(read(&deep(32)).is_ok());
assert!(read(&deep(33)).is_err());
assert!(read(&deep(100_000)).is_err(), "refused before any recursion that deep");
// Numbers are never a parse failure; only an unsigned integer that fits in a u64
// is kept as one.
assert_eq!(read("9007199254740991"), Ok(Value::from(9_007_199_254_740_991u64)));
assert_eq!(read("18446744073709551615"), Ok(Value::from(u64::MAX)));
let four_hundred_digits = format!("1{}", "0".repeat(400));
for t in ["-0", "-1", "0.0", "1e0", "1E2", "1e400", "-1e400", "18446744073709551616", four_hundred_digits.as_str()] {
assert_eq!(read(t), Ok(Value::from(-1i64)), "{t}");
}
// Outside RFC 8259.
for t in ["01", "+1", ".5", "1.", "1e", "1e+", "-", "0x10", "NaN", "Infinity", "-Infinity", "\u{ff11}", "\u{feff}{}",
"{} x", "", " ", "\"\u{1}\"", "\"\\x41\"", "\"\\u12\"", "{\"a\":1,}", "[1,]", "tru", "nul", "{\"a\" 1}", "{,}", "'a'",
"\"a", "\"\t\""] {
assert!(read(t).is_err(), "{t:?}");
}
// Everything the canonical subset holds reads as serde_json reads it, member order
// included.
for t in [r#"{"b":[1,"x\u00e9\/\n",null,{"c":[]}],"a":"\u001f\"","é":9007199254740991}"#, " [ ] ", "\"\u{2028}\u{7f}\"",
"{\"k\u{e9}\":\"\u{1f600}\"}", "{}", "0", "\"\""] {
let want: Value = serde_json::from_str(t).unwrap();
let got = read(t).unwrap();
assert_eq!(got, want, "{t}");
assert_eq!(serde_json::to_string(&got).unwrap(), serde_json::to_string(&want).unwrap(), "{t}: order");
}
}
}

View file

@ -259,6 +259,21 @@ pub mod energy_binding;
#[cfg(feature = "c2pa_emit")]
pub mod c2pa_emit;
// Typed receipt classes (extensions/jwp-receipts.md §2.4.3–§2.4.7, §2.6, §3.1–§3.2):
// energy, lawful-basis, log-inclusion, soft-binding and binding-recovery statements
// about delivered objects, on one `claim` envelope and one signature rule beside the
// delivery class `group`/`object` carry. A recovery through a soft binding attests the
// ancestor, never the object in hand. An OER receipt an energy claim names is read and
// verified by `oer` under oer-receipts' own rules.
#[cfg(feature = "receipt_claims")]
pub mod claim_receipt;
// §3.1 step 1's reading of a claim's JSON text: RFC 8259 with a claim's own limits
// (no repeated member name, no unpaired surrogate escape, 32 levels), each number read
// from its spelling. Claims only; an OER receipt is read by `oer::load`.
#[cfg(feature = "receipt_claims")]
mod claim_text;
// wai.world.model — generative-world transport: a signed, replay-equivalent action
// trace (the world CausalOp log) + per-frame provenance. The wire carries the
// action trace + a declared model_id, not the pixels; the sink renders. The neural

View file

@ -75,9 +75,26 @@ const MAX_DIGITS: usize = 34;
/// `repr(float)` as CPython writes it: the shortest round-trip digits, laid out in
/// fixed notation for `1e-4 <= |x| < 1e16` (always with a fractional part) and in
/// exponent notation otherwise, with a signed, at-least-two-digit exponent.
///
/// Of the decimals of that shortest length that read back as `x`, CPython writes the
/// one nearest `x`, and of two equally near, the one whose last digit is even. `{:e}`
/// alone gives the shortest length but breaks that tie upward: `75.036163330078125` is
/// exactly half-way between `75.03616333007812` and `…813`, and `{:e}` writes `…813`
/// where CPython writes `…812`. So the length is taken from `{:e}` and the digits from
/// the exact formatter at that length, which rounds half to even. Where the nearest
/// decimal of that length does not read back as `x` — next to a power of two, where the
/// interval below `x` is half as wide as the one above — the shortest digits are kept.
///
/// No receipt body carries a float (§4), so no content address depends on this. What
/// does is `str()` of a number that is not a quantity, such as a vector's name, which
/// both verifiers print.
pub fn py_float_repr(x: f64) -> String {
let neg = x.is_sign_negative();
let e = format!("{:e}", x.abs()); // shortest round-trip, e.g. "2.24298e4"
let a = x.abs();
let shortest = format!("{a:e}"); // shortest round-trip, e.g. "2.24298e4"
let n = shortest.split_once('e').map_or(0, |(m, _)| m.chars().filter(char::is_ascii_digit).count());
let nearest = format!("{:.*e}", n.saturating_sub(1), a);
let e = if nearest.parse::<f64>() == Ok(a) { nearest } else { shortest };
let (mant, exp) = e.split_once('e').expect("LowerExp always has an exponent");
let digits: String = mant.chars().filter(|c| *c != '.').collect();
let decpt: i32 = exp.parse::<i32>().expect("integer exponent") + 1;
@ -483,6 +500,21 @@ fn exceeds(a: &Dec, b: &Dec, base: &Dec) -> bool {
a.sub(b).abs().mul(&Dec::int(1_000_000_000)).cmp(base) == Ordering::Greater
}
/// Whether the quantity `v`, in joules, is `uj` microjoules to the nearest microjoule:
/// `2·|v·10⁶ − uj| ≤ 1`, exactly, so a figure half-way between two microjoules is
/// within reach of both. `None` when `v` is not a quantity (§4: a decimal string, or
/// an integer within I-JSON's range), which is the only reading this module has.
///
/// The `energy` claim class (`jwp-receipts.md` §2.4.5 check 4) compares an OER
/// receipt's figure with the microjoules it restates through this, so the figure is
/// read exactly as the composition checks read it, and no second reading of §4 exists.
#[cfg_attr(not(feature = "receipt_claims"), allow(dead_code))]
pub(crate) fn rounds_to_micro(v: &Value, uj: u64) -> Option<bool> {
let micro = Dec::from_value(v)?.mul(&Dec::int(1_000_000));
let off = micro.sub(&Dec { m: BigInt::from(uj), s: 0 }).abs().mul(&Dec::int(2));
Some(off.cmp(&Dec::int(1)) != Ordering::Greater)
}
// --- verification ----------------------------------------------------------------
fn get<'a>(v: &'a Value, path: &[&str]) -> Result<&'a Value, String> {
@ -860,6 +892,99 @@ mod tests {
}
}
#[test]
fn float_repr_breaks_a_tie_between_shortest_decimals_like_cpython() {
for &(bits, want) in REPR_TIES {
let x = f64::from_bits(bits);
assert_eq!(py_float_repr(x), want, "repr of {x:e}");
assert_eq!(want.parse::<f64>(), Ok(x), "{want} reads back as {x:e}");
}
}
/// No receipt body carries a float, so the tie reaches no content address. It did
/// reach what both verifiers print: a vector's name is `str()` of whatever the file
/// holds there, and the reference prints the even digit.
#[test]
fn a_vector_named_by_a_double_at_a_tie_is_named_as_the_reference_names_it() {
let doc = load(br#"{"keys":{},"vectors":[{"name":75.036163330078125,"receipts":[],"expected":[]}]}"#)
.expect("a readable document");
let results = run(&doc).expect("a vector file");
assert_eq!(results[0].name, "75.03616333007812");
}
/// §2.4.5 check 4 of jwp-receipts reads an OER figure through the quantity reader,
/// exactly: a tie admits both neighbours at any length §4 allows, and anything that
/// is not a quantity is no figure.
#[test]
fn a_figure_rounds_to_the_microjoule_exactly() {
let r = |v: Value, uj: u64| rounds_to_micro(&v, uj);
let s = |t: &str| Value::String(t.into());
assert_eq!(r(s("0.0120004"), 12_000), Some(true));
assert_eq!(r(s("0.0120004"), 12_001), Some(false));
assert_eq!(r(s("0.0120005"), 12_000), Some(true));
assert_eq!(r(s("0.0120005"), 12_001), Some(true));
assert_eq!(r(s("0.0120006"), 12_000), Some(false));
// 34 significand digits, the leading zero counted, the most a decimal string
// holds: still exact. Rounded to 28 significant digits first, the second would
// become a tie. One digit more and neither is a quantity.
let tie = format!("0.0120005{}", "0".repeat(26));
assert_eq!((r(s(&tie), 12_000), r(s(&tie), 12_001)), (Some(true), Some(true)));
let below = format!("0.0120004{}", "9".repeat(26));
assert_eq!((r(s(&below), 12_000), r(s(&below), 12_001)), (Some(true), Some(false)));
assert_eq!(r(s(&format!("{below}9")), 12_000), None);
// Exponents, both signs and the grammar's widest.
assert_eq!(r(s("1.20005e-2"), 12_001), Some(true));
assert_eq!(r(s("120005E-7"), 12_000), Some(true));
assert_eq!(r(s("1e-999"), 0), Some(true));
assert_eq!(r(s("1e-999"), 1), Some(false));
assert_eq!(r(s("9.99e999"), u64::MAX), Some(false));
assert_eq!(r(s("18446744073709.551615"), u64::MAX), Some(true));
assert_eq!(r(s("18446744073709.5516155"), u64::MAX), Some(true), "half-way above u64::MAX admits it");
assert_eq!(r(s("18446744073709.5516156"), u64::MAX), Some(false));
// Negative figures agree only with zero, and only within half a microjoule of it.
assert_eq!(r(s("-0.012"), 12_000), Some(false));
assert_eq!(r(s("-0.0000005"), 0), Some(true));
assert_eq!(r(s("-0.00000051"), 0), Some(false));
assert_eq!(r(s("-0"), 0), Some(true));
// Integers within I-JSON's range are quantities; nothing else is.
assert_eq!(r(serde_json::json!(12), 12_000_000), Some(true));
assert_eq!(r(serde_json::json!(18_446_744_073_709u64), 18_446_744_073_709_000_000), Some(true));
for v in [serde_json::json!(9007199254740992u64), serde_json::json!(0.0120005), serde_json::json!(true),
Value::Null, s("+0.012"), s(" 0.012"), s(".5"), s("1e1000"), s(&format!("0.{}", "1".repeat(34)))] {
assert_eq!(r(v.clone(), 12_000), None, "{v} is not a quantity");
}
}
/// Against plain 128-bit arithmetic wherever that is exact: `2·|m·10⁶ − uj·10^s| ≤
/// 10^s` for the figure `m × 10^-s`, written in plain and in exponent form.
#[test]
fn a_figure_rounds_to_the_microjoule_as_integer_arithmetic_does() {
let mut x: u64 = 0x9e37_79b9_7f4a_7c15;
let mut next = move || {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
x
};
for _ in 0..20_000 {
let m = i128::from(next() % 10u64.pow((next() % 12) as u32 + 1)) * if next() % 5 == 0 { -1 } else { 1 };
let s = (next() % 14) as u32;
let near = (m.unsigned_abs() * 1_000_000 / 10u128.pow(s)) as u64;
let uj = near.saturating_add(next() % 3).saturating_sub(1);
let scale = 10i128.pow(s);
let want = (m * 1_000_000 - i128::from(uj) * scale).abs() * 2 <= scale;
let digits = m.unsigned_abs().to_string();
let padded = format!("{digits:0>width$}", width = s as usize + 1);
let (int, frac) = padded.split_at(padded.len() - s as usize);
let sign = if m < 0 { "-" } else { "" };
let plain = if frac.is_empty() { format!("{sign}{int}") } else { format!("{sign}{int}.{frac}") };
let exponent = format!("{sign}{digits}e-{s}");
for t in [plain, exponent] {
assert_eq!(rounds_to_micro(&Value::String(t.clone()), uj), Some(want), "{t} vs {uj} uJ");
}
}
}
#[test]
fn one_decimal_rounds_half_to_even_like_cpython() {
for &(bits, want) in F1 {
@ -1047,6 +1172,29 @@ mod tests {
(0xbbe1bf147ff1b71d, "-3.006358208957283e-20"),
(0x457c900c54a59386, "5.524827389298433e+26"),
];
// (f64 bits, Python repr) — generated from CPython's own repr(). Each double's exact
// value (in the comment) ends in 5 one digit past its shortest round-trip length, so
// two decimals of that length are equally near it and both read back as it: CPython
// writes the one whose last digit is even. The last three are powers of two, whose
// interval below is half as wide as the one above: there the nearest decimal of the
// shortest length does not read back (at 2^-24 the even candidate of a tie, `…062`;
// at 2^-44 and 2^481 the strictly nearer one), and CPython writes the other.
const REPR_TIES: &[(u64, &str)] = &[
(0x4052c25080000000, "75.03616333007812"), // 75.036163330078125
(0xc052c25080000000, "-75.03616333007812"), // -75.036163330078125
(0x40a27a4da8000000, "2365.1516723632812"), // 2365.15167236328125
(0x430b83a6ec774f62, "968072165779948.2"), // 968072165779948.25
(0x403b8f4d00000000, "27.559768676757812"), // 27.5597686767578125
(0x3f29500000000000, "0.00019311904907226562"), // 0.000193119049072265625
(0x4206b989d181d000, "12200262192.226562"), // 12200262192.2265625
(0x42e18995fb852e44, "154260995844466.12"), // 154260995844466.125
(0x3eba000000000000, "1.5497207641601562e-06"), // 1.54972076416015625e-6
(0x3ea4000000000000, "5.960464477539062e-07"), // 5.9604644775390625e-7
(0xbe60000000000000, "-2.9802322387695312e-08"), // -2.98023223876953125e-8
(0x3e70000000000000, "5.960464477539063e-08"), // 2^-24 = 5.9604644775390625e-8
(0x3d30000000000000, "5.684341886080802e-14"), // 2^-44 = 5.6843418860808014869…e-14
(0x5e00000000000000, "6.243497100631985e+144"), // 2^481 = 6.2434971006319844627…e+144
];
// (f64 bits, Python '%.1f') — ties land half-to-even on the exact binary value.
const F1: &[(u64, &str)] = &[
(0x3fd0000000000000, "0.2"),