wai: typed receipt classes and the soft binding class, reading OER through the OER module #112
20 changed files with 10419 additions and 22 deletions
8
.github/workflows/wai-conformance.yml
vendored
8
.github/workflows/wai-conformance.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
3
wai/claim-conformance/.gitattributes
vendored
Normal 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
|
||||
239
wai/claim-conformance/README.md
Normal file
239
wai/claim-conformance/README.md
Normal 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.
|
||||
795
wai/claim-conformance/claim_verify.py
Normal file
795
wai/claim-conformance/claim_verify.py
Normal 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))
|
||||
789
wai/claim-conformance/differential.py
Normal file
789
wai/claim-conformance/differential.py
Normal 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))
|
||||
3054
wai/claim-conformance/vectors.json
Normal file
3054
wai/claim-conformance/vectors.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
1
wai/wai-rs/Cargo.lock
generated
|
|
@ -3874,6 +3874,7 @@ dependencies = [
|
|||
"c2pa",
|
||||
"cbindgen",
|
||||
"claxon",
|
||||
"curve25519-dalek",
|
||||
"dav1d",
|
||||
"ed25519-dalek",
|
||||
"flac-bound",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
128
wai/wai-rs/src/bin/wai_claim_vectors.rs
Normal file
128
wai/wai-rs/src/bin/wai_claim_vectors.rs
Normal 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
|
||||
}
|
||||
}
|
||||
3969
wai/wai-rs/src/claim_receipt.rs
Normal file
3969
wai/wai-rs/src/claim_receipt.rs
Normal file
File diff suppressed because it is too large
Load diff
304
wai/wai-rs/src/claim_text.rs
Normal file
304
wai/wai-rs/src/claim_text.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Reference in a new issue