#!/usr/bin/env python3 """Independent verifier of the live-events corpus (extensions/live-events.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 with ../claim-conformance/_blake3.py, written from its specification. Everything else is here: - the record (§3): its reader and refusals, its digest (SHA-256 without `sig`) and the bytes its signature covers; - every kind's payload (§4) and the text rules (§4.1); - the policy record (§5) and every policy case; - verification (§6): one record, each principal's chain in stream order with duplicates, gaps and their refusals, both rates, and the outcome of a sequence; - the poll tallies (§7): which votes count and which close closes, the feed0 records and their state hash, and the check of a stated tally; - the bindings (§8): the MSF record, the EXT-X-DATERANGE tag and the version 1 emsg box, each rendered byte for byte and read back, and every binding case. For every vector it requires the outcome, reason, index, gaps, digests, tally and renderings the corpus states; and of the corpus, that every kind has an accepted event and every reason a case. python3 live_events_verify.py Exit status 0 only if every case verifies. Python 3.8 or later; standard library only. """ import base64 import datetime import hashlib import json import os import re import struct import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance")) from _blake3 import blake3 # noqa: E402 import _ed25519 as ED # noqa: E402 MAGIC = b"WEV1" POLICY_MAGIC = b"WEP1" SIGN_DOMAIN = b"wai:live-event\x01" EVENT_TYPE = "urn:wai:event:1" HLS_CLASS = "science.transaction.wai.event" HLS_TAG = "#EXT-X-DATERANGE:" MAX_SAFE = (1 << 53) - 1 # §4: the kinds and their names. KINDS = { 0x01: "meta", 0x02: "cue", 0x03: "chat", 0x04: "reaction", 0x05: "poll_open", 0x06: "vote", 0x07: "poll_close", 0x08: "marker", 0x09: "control", 0x0A: "caption", 0x0B: "decision", 0x0C: "take_commit", 0x10: "join", 0x11: "leave", 0x12: "publish", 0x13: "unpublish", 0x14: "grant_exchange", 0x15: "takeover", 0x16: "failover", } LIFECYCLE = range(0x10, 0x17) # §9, in its order. REASONS = [ "truncated", "malformed", "unknown_version", "unknown_kind", "wrong_room", "bad_key", "bad_signature", "kind_not_permitted", "payload_too_large", "malformed_payload", "bad_utf8", "text_too_long", "seq_replay", "seq_not_increasing", "chain_broken", "time_reversed", "delivery_gap", "rate_exceeded", "tally_mismatch", "policy_malformed", "binding_malformed", "binding_mismatch", ] class Refuse(Exception): def __init__(self, reason): super().__init__(reason) self.reason = reason class Reader: """Reads fields in order; running out raises the reason the caller names.""" def __init__(self, b, short): self.b, self.p, self.short = b, 0, short def take(self, n): if self.p + n > len(self.b): raise Refuse(self.short) 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(" max_cp: raise Refuse("text_too_long") return s def splice_info_section_ok(b): """§4.3, form 1.""" return len(b) >= 3 and b[0] == 0xFC and 3 + (((b[1] & 0x0F) << 8) | b[2]) == len(b) def stated_origin(r): """§4.4, §4.5: the origin a chat or a reaction may state after its last field, as (origin, model), or (None, None) when no byte follows.""" if r.done(): return None, None o = r.u8() if o == 1: return 1, None if o in (2, 3): return o, r.take(32) raise Refuse("malformed_payload") def read_payload(kind, b, max_cp): """§4: the payload as a dict, or Refuse.""" m = "malformed_payload" r = Reader(b, m) p = {"kind": kind} if kind in (0x01, 0x0C): p["bytes"] = r.rest() elif kind == 0x02: p["type"] = r.u8() if p["type"] not in (1, 2, 3, 4): raise Refuse(m) p["cue_id"], p["duration_ms"] = r.u32(), r.u32() form = r.u8() p["form"] = form if form in (1, 2): p["scte"] = r.take(r.u16()) if form == 1 and not splice_info_section_ok(p["scte"]): raise Refuse(m) if form == 2 and len(p["scte"]) == 0: raise Refuse(m) elif form != 0: raise Refuse(m) elif kind == 0x03: p["text"] = text(r, max_cp) p["origin"], p["model"] = stated_origin(r) elif kind == 0x04: p["type"] = r.u8() if p["type"] not in (1, 2, 3, 4, 5): raise Refuse(m) p["value"] = r.u16() if p["value"] == 0: raise Refuse(m) flags = r.u8() if flags & ~0x03: raise Refuse(m) p["target"] = r.take(32) if flags & 1 else None p["remove"] = bool(flags & 2) p["origin"], p["model"] = stated_origin(r) elif kind == 0x05: p["question"] = text(r, max_cp) n = r.u8() if n < 2: raise Refuse(m) p["options"] = [text(r, max_cp) for _ in range(n)] elif kind == 0x06: p["poll"], p["option"] = r.take(32), r.u8() elif kind == 0x07: p["poll"] = r.take(32) n = r.u8() if n < 2: raise Refuse(m) p["counts"] = [r.u32() for _ in range(n)] elif kind == 0x08: p["taxonomy"] = r.u32() p["polarity"] = r.u8() if p["polarity"] > 2: raise Refuse(m) p["text"] = text(r, max_cp) has = r.u8() if has not in (0, 1): raise Refuse(m) p["attachment"] = r.take(32) if has else None elif kind == 0x09: p["target"] = r.take(32) c = r.u8() p["command"] = c if c == 1: p["count_in_ms"] = r.u32() elif c == 2: pass elif c == 3: p["q16"] = r.u32() if p["q16"] == 0: raise Refuse(m) elif c == 4: p["on"] = r.u8() if p["on"] not in (0, 1): raise Refuse(m) elif c == 5: p["layer"] = r.u8() else: raise Refuse(m) elif kind == 0x0A: p["duration_ms"] = r.u32() p["origin"] = r.u8() if p["origin"] == 1: p["model"] = None elif p["origin"] in (2, 3): p["model"] = r.take(32) else: raise Refuse(m) lb = r.take(r.u8()) try: lang = lb.decode("ascii") except UnicodeDecodeError: raise Refuse(m) if not (2 <= len(lang) <= 35 and LANG.fullmatch(lang)): raise Refuse(m) p["lang"] = lang p["text"] = text(r, max_cp) elif kind == 0x0B: p["verdict"] = r.u8() if p["verdict"] not in (1, 2, 3): raise Refuse(m) p["target"], p["reason"] = r.take(32), r.u16() elif kind in LIFECYCLE: p["subject"], p["detail"] = r.take(32), r.rest() else: raise Refuse("unknown_kind") if not r.done(): raise Refuse(m) return p # ---------------------------------------------------------------- §5 the policy def read_policy(b): if len(b) < 4: raise Refuse("truncated") if b[:4] != POLICY_MAGIC: raise Refuse("policy_malformed") if len(b) < 5: raise Refuse("truncated") if b[4] != 1: raise Refuse("unknown_version") if len(b) < 65: raise Refuse("truncated") if len(b) > 65: raise Refuse("policy_malformed") kinds = b[5:37] max_payload, max_cp, max_events, window, max_reactions, rwindow = struct.unpack("> (k % 8) & 1 and k not in KINDS: raise Refuse("policy_malformed") if 0 in (max_payload, max_cp, max_events, window, max_reactions, rwindow): raise Refuse("policy_malformed") return {"kinds": kinds, "max_payload": max_payload, "max_text_cp": max_cp, "max_events": max_events, "window_us": window, "max_reactions": max_reactions, "reaction_window_us": rwindow} def permits(policy, k): return policy["kinds"][k // 8] >> (k % 8) & 1 == 1 # ---------------------------------------------------------------- §6 verification class Verifier: def __init__(self, room, default_policy, own): self.room, self.default, self.own = room, default_policy, own self.chains = {} # principal -> {"held": {seq: digest}, "head": (seq, digest, t) | None, "times": [], "reactions": []} self.accepted = [] # (index, ev, payload) self.gaps = [] self.offered = 0 def offer(self, b): index = self.offered self.offered += 1 ev = read_record(b) # §6.1 step 1 if ev["kind"] not in KINDS: # step 2 raise Refuse("unknown_kind") if ev["room"] != self.room: # step 3 raise Refuse("wrong_room") bad = ED.verify(ev["principal"], SIGN_DOMAIN + ev["body"], ev["sig"]) # step 4 if bad is not None: raise Refuse("bad_key" if bad == "bad_key" else "bad_signature") policy = self.own.get(ev["principal"], self.default) if not permits(policy, ev["kind"]): # step 5 raise Refuse("kind_not_permitted") if len(ev["payload"]) > policy["max_payload"]: # step 6 raise Refuse("payload_too_large") p = read_payload(ev["kind"], ev["payload"], policy["max_text_cp"]) # step 7 c = self.chains.setdefault(ev["principal"], {"held": {}, "head": None, "times": [], "reactions": []}) seq, d, t = ev["seq"], ev["digest"], ev["t"] if c["held"].get(seq) == d: # §6.2 step 1 return "duplicate" gap = None if c["head"] is not None: # step 2 hseq, hd, ht = c["head"] if seq <= hseq: raise Refuse("seq_replay" if seq in c["held"] else "seq_not_increasing") if seq == hseq + 1: if ev["prev"] != hd: raise Refuse("chain_broken") else: gap = (hseq + 1, seq - 1) if t < ht: raise Refuse("time_reversed") elif seq == 0: # step 3 if ev["prev"] != bytes(32): raise Refuse("chain_broken") else: gap = (0, seq - 1) within = lambda times, w: 1 + sum(1 for f in times if t - f < w) # noqa: E731 if within(c["times"], policy["window_us"]) > policy["max_events"]: # §6.3 raise Refuse("rate_exceeded") standalone = ev["kind"] == 0x04 and p["target"] is None if standalone and within(c["reactions"], policy["reaction_window_us"]) > policy["max_reactions"]: raise Refuse("rate_exceeded") c["held"][seq] = d c["head"] = (seq, d, t) c["times"].append(t) if standalone: c["reactions"].append(t) if gap: self.gaps.append((ev["principal"], gap[0], gap[1])) self.accepted.append((index, ev, p)) return "accepted" def run(verifier, records): """§6.4: ("refuse", reason, index) | ("gap", gaps) | ("accept",).""" for i, b in enumerate(records): try: verifier.offer(b) except Refuse as e: return ("refuse", e.reason, i) if verifier.gaps: return ("gap", list(verifier.gaps)) bad = stated_tally_mismatch(verifier.accepted) if bad is not None: return ("refuse", "tally_mismatch", bad) return ("accept",) # ---------------------------------------------------------------- §7 polls def poll_state(accepted): polls = {} for _, ev, p in accepted: if p["kind"] == 0x05: polls[ev["digest"]] = {"author": ev["principal"], "seq": ev["seq"], "n": len(p["options"]), "t": ev["t"]} closes = {} for index, ev, p in accepted: if p["kind"] == 0x07 and p["poll"] in polls and polls[p["poll"]]["author"] == ev["principal"]: cur = closes.get(p["poll"]) if cur is None or ev["seq"] < cur["seq"]: closes[p["poll"]] = {"seq": ev["seq"], "t": ev["t"], "index": index, "counts": p["counts"]} votes = {} for _, ev, p in sorted((a for a in accepted if a[2]["kind"] == 0x06), key=lambda a: (a[1]["principal"], a[1]["seq"])): poll = polls.get(p["poll"]) if poll is None or p["option"] >= poll["n"] or ev["t"] < poll["t"]: continue close = closes.get(p["poll"]) if close is not None and ev["t"] >= close["t"]: continue votes[(p["poll"], ev["principal"])] = p["option"] return polls, votes, closes def counts_of(poll_digest, poll, votes): counts = [0] * poll["n"] for (d, _), option in votes.items(): if d == poll_digest: counts[option] += 1 return counts def node_id(author, seq): return blake3(b"wai:world-node\x01" + author + struct.pack(" MAX_SAFE: raise Refuse("binding_malformed") idx = '"t":%d' % ms return '{%s,"data":{"wev1":"%s"}}' % (idx, b64url(b)) def is_int(v): return isinstance(v, int) and not isinstance(v, bool) def msf_read(s, t0): try: o = json.loads(s) except ValueError: raise Refuse("binding_malformed") if not isinstance(o, dict) or not isinstance(o.get("data"), dict) or not isinstance(o["data"].get("wev1"), str): raise Refuse("binding_malformed") b = b64url_read(o["data"]["wev1"]) if b is None: raise Refuse("binding_malformed") ev = bound_record(b) if sum(k in o for k in ("t", "l", "m")) != 1: raise Refuse("binding_malformed") want = json.loads(msf_render(b, t0)) if "l" in want: got = o.get("l") ok = isinstance(got, list) and len(got) == 2 and all(is_int(x) for x in got) and got == want["l"] else: ok = is_int(o.get("t")) and o["t"] == want["t"] if not ok: raise Refuse("binding_mismatch") del ev return b def daterange_attrs(b, t0): """§8.3's table: (name, value as written), in order.""" ev = bound_record(b) a = [("ID", '"%s"' % ev["digest"].hex()), ("CLASS", '"%s"' % HLS_CLASS), ("START-DATE", '"%s"' % iso(wallclock_ms(ev, t0)))] p = ev["p"] if p is not None and p["kind"] == 0x02: if p["duration_ms"] > 0: a.append(("PLANNED-DURATION" if p["type"] in (1, 4) else "DURATION", seconds(p["duration_ms"]))) if p["form"] == 1: name = {1: "SCTE35-OUT", 4: "SCTE35-OUT", 2: "SCTE35-IN"}.get(p["type"], "SCTE35-CMD") a.append((name, "0x" + p["scte"].hex().upper())) elif p is not None and p["duration_ms"] > 0: a.append(("DURATION", seconds(p["duration_ms"]))) a.append(("X-WAI-EVENT", '"%s"' % b64url(b))) return a DATERANGE_NAMES = ("ID", "CLASS", "START-DATE", "DURATION", "PLANNED-DURATION", "SCTE35-CMD", "SCTE35-OUT", "SCTE35-IN", "X-WAI-EVENT") def daterange_render(b, t0): return HLS_TAG + ",".join("%s=%s" % nv for nv in daterange_attrs(b, t0)) def attribute_list(s): """RFC 8216 §4.2, as §8.3 reads it; None when it does not read.""" out, i = {}, 0 while i < len(s): m = re.match(r"[A-Z0-9-]+", s[i:]) if not m or i + m.end() >= len(s) or s[i + m.end()] != "=": return None name = m.group(0) i += m.end() + 1 start = i if s[i] == '"': j = s.find('"', i + 1) if j < 0: return None i = j + 1 else: while i < len(s) and s[i] != ",": i += 1 if i == start or name in out: return None out[name] = s[start:i] if i < len(s): if s[i] != "," or i + 1 == len(s): return None i += 1 return out def daterange_read(line, t0): if not line.startswith(HLS_TAG): raise Refuse("binding_malformed") got = attribute_list(line[len(HLS_TAG):]) if got is None or "X-WAI-EVENT" not in got: raise Refuse("binding_malformed") v = got["X-WAI-EVENT"] if len(v) < 2 or v[0] != '"' or v[-1] != '"': raise Refuse("binding_malformed") b = b64url_read(v[1:-1]) if b is None: raise Refuse("binding_malformed") want = dict(daterange_attrs(b, t0)) for name in DATERANGE_NAMES: if got.get(name) != want.get(name): raise Refuse("binding_mismatch") return b def emsg_render(b, pt0): ev = bound_record(b) p = ev["p"] if p is not None: duration = p["duration_ms"] if p["duration_ms"] else 0xFFFFFFFF else: duration = 0 pt = pt0 + ev["t"] // 1000 if pt >= 1 << 64: raise Refuse("binding_malformed") body = struct.pack(">BBBBIQI", 1, 0, 0, 0, 1000, pt, duration) + ev["digest"][:4] body += EVENT_TYPE.encode() + b"\x00" + KINDS[ev["kind"]].encode() + b"\x00" + b return struct.pack(">I", 8 + len(body)) + b"emsg" + body def emsg_read(box, pt0): if len(box) < 32 or struct.unpack(">I", box[:4])[0] != len(box) or box[4:8] != b"emsg" or box[8] != 1: raise Refuse("binding_malformed") rest = box[32:] z1 = rest.find(b"\x00") z2 = rest.find(b"\x00", z1 + 1) if z1 >= 0 else -1 if z2 < 0: raise Refuse("binding_malformed") b = rest[z2 + 1:] bound_record(b) if emsg_render(b, pt0) != box: raise Refuse("binding_mismatch") return b def read_binding(kind, value, t0, pt0): if kind == "daterange": return daterange_read(value, t0) if kind == "msf": return msf_read(value, t0) if kind == "emsg": try: box = bytes.fromhex(value) except ValueError: raise Refuse("binding_malformed") return emsg_read(box, pt0) raise Refuse("binding_malformed") # ---------------------------------------------------------------- the corpus def main(corpus_dir): fails = [] st = ED._self_test() print(("PASS" if not st else "FAIL") + " Ed25519 strict rule self-test " + ",".join(st)) if st: fails.append("ed25519 self-test") with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as f: doc = json.load(f) t0, pt0 = doc["t0_unix_us"], doc["pt0_ms"] policies = {} for name, h in doc["policies"].items(): try: policies[name] = read_policy(bytes.fromhex(h)) except Refuse as e: fails.append("policy %s: %s" % (name, e.reason)) rooms = {n: bytes.fromhex(h) for n, h in doc["rooms"].items()} kinds_accepted, reasons_seen = set(), set() n_vec = 0 for v in doc["vectors"]: n_vec += 1 name = v["name"] own = {bytes.fromhex(k): policies[p] for k, p in v["policies"].items()} ver = Verifier(rooms[v["room"]], policies[v["default_policy"]], own) records = [bytes.fromhex(e) for e in v["events"]] out = run(ver, records) problems = [] if out[0] != v["outcome"]: problems.append("outcome %s, the corpus states %s" % (out, v["outcome"])) elif out[0] == "refuse": if (out[1], out[2]) != (v["reason"], v["index"]): problems.append("refused %s at %d, the corpus states %s at %s" % (out[1], out[2], v["reason"], v["index"])) elif out[0] == "gap": gaps = [{"principal": p.hex(), "first": a, "last": z} for p, a, z in out[1]] if gaps != v["gaps"] or v.get("reason") != "delivery_gap": problems.append("gaps %s differ" % gaps) if out[0] in ("accept", "gap"): if [hashlib.sha256(read_record(r)["body"]).hexdigest() for r in records] != v["digests"]: problems.append("digests differ") if out[0] == "accept": if tally(ver.accepted) != v["tally"]: problems.append("tally %s differs" % tally(ver.accepted)) if len(v["bindings"]) != len(records): problems.append("not one binding entry per event") for i, (r, bnd) in enumerate(zip(records, v["bindings"])): rendered = {"msf": msf_render(r, t0), "daterange": daterange_render(r, t0), "emsg": emsg_render(r, pt0).hex()} if rendered != bnd: problems.append("event %d: a rendering differs: %s" % (i, [k for k in rendered if rendered[k] != bnd.get(k)])) for k in ("msf", "daterange", "emsg"): try: if read_binding(k, bnd[k], t0, pt0) != r: problems.append("event %d: %s reads back to other bytes" % (i, k)) except Refuse as e: problems.append("event %d: %s refused %s" % (i, k, e.reason)) for _, ev, _ in ver.accepted: kinds_accepted.add(ev["kind"]) if v.get("reason"): reasons_seen.add(v["reason"]) print(("PASS" if not problems else "FAIL") + " vector %s: %s%s" % (name, out[0], "" if out[0] == "accept" else " " + str(out[1] if out[0] == "refuse" else "delivery_gap"))) fails += ["%s: %s" % (name, p) for p in problems] for c in doc["policy_cases"]: try: read_policy(bytes.fromhex(c["policy"])) got = ("accept", None) except Refuse as e: got = ("refuse", e.reason) want = (c["outcome"], c.get("reason")) if got != want: fails.append("policy case %s: %s, the corpus states %s" % (c["name"], got, want)) if c.get("reason"): reasons_seen.add(c["reason"]) print("%s policy cases" % ("PASS" if not any(f.startswith("policy case") for f in fails) else "FAIL")) for c in doc["binding_cases"]: try: got = ("accept", read_binding(c["binding"], c["input"], t0, pt0).hex()) except Refuse as e: got = ("refuse", e.reason) want = (c["outcome"], c["record"] if c["outcome"] == "accept" else c.get("reason")) if got != want: fails.append("binding case %s: %s, the corpus states %s" % (c["name"], got, want)) if c.get("reason"): reasons_seen.add(c["reason"]) print("%s binding cases" % ("PASS" if not any(f.startswith("binding case") for f in fails) else "FAIL")) missing_kinds = sorted(KINDS[k] for k in set(KINDS) - kinds_accepted) missing_reasons = [r for r in REASONS if r not in reasons_seen] if missing_kinds: fails.append("no accepted event of kind %s" % ", ".join(missing_kinds)) if missing_reasons: fails.append("no case refused with %s" % ", ".join(missing_reasons)) print("%s every kind accepted and every reason exercised" % ("PASS" if not (missing_kinds or missing_reasons) else "FAIL")) for f in fails: print("FAIL " + f) print(("PASS" if not fails else "FAIL") + " live-events corpus: %d vectors, every policy and binding case" % n_vec) return 1 if fails else 0 if __name__ == "__main__": if len(sys.argv) != 2: print("usage: live_events_verify.py ", file=sys.stderr) sys.exit(2) sys.exit(main(sys.argv[1]))