wai: key discovery, signing authority and module revocation #134
44 changed files with 12865 additions and 157 deletions
52
.github/workflows/wai-conformance.yml
vendored
52
.github/workflows/wai-conformance.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
5
.github/workflows/wai-energy-receipts.yml
vendored
5
.github/workflows/wai-energy-receipts.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
23
.github/workflows/wai-neural-gates.yml
vendored
23
.github/workflows/wai-neural-gates.yml
vendored
|
|
@ -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
37
.github/workflows/wai-section-refs.yml
vendored
Normal 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
|
||||
92
scripts/check-wai-section-refs.py
Normal file
92
scripts/check-wai-section-refs.py
Normal 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())
|
||||
139
wai/CHANGELOG.md
139
wai/CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
108
wai/SPEC.md
108
wai/SPEC.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
3
wai/signer-conformance/.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# The pinned corpus is compared byte-for-byte with what the generator writes (LF), on
|
||||
# every platform of the CI matrix. Keep a CRLF checkout from turning that into a failure.
|
||||
* text eol=lf
|
||||
79
wai/signer-conformance/README.md
Normal file
79
wai/signer-conformance/README.md
Normal 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
|
||||
754
wai/signer-conformance/signer_verify.py
Normal file
754
wai/signer-conformance/signer_verify.py
Normal 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))
|
||||
4150
wai/signer-conformance/vectors.json
Normal file
4150
wai/signer-conformance/vectors.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
492
wai/wai-rs/src/bin/wai_keys.rs
Normal file
492
wai/wai-rs/src/bin/wai_keys.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<_>>());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
126
wai/wai-rs/src/bin/wai_signer_vectors.rs
Normal file
126
wai/wai-rs/src/bin/wai_signer_vectors.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
|
|
|
|||
|
|
@ -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
1956
wai/wai-rs/src/key_log.rs
Normal file
File diff suppressed because it is too large
Load diff
1906
wai/wai-rs/src/key_log/corpus.rs
Normal file
1906
wai/wai-rs/src/key_log/corpus.rs
Normal file
File diff suppressed because it is too large
Load diff
158
wai/wai-rs/src/key_log/revocation_tests.rs
Normal file
158
wai/wai-rs/src/key_log/revocation_tests.rs
Normal 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");
|
||||
}
|
||||
577
wai/wai-rs/src/key_log/tests.rs
Normal file
577
wai/wai-rs/src/key_log/tests.rs
Normal 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(¬_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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, ¶ms) {
|
||||
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, ¶ms) {
|
||||
Err(e) if is_set_incomplete(&e) => {
|
||||
|
|
|
|||
|
|
@ -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
389
wai/wai-rs/src/signer_id.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -448,6 +448,78 @@ fn int_mlicpp_set_pin_through_the_strict_dispatch() {
|
|||
assert_eq!(image_fnv(decode_envelope_strict(&env, ®)), "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, ®(sink(vec![digest], true)))), "prior_revoked");
|
||||
assert_eq!(refusal(decode_envelope(&env, ®(sink(vec![], false)))), "prior_not_authorised");
|
||||
assert_eq!(image_fnv(decode_envelope_strict(&env, ®(sink(vec![], true)))), "7c0b9a4b3423d775");
|
||||
assert_eq!(refusal(decode_envelope(&env, ®(sink_held(vec![digest], true, false)))), "prior_revoked");
|
||||
assert_eq!(image_fnv(decode_envelope(&env, ®(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, ®(sink(vec![digest], true)))), "prior_revoked");
|
||||
assert_eq!(refusal(decode_envelope(&unpinned, ®(sink_held(vec![digest], true, false)))), "prior_revoked");
|
||||
assert_eq!(refusal(decode_envelope(&unpinned, ®(sink(vec![], false)))), "prior_not_authorised");
|
||||
assert_eq!(image_fnv(decode_envelope(&unpinned, ®(sink(vec![], true)))), "7c0b9a4b3423d775");
|
||||
assert_eq!(image_fnv(decode_envelope(&unpinned, ®(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, ®(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
|
||||
|
|
|
|||
160
wai/wai-rs/tests/signer_ids_are_labels.rs
Normal file
160
wai/wai-rs/tests/signer_ids_are_labels.rs
Normal 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 { .. }));
|
||||
}
|
||||
319
wai/wai-rs/tests/wai_keys_cli.rs
Normal file
319
wai/wai-rs/tests/wai_keys_cli.rs
Normal 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"));
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue