jwp-receipts registers the consumption class (§2.4.13) with its step 4 and step 6 reasons and its authority; live-rooms gains §3.8 and the ai_usage grant attribute; http-delivery §5.6 maps the signed statement onto the Content-Usage and TDMRep fields; moq-streaming-format §4 adds the wai_ai_usage catalog field. The registry export lists wai.ai.usage, WAU1, wai_ai_usage and the proposed WAI-Usage-Claim field. ai_usage_verify.py is the corpus's second implementation, and claim_verify.py reads the class's steps 1 to 4. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1228 lines
47 KiB
Python
1228 lines
47 KiB
Python
#!/usr/bin/env python3
|
|
"""Independent verifier of the AI usage corpus (extensions/ai-usage.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. A room's
|
|
descriptor and grants are read by the live-rooms corpus's own second implementation,
|
|
live_rooms_verify.py (live-rooms §3, §4), written from its text alone. Everything else is here:
|
|
|
|
- RFC 9651's Dictionary parser, as §3.3 reads it, and the vocabularies' preferences (§3);
|
|
- the canonical form (RFC 8785 over the subset jwp-receipts §2.4.3 allows) and the digest;
|
|
- the envelope's binding (§4.1): the content hash, the group receipt's legacy payload and its
|
|
signature, the object's inclusion proof (jwp-receipts §2.1-§2.3), the manifest's member;
|
|
- the room usage record (§4.2) and a grant's terms (§4.3);
|
|
- the HTTP fields (§4.4) and the catalog field (§4.5);
|
|
- the precedence rule (§5);
|
|
- the consumption claim (§6): jwp-receipts §3.1 steps 1 to 5 for its class, then §6.3;
|
|
- the gate (§7).
|
|
|
|
For every case it requires the outcome the corpus states; of the corpus, that every reason of
|
|
§9 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; and it drops each of its own checks in turn and
|
|
requires the corpus to catch the weakened verifier.
|
|
|
|
python3 ai_usage_verify.py <live-conformance/usage>
|
|
|
|
Exit status 0 only if every case verifies, every mutated copy fails and every weakened
|
|
verifier fails. Python 3.8 or later; standard library only.
|
|
"""
|
|
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
|
|
import live_events_verify as LEV # noqa: E402
|
|
import live_rooms_verify as LRV # noqa: E402
|
|
import _ed25519 as ED # noqa: E402
|
|
import _blake3 as B3 # noqa: E402
|
|
|
|
EXTENSION = os.path.join(HERE, "..", "extensions", "ai-usage.md")
|
|
|
|
AIPREF_REV = "draft-ietf-aipref-vocab-08" # §3.2
|
|
CAWG_REV = "1.1" # §3.4
|
|
TDMREP_REV = "CG-FINAL-tdmrep-20240510" # §3.5
|
|
LABELS = ("train-ai", "ai-use", "search") # draft-ietf-aipref-vocab-08 §6.1
|
|
CAWG_KEYS = ("cawg.data_mining", "cawg.ai_inference", "cawg.ai_generative_training", "cawg.ai_training")
|
|
TDM_TERM = "tdm-reservation"
|
|
VOCABULARIES = ("aipref", "cawg", "tdmrep")
|
|
ROOM_MAGIC = b"WAU1"
|
|
ROOM_DOMAIN = b"wai:ai-usage-room\x01" # §4.2
|
|
OBJECT_DOMAIN = b"moq-jwp:object\x01" # jwp-receipts §2.1
|
|
LEAF, NODE = b"moq-jwp:merkle-leaf\x01", b"moq-jwp:merkle-node\x01"
|
|
GROUP_SIGN = b"moq-jwp:group-sign\x01" # jwp-receipts §2.3
|
|
CLAIM_SIGN, CLAIM_LINK = b"moq-jwp:claim\x01", b"moq-jwp:claim-link\x01"
|
|
MAX_INT = 2 ** 53 - 1
|
|
|
|
# A check named here is left out: the verifier weakened on purpose, to show the corpus catches it.
|
|
SKIP = set()
|
|
|
|
|
|
class Refuse(Exception):
|
|
def __init__(self, reason, step=None):
|
|
super().__init__(reason)
|
|
self.reason, self.step = reason, step
|
|
|
|
|
|
def sha(*parts):
|
|
h = hashlib.sha256()
|
|
for p in parts:
|
|
h.update(p)
|
|
return h.digest()
|
|
|
|
|
|
def b3(b):
|
|
return B3.blake3(b)
|
|
|
|
|
|
def reasons_of_extension():
|
|
"""§9's codes, read from the text."""
|
|
with open(EXTENSION, encoding="utf-8") as f:
|
|
text = f.read()
|
|
at = text.index("\n## 9. Reasons")
|
|
end = text.index("\n## ", at + 1)
|
|
return [line.split("`")[1] for line in text[at:end].splitlines() if line.startswith("| `")]
|
|
|
|
|
|
REASONS = reasons_of_extension()
|
|
|
|
|
|
# ---------------------------------------------------------------- JSON and the canonical form
|
|
|
|
class Number:
|
|
"""A JSON number that is not an unsigned integer: never a value a statement or claim holds."""
|
|
|
|
|
|
def strict_json(text):
|
|
"""JSON in which no object names a member twice (§3.6); a number that is not a plain
|
|
unsigned integer is kept as a Number marker. ValueError when it does not read."""
|
|
def pairs(ps):
|
|
d = {}
|
|
for k, v in ps:
|
|
if k in d:
|
|
raise ValueError("repeated member " + k)
|
|
d[k] = v
|
|
return d
|
|
|
|
def no_constant(c):
|
|
raise ValueError(c)
|
|
|
|
v = json.loads(text, object_pairs_hook=pairs, parse_constant=no_constant,
|
|
parse_float=lambda s: Number(), parse_int=lambda s: Number() if s.startswith("-") else int(s))
|
|
|
|
def walk(x, level):
|
|
# jwp-receipts §3.1 step 1: no escape that leaves a surrogate unpaired, at most 32 levels.
|
|
if isinstance(x, str):
|
|
if any("\ud800" <= c <= "\udfff" for c in x):
|
|
raise ValueError("an unpaired surrogate")
|
|
elif isinstance(x, (list, dict)):
|
|
if level + 1 > 32:
|
|
raise ValueError("nested too deeply")
|
|
for k in (x if isinstance(x, dict) else []):
|
|
walk(k, level)
|
|
for y in (x.values() if isinstance(x, dict) else x):
|
|
walk(y, level + 1)
|
|
walk(v, 0)
|
|
return v
|
|
|
|
|
|
def is_uint(v):
|
|
return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= MAX_INT
|
|
|
|
|
|
def jcs(v):
|
|
"""RFC 8785 over strings, unsigned integers, null, booleans, arrays and objects."""
|
|
if v is None:
|
|
return "null"
|
|
if v is True or v is False:
|
|
return "true" if v else "false"
|
|
if isinstance(v, int) and not isinstance(v, Number):
|
|
if not 0 <= v <= MAX_INT:
|
|
raise ValueError("an integer outside the subset")
|
|
return str(v)
|
|
if isinstance(v, str):
|
|
return json.dumps(v, ensure_ascii=False)
|
|
if isinstance(v, list):
|
|
return "[" + ",".join(jcs(x) for x in v) + "]"
|
|
if isinstance(v, dict):
|
|
keys = sorted(v, key=lambda k: k.encode("utf-16-be"))
|
|
return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + jcs(v[k]) for k in keys) + "}"
|
|
raise ValueError("a value outside the subset")
|
|
|
|
|
|
# ---------------------------------------------------------------- §3.3 RFC 9651 Dictionary
|
|
|
|
class SFFail(Exception):
|
|
pass
|
|
|
|
|
|
LCALPHA = set("abcdefghijklmnopqrstuvwxyz")
|
|
DIGIT = set("0123456789")
|
|
ALPHA = LCALPHA | set("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
|
TCHAR = ALPHA | DIGIT | set("!#$%&'*+-.^_`|~")
|
|
B64 = ALPHA | DIGIT | set("+/")
|
|
|
|
|
|
class SF:
|
|
def __init__(self, s):
|
|
self.s, self.i = s, 0
|
|
|
|
def peek(self):
|
|
return self.s[self.i] if self.i < len(self.s) else None
|
|
|
|
def take(self):
|
|
c = self.peek()
|
|
if c is None:
|
|
raise SFFail()
|
|
self.i += 1
|
|
return c
|
|
|
|
def need(self, c):
|
|
if self.peek() != c:
|
|
raise SFFail()
|
|
self.i += 1
|
|
|
|
def spaces(self):
|
|
while self.peek() == " ":
|
|
self.i += 1
|
|
|
|
def ows(self):
|
|
while self.peek() in (" ", "\t"):
|
|
self.i += 1
|
|
|
|
def key(self): # RFC 9651 §4.2.3.3
|
|
c = self.peek()
|
|
if c is None or not (c in LCALPHA or c == "*"):
|
|
raise SFFail()
|
|
out = ""
|
|
while self.peek() is not None and (self.peek() in LCALPHA or self.peek() in DIGIT or self.peek() in "_-.*"):
|
|
out += self.take()
|
|
return out
|
|
|
|
def parameters(self): # §4.2.3.2
|
|
while self.peek() == ";":
|
|
self.i += 1
|
|
self.spaces()
|
|
self.key()
|
|
if self.peek() == "=":
|
|
self.i += 1
|
|
self.bare()
|
|
|
|
def number(self): # §4.2.4; True for an Integer
|
|
kind, digits = "integer", ""
|
|
if self.peek() == "-":
|
|
self.i += 1
|
|
if self.peek() is None or self.peek() not in DIGIT:
|
|
raise SFFail()
|
|
while self.peek() is not None:
|
|
c = self.take()
|
|
if c in DIGIT:
|
|
digits += c
|
|
elif kind == "integer" and c == ".":
|
|
if len(digits) > 12:
|
|
raise SFFail()
|
|
digits += c
|
|
kind = "decimal"
|
|
else:
|
|
self.i -= 1
|
|
break
|
|
if kind == "integer" and len(digits) > 15:
|
|
raise SFFail()
|
|
if kind == "decimal" and len(digits) > 16:
|
|
raise SFFail()
|
|
if kind == "decimal":
|
|
if digits.endswith(".") or len(digits.split(".")[1]) > 3:
|
|
raise SFFail()
|
|
return kind == "integer"
|
|
|
|
def string(self): # §4.2.5
|
|
self.need('"')
|
|
while self.peek() is not None:
|
|
c = self.take()
|
|
if c == "\\":
|
|
n = self.take()
|
|
if n not in ('"', "\\"):
|
|
raise SFFail()
|
|
elif c == '"':
|
|
return
|
|
elif ord(c) < 0x20 or ord(c) >= 0x7F:
|
|
raise SFFail()
|
|
raise SFFail()
|
|
|
|
def token(self): # §4.2.6
|
|
c = self.peek()
|
|
if c is None or not (c in ALPHA or c == "*"):
|
|
raise SFFail()
|
|
out = ""
|
|
while self.peek() is not None and (self.peek() in TCHAR or self.peek() in ":/"):
|
|
out += self.take()
|
|
return out
|
|
|
|
def byte_sequence(self): # §4.2.7, with §3.3's reading of its content
|
|
self.need(":")
|
|
end = self.s.find(":", self.i)
|
|
if end < 0:
|
|
raise SFFail()
|
|
content, self.i = self.s[self.i:end], end + 1
|
|
body = content.rstrip("=")
|
|
pad = len(content) - len(body)
|
|
if pad > 2 or any(c not in B64 for c in body) or len(body) % 4 == 1:
|
|
raise SFFail()
|
|
|
|
def boolean(self): # §4.2.8
|
|
self.need("?")
|
|
if self.peek() not in ("0", "1"):
|
|
raise SFFail()
|
|
self.i += 1
|
|
|
|
def date(self): # §4.2.9
|
|
self.need("@")
|
|
if not self.number():
|
|
raise SFFail()
|
|
|
|
def display(self): # §4.2.10
|
|
self.need("%")
|
|
self.need('"')
|
|
out = b""
|
|
while self.peek() is not None:
|
|
c = self.take()
|
|
if ord(c) < 0x20 or ord(c) >= 0x7F:
|
|
raise SFFail()
|
|
if c == "%":
|
|
h = self.s[self.i:self.i + 2]
|
|
if len(h) != 2 or any(x not in "0123456789abcdef" for x in h):
|
|
raise SFFail()
|
|
out += bytes.fromhex(h)
|
|
self.i += 2
|
|
elif c == '"':
|
|
try:
|
|
out.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
raise SFFail()
|
|
return
|
|
else:
|
|
out += c.encode()
|
|
raise SFFail()
|
|
|
|
def bare(self): # §4.2.3.1; ("token", text) or ("other",)
|
|
c = self.peek()
|
|
if c is None:
|
|
raise SFFail()
|
|
if c == "-" or c in DIGIT:
|
|
self.number()
|
|
return ("other",)
|
|
if c == '"':
|
|
self.string()
|
|
return ("other",)
|
|
if c in ALPHA or c == "*":
|
|
return ("token", self.token())
|
|
if c == ":":
|
|
self.byte_sequence()
|
|
return ("other",)
|
|
if c == "?":
|
|
self.boolean()
|
|
return ("other",)
|
|
if c == "@":
|
|
self.date()
|
|
return ("other",)
|
|
if c == "%":
|
|
self.display()
|
|
return ("other",)
|
|
raise SFFail()
|
|
|
|
def item(self): # §4.2.3
|
|
v = self.bare()
|
|
self.parameters()
|
|
return v
|
|
|
|
def inner_list(self): # §4.2.1.2
|
|
self.need("(")
|
|
while self.peek() is not None:
|
|
self.spaces()
|
|
if self.peek() == ")":
|
|
self.i += 1
|
|
self.parameters()
|
|
return ("other",)
|
|
self.item()
|
|
if self.peek() not in (" ", ")"):
|
|
raise SFFail()
|
|
raise SFFail()
|
|
|
|
def dictionary(self): # §4.2.2
|
|
d = {}
|
|
while self.peek() is not None:
|
|
k = self.key()
|
|
if self.peek() == "=":
|
|
self.i += 1
|
|
d[k] = self.inner_list() if self.peek() == "(" else self.item()
|
|
else:
|
|
self.parameters()
|
|
d[k] = ("other",)
|
|
self.ows()
|
|
if self.peek() is None:
|
|
return d
|
|
if self.take() != ",":
|
|
raise SFFail()
|
|
self.ows()
|
|
if self.peek() is None:
|
|
raise SFFail()
|
|
return d
|
|
|
|
|
|
def parse_dictionary(text):
|
|
"""§4.2 for a Dictionary: the members, or None when it fails to parse."""
|
|
if any(ord(c) > 0x7F for c in text):
|
|
return None
|
|
p = SF(text)
|
|
try:
|
|
p.spaces()
|
|
d = p.dictionary()
|
|
p.spaces()
|
|
except SFFail:
|
|
return None
|
|
return d if p.peek() is None else None
|
|
|
|
|
|
def aipref_prefs(value):
|
|
"""draft-ietf-aipref-vocab-08 §6.5: each label's preference, or None when it does not parse."""
|
|
d = parse_dictionary(value)
|
|
if d is None:
|
|
return None
|
|
out = {}
|
|
for label in LABELS:
|
|
m = d.get(label)
|
|
out[label] = ("allow" if m == ("token", "y") else "disallow" if m == ("token", "n") else "unknown")
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------- §3 the statement
|
|
|
|
def cawg_key(k):
|
|
"""§3.4's key form."""
|
|
if not 1 <= len(k.encode()) <= 255:
|
|
return False
|
|
return all(part and all(c in ALPHA or c in DIGIT or c in "_-" for c in part) for part in k.split("."))
|
|
|
|
|
|
def is_policy(s):
|
|
b = s.encode()
|
|
return (8 <= len(b) <= 2048 and all(0x21 <= c <= 0x7E for c in b)
|
|
and (s.startswith("https://") or s.startswith("http://")))
|
|
|
|
|
|
def read_statement(v):
|
|
"""§3.6: the statement as {vocabulary: {term: preference}} with its digest, or Refuse."""
|
|
bad = Refuse("usage_malformed")
|
|
if not isinstance(v, dict) or not v or any(k not in VOCABULARIES for k in v):
|
|
raise bad
|
|
prefs, revisions = {}, []
|
|
if "aipref" in v:
|
|
a = v["aipref"]
|
|
if not isinstance(a, dict) or set(a) != {"revision", "value"}:
|
|
raise bad
|
|
if not isinstance(a["revision"], str) or not isinstance(a["value"], str):
|
|
raise bad
|
|
val = a["value"]
|
|
if "aipref-length" not in SKIP and not 1 <= len(val) <= 1024:
|
|
raise bad
|
|
p = aipref_prefs(val) if "aipref-parse" not in SKIP else (aipref_prefs(val) or {x: "unknown" for x in LABELS})
|
|
if p is None or any(ord(c) > 0x7F for c in val):
|
|
raise bad
|
|
prefs["aipref"] = p
|
|
revisions.append((a["revision"], AIPREF_REV))
|
|
if "cawg" in v:
|
|
c = v["cawg"]
|
|
if not isinstance(c, dict) or set(c) != {"revision", "entries"} or not isinstance(c["revision"], str):
|
|
raise bad
|
|
e = c["entries"]
|
|
if not isinstance(e, dict) or not 1 <= len(e) <= 32:
|
|
raise bad
|
|
p = {}
|
|
for k, x in e.items():
|
|
if not cawg_key(k):
|
|
raise bad
|
|
if "cawg-reserved" not in SKIP and k.startswith("cawg.") and k not in CAWG_KEYS:
|
|
raise bad
|
|
if not isinstance(x, dict) or "use" not in x or any(m not in ("use", "constraint_info") for m in x):
|
|
raise bad
|
|
if x["use"] not in ("allowed", "notAllowed", "constrained"):
|
|
raise bad
|
|
if "constraint_info" in x and not (isinstance(x["constraint_info"], str)
|
|
and 1 <= len(x["constraint_info"].encode()) <= 1024):
|
|
raise bad
|
|
p[k] = "allow" if x["use"] == "allowed" else "disallow"
|
|
prefs["cawg"] = p
|
|
revisions.append((c["revision"], CAWG_REV))
|
|
if "tdmrep" in v:
|
|
t = v["tdmrep"]
|
|
if not isinstance(t, dict) or set(t) != {"revision", "tdm-reservation", "tdm-policy"}:
|
|
raise bad
|
|
if not isinstance(t["revision"], str):
|
|
raise bad
|
|
r = t["tdm-reservation"]
|
|
if "tdm-values" not in SKIP and not (is_uint(r) and r in (0, 1)):
|
|
raise bad
|
|
pol = t["tdm-policy"]
|
|
if not (pol is None or (isinstance(pol, str) and is_policy(pol))):
|
|
raise bad
|
|
prefs["tdmrep"] = {TDM_TERM: "disallow" if r == 1 else "allow"}
|
|
revisions.append((t["revision"], TDMREP_REV))
|
|
if "revisions" not in SKIP and any(got != want for got, want in revisions):
|
|
raise Refuse("usage_revision_unknown")
|
|
canonical = jcs(v).encode()
|
|
if len(canonical) > 4096:
|
|
raise bad
|
|
return {"prefs": prefs, "digest": sha(canonical), "canonical": canonical, "policy": v.get("tdmrep", {}).get("tdm-policy")}
|
|
|
|
|
|
def statement_text(text):
|
|
try:
|
|
v = strict_json(text)
|
|
except ValueError:
|
|
raise Refuse("usage_malformed")
|
|
return read_statement(v)
|
|
|
|
|
|
def statement_canonical(b):
|
|
"""§4.2, §4.3: the bytes read and are the canonical form of what they hold."""
|
|
try:
|
|
text = b.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
raise Refuse("usage_malformed")
|
|
s = statement_text(text)
|
|
if "canonical-bytes" not in SKIP and s["canonical"] != b:
|
|
raise Refuse("usage_malformed")
|
|
return s
|
|
|
|
|
|
def pref(s, vocabulary, term):
|
|
"""The preference a statement gives a term, or None when it does not carry the vocabulary."""
|
|
if vocabulary not in s["prefs"]:
|
|
return None
|
|
return s["prefs"][vocabulary].get(term, "unknown")
|
|
|
|
|
|
def statement_report(s):
|
|
return {"digest": s["digest"].hex(), "preferences": {v: dict(p) for v, p in s["prefs"].items()}}
|
|
|
|
|
|
# ---------------------------------------------------------------- §4.1 the envelope
|
|
|
|
def node(a, b):
|
|
return b3(NODE + a + b)
|
|
|
|
|
|
def group_payload(g):
|
|
"""jwp-receipts §2.3, the legacy payload."""
|
|
b = GROUP_SIGN + g["track_alias"].to_bytes(8, "big") + g["group_id"].to_bytes(8, "big")
|
|
b += bytes.fromhex(g["root_hash"]) + g["leaf_count"].to_bytes(8, "big") + g["joules_total"].to_bytes(8, "big")
|
|
for k in ("group_start", "group_end"):
|
|
e = g[k].encode()
|
|
b += len(e).to_bytes(4, "big") + e
|
|
sid = g["signer_id"].encode()
|
|
return b + bytes.fromhex(g["signer_pubkey"]) + len(sid).to_bytes(4, "big") + sid
|
|
|
|
|
|
def receipts_hold(g, o):
|
|
"""jwp-receipts §3 steps 1-5, the content hash left to §4.1 step 2: the legacy group
|
|
verifies, and the object's unlabelled leaf is included at its position."""
|
|
if "sign_version" in g or "leaf_version" in o:
|
|
return False # a labelled form: no case of this corpus uses one
|
|
if "group-signature" not in SKIP and ED.verify(bytes.fromhex(g["signer_pubkey"]), group_payload(g), bytes.fromhex(g["sig"])):
|
|
return False
|
|
n, p = g["leaf_count"], o["merkle_proof"]
|
|
i = p["leaf_index"]
|
|
depth = 0 if n <= 1 else (n - 1).bit_length()
|
|
if (o["track_alias"], o["group_id"]) != (g["track_alias"], g["group_id"]) or o["object_id"] != i or not i < n \
|
|
or len(p["siblings"]) != depth or p["direction_bits"] != i:
|
|
return False
|
|
cur = b3(LEAF + bytes.fromhex(o["content_hash"]))
|
|
for k, sib in enumerate(p["siblings"]):
|
|
s = bytes.fromhex(sib)
|
|
cur = node(s, cur) if (i >> k) & 1 else node(cur, s)
|
|
return cur == bytes.fromhex(g["root_hash"])
|
|
|
|
|
|
def manifest_of(env):
|
|
"""SPEC §2, §7.1: the manifest of a WAI1 or WAI2 envelope, read with no member named twice."""
|
|
if len(env) < 8 or env[:4] not in (b"WAI1", b"WAI2"):
|
|
return None
|
|
n = int.from_bytes(env[4:8], "little")
|
|
if 8 + n > len(env):
|
|
return None
|
|
try:
|
|
return strict_json(env[8:8 + n].decode("utf-8"))
|
|
except (ValueError, UnicodeDecodeError):
|
|
return None
|
|
|
|
|
|
def hold(env, o, g):
|
|
"""§4.1: the object held, or Refuse."""
|
|
if not receipts_hold(g, o):
|
|
raise Refuse("receipt_invalid")
|
|
h = b3(OBJECT_DOMAIN + env)
|
|
if "content-hash" not in SKIP and h != bytes.fromhex(o["content_hash"]):
|
|
raise Refuse("usage_unbound")
|
|
m = manifest_of(env)
|
|
if not isinstance(m, dict):
|
|
raise Refuse("usage_malformed")
|
|
s = read_statement(m["ai_usage"]) if "ai_usage" in m else None
|
|
live = m.get("live")
|
|
lt = live.get("t") if isinstance(live, dict) else None
|
|
t = lt if isinstance(lt, int) and not isinstance(lt, bool) and 0 <= lt < 2 ** 64 else None
|
|
return {"hash": h, "statement": s, "t": t}
|
|
|
|
|
|
# ---------------------------------------------------------------- §4.2 the room, §4.3 terms
|
|
|
|
def read_record(b):
|
|
"""§4.2's reading."""
|
|
if len(b) < 4:
|
|
raise Refuse("truncated")
|
|
if b[:4] != ROOM_MAGIC:
|
|
raise Refuse("malformed")
|
|
if len(b) < 5:
|
|
raise Refuse("truncated")
|
|
if b[4] != 1:
|
|
raise Refuse("unknown_version")
|
|
if len(b) < 5 + 32 + 32 + 8 + 2:
|
|
raise Refuse("truncated")
|
|
room, prev = b[5:37], b[37:69]
|
|
effective = int.from_bytes(b[69:77], "little")
|
|
n = int.from_bytes(b[77:79], "little")
|
|
if len(b) < 79 + n + 64:
|
|
raise Refuse("truncated")
|
|
if len(b) > 79 + n + 64:
|
|
raise Refuse("malformed")
|
|
st = statement_canonical(b[79:79 + n])
|
|
return {"room": room, "prev": prev, "effective": effective, "statement": st, "body": b[:79 + n],
|
|
"sig": b[79 + n:], "digest": sha(b[:79 + n])}
|
|
|
|
|
|
def check_records(d, records):
|
|
"""§4.2 over a room's records: the list, or (index, reason)."""
|
|
out = []
|
|
for i, b in enumerate(records):
|
|
try:
|
|
r = read_record(b)
|
|
except Refuse as e:
|
|
return None, (i, e.reason)
|
|
if "record-room" not in SKIP and r["room"] != d["room"]:
|
|
return None, (i, "wrong_room")
|
|
bad = ED.verify(d["authority"], ROOM_DOMAIN + r["body"], r["sig"])
|
|
if bad and "record-signature" not in SKIP:
|
|
return None, (i, bad)
|
|
if out:
|
|
linked = r["prev"] == out[-1]["digest"] and r["effective"] > out[-1]["effective"]
|
|
else:
|
|
linked = r["prev"] == bytes(32) and r["effective"] == 0
|
|
if not linked and "record-chain" not in SKIP:
|
|
return None, (i, "room_usage_unlinked")
|
|
out.append(r)
|
|
return out, None
|
|
|
|
|
|
def terms_of(g):
|
|
"""§4.3: None for no terms, the statement, or the string "grant_usage_malformed"."""
|
|
attrs = dict(LRV.read_attrs(g["attrs"]) or [])
|
|
if "ai_usage" not in attrs:
|
|
return None
|
|
try:
|
|
return statement_canonical(attrs["ai_usage"].encode())
|
|
except Refuse:
|
|
return "grant_usage_malformed"
|
|
|
|
|
|
def check_room(case):
|
|
d = LRV.read_descriptor(bytes.fromhex(case["descriptor"]))
|
|
grants, gref = LRV.check_grants(d, [bytes.fromhex(x) for x in case["grants"]])
|
|
records, rref = check_records(d, [bytes.fromhex(x) for x in case["records"]])
|
|
return {"d": d, "grants": grants or [], "grants_refused": gref, "records": records, "records_refused": rref}
|
|
|
|
|
|
def records_at(room, t):
|
|
"""§4.2: the record in effect at t, every record where t is None; none when refused."""
|
|
rs = room["records"] or []
|
|
if t is None:
|
|
return rs
|
|
if "record-in-effect" in SKIP:
|
|
return rs[:1]
|
|
at = [r for r in rs if r["effective"] <= t]
|
|
return at[-1:]
|
|
|
|
|
|
def room_outcome(case):
|
|
r = check_room(case)
|
|
terms = []
|
|
for g in r["grants"]:
|
|
t = terms_of(g)
|
|
terms.append({"grant": g["digest"].hex(), "principal": g["principal"].hex(),
|
|
"terms": None if t is None else t if isinstance(t, str) else t["digest"].hex()})
|
|
ref = lambda e: {"index": e[0], "reason": e[1]} # noqa: E731
|
|
out = {"grants": ref(r["grants_refused"]) if r["grants_refused"] else "accept",
|
|
"records": ref(r["records_refused"]) if r["records_refused"] else "accept", "terms": terms}
|
|
if r["records"] is not None:
|
|
S = 1000000
|
|
out["in_effect"] = [{"t": t, "statements": [x["statement"]["digest"].hex() for x in records_at(r, t)]}
|
|
for t in (0, 59 * S, 60 * S, 3600 * S, None)]
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------- §4.4 fields, §4.5 catalog
|
|
|
|
def check_fields(signed, fields):
|
|
"""§4.4: the unsigned vocabularies, or Refuse."""
|
|
lines = lambda n: [v for k, v in fields if k.lower() == n.lower()] # noqa: E731
|
|
trim = lambda s: s.strip(" \t") # noqa: E731
|
|
unsigned = []
|
|
usage = lines("Content-Usage")
|
|
if usage:
|
|
if signed is None or "aipref" not in signed["prefs"]:
|
|
unsigned.append("aipref")
|
|
elif "field-meaning" not in SKIP and aipref_prefs(", ".join(usage)) != signed["prefs"]["aipref"]:
|
|
raise Refuse("header_conflicts")
|
|
res, pol = lines("tdm-reservation"), lines("tdm-policy")
|
|
if res or pol:
|
|
if signed is None or "tdmrep" not in signed["prefs"]:
|
|
unsigned.append("tdmrep")
|
|
else:
|
|
want = "1" if signed["prefs"]["tdmrep"][TDM_TERM] == "disallow" else "0"
|
|
if len(res) > 1 or len(pol) > 1:
|
|
raise Refuse("header_conflicts")
|
|
if res and "field-tdm" not in SKIP and trim(res[0]) != want:
|
|
raise Refuse("header_conflicts")
|
|
if pol and trim(pol[0]) != signed["policy"]:
|
|
raise Refuse("header_conflicts")
|
|
return unsigned
|
|
|
|
|
|
def check_catalog(text, objects):
|
|
"""§4.5."""
|
|
try:
|
|
e = strict_json(text)
|
|
except ValueError:
|
|
raise Refuse("catalog_conflicts")
|
|
if not isinstance(e, dict) or "wai_ai_usage" not in e:
|
|
return
|
|
try:
|
|
s = read_statement(e["wai_ai_usage"])
|
|
except Refuse:
|
|
raise Refuse("catalog_conflicts")
|
|
if "catalog-compare" not in SKIP and any(o["statement"] and o["statement"]["digest"] != s["digest"] for o in objects):
|
|
raise Refuse("catalog_conflicts")
|
|
|
|
|
|
# ---------------------------------------------------------------- §5 precedence
|
|
|
|
def governing(o, room):
|
|
"""§5.1."""
|
|
out = [o["statement"]] if o["statement"] else []
|
|
if room is not None and o["room"]:
|
|
out += [r["statement"] for r in records_at(room, o["t"])]
|
|
return out
|
|
|
|
|
|
def preference(vocabulary, term, statements, terms):
|
|
"""§5.2 for one use; None outside the vocabulary."""
|
|
if term is None:
|
|
return None
|
|
if terms is not None and "terms" not in SKIP:
|
|
p = pref(terms, vocabulary, term)
|
|
if p in ("allow", "disallow"):
|
|
return p
|
|
ps = [pref(s, vocabulary, term) for s in statements]
|
|
if "disallow" in ps and "most-restrictive" not in SKIP:
|
|
return "disallow"
|
|
if "allow" in ps:
|
|
return "allow"
|
|
return "unknown"
|
|
|
|
|
|
def combine(prefs):
|
|
"""§5.2's last paragraph; None for a disallow."""
|
|
if "disallow" in prefs:
|
|
return None
|
|
if "allow" in prefs:
|
|
return "allowed"
|
|
if all(p is None for p in prefs):
|
|
return "outside"
|
|
return "unknown"
|
|
|
|
|
|
# ---------------------------------------------------------------- §6 the claim
|
|
|
|
ENVELOPE = ("kind", "class", "subject_type", "subject", "seq", "issued_at", "body", "signer_pubkey", "signer_id",
|
|
"parent_receipt_hash", "sig")
|
|
BODY = ("objects", "usage", "seen", "grant")
|
|
B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
|
|
|
|
def did_key(pk):
|
|
"""jwp-receipts §7.1: did:key:z and base58btc of 0xed 0x01 and the key."""
|
|
b = b"\xed\x01" + pk
|
|
n = int.from_bytes(b, "big")
|
|
out = ""
|
|
while n:
|
|
n, r = divmod(n, 58)
|
|
out = B58[r] + out
|
|
return "did:key:z" + "1" * (len(b) - len(b.lstrip(b"\x00"))) + out
|
|
|
|
|
|
def is_hex(v, n):
|
|
return isinstance(v, str) and len(v) == 2 * n and all(c in "0123456789abcdef" for c in v)
|
|
|
|
|
|
def is_date_time(s):
|
|
"""jwp-receipts §2.4.3's date-time."""
|
|
import re
|
|
m = re.fullmatch(r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-]([0-9]{2}):([0-9]{2}))", s)
|
|
if not m:
|
|
return False
|
|
y, mo, d, h, mi, se = (int(m.group(i)) for i in range(1, 7))
|
|
leap = y % 4 == 0 and (y % 100 != 0 or y % 400 == 0)
|
|
days = [31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
|
if not (1 <= mo <= 12 and 1 <= d <= days[mo - 1] and h <= 23 and mi <= 59 and se <= 60):
|
|
return False
|
|
return m.group(9) is None or (int(m.group(9)) <= 23 and int(m.group(10)) <= 59)
|
|
|
|
|
|
def read_claim(wire):
|
|
"""jwp-receipts §3.1 steps 1-4 for a consumption claim: the claim, or Refuse(reason, step)."""
|
|
def at(step, reason):
|
|
return Refuse(reason, step)
|
|
try:
|
|
c = strict_json(wire)
|
|
except ValueError:
|
|
raise at(1, "malformed")
|
|
if not isinstance(c, dict):
|
|
raise at(1, "malformed")
|
|
if "kind" not in c:
|
|
raise at(1, "missing_required_field")
|
|
if c["kind"] != "claim":
|
|
raise at(1, "wrong_kind")
|
|
if any(k not in c for k in ENVELOPE):
|
|
raise at(1, "missing_required_field")
|
|
if any(k not in ENVELOPE for k in c):
|
|
raise at(1, "unknown_member")
|
|
if not isinstance(c["class"], str):
|
|
raise at(1, "field_type")
|
|
if c["class"] == "delivery":
|
|
raise at(1, "class_reserved")
|
|
if c["class"] != "consumption":
|
|
raise at(1, "unknown_class")
|
|
ok = (c["subject_type"] in ("object", "receipt") and is_hex(c["subject"], 32) and is_uint(c["seq"])
|
|
and isinstance(c["issued_at"], str) and is_date_time(c["issued_at"]) and is_hex(c["signer_pubkey"], 32)
|
|
and isinstance(c["signer_id"], str) and (c["parent_receipt_hash"] is None or is_hex(c["parent_receipt_hash"], 32))
|
|
and is_hex(c["sig"], 64))
|
|
if not ok:
|
|
raise at(1, "field_type")
|
|
if c["subject_type"] != "object":
|
|
raise at(1, "subject_type_not_admitted")
|
|
# Step 2: §6.1.
|
|
b = c["body"]
|
|
if not isinstance(b, dict):
|
|
raise at(2, "field_type")
|
|
if any(k not in b for k in BODY):
|
|
raise at(2, "missing_required_field")
|
|
if any(k not in BODY for k in b):
|
|
raise at(2, "unknown_member")
|
|
if not (isinstance(b["objects"], list) and 1 <= len(b["objects"]) <= 1024 and all(is_hex(h, 32) for h in b["objects"])):
|
|
raise at(2, "field_type")
|
|
if not (isinstance(b["usage"], list) and 1 <= len(b["usage"]) <= 3):
|
|
raise at(2, "field_type")
|
|
for e in b["usage"]:
|
|
if not isinstance(e, dict):
|
|
raise at(2, "field_type")
|
|
if "vocabulary" not in e or "term" not in e:
|
|
raise at(2, "missing_required_field")
|
|
if len(e) != 2:
|
|
raise at(2, "unknown_member")
|
|
if e["vocabulary"] not in VOCABULARIES:
|
|
raise at(2, "field_type")
|
|
if not (e["term"] is None or (isinstance(e["term"], str) and 1 <= len(e["term"].encode()) <= 255)):
|
|
raise at(2, "field_type")
|
|
if not (isinstance(b["seen"], list) and len(b["seen"]) <= 64 and all(is_hex(h, 32) for h in b["seen"])):
|
|
raise at(2, "field_type")
|
|
if not (b["grant"] is None or is_hex(b["grant"], 32)):
|
|
raise at(2, "field_type")
|
|
# Step 3.
|
|
unsigned = {k: v for k, v in c.items() if k != "sig"}
|
|
why = ED.verify(bytes.fromhex(c["signer_pubkey"]), CLAIM_SIGN + jcs(unsigned).encode(), bytes.fromhex(c["sig"]))
|
|
if why == "bad_key":
|
|
raise at(3, "bad_signer_key")
|
|
if why and "claim-signature" not in SKIP:
|
|
raise at(3, "signature_verify_failed")
|
|
# Step 4: §6.2, in its order.
|
|
asc = lambda xs: all(x < y for x, y in zip(xs, xs[1:])) # noqa: E731
|
|
if "claim-order" not in SKIP and not asc(b["objects"]):
|
|
raise at(4, "consumption_objects_unordered")
|
|
if c["subject"] != b["objects"][0]:
|
|
raise at(4, "consumption_subject_mismatch")
|
|
if not asc([e["vocabulary"] for e in b["usage"]]):
|
|
raise at(4, "consumption_usage_unordered")
|
|
for e in b["usage"]:
|
|
t, v = e["term"], e["vocabulary"]
|
|
if t is None:
|
|
continue
|
|
known = (t in LABELS if v == "aipref" else t == TDM_TERM if v == "tdmrep"
|
|
else t in CAWG_KEYS or (cawg_key(t) and not t.startswith("cawg.")))
|
|
if not known and "claim-terms" not in SKIP:
|
|
raise at(4, "consumption_term_unknown")
|
|
if not asc(b["seen"]):
|
|
raise at(4, "consumption_seen_unordered")
|
|
if "claim-did-key" not in SKIP and c["signer_id"] != did_key(bytes.fromhex(c["signer_pubkey"])):
|
|
raise at(4, "consumption_signer_not_principal")
|
|
return c
|
|
|
|
|
|
def link_hash(c):
|
|
return b3(CLAIM_LINK + jcs(c).encode())
|
|
|
|
|
|
def verify_claim(wire, objects, room, previous_wire):
|
|
"""§6.3 after jwp-receipts §3.1 steps 1-5: ("accept", outcomes) or Refuse."""
|
|
c = read_claim(wire)
|
|
# Step 5: the previous claim is held only if it passes steps 1-4.
|
|
prev = None
|
|
if previous_wire is not None:
|
|
try:
|
|
prev = read_claim(previous_wire)
|
|
except Refuse:
|
|
prev = None
|
|
parent = c["parent_receipt_hash"]
|
|
if prev is None and parent is not None and "chain" not in SKIP:
|
|
raise Refuse("parent_not_held", 5)
|
|
if prev is not None:
|
|
if parent is None or prev["signer_pubkey"] != c["signer_pubkey"] or bytes.fromhex(parent) != link_hash(prev) \
|
|
or c["seq"] <= prev["seq"]:
|
|
raise Refuse("chain_break", 5)
|
|
b = c["body"]
|
|
held = []
|
|
for h in b["objects"]:
|
|
if h not in objects:
|
|
raise Refuse("consumption_object_unknown", 6)
|
|
held.append(objects[h])
|
|
terms = None
|
|
if b["grant"] is not None and "grant" not in SKIP:
|
|
g = None if room is None else next((x for x in room["grants"] if x["digest"].hex() == b["grant"]), None)
|
|
good = g is not None and g["principal"].hex() == c["signer_pubkey"] and all(
|
|
o["room"] and (o["t"] is None or LRV.good_at(g, room["d"], o["t"])) for o in held)
|
|
if not good:
|
|
raise Refuse("grant_mismatch", 6)
|
|
t = terms_of(g)
|
|
terms = t if isinstance(t, dict) else None
|
|
rooms = lambda o: room if o["room"] else None # noqa: E731
|
|
seen = sorted({s["digest"].hex() for o in held for s in governing(o, rooms(o))})
|
|
if "seen" not in SKIP and seen != b["seen"]:
|
|
raise Refuse("preference_mismatch", 6)
|
|
carried = {v for o in held for s in governing(o, rooms(o)) for v in s["prefs"]}
|
|
if "answered" not in SKIP and not carried <= {e["vocabulary"] for e in b["usage"]}:
|
|
raise Refuse("usage_unanswered", 6)
|
|
outcomes = []
|
|
for h, o in zip(b["objects"], held):
|
|
g = governing(o, rooms(o))
|
|
r = combine([preference(e["vocabulary"], e["term"], g, terms) for e in b["usage"]])
|
|
if r is None:
|
|
raise Refuse("usage_disallowed", 6)
|
|
outcomes.append({"object": h, "outcome": r})
|
|
return c, outcomes
|
|
|
|
|
|
# ---------------------------------------------------------------- §7 the gate
|
|
|
|
def gate(case, keys, results):
|
|
"""§7.2: None to serve, or the reason."""
|
|
agent = keys[case["agent"]]
|
|
allowed, disallowing = [], []
|
|
for name in case["claims"]:
|
|
c, r = results[name]
|
|
if c is None or c["signer_pubkey"] != agent:
|
|
continue
|
|
if isinstance(r, list):
|
|
allowed.append((c, r))
|
|
elif r == "usage_disallowed":
|
|
disallowing.append(c)
|
|
w = case["policy"]["window"]
|
|
if w == 0:
|
|
checked = list(case["request"])
|
|
else:
|
|
served = case["served"]
|
|
checked = [h for grp in served[:max(len(served) - w, 0)] for h in grp]
|
|
for h in checked:
|
|
names = lambda c: h in c["body"]["objects"] # noqa: E731
|
|
if not any(names(c) for c, _ in allowed) and not any(names(c) for c in disallowing):
|
|
return "claim_required"
|
|
if any(names(c) for c in disallowing):
|
|
return "usage_disallowed"
|
|
if case["policy"]["require_allowed"] and "require-allowed" not in SKIP and any(
|
|
o["object"] == h and o["outcome"] != "allowed" for _, r in allowed for o in r):
|
|
return "usage_not_granted"
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------- the corpus
|
|
|
|
def held_objects(doc):
|
|
"""The held objects of each publication, by content hash."""
|
|
groups = {p["name"]: p for p in doc["publications"]}
|
|
out = {}
|
|
for case in doc["objects"]:
|
|
p = groups[case["publication"]]
|
|
try:
|
|
o = hold(bytes.fromhex(case["envelope"]), case["receipt"], p["group"])
|
|
except Refuse:
|
|
continue
|
|
o["room"] = p["room"] is not None
|
|
out.setdefault(case["publication"], {})[o["hash"].hex()] = o
|
|
return out
|
|
|
|
|
|
def object_named(doc, held, publication, name):
|
|
case = next(o for o in doc["objects"] if o["publication"] == publication and o["name"] == name)
|
|
return held[publication][b3(OBJECT_DOMAIN + bytes.fromhex(case["envelope"])).hex()]
|
|
|
|
|
|
def outcomes(doc):
|
|
"""Every case's outcome, by section, from the document's inputs."""
|
|
out = {}
|
|
res = []
|
|
for case in doc["statements"]:
|
|
try:
|
|
r = statement_report(statement_text(case["json"]))
|
|
r["outcome"] = "accept"
|
|
except Refuse as e:
|
|
r = {"outcome": "refuse", "reason": e.reason}
|
|
res.append(r)
|
|
out["statements"] = res
|
|
groups = {p["name"]: p for p in doc["publications"]}
|
|
res = []
|
|
for case in doc["objects"]:
|
|
try:
|
|
o = hold(bytes.fromhex(case["envelope"]), case["receipt"], groups[case["publication"]]["group"])
|
|
res.append({"outcome": "held", "content_hash": o["hash"].hex(),
|
|
"statement": o["statement"]["digest"].hex() if o["statement"] else None, "t": o["t"]})
|
|
except Refuse as e:
|
|
res.append({"outcome": "refuse", "reason": e.reason})
|
|
out["objects"] = res
|
|
out["rooms"] = [room_outcome(case) for case in doc["rooms"]]
|
|
held = held_objects(doc)
|
|
res = []
|
|
for case in doc["responses"]:
|
|
o = object_named(doc, held, case["publication"], case["object"])
|
|
try:
|
|
res.append({"outcome": "accept", "unsigned": check_fields(o["statement"], [tuple(f) for f in case["fields"]])})
|
|
except Refuse as e:
|
|
res.append({"outcome": "refuse", "reason": e.reason})
|
|
out["responses"] = res
|
|
res = []
|
|
for case in doc["catalogs"]:
|
|
os_ = [object_named(doc, held, case["publication"], n) for n in case["objects"]]
|
|
try:
|
|
check_catalog(case["entry"], os_)
|
|
res.append({"outcome": "accept"})
|
|
except Refuse as e:
|
|
res.append({"outcome": "refuse", "reason": e.reason})
|
|
out["catalogs"] = res
|
|
rooms = {case["name"]: case for case in doc["rooms"]}
|
|
wires, results, res = {}, {}, []
|
|
for case in doc["claims"]:
|
|
ctx = case["context"]
|
|
objects = {}
|
|
for p in ctx["publications"]:
|
|
objects.update(held.get(p, {}))
|
|
room = check_room(rooms[ctx["room"]]) if ctx["room"] else None
|
|
prev = wires[ctx["previous"]] if ctx["previous"] else None
|
|
try:
|
|
c, outs = verify_claim(case["wire"], objects, room, prev)
|
|
res.append({"outcome": "accept", "objects": outs})
|
|
results[case["name"]] = (c, outs)
|
|
except Refuse as e:
|
|
res.append({"outcome": "refuse", "step": e.step, "reason": e.reason})
|
|
claim = None
|
|
if e.step == 6:
|
|
claim = read_claim(case["wire"])
|
|
results[case["name"]] = (claim, e.reason)
|
|
wires[case["name"]] = case["wire"]
|
|
out["claims"] = res
|
|
keys = {k: v for k, v in doc["keys"].items()}
|
|
res = []
|
|
for case in doc["gates"]:
|
|
r = gate(case, keys, results)
|
|
res.append({"outcome": "serve"} if r is None else {"outcome": "refuse", "reason": r})
|
|
out["gates"] = res
|
|
return out
|
|
|
|
|
|
SECTIONS = ("statements", "objects", "rooms", "responses", "catalogs", "claims", "gates")
|
|
|
|
|
|
def check(doc, quiet=False):
|
|
"""The failures: every case whose stated outcome is not the one this verifier reaches."""
|
|
fails = []
|
|
try:
|
|
got = outcomes(doc)
|
|
except (Refuse, KeyError, ValueError, TypeError, StopIteration, AttributeError, LEV.Refuse) as e:
|
|
return ["the corpus does not read: %r" % e]
|
|
for s in SECTIONS:
|
|
if len(got[s]) != len(doc[s]):
|
|
fails.append("%s: %d cases, %d outcomes" % (s, len(doc[s]), len(got[s])))
|
|
for case, out in zip(doc[s], got[s]):
|
|
if case.get("outcome") != out:
|
|
fails.append("%s/%s: stated %s, got %s" % (s, case["name"], json.dumps(case.get("outcome"))[:120],
|
|
json.dumps(out)[:120]))
|
|
if not quiet:
|
|
for s in SECTIONS:
|
|
bad = [f for f in fails if f.startswith(s + "/")]
|
|
print(("PASS" if not bad else "FAIL") + " %s: %d cases" % (s, len(doc[s])))
|
|
return fails
|
|
|
|
|
|
def reasons_given(doc):
|
|
seen = set()
|
|
for s in SECTIONS:
|
|
for case in doc[s]:
|
|
o = case["outcome"]
|
|
if s == "rooms":
|
|
for k in ("records", "grants"):
|
|
if isinstance(o[k], dict):
|
|
seen.add(o[k]["reason"])
|
|
seen.update(t["terms"] for t in o["terms"] if isinstance(t["terms"], str) and len(t["terms"]) != 64)
|
|
elif "reason" in o:
|
|
seen.add(o["reason"])
|
|
return seen
|
|
|
|
|
|
def flip_hex(h, at):
|
|
"""A hex string with one digit changed."""
|
|
c = "0" if h[at] != "0" else "1"
|
|
return h[:at] + c + h[at + 1:]
|
|
|
|
|
|
def mutations(doc):
|
|
"""Copies of the corpus, each with one defect a conforming verifier catches."""
|
|
def named(d, s, name):
|
|
return next(c for c in d[s] if c["name"] == name)
|
|
|
|
def m(label, f):
|
|
d = copy.deepcopy(doc)
|
|
f(d)
|
|
return label, d
|
|
|
|
def set_(s, name, path, value):
|
|
def f(d):
|
|
x = named(d, s, name)
|
|
for k in path[:-1]:
|
|
x = x[k]
|
|
x[path[-1]] = value
|
|
return f
|
|
|
|
def edit(s, name, key, old, new):
|
|
def f(d):
|
|
x = named(d, s, name)
|
|
assert old in x[key], (s, name, old)
|
|
x[key] = x[key].replace(old, new, 1)
|
|
return f
|
|
|
|
yield m("a statement's stated preference", set_("statements", "aipref_only", ["outcome", "preferences", "aipref", "train-ai"], "allow"))
|
|
yield m("a statement's value", edit("statements", "aipref_only", "json", "train-ai=n", "train-ai=y"))
|
|
yield m("an unknown revision stated accepted", set_("statements", "aipref_revision_unknown", ["outcome"], {"outcome": "accept"}))
|
|
yield m("an object's payload byte", lambda d: named(d, "objects", "feature").update(
|
|
envelope=flip_hex(named(d, "objects", "feature")["envelope"], len(named(d, "objects", "feature")["envelope"]) - 2)))
|
|
yield m("an object's stated statement", set_("objects", "trailer", ["outcome", "statement"], None))
|
|
yield m("a group receipt's signature", lambda d: d["publications"][0]["group"].update(
|
|
sig=flip_hex(d["publications"][0]["group"]["sig"], 10)))
|
|
yield m("a room record's signature", lambda d: named(d, "rooms", "lecture")["records"].__setitem__(
|
|
1, flip_hex(named(d, "rooms", "lecture")["records"][1], len(named(d, "rooms", "lecture")["records"][1]) - 4)))
|
|
yield m("a room's stated records in effect", lambda d: named(d, "rooms", "lecture").__setitem__(
|
|
"outcome", dict(named(d, "rooms", "lecture")["outcome"], in_effect=[])))
|
|
yield m("a response's field", lambda d: named(d, "responses", "respelled")["fields"].__setitem__(0, ["Content-Usage", "train-ai=y"]))
|
|
yield m("a catalog's statement", edit("catalogs", "agrees", "entry", '"tdm-reservation": 1', '"tdm-reservation": 0'))
|
|
def flip_sig(d):
|
|
x = named(d, "claims", "inference_allowed")
|
|
at = x["wire"].index('"sig":"') + len('"sig":"') + 5
|
|
x["wire"] = flip_hex(x["wire"], at)
|
|
yield m("a claim's signature", flip_sig)
|
|
yield m("a claim's stated object outcome", set_("claims", "unknown_use", ["outcome", "objects", 0, "outcome"], "allowed"))
|
|
yield m("a claim's context room", set_("claims", "room_terms_lift", ["context", "room"], None))
|
|
yield m("a claim's previous", set_("claims", "unknown_use", ["context", "previous"], None))
|
|
yield m("a gate's window", set_("gates", "claim_behind_past_window", ["policy", "window"], 2))
|
|
yield m("a gate's policy", set_("gates", "unknown_not_granted", ["policy", "require_allowed"], False))
|
|
yield m("a gate's claims", lambda d: named(d, "gates", "claim_ahead_disallowed").update(claims=["inference_allowed"]))
|
|
|
|
|
|
CHECKS = {
|
|
"aipref-length": "§3.2: the length of an aipref value",
|
|
"aipref-parse": "§3.3: a value that does not parse is refused",
|
|
"cawg-reserved": "§3.4: the reserved cawg. prefix",
|
|
"tdm-values": "§3.5: tdm-reservation is 0 or 1",
|
|
"revisions": "§3.6 step 2: the revisions",
|
|
"canonical-bytes": "§4.2, §4.3: the bytes are the canonical form",
|
|
"group-signature": "§4.1 step 1: the group receipt's signature",
|
|
"content-hash": "§4.1 step 2: the envelope hashes to its receipt",
|
|
"record-room": "§4.2: a record names the room",
|
|
"record-signature": "§4.2: the authority signs a record",
|
|
"record-chain": "§4.2: records are chained",
|
|
"record-in-effect": "§4.2: the record in effect at an instant",
|
|
"field-meaning": "§4.4: Content-Usage compared by meaning",
|
|
"field-tdm": "§4.4: tdm-reservation compared",
|
|
"catalog-compare": "§4.5: the catalog compared with the objects",
|
|
"terms": "§5.2 rule 1: a grant's terms",
|
|
"most-restrictive": "§5.2 rule 2: the most restrictive",
|
|
"claim-signature": "jwp-receipts §3.1 step 3: the claim's signature",
|
|
"claim-order": "§6.2 rule 1: objects in order",
|
|
"claim-terms": "§6.2 rule 4: the vocabularies' own terms",
|
|
"claim-did-key": "§6.2 rule 6: the signer's did:key",
|
|
"chain": "jwp-receipts §3.1 step 5: the parent is held",
|
|
"grant": "§6.3 step 2: the grant",
|
|
"seen": "§6.3 step 3: the statements seen",
|
|
"answered": "§6.3 step 4: every vocabulary answered",
|
|
"require-allowed": "§7.2 step 3: require allowed",
|
|
}
|
|
|
|
|
|
def main(corpus_dir):
|
|
fails = []
|
|
st = ED._self_test()
|
|
print(("PASS" if not st else "FAIL") + " Ed25519 strict rule self-test " + ",".join(st))
|
|
fails += ["ed25519 self-test"] if st else []
|
|
print(("PASS" if len(REASONS) == 21 else "FAIL") + " §9 read from the extension: %d reasons" % len(REASONS))
|
|
fails += [] if len(REASONS) == 21 else ["§9 does not read"]
|
|
with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as f:
|
|
doc = json.load(f)
|
|
fails += check(doc)
|
|
extra = sorted(set(os.listdir(corpus_dir)) - {"vectors.json", "README.md"})
|
|
print(("PASS" if not extra else "FAIL") + " no file but vectors.json and README.md")
|
|
fails += ["an extra file: %s" % n for n in extra]
|
|
missing = [r for r in REASONS if r not in reasons_given(doc)]
|
|
print(("PASS" if not missing else "FAIL") + " every reason of §9 appears in the corpus")
|
|
fails += ["no case gives %s" % r for r in missing]
|
|
for label, d in mutations(doc):
|
|
caught = check(d, quiet=True)
|
|
print(("PASS" if caught else "FAIL") + " mutated copy fails: %s%s" % (label, " (%s)" % caught[0][:90] if caught else ""))
|
|
if not caught:
|
|
fails.append("a mutated copy verifies: %s" % label)
|
|
for name, what in CHECKS.items():
|
|
SKIP.clear()
|
|
SKIP.add(name)
|
|
caught = check(doc, quiet=True)
|
|
SKIP.clear()
|
|
print(("PASS" if caught else "FAIL") + " a verifier without %s fails the corpus%s" % (what, " (%s)" % caught[0][:70] if caught else ""))
|
|
if not caught:
|
|
fails.append("the corpus does not catch a verifier without %s" % what)
|
|
for f in fails:
|
|
print("FAIL " + f)
|
|
print(("PASS" if not fails else "FAIL") + " AI usage corpus: %s" % ", ".join("%d %s" % (len(doc[s]), s) for s in SECTIONS))
|
|
return 1 if fails else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) != 2:
|
|
print("usage: ai_usage_verify.py <live-conformance/usage>", file=sys.stderr)
|
|
sys.exit(2)
|
|
sys.exit(main(sys.argv[1]))
|