A falsification (§10's report_mismatch row deleted) was caught by tests/fleet.rs and not by the second implementation, which required every listed reason to appear in the corpus but not every raised reason to be listed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
855 lines
36 KiB
Python
855 lines
36 KiB
Python
#!/usr/bin/env python3
|
|
"""Independent verifier of the fleet corpus (extensions/fleet.md).
|
|
|
|
Written from the extension's text. It shares no code with wai-rs, which generates the corpus,
|
|
and never runs it. It checks Ed25519 signatures with ../claim-conformance/_ed25519.py, WAI's
|
|
strict rule (SPEC §7 *Signatures*) written from RFC 8032 with the standard library, and reads
|
|
per-hop records and their ledgers with live_metering_verify.py, the second implementation of
|
|
live metering, written from that text alone (§4.3). Everything else is here:
|
|
|
|
- the enrolment (§3): reading, rules, signature;
|
|
- the records and their carriage (§4.1): the relay's summary line and its text read strictly
|
|
(§4.2), the room descriptor as far as the fleet reads it (§4.4);
|
|
- placement in time and enrolment (§4.5), and one thing once (§4.6);
|
|
- the report (§6): reading, rules, signature;
|
|
- every line, row, unmetered count and citation recomputed (§5), compared in §7.2 step 6's
|
|
order, and the outcome of §7.3.
|
|
|
|
For every vector it requires the outcome the corpus states; of the corpus, that every reason of
|
|
§10 and every code it reuses appears in it, that §10 names every reason it raises, and that no other
|
|
file is there. Then it mutates
|
|
copies of the corpus, one defect each, and requires every copy to fail; and it drops each of its
|
|
own checks in turn and requires the corpus to catch the weakened verifier.
|
|
|
|
python3 -I fleet_verify.py <live-conformance/fleet>
|
|
python3 -I fleet_verify.py --bundle <bundle.wai-fleet.json>
|
|
|
|
Exit status 0 only if every vector verifies, every mutated copy fails and every weakened verifier
|
|
fails. Python 3.8 or later; standard library only.
|
|
"""
|
|
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import struct
|
|
import sys
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
|
|
import _ed25519 as ED # noqa: E402
|
|
import live_metering_verify as LMV # noqa: E402
|
|
|
|
EXTENSION = os.path.join(HERE, "..", "extensions", "fleet.md")
|
|
|
|
ENROL_MAGIC, REPORT_MAGIC, DESCRIPTOR_MAGIC = b"WFE1", b"WFR1", b"WRD1" # §3.1, §6.1, §4.4
|
|
ENROL_DOMAIN = b"wai:fleet-enrol\x01" # §3.2
|
|
REPORT_DOMAIN = b"wai:fleet-report\x01" # §6.3
|
|
CITES_DOMAIN = b"wai:fleet-cites\x01" # §5.5
|
|
SUMMARY_DOMAIN = b"joule-moq-relay:session-summary\x01" # §4.2
|
|
DESCRIPTOR_DOMAIN = b"wai:live-room\x01" # §4.4
|
|
SUMMARY_FORMAT = "joule-moq-relay/session-summary/2"
|
|
LINE_HEAD, LINE_SIG, LINE_TAIL = b'{"summary":', b',"signature":"', b'"}'
|
|
BUNDLE_KIND = "wai-fleet-bundle" # §7.1
|
|
HOP, SUMMARY, DESCRIPTOR = 2, 10, 11 # §4.1
|
|
ROLES = {1: "relay", 2: "compositor", 3: "ingest", 4: "room", 5: "ledger", 6: "audit"} # §2
|
|
LABELS = {2: "ModelBased", 3: "Estimator", 4: "OnChipCounter", 5: "CalibratedInstrument"}
|
|
SCOPES = {1: "sessions", 2: "hops"}
|
|
U64, U32, U16 = 2 ** 64 - 1, 2 ** 32 - 1, 2 ** 16 - 1
|
|
HEX = re.compile(r"\A(?:[0-9a-f]{2})*\Z")
|
|
|
|
SKIP = set()
|
|
|
|
|
|
class Refuse(Exception):
|
|
def __init__(self, reason, at=None):
|
|
super().__init__(reason)
|
|
self.reason, self.at = reason, at
|
|
|
|
|
|
def sha(*parts):
|
|
h = hashlib.sha256()
|
|
for p in parts:
|
|
h.update(p)
|
|
return h.digest()
|
|
|
|
|
|
_VERIFIED = {}
|
|
|
|
|
|
def ed_verify(key, msg, sig):
|
|
"""ED.verify, remembered: the corpus is verified many times over (its mutated copies and its
|
|
weakened verifiers), and a signature's verdict depends on its bytes alone."""
|
|
k = (key, sha(msg), sig)
|
|
if k not in _VERIFIED:
|
|
_VERIFIED[k] = ED.verify(key, msg, sig)
|
|
return _VERIFIED[k]
|
|
|
|
|
|
def check_sig(name, key, domain, body, sig):
|
|
"""SPEC §7 *Signatures*: bad_key or bad_signature."""
|
|
if name in SKIP:
|
|
return
|
|
bad = ed_verify(key, domain + body, sig)
|
|
if bad:
|
|
raise Refuse(bad)
|
|
|
|
|
|
class R:
|
|
"""A cursor; running out is `truncated`."""
|
|
|
|
def __init__(self, b, p=0):
|
|
self.b, self.p = b, p
|
|
|
|
def take(self, n):
|
|
if self.p + n > len(self.b):
|
|
raise Refuse("truncated")
|
|
s = self.b[self.p:self.p + n]
|
|
self.p += n
|
|
return s
|
|
|
|
def u8(self):
|
|
return self.take(1)[0]
|
|
|
|
def u16(self):
|
|
return struct.unpack("<H", self.take(2))[0]
|
|
|
|
def u32(self):
|
|
return struct.unpack("<I", self.take(4))[0]
|
|
|
|
def u64(self):
|
|
return struct.unpack("<Q", self.take(8))[0]
|
|
|
|
|
|
def read_head(b, magic):
|
|
"""live-events §3.3's first two steps, with `magic` and version 1."""
|
|
if len(b) < 4:
|
|
raise Refuse("truncated")
|
|
if b[:4] != magic:
|
|
raise Refuse("malformed")
|
|
if len(b) < 5:
|
|
raise Refuse("truncated")
|
|
if b[4] != 1:
|
|
raise Refuse("unknown_version")
|
|
|
|
|
|
def hexb(s):
|
|
if not isinstance(s, str) or not HEX.match(s):
|
|
raise Refuse("malformed")
|
|
return bytes.fromhex(s)
|
|
|
|
|
|
# ---------------------------------------------------------------- §3 the enrolment
|
|
|
|
def read_enrolment(b):
|
|
read_head(b, ENROL_MAGIC)
|
|
r = R(b, 5)
|
|
e = {"organisation": r.take(32), "node": r.take(32), "role": r.u8(), "from": r.u64(), "until": r.u64()}
|
|
e["label"] = r.take(r.u8())
|
|
sig = r.take(64)
|
|
if r.p != len(b):
|
|
raise Refuse("malformed")
|
|
try:
|
|
text = e["label"].decode("utf-8")
|
|
label_ok = len(e["label"]) <= 64 and not any(ord(c) < 0x20 or ord(c) == 0x7F for c in text)
|
|
except UnicodeDecodeError:
|
|
label_ok = False
|
|
rules = 1 <= e["role"] <= 6 and e["from"] < e["until"] and label_ok and e["node"] != e["organisation"]
|
|
if not rules and "enrolment-rules" not in SKIP:
|
|
raise Refuse("enrolment_invalid")
|
|
check_sig("enrolment-signature", e["organisation"], ENROL_DOMAIN, b[:-64], sig)
|
|
e["digest"] = sha(b[:-64])
|
|
return e
|
|
|
|
|
|
# ---------------------------------------------------------------- §4 the records
|
|
|
|
class Int(int):
|
|
"""A number spelled as a plain non-negative decimal integer (§4.2)."""
|
|
|
|
|
|
class NotInt:
|
|
"""Any other number."""
|
|
|
|
def __init__(self, spelling):
|
|
self.spelling = spelling
|
|
|
|
|
|
def no_duplicates(pairs):
|
|
names = [k for k, _ in pairs]
|
|
if len(set(names)) != len(names) and "summary-duplicates" not in SKIP:
|
|
raise ValueError("a member named twice")
|
|
return dict(pairs)
|
|
|
|
|
|
def no_constant(s):
|
|
raise ValueError("not JSON: %s" % s)
|
|
|
|
|
|
def depth_ok(v, d=0):
|
|
"""No value inside more than 63 objects and arrays."""
|
|
if d >= 64:
|
|
return False
|
|
if isinstance(v, dict):
|
|
return all(isinstance(k, str) for k in v) and all(depth_ok(x, d + 1) for x in v.values())
|
|
if isinstance(v, list):
|
|
return all(depth_ok(x, d + 1) for x in v)
|
|
return True
|
|
|
|
|
|
def surrogate_free(v):
|
|
if isinstance(v, str):
|
|
return not any(0xD800 <= ord(c) <= 0xDFFF for c in v)
|
|
if isinstance(v, dict):
|
|
return all(surrogate_free(k) and surrogate_free(x) for k, x in v.items())
|
|
if isinstance(v, list):
|
|
return all(surrogate_free(x) for x in v)
|
|
return True
|
|
|
|
|
|
def strict_json(b):
|
|
"""§4.2: one JSON text read strictly, or Refuse("malformed")."""
|
|
try:
|
|
text = b.decode("utf-8")
|
|
v = json.loads(text, object_pairs_hook=no_duplicates, parse_constant=no_constant,
|
|
parse_int=lambda s: Int(s) if not s.startswith("-") else NotInt(s), parse_float=NotInt)
|
|
except (UnicodeDecodeError, ValueError, RecursionError):
|
|
raise Refuse("malformed")
|
|
if not depth_ok(v) or not surrogate_free(v):
|
|
raise Refuse("malformed")
|
|
return v
|
|
|
|
|
|
def integer(o, k):
|
|
v = o.get(k) if isinstance(o, dict) else None
|
|
if not isinstance(v, Int) or int(v) > U64:
|
|
raise Refuse("malformed")
|
|
return int(v)
|
|
|
|
|
|
def key_hex(v):
|
|
if not isinstance(v, str) or len(v) != 64 or not HEX.match(v):
|
|
raise Refuse("malformed")
|
|
return bytes.fromhex(v)
|
|
|
|
|
|
def obj(v):
|
|
if not isinstance(v, dict):
|
|
raise Refuse("malformed")
|
|
return v
|
|
|
|
|
|
def split_line(line):
|
|
"""§4.2: a summary's text and its signature, from its line in the relay's form."""
|
|
b = line.encode("utf-8") if isinstance(line, str) else None
|
|
if b is None:
|
|
raise Refuse("malformed")
|
|
if "summary-form" in SKIP:
|
|
b = b.rstrip()
|
|
sig_len = len(LINE_SIG) + 128
|
|
if not (b.startswith(LINE_HEAD) and b.endswith(LINE_TAIL)) or len(b) < len(LINE_HEAD) + sig_len + len(LINE_TAIL):
|
|
raise Refuse("malformed")
|
|
tail = b[len(b) - len(LINE_TAIL) - sig_len:len(b) - len(LINE_TAIL)]
|
|
if not tail.startswith(LINE_SIG) or not HEX.match(tail[len(LINE_SIG):].decode("latin-1")):
|
|
raise Refuse("malformed")
|
|
return b[len(LINE_HEAD):len(b) - len(LINE_TAIL) - sig_len], bytes.fromhex(tail[len(LINE_SIG):].decode("ascii"))
|
|
|
|
|
|
def read_summary(line):
|
|
"""§4.2: the line, the text, its members in the table's order, then its energy, its rules and
|
|
its signature."""
|
|
text, sig = split_line(line)
|
|
s = obj(strict_json(text))
|
|
if not isinstance(s.get("format"), str):
|
|
raise Refuse("malformed")
|
|
if s["format"] != SUMMARY_FORMAT and "summary-format" not in SKIP:
|
|
raise Refuse("unknown_version")
|
|
x = {"kind": SUMMARY, "relay": key_hex(s.get("relay_key")), "session": integer(s, "session"),
|
|
"opened": integer(s, "opened_unix_ms"), "closed": integer(s, "closed_unix_ms")}
|
|
energy = obj(s.get("relay_energy"))
|
|
if not isinstance(energy.get("acquisition"), str):
|
|
raise Refuse("malformed")
|
|
acquisition, joules = energy["acquisition"], integer(energy, "joules_micro")
|
|
tally = obj(s.get("tally"))
|
|
x["peer"] = key_hex(tally["peer"]) if "peer" in tally else None
|
|
x["bytes_in"] = integer(obj(tally.get("publisher")), "object_bytes_received")
|
|
sub = obj(tally.get("subscriber"))
|
|
forwarded = integer(sub, "object_bytes_forwarded")
|
|
fetched = 0
|
|
for t in obj(sub.get("tracks")).values():
|
|
fetched += integer(t, "fetch_bytes") if "fetch_bytes" in obj(t) else 0
|
|
two = len(energy) == 2
|
|
if acquisition == "unmetered" and joules == 0 and two:
|
|
x["figure"] = None
|
|
elif acquisition in ("ModelBased", "Estimator") and joules > 0 and two:
|
|
x["figure"] = (2 if acquisition == "ModelBased" else 3, joules, 0)
|
|
elif "summary-energy" in SKIP:
|
|
x["figure"] = None
|
|
else:
|
|
raise Refuse("energy_invalid")
|
|
x["bytes_out"] = forwarded + fetched
|
|
if (x["opened"] > x["closed"] or x["closed"] * 1000 > U64 or x["bytes_out"] > U64) and "summary-rules" not in SKIP:
|
|
raise Refuse("record_invalid")
|
|
check_sig("summary-signature", x["relay"], SUMMARY_DOMAIN, text, sig)
|
|
x["id"] = sha(text)
|
|
x["signer"] = x["relay"]
|
|
return x
|
|
|
|
|
|
_HOPS = {}
|
|
|
|
|
|
def lmv_read(rec, led):
|
|
"""live_metering_verify.py's reading of a record and its ledger, remembered by their bytes: the
|
|
reason it refuses them, or what it reads."""
|
|
if (rec, led) not in _HOPS:
|
|
try:
|
|
_HOPS[rec, led] = (None, LMV.read_hop(rec, rec[5:37]), LMV.read_ledger(led))
|
|
except LMV.Refuse as e:
|
|
_HOPS[rec, led] = (e.reason, None, None)
|
|
return _HOPS[rec, led]
|
|
|
|
|
|
def read_hop(rec, led):
|
|
"""§4.3: live metering's reading of the record and its ledger, and the ledger's binding."""
|
|
reason, h, entries = lmv_read(rec, led)
|
|
if reason:
|
|
raise Refuse(reason)
|
|
if (len(entries) != h["grants"] or LMV.ledger_root(entries) != h["ledger"]) and "ledger-binding" not in SKIP:
|
|
raise Refuse("ledger_mismatch")
|
|
cls = h["class"]
|
|
figure = None if cls is None else (cls[0], h["joules"], cls[1] if cls[0] in (4, 5) else 0)
|
|
return {"kind": HOP, "id": h["digest"], "signer": h["hop"], "room": h["room"], "role": h["role"], "start": h["start"],
|
|
"end": h["end"], "figure": figure, "sessions": h["sessions"], "sub": sum(h["sub"]),
|
|
"bytes_in": sum(e["bytes"] for e in h["tracks"] if e["dir"] == 1),
|
|
"bytes_out": sum(e["bytes"] for e in h["tracks"] if e["dir"] == 2)}
|
|
|
|
|
|
def read_descriptor(b):
|
|
"""§4.4: magic, version, authority, t0 and the signature; the digest is the room id."""
|
|
read_head(b, DESCRIPTOR_MAGIC)
|
|
if len(b) < 77 + 64:
|
|
raise Refuse("truncated")
|
|
check_sig("descriptor-signature", b[5:37], DESCRIPTOR_DOMAIN, b[:-64], b[-64:])
|
|
return {"kind": DESCRIPTOR, "id": sha(b[:-64]), "t0": struct.unpack("<Q", b[69:77])[0], "signer": None}
|
|
|
|
|
|
def read_record(c):
|
|
"""§4.1: a carriage, read as its kind."""
|
|
if not isinstance(c, dict):
|
|
raise Refuse("malformed")
|
|
k = c.get("kind")
|
|
if k == "hop" and set(c) == {"kind", "record", "ledger"}:
|
|
rec, led = hexb(c["record"]), hexb(c["ledger"])
|
|
return read_hop(rec, led)
|
|
if k == "summary" and set(c) == {"kind", "line"}:
|
|
return read_summary(c["line"])
|
|
if k == "descriptor" and set(c) == {"kind", "descriptor"}:
|
|
return read_descriptor(hexb(c["descriptor"]))
|
|
raise Refuse("malformed")
|
|
|
|
|
|
def instant(x, t0s):
|
|
"""§4.5: a summary's or a per-hop record's instant; None for one in no period."""
|
|
if x["kind"] == SUMMARY:
|
|
return x["closed"] * 1000
|
|
if x["room"] not in t0s:
|
|
if "descriptor" in SKIP:
|
|
return x["end"] - 1
|
|
raise Refuse("descriptor_missing")
|
|
t = t0s[x["room"]] + x["end"] - 1
|
|
return t if t <= U64 else None
|
|
|
|
|
|
def place(x, enrolments, t0s, period):
|
|
"""§7.2 step 4."""
|
|
e = next((e for e in enrolments if e["node"] == x["signer"]), None)
|
|
if e is None:
|
|
if "enrolled" in SKIP:
|
|
return
|
|
raise Refuse("signer_not_enrolled")
|
|
admits = e["role"] == 1 if x["kind"] == SUMMARY else (1 <= e["role"] <= 3 and x["role"] == e["role"])
|
|
if not admits and "role" not in SKIP:
|
|
raise Refuse("role_mismatch")
|
|
t = instant(x, t0s)
|
|
if not (t is not None and period[0] <= t < period[1]) and "period" not in SKIP:
|
|
raise Refuse("outside_period")
|
|
if not (t is not None and e["from"] <= t < e["until"]) and "enrolment-window" not in SKIP:
|
|
raise Refuse("outside_enrolment")
|
|
|
|
|
|
def once(records):
|
|
"""§4.6: the index of the first record that repeats one before it, and why."""
|
|
heads, sessions, spans = set(), set(), {}
|
|
for i, x in enumerate(records):
|
|
head = (x["kind"], x["id"])
|
|
if head in heads and "duplicate" not in SKIP:
|
|
raise Refuse("record_duplicate", ["record", i])
|
|
if "overlap" not in SKIP:
|
|
if x["kind"] == SUMMARY and (x["relay"], x["session"], x["opened"]) in sessions:
|
|
raise Refuse("record_overlap", ["record", i])
|
|
if x["kind"] == HOP and any(s < x["end"] and x["start"] < e for s, e in spans.get((x["signer"], x["room"]), [])):
|
|
raise Refuse("record_overlap", ["record", i])
|
|
heads.add(head)
|
|
if x["kind"] == SUMMARY:
|
|
sessions.add((x["relay"], x["session"], x["opened"]))
|
|
if x["kind"] == HOP:
|
|
spans.setdefault((x["signer"], x["room"]), []).append((x["start"], x["end"]))
|
|
|
|
|
|
# ---------------------------------------------------------------- §6 the report
|
|
|
|
def read_block(r):
|
|
rows = [{"scope": r.u8(), "tag": r.u8(), "figures": r.u32(), "joules": r.u64(), "max_ppm": r.u32()} for _ in range(r.u8())]
|
|
return {"rows": rows, "unmetered": [r.u32(), r.u32()]}
|
|
|
|
|
|
def block_ok(bl, room):
|
|
keys = [(x["scope"], x["tag"]) for x in bl["rows"]]
|
|
return (all(a < b for a, b in zip(keys, keys[1:]))
|
|
and all(x["scope"] in (1, 2) and 2 <= x["tag"] <= 5 and x["figures"] > 0 and (x["tag"] >= 4 or x["max_ppm"] == 0)
|
|
for x in bl["rows"])
|
|
and (not room or (all(x["scope"] != 1 for x in bl["rows"]) and bl["unmetered"][0] == 0)))
|
|
|
|
|
|
def read_report(b):
|
|
"""§6.4: the report, read, its rules, its signature."""
|
|
read_head(b, REPORT_MAGIC)
|
|
r = R(b, 5)
|
|
rep = {"organisation": r.take(32), "start": r.u64(), "end": r.u64()}
|
|
rep["records"] = [(r.u8(), r.take(32)) for _ in range(r.u32())]
|
|
rep["enrolments"] = [(r.take(32), r.take(32)) for _ in range(r.u16())]
|
|
nodes = []
|
|
for _ in range(r.u16()):
|
|
l = {"node": r.take(32), "role": r.u8()}
|
|
for k in ("sessions", "links", "bytes_in", "bytes_out", "link_bytes_in", "link_bytes_out"):
|
|
l[k] = r.u64()
|
|
l["hops"], l["hop_bytes_in"], l["hop_bytes_out"] = r.u32(), r.u64(), r.u64()
|
|
l["energy"], l["cited"], l["cites"] = read_block(r), r.u32(), r.take(32)
|
|
nodes.append(l)
|
|
rooms = []
|
|
for _ in range(r.u16()):
|
|
l = {"room": r.take(32), "t0": r.u64(), "hops": r.u32()}
|
|
for k in ("sessions", "subscriber_us", "bytes_in", "bytes_out"):
|
|
l[k] = r.u64()
|
|
l["energy"], l["cited"], l["cites"] = read_block(r), r.u32(), r.take(32)
|
|
rooms.append(l)
|
|
t = {}
|
|
for k in ("sessions", "links", "bytes_in", "bytes_out", "link_bytes_in", "link_bytes_out"):
|
|
t[k] = r.u64()
|
|
t["hops"], t["hop_bytes_in"], t["hop_bytes_out"] = r.u32(), r.u64(), r.u64()
|
|
t["energy"] = read_block(r)
|
|
sig = r.take(64)
|
|
if r.p != len(b):
|
|
raise Refuse("malformed")
|
|
rep["nodes"], rep["rooms"], rep["totals"] = nodes, rooms, t
|
|
blocks = [l["energy"] for l in nodes + rooms] + [t["energy"]]
|
|
if any(x["tag"] == 0 for bl in blocks for x in bl["rows"]) and "row-of-no-class" not in SKIP:
|
|
raise Refuse("classes_summed")
|
|
asc = lambda xs: all(a < b for a, b in zip(xs, xs[1:])) # noqa: E731
|
|
rules = (rep["start"] < rep["end"] and asc(rep["records"]) and all(k in (HOP, SUMMARY, DESCRIPTOR) for k, _ in rep["records"])
|
|
and asc([n for n, _ in rep["enrolments"]]) and asc([l["node"] for l in nodes])
|
|
and all(1 <= l["role"] <= 6 and block_ok(l["energy"], False) for l in nodes)
|
|
and asc([l["room"] for l in rooms]) and all(block_ok(l["energy"], True) for l in rooms)
|
|
and block_ok(t["energy"], False))
|
|
if not rules and "report-rules" not in SKIP:
|
|
raise Refuse("report_invalid")
|
|
check_sig("report-signature", rep["organisation"], REPORT_DOMAIN, b[:-64], sig)
|
|
rep["digest"] = sha(b[:-64])
|
|
return rep
|
|
|
|
|
|
# ---------------------------------------------------------------- §5 the figures
|
|
|
|
class Line:
|
|
"""A line's sums, before they are stated."""
|
|
|
|
def __init__(self):
|
|
self.n = [0] * 9
|
|
self.rows = {} # (scope, tag) -> [figures, joules, max_ppm]
|
|
self.unmetered = [0, 0]
|
|
self.cites = set()
|
|
self.role, self.t0 = 0, 0
|
|
|
|
def add(self, scope, figure):
|
|
if figure is None:
|
|
self.unmetered[scope - 1] += 1
|
|
return
|
|
tag, joules, ppm = figure
|
|
row = self.rows.setdefault((scope, tag), [0, 0, 0])
|
|
row[0] += 1
|
|
row[1] += joules
|
|
if tag >= 4:
|
|
row[2] = max(row[2], ppm)
|
|
|
|
def block(self):
|
|
rows = [{"scope": s, "tag": t, "figures": v[0], "joules": v[1], "max_ppm": v[2]} for (s, t), v in sorted(self.rows.items())]
|
|
if len(rows) > 255 or any(x["figures"] > U32 or x["joules"] > U64 for x in rows) or max(self.unmetered) > U32:
|
|
return None
|
|
return {"rows": rows, "unmetered": list(self.unmetered)}
|
|
|
|
def class_sums(self, scope):
|
|
return {t: v[1] for (s, t), v in self.rows.items() if s == scope}
|
|
|
|
|
|
def cites_digest(c):
|
|
return sha(CITES_DOMAIN, b"".join(bytes([k]) + i for k, i in sorted(c)))
|
|
|
|
|
|
def compute(enrolments, records):
|
|
"""§5: every line and the totals, from the records."""
|
|
roles = {e["node"]: e["role"] for e in enrolments}
|
|
t0s = {x["id"]: x["t0"] for x in records if x["kind"] == DESCRIPTOR}
|
|
nodes, rooms, total = {}, {}, Line()
|
|
for x in records:
|
|
if x["kind"] == SUMMARY:
|
|
l = nodes.setdefault(x["relay"], Line())
|
|
count, b_in, b_out = (1, 4, 5) if x["peer"] is not None else (0, 2, 3)
|
|
for y in (l, total):
|
|
y.n[count] += 1
|
|
y.n[b_in] += x["bytes_in"]
|
|
y.n[b_out] += x["bytes_out"]
|
|
l.add(1, x["figure"])
|
|
total.add(1, x["figure"])
|
|
l.cites.add((SUMMARY, x["id"]))
|
|
elif x["kind"] == HOP:
|
|
l = nodes.setdefault(x["signer"], Line())
|
|
for y in (l, total):
|
|
y.n[6] += 1
|
|
y.n[7] += x["bytes_in"]
|
|
y.n[8] += x["bytes_out"]
|
|
l.add(2, x["figure"])
|
|
total.add(2, x["figure"])
|
|
l.cites.add((HOP, x["id"]))
|
|
room = rooms.setdefault(x["room"], Line())
|
|
room.t0 = t0s.get(x["room"], 0)
|
|
for k, v in enumerate((1, x["sessions"], x["sub"], x["bytes_in"], x["bytes_out"])):
|
|
room.n[k] += v
|
|
room.add(2, x["figure"])
|
|
room.cites |= {(HOP, x["id"]), (DESCRIPTOR, x["room"])}
|
|
for node, l in nodes.items():
|
|
l.role = roles.get(node, 0)
|
|
return {"records": sorted((x["kind"], x["id"]) for x in records), "enrolments": sorted((e["node"], e["digest"]) for e in enrolments),
|
|
"nodes": nodes, "rooms": rooms, "total": total}
|
|
|
|
|
|
def stated(c):
|
|
"""The lines and totals as a report states them, or None for a figure no field holds."""
|
|
def fits(vals, top):
|
|
return all(v <= top for v in vals)
|
|
nodes = []
|
|
for node in sorted(c["nodes"]):
|
|
x = c["nodes"][node]
|
|
bl = x.block()
|
|
if bl is None or not fits(x.n[:6] + x.n[7:9], U64) or x.n[6] > U32 or len(x.cites) > U32:
|
|
return None
|
|
nodes.append({"node": node, "role": x.role, "sessions": x.n[0], "links": x.n[1], "bytes_in": x.n[2], "bytes_out": x.n[3],
|
|
"link_bytes_in": x.n[4], "link_bytes_out": x.n[5], "hops": x.n[6], "hop_bytes_in": x.n[7],
|
|
"hop_bytes_out": x.n[8], "energy": bl, "cited": len(x.cites), "cites": cites_digest(x.cites)})
|
|
rooms = []
|
|
for room in sorted(c["rooms"]):
|
|
x = c["rooms"][room]
|
|
bl = x.block()
|
|
if bl is None or not fits(x.n[1:5], U64) or x.n[0] > U32 or len(x.cites) > U32:
|
|
return None
|
|
rooms.append({"room": room, "t0": x.t0, "hops": x.n[0], "sessions": x.n[1], "subscriber_us": x.n[2], "bytes_in": x.n[3],
|
|
"bytes_out": x.n[4], "energy": bl, "cited": len(x.cites), "cites": cites_digest(x.cites)})
|
|
t = c["total"]
|
|
bl = t.block()
|
|
if bl is None or not fits(t.n[:6] + t.n[7:9], U64) or t.n[6] > U32:
|
|
return None
|
|
totals = {"sessions": t.n[0], "links": t.n[1], "bytes_in": t.n[2], "bytes_out": t.n[3], "link_bytes_in": t.n[4],
|
|
"link_bytes_out": t.n[5], "hops": t.n[6], "hop_bytes_in": t.n[7], "hop_bytes_out": t.n[8], "energy": bl}
|
|
return nodes, rooms, totals
|
|
|
|
|
|
def summed_across(row, sums):
|
|
"""§7.2 step 6.2: a row whose joules is its class's with at least one other class's of its scope."""
|
|
own = sums.get(row["tag"], 0)
|
|
if row["joules"] == own:
|
|
return False
|
|
others = [j for t, j in sums.items() if t != row["tag"]]
|
|
for m in range(1, 1 << len(others)):
|
|
if own + sum(j for i, j in enumerate(others) if m >> i & 1) == row["joules"]:
|
|
return True
|
|
return False
|
|
|
|
|
|
def compare(rep, c):
|
|
"""§7.2 step 6, in its order."""
|
|
for j, l in enumerate(rep["nodes"]):
|
|
if (l["node"] not in c["nodes"] or l["cited"] == 0) and "unsourced" not in SKIP:
|
|
raise Refuse("line_unsourced", ["report", "nodes", j])
|
|
for j, l in enumerate(rep["rooms"]):
|
|
if (l["room"] not in c["rooms"] or l["cited"] == 0) and "unsourced" not in SKIP:
|
|
raise Refuse("line_unsourced", ["report", "rooms", j])
|
|
blocks = [(l["energy"], c["nodes"].get(l["node"]), ["report", "nodes", j]) for j, l in enumerate(rep["nodes"])]
|
|
blocks += [(l["energy"], c["rooms"].get(l["room"]), ["report", "rooms", j]) for j, l in enumerate(rep["rooms"])]
|
|
blocks += [(rep["totals"]["energy"], c["total"], ["report", "totals"])]
|
|
blocks = [b for b in blocks if b[1] is not None]
|
|
if "summed" not in SKIP:
|
|
for bl, x, at in blocks:
|
|
if any(summed_across(row, x.class_sums(row["scope"])) for row in bl["rows"]):
|
|
raise Refuse("classes_summed", at)
|
|
if "unmetered" not in SKIP:
|
|
for bl, x, at in blocks:
|
|
if any(bl["unmetered"][s] < x.unmetered[s] for s in (0, 1)):
|
|
raise Refuse("unmetered_summed", at)
|
|
if "citations" not in SKIP:
|
|
for which, key in (("nodes", "node"), ("rooms", "room")):
|
|
for j, l in enumerate(rep[which]):
|
|
x = c[which].get(l[key])
|
|
if x is not None and (l["cited"] != len(x.cites) or l["cites"] != cites_digest(x.cites)):
|
|
raise Refuse("citation_mismatch", ["report", which, j])
|
|
s = stated(c)
|
|
same = s is not None and rep["records"] == c["records"] and rep["enrolments"] == c["enrolments"] \
|
|
and rep["nodes"] == s[0] and rep["rooms"] == s[1] and rep["totals"] == s[2]
|
|
if not same and "mismatch" not in SKIP:
|
|
raise Refuse("report_mismatch", ["report"])
|
|
|
|
|
|
# ---------------------------------------------------------------- §7 the bundle
|
|
|
|
def energy_json(bl):
|
|
out = {}
|
|
for s, name in SCOPES.items():
|
|
rows = [{"class": LABELS[x["tag"]], "figures": x["figures"], "joules_micro": x["joules"], "max_ppm": x["max_ppm"]}
|
|
for x in bl["rows"] if x["scope"] == s]
|
|
un = bl["unmetered"][s - 1]
|
|
cov = {(True, False): "whole", (True, True): "partial", (False, True): "unmetered", (False, False): "none"}[(bool(rows), un > 0)]
|
|
out[name] = {"rows": rows, "unmetered": un, "coverage": cov}
|
|
return out
|
|
|
|
|
|
def cites_json(c):
|
|
return [[k, i.hex()] for k, i in sorted(c)]
|
|
|
|
|
|
def outcome(rep, enrolments, records, c):
|
|
labels = {e["node"]: e["label"].decode("utf-8") for e in enrolments}
|
|
signed = {}
|
|
for x in records:
|
|
if x["signer"] is not None:
|
|
signed[x["signer"]] = signed.get(x["signer"], 0) + 1
|
|
t = rep["totals"]
|
|
return {
|
|
"outcome": "accept", "digest": rep["digest"].hex(), "organisation": rep["organisation"].hex(),
|
|
"start": rep["start"], "end": rep["end"], "records": len(rep["records"]),
|
|
"enrolments": [{"node": e["node"].hex(), "role": ROLES[e["role"]], "label": labels[e["node"]], "from": e["from"],
|
|
"until": e["until"], "digest": e["digest"].hex(), "records": signed.get(e["node"], 0),
|
|
"silent": signed.get(e["node"], 0) == 0} for e in sorted(enrolments, key=lambda e: e["node"])],
|
|
"nodes": [{"node": l["node"].hex(), "label": labels.get(l["node"]), "role": ROLES.get(l["role"]),
|
|
**{k: l[k] for k in ("sessions", "links", "bytes_in", "bytes_out", "link_bytes_in", "link_bytes_out", "hops",
|
|
"hop_bytes_in", "hop_bytes_out")},
|
|
"energy": energy_json(l["energy"]), "cites": cites_json(c["nodes"][l["node"]].cites)} for l in rep["nodes"]],
|
|
"rooms": [{"room": l["room"].hex(), **{k: l[k] for k in ("t0", "hops", "sessions", "subscriber_us", "bytes_in", "bytes_out")},
|
|
"energy": energy_json(l["energy"]), "cites": cites_json(c["rooms"][l["room"]].cites)} for l in rep["rooms"]],
|
|
"totals": {**{k: t[k] for k in ("sessions", "links", "bytes_in", "bytes_out", "link_bytes_in", "link_bytes_out", "hops",
|
|
"hop_bytes_in", "hop_bytes_out")}, "energy": energy_json(t["energy"])},
|
|
}
|
|
|
|
|
|
def verify(bundle):
|
|
"""§7.2, stopping at the first refusal; the outcome of §7.3."""
|
|
try:
|
|
return verify_steps(bundle)
|
|
except Refuse as e:
|
|
return {"outcome": "refuse", "reason": e.reason, "at": e.at}
|
|
|
|
|
|
def at(place):
|
|
"""Run a step's reading, its refusal placed."""
|
|
class Ctx:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, kind, e, tb):
|
|
if isinstance(e, Refuse) and e.at is None:
|
|
e.at = place
|
|
return False
|
|
return Ctx()
|
|
|
|
|
|
def verify_steps(b):
|
|
with at(["bundle"]):
|
|
if not isinstance(b, dict) or set(b) != {"kind", "report", "enrolments", "records"} or b["kind"] != BUNDLE_KIND:
|
|
raise Refuse("malformed")
|
|
rb = hexb(b["report"])
|
|
if not isinstance(b["enrolments"], list) or not isinstance(b["records"], list):
|
|
raise Refuse("malformed")
|
|
eb = [hexb(e) for e in b["enrolments"]]
|
|
# 1. The report.
|
|
with at(["report"]):
|
|
rep = read_report(rb)
|
|
period = (rep["start"], rep["end"])
|
|
# 2. Each enrolment.
|
|
enrolments = []
|
|
for i, e in enumerate(eb):
|
|
with at(["enrolment", i]):
|
|
e = read_enrolment(e)
|
|
if e["organisation"] != rep["organisation"] and "organisation" not in SKIP:
|
|
raise Refuse("wrong_organisation")
|
|
if any(x["node"] == e["node"] for x in enrolments) and "enrolment-duplicate" not in SKIP:
|
|
raise Refuse("enrolment_duplicate")
|
|
if not (e["from"] < rep["end"] and rep["start"] < e["until"]) and "enrolment-period" not in SKIP:
|
|
raise Refuse("outside_period")
|
|
enrolments.append(e)
|
|
# 3. Each record reads and verifies.
|
|
records = []
|
|
for i, c in enumerate(b["records"]):
|
|
with at(["record", i]):
|
|
records.append(read_record(c))
|
|
# 4. Each summary and per-hop record is enrolled, of its role, placed, in the period and within
|
|
# its enrolment.
|
|
t0s = {x["id"]: x["t0"] for x in records if x["kind"] == DESCRIPTOR}
|
|
for i, x in enumerate(records):
|
|
if x["signer"] is not None:
|
|
with at(["record", i]):
|
|
place(x, enrolments, t0s, period)
|
|
# 5. Across records.
|
|
once(records)
|
|
# 6. The figures.
|
|
c = compute(enrolments, records)
|
|
compare(rep, c)
|
|
return outcome(rep, enrolments, records, c)
|
|
|
|
|
|
# ---------------------------------------------------------------- the corpus
|
|
|
|
def check(doc, quiet=False):
|
|
"""The failures: every vector whose stated outcome is not the one this verifier reaches."""
|
|
fails = []
|
|
for v in doc.get("vectors", []):
|
|
try:
|
|
got = verify(v["bundle"])
|
|
except Exception as e: # a crash on a mutated copy is a failure to verify it
|
|
got = {"outcome": "crash", "error": "%s: %s" % (type(e).__name__, e)}
|
|
ok = got == v.get("outcome")
|
|
if not quiet:
|
|
what = got["outcome"] + (" %s at %s" % (got["reason"], got["at"]) if got["outcome"] == "refuse" else "")
|
|
print(("PASS" if ok else "FAIL") + " %s: %s" % (v.get("name"), what))
|
|
if not ok:
|
|
fails.append(v.get("name"))
|
|
return fails
|
|
|
|
|
|
def reasons_listed():
|
|
"""§10's table, in order, and the codes its paragraph names as reused."""
|
|
text = open(EXTENSION, encoding="utf-8").read()
|
|
start = text.index("\n## 10. Reasons")
|
|
section = text[start:text.find("\n## ", start + 1)]
|
|
added = [m.group(1) for m in re.finditer(r"^\| `([a-z_]+)` \|", section, re.M)]
|
|
reused = [r for r in re.findall(r"`([a-z_]+)`", section.split("also raises", 1)[1]) if r not in added]
|
|
return added, reused
|
|
|
|
|
|
def flip_hex(h, at):
|
|
b = bytearray(bytes.fromhex(h))
|
|
b[at % len(b)] ^= 0x01
|
|
return b.hex()
|
|
|
|
|
|
def mutations(doc):
|
|
"""Copies of the corpus, one defect each, that a verifier must reject."""
|
|
out = []
|
|
|
|
def copy_with(name, k, edit):
|
|
m = copy.deepcopy(doc)
|
|
edit(m["vectors"][k])
|
|
out.append(("%s: %s" % (doc["vectors"][k]["name"], name), m))
|
|
|
|
for k, v in enumerate(doc["vectors"]):
|
|
if v["outcome"]["outcome"] != "accept":
|
|
continue
|
|
b = v["bundle"]
|
|
for at_ in (6, 50, -100, -1):
|
|
copy_with("report byte %d flipped" % at_, k, lambda x, a=at_: x["bundle"].__setitem__("report", flip_hex(b["report"], a)))
|
|
if b["enrolments"]:
|
|
copy_with("an enrolment's byte flipped", k, lambda x: x["bundle"]["enrolments"].__setitem__(0, flip_hex(b["enrolments"][0], 70)))
|
|
copy_with("its last enrolment dropped", k, lambda x: x["bundle"]["enrolments"].pop())
|
|
for i, r in enumerate(b["records"]):
|
|
if r["kind"] == "summary":
|
|
copy_with("record %d's line changed by a digit" % i, k,
|
|
lambda x, i=i: x["bundle"]["records"][i].__setitem__("line", re.sub(r'"session":(\d)', lambda m: '"session":%d' % ((int(m.group(1)) + 1) % 10), b["records"][i]["line"], count=1)))
|
|
elif r["kind"] == "hop":
|
|
copy_with("record %d's byte flipped" % i, k, lambda x, i=i: x["bundle"]["records"][i].__setitem__("record", flip_hex(b["records"][i]["record"], 120)))
|
|
if b["records"]:
|
|
copy_with("its last record dropped", k, lambda x: x["bundle"]["records"].pop())
|
|
copy_with("its first record given twice", k, lambda x: x["bundle"]["records"].append(b["records"][0]))
|
|
o = v["outcome"]
|
|
copy_with("its stated totals' bytes in raised", k, lambda x: x["outcome"]["totals"].__setitem__("bytes_in", o["totals"]["bytes_in"] + 1))
|
|
copy_with("its stated hops' coverage changed", k, lambda x: x["outcome"]["totals"]["energy"]["hops"].__setitem__(
|
|
"coverage", "whole" if o["totals"]["energy"]["hops"]["coverage"] != "whole" else "partial"))
|
|
copy_with("its stated sessions' unmetered figures set to zero", k, lambda x: x["outcome"]["totals"]["energy"]["sessions"].__setitem__(
|
|
"unmetered", 0 if o["totals"]["energy"]["sessions"]["unmetered"] else 1))
|
|
if o["enrolments"]:
|
|
copy_with("an enrolment stated silent the other way", k, lambda x: x["outcome"]["enrolments"][0].__setitem__("silent", not o["enrolments"][0]["silent"]))
|
|
if o["nodes"]:
|
|
copy_with("its first node line's citations dropped", k, lambda x: x["outcome"]["nodes"][0].__setitem__("cites", []))
|
|
for k, v in enumerate(doc["vectors"]):
|
|
if v["outcome"]["outcome"] == "refuse":
|
|
r = v["outcome"]["reason"]
|
|
copy_with("its stated reason changed", k, lambda x, r=r: x["outcome"].__setitem__("reason", "report_mismatch" if r != "report_mismatch" else "malformed"))
|
|
copy_with("its stated place changed", k, lambda x: x["outcome"].__setitem__("at", ["bundle"] if x["outcome"]["at"] != ["bundle"] else ["report"]))
|
|
return out
|
|
|
|
|
|
CHECKS = ["report-signature", "enrolment-signature", "summary-signature", "descriptor-signature", "report-rules", "row-of-no-class",
|
|
"enrolment-rules", "organisation", "enrolment-duplicate", "enrolment-period", "summary-form", "summary-duplicates",
|
|
"summary-format", "summary-energy", "summary-rules", "ledger-binding", "enrolled", "role", "descriptor", "period",
|
|
"enrolment-window", "duplicate", "overlap", "unsourced", "summed", "unmetered", "citations", "mismatch"]
|
|
|
|
|
|
def main(corpus_dir):
|
|
doc = json.load(open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8"))
|
|
fails = check(doc)
|
|
files = sorted(os.listdir(corpus_dir))
|
|
if files != ["README.md", "vectors.json"]:
|
|
fails.append("the corpus holds other files: %s" % files)
|
|
seen = {v["outcome"].get("reason") for v in doc["vectors"]}
|
|
added, reused = reasons_listed()
|
|
missing = [r for r in added + reused if r not in seen]
|
|
if missing:
|
|
fails.append("no vector is refused %s" % missing)
|
|
print("%s every reason of §10 and every code it reuses refuses a vector (%d added, %d reused)"
|
|
% ("PASS" if not missing else "FAIL", len(added), len(reused)))
|
|
unlisted = sorted(r for r in seen if r is not None and r not in added + reused)
|
|
if unlisted:
|
|
fails.append("§10 does not name %s, which the corpus raises" % unlisted)
|
|
print("%s §10 names every reason the corpus raises" % ("PASS" if not unlisted else "FAIL"))
|
|
copies = mutations(doc)
|
|
weak = [name for name, m in copies if not check(m, quiet=True)]
|
|
print("%s every mutated copy of the corpus fails (%d copies)" % ("PASS" if not weak else "FAIL", len(copies)))
|
|
fails += ["the mutated copy %r verifies" % w for w in weak]
|
|
undetected = []
|
|
for c in CHECKS:
|
|
SKIP.add(c)
|
|
try:
|
|
if not check(doc, quiet=True):
|
|
undetected.append(c)
|
|
finally:
|
|
SKIP.discard(c)
|
|
print("%s the corpus catches the verifier with each of its checks dropped (%d checks)" % ("PASS" if not undetected else "FAIL", len(CHECKS)))
|
|
fails += ["dropping the check %r goes unnoticed" % c for c in undetected]
|
|
for f in fails:
|
|
print("FAIL", f)
|
|
return 1 if fails else 0
|
|
|
|
|
|
def check_bundle(path):
|
|
"""One bundle, such as a fleet service exports with a report: its outcome, printed. Status 0
|
|
only when it is accepted."""
|
|
got = verify(json.load(open(path, encoding="utf-8")))
|
|
print(json.dumps(got, indent=1))
|
|
return 0 if got["outcome"] == "accept" else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) == 3 and sys.argv[1] == "--bundle":
|
|
sys.exit(check_bundle(sys.argv[2]))
|
|
if len(sys.argv) != 2:
|
|
print("usage: fleet_verify.py <live-conformance/fleet> | --bundle FILE", file=sys.stderr)
|
|
sys.exit(2)
|
|
sys.exit(main(sys.argv[1]))
|