influence_verify.py is the second implementation of extensions/influence.md: it verifies each session with contribution_verify.py, regenerates every conditioned step, reads usage statements with ai_usage_verify.py and statements with settlement_verify.py (now amended for kind 2 lines and their bases), and computes every share, line and report itself. Registers wai.media.influence (registry, wire identifiers, SPEC §5, CHANGELOG, CI step, export); the settlement corpus's reserved-line vector becomes a line whose basis is not given. Co-Authored-By: Claude Opus 5.5 <[email protected]>
860 lines
37 KiB
Python
860 lines
37 KiB
Python
#!/usr/bin/env python3
|
||
"""Independent verifier of the delivery settlement corpus (extensions/settlement.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
|
||
computes BLAKE3, its subtree chaining values and its parent nodes with
|
||
../claim-conformance/_blake3.py, written from the BLAKE3 specification: the module the HTTP head
|
||
proof's verifier reads its tree from. A room's descriptor, its grants and a grant's proof record
|
||
are read by the live-rooms corpus's own second implementation, live_rooms_verify.py (live-rooms
|
||
§3, §4, §10.5.1), and a per-hop record by the live-metering corpus's, live_metering_verify.py
|
||
(live-metering §3.6), each written from its text alone. Everything else is here:
|
||
|
||
- the acknowledgement and the declaration (§3, §4): reading, rules, signature, item lists, the
|
||
tree root of jwp-receipts §2.2, chains;
|
||
- the range proof (§5): reading, the covered chunks and the root over BLAKE3's chunk tree;
|
||
- a grant and its proof on an entry (§6.3);
|
||
- the statement (§7): reading, rules, signature, and every figure recomputed;
|
||
- verification in §8.2's order, with each refusal's place.
|
||
|
||
For every statement and range proof it requires the outcome the corpus states; of the corpus,
|
||
that every reason of §11 appears in it 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 settlement_verify.py <live-conformance/settlement>
|
||
|
||
Exit status 0 only if every case 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 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 live_events_verify as LEV # noqa: E402
|
||
import live_rooms_verify as LRV # noqa: E402
|
||
import live_metering_verify as LMV # noqa: E402
|
||
import _ed25519 as ED # noqa: E402
|
||
import _blake3 as B3 # noqa: E402
|
||
|
||
EXTENSION = os.path.join(HERE, "..", "extensions", "settlement.md")
|
||
|
||
Refuse = LEV.Refuse
|
||
|
||
ACK_MAGIC, RANGE_MAGIC, STATEMENT_MAGIC = b"WDA1", b"WBR1", b"WSS1" # §3.1, §5.2, §7.1
|
||
ACK_DOMAIN = b"wai:settle-ack\x01" # §3.5
|
||
ITEMS_DOMAIN = b"wai:settle-items\x01" # §3.2
|
||
STATEMENT_DOMAIN = b"wai:settle-statement\x01" # §7.4
|
||
OBJECT_DOMAIN = b"moq-jwp:object\x01" # jwp-receipts §2.1
|
||
LEAF, NODE = b"moq-jwp:merkle-leaf\x01", b"moq-jwp:merkle-node\x01" # jwp-receipts §2.2
|
||
PROOF_DOMAIN = b"wai:live-room-proof\x01" # live-rooms §10.5.1
|
||
SEEK = 16 # live-rooms §3.3, bit 4
|
||
ACK, DECLARATION = 1, 2
|
||
HOP, SINK = 1, 2
|
||
LIVE, FETCH, FILL, ANCILLARY = 1, 2, 3, 4
|
||
DELIVERY_LINE, ATTRIBUTION_LINE = 1, 2 # §7.2
|
||
CLASS_NAMES = {LIVE: "live", FETCH: "fetch", FILL: "fill", ANCILLARY: "ancillary"}
|
||
RETRIEVAL = (FETCH, FILL)
|
||
LABELS = {2: "ModelBased", 3: "Estimator", 4: "OnChipCounter", 5: "CalibratedInstrument"}
|
||
ZERO = bytes(32)
|
||
U64 = 2 ** 64 - 1
|
||
CHUNK = 1024
|
||
DLEN = 15 # the length of jwp-receipts §2.1's domain
|
||
PER = 10 ** 9 # §7.3: prices per 10^9 bytes
|
||
|
||
SKIP = set()
|
||
|
||
# Signature checks are pure functions of their inputs, and the corpus checks the same records under
|
||
# every mutated copy and every weakened verifier: each is computed once.
|
||
_ED_VERIFY = ED.verify
|
||
_ED_MEMO = {}
|
||
|
||
|
||
def _ed_verify(pk, msg, sig, skip=()):
|
||
k = (bytes(pk), bytes(msg), bytes(sig), tuple(skip))
|
||
if k not in _ED_MEMO:
|
||
_ED_MEMO[k] = _ED_VERIFY(pk, msg, sig, skip=skip)
|
||
return _ED_MEMO[k]
|
||
|
||
|
||
ED.verify = _ed_verify
|
||
|
||
|
||
def reasons_of_extension():
|
||
"""§11's codes, read from the text."""
|
||
with open(EXTENSION, encoding="utf-8") as f:
|
||
text = f.read()
|
||
at = text.index("\n## 11. Reasons")
|
||
end = text.index("\n## ", at + 1)
|
||
return [line.split("`")[1] for line in text[at:end].splitlines() if line.startswith("| `")]
|
||
|
||
|
||
REASONS = reasons_of_extension()
|
||
REUSED = ["truncated", "malformed", "unknown_version", "bad_key", "bad_signature", "record_missing", "classes_summed",
|
||
"outside_grant_window", "grant_wrong_principal", "seek_not_granted", "pop_malformed", "pop_wrong_grant",
|
||
"pop_wrong_key", "pop_bad_signature", "pop_replayed"]
|
||
|
||
|
||
class Stop(Exception):
|
||
"""A refusal and its place (§8.2)."""
|
||
|
||
def __init__(self, reason, at):
|
||
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()
|
||
|
||
|
||
def given_digest(b):
|
||
"""§8.2: SHA-256 over all but the last 64 bytes."""
|
||
return sha(b[:max(len(b) - 64, 0)])
|
||
|
||
|
||
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):
|
||
"""§3.6: the magic, then 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 check_sig(key, domain, body, sig):
|
||
bad = ED.verify(key, domain + body, sig)
|
||
if bad:
|
||
raise Refuse(bad)
|
||
|
||
|
||
# ---------------------------------------------------------------- §2, §3.4: the tree and items
|
||
|
||
def tree_root(hashes):
|
||
"""§2: jwp-receipts §2.2's root in its first form."""
|
||
if not hashes:
|
||
return ZERO
|
||
level = [B3.blake3(LEAF + h) for h in hashes]
|
||
while len(level) > 1:
|
||
if len(level) % 2:
|
||
level.append(level[-1])
|
||
level = [B3.blake3(NODE + level[k] + level[k + 1]) for k in range(0, len(level), 2)]
|
||
return level[0]
|
||
|
||
|
||
def read_items(b):
|
||
"""§3.6: an item list, or Refuse(items_invalid)."""
|
||
if len(b) % 48:
|
||
raise Refuse("items_invalid")
|
||
items = [(b[k:k + 32], int.from_bytes(b[k + 32:k + 40], "little"), int.from_bytes(b[k + 40:k + 48], "little"))
|
||
for k in range(0, len(b), 48)]
|
||
if "items-rules" not in SKIP:
|
||
if any(s >= e for _, s, e in items):
|
||
raise Refuse("items_invalid")
|
||
for x in range(len(items)):
|
||
for y in range(x + 1, len(items)):
|
||
if items[x][0] == items[y][0] and overlaps(items[x][1:], items[y][1:]):
|
||
raise Refuse("items_invalid")
|
||
return items
|
||
|
||
|
||
def overlaps(a, b):
|
||
return a[0] < b[1] and b[0] < a[1]
|
||
|
||
|
||
def recomputes(e, items, raw):
|
||
"""§3.6: the item list against its entry."""
|
||
if "items-recompute" in SKIP:
|
||
return True
|
||
return (len(items) == e["objects"] and sum(z - s for _, s, z in items) == e["bytes"]
|
||
and tree_root([h for h, _, _ in items]) == e["root"] and sha(ITEMS_DOMAIN, raw) == e["items"])
|
||
|
||
|
||
# ---------------------------------------------------------------- §3 the acknowledgement
|
||
|
||
def read_ack(b):
|
||
"""§3.6: a record, its rules, then its signature."""
|
||
read_head(b, ACK_MAGIC)
|
||
r = R(b, 5)
|
||
a = {"mode": r.u8(), "role": r.u8(), "upstream": r.take(32), "downstream": r.take(32), "start": r.u64(),
|
||
"end": r.u64(), "prev": r.take(32), "entries": []}
|
||
for _ in range(r.u16()):
|
||
a["entries"].append({"class": r.u8(), "grant": r.take(32), "proof": r.take(32), "objects": r.u32(),
|
||
"bytes": r.u64(), "root": r.take(32), "items": r.take(32)})
|
||
sig = r.take(64)
|
||
if r.p != len(b):
|
||
raise Refuse("malformed")
|
||
|
||
def entry_ok(e):
|
||
if e["class"] not in CLASS_NAMES or e["objects"] < 1 or e["bytes"] < e["objects"]:
|
||
return False
|
||
if e["class"] in RETRIEVAL:
|
||
return (e["grant"] == ZERO) == (e["proof"] == ZERO)
|
||
return e["proof"] == ZERO
|
||
keys = [(e["class"], e["grant"], e["proof"]) for e in a["entries"]]
|
||
rules = (a["mode"] in (ACK, DECLARATION) and a["role"] in (HOP, SINK) and a["start"] < a["end"]
|
||
and a["upstream"] != ZERO and (a["mode"] == DECLARATION or a["downstream"] != ZERO)
|
||
and a["entries"] and all(x < y for x, y in zip(keys, keys[1:])) and all(entry_ok(e) for e in a["entries"]))
|
||
if "ack-rules" not in SKIP and not rules:
|
||
raise Refuse("ack_invalid")
|
||
if "self-ack" not in SKIP and a["mode"] == ACK and a["downstream"] == a["upstream"]:
|
||
raise Refuse("self_acknowledged")
|
||
if "ack-signature" not in SKIP:
|
||
check_sig(a["upstream"] if a["mode"] == DECLARATION else a["downstream"], ACK_DOMAIN, b[:-64], sig)
|
||
a["digest"] = sha(b[:-64])
|
||
return a
|
||
|
||
|
||
# ---------------------------------------------------------------- §5 range proofs
|
||
|
||
def left(n):
|
||
"""§5.1: 1024 · 2^⌊log₂((n − 1)/1024)⌋, for n above 1024."""
|
||
q = (n - 1) // CHUNK
|
||
return CHUNK << (q.bit_length() - 1)
|
||
|
||
|
||
def covered(n, start, end):
|
||
"""§5.2: the covered chunks, as offsets into X."""
|
||
c0 = (DLEN + start) // CHUNK * CHUNK
|
||
c1 = min(DLEN + n, -(-(DLEN + end) // CHUNK) * CHUNK)
|
||
return c0, c1
|
||
|
||
|
||
def read_range(b):
|
||
"""§5.3."""
|
||
read_head(b, RANGE_MAGIC)
|
||
if len(b) < 62:
|
||
raise Refuse("truncated")
|
||
r = R(b, 5)
|
||
p = {"content_hash": r.take(32), "envelope_len": r.u64(), "start": r.u64(), "end": r.u64()}
|
||
n = r.u8()
|
||
if "range-bounds" not in SKIP:
|
||
if p["start"] >= p["end"]:
|
||
raise Refuse("range_invalid")
|
||
if p["end"] > p["envelope_len"] or DLEN + p["envelope_len"] > U64:
|
||
raise Refuse("range_outside_object")
|
||
c0, c1 = covered(p["envelope_len"], p["start"], p["end"])
|
||
want = 62 + 32 * n + (c1 - max(c0, DLEN))
|
||
if len(b) < want:
|
||
raise Refuse("truncated")
|
||
if len(b) > want:
|
||
raise Refuse("malformed")
|
||
p["cvs"] = [r.take(32) for _ in range(n)]
|
||
p["bytes"] = b[r.p:]
|
||
return p
|
||
|
||
|
||
def verify_range(p, h, audit):
|
||
"""§5.4, in order; the facts of §8.3."""
|
||
if "range-hash" not in SKIP and p["content_hash"] != h:
|
||
raise Refuse("range_proof_mismatch")
|
||
if audit is not None and "range-audit" not in SKIP:
|
||
if audit[0] != h or p["start"] < audit[1] or p["end"] > audit[2]:
|
||
raise Refuse("range_not_acknowledged")
|
||
L = DLEN + p["envelope_len"]
|
||
c0, c1 = covered(p["envelope_len"], p["start"], p["end"])
|
||
xc = (OBJECT_DOMAIN if c0 == 0 else b"") + p["bytes"]
|
||
if c0 == 0 and c1 == L:
|
||
if p["cvs"]:
|
||
raise Refuse("range_proof_mismatch")
|
||
root = B3.blake3(xc)
|
||
else:
|
||
cvs = list(p["cvs"])
|
||
|
||
def cv(s, n):
|
||
if s + n <= c0 or s >= c1:
|
||
if not cvs:
|
||
raise Refuse("range_proof_mismatch")
|
||
return cvs.pop(0)
|
||
if c0 <= s and s + n <= c1:
|
||
return B3.subtree_cv(xc[s - c0:s - c0 + n], s)
|
||
if n <= CHUNK:
|
||
raise Refuse("range_proof_mismatch")
|
||
k = left(n)
|
||
return B3.parent_cv(cv(s, k), cv(s + k, n - k))
|
||
k = left(L)
|
||
a = cv(0, k)
|
||
z = cv(k, L - k)
|
||
if cvs and "range-exact" not in SKIP:
|
||
raise Refuse("range_proof_mismatch")
|
||
root = B3.parent_cv(a, z, root=True)
|
||
if root != h and "range-root" not in SKIP:
|
||
raise Refuse("range_proof_mismatch")
|
||
return {"outcome": "accept", "start": p["start"], "end": p["end"], "envelope_len": p["envelope_len"],
|
||
"covered": [c0, c1], "length_established": c1 == L}
|
||
|
||
|
||
def range_outcome(v):
|
||
audit = None
|
||
if v["audit"] is not None:
|
||
audit = (bytes.fromhex(v["audit"]["content_hash"]), v["audit"]["start"], v["audit"]["end"])
|
||
try:
|
||
return verify_range(read_range(bytes.fromhex(v["proof"])), bytes.fromhex(v["content_hash"]), audit)
|
||
except Refuse as e:
|
||
return {"outcome": "refuse", "reason": e.reason}
|
||
|
||
|
||
# ---------------------------------------------------------------- §7 the statement
|
||
|
||
def read_statement(b):
|
||
"""§7.5: a statement, its rules, then its signature."""
|
||
read_head(b, STATEMENT_MAGIC)
|
||
r = R(b, 5)
|
||
s = {"party": r.take(32), "start": r.u64(), "end": r.u64()}
|
||
s["records"] = [r.take(32) for _ in range(r.u16())]
|
||
s["hops"] = [r.take(32) for _ in range(r.u16())]
|
||
s["currency"] = r.take(3)
|
||
s["prices"] = [r.u64() for _ in range(4)]
|
||
s["classes"] = [(r.u64(), r.u64()) for _ in range(4)]
|
||
s["unack"] = (r.u32(), r.u64(), r.u64())
|
||
s["unmetered"] = r.u16()
|
||
s["totals"] = [(r.u8(), r.u32(), r.u64()) for _ in range(r.u8())]
|
||
s["lines"] = []
|
||
for _ in range(r.u16()):
|
||
s["lines"].append({"kind": r.u8(), "payee": r.take(32), "class": r.u8(), "payer": r.take(32),
|
||
"objects": r.u64(), "bytes": r.u64(), "amount": r.u64()})
|
||
s["amount"] = r.u64()
|
||
sig = r.take(64)
|
||
if r.p != len(b):
|
||
raise Refuse("malformed")
|
||
if any(t[0] == 0 for t in s["totals"]):
|
||
raise Refuse("classes_summed")
|
||
|
||
def ascending(xs):
|
||
return all(x < y for x, y in zip(xs, xs[1:]))
|
||
tariff = all(p == 0 for p in s["prices"]) if s["currency"] == bytes(3) else all(0x41 <= c <= 0x5A for c in s["currency"])
|
||
keys = [(x["kind"], x["payee"], x["class"], x["payer"]) for x in s["lines"]]
|
||
rules = (s["start"] < s["end"] and s["records"] and ascending(s["records"]) and ascending(s["hops"]) and tariff
|
||
and all(2 <= t[0] <= 5 and t[1] > 0 for t in s["totals"]) and ascending([t[0] for t in s["totals"]])
|
||
and all(((x["kind"] == DELIVERY_LINE and x["payee"] == s["party"])
|
||
or (x["kind"] == ATTRIBUTION_LINE and x["payee"] != ZERO and x["payer"] != ZERO))
|
||
and x["class"] in CLASS_NAMES and x["objects"] > 0 for x in s["lines"])
|
||
and ascending(keys))
|
||
if "statement-rules" not in SKIP and not rules:
|
||
raise Refuse("statement_invalid")
|
||
if "statement-signature" not in SKIP:
|
||
check_sig(s["party"], STATEMENT_DOMAIN, b[:-64], sig)
|
||
s["digest"] = sha(b[:-64])
|
||
return s
|
||
|
||
|
||
def amount_of(prices, c, nbytes):
|
||
"""§7.3: ⌊bytes × p / 10^9⌋."""
|
||
return nbytes * prices[c - 1] // PER
|
||
|
||
|
||
def basis_order(s):
|
||
"""§8.2 step 6: the payers of the attribution lines, in the order the lines first name them."""
|
||
out = []
|
||
for x in s["lines"]:
|
||
if x["kind"] == ATTRIBUTION_LINE and x["payer"] not in out:
|
||
out.append(x["payer"])
|
||
return out
|
||
|
||
|
||
def attribution_figures(s, acks, bases):
|
||
"""§7.8: the attribution lines per (payee, class, basis), and each basis's deliveries per
|
||
(basis, class). `bases` maps a basis to its output, its whole and its payable buckets, each
|
||
(key, units, per_use, share_ppm)."""
|
||
lines, deliveries = {}, {}
|
||
for d in basis_order(s):
|
||
b = bases.get(d)
|
||
if b is None:
|
||
continue
|
||
for c in (LIVE, FETCH, FILL, ANCILLARY):
|
||
n = y = paid = 0
|
||
for a in acks:
|
||
if a["mode"] != ACK:
|
||
continue
|
||
held = False
|
||
for e, its in zip(a["entries"], a["items_read"]):
|
||
if e["class"] != c:
|
||
continue
|
||
free = c in RETRIEVAL and e["grant"] == ZERO
|
||
for h, start, end in its:
|
||
if h == b["output"]:
|
||
held = True
|
||
y += end - start
|
||
paid += 0 if free else end - start
|
||
n += 1 if held else 0
|
||
if n == 0:
|
||
continue
|
||
deliveries[(d, c)] = (n, y)
|
||
revenue = amount_of(s["prices"], c, paid)
|
||
for key, units, per_use, share in b["payees"]:
|
||
x = lines.setdefault((key, c, d), [n, y, 0])
|
||
x[2] += per_use * n + revenue * share * units // (10 ** 6 * b["whole"])
|
||
return lines, deliveries
|
||
|
||
|
||
def figures(s, acks, hops):
|
||
"""§7.6, §7.7: every figure, recomputed."""
|
||
lines = {}
|
||
unack = [0, 0, 0]
|
||
for a in acks:
|
||
if a["mode"] == DECLARATION:
|
||
unack[0] += 1
|
||
for e in a["entries"]:
|
||
unack[1] += e["objects"]
|
||
unack[2] += e["bytes"]
|
||
continue
|
||
for e in a["entries"]:
|
||
payer = e["grant"] if e["class"] in RETRIEVAL else a["downstream"]
|
||
x = lines.setdefault((e["class"], payer), [0, 0, 0])
|
||
x[0] += e["objects"]
|
||
x[1] += e["bytes"]
|
||
classes = [[0, 0] for _ in range(4)]
|
||
total = 0
|
||
for (c, payer), x in lines.items():
|
||
x[2] = 0 if (c in RETRIEVAL and payer == ZERO) else amount_of(s["prices"], c, x[1])
|
||
classes[c - 1][0] += x[0]
|
||
classes[c - 1][1] += x[1]
|
||
total += x[2]
|
||
totals, unmetered = {}, 0
|
||
for h in hops:
|
||
if h["class"] is None:
|
||
unmetered += 1
|
||
else:
|
||
t = totals.setdefault(h["class"][0], [0, 0])
|
||
t[0] += 1
|
||
t[1] += h["joules"]
|
||
return {"lines": lines, "classes": classes, "unack": unack, "unmetered": unmetered, "totals": totals, "amount": total}
|
||
|
||
|
||
def check_figures(s, f):
|
||
"""§7.6's comparison, in its order."""
|
||
delivery = [x for x in s["lines"] if x["kind"] == DELIVERY_LINE]
|
||
attribution = [x for x in s["lines"] if x["kind"] == ATTRIBUTION_LINE]
|
||
if "free-line" not in SKIP:
|
||
for x in delivery:
|
||
if x["class"] in RETRIEVAL and x["payer"] == ZERO and x["amount"] != 0:
|
||
raise Refuse("paid_without_grant")
|
||
if "bytes-covered" not in SKIP:
|
||
for x in delivery:
|
||
got = f["lines"].get((x["class"], x["payer"]))
|
||
if got is None or x["objects"] > got[0] or x["bytes"] > got[1]:
|
||
raise Refuse("bytes_not_acknowledged")
|
||
if "attribution-covered" not in SKIP:
|
||
for x in attribution:
|
||
got = f["deliveries"].get((x["payer"], x["class"]))
|
||
if got is None or x["objects"] > got[0] or x["bytes"] > got[1]:
|
||
raise Refuse("bytes_not_acknowledged")
|
||
if "classes-summed" not in SKIP:
|
||
tags = sorted(f["totals"])
|
||
for tag, _, joules in s["totals"]:
|
||
if tag in f["totals"] and joules != f["totals"][tag][1]:
|
||
others = [x for x in tags if x != tag]
|
||
for mask in range(1, 1 << len(others)):
|
||
extra = sum(f["totals"][others[i]][1] for i in range(len(others)) if mask >> i & 1)
|
||
if extra + f["totals"][tag][1] == joules:
|
||
raise Refuse("classes_summed")
|
||
if "figures" in SKIP:
|
||
return
|
||
same = (len(delivery) == len(f["lines"])
|
||
and all(f["lines"].get((x["class"], x["payer"])) == [x["objects"], x["bytes"], x["amount"]] for x in delivery)
|
||
and ("attribution-figures" in SKIP or (
|
||
len(attribution) == len(f["attribution"])
|
||
and all(f["attribution"].get((x["payee"], x["class"], x["payer"])) == [x["objects"], x["bytes"], x["amount"]]
|
||
for x in attribution)))
|
||
and [list(c) for c in s["classes"]] == f["classes"] and list(s["unack"]) == f["unack"]
|
||
and s["unmetered"] == f["unmetered"]
|
||
and len(s["totals"]) == len(f["totals"]) and all(f["totals"].get(t) == [n, j] for t, n, j in s["totals"])
|
||
and s["amount"] == f["amount"])
|
||
if not same:
|
||
raise Refuse("statement_mismatch")
|
||
|
||
|
||
# ---------------------------------------------------------------- §6.3 grants and proofs
|
||
|
||
def grant_check(a, e, rooms, proofs):
|
||
held = [g for r in rooms for g in r["grants"] if g["digest"] == e["grant"]]
|
||
if not held:
|
||
raise Refuse("record_missing")
|
||
g = held[0]
|
||
if "grant-window" not in SKIP and not (g["nbf"] * 10 ** 6 <= a["start"] and a["end"] <= g["exp"] * 10 ** 6):
|
||
raise Refuse("outside_grant_window")
|
||
if e["class"] not in RETRIEVAL:
|
||
if "grant-principal" not in SKIP and g["principal"] != a["downstream"]:
|
||
raise Refuse("grant_wrong_principal")
|
||
return
|
||
b = proofs.get(e["proof"])
|
||
if b is None:
|
||
raise Refuse("record_missing")
|
||
if "proof-checks" not in SKIP:
|
||
p = LRV.read_proof(b)
|
||
if p["grant"] != g["digest"]:
|
||
raise Refuse("pop_wrong_grant")
|
||
if p["key"] != g["principal"]:
|
||
raise Refuse("pop_wrong_key")
|
||
if ED.verify(p["key"], PROOF_DOMAIN + p["body"], p["sig"]) is not None:
|
||
raise Refuse("pop_bad_signature")
|
||
if "seek" not in SKIP and e["class"] == FETCH and not g["powers"] & SEEK:
|
||
raise Refuse("seek_not_granted")
|
||
|
||
|
||
# ---------------------------------------------------------------- §8 verification
|
||
|
||
def inputs_of(doc, v):
|
||
"""The pool, less what the vector withholds, with what it adds; its rooms."""
|
||
withhold = {bytes.fromhex(d) for d in v["withhold"]}
|
||
records, items, proofs, hops = {}, {}, {}, {}
|
||
for r in doc["records"] + v["extra"]["records"]:
|
||
b = bytes.fromhex(r["record"])
|
||
d = given_digest(b)
|
||
if d in withhold and "name" in r:
|
||
continue
|
||
records[d] = b
|
||
items[d] = [bytes.fromhex(x) for x in r["items"]]
|
||
for raw in [p["proof"] for p in doc["proofs"]] + v["extra"]["proofs"]:
|
||
b = bytes.fromhex(raw)
|
||
if sha(b) not in withhold:
|
||
proofs[sha(b)] = b
|
||
for h in doc["hops"]:
|
||
b = bytes.fromhex(h["hop"])
|
||
if given_digest(b) not in withhold:
|
||
hops[given_digest(b)] = b
|
||
rooms = [doc["rooms"][n] for n in v["rooms"]]
|
||
return records, items, proofs, hops, rooms
|
||
|
||
|
||
def verify_statement(doc, v, bases=None):
|
||
"""§8.2. `bases`, where given, maps each basis an attribution line may name to its figures
|
||
(§7.8), as influence §6.2 verifies them; a verifier of this extension alone holds none."""
|
||
bases = bases or {}
|
||
records, items, proofs, hops, room_inputs = inputs_of(doc, v)
|
||
# Step 1.
|
||
try:
|
||
s = read_statement(bytes.fromhex(v["statement"]))
|
||
except Refuse as e:
|
||
raise Stop(e.reason, ["statement"])
|
||
# Step 2.
|
||
rooms = []
|
||
for ri in room_inputs:
|
||
desc = bytes.fromhex(ri["descriptor"])
|
||
try:
|
||
d = LRV.read_descriptor(desc)
|
||
except Refuse as e:
|
||
raise Stop(e.reason, ["room", given_digest(desc).hex()])
|
||
held, err = LRV.check_grants(d, [bytes.fromhex(g) for g in ri["grants"]])
|
||
if err is not None and "room-grants" not in SKIP:
|
||
raise Stop(err[1], ["room", d["room"].hex(), "grant", err[0]])
|
||
rooms.append({"d": d, "grants": held or []})
|
||
# Step 3.
|
||
acks = []
|
||
for k, d in enumerate(s["records"]):
|
||
if d not in records:
|
||
raise Stop("record_missing", ["record", k])
|
||
try:
|
||
a = read_ack(records[d])
|
||
except Refuse as e:
|
||
raise Stop(e.reason, ["record", k])
|
||
if "upstream" not in SKIP and a["upstream"] != s["party"]:
|
||
raise Stop("ack_wrong_party", ["record", k])
|
||
if "period" not in SKIP and not (s["start"] <= a["start"] and a["end"] <= s["end"]):
|
||
raise Stop("ack_outside_period", ["record", k])
|
||
a["items_read"] = []
|
||
for j, e in enumerate(a["entries"]):
|
||
lists = items.get(d, [])
|
||
if j >= len(lists):
|
||
raise Stop("record_missing", ["record", k, "entry", j])
|
||
try:
|
||
its = read_items(lists[j])
|
||
except Refuse as x:
|
||
raise Stop(x.reason, ["record", k, "entry", j])
|
||
if not recomputes(e, its, lists[j]):
|
||
raise Stop("items_mismatch", ["record", k, "entry", j])
|
||
a["items_read"].append(its)
|
||
if e["grant"] != ZERO:
|
||
try:
|
||
grant_check(a, e, rooms, proofs)
|
||
except Refuse as x:
|
||
raise Stop(x.reason, ["record", k, "entry", j])
|
||
acks.append(a)
|
||
# Step 4.
|
||
chains = {}
|
||
for k, a in enumerate(acks):
|
||
chains.setdefault((a["mode"], a["upstream"], a["downstream"]), []).append(k)
|
||
before = {}
|
||
for ks in chains.values():
|
||
ks.sort(key=lambda k: (acks[k]["start"], k))
|
||
for p, q in zip(ks, ks[1:]):
|
||
before[q] = p
|
||
for k, a in enumerate(acks):
|
||
if k in before:
|
||
p = acks[before[k]]
|
||
if "chain-overlap" not in SKIP and p["end"] > a["start"]:
|
||
raise Stop("ack_overlap", ["record", k])
|
||
if "chain-prev" not in SKIP and a["prev"] != p["digest"]:
|
||
raise Stop("ack_chain_broken", ["record", k])
|
||
if "declared" not in SKIP:
|
||
for k, a in enumerate(acks):
|
||
if a["mode"] != DECLARATION or a["downstream"] == ZERO:
|
||
continue
|
||
mine = [x for its in a["items_read"] for x in its]
|
||
for b in acks:
|
||
if b["mode"] != ACK or b["downstream"] != a["downstream"] or not overlaps((a["start"], a["end"]), (b["start"], b["end"])):
|
||
continue
|
||
theirs = [x for its in b["items_read"] for x in its]
|
||
if any(x[0] == y[0] and overlaps(x[1:], y[1:]) for x in mine for y in theirs):
|
||
raise Stop("declared_and_acknowledged", ["record", k])
|
||
if "pop-replayed" not in SKIP:
|
||
named = {}
|
||
for k, a in enumerate(acks):
|
||
for j, e in enumerate(a["entries"]):
|
||
if e["proof"] == ZERO:
|
||
continue
|
||
if e["proof"] in named and named[e["proof"]] != k:
|
||
raise Stop("pop_replayed", ["record", k, "entry", j])
|
||
named.setdefault(e["proof"], k)
|
||
# Step 5.
|
||
held_hops = []
|
||
for k, d in enumerate(s["hops"]):
|
||
if d not in hops:
|
||
raise Stop("record_missing", ["hop", k])
|
||
b = hops[d]
|
||
try:
|
||
h = LMV.read_hop(b, b[5:37])
|
||
except Refuse as e:
|
||
raise Stop(e.reason, ["hop", k])
|
||
room = next((r for r in rooms if r["d"]["room"] == h["room"]), None)
|
||
if room is None:
|
||
raise Stop("record_missing", ["hop", k])
|
||
if "hop-party" not in SKIP and h["hop"] != s["party"]:
|
||
raise Stop("hop_not_party", ["hop", k])
|
||
t0 = room["d"]["t0"]
|
||
if "hop-span" not in SKIP and not (s["start"] <= t0 + h["start"] and t0 + h["end"] <= s["end"]):
|
||
raise Stop("span_outside_period", ["hop", k])
|
||
held_hops.append(h)
|
||
# Step 6: the bases.
|
||
if "bases" not in SKIP:
|
||
for k, d in enumerate(basis_order(s)):
|
||
if d not in bases:
|
||
raise Stop("record_missing", ["basis", k])
|
||
# Step 7: the figures.
|
||
f = figures(s, acks, held_hops)
|
||
f["attribution"], f["deliveries"] = attribution_figures(s, acks, {d: b for d, b in bases.items() if d in basis_order(s)})
|
||
try:
|
||
check_figures(s, f)
|
||
except Refuse as e:
|
||
raise Stop(e.reason, ["statement"])
|
||
coverage = "none" if not held_hops else ("whole" if s["unmetered"] == 0 else "partial")
|
||
out = {
|
||
"outcome": "accept",
|
||
"digest": s["digest"].hex(),
|
||
"currency": None if s["currency"] == bytes(3) else s["currency"].decode("ascii"),
|
||
"classes": {CLASS_NAMES[c]: {"objects": s["classes"][c - 1][0], "bytes": s["classes"][c - 1][1]} for c in (1, 2, 3, 4)},
|
||
"lines": [{"class": CLASS_NAMES[x["class"]], "payer": x["payer"].hex(), "objects": x["objects"], "bytes": x["bytes"],
|
||
"amount": x["amount"], "free": x["class"] in RETRIEVAL and x["payer"] == ZERO}
|
||
for x in s["lines"] if x["kind"] == DELIVERY_LINE],
|
||
"unacknowledged": {"declarations": s["unack"][0], "objects": s["unack"][1], "bytes": s["unack"][2]},
|
||
"coverage": coverage,
|
||
"unmetered": s["unmetered"],
|
||
"totals": [{"class": LABELS[t], "figures": n, "joules": j} for t, n, j in s["totals"]],
|
||
"amount": s["amount"],
|
||
}
|
||
attribution = [{"payee": x["payee"].hex(), "class": CLASS_NAMES[x["class"]], "basis": x["payer"].hex(), "objects": x["objects"],
|
||
"bytes": x["bytes"], "amount": x["amount"]} for x in s["lines"] if x["kind"] == ATTRIBUTION_LINE]
|
||
if attribution:
|
||
out["attribution"] = attribution
|
||
return out
|
||
|
||
|
||
def statement_outcome(doc, v):
|
||
try:
|
||
return verify_statement(doc, v)
|
||
except Stop as e:
|
||
return {"outcome": "refuse", "reason": e.reason, "at": e.at}
|
||
|
||
|
||
# ---------------------------------------------------------------- the corpus
|
||
|
||
def check(doc, quiet=False):
|
||
"""The failures: every case whose stated outcome is not the one this verifier reaches."""
|
||
fails = []
|
||
try:
|
||
got = {"statements": [statement_outcome(doc, v) for v in doc["statements"]],
|
||
"ranges": [range_outcome(v) for v in doc["ranges"]]}
|
||
except (Refuse, KeyError, ValueError, TypeError, IndexError, AttributeError) as e:
|
||
return ["the corpus does not read: %r" % e]
|
||
for sec in ("statements", "ranges"):
|
||
for case, out in zip(doc[sec], got[sec]):
|
||
if case.get("outcome") != out:
|
||
fails.append("%s/%s: stated %s, got %s" % (sec, case["name"], json.dumps(case.get("outcome"))[:140],
|
||
json.dumps(out)[:140]))
|
||
if not quiet:
|
||
bad = [x for x in fails if x.startswith(sec + "/")]
|
||
print(("PASS" if not bad else "FAIL") + " %s: %d cases" % (sec, len(doc[sec])))
|
||
return fails
|
||
|
||
|
||
def reasons_given(doc):
|
||
return {v["outcome"]["reason"] for sec in ("statements", "ranges") for v in doc[sec] if v["outcome"]["outcome"] == "refuse"}
|
||
|
||
|
||
def flip_hex(h, at):
|
||
c = "0" if h[at] != "0" else "1"
|
||
return h[:at] + c + h[at + 1:]
|
||
|
||
|
||
def mutations(doc):
|
||
"""Copies of the corpus, each with one defect a conforming verifier catches."""
|
||
def named(d, sec, name):
|
||
return next(c for c in d[sec] if c["name"] == name)
|
||
|
||
def m(label, f):
|
||
d = copy.deepcopy(doc)
|
||
f(d)
|
||
return label, d
|
||
|
||
def pool(d, sec, name):
|
||
return next(c for c in d[sec] if c["name"] == name)
|
||
|
||
def outcome(sec, name, key, value):
|
||
def f(d):
|
||
named(d, sec, name)["outcome"][key] = value
|
||
return f
|
||
|
||
def statement_byte(name, at):
|
||
def f(d):
|
||
x = named(d, "statements", name)
|
||
x["statement"] = flip_hex(x["statement"], at)
|
||
return f
|
||
|
||
yield m("an accepted statement's signature", statement_byte("relay_paid_by_edge_and_viewer", -6))
|
||
yield m("an accepted statement's amount", outcome("statements", "relay_paid_by_edge_and_viewer", "amount", 81))
|
||
yield m("an accepted statement's coverage", outcome("statements", "relay_unmetered_hop", "coverage", "whole"))
|
||
yield m("a line's free flag", lambda d: named(d, "statements", "edge_fetch_granted_and_free")["outcome"]["lines"][0].update(free=True))
|
||
yield m("a refused statement stated accepted", lambda d: named(d, "statements", "bills_bytes_not_acknowledged").update(
|
||
outcome={"outcome": "accept"}))
|
||
yield m("a refusal's place", outcome("statements", "chain_broken", "at", ["record", 0]))
|
||
yield m("a refusal's reason", outcome("statements", "sums_joules_across_classes", "reason", "statement_mismatch"))
|
||
yield m("a record's item list", lambda d: pool(d, "records", "relay_from_edge_1")["items"].__setitem__(
|
||
0, flip_hex(pool(d, "records", "relay_from_edge_1")["items"][0], 70)))
|
||
yield m("a record's signature", lambda d: pool(d, "records", "edge_from_viewer_a").update(
|
||
record=flip_hex(pool(d, "records", "edge_from_viewer_a")["record"], len(pool(d, "records", "edge_from_viewer_a")["record"]) - 3)))
|
||
yield m("a proof withdrawn from the pool", lambda d: d["proofs"].remove(pool(d, "proofs", "viewer_a")))
|
||
yield m("a hop record's figure", lambda d: pool(d, "hops", "h2").update(hop=flip_hex(pool(d, "hops", "h2")["hop"], 2 * 85)))
|
||
yield m("a vector's rooms", lambda d: named(d, "statements", "origin_fills_billed_to_grant").update(rooms=[]))
|
||
yield m("a vector's withheld record restored", lambda d: named(d, "statements", "acknowledgement_missing").update(withhold=[]))
|
||
yield m("a range proof's byte", lambda d: named(d, "ranges", "interior").update(
|
||
proof=flip_hex(named(d, "ranges", "interior")["proof"], len(named(d, "ranges", "interior")["proof"]) - 9)))
|
||
yield m("a range proof's stated length", outcome("ranges", "tail_establishes_length", "length_established", False))
|
||
yield m("a range proof's covered chunks", outcome("ranges", "head", "covered", [0, 2048]))
|
||
yield m("an audit's item", lambda d: named(d, "ranges", "audit_answered")["audit"].update(end=4000))
|
||
yield m("a range proof's content hash", lambda d: named(d, "ranges", "sealed_object").update(
|
||
content_hash=flip_hex(named(d, "ranges", "sealed_object")["content_hash"], 0)))
|
||
|
||
|
||
CHECKS = {
|
||
"ack-rules": "§3.6: a record's rules",
|
||
"self-ack": "§3.6: a self-acknowledgement",
|
||
"ack-signature": "§3.5: a record's signature",
|
||
"items-rules": "§3.6: an item list's rules",
|
||
"items-recompute": "§3.6: an item list recomputes",
|
||
"upstream": "§8.2 step 3.3: the record's upstream is the party",
|
||
"period": "§8.2 step 3.4: the window within the period",
|
||
"grant-window": "§6.3 step 2: the grant's window",
|
||
"grant-principal": "§6.3 step 3: the grant's principal",
|
||
"proof-checks": "§6.3 step 4: the proof",
|
||
"seek": "§6.3 step 5: the seek power",
|
||
"room-grants": "§8.2 step 2: a room's grants",
|
||
"chain-overlap": "§3.7: windows of a chain do not overlap",
|
||
"chain-prev": "§3.7: prev names the record before",
|
||
"declared": "§4.2: no byte both declared and acknowledged",
|
||
"pop-replayed": "§6.3: a proof pays for one record",
|
||
"hop-party": "§7.7: a hop record is the party's",
|
||
"hop-span": "§7.7: a hop record's span within the period",
|
||
"free-line": "§7.6 step 1: a free line bills nothing",
|
||
"bytes-covered": "§7.6 step 2: bytes no acknowledgement covers",
|
||
"classes-summed": "§7.6 step 3: a total across classes",
|
||
"figures": "§7.6 step 4: every other figure",
|
||
"bases": "§8.2 step 6: an attribution line's basis is given",
|
||
"statement-rules": "§7.5: a statement's rules",
|
||
"statement-signature": "§7.4: a statement's signature",
|
||
"range-bounds": "§5.3: a range within the object",
|
||
"range-hash": "§5.4 step 1: the proof's content hash",
|
||
"range-audit": "§5.4 step 2: an audit's item",
|
||
"range-exact": "§5.4 step 3: chaining values consumed exactly",
|
||
"range-root": "§5.4 step 3: the root is the content hash",
|
||
}
|
||
|
||
|
||
def main(corpus_dir):
|
||
fails = []
|
||
st = ED._self_test()
|
||
print(("PASS" if not st else "FAIL") + " Ed25519 strict rule self-test " + ",".join(st))
|
||
fails += ["ed25519 self-test"] if st else []
|
||
print(("PASS" if len(REASONS) == 19 else "FAIL") + " §11 read from the extension: %d reasons" % len(REASONS))
|
||
fails += [] if len(REASONS) == 19 else ["§11 does not read"]
|
||
with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as f:
|
||
doc = json.load(f)
|
||
fails += check(doc)
|
||
extra = sorted(set(os.listdir(corpus_dir)) - {"vectors.json", "README.md"})
|
||
print(("PASS" if not extra else "FAIL") + " no file but vectors.json and README.md")
|
||
fails += ["an extra file: %s" % n for n in extra]
|
||
missing = [r for r in REASONS + REUSED if r not in reasons_given(doc)]
|
||
print(("PASS" if not missing else "FAIL") + " every reason of §11, and every code it reuses, appears in the corpus")
|
||
fails += ["no case gives %s" % r for r in missing]
|
||
for label, d in mutations(doc):
|
||
caught = check(d, quiet=True)
|
||
print(("PASS" if caught else "FAIL") + " mutated copy fails: %s%s" % (label, " (%s)" % caught[0][:90] if caught else ""))
|
||
if not caught:
|
||
fails.append("a mutated copy verifies: %s" % label)
|
||
for name, what in CHECKS.items():
|
||
SKIP.clear()
|
||
SKIP.add(name)
|
||
caught = check(doc, quiet=True)
|
||
SKIP.clear()
|
||
print(("PASS" if caught else "FAIL") + " a verifier without %s fails the corpus%s" % (what, " (%s)" % caught[0][:70] if caught else ""))
|
||
if not caught:
|
||
fails.append("the corpus does not catch a verifier without %s" % what)
|
||
for x in fails:
|
||
print("FAIL " + x)
|
||
print(("PASS" if not fails else "FAIL") + " settlement corpus: %d statements, %d range proofs" % (len(doc["statements"]), len(doc["ranges"])))
|
||
return 1 if fails else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
if len(sys.argv) != 2:
|
||
print("usage: settlement_verify.py <live-conformance/settlement>", file=sys.stderr)
|
||
sys.exit(2)
|
||
sys.exit(main(sys.argv[1]))
|