open-standards/wai/signer-conformance/signer_verify.py
Transaction Science 793ed1f2dc
All checks were successful
wai-app and Create / wai-app · does this change touch it (pull_request) Successful in 7s
wai-audit collector and player integration / does this change touch it (pull_request) Successful in 10s
joule-moq-room room service / joule-moq-room · does this change touch it (pull_request) Successful in 12s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (pull_request) Successful in 14s
WAI operation catalogue / does this change touch it (pull_request) Successful in 13s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (pull_request) Successful in 13s
joule-moq-web browser client / joule-moq-web · does this change touch it (pull_request) Successful in 14s
WAI examples / WAI examples · does this change touch them (pull_request) Successful in 7s
WAI HTTP delivery / WAI HTTP · does this change touch it (pull_request) Successful in 6s
wai-fleet service / does this change touch it (pull_request) Successful in 14s
WAI neural sink gates / does this change touch it (pull_request) Successful in 13s
wai-pack / wai-pack · does this change touch it (pull_request) Successful in 12s
wai-quantum for JavaScript / does this change touch it (pull_request) Successful in 9s
wai-ledger service / does this change touch it (pull_request) Successful in 14s
wai-ops-gpu integer operators on GPUs / does this change touch it (pull_request) Successful in 14s
wai-key-proxy / does this change touch it (pull_request) Successful in 16s
WAI conformance / does this change touch it (pull_request) Successful in 31s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 32s
wai-quantum studio and desktop / does this change touch it (pull_request) Successful in 6s
WAI energy receipts / does this change touch it (pull_request) Successful in 33s
wai-sink-android native sink library / does this change touch it (pull_request) Successful in 8s
wai-sink-swift native sink package / does this change touch it (pull_request) Successful in 7s
WAI Studio / does this change touch it (pull_request) Successful in 8s
wai-web reach builds / wai-web reach · does this change touch it (pull_request) Successful in 9s
wai-web wasm32 / does this change touch it (pull_request) Successful in 25s
WAI HTTP delivery / HTTP corpus · every patch and mutation in exactly one shard (pull_request) Successful in 5s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (pull_request) Successful in 55s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (pull_request) Successful in 1m11s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 1m58s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (pull_request) Successful in 1m22s
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (pull_request) Successful in 1m51s
wai-audit collector and player integration / collector · linux (pull_request) Successful in 1m32s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (pull_request) Successful in 2m31s
WAI neural sink gates / model-free refusals of the neural sink (pull_request) Successful in 1m51s
WAI neural sink gates / the exact JPEG-AI decoder reports its path (pinned inputs, required) (pull_request) Successful in 2m2s
WAI conformance / fast integer kernels, every x86_64 kernel set (AVX-512 silicon) (pull_request) Successful in 3m36s
joule-moq-room room service / joule-moq-room, every target (pull_request) Successful in 4m32s
wai-fleet service / service · linux (pull_request) Successful in 2m42s
wai-key-proxy / proxy · linux (pull_request) Successful in 57s
wai-pack / wai-pack · linux (pull_request) Successful in 3m19s
WAI conformance / faces from keypoints under wasm32-wasip2 (wasmtime) (pull_request) Successful in 1m30s
WAI conformance / cross-arch byte-equality · linux (pull_request) Successful in 27s
WAI examples / WAI examples · android-app · linux-arm64 (pull_request) Successful in 5m59s
wai-ops-gpu integer operators on GPUs / builds · no adapter is not a pass (linux) (pull_request) Successful in 4m11s
WAI operation catalogue / the catalogue and every door · linux (pull_request) Successful in 5m56s
wai-ledger service / service · linux (pull_request) Successful in 1m50s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (pull_request) Successful in 7m18s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (pull_request) Successful in 1m7s
WAI conformance / quantum core and corpus under wasm32-wasip2 (wasmtime) (pull_request) Successful in 3m48s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (pull_request) Successful in 7m43s
wai-quantum for JavaScript / javascript · linux (pull_request) Successful in 3m41s
wai-fleet service / service · macos (pull_request) Successful in 48s
wai-quantum studio and desktop / studio · browser (pull_request) Successful in 3m39s
wai-quantum studio and desktop / desktop app · linux (pull_request) Successful in 4m54s
wai-web reach builds / wai-web reach builds · the package of record, every output as recorded (pull_request) Successful in 3m2s
wai-pack / wai-pack · macos (pull_request) Successful in 3m27s
wai-web reach builds / wai-web reach builds · this tree's wai-web, every rung on its engine line (pull_request) Successful in 5m55s
WAI examples / WAI examples · the archive, the readiness check, the browser and Apple · macos (pull_request) Successful in 13m52s
wai-web wasm32 / wai-web wasm32 build · corpus, menu and writers (pull_request) Successful in 5m28s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (pull_request) Successful in 16m34s
WAI Studio / Studio in WebKit · linux (pull_request) Successful in 11m38s
WAI conformance / cross-arch byte-equality · macos (pull_request) Successful in 27s
WAI conformance / codec wrappers · fuzz smoke, text decode limits, C entry points, provenance, the semantic path (pull_request) Successful in 1m7s
wai-ledger service / service · macos (pull_request) Successful in 1m6s
wai-quantum studio and desktop / desktop app · windows (pull_request) Successful in 9m35s
wai-ops-gpu integer operators on GPUs / Metal GPU and browser WebGPU (macos) (pull_request) Successful in 10m56s
WAI energy receipts / energy receipts · linux (pull_request) Successful in 17m20s
WAI HTTP delivery / HTTP corpus · falsify patches 0/3 (pull_request) Successful in 18m48s
WAI examples / WAI examples · portable · linux (pull_request) Successful in 24m50s
WAI HTTP delivery / HTTP corpus · falsify patches 1/3 (pull_request) Successful in 18m38s
WAI HTTP delivery / HTTP corpus · verified both ways, and the differential (pull_request) Successful in 2m31s
wai-quantum studio and desktop / desktop app · macos (pull_request) Successful in 7m3s
WAI HTTP delivery / cache rules · controls and labelled caches (pull_request) Successful in 1m46s
WAI energy receipts / energy receipts · macos (pull_request) Successful in 8m4s
wai-pack / wai-pack · linux-arm64 (pull_request) Successful in 1m47s
WAI conformance / cargo-class corpora · macos (pull_request) Successful in 17m16s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (pull_request) Successful in 7m47s
WAI HTTP delivery / HTTP corpus · falsify mutations 0/3 (pull_request) Successful in 34m23s
wai-pack / wai-pack · the floor through a media prober (pull_request) Successful in 21s
wai-pack / wai-pack · dependency licences (pull_request) Successful in 16s
wai-pack / wai-pack · independent verifier (Python) (pull_request) Successful in 7m5s
WAI conformance / v2 vocabulary · manifests validate as stated (pull_request) Successful in 18s
WAI conformance / cargo-class corpora · linux (pull_request) Successful in 37m20s
WAI Studio / Studio in Chromium and Firefox · macos (pull_request) Successful in 10m52s
WAI HTTP delivery / HTTP corpus · falsify patches 2/3 (pull_request) Successful in 18m17s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 11m18s
WAI HTTP delivery / HTTP corpus · falsify mutations 1/3 (pull_request) Successful in 39m59s
WAI conformance / delivery telemetry · CMCD and CMSD against an independent library (pull_request) Successful in 15s
WAI HTTP delivery / HTTP corpus · falsify mutations 2/3 (pull_request) Successful in 40m12s
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Successful in 50s
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Successful in 2m42s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (pull_request) Successful in 3m47s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (pull_request) Successful in 17m30s
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Successful in 18m34s
WAI conformance / each feature of the independent verifiers' steps compiles alone (pull_request) Successful in 14m28s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 14m15s
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Successful in 30m4s
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Successful in 1h18m5s
wai/one-way-delivery, converted to the layout of the fix: no shared append point
The change of wai/one-way-delivery (f1258784d..987625394, 28 commits), on the fix branch: its
changelog entries as files of wai/changes/, its wai-conformance.yml edits as step files,
corpus files and test tags, and every other file as the branch has it.

- 25240c4f0 wai: timeliness — when each object of a group is of use, and whether each holder held it in time
- c84992362 wai: timeliness_verify.py — the timeliness corpus's second implementation
- 44036fcdc wai: timeliness in the registry, SPEC §5, the changelog and CI; http_receipts.py checks its claims
- 27ce6754c wai: claim_verify.py lists not-held-by-deadline, and the claim corpus holds a session that switches for it
- 73d8d8eb9 wai: timeliness corpus — each holder's own line in key order, a holder's earliest fallback, and §7.2–§7.3's unreached plan rules
- 64c735f0c wai: the files timeliness changes are clippy-clean — a let chain, and the fetch callback's type allowed
- b72dc36bf wai: rung price — what delivering a rung costs, by bearer and direction, and which option a comparer takes
- 4e905e213 wai: rung_price_verify.py — the rung price corpus's second implementation
- 711fa6126 wai: rung price in the registry, SPEC §5, the changelog and CI
- 8ab70654e wai: rung price corpus — a refusal for every form rule 1 and §6.1 set, and the neighbour order a plan gives
- 1bc465959 wai: rung price ranks energy by checked evidence — a class counts only on the seller's own per-hop records, current and under a confirmed key
- 9e97997d9 wai: rung_price — the evidence text bound at its exact sizes, and a key confirmed for another class confirming nothing
- 8f1168683 wai: rung price corpus — a key confirmed for another class, the uncertainty product, each edge and order of the evidence verdict, and the evidence a plan read
- 7330e26ff wai: rung price — §6.1 links jwp-receipts at its first mention, and §10 says a record's age is its room's word
- 12c099457 wai: cache authority — who served what a node holds, and on whose authority
- f8f5d3e09 wai: cache_authority_verify.py — the cache authority corpus's second implementation
- 354146619 wai: cache authority in the registry, SPEC §5, the changelog and CI; its corpus holds what §12 states
- 55c7f3031 wai: timeliness §6 reads holdings records alone; cache-authority §9 derives the lines that read every kind
- b8fc6f02b wai: cache authority's lines read records as timeliness does, its deadline claim meets the envelopes held, and its corpus fixes each order and instant
- 91c5291cf wai: the files cache authority changes are clippy-clean — a boxed envelope, the base's names named once, slices borrowed
- 8e479daff wai: a held envelope boxes both its forms, so neither outweighs the other
- 3ec8da807 wai: impression aggregates — ad presentations counted over a bearer with no return path
- 0433a35c3 wai: impression_aggregate_verify.py — the impression aggregates corpus's second implementation
- 7d8e150e4 wai: impression aggregates in the registry, SPEC §5, the changelog and CI; its corpus states each receipt whole and a control for every malformed file
- d5e8ace02 wai: impression aggregates' size bounds are checked at their edge, and §4.3's decides no outcome
- 4bc8e13bd wai: impression aggregates' table totals are exact, and a receipt given twice is checked when either occurrence was
- 3c9f20524 wai: key_log's two result enums keep their public form, and say so to clippy
- 987625394 wai: Item::with_authority and Count::instance_received build only with the feature that calls each

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 01:45:32 -05:00

765 lines
30 KiB
Python

#!/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", "aggregate/task"}
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:
return CV.ED.strict_key(pk)
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 figure_authorities(c):
"""§7.5: the energy figure of a `reconstruct-session` or a `transmission` claim is an
item of its own, needing the claim's class and the energy authority of the class its
`energy` writes; None for a claim of another class, or one that was not metered."""
e = c["body"].get("energy") if c["class"] in ("reconstruct-session", "transmission") else None
return None if e is None else [c["class"], "energy/" + e["energy_provenance"]]
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"]
figure = figure_authorities(cl)
else:
it = c["item"]
pub, sid, time = bytes.fromhex(it["signer_pubkey"]), it["signer_id"], it["time"]
auth, figure = item_authorities(it["kind"], it["classes"]), None
exp = c.get("expected_origin")
held, expected = held_revisions(c["revisions"]), None if exp is None else [exp]
out = confirm(pub, sid, auth, time, held, c["anchors"], expected)
if figure is not None:
out += "; figure " + confirm(pub, sid, figure, time, held, c["anchors"], expected)
return out
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))