The second implementation allows powers bit 4, states each accepted grant's seek, and gives every request of seek its outcome by §10.6's rule from the room's grants as §4.3 checks them; the README describes the dvr room, the new refusals and the seek section. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1144 lines
46 KiB
Python
1144 lines
46 KiB
Python
#!/usr/bin/env python3
|
|
"""Independent verifier of the live-rooms corpus (extensions/live-rooms.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 (for a key log's commitments)
|
|
with ../claim-conformance/_blake3.py. Every event is verified by the live-events corpus's
|
|
own second implementation, live_events_verify.py (live-events §6), which is likewise
|
|
written from its text alone. Everything else is here:
|
|
|
|
- the room descriptor (§3): its reader, its field rules, its digest and signature, and the
|
|
registered publish profiles (§8), each written from §8.2's table and compared with the
|
|
record the corpus states;
|
|
- the room grant (§4): its reader and the eight checks of §4.3 in their order, its
|
|
attributes (§4.4), and its window;
|
|
- revocation (§5): the key-log head held for the descriptor's origin (jwp-receipts §7.2
|
|
rules 1 to 3: its members, its canonical form and its root's signature), the
|
|
authority's entry, and the revoked grants and viewers;
|
|
- track naming (§6) and the lifecycle detail (§7.2), with slots, takeover and failover;
|
|
- room verification (§9): the merged order, each record's steps, the room state, the
|
|
outcome, and the `moqt` scopes of each grant (§10.2), with the catalog and receipts
|
|
tracks of §6.1;
|
|
- proof of possession (§10.5): the proof record, its base64url header form, and the seven
|
|
checks of §10.5.4 in their order;
|
|
- seeking back (§10.6): a request that starts in a group below the track's live edge is
|
|
served only under a grant whose powers have bit 4, seek.
|
|
|
|
For every vector it requires the outcome, reason and place, and for one that does not
|
|
refuse the room id, the gaps or the grant digests, merged order, state, tally and scopes
|
|
the corpus states, with each grant's seek power; for every proof and every request of
|
|
`seek` its outcome and reason; and of the corpus, that every reason of §11 refuses a
|
|
vector, a proof or a request.
|
|
|
|
python3 live_rooms_verify.py <live-conformance/rooms>
|
|
|
|
Exit status 0 only if every vector verifies. Python 3.8 or later; standard library only.
|
|
"""
|
|
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 live_events_verify as LEV # noqa: E402
|
|
from _blake3 import blake3 # noqa: E402
|
|
import _ed25519 as ED # noqa: E402
|
|
|
|
Refuse = LEV.Refuse
|
|
|
|
DESCRIPTOR_MAGIC = b"WRD1"
|
|
GRANT_MAGIC = b"WRG1"
|
|
DESCRIPTOR_DOMAIN = b"wai:live-room\x01"
|
|
GRANT_DOMAIN = b"wai:live-room-grant\x01"
|
|
VIEWER_DOMAIN = b"wai:live-viewer\x01"
|
|
AUTHORITY = "live/room"
|
|
MAX_GRANT_S = 14 * 86400
|
|
NO_CEILING = 2 ** 64 - 1
|
|
MEDIA = {1: "audio", 2: "video", 3: "screen"}
|
|
MODERATE, DIRECT, COMPOSE, RECORD, SEEK = 1, 2, 4, 8, 16
|
|
POWERS = MODERATE | DIRECT | COMPOSE | RECORD | SEEK # §3.4, §4.3 step 5: none above bit 4
|
|
CONTROL, DECISION = 0x09, 0x0B
|
|
JOIN, LEAVE, PUBLISH, UNPUBLISH, EXCHANGE, TAKEOVER, FAILOVER = range(0x10, 0x17)
|
|
|
|
# §11, in its order.
|
|
REASONS = [
|
|
"descriptor_invalid", "key_log_missing", "key_log_not_held", "authority_not_listed",
|
|
"authority_revoked", "grant_invalid", "grant_too_long", "caps_exceed_role",
|
|
"grant_exceeds_room", "exchange_unlinked", "exchange_mismatch", "lifecycle_malformed",
|
|
"grant_unknown", "grant_wrong_principal", "grant_revoked", "grant_reused",
|
|
"already_present", "not_present", "participant_limit", "outside_grant_window",
|
|
"power_not_granted", "publish_not_permitted", "profile_not_granted", "already_published",
|
|
"not_published", "slot_unknown", "slot_full", "publisher_limit", "exchange_not_current",
|
|
"exchange_off_tick", "takeover_not_higher", "failover_not_standby", "failover_early",
|
|
"pop_missing", "pop_malformed", "pop_wrong_grant", "pop_wrong_key", "pop_bad_signature",
|
|
"pop_replayed", "pop_wrong_challenge", "pop_wrong_context", "seek_not_granted",
|
|
]
|
|
|
|
|
|
class Reader:
|
|
"""Reads fields in order; running out is `truncated` (§3.4)."""
|
|
|
|
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 name(self):
|
|
return self.take(self.u8())
|
|
|
|
def string(self):
|
|
return self.take(self.u16())
|
|
|
|
|
|
def read_head(b, magic):
|
|
"""§3.4 step 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")
|
|
|
|
|
|
NAME = re.compile(rb"[a-z0-9_-]{1,32}")
|
|
SLOT_NAME = re.compile(rb"[a-z0-9_]{1,32}")
|
|
MODE = re.compile(rb"[A-Z0-9_]{1,16}")
|
|
|
|
|
|
def is_name(b):
|
|
return NAME.fullmatch(b) is not None
|
|
|
|
|
|
def is_slot_name(b):
|
|
return SLOT_NAME.fullmatch(b) is not None
|
|
|
|
|
|
def is_token(b, most):
|
|
return 1 <= len(b) <= most and all(0x21 <= c <= 0x7E for c in b)
|
|
|
|
|
|
def bitset(kinds):
|
|
out = bytearray(32)
|
|
for k in kinds:
|
|
out[k // 8] |= 1 << (k % 8)
|
|
return bytes(out)
|
|
|
|
|
|
def has(bm, k):
|
|
return bm[k // 8] >> (k % 8) & 1 == 1
|
|
|
|
|
|
def within(a, b):
|
|
"""Every bit of bitmap or byte `a` is set in `b`."""
|
|
if isinstance(a, int):
|
|
return a & ~b == 0
|
|
return all(x & ~y == 0 for x, y in zip(a, b))
|
|
|
|
|
|
def lifecycle_permitted(policy):
|
|
return any(LEV.permits(policy, k) for k in range(0x10, 0x20))
|
|
|
|
|
|
def powers_cover(kinds, powers):
|
|
"""§3.3: decision needs moderate; control needs direct or record."""
|
|
return (not has(kinds, DECISION) or powers & MODERATE) and (not has(kinds, CONTROL) or powers & (DIRECT | RECORD))
|
|
|
|
|
|
# ---------------------------------------------------------------- §8 profiles
|
|
|
|
def profile_bytes(name, media, codecs, layering, mode, layers, sample_rate, channels, processing):
|
|
"""§8.1's record."""
|
|
o = bytes([len(name)]) + name.encode() + bytes([media, len(codecs)])
|
|
for c in codecs:
|
|
o += bytes([len(c)]) + c.encode()
|
|
o += bytes([layering, len(mode)]) + mode.encode() + bytes([len(layers)])
|
|
for w, h, fps, lo, hi in layers:
|
|
o += struct.pack("<HHHII", w, h, fps, lo, hi)
|
|
return o + struct.pack("<IBB", sample_rate, channels, processing)
|
|
|
|
|
|
# §8.2's table: processing bit 0 echo cancellation, 1 noise suppression, 2 gain control, 3 DTX.
|
|
REGISTERED = {
|
|
"speech": profile_bytes("speech", 1, ["opus"], 0, "", [(0, 0, 0, 16, 64)], 48000, 1, 0b1111),
|
|
"music": profile_bytes("music", 1, ["opus"], 0, "", [(0, 0, 0, 128, 510)], 48000, 2, 0),
|
|
"screen": profile_bytes("screen", 3, ["av01.0.08M.08", "avc1.640028"], 2, "L1T3", [(1920, 1080, 15, 300, 2500)], 0, 0, 0),
|
|
"camera-720": profile_bytes("camera-720", 2, ["av01.0.05M.08", "avc1.64001F"], 1, "",
|
|
[(640, 360, 30, 150, 500), (1280, 720, 30, 500, 1500)], 0, 0, 0),
|
|
"camera-1080": profile_bytes("camera-1080", 2, ["av01.0.08M.08", "avc1.640028"], 1, "",
|
|
[(640, 360, 30, 150, 500), (1280, 720, 30, 500, 1500), (1920, 1080, 30, 1500, 4000)], 0, 0, 0),
|
|
}
|
|
|
|
|
|
def read_profile(r):
|
|
start = r.p
|
|
p = {"name": r.name(), "media": r.u8()}
|
|
p["codecs"] = [r.name() for _ in range(r.u8())]
|
|
p["layering"], p["mode"] = r.u8(), r.name()
|
|
p["layers"] = [struct.unpack("<HHHII", r.take(14)) for _ in range(r.u8())]
|
|
p["sample_rate"], p["channels"], p["processing"] = r.u32(), r.u8(), r.u8()
|
|
p["bytes"] = r.b[start:r.p]
|
|
return p
|
|
|
|
|
|
def profile_ok(p):
|
|
"""§8.1."""
|
|
if not is_name(p["name"]) or p["media"] not in MEDIA or not 1 <= len(p["codecs"]) <= 4:
|
|
return False
|
|
if not all(is_token(c, 64) for c in p["codecs"]) or p["layering"] > 3:
|
|
return False
|
|
if (p["layering"] == 2) != (len(p["mode"]) > 0) or (p["mode"] and not MODE.fullmatch(p["mode"])):
|
|
return False
|
|
n = len(p["layers"])
|
|
if not 1 <= n <= 8 or (p["layering"] == 0 and n != 1):
|
|
return False
|
|
audio = p["media"] == 1
|
|
for w, h, fps, lo, hi in p["layers"]:
|
|
if hi == 0 or lo > hi:
|
|
return False
|
|
if audio and (w, h, fps) != (0, 0, 0):
|
|
return False
|
|
if not audio and not (w and h and fps):
|
|
return False
|
|
if audio:
|
|
return p["sample_rate"] > 0 and 1 <= p["channels"] <= 8 and p["processing"] & ~0x0F == 0
|
|
return p["sample_rate"] == 0 and p["channels"] == 0 and p["processing"] == 0
|
|
|
|
|
|
# ---------------------------------------------------------------- §3 the descriptor
|
|
|
|
def read_descriptor(b):
|
|
"""§3.4, then §3.2: the descriptor, or Refuse."""
|
|
read_head(b, DESCRIPTOR_MAGIC)
|
|
r = Reader(b, 5)
|
|
d = {"authority": r.take(32), "clock": r.take(32), "t0": r.u64(), "tick": r.u64(), "origin": r.string(),
|
|
"max_present": r.u32(), "max_publishers": r.u16(), "reval": r.u32(), "ceiling": r.take(65)}
|
|
d["roles"] = []
|
|
for _ in range(r.u8()):
|
|
d["roles"].append({"name": r.name(), "publish": r.u8(), "subscribe": r.u8(), "max_layer": r.u8(),
|
|
"powers": r.u8(), "events": r.take(32)})
|
|
d["slots"] = []
|
|
for _ in range(r.u8()):
|
|
d["slots"].append({"name": r.name(), "media": r.u8(), "redundancy": r.u8(), "failover_ms": r.u32()})
|
|
d["profiles"] = [read_profile(r) for _ in range(r.u8())]
|
|
d["review"] = (r.take(32), r.u32(), r.u8())
|
|
d["mode"], d["retention"], d["commit_interval_ms"], d["jitter_ms"] = r.u8(), r.u16(), r.u32(), r.u32()
|
|
d["regions"] = [r.take(2) for _ in range(r.u8())]
|
|
body = b[:r.p]
|
|
d["sig"] = r.take(64)
|
|
if r.p != len(b):
|
|
raise Refuse("malformed")
|
|
if not descriptor_ok(d):
|
|
raise Refuse("descriptor_invalid")
|
|
bad = ED.verify(d["authority"], DESCRIPTOR_DOMAIN + body, d["sig"])
|
|
if bad:
|
|
raise Refuse(bad)
|
|
d["room"] = hashlib.sha256(body).digest()
|
|
try:
|
|
d["ceiling_policy"] = LEV.read_policy(d["ceiling"])
|
|
except Refuse:
|
|
raise Refuse("descriptor_invalid")
|
|
return d
|
|
|
|
|
|
def descriptor_ok(d):
|
|
"""§3.4's field rules."""
|
|
if d["tick"] == 0 or not (d["origin"] == b"" or is_token(d["origin"], 255)):
|
|
return False
|
|
if d["max_present"] == 0 or d["max_publishers"] == 0:
|
|
return False
|
|
try:
|
|
ceiling = LEV.read_policy(d["ceiling"])
|
|
except Refuse:
|
|
return False
|
|
if lifecycle_permitted(ceiling) or not 1 <= len(d["roles"]) <= 16:
|
|
return False
|
|
names = [x["name"] for x in d["roles"]]
|
|
if len(set(names)) != len(names):
|
|
return False
|
|
for x in d["roles"]:
|
|
if not is_name(x["name"]) or x["subscribe"] > 1 or x["publish"] & ~0b111 or x["powers"] & ~POWERS:
|
|
return False
|
|
if x["subscribe"] == 0 and x["max_layer"] != 0:
|
|
return False
|
|
if not within(x["events"], ceiling["kinds"]) or not powers_cover(x["events"], x["powers"]):
|
|
return False
|
|
if len(d["slots"]) > 16 or len({s["name"] for s in d["slots"]}) != len(d["slots"]):
|
|
return False
|
|
for s in d["slots"]:
|
|
if not is_slot_name(s["name"]) or s["media"] not in MEDIA:
|
|
return False
|
|
if (s["redundancy"], s["failover_ms"] > 0) not in ((1, False), (2, True)):
|
|
return False
|
|
if len(d["profiles"]) > 16 or len({p["name"] for p in d["profiles"]}) != len(d["profiles"]):
|
|
return False
|
|
for p in d["profiles"]:
|
|
if not profile_ok(p):
|
|
return False
|
|
reg = REGISTERED.get(p["name"].decode())
|
|
if reg is not None and reg != p["bytes"]:
|
|
return False
|
|
kinds, deadline, fallback = d["review"]
|
|
if any(kinds):
|
|
if not within(kinds, ceiling["kinds"]) or deadline == 0 or fallback not in (1, 2):
|
|
return False
|
|
elif deadline != 0 or fallback != 0:
|
|
return False
|
|
if d["mode"] > 2 or len(d["regions"]) > 16:
|
|
return False
|
|
if d["mode"] == 0:
|
|
if d["commit_interval_ms"] != 0 or d["jitter_ms"] != 0:
|
|
return False
|
|
elif d["commit_interval_ms"] == 0 or d["jitter_ms"] > d["commit_interval_ms"]:
|
|
return False
|
|
if not all(re.fullmatch(rb"[A-Z]{2}", x) for x in d["regions"]):
|
|
return False
|
|
return all(a < b for a, b in zip(d["regions"], d["regions"][1:]))
|
|
|
|
|
|
def is_room_key(d, k):
|
|
return k == d["authority"] or k == d["clock"]
|
|
|
|
|
|
# ---------------------------------------------------------------- §4 grants
|
|
|
|
def read_attrs(b):
|
|
"""§4.4: the entries, or None when they break a rule."""
|
|
if len(b) > 1024:
|
|
return None
|
|
r = Reader(b)
|
|
out, last = [], None
|
|
try:
|
|
while r.p < len(b):
|
|
k, v = r.name(), r.string()
|
|
if not is_slot_name(k) or (last is not None and last >= k):
|
|
return None
|
|
out.append((k.decode(), v.decode("utf-8")))
|
|
last = k
|
|
except (Refuse, UnicodeDecodeError):
|
|
return None
|
|
return out
|
|
|
|
|
|
def read_grant(b):
|
|
"""§4.3 step 1: the grant's fields as read."""
|
|
read_head(b, GRANT_MAGIC)
|
|
r = Reader(b, 5)
|
|
g = {"room": r.take(32), "principal": r.take(32), "prev": r.take(32), "effective": r.u64(), "nbf": r.u64(),
|
|
"exp": r.u64(), "role": r.u8(), "publish": r.u8(), "subscribe": r.u8(), "max_layer": r.u8(),
|
|
"powers": r.u8(), "precedence": r.u8(), "once": r.take(16), "viewer": r.take(16), "policy_bytes": r.take(65),
|
|
"joules": r.u64(), "funds": r.u64(), "currency": r.take(3)}
|
|
g["profiles"] = list(r.take(r.u8()))
|
|
g["origins"] = [r.name() for _ in range(r.u8())]
|
|
g["attrs"] = r.string()
|
|
g["body"] = b[:r.p]
|
|
g["sig"] = r.take(64)
|
|
if r.p != len(b):
|
|
raise Refuse("malformed")
|
|
g["digest"] = hashlib.sha256(g["body"]).digest()
|
|
return g
|
|
|
|
|
|
def grant_fields_ok(g, d, held):
|
|
"""§4.3 step 5."""
|
|
if g["exp"] <= g["nbf"]:
|
|
return False
|
|
if g["prev"] == bytes(32):
|
|
if g["effective"] != 0:
|
|
return False
|
|
elif g["effective"] % d["tick"] != 0:
|
|
return False
|
|
if g["role"] >= len(d["roles"]) or g["subscribe"] > 1:
|
|
return False
|
|
if g["publish"] & ~0b111 or g["powers"] & ~POWERS or (g["subscribe"] == 0 and g["max_layer"] != 0):
|
|
return False
|
|
try:
|
|
policy = LEV.read_policy(g["policy_bytes"])
|
|
except Refuse:
|
|
return False
|
|
if lifecycle_permitted(policy) or not powers_cover(policy["kinds"], g["powers"]):
|
|
return False
|
|
funded = g["funds"] != NO_CEILING
|
|
if funded != (g["currency"] != bytes(3)) or (funded and not re.fullmatch(rb"[A-Z]{3}", g["currency"])):
|
|
return False
|
|
media = set()
|
|
for i in g["profiles"]:
|
|
if i >= len(d["profiles"]):
|
|
return False
|
|
m = d["profiles"][i]["media"]
|
|
if m in media or not publishes(g, m):
|
|
return False
|
|
media.add(m)
|
|
if len(g["origins"]) > 8 or not all(is_token(o, 255) for o in g["origins"]):
|
|
return False
|
|
if read_attrs(g["attrs"]) is None:
|
|
return False
|
|
return all(h["digest"] != g["digest"] for h in held)
|
|
|
|
|
|
def publishes(g, m):
|
|
return m in MEDIA and g["publish"] >> (m - 1) & 1 == 1
|
|
|
|
|
|
def check_grants(d, grants):
|
|
"""§4.3 over every grant, in order: the grants, or (index, reason)."""
|
|
held = []
|
|
for i, b in enumerate(grants):
|
|
try:
|
|
g = read_grant(b)
|
|
except Refuse as e:
|
|
return None, (i, e.reason)
|
|
bad = ED.verify(d["authority"], GRANT_DOMAIN + g["body"], g["sig"])
|
|
if bad:
|
|
return None, (i, bad)
|
|
if g["prev"] != bytes(32):
|
|
old = next((h for h in held if h["digest"] == g["prev"]), None)
|
|
if old is None:
|
|
return None, (i, "exchange_unlinked")
|
|
if old["room"] != g["room"] or old["principal"] != g["principal"]:
|
|
return None, (i, "exchange_mismatch")
|
|
if g["room"] != d["room"]:
|
|
return None, (i, "wrong_room")
|
|
if not grant_fields_ok(g, d, held):
|
|
return None, (i, "grant_invalid")
|
|
g["policy"] = LEV.read_policy(g["policy_bytes"])
|
|
if g["exp"] - g["nbf"] > MAX_GRANT_S:
|
|
return None, (i, "grant_too_long")
|
|
role = d["roles"][g["role"]]
|
|
if (not within(g["publish"], role["publish"]) or g["subscribe"] > role["subscribe"]
|
|
or g["max_layer"] > role["max_layer"] or not within(g["powers"], role["powers"])
|
|
or not within(g["policy"]["kinds"], role["events"])):
|
|
return None, (i, "caps_exceed_role")
|
|
p, c = g["policy"], d["ceiling_policy"]
|
|
if (p["max_payload"] > c["max_payload"] or p["max_text_cp"] > c["max_text_cp"]
|
|
or p["max_events"] > c["max_events"] or p["window_us"] < c["window_us"]
|
|
or p["max_reactions"] > c["max_reactions"] or p["reaction_window_us"] < c["reaction_window_us"]):
|
|
return None, (i, "grant_exceeds_room")
|
|
held.append(g)
|
|
return held, None
|
|
|
|
|
|
def good_at(g, d, t):
|
|
"""§4.3: nbf ≤ ⌊(t0 + t) / 10^6⌋ < exp."""
|
|
return g["nbf"] <= (d["t0"] + t) // 1000000 < g["exp"]
|
|
|
|
|
|
def viewer_tag(g):
|
|
return None if g["viewer"] == bytes(16) else hashlib.sha256(VIEWER_DOMAIN + g["viewer"]).digest()
|
|
|
|
|
|
# ---------------------------------------------------------------- §5 the key log
|
|
|
|
REVISION_MEMBERS = {"kind", "origin", "seq", "prev", "issued_at", "root", "next_root", "keys", "revoked_modules",
|
|
"grant_max_s", "status_max_age_s", "sig"}
|
|
ENTRY_MEMBERS = {"id", "key", "may_sign", "status", "until", "compromised_since"}
|
|
HEX32 = re.compile(r"[0-9a-f]{64}")
|
|
LOG_TIME = re.compile(r"[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]Z")
|
|
KEY_ID = re.compile(r"[A-Za-z0-9._-]{1,64}")
|
|
AUTH_SEG = re.compile(r"[A-Za-z0-9._-]+")
|
|
|
|
|
|
def no_duplicates(pairs):
|
|
keys = [k for k, _ in pairs]
|
|
if len(set(keys)) != len(keys):
|
|
raise ValueError("a member named twice")
|
|
return dict(pairs)
|
|
|
|
|
|
def is_int(v):
|
|
return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 2 ** 53 - 1
|
|
|
|
|
|
def commitment(k):
|
|
return blake3(b"wai:key-set-next\x01" + k)
|
|
|
|
|
|
def jcs(v):
|
|
"""RFC 8785 over what a key-log revision holds: strings of ASCII, integers, null, arrays."""
|
|
return json.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
|
|
|
|
def entry_ok(e):
|
|
if not isinstance(e, dict) or set(e) != ENTRY_MEMBERS:
|
|
return False
|
|
if not isinstance(e["id"], str) or not KEY_ID.fullmatch(e["id"]):
|
|
return False
|
|
if not isinstance(e["key"], str) or not HEX32.fullmatch(e["key"]) or not ED.strict_key(bytes.fromhex(e["key"])):
|
|
return False
|
|
ms = e["may_sign"]
|
|
if not isinstance(ms, list) or not ms or len(set(map(str, ms))) != len(ms):
|
|
return False
|
|
for a in ms:
|
|
if not isinstance(a, str) or not 1 <= len(a) <= 128 or not all(AUTH_SEG.fullmatch(s) for s in a.split("/")):
|
|
return False
|
|
for t in ("until", "compromised_since"):
|
|
if e[t] is not None and not (isinstance(e[t], str) and LOG_TIME.fullmatch(e[t])):
|
|
return False
|
|
shape = {"active": e["until"] is None and e["compromised_since"] is None,
|
|
"retired": e["until"] is not None and e["compromised_since"] is None,
|
|
"revoked": e["compromised_since"] is not None}
|
|
return shape.get(e["status"], False)
|
|
|
|
|
|
def revision_ok(v):
|
|
"""jwp-receipts §7.2 rule 1, as far as this verifier reads it."""
|
|
if not isinstance(v, dict) or set(v) != REVISION_MEMBERS or v["kind"] != "wai-key-set":
|
|
return False
|
|
if not isinstance(v["origin"], str) or not is_int(v["seq"]):
|
|
return False
|
|
if (v["seq"] == 0) != (v["prev"] is None) or (v["prev"] is not None and not (isinstance(v["prev"], str) and HEX32.fullmatch(v["prev"]))):
|
|
return False
|
|
if not isinstance(v["issued_at"], str) or not LOG_TIME.fullmatch(v["issued_at"]):
|
|
return False
|
|
for k in ("root", "next_root"):
|
|
if not isinstance(v[k], str) or not HEX32.fullmatch(v[k]):
|
|
return False
|
|
root, nxt = bytes.fromhex(v["root"]), bytes.fromhex(v["next_root"])
|
|
if nxt == commitment(root):
|
|
return False
|
|
if not isinstance(v["keys"], list) or not all(entry_ok(e) for e in v["keys"]):
|
|
return False
|
|
ids, keys = [e["id"] for e in v["keys"]], [e["key"] for e in v["keys"]]
|
|
if len(set(ids)) != len(ids) or len(set(keys)) != len(keys):
|
|
return False
|
|
if any(bytes.fromhex(k) == root or commitment(bytes.fromhex(k)) == nxt for k in keys):
|
|
return False
|
|
rm = v["revoked_modules"]
|
|
if not isinstance(rm, list) or not all(isinstance(x, str) and HEX32.fullmatch(x) for x in rm) or len(set(rm)) != len(rm):
|
|
return False
|
|
if not (is_int(v["grant_max_s"]) and 1 <= v["grant_max_s"] <= 86400):
|
|
return False
|
|
if not (is_int(v["status_max_age_s"]) and 1 <= v["status_max_age_s"] <= 3600):
|
|
return False
|
|
return isinstance(v["sig"], str) and re.fullmatch(r"[0-9a-f]{128}", v["sig"]) is not None
|
|
|
|
|
|
def check_key_log(d, head):
|
|
"""§5: the revoked digests, or Refuse."""
|
|
if d["origin"] == b"":
|
|
return set()
|
|
if head is None:
|
|
raise Refuse("key_log_missing")
|
|
try:
|
|
v = json.loads(head, object_pairs_hook=no_duplicates)
|
|
except ValueError:
|
|
raise Refuse("key_log_not_held")
|
|
if not revision_ok(v) or v["origin"] != d["origin"].decode():
|
|
raise Refuse("key_log_not_held")
|
|
unsigned = {k: x for k, x in v.items() if k != "sig"}
|
|
if ED.verify(bytes.fromhex(v["root"]), b"wai:key-set\x01" + jcs(unsigned).encode(), bytes.fromhex(v["sig"])):
|
|
raise Refuse("key_log_not_held")
|
|
entry = next((e for e in v["keys"] if e["key"] == d["authority"].hex() and AUTHORITY in e["may_sign"]), None)
|
|
if entry is None:
|
|
raise Refuse("authority_not_listed")
|
|
if entry["status"] == "revoked":
|
|
raise Refuse("authority_revoked")
|
|
return {bytes.fromhex(x) for x in v["revoked_modules"]}
|
|
|
|
|
|
def revoked(g, revoked_set):
|
|
return g["digest"] in revoked_set or viewer_tag(g) in revoked_set
|
|
|
|
|
|
# ---------------------------------------------------------------- §7 lifecycle detail
|
|
|
|
def read_detail(kind, b):
|
|
"""§7.2: the detail as a dict, or Refuse("lifecycle_malformed")."""
|
|
r = Reader(b)
|
|
try:
|
|
def slot(required):
|
|
s = r.name()
|
|
if (not s and required) or (s and not is_slot_name(s)):
|
|
raise Refuse("lifecycle_malformed")
|
|
return s.decode()
|
|
|
|
def media():
|
|
m = r.u8()
|
|
if m not in MEDIA:
|
|
raise Refuse("lifecycle_malformed")
|
|
return m
|
|
|
|
def layers():
|
|
n = r.u8()
|
|
if n == 0:
|
|
raise Refuse("lifecycle_malformed")
|
|
return n
|
|
|
|
if kind == JOIN:
|
|
x = {"grant": r.take(32)}
|
|
elif kind == LEAVE:
|
|
x = {"reason": r.u8()}
|
|
if x["reason"] not in (1, 2, 3, 4):
|
|
raise Refuse("lifecycle_malformed")
|
|
elif kind == PUBLISH:
|
|
x = {"slot": slot(False), "media": media(), "profile": r.u8()}
|
|
x["layers"] = layers()
|
|
elif kind == UNPUBLISH:
|
|
x = {"slot": slot(False), "media": media()}
|
|
elif kind == EXCHANGE:
|
|
x = {"old": r.take(32), "new": r.take(32)}
|
|
elif kind == TAKEOVER:
|
|
x = {"slot": slot(True), "media": media(), "profile": r.u8()}
|
|
x["layers"] = layers()
|
|
x["previous"] = r.take(32)
|
|
elif kind == FAILOVER:
|
|
x = {"slot": slot(True), "media": media(), "failed": r.take(32), "last_seen": r.u64()}
|
|
else:
|
|
raise Refuse("lifecycle_malformed")
|
|
except Refuse:
|
|
raise Refuse("lifecycle_malformed")
|
|
if r.p != len(b):
|
|
raise Refuse("lifecycle_malformed")
|
|
return x
|
|
|
|
|
|
class Room:
|
|
"""The room state of §9.3 and the checks of §7.2 that move it."""
|
|
|
|
def __init__(self, d, grants, revoked_set):
|
|
self.d, self.grants, self.revoked = d, grants, revoked_set
|
|
self.present = {} # principal -> grant digest
|
|
self.own = {} # (principal, media) -> (profile, layers)
|
|
self.slots = [{"live": None, "standby": None} for _ in d["slots"]] # each (principal, profile, layers)
|
|
self.timeline = []
|
|
self.used_once = set()
|
|
|
|
def grant(self, digest):
|
|
return next((g for g in self.grants if g["digest"] == digest), None)
|
|
|
|
def current(self, p):
|
|
return self.grant(self.present[p]) if p in self.present else None
|
|
|
|
def slot_index(self, name, m):
|
|
return next((i for i, s in enumerate(self.d["slots"]) if s["name"].decode() == name and s["media"] == m), None)
|
|
|
|
def publishers(self):
|
|
out = {p for p, _ in self.own}
|
|
for s in self.slots:
|
|
out |= {h[0] for h in (s["live"], s["standby"]) if h}
|
|
return out
|
|
|
|
def may_publish(self, g, m, profile, layers):
|
|
if not publishes(g, m):
|
|
raise Refuse("publish_not_permitted")
|
|
ps = self.d["profiles"]
|
|
if not (profile < len(ps) and profile in g["profiles"] and ps[profile]["media"] == m and layers <= len(ps[profile]["layers"])):
|
|
raise Refuse("profile_not_granted")
|
|
|
|
def limit(self):
|
|
if len(self.publishers()) > self.d["max_publishers"]:
|
|
raise Refuse("publisher_limit")
|
|
|
|
def drop(self, p):
|
|
for s in self.slots:
|
|
if s["standby"] and s["standby"][0] == p:
|
|
s["standby"] = None
|
|
if s["live"] and s["live"][0] == p:
|
|
s["live"], s["standby"] = s["standby"], None
|
|
|
|
def publishing(self, p):
|
|
out = [(m, v[0]) for (q, m), v in sorted(self.own.items()) if q == p]
|
|
for i, s in enumerate(self.slots):
|
|
for h in (s["live"], s["standby"]):
|
|
if h and h[0] == p:
|
|
out.append((self.d["slots"][i]["media"], h[1]))
|
|
return out
|
|
|
|
def need_present(self, p):
|
|
g = self.current(p)
|
|
if g is None:
|
|
raise Refuse("not_present")
|
|
return g
|
|
|
|
def slot_of(self, name, m):
|
|
i = self.slot_index(name, m)
|
|
if i is None:
|
|
raise Refuse("slot_unknown")
|
|
return i
|
|
|
|
def apply(self, kind, subject, x, t):
|
|
if kind == JOIN:
|
|
g = self.grant(x["grant"])
|
|
if g is None:
|
|
raise Refuse("grant_unknown")
|
|
if g["principal"] != subject:
|
|
raise Refuse("grant_wrong_principal")
|
|
if subject in self.present:
|
|
raise Refuse("already_present")
|
|
if revoked(g, self.revoked):
|
|
raise Refuse("grant_revoked")
|
|
if g["once"] != bytes(16) and g["once"] in self.used_once:
|
|
raise Refuse("grant_reused")
|
|
if not good_at(g, self.d, t):
|
|
raise Refuse("outside_grant_window")
|
|
if len(self.present) + 1 > self.d["max_present"]:
|
|
raise Refuse("participant_limit")
|
|
if g["once"] != bytes(16):
|
|
self.used_once.add(g["once"])
|
|
self.present[subject] = g["digest"]
|
|
self.timeline.append((subject, g["digest"], t))
|
|
elif kind == LEAVE:
|
|
self.need_present(subject)
|
|
del self.present[subject]
|
|
self.own = {k: v for k, v in self.own.items() if k[0] != subject}
|
|
self.drop(subject)
|
|
elif kind == PUBLISH:
|
|
g = self.need_present(subject)
|
|
i = None if x["slot"] == "" else self.slot_of(x["slot"], x["media"])
|
|
self.may_publish(g, x["media"], x["profile"], x["layers"])
|
|
held = (subject, x["profile"], x["layers"])
|
|
if i is None:
|
|
if (subject, x["media"]) in self.own:
|
|
raise Refuse("already_published")
|
|
self.own[(subject, x["media"])] = (x["profile"], x["layers"])
|
|
else:
|
|
s = self.slots[i]
|
|
if any(h and h[0] == subject for h in (s["live"], s["standby"])):
|
|
raise Refuse("already_published")
|
|
if s["live"] and (self.d["slots"][i]["redundancy"] < 2 or s["standby"]):
|
|
raise Refuse("slot_full")
|
|
if s["live"] is None:
|
|
s["live"] = held
|
|
else:
|
|
s["standby"] = held
|
|
self.limit()
|
|
elif kind == UNPUBLISH:
|
|
self.need_present(subject)
|
|
if x["slot"] == "":
|
|
if (subject, x["media"]) not in self.own:
|
|
raise Refuse("not_published")
|
|
del self.own[(subject, x["media"])]
|
|
else:
|
|
s = self.slots[self.slot_of(x["slot"], x["media"])]
|
|
if not any(h and h[0] == subject for h in (s["live"], s["standby"])):
|
|
raise Refuse("not_published")
|
|
if s["standby"] and s["standby"][0] == subject:
|
|
s["standby"] = None
|
|
else:
|
|
s["live"], s["standby"] = s["standby"], None
|
|
elif kind == EXCHANGE:
|
|
cur = self.need_present(subject)
|
|
if cur["digest"] != x["old"]:
|
|
raise Refuse("exchange_not_current")
|
|
g = self.grant(x["new"])
|
|
if g is None:
|
|
raise Refuse("grant_unknown")
|
|
if g["prev"] != x["old"]:
|
|
raise Refuse("exchange_unlinked")
|
|
if t != g["effective"]:
|
|
raise Refuse("exchange_off_tick")
|
|
if revoked(g, self.revoked):
|
|
raise Refuse("grant_revoked")
|
|
if not good_at(g, self.d, t):
|
|
raise Refuse("outside_grant_window")
|
|
for m, profile in self.publishing(subject):
|
|
if not publishes(g, m):
|
|
raise Refuse("publish_not_permitted")
|
|
if profile not in g["profiles"]:
|
|
raise Refuse("profile_not_granted")
|
|
self.present[subject] = g["digest"]
|
|
self.timeline.append((subject, g["digest"], t))
|
|
elif kind == TAKEOVER:
|
|
g = self.need_present(subject)
|
|
i = self.slot_of(x["slot"], x["media"])
|
|
s = self.slots[i]
|
|
if not s["live"] or s["live"][0] != x["previous"]:
|
|
raise Refuse("not_published")
|
|
self.may_publish(g, x["media"], x["profile"], x["layers"])
|
|
theirs = self.current(x["previous"])
|
|
if subject == x["previous"] or g["precedence"] <= (theirs["precedence"] if theirs else 0):
|
|
raise Refuse("takeover_not_higher")
|
|
if s["standby"] and s["standby"][0] == subject:
|
|
s["standby"] = None
|
|
s["live"] = (subject, x["profile"], x["layers"])
|
|
self.limit()
|
|
elif kind == FAILOVER:
|
|
self.need_present(subject)
|
|
i = self.slot_of(x["slot"], x["media"])
|
|
s = self.slots[i]
|
|
if not s["live"] or s["live"][0] != x["failed"]:
|
|
raise Refuse("not_published")
|
|
if not s["standby"] or s["standby"][0] != subject:
|
|
raise Refuse("failover_not_standby")
|
|
if x["last_seen"] > t or t - x["last_seen"] < self.d["slots"][i]["failover_ms"] * 1000:
|
|
raise Refuse("failover_early")
|
|
s["live"], s["standby"] = s["standby"], None
|
|
|
|
|
|
# ---------------------------------------------------------------- §6 names, §10.2 scopes
|
|
|
|
def render(fields, name):
|
|
"""MOQT §8.8: a-z, A-Z, 0-9 and _ as themselves; every other byte as . and two hex digits."""
|
|
def one(f):
|
|
return "".join(chr(c) if (chr(c).isascii() and chr(c).isalnum()) or c == 0x5F else ".%02x" % c for c in f.encode())
|
|
return "-".join(one(f) for f in fields) + "--" + one(name)
|
|
|
|
|
|
def scopes(d, g):
|
|
"""§10.2, in its order."""
|
|
room, me = d["room"].hex(), g["principal"].hex()
|
|
out = [[[0]]]
|
|
for m in (1, 2, 3):
|
|
if publishes(g, m):
|
|
out.append([[2, 6], [room, me, MEDIA[m], None]])
|
|
for s in d["slots"]:
|
|
if s["media"] == m:
|
|
out.append([[6], [room, "_" + s["name"].decode(), MEDIA[m], None]])
|
|
out.append([[6], [room, "_" + s["name"].decode(), None], "catalog"])
|
|
if any(publishes(g, m) for m in (1, 2, 3)):
|
|
out.append([[2, 6], [room, me, None], "catalog"])
|
|
if any(g["policy"]["kinds"]):
|
|
out.append([[2, 6], [room, me, "events", None], "0"])
|
|
if g["subscribe"]:
|
|
out.append([[3], [room]])
|
|
out.append([[4, 7, 8], [room], "catalog"])
|
|
for layer in range(g["max_layer"] + 1):
|
|
out.append([[4, 7, 8], [room], str(layer)])
|
|
out.append([[4, 7, 8], [room], str(layer) + "_receipts"])
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------- §10.5 proof of possession
|
|
|
|
PROOF_MAGIC = b"WRP1"
|
|
PROOF_DOMAIN = b"wai:live-room-proof\x01"
|
|
B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
|
|
|
|
|
def from_base64url(t):
|
|
"""RFC 4648 §5 without padding, strictly: the alphabet, a length an encoding has, and
|
|
zero bits past the last byte; None otherwise."""
|
|
if any(c not in B64 for c in t) or len(t) % 4 == 1:
|
|
return None
|
|
out = bytearray()
|
|
for i in range(0, len(t), 4):
|
|
c = t[i:i + 4]
|
|
n = 0
|
|
for j, ch in enumerate(c):
|
|
n |= B64.index(ch) << (18 - 6 * j)
|
|
k = len(c) - 1
|
|
out += n.to_bytes(3, "big")[:k]
|
|
if k < 3 and n & ((1 << (8 * (3 - k))) - 1):
|
|
return None
|
|
return bytes(out)
|
|
|
|
|
|
def read_proof(b):
|
|
"""§10.5.4 step 2."""
|
|
r = Reader(b)
|
|
try:
|
|
if r.take(4) != PROOF_MAGIC or r.u8() != 1:
|
|
raise Refuse("pop_malformed")
|
|
p = {"grant": r.take(32), "key": r.take(32), "challenge": r.take(32)}
|
|
ctx = r.take(r.u16())
|
|
p["body"] = b[:r.p]
|
|
p["sig"] = r.take(64)
|
|
except Refuse:
|
|
raise Refuse("pop_malformed")
|
|
if r.p != len(b) or any(c < 0x20 or c > 0x7E for c in ctx):
|
|
raise Refuse("pop_malformed")
|
|
p["context"] = ctx.decode("ascii")
|
|
return p
|
|
|
|
|
|
def check_proof(grant, proof, challenge, consumed, context):
|
|
"""§10.5.4, in its order, for a grant record already checked by §4.3."""
|
|
g = read_grant(grant)
|
|
if proof is None:
|
|
raise Refuse("pop_missing")
|
|
p = read_proof(proof)
|
|
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 p["challenge"] != challenge:
|
|
raise Refuse("pop_replayed" if p["challenge"] in consumed else "pop_wrong_challenge")
|
|
if p["context"] != context:
|
|
raise Refuse("pop_wrong_context")
|
|
|
|
|
|
def proof_outcome(v):
|
|
"""A proof vector's outcome: the grant and proof from the MOQT options or the HTTP
|
|
headers (§10.5.2, §10.5.3), then §10.5.4."""
|
|
if v["binding"] == "moqt":
|
|
grant = bytes.fromhex(v["token"])
|
|
if len(v["proofs"]) > 1:
|
|
raise ValueError("one proof per vector")
|
|
proof = bytes.fromhex(v["proofs"][0]) if v["proofs"] else None
|
|
else:
|
|
auth = v["headers"]["authorization"]
|
|
if not auth.startswith("Bearer "):
|
|
raise ValueError("a Bearer token")
|
|
grant = from_base64url(auth[len("Bearer "):])
|
|
raw = v["headers"].get("wai-room-proof")
|
|
proof = None if raw is None else (from_base64url(raw) or b"")
|
|
consumed = {bytes.fromhex(c) for c in v["consumed"]}
|
|
try:
|
|
check_proof(grant, proof, bytes.fromhex(v["challenge"]), consumed, v["verb"] + " " + v["target"])
|
|
except Refuse as r:
|
|
return {"outcome": "refuse", "reason": r.reason}
|
|
return {"outcome": "accept"}
|
|
|
|
|
|
# ---------------------------------------------------------------- §10.6 seeking back
|
|
|
|
def seeks_back(live_edge, start):
|
|
"""§10.6: a request seeks back when its start Location (g, o) has g below the live
|
|
edge L, the group of the track's Largest Object; one from (L, o) does not."""
|
|
return start[0] < live_edge
|
|
|
|
|
|
def seek_outcome(doc, v):
|
|
"""A request of `seek`: the grant it names, by vector and index, from that room's
|
|
grants as §4.3 checks them, then §10.6's rule."""
|
|
room = next(x for x in doc["vectors"] if x["name"] == v["vector"])
|
|
d = read_descriptor(bytes.fromhex(room["descriptor"]))
|
|
held, bad = check_grants(d, [bytes.fromhex(g) for g in room["grants"]])
|
|
if bad:
|
|
raise ValueError("the room's grants do not check: %s" % (bad,))
|
|
g = held[v["grant"]]
|
|
if v["request"] not in ("fetch", "fill"):
|
|
raise ValueError("a request is a fetch or a fill")
|
|
start = v["start"]
|
|
if not (isinstance(start, list) and len(start) == 2 and all(is_int(x) for x in start) and is_int(v["live_edge"])):
|
|
raise ValueError("a live edge and a start Location of integers")
|
|
if seeks_back(v["live_edge"], start) and not g["powers"] & SEEK:
|
|
return {"outcome": "refuse", "reason": "seek_not_granted"}
|
|
return {"outcome": "serve"}
|
|
|
|
|
|
# ---------------------------------------------------------------- §9 room verification
|
|
|
|
def authority_policy():
|
|
"""live-events §5: the default bounds, every kind permitted."""
|
|
b = b"WEP1\x01" + bitset(LEV.KINDS) + struct.pack("<HHIQIQ", 1024, 500, 50, 1000000, 1, 1000000)
|
|
return LEV.read_policy(b)
|
|
|
|
|
|
def merged_order(d, chains):
|
|
"""§9.2."""
|
|
def key(c, i):
|
|
try:
|
|
ev = LEV.read_record(chains[c][i])
|
|
except Refuse:
|
|
return (0, 0, c)
|
|
return (ev["t"], 0 if is_room_key(d, ev["principal"]) else 1, c)
|
|
nxt, out = [0] * len(chains), []
|
|
while True:
|
|
live = [c for c in range(len(chains)) if nxt[c] < len(chains[c])]
|
|
if not live:
|
|
return out
|
|
c = min(live, key=lambda c: key(c, nxt[c]))
|
|
out.append((c, nxt[c]))
|
|
nxt[c] += 1
|
|
|
|
|
|
def verify_room(descriptor, grants, head, chains):
|
|
"""§9: a dict of what the corpus states for the vector."""
|
|
try:
|
|
d = read_descriptor(descriptor)
|
|
except Refuse as e:
|
|
return {"outcome": "refuse", "reason": e.reason, "at": ["descriptor"]}
|
|
try:
|
|
revoked_set = check_key_log(d, head)
|
|
except Refuse as e:
|
|
return {"outcome": "refuse", "reason": e.reason, "at": ["key_log"]}
|
|
held, bad = check_grants(d, grants)
|
|
if bad:
|
|
return {"outcome": "refuse", "reason": bad[1], "at": ["grant", bad[0]]}
|
|
room = Room(d, held, revoked_set)
|
|
auth = authority_policy()
|
|
ver = LEV.Verifier(d["room"], auth, {})
|
|
seen = {}
|
|
order, at_index = [], []
|
|
for c, i in merged_order(d, chains):
|
|
b = chains[c][i]
|
|
at_index.append((c, i))
|
|
try:
|
|
under = step(d, room, ver, seen, auth, b)
|
|
except Refuse as e:
|
|
return {"outcome": "refuse", "reason": e.reason, "at": ["record", c, i]}
|
|
order.append([c, i, under.hex() if under else None])
|
|
if ver.gaps:
|
|
return {"outcome": "gap", "reason": "delivery_gap", "room": d["room"].hex(),
|
|
"gaps": [{"principal": p.hex(), "first": a, "last": z} for p, a, z in ver.gaps], "order": order}
|
|
mismatch = LEV.stated_tally_mismatch(ver.accepted)
|
|
if mismatch is not None:
|
|
c, i = at_index[mismatch]
|
|
return {"outcome": "refuse", "reason": "tally_mismatch", "at": ["record", c, i]}
|
|
return {"outcome": "accept", "room": d["room"].hex(), "grant_digests": [g["digest"].hex() for g in held],
|
|
"order": order, "state": state_of(d, room), "tally": LEV.tally(ver.accepted),
|
|
"moqt": [{"grant": g["digest"].hex(), "reval": d["reval"], "seek": g["powers"] & SEEK != 0,
|
|
"scopes": scopes(d, g)} for g in held]}
|
|
|
|
|
|
def step(d, room, ver, seen, auth, b):
|
|
"""§9.2 over one record: the digest of the grant it was verified under, or None."""
|
|
ev = LEV.read_record(b)
|
|
if ev["kind"] not in LEV.KINDS:
|
|
raise Refuse("unknown_kind")
|
|
if ev["room"] != d["room"]:
|
|
raise Refuse("wrong_room")
|
|
bad = ED.verify(ev["principal"], LEV.SIGN_DOMAIN + ev["body"], ev["sig"])
|
|
if bad:
|
|
raise Refuse(bad)
|
|
p = ev["principal"]
|
|
if ev["digest"] in seen:
|
|
policy, under = seen[ev["digest"]]
|
|
ver.own[p] = policy
|
|
ver.offer(b)
|
|
return under
|
|
if is_room_key(d, p):
|
|
ver.own[p] = auth
|
|
ver.offer(b)
|
|
seen[ev["digest"]] = (auth, None)
|
|
payload = ver.accepted[-1][2]
|
|
if 0x10 <= ev["kind"] <= 0x16:
|
|
room.apply(ev["kind"], payload["subject"], read_detail(ev["kind"], payload["detail"]), ev["t"])
|
|
return None
|
|
g = room.current(p)
|
|
if g is None:
|
|
raise Refuse("not_present")
|
|
if not good_at(g, d, ev["t"]):
|
|
raise Refuse("outside_grant_window")
|
|
ver.own[p] = g["policy"]
|
|
ver.offer(b)
|
|
seen[ev["digest"]] = (g["policy"], g["digest"])
|
|
payload = ver.accepted[-1][2]
|
|
if payload["kind"] == CONTROL:
|
|
need = RECORD if payload["command"] in (1, 2) else DIRECT
|
|
if g["powers"] & need == 0:
|
|
raise Refuse("power_not_granted")
|
|
return g["digest"]
|
|
|
|
|
|
def state_of(d, room):
|
|
"""§9.3, as the corpus writes it."""
|
|
def precedence(p):
|
|
g = room.current(p)
|
|
return g["precedence"] if g else None
|
|
tracks = []
|
|
for (p, m), (_, layers) in room.own.items():
|
|
tracks += [render([d["room"].hex(), p.hex(), MEDIA[m]], str(n)) for n in range(layers)]
|
|
for s, st in zip(d["slots"], room.slots):
|
|
if st["live"]:
|
|
tracks += [render([d["room"].hex(), "_" + s["name"].decode(), MEDIA[s["media"]]], str(n)) for n in range(st["live"][2])]
|
|
return {
|
|
"present": [{"principal": p.hex(), "grant": g.hex()} for p, g in sorted(room.present.items())],
|
|
"published": [{"principal": p.hex(), "media": MEDIA[m], "profile": d["profiles"][v[0]]["name"].decode(), "layers": v[1]}
|
|
for (p, m), v in sorted(room.own.items())],
|
|
"slots": [{"slot": s["name"].decode(), "media": MEDIA[s["media"]],
|
|
"live": st["live"][0].hex() if st["live"] else None,
|
|
"standby": st["standby"][0].hex() if st["standby"] else None,
|
|
"precedence": precedence(st["live"][0]) if st["live"] else None}
|
|
for s, st in zip(d["slots"], room.slots)],
|
|
"tracks": sorted(tracks),
|
|
"timeline": [{"principal": p.hex(), "grant": g.hex(), "t": t} for p, g, t in room.timeline],
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------- 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)
|
|
stated = {p["name"]: p["record"] for p in doc["registered_profiles"]}
|
|
mine = {n: b.hex() for n, b in REGISTERED.items()}
|
|
if stated != mine:
|
|
fails.append("the registered profiles differ from §8.2's table: %s" % sorted(n for n in set(stated) | set(mine) if stated.get(n) != mine.get(n)))
|
|
print("%s registered profiles (§8.2)" % ("PASS" if stated == mine else "FAIL"))
|
|
reasons_seen, outcomes = set(), {}
|
|
for v in doc["vectors"]:
|
|
name = v["name"]
|
|
got = verify_room(bytes.fromhex(v["descriptor"]), [bytes.fromhex(g) for g in v["grants"]], v["key_log_head"],
|
|
[[bytes.fromhex(r) for r in c] for c in v["chains"]])
|
|
problems = [k for k in got if v.get(k) != got[k]]
|
|
problems += [k for k in ("outcome", "reason", "at") if k in v and k not in got]
|
|
if v.get("reason"):
|
|
reasons_seen.add(v["reason"])
|
|
outcomes[name] = got["outcome"]
|
|
what = got["outcome"] + ("" if got["outcome"] == "accept" else " " + got["reason"]) + (" at %s" % got["at"] if "at" in got else "")
|
|
print(("PASS" if not problems else "FAIL") + " vector %s: %s" % (name, what))
|
|
for k in problems:
|
|
fails.append("%s: %s is %s, the corpus states %s" % (name, k, json.dumps(got.get(k))[:300], json.dumps(v.get(k))[:300]))
|
|
proofs = doc.get("proofs", [])
|
|
if not proofs:
|
|
fails.append("the corpus holds no proof vector")
|
|
for v in proofs:
|
|
got = proof_outcome(v)
|
|
want = {k: v[k] for k in ("outcome", "reason") if k in v}
|
|
if want.get("reason"):
|
|
reasons_seen.add(want["reason"])
|
|
ok = got == want
|
|
print(("PASS" if ok else "FAIL") + " proof %s: %s" % (v["name"], got["outcome"] + (" " + got["reason"] if "reason" in got else "")))
|
|
if not ok:
|
|
fails.append("%s: %s, the corpus states %s" % (v["name"], got, want))
|
|
seek = doc.get("seek", [])
|
|
if not seek:
|
|
fails.append("the corpus holds no seek request")
|
|
for v in seek:
|
|
try:
|
|
got = seek_outcome(doc, v)
|
|
except (ValueError, StopIteration, IndexError, KeyError, TypeError, Refuse) as e:
|
|
got = {"outcome": "unreadable: %r" % (e,)}
|
|
want = {k: v[k] for k in ("outcome", "reason") if k in v}
|
|
if want.get("reason"):
|
|
reasons_seen.add(want["reason"])
|
|
ok = got == want
|
|
print(("PASS" if ok else "FAIL") + " seek %s: %s" % (v["name"], got["outcome"] + (" " + got["reason"] if "reason" in got else "")))
|
|
if not ok:
|
|
fails.append("%s: %s, the corpus states %s" % (v["name"], got, want))
|
|
if {v.get("outcome") for v in seek} != {"serve", "refuse"}:
|
|
fails.append("seek does not hold both a request served and one refused")
|
|
missing = [r for r in REASONS if r not in reasons_seen]
|
|
if missing:
|
|
fails.append("no vector refused with %s" % ", ".join(missing))
|
|
rooms = ["lesson", "broadcast", "takeover", "redundant_failover", "promote_viewer", "dvr"]
|
|
absent = [r for r in rooms if outcomes.get(r) != "accept"]
|
|
if absent:
|
|
fails.append("rooms not accepted: %s" % ", ".join(absent))
|
|
print("%s every reason of §11 refuses a vector, a proof or a request, and every room of §12 is accepted"
|
|
% ("PASS" if not (missing or absent) else "FAIL"))
|
|
for f in fails:
|
|
print("FAIL " + f)
|
|
print(("PASS" if not fails else "FAIL") + " live-rooms corpus: %d vectors, %d proofs, %d seek requests"
|
|
% (len(doc["vectors"]), len(proofs), len(seek)))
|
|
return 1 if fails else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) != 2:
|
|
print("usage: live_rooms_verify.py <live-conformance/rooms>", file=sys.stderr)
|
|
sys.exit(2)
|
|
sys.exit(main(sys.argv[1]))
|