#!/usr/bin/env python3 """Independent verifier of the key-release corpus (extensions/key-release.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. It reads CPIX documents with the standard library's XML parser. Everything else is here: - the scheme, delivery and decision codes, the deliveries' schemes and the reasons, read from the extension's tables (§3.1, §3.2, §3.3, §7.2, §11, §14.4); - the commitment list (§4): its layout of version 1, 2 (with the content's release time) or 3 (extending another), rules, signature and identity, its derivation from a CPIX document (§4.4), and a chain of lists (§4.6); - the entitlement (§5) and the device's proof over a request (§6.1); - the release decision and the licence it permits (§7); - the proxy form of the wai.video.keys receipt (§8): its JSON carriage, signing payload, identity and own rules; - the release ticket (§9.3) and verification of a log (§10); - the key provider (§15): a request, the provider's decision (a scheduled content's periods held to its schedule among it), its receipt, and the answer and the list a provision comes with, each held to the others (§15.5), with every key of every answer derived again from the corpus's seed, or by the keyed derivation (HMAC-SHA-256) under its test secret; - the entitlement rules (§14): the entitlement of version 2, the output statement, the four decisions of §14.4 with the leases a stream limit counted, and the receipt's form 3; - the revocation (§16): its layout, signature and what it names, and check 1a; - the checkpoint of a log (§17): its JSON, signing payload, identity and own rules, what it holds of the log before it, and the decisions after it, which read it. For every vector it requires the outcome, reason and place, and every fact the corpus states; of the corpus, that every reason of §11 and every decision of §7.2 and §14.4 appears in it and that no other file is there. Then it mutates copies of the corpus, one defect each, and requires every copy to fail. Last it weakens itself, dropping one of its own checks of revocation at a time (`WEAKENINGS`), and requires the corpus to catch each weakened verifier. python3 key_release_verify.py Exit status 0 only if every vector verifies and every mutated copy fails. Python 3.8 or later; standard library only. """ import base64 import calendar import copy import datetime import hashlib import hmac import json import os import re import struct import sys import xml.etree.ElementTree as ET HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance")) import _ed25519 as ED # noqa: E402 import _blake3 as B3 # noqa: E402 EXTENSION = os.path.join(HERE, "..", "extensions", "key-release.md") LIST_MAGIC, ENT_MAGIC, TICKET_MAGIC = b"WKL1", b"WKE1", b"WKT1" LIST_DOMAIN = b"wai:video-keys-list\x01" # §4.2 LIST_ID_DOMAIN = b"wai:video-keys-list-id\x01" ENT_DOMAIN = b"wai:video-keys-entitlement\x01" # §5.2 REQUEST_DOMAIN = b"wai:video-keys-request\x01" # §6.1 RECEIPT_DOMAIN = b"wai:video-keys-proxy-receipt\x01" # §8.3 RECEIPT_DOMAIN_3 = b"wai:video-keys-proxy-receipt\x02" # §14.5 OUTPUT_DOMAIN = b"wai:video-keys-output\x01" # §14.2 OUTPUT_MAGIC = b"WKO1" REVOCATION_MAGIC = b"WKR1" # §16.1 REVOCATION_DOMAIN = b"wai:video-keys-revocation\x01" MAX_WINDOW = 86400 # §14.1 MAX_LEASES, MAX_OUTPUT = 4096, 1024 # §14.5 ID_DOMAIN = b"wai:video-keys-receipt-id\x01" TICKET_DOMAIN = b"wai:video-keys-ticket\x01" # §9.3 CHECKPOINT_DOMAIN = b"wai:video-keys-checkpoint\x01" # §17.1 CHECKPOINT_ID_DOMAIN = b"wai:video-keys-checkpoint-id\x01" COMMIT_DOMAIN = b"wai:video-keys-commit\x01" # §4.1, form 1's commitment CPIX = "urn:dashif:org:cpix" # §4.4 PSKC = "urn:ietf:params:xml:ns:keyprov:pskc" FRESH = 300 # §7.2 check 2 MAX_KEYS, MAX_LIST, MAX_TICKET = 64, 4096, 300 U64, U32, U16 = 2 ** 64 - 1, 2 ** 32 - 1, 2 ** 16 - 1 FLOOR = 288676 # §8.4 rule 10 CLASS_TAG = {"ModelBased": 2, "Estimator": 3, "OnChipCounter": 4, "CalibratedInstrument": 5} # energy-measurement §2.7 FILTERING = {"Unknown": 0, "Off": 1, "On": 2} KINDS = {"streaming": 1, "offline": 2, "renewal": 3} # §6.1 WHITE_SPACE = set(map(chr, [0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20, 0x85, 0xA0, 0x1680] + list(range(0x2000, 0x200B)) + [0x2028, 0x2029, 0x202F, 0x205F, 0x3000])) # ---------------------------------------------------------------- the extension's tables def _text(): with open(EXTENSION, encoding="utf-8") as f: return f.read() def table(heading): """The body rows of the first table under `heading`, each a list of its cells.""" text = _text() at = text.index("\n" + heading + "\n") + len(heading) + 2 end = text.find("\n#", at) rows = [] for line in text[at:end if end >= 0 else len(text)].splitlines(): if line.startswith("|") and not set(line) <= set("|- "): rows.append([c.strip() for c in line.strip("|").split("|")]) return rows[1:] def code(cell): return cell.strip().strip("`") SCHEMES = {code(r[1]): (int(r[0]), int(r[4])) for r in table("### 3.1 Schemes")} # label: (code, octets) DELIVERIES = {code(r[1]): int(r[0]) for r in table("### 3.2 Key deliveries")} # kind: code CARRIES = {} for _r in table("### 3.3 Which deliveries carry which schemes"): _named = re.findall(r"`([^`]+)`", _r[1]) CARRIES[code(_r[0])] = set(SCHEMES) if _r[1].startswith("all") else {s for s in _named if s in SCHEMES} DECISIONS = {code(r[1]): int(r[2]) for r in table("### 7.2 The checks, in order")} # label: code RULE_DECISIONS = {code(r[1]): int(r[2]) for r in table("### 14.4 The checks")} # §14.4's four DECISIONS.update(RULE_DECISIONS) REASONS = [code(r[0]) for r in table("## 11. Reasons")] SCHEME_OF = {c: label for label, (c, _) in SCHEMES.items()} DELIVERY_OF = {c: label for label, c in DELIVERIES.items()} # A weakened run drops one named check (`WEAKENINGS`); None checks everything. DROPPED = None WEAKENINGS = [ "revocation_signature", # §16.1: a revocation's signature "revocation_authority", # §10.2 step 1a: its authority is one of the authorities "revoked", # §7.2 check 1a "revoked_from", # §16.2: from its instant on "revoked_authority", # §16.2: the same authority "revoked_scope", # §16.2: a session, or a group, as its scope says "revoked_order", # §7.2: check 1a before check 2 "schedule", # §15.3 check 8 "schedule_count", # §15.1: a period's index below the count "schedule_window", # §15.1: an indexed period's window is its index's "schedule_period_required", # §15.3 check 8: a scheduled content's key has a period "secret_source", # §15.3: a case of the keyed source derives by its secret "chain_named", # §10.2 step 1: an extending list names a list given "chain_one", # §10.2 step 1: one list extends a list "chain_signer", # §4.6 rule 1: the chain's signer "chain_new_kid", # §4.6 rule 2: no KID of the chain again "chain_later", # §4.6 rule 3: later periods "chain_lookup", # §7.1: a key of any list of the chain, not only the named one "checkpoint_signature", # §17.1: a checkpoint's signature "checkpoint_signer", # §17.4: a checkpoint by one of the proxies "checkpoint_contents", # §17.4: a later checkpoint is what §17.2 computes "checkpoint_proofs_freshness", # §17.2 item 1: proofs whose request time a later request can still present "checkpoint_streams_window", # §17.2 item 2: releases less than a day old "checkpoint_last_per_class", # §17.2 item 2: each session's last release of each limit "checkpoint_renewable_expiry", # §17.2 item 3: licences not lapsed "checkpoint_previous", # §17.1: the checkpoint before it named "checkpoint_floor", # §17.3: no receipt after a checkpoint decided before it "checkpoint_reads_whole_log", # §17.3: a decision after a checkpoint reads the checkpoint ] def drops(name): return DROPPED == name class Refuse(Exception): def __init__(self, reason, at=None): super().__init__(reason) self.reason, self.at = reason, at def sha(*parts): h = hashlib.sha256() for p in parts: h.update(p) return h.digest() def b3(*parts): return B3.blake3(b"".join(parts)) def be(n, v): return v.to_bytes(n, "big") def string(s): b = s.encode("utf-8") return be(4, len(b)) + b def opt(b): return b"\x00" if b is None else b"\x01" + b def commitment(key): """§4.1: form 1's key commitment.""" return b3(COMMIT_DOMAIN, be(4, len(key)), key) class Reader: def __init__(self, b): self.b, self.at = b, 0 def take(self, n): if self.at + n > len(self.b): raise ValueError("short") s = self.b[self.at:self.at + n] self.at += n return s def int(self, n): return int.from_bytes(self.take(n), "big") def string(self): return self.take(self.int(4)).decode("utf-8") def done(self): return self.at == len(self.b) def period_bytes(p): """§4.1: a period's tag and fields; None is tag 0.""" if p is None: return b"\x00" tag = (1 if p["index"] is not None else 0) + (2 if p["start"] is not None else 0) out = bytes([tag]) if p["index"] is not None: out += be(4, p["index"]) if p["start"] is not None: out += be(8, p["start"]) + be(8, p["end"]) return out def read_period(r): tag = r.int(1) if tag == 0: return None if tag > 3: raise ValueError("tag") index = r.int(4) if tag & 1 else None start, end = (r.int(8), r.int(8)) if tag & 2 else (None, None) return {"index": index, "start": start, "end": end} # ---------------------------------------------------------------- §4 the commitment list def read_list(b): """§4.1 and §4.3: the list, or Refuse.""" try: r = Reader(b) if r.take(4) != LIST_MAGIC: raise ValueError("magic") version = r.int(1) if version not in (1, 2, 3): raise ValueError("version") content, required = r.string(), r.string() flag = r.int(1) if flag > 1: raise ValueError("flag") cpix = r.take(32) if flag else None issued = r.int(8) not_before = r.int(8) if version == 2 else None # §4.1: version 2 only extends = r.take(32) if version == 3 else None # §4.6: version 3 only n = r.int(4) if not 1 <= n <= MAX_LIST or not content or not required: raise ValueError("count") keys = [] for _ in range(n): kid, scheme, commit = r.take(16), r.int(1), r.take(32) p = read_period(r) if scheme not in SCHEME_OF or commit == bytes(32) or (keys and keys[-1]["kid"] >= kid): raise ValueError("key") if p is not None and p["start"] is not None and p["start"] >= p["end"]: raise ValueError("window") if version == 3 and p is None: # §4.3 raise ValueError("period") keys.append({"kid": kid, "scheme": SCHEME_OF[scheme], "commit": commit, "period": p}) pk = r.take(32) sid = r.string() signed_end = r.at sig = r.take(64) if not r.done(): raise ValueError("trailing") except (ValueError, UnicodeDecodeError): raise Refuse("list_malformed") if ED.verify(pk, LIST_DOMAIN + b[:signed_end], sig): raise Refuse("list_bad_signature") return {"content": content, "required": required, "cpix": cpix, "keys": {k["kid"]: k for k in keys}, "not_before": not_before, "extends": extends, "issued": issued, "identity": b3(LIST_ID_DOMAIN, b), "signer": pk, "signer_id": sid} def chain_to(by_id, ident, limit): """§4.6: the chain to the list `ident`, its first list first; None when a list on the way is not given.""" chain = [] while True: lst = by_id.get(ident) if lst is None or len(chain) > limit: return None chain.append(lst) if lst["extends"] is None: return chain[::-1] ident = lst["extends"] def extends_chain(lst, chain): """§4.6 rules 1 to 3: `lst` extends `chain`.""" first, last = chain[0], chain[-1] if lst["content"] != first["content"] or lst["required"] != first["required"] or lst["issued"] < last["issued"]: return False if lst["signer"] != first["signer"] and not drops("chain_signer"): return False held = [k for c in chain for k in c["keys"].values()] indices = [k["period"]["index"] for k in held if k["period"] is not None and k["period"]["index"] is not None] ends = [k["period"]["end"] for k in held if k["period"] is not None and k["period"]["end"] is not None] for kid, k in lst["keys"].items(): p = k["period"] if any(kid in c["keys"] for c in chain) and not drops("chain_new_kid"): return False if drops("chain_later"): continue if indices and (p["index"] is None or p["index"] <= max(indices)): return False if ends and (p["start"] is None or p["start"] < max(ends)): return False return True def chain_key(chain, kid): """The key `kid` as the list of the chain that holds it states it, or None.""" for c in (chain[-1:] if drops("chain_lookup") else chain): if kid in c["keys"]: return c["keys"][kid] return None # ---------------------------------------------------------------- §4.4 a CPIX document UUID = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") TIME = re.compile(r"^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z$") def q(name, ns=CPIX): return "{%s}%s" % (ns, name) def uuid(s): return bytes.fromhex(s.replace("-", "")) if s is not None and UUID.match(s) else None def seconds(s): m = TIME.match(s or "") if not m: return None try: t = calendar.timegm(datetime.datetime(*map(int, m.groups())).timetuple()) except ValueError: return None return t if t >= 0 else None def b64(text): s = "".join(c for c in (text or "") if c not in " \t\n\r") try: v = base64.b64decode(s, validate=True) except ValueError: return None return v if base64.b64encode(v).decode() == s else None def derive(data, asked=False, octets=None): """§4.4 steps 1 to 5: (content_id, digest, keys by KID), or Refuse. With `asked`, the document is a key request (§15.2), whose keys carry no Data and have no commitment. `octets`, when given, is filled with each key's octets by KID.""" # Step 1. head = data[:200].decode("utf-8", "replace") m = re.match(r"^?<\?xml[^>]*encoding\s*=\s*[\"']([^\"']+)[\"']", head) if b" U32: raise Refuse("cpix_unsupported") index = int(index) start, end = p.get("start"), p.get("end") if (start is None) != (end is None): raise Refuse("cpix_unsupported") if start is not None: start, end = seconds(start), seconds(end) if start is None or end is None: raise Refuse("cpix_unsupported") if start >= end: raise Refuse("cpix_malformed") if index is None and start is None: raise Refuse("cpix_unsupported") return {"index": index, "start": start, "end": end} # ---------------------------------------------------------------- §5 the entitlement, §6 the proof def read_entitlement(b): """§5.1's layout, or None.""" try: r = Reader(b) if r.take(4) != ENT_MAGIC: return None version = r.int(1) if version not in (1, 2): return None e = {"session": r.take(16), "holder": r.take(32), "class": r.string(), "nb": r.int(8), "na": r.int(8), "offline": r.int(8), "renewals": r.int(4), "rules": None} if version == 2: # §14.1 e["rules"] = {"group": r.take(16), "streams": r.int(4), "window": r.int(4), "out_min": r.int(1), "out_class": r.int(1)} e.update(authority=r.take(32), authority_id=r.string()) end = r.at sig = r.take(64) if not r.done(): return None except (ValueError, UnicodeDecodeError): return None x = e["rules"] ruled = x is None or ( (x["window"] == 0 if x["streams"] == 0 else 1 <= x["window"] <= MAX_WINDOW) and (x["out_class"] == 0 if x["out_min"] == 0 else (x["out_min"] <= 9 and x["out_class"] in (1, 2))) and (x["streams"] > 0 or x["out_min"] > 0)) e["holds"] = (bool(e["class"]) and e["nb"] <= e["na"] and (e["offline"] > 0 or e["renewals"] == 0) and ruled and ED.verifies(e["authority"], ENT_DOMAIN + b[:end], sig)) return e def states_rules(eb): """§14.1: the entitlement's first five bytes are WKE1 and 2.""" return eb is not None and eb[:5] == ENT_MAGIC + b"\x02" def read_statement(b): """§14.2: an output statement that holds, or None.""" try: r = Reader(b) if r.take(4) != OUTPUT_MAGIC or r.int(1) != 1: return None s = {"holder": r.take(32), "level": r.int(1), "class": r.int(1), "nb": r.int(8), "na": r.int(8), "signer": r.take(32)} end = r.at sig = r.take(64) if not r.done(): return None except ValueError: return None if not (1 <= s["level"] <= 9 and s["class"] in (1, 2) and s["nb"] <= s["na"]): return None if s["class"] == 1 and s["signer"] != s["holder"]: return None return s if ED.verifies(s["signer"], OUTPUT_DOMAIN + b[:end], sig) else None def read_revocation(b): """§16.1: a revocation that holds, or None.""" try: r = Reader(b) if r.take(4) != REVOCATION_MAGIC or r.int(1) != 1: return None v = {"scope": r.int(1), "target": r.take(16), "from": r.int(8), "authority": r.take(32), "authority_id": r.string()} end = r.at sig = r.take(64) if not r.done() or v["scope"] not in (1, 2): return None except (ValueError, UnicodeDecodeError): return None if not drops("revocation_signature") and not ED.verifies(v["authority"], REVOCATION_DOMAIN + b[:end], sig): return None return v def revoked(e, issued, revocations): """§16.2: a revocation names the entitlement, and `issued` is at or above its instant.""" for v in revocations: if issued < v["from"] and not drops("revoked_from"): continue if v["authority"] != e["authority"] and not drops("revoked_authority"): continue group = e["rules"]["group"] if e["rules"] is not None else None if drops("revoked_scope"): if v["target"] in (e["session"], group): return True elif (v["scope"] == 1 and v["target"] == e["session"]) or (v["scope"] == 2 and group is not None and v["target"] == group): return True return False def proof_payload(ent, r): """§6.1.""" d = r["delivery"] out = [REQUEST_DOMAIN, sha(ent), string(r["content_id"]), bytes([DELIVERIES[d["kind"]]]), opt(d["system_id"]), be(4, len(r["keys"]))] out += [k["kid"] for k in r["keys"]] out += [bytes([KINDS[r["kind"]]]), opt(r["renews"]), r["request_sha256"], be(8, r["request_t"])] if states_rules(ent): # §14.3 out.append(opt(None if r.get("output") is None else sha(r["output"]))) return b"".join(out) # ---------------------------------------------------------------- §8 the receipt MEMBERS = ["form", "content_id", "list", "cpix_sha256", "required_entitlement", "delivery", "keys", "kind", "renews", "entitlement", "request_sha256", "request_t", "proof", "issued", "decision", "licence_expiry", "renewal", "upstream_status", "response_sha256", "released", "joules_micro", "energy_provenance", "signer_pubkey", "signer_id", "sig"] HEX = re.compile(r"^(?:[0-9a-f]{2})*$") def is_int(v, top=U64): return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= top def hexb(v, n=None, null=False): if v is None and null: return None if not isinstance(v, str) or not HEX.match(v) or (n is not None and len(v) != 2 * n): raise ValueError("hex") return bytes.fromhex(v) def nint(v, top=U64): if v is None: return None if not is_int(v, top): raise ValueError("int") return v def nstr(v): if v is None or isinstance(v, str): return v raise ValueError("str") def read_receipt(o): """§8.2: the receipt, or Refuse receipt_malformed.""" try: if not isinstance(o, dict): raise ValueError("object") label = o.get("energy_provenance") want = list(MEMBERS) if label == "OnChipCounter": want += ["energy_uncertainty", "energy_counter_filtering"] elif label == "CalibratedInstrument": want += ["energy_uncertainty", "energy_calibration_ref"] elif label is not None and label not in CLASS_TAG: raise ValueError("class") if not is_int(o.get("form")) or o["form"] not in (2, 3): raise ValueError("form") if o["form"] == 3: # §14.5 want += ["output", "leases"] if set(o) != set(want): raise ValueError("members") cls = None if label is not None: cls = {"label": label} if label in ("OnChipCounter", "CalibratedInstrument"): u = o["energy_uncertainty"] if not isinstance(u, dict) or set(u) != {"relative_ppm", "window_us"} or not is_int(u["relative_ppm"], U32) \ or not is_int(u["window_us"]): raise ValueError("uncertainty") cls.update(ppm=u["relative_ppm"], window=u["window_us"]) if label == "OnChipCounter": if o["energy_counter_filtering"] not in FILTERING: raise ValueError("filtering") cls["filtering"] = FILTERING[o["energy_counter_filtering"]] if label == "CalibratedInstrument": if not isinstance(o["energy_calibration_ref"], str): raise ValueError("ref") cls["ref"] = o["energy_calibration_ref"] d = o["delivery"] if not isinstance(d, dict) or set(d) != {"kind", "system_id"} or d["kind"] not in DELIVERIES: raise ValueError("delivery") keys = [] if not isinstance(o["keys"], list): raise ValueError("keys") for k in o["keys"]: if not isinstance(k, dict) or set(k) != {"kid", "scheme", "period", "key_commit"}: raise ValueError("key") if k["scheme"] is not None and k["scheme"] not in SCHEMES: raise ValueError("scheme") p = k["period"] if p is not None: if not isinstance(p, dict) or set(p) != {"index", "start", "end"}: raise ValueError("period") p = {"index": nint(p["index"], U32), "start": nint(p["start"]), "end": nint(p["end"])} if (p["start"] is None) != (p["end"] is None) or (p["index"] is None and p["start"] is None): raise ValueError("period") keys.append({"kid": hexb(k["kid"], 16), "scheme": k["scheme"], "period": p, "key_commit": hexb(k["key_commit"], 32, True)}) if o["kind"] not in KINDS or o["decision"] not in DECISIONS or not isinstance(o["released"], bool): raise ValueError("labels") for k in ("content_id", "signer_id"): if not isinstance(o[k], str): raise ValueError(k) r = { "content_id": o["content_id"], "list": hexb(o["list"], 32, True), "cpix_sha256": hexb(o["cpix_sha256"], 32, True), "required": nstr(o["required_entitlement"]), "delivery": {"kind": d["kind"], "system_id": hexb(d["system_id"], 16, True)}, "keys": keys, "kind": o["kind"], "renews": hexb(o["renews"], 32, True), "entitlement": hexb(o["entitlement"], None, True), "request_sha256": hexb(o["request_sha256"], 32), "request_t": nint(o["request_t"]), "proof": hexb(o["proof"], 64, True), "issued": nint(o["issued"]), "decision": o["decision"], "licence_expiry": nint(o["licence_expiry"]), "renewal": nint(o["renewal"], U32), "upstream_status": nint(o["upstream_status"], U16), "response_sha256": hexb(o["response_sha256"], 32, True), "released": o["released"], "joules_micro": nint(o["joules_micro"]), "class": cls, "signer_pubkey": hexb(o["signer_pubkey"], 32), "signer_id": o["signer_id"], "sig": hexb(o["sig"], 64), } if r["request_t"] is None or r["issued"] is None or r["joules_micro"] is None: raise ValueError("int") r["form"] = o["form"] if o["form"] == 3: if not isinstance(o["leases"], list): raise ValueError("leases") r["output"], r["leases"] = hexb(o["output"], None, True), [hexb(x, 32) for x in o["leases"]] except (ValueError, KeyError, TypeError): raise Refuse("receipt_malformed") return r def class_bytes(c): """energy-measurement §2.7's signed encoding.""" if c is None: return b"\x00" out = bytes([CLASS_TAG[c["label"]]]) if c["label"] == "OnChipCounter": out += struct.pack(">IQ", c["ppm"], c["window"]) + bytes([c["filtering"]]) elif c["label"] == "CalibratedInstrument": ref = c["ref"].encode() out += struct.pack(">IQ", c["ppm"], c["window"]) + struct.pack(">I", len(ref)) + ref return out def payload(r): """§8.3.""" d = r["delivery"] out = [RECEIPT_DOMAIN_3 if r["form"] == 3 else RECEIPT_DOMAIN, string(r["content_id"]), opt(r["list"]), opt(r["cpix_sha256"]), opt(None if r["required"] is None else string(r["required"])), bytes([DELIVERIES[d["kind"]]]), opt(d["system_id"]), be(4, len(r["keys"]))] for k in r["keys"]: out += [k["kid"], bytes([0 if k["scheme"] is None else SCHEMES[k["scheme"]][0]]), period_bytes(k["period"]), opt(k["key_commit"])] e = r["entitlement"] out += [bytes([KINDS[r["kind"]]]), opt(r["renews"]), opt(None if e is None else be(4, len(e)) + e), r["request_sha256"], be(8, r["request_t"]), opt(r["proof"]), be(8, r["issued"]), bytes([DECISIONS[r["decision"]]]), opt(None if r["licence_expiry"] is None else be(8, r["licence_expiry"])), opt(None if r["renewal"] is None else be(4, r["renewal"])), opt(None if r["upstream_status"] is None else be(2, r["upstream_status"])), opt(r["response_sha256"]), bytes([1 if r["released"] else 0])] if r["form"] == 3: # §14.5 o = r["output"] out += [opt(None if o is None else be(4, len(o)) + o), be(4, len(r["leases"]))] + r["leases"] out += [be(8, r["joules_micro"]), class_bytes(r["class"]), r["signer_pubkey"], string(r["signer_id"])] return b"".join(out) def identity(r): return b3(ID_DOMAIN, payload(r), r["sig"]) def own_rules(r): """§8.4, rules in order: None, or the refusal.""" bad = ED.verify(r["signer_pubkey"], payload(r), r["sig"]) # rule 1 if bad: return bad keys, kind, d = r["keys"], r["kind"], r["delivery"] kids = [k["kid"] for k in keys] if not 1 <= len(keys) <= MAX_KEYS or any(a >= b for a, b in zip(kids, kids[1:])): # rule 2 return "receipt_invalid" drm = d["kind"] == "drm" if (d["system_id"] is not None) != drm or d["system_id"] == bytes(16) or (d["kind"] == "hls-key-uri" and len(keys) != 1): return "receipt_invalid" # rule 3 if any(k["scheme"] is not None and k["scheme"] not in CARRIES[d["kind"]] for k in keys): return "scheme_delivery_mismatch" # rule 4 if r["list"] is None: # rule 5 if r["cpix_sha256"] is not None or r["required"] is not None or any( k["scheme"] is not None or k["period"] is not None or k["key_commit"] is not None for k in keys): return "receipt_invalid" elif r["required"] is None: return "receipt_invalid" if (r["entitlement"] is None) != (r["proof"] is None): # rule 6 return "receipt_invalid" if r["entitlement"] is None and r["decision"] != "DeniedEntitlementInvalid": return "receipt_invalid" if (r["renews"] is not None) != (kind == "renewal"): return "receipt_invalid" granted, rel = r["decision"] == "Granted", r["released"] if (rel and not granted) or any((k["key_commit"] is not None) != rel for k in keys): # rule 7 return "receipt_invalid" st = r["upstream_status"] # rule 8 if drm: if (st is not None) != granted or (r["response_sha256"] is not None) != granted: return "receipt_invalid" if rel != (st is not None and 200 <= st <= 299) or (st is not None and not 100 <= st <= 599): return "receipt_invalid" elif st is not None or r["response_sha256"] is not None or rel != granted: return "receipt_invalid" licence = rel and kind != "streaming" # rule 9 if (r["licence_expiry"] is not None) != licence or (r["renewal"] is not None) != licence: return "receipt_invalid" if licence and (r["licence_expiry"] < r["issued"] or (r["renewal"] == 0) != (kind == "offline")): return "receipt_invalid" if (r["form"] == 3) != states_rules(r["entitlement"]): # §14.5 return "receipt_invalid" if r["form"] != 3 and r["decision"] in RULE_DECISIONS: return "receipt_invalid" if r["form"] == 3: counted = kind == "streaming" and r["decision"] in ("Granted", "UndecidedStreams", "DeniedStreamLimit") if (r["leases"] and not counted) or len(r["leases"]) > MAX_LEASES or len(set(r["leases"])) != len(r["leases"]): return "receipt_invalid" if r["output"] is not None and len(r["output"]) > MAX_OUTPUT: return "receipt_invalid" c, j = r["class"], r["joules_micro"] # rule 10 if c is None: return None if j == 0 else "receipt_invalid" if j == 0: return "receipt_invalid" if c["label"] in ("OnChipCounter", "CalibratedInstrument"): if c["ppm"] == 0 or c["window"] == 0 or j * c["ppm"] < FLOOR: return "receipt_invalid" if c["label"] == "CalibratedInstrument" and all(ch in WHITE_SPACE for ch in c["ref"]): return "receipt_invalid" return None # ---------------------------------------------------------------- §7 the decision def decide(authorities, attesters, r, lst, earlier, revocations=()): """§7.2, §7.3 and §14.4: (decision, licence_expiry, renewal, leases) of receipt r's request.""" d, expiry, renewal = decide_13(authorities, r, lst, earlier, revocations) e = read_entitlement(r["entitlement"]) if r["entitlement"] is not None else None if d != "Granted" or e is None or e["rules"] is None: return d, expiry, renewal, [] x, issued = e["rules"], r["issued"] if x["out_min"] > 0: s = read_statement(r["output"]) if r.get("output") is not None else None established = (s is not None and s["holder"] == e["holder"] and s["nb"] <= issued <= s["na"] and s["class"] >= x["out_class"] and (s["class"] != 2 or s["signer"] in attesters)) if not established: return "UndecidedOutput", None, None, [] # check 14 if s["level"] < x["out_min"]: return "DeniedOutputUnmet", None, None, [] # check 15 if x["streams"] == 0 or r["kind"] != "streaming": return d, expiry, renewal, [] last, agree = {}, True # session: (place in the log, identity) for n, p in enumerate(stream_releases(earlier)): if abs(issued - p["issued"]) >= x["window"]: continue if p["authority"] != e["authority"] or p["group"] != x["group"] or p["session"] == e["session"]: continue agree = agree and p["streams"] == x["streams"] and p["window"] == x["window"] last[p["session"]] = (n, p["receipt"]) leases = [i for _, i in sorted(last.values())] if not agree: return "UndecidedStreams", None, None, leases # check 16 if len(leases) >= x["streams"]: return "DeniedStreamLimit", None, None, leases # check 17 return d, expiry, renewal, leases def decide_13(authorities, r, lst, earlier, revocations=()): """§7.2 checks 1 to 13 and §7.3: (decision, licence_expiry, renewal).""" def denied(d): return d, None, None eb, proof, issued = r["entitlement"], r["proof"], r["issued"] e = read_entitlement(eb) if eb is not None else None if (e is None or proof is None or not e["holds"] or e["authority"] not in authorities or not ED.verifies(e["holder"], proof_payload(eb, r), proof)): return denied("DeniedEntitlementInvalid") # check 1 weak = drops("revoked") or drops("revoked_order") if not weak and revoked(e, issued, revocations): return denied("DeniedRevoked") # check 1a if abs(issued - r["request_t"]) > FRESH: return denied("DeniedStale") # check 2 if drops("revoked_order") and revoked(e, issued, revocations): return denied("DeniedRevoked") if proof_seen(earlier, proof): return denied("DeniedReplayed") # check 3 chain = lst or [] # §7.1: the chain to the named list if not chain or any(chain_key(chain, k["kid"]) is None for k in r["keys"]): return denied("DeniedUnknownKey") # check 4 if chain[0]["not_before"] is not None and issued < chain[0]["not_before"]: return denied("DeniedNotReleased") # check 5 if e["class"] != chain[0]["required"]: return denied("DeniedNoEntitlement") # check 6 if issued < e["nb"]: return denied("DeniedNotYetValid") # check 7 if issued > e["na"]: return denied("DeniedExpired") # check 8 for k in r["keys"]: # check 9 p = chain_key(chain, k["kid"])["period"] if p is not None and p["start"] is not None and (p["start"] > e["na"] or p["end"] <= e["nb"]): return denied("DeniedPeriodOutside") if r["kind"] != "streaming" and e["offline"] == 0: return denied("DeniedOfflineNotEntitled") # check 10 expiry = min(issued + e["offline"], e["na"]) if r["kind"] == "streaming": return "Granted", None, None if r["kind"] == "offline": return "Granted", expiry, 0 kids = [k["kid"] for k in r["keys"]] prior = renewable(earlier, r["renews"]) # check 11 if prior is not None and not (prior["content_id"] == r["content_id"] and prior["kids"] == kids and prior["session"] == e["session"] and prior["holder"] == e["holder"]): prior = None if prior is None: return denied("DeniedRenewalUnknown") if prior["renewal"] + 1 > e["renewals"]: return denied("DeniedRenewalsExhausted") # check 12 if issued > prior["licence_expiry"]: return denied("DeniedRenewalLapsed") # check 13 return "Granted", expiry, prior["renewal"] + 1 # ---------------------------------------------------------------- §17 what decisions read of the log class State: """What a decision reads (§17.3): the receipts since the log's first line, or a checkpoint and the receipts after it.""" def __init__(self, base=None): self.base, self.receipts = base, [] def release_of(r): """§14.4: the release a receipt is, for a stream limit, or None.""" if not (r["released"] and r["kind"] == "streaming") or r["entitlement"] is None: return None e = read_entitlement(r["entitlement"]) if e is None or e["rules"] is None: return None x = e["rules"] return {"receipt": r["identity"], "issued": r["issued"], "authority": e["authority"], "group": x["group"], "session": e["session"], "streams": x["streams"], "window": x["window"]} def renewable_of(r): """§7.2 check 11: the licence a receipt released that a renewal may name, or None.""" if not r["released"] or r["kind"] == "streaming" or r["entitlement"] is None or r["licence_expiry"] is None: return None e = read_entitlement(r["entitlement"]) if e is None: return None return {"receipt": r["identity"], "content_id": r["content_id"], "kids": [k["kid"] for k in r["keys"]], "session": e["session"], "holder": e["holder"], "renewal": r["renewal"] or 0, "licence_expiry": r["licence_expiry"]} def proof_seen(state, proof): return any(p["proof"] == proof for p in state.receipts) or (state.base is not None and proof in state.base["proofs"]) def renewable(state, ident): for p in state.receipts: if p["identity"] == ident: return renewable_of(p) if state.base is not None: return next((x for x in state.base["renewable"] if x["receipt"] == ident), None) return None def stream_releases(state): """Every release a stream limit may count, in the log's order: the checkpoint's, then the receipts'.""" out = list(state.base["streams"]) if state.base is not None else [] return out + [x for x in map(release_of, state.receipts) if x is not None] def read_checkpoint(o): """§17.1: the checkpoint, or Refuse checkpoint_invalid.""" want = {"kind", "version", "at", "count", "last", "previous", "proofs", "streams", "renewable", "signer_pubkey", "signer_id", "sig"} try: if not isinstance(o, dict) or set(o) != want or o["kind"] != "wai-key-checkpoint" or not is_int(o["version"]) or o["version"] != 1: raise ValueError("members") if not isinstance(o["signer_id"], str) or not all(isinstance(o[k], list) for k in ("proofs", "streams", "renewable")): raise ValueError("types") cp = {"at": nint(o["at"]), "count": nint(o["count"]), "last": hexb(o["last"], 32, True), "previous": hexb(o["previous"], 32, True), "signer": hexb(o["signer_pubkey"], 32), "signer_id": o["signer_id"], "sig": hexb(o["sig"], 64)} if cp["at"] is None or cp["count"] is None: raise ValueError("int") cp["proofs"] = [] for p in o["proofs"]: if not isinstance(p, dict) or set(p) != {"proof", "request_t"} or not is_int(p["request_t"]): raise ValueError("proof") cp["proofs"].append((hexb(p["proof"], 64), p["request_t"])) cp["streams"] = [] for x in o["streams"]: if not isinstance(x, dict) or set(x) != {"receipt", "issued", "authority", "group", "session", "streams", "window_s"}: raise ValueError("stream") if not (is_int(x["issued"]) and is_int(x["streams"], U32) and is_int(x["window_s"], U32)): raise ValueError("stream") cp["streams"].append({"receipt": hexb(x["receipt"], 32), "issued": x["issued"], "authority": hexb(x["authority"], 32), "group": hexb(x["group"], 16), "session": hexb(x["session"], 16), "streams": x["streams"], "window": x["window_s"]}) cp["renewable"] = [] for x in o["renewable"]: if not isinstance(x, dict) or set(x) != {"receipt", "content_id", "kids", "session", "holder", "renewal", "licence_expiry"}: raise ValueError("licence") if not (isinstance(x["content_id"], str) and isinstance(x["kids"], list) and is_int(x["renewal"], U32) and is_int(x["licence_expiry"])): raise ValueError("licence") cp["renewable"].append({"receipt": hexb(x["receipt"], 32), "content_id": x["content_id"], "kids": [hexb(k, 16) for k in x["kids"]], "session": hexb(x["session"], 16), "holder": hexb(x["holder"], 32), "renewal": x["renewal"], "licence_expiry": x["licence_expiry"]}) except (ValueError, KeyError, TypeError): raise Refuse("checkpoint_invalid") out = [CHECKPOINT_DOMAIN, be(8, cp["at"]), be(8, cp["count"]), opt(cp["last"]), opt(cp["previous"]), be(4, len(cp["proofs"]))] for p, t in cp["proofs"]: out += [p, be(8, t)] out.append(be(4, len(cp["streams"]))) for x in cp["streams"]: out += [x["receipt"], be(8, x["issued"]), x["authority"], x["group"], x["session"], be(4, x["streams"]), be(4, x["window"])] out.append(be(4, len(cp["renewable"]))) for x in cp["renewable"]: out += [x["receipt"], string(x["content_id"]), be(4, len(x["kids"]))] + x["kids"] out += [x["session"], x["holder"], be(4, x["renewal"]), be(8, x["licence_expiry"])] out += [cp["signer"], string(cp["signer_id"])] payload = b"".join(out) if not drops("checkpoint_signature") and ED.verify(cp["signer"], payload, cp["sig"]): raise Refuse("checkpoint_invalid") at, proofs = cp["at"], [p for p, _ in cp["proofs"]] holds = (all(a < b for a, b in zip(proofs, proofs[1:])) and all(t + FRESH >= at for _, t in cp["proofs"]) and all(x["issued"] <= at and at - x["issued"] < MAX_WINDOW and x["streams"] > 0 and 1 <= x["window"] <= MAX_WINDOW for x in cp["streams"]) and all(x["licence_expiry"] >= at and 1 <= len(x["kids"]) <= MAX_KEYS and all(a < b for a, b in zip(x["kids"], x["kids"][1:])) for x in cp["renewable"]) and (cp["count"] == 0) == (cp["last"] is None)) if not holds: raise Refuse("checkpoint_invalid") cp["identity"] = b3(CHECKPOINT_ID_DOMAIN, payload, cp["sig"]) return cp def checkpoint_at(state, at): """§17.2: what a checkpoint at `at` holds of the log so far, as (proofs, streams, renewable, count, last, previous).""" base = state.base proofs = dict(base["proofs"]) if base is not None else {} for r in state.receipts: if r["proof"] is not None: proofs[r["proof"]] = max(proofs.get(r["proof"], r["request_t"]), r["request_t"]) proofs = sorted((p, t) for p, t in proofs.items() if t + FRESH >= at or drops("checkpoint_proofs_freshness")) releases = stream_releases(state) if drops("checkpoint_last_per_class"): last = releases else: last = [] for x in releases: cls = (x["authority"], x["group"], x["session"], x["streams"], x["window"]) last = [y for y in last if (y["authority"], y["group"], y["session"], y["streams"], y["window"]) != cls] + [x] streams = [x for x in last if at - min(x["issued"], at) < MAX_WINDOW or drops("checkpoint_streams_window")] licences = (list(base["renewable"]) if base is not None else []) + [x for x in map(renewable_of, state.receipts) if x is not None] licences = [x for x in licences if x["licence_expiry"] >= at or drops("checkpoint_renewable_expiry")] count = (base["count"] if base is not None else 0) + len(state.receipts) last_id = state.receipts[-1]["identity"] if state.receipts else (base["last"] if base is not None else None) previous = None if base is None or drops("checkpoint_previous") else base["identity"] return proofs, streams, licences, count, last_id, previous def base_of(cp): return {"identity": cp["identity"], "at": cp["at"], "count": cp["count"], "last": cp["last"], "proofs": dict(cp["proofs"]), "streams": cp["streams"], "renewable": cp["renewable"]} # ---------------------------------------------------------------- §9.3 the ticket def read_ticket(b): try: r = Reader(b) if r.take(4) != TICKET_MAGIC or r.int(1) != 1: return None t = {"receipt": r.take(32), "delivery": r.int(1), "session_type": r.int(1)} n = r.int(4) if t["delivery"] not in (1, 2) or t["session_type"] not in (1, 2) or not 1 <= n <= MAX_KEYS: return None t["kids"] = [r.take(16) for _ in range(n)] if any(a >= b for a, b in zip(t["kids"], t["kids"][1:])): return None t["expires"], t["pk"] = r.int(8), r.take(32) end = r.at sig = r.take(64) if not r.done() or not ED.verifies(t["pk"], TICKET_DOMAIN + b[:end], sig): return None except ValueError: return None return t def ticket_follows(t, r): return (r["released"] and r["delivery"]["kind"] != "drm" and t["receipt"] == r["identity"] and DELIVERY_OF.get(t["delivery"]) == r["delivery"]["kind"] and t["session_type"] == (1 if r["kind"] == "streaming" else 2) and t["kids"] == [k["kid"] for k in r["keys"]] and t["pk"] == r["signer_pubkey"] and 1 <= t["expires"] - r["issued"] <= MAX_TICKET) # ---------------------------------------------------------------- §15 the key provider PROVIDER_REQUEST_DOMAIN = b"wai:key-provider-request\x01" # §15.2 PROVIDER_RECEIPT_DOMAIN = b"wai:key-provider-receipt\x01" # §15.4 PROVIDER_ID_DOMAIN = b"wai:key-provider-receipt-id\x01" PROVIDER_KEY_DOMAIN = b"wai:key-provider-test-key\x01" # §15.3 PROVIDER_SECRET_DOMAIN = b"wai:key-provider-key\x01" # §15.3, the keyed derivation PROVIDER_MEMBERS = {"kind", "version", "content_id", "requester", "request_sha256", "request_t", "proof", "issued", "decision", "kids", "response_sha256", "list", "signer_pubkey", "signer_id", "sig"} MAX_REQUEST = 1 << 20 # §15.2 PROVISIONS = {code(r[1]): int(r[2]) for r in table("### 15.3 The decision and the answer")} PROVIDER_REASONS = list(dict.fromkeys(c for r in table("### 15.5 Verification") for c in re.findall(r"`([a-z_]+)`", r[1]))) def on_schedule(sched, p): """§15.1 and §15.3 check 8: a key's period is one of the schedule's.""" o, length, n = sched["origin"], sched["length"], sched["count"] if p is None: return drops("schedule_period_required") if p["index"] is not None: i = p["index"] if i >= n and not drops("schedule_count"): return False if p["start"] is not None and not drops("schedule_window") and (p["start"], p["end"]) != (o + i * length, o + (i + 1) * length): return False return True start = p["start"] if start < o or (start - o) % length: return False return ((start - o) // length < n or drops("schedule_count")) and p["end"] == start + length def provider_verify(doc, c): """§15.5 over one exchange: what the corpus states for it.""" try: return {"accept": provider_exchange(doc, c)} except Refuse as s: return {"refuse": s.reason} def provider_exchange(doc, c): o = c["receipt"] try: # step 1 if not isinstance(o, dict) or set(o) != PROVIDER_MEMBERS or o["kind"] != "wai-key-provider-receipt" \ or not is_int(o["version"]) or o["version"] != 1 or o["decision"] not in PROVISIONS \ or not isinstance(o["signer_id"], str) or not isinstance(o["kids"], list): raise ValueError("members") r = {"content": nstr(o["content_id"]), "requester": hexb(o["requester"], 32), "request_sha256": hexb(o["request_sha256"], 32), "request_t": nint(o["request_t"]), "proof": hexb(o["proof"], 64), "issued": nint(o["issued"]), "decision": o["decision"], "kids": [hexb(k, 16) for k in o["kids"]], "response": hexb(o["response_sha256"], 32, True), "list": hexb(o["list"], 32, True), "signer": hexb(o["signer_pubkey"], 32), "signer_id": o["signer_id"], "sig": hexb(o["sig"], 64)} if r["request_t"] is None or r["issued"] is None: raise ValueError("int") except (ValueError, KeyError, TypeError): raise Refuse("provider_receipt_malformed") provided = r["decision"] == "Provided" # step 2 if ((r["response"] is not None) != provided or (r["list"] is not None) != provided or bool(r["kids"]) != provided or len(r["kids"]) > MAX_LIST or any(a >= b for a, b in zip(r["kids"], r["kids"][1:])) or (provided and r["content"] is None)): raise Refuse("provider_receipt_invalid") signed = b"".join([PROVIDER_RECEIPT_DOMAIN, opt(None if r["content"] is None else string(r["content"])), r["requester"], r["request_sha256"], be(8, r["request_t"]), r["proof"], be(8, r["issued"]), bytes([PROVISIONS[r["decision"]]]), be(4, len(r["kids"]))] + r["kids"] + [opt(r["response"]), opt(r["list"]), r["signer"], string(r["signer_id"])]) if ED.verify(r["signer"], signed, r["sig"]): # step 3 raise Refuse("provider_bad_signature") if r["signer"].hex() not in c["providers"]: # step 4 raise Refuse("provider_unknown") request = c["request"].encode("utf-8") if sha(request) != r["request_sha256"]: # step 5 raise Refuse("provider_request_mismatch") asked, why = None, None # step 6: §15.3 try: if len(request) > MAX_REQUEST: raise Refuse("cpix_malformed") asked = derive(request, asked=True) except Refuse as s: why = s.reason if r["requester"].hex() not in doc["requesters"]: want = "DeniedRequesterUnknown" elif not ED.verifies(r["requester"], PROVIDER_REQUEST_DOMAIN + sha(request) + be(8, r["request_t"]), r["proof"]): want = "DeniedProofInvalid" elif abs(r["issued"] - r["request_t"]) > FRESH: want = "DeniedStale" elif c["seen"]: want = "DeniedReplayed" elif why is not None: want = "DeniedRequestMalformed" if why == "cpix_malformed" else "DeniedRequestUnsupported" elif asked[0] not in [x["content_id"] for x in doc["contents"]]: want = "DeniedContentUnknown" elif (next(x for x in doc["contents"] if x["content_id"] == asked[0]).get("schedule") is not None and not drops("schedule") and not all(on_schedule(next(x for x in doc["contents"] if x["content_id"] == asked[0])["schedule"], k["period"]) for k in asked[2].values())): want = "DeniedPeriodUnknown" # check 8 else: want = "Provided" if want != r["decision"] or r["content"] != (asked[0] if asked else None): raise Refuse("provider_decision_mismatch") identity = b3(PROVIDER_ID_DOMAIN, signed, r["sig"]).hex() if not provided: # step 7 if c["response"] is not None or c["list"] is not None: raise Refuse("provider_response_mismatch") return {"identity": identity, "decision": r["decision"], "keys": []} if c["response"] is None or c["list"] is None: raise Refuse("provider_response_mismatch") response = c["response"].encode("utf-8") if sha(response) != r["response"] or sorted(asked[2]) != r["kids"]: raise Refuse("provider_response_mismatch") lst = read_list(bytes.fromhex(c["list"])) # step 8 if lst["identity"] != r["list"] or lst["signer"] != r["signer"]: # step 9 raise Refuse("provider_list_mismatch") octets = {} content, digest, keys = derive(response, octets=octets) # step 10 if lst["content"] != content or lst["cpix"] != digest or lst["keys"] != keys: raise Refuse("cpix_mismatch") same = content == asked[0] and sorted(keys) == sorted(asked[2]) and all( # step 11 keys[k]["scheme"] == asked[2][k]["scheme"] and keys[k]["period"] == asked[2][k]["period"] for k in keys) if not same: raise Refuse("provider_response_mismatch") # The contents' own statements, which the list carries, and the reference provider's keys. stated = next(x for x in doc["contents"] if x["content_id"] == content) if lst["required"] != stated["required_entitlement"] or lst["not_before"] != stated["not_before"]: raise ValueError("the list does not state its content's entitlement class and release time") seed, secret = bytes.fromhex(doc["seed"]), bytes.fromhex(doc["secret"]) keyed = c.get("source") == "secret" and not drops("secret_source") for kid, key in octets.items(): n = SCHEMES[keys[kid]["scheme"]][1] if keyed: want_key = hmac.new(secret, PROVIDER_SECRET_DOMAIN + string(content) + kid, hashlib.sha256).digest()[:n] else: want_key = sha(PROVIDER_KEY_DOMAIN, seed, string(content), kid)[:n] if key != want_key: raise ValueError("a key of the answer is not the one §15.3 derives from the %s" % ("secret" if keyed else "seed")) return {"identity": identity, "decision": r["decision"], "keys": [{"kid": k.hex(), "key_commit": keys[k]["commit"].hex(), "period": keys[k]["period"]} for k in sorted(keys)]} def provider_check(doc): """Every exchange against its statement: the failures.""" fails = [] for c in doc["cases"]: try: got = provider_verify(doc, c) except Exception as e: # a mutated copy may break an exchange in any way got = "%s: %s" % (type(e).__name__, e) if got != c["outcome"]: fails.append("provider case %s: %s, where the corpus states %s" % (c.get("name"), json.dumps(got)[:150], json.dumps(c["outcome"])[:150])) return fails def provider_mutations(doc): by = {c["name"]: k for k, c in enumerate(doc["cases"])} def m(label, f): d = copy.deepcopy(doc) f(d, lambda name: d["cases"][by[name]]) return label, d return [ m("a stated provider decision", lambda d, c: c("refused_stale")["outcome"]["accept"].update(decision="DeniedReplayed")), m("a stated provider reason", lambda d, c: c("n_response_not_the_lists").update(outcome={"refuse": "provider_response_mismatch"})), m("a stated key commitment of a provision", lambda d, c: c("provided")["outcome"]["accept"]["keys"][0].update(key_commit="00" * 32)), m("a stated period of a provision", lambda d, c: c("provided_rotation")["outcome"]["accept"]["keys"][0]["period"].update(index=7)), m("a replay given as a first request", lambda d, c: c("refused_replayed").update(seen=False)), m("the untrusted requester served", lambda d, c: d["requesters"].append(c("refused_requester_unknown")["receipt"]["requester"])), m("the content the provider does not serve, served", lambda d, c: d["contents"].append( {"content_id": "film-0001", "required_entitlement": "svod:premium", "not_before": None})), m("the seed changed", lambda d, c: d.update(seed="11" * 32)), m("a content's release time withheld", lambda d, c: d["contents"][1].update(not_before=None)), m("the answer whose key is not the list's stated as accepted", lambda d, c: c("n_response_not_the_lists").update( outcome=copy.deepcopy(c("provided")["outcome"]))), m("a byte of a provision's request", lambda d, c: c("provided").update(request=c("provided")["request"].replace("cbcs", "cenc", 1))), m("a provision's list replaced by the release-time one", lambda d, c: c("provided").update(list=c("provided_release_time")["list"])), # §15.3 check 8, and the keyed derivation. m("the scheduled content's schedule withheld", lambda d, c: d["contents"][2].pop("schedule")), m("the schedule cut to one period", lambda d, c: d["contents"][2]["schedule"].update(count=1)), m("the schedule's periods moved a second later", lambda d, c: d["contents"][2]["schedule"].update(origin=d["contents"][2]["schedule"]["origin"] + 1)), m("a refusal for a period stated as a provision's decision", lambda d, c: c("refused_no_period")["outcome"]["accept"].update(decision="Provided")), m("the test secret changed", lambda d, c: d.update(secret="22" * 32)), m("the keyed provision read as the seed's", lambda d, c: c("provided_by_secret").pop("source")), ] # ---------------------------------------------------------------- §10 verification def verify(v, doc, files): """§10.2 over vector v: what the corpus states for it.""" try: facts, checkpoints = verify_log(v, doc, files) got = {"outcome": "accept", "receipts": facts} if checkpoints or "checkpoints" in v: got["checkpoints"] = checkpoints return got except Refuse as s: return {"outcome": "refuse", "reason": s.reason, "at": s.at} def verify_log(v, doc, files): authorities = [bytes.fromhex(a) for a in v["authorities"]] proxies = [bytes.fromhex(p) for p in v["proxies"]] attesters = [bytes.fromhex(a) for a in v.get("attesters", [])] # §14.6 lists, by_content = [], {} for i, name in enumerate(v["lists"]): # step 1 try: lst = read_list(bytes.fromhex(doc["lists"][name])) except Refuse as s: raise Refuse(s.reason, ["list", i]) if lst["extends"] is None: if lst["content"] in by_content: raise Refuse("list_malformed", ["list", i]) by_content[lst["content"]] = lst lists.append(lst) by_id = {lst["identity"]: lst for lst in lists} extended = set() for i, lst in enumerate(lists): # §4.6 if lst["extends"] is None: continue chain = chain_to(by_id, lst["extends"], len(lists)) if chain is None and drops("chain_named"): continue if (lst["extends"] in extended and not drops("chain_one")) or chain is None or not extends_chain(lst, chain): raise Refuse("list_chain_invalid", ["list", i]) extended.add(lst["extends"]) revocations = [] for i, h in enumerate(v.get("revocations", [])): # step 1a rv = read_revocation(bytes.fromhex(h)) if rv is None or (rv["authority"] not in authorities and not drops("revocation_authority")): raise Refuse("revocation_invalid", ["revocation", i]) revocations.append(rv) for j, c in enumerate(v["cpix"]): # step 2 try: content, digest, keys = derive(files[c["file"]]) except Refuse as s: raise Refuse(s.reason, ["cpix", j]) lst = lists[c["list"]] if c["list"] < len(lists) else None if lst is None or lst["content"] != content or lst["cpix"] != digest or lst["keys"] != keys: raise Refuse("cpix_mismatch", ["cpix", j]) log, facts, checkpoints, state = [], [], [], State() for i, o in enumerate(v["log"]): # step 3 if isinstance(o, dict) and o.get("kind") == "wai-key-checkpoint": # §17.4 at = ["checkpoint", i] try: cp = read_checkpoint(o) except Refuse as s: raise Refuse(s.reason, at) if cp["signer"] not in proxies and not drops("checkpoint_signer"): raise Refuse("checkpoint_invalid", at) if i > 0 and not drops("checkpoint_contents"): latest = max([p["issued"] for p in state.receipts] + [state.base["at"] if state.base else 0]) stated = (cp["proofs"], cp["streams"], cp["renewable"], cp["count"], cp["last"], cp["previous"]) if cp["at"] < latest or checkpoint_at(state, cp["at"]) != stated: raise Refuse("checkpoint_mismatch", at) checkpoints.append(cp["identity"].hex()) if drops("checkpoint_reads_whole_log") and i > 0: continue state = State(base_of(cp)) continue at = ["receipt", i] try: r = read_receipt(o) except Refuse as s: raise Refuse(s.reason, at) bad = own_rules(r) if bad: raise Refuse(bad, at) if r["signer_pubkey"] not in proxies: raise Refuse("signer_unknown", at) r["identity"] = identity(r) if any(p["identity"] == r["identity"] for p in log): raise Refuse("receipt_duplicate", at) chain = None if r["list"] is not None: chain = chain_to(by_id, r["list"], len(lists)) if chain is None: raise Refuse("list_unknown", at) if chain[0]["content"] != r["content_id"]: raise Refuse("list_mismatch", at) elif r["content_id"] in by_content: raise Refuse("list_mismatch", at) if chain is not None: if r["cpix_sha256"] != chain[-1]["cpix"] or r["required"] != chain[0]["required"]: raise Refuse("list_mismatch", at) for k in r["keys"]: lk = chain_key(chain, k["kid"]) if lk is None: ok = k["scheme"] is None and k["period"] is None and k["key_commit"] is None else: ok = (k["scheme"] == lk["scheme"] and k["period"] == lk["period"] and k["key_commit"] in (None, lk["commit"])) if not ok: raise Refuse("list_mismatch", at) if state.base is not None and r["issued"] < state.base["at"] and not drops("checkpoint_floor"): raise Refuse("checkpoint_mismatch", at) # §17.3 d, expiry, renewal, leases = decide(authorities, attesters, r, chain, state, revocations) if d != r["decision"] or (r["released"] and r["kind"] != "streaming" and (r["licence_expiry"], r["renewal"]) != (expiry, renewal)): raise Refuse("decision_mismatch", at) if r["form"] == 3 and r["leases"] != leases: raise Refuse("decision_mismatch", at) facts.append({"identity": r["identity"].hex(), "decision": r["decision"], "released": r["released"], "keys": [{"kid": k["kid"].hex(), "key_commit": k["key_commit"].hex()} for k in r["keys"] if k["key_commit"] is not None], "licence_expiry": r["licence_expiry"], "renewal": r["renewal"]}) if r["form"] == 3: facts[-1]["leases"] = [x.hex() for x in leases] log.append(r) state.receipts.append(r) for j, h in enumerate(v["tickets"]): # step 4 t = read_ticket(bytes.fromhex(h)) r = next((p for p in log if t is not None and p["identity"] == t["receipt"]), None) if t is None or r is None or not ticket_follows(t, r): raise Refuse("ticket_invalid", ["ticket", j]) return facts, checkpoints def check(doc, files, quiet=False): """Every vector against its statement: the failures.""" fails = [] for v in doc.get("vectors", []): try: got = verify(v, doc, files) except Exception as e: # a crash on a mutated copy is a failure to read it got, bad = None, ["the vector does not verify: %s: %s" % (type(e).__name__, e)] else: bad = [k for k in got if v.get(k) != got[k]] if not quiet: what = "?" if got is None else got["outcome"] + (" %s at %s" % (got["reason"], got["at"]) if got["outcome"] == "refuse" else "") print(("PASS" if not bad else "FAIL") + " log %s: %s" % (v.get("name"), what)) fails += ["log %s: %s differs from the corpus" % (v.get("name"), k) for k in bad] return fails def flip(h, at): b = bytearray(bytes.fromhex(h)) b[at] ^= 0x01 return b.hex() def mutations(doc, files): """Copies of the corpus, each with one defect a conforming verifier must catch.""" vn = {v["name"]: k for k, v in enumerate(doc["vectors"])} def mutate(label, f): d, fs = copy.deepcopy(doc), dict(files) f(d, d["vectors"], fs) return label, d, fs def receipt(name, i): return lambda d, vs, fs: vs[vn[name]]["log"][i] def edit_receipt(name, i, key, value): return lambda d, vs, fs: vs[vn[name]]["log"][i].update({key: value}) def ent_byte(d, vs, fs): r = vs[vn["refusals"]]["log"][4] r["entitlement"] = flip(r["entitlement"], 60) return [ mutate("a byte of a release's signature", lambda d, vs, fs: edit_receipt("release_drm", 0, "sig", flip(vs[vn["release_drm"]]["log"][0]["sig"], 3))(d, vs, fs)), mutate("a release's issued time moved", lambda d, vs, fs: edit_receipt("release_drm", 1, "issued", vs[vn["release_drm"]]["log"][1]["issued"] + 1)(d, vs, fs)), mutate("a stated decision", lambda d, vs, fs: vs[vn["refusals"]]["receipts"][3].update(decision="DeniedExpired")), mutate("a stated licence expiry", lambda d, vs, fs: vs[vn["offline"]]["receipts"][0].update(licence_expiry=1)), mutate("a stated key commitment", lambda d, vs, fs: vs[vn["release_drm"]]["receipts"][0]["keys"][0].update(key_commit="00" * 32)), mutate("the granted request removed from before its replay", lambda d, vs, fs: (vs[vn["refusals"]]["log"].pop(4), vs[vn["refusals"]]["receipts"].pop(4))), mutate("a renewal moved before the licence it renews", lambda d, vs, fs: vs[vn["offline"]]["log"].insert(0, vs[vn["offline"]]["log"].pop(1))), mutate("the untrusted authority trusted", lambda d, vs, fs: vs[vn["refusals"]]["authorities"].append( d["principals"]["rogue_authority"])), mutate("a byte of an entitlement inside a release", ent_byte), mutate("a key of the film document changed", lambda d, vs, fs: fs.update({"film.cpix.xml": fs["film.cpix.xml"].replace(b"", b"AAAA", 1)})), mutate("the forged list replaced by the genuine one", lambda d, vs, fs: vs[vn["n_list_forged"]].update(lists=["film"])), mutate("the stated reason of a negative control", lambda d, vs, fs: vs[vn["n_misreported_grant"]].update(reason="list_mismatch")), mutate("the stated place of a negative control", lambda d, vs, fs: vs[vn["n_duplicate"]].update(at=["receipt", 0])), mutate("a byte of a ticket's expiry", lambda d, vs, fs: vs[vn["release_hls_key_uri"]].update( tickets=[vs[vn["release_hls_key_uri"]]["tickets"][0][:-200] + "ff" + vs[vn["release_hls_key_uri"]]["tickets"][0][-198:]])), mutate("a period of the channel list stated otherwise", lambda d, vs, fs: vs[vn["rotation"]]["log"][0]["keys"][0].update( period={"index": 2, "start": None, "end": None})), mutate("a refusal's scheme stated for a key not in the list", lambda d, vs, fs: next( k for k in vs[vn["refusals"]]["log"][6]["keys"] if k["scheme"] is None).update(scheme="cenc")), mutate("a CPIX document removed", lambda d, vs, fs: fs.pop("channel.cpix.xml")), mutate("the release time of a list moved a second earlier, its signature kept", lambda d, vs, fs: d["lists"].update(film_release=release_moved(d["lists"]["film_release"], -1))), mutate("the list with a release time read as version 1", lambda d, vs, fs: d["lists"].update(film_release=d["lists"]["film_release"][:8] + "01" + d["lists"]["film_release"][10:])), mutate("the release-time log given the list without one", lambda d, vs, fs: vs[vn["release_time"]].update(lists=["film"])), mutate("a refusal before the release time stated as an entitlement's", lambda d, vs, fs: vs[vn["release_time"]]["receipts"][0].update(decision="DeniedNoEntitlement")), # §14: the entitlement rules. mutate("the attester withheld from the verifier", lambda d, vs, fs: vs[vn["output"]].update(attesters=[])), mutate("the untrusted attester trusted", lambda d, vs, fs: vs[vn["output"]]["attesters"].append(d["principals"]["rogue_attester"])), mutate("a stated lease left out", lambda d, vs, fs: vs[vn["streams"]]["receipts"][2]["leases"].pop()), mutate("the stated leases in another order", lambda d, vs, fs: vs[vn["streams"]]["receipts"][2]["leases"].reverse()), mutate("a release removed from before the refusal that counted it", lambda d, vs, fs: (vs[vn["streams"]]["log"].pop(1), vs[vn["streams"]]["receipts"].pop(1))), mutate("an undecided request stated refused for the limit", lambda d, vs, fs: vs[vn["streams"]]["receipts"][6].update(decision="DeniedStreamLimit")), mutate("an undecided output stated refused as unmet", lambda d, vs, fs: vs[vn["output"]]["receipts"][0].update(decision="DeniedOutputUnmet")), mutate("a receipt of form 3 stated as form 2", lambda d, vs, fs: edit_receipt("streams", 0, "form", 2)(d, vs, fs)), mutate("a byte of an output statement inside a release", lambda d, vs, fs: edit_receipt( "output", 2, "output", flip(vs[vn["output"]]["log"][2]["output"], 40))(d, vs, fs)), mutate("the lease negative control stated as accepted", lambda d, vs, fs: vs[vn["n_lease_beyond_limit_released"]].update( outcome="accept", receipts=vs[vn["streams"]]["receipts"])), mutate("the release-time negative control stated as accepted", lambda d, vs, fs: vs[vn["n_refused_at_release_time"]].update( outcome="accept", receipts=vs[vn["release_time"]]["receipts"])), # §16: revocation. mutate("the revocations withheld from an accepted log", lambda d, vs, fs: vs[vn["revoked"]].update(revocations=[])), mutate("a revocation's instant moved a second later, its signature kept", lambda d, vs, fs: vs[vn["revoked"]]["revocations"].__setitem__( 0, revocation_moved(vs[vn["revoked"]]["revocations"][0], 1))), mutate("a revocation's authority dropped from the authorities", lambda d, vs, fs: vs[vn["revoked"]]["authorities"].pop(0)), mutate("a revoked request stated as stale", lambda d, vs, fs: vs[vn["revoked"]]["receipts"][2].update(decision="DeniedStale")), mutate("the per-content groups' refusal stated as a release", lambda d, vs, fs: vs[vn["streams_per_content"]]["receipts"][3].update(decision="Granted")), mutate("the per-content groups' log given only the film's list", lambda d, vs, fs: vs[vn["streams_per_content"]].update(lists=["film"])), # §4.6: a chain of lists. mutate("the extending list withheld from the chain's log", lambda d, vs, fs: vs[vn["extended"]].update(lists=["channel"])), mutate("the extending list's named list changed, its signature kept", lambda d, vs, fs: d["lists"].update(channel_next=extends_moved(d["lists"]["channel_next"]))), mutate("a release across the chain stated as refused", lambda d, vs, fs: vs[vn["extended"]]["receipts"][3].update(decision="DeniedUnknownKey")), # §17: checkpoints. mutate("a stated checkpoint identity", lambda d, vs, fs: vs[vn["checkpointed"]]["checkpoints"].__setitem__(0, "00" * 32)), mutate("the first checkpoint's instant moved a second, its signature kept", lambda d, vs, fs: vs[vn["checkpointed"]]["log"][7].update(at=vs[vn["checkpointed"]]["log"][7]["at"] + 1)), mutate("the log gone on from its first checkpoint, given without it", lambda d, vs, fs: (vs[vn["from_checkpoint"]]["log"].pop(0), vs[vn["from_checkpoint"]].pop("checkpoints"))), mutate("a decision after a checkpoint stated as the whole log's", lambda d, vs, fs: vs[vn["checkpointed"]]["receipts"][10].update(decision="DeniedRenewalLapsed")), ] def extends_moved(h): """A list of version 3 with one bit of its `extends` changed and nothing else (§4.1).""" b = bytearray(bytes.fromhex(h)) r = Reader(bytes(b)) r.take(5) r.string() r.string() r.take(32 if r.int(1) else 0) at = r.at + 8 b[at] ^= 1 return bytes(b).hex() def revocation_moved(h, by): """A revocation with its `from` moved by `by` seconds and nothing else (§16.1).""" b = bytes.fromhex(h) at = 4 + 1 + 1 + 16 t = int.from_bytes(b[at:at + 8], "big") + by return (b[:at] + t.to_bytes(8, "big") + b[at + 8:]).hex() def release_moved(h, by): """A list of version 2 with its `not_before` moved by `by` seconds and nothing else.""" b = bytes.fromhex(h) r = Reader(b) r.take(5) r.string() r.string() r.take(32 if r.int(1) else 0) at = r.at + 8 t = int.from_bytes(b[at:at + 8], "big") + by return (b[:at] + t.to_bytes(8, "big") + b[at + 8:]).hex() 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 [] ok = (len(SCHEMES), len(DELIVERIES), len(DECISIONS), len(REASONS)) == (7, 3, 19, 20) and len(RULE_DECISIONS) == 4 and len(CARRIES) == 3 print(("PASS" if ok else "FAIL") + " the extension's tables read: %d schemes, %d deliveries, %d decisions, %d reasons" % (len(SCHEMES), len(DELIVERIES), len(DECISIONS), len(REASONS))) fails += [] if ok else ["the extension's tables do not read"] with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as f: doc = json.load(f) named = sorted({c["file"] for v in doc["vectors"] for c in v["cpix"]}) files = {} for name in named: with open(os.path.join(corpus_dir, name), "rb") as f: files[name] = f.read() fails += check(doc, files) extra = sorted(set(os.listdir(corpus_dir)) - set(named) - {"vectors.json", "provider.json", "README.md"}) print(("PASS" if not extra else "FAIL") + " no file but vectors.json, provider.json, README.md and the CPIX documents the vectors name") fails += ["an extra file: %s" % n for n in extra] seen = {v.get("reason") for v in doc["vectors"]} 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] decided = {r["decision"] for v in doc["vectors"] for r in v.get("receipts", [])} undecided = [d for d in DECISIONS if d not in decided] print(("PASS" if not undecided else "FAIL") + " every decision of §7.2 appears in an accepted log") fails += ["no accepted receipt is %s" % d for d in undecided] for label, d, fs in mutations(doc, files): caught = check(d, fs, quiet=True) print(("PASS" if caught else "FAIL") + " mutated copy fails: %s%s" % (label, " (%s)" % caught[0][:100] if caught else "")) if not caught: fails.append("a mutated copy verifies: %s" % label) # §15: the key provider. with open(os.path.join(corpus_dir, "provider.json"), encoding="utf-8") as f: prov = json.load(f) ok = len(PROVISIONS) == 9 and len(PROVIDER_REASONS) == 13 print(("PASS" if ok else "FAIL") + " §15's tables read: %d decisions, %d reasons" % (len(PROVISIONS), len(PROVIDER_REASONS))) fails += [] if ok else ["§15's tables do not read"] bad = provider_check(prov) print(("PASS" if not bad else "FAIL") + " key provider: %d exchanges reach the outcome the corpus states" % len(prov["cases"])) fails += bad stated = [c["outcome"] for c in prov["cases"]] missing = [r for r in PROVIDER_REASONS if {"refuse": r} not in stated] + [ d for d in PROVISIONS if not any(o.get("accept", {}).get("decision") == d for o in stated)] print(("PASS" if not missing else "FAIL") + " every decision of §15.3 and every reason of §15.5 appears in the provider's cases") fails += ["no provider case gives %s" % x for x in missing] for label, d in provider_mutations(prov): caught = provider_check(d) print(("PASS" if caught else "FAIL") + " mutated copy fails: %s%s" % (label, " (%s)" % caught[0][:100] if caught else "")) if not caught: fails.append("a mutated copy verifies: %s" % label) global DROPPED for w in WEAKENINGS: DROPPED = w caught = check(copy.deepcopy(doc), files, quiet=True) or provider_check(copy.deepcopy(prov)) DROPPED = None print(("PASS" if caught else "FAIL") + " weakened verifier caught: without its %s check%s" % (w, " (%s)" % caught[0][:100] if caught else "")) if not caught: fails.append("a verifier without its %s check passes the corpus" % w) for f in fails: print("FAIL " + f) print(("PASS" if not fails else "FAIL") + " key-release corpus: %d logs, %d CPIX documents, %d provider exchanges" % (len(doc["vectors"]), len(files), len(prov["cases"]))) return 1 if fails else 0 if __name__ == "__main__": if len(sys.argv) != 2: print("usage: key_release_verify.py ", file=sys.stderr) sys.exit(2) sys.exit(main(sys.argv[1]))