open-standards/wai/live-conformance/live_watermark_verify.py
Transaction Science aafbf8e1d6 wai.live.watermark: the corpus, its second implementation, the CI step and the changelog
live-conformance/watermark/vectors.json from wai_live_watermark_vectors, and
live_watermark_verify.py, written from the extension's text, which agrees on every
vector, fails every mutated copy and catches a verifier that leaves out any one of
its checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 08:40:15 -04:00

793 lines
32 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Independent verifier of the forensic-watermark corpus (extensions/forensic-watermark.md).
Written from the extension's text. It shares no code with wai-rs, which generates the corpus,
and never runs it. It checks Ed25519 signatures with ../claim-conformance/_ed25519.py, WAI's
strict rule (SPEC §7 *Signatures*) written from RFC 8032 with the standard library, and reads
descriptors, grants and key-log heads with the live-rooms corpus's own second implementation,
live_rooms_verify.py (live-rooms §3, §4.3, §5). Everything else is here:
- the token (§4): a reader of deterministically encoded CBOR written from RFC 8949 §3 and §4.2.1,
the COSE_Mac0 and COSE_Sign1 structures of RFC 9052, and the claims of the TS's §5.4, in the
order of §4.2's steps;
- the session record (§5), its rules, its signature, who may sign it, its content, its keys,
its region and retention (§6.1), and its token beside it (§5.7);
- the recovered sequence (§7.1, §7.2), candidates and agreement (§7.3) and the outcome (§7.4);
- the disclosure (§8) and its checks (§9.3).
For every vector it requires the outcome, the reason, the place and everything the corpus
states; of the corpus, that every reason of §11 appears in it, which it reads from the
extension's own table. 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 verifier that lacks it.
python3 live_watermark_verify.py <live-conformance/watermark>
Exit status 0 only if every case verifies and every mutated copy 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 _ed25519 as ED # noqa: E402
Refuse = LEV.Refuse
EXTENSION = os.path.join(HERE, "..", "extensions", "forensic-watermark.md")
SESSION_MAGIC, SEQUENCE_MAGIC, DISCLOSURE_MAGIC = b"WWS1", b"WWQ1", b"WWD1"
SESSION_DOMAIN = b"wai:live-watermark-session\x01"
SEQUENCE_DOMAIN = b"wai:live-watermark-sequence\x01"
DISCLOSURE_DOMAIN = b"wai:live-watermark-disclosure\x01"
BINDING_DOMAIN = b"wai:live-watermark-binding\x01"
EDGE_AUTHORITY = "live/watermark"
WRE1, WRE1_DOMAIN, WRE1_LEN = b"WRE1", b"wai:live-room-e2ee\x01", 105
MAX_DEPTH, MAX_KIDS = 16, 8
CWT, COSE = 61, (16, 17, 18, 96, 97, 98)
MAC0, SIGN1 = 17, 18
HMAC, ES256 = 5, -7
EXP, NBF, IAT, ISS, SUB, AUD, CTI = 4, 5, 6, 1, 2, 3, 7
WMVER, WMVND, WMPATLEN, WMSEGDURATION, WMPATTERN, WMID, WMOPID, WMKEYVER = range(300, 308)
CLEAR, DECRYPTED, DERIVED = 1, 2, 3
# A check named here is left out: the verifier weakened on purpose, to show the corpus catches it.
SKIP = set()
def sha(*parts):
h = hashlib.sha256()
for p in parts:
h.update(p)
return h.digest()
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()
# ---------------------------------------------------------------- bitmaps (§2)
def nbytes(patlen):
return (patlen + 7) // 8
def bit(m, p):
return p // 8 < len(m) and (m[p // 8] >> (7 - p % 8)) & 1 == 1
def is_bitmap(m, patlen):
return len(m) == nbytes(patlen) and not any(bit(m, p) for p in range(patlen, len(m) * 8))
def within(a, b):
return len(a) == len(b) and all(x & ~y & 0xFF == 0 for x, y in zip(a, b))
def count(m):
return sum(bin(x).count("1") for x in m)
# ---------------------------------------------------------------- §4.2 step 1: CBOR
class Bad(Exception):
"""A CBOR item that does not read: "malformed" or "unsupported"."""
def __init__(self, kind):
super().__init__(kind)
self.kind = kind
class Cbor:
"""RFC 8949 §3, held to §4.2.1's core deterministic encoding, as §4.2 step 1 reads."""
def __init__(self, b):
self.b, self.p = b, 0
def take(self, n):
if len(self.b) - self.p < n:
raise Bad("malformed")
s = self.b[self.p:self.p + n]
self.p += n
return s
def arg(self, ai):
if ai < 24:
return ai
n, least = {24: (1, 24), 25: (2, 256), 26: (4, 65536), 27: (8, 1 << 32)}[ai]
v = int.from_bytes(self.take(n), "big")
if v < least:
raise Bad("malformed")
return v
def item(self, depth):
if depth > MAX_DEPTH and "depth" not in SKIP:
raise Bad("unsupported")
ib = self.take(1)[0]
major, ai = ib >> 5, ib & 31
if ai >= 28:
raise Bad("malformed")
if major == 7:
if ai == 20:
return ("false",)
if ai == 21:
return ("true",)
if ai == 22:
return ("null",)
if ai == 24:
raise Bad("malformed" if self.take(1)[0] < 32 else "unsupported")
raise Bad("unsupported")
v = self.arg(ai)
if major == 0:
return ("uint", v)
if major == 1:
return ("nint", v)
if major == 2:
return ("bytes", self.take(v))
if major == 3:
try:
return ("text", self.take(v).decode("utf-8"))
except UnicodeDecodeError:
raise Bad("malformed")
if major == 4:
return ("array", [self.item(depth + 1) for _ in range(v)])
if major == 5:
out, prev = [], None
for _ in range(v):
s = self.p
k = self.item(depth + 1)
kb = self.b[s:self.p]
if prev is not None and prev >= kb and "map-order" not in SKIP:
raise Bad("malformed")
prev = kb
out.append((k, self.item(depth + 1)))
return ("map", out)
return ("tag", v, self.item(depth + 1))
def read_one(b):
r = Cbor(b)
item = r.item(1)
if r.p != len(b):
raise Bad("malformed")
return item
def as_int(i):
if i[0] == "uint":
return i[1]
if i[0] == "nint":
return -1 - i[1]
return None
def get(m, k):
for key, v in m[1]:
if key == ("uint", k):
return v
return None
# ---------------------------------------------------------------- §4.2 the token
def token_refuse(kind):
return Refuse("token_" + kind)
def cbor(b):
try:
return read_one(b)
except Bad as e:
raise token_refuse(e.kind)
def is_cose_tag(i):
return i[0] == "tag" and i[1] in COSE
def read_token(b):
"""§4.2: what the token gives (§4.3), or Refuse."""
item = cbor(b) # step 1
cwt_tag = False
if item[0] == "tag" and item[1] == CWT: # step 2
if not is_cose_tag(item[2]):
raise token_refuse("malformed")
cwt_tag, item = True, item[2]
if not (item[0] == "tag" and item[1] in (MAC0, SIGN1)): # step 3
raise token_refuse("unsupported")
tag, body = item[1], item[2]
if not (body[0] == "array" and len(body[1]) == 4): # step 4
raise token_refuse("malformed")
prot, unprot, payload, mac = body[1]
if prot[0] != "bytes" or unprot[0] != "map" or payload[0] not in ("bytes", "null") or mac[0] != "bytes":
raise token_refuse("malformed")
if payload[0] == "null":
raise token_refuse("unsupported")
if prot[1] == b"": # step 5
pmap = []
else:
pm = cbor(prot[1])
if pm[0] != "map":
raise token_refuse("malformed")
pmap = pm[1]
umap = unprot[1]
if any(as_int(k) not in (1, 4) for k, _ in pmap + umap):
raise token_refuse("unsupported")
if any(k == u for k, _ in pmap for u, _ in umap):
raise token_refuse("malformed")
alg = next((v for k, v in pmap if as_int(k) == 1), None)
if alg is None:
raise token_refuse("malformed")
alg = as_int(alg)
if alg not in (HMAC, ES256):
raise token_refuse("unsupported")
if not ((alg == HMAC and tag == MAC0 and len(mac[1]) == 32) or (alg == ES256 and tag == SIGN1 and len(mac[1]) == 64)):
raise token_refuse("malformed")
if any(as_int(k) == 4 and v[0] != "bytes" for k, v in pmap + umap):
raise token_refuse("malformed")
claims = cbor(payload[1]) # step 6
if claims[0] == "tag" and (claims[1] == CWT or claims[1] in COSE):
raise token_refuse("unsupported")
if claims[0] != "map" or any(k[0] not in ("uint", "nint") for k, _ in claims[1]):
raise token_refuse("malformed")
def u(k):
v = get(claims, k)
return v[1] if v is not None and v[0] == "uint" else None
def typed(k, kind):
v = get(claims, k)
if v is not None and v[0] != kind:
raise token_refuse("malformed")
exp, iat = u(EXP), u(IAT) # step 7.1
if exp is None or iat is None:
raise token_refuse("malformed")
typed(NBF, "uint")
for k in (ISS, SUB, AUD): # 7.2
typed(k, "text")
typed(CTI, "bytes")
if u(WMVER) is None: # 7.3
raise token_refuse("malformed")
if u(WMVER) != 1:
raise token_refuse("unsupported")
vendor, patlen = u(WMVND), u(WMPATLEN) # 7.4
if vendor is None or vendor > 255 or patlen is None or patlen > 65535:
raise token_refuse("malformed")
seg = get(claims, WMSEGDURATION) # 7.5
if seg is not None and not (seg[0] == "array" and len(seg[1]) == 2 and all(x[0] == "uint" for x in seg[1])):
raise token_refuse("malformed")
typed(WMID, "text")
typed(WMOPID, "uint")
typed(WMKEYVER, "uint")
pat = get(claims, WMPATTERN) # 7.6
pattern = None
if pat is None:
if get(claims, WMID) is None or get(claims, WMOPID) is None or get(claims, WMKEYVER) is None:
raise token_refuse("malformed")
source = DERIVED
elif pat[0] == "bytes" and len(pat[1]) == nbytes(patlen):
source, pattern = CLEAR, pat[1]
elif pat[0] == "array" and encrypted_shape(pat[1]):
source = DECRYPTED
else:
raise token_refuse("malformed")
nbf = get(claims, NBF)
return {"cwt_tag": cwt_tag, "alg": alg, "vendor": vendor, "patlen": patlen, "source": source,
"pattern": pattern.hex() if pattern is not None else None, "iat": iat, "exp": exp,
"nbf": nbf[1] if nbf is not None else None}
def encrypted_shape(a):
"""COSE_Encrypt0 (RFC 9052 §5.2) or COSE_Encrypt (§5.1), untagged."""
if len(a) not in (3, 4):
return False
if a[0][0] != "bytes" or a[1][0] != "map" or a[2][0] not in ("bytes", "null"):
return False
return len(a) == 3 or (a[3][0] == "array" and len(a[3][1]) > 0)
def good_at(t, u):
"""§4.3: max(iat, nbf) × 10^6 ≤ u < exp × 10^6."""
return max(t["iat"], t["nbf"] or 0) * 1000000 <= u < t["exp"] * 1000000
# ---------------------------------------------------------------- §5 the session record
def read_head(b, magic):
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")
HEX_KEY = set(b"0123456789abcdef")
SLOT_CHARS = set(b"abcdefghijklmnopqrstuvwxyz0123456789_")
def content_form_ok(owner, media):
hex_key = len(owner) == 64 and all(c in HEX_KEY for c in owner)
slot = owner[:1] == b"_" and 1 <= len(owner) - 1 <= 32 and all(c in SLOT_CHARS for c in owner[1:])
return (hex_key or slot) and media in (1, 2, 3)
def read_session(b):
"""§5.5: the record, or Refuse."""
read_head(b, SESSION_MAGIC)
r = LRV.Reader(b, 5)
s = {"room": r.take(32), "edge": r.take(32)}
s["owner"] = r.take(r.u8())
s["media"] = r.u8()
s["kids"] = [r.take(16) for _ in range(r.u8())]
s["start"], s["end"] = r.u64(), r.u64()
s["token"], s["vendor"], s["source"], s["patlen"] = r.take(32), r.u8(), r.u8(), r.u16()
s["served"], s["bits"] = r.take(nbytes(s["patlen"])), r.take(nbytes(s["patlen"]))
s["segments"], s["unmarked"] = r.u32(), r.u32()
s["binding"], s["region"], s["expires"] = r.take(32), r.take(2), r.u64()
s["body"] = b[:r.p]
s["sig"] = r.take(64)
if r.p != len(b):
raise Refuse("malformed")
kids = s["kids"]
ok = (content_form_ok(s["owner"], s["media"])
and len(kids) <= MAX_KIDS and all(a < c for a, c in zip(kids, kids[1:])) and bytes(16) not in kids
and s["start"] < s["end"] and s["source"] in (1, 2, 3) and s["patlen"] >= 1
and is_bitmap(s["served"], s["patlen"]) and is_bitmap(s["bits"], s["patlen"])
and within(s["bits"], s["served"]) and s["segments"] >= count(s["served"])
and all(65 <= c <= 90 for c in s["region"]))
if not ok:
raise Refuse("record_invalid")
s["digest"] = sha(s["body"])
return s
def binding(salt, grant, principal):
return sha(BINDING_DOMAIN, salt, grant, principal)
# ---------------------------------------------------------------- §7 the recovered sequence
def read_sequence(b):
read_head(b, SEQUENCE_MAGIC)
r = LRV.Reader(b, 5)
q = {"room": r.take(32)}
q["owner"] = r.take(r.u8())
q["media"], q["vendor"], q["patlen"] = r.u8(), r.u8(), r.u16()
q["start"], q["end"] = r.u64(), r.u64()
q["known"], q["bits"] = r.take(nbytes(q["patlen"])), r.take(nbytes(q["patlen"]))
q["copy"], q["claimant"] = r.take(32), r.take(32)
q["body"] = b[:r.p]
q["sig"] = r.take(64)
if r.p != len(b):
raise Refuse("malformed")
ok = (content_form_ok(q["owner"], q["media"]) and q["patlen"] >= 1 and q["start"] < q["end"]
and is_bitmap(q["known"], q["patlen"]) and is_bitmap(q["bits"], q["patlen"])
and within(q["bits"], q["known"]) and count(q["known"]) > 0)
if not ok:
raise Refuse("sequence_invalid")
q["digest"] = sha(q["body"])
return q
def candidate(s, q):
"""§7.3."""
return (s["owner"] == q["owner"] and s["media"] == q["media"] and s["vendor"] == q["vendor"]
and s["patlen"] == q["patlen"] and s["start"] < q["end"] and q["start"] < s["end"])
def disagreement(s, q):
"""§7.3: None when s agrees with q, else (position, how)."""
for p in range(q["patlen"]):
if not bit(q["known"], p):
continue
if not bit(s["served"], p):
return (p, "unserved")
if bit(s["bits"], p) != bit(q["bits"], p) and "compare-bits" not in SKIP:
return (p, "differs")
return None
# ---------------------------------------------------------------- §9 verification
class Stop(Exception):
def __init__(self, reason, at):
super().__init__(reason)
self.reason, self.at = reason, at
def check_room(room):
"""§9.1 steps 1 and 2."""
try:
d = LRV.read_descriptor(room["descriptor"])
except Refuse as e:
raise Stop(e.reason, {"place": "descriptor"})
try:
LRV.check_key_log(d, room["key_log"])
except Refuse as e:
raise Stop(e.reason, {"place": "key_log"})
entries = None
if d["origin"] != b"":
entries = json.loads(room["key_log"])["keys"]
held, err = LRV.check_grants(d, room["grants"])
if err is not None:
raise Stop(err[1], {"place": "grant", "index": err[0]})
c = room["confidentiality"]
if c is not None:
at = {"place": "confidentiality"}
if len(c) < 4:
raise Stop("truncated", at)
if c[:4] != WRE1:
raise Stop("malformed", at)
if len(c) < 5:
raise Stop("truncated", at)
if c[4] != 1:
raise Stop("unknown_version", at)
if len(c) < WRE1_LEN:
raise Stop("truncated", at)
if len(c) > WRE1_LEN:
raise Stop("malformed", at)
if c[5:37] != d["room"]:
raise Stop("wrong_room", at)
bad = ED.verify(d["authority"], WRE1_DOMAIN + c[:41], c[41:])
if bad:
raise Stop(bad, at)
if "e2ee" not in SKIP:
raise Stop("e2ee_room", at)
return {"d": d, "entries": entries, "grants": held}
def check_session(room, record, token, earlier):
"""§9.1 step 3 for one record: its facts, or Refuse."""
d = room["d"]
s = read_session(record) # 1
if s["room"] != d["room"]: # 2
raise Refuse("wrong_room")
bad = ED.verify(s["edge"], SESSION_DOMAIN + s["body"], s["sig"]) # 3
if bad:
raise Refuse(bad)
if "edge" not in SKIP: # 4
if room["entries"] is None:
if s["edge"] not in (d["authority"], d["clock"]):
raise Refuse("edge_not_authorised")
else:
e = next((e for e in room["entries"] if e["key"] == s["edge"].hex() and EDGE_AUTHORITY in e["may_sign"]), None)
if e is None:
raise Refuse("edge_not_authorised")
if e["status"] == "revoked":
raise Refuse("edge_revoked")
if s["owner"][:1] == b"_" and "content" not in SKIP: # 5
if not any(x["name"] == s["owner"][1:] and x["media"] == s["media"] for x in d["slots"]):
raise Refuse("content_unknown")
if not s["kids"] and "kids" not in SKIP: # 6
raise Refuse("not_protected")
if d["regions"] and s["region"] not in d["regions"] and "region" not in SKIP: # 7
raise Refuse("region_not_pinned")
end_us = d["t0"] + s["end"]
if s["expires"] * 1000000 < end_us and "expires" not in SKIP:
raise Refuse("record_invalid")
if d["retention"] and s["expires"] * 1000000 > end_us + d["retention"] * 86400 * 1000000 and "retention" not in SKIP:
raise Refuse("retention_exceeded")
if s["token"] in earlier and "reuse" not in SKIP: # 8
raise Refuse("token_reused")
t = None
if token is not None: # 9, §5.7
if sha(token) != s["token"]:
raise Refuse("token_mismatch")
t = read_token(token)
if (t["vendor"], t["patlen"], t["source"]) != (s["vendor"], s["patlen"], s["source"]):
raise Refuse("token_mismatch")
if t["pattern"] is not None and "pattern" not in SKIP:
p = bytes.fromhex(t["pattern"])
if bytes(a & b for a, b in zip(p, s["served"])) != s["bits"]:
raise Refuse("token_mismatch")
if not (good_at(t, d["t0"] + s["start"]) and good_at(t, d["t0"] + s["end"] - 1)) and "window" not in SKIP:
raise Refuse("outside_token_window")
s["token_facts"] = t
return s
def session_facts(s):
return {"digest": s["digest"].hex(), "edge": s["edge"].hex(), "owner": s["owner"].decode(), "media": s["media"],
"kids": [k.hex() for k in s["kids"]], "start": s["start"], "end": s["end"], "vendor": s["vendor"],
"source": s["source"], "patlen": s["patlen"], "served": count(s["served"]), "segments": s["segments"],
"unmarked": s["unmarked"], "region": s["region"].decode(), "expires": s["expires"],
"token": s["token_facts"]}
def verify_sessions(room_in, records):
room = check_room(room_in)
out = []
for i, (record, token) in enumerate(records):
try:
out.append(check_session(room, record, token, [s["token"] for s in out]))
except Refuse as e:
raise Stop(e.reason, {"place": "session", "index": i})
return room, out
def check_sequence(room, b):
at = {"place": "sequence"}
try:
q = read_sequence(b)
except Refuse as e:
raise Stop(e.reason, at)
if q["room"] != room["d"]["room"]:
raise Stop("wrong_room", at)
bad = ED.verify(q["claimant"], SEQUENCE_DOMAIN + q["body"], q["sig"])
if bad:
raise Stop(bad, at)
return q
def verify_match(room_in, records, seq):
"""§9.2: (outcome, reason, facts)."""
room, sessions = verify_sessions(room_in, records)
q = check_sequence(room, seq)
cands = []
for i, s in enumerate(sessions):
if candidate(s, q):
dis = disagreement(s, q)
cands.append({"index": i, "digest": s["digest"].hex(), "agrees": dis is None,
"first": None if dis is None else {"position": dis[0], "how": dis[1]}})
agreeing = [c["digest"] for c in cands if c["agrees"]]
if len(agreeing) == 1:
outcome, reason, matched = "matched", None, agreeing[0]
else:
outcome, matched = "unmatched", None
reason = "no_match" if not agreeing else "ambiguous_match"
return outcome, reason, {"sequence": q["digest"].hex(), "stated": count(q["known"]), "candidates": cands, "session": matched}
def verify_disclosure(room_in, record, token, seq, disclosure):
"""§9.3: the facts, or Stop."""
room, sessions = verify_sessions(room_in, [(record, token)])
s = sessions[0]
q = check_sequence(room, seq)
d = room["d"]
at = {"place": "disclosure"}
def stop(r):
return Stop(r, at)
try:
read_head(disclosure, DISCLOSURE_MAGIC)
except Refuse as e:
raise stop(e.reason)
if len(disclosure) < 269:
raise stop("truncated")
if len(disclosure) > 269:
raise stop("malformed")
f = {name: disclosure[5 + 32 * i:37 + 32 * i] for i, name in enumerate(("room", "session", "sequence", "grant", "principal", "salt"))}
t = struct.unpack("<Q", disclosure[197:205])[0]
if f["room"] != d["room"]:
raise stop("wrong_room")
bad = ED.verify(d["authority"], DISCLOSURE_DOMAIN + disclosure[:205], disclosure[205:])
if bad:
raise stop(bad)
if (f["session"] != s["digest"] or f["sequence"] != q["digest"]) and "names" not in SKIP:
raise stop("disclosure_mismatch")
if not candidate(s, q) or disagreement(s, q) is not None:
raise stop("sequence_disagrees")
if binding(f["salt"], f["grant"], f["principal"]) != s["binding"] and "opening" not in SKIP:
raise stop("opening_mismatch")
g = next((g for g in room["grants"] if g["digest"] == f["grant"]), None)
if g is None:
raise stop("grant_unknown")
if g["principal"] != f["principal"]:
raise stop("grant_wrong_principal")
if (g["nbf"] * 1000000 > d["t0"] + s["start"] or d["t0"] + s["end"] > g["exp"] * 1000000) and "grant-window" not in SKIP:
raise stop("outside_grant_window")
if t > s["expires"] and "disclosure-expires" not in SKIP:
raise stop("disclosure_expired")
return {"digest": sha(disclosure[:205]).hex(), "session": s["digest"].hex(), "sequence": q["digest"].hex(),
"grant": f["grant"].hex(), "principal": f["principal"].hex(), "stated": count(q["known"]),
"token_given": token is not None}
# ---------------------------------------------------------------- the corpus
def unhex(x):
return None if x is None else bytes.fromhex(x)
def room_input(doc, v):
r = dict(doc["rooms"][v["room"]])
r.update(v.get("room_override", {}))
return {"descriptor": unhex(r["descriptor"]), "grants": [unhex(g) for g in r["grants"]],
"key_log": r["key_log"], "confidentiality": unhex(r["confidentiality"])}
def records_of(v):
return [(unhex(x["record"]), unhex(x["token"])) for x in v["records"]]
def check(doc, quiet=False):
"""Every vector's outcome against the corpus: a list of failures."""
fails = []
def note(kind, v, got, want):
if got != want:
fails.append("%s %s: got %s, the corpus states %s" % (kind, v["name"], got, want))
if not quiet:
print("FAIL %s %s" % (kind, v["name"]))
elif not quiet:
print("PASS %s %s: %s %s" % (kind, v["name"], want[0], want[1] or ""))
for v in doc["tokens"]:
try:
got = ("read", None, read_token(unhex(v["token"])))
except Refuse as e:
got = ("refuse", e.reason, None)
note("token", v, got, (v["outcome"], v["reason"], v["facts"]))
for v in doc["sessions"]:
try:
_, ss = verify_sessions(room_input(doc, v), records_of(v))
got = ("accept", None, None, [session_facts(s) for s in ss])
except Stop as e:
got = ("refuse", e.reason, e.at, None)
note("sessions", v, got, (v["outcome"], v["reason"], v["at"], v["sessions"]))
for v in doc["matches"]:
try:
got = verify_match(room_input(doc, v), records_of(v), unhex(v["sequence"])) + (None,)
except Stop as e:
got = ("refuse", e.reason, None, e.at)
note("match", v, got, (v["outcome"], v["reason"], v["match"], v["at"]))
for v in doc["disclosures"]:
rec = v["record"]
try:
got = ("accept", None, None, verify_disclosure(room_input(doc, v), unhex(rec["record"]), unhex(rec["token"]),
unhex(v["sequence"]), unhex(v["disclosure"])))
except Stop as e:
got = ("refuse", e.reason, e.at, None)
note("disclosure", v, got, (v["outcome"], v["reason"], v["at"], v["facts"]))
return fails
# ---------------------------------------------------------------- mutations
def by_name(vs, name):
return next(v for v in vs if v["name"] == name)
def flip(hexstr, at):
b = bytearray(bytes.fromhex(hexstr))
b[at] ^= 0x01
return b.hex()
def mutations(doc):
"""(label, mutated copy) for each defect the corpus must catch."""
out = []
def mutate(label, f):
d = copy.deepcopy(doc)
f(d)
out.append((label, d))
lane = lambda d: by_name(d["sessions"], "lane_a_sessions") # noqa: E731
mutate("a byte of a record's signature flipped", lambda d: lane(d)["records"][0].update(record=flip(lane(d)["records"][0]["record"], -1)))
mutate("a byte of a record's served bitmap flipped",
lambda d: lane(d)["records"][1].update(record=flip(lane(d)["records"][1]["record"], 165)))
mutate("two records' tokens swapped", lambda d: (lambda r: (r[0].update(token=r[2]["token"]), r[2].update(token=lane(doc)["records"][0]["token"])))(lane(d)["records"]))
mutate("a record's stated served count", lambda d: lane(d)["sessions"][0].update(served=63))
mutate("a record's stated token facts", lambda d: lane(d)["sessions"][2]["token"].update(source=1))
mutate("a token's stated reason", lambda d: by_name(d["tokens"], "untagged").update(reason="token_malformed"))
mutate("a token's pattern byte flipped", lambda d: by_name(d["tokens"], "mac0_clear").update(token=flip(by_name(d["tokens"], "mac0_clear")["token"], -40)))
mutate("the end-to-end encrypted room's confidentiality record removed", lambda d: d["rooms"]["e2ee"].update(confidentiality=None))
mutate("the pin's stated outcome", lambda d: by_name(d["sessions"], "region_not_pinned").update(outcome="accept", reason=None, at=None))
mutate("a byte of the matched sequence flipped", lambda d: by_name(d["matches"], "recovered_from_session_1").update(sequence=flip(by_name(d["matches"], "recovered_from_session_1")["sequence"], 120)))
mutate("the matched session's stated digest", lambda d: by_name(d["matches"], "recovered_from_session_1")["match"].update(session="00" * 32))
mutate("an ambiguous match stated as matched", lambda d: by_name(d["matches"], "agrees_with_two").update(outcome="matched", reason=None))
mutate("a candidate's first disagreement", lambda d: by_name(d["matches"], "agrees_with_two")["match"]["candidates"][1]["first"].update(how="differs"))
mutate("the disclosure's principal stated as another's", lambda d: by_name(d["disclosures"], "disclosure_of_session_1")["facts"].update(principal="00" * 32))
mutate("a byte of the disclosure's salt flipped", lambda d: by_name(d["disclosures"], "disclosure_of_session_1").update(disclosure=flip(by_name(d["disclosures"], "disclosure_of_session_1")["disclosure"], 170)))
mutate("the disclosure checked against another session's record", lambda d: by_name(d["disclosures"], "disclosure_without_token").update(record=by_name(d["disclosures"], "sequence_disagrees")["record"]))
mutate("the expired disclosure stated as accepted", lambda d: by_name(d["disclosures"], "disclosure_expired").update(outcome="accept", reason=None, at=None))
mutate("a refusal's stated place", lambda d: by_name(d["sessions"], "token_reused")["at"].update(index=0))
return out
# Each of the verifier's own checks, by its SKIP name: the corpus must catch a verifier without it.
CHECKS = {
"depth": "§4.2 step 1: the nesting bound",
"map-order": "§4.2 step 1: map keys in deterministic order",
"edge": "§5.6: who may sign",
"content": "§9.1 step 3.5: the content is the room's",
"kids": "§3.1: a record names a content key",
"region": "§6.1: the region pin",
"expires": "§6.1: expires at or after the session's end",
"retention": "§6.1: the retention bound",
"reuse": "§9.1 step 3.8: one token, one session",
"pattern": "§5.7 step 3: the bits are the clear pattern's",
"window": "§5.7 step 4: the token's window",
"e2ee": "§3.2: an end-to-end encrypted room",
"compare-bits": "§7.3: a served position's variant is compared",
"names": "§9.3 step 4: the disclosure names the record and the sequence",
"opening": "§9.3 step 6: the opening opens the binding",
"grant-window": "§9.3 step 8: the grant's window",
"disclosure-expires": "§9.3 step 9: before expires",
}
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) == 20 else "FAIL") + " §11 read from the extension: %d reasons" % len(REASONS))
fails += [] if len(REASONS) == 20 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]
seen = {v["reason"] for k in ("tokens", "sessions", "matches", "disclosures") for v in doc[k]}
missing = [r for r in REASONS if r not in seen]
print(("PASS" if not missing else "FAIL") + " every reason of §11 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 f in fails:
print("FAIL " + f)
print(("PASS" if not fails else "FAIL") + " forensic-watermark corpus: %d tokens, %d session lists, %d matches, %d disclosures"
% (len(doc["tokens"]), len(doc["sessions"]), len(doc["matches"]), len(doc["disclosures"])))
return 1 if fails else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: live_watermark_verify.py <live-conformance/watermark>", file=sys.stderr)
sys.exit(2)
sys.exit(main(sys.argv[1]))