#!/usr/bin/env python3 """Independent verifier of the contribution link-record corpus (extensions/live-contribution.md §6, the record `WCL1`, and §7's refusals of it). Written from extensions/live-contribution.md alone. It shares no code with the reference implementation (joule-moq-ingest), which generates the corpus, and never runs it. It checks every Ed25519 signature with ../claim-conformance/_ed25519.py, WAI's strict rule (SPEC §7 *Signatures*) written from RFC 8032 with the standard library. Everything else is here, from the text, with Python's integers: - a part (§6.5), in the stated order: the size bound (§6.7); the reading of §6.2's layout, field by field, with its reasons; the signature by `hop` over `wai:contrib-link` 0x01 and the bytes before `sig`; the link rules; the account rules (§6.3); the loss runs; - a series (§6.6): each part verified, then chained to the one before (`chain_broken`) and continuing its sequence numbers and its span (`sequence_gap`). For every vector it requires the outcome and the reason the corpus states, and for a series the index of the part refused; for an accepted part its digest and every summary field the corpus states, each recomputed from the record's bytes; for an accepted series each part's digest and summary. Of the corpus, it requires that every reason of §7's last list refuses a vector and that the index lists exactly those reasons. Then it runs the Ed25519 helper's own self-test; mutates accepted records and series in memory, one defect each, and requires every mutated copy to be refused; and leaves out its own checks one at a time and requires the corpus to catch the absence of each one it lists as required, noting the others it does not. python3 -I live_contribution_verify.py Exit status 0 only if every vector verifies as stated, every mutated copy is refused and the corpus catches every required dropped check; 1 otherwise; 2 for a wrong command line. Python 3.8 or later; standard library only. """ import hashlib import json import os import re 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 _ed25519 as ED # noqa: E402 FORMAT = "wai-live-contribution-links/1" MAGIC = b"WCL1" VERSION = 1 DOMAIN = b"wai:contrib-link\x01" # §6.5: the 17 bytes the signature covers before the record FIXED = 415 # §6.7: the fixed fields and the signature TRACK = 80 # §6.2: digest 32, track_alias 8, groups 8, head 32 RUN = 20 # §6.2: first 8, count 4, recovered 4, dropped 4 MAX_TRACKS = 8 MAX_RUNS = 512 MAX_SIZE = FIXED + TRACK * MAX_TRACKS + RUN * MAX_RUNS # §6.7: 11 295 SIG = 64 ZERO = bytes(32) # §6.5 step 4: the key lengths each protocol allows, in bits. CIPHERS = {1: (0, 128, 192, 256), 2: (0,), 3: (0, 128, 256)} ENDED_MAX = 7 # §7's last list, in its order: the reasons this verifier raises. REASONS = [ "record_too_large", "truncated", "malformed", "unknown_version", "bad_key", "bad_signature", "link_invalid", "account_invalid", "runs_invalid", "chain_broken", "sequence_gap", ] # Every check this verifier makes, by name. A self-test run may leave one out to show the # corpus catches its absence; a verification never does. CHECKS = ( # §6.5 step 1 "size-bound", # step 2 "magic", "version", "last-flag", "tracks-bound", "runs-bound", "truncated-flag", "trailing-byte", # step 3 "signature", "small-order-key", "small-order-r", # step 4 "protocol", "mode", "cipher", "last-ended", "ended-max", "span", "window", "parent-zero", "track-zero-digest", "track-head-groups", "tracks-order", # step 5 "first-try", "account-identity", "outstanding-last", "recovery-samples", "rtt-empty", "rtt-order", "recovery-empty", "recovery-order", "margin-recovered", "margin-window", "requested", "bytes-delivered", # step 6 "truncated-full", "run-count", "run-overfull", "run-within", "run-order", "runs-sums", # §6.6 "series-first-part", "series-same-room", "series-same-hop", "series-same-principal", "series-same-grant", "series-same-session", "series-same-protocol", "series-same-mode", "series-same-cipher", "series-same-window", "series-after-last", "series-part-index", "series-parent", "series-track-kept", "series-track-alias", "series-track-advance", "series-time", "series-seq", ) # The checks whose absence the corpus must catch: with any one of them left out, at least one # vector must come out other than the corpus states. Every other check is left out too, and # those the corpus does not catch are listed in a NOTE line. `first-try` can never be caught # alone: `recovery.samples` is unsigned, so `recovery.samples = delivered - first_try` # already holds `first_try` to at most `delivered`, with the same reason. REQUIRED_CAUGHT = ( "size-bound", "magic", "version", "trailing-byte", "signature", "small-order-key", "protocol", "mode", "cipher", "last-ended", "ended-max", "span", "window", "parent-zero", "track-zero-digest", "track-head-groups", "tracks-order", "account-identity", "outstanding-last", "recovery-samples", "rtt-empty", "rtt-order", "margin-window", "requested", "truncated-full", "run-count", "run-within", "run-order", "runs-sums", "series-first-part", "series-same-session", "series-after-last", "series-part-index", "series-parent", "series-track-kept", "series-track-advance", "series-time", "series-seq", ) FIXED_U64 = ( "start", "end", "window", "seq_first", "seq_count", "delivered", "first_try", "dropped", "outstanding", "late", "duplicates", "bytes", "requests", "requested", "retransmitted", ) class Refuse(Exception): def __init__(self, reason, check): super().__init__(reason) self.reason, self.check = reason, check def need(ok, reason, check, skip): """Refuse with `reason` unless `ok`, or unless a self-test run left `check` out.""" assert check in CHECKS, check if not ok and check not in skip: raise Refuse(reason, check) class Cursor: """§6.5 step 2: a record read field by field; running out is `truncated`.""" def __init__(self, b): self.b, self.p = b, 0 def take(self, n): if self.p + n > len(self.b): raise Refuse("truncated", "read") s = self.b[self.p:self.p + n] self.p += n return s def uint(self, n): return int.from_bytes(self.take(n), "little") def read_part(b, skip): """§6.5 step 2: the fields of §6.2 in their order, to exactly the last byte.""" c = Cursor(b) rec = {} need(c.take(4) == MAGIC, "malformed", "magic", skip) rec["version"] = c.uint(1) need(rec["version"] == VERSION, "unknown_version", "version", skip) for name, n in (("room", 32), ("hop", 32), ("principal", 32), ("grant", 32), ("session", 16)): rec[name] = c.take(n) rec["part"] = c.uint(4) rec["parent"] = c.take(32) rec["protocol"] = c.uint(1) rec["mode"] = c.uint(1) rec["cipher"] = c.uint(2) rec["last"] = c.uint(1) need(rec["last"] in (0, 1), "malformed", "last-flag", skip) rec["ended"] = c.uint(1) for name in FIXED_U64: rec[name] = c.uint(8) rec["rtt"] = tuple(c.uint(4) for _ in range(4)) rec["recovery"] = tuple(c.uint(4) for _ in range(4)) rec["margin_min"] = c.uint(4) n = c.uint(1) need(n <= MAX_TRACKS, "malformed", "tracks-bound", skip) rec["tracks"] = [] for _ in range(n): rec["tracks"].append({"digest": c.take(32), "alias": c.uint(8), "groups": c.uint(8), "head": c.take(32)}) k = c.uint(2) need(k <= MAX_RUNS, "malformed", "runs-bound", skip) rec["truncated"] = c.uint(1) need(rec["truncated"] in (0, 1), "malformed", "truncated-flag", skip) rec["runs"] = [] for _ in range(k): rec["runs"].append({"first": c.uint(8), "count": c.uint(4), "recovered": c.uint(4), "dropped": c.uint(4)}) rec["signed"] = b[:c.p] rec["sig"] = c.take(SIG) need(c.p == len(b), "malformed", "trailing-byte", skip) rec["digest"] = hashlib.sha256(rec["signed"]).digest() return rec _SIGNATURES = {} def signature_verdict(pk, msg, sig, ed_skip): """The shared strict rule, remembered so repeated runs over the corpus verify each signature once.""" key = (pk, msg, sig, ed_skip) if key not in _SIGNATURES: _SIGNATURES[key] = ED.verify(pk, msg, sig, skip=ed_skip) return _SIGNATURES[key] def verify_part(b, skip=frozenset()): """§6.5: the steps in order, refusing at the first that fails; the record read.""" # 1. The size bound, before anything is read. need(len(b) <= MAX_SIZE, "record_too_large", "size-bound", skip) # 2. The reading. rec = read_part(b, skip) # 3. The signature, under `hop`, strictly. if "signature" not in skip: ed_skip = tuple(s for s in ED.SKIPPABLE if s in skip) bad = signature_verdict(rec["hop"], DOMAIN + rec["signed"], rec["sig"], ed_skip) if bad is not None: raise Refuse(bad, "signature") # 4. The link. L = "link_invalid" p = rec["protocol"] need(p in (1, 2, 3), L, "protocol", skip) need(rec["mode"] in (1, 2), L, "mode", skip) if p in CIPHERS: need(rec["cipher"] in CIPHERS[p], L, "cipher", skip) need((rec["last"] == 1) == (rec["ended"] != 0), L, "last-ended", skip) need(rec["ended"] <= ENDED_MAX, L, "ended-max", skip) need(rec["start"] < rec["end"], L, "span", skip) need(rec["window"] > 0, L, "window", skip) need((rec["part"] == 0) == (rec["parent"] == ZERO), L, "parent-zero", skip) prev = None for t in rec["tracks"]: need(t["digest"] != ZERO, L, "track-zero-digest", skip) need((t["head"] == ZERO) == (t["groups"] == 0), L, "track-head-groups", skip) if prev is not None: # a digest is a byte string: ordered byte by byte, the first byte first need(prev < t["digest"], L, "tracks-order", skip) prev = t["digest"] # 5. The account. A = "account_invalid" delivered, first_try = rec["delivered"], rec["first_try"] recovered = delivered - first_try need(first_try <= delivered, A, "first-try", skip) need(delivered + rec["dropped"] + rec["outstanding"] == rec["seq_count"], A, "account-identity", skip) need(rec["outstanding"] == 0 or rec["last"] == 1, A, "outstanding-last", skip) need(rec["recovery"][0] == recovered, A, "recovery-samples", skip) for name in ("rtt", "recovery"): # with no sample all four are 0; with samples, min <= median <= max samples, lo, mid, hi = rec[name] if samples == 0: need((lo, mid, hi) == (0, 0, 0), A, name + "-empty", skip) else: need(lo <= mid <= hi, A, name + "-order", skip) need(recovered != 0 or rec["margin_min"] == 0, A, "margin-recovered", skip) need(rec["margin_min"] <= rec["window"], A, "margin-window", skip) need(rec["requested"] >= rec["requests"], A, "requested", skip) need(delivered != 0 or rec["bytes"] == 0, A, "bytes-delivered", skip) # 6. The runs. R = "runs_invalid" runs, truncated = rec["runs"], rec["truncated"] == 1 if truncated: need(len(runs) == MAX_RUNS, R, "truncated-full", skip) lo, hi = rec["seq_first"], rec["seq_first"] + rec["seq_count"] end = None for r in runs: need(r["count"] > 0, R, "run-count", skip) need(r["recovered"] + r["dropped"] <= r["count"], R, "run-overfull", skip) need(lo <= r["first"] and r["first"] + r["count"] <= hi, R, "run-within", skip) if end is not None: need(r["first"] >= end + 1, R, "run-order", skip) end = r["first"] + r["count"] sums = ( (sum(r["count"] for r in runs), rec["seq_count"] - first_try), (sum(r["recovered"] for r in runs), recovered), (sum(r["dropped"] for r in runs), rec["dropped"]), (sum(r["count"] - r["recovered"] - r["dropped"] for r in runs), rec["outstanding"]), ) need(all(got <= of if truncated else got == of for got, of in sums), R, "runs-sums", skip) return rec SERIES_SAME = ("room", "hop", "principal", "grant", "session", "protocol", "mode", "cipher", "window") def chain(prev, rec, skip): """§6.6 steps 1 and 2: a part after the first, against the part before it.""" C = "chain_broken" for f in SERIES_SAME: need(rec[f] == prev[f], C, "series-same-" + f, skip) need(prev["last"] == 0, C, "series-after-last", skip) need(rec["part"] == prev["part"] + 1, C, "series-part-index", skip) need(rec["parent"] == prev["digest"], C, "series-parent", skip) named = {t["digest"]: t for t in rec["tracks"]} for t in prev["tracks"]: again = named.get(t["digest"]) need(again is not None, C, "series-track-kept", skip) if again is None: continue need(again["alias"] == t["alias"], C, "series-track-alias", skip) need(again["groups"] > t["groups"] or (again["groups"] == t["groups"] and again["head"] == t["head"]), C, "series-track-advance", skip) G = "sequence_gap" need(rec["start"] == prev["end"], G, "series-time", skip) need(rec["seq_first"] == prev["seq_first"] + prev["seq_count"], G, "series-seq", skip) def verify_series(parts, skip=frozenset()): """§6.6: the records in order, each verified, then chained to the one before; the records read, or a Refuse with `at` set to the index of the part refused.""" out, prev = [], None for i, b in enumerate(parts): try: rec = verify_part(b, skip) if prev is None: need(rec["part"] == 0, "chain_broken", "series-first-part", skip) else: chain(prev, rec, skip) except Refuse as e: e.at = i raise out.append(rec) prev = rec return out def summary(rec, b): """The figures a corpus may state of an accepted part, each from the record's bytes.""" return { "part": rec["part"], "protocol": rec["protocol"], "mode": rec["mode"], "cipher": rec["cipher"], "last": rec["last"] == 1, "ended": rec["ended"], "start": rec["start"], "end": rec["end"], "window": rec["window"], "seq_first": rec["seq_first"], "seq_count": rec["seq_count"], "delivered": rec["delivered"], "first_try": rec["first_try"], "recovered": rec["delivered"] - rec["first_try"], "dropped": rec["dropped"], "outstanding": rec["outstanding"], "late": rec["late"], "duplicates": rec["duplicates"], "bytes": rec["bytes"], "requests": rec["requests"], "requested": rec["requested"], "retransmitted": rec["retransmitted"], "margin_min": rec["margin_min"], "tracks": len(rec["tracks"]), "runs": len(rec["runs"]), "truncated": rec["truncated"] == 1, "size": len(b), } def outcome(kind, parts, skip=frozenset()): """What this verifier makes of a vector's records: the outcome and its particulars.""" if kind == "part": if len(parts) != 1: raise ValueError("a part vector names %d files" % len(parts)) try: rec = verify_part(parts[0], skip) except Refuse as e: return {"outcome": "refuse", "reason": e.reason, "check": e.check} return {"outcome": "accept", "digest": rec["digest"].hex(), "summary": summary(rec, parts[0]), "records": [rec]} if kind == "series": try: recs = verify_series(parts, skip) except Refuse as e: return {"outcome": "refuse", "reason": e.reason, "at": e.at, "check": e.check} return {"outcome": "accept", "digests": [r["digest"].hex() for r in recs], "summaries": [summary(r, b) for r, b in zip(recs, parts)], "records": recs} raise ValueError("kind %r" % kind) def same(a, b): """Equal and of the same JSON type: a stated `true` is not a computed 1.""" return type(a) is type(b) and a == b def compare_summary(stated, got, where): problems = [] if not isinstance(stated, dict): return ["%s: the summary is not an object" % where] for k, v in sorted(stated.items()): if k not in got: problems.append("%s: the corpus states %s, which this verifier does not compute" % (where, k)) elif not same(v, got[k]): problems.append("%s: %s is %s, the corpus states %s" % (where, k, json.dumps(got[k]), json.dumps(v))) return problems def compare(v, got): """The ways `got` differs from what vector `v` states.""" problems = [] if v.get("outcome") != got["outcome"]: what = got["outcome"] + (" " + got["reason"] if got["outcome"] == "refuse" else "") return ["outcome is %s (%s), the corpus states %s %s" % (what, got.get("check", ""), v.get("outcome"), v.get("reason", ""))] if got["outcome"] == "refuse": if v.get("reason") != got["reason"]: problems.append("reason is %s (check %s), the corpus states %s" % (got["reason"], got["check"], v.get("reason"))) if v["kind"] == "series" and v.get("at") != got["at"]: problems.append("at is %s, the corpus states %s" % (got["at"], v.get("at"))) if v["kind"] == "part" and "at" in v: problems.append("a part vector states `at`") return problems if v["kind"] == "part": if "digest" not in v or "summary" not in v: problems.append("an accepted part states no digest or no summary") if "digest" in v and v["digest"] != got["digest"]: problems.append("digest is %s, the corpus states %s" % (got["digest"], v["digest"])) if "summary" in v: problems += compare_summary(v["summary"], got["summary"], "summary") else: if "digests" not in v or "summaries" not in v: problems.append("an accepted series states no digests or no summaries") if "digests" in v and v["digests"] != got["digests"]: problems.append("digests are %s, the corpus states %s" % (got["digests"], v["digests"])) if "summaries" in v: if len(v["summaries"]) != len(got["summaries"]): problems.append("the corpus states %d summaries for %d parts" % (len(v["summaries"]), len(got["summaries"]))) for i, (s, g) in enumerate(zip(v["summaries"], got["summaries"])): problems += compare_summary(s, g, "summaries[%d]" % i) return problems def load(corpus_dir, v): if v.get("kind") not in ("part", "series") or not v.get("files"): raise ValueError("vector %s: kind %r with files %r" % (v.get("name"), v.get("kind"), v.get("files"))) parts = [] for f in v["files"]: if os.path.basename(f) != f: raise ValueError("vector %s: file %r outside the corpus directory" % (v["name"], f)) with open(os.path.join(corpus_dir, f), "rb") as fh: parts.append(fh.read()) return parts def stated(v): return (v.get("outcome"), v.get("reason"), v.get("at")) def result(got): return (got["outcome"], got.get("reason"), got.get("at")) def check_vectors(doc, loaded): fails = [] for v in doc["vectors"]: try: got = outcome(v["kind"], loaded[v["name"]]) problems = compare(v, got) except Exception as e: # a crash is a failure to verify the vector got, problems = None, ["the vector does not verify: %s: %s" % (type(e).__name__, e)] if got is None: what = "?" elif got["outcome"] == "refuse": what = "refuse %s" % got["reason"] + (" at %d" % got["at"] if "at" in got else "") else: what = "accept" print(("PASS" if not problems else "FAIL") + " vector %s: %s" % (v["name"], what)) fails += ["vector %s: %s" % (v["name"], p) for p in problems] if got is not None and got["outcome"] == "accept" and "hop" in doc: for rec in got["records"]: if rec["hop"].hex() != doc["hop"]: fails.append("vector %s: signed by %s, not the corpus's hop key" % (v["name"], rec["hop"].hex())) return fails def reasons_listed(): """The reasons of §7's last list, in its order.""" with open(os.path.join(HERE, "..", "extensions", "live-contribution.md"), encoding="utf-8") as f: text = f.read() at = text.index("\n## 7. Refusals") end = text.find("\n## ", at + 1) section = text[at:end if end != -1 else len(text)].strip() last = re.split(r"\n\s*\n", section)[-1] if not last.startswith("Of the link record"): raise ValueError("§7 does not end with the link record's list") return re.findall(r"`([a-z_]+)`", last) # --------------------------------------------------------------------------------------------- # The mutation self-test: copies of accepted records and series, one defect each, in memory. FIELD_AT = {} # §6.2: the offset and size of each fixed field def _offsets(): at = 0 for name, n in (("magic", 4), ("version", 1), ("room", 32), ("hop", 32), ("principal", 32), ("grant", 32), ("session", 16), ("part", 4), ("parent", 32), ("protocol", 1), ("mode", 1), ("cipher", 2), ("last", 1), ("ended", 1)) + tuple((f, 8) for f in FIXED_U64) + ( ("rtt", 16), ("recovery", 16), ("margin_min", 4), ("tracks", 1)): FIELD_AT[name] = (at, n) at += n _offsets() NUMERIC = ("part", "protocol", "mode", "cipher", "last", "ended") + FIXED_U64 + ("rtt", "recovery", "margin_min", "tracks") IDENTITY = ("room", "principal", "grant", "session", "parent") def flipped(b, at, bit=0x01): m = bytearray(b) m[at] ^= bit return bytes(m) def mutations(records, series): """(kind, [(label, kind of input, the records)]) for every defect; each must be refused.""" kinds = [] def each(label, f): out = [] for name, b in records: for tag, m in f(b): out.append(("%s %s" % (name, tag), "part", [m])) kinds.append((label, out)) def runs_at(b): n = b[FIELD_AT["tracks"][0]] return FIELD_AT["tracks"][0] + 1 + TRACK * n each("a byte of the signature flipped", lambda b: [("sig[%d]" % i, flipped(b, len(b) - SIG + i)) for i in (0, 31, 32, 63)]) each("a byte of each fixed numeric field flipped", lambda b: [("%s+%d" % (f, j), flipped(b, FIELD_AT[f][0] + j)) for f in NUMERIC for j in (0, FIELD_AT[f][1] - 1)]) each("a byte of each fixed identity field flipped", lambda b: [("%s" % f, flipped(b, FIELD_AT[f][0] + 7)) for f in IDENTITY]) each("a byte of each track head flipped", lambda b: [("track %d %s" % (i, part), flipped(b, FIELD_AT["tracks"][0] + 1 + TRACK * i + off)) for i in range(b[FIELD_AT["tracks"][0]]) for part, off in (("digest", 0), ("alias", 32), ("groups", 40), ("head", 79))]) each("a byte of the runs flipped", lambda b: [("runs count", flipped(b, runs_at(b))), ("truncated", flipped(b, runs_at(b) + 2))] + [("run %d %s" % (i, part), flipped(b, runs_at(b) + 3 + RUN * i + off)) for i in range(min(3, b[runs_at(b)] | b[runs_at(b) + 1] << 8)) for part, off in (("first", 0), ("count", 8), ("recovered", 12), ("dropped", 16))]) each("truncated at every length", lambda b: [("to %d" % n, b[:n]) for n in range(len(b))]) each("a byte appended", lambda b: [("0x00", b + b"\x00"), ("0xff", b + b"\xff")]) each("the magic changed", lambda b: [("byte %d" % i, flipped(b, i, 0x20)) for i in range(4)] + [("WCL2", b"WCL2" + b[4:])]) each("the version changed", lambda b: [("to %d" % n, b[:4] + bytes([n]) + b[5:]) for n in (0, 2, 255)]) each("a byte of the hop key changed", lambda b: [("hop[%d]" % i, flipped(b, FIELD_AT["hop"][0] + i, bit)) for i, bit in ((0, 1), (15, 0x10), (31, 0x80))]) each("the record padded past its bound", lambda b: [("to %d" % (MAX_SIZE + 1), b + bytes(MAX_SIZE + 1 - len(b)))]) def each_series(label, f): out = [] for name, parts in series: for tag, m in f(parts): out.append(("%s %s" % (name, tag), "series", m)) kinds.append((label, out)) def swaps(p): return [("%d<->%d" % (i, j), [p[j] if k == i else p[i] if k == j else x for k, x in enumerate(p)]) for i in range(len(p)) for j in range(i + 1, len(p))] def others(p): out = [] for i in range(len(p)): for name, b in records: if b not in p: out.append(("[%d]=%s" % (i, name), p[:i] + [b] + p[i + 1:])) return out each_series("two parts of a series swapped", swaps) each_series("the middle part of a series dropped", lambda p: [("[%d] dropped" % i, p[:i] + p[i + 1:]) for i in range(1, len(p) - 1)]) each_series("the first part of a series dropped", lambda p: [("[0] dropped", p[1:])]) each_series("a part of a series repeated", lambda p: [("[%d] twice" % i, p[:i + 1] + p[i:]) for i in range(len(p))]) each_series("a series reversed", lambda p: [("reversed", p[::-1])]) each_series("a part of a series replaced by a part of another accepted vector", others) each_series("a byte of a later part's parent flipped", lambda p: [("[%d] parent" % i, p[:i] + [flipped(p[i], FIELD_AT["parent"][0])] + p[i + 1:]) for i in range(1, len(p))]) return kinds def run_mutations(records, series): fails = [] for label, copies in mutations(records, series): bad, reasons = [], set() for tag, kind, parts in copies: try: got = outcome(kind, parts) except Exception as e: # a crash is not a refusal bad.append("%s crashed: %s: %s" % (tag, type(e).__name__, e)) continue if got["outcome"] != "refuse": bad.append("%s accepted" % tag) else: reasons.add(got["reason"]) ok = copies and not bad print(("PASS" if ok else "FAIL") + " mutated copies refused: %s (%s)" % (label, ", ".join(sorted(reasons)) or "none made")) if not copies: fails.append("mutation %s: no copy made" % label) fails += ["mutation %s: %s" % (label, b) for b in bad] return fails def run_dropped_checks(doc, loaded): """Each check left out in turn: for a required one, the corpus must then come out otherwise somewhere; the others it does not catch are noted.""" fails, uncaught = [], [] for check in CHECKS: skip = frozenset([check]) caught = [] for v in doc["vectors"]: try: got = outcome(v["kind"], loaded[v["name"]], skip) except Exception as e: caught.append("%s (%s)" % (v["name"], type(e).__name__)) continue if result(got) != stated(v): caught.append("%s (%s)" % (v["name"], " ".join(str(x) for x in result(got) if x is not None))) if check not in REQUIRED_CAUGHT: if not caught: uncaught.append(check) continue print(("PASS" if caught else "FAIL") + " the corpus catches the check %s left out%s" % ( check, ": " + caught[0] if caught else "")) if not caught: fails.append("with the check %s left out, every vector still comes out as stated" % check) if uncaught: print("NOTE no vector isolates the checks %s" % ", ".join(uncaught)) return fails def main(corpus_dir): fails = [] st = ED._self_test() print(("PASS" if not st else "FAIL") + " Ed25519 strict rule self-test " + ",".join(st)) if st: fails.append("Ed25519 self-test: %s" % ",".join(st)) with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as f: doc = json.load(f) if doc.get("format") != FORMAT: fails.append("the index's format is %r" % doc.get("format")) names = [v.get("name") for v in doc["vectors"]] if len(set(names)) != len(names): fails.append("vector names repeat") loaded = {} for v in doc["vectors"]: try: loaded[v["name"]] = load(corpus_dir, v) except (OSError, ValueError) as e: fails.append("vector %s: %s" % (v.get("name"), e)) if len(loaded) != len(doc["vectors"]): for x in fails: print("FAIL " + x) print("FAIL live-contribution link corpus: the vectors do not load") return 1 fails += check_vectors(doc, loaded) listed = reasons_listed() ok = listed == REASONS print("%s §7's last list is exactly the reasons this verifier raises" % ("PASS" if ok else "FAIL")) if not ok: fails.append("§7's last list is %s" % listed) ok = doc.get("reasons") == listed print("%s the index lists exactly §7's last list" % ("PASS" if ok else "FAIL")) if not ok: fails.append("the index lists %s" % doc.get("reasons")) refused = {v.get("reason") for v in doc["vectors"] if v.get("outcome") == "refuse"} missing = [r for r in listed if r not in refused] print("%s every reason of §7's last list refuses a vector" % ("PASS" if not missing else "FAIL")) if missing: fails.append("no vector is refused %s" % ", ".join(missing)) records, series = [], [] for v in doc["vectors"]: if v.get("outcome") != "accept": continue if v["kind"] == "part": records.append((v["name"], loaded[v["name"]][0])) else: series.append((v["name"], loaded[v["name"]])) records += [("%s[%d]" % (v["name"], i), b) for i, b in enumerate(loaded[v["name"]])] if not records or not series: fails.append("the corpus holds no accepted part or no accepted series to mutate") fails += run_mutations(records, series) fails += run_dropped_checks(doc, loaded) for x in fails: print("FAIL " + x) print(("PASS" if not fails else "FAIL") + " live-contribution link corpus") return 1 if fails else 0 if __name__ == "__main__": if len(sys.argv) != 2: print("usage: live_contribution_verify.py ", file=sys.stderr) sys.exit(2) sys.exit(main(sys.argv[1]))