open-standards/wai/live-conformance/key_release_verify.py
Transaction Science 86d8d6896d A key-release commitment list can state a release time before which no key is released
A WKL1 list of version 2 carries not_before after issued, signed by the key holder; a list
without one keeps version 1 and its bytes. §7.2 check 5 refuses a request decided before the
release time with its own decision, DeniedNotReleased (code 14), whatever the entitlement
grants: after the entitlement's validity, freshness, replay and the keys, before its class,
window and allowance. Later checks renumber and decide as before; no earlier code changes.
The corpus gains a version-2 list derived from the film's CPIX document, the log
release_time and two negative controls; key_release_verify.py reads both versions and
decides check 5. Earlier vectors and lists are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:01:50 -05:00

894 lines
40 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. 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 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);
- the commitment list (§4): its layout of version 1 or 2 (with the content's release time),
rules, signature and identity, and its derivation from a CPIX document (§4.4);
- 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).
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 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.
python3 key_release_verify.py <live-conformance/keys>
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 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
ID_DOMAIN = b"wai:video-keys-receipt-id\x01"
TICKET_DOMAIN = b"wai:video-keys-ticket\x01" # §9.3
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
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()}
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):
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
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")
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, "identity": b3(LIST_ID_DOMAIN, b), "signer": pk, "signer_id": sid}
# ---------------------------------------------------------------- §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):
"""§4.4 steps 1 to 5: (content_id, digest, keys by KID), or Refuse."""
# Step 1.
head = data[:200].decode("utf-8", "replace")
m = re.match(r"^?<\?xml[^>]*encoding\s*=\s*[\"']([^\"']+)[\"']", head)
if b"<!DOCTYPE" in data or (m and m.group(1).lower() != "utf-8"):
raise Refuse("cpix_malformed")
try:
data.decode("utf-8")
root = ET.fromstring(data)
except (ET.ParseError, UnicodeDecodeError):
raise Refuse("cpix_malformed")
if root.tag != q("CPIX"):
raise Refuse("cpix_malformed")
digest = sha(data)
# Step 2.
content = root.get("contentId")
if content is None:
raise Refuse("cpix_unsupported")
# Step 3.
keys = {}
lists = root.findall(q("ContentKeyList"))
for ck in lists[0].findall(q("ContentKey")) if lists else []:
kid = uuid(ck.get("kid"))
if kid is None or kid in keys:
raise Refuse("cpix_malformed")
if ck.get("contentId") is not None or ck.get("dependsOnKey") is not None or ck.get("commonEncryptionScheme") not in SCHEMES:
raise Refuse("cpix_unsupported")
scheme = ck.get("commonEncryptionScheme")
data_el = ck.find(q("Data"))
secret = data_el.find(q("Secret", PSKC)) if data_el is not None else None
plain = secret.find(q("PlainValue", PSKC)) if secret is not None else None
if plain is None or secret.find(q("EncryptedValue", PSKC)) is not None:
raise Refuse("cpix_unsupported")
key = b64(plain.text)
if key is None or len(key) != SCHEMES[scheme][1]:
raise Refuse("cpix_malformed")
keys[kid] = {"kid": kid, "scheme": scheme, "commit": commitment(key), "period": None}
if not keys:
raise Refuse("cpix_malformed")
# Step 4.
pl = root.find(q("ContentKeyPeriodList"))
periods = pl.findall(q("ContentKeyPeriod")) if pl is not None else []
named = {}
ul = root.find(q("ContentKeyUsageRuleList"))
for rule in ul.findall(q("ContentKeyUsageRule")) if ul is not None else []:
kid = uuid(rule.get("kid"))
if kid not in keys:
raise Refuse("cpix_malformed")
for f in rule.findall(q("KeyPeriodFilter")):
pid = f.get("periodId")
hits = [i for i, p in enumerate(periods) if p.get("id") == pid and pid is not None]
if not hits:
raise Refuse("cpix_malformed")
if kid in named and named[kid] != hits[0]:
raise Refuse("cpix_unsupported")
named[kid] = hits[0]
# Step 5.
for kid in sorted(keys):
if kid in named:
keys[kid]["period"] = cpix_period(periods[named[kid]])
return content, digest, keys
def cpix_period(p):
if any(p.get(a) is not None for a in ("startOffset", "endOffset", "duration")):
raise Refuse("cpix_unsupported")
index = p.get("index")
if index is not None:
if not re.match(r"^[0-9]+$", index) or int(index) > 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 or r.int(1) != 1:
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), "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
e["holds"] = (bool(e["class"]) and e["nb"] <= e["na"] and (e["offline"] > 0 or e["renewals"] == 0)
and ED.verifies(e["authority"], ENT_DOMAIN + b[:end], sig))
return e
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"])]
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 set(o) != set(want) or o["form"] != 2 or not is_int(o["form"]):
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")
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, 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]), 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"
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, r, lst, earlier):
"""§7.2 and §7.3: (decision, licence_expiry, renewal) of receipt r's request."""
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
if abs(issued - r["request_t"]) > FRESH:
return denied("DeniedStale") # check 2
if any(p["proof"] == proof for p in earlier):
return denied("DeniedReplayed") # check 3
if lst is None or any(k["kid"] not in lst["keys"] for k in r["keys"]):
return denied("DeniedUnknownKey") # check 4
if lst["not_before"] is not None and issued < lst["not_before"]:
return denied("DeniedNotReleased") # check 5
if e["class"] != lst["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 = lst["keys"][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 = None
for p in earlier: # check 11
if p["identity"] != r["renews"]:
continue
pe = read_entitlement(p["entitlement"]) if p["entitlement"] is not None else None
if (p["released"] and p["kind"] != "streaming" and p["content_id"] == r["content_id"]
and [k["kid"] for k in p["keys"]] == kids and pe is not None
and pe["session"] == e["session"] and pe["holder"] == e["holder"]):
prior = p
break
if prior is None:
return denied("DeniedRenewalUnknown")
if (prior["renewal"] or 0) + 1 > e["renewals"]:
return denied("DeniedRenewalsExhausted") # check 12
if prior["licence_expiry"] is None or issued > prior["licence_expiry"]:
return denied("DeniedRenewalLapsed") # check 13
return "Granted", expiry, (prior["renewal"] or 0) + 1
# ---------------------------------------------------------------- §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)
# ---------------------------------------------------------------- §10 verification
def verify(v, doc, files):
"""§10.2 over vector v: what the corpus states for it."""
try:
return {"outcome": "accept", "receipts": verify_log(v, doc, files)}
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"]]
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["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}
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 = [], []
for i, o in enumerate(v["log"]): # step 3
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)
lst = None
if r["list"] is not None:
lst = by_id.get(r["list"])
if lst is None:
raise Refuse("list_unknown", at)
if lst["content"] != r["content_id"]:
raise Refuse("list_mismatch", at)
elif r["content_id"] in by_content:
raise Refuse("list_mismatch", at)
if lst is not None:
if r["cpix_sha256"] != lst["cpix"] or r["required"] != lst["required"]:
raise Refuse("list_mismatch", at)
for k in r["keys"]:
lk = lst["keys"].get(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)
d, expiry, renewal = decide(authorities, r, lst, log)
if d != r["decision"] or (r["released"] and r["kind"] != "streaming"
and (r["licence_expiry"], r["renewal"]) != (expiry, renewal)):
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"]})
log.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
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"<pskc:PlainValue>", b"<pskc:PlainValue>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")),
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"])),
]
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, 14, 16) 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", "README.md"})
print(("PASS" if not extra else "FAIL") + " no file but vectors.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)
for f in fails:
print("FAIL " + f)
print(("PASS" if not fails else "FAIL") + " key-release corpus: %d logs, %d CPIX documents" % (len(doc["vectors"]), len(files)))
return 1 if fails else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: key_release_verify.py <live-conformance/keys>", file=sys.stderr)
sys.exit(2)
sys.exit(main(sys.argv[1]))