wai: key discovery, signing authority and module revocation #134

Merged
dcharlot merged 15 commits from wai/land-15-key-discovery into main 2026-10-01 14:54:56 -04:00
44 changed files with 12865 additions and 157 deletions

View file

@ -112,7 +112,7 @@ jobs:
run: cargo build --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,receipt_claims
- 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,receipt_claims
run: cargo test --lib --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,oer,receipt_claims,key_discovery
# 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
@ -202,6 +202,31 @@ jobs:
if: matrix.os == 'linux'
working-directory: wai/wai-web
run: cargo test --locked
# jwp-receipts §7.1, §7.8: no `did:web`/`did:key` literal in a tracked .rs, .json or
# .js file under wai/ names a real web origin — every key the tree signs with has a
# published seed. Ungated: it reads the tree through `git ls-files` and classifies
# with `signer_id`, which builds with no feature.
- name: Every signer id in the tree is a label
working-directory: wai/wai-rs
run: cargo test --no-default-features --test signer_ids_are_labels
# jwp-receipts §7: `wai-keys` builds a key log from operator-supplied seeds, appends
# revocations and rotations, signs statuses and module grants, verifies a log as
# §7.3 anchors one, and checks a DID document against it. The test drives the binary.
- name: wai-keys — build, check and publish an origin key log
working-directory: wai/wai-rs
run: cargo test --no-default-features --features key_discovery --test wai_keys_cli
# key_log has no I/O, clock or threads, so a browser sink could run it before it
# instantiates a module (§7.9); no browser sink does yet. This step checks that it
# builds for wasm32 on the unix legs (the target is added to the pinned toolchain
# here; the Windows host runner is not set up for it).
- name: key_log builds for wasm32 (unix)
if: matrix.os != 'windows'
shell: bash
working-directory: wai/wai-rs
run: |
set -euo pipefail
rustup target add --toolchain 1.98.0 wasm32-unknown-unknown
cargo check --lib --target wasm32-unknown-unknown --no-default-features --features key_discovery
# The corpus-verify loop is genuinely shell-specific (a helper that groups
# log output and propagates the first non-zero exit), so it ships once per
@ -212,7 +237,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,receipt_claims
F=world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,quantum,quantum_ops,oer,receipt_claims,key_discovery
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
@ -229,6 +254,7 @@ jobs:
v jwp wai_jwp_vectors ../jwp-conformance
v claims wai_claim_vectors ../claim-conformance
v pin wai_pin_vectors ../pin-conformance
v signers wai_signer_vectors ../signer-conformance
# The pin corpus's second implementation: Python standard library only, sharing no
# code with wai-rs. One leg is enough — it checks digests and listing rules, which
# do not depend on the architecture — and the macOS host has python3.
@ -257,7 +283,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,receipt_claims"
$F = "world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,quantum,quantum_ops,oer,receipt_claims,key_discovery"
function v($grp, $bin, $path) {
Write-Output "::group::$grp"
cargo run -q --no-default-features --features $F --bin $bin -- verify $path
@ -279,6 +305,7 @@ jobs:
v jwp wai_jwp_vectors ../jwp-conformance
v claims wai_claim_vectors ../claim-conformance
v pin wai_pin_vectors ../pin-conformance
v signers wai_signer_vectors ../signer-conformance
# The generated v2 vocabulary (wai/vocab/v2.*) is checked as a consumer uses it:
# sample manifests, typed and untyped, WAI1 and WAI2, are expanded through the
@ -306,11 +333,13 @@ jobs:
# code with wai-rs: claim_verify.py (every claim vector, the canonical fixture, and the
# bracket arithmetic of energy-measurement §6.2 recomputed with Python's integers),
# differential.py (both verifiers' verdicts on generated inputs the corpus does not hold,
# under a fixed seed, so a run is reproducible), and jwp_verify.py (every JWP vector).
# under a fixed seed, so a run is reproducible), jwp_verify.py (every JWP vector), and
# signer_verify.py (every key-discovery vector and the canonical fixture, with its
# verdicts required to equal wai_signer_vectors' line for line).
# Each exits non-zero on any failure or any difference, and the step fails with it.
# One platform: what they check is the reading of the text, not the architecture.
independent-verifiers:
name: independent verifiers · claims and JWP (Python)
name: independent verifiers · claims, JWP and key discovery (Python)
runs-on: linux
steps:
- uses: actions/checkout@v5
@ -335,13 +364,14 @@ jobs:
shell: bash
run: python -m pip install cryptography
# differential.py drives wai_claim_vectors' `verdicts` mode.
- name: Build the corpus verifier wai-rs ships
# differential.py drives wai_claim_vectors' `verdicts` mode; the key-discovery
# verdicts are compared with wai_signer_vectors'.
- name: Build the corpus verifiers wai-rs ships
working-directory: wai/wai-rs
shell: bash
run: cargo build --no-default-features --features receipt_claims --bin wai_claim_vectors --bin wai_jwp_vectors
run: cargo build --no-default-features --features key_discovery --bin wai_claim_vectors --bin wai_jwp_vectors --bin wai_signer_vectors
- name: claim_verify.py, differential.py, jwp_verify.py
- name: claim_verify.py, differential.py, jwp_verify.py, signer_verify.py
working-directory: wai
shell: bash
run: |
@ -349,6 +379,10 @@ jobs:
python3 claim-conformance/claim_verify.py claim-conformance/vectors.json
python3 claim-conformance/differential.py "$GITHUB_WORKSPACE/wai/wai-rs/target/debug/wai_claim_vectors" --random 2000 --seed 1
python3 jwp-conformance/jwp_verify.py jwp-conformance/vectors.json
python3 signer-conformance/signer_verify.py signer-conformance/vectors.json
python3 signer-conformance/signer_verify.py verdicts signer-conformance/vectors.json > "$RUNNER_TEMP/signer-py.tsv"
"$GITHUB_WORKSPACE/wai/wai-rs/target/debug/wai_signer_vectors" verdicts signer-conformance/vectors.json > "$RUNNER_TEMP/signer-rs.tsv"
diff "$RUNNER_TEMP/signer-py.tsv" "$RUNNER_TEMP/signer-rs.tsv"
byte-exact:
name: cross-arch byte-equality · ${{ matrix.os }}

View file

@ -136,10 +136,11 @@ jobs:
run: cargo test --locked --features full --lib
# The HTTP handler's POST /verify must accept a receipt in either form —
# as sealed, or labelled as wai_quantum_meter emits it. The verdict
# as sealed, or labelled as wai_quantum_meter emits it — and report the
# signer's level beside validity (jwp-receipts §7.1, §7.4). The verdict
# logic is a plain function, tested natively; the wasm32-wasip2 build of
# the component is not part of this workflow.
- name: wai-quantum-serve — POST /verify accepts either form
- name: wai-quantum-serve — POST /verify accepts either form and reports the signer's level
working-directory: wai/wai-quantum-serve
shell: bash
run: cargo test --locked --lib

View file

@ -21,6 +21,10 @@ name: WAI neural sink gates
# primary), SPEC §4 step 6's order of reasons after a set_incomplete decode, and the
# replicate_* reason the strict WAI2 policy reports for an entry it leaves out,
# and a candidate held at the sink leaving the strict path's reason unchanged.
# With `key_discovery` as well, it runs SPEC §3.1's check 5 at the neural sink: a
# registry given a key log refuses the committed int_mlicpp set once the log revokes
# its digest, and when the sink holds no fresh status, and decodes it while a grant
# holds it.
on:
push:
@ -31,6 +35,11 @@ on:
- 'wai/wai-rs/src/codecs/mod.rs'
- 'wai/wai-rs/src/codecs/prior_form.rs'
- 'wai/wai-rs/src/pin.rs'
- 'wai/wai-rs/src/key_log.rs'
- 'wai/wai-rs/src/key_log/**'
- 'wai/wai-rs/src/signer_id.rs'
- 'wai/wai-rs/src/claim_receipt.rs'
- 'wai/wai-rs/src/claim_text.rs'
- 'wai/wai-rs/src/ffi.rs'
- 'wai/wai-rs/src/ffi_neural.rs'
- 'wai/wai-rs/tests/ffi_neural_pin.rs'
@ -51,6 +60,11 @@ on:
- 'wai/wai-rs/src/codecs/mod.rs'
- 'wai/wai-rs/src/codecs/prior_form.rs'
- 'wai/wai-rs/src/pin.rs'
- 'wai/wai-rs/src/key_log.rs'
- 'wai/wai-rs/src/key_log/**'
- 'wai/wai-rs/src/signer_id.rs'
- 'wai/wai-rs/src/claim_receipt.rs'
- 'wai/wai-rs/src/claim_text.rs'
- 'wai/wai-rs/src/ffi.rs'
- 'wai/wai-rs/src/ffi_neural.rs'
- 'wai/wai-rs/tests/ffi_neural_pin.rs'
@ -103,6 +117,15 @@ jobs:
decode_multi_strict_reports_replicate_reasons strict_gate_ignores_a_held_capability_it_cannot_dispatch \
2>&1 | tee "$RUNNER_TEMP/neural-gates.log"
grep -q "test result: ok. 12 passed; 0 failed" "$RUNNER_TEMP/neural-gates.log"
- name: A key log refuses a revoked pinned set at the neural sink (SPEC §3.1 check 5)
working-directory: wai/wai-rs
shell: bash
run: |
set -euo pipefail
cargo test --no-default-features --features neural,key_discovery --test neural_native_decode -- \
--exact int_mlicpp_revoked_set_is_refused_through_the_key_log \
2>&1 | tee "$RUNNER_TEMP/neural-key-log.log"
grep -q "test result: ok. 1 passed; 0 failed" "$RUNNER_TEMP/neural-key-log.log"
# Library unit tests that exist only under `neural` and need no model: the
# bmshj2018 CDF table cache stays bounded and evicts least recently used;
# the ONNX reader finds a tensor kept in another file, wherever it sits; and a

37
.github/workflows/wai-section-refs.yml vendored Normal file
View file

@ -0,0 +1,37 @@
name: WAI section references
# Every section that SPEC.md or an extension cites in another WAI text exists:
# a link followed by §N, a link whose text holds §N, or an extension's name (or
# SPEC) in prose followed by §N, including references that break across lines.
# A citation of a section that was never written — as transparency §6's pointer
# to "jwp-receipts §7" was, before that section existed — fails the job. Research
# notes are dated records and are not read. The check is
# scripts/check-wai-section-refs.py; it needs Python and nothing else.
on:
push:
branches: [main]
paths:
- 'wai/extensions/**'
- 'wai/SPEC.md'
- 'scripts/check-wai-section-refs.py'
- '.github/workflows/wai-section-refs.yml'
pull_request:
paths:
- 'wai/extensions/**'
- 'wai/SPEC.md'
- 'scripts/check-wai-section-refs.py'
- '.github/workflows/wai-section-refs.yml'
jobs:
section-refs:
name: cited sections exist
runs-on: linux
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Every cited section exists
shell: bash
run: python scripts/check-wai-section-refs.py

View file

@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Every section a WAI specification text cites in another WAI text exists.
Reads wai/SPEC.md and wai/extensions/*.md. In each, a reference to a section of
SPEC.md or of an extension is checked against the headings of the file it names. Three
spellings are recognised, including where the reference breaks across lines:
[text](X.md) §N a link, then the section
[text §N](X.md) the section inside the link text
X §N, X.md §N, `X` §N an extension's file name (or SPEC) in prose, then the section
where §N is a section number of one or more parts (§7, §2.4.3), and a range written
§A–§B checks both ends. A heading is numbered when it begins `#`…, then the number, as
in `## 7.`, `### 6.4 ` or `## 7.1.`. Files under wai/research/ are dated records, kept as
written, and are not read.
Exit status 1, with one line per reference that names a missing section; 0 otherwise.
python3 scripts/check-wai-section-refs.py [repo-root]
"""
import os, re, sys
ROOT = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
WAI = os.path.join(ROOT, "wai")
EXT = os.path.join(WAI, "extensions")
HEADING = re.compile(r"^#{1,6}[ \t]+([0-9]+(?:\.[0-9]+)*)\.?(?=[ \t])", re.M)
SECTION = r"§[ \t]*([0-9]+(?:\.[0-9]+)*)"
RANGE = re.compile(r"[ \t]*[–-][ \t]*§[ \t]*([0-9]+(?:\.[0-9]+)*)")
def sections(path):
with open(path, encoding="utf-8") as f:
return set(HEADING.findall(f.read()))
def texts():
out = [os.path.join(WAI, "SPEC.md")]
out += sorted(os.path.join(EXT, n) for n in os.listdir(EXT) if n.endswith(".md"))
return out
def main():
files = texts()
known = {os.path.normpath(p): sections(p) for p in files}
stems = sorted((os.path.splitext(os.path.basename(p))[0] for p in files if os.path.dirname(p) == EXT),
key=len, reverse=True)
by_stem = {os.path.splitext(os.path.basename(p))[0]: os.path.normpath(p) for p in files if os.path.dirname(p) == EXT}
by_stem["SPEC"] = os.path.normpath(os.path.join(WAI, "SPEC.md"))
# A file name in prose, not inside a link target or a longer word or path.
bare = re.compile(r"(?<![\w/.(-])`?(" + "|".join(map(re.escape, stems + ["SPEC"])) + r")(?:\.md)?`?[ \t\n]+" + SECTION)
link_then = re.compile(r"\[[^\]]*\]\(([^)\s]+\.md)\)[ \t\n]*" + SECTION)
link_with = re.compile(r"\[([^\]]*?)" + SECTION + r"[^\]]*\]\(([^)\s]+\.md)\)")
findings = []
for path in files:
with open(path, encoding="utf-8") as f:
text = f.read()
here = os.path.dirname(path)
def check(target, num, at):
target = os.path.normpath(target)
if target not in known:
return # not a WAI specification text (a README, a research note)
if num not in known[target]:
line = text.count("\n", 0, at) + 1
rel = os.path.relpath(path, ROOT)
findings.append(f"{rel}:{line}: §{num} of {os.path.relpath(target, ROOT)} does not exist")
def check_range(target, m, end):
r = RANGE.match(text, end)
if r:
check(target, r.group(1), m.start())
for m in link_then.finditer(text):
target = os.path.join(here, m.group(1).split("#")[0])
check(target, m.group(2), m.start())
check_range(target, m, m.end())
for m in link_with.finditer(text):
target = os.path.join(here, m.group(3).split("#")[0])
check(target, m.group(2), m.start())
for m in bare.finditer(text):
check(by_stem[m.group(1)], m.group(2), m.start())
check_range(by_stem[m.group(1)], m, m.end())
for f in sorted(set(findings)):
print(f)
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -124,11 +124,11 @@ wire format may change and such changes are recorded here.
status, the October 2026 call for content); names are redacted, and comparative
framing is restated, in place. In this changelog's history, names are redacted and
nothing else changes.
- Notes for changes in flight: a change that adds a dispatch or selection function
uses `codecs::is_dispatchable`; a change that registers a string (for example a prior
set) adds it to the registry table, runs `wai registry export`, regenerates vocab v2
(not v1) and lists the string in the glossary's `GLOSSARY_NO_ENTRY` or links it; the
quantum-ops corpus and the wai-quantum calibration pin have the values above; the
- For later changes: a new dispatch or selection path uses `codecs::is_dispatchable`;
a newly registered string (for example a prior set) goes into the registry table,
`wai registry export` regenerates the exports and vocab v2 (vocab v1 is stable and only
gains terms), and the string is listed in the glossary's `GLOSSARY_NO_ENTRY` or linked.
The quantum-ops corpus and the wai-quantum calibration pin have the values above; the
pwsh steps of the conformance workflow remain, inactive.
- [`extensions/jwp-receipts.md`](extensions/jwp-receipts.md) §5: the MUST is over the
cases §5.1–§5.5 define; verifying against their vectors becomes a MUST once the
@ -145,9 +145,10 @@ wire format may change and such changes are recorded here.
royalty-free, the one form listed as established exact). A sink verifies the pin
before it decodes, reports a refusal with one of an ordered set of snake_case
reasons (`pin_malformed`, `form_not_registered`, `form_unreadable`,
`form_required`, the reserved `prior_revoked`, `prior_not_held` /
`digest_superseded`, `set_malformed`, `part_mismatch`; `set_incomplete` during a
decode), and decodes only from the set it verified. A pin over a capability this
`form_required`, `prior_revoked` / `prior_not_authorised` (check 5, run by a sink
that holds a key log; see *Key discovery, signing authority and module revocation*
below), `prior_not_held` / `digest_superseded`, `set_malformed`, `part_mismatch`;
`set_incomplete` during a decode), and decodes only from the set it verified. A pin over a capability this
revision does not register is verified as several files. `null` in an optional
member is the same as absent.
- **Tier** is a closed vocabulary (`decode-equivalence`, `entropy-consistency`,
@ -388,6 +389,128 @@ where it rendered its marker as the integer `0`, and every other receipt adds
seal from the published integers, and a meter now refuses a rail that is not live where
it used to seal a figure.
### Key discovery, signing authority and module revocation
- [`extensions/jwp-receipts.md`](extensions/jwp-receipts.md) §7: a signer can say
where its keys are published, and a verifier can confirm it. A `signer_id` of the
form `did:web:<host>[%3A<port>]#<key-id>` names a web origin, which publishes at
`/.well-known/wai-keys` a **key log**: revisions signed by a root key that
pre-commits to its successor, each listing the origin's keys, the authorities each
holds (`delivery`, `energy/<acquisition class>`, `lawful-basis/declaration` or
`/decision`, `log-inclusion`, `soft-binding`, `binding-recovery`, `measurement`,
`operating-point`, `receipt`, `module/decoder`, `module/reconstruct`) and whether it
is active, retired or revoked. The path is published now; its registration in the IANA Well-Known URIs
registry is to be requested. A `did.json` on the same origin SHOULD list exactly
the log's active keys, and the log governs.
- A verifier that has anchored the origin — pinned out of band, or fetched from the
origin over HTTPS and recorded as such — confirms an accepted receipt, claim or
module grant against the log, offline, and reports `confirmed`, `refused` or
`unconfirmed` with a named reason beside the item's own verdict, never folded into
it. An item needs every authority the item fixes: a group receipt needs `delivery`
and `energy/<class>` for each class its objects' labelled leaves and its labelled
total carry, another family's receipt `receipt` and `energy/<class>` for each class
its figures carry, a claim its class's authority, and an energy authority is read from any
`energy_provenance` a claim's body carries, whatever its class — so a `measurement`
or `operating-point` claim needs its class's authority and the energy authority of
the figure it restates, pinned-only for a hardware class. An Ed25519 `did:key`
names one key, and one naming a key other than the signer's is refused; a `did:key`
longer than any Ed25519 key's spelling is a label, refused before it is decoded. The head walk
ignores revisions that are not held, unattached, or signed by a key their parent
does not authorise; the owner's rotation supersedes a stolen root's revisions; an
older log retrieved after a newer one was anchored is reported as rolled back.
- Pinned-only authorities: the hardware-class energy authorities and the
lawful-basis authorities, and any authority registered for pre-emption, are
confirmed only under a pinned anchor that has not moved (`anchor_not_pinned`);
every confirmation reports whether its anchor was pinned or fetched. A pinned record
moved forward by a later retrieval stays pinned and keeps the revision that was
pinned, and a pre-emption authority is read from that revision only. Beside an item
that needs a hardware-class authority, a verifier reports whether its key also
holds `delivery` (`also_delivers`); the recommendation that it not is a SHOULD.
- Revocation refuses every item under a revoked key, whatever time the item carries.
A retired key's untimed items stay confirmable until the key is revoked, and are
reported as untimed.
- §7.9, decoder and reconstruct modules: a module, or a parameter set an envelope
pins, is identified by the SHA-256 of its bytes and authorised by a signed **module
grant** from a key holding `module/<form>`, for named capabilities and a bounded
window. A grant is held only from one of the sink's **module origins** — an origin
its relying party names, or the origin under which it confirmed the envelope's
delivery — so anchoring an origin for its receipts does not make it a source of
modules. The key log revokes modules by digest (`revoked_modules`, append-only), and
a digest revoked in the head of any origin the sink has anchored is not held,
whichever origin grants it. Each log declares `grant_max_s` and `status_max_age_s`
under normative ceilings; a signed **key status** from the head's root, re-issued on
each heartbeat and held only if it names the head or an ancestor of it, keeps a sink
current. Eight ordered checks decide whether a sink runs a module; every failure —
including no network, a stale status and no trusted clock — means the module is not
held, and dispatch falls back: a sink never reports a file inert because of module
authorisation while a fallback remains, and an encoder whose capability needs a
module declares one. The kill latency `grant_max_s + status_max_age_s` is a
normative parameter. The ceilings are 86400 s for `grant_max_s` and 3600 s for
`status_max_age_s`.
- Module revocation through the parameter-set pin. [`SPEC.md`](SPEC.md) §4 folds the
hook into its steps: a sink that runs a module obtained apart from an envelope holds
it under §7.9, or the capability is not advertised for that envelope. SPEC §3.1's
check 5, reserved until now, runs at a sink that holds a key log, keyed on the pin's
`form` and `sha256`, before check 6. It has two parts. Revocation runs for every
capability a pin applies to and needs no grant, status or clock: a set whose digest
the head the sink reaches from any anchor it holds revokes is refused with
`prior_revoked`, whichever check a grant would fail first. Authorisation runs where
the relying party holds the capability's pinned sets to §7.9, its choice per
capability: §7.9 authorises the set as a decoder module, and any other failure,
offline and no grant included, is `prior_not_authorised`. Dispatch moves to the
fallback as for any refused pin. An envelope that pins no set does not escape check
5: the set an unpinned decode reads is checked under each digest a pin over it could
name. A sink configured with no key log does not run check 5.
`key_log::module_check` implements §7.9's checks; `key_log::SinkKeyLog` runs check 5
in the reference dispatcher, given to `pin::verify_with` or to
`neural::ModelRegistry::with_key_log`, which also runs it before an unpinned decode.
Library tests in CI dispatch a revoked set to its fallback, report `prior_revoked`
when none remains, refuse a revoked set whose grant fails an earlier check, whose
sink holds no grant, or whose capability is not held to §7.9, and fall back when the
sink is offline; the neural sink gates run the same refusals through
`neural::decode_envelope` over the committed int_mlicpp set, pinned and unpinned. A
key log that names the subject's origin holds for one envelope, so a registry given
one decodes that envelope only. The reference dispatcher runs no module obtained
apart from an envelope; `wai-web` and the C ABI take no key log.
`neural::ModelRegistry` gains a private field, so a struct literal no longer builds
one; build it with `ModelRegistry::new()` and its builder methods.
- `wai-rs`: `signer_id` (ungated, no dependency) reads a `signer_id`;
`key_log` (feature `key_discovery`, no new dependency, checked to build for wasm32)
implements §7. Outside the crate, an item to confirm is read from bytes that
verified: `Item::from_claim` from an accepted claim, `Item::from_group` from a group
receipt and the objects included under it, `Item::from_family` from another
family's receipt through its figure view, given the `signer_id` and signed time the
same receipt carries. `wai-keys` builds an origin's key log from seeds the operator names,
appends retirements, revocations, revoked modules and rotations, signs statuses and
module grants, verifies a log as §7.3 anchors one — afresh, or as a later retrieval
against a fetched or pinned anchor already held — and emits and checks a `did.json`.
It creates no key material, never prints a seed, and refuses a published test key
unless told the log is a test log.
- `signer-conformance/`: a corpus of anchoring, confirmation and module vectors with
declared verdicts, generated and verified by `wai_signer_vectors` in CI on every
platform of the conformance matrix, with a canonical fixture computed by a separate
implementation (`signer_verify.py`), which also reproduces every vector's verdict. CI
runs it on the linux runner and requires its verdicts to equal `wai_signer_vectors`'
line for line (jwp-receipts §5.8).
- No wire format or committed signed vector changes. Every receipt and claim verifies
as before, and every vector of the claim corpus, those of the `measurement` and
`operating-point` classes included, keeps its outcome. The reference meters, whose seeds are published, now write a label as
`signer_id` rather than a real origin, so their signed output differs from before;
the world demo's signer is a label too. A CI test holds every `did:web` literal in
a tracked `.rs`, `.json` or `.js` file under `wai/` (`signer-conformance/` aside)
that names a domain, and every origin-bound one, to a reserved example name. `wai-quantum-serve`'s `/verify`
reports the signer's level beside validity.
- transparency §6 pointed to "jwp-receipts §7" when no §7 existed; it now points at
this section. SPEC §6.2–§6.3, confidentiality §6–§7 and jwp-receipts §6.6 no longer
say that a relay signs, or that checking a receipt needs no key: it needs the
signer's verifying key, which the receipt carries. prior-carriage §6 no longer says
WAI has no key discovery.
- Not done here: a key log for `wai-web` and the C ABI; an independent time that could
reinstate an item signed before its key was compromised; sub-delegation between
module grants; an authority for pre-emption, relay attestation or reconciliation,
which have no class; checking a log's evidence under its profile; the keys an OER
receipt is checked with.
### Typed receipt classes, and a second (soft) binding class
- [`extensions/jwp-receipts.md`](extensions/jwp-receipts.md) §2.4.3–§2.4.7: every

View file

@ -128,7 +128,8 @@ All WAI multi-byte integers are little-endian unless stated otherwise.
- `model_requirement.fallback` — a second capability the sink MAY use
if it lacks the primary, or if the envelope's pin does not resolve for the
primary at the sink (§4 step 3). `null` means the file is inert without the
primary.
primary. An encoder whose primary capability decodes with a module a
sink obtains apart from the envelope does not write `null` (§4).
- `conditioning.kind` — short codec id matching the capability. Helps
multiplexers route bytes without parsing the full capability string.
- `conditioning.prior` — OPTIONAL. The pin (§3.1) of the parameter set the
@ -233,8 +234,21 @@ order, and the first that fails is the reason reported:
This is the sink's gap, and it is never reported as a mismatch.
4. `form_required`: `form` is absent and the capability is not registered with a
one-file set.
5. `prior_revoked`: reserved, for a revocation of the pinned set, keyed on the
pin's `form` and `sha256`.
5. `prior_revoked` or `prior_not_authorised`: the sink holds a key log
([jwp-receipts](extensions/jwp-receipts.md) §7), and it refuses the set this
pin names, which it identifies by the pin's `sha256` in the pin's `form`. The
reason is `prior_revoked` when that digest is in the `revoked_modules` of the
head the sink reaches from any anchor it holds, or, where none can be reached,
of that anchor revision (§7.9 check 5). A revocation needs no grant, status or
clock, so this part runs for every capability a pin applies to, and whichever
check of §7.9 a grant fails first. Where the sink also holds the capability's
pinned parameter sets to §7.9 (§4, *Module authorisation*), §7.9 authorises the
set as a decoder module, and the reason is `prior_not_authorised` when §7.9 does
not hold it for any other reason — no grant from one of the sink's module
origins, a granting key §7.4 does not confirm, no fresh status because the sink
is offline or has no clock it trusts, a grant outside its window or for another
capability — or the sink holds no grant for the set. The sink reports §7.9's
check and reason with it. A sink that holds no key log skips this check.
6. `prior_not_held`: the sink holds no parameter set for the capability whose
digest, in the pin's form, equals `sha256`; a file the sink cannot read is not
held. A sink that holds the set a well-formed `supersedes` names — a file when
@ -250,7 +264,9 @@ order, and the first that fails is the reason reported:
These checks read the envelope, the registry, the forms the sink can read and the
bytes it holds, and nothing that depends on how the sink was built, so two
conforming sinks that read the same forms and hold the same bytes report the same
reason. Refusal reasons are snake_case; tier names and class labels are
reason. Check 5 also reads what the sink holds under jwp-receipts §7.9 and its
clock, so two sinks that hold the same key-log material and agree on the time
report the same reason there. Refusal reasons are snake_case; tier names and class labels are
kebab-case.
**Decoding against a verified set.** A pinned decode MUST read its parameters
@ -341,16 +357,18 @@ A conforming sink MUST implement this algorithm:
1. Read the manifest. If `wai` major version is unknown → reject.
2. Let `cap = model_requirement.capability`.
3. If `cap` is dispatchable (below), the sink advertises `cap`, and — when a pin applies to `cap` (§3.1) and the
manifest carries `conditioning.prior` — the pin resolves at the sink for
`cap`, decode `payload` via the codec registered for `cap` (§5), against the
verified set, and return. A pin that does not resolve makes `cap` unsupported
for this envelope: keep its reason and continue at step 4.
3. If `cap` is dispatchable (below), the sink advertises `cap` — holding under
jwp-receipts §7.9 any module it obtained apart from the envelope to decode
`cap` (*Module authorisation*, below) — and, when a pin applies to `cap`
(§3.1) and the manifest carries `conditioning.prior`, the pin resolves at the
sink for `cap`, decode `payload` via the codec registered for `cap` (§5),
against the verified set, and return. A pin that does not resolve makes `cap`
unsupported for this envelope: keep its reason and continue at step 4.
4. Else, let `fb = model_requirement.fallback`.
5. If `fb != null`, `fb` is dispatchable and the sink advertises `fb`, and — when a pin applies to `fb`
and the manifest carries `conditioning.prior` — the pin resolves at the sink
for `fb`, decode via `fb`'s codec and return. A pin that does not resolve: keep
its reason.
5. If `fb != null`, `fb` is dispatchable and the sink advertises `fb` on the same
terms, and, when a pin applies to `fb` and the manifest carries
`conditioning.prior`, the pin resolves at the sink for `fb`, decode via `fb`'s
codec and return. A pin that does not resolve: keep its reason.
6. Else the file is **inert at this sink** — return an error to the calling
application that names why: the reason kept at step 3, else the one kept at
step 5, else `missing_capability`.
@ -379,6 +397,37 @@ If the manifest declares `security.encryption` other than `"none"`
(§6), the sink MUST recover the plaintext payload before step 3 and run
this algorithm on the plaintext; see §6.4.
**Module authorisation.** A module is code a sink obtains apart from the envelope
and runs to decode a capability, or to rebuild a reconstruction from what a decoder
produced: a decoder or reconstruct module
([`extensions/jwp-receipts.md`](extensions/jwp-receipts.md) §7.9). A sink that
decodes `cap` with one MUST hold it under §7.9 for the envelope before it runs it, from one of the sink's module origins (§7.9): the origins its
relying party names, and the origin under which it confirmed the envelope's
delivery. A module that is not held — not granted by a module origin, revoked, or
not checkable because the sink is offline or has no clock it trusts — leaves `cap`
not advertised for that envelope at steps 3 and 5. A sink that holds a key log
refuses a parameter set whose digest the key log revokes (§3.1 check 5,
`prior_revoked`). A sink MAY also hold the parameter sets envelopes pin to §7.9, by
its relying party's choice per capability; for such a capability §3.1's check 5
also refuses a pinned set §7.9 does not hold (`prior_not_authorised`). A refused
pin does not resolve. An envelope that pins no set is decoded against the set the
sink holds for `cap`, and a sink that holds a key log runs check 5 over that set
too, identified by each digest a pin over it could name: it does not decode `cap`
for that envelope when any of them is revoked, or, for a capability it holds to
§7.9, when §7.9 holds the set under none of them. Either way
the algorithm above runs unchanged, so a sink MUST NOT report a file inert because
of module authorisation while a fallback it can decode without that module remains,
and an encoder emitting a capability whose decode needs a module MUST declare such a
fallback (§3). A sink configured with no key log holds no module under §7.9, so it
runs no module obtained apart from an envelope, and it does not run check 5. The
reference dispatcher in `wai-rs` runs no module obtained apart from an envelope; it
runs check 5 when the sink is configured with a key log (`key_log::SinkKeyLog`,
feature `key_discovery`, given to `pin::verify_with` or to
`neural::ModelRegistry::with_key_log`, which also runs it before an unpinned
decode): the revocation part for every capability a pin applies to, and §7.9's
authorisation for the capabilities the sink holds to it. `wai-web` and the C ABI
take no key log.
---
## 5. Registered capabilities (the zeroth menu)
@ -912,23 +961,34 @@ membership is established out of band via MLS; each member derives the
derive the key and the file is **inert at that sink** — the
confidentiality analog of a missing capability.
This is the content key. Checking a receipt needs no content key, only
the signer's verifying key, which the receipt itself carries
(`signer_pubkey`): where a signer publishes its verifying keys, and how
a verifier confirms whose a key is, is
[`extensions/jwp-receipts.md`](extensions/jwp-receipts.md) §7.
### 6.3 Relay transparency and attestation
A WAI relay or multiplexer that carries an encrypted payload MUST treat
it as opaque: it MUST NOT require the plaintext to route, meter, or
attest, and MUST NOT attempt capability dispatch on ciphertext. The JWP
receipt chain ([`extensions/jwp-receipts.md`](extensions/jwp-receipts.md))
commits over the **ciphertext** content hash, so per-object delivery and
integrity are attested end-to-end without any relay holding the key. Each
object's figure, class and origin are signed where its leaf is labelled, and
the group total, its class, its coverage and the parent link are signed under
the labelled group payload (jwp-receipts §2.2–§2.3). An object's figure and
origin beside a legacy leaf are the publisher's statement, unsigned beside the
signed bytes; a legacy group payload signs the total, but no class, coverage or
parent link. Content secrecy (SFrame/MLS) and delivery attestation (JWP) are
independent layers over the same bytes and compose: a relay proves it
forwarded the exact sealed bytes it was given while never learning what
they say.
commits over the **ciphertext** content hash, so each object's integrity,
and its delivery under the publisher's signature, are attested end-to-end
without any relay holding the content key. Each object's figure, class and
origin are signed where its leaf is labelled, and the group total, its class,
its coverage and the parent link are signed under the labelled group payload
(jwp-receipts §2.2–§2.3). An object's figure and origin beside a legacy leaf
are the publisher's statement, unsigned beside the signed bytes; such an
object's figure is signed only where an `energy` claim restates it
(jwp-receipts §2.4.5). A legacy group payload signs the total, but no class,
coverage or parent link.
Content secrecy (SFrame/MLS) and delivery attestation (JWP) are independent
layers over the same bytes and compose: a relay forwards the exact sealed
bytes and their receipts unaltered, signs nothing in the publisher's name
(jwp-receipts §4.3), and never learns what the bytes say. Checking the
attestation needs the publisher's verifying key, which the receipt carries,
never the content key; whose key it is, jwp-receipts §7 confirms.
### 6.4 Decrypt-then-dispatch

View file

@ -191,15 +191,22 @@ MUST NOT attempt capability dispatch on ciphertext.
Attestation composes with confidentiality because the JWP receipt chain
([`jwp-receipts.md`](jwp-receipts.md)) commits over the **ciphertext**
content hash. A relay signs that it forwarded the exact sealed bytes it
was handed — with the per-object energy and delivery lineage bound into
the Merkle leaf — while never learning what they say. Content secrecy
content hash. The publisher signs the group's Merkle root over the
objects' leaves, and a Merkle leaf commits to the object's content hash
and, for a labelled object, its figure, class and origin (jwp-receipts
§2.2); an unlabelled object's figure is signed only where an `energy`
claim restates it (jwp-receipts §2.4.5). A relay forwards the
sealed bytes and their receipts unaltered, signs nothing in the
publisher's name (jwp-receipts §4.3), and never learns what the bytes
say. Content secrecy
(SFrame/MLS) and delivery attestation (JWP) are independent layers over
the same bytes:
- SFrame answers *what does it say* — only members can.
- JWP answers *who delivered it and at what energy* — anyone can verify,
no key required.
- JWP answers *which bytes the publisher signed for, and the group's
declared energy* — anyone can check it with the publisher's verifying
key, which the receipt carries, and no content key; whose key it is,
[`jwp-receipts.md`](jwp-receipts.md) §7 confirms.
---
@ -218,8 +225,10 @@ the same bytes:
object sizes/timing are visible to relays by design. This extension
protects payload content, not traffic analysis.
- **Trust boundary.** A relay is untrusted for confidentiality (it never
holds a key) and semi-trusted only for the attestation it signs under
its own key; the two roles do not overlap.
holds a content key) and signs nothing in the JWP chain (jwp-receipts
§4.3). A relay attestation, where a deployment wants one, is a separate
receipt under the relay's own key, and no class for it is registered
yet.
---

View file

@ -92,6 +92,18 @@ 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, and nothing above changes it.
Whose key signed a figure's class can be confirmed under
[jwp-receipts](jwp-receipts.md) §7: an origin the verifier has anchored lists
the key with the authority `energy/` followed by the class. That is the
origin's statement of which of its keys states figures of that class, and a key
given one class cannot state another. For the hardware classes the
confirmation holds only under an anchor the relying party pinned, never one
fetched on first use (jwp-receipts §7.5). It is not evidence about the
instrument (§2.2), and no confirmation ranks a figure above its class (§2).
jwp-receipts §7.5 recommends that a hardware-class figure be signed by a key
held by the component that signs the reading, apart from the key that signs
delivery, and has a verifier report whether the key also holds `delivery`.
## 2. The acquisition class (REQUIRED)
A receipt this extension governs (§1) that carries a non-zero energy figure
@ -272,7 +284,9 @@ single class put them at one trust level.
latter.)
A signature settles who said a number. It says nothing about how far the number
can be from the energy actually spent. The declared uncertainty (§2.3) is the
can be from the energy actually spent. Confirming whose key it is
([jwp-receipts](jwp-receipts.md) §7) settles which origin stands behind the key,
and for which class, and nothing about the reading. The declared uncertainty (§2.3) is the
statement about that, and the class says how much of it the declaration covers.
For `CalibratedInstrument` it covers the instrument's error as established by
its calibration, together with the measurement's own scatter. For

File diff suppressed because it is too large Load diff

View file

@ -243,8 +243,11 @@ WAI's binding sits beside the standard's, never in place of it.
signature.
- **Stripping is not repackaging.** Removing signature units changes the bytes,
so the result no longer matches its pin. It is a different artifact.
- **Keys.** WAI has no key-discovery mechanism of its own. A key location found
in-band is recorded, not trusted.
- **Keys.** WAI's key discovery ([jwp-receipts](jwp-receipts.md) §7) covers the
signers of WAI's own receipts and claims, and who may authorise a pinned
parameter set (jwp-receipts §7.9). A key location found in-band in a prior
artifact is outside it: it is recorded, not trusted, and never an anchor
(jwp-receipts §7.3).
## 7. Reference implementation
@ -266,12 +269,17 @@ WAI's binding sits beside the standard's, never in place of it.
`pin::write_listing` and `pin::listing_of_dir` read and write a listing.
`pin::READABLE_FORMS` holds `wai.prior.set`: the reference sink reads no coded
form.
- `pin::verify_bytes_with` and `pin::verify_with` run SPEC §3.1's check 5 with a
`pin::PinAuthority`, before any held file is read. `key_log::SinkKeyLog`
(feature `key_discovery`) is the one in this crate: built from the key log a
sink holds, it runs jwp-receipts §7.9 over the pinned sets of the capabilities
the sink's relying party holds to it. Without one, check 5 is skipped.
- `neural::ModelRegistry` holds, for each capability, its registered path and any
further paths given to `hold`. For a form-less pin it hashes each held file —
for `wai.neural.int_synth`, the `int_synth.json` beside it. For a
`wai.prior.set` pin it takes every `*.sha256` file directly inside each held
path's directory (the path itself if it is a directory) as a listing rooted
there.
there. `ModelRegistry::with_key_log` gives it a key log for check 5.
- `container::Wai::dispatch_verified` applies SPEC §4 steps 3–6 and
`container::WaiMulti::pick` SPEC §7.1 steps 2 and 5, each with a pin verifier
the caller supplies. `neural::decode_envelope` and `neural::decode_multi`

View file

@ -61,6 +61,11 @@ forcing them together would put a registration round-trip in the per-object path
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).
- An origin that publishes a key log ([`jwp-receipts`](jwp-receipts.md) §7)
**SHOULD** register each revision as it registers group receipts. A fork in
the log is then visible to every Relying Party that checks the log, not only
to one that happened to hold both branches. The registration witnesses a
revision; it does not anchor the origin (§6).
The checking party is the **Relying Party** in the terminology of the SCITT
architecture; this extension uses that term rather than coining another.
@ -111,6 +116,8 @@ cannot check a proof without knowing the log's rules.
witnesses that a receipt existed at a time; it does not attest that the receipt
is true, and an implementation **MUST NOT** report registration as
verification.
- It does **not** solve key discovery. That is [`jwp-receipts`](jwp-receipts.md)
§7, and a log entry naming a key the Relying Party cannot resolve is still
unresolvable.
- It does **not** solve key discovery. [`jwp-receipts`](jwp-receipts.md) §7
does, for a signer that names its web origin and publishes its keys there. A
log entry naming a key the Relying Party cannot confirm under that section is
still unconfirmed, and registering a key-log revision witnesses it without
anchoring the origin.

View file

@ -61,6 +61,11 @@ an object either has no such field or has one anyone may set.
signed, never that they were entitled to.
- Authority **MUST NOT** be inferred from position in the delivery chain. The
entity that transmits is not thereby entitled to interrupt.
- A key-log anchor a sink fetched from an origin on first use
([`jwp-receipts`](jwp-receipts.md) §7.3) is not a trust anchor here. When a
class is registered for pre-emption, its authority is confirmed only under a
pinned anchor, and only for a key the pinned revision itself lists with it
(jwp-receipts §7.4, §7.5).
## 4. Detached receipts

View file

@ -10,7 +10,10 @@ for every case:
SPEC §3.1 names: `pin_malformed`, `form_not_registered`, `form_unreadable`,
`form_required`, `prior_not_held`, `digest_superseded`, `set_malformed`,
`part_mismatch`. A held artifact is a `file`, or a `listing` together with the
`root` its paths are read below. Paths are relative to this directory.
`root` its paths are read below. Paths are relative to this directory. The cases
model a sink that holds no key log, so check 5 (`prior_revoked`,
`prior_not_authorised`) does not run; it is covered by the parameter-set vectors of
[`../signer-conformance/`](../signer-conformance/) and the `key_log` library tests.
- `selection.json` — given a `WAI2` manifest, its payloads, the capabilities the
sink advertises and the one-file parameter sets it holds, the rendition SPEC §7.1
picks and the reason for each entry it skips (`not_selectable`,

View file

@ -689,7 +689,7 @@ review.
| G3 | **SyfAI's AI update-framework thread, opened at MPEG 155 jointly with WG 7 (S ⚠), covers parameter formats, manifests, repositories, integrity checking and bit exactness for NN decoders. The artifact a WAI `Prior` names has no normative wire form.** `Prior { id, sha256, determinism }` pins identity: `sha256` hashes the decoder artifact the sink runs. No wire format for that artifact is specified (2030 §6.4). | `wai-rs/src/container.rs` `struct Prior`; 2030 landscape §6.4; no SyfAI or Exploration 52 mention in the tree (grep) | **Addressed at link 05 (PR #106), with limits.** [`prior-carriage.md`](../extensions/prior-carriage.md) §2: a `Prior` MAY declare `form`; when it does, the digest covers the coded bitstream, and a sink that cannot read the form MUST refuse the prior. §3 registers `wai.prior.nnc` (ISO/IEC 15938-17:2024) as the one form, optional and not recommended, because its royalty status and cross-decoder exactness are not established. `Prior::is_cross_hardware_exact` requires a declared form to be established exact, none is, so a `replicate` resting on a declared form is refused (§5). The reference sink reads no form (`READABLE_PRIOR_FORMS` is empty), and `wai-rs` has no NNC reader or writer. SPEC's opening section now says a model's identity MAY be pinned by digest (a `Prior`). [`syfai-input.md`](syfai-input.md), committed with this record, maps WAI onto the update framework; its G1 records what SPEC still does not define |
| G4 | **WAI's `HwShunt` takes in IOReport, a reading the monorepo classes five ways; and neither WAI nor the family's receipt ladder separates platform-relayed counters from independently rooted instruments. Only `sandbox-tee` does.** Three layers. **(a) The family's receipt ladder.** Every standard that lists interfaces under `HwShunt` puts on-die counters and dedicated shunts in it together (JCR-1 §2.3, JCP §5.1, Sandbox §6.3, `mesh-schema`, `proof-schema`), and IOReport is classed inconsistently (§5.3). **(b) `sandbox-tee`'s `CounterClass`** reserves `HwShunt` for a dedicated shunt and gives RAPL, NVML, IOReport and Redfish classes of their own; its `AttestTrust` ladder is derived from evidence, and `efficiency/1` commits to the attestation (§5.1). **(c) WAI** has `HwShunt` cover "RAPL, NVML, SMC, IOReport, or an external shunt", and `wai_video_meter` stamps IOReport readings `HwShunt`. That places WAI with Joule Code's text and the Sandbox meter, and against JCR-1 §2.3. WAI's texts present the ladder as the family's own without noting the split: `video_receipt.rs` says it is "reused verbatim from `mesh-schema` / `proof-schema`", which list no IOReport; `energy_binding.rs` calls it "the family's ladder"; `energy-measurement.md` §2 says WAI reuses "the same three names the receipt families elsewhere in the ecosystem already use". At every layer but (b), a verifier cannot tell a platform-relayed counter, which may be deliberately filtered (§5.1), from an independently rooted instrument. | §5.3 (the per-standard survey); `wai/extensions/energy-measurement.md` §2; `wai-rs/src/energy_binding.rs`:30, :47–51 (`Acquisition`); `wai-rs/src/video_receipt.rs` `Provenance` doc comment; `wai-rs/src/bin/wai_video_meter.rs`:184–190; `jouleclaw/jouleclaw-rs/crates/jouleclaw-jcr1/docs/JCR-1-spec.md` §2.3; `jcp/SPEC.md` §5.1; `sandbox/SPEC.md` §6.3–§6.4; `mesh/mesh-rs/crates/mesh-schema/src/lib.rs` and `proof/proof-rs/crates/proof-schema/src/lib.rs` (`Provenance`); `sandbox/sandbox-rs/crates/sandbox-tee/src/energy.rs` (`AttestTrust`, `CounterClass`, `is_independently_rooted`); `jcp/jcp-rs/crates/jcp-efficiency/MAPPING.md` §4 and `src/training.rs` (`energy_attestation`). No mention of `sandbox-tee`, `CounterClass`, `AttestTrust`, `energy_attestation`, `PhysicallyRooted` or `IntegrityAttested` under `wai/` (grep) | **WAI's side closed at link 08 (PR #109); the family's side open.** energy-measurement §2 separates `CalibratedInstrument` (an external instrument with a current calibration) from `OnChipCounter` (a counter the measured silicon keeps, or any instrument whose uncertainty is not traceable). Each carries a declared uncertainty (§2.3); the counter carries its filtering state (§2.4), the instrument a calibration reference (§2.5). `HwShunt` is legacy, accepted and never emitted (§2.6), and IOReport is `OnChipCounter` (§2.9). `energy_class.rs` is the one definition, and the reference meters seal `OnChipCounter`. The "reused verbatim" sentence is gone; `video_receipt.rs` and `energy_binding.rs` still call the ladder the family's, and §2 says the three-name vocabulary is extended, not replaced, with both refinements mapping to `HwShunt`. §2.9's list of differing documents is partial (§5.3). No WAI text references `sandbox-tee`'s ladder (grep). The family's IOReport split and whether its receipt-level ladder should separate the two kinds of hardware evidence are unassigned (below) |
| G5 | **The codec registry has no AV2, VVC, HEVC, LCEVC, MPEG-H Audio, AVS3 or Audio Vivid capability.** Brazil mandates three of these (§3.3). Two are Chinese standards: AVS3 (a group standard, with its national standard pending) and Audio Vivid (GB/T 46271-2025). 2030 §6.1 (*Honest scope*) records that WAI registers no VSEI-family codec. **The tree's codec survey is a second surface to reconcile.** `wai/glossary/codecs.json`, which `wai/README.md` describes as the "Reference catalog of media codecs surveyed for capability registration", has entries for HEVC / H.265 and VVC / H.266 (both `niche`), LCEVC (`niche`) and ECM (`academic`, JVET's exploration model beyond VVC), and none for AV2, MPEG-H Audio, AVS3 or Audio Vivid. `glossary/SCHEMA.md` defines `success` as "carries non-trivial production traffic today" and `niche` as "alive in specific contexts". §4 reports HEVC in production use at 65% in a vendor survey (**S** ⚠), §3.3 reports VVC as a national broadcast system's main codec (**R** ⚠), and the HEVC entry's own description calls it universal in broadcast UHD. The grades are descriptive and the contrary evidence is unverified, so this is a reconciliation item, not a correction. | capability strings in `wai-rs/src/codecs/*.rs`: none for these codecs. Other mentions, from a grep over `*.rs`, `*.md`, `*.json`, `*.toml`, `*.yml`, `*.yaml`, `*.html`, `*.ts` and `*.js` under `wai/`: the `glossary/codecs.json` entries above; the H.264/H.265 royalty sentence in `SPEC.md` and `CHANGELOG.md`; the BD-rate note that names VVC in `wai-rs/src/codecs/mod.rs` and `wai-rs/src/neural/mlicpp.rs`; `HEVC` as an example name in `glossary/SCHEMA.md`; the 2030 landscape. Royalty class per codec **not established by this sweep**: only AV2's royalty-free patent policy was seen at the issuing body (the AOMedia press release, §2.4) | **Closed in the registry at link 04 (PR #105); the glossary is not reconciled.** SPEC §5 *Broadcast and streaming codecs* registers `wai.video.{av2,vvc,hevc,avc,vp9,evc,evc.baseline,avs3,lcevc}` and `wai.audio.{mpegh,avs3,aac}`, each with one payload framing and a licence class whose basis is recorded in [`media-codec-registry.md`](media-codec-registry.md). `wai.audio.avs3` names AVS3 audio / Audio Vivid (GB/T 46271-2025). All are sink-supplied, none is a default, and `wai.video.lcevc` is companion-only. SPEC §7 classes AVC, HEVC, VVC and VP9 `StandardDefined` on the conformance clauses read, AV2 `StandardTolerance`, and EVC, AVS3, LCEVC, MPEG-H 3D Audio and AAC `StandardUnclassified`. The licence classes rest on the registry's recorded bases, not on this sweep. `glossary/codecs.json` is unchanged, so its grades still stand against §3.3 and §4 |
| G6 | **No WAI receipt says who can check the energy figure, and the base JWP receipt does not carry even the acquisition class that `energy-measurement.md` requires.** The precedents in §5.1 differ exactly on who can check: instrument-signed (OCMF), peer-reviewed but unsigned, log-registered (SCITT), TEE-attested with a derived trust level (the family's `sandbox-tee` `AttestTrust`), or producer-asserted. **The base JWP receipt** has no acquisition-class field; its group signature covers `total_joules_micro` and no class, and the per-object `joules_micro` is in neither the Merkle leaf nor the signature. `energy-measurement.md` §2 requires a class inside the signature for any non-zero figure, while its header says it adds "no new wire format". **The video receipt** signs its class. **The energy binding** carries its class in a C2PA companion assertion signed as part of whatever manifest carries it. **The OER receipt** carries an evidential grade and a ledger identity, and no class. 2030 §8 item 4 queues typed receipt classes. | `extensions/energy-measurement.md` header, §1, §2 and §5; `extensions/jwp-receipts.md` §2.1–§2.4; `joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs` (`ObjectReceipt`, `GroupReceipt::signing_payload`: no class); `wai-rs/src/energy_binding.rs`, `wai-rs/src/video_receipt.rs`; `extensions/oer-receipts.md` §2–§3, `wai-rs/src/oer.rs`; no reference to the family ladder under `wai/` (grep, as in G4) | **Partly closed at link 10 (PR #112); the rest open.** Every receipt within energy-measurement's scope that `wai-rs` and `wai-quantum` seal can carry the class (link 08, PR #109). jwp-receipts §2.4.3–§2.4.7 add a `claim` kind with typed classes. An `energy` claim (§2.4.5) restates a JWP object's figure beside its class, the class's evidence, a coverage statement and a grade; a verifier holding the object receipt rejects the pair if the figures differ, and one holding the OER receipt the claim names checks that grade and figure agree. `claim_receipt.rs` (feature `receipt_claims`) implements it, with vectors in `claim-conformance/`. Open, as energy-measurement §1 records: the JWP object receipt has no member of its own for the class; the group total has none, and nothing carries one for it; the `WAI_RECEIPT` property's class and evidence sit outside any signature; an OER/2 receipt has no member for the class. On who can check: a claim carries its signer's authority and no more (§2.4.4); key discovery and an authority class for who may sign which class are not done (CHANGELOG, v0.4.0 entry for typed receipt classes); no WAI receipt references `sandbox-tee`'s ladder or `efficiency/1`'s attestation (grep) *Status after the v0.4.0 change "Energy reports, labelled object leaves, and what the signatures cover":* the object's class rides in its labelled leaf, a group total can carry a signed class of its own, and a `WAI_RECEIPT` property's class counts only where a signed statement restates it; joule-moq verifies both labelled forms. Who can check remains open. |
| G6 | **No WAI receipt says who can check the energy figure, and the base JWP receipt does not carry even the acquisition class that `energy-measurement.md` requires.** The precedents in §5.1 differ exactly on who can check: instrument-signed (OCMF), peer-reviewed but unsigned, log-registered (SCITT), TEE-attested with a derived trust level (the family's `sandbox-tee` `AttestTrust`), or producer-asserted. **The base JWP receipt** has no acquisition-class field; its group signature covers `total_joules_micro` and no class, and the per-object `joules_micro` is in neither the Merkle leaf nor the signature. `energy-measurement.md` §2 requires a class inside the signature for any non-zero figure, while its header says it adds "no new wire format". **The video receipt** signs its class. **The energy binding** carries its class in a C2PA companion assertion signed as part of whatever manifest carries it. **The OER receipt** carries an evidential grade and a ledger identity, and no class. 2030 §8 item 4 queues typed receipt classes. | `extensions/energy-measurement.md` header, §1, §2 and §5; `extensions/jwp-receipts.md` §2.1–§2.4; `joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs` (`ObjectReceipt`, `GroupReceipt::signing_payload`: no class); `wai-rs/src/energy_binding.rs`, `wai-rs/src/video_receipt.rs`; `extensions/oer-receipts.md` §2–§3, `wai-rs/src/oer.rs`; no reference to the family ladder under `wai/` (grep, as in G4) | **Partly closed at link 10 (PR #112); the rest open.** Every receipt within energy-measurement's scope that `wai-rs` and `wai-quantum` seal can carry the class (link 08, PR #109). jwp-receipts §2.4.3–§2.4.7 add a `claim` kind with typed classes. An `energy` claim (§2.4.5) restates a JWP object's figure beside its class, the class's evidence, a coverage statement and a grade; a verifier holding the object receipt rejects the pair if the figures differ, and one holding the OER receipt the claim names checks that grade and figure agree. `claim_receipt.rs` (feature `receipt_claims`) implements it, with vectors in `claim-conformance/`. Open, as energy-measurement §1 records: the JWP object receipt has no member of its own for the class; the group total has none, and nothing carries one for it; the `WAI_RECEIPT` property's class and evidence sit outside any signature; an OER/2 receipt has no member for the class. On who can check: a claim carries its signer's authority and no more (§2.4.4); key discovery and an authority class for who may sign which class are not done (CHANGELOG, v0.4.0 entry for typed receipt classes); no WAI receipt references `sandbox-tee`'s ladder or `efficiency/1`'s attestation (grep). *Status after the v0.4.0 change "Energy reports, labelled object leaves, and what the signatures cover":* the object's class rides in its labelled leaf, a group total can carry a signed class of its own, and a `WAI_RECEIPT` property's class counts only where a signed statement restates it; joule-moq verifies both labelled forms. *Status after the v0.4.0 change "Key discovery, signing authority and module revocation":* key discovery and an authority class for who may sign which class are jwp-receipts §7 (`wai-rs` `key_log`, feature `key_discovery`; `signer-conformance/`); a hardware-class energy authority is confirmed only under a pinned anchor, and a verifier reports whether the key also holds `delivery` |
| G7 | **Explicit parameter transmission appears in `determinism-tiers.md` only as a generic route. Its first published cross-NPU instance (arXiv 2606.28027) and its cost are not recorded.** Also unrecorded: the mixed-precision entropy-synchronisation line (arXiv 2608.00483: FP16/FP32 across GPUs, no bit-exactness claim); the quantised learned-coding line (arXiv 2007.04684, 2111.09348); and IEEE 1857.11's use of *reproducibility cross-checks* rather than bit-exact decode (**R** ⚠), a reproducibility notion the tier vocabulary does not name. Both 2606.28027 and 2608.00483 target a synchronised entropy stage, so at most `entropy-consistency`, and 2608.00483 only over the GPUs it tested. On `main`, whether such a decode may back `replicate` depends on which of G10's conditions is read. | `extensions/determinism-tiers.md` §1–§3; `SPEC.md` §7 (read) | **Partly closed at link 02 (PR #103).** determinism-tiers §1 names arXiv 2606.28027 as the explicit-parameter instance and says the side information costs bitrate (no magnitude), and names a quantised decoder, arXiv 2312.11209, as the route to a reproducible reconstruction. Such a decode reaches `entropy-consistency` and does not back `replicate` (G10). Not in the tree at link 10 (grep): arXiv 2608.00483, the quantised line 2007.04684 / 2111.09348, and IEEE 1857.11's reproducibility cross-checks; the last is described in [`syfai-input.md`](syfai-input.md) §5, committed with this record |
| G8 | **Standards inputs not tracked in the tree:** SyfAI; 3GPP TR 26.942; DVB S100 and CM-EE; the beyond-VVC CfP; Gaussian Splat Coding Exploration 45; IEEE 1857.11 / P1857.11.1; UWA 042; India D2M; the JPEG AI core experiments on energy consumption and bit-exact reconstruction. | a case-insensitive, word-bounded `git grep -P` over tracked `*.md`, `*.rs`, `*.json`, `*.toml`, `*.yml`, `*.yaml`, `*.html`, `*.ts` and `*.js` files finds none of these designations outside this record. The one other hit for `uwa` is the Hausa word in `aac/aac-rs/crates/aac-core/src/translations_data.json` (`"ha":"uwa"`), which is not the organisation. MPEG's splat work *is* tracked, though not Exploration 45 by name: `media-codec-registry.md` and `codecs/mod.rs` register `wai.splat.sog` against "MPEG WG4" work in progress, `3d-neural-coding-landscape.md` records splat coding under standardisation, and 2030 §6.4 records an NNC amendment covering Gaussian splats (G11) | **Partly closed by the two notes committed with this record.** [`syfai-input.md`](syfai-input.md) tracks SyfAI, IEEE 1857.11 and UWA 042; [`energy-exposure-mapping.md`](energy-exposure-mapping.md) tracks TR 26.942, DVB S100 and CM-EE. The same grep over the tree at link 10 finds the beyond-VVC CfP, Exploration 45, India D2M and the JPEG AI core experiments in no document but this record. Those four stay open |
| G9 | **WAI's texts and code class `wai.image.jpegai` in ways that contradict one another, and SPEC contradicts itself on it.** SPEC §5, the capability's registration (`SPEC.md`:197), describes the decode as "integer entropy front end (mANS) + integer dequant/value path + fixed-order synthesis and post-filters" and declares "**decode-equivalence**: one pinned reconstruction … reproducible on every machine (a portable decoded-pixel hash)". `reconstruction-binding.md` §1 (:30–31) says the decode to RGB is "byte-identical on every machine", and §2 (:72) sets the binding's criterion at `decode-equivalence`; `determinism-tiers.md` §2–§3 list the capability there. SPEC §7 lets a neural capability back `replicate` only "with no float operation anywhere" (:445–448). `capability_replicate_class` returns `StandardDefined` for it through the default arm, and SPEC §7 defines that class as "reconstruction is fixed by the named codec/standard … the neural float hazard does not apply"; neither half holds for JPEG AI (§2.2; its decode runs the model). The strict `replicate` check reads only the envelope's prior (`neural/mod.rs`:282–292). **Loose ends:** (i) `jpegai_synthesis.rs` and `jpegai_mans.rs` say they back `wai.neural.jpeg_ai`, a string registered nowhere; (ii) the decode has f32 steps between its integer stages — its dequantisation among them, although SPEC §5 calls it integer; (iii) the byte-for-byte claim is made normatively, in SPEC §5 and `reconstruction-binding.md` §1, and rests on an `exp()` from the platform maths library and on no measurement on a second platform; (iv) the reference sink's dispatch (`neural/mod.rs`:205–216) uses the exact decoder only when the integer weight bundle is present, and otherwise falls back to a float ONNX Runtime decode. | `SPEC.md` §5 (:197) and §7 (:445–448, the `StandardDefined` bullet at :476–478, :492–495); `extensions/reconstruction-binding.md` §1 (:21–35) and §2 (:72); `extensions/determinism-tiers.md` §2–§3; `wai-rs/src/codecs/mod.rs` (the default arm and the test asserting `StandardDefined`); `wai-rs/src/jpegai_decode.rs`, `jpegai_synthesis.rs`, `jpegai_mans.rs`, `jpegai_pipeline.rs`; `wai-rs/src/neural/mod.rs`:205–216, :282–292; `git grep -n decode-equivalence` over `wai/` | **Closed at link 02 (PR #103), with the maths library removed at link 03 (PR #104).** SPEC §5's row registers `entropy-consistency` (registry class `NeuralEntropyExact`), describes dequantisation, latent refinement, post-filters and colour conversion as IEEE-754 float with a fixed evaluation order, and says "**Downgraded in v0.4.0:** this row previously claimed decode-equivalence". `reconstruction-binding.md` §1–§2 make the pinned reconstruction a property of the exact decoder, reproducible under that decoder's build and floating-point conditions. SPEC §7 states the new class and the reason. Loose ends: (i) fixed — the module docs name `wai.image.jpegai`, and `wai.neural.jpeg_ai` appears nowhere else in the tree (grep); (ii) the float steps remain and are now stated as float; (iii) link 03 builds `exp` and rounding from IEEE-754 basic operations (`float_repro`), guards the decode path with clippy's `disallowed_methods` and a linked-symbol check (`tests/jpegai_decode_symbols.rs`), and pins nine hashes for one codestream; determinism-tiers §6 records them identical on macOS arm64, wasm32-wasip2, Linux arm64 and Linux x86_64 under user-mode emulation, and not measured on Windows, macOS x86_64 or x86_64 hardware; (iv) the float fallback remains, and SPEC §5, determinism-tiers §5 and reconstruction-binding §1 now state it, and that the result does not record which path ran. Still true: a `norm.json` with a missing or mistyped key panics in the loaders instead of falling back, as the `try_jpegai_exact` doc comment says |
@ -736,8 +736,10 @@ review.
total can carry its own class under a labelled group payload that also signs
the parent link (§2.3, §2.4.1); a `WAI_RECEIPT` property's class reads as
signed only where it equals the leaf's label or an `energy` claim
(moq-streaming-format §5). Open: key discovery for claim signers, an OER/2
receipt's class, and who, other than the signer, stands behind a figure.
(moq-streaming-format §5).
*Status after the v0.4.0 change "Key discovery, signing authority and module
revocation":* key discovery for claim signers is jwp-receipts §7. Open: an
OER/2 receipt's class, and who, other than the signer, stands behind a figure.
- **G7's other lines** (arXiv 2608.00483, the quantised learned-coding line,
IEEE 1857.11's reproducibility cross-checks) and **G8's other inputs** (the
beyond-VVC CfP, Exploration 45, India D2M, the JPEG AI core experiments) are
@ -775,6 +777,7 @@ when that document is next revised.
| §4.4 | `draft-ietf-moq-transport` rev 18, approaching WG Last Call | now `-21` (2026-09-08), state WG Document; transport and C4M to IESG targeted Dec 2026 | P✓ |
| §5.1 | `draft-ietf-avtcore-rtcp-green-metadata-16`, IESG Evaluation | now `-17` (2026-09-09); IESG state **RFC Ed Queue** | P✓ |
| §5.1 | 23001-11 4th ed. at CD (N01742) | dated on the part page: WD 2026-03-02 (MPEG 153), CD text 2026-09-21 (MPEG 155). The CD text is WG 3 N1864 ([`energy-exposure-mapping.md`](energy-exposure-mapping.md) §1) | P✓ / tree |
| §4.1 (grid-carbon paragraph) | "`jwp-receipts.md` §7 states that conversion of joules to currency, carbon or grid mix is performed by downstream aggregators" | That statement is jwp-receipts §6.6, *Out of scope* ("Pricing semantics for joules"). When the landscape was written jwp-receipts had no §7 in the tree; its §7 is now *Key discovery and signing authority*, so the reference names the wrong section | tree |
| §9 item 1 | three legs (23001-11 4th ed., Part 6 WD, a GREEN WG document); "check at each MPEG meeting and at each GREEN WG revision" | GREEN leg checked and not triggered; Part 6 leg not applicable yet; 23001-11 leg not triggered at CD on energy-exposure-mapping §4's search (§6.1) | P✓ / tree |
---

View file

@ -422,6 +422,13 @@ claim checkable, not true (§2.5 there). Still open: a rule on which key signs
the publisher's, or one held by the metering component — and a mapping onto
GREEN's accuracy identities.
*Status after the v0.4.0 change "Key discovery, signing authority and module
revocation":* jwp-receipts §7.5 recommends (SHOULD) that a hardware-class figure be
signed by a key held by the component that signs the reading and distinct from the
delivery key, has a verifier report whether the key also holds `delivery`, and
confirms a hardware-class energy authority only under a pinned anchor. The mapping
onto GREEN's accuracy identities is still open.
**L3: Declared tolerances.** energy-measurement §2.1 requires an implementation
to state how far its class lets the figure be leaned on. On `main` no tolerance
figure appears in the extensions or in `energy_binding.rs` / `video_receipt.rs`.
@ -454,6 +461,14 @@ its sections end at §6.8. OCMF leaves this out of band too: public keys reach t
verifier "by means other than this protocol (out-of-band) … e.g. via a central
register". WAI names no register or other mechanism.
*Status after the v0.4.0 change "Key discovery, signing authority and module
revocation":* jwp-receipts §7 now exists, and transparency §6 points to it. A
signer names its web origin in `signer_id`; the origin publishes a signed, chained
key log at `/.well-known/wai-keys`; a verifier anchors the origin by pinning or by
one recorded fetch and confirms offline. The anchor reaches the verifier out of
band or on first use, and the hardware-class energy authorities are confirmed only
under a pinned one.
**L8: Not re-measurable, by construction.** A third party reproduces the output
and the work unit, never the joules (energy-measurement §5). The same holds for
every field that describes the measurement: the acquisition class and its

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

@ -0,0 +1,3 @@
# The pinned corpus is compared byte-for-byte with what the generator writes (LF), on
# every platform of the CI matrix. Keep a CRLF checkout from turning that into a failure.
* text eol=lf

View file

@ -0,0 +1,79 @@
# signer-conformance
Conformance for [key discovery, signing authority and module authorisation](../extensions/jwp-receipts.md)
(§7): the forms of a `signer_id`, an origin's key log, anchoring, the confirmation a
verifier reports beside a receipt's or claim's verdict, and the checks a sink runs
before it runs a decoder or reconstruct module.
Each vector declares **one verdict**, set from the text in the generator and never
copied from a verifier. A verifier that reaches another verdict — another level, step,
reason, head or anchor path — does not conform.
## Files
| file | what it is |
|---|---|
| `vectors.json` | The corpus. Regenerate with `wai_signer_vectors gen .`; `wai_signer_vectors verify .` checks the canonical fixture, a byte-identical regeneration and every verdict. |
| `signer_verify.py` | A second verifier, written from §7, that shares no code with `wai-rs`. It takes from `../claim-conformance/claim_verify.py` only what §7 reuses of §3.1: the JSON reading, JCS, BLAKE3, the strict Ed25519 rule, the RFC 3339 syntax and claim verification. |
## Procedures
**`confirm`** (§7.4) runs on an item and reports `confirmed` with the origin, key-id,
head `revision_hash` and anchor path — and, where they apply, `moved_to`,
`retired_until`, `untimed=true`, `expected=` and `also_delivers=` — or `unconfirmed` or
`refused` at a §7.4 step (`1`–`7`, or `6a`) with a reason of §7.7. The item is either
`receipt`, a claim's wire form, which is first verified under §3.1 (a claim that fails
is `reject step=<n> reason=<label>`, and is never confirmed), or `context.item`: the
`kind` (`group` or `family`), `signer_pubkey`, `signer_id`, `classes` (for a group,
the acquisition classes its objects' labelled leaves and its labelled total carry; for a
family receipt, the acquisition classes its figures' labels carry) and its
signed `time` (or `null`). The authorities an item needs are derived from it by §7.5's
table. The context also holds `revisions` (revision texts the verifier holds, in any
order; one that fails rule 1 is not held), `anchors` (`origin`, `revision`, `path`,
`acquired_at`, and `pinned_revision` for a pinned record, which a forward move keeps)
and, optionally, `expected_origin` (`origin` and `source`, `relying-party` or
`subject`).
**`anchor`** (§7.3) runs on a key-log document (`log`) retrieved for `origin` by `path`
(`pinned` or `fetched`, with `acquired_at`), and optionally `current`: an anchor already
held, with the revisions held with it. It reports `anchored revision=<hash>
anchor=<path>` — with `pinned_revision=<hash>` where a pinned record moved forward —
`not_anchored reason=<label>` or `replaced reason=key_log_replaced`.
**`module`** (§7.9) runs on a module's bytes (`module`, hex), whether they are a module
or a pinned parameter set (`object`), a `grant`, the `capability` being decoded, the
sink's `module_origins` (each an `origin` and `source`), the `revisions`, `anchors` and
`statuses` the sink holds, its trusted clock (`now`, or `null` for none) and whether
the envelope leaves a `fallback`. It reports `run` with the granting key's origin,
key-id, head, anchor path, where its module origin came from (`expected=`) and
`kill_latency_s`, or `fallback` or `inert` at a §7.9 check with its reason; a digest
another anchored origin revokes adds `revoked_by=<origin>`. A parameter set that is not
held adds the refusal SPEC §3.1's check 5 reports: `refusal=prior_revoked` where its
digest is revoked, `refusal=prior_not_authorised` for any other check. A parameter set
whose digest the head of an anchor the sink holds revokes is reported at check 5 with
`revoked_by=<that origin>` and `prior_revoked` whichever check its grant failed first,
unless the grant reached check 5 itself. For a parameter set `grant` may be `null`, a
sink that holds no grant: the set is then refused as revoked where its digest is, and
otherwise reported as `no_grant refusal=prior_not_authorised`.
## Keys, origins and times
Everything is synthetic. Keys are Ed25519 from published one-byte seeds and attest
nothing: 0x61 the root in use, 0x62 the key it commits to, 0x68 the key that one commits
to, 0x63 and 0x66 delivery keys (0x66 also meters), 0x64 and 0x69 metering keys, 0x65
and 0x6e lawful-basis keys, 0x6b and 0x6c module-granting keys, 0x60 a key added later,
0x67, 0x6a and 0x6f keys an attacker holds, 0x70, 0x71 and 0x72 the root, committed key
and module-granting key of a second origin, 0x73 and 0x74 a measurement key and an
operating-point key, and 0x75 a key that signs family receipts with a ModelBased figure. jwp-receipts §7.8 says such a key must
never be listed in a real key log: every anchored verdict here carries
`note=published_test_key`, which is what a verifier reports on finding one. Origins are
reserved example names (`publisher.example`, with and without port 8443,
`service.example`, `other.example`). Times are in September 2026 and invented.
`signer_verify.py` needs Python 3 with the `cryptography` package. CI runs it on the
linux runner (`independent-verifiers` in `wai-conformance.yml`), and requires its
`verdicts` output to equal `wai_signer_vectors verdicts` line for line. By hand:
python3 signer_verify.py # every vector, and the fixture against wai-rs
python3 signer_verify.py fixture # the fixture's bytes, signature and hash
python3 signer_verify.py verdicts vectors.json

View file

@ -0,0 +1,754 @@
#!/usr/bin/env python3
"""Second verifier for signer-conformance/ — key discovery, signing authority and module
authorisation (extensions/jwp-receipts.md §7).
Written from the extension text. It shares no code with wai-rs (`crate::key_log`,
`crate::signer_id`), which generates the corpus. It takes from the claim corpus's own
second verifier (`../claim-conformance/claim_verify.py`) what §7 reuses from §3.1: the
JSON reading of step 1, the canonical form (JCS), BLAKE3 written from its specification,
the strict Ed25519 rule of step 3, the RFC 3339 syntax, and claim verification itself.
Everything §7 adds — the signer_id grammar and base58btc, the key log's rules, the head
walk, anchoring, confirmation, the time comparison and §7.9's module checks — is written
below.
python3 signer_verify.py [vectors.json] verify every vector and the canonical fixture
python3 signer_verify.py fixture print the fixture's canonical bytes,
signature and revision hash
python3 signer_verify.py verdicts <file> print `name<TAB>verdict` for every vector,
ignoring any declared verdict
Exit status 0 only if every vector reaches its declared verdict and the fixture this
script computes equals the literals pinned in wai-rs.
"""
import datetime, hashlib, json, os, re, sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
import claim_verify as CV # noqa: E402
from cryptography.hazmat.primitives import serialization # noqa: E402
from cryptography.hazmat.primitives.asymmetric import ed25519 # noqa: E402
D_SET, D_LINK, D_NEXT = b"wai:key-set\x01", b"wai:key-set-link\x01", b"wai:key-set-next\x01"
D_GRANT, D_STATUS = b"wai:module-grant\x01", b"wai:key-status\x01"
GRANT_CEILING, STATUS_CEILING = 86400, 3600
HARDWARE = {"CalibratedInstrument", "OnChipCounter", "HwShunt"}
PINNED_ONLY = {"energy/CalibratedInstrument", "energy/OnChipCounter", "energy/HwShunt",
"lawful-basis/declaration", "lawful-basis/decision"}
REVISION = ["kind", "origin", "seq", "prev", "issued_at", "root", "next_root", "keys", "revoked_modules",
"grant_max_s", "status_max_age_s", "sig"]
ENTRY = ["id", "key", "may_sign", "status", "until", "compromised_since"]
GRANT = ["kind", "origin", "module", "form", "capabilities", "not_before", "not_after", "signer_pubkey",
"signer_id", "sig"]
STATUS = ["kind", "origin", "head", "seq", "issued_at", "sig"]
RANK = {"active": 0, "retired": 1, "revoked": 2}
class Stop(Exception):
"""A reason: a log not anchored, a revision not held, an item not confirmed."""
def __init__(self, reason, level=None, step=None):
super().__init__(reason)
self.reason, self.level, self.step = reason, level, step
# ------------------------------------------------------------------ §7.1 signer_id
B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
def b58encode(b: bytes) -> str:
n = int.from_bytes(b, "big")
out = ""
while n:
n, r = divmod(n, 58)
out = B58[r] + out
return "1" * (len(b) - len(b.lstrip(b"\0"))) + out
def b58decode(s: str):
if any(c not in B58 for c in s):
return None
n = 0
for c in s:
n = n * 58 + B58.index(c)
body = n.to_bytes((n.bit_length() + 7) // 8, "big") if n else b""
return b"\0" * (len(s) - len(s.lstrip("1"))) + body
LABEL = re.compile(r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\Z")
def host_ok(h):
labels = h.split(".")
return (len(h) <= 253 and len(labels) >= 2 and all(LABEL.match(x) for x in labels)
and not labels[-1].isdigit())
def port_ok(p):
return re.fullmatch(r"[1-9][0-9]{0,4}", p) is not None and 1 <= int(p) <= 65535 and int(p) != 443
def key_id_ok(k):
return re.fullmatch(r"[A-Za-z0-9._-]{1,64}", k) is not None
def did_key(pk: bytes) -> str:
return "did:key:z" + b58encode(b"\xed\x01" + pk)
def classify(sid):
"""('origin', origin, key_id) | ('didkey', key) | ('label',)."""
if sid.startswith("did:web:"):
rest = sid[len("did:web:"):]
if "#" in rest:
auth, kid = rest.split("#", 1)
host, port = (auth.split("%3A", 1) + [None])[:2]
if key_id_ok(kid) and host_ok(host) and (port is None or port_ok(port)):
return ("origin", "https://" + host + (":" + port if port else ""), kid)
return ("label",)
# The base58btc text of 0xed 0x01 and a 32-byte key is 47 characters; a longer one is
# not its spelling and is not decoded.
if sid.startswith("did:key:z") and len(sid) - len("did:key:z") <= 47:
b = b58decode(sid[len("did:key:z"):])
if b is not None and len(b) == 34 and b[:2] == b"\xed\x01" and did_key(b[2:]) == sid:
return ("didkey", b[2:])
return ("label",)
def origin_ok(o):
m = re.fullmatch(r"https://([^:/]+)(?::([0-9]+))?", o)
return m is not None and host_ok(m.group(1)) and (m.group(2) is None or port_ok(m.group(2)))
# ------------------------------------------------------------------ times
LOG_TIME = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-5][0-9]Z\Z")
def is_log_time(t):
return isinstance(t, str) and LOG_TIME.match(t) is not None and CV.is_rfc3339(t)
def secs(t):
d = datetime.datetime(int(t[0:4]), int(t[5:7]), int(t[8:10]), int(t[11:13]), int(t[14:16]), int(t[17:19]),
tzinfo=datetime.timezone.utc)
return int(d.timestamp())
def instant(t):
"""(minute in UTC, second as written, fraction digits without trailing zeros)."""
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}))", t)
day = datetime.date(int(m.group(1)), int(m.group(2)), int(m.group(3))).toordinal()
minute = day * 1440 + int(m.group(4)) * 60 + int(m.group(5))
if m.group(8):
off = int(m.group(9)) * 60 + int(m.group(10))
minute -= off if m.group(8) == "+" else -off
return minute, int(m.group(6)), (m.group(7) or "").rstrip("0")
def later_than(t, until):
if not (isinstance(t, str) and CV.is_rfc3339(t)):
return True
a, b = instant(t), instant(until)
if a[:2] != b[:2]:
return a[:2] > b[:2]
w = max(len(a[2]), len(b[2]))
return a[2].ljust(w, "0") > b[2].ljust(w, "0")
# ------------------------------------------------------------------ signatures and hashes
def hexb(v, n):
if isinstance(v, str) and len(v) == 2 * n and re.fullmatch(r"[0-9a-f]*", v):
return bytes.fromhex(v)
return None
def verifies(pk: bytes, sig: bytes, msg: bytes) -> bool:
try:
CV.check_signature(pk, sig, msg)
return True
except CV.Reject:
return False
def key_accepted(pk: bytes) -> bool:
a = CV.point_decode(pk)
return a is not None and not CV.small_order(a)
def commitment(k: bytes) -> bytes:
return CV.blake3(D_NEXT + k)
def without_sig(d):
return {k: v for k, v in d.items() if k != "sig"}
def rev_hash(r) -> str:
return CV.blake3(D_LINK + CV.jcs(r).encode()).hex()
def module_digest(b: bytes) -> str:
return hashlib.sha256(b).hexdigest()
# ------------------------------------------------------------------ §7.2 the key log
def authority_ok(a):
return isinstance(a, str) and 1 <= len(a) <= 128 and re.fullmatch(r"[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*", a)
def exact(d, members):
return isinstance(d, dict) and set(d) == set(members)
def check_entry(e):
ok = (exact(e, ENTRY) and isinstance(e["id"], str) and key_id_ok(e["id"]) and hexb(e["key"], 32) is not None
and key_accepted(bytes.fromhex(e["key"])) and isinstance(e["may_sign"], list) and e["may_sign"]
and all(authority_ok(a) for a in e["may_sign"]) and len(set(e["may_sign"])) == len(e["may_sign"])
and e["status"] in RANK and all(e[t] is None or is_log_time(e[t]) for t in ("until", "compromised_since")))
if not ok:
return False
st, u, c = e["status"], e["until"], e["compromised_since"]
return {"active": u is None and c is None, "retired": u is not None and c is None,
"revoked": c is not None}[st]
def check_revision(r):
"""Rule 1 on a parsed value."""
bad = Stop("key_log_malformed")
if not exact(r, REVISION) or r["kind"] != "wai-key-set" or not isinstance(r["origin"], str):
raise bad
if not CV.is_int(r["seq"]) or not (r["prev"] is None or hexb(r["prev"], 32) is not None):
raise bad
if (r["seq"] == 0) != (r["prev"] is None) or not is_log_time(r["issued_at"]):
raise bad
root, nxt = hexb(r["root"], 32), hexb(r["next_root"], 32)
if root is None or nxt is None or nxt == commitment(root) or hexb(r["sig"], 64) is None:
raise bad
if not (CV.is_int(r["grant_max_s"]) and 1 <= r["grant_max_s"] <= GRANT_CEILING):
raise bad
if not (CV.is_int(r["status_max_age_s"]) and 1 <= r["status_max_age_s"] <= STATUS_CEILING):
raise bad
if not isinstance(r["keys"], list) or not all(check_entry(e) for e in r["keys"]):
raise bad
ids, keys = [e["id"] for e in r["keys"]], [e["key"] for e in r["keys"]]
if len(set(ids)) != len(ids) or len(set(keys)) != len(keys):
raise bad
if any(bytes.fromhex(k) == root or commitment(bytes.fromhex(k)) == nxt for k in keys):
raise bad
rm = r["revoked_modules"]
if not isinstance(rm, list) or not all(hexb(d, 32) is not None for d in rm) or len(set(rm)) != len(rm):
raise bad
return r
def parse_revision(text):
try:
return check_revision(CV.parse_strict(text))
except ValueError:
raise Stop("key_log_malformed")
def check_held(r, origin):
"""Rules 2-3."""
if r["origin"] != origin:
raise Stop("origin_mismatch")
if not verifies(bytes.fromhex(r["root"]), bytes.fromhex(r["sig"]), D_SET + CV.jcs(without_sig(r)).encode()):
raise Stop("key_log_signature_invalid")
def is_held(r, origin):
try:
check_held(r, origin)
return True
except Stop:
return False
def rotation_of(c, p):
return c["root"] != p["root"] and commitment(bytes.fromhex(c["root"])).hex() == p["next_root"]
def authorised(c, p):
return c["root"] == p["root"] or rotation_of(c, p)
def check_link(p, c):
"""Rules 4-6."""
if c["prev"] != rev_hash(p) or c["seq"] != p["seq"] + 1 or c["issued_at"] < p["issued_at"]:
raise Stop("key_log_chain_break")
if not ((c["root"] == p["root"] and c["next_root"] == p["next_root"]) or rotation_of(c, p)):
raise Stop("key_log_root_not_committed")
pk, ck = p["keys"], c["keys"]
if len(ck) < len(pk) or c["revoked_modules"][:len(p["revoked_modules"])] != p["revoked_modules"]:
raise Stop("key_log_rewrites_history")
for a, b in zip(pk, ck):
same = a["id"] == b["id"] and a["key"] == b["key"] and a["may_sign"] == b["may_sign"]
if not same or RANK[b["status"]] < RANK[a["status"]]:
raise Stop("key_log_rewrites_history")
for t in ("until", "compromised_since"):
if a[t] is not None and a[t] != b[t]:
raise Stop("key_log_rewrites_history")
def read_log(origin, text):
try:
doc = CV.parse_strict(text)
except ValueError:
raise Stop("key_log_malformed")
if not exact(doc, ["kind", "revisions"]) or doc["kind"] != "wai-key-log":
raise Stop("key_log_malformed")
revs = doc["revisions"]
if not isinstance(revs, list) or not revs:
raise Stop("key_log_malformed")
for r in revs:
check_revision(r)
check_held(r, origin)
if revs[0]["seq"] != 0:
raise Stop("key_log_chain_break")
for p, c in zip(revs, revs[1:]):
check_link(p, c)
return revs
# ------------------------------------------------------------------ §7.4 step 3, the head
def head(held, anchor):
"""Returns (head_hash, head_revision, moved_to or None)."""
by = {rev_hash(r): r for r in held}
if anchor not in by:
raise Stop("origin_not_anchored")
def kids(h):
p = by[h]
return [(k, c) for k, c in sorted(by.items()) if c["prev"] == h and authorised(c, p)]
line = [anchor]
while by[line[-1]]["prev"] in by and by[line[-1]]["prev"] not in line:
line.append(by[line[-1]]["prev"])
line.reverse()
start, moved = anchor, None
for t, n in zip(line, line[1:]):
if rotation_of(by[n], by[t]):
continue
rots = [(k, c) for k, c in kids(t) if k != n and rotation_of(c, by[t])]
if len(rots) == 1:
check_link(by[t], rots[0][1])
start = moved = rots[0][0]
break
if len(rots) > 1:
raise Stop("key_log_fork")
cur = start
while True:
ks = kids(cur)
if not ks:
return cur, by[cur], moved
if len(ks) > 1:
ks = [kc for kc in ks if rotation_of(kc[1], by[cur])]
if len(ks) != 1:
raise Stop("key_log_fork")
check_link(by[cur], ks[0][1])
cur = ks[0][0]
# ------------------------------------------------------------------ §7.3 anchoring
_PUBLISHED = None
def is_published(k: bytes) -> bool:
"""Whether k is the public key of a one-byte seed, or the commitment to one."""
global _PUBLISHED
if _PUBLISHED is None:
keys = [ed25519.Ed25519PrivateKey.from_private_bytes(bytes([b]) * 32).public_key().public_bytes(
serialization.Encoding.Raw, serialization.PublicFormat.Raw) for b in range(256)]
_PUBLISHED = set(keys) | {commitment(pk) for pk in keys}
return k in _PUBLISHED
def lists_published(r):
return any(is_published(bytes.fromhex(x)) for x in [r["root"], r["next_root"]] + [e["key"] for e in r["keys"]])
def anchor_log(origin, text, path, current):
"""('anchored', hash, path) | ('not_anchored', reason) | ('replaced',)."""
try:
revs = read_log(origin, text)
except Stop as s:
return ("not_anchored", s.reason)
hs = [rev_hash(r) for r in revs]
if current is None or path == "pinned":
return ("anchored", hs[-1], path, hs[-1] if path == "pinned" else None)
cur, held = current
if cur["revision"] in hs:
# Forward: a pinned record stays pinned and keeps the revision that was pinned.
if cur["path"] == "pinned" and cur.get("pinned_revision") is not None:
return ("anchored", hs[-1], "pinned", cur["pinned_revision"])
return ("anchored", hs[-1], path, None)
hb = {rev_hash(r): r for r in held if is_held(r, origin)}
trusted = [cur["revision"]] # newest first
while trusted[-1] in hb and hb[trusted[-1]]["prev"] in hb and hb[trusted[-1]]["prev"] not in trusted:
trusted.append(hb[trusted[-1]]["prev"])
if hs[-1] in trusted:
return ("not_anchored", "key_log_rolled_back")
shared = [i for i, x in enumerate(hs) if x in trusted]
if shared:
i = shared[-1]
if i + 1 < len(revs) and rotation_of(revs[i + 1], revs[i]):
pos = trusted.index(hs[i])
child = hb.get(trusted[pos - 1]) if pos > 0 else None
if child is None or not rotation_of(child, revs[i]):
return ("anchored", hs[-1], path, None)
return ("replaced",)
# ------------------------------------------------------------------ §7.4-§7.5 confirmation
def claim_authorities(c):
body = c["body"]
prov = body.get("energy_provenance") if isinstance(body, dict) else None
if c["class"] == "energy":
own = "energy/" + prov
elif c["class"] == "lawful-basis":
own = "lawful-basis/" + body["record"]
else:
own = c["class"]
out = [own]
if isinstance(prov, str) and "energy/" + prov not in out:
out.append("energy/" + prov)
return out
def item_authorities(kind, classes):
if kind == "group":
# delivery, and energy/<class> for each class the group's labelled leaves and
# its labelled total carry (section 7.5).
out = ["delivery"]
for c in classes:
if "energy/" + c not in out:
out.append("energy/" + c)
return out
# receipt, and energy/<class> for each class the receipt's figures carry.
out = ["receipt"]
for c in classes:
if "energy/" + c not in out:
out.append("energy/" + c)
return out
def confirm(pubkey, sid, authorities, time, revisions, anchors, expected):
"""`expected` is None or a list of {origin, source}; the origin must be in a list given."""
form = classify(sid)
if form[0] == "didkey":
if form[1] != pubkey:
return "refused step=1 reason=signer_id_names_another_key"
return "unconfirmed step=1 reason=signer_id_not_origin_bound"
if form[0] == "label":
return "unconfirmed step=1 reason=signer_id_not_origin_bound"
_, origin, kid = form
held = [r for r in revisions if is_held(r, origin)]
anc = next((a for a in anchors if a["origin"] == origin), None)
if anc is None or anc["revision"] not in [rev_hash(r) for r in held]:
return "unconfirmed step=2 reason=origin_not_anchored"
try:
hh, hr, moved = head(held, anc["revision"])
except Stop as s:
return "unconfirmed step=3 reason=" + s.reason
e = next((x for x in hr["keys"] if x["id"] == kid), None)
if e is None:
return "refused step=4 reason=key_not_listed"
if bytes.fromhex(e["key"]) != pubkey:
return "refused step=4 reason=key_mismatch"
if e["status"] == "revoked":
return "refused step=5 reason=key_revoked"
if e["status"] == "retired" and time is not None and later_than(time, e["until"]):
return "refused step=5 reason=key_retired"
if not all(a in e["may_sign"] for a in authorities):
return "refused step=6 reason=authority_not_granted"
pinned = anc["path"] == "pinned" and anc.get("pinned_revision") is not None and moved is None
if any(a in PINNED_ONLY for a in authorities) and not pinned:
return "unconfirmed step=6a reason=anchor_not_pinned"
match = None
if expected is not None:
match = next((x for x in expected if x["origin"] == origin), None)
if match is None:
return "refused step=7 reason=origin_not_expected"
v = f"confirmed origin={origin} key_id={kid} head={hh} anchor={anc['path']}"
if moved:
v += " moved_to=" + moved
if e["status"] == "retired":
v += " retired_until=" + e["until"]
if time is None:
v += " untimed=true"
if match is not None:
v += " expected=" + match["source"]
if any(a.startswith("energy/") and a[len("energy/"):] in HARDWARE for a in authorities):
v += " also_delivers=" + ("true" if "delivery" in e["may_sign"] else "false")
return v
# ------------------------------------------------------------------ §7.9 modules
def parse_grant(text):
try:
g = CV.parse_strict(text)
except ValueError:
return None
if not exact(g, GRANT) or g["kind"] != "wai-module-grant":
return None
caps = g["capabilities"]
ok = (isinstance(g["origin"], str) and origin_ok(g["origin"]) and hexb(g["module"], 32) is not None
and g["form"] in ("decoder", "reconstruct") and isinstance(caps, list) and caps
and all(isinstance(c, str) and CV.is_token(c) for c in caps) and len(set(caps)) == len(caps)
and is_log_time(g["not_before"]) and is_log_time(g["not_after"]) and g["not_before"] <= g["not_after"]
and hexb(g["signer_pubkey"], 32) is not None and isinstance(g["signer_id"], str)
and classify(g["signer_id"])[:2] == ("origin", g["origin"])
and hexb(g["sig"], 64) is not None)
if not ok or not verifies(bytes.fromhex(g["signer_pubkey"]), bytes.fromhex(g["sig"]),
D_GRANT + CV.jcs(without_sig(g)).encode()):
return None
return g
def parse_status(text):
try:
s = CV.parse_strict(text)
except ValueError:
return None
if (not exact(s, STATUS) or s["kind"] != "wai-key-status" or not isinstance(s["origin"], str)
or hexb(s["head"], 32) is None or not CV.is_int(s["seq"]) or not is_log_time(s["issued_at"])
or hexb(s["sig"], 64) is None):
return None
return s
def revoked_by(digest, revisions, anchors):
"""The origin of the first anchor whose head (or, where none can be reached, whose
anchor revision) lists digest in revoked_modules, or None (7.9 check 5)."""
for a in anchors:
ah = [r for r in revisions if is_held(r, a["origin"])]
try:
lst = head(ah, a["revision"])[1]["revoked_modules"]
except Stop:
ar = next((r for r in ah if rev_hash(r) == a["revision"]), None)
lst = ar["revoked_modules"] if ar is not None else []
if digest in lst:
return a["origin"]
return None
def module(c):
v = module_checks(c)
if c["object"] != "parameter-set" or v.startswith("run") or "reason=module_revoked" in v:
return v
# SPEC 3.1 check 5: a pinned set whose digest a held anchor's head revokes is refused
# as revoked, whichever check a grant failed first, and with no grant at all.
o = revoked_by(module_digest(bytes.fromhex(c["module"])), held_revisions(c["revisions"]), c["anchors"])
if o is not None:
fb = "fallback" if c["fallback"] else "inert"
return f"{fb} step=5 reason=module_revoked revoked_by={o} refusal=prior_revoked"
return v
def module_checks(c):
fb = "fallback" if c["fallback"] else "inert"
if c["grant"] is None and c["object"] == "parameter-set":
return f"{fb} no_grant refusal=prior_not_authorised"
def no(step, reason, revoked_by=None):
v = f"{fb} step={step} reason={reason}"
if revoked_by is not None:
v += " revoked_by=" + revoked_by
if c["object"] == "parameter-set":
# SPEC 3.1 check 5: a revoked digest, or any other failure of 7.9.
v += " refusal=" + ("prior_revoked" if reason == "module_revoked" else "prior_not_authorised")
return v
g = parse_grant(c["grant"])
if g is None:
return no(1, "module_grant_invalid")
if module_digest(bytes.fromhex(c["module"])) != g["module"]:
return no(2, "module_digest_mismatch")
revisions = held_revisions(c["revisions"])
v = confirm(bytes.fromhex(g["signer_pubkey"]), g["signer_id"], ["module/" + g["form"]], g["not_before"],
revisions, c["anchors"], c["module_origins"])
if not v.startswith("confirmed"):
return no(3, v.split("reason=")[1])
fields = dict(x.split("=", 1) for x in v.split()[1:])
origin, hh = fields["origin"], fields["head"]
held = [r for r in revisions if is_held(r, origin)]
hr = next(r for r in held if rev_hash(r) == hh)
now = c["now"]
if not is_log_time(now):
return no(4, "module_status_stale")
# The head and its held ancestors: a status naming anything else is not held.
by = {rev_hash(r): r for r in held}
line = [hh]
while by[line[-1]]["prev"] in by and by[line[-1]]["prev"] not in line:
line.append(by[line[-1]]["prev"])
valid = []
for t in c["statuses"]:
s = parse_status(t)
if s is None or s["origin"] != origin or s["head"] not in line:
continue
for r in held:
if (rev_hash(r) == s["head"] and r["seq"] == s["seq"]
and verifies(bytes.fromhex(r["root"]), bytes.fromhex(s["sig"]),
D_STATUS + CV.jcs(without_sig(s)).encode())):
valid.append(s)
break
current = [s["issued_at"] for s in valid if s["head"] == hh]
if not current:
return no(4, "module_status_not_descended" if valid else "module_status_stale")
age = secs(now) - secs(max(current))
if age < 0 or age > hr["status_max_age_s"]:
return no(4, "module_status_stale")
if g["module"] in hr["revoked_modules"]:
return no(5, "module_revoked")
o = revoked_by(g["module"], revisions, c["anchors"])
if o is not None:
return no(5, "module_revoked", o)
if now < g["not_before"]:
return no(6, "module_grant_not_yet_valid")
if now > g["not_after"]:
return no(6, "module_grant_expired")
if secs(g["not_after"]) - secs(g["not_before"]) > hr["grant_max_s"]:
return no(7, "module_grant_too_long")
if c["capability"] not in g["capabilities"]:
return no(8, "module_capability_not_granted")
return (f"run origin={origin} key_id={fields['key_id']} head={hh} anchor={fields['anchor']} "
f"expected={fields['expected']} kill_latency_s={hr['grant_max_s'] + hr['status_max_age_s']}")
# ------------------------------------------------------------------ vectors
def held_revisions(texts):
out = []
for t in texts:
try:
out.append(parse_revision(t))
except Stop:
pass # a revision that fails rule 1 is not held
return out
def verdict(v):
c = v["context"]
if v["procedure"] == "confirm":
if "receipt" in v:
try:
cl = CV.verify_claim(v["receipt"])
except CV.Reject as r:
return f"reject step={r.step} reason={r.reason}"
pub, sid, auth, time = bytes.fromhex(cl["signer_pubkey"]), cl["signer_id"], claim_authorities(cl), cl["issued_at"]
else:
it = c["item"]
pub, sid, time = bytes.fromhex(it["signer_pubkey"]), it["signer_id"], it["time"]
auth = item_authorities(it["kind"], it["classes"])
exp = c.get("expected_origin")
return confirm(pub, sid, auth, time, held_revisions(c["revisions"]), c["anchors"],
None if exp is None else [exp])
if v["procedure"] == "anchor":
cur = c.get("current")
current = (cur["anchor"], held_revisions(cur["held"])) if cur else None
out = anchor_log(c["origin"], c["log"], c["path"], current)
if out[0] == "anchored":
latest = read_log(c["origin"], c["log"])[-1]
note = " note=published_test_key" if lists_published(latest) else ""
pinned = f" pinned_revision={out[3]}" if out[3] is not None and out[3] != out[1] else ""
return f"anchored revision={out[1]} anchor={out[2]}{pinned}{note}"
if out[0] == "not_anchored":
return "not_anchored reason=" + out[1]
return "replaced reason=key_log_replaced"
if v["procedure"] == "module":
return module(c)
raise ValueError("unknown procedure")
# ------------------------------------------------------------------ canonical fixture
def seed_key(b):
return ed25519.Ed25519PrivateKey.from_private_bytes(bytes([b]) * 32)
def pub(sk):
return sk.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
def fixture():
"""The revision wai-rs pins: computed here from its members and the seeds 0x61 (root),
0x62 (committed) and 0x63 (its one key)."""
sk = seed_key(0x61)
r = {
"status_max_age_s": 600, "keys": [{"until": None, "status": "active", "key": pub(seed_key(0x63)).hex(),
"may_sign": ["delivery", "energy/OnChipCounter"], "id": "d1",
"compromised_since": None}],
"root": pub(sk).hex(), "seq": 0, "prev": None, "origin": "https://publisher.example",
"next_root": commitment(pub(seed_key(0x62))).hex(), "kind": "wai-key-set",
"issued_at": "2026-09-01T00:00:00Z", "revoked_modules": [], "grant_max_s": 3600,
}
r["sig"] = sk.sign(D_SET + CV.jcs(r).encode()).hex()
return r, CV.jcs(r), rev_hash(r)
def fixture_matches_wai_rs():
src_path = os.path.join(HERE, "..", "wai-rs", "src", "key_log", "corpus.rs")
try:
src = open(src_path, encoding="utf-8").read()
except OSError as e:
return [f"cannot read {src_path}: {e}"]
r, canon, h = 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 HASH: &str = "([0-9a-f]{64})";', src)
if not m or m.group(1) != h:
fails.append("revision hash differs from wai-rs")
if r["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:
doc = json.load(open(argv[2], encoding="utf-8"))
for v in doc["vectors"]:
try:
got = verdict(v)
except (ValueError, KeyError, TypeError):
got = "error"
print(f"{v['name']}\t{got}")
return 0
if argv[1:2] == ["fixture"]:
r, canon, h = fixture()
print("sig ", r["sig"])
print("canonical ", canon)
print("revision_hash", h)
return 0
fail = 0
pk11 = pub(seed_key(0x11))
sid = did_key(pk11) == "did:key:z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S"
print(("PASS" if sid else "FAIL") + " did:key of seed 0x11 reproduces its literal")
fail += not sid
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)
path = argv[1] if len(argv) > 1 else os.path.join(HERE, "vectors.json")
doc = json.load(open(path, encoding="utf-8"))
for v in doc["vectors"]:
got = verdict(v)
ok = got == v["expected"]
fail += not ok
print(f"{'PASS' if ok else 'FAIL'} {v['name']} expected={v['expected']} 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))

File diff suppressed because one or more lines are too long

View file

@ -20,7 +20,11 @@
//!
//! `POST /verify` accepts a receipt in either form: as sealed, or with its
//! joule figure's acquisition class inside the signature
//! (`quantum_energy::Labelled`), and restates the class in its verdict.
//! (`quantum_energy::Labelled`), and restates the class in its verdict. It
//! reports `signer_id` as the signer's own label, beside the level a
//! confirmation of the signer reached (`extensions/jwp-receipts.md` §7): this
//! handler holds no key log, so a valid receipt's signer is never more than
//! `unconfirmed`, with the reason it stopped at.
use ed25519_dalek::SigningKey;
use wai::quantum::{Circuit, Measure};
@ -48,6 +52,13 @@ struct Component;
/// zero joules (energy-measurement §2), so the verdict renders it as
/// `"joules_micro":null` with `"metered":false`: a client totalling or comparing
/// the field cannot read an unmetered run as free work.
///
/// Beside `signer_id` the verdict reports `signer_level` and `signer_reason`
/// (jwp-receipts §7.1, §7.4): `unconfirmed` and `signer_id_not_origin_bound`
/// for a label or a `did:key`, `refused` and `signer_id_names_another_key`
/// for a `did:key` of another key, and `unconfirmed` and `origin_not_anchored`
/// for an origin-bound id, since the handler anchors no origin. Both are
/// `null` when the receipt is not valid.
fn verify_response(src: &str) -> (u16, String) {
let Some(any) = MaybeLabelled::<QuantumReceipt>::from_json(src) else {
return (400, "{\"valid\":false,\"reason\":\"unparseable receipt\"}".to_string());
@ -58,6 +69,12 @@ fn verify_response(src: &str) -> (u16, String) {
0 => ("null".to_string(), false),
j => (j.to_string(), true),
};
let signer = if ok {
let (level, reason) = wai::signer_id::level_without_anchors(&r.signer_id, &r.signer_pubkey);
format!("\"signer_level\":\"{}\",\"signer_reason\":\"{reason}\"", level.label())
} else {
"\"signer_level\":null,\"signer_reason\":null".to_string()
};
let class = match any.energy_class() {
None => "\"energy_provenance\":null".to_string(),
Some(c) => c.json_fields().iter().map(|(k, v)| format!("\"{k}\":{v}")).collect::<Vec<_>>().join(","),
@ -66,7 +83,7 @@ fn verify_response(src: &str) -> (u16, String) {
if ok { 200 } else { 409 },
format!(
"{{\"valid\":{},\"circuit_hash\":\"{}\",\"statevector_hash\":\"{}\",\
\"n_qubits\":{},\"n_ops\":{},\"work_amp_updates\":{},\"joules_micro\":{joules},\"metered\":{metered},\"signer_id\":\"{}\",{class}}}",
\"n_qubits\":{},\"n_ops\":{},\"work_amp_updates\":{},\"joules_micro\":{joules},\"metered\":{metered},\"signer_id\":\"{}\",{signer},{class}}}",
ok,
hx(&r.circuit_hash),
hx(&r.statevector_hash),
@ -335,6 +352,11 @@ mod tests {
let (status, body) = verify_response(&r0.to_json());
assert_eq!(status, 200, "{body}");
assert!(body.contains("\"valid\":true") && body.ends_with("\"energy_provenance\":null}"), "{body}");
// The signer's id is a label: a valid receipt, an unconfirmed signer.
assert!(
body.contains("\"signer_id\":\"did:key:wai-quantum-cli\",\"signer_level\":\"unconfirmed\",\"signer_reason\":\"signer_id_not_origin_bound\""),
"{body}"
);
// As the reference quantum meter emits it: a 1 µJ figure labelled
// OnChipCounter, declaring its own rounding.
@ -352,9 +374,23 @@ mod tests {
// The unlabelled parse alone refuses it: what this handler did before.
assert!(!QuantumReceipt::from_json(&j).unwrap().verify());
// A restated label is refused; so is anything unparseable.
assert_eq!(verify_response(&j.replace("288830", "288831")).0, 409);
// A restated label is refused, and the signer is then reported with no
// level; anything unparseable is refused.
let (status, body) = verify_response(&j.replace("288830", "288831"));
assert_eq!(status, 409);
assert!(body.contains("\"signer_level\":null,\"signer_reason\":null"), "{body}");
assert_eq!(verify_response("not a receipt").0, 400);
// An origin-bound id: valid, but no origin is anchored here.
let r2 = QuantumReceipt::seal(&key, "did:web:publisher.example#q1", &ghz3(), None, 0, None).unwrap();
let (status, body) = verify_response(&r2.to_json());
assert_eq!(status, 200, "{body}");
assert!(body.contains("\"signer_level\":\"unconfirmed\",\"signer_reason\":\"origin_not_anchored\""), "{body}");
// A did:key of another key contradicts the signer_pubkey beside it.
let other = wai::signer_id::did_key(&SigningKey::from_bytes(&[0x72u8; 32]).verifying_key().to_bytes());
let r3 = QuantumReceipt::seal(&key, &other, &ghz3(), None, 0, None).unwrap();
let (_, body) = verify_response(&r3.to_json());
assert!(body.contains("\"signer_level\":\"refused\",\"signer_reason\":\"signer_id_names_another_key\""), "{body}");
}
/// An unmetered receipt's `0` is the unmetered marker: the verdict never

View file

@ -183,6 +183,12 @@ oer = ["dep:blake3", "dep:ed25519-dalek", "dep:num-bigint", "serde_json/float_ro
# 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"]
# Key discovery and signing authority (extensions/jwp-receipts.md §7): the origin key
# log a `did:web` signer_id names, anchoring, the confirmation reported beside a receipt's
# or claim's verdict, and the authorisation and revocation of decoder and reconstruct
# modules (§7.9). Adds no dependency: it signs and hashes with what `receipt_claims`
# already brings, and a module digest is the SHA-256 `oer` already brings.
key_discovery = ["receipt_claims"]
# 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.
@ -525,6 +531,16 @@ name = "wai_jwp_vectors"
path = "src/bin/wai_jwp_vectors.rs"
required-features = ["receipt_claims"]
[[bin]]
name = "wai_signer_vectors"
path = "src/bin/wai_signer_vectors.rs"
required-features = ["key_discovery"]
[[bin]]
name = "wai-keys"
path = "src/bin/wai_keys.rs"
required-features = ["key_discovery"]
[[bin]]
name = "wai_quantum_vectors"
path = "src/bin/wai_quantum_vectors.rs"
@ -636,6 +652,12 @@ name = "jpegai_e2e_full"
path = "tests/jpegai_e2e_full.rs"
required-features = ["neural_int"]
# Drives the `wai-keys` binary, so it needs the feature that builds it.
[[test]]
name = "wai_keys_cli"
path = "tests/wai_keys_cli.rs"
required-features = ["key_discovery"]
[dev-dependencies]
serde_json = { version = "1", features = ["preserve_order"] } # construct manifests in tests

View file

@ -0,0 +1,492 @@
//! `wai-keys` — build, check and publish an origin's WAI key log
//! (extensions/jwp-receipts.md §7).
//!
//! wai-keys init --origin URL --root-seed-file F --next-root-key HEX --issued-at T
//! --key id=ID,key=HEX,may_sign=A|B [--key …]
//! [--grant-max-s N] [--status-max-age-s N] [--published-test-seed]
//! a one-revision key log, on stdout
//! wai-keys append LOG --root-seed-file F --issued-at T
//! [--rotate-with-seed-file F2 --next-root-key HEX]
//! [--add id=ID,key=HEX,may_sign=A|B …] [--retire ID@T …] [--revoke ID@T …]
//! [--revoke-module SHA256 …] [--grant-max-s N] [--status-max-age-s N]
//! [--published-test-seed]
//! LOG with one more revision, on stdout. A rotation is signed by the committed
//! key (`--rotate-with-seed-file`) and must name the key it commits to next.
//! wai-keys status LOG --root-seed-file F --issued-at T
//! a signed key status (§7.9) for LOG's latest revision, on stdout
//! wai-keys grant --origin URL --seed-file F --key-id ID (--module-file M | --module SHA256)
//! --form decoder|reconstruct --capability C [--capability …]
//! --not-before T --not-after T [--published-test-seed]
//! a signed module grant (§7.9), on stdout
//! wai-keys verify LOG --origin URL [--pinned]
//! [--anchor HASH --held HELD_LOG [--held-pinned [--pinned-revision HASH]]]
//! the §7.3 anchoring checks: `anchored revision=…`, or `not_anchored reason=…` /
//! `replaced reason=…` with exit status 1. With `--anchor` and `--held`, LOG is
//! checked as a later retrieval against an anchor already held: fetched, or with
//! `--held-pinned` pinned at `--pinned-revision` (default: the anchor). `--pinned`
//! instead pins LOG anew, whatever is held.
//! wai-keys did-json LOG
//! a DID document listing exactly the active keys of LOG's latest revision
//! wai-keys check-did LOG DID_JSON
//! every difference between DID_JSON and LOG's active keys; exit status 1 if any
//!
//! It never creates key material: there is no key-generation command and no random
//! number generator. Seeds are read only from files the operator names — 64 hexadecimal
//! digits or 32 raw bytes — and never printed. A key that is one of
//! `key_log::published_test_keys()` (every one-byte seed, the form every seed published in
//! this repository has) is refused unless `--published-test-seed` says the log is a test
//! log; `verify` reports one it finds as `note=published_test_key`.
//!
//! Date-times are UTC as the key log writes them: `2026-09-01T00:00:00Z`.
use ed25519_dalek::SigningKey;
use serde_json::{json, Value};
use std::collections::BTreeMap;
use wai::key_log::{
self, anchor_log, commitment, is_published_test_key, lists_published_test_key, log_json, module_digest,
read_log, Anchor, AnchorPath, Anchoring, EntryStatus, KeyEntry, KeyStatus, ModuleForm, ModuleGrant, Revision,
};
use wai::signer_id;
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let code = match run(&args) {
Ok(code) => code,
Err(e) => {
eprintln!("wai-keys: {e}");
2
}
};
std::process::exit(code);
}
/// Flags: `--name value` pairs (repeatable), bare `--flag` switches, and positionals.
struct Args {
pos: Vec<String>,
opts: BTreeMap<String, Vec<String>>,
switches: Vec<String>,
}
const SWITCHES: &[&str] = &["--published-test-seed", "--pinned", "--held-pinned"];
fn parse(args: &[String]) -> Result<Args, String> {
let mut a = Args { pos: vec![], opts: BTreeMap::new(), switches: vec![] };
let mut i = 0;
while i < args.len() {
let x = &args[i];
if SWITCHES.contains(&x.as_str()) {
a.switches.push(x.clone());
} else if x.starts_with("--") {
let v = args.get(i + 1).ok_or(format!("{x} needs a value"))?;
a.opts.entry(x.clone()).or_default().push(v.clone());
i += 1;
} else {
a.pos.push(x.clone());
}
i += 1;
}
Ok(a)
}
impl Args {
fn one(&self, k: &str) -> Result<&str, String> {
match self.opts.get(k).map(Vec::as_slice) {
Some([v]) => Ok(v),
Some(_) => Err(format!("{k} given more than once")),
None => Err(format!("{k} is required")),
}
}
fn opt(&self, k: &str) -> Result<Option<&str>, String> {
if self.opts.contains_key(k) { self.one(k).map(Some) } else { Ok(None) }
}
fn all(&self, k: &str) -> &[String] {
self.opts.get(k).map_or(&[], Vec::as_slice)
}
fn has(&self, s: &str) -> bool {
self.switches.iter().any(|x| x == s)
}
fn int(&self, k: &str) -> Result<Option<u64>, String> {
self.opt(k)?.map(|v| v.parse::<u64>().map_err(|_| format!("{k}: not an integer"))).transpose()
}
}
fn run(args: &[String]) -> Result<i32, String> {
let Some(cmd) = args.first() else {
return Err("usage: wai-keys init|append|status|grant|verify|did-json|check-did … (see the source's doc)".into());
};
let a = parse(&args[1..])?;
match cmd.as_str() {
"init" => init(&a),
"append" => append(&a),
"status" => status(&a),
"grant" => grant(&a),
"verify" => verify(&a),
"did-json" => did_json(&a),
"check-did" => check_did(&a),
c => Err(format!("unknown command {c}")),
}
}
// ───────────────────────────── inputs ─────────────────────────────
fn read(path: &str) -> Result<Vec<u8>, String> {
std::fs::read(path).map_err(|e| format!("read {path}: {e}"))
}
fn read_text(path: &str) -> Result<String, String> {
String::from_utf8(read(path)?).map_err(|_| format!("{path}: not UTF-8"))
}
fn hex32(s: &str) -> Option<[u8; 32]> {
if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let v: Vec<u8> = (0..64).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect::<Option<_>>()?;
v.try_into().ok()
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
/// A seed file: 64 hexadecimal digits (surrounding whitespace ignored) or 32 raw bytes.
/// Its contents are never echoed, even in an error.
fn seed(path: &str) -> Result<SigningKey, String> {
let bytes = read(path)?;
let seed = match std::str::from_utf8(&bytes).ok().map(str::trim).and_then(hex32) {
Some(s) => s,
None if bytes.len() == 32 => bytes.try_into().expect("32 bytes"),
None => return Err(format!("{path}: a seed file holds 64 hexadecimal digits or 32 raw bytes")),
};
Ok(SigningKey::from_bytes(&seed))
}
fn public_key(k: &SigningKey) -> [u8; 32] {
k.verifying_key().to_bytes()
}
/// Refuse a published test key unless the operator said this is a test log.
fn refuse_published(a: &Args, keys: &[[u8; 32]]) -> Result<(), String> {
if !a.has("--published-test-seed") && keys.iter().any(is_published_test_key) {
return Err("refusing a published test key (a one-byte seed, as the repository's corpora use): \
it attests nothing. Pass --published-test-seed only to build a test log."
.into());
}
Ok(())
}
/// `id=ID,key=HEX,may_sign=A|B`.
fn entry_spec(s: &str) -> Result<KeyEntry, String> {
let mut m = BTreeMap::new();
for part in s.split(',') {
let (k, v) = part.split_once('=').ok_or(format!("--key/--add: `{part}` is not name=value"))?;
m.insert(k, v);
}
let id = m.get("id").ok_or("--key/--add needs id=")?;
let key = m.get("key").and_then(|k| hex32(&k.to_ascii_lowercase())).ok_or("--key/--add needs key=<64 hex digits>")?;
let may: Vec<&str> = m.get("may_sign").ok_or("--key/--add needs may_sign=")?.split('|').collect();
Ok(KeyEntry::active(id, key, &may))
}
fn time_at(s: &str) -> Result<(&str, &str), String> {
let (id, t) = s.split_once('@').ok_or(format!("`{s}`: expected ID@TIME"))?;
if !key_log::is_log_time(t) {
return Err(format!("`{t}`: not a key-log date-time (YYYY-MM-DDTHH:MM:SSZ)"));
}
Ok((id, t))
}
fn issued_at(a: &Args) -> Result<String, String> {
let t = a.one("--issued-at")?;
if !key_log::is_log_time(t) {
return Err(format!("--issued-at {t}: not a key-log date-time (YYYY-MM-DDTHH:MM:SSZ)"));
}
Ok(t.to_owned())
}
/// A key log file: its revisions, checked as §7.3 checks a log, under the origin its
/// first revision names.
fn load_log(path: &str) -> Result<(String, Vec<Revision>), String> {
let text = read_text(path)?;
let v: Value = serde_json::from_str(&text).map_err(|e| format!("{path}: {e}"))?;
let origin = v["revisions"][0]["origin"].as_str().ok_or(format!("{path}: no origin"))?.to_owned();
let revs = read_log(&origin, &text).map_err(|r| format!("{path}: {}", r.label()))?;
Ok((origin, revs))
}
fn emit(revs: &[Revision]) -> i32 {
println!("{}", log_json(revs));
0
}
// ───────────────────────────── commands ─────────────────────────────
fn init(a: &Args) -> Result<i32, String> {
let origin = a.one("--origin")?;
if !signer_id::origin_ok(origin) {
return Err(format!("--origin {origin}: not an origin of jwp-receipts §7.1 (https://host[:port])"));
}
let root = seed(a.one("--root-seed-file")?)?;
let next = hex32(a.one("--next-root-key")?).ok_or("--next-root-key: 64 hex digits")?;
let keys: Vec<KeyEntry> = a.all("--key").iter().map(|s| entry_spec(s)).collect::<Result<_, _>>()?;
let mut check: Vec<[u8; 32]> = vec![public_key(&root), next];
check.extend(keys.iter().map(|e| e.key));
refuse_published(a, &check)?;
let unsigned = Revision {
origin: origin.into(),
seq: 0,
prev: None,
issued_at: issued_at(a)?,
root: [0; 32],
next_root: commitment(&next),
keys,
revoked_modules: vec![],
grant_max_s: a.int("--grant-max-s")?.unwrap_or(key_log::module_grant_ceiling_s()),
status_max_age_s: a.int("--status-max-age-s")?.unwrap_or(key_log::status_ceiling_s()),
sig: [0; 64],
};
let r = Revision::seal(&root, unsigned).map_err(|e| format!("the revision breaks §7.2 rule 1: {}", e.label()))?;
Ok(emit(&[r]))
}
fn append(a: &Args) -> Result<i32, String> {
let path = a.pos.first().ok_or("append needs LOG")?;
let (origin, mut revs) = load_log(path)?;
let parent = revs.last().expect("a log is non-empty").clone();
let (signer, next_root) = match a.opt("--rotate-with-seed-file")? {
Some(f) => {
let k = seed(f)?;
if commitment(&public_key(&k)) != parent.next_root {
return Err("--rotate-with-seed-file: not the key the latest revision committed to".into());
}
let next = hex32(a.one("--next-root-key")?).ok_or("--next-root-key: 64 hex digits")?;
(k, commitment(&next))
}
None => {
let k = seed(a.one("--root-seed-file")?)?;
if public_key(&k) != parent.root {
return Err("--root-seed-file: not the root of the latest revision (rotate with --rotate-with-seed-file)".into());
}
if a.opts.contains_key("--next-root-key") {
return Err("--next-root-key changes the commitment, which only a rotation may do (§7.2 rule 5)".into());
}
(k, parent.next_root)
}
};
let mut keys = parent.keys.clone();
for s in a.all("--add") {
keys.push(entry_spec(s)?);
}
for s in a.all("--retire") {
let (id, t) = time_at(s)?;
let e = keys.iter_mut().find(|e| e.id == id).ok_or(format!("--retire: no key {id}"))?;
if e.status != EntryStatus::Active {
return Err(format!("--retire: {id} is not active"));
}
e.status = EntryStatus::Retired;
e.until = Some(t.into());
}
for s in a.all("--revoke") {
let (id, t) = time_at(s)?;
let e = keys.iter_mut().find(|e| e.id == id).ok_or(format!("--revoke: no key {id}"))?;
if e.status == EntryStatus::Revoked {
return Err(format!("--revoke: {id} is already revoked"));
}
e.status = EntryStatus::Revoked;
e.compromised_since = Some(t.into());
}
let mut revoked = parent.revoked_modules.clone();
for d in a.all("--revoke-module") {
let d = hex32(d).filter(|_| d.bytes().all(|b| !b.is_ascii_uppercase())).ok_or("--revoke-module: 64 lowercase hex digits")?;
if !revoked.contains(&d) {
revoked.push(d);
}
}
let mut check: Vec<[u8; 32]> = vec![public_key(&signer)];
check.extend(keys.iter().skip(parent.keys.len()).map(|e| e.key));
if let Some(n) = a.opt("--next-root-key")? {
check.extend(hex32(n));
}
refuse_published(a, &check)?;
let unsigned = Revision {
origin: origin.clone(),
seq: parent.seq + 1,
prev: Some(parent.revision_hash()),
issued_at: issued_at(a)?,
root: [0; 32],
next_root,
keys,
revoked_modules: revoked,
grant_max_s: a.int("--grant-max-s")?.unwrap_or(parent.grant_max_s),
status_max_age_s: a.int("--status-max-age-s")?.unwrap_or(parent.status_max_age_s),
sig: [0; 64],
};
let r = Revision::seal(&signer, unsigned).map_err(|e| format!("the revision breaks §7.2 rule 1: {}", e.label()))?;
key_log::check_link(&parent, &r).map_err(|e| format!("the revision breaks §7.2: {}", e.label()))?;
revs.push(r);
Ok(emit(&revs))
}
fn status(a: &Args) -> Result<i32, String> {
let path = a.pos.first().ok_or("status needs LOG")?;
let (_, revs) = load_log(path)?;
let head = revs.last().expect("non-empty");
let root = seed(a.one("--root-seed-file")?)?;
if public_key(&root) != head.root {
return Err("--root-seed-file: a status is signed by the latest revision's root".into());
}
println!("{}", KeyStatus::seal(&root, head, &issued_at(a)?).to_json());
Ok(0)
}
fn grant(a: &Args) -> Result<i32, String> {
let origin = a.one("--origin")?;
let did = signer_id::did_web_of_origin(origin).ok_or(format!("--origin {origin}: not an origin of §7.1"))?;
let key_id = a.one("--key-id")?;
let signer = seed(a.one("--seed-file")?)?;
refuse_published(a, &[public_key(&signer)])?;
let module = match (a.opt("--module-file")?, a.opt("--module")?) {
(Some(f), None) => module_digest(&read(f)?),
(None, Some(d)) => hex32(d).filter(|_| d.bytes().all(|b| !b.is_ascii_uppercase())).ok_or("--module: 64 lowercase hex digits")?,
_ => return Err("give exactly one of --module-file and --module".into()),
};
let form = match a.one("--form")? {
"decoder" => ModuleForm::Decoder,
"reconstruct" => ModuleForm::Reconstruct,
f => return Err(format!("--form {f}: decoder or reconstruct")),
};
let caps: Vec<&str> = a.all("--capability").iter().map(String::as_str).collect();
let g = ModuleGrant::seal(&signer, &format!("{did}#{key_id}"), origin, module, form, &caps, a.one("--not-before")?, a.one("--not-after")?)
.map_err(|e| format!("the grant breaks §7.9: {}", e.label()))?;
println!("{}", g.to_json());
Ok(0)
}
fn verify(a: &Args) -> Result<i32, String> {
let path = a.pos.first().ok_or("verify needs LOG")?;
let origin = a.one("--origin")?;
let text = read_text(path)?;
let path_kind = if a.has("--pinned") { AnchorPath::Pinned } else { AnchorPath::Fetched { acquired_at: "unrecorded".into() } };
let current = match (a.opt("--anchor")?, a.opt("--held")?) {
(Some(h), Some(f)) => {
let revision = hex32(h).ok_or("--anchor: 64 hex digits")?;
let held = read_log(origin, &read_text(f)?).map_err(|r| format!("{f}: {}", r.label()))?;
let anchor = if a.has("--held-pinned") {
let at = match a.opt("--pinned-revision")? {
Some(p) => hex32(p).ok_or("--pinned-revision: 64 hex digits")?,
None => revision,
};
Anchor { pinned_revision: Some(at), ..Anchor::pinned(origin, revision) }
} else {
if a.opts.contains_key("--pinned-revision") {
return Err("--pinned-revision goes with --held-pinned".into());
}
Anchor::fetched(origin, revision, "unrecorded")
};
Some((anchor, held))
}
(None, None) if a.has("--held-pinned") || a.opts.contains_key("--pinned-revision") => {
return Err("--held-pinned and --pinned-revision describe an anchor already held (--anchor, --held)".into());
}
(None, None) => None,
_ => return Err("--anchor and --held go together".into()),
};
match anchor_log(origin, &text, path_kind, current.as_ref().map(|(a, h)| (a, h.as_slice()))) {
Anchoring::Anchored(anchor) => {
let latest = read_log(origin, &text).map_err(|r| r.label().to_owned())?.pop().expect("non-empty");
let note = if lists_published_test_key(&latest) {
eprintln!("warning: the log lists a published test key, which attests nothing (jwp-receipts §7.8)");
" note=published_test_key"
} else {
""
};
let pinned = match anchor.pinned_revision.filter(|p| *p != anchor.revision) {
Some(p) => format!(" pinned_revision={}", hex(&p)),
None => String::new(),
};
println!("anchored revision={} anchor={}{pinned}{note}", hex(&anchor.revision), anchor.path.label());
Ok(0)
}
Anchoring::NotAnchored(r) => {
println!("not_anchored reason={}", r.label());
Ok(1)
}
Anchoring::Replaced => {
println!("replaced reason=key_log_replaced");
Ok(1)
}
}
}
/// The active keys of the latest revision, as DID verification-method ids and their
/// `publicKeyMultibase` values.
fn active_methods(origin: &str, latest: &Revision) -> Result<(String, BTreeMap<String, String>), String> {
let did = signer_id::did_web_of_origin(origin).ok_or(format!("{origin}: not an origin of §7.1"))?;
let methods = latest
.keys
.iter()
.filter(|e| e.status == EntryStatus::Active)
.map(|e| (format!("{did}#{}", e.id), format!("z{}", signer_id::multibase_ed25519(&e.key))))
.collect();
Ok((did, methods))
}
fn did_json(a: &Args) -> Result<i32, String> {
let path = a.pos.first().ok_or("did-json needs LOG")?;
let (origin, revs) = load_log(path)?;
let (did, methods) = active_methods(&origin, revs.last().expect("non-empty"))?;
let vm: Vec<Value> = methods
.iter()
.map(|(id, mb)| json!({ "id": id, "type": "Multikey", "controller": did, "publicKeyMultibase": mb }))
.collect();
let ids: Vec<&String> = methods.keys().collect();
let doc = json!({
"@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1"],
"id": did,
"verificationMethod": vm,
"assertionMethod": ids,
});
println!("{}", serde_json::to_string_pretty(&doc).expect("serialises"));
Ok(0)
}
fn check_did(a: &Args) -> Result<i32, String> {
let (Some(log), Some(doc_path)) = (a.pos.first(), a.pos.get(1)) else {
return Err("check-did needs LOG DID_JSON".into());
};
let (origin, revs) = load_log(log)?;
let (did, want) = active_methods(&origin, revs.last().expect("non-empty"))?;
let doc: Value = serde_json::from_str(&read_text(doc_path)?).map_err(|e| format!("{doc_path}: {e}"))?;
let mut diffs = Vec::new();
if doc["id"].as_str() != Some(did.as_str()) {
diffs.push(format!("id: the document is {}, the log's origin is {did}", doc["id"]));
}
let mut have = BTreeMap::new();
for m in doc["verificationMethod"].as_array().map_or(&[][..], Vec::as_slice) {
let id = m["id"].as_str().unwrap_or("").to_owned();
let mb = m["publicKeyMultibase"].as_str().unwrap_or("").to_owned();
if signer_id::read_multibase_ed25519(&mb).is_none() {
diffs.push(format!("{id}: publicKeyMultibase is not an Ed25519 multikey"));
}
have.insert(id, mb);
}
for (id, mb) in &want {
match have.get(id) {
None => diffs.push(format!("{id}: active in the key log, missing from the DID document")),
Some(h) if h != mb => diffs.push(format!("{id}: the DID document lists another key")),
Some(_) => {}
}
}
for id in have.keys().filter(|id| !want.contains_key(*id)) {
diffs.push(format!("{id}: in the DID document, not an active key of the key log"));
}
for d in &diffs {
println!("{d}");
}
if diffs.is_empty() {
println!("consistent: the DID document lists exactly the key log's active keys");
Ok(0)
} else {
Ok(1)
}
}

View file

@ -40,7 +40,8 @@ use wai::score::{Score, ScoreReceipt};
use wai::splat4d::{Splat4dClipData, Splat4dReceipt};
const SIGNER_SEED: u8 = 0x6d; // 'm' for meter
const SIGNER_ID: &str = "did:web:wai.transaction.science#wai_meter";
// A demo signer whose seed is published: a label, never origin-bound (jwp-receipts §7.1, §7.8).
const SIGNER_ID: &str = "did:example:wai-meter";
fn main() {
let report_args = meter_report::parse_args_or_exit(&std::env::args().collect::<Vec<_>>());

View file

@ -84,7 +84,8 @@ fn main() {
];
let signer = SigningKey::from_bytes(&[0x70u8; 32]); // 'p' for prov
let signer_id = "did:web:wai.transaction.science#wai_prov";
// A demo signer whose seed is published: a label, never origin-bound (jwp-receipts §7.1, §7.8).
let signer_id = "did:example:wai-prov";
let receipt = AssetReceipt::build(
&signer,
signer_id,

View file

@ -111,7 +111,8 @@ fn main() {
// A native sink signs with its own identity key; this metering tool
// uses a fixed, clearly-labeled demo signer.
let signer = SigningKey::from_bytes(&[0x71u8; 32]); // 'q' for quantum-meter
let signer_id = "did:web:wai.transaction.science#wai_quantum_meter";
// A demo signer whose seed is published: a label, never origin-bound (jwp-receipts §7.1, §7.8).
let signer_id = "did:example:wai-quantum-meter";
let receipt = QuantumReceipt::seal(
&signer,
signer_id,

View file

@ -0,0 +1,126 @@
//! Conformance corpus for key discovery, signing authority and module authorisation
//! (`signer-conformance/`, extensions/jwp-receipts.md §7, §5.8).
//!
//! Every vector declares one verdict: for `confirm`, `confirmed` with the origin, key-id,
//! head and anchor path, or `unconfirmed`/`refused` at a named §7.4 step with a named
//! reason, or `reject` for a claim that fails §3.1; for `anchor`, `anchored`,
//! `not_anchored` with a reason, or `replaced`; for `module`, `run` with the kill latency,
//! or `fallback`/`inert` at a named §7.9 check. A verifier that reaches another verdict
//! does not conform.
//!
//! wai_signer_vectors gen <corpus-dir> write vectors.json
//! wai_signer_vectors verify <corpus-dir> the canonical fixture holds, the pinned file
//! regenerates byte-identically, and every
//! vector gets its declared verdict
//! wai_signer_vectors verdicts <file> one `name<TAB>verdict` line per vector,
//! any declared verdict ignored
//!
//! The generator and this verifier are one implementation. The canonical fixture's bytes,
//! signature and revision hash were computed by `signer-conformance/signer_verify.py`,
//! which shares no code with it and also reports every vector's verdict from the text
//! of §7. CI runs that script and compares its `verdicts` output with this binary's.
use std::path::Path;
use wai::key_log::{check_key_log_canonical_form as 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_signer_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 signed text, 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
}
}

View file

@ -135,7 +135,8 @@ fn main() {
// A native sink signs with its own identity key; this metering tool
// uses a fixed, clearly-labeled demo signer.
let signer = SigningKey::from_bytes(&[0x76u8; 32]); // 'v' for video-meter
let signer_id = "did:web:wai.transaction.science#wai_video_meter";
// A demo signer whose seed is published: a label, never origin-bound (jwp-receipts §7.1, §7.8).
let signer_id = "did:example:wai-video-meter";
let (uj, class) = match &measured {
Some((uj, class)) => (*uj, Some(class.clone())),
// With no reader present there is no measurement at all, and the

View file

@ -117,7 +117,8 @@ fn main() {
// tool uses a fixed, clearly-labeled demo signer.
let signer = SigningKey::from_bytes(&[0x6du8; 32]); // 'm' for meter
let wh = world_hash(&container.contract_bytes, &container.graph_bytes);
let signer_id = "did:web:wai.transaction.science#wai_world_meter";
// A demo signer whose seed is published: a label, never origin-bound (jwp-receipts §7.1, §7.8).
let signer_id = "did:example:wai-world-meter";
let receipt = SessionReceipt::build(
&signer,
signer_id,

View file

@ -77,9 +77,9 @@
//!
//! # What accepting a claim does not establish
//!
//! A claim carries the authority of its signer and no more. Key discovery is not
//! specified (as for `group` receipts, the verifier is presumed to hold the key). An
//! accepted `log-inclusion` claim establishes that its signer asserted inclusion and
//! A claim carries the authority of its signer and no more. Who the signer is, is
//! [`crate::key_log`]'s to confirm (jwp-receipts §7, feature `key_discovery`); `verify`
//! does not consult it, and no outcome here depends on it. An accepted `log-inclusion` claim establishes that its signer asserted inclusion and
//! carried the log's evidence — not that inclusion holds; that is checked under the
//! named profile, which this module does not implement. A `binding-recovery` claim
//! accepted without being checked against its declaration and the publisher key
@ -1561,6 +1561,18 @@ impl ClaimReceipt {
self.body.class()
}
/// The authorities a key-log entry must hold for this claim to be confirmed under
/// jwp-receipts §7.5: the class's own — `energy/<energy_provenance>` for `energy`,
/// `lawful-basis/declaration` or `lawful-basis/decision` by its `record`, the class
/// label otherwise — and, whenever the body carries an `energy_provenance`,
/// `energy/<energy_provenance>` as well. The second is read from the body's members,
/// not from its class, so a class that restates a figure with its acquisition class
/// needs that class's authority without this list naming it. No authority is listed
/// twice.
pub fn authorities(&self) -> Vec<String> {
claim_authorities(self.class().label(), &self.body.to_value())
}
fn to_value(&self, with_sig: bool) -> Value {
let mut m = Map::new();
m.insert("kind".into(), "claim".into());
@ -2039,7 +2051,7 @@ impl ClaimReceipt {
/// A key of small order (one whose order divides 8) is refused because the verification
/// equation can then hold for a signature made without any private key. Under the
/// identity key, every `R` = [s]B with `S` = s verifies.
fn signer_key(bytes: &[u8; 32]) -> Result<VerifyingKey, Reason> {
pub(crate) fn signer_key(bytes: &[u8; 32]) -> Result<VerifyingKey, Reason> {
let key = VerifyingKey::from_bytes(bytes).map_err(|_| Reason::BadSignerKey)?;
if key.to_edwards().compress().to_bytes() != *bytes || key.is_weak() {
return Err(Reason::BadSignerKey);
@ -2047,11 +2059,14 @@ fn signer_key(bytes: &[u8; 32]) -> Result<VerifyingKey, Reason> {
Ok(key)
}
/// §3.1 step 3's Ed25519 rule: the key checks of [`signer_key`] (`bad_signer_key`), then
/// `S` < L, `R` not of small order, and the cofactorless equation with `R` compared byte
/// for byte (`signature_verify_failed`). One routine, so a JWP group signature
/// (jwp-receipts §3 step 2, `crate::jwp_delivery`) and a claim's are held to the same
/// rule and every conforming verifier accepts the same signatures.
/// §3.1 step 3's Ed25519 rule over `msg`: the key decodes from its one canonical
/// encoding and is not of small order (`bad_signer_key`, the checks of [`signer_key`]);
/// then `S` < L, `R` is not of small order, and the cofactorless equation holds with `R`
/// compared byte for byte (`signature_verify_failed`). One routine, so a claim, a JWP
/// group signature in either group form, legacy or labelled (jwp-receipts §3 step 2,
/// `crate::jwp_delivery`), and the documents of `crate::key_log` (key-log revisions,
/// module grants and statuses, jwp-receipts §7) are held to the same rule, and every
/// conforming verifier accepts the same signatures.
pub(crate) fn verify_ed25519_strict(pubkey: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> Result<(), Reason> {
let key = signer_key(pubkey)?;
// S < L (RFC 8032 §5.1.7), checked here as well as inside `verify_strict`: the
@ -2085,6 +2100,28 @@ pub fn rfc3339_utc(seconds: u64) -> String {
format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", secs / 3_600, secs % 3_600 / 60, secs % 60)
}
/// [`ClaimReceipt::authorities`] from a claim's class label and body, so the rule is the
/// same for every class, including one whose body restates a figure with its acquisition
/// class.
pub(crate) fn claim_authorities(class: &str, body: &Value) -> Vec<String> {
let provenance = body.get("energy_provenance").and_then(Value::as_str).map(|p| format!("energy/{p}"));
let own = match class {
"energy" => provenance.clone().unwrap_or_else(|| "energy".into()),
"lawful-basis" => match body.get("record").and_then(Value::as_str) {
Some(r) => format!("lawful-basis/{r}"),
None => "lawful-basis".into(),
},
other => other.into(),
};
let mut out = vec![own];
if let Some(p) = provenance
&& !out.contains(&p)
{
out.push(p);
}
out
}
/// Steps 1–4 on the wire form.
pub fn verify_claim_json(wire: &str) -> Result<ClaimReceipt, Rejection> {
let r = ClaimReceipt::from_json(wire)?;
@ -2299,7 +2336,7 @@ fn jcs_into(v: &Value, out: &mut String) -> Result<(), Reason> {
/// `crate::claim_text::MAX_DEPTH` levels — and every number read from its spelling, so a
/// number is never `malformed` (see `crate::claim_text`). Not public: it reads claims, not
/// JSON in general.
fn parse_strict(s: &str) -> Result<Value, String> {
pub(crate) fn parse_strict(s: &str) -> Result<Value, String> {
crate::claim_text::read(s)
}
@ -2505,12 +2542,12 @@ fn int_ok(n: u64) -> Result<(), Reason> {
if n <= MAX_INTEGER { Ok(()) } else { Err(Reason::FieldType) }
}
fn is_lower_hex(s: &str) -> bool {
pub(crate) fn is_lower_hex(s: &str) -> bool {
s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
/// Lowercase hex only, so one byte string has one signed encoding.
fn unhex(s: &str) -> Option<Vec<u8>> {
pub(crate) fn unhex(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) || !is_lower_hex(s) {
return None;
}
@ -2520,7 +2557,7 @@ fn unhex(s: &str) -> Option<Vec<u8>> {
.collect()
}
fn hex(b: &[u8]) -> String {
pub(crate) fn hex(b: &[u8]) -> String {
let mut s = String::with_capacity(b.len() * 2);
for x in b {
let _ = write!(s, "{x:02x}");
@ -2551,7 +2588,7 @@ fn token_ok(s: &str) -> Result<(), Reason> {
}
/// A vocabulary term: a compact IRI (`dpv:Consent`) or an IRI — a token.
fn term_ok(s: &str) -> Result<(), Reason> {
pub(crate) fn term_ok(s: &str) -> Result<(), Reason> {
token_ok(s)
}
@ -2608,7 +2645,7 @@ fn alg_registry_form(s: &str) -> bool {
}
/// Days in a month of the proleptic Gregorian calendar RFC 3339 uses (its Appendix C).
fn days_in_month(year: u32, month: u32) -> u32 {
pub(crate) fn days_in_month(year: u32, month: u32) -> u32 {
match month {
4 | 6 | 9 | 11 => 30,
2 if year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)) => 29,
@ -2621,7 +2658,7 @@ fn days_in_month(year: u32, month: u32) -> u32 {
/// has in its year. Digits are ASCII. 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.
fn rfc3339(s: &str) -> bool {
pub(crate) fn rfc3339(s: &str) -> bool {
let b = s.as_bytes();
let d = |i: usize| b.get(i).is_some_and(u8::is_ascii_digit);
let num = |i: usize, n: usize| -> u32 { (i..i + n).fold(0, |a, j| a * 10 + (b[j] - b'0') as u32) };

View file

@ -373,10 +373,11 @@ impl Prior {
/// Why a sink refuses a pin (SPEC §3.1). The checks run in order, and the first
/// that fails is reported: `pin_malformed`, `form_not_registered`,
/// `form_unreadable`, `form_required`, (check 5, `prior_revoked`, is reserved for
/// a revocation of `(form, sha256)`), `prior_not_held` or `digest_superseded`,
/// `set_malformed`, `part_mismatch`. `set_incomplete` is raised during a pinned
/// decode. Each makes the capability unsupported for that envelope at that sink;
/// `form_unreadable`, `form_required`, `prior_revoked` or `prior_not_authorised`
/// (check 5, run only by a sink that holds the capability's pinned sets to
/// jwp-receipts §7.9, [`crate::pin::PinAuthority`]), `prior_not_held` or
/// `digest_superseded`, `set_malformed`, `part_mismatch`. `set_incomplete` is
/// raised during a pinned decode. Each makes the capability unsupported for that envelope at that sink;
/// none is a reason to decode anyway.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
@ -391,6 +392,16 @@ pub enum PinRefusal {
/// Check 4: `form` is absent and `capability` is not registered with a
/// one-file set.
FormRequired { capability: String },
/// Check 5: the sink holds `capability`'s pinned sets to jwp-receipts §7.9, and a
/// key log it holds revokes the set's digest (§7.9 check 5). `revoked_by` names the
/// revoking origin where it is not the one whose grant was checked.
Revoked { capability: String, revoked_by: Option<String> },
/// Check 5: the sink holds `capability`'s pinned sets to jwp-receipts §7.9, and §7.9
/// does not hold the set for a reason other than revocation. `check` and `reason`
/// are §7.9's (for example `4` and `module_status_stale`: no fresh status, so the
/// sink is offline or has no trusted clock), or `None` when the sink holds no
/// grant of form `decoder` for the set.
NotAuthorised { capability: String, check: Option<&'static str>, reason: Option<&'static str> },
/// Check 6: the sink holds no parameter set for `capability` whose digest, in
/// the pin's form, equals the pin.
NotHeld { capability: String },
@ -413,6 +424,8 @@ pub const PIN_REFUSAL_CODES: &[&str] = &[
"form_not_registered",
"form_unreadable",
"form_required",
"prior_revoked",
"prior_not_authorised",
"prior_not_held",
"digest_superseded",
"set_malformed",
@ -428,6 +441,8 @@ impl PinRefusal {
Self::FormNotRegistered { .. } => "form_not_registered",
Self::FormUnreadable { .. } => "form_unreadable",
Self::FormRequired { .. } => "form_required",
Self::Revoked { .. } => "prior_revoked",
Self::NotAuthorised { .. } => "prior_not_authorised",
Self::NotHeld { .. } => "prior_not_held",
Self::Superseded { .. } => "digest_superseded",
Self::SetMalformed { .. } => "set_malformed",
@ -445,6 +460,17 @@ impl std::fmt::Display for PinRefusal {
Self::FormUnreadable { form } => write!(f, "declared prior form '{form}' cannot be read by this sink"),
Self::FormRequired { capability } => write!(
f, "{capability} is not registered with a one-file parameter set, so a pin over it must declare a form (wai.prior.set)"),
Self::Revoked { capability, revoked_by } => {
write!(f, "the pinned parameter set for {capability} is revoked by a key log this sink holds (jwp-receipts §7.9 check 5)")?;
match revoked_by {
Some(o) => write!(f, ", in {o}'s"),
None => Ok(()),
}
}
Self::NotAuthorised { capability, check: Some(c), reason: Some(r) } => write!(
f, "the pinned parameter set for {capability} is not held under jwp-receipts §7.9: check {c}, {r}"),
Self::NotAuthorised { capability, .. } => write!(
f, "the pinned parameter set for {capability} is not held under jwp-receipts §7.9: this sink holds no grant for it"),
Self::NotHeld { capability } => write!(
f, "this sink holds no parameter set for {capability} whose digest equals the pin"),
Self::Superseded { capability, predecessor } => write!(

1956
wai/wai-rs/src/key_log.rs Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,158 @@
//! SPEC §3.1 check 5 at a sink that holds a key log: the revocation part runs for every
//! capability a pin applies to and needs no grant, status or clock; the authorisation
//! part runs for the capabilities held to jwp-receipts §7.9.
use super::*;
use crate::container::{PinRefusal, Prior};
use crate::pin::PinAuthority;
const P: &str = "https://publisher.example";
const CAP: &str = "wai.neural.int_mlicpp";
const NOW: &str = "2026-09-20T06:00:00Z";
fn key(seed: u8) -> SigningKey {
SigningKey::from_bytes(&[seed; 32])
}
fn set_digest() -> [u8; 32] {
[0x5e; 32]
}
fn other_digest() -> [u8; 32] {
[0x5f; 32]
}
/// A key log whose head revokes `revoked`, anchored by a pinned record of its first
/// revision, with a current status where `online`, a grant for the set where `grant`,
/// and `CAP`'s sets held to §7.9 where `held_to`.
fn sink(revoked: &[[u8; 32]], online: bool, grant: bool, held_to: bool) -> SinkKeyLog {
let unsigned = Revision {
origin: P.into(),
seq: 0,
prev: None,
issued_at: "2026-09-01T00:00:00Z".into(),
root: [0; 32],
next_root: commitment(&key(0x62).verifying_key().to_bytes()),
keys: vec![KeyEntry::active("modd", key(0x6b).verifying_key().to_bytes(), &["module/decoder"])],
revoked_modules: vec![],
grant_max_s: 3_600,
status_max_age_s: 600,
sig: [0; 64],
};
let r0 = Revision::seal(&key(0x61), unsigned.clone()).unwrap();
let r1 = Revision::seal(
&key(0x61),
Revision {
seq: 1,
prev: Some(r0.revision_hash()),
issued_at: "2026-09-10T00:00:00Z".into(),
revoked_modules: revoked.to_vec(),
..unsigned
},
)
.unwrap();
let statuses = if online { vec![KeyStatus::seal(&key(0x61), &r1, "2026-09-20T05:55:00Z").to_json()] } else { vec![] };
let grants = if grant {
vec![ModuleGrant::seal(
&key(0x6b),
"did:web:publisher.example#modd",
P,
set_digest(),
ModuleForm::Decoder,
&[CAP],
"2026-09-20T05:30:00Z",
"2026-09-20T06:30:00Z",
)
.unwrap()
.to_json()]
} else {
vec![]
};
let s = SinkKeyLog::new(vec![r0.clone(), r1], vec![Anchor::pinned(P, r0.revision_hash())])
.with_statuses(statuses)
.with_grants(grants)
.at(NOW);
if held_to {
s.hold_pinned_sets(CAP, &[P])
} else {
s
}
}
fn hex32(d: &[u8; 32]) -> String {
d.iter().map(|b| format!("{b:02x}")).collect()
}
fn pin(d: &[u8; 32]) -> Prior {
Prior::new("set", hex32(d), "decode-equivalence")
}
fn code(r: Result<(), PinRefusal>) -> &'static str {
match r {
Ok(()) => "ok",
Err(e) => e.code(),
}
}
#[test]
fn a_revoked_set_is_refused_whatever_check_a_grant_fails_first() {
// Offline: the grant fails at check 4, and the digest is still revoked.
let offline = sink(&[set_digest()], false, true, true);
assert_eq!(
offline.check(CAP, &pin(&set_digest())),
Err(PinRefusal::Revoked { capability: CAP.into(), revoked_by: Some(P.into()) })
);
// No grant at all.
let no_grant = sink(&[set_digest()], true, false, true);
assert_eq!(code(no_grant.check(CAP, &pin(&set_digest()))), "prior_revoked");
// No trusted clock.
let no_clock = SinkKeyLog { now: None, ..sink(&[set_digest()], true, true, true) };
assert_eq!(code(no_clock.check(CAP, &pin(&set_digest()))), "prior_revoked");
// A grant that reaches check 5 at the granting origin's own head reports no
// other origin.
assert_eq!(
sink(&[set_digest()], true, true, true).check(CAP, &pin(&set_digest())),
Err(PinRefusal::Revoked { capability: CAP.into(), revoked_by: None })
);
}
#[test]
fn an_unrevoked_set_without_authorisation_is_not_authorised() {
assert_eq!(
sink(&[other_digest()], false, true, true).check(CAP, &pin(&set_digest())),
Err(PinRefusal::NotAuthorised { capability: CAP.into(), check: Some("4"), reason: Some("module_status_stale") })
);
assert_eq!(
sink(&[], true, false, true).check(CAP, &pin(&set_digest())),
Err(PinRefusal::NotAuthorised { capability: CAP.into(), check: None, reason: None })
);
assert_eq!(code(sink(&[], true, true, true).check(CAP, &pin(&set_digest()))), "ok");
}
#[test]
fn revocation_needs_no_hold_to_section_7_9() {
let s = sink(&[set_digest()], false, false, false);
assert!(!s.holds_to_section_7_9(CAP));
assert_eq!(code(s.check(CAP, &pin(&set_digest()))), "prior_revoked");
assert_eq!(code(s.check(CAP, &pin(&other_digest()))), "ok");
// A key log with no anchor revokes nothing.
let unanchored = SinkKeyLog { anchors: vec![], ..s };
assert_eq!(code(unanchored.check(CAP, &pin(&set_digest()))), "ok");
}
#[test]
fn an_unpinned_set_is_checked_under_every_digest_a_pin_could_name() {
let hexes = |ds: &[[u8; 32]]| ds.iter().map(hex32).collect::<Vec<_>>();
// Revoked under one of its digests: refused, held to §7.9 or not.
for held_to in [false, true] {
let s = sink(&[set_digest()], true, true, held_to);
assert_eq!(code(s.check_unpinned(CAP, &hexes(&[other_digest(), set_digest()]))), "prior_revoked", "held_to={held_to}");
}
// Not held to §7.9 and not revoked: nothing to authorise.
assert_eq!(code(sink(&[], false, false, false).check_unpinned(CAP, &hexes(&[set_digest()]))), "ok");
// Held to §7.9: authorised under one of its digests is enough.
assert_eq!(code(sink(&[], true, true, true).check_unpinned(CAP, &hexes(&[other_digest(), set_digest()]))), "ok");
// Held to §7.9 with no grant, or no digest to identify it: not authorised.
assert_eq!(code(sink(&[], true, false, true).check_unpinned(CAP, &hexes(&[set_digest()]))), "prior_not_authorised");
assert_eq!(code(sink(&[], true, true, true).check_unpinned(CAP, &[])), "prior_not_authorised");
}

View file

@ -0,0 +1,577 @@
use super::*;
use crate::claim_receipt::verify_claim_json;
fn key(seed: u8) -> SigningKey {
SigningKey::from_bytes(&[seed; 32])
}
fn pk(seed: u8) -> [u8; 32] {
key(seed).verifying_key().to_bytes()
}
const P: &str = "https://publisher.example";
fn rev(root: u8, next: u8, parent: Option<&Revision>, at: &str, keys: Vec<KeyEntry>) -> Revision {
Revision::seal(
&key(root),
Revision {
origin: P.into(),
seq: parent.map_or(0, |p| p.seq + 1),
prev: parent.map(Revision::revision_hash),
issued_at: at.into(),
root: [0; 32],
next_root: commitment(&pk(next)),
keys,
revoked_modules: vec![],
grant_max_s: 3_600,
status_max_age_s: 600,
sig: [0; 64],
},
)
.unwrap()
}
/// Every claim the claim corpus accepts names a label: none is confirmed, and each stops
/// at step 1. The claim corpus needs no change for §7 to exist beside it.
#[test]
fn claim_corpus_signers_unconfirmed() {
let doc: Value = serde_json::from_str(include_str!("../../../claim-conformance/vectors.json")).unwrap();
let mut accepted = 0;
for v in doc["vectors"].as_array().unwrap() {
let Some(wire) = v.get("receipt").and_then(Value::as_str) else { continue };
let Ok(c) = verify_claim_json(wire) else { continue };
accepted += 1;
let got = confirm(&Item::from_claim(&c), &[], &[], None);
assert_eq!(got, Confirmation::Unconfirmed { step: "1", reason: Reason::SignerIdNotOriginBound }, "{}", v["name"]);
}
assert!(accepted > 0, "the claim corpus was not read");
}
#[test]
fn later_than_table() {
let until = "2026-12-31T23:30:00Z";
for (t, later) in [
("2026-12-31T23:30:00Z", false),
("2026-12-31T23:30:00.000Z", false),
("2026-12-31T23:30:00.001Z", true),
("2027-01-01T00:29:59+01:00", false), // 23:29:59Z
("2027-01-01T00:30:01+01:00", true), // 23:30:01Z
("2026-12-31T23:00:00-00:30", false), // 23:30:00Z
("2026-12-31T23:59:59-23:59", true), // next day
("2026-12-31T23:29:60Z", false), // a leap second before the minute
("2026-12-31T23:30:00z", false), // lower-case z is RFC 3339
("2026-12-31 23:30:01Z", true), // not a date-time: later than every until
("2026-02-29T00:00:00Z", true), // 2026 has no 29 February
] {
assert_eq!(later_than(t, until), later, "{t}");
}
assert!(!later_than("2028-02-29T00:00:00Z", "2028-03-01T00:00:00Z"));
assert!(!later_than("2026-09-20T00:00:00.5Z", "2026-09-20T00:00:00.50Z") && !later_than("2026-09-20T00:00:00.50Z", "2026-09-20T00:00:00.5Z"));
assert!(is_log_time("2026-09-20T00:00:59Z") && !is_log_time("2026-09-20T00:00:60Z"));
assert!(!is_log_time("2026-09-20t00:00:00Z") && !is_log_time("2026-09-20T00:00:00.0Z") && !is_log_time("2026-09-20T00:00:00+00:00"));
assert_eq!(log_seconds("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(log_seconds("2000-03-01T00:00:00Z"), Some(951_868_800));
}
/// The head does not depend on the order revisions arrive in, with orphans, strangers,
/// a thief's revision and the owner's rotation among them.
#[test]
fn head_is_order_independent() {
let r0 = rev(0x61, 0x62, None, "2026-09-01T00:00:00Z", vec![KeyEntry::active("d1", pk(0x63), &["delivery"])]);
let r1 = rev(0x61, 0x62, Some(&r0), "2026-09-02T00:00:00Z", r0.keys.clone());
let mut tk = r1.keys.clone();
tk.push(KeyEntry::active("x", pk(0x6a), &["delivery"]));
let thief = rev(0x61, 0x62, Some(&r1), "2026-09-03T00:00:00Z", tk);
let owner = rev(0x62, 0x68, Some(&r1), "2026-09-04T00:00:00Z", r1.keys.clone());
let owner2 = rev(0x62, 0x68, Some(&owner), "2026-09-05T00:00:00Z", r1.keys.clone());
let orphan = rev(0x62, 0x68, Some(&owner2), "2026-09-06T00:00:00Z", r1.keys.clone());
let stranger = Revision::seal_unchecked(
&key(0x6f),
Revision { next_root: commitment(&pk(0x67)), ..rev(0x61, 0x62, Some(&r1), "2026-09-03T00:00:00Z", r1.keys.clone()) },
);
let pool = [r0, r1.clone(), thief, owner.clone(), stranger, orphan];
let anchor = r1.revision_hash();
let want = head(&pool, &anchor).unwrap();
assert_eq!(want.hash, owner.revision_hash());
// Every rotation of the pool, forwards and backwards.
for i in 0..pool.len() {
let mut p = pool.to_vec();
p.rotate_left(i);
assert_eq!(head(&p, &anchor).unwrap().hash, want.hash);
p.reverse();
assert_eq!(head(&p, &anchor).unwrap().hash, want.hash);
}
let mut with_parent = pool.to_vec();
with_parent.push(owner2.clone());
assert_eq!(head(&with_parent, &anchor).unwrap().hash, pool[5].revision_hash());
}
#[test]
fn seal_refuses_rule1_breaks() {
let base = || Revision {
origin: P.into(),
seq: 0,
prev: None,
issued_at: "2026-09-01T00:00:00Z".into(),
root: [0; 32],
next_root: commitment(&pk(0x62)),
keys: vec![KeyEntry::active("d1", pk(0x63), &["delivery"])],
revoked_modules: vec![],
grant_max_s: 60,
status_max_age_s: 60,
sig: [0; 64],
};
assert!(Revision::seal(&key(0x61), base()).is_ok());
type Break = Box<dyn Fn(&mut Revision)>;
let breaks: Vec<Break> = vec![
Box::new(|r| r.seq = 1),
Box::new(|r| r.issued_at = "2026-09-01T00:00:00.5Z".into()),
Box::new(|r| r.next_root = commitment(&pk(0x61))),
Box::new(|r| r.keys.push(KeyEntry::active("d1", pk(0x64), &["delivery"]))),
Box::new(|r| r.keys.push(KeyEntry::active("d2", pk(0x63), &["delivery"]))),
Box::new(|r| r.keys.push(KeyEntry::active("r", pk(0x61), &["delivery"]))),
Box::new(|r| r.keys.push(KeyEntry::active("n", pk(0x62), &["delivery"]))),
Box::new(|r| r.keys[0].may_sign.clear()),
Box::new(|r| r.keys[0].may_sign.push("delivery".into())),
Box::new(|r| r.keys[0].may_sign.push("energy//x".into())),
Box::new(|r| r.keys[0].id = "a b".into()),
Box::new(|r| r.keys[0].until = Some("2026-09-02T00:00:00Z".into())),
Box::new(|r| r.keys[0].status = EntryStatus::Retired),
Box::new(|r| r.keys[0].status = EntryStatus::Revoked),
Box::new(|r| r.revoked_modules = vec![[1; 32], [1; 32]]),
Box::new(|r| r.grant_max_s = 0),
Box::new(|r| r.grant_max_s = module_grant_ceiling_s() + 1),
Box::new(|r| r.status_max_age_s = status_ceiling_s() + 1),
];
for (i, b) in breaks.iter().enumerate() {
let mut r = base();
b(&mut r);
assert_eq!(Revision::seal(&key(0x61), r), Err(Reason::KeyLogMalformed), "break {i}");
}
// A key of small order is not a key §3.1 step 3 accepts.
let mut r = base();
let mut identity = [0u8; 32];
identity[0] = 1;
r.keys[0].key = identity;
assert_eq!(Revision::seal(&key(0x61), r), Err(Reason::KeyLogMalformed));
}
/// Ed25519 did:key spellings of the claim corpus's publisher and meter keys.
#[test]
fn did_key_literals_from_seeds() {
assert_eq!(signer_id::did_key(&pk(0x11)), "did:key:z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S");
assert_eq!(signer_id::did_key(&pk(0x22)), "did:key:z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK");
}
/// An authority registered for pre-emption is confirmed only when the pinned revision
/// itself lists the key with it, not a later revision taken from anywhere.
#[test]
fn preemption_requires_the_pinned_revision() {
let r0 = rev(0x61, 0x62, None, "2026-09-01T00:00:00Z", vec![KeyEntry::active("d1", pk(0x63), &["delivery"])]);
let mut k1 = r0.keys.clone();
k1.push(KeyEntry::active("pe", pk(0x64), &["test/preempt"]));
let r1 = rev(0x61, 0x62, Some(&r0), "2026-09-02T00:00:00Z", k1);
let item = Item {
signer_pubkey: pk(0x64),
signer_id: "did:web:publisher.example#pe".into(),
authorities: vec!["test/preempt".into()],
time: None,
};
let pinned_at = |r: &Revision| vec![Anchor::pinned(P, r.revision_hash())];
let held = [r0.clone(), r1.clone()];
let got = confirm_with(&item, &held, &pinned_at(&r0), None, &["test/preempt"]);
assert_eq!(got, Confirmation::Unconfirmed { step: "6a", reason: Reason::AnchorNotPinned });
let got = confirm_with(&item, &held, &pinned_at(&r1), None, &["test/preempt"]);
assert_eq!(got.level(), Level::Confirmed);
// Without the registration, the same item confirms from the later revision.
assert_eq!(confirm(&item, &held, &pinned_at(&r0), None).level(), Level::Confirmed);
}
#[test]
fn published_test_keys_cover_the_tree_seeds() {
for seed in [0x01u8, 0x09, 0x11, 0x22, 0x33, 0x44, 0x55, 0x61, 0x6d, 0x6f, 0x70, 0x71, 0x76] {
assert!(is_published_test_key(&pk(seed)), "{seed:#x}");
}
let mut other = [0x5au8; 32];
other[0] = 0x5b;
assert!(!is_published_test_key(&SigningKey::from_bytes(&other).verifying_key().to_bytes()));
}
#[test]
fn canonical_form_holds() {
assert_eq!(check_key_log_canonical_form(), Vec::<String>::new());
}
#[test]
fn corpus_is_the_committed_file_and_passes() {
let pinned = include_str!("../../../signer-conformance/vectors.json").replace("\r\n", "\n");
assert!(pinned == conformance_vectors(), "signer-conformance/vectors.json does not regenerate");
let results = run_corpus(&pinned).unwrap();
assert_eq!(results.len(), corpus_vector_count());
let failed: Vec<_> = results.iter().filter(|r| !r.pass).map(|r| format!("{}: {} != {}", r.name, r.got, r.expected)).collect();
assert!(failed.is_empty(), "{failed:#?}");
}
#[test]
fn claim_authorities_follow_the_table() {
let doc: Value = serde_json::from_str(include_str!("../../../claim-conformance/vectors.json")).unwrap();
let mut seen = std::collections::BTreeSet::new();
for v in doc["vectors"].as_array().unwrap() {
let Some(wire) = v.get("receipt").and_then(Value::as_str) else { continue };
let Ok(c) = verify_claim_json(wire) else { continue };
let a = c.authorities();
let body = serde_json::from_str::<Value>(wire).unwrap()["body"].clone();
let provenance = body.get("energy_provenance").and_then(Value::as_str).map(|p| format!("energy/{p}"));
let mut want = match c.class().label() {
"energy" => vec![provenance.clone().unwrap()],
"lawful-basis" => vec![format!("lawful-basis/{}", body["record"].as_str().unwrap())],
other => vec![other.to_owned()],
};
// Any body that restates a figure with its acquisition class needs that class's
// authority too (§7.5), whatever its class.
if let Some(p) = provenance
&& !want.contains(&p)
{
want.push(p);
}
assert_eq!(a, want);
seen.insert(a[0].split('/').next().unwrap().to_owned());
}
for class in ["energy", "lawful-basis", "measurement", "operating-point"] {
assert!(seen.contains(class), "the claim corpus holds no confirmable {class} claim: {seen:?}");
}
}
/// A pinned record moved forward by a retrieval keeps the revision that was pinned. A key
/// a later, fetched revision adds with a pre-emption authority is not confirmed, though
/// the record stays pinned for the hardware and lawful-basis authorities.
#[test]
fn a_forward_move_keeps_the_pinned_revision_for_preemption() {
let r0 = rev(0x61, 0x62, None, "2026-09-01T00:00:00Z", vec![KeyEntry::active("d1", pk(0x63), &["delivery"])]);
let mut k1 = r0.keys.clone();
k1.push(KeyEntry::active("pe", pk(0x64), &["test/preempt"]));
k1.push(KeyEntry::active("m2", pk(0x69), &["energy/CalibratedInstrument"]));
let r1 = rev(0x61, 0x62, Some(&r0), "2026-09-02T00:00:00Z", k1);
let Anchoring::Anchored(pinned) = anchor_log(P, &log_json(std::slice::from_ref(&r0)), AnchorPath::Pinned, None) else {
panic!("pinning r0 anchors")
};
let fetched = AnchorPath::Fetched { acquired_at: "2026-09-03T00:00:00Z".into() };
let held0 = [r0.clone()];
let Anchoring::Anchored(moved) = anchor_log(P, &log_json(&[r0.clone(), r1.clone()]), fetched, Some((&pinned, &held0))) else {
panic!("a retrieval that contains the anchor moves it forward")
};
assert_eq!(moved.path, AnchorPath::Pinned);
assert_eq!(moved.revision, r1.revision_hash());
assert_eq!(moved.pinned_revision, Some(r0.revision_hash()));
let held = [r0.clone(), r1.clone()];
let preempt = Item {
signer_pubkey: pk(0x64),
signer_id: "did:web:publisher.example#pe".into(),
authorities: vec!["test/preempt".into()],
time: None,
};
let got = confirm_with(&preempt, &held, std::slice::from_ref(&moved), None, &["test/preempt"]);
assert_eq!(got, Confirmation::Unconfirmed { step: "6a", reason: Reason::AnchorNotPinned });
let hw = Item::for_family(pk(0x69), "did:web:publisher.example#m2", &["CalibratedInstrument"], None);
let hw = Item { authorities: vec!["energy/CalibratedInstrument".into()], ..hw };
assert_eq!(confirm(&hw, &held, std::slice::from_ref(&moved), None).level(), Level::Confirmed);
// A record that says pinned but names no pinned revision is not pinned for step 6a.
let bare = Anchor { pinned_revision: None, ..moved.clone() };
assert_eq!(
confirm(&hw, &held, &[bare], None),
Confirmation::Unconfirmed { step: "6a", reason: Reason::AnchorNotPinned }
);
}
/// `energy/<class>` is read from any `energy_provenance` a body carries, whatever its
/// class, beside the class's own authority, and is never listed twice.
#[test]
fn energy_provenance_adds_its_authority_whatever_the_class() {
use crate::claim_receipt::claim_authorities;
let body = serde_json::json!({ "energy_provenance": "OnChipCounter", "joules_micro": 1 });
assert_eq!(claim_authorities("measurement", &body), vec!["measurement", "energy/OnChipCounter"]);
assert_eq!(claim_authorities("energy", &body), vec!["energy/OnChipCounter"]);
assert_eq!(claim_authorities("soft-binding", &serde_json::json!({ "alg": "x" })), vec!["soft-binding"]);
assert_eq!(claim_authorities("lawful-basis", &serde_json::json!({ "record": "decision" })), vec!["lawful-basis/decision"]);
}
/// An item read from a verified JWP group needs `delivery` and the energy authority of
/// each class its labelled leaves and its labelled total carry; the same item as the
/// corpus describes it. A group that does not verify, or an object not included under it,
/// gives no item.
#[test]
fn from_group_reads_the_classes_a_verified_group_carries() {
use crate::energy_class::{CounterFiltering, EnergyProvenance, Uncertainty};
use crate::jwp_delivery::{GroupBuilder, ObjectLabel};
let chip = EnergyProvenance::OnChipCounter {
uncertainty: Uncertainty { relative_ppm: 20_000, window_us: 1_000_000 },
filtering: CounterFiltering::Unknown,
};
let sid = "did:web:publisher.example#d2";
let build = |labels: &[Option<ObjectLabel>], labelled: bool| {
let mut b = GroupBuilder::default();
for (i, l) in labels.iter().enumerate() {
b.add(crate::claim_receipt::object_content_hash(&[i as u8]), 12_000, "origin.example", l.clone());
}
b.finish(&key(0x66), sid, 7, 3, "2026-09-18T11:00:00Z", "2026-09-18T12:00:00Z", None, labelled).expect("composes")
};
let lab = Some(ObjectLabel::Class(chip.clone()));
// A legacy group of unlabelled objects: delivery alone.
let (g, objs) = build(&[None, None], false);
let refs: Vec<_> = objs.iter().collect();
let it = Item::from_group(&g, &refs).unwrap();
assert_eq!(it.authorities(), ["delivery"]);
assert_eq!(it, Item::for_group(pk(0x66), sid, &[], "2026-09-18T12:00:00Z"));
// Labelled leaves under a labelled total: the class once, beside delivery.
let (g, objs) = build(&[lab.clone(), lab.clone()], true);
assert!(g.label.as_ref().and_then(|l| l.class.as_ref()).is_some(), "the total carries the class");
let refs: Vec<_> = objs.iter().collect();
let it = Item::from_group(&g, &refs).unwrap();
assert_eq!(it.authorities(), ["delivery", "energy/OnChipCounter"]);
assert_eq!(it, Item::for_group(pk(0x66), sid, &["OnChipCounter", "OnChipCounter"], "2026-09-18T12:00:00Z"));
// The labelled total alone carries the class, objects or none.
assert_eq!(Item::from_group(&g, &[]).unwrap().authorities(), ["delivery", "energy/OnChipCounter"]);
// A labelled leaf under a legacy payload still adds its class.
let (g, objs) = build(&[lab.clone(), None], false);
assert!(g.label.is_none());
let it = Item::from_group(&g, &[&objs[0]]).unwrap();
assert_eq!(it.authorities(), ["delivery", "energy/OnChipCounter"]);
assert_eq!(Item::from_group(&g, &[&objs[1]]).unwrap().authorities(), ["delivery"]);
// A group whose signature fails, and an object from another group, give no item.
let mut bad = g.clone();
bad.group_end = "2026-09-18T12:00:01Z".into();
assert!(Item::from_group(&bad, &[]).is_err());
let (other, other_objs) = build(&[lab.clone(), lab], true);
assert_ne!(other.root_hash, g.root_hash);
assert!(Item::from_group(&g, &[&other_objs[1]]).is_err());
}
/// An item read from another family's receipt takes the key that signed it and every
/// class its figures carry; a receipt that does not verify, and a JWP claim, give none.
#[test]
fn from_family_reads_a_verified_receipt() {
use crate::energy_class::{CounterFiltering, EnergyProvenance, Uncertainty};
use crate::provenance::{AssetReceipt, ProvStep};
let chip = EnergyProvenance::OnChipCounter {
uncertainty: Uncertainty { relative_ppm: 20_000, window_us: 1_000_000 },
filtering: CounterFiltering::Unknown,
};
let sid = "did:web:publisher.example#d1";
let a = [1u8; 32];
let b = [2u8; 32];
let mut labelled = ProvStep::new("capture", [0; 32], a, 100, 12_000);
labelled.energy_provenance = Some(chip);
let mut model = ProvStep::new("encode.jpeg", a, b, 100, 3_000);
model.energy_provenance = Some(EnergyProvenance::ModelBased);
let r = AssetReceipt::build(&key(0x63), sid, b, "image", "wai.image.jpeg", 1, vec![labelled, model], None);
let it = Item::from_family(&r, sid, None).expect("a verified provenance receipt");
assert_eq!(it.signer_pubkey(), &pk(0x63));
assert_eq!(it.authorities(), ["receipt", "energy/OnChipCounter", "energy/ModelBased"]);
let plain = AssetReceipt::build(&key(0x63), sid, a, "image", "wai.image.jpeg", 1, vec![ProvStep::new("capture", [0; 32], a, 100, 0)], None);
let it = Item::from_family(&plain, sid, Some("2026-09-18T12:00:00Z")).unwrap();
assert_eq!(it, Item::for_family(pk(0x63), sid, &[], Some("2026-09-18T12:00:00Z")));
let mut forged = r.clone();
forged.steps[0].joules_micro += 1;
assert_eq!(Item::from_family(&forged, sid, None), None, "a receipt that does not verify gives no item");
let claim = ClaimReceipt::seal(
&key(0x64),
"did:web:publisher.example#m1",
crate::claim_receipt::SubjectType::Object,
crate::claim_receipt::object_content_hash(b"subject"),
0,
"2026-09-18T12:00:00Z",
crate::claim_receipt::ClaimBody::SoftBinding(crate::claim_receipt::SoftBindingClaim {
alg: "org.example.watermark.1".into(),
registry: "https://registry.example.org/soft-binding-algorithms".into(),
binding_type: crate::claim_receipt::BindingType::Watermark,
value: vec![1, 2, 3],
resolution_endpoint: "https://resolver.example.org/matches".into(),
}),
None,
)
.unwrap();
assert_eq!(Item::from_family(&claim, "did:web:publisher.example#m1", None), None, "a claim is read by from_claim");
}
/// SPEC §3.1 check 5 through the reference dispatcher: a sink configured with a key log
/// that holds a capability's pinned parameter sets to jwp-receipts §7.9.
mod pinned_sets_through_dispatch {
use super::*;
use crate::codecs::CAP_NEURAL_INT_MLICPP as MLIC;
use crate::container::{Conditioning, Inert, Manifest, ModelRequirement, PinRefusal, Prior, Skip, Wai, WaiMulti};
use crate::pin::{self, HeldBytes, PinAuthority, READABLE_FORMS};
const NOW: &str = "2026-09-20T06:00:00Z";
const PNG: &str = "wai.image.png";
fn vectors() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors/int_mlicpp")
}
/// The committed int_mlicpp set: its listing and its one file.
fn set() -> (Vec<u8>, Vec<u8>) {
(std::fs::read(vectors().join("model.sha256")).unwrap(), std::fs::read(vectors().join("model.wmb")).unwrap())
}
/// The pin the committed set-form envelope carries.
fn pin() -> Prior {
let w = Wai::read(vectors().join("stream_set.wai")).unwrap();
let p = w.prior().expect("stream_set.wai pins its set").clone();
assert_eq!(p.form(), Some(crate::codecs::CAP_PRIOR_SET));
p
}
fn envelope(fallback: Option<&str>) -> Wai {
let mut req = ModelRequirement::new(MLIC);
req.fallback = fallback.map(String::from);
let mut c = Conditioning::new("int_mlicpp");
c.prior = Some(pin());
Wai::new(Manifest::new("image", "decode", req, c), b"payload".to_vec())
}
fn digest() -> [u8; 32] {
pin::sha256(&set().0)
}
/// r0 lists a module/decoder key; r1, its child, revokes `revoked`.
fn log(revoked: &[[u8; 32]]) -> (Revision, Revision) {
let r0 = rev(0x61, 0x62, None, "2026-09-01T00:00:00Z", vec![KeyEntry::active("modd", pk(0x6b), &["module/decoder"])]);
let mut r1 = Revision { seq: 1, prev: Some(r0.revision_hash()), issued_at: "2026-09-10T00:00:00Z".into(), ..r0.clone() };
r1.revoked_modules = revoked.to_vec();
let r1 = Revision::seal(&key(0x61), r1).unwrap();
(r0, r1)
}
/// A sink holding `log`, anchored at its first revision by pinning, with a fresh
/// status for the head, a grant for the committed set, and int_mlicpp held to §7.9.
fn sink(revoked: &[[u8; 32]]) -> SinkKeyLog {
let (r0, r1) = log(revoked);
let grant = ModuleGrant::seal(
&key(0x6b),
"did:web:publisher.example#modd",
P,
digest(),
ModuleForm::Decoder,
&[MLIC],
"2026-09-20T05:30:00Z",
"2026-09-20T06:30:00Z",
)
.unwrap()
.to_json();
let status = KeyStatus::seal(&key(0x61), &r1, "2026-09-20T05:55:00Z").to_json();
SinkKeyLog::new(vec![r0.clone(), r1], vec![Anchor::pinned(P, r0.revision_hash())])
.with_statuses(vec![status])
.with_grants(vec![grant])
.hold_pinned_sets(MLIC, &[P])
.at(NOW)
}
fn dispatch<'a>(w: &'a Wai, key_log: Option<&dyn PinAuthority>) -> Result<&'a str, Inert> {
let (listing, model) = set();
let files: std::collections::BTreeMap<String, &[u8]> = [("model.wmb".to_owned(), model.as_slice())].into_iter().collect();
let held = [HeldBytes::Set { listing: &listing, files: &files }];
w.dispatch_verified(|_| true, |c, p| pin::verify_bytes_with(c, p, &held, READABLE_FORMS, key_log).map(|_| ()))
.map(|d| d.capability)
}
#[test]
fn a_held_grant_lets_the_pinned_set_decode() {
let w = envelope(Some(PNG));
assert_eq!(dispatch(&w, Some(&sink(&[]))), Ok(MLIC));
}
/// The set's digest revoked in the head: the pin is refused at SPEC §3.1's check 5
/// with `prior_revoked`, and dispatch takes the fallback; with no fallback the
/// envelope is inert and reports it.
#[test]
fn a_revoked_parameter_set_is_refused_through_dispatch() {
let k = sink(&[digest()]);
assert_eq!(dispatch(&envelope(Some(PNG)), Some(&k)), Ok(PNG));
let e = dispatch(&envelope(None), Some(&k)).unwrap_err();
assert_eq!(e.code(), "prior_revoked");
assert!(
matches!(e, Inert::PinRefused { ref capability, refusal: PinRefusal::Revoked { revoked_by: None, .. } } if capability == MLIC),
"{e:?}"
);
// Another digest revoked leaves this set held.
assert_eq!(dispatch(&envelope(None), Some(&sink(&[[7; 32]]))), Ok(MLIC));
}
/// Check 5 runs before check 6: a revoked set is refused whether or not the sink
/// holds its bytes, and a sink that does not hold the set reads none of it.
#[test]
fn revocation_is_check_5_before_prior_not_held() {
let k = sink(&[digest()]);
assert_eq!(pin::verify_bytes_with(MLIC, &pin(), &[], READABLE_FORMS, Some(&k)).unwrap_err().code(), "prior_revoked");
assert_eq!(pin::verify_bytes_with(MLIC, &pin(), &[], READABLE_FORMS, None).unwrap_err().code(), "prior_not_held");
// On disk, through the path the neural sink's ModelRegistry takes.
let held = [pin::Held::Listing(vectors().join("model.sha256"))];
assert_eq!(pin::verify_with(MLIC, &pin(), &held, READABLE_FORMS, Some(&k)).unwrap_err().code(), "prior_revoked");
assert!(pin::verify_with(MLIC, &pin(), &held, READABLE_FORMS, Some(&sink(&[]))).is_ok());
}
/// Fail closed: offline (no status), no trusted clock, no grant, and a grant from an
/// origin that is not a module origin all refuse with `prior_not_authorised`, naming
/// §7.9's check, and dispatch falls back.
#[test]
fn offline_and_unauthorised_sets_fall_back() {
let offline = sink(&[]).with_statuses(vec![]);
let no_clock = SinkKeyLog { now: None, ..sink(&[]) };
let no_grant = sink(&[]).with_grants(vec![]);
let other_origin = SinkKeyLog { held_to: BTreeMap::new(), ..sink(&[]) }.hold_pinned_sets(MLIC, &["https://other.example"]);
for (what, k, want) in [
("offline", &offline, Some(("4", "module_status_stale"))),
("no clock", &no_clock, Some(("4", "module_status_stale"))),
("no grant", &no_grant, None),
("other origin", &other_origin, Some(("3", "origin_not_expected"))),
] {
assert_eq!(dispatch(&envelope(Some(PNG)), Some(k)), Ok(PNG), "{what}");
let e = dispatch(&envelope(None), Some(k)).unwrap_err();
assert_eq!(e.code(), "prior_not_authorised", "{what}");
let Inert::PinRefused { refusal: PinRefusal::NotAuthorised { check, reason, .. }, .. } = e else { panic!("{what}: {e:?}") };
assert_eq!(check.zip(reason), want, "{what}");
}
// The subject's origin is a module origin too.
let subject = SinkKeyLog { held_to: BTreeMap::new(), ..sink(&[]) }.hold_pinned_sets(MLIC, &[]).with_subject_origin(P);
assert_eq!(dispatch(&envelope(None), Some(&subject)), Ok(MLIC));
}
/// A sink with no key log does not run §7.9, and one whose relying party does not
/// hold the capability to §7.9 skips check 5 for it: a key log refuses a set it revokes, held to §7.9 or not.
#[test]
fn no_key_log_runs_no_section_7_9() {
assert_eq!(dispatch(&envelope(None), None), Ok(MLIC));
let not_held_to = SinkKeyLog { held_to: BTreeMap::new(), ..sink(&[digest()]) };
assert_eq!(dispatch(&envelope(None), Some(&not_held_to)), Err(crate::container::Inert::PinRefused {
capability: MLIC.to_owned(),
refusal: crate::container::PinRefusal::Revoked { capability: MLIC.to_owned(), revoked_by: Some("https://publisher.example".to_owned()) },
}));
}
/// WAI2 selection skips a rendition whose pinned set is revoked and picks the next.
#[test]
fn wai2_selection_skips_a_revoked_rendition() {
use crate::container::Rendition;
let (listing, model) = set();
let files: std::collections::BTreeMap<String, &[u8]> = [("model.wmb".to_owned(), model.as_slice())].into_iter().collect();
let held = [HeldBytes::Set { listing: &listing, files: &files }];
let m = WaiMulti::new(
"image",
"decode",
Value::Null,
vec![Rendition::media(MLIC, "int_mlicpp", b"a".to_vec()).with_prior(pin()), Rendition::media(PNG, "png", b"b".to_vec())],
);
let k = sink(&[digest()]);
let p = m.pick(|_| true, |c, p| pin::verify_bytes_with(c, p, &held, READABLE_FORMS, Some(&k)).map(|_| ())).unwrap();
assert_eq!(p.index, 1);
assert!(matches!(p.skipped.as_slice(), [(0, Skip::Pin(PinRefusal::Revoked { .. }))]), "{:?}", p.skipped);
let p = m.pick(|_| true, |c, p| pin::verify_bytes_with(c, p, &held, READABLE_FORMS, Some(&sink(&[]))).map(|_| ())).unwrap();
assert_eq!(p.index, 0);
}
}

View file

@ -288,7 +288,8 @@ 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`.
// from its spelling. Claims, and §7's key logs, grants and statuses, which jwp-receipts
// reads the same way; an OER receipt is read by `oer::load`.
#[cfg(feature = "receipt_claims")]
mod claim_text;
@ -306,6 +307,17 @@ pub mod jwp_delivery;
#[cfg(feature = "receipt_claims")]
pub mod energy_figure;
// jwp-receipts §7.1: what a `signer_id` names — an origin-bound `did:web`, an Ed25519
// `did:key`, or a label. Ungated and dependency-free, so every surface that shows a
// signer can say which.
pub mod signer_id;
// jwp-receipts §7: key discovery and signing authority — origin key logs, anchors, the
// confirmation reported beside a verdict and never in it, and the authorisation and
// revocation of decoder and reconstruct modules (§7.9). No network, no clock.
#[cfg(feature = "key_discovery")]
pub mod key_log;
// wai.world.model — generative-world transport: a signed, replay-equivalent action
// trace (the world CausalOp log) + per-frame provenance. The wire carries the
// action trace + a declared model_id, not the pixels; the sink renders. The neural

View file

@ -59,11 +59,18 @@ pub use video::DecodedVideo;
/// - `wai.prior.set`: every `*.sha256` file directly inside the path's directory
/// (the path itself if it is a directory), each a listing rooted there.
///
/// A key log, where set ([`ModelRegistry::with_key_log`]), runs SPEC §3.1's check 5:
/// a set whose digest it revokes is refused, and the sets of the capabilities the
/// sink's relying party holds to jwp-receipts §7.9 must be held under §7.9. It runs
/// over the set an unpinned decode reads too (SPEC §4, *Module authorisation*). A
/// registry without one does not run check 5.
///
/// The browser equivalent is the `NEURAL_MODELS` map in `wai-web/demo/index.html`.
#[derive(Default, Debug, Clone)]
pub struct ModelRegistry {
pub paths: HashMap<String, PathBuf>,
pub held: HashMap<String, Vec<PathBuf>>,
key_log: Option<std::sync::Arc<dyn crate::pin::PinAuthority + Send + Sync>>,
}
impl ModelRegistry {
@ -76,6 +83,16 @@ impl ModelRegistry {
self
}
/// Run SPEC §3.1's check 5 with `key_log`: the key log this sink holds
/// (`crate::key_log::SinkKeyLog`, feature `key_discovery`). A key log that names
/// the subject's origin (`SinkKeyLog::with_subject_origin`) holds for one envelope
/// only, so a registry given one decodes that envelope only; give each other
/// envelope a registry whose key log names its own subject's origin, or none.
pub fn with_key_log(mut self, key_log: impl crate::pin::PinAuthority + Send + Sync + 'static) -> Self {
self.key_log = Some(std::sync::Arc::new(key_log));
self
}
/// Hold a further artifact a pin over `capability` may resolve against.
pub fn hold(mut self, capability: impl Into<String>, path: impl Into<PathBuf>) -> Self {
self.held.entry(capability.into()).or_default().push(path.into());
@ -93,48 +110,83 @@ impl ModelRegistry {
/// The artifacts a pin over `capability` is checked against (see the type's doc).
fn held_artifacts(&self, capability: &str, set_form: bool) -> Vec<crate::pin::Held> {
use crate::pin::Held;
let paths = self.paths.get(capability).into_iter().chain(self.held.get(capability).into_iter().flatten());
let mut out = Vec::new();
for p in paths {
if set_form {
let dir = if p.is_dir() { p.clone() } else { p.parent().map(Path::to_path_buf).unwrap_or_default() };
let mut listings: Vec<PathBuf> = std::fs::read_dir(&dir).into_iter().flatten().flatten()
.map(|e| e.path())
.filter(|f| f.is_file() && f.extension().is_some_and(|x| x == "sha256"))
.collect();
listings.sort();
out.extend(listings.into_iter().map(Held::Listing));
} else if capability == crate::codecs::CAP_NEURAL_INT_SYNTH
&& p.file_name().is_some_and(|n| n != "int_synth.json")
{
out.push(Held::File(p.parent().map(|d| d.join("int_synth.json")).unwrap_or_default()));
} else if p.is_file() {
out.push(Held::File(p.clone()));
}
}
out
artifacts_at(capability, paths, set_form)
}
/// The parameters an unpinned decode through `capability` reads: its registered
/// artifact. At a registry with a key log, for a capability a pin applies to, check
/// 5 runs first over that artifact (SPEC §4, *Module authorisation*), identified by
/// each digest a pin over it could name: its file's SHA-256 where the capability's
/// set is one file, and the SHA-256 of each listing the registry finds for it in the
/// form `wai.prior.set`. The digests are read when the decode starts; an unpinned
/// decode then reads the path, as it always does. `capability` is registered.
fn unpinned(&self, capability: &str) -> Result<ParamSource, PinRefusal> {
let path = &self.paths[capability];
if let Some(k) = self.key_log.as_deref().filter(|_| crate::container::pin_applies_to(capability)) {
let one_file = crate::container::pin_shape(capability) == crate::codecs::ParameterSetShape::OneFile;
let mut digests = Vec::new();
let files = if one_file { artifacts_at(capability, [path], false) } else { Vec::new() };
for h in files.iter().chain(artifacts_at(capability, [path], true).iter()) {
let (crate::pin::Held::File(f) | crate::pin::Held::Listing(f)) = h;
if let Ok(bytes) = std::fs::read(f) {
digests.push(crate::pin::sha256_hex(&bytes));
}
}
k.check_unpinned(capability, &digests)?;
}
Ok(ParamSource::Unpinned(path.clone()))
}
}
/// The artifacts at `paths` a pin over `capability` is checked against: see
/// [`ModelRegistry`].
fn artifacts_at<'a>(capability: &str, paths: impl IntoIterator<Item = &'a PathBuf>, set_form: bool) -> Vec<crate::pin::Held> {
use crate::pin::Held;
let mut out = Vec::new();
for p in paths {
if set_form {
let dir = if p.is_dir() { p.clone() } else { p.parent().map(Path::to_path_buf).unwrap_or_default() };
let mut listings: Vec<PathBuf> = std::fs::read_dir(&dir).into_iter().flatten().flatten()
.map(|e| e.path())
.filter(|f| f.is_file() && f.extension().is_some_and(|x| x == "sha256"))
.collect();
listings.sort();
out.extend(listings.into_iter().map(Held::Listing));
} else if capability == crate::codecs::CAP_NEURAL_INT_SYNTH
&& p.file_name().is_some_and(|n| n != "int_synth.json")
{
out.push(Held::File(p.parent().map(|d| d.join("int_synth.json")).unwrap_or_default()));
} else if p.is_file() {
out.push(Held::File(p.clone()));
}
}
out
}
impl ModelRegistry {
/// Verify `prior` for `capability` against the parameter sets this sink holds
/// (SPEC §3.1), returning the verified bytes.
/// (SPEC §3.1), returning the verified bytes. Check 5 runs when the registry has a
/// key log.
pub fn verify(&self, capability: &str, prior: &Prior) -> Result<crate::pin::Verified, PinRefusal> {
let set_form = prior.form() == Some(crate::codecs::CAP_PRIOR_SET);
crate::pin::verify(capability, prior, &self.held_artifacts(capability, set_form), READABLE_PRIOR_FORMS)
let key_log = self.key_log.as_deref().map(|k| k as &dyn crate::pin::PinAuthority);
crate::pin::verify_with(capability, prior, &self.held_artifacts(capability, set_form), READABLE_PRIOR_FORMS, key_log)
}
/// The parameters a decode through `capability` reads: verified against `prior`
/// when a pin applies to the capability ([`crate::container::pin_applies_to`]),
/// otherwise the registered artifact, unverified — the honest meaning of an
/// unpinned decode.
/// unpinned decode — once a key log the registry holds has not refused it.
pub fn resolve_verified(&self, capability: &str, prior: Option<&Prior>) -> Result<ParamSource, DecodeError> {
let path = self.paths.get(capability)
.ok_or_else(|| DecodeError::ModelNotRegistered(capability.to_string()))?;
if !self.paths.contains_key(capability) {
return Err(DecodeError::ModelNotRegistered(capability.to_string()));
}
match prior {
Some(p) if crate::container::pin_applies_to(capability) => {
self.verify(capability, p).map(ParamSource::Pinned).map_err(DecodeError::PriorMismatch)
}
_ => Ok(ParamSource::Unpinned(path.clone())),
_ => self.unpinned(capability).map_err(DecodeError::PriorMismatch),
}
}
@ -289,16 +341,19 @@ pub fn decode_payload(capability: &str, payload: &[u8], params: &ParamSource) ->
/// SPEC §4 for a parsed `WAI1` envelope at a sink whose parameter sets `registry`
/// holds, through the capabilities `gate` admits. A refused pin makes its capability
/// unsupported for this envelope and dispatch moves on to the fallback; a pinned
/// decode refused with `set_incomplete` does too. When nothing decodes, the error is
/// step 6's: the reason kept for the capability (step 3) before the one kept for
/// the fallback (step 5), whether that reason is a refused pin or `set_incomplete`.
/// decode refused with `set_incomplete` does too, and so does an unpinned decode whose
/// set the registry's key log refuses. When nothing decodes, the error is step 6's:
/// the reason kept for the capability (step 3) before the one kept for the fallback
/// (step 5), whether that reason is a refused pin, `set_incomplete` or the key log's.
fn decode_envelope_with(
env: &Wai,
registry: &ModelRegistry,
gate: impl Fn(&str) -> Result<(), DecodeError>,
) -> Result<Decoded, DecodeError> {
let mut excluded: Vec<String> = Vec::new();
// The `set_incomplete` refusal, with the capability whose decode raised it.
// The first refusal raised after dispatch picked a capability — `set_incomplete`
// from a pinned decode, or the key log's refusal of an unpinned one — with that
// capability.
let mut incomplete: Option<(String, DecodeError)> = None;
loop {
let d = env.dispatch_verified(
@ -323,7 +378,16 @@ fn decode_envelope_with(
gate(d.capability)?;
let params = match d.verified {
Some(v) => ParamSource::Pinned(v),
None => ParamSource::Unpinned(registry.paths[d.capability].clone()),
None => match registry.unpinned(d.capability) {
Ok(p) => p,
Err(r) => {
excluded.push(d.capability.to_string());
if incomplete.is_none() {
incomplete = Some((d.capability.to_string(), DecodeError::PriorMismatch(r)));
}
continue;
}
},
};
match decode_payload(d.capability, &env.payload, &params) {
Err(e) if is_set_incomplete(&e) => {
@ -410,7 +474,8 @@ pub fn decode_envelope_strict(env: &Wai, registry: &ModelRegistry) -> Result<Dec
/// SPEC §7.1 for a parsed `WAI2` envelope: pick the first selectable entry whose
/// capability the registry holds, whose payload matches its `sha256`, and whose pin
/// resolves against a held parameter set, then decode it from the verified bytes. A
/// pinned decode refused with `set_incomplete` excludes that entry and picks again.
/// pinned decode refused with `set_incomplete`, or an unpinned entry whose set the
/// registry's key log refuses, excludes that entry and picks again.
/// Returns the picked index with the output, or [`DecodeError::NoRendition`] with a
/// reason per entry.
pub fn decode_multi(m: &WaiMulti, registry: &ModelRegistry) -> Result<(usize, Decoded), DecodeError> {
@ -427,8 +492,9 @@ fn decode_multi_where(
let picked = m.pick_where(
|i, meta| {
admit(i)?;
// An entry excluded after `set_incomplete` is reported with that
// reason below.
// An entry excluded after it was picked (`set_incomplete`, or the
// key log's refusal of its unpinned set) is reported with that reason
// below.
if !registry.advertises(&meta.capability) || incomplete.iter().any(|(x, _)| *x == i) {
return Err(Skip::NotEligible);
}
@ -440,7 +506,13 @@ fn decode_multi_where(
Ok(p) => {
let params = match p.verified {
Some(v) => ParamSource::Pinned(v),
None => ParamSource::Unpinned(registry.paths[&p.meta.capability].clone()),
None => match registry.unpinned(&p.meta.capability) {
Ok(params) => params,
Err(r) => {
incomplete.push((p.index, format!("{}: {r}", r.code())));
continue;
}
},
};
match decode_payload(&p.meta.capability, p.payload, &params) {
Err(e) if is_set_incomplete(&e) => {

View file

@ -226,6 +226,28 @@ pub enum HeldBytes<'a> {
Set { listing: &'a [u8], files: &'a BTreeMap<String, &'a [u8]> },
}
/// SPEC §3.1's check 5, run by a sink that holds a key log before it looks for the set
/// a pin names among what it holds: a set whose digest the key log revokes is refused,
/// and where the sink's relying party holds the capability's pinned parameter sets to
/// jwp-receipts §7.9, a set §7.9 does not hold is refused too.
/// `crate::key_log::SinkKeyLog` (feature `key_discovery`) implements it from the key
/// log a sink holds. A sink given no authority skips the check.
pub trait PinAuthority: std::fmt::Debug {
/// `Ok` when the key log does not revoke the set `prior` names — its `sha256` in
/// its `form` — and either the sink does not hold `capability`'s pinned sets to
/// §7.9 or §7.9 holds that set. Otherwise the refusal: `prior_revoked`
/// ([`PinRefusal::Revoked`]) or `prior_not_authorised`
/// ([`PinRefusal::NotAuthorised`]).
fn check(&self, capability: &str, prior: &Prior) -> Result<(), PinRefusal>;
/// The same check for an unpinned decode through `capability` (SPEC §4, *Module
/// authorisation*), over the set it reads, identified by `digests`: each digest a
/// pin over that set could name, as 64 lowercase hexadecimal digits. Refused with
/// `prior_revoked` when any of them is revoked, and, for a capability held to
/// §7.9, with `prior_not_authorised` unless §7.9 holds the set under one of them.
fn check_unpinned(&self, capability: &str, digests: &[String]) -> Result<(), PinRefusal>;
}
/// Which held artifact satisfied a pin: its index in the `held` slice given to
/// [`verify_bytes`], and, for a set, the verified files' paths.
#[derive(Debug, Clone, PartialEq, Eq)]
@ -238,17 +260,31 @@ pub enum VerifiedRef {
}
/// Run SPEC §3.1's checks for `prior` over `capability` against the parameter sets
/// in `held`, reading the forms in `readable`. The first check that fails is the
/// reason: `pin_malformed`, `form_not_registered`, `form_unreadable`,
/// `form_required`, `prior_not_held` / `digest_superseded`, `set_malformed`,
/// `part_mismatch`. (Check 5, `prior_revoked`, is reserved.) The checks read only
/// the pin, the registry, `readable` and the bytes, so two sinks that hold the same
/// bytes and read the same forms report the same reason.
/// in `held`, reading the forms in `readable`, at a sink with no key log: check 5 is
/// skipped. See [`verify_bytes_with`].
pub fn verify_bytes(
capability: &str,
prior: &Prior,
held: &[HeldBytes<'_>],
readable: &[&str],
) -> Result<VerifiedRef, PinRefusal> {
verify_bytes_with(capability, prior, held, readable, None)
}
/// Run SPEC §3.1's checks for `prior` over `capability` against the parameter sets
/// in `held`, reading the forms in `readable`, with check 5 run by `authority` where
/// the sink has one. The first check that fails is the reason: `pin_malformed`,
/// `form_not_registered`, `form_unreadable`, `form_required`, `prior_revoked` /
/// `prior_not_authorised`, `prior_not_held` / `digest_superseded`, `set_malformed`,
/// `part_mismatch`. Checks other than 5 read only the pin, the registry, `readable`
/// and the bytes, so two sinks that hold the same bytes and read the same forms
/// report the same reason; check 5 also reads what `authority` holds and its clock.
pub fn verify_bytes_with(
capability: &str,
prior: &Prior,
held: &[HeldBytes<'_>],
readable: &[&str],
authority: Option<&dyn PinAuthority>,
) -> Result<VerifiedRef, PinRefusal> {
// 1. pin_malformed (only `sha256`: a bad `supersedes` is ignored).
prior.check_syntax()?;
@ -258,7 +294,11 @@ pub fn verify_bytes(
if prior.form().is_none() && pin_shape(capability) != ParameterSetShape::OneFile {
return Err(PinRefusal::FormRequired { capability: capability.to_string() });
}
// 5. reserved: prior_revoked, keyed on (form, sha256).
// 5. prior_revoked / prior_not_authorised, keyed on (form, sha256): jwp-receipts
// §7.9 over the set the pin names, at a sink that holds the capability to it.
if let Some(a) = authority {
a.check(capability, prior)?;
}
let set_form = prior.form() == Some(CAP_PRIOR_SET);
let digest_of = |h: &HeldBytes<'_>| -> Option<String> {
match (h, set_form) {
@ -353,14 +393,30 @@ impl Verified {
/// [`verify_bytes`] over artifacts on disk. Each held file is read once and the
/// bytes that were hashed are the bytes returned; a file that cannot be read is not
/// held. For a listing, only the files of a listing whose digest matches the pin
/// are read.
/// are read. Check 5 is skipped; see [`verify_with`].
pub fn verify(capability: &str, prior: &Prior, held: &[Held], readable: &[&str]) -> Result<Verified, PinRefusal> {
// The cheap checks first, so a malformed or form-less pin reads no file.
verify_with(capability, prior, held, readable, None)
}
/// [`verify_bytes_with`] over artifacts on disk, as [`verify`] reads them. Check 5
/// runs before any file is read, so a set the sink's key log refuses is never read.
pub fn verify_with(
capability: &str,
prior: &Prior,
held: &[Held],
readable: &[&str],
authority: Option<&dyn PinAuthority>,
) -> Result<Verified, PinRefusal> {
// The cheap checks first, so a malformed or form-less pin, or one check 5
// refuses, reads no file.
prior.check_syntax()?;
prior.check_form(readable)?;
if prior.form().is_none() && pin_shape(capability) != ParameterSetShape::OneFile {
return Err(PinRefusal::FormRequired { capability: capability.to_string() });
}
if let Some(a) = authority {
a.check(capability, prior)?;
}
let set_form = prior.form() == Some(CAP_PRIOR_SET);
struct Loaded {
bytes: Vec<u8>,
@ -403,7 +459,8 @@ pub fn verify(capability: &str, prior: &Prior, held: &[Held], readable: &[&str])
HeldBytes::File(&l.bytes)
})
.collect();
match verify_bytes(capability, prior, &held_bytes, readable)? {
// Check 5 ran above, before any file was read.
match verify_bytes_with(capability, prior, &held_bytes, readable, None)? {
VerifiedRef::File { index } => Ok(Verified::File(loaded.swap_remove(index).bytes)),
VerifiedRef::Set { index, paths } => {
let l = loaded.swap_remove(index);

389
wai/wai-rs/src/signer_id.rs Normal file
View file

@ -0,0 +1,389 @@
//! What a `signer_id` names — `extensions/jwp-receipts.md` §7.1.
//!
//! Every WAI receipt family that signs a `signer_pubkey` signs a `signer_id` beside it.
//! This module reads that string, and nothing else: it has no dependency, no I/O and no
//! clock, so every surface that shows a signer can say what its id is before any key log
//! is consulted (the confirmation itself is `crate::key_log`, feature `key_discovery`).
//!
//! A `signer_id` has one of three forms:
//!
//! - **origin-bound** — `did:web:<host>[%3A<port>]#<key-id>`: it names the web origin
//! whose key log, published at `<origin>/.well-known/wai-keys`, lists the signer's key;
//! - **Ed25519 `did:key`** — `did:key:z` and the base58btc encoding of `0xed 0x01` and a
//! 32-byte key, in its one canonical spelling: it names that key and nothing else;
//! - **label** — any other string. A verifier reads nothing from a label.
//!
//! Each origin and key-id has one spelling, so a signer cannot be confirmed under a
//! second spelling of an origin a verifier anchored: a capital letter in the host, an
//! escape other than `%3A`, a path, a missing fragment, the default port written out and
//! an IP address are not the origin-bound form.
/// Where an origin publishes its key log, after the origin (jwp-receipts §7.1).
pub const KEY_LOG_PATH: &str = "/.well-known/wai-keys";
/// The form of a `signer_id` (jwp-receipts §7.1).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SignerIdForm {
/// `did:web:<host>[%3A<port>]#<key-id>`: the web origin (`https://<host>[:<port>]`)
/// and the key-id its key log lists the key under.
OriginBound { origin: String, key_id: String },
/// An Ed25519 `did:key` in its canonical spelling, naming this key.
DidKey([u8; 32]),
/// Anything else: the signer's own label, from which a verifier reads nothing.
Label,
}
/// The level a confirmation reports beside an item's own verdict (jwp-receipts §7).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Level {
/// An origin the verifier anchored lists the item's key, in force, with its authority.
Confirmed,
/// What the verifier holds contradicts the item's claim to speak for its `signer_id`.
Refused,
/// The verifier holds nothing it can confirm against.
Unconfirmed,
}
impl Level {
pub fn label(self) -> &'static str {
match self {
Level::Confirmed => "confirmed",
Level::Refused => "refused",
Level::Unconfirmed => "unconfirmed",
}
}
}
/// Read a `signer_id` under §7.1's grammar.
pub fn classify(signer_id: &str) -> SignerIdForm {
if let Some(rest) = signer_id.strip_prefix("did:web:")
&& let Some((authority, key_id)) = rest.split_once('#')
&& key_id_ok(key_id)
{
let (host, port) = match authority.split_once("%3A") {
Some((h, p)) => (h, Some(p)),
None => (authority, None),
};
if host_ok(host) && port.is_none_or(port_ok) {
let origin = match port {
Some(p) => format!("https://{host}:{p}"),
None => format!("https://{host}"),
};
return SignerIdForm::OriginBound { origin, key_id: key_id.to_owned() };
}
return SignerIdForm::Label;
}
if let Some(enc) = signer_id.strip_prefix("did:key:z")
&& let Some(bytes) = b58_decode(enc)
&& bytes.len() == 34
&& bytes[0] == 0xed
&& bytes[1] == 0x01
{
let key: [u8; 32] = bytes[2..].try_into().expect("34 bytes less the two-byte prefix");
// One spelling per key: the text must be what encoding the key gives.
if did_key(&key) == signer_id {
return SignerIdForm::DidKey(key);
}
}
SignerIdForm::Label
}
/// The Ed25519 `did:key` of a key: `did:key:z` + base58btc(`0xed 0x01` || key).
pub fn did_key(key: &[u8; 32]) -> String {
format!("did:key:z{}", multibase_ed25519(key))
}
/// The base58btc text (without the multibase `z`) of `0xed 0x01` || key — the
/// `publicKeyMultibase` of an Ed25519 key, less its leading `z`.
pub fn multibase_ed25519(key: &[u8; 32]) -> String {
let mut b = Vec::with_capacity(34);
b.extend_from_slice(&[0xed, 0x01]);
b.extend_from_slice(key);
b58_encode(&b)
}
/// Read a `publicKeyMultibase` value holding an Ed25519 key (`z` + base58btc of
/// `0xed 0x01` || key), in its canonical spelling.
pub fn read_multibase_ed25519(text: &str) -> Option<[u8; 32]> {
let enc = text.strip_prefix('z')?;
let b = b58_decode(enc)?;
if b.len() != 34 || b[0] != 0xed || b[1] != 0x01 {
return None;
}
let key: [u8; 32] = b[2..].try_into().ok()?;
(multibase_ed25519(&key) == enc).then_some(key)
}
/// The key log's location for an origin of §7.1's form.
pub fn key_log_url(origin: &str) -> String {
format!("{origin}{KEY_LOG_PATH}")
}
/// The `did:web` DID (no fragment) of an origin of §7.1's form, or `None` for a string
/// that is not one: `https://host` → `did:web:host`, `https://host:8443` →
/// `did:web:host%3A8443`.
pub fn did_web_of_origin(origin: &str) -> Option<String> {
let rest = origin.strip_prefix("https://")?;
let (host, port) = match rest.split_once(':') {
Some((h, p)) => (h, Some(p)),
None => (rest, None),
};
if !host_ok(host) || !port.is_none_or(port_ok) {
return None;
}
Some(match port {
Some(p) => format!("did:web:{host}%3A{p}"),
None => format!("did:web:{host}"),
})
}
/// Whether `origin` is an origin of §7.1's form: `https://` and a host, and a port where
/// one other than 443 is written.
pub fn origin_ok(origin: &str) -> bool {
did_web_of_origin(origin).is_some()
}
/// §7.4 step 1: the origin and key-id of an origin-bound `signer_id`, or the level and
/// reason label at which the confirmation stops.
pub fn step1(signer_id: &str, signer_pubkey: &[u8; 32]) -> Result<(String, String), (Level, &'static str)> {
match classify(signer_id) {
SignerIdForm::OriginBound { origin, key_id } => Ok((origin, key_id)),
SignerIdForm::DidKey(k) if k != *signer_pubkey => Err((Level::Refused, "signer_id_names_another_key")),
SignerIdForm::DidKey(_) | SignerIdForm::Label => Err((Level::Unconfirmed, "signer_id_not_origin_bound")),
}
}
/// What a surface that holds no key log can report about a signer: its level and reason
/// after §7.4 step 1, with an origin-bound id stopping at step 2 (`origin_not_anchored`).
pub fn level_without_anchors(signer_id: &str, signer_pubkey: &[u8; 32]) -> (Level, &'static str) {
match step1(signer_id, signer_pubkey) {
Ok(_) => (Level::Unconfirmed, "origin_not_anchored"),
Err(e) => e,
}
}
/// A host of §7.1: dot-separated labels of 1–63 characters from `a-z`, `0-9` and `-`,
/// none beginning or ending with `-`, at least two labels, the last not all digits, at
/// most 253 characters in all.
fn host_ok(host: &str) -> bool {
let labels: Vec<&str> = host.split('.').collect();
host.len() <= 253
&& labels.len() >= 2
&& labels.iter().all(|l| {
(1..=63).contains(&l.len())
&& l.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& !l.starts_with('-')
&& !l.ends_with('-')
})
&& !labels.last().is_some_and(|l| l.bytes().all(|b| b.is_ascii_digit()))
}
/// A port of §7.1: 1–65535 in decimal, no leading zero, not 443.
fn port_ok(p: &str) -> bool {
!p.is_empty()
&& p.len() <= 5
&& p.bytes().all(|b| b.is_ascii_digit())
&& !p.starts_with('0')
&& p.parse::<u32>().is_ok_and(|n| (1..=65535).contains(&n) && n != 443)
}
/// A key-id of §7.1: 1–64 characters from `A-Z`, `a-z`, `0-9`, `-`, `_` and `.`.
pub fn key_id_ok(k: &str) -> bool {
(1..=64).contains(&k.len()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
}
const B58: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
/// The length of the base58btc text of `0xed 0x01` and a 32-byte key: every such value
/// lies between 58^46 and 58^47, so its one spelling has exactly this many characters.
/// A longer text is not that spelling, and is refused before any decoding: decoding is
/// quadratic in its input, and a `signer_id` arrives from whoever signed the item.
const ED25519_B58_LEN: usize = 47;
/// base58btc (the Bitcoin alphabet), each leading zero byte written as `1`.
fn b58_encode(input: &[u8]) -> String {
let zeros = input.iter().take_while(|&&b| b == 0).count();
let mut digits: Vec<u8> = Vec::new(); // base-58 digits, least significant first
for &byte in &input[zeros..] {
let mut carry = u32::from(byte);
for d in digits.iter_mut() {
carry += u32::from(*d) << 8;
*d = (carry % 58) as u8;
carry /= 58;
}
while carry > 0 {
digits.push((carry % 58) as u8);
carry /= 58;
}
}
let mut s = String::with_capacity(zeros + digits.len());
s.extend(std::iter::repeat_n('1', zeros));
s.extend(digits.iter().rev().map(|&d| B58[d as usize] as char));
s
}
/// The inverse of [`b58_encode`] for a text of at most [`ED25519_B58_LEN`] characters;
/// `None` for a longer text or a character outside the alphabet.
fn b58_decode(s: &str) -> Option<Vec<u8>> {
if s.len() > ED25519_B58_LEN {
return None;
}
let zeros = s.bytes().take_while(|&b| b == b'1').count();
let mut bytes: Vec<u8> = Vec::new(); // base-256, least significant first
for c in s.bytes().skip(zeros) {
let mut carry = B58.iter().position(|&a| a == c)? as u32;
for b in bytes.iter_mut() {
carry += u32::from(*b) * 58;
*b = (carry & 0xff) as u8;
carry >>= 8;
}
while carry > 0 {
bytes.push((carry & 0xff) as u8);
carry >>= 8;
}
}
let mut out = vec![0u8; zeros];
out.extend(bytes.iter().rev());
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn unhex32(s: &str) -> [u8; 32] {
let v: Vec<u8> = (0..64).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect();
v.try_into().unwrap()
}
#[test]
fn origin_bound_form_and_its_origin() {
assert_eq!(
classify("did:web:publisher.example#d1"),
SignerIdForm::OriginBound { origin: "https://publisher.example".into(), key_id: "d1".into() }
);
assert_eq!(
classify("did:web:publisher.example%3A8443#m_1.v-2"),
SignerIdForm::OriginBound { origin: "https://publisher.example:8443".into(), key_id: "m_1.v-2".into() }
);
assert_eq!(key_log_url("https://publisher.example"), "https://publisher.example/.well-known/wai-keys");
assert_eq!(did_web_of_origin("https://publisher.example:8443").as_deref(), Some("did:web:publisher.example%3A8443"));
assert_eq!(did_web_of_origin("https://publisher.example:443"), None);
}
/// Each origin has one spelling: every other spelling is a label, so it can never be
/// confirmed under an origin the verifier anchored.
#[test]
fn boundary_spellings() {
let long_id = "k".repeat(65);
let labels = [
"did:web:Publisher.example#d1",
"did:web:publisher.example:users:a#k",
"did:web:publisher.example%3a8443#d1",
"did:web:publisher.example%3A443#d1",
"did:web:publisher.example%3A0443#d1",
"did:web:publisher.example%3A65536#d1",
"did:web:publisher.example%3A#d1",
"did:web:publisher.example",
"did:web:publisher.example#",
"did:web:127.0.0.1#d1",
"did:web:-a.example#d1",
"did:web:a-.example#d1",
"did:web:publisher..example#d1",
"did:web:localhost#d1",
"did:web:publisher.example#a/b",
"did:web:publisher.example#a#b",
"did:web:publisher.example/#d1",
];
for l in labels {
assert_eq!(classify(l), SignerIdForm::Label, "{l}");
}
assert_eq!(classify(&format!("did:web:publisher.example#{long_id}")), SignerIdForm::Label);
assert!(matches!(classify(&format!("did:web:publisher.example#{}", &long_id[..64])), SignerIdForm::OriginBound { .. }));
assert!(matches!(classify("did:web:publisher.example%3A65535#d1"), SignerIdForm::OriginBound { .. }));
assert!(matches!(classify("did:web:a1.b2c.example#d1"), SignerIdForm::OriginBound { .. }));
}
/// Every DID-shaped id the tree's pins, tests and demos write is a label (or, for a
/// canonical `did:key`, names one key): none is origin-bound.
#[test]
fn tree_ids_are_labels() {
for id in [
"did:key:pin",
"did:key:demo-lab",
"did:key:z6Mk",
"did:key:test",
"did:key:wai-quantum-cli",
"did:web:pin#audio",
"did:web:wai#prov",
"did:web:meter",
"did:web:publisher.example",
"did:example:meter",
"did:example:wai-meter",
] {
assert_eq!(classify(id), SignerIdForm::Label, "{id}");
}
}
/// Cross-checked against literals another pack in the repository computed
/// (`jcp/conformance/v1/vectors.json`) and against keys from the claim corpus's seeds.
#[test]
fn did_key_literals() {
let cases = [
(
"8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c",
"did:key:z6Mkon3Necd6NkkyfoGoHxid2znGc59LU3K7mubaRcFbLfLX",
),
(
"8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394",
"did:key:z6Mko9hTggMwjSTEaJaPUfE6tqcy2xvU6BnNq3e3o8qVBiyH",
),
];
for (k, d) in cases {
let key = unhex32(k);
assert_eq!(did_key(&key), d);
assert_eq!(classify(d), SignerIdForm::DidKey(key));
assert_eq!(step1(d, &key), Err((Level::Unconfirmed, "signer_id_not_origin_bound")));
assert_eq!(step1(d, &[0u8; 32]), Err((Level::Refused, "signer_id_names_another_key")));
assert_eq!(read_multibase_ed25519(&d["did:key:".len()..]), Some(key));
}
}
/// A `did:key` that decodes to a key but is not that key's one spelling — an extra
/// leading `1` adds a zero byte — is a label.
#[test]
fn did_key_noncanonical_is_label() {
let key = unhex32("8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c");
let canonical = did_key(&key);
let extra = canonical.replacen("did:key:z", "did:key:z1", 1);
assert_eq!(classify(&extra), SignerIdForm::Label);
assert_eq!(classify("did:key:z0OIl"), SignerIdForm::Label);
assert_eq!(b58_decode("1112").unwrap(), vec![0, 0, 0, 1]);
assert_eq!(b58_encode(&[0, 0, 0, 1]), "1112");
}
/// An over-long `did:key` is a label, and is refused before it is decoded: a
/// `signer_id` is chosen by whoever signed the item, and decoding is quadratic.
#[test]
fn long_did_key_is_refused_before_decoding() {
assert!(b58_decode(&"2".repeat(ED25519_B58_LEN + 1)).is_none());
assert!(b58_decode(&"2".repeat(ED25519_B58_LEN)).is_some());
let key = unhex32("8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c");
assert_eq!(did_key(&key).len(), "did:key:z".len() + ED25519_B58_LEN);
let long = format!("did:key:z{}", "2".repeat(4 << 20));
let t = std::time::Instant::now();
assert_eq!(classify(&long), SignerIdForm::Label);
assert_eq!(read_multibase_ed25519(&long["did:key:".len()..]), None);
assert_eq!(level_without_anchors(&long, &key), (Level::Unconfirmed, "signer_id_not_origin_bound"));
assert!(t.elapsed() < std::time::Duration::from_secs(5), "{:?}", t.elapsed());
}
#[test]
fn level_without_anchors_stops_at_step_two() {
assert_eq!(
level_without_anchors("did:web:publisher.example#d1", &[0; 32]),
(Level::Unconfirmed, "origin_not_anchored")
);
assert_eq!(level_without_anchors("did:key:wai-quantum-cli", &[0; 32]), (Level::Unconfirmed, "signer_id_not_origin_bound"));
}
}

View file

@ -220,6 +220,7 @@ fn every_decoded_output_states_a_sha256() {
("jwp-conformance", "JWP delivery receipts: receipt content, no decoded output"),
("oer-conformance", "OER/2 receipts: receipt content, no decoded output"),
("pin-conformance", "parameter-set pins: every digest there is a SHA-256 of a parameter set"),
("signer-conformance", "key logs, grants and statuses: signed documents, no decoded output"),
("byte-exact-conformance", "goldens over tests/vectors, whose sidecars state the SHA-256"),
("vectors/int_mlicpp", "expected.json states rgb_sha256 (RGB8)"),
("vectors/jpegai", "inputs only; the decoded pixels' SHA-256 is pinned by the JPEG AI e2e job"),

View file

@ -448,6 +448,78 @@ fn int_mlicpp_set_pin_through_the_strict_dispatch() {
assert_eq!(image_fnv(decode_envelope_strict(&env, &reg)), "7c0b9a4b3423d775");
}
/// SPEC §3.1 check 5 at the neural sink: a registry given a key log that holds
/// `wai.neural.int_mlicpp`'s pinned sets to jwp-receipts §7.9 refuses the committed set
/// once a key log revocation names its digest (`prior_revoked`), refuses it offline
/// (`prior_not_authorised`), and decodes it while a current grant holds it. A key log
/// that does not hold the capability to §7.9 still refuses the revoked set. The same
/// envelope with its pin removed decodes the registered set unpinned, and check 5 runs
/// over that set too (SPEC §4, *Module authorisation*).
#[cfg(feature = "key_discovery")]
#[test]
fn int_mlicpp_revoked_set_is_refused_through_the_key_log() {
use ed25519_dalek::SigningKey;
use wai::key_log::{commitment, Anchor, KeyEntry, KeyStatus, ModuleForm, ModuleGrant, Revision, SinkKeyLog};
const P: &str = "https://publisher.example";
const MLIC: &str = "wai.neural.int_mlicpp";
let key = |s: u8| SigningKey::from_bytes(&[s; 32]);
let d = int_mlicpp_dir();
let env = Wai::from_bytes(&std::fs::read(d.join("stream_set.wai")).unwrap()).unwrap();
let digest = wai::pin::sha256(&std::fs::read(d.join("model.sha256")).unwrap());
let sink_held = |revoked: Vec<[u8; 32]>, statuses: bool, held_to: bool| {
let unsigned = Revision {
origin: P.into(),
seq: 0,
prev: None,
issued_at: "2026-09-01T00:00:00Z".into(),
root: [0; 32],
next_root: commitment(&key(0x62).verifying_key().to_bytes()),
keys: vec![KeyEntry::active("modd", key(0x6b).verifying_key().to_bytes(), &["module/decoder"])],
revoked_modules: vec![],
grant_max_s: 3_600,
status_max_age_s: 600,
sig: [0; 64],
};
let r0 = Revision::seal(&key(0x61), unsigned.clone()).unwrap();
let r1 = Revision::seal(
&key(0x61),
Revision { seq: 1, prev: Some(r0.revision_hash()), issued_at: "2026-09-10T00:00:00Z".into(), revoked_modules: revoked, ..unsigned },
)
.unwrap();
let grant = ModuleGrant::seal(&key(0x6b), "did:web:publisher.example#modd", P, digest, ModuleForm::Decoder, &[MLIC],
"2026-09-20T05:30:00Z", "2026-09-20T06:30:00Z").unwrap().to_json();
let st = if statuses { vec![KeyStatus::seal(&key(0x61), &r1, "2026-09-20T05:55:00Z").to_json()] } else { vec![] };
let k = SinkKeyLog::new(vec![r0.clone(), r1], vec![Anchor::pinned(P, r0.revision_hash())])
.with_statuses(st)
.with_grants(vec![grant])
.at("2026-09-20T06:00:00Z");
if held_to { k.hold_pinned_sets(MLIC, &[P]) } else { k }
};
let sink = |revoked: Vec<[u8; 32]>, statuses: bool| sink_held(revoked, statuses, true);
let reg = |k: SinkKeyLog| ModelRegistry::new().register(MLIC, d.join("model.wmb")).with_key_log(k);
assert_eq!(refusal(decode_envelope(&env, &reg(sink(vec![digest], true)))), "prior_revoked");
assert_eq!(refusal(decode_envelope(&env, &reg(sink(vec![], false)))), "prior_not_authorised");
assert_eq!(image_fnv(decode_envelope_strict(&env, &reg(sink(vec![], true)))), "7c0b9a4b3423d775");
assert_eq!(refusal(decode_envelope(&env, &reg(sink_held(vec![digest], true, false)))), "prior_revoked");
assert_eq!(image_fnv(decode_envelope(&env, &reg(sink_held(vec![], false, false)))), "7c0b9a4b3423d775");
let mut manifest = env.manifest.clone();
manifest.conditioning.prior = None;
let unpinned = Wai::new(manifest, env.payload.clone());
let plain = ModelRegistry::new().register(MLIC, d.join("model.wmb"));
assert_eq!(image_fnv(decode_envelope(&unpinned, &plain)), "7c0b9a4b3423d775", "no key log: unpinned decodes");
assert_eq!(refusal(decode_envelope(&unpinned, &reg(sink(vec![digest], true)))), "prior_revoked");
assert_eq!(refusal(decode_envelope(&unpinned, &reg(sink_held(vec![digest], true, false)))), "prior_revoked");
assert_eq!(refusal(decode_envelope(&unpinned, &reg(sink(vec![], false)))), "prior_not_authorised");
assert_eq!(image_fnv(decode_envelope(&unpinned, &reg(sink(vec![], true)))), "7c0b9a4b3423d775");
assert_eq!(image_fnv(decode_envelope(&unpinned, &reg(sink_held(vec![], false, false)))), "7c0b9a4b3423d775");
// The bundle's own digest, which a form-less pin names, is checked too.
let bundle = wai::pin::sha256(&std::fs::read(d.join("model.wmb")).unwrap());
assert_eq!(refusal(decode_envelope(&unpinned, &reg(sink_held(vec![bundle], true, false)))), "prior_revoked");
let refused = reg(sink(vec![digest], true)).resolve_verified(MLIC, None);
assert!(matches!(refused, Err(DecodeError::PriorMismatch(ref r)) if r.code() == "prior_revoked"), "resolve_verified checks an unpinned set too");
}
/// The int_synth decode reads `int_synth.json` beside the registered path, so that
/// one file is its parameter set and what a form-less pin covers (SPEC §5). The
/// gradient corpus vector, pinned by the digest of its spec, decodes to the corpus

View file

@ -0,0 +1,160 @@
//! No signer id written in the WAI tree names a real web origin (jwp-receipts §7.1, §7.8).
//!
//! An origin-bound `signer_id` — `did:web:<host>[%3A<port>]#<key-id>` — says that the
//! origin's key log lists the signer's key. Every key the tree signs with comes from a
//! seed published in it, and a published key attests nothing, so no id the tree writes may
//! name an origin a verifier could anchor, nor a domain name at all, even in a spelling
//! that is a label today. This test reads every tracked `.rs`, `.json` and `.js` file
//! under `wai/` and checks each `did:web:` and `did:key:` literal: a `did:web` literal
//! whose host is a domain name, with or without a fragment, must be under a reserved
//! example name (`.example`, `.test`, `.invalid`, RFC 2606), which no one can publish a
//! key log for; an origin-bound one must be too; anything else is a label or an Ed25519
//! `did:key`, which names one key and no origin. `signer-conformance/`, whose vectors are
//! about such ids, is skipped.
//!
//! The files are listed with `git ls-files`, so an untracked scratch file does not fail
//! it. Where git cannot list them — not installed, or refusing a checkout another user
//! owns — the test walks the directory instead, leaving out build output and dependencies.
use std::path::{Path, PathBuf};
use std::process::Command;
use wai::signer_id::{classify, SignerIdForm};
const RESERVED: &[&str] = &[".example", ".test", ".invalid"];
const EXTENSIONS: &[&str] = &["rs", "json", "js"];
fn tracked(wai: &Path) -> Vec<PathBuf> {
let listed = Command::new("git")
.arg("-C")
.arg(wai)
.args(["ls-files", "-z", "--", "*.rs", "*.json", "*.js"])
.output()
.ok()
.filter(|o| o.status.success() && !o.stdout.is_empty());
match listed {
Some(out) => out
.stdout
.split(|b| *b == 0)
.filter(|p| !p.is_empty())
.map(|p| wai.join(String::from_utf8_lossy(p).as_ref()))
.collect(),
None => {
eprintln!("git cannot list {}; walking the directory instead", wai.display());
let mut out = Vec::new();
walk(wai, &mut out);
out
}
}
}
/// Every file with one of [`EXTENSIONS`] under `dir`, leaving out hidden directories,
/// build output and dependencies.
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for e in entries.flatten() {
let p = e.path();
let name = e.file_name().to_string_lossy().into_owned();
if p.is_dir() {
if !name.starts_with('.') && !matches!(name.as_str(), "target" | "node_modules" | "pkg" | "dist") {
walk(&p, out);
}
} else if p.extension().and_then(|x| x.to_str()).is_some_and(|x| EXTENSIONS.contains(&x)) {
out.push(p);
}
}
}
/// The host a `did:web:` literal names, where it is a domain name — dot-separated labels
/// of letters, digits and `-`, the last not all digits — or `None`.
fn domain_of(lit: &str) -> Option<String> {
let rest = lit.strip_prefix("did:web:")?;
let host = rest.split(['#', '%', ':', '/']).next().unwrap_or("").to_ascii_lowercase();
let labels: Vec<&str> = host.split('.').collect();
let domain = labels.len() >= 2
&& labels.iter().all(|l| !l.is_empty() && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-'))
&& !labels.last().is_some_and(|l| l.bytes().all(|b| b.is_ascii_digit()));
domain.then_some(host)
}
fn reserved(host: &str) -> bool {
RESERVED.iter().any(|t| host.ends_with(t))
}
/// Every `did:web:…` and `did:key:…` run of identifier characters in `text`.
fn literals(text: &str) -> Vec<String> {
let ok = |c: char| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '%' | '#');
let mut out = Vec::new();
for start in ["did:web:", "did:key:"] {
let mut from = 0;
while let Some(i) = text[from..].find(start) {
let at = from + i;
let end = text[at..].find(|c: char| !ok(c)).map_or(text.len(), |e| at + e);
out.push(text[at..end].trim_end_matches(['.', ':']).to_owned());
from = end.max(at + start.len());
}
}
out
}
#[test]
fn every_signer_id_in_the_tree_is_a_label() {
let wai = Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("wai-rs sits in wai/").to_path_buf();
let files = tracked(&wai);
assert!(files.len() > 50, "too few files listed under {}: {}", wai.display(), files.len());
let mut bad = Vec::new();
let mut seen = 0usize;
for f in files {
let rel = f.strip_prefix(&wai).unwrap_or(&f).to_string_lossy().replace('\\', "/");
if rel.starts_with("signer-conformance/") {
continue;
}
let Ok(text) = std::fs::read_to_string(&f) else { continue };
for lit in literals(&text) {
seen += 1;
if let Some(host) = domain_of(&lit)
&& !reserved(&host)
{
bad.push(format!("{rel}: {lit} names the domain {host}"));
continue;
}
if let SignerIdForm::OriginBound { origin, .. } = classify(&lit) {
let host = origin.trim_start_matches("https://").split(':').next().unwrap_or("");
if !reserved(host) {
bad.push(format!("{rel}: {lit} names the origin {origin}"));
}
}
}
}
assert!(seen > 0, "no did: literal found: the scan read nothing");
assert!(bad.is_empty(), "signer ids naming a domain outside reserved names:\n{}", bad.join("\n"));
}
/// A `did:web` literal naming a domain is caught with or without a fragment; one under a
/// reserved name, or with a host that is not a domain name, is not. The caught cases are
/// spelled in two pieces, so this file does not hold what the scan above refuses, and
/// their top-level label is not a delegated one.
#[test]
fn a_domain_is_caught_whatever_the_spelling() {
let w = |rest: &str| format!("{}{rest}", concat!("did:", "web:"));
for (lit, caught) in [
(w("wai.signer.zz"), true),
(w("Wai.Signer.zz#k"), true),
(w("wai.signer.zz%3A8443#k"), true),
(w("wai.signer.zz:users:a"), true),
(w("publisher.example"), false),
(w("publisher.example%3A8443#k"), false),
(w("wai#prov"), false),
(w("meter"), false),
(w("127.0.0.1#d1"), false),
] {
assert_eq!(domain_of(&lit).is_some_and(|h| !reserved(&h)), caught, "{lit}");
}
}
#[test]
fn the_scan_finds_what_it_must() {
let found = literals(r#"x "did:web:wai.example#m", `did:key:wai-${k}` did:web:a.example%3A8443#k1. did:key:z6Mk"#);
assert_eq!(found, vec!["did:web:wai.example#m", "did:web:a.example%3A8443#k1", "did:key:wai-", "did:key:z6Mk"]);
assert!(matches!(classify(&found[0]), SignerIdForm::OriginBound { .. }));
}

View file

@ -0,0 +1,319 @@
//! `wai-keys` end to end (jwp-receipts §7): a key log built from operator-supplied seeds
//! anchors, a revocation appended to it still anchors and moves an anchor forward, a
//! rotation by the committed key anchors, one changed byte refuses the log, a published
//! test seed is refused without the flag that says the log is a test log, a seed is never
//! printed, a status and a module grant come out signed, and the DID document it emits is
//! consistent with the log while one with a key missing is not.
//!
//! Seeds are the corpus's published one-byte seeds, so every run passes
//! `--published-test-seed`, except the one that checks the refusal.
use ed25519_dalek::SigningKey;
use std::path::PathBuf;
use std::process::{Command, Output};
use wai::key_log::{read_log, KeyStatus, ModuleGrant};
const BIN: &str = env!("CARGO_BIN_EXE_wai-keys");
const ORIGIN: &str = "https://publisher.example";
fn pk(seed: u8) -> String {
SigningKey::from_bytes(&[seed; 32]).verifying_key().to_bytes().iter().map(|b| format!("{b:02x}")).collect()
}
struct Dir(PathBuf);
impl Drop for Dir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
impl Dir {
fn new(tag: &str) -> Dir {
let d = std::env::temp_dir().join(format!("wai-keys-cli-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
Dir(d)
}
fn file(&self, name: &str, contents: &str) -> String {
let p = self.0.join(name);
std::fs::write(&p, contents).unwrap();
p.to_string_lossy().into_owned()
}
fn seed(&self, seed: u8) -> String {
self.file(&format!("seed-{seed:02x}"), &format!("{}\n", format!("{seed:02x}").repeat(32)))
}
}
fn run(args: &[&str]) -> Output {
Command::new(BIN).args(args).output().expect("run wai-keys")
}
fn stdout(o: &Output) -> String {
String::from_utf8_lossy(&o.stdout).into_owned()
}
fn ok(o: Output) -> String {
assert!(o.status.success(), "wai-keys failed: {}{}", stdout(&o), String::from_utf8_lossy(&o.stderr));
stdout(&o)
}
fn never_prints(o: &Output, seed: u8) {
let s = format!("{seed:02x}").repeat(32);
assert!(!stdout(o).contains(&s) && !String::from_utf8_lossy(&o.stderr).contains(&s), "seed {seed:#x} printed");
}
fn init(d: &Dir) -> String {
let root = d.seed(0x61);
let o = run(&[
"init",
"--origin",
ORIGIN,
"--root-seed-file",
&root,
"--next-root-key",
&pk(0x62),
"--issued-at",
"2026-09-01T00:00:00Z",
"--key",
&format!("id=d1,key={},may_sign=delivery|receipt", pk(0x63)),
"--key",
&format!("id=modd,key={},may_sign=module/decoder", pk(0x6b)),
"--grant-max-s",
"43200",
"--status-max-age-s",
"3600",
"--published-test-seed",
]);
never_prints(&o, 0x61);
d.file("log.json", &ok(o))
}
fn verify(log: &str, extra: &[&str]) -> Output {
let mut a = vec!["verify", log, "--origin", ORIGIN];
a.extend_from_slice(extra);
run(&a)
}
#[test]
fn init_append_rotate_and_verify() {
let d = Dir::new("chain");
let log = init(&d);
let first = ok(verify(&log, &[]));
assert!(first.starts_with("anchored revision=") && first.contains("anchor=fetched"), "{first}");
assert!(first.trim_end().ends_with("note=published_test_key"), "{first}");
let anchor0 = first.split_whitespace().nth(1).unwrap().trim_start_matches("revision=").to_owned();
// A revocation and a revoked module, appended under the same root.
let root = d.seed(0x61);
let digest = "ab".repeat(32);
let o = run(&[
"append",
&log,
"--root-seed-file",
&root,
"--issued-at",
"2026-09-02T00:00:00Z",
"--revoke",
"d1@2026-09-01T12:00:00Z",
"--revoke-module",
&digest,
"--add",
&format!("id=d2,key={},may_sign=delivery", pk(0x66)),
"--published-test-seed",
]);
never_prints(&o, 0x61);
let log2 = d.file("log2.json", &ok(o));
let revs = read_log(ORIGIN, &std::fs::read_to_string(&log2).unwrap()).unwrap();
assert_eq!(revs.len(), 2);
assert_eq!(revs[1].keys[0].compromised_since.as_deref(), Some("2026-09-01T12:00:00Z"));
assert_eq!(revs[1].revoked_modules.len(), 1);
// Anchored afresh, and as a later retrieval that moves an anchor forward.
assert!(ok(verify(&log2, &["--pinned"])).contains("anchor=pinned"));
let fwd = ok(verify(&log2, &["--anchor", &anchor0, "--held", &log]));
assert!(fwd.starts_with(&format!("anchored revision={}", hex(&revs[1].revision_hash()))), "{fwd}");
// An older log retrieved after the newer one was anchored is a rollback.
let anchor1 = hex(&revs[1].revision_hash());
let back = verify(&log, &["--anchor", &anchor1, "--held", &log2]);
assert_eq!(back.status.code(), Some(1));
assert_eq!(stdout(&back).trim(), "not_anchored reason=key_log_rolled_back");
// A rotation, signed by the committed key, naming its own successor.
let committed = d.seed(0x62);
let log3 = d.file(
"log3.json",
&ok(run(&[
"append",
&log2,
"--rotate-with-seed-file",
&committed,
"--next-root-key",
&pk(0x68),
"--issued-at",
"2026-09-03T00:00:00Z",
"--published-test-seed",
])),
);
assert!(ok(verify(&log3, &[])).starts_with("anchored"));
// The root in use cannot change the commitment without rotating.
let o = run(&["append", &log2, "--root-seed-file", &root, "--next-root-key", &pk(0x68), "--issued-at", "2026-09-03T00:00:00Z", "--published-test-seed"]);
assert_eq!(o.status.code(), Some(2));
// A status for the head, and a module grant.
let st = ok(run(&["status", &log3, "--root-seed-file", &committed, "--issued-at", "2026-09-03T01:00:00Z"]));
let st = KeyStatus::from_json(st.trim()).expect("a status");
let revs3 = read_log(ORIGIN, &std::fs::read_to_string(&log3).unwrap()).unwrap();
assert!(st.verifies_under(revs3.last().unwrap()));
let module = d.file("module.wasm", "\0asm a module");
let g = ok(run(&[
"grant",
"--origin",
ORIGIN,
"--seed-file",
&d.seed(0x6b),
"--key-id",
"modd",
"--module-file",
&module,
"--form",
"decoder",
"--capability",
"wai.neural.int_synth",
"--not-before",
"2026-09-03T00:00:00Z",
"--not-after",
"2026-09-03T12:00:00Z",
"--published-test-seed",
]));
let g = ModuleGrant::from_json(g.trim()).expect("a grant that verifies");
assert_eq!(g.module, wai::key_log::module_digest(b"\0asm a module"));
assert_eq!(g.signer_id, "did:web:publisher.example#modd");
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
#[test]
fn a_changed_byte_is_refused() {
let d = Dir::new("tamper");
let log = init(&d);
let text = std::fs::read_to_string(&log).unwrap();
let i = text.find("\"sig\":\"").unwrap() + 7;
let mut b = text.into_bytes();
b[i] = if b[i] == b'0' { b'1' } else { b'0' };
let bad = d.file("bad.json", std::str::from_utf8(&b).unwrap());
let o = verify(&bad, &[]);
assert_eq!(o.status.code(), Some(1));
assert_eq!(stdout(&o).trim(), "not_anchored reason=key_log_signature_invalid");
}
#[test]
fn a_published_test_seed_is_refused_without_the_flag() {
let d = Dir::new("published");
let root = d.seed(0x61);
let o = run(&[
"init",
"--origin",
ORIGIN,
"--root-seed-file",
&root,
"--next-root-key",
&pk(0x62),
"--issued-at",
"2026-09-01T00:00:00Z",
"--key",
&format!("id=d1,key={},may_sign=delivery", pk(0x63)),
]);
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("refusing a published test key"));
assert!(o.stdout.is_empty());
never_prints(&o, 0x61);
// Each command that puts a key into a log or signs with one refuses a published key
// without the flag: an append that adds one, a rotation to one, and a grant.
let log = init(&d);
let refused = |o: Output| {
assert_eq!(o.status.code(), Some(2), "{}", String::from_utf8_lossy(&o.stderr));
assert!(String::from_utf8_lossy(&o.stderr).contains("refusing a published test key"));
assert!(o.stdout.is_empty());
};
let add = format!("id=d2,key={},may_sign=delivery", pk(0x66));
refused(run(&["append", &log, "--root-seed-file", &root, "--issued-at", "2026-09-02T00:00:00Z", "--add", &add]));
let committed = d.seed(0x62);
refused(run(&[
"append",
&log,
"--rotate-with-seed-file",
&committed,
"--next-root-key",
&pk(0x68),
"--issued-at",
"2026-09-02T00:00:00Z",
]));
let module = d.file("module.wasm", "\0asm a module");
refused(run(&[
"grant",
"--origin",
ORIGIN,
"--seed-file",
&d.seed(0x6b),
"--key-id",
"modd",
"--module-file",
&module,
"--form",
"decoder",
"--capability",
"wai.neural.int_synth",
"--not-before",
"2026-09-03T00:00:00Z",
"--not-after",
"2026-09-03T12:00:00Z",
]));
}
/// A later retrieval checked against a pinned anchor: the record stays pinned and keeps
/// the revision that was pinned (§7.3), which `--held-pinned` lets an operator check.
#[test]
fn a_pinned_anchor_stays_pinned_on_a_forward_move() {
let d = Dir::new("heldpinned");
let log = init(&d);
let root = d.seed(0x61);
let add = format!("id=d2,key={},may_sign=delivery", pk(0x66));
let log2 = d.file(
"log2.json",
&ok(run(&["append", &log, "--root-seed-file", &root, "--issued-at", "2026-09-02T00:00:00Z", "--add", &add, "--published-test-seed"])),
);
let revs = read_log(ORIGIN, &std::fs::read_to_string(&log2).unwrap()).unwrap();
let (r0, r1) = (hex(&revs[0].revision_hash()), hex(&revs[1].revision_hash()));
let pinned = ok(verify(&log2, &["--anchor", &r0, "--held", &log, "--held-pinned"]));
assert_eq!(pinned.trim(), format!("anchored revision={r1} anchor=pinned pinned_revision={r0} note=published_test_key"));
let fetched = ok(verify(&log2, &["--anchor", &r0, "--held", &log]));
assert_eq!(fetched.trim(), format!("anchored revision={r1} anchor=fetched note=published_test_key"));
// The flag describes an anchor already held, and needs one.
assert_eq!(verify(&log2, &["--held-pinned"]).status.code(), Some(2));
}
#[test]
fn did_json_is_consistent_and_a_difference_is_reported() {
let d = Dir::new("did");
let log = init(&d);
let doc = ok(run(&["did-json", &log]));
assert!(doc.contains("\"id\": \"did:web:publisher.example\"") && doc.contains("did:web:publisher.example#d1"), "{doc}");
let did = d.file("did.json", &doc);
assert!(ok(run(&["check-did", &log, &did])).starts_with("consistent"));
let mut v: serde_json::Value = serde_json::from_str(&doc).unwrap();
v["verificationMethod"].as_array_mut().unwrap().retain(|m| m["id"] != "did:web:publisher.example#d1");
let missing = d.file("did-missing.json", &v.to_string());
let o = run(&["check-did", &log, &missing]);
assert_eq!(o.status.code(), Some(1));
assert!(stdout(&o).contains("did:web:publisher.example#d1: active in the key log, missing from the DID document"), "{}", stdout(&o));
}
#[test]
fn the_seed_file_must_be_a_seed() {
let d = Dir::new("seedfile");
let bad = d.file("seed", "not a seed");
let o = run(&["init", "--origin", ORIGIN, "--root-seed-file", &bad, "--next-root-key", &pk(0x62), "--issued-at", "2026-09-01T00:00:00Z"]);
assert_eq!(o.status.code(), Some(2));
assert!(!String::from_utf8_lossy(&o.stderr).contains("not a seed"));
}

View file

@ -749,7 +749,7 @@ ui.seal.addEventListener('click', () => {
if (!sink) return;
try {
const seed = crypto.getRandomValues(new Uint8Array(32));
const signerId = live ? `did:key:wai-${live.pub.slice(0, 16)}` : 'did:web:wai.transaction.science';
const signerId = live ? `did:key:wai-${live.pub.slice(0, 16)}` : 'did:example:wai-world';
const worldHash = sink.world_hash_hex();
const head = chainHead(worldHash);
const groupId = head ? BigInt(head.group_id + 1) : 0n;