open-standards/wai/jwp-conformance/chain_differential.py
Transaction Science 3d2939cd04 wai: a legacy chain's history cannot be replayed as new; gaps are told from forks and refilled
Fix round, items 1, 7, 9 and 10 of the review of W1-5.

- A legacy group's parent link is outside its signature, and its link hash does not
  cover the link: rewriting the first group's link to name the head re-linked the whole
  history, and every replay was accepted with the guard off. The legacy payload does sign
  `group_id`, so ReceiptChains now holds every legacy group to the strictly increasing
  `group_id` guard, whatever the opt-in setting; the guard stays opt-in for labelled
  groups, whose payload signs the link.
- ReceiptChains keeps, per chain, the link hashes of the groups it accepted. With a head
  held, a parent that names one of them other than the head is `chain_break` (a fork);
  a parent that names none is `parent_not_held` (a gap), and is recorded as requested. A
  requested group fills a gap, is history, and is exempt from the freshness bound, so one
  group a relay delays past the window no longer wedges the chain.
- GroupReceipt::check_parent treats a previous group of another key or track as not held:
  a null parent starts the chain, any other is `parent_not_held`; a parent that is not the
  held head's link names a group not held, and is `parent_not_held` too.
- jwp_verify.py mirrors all three, and computes days from civil dates in integers, so a
  `group_end` in year 0000 no longer crashes it.
- jwp-conformance: v6's base track is now labelled, so the headline vector does not rest
  on legacy links; new vectors for the legacy guard, the legacy replay and its loop, a
  labelled replay, a fork, a gap, the freshness gap refilled, receipts chained per key
  across tracks, and previous groups of another track or key. `group_chain_break`, which
  predates this branch, now holds a genuine chain break (a second chain head); its old
  input, a parent that is not the previous group held, is `group_parent_not_the_previous`
  and is `parent_not_held`. A lib test now regenerates and runs the pinned JWP corpus.
- chain_differential.py, the review's differential over signed chains, is committed and
  runs in CI on two seeds (0 differences on each).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 06:42:01 -04:00

143 lines
6.7 KiB
Python

#!/usr/bin/env python3
"""Adversarial differential for jwp-receipts §3 step 3 / §6.1 receipt chains per
(signer_pubkey, track_alias): wai-rs (`wai_jwp_vectors verdicts`) against
jwp_verify.py (`verdicts`). Generates `chain` vectors with no declared outcome:
two keys, two tracks, legacy and labelled payloads, genuine links, replays, replays
with a rewritten (unsigned, in legacy) parent, forks, cross-track and cross-key
parents, fresh genesis beside a held head, bad signatures, group_id up/equal/down,
group_end at, before and after the clock, offsets, fractions, leap second, year 0000
and non-dates; guard on/off; freshness on/off.
Every chain is signed with the corpus's published seeds, so a run is reproducible from
its seed. Exit status 0 only if the two verifiers give the same verdict on every chain.
usage: chain_differential.py <wai_jwp_vectors binary> [--n N] [--seed S]
"""
import json, os, random, subprocess, sys, tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
import jwp_verify as J # noqa: E402
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey # noqa: E402
from cryptography.hazmat.primitives import serialization # noqa: E402
KEYS = {s: Ed25519PrivateKey.from_private_bytes(bytes([s]) * 32) for s in (0x11, 0x22)}
def pk(k):
return k.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw).hex()
NOW = "2026-09-30T12:00:01Z"
ENDS = [NOW, "2026-09-30T12:00:01Z", "2026-09-30T11:59:01Z", "2026-09-30T11:59:00Z", "2026-09-30T11:00:00Z",
"2026-09-30T12:00:06Z", "2026-09-30T12:00:07Z", "2026-09-30T14:00:01+02:00", "2026-09-30T11:59:01.999Z",
"2026-09-30T11:59:60Z", "0000-03-01T00:00:00Z", "9999-12-31T23:59:59Z", "2026-02-29T00:00:00Z", "now",
"2026-09-30T12:00:01-00:00"]
def make(seed, track, gid, root_tag, parent, end, labelled, bad_sig=False):
g = {"kind": "group", "track_alias": track, "group_id": gid,
"root_hash": J.blake3(b"root %d" % root_tag).hex(), "leaf_count": 1, "joules_total": 0,
"group_start": "2026-09-30T12:00:00Z", "group_end": end, "signer_pubkey": pk(KEYS[seed]),
"signer_id": "did:example:publisher", "sig": "00" * 64,
"parent_receipt_hash": parent.hex() if parent is not None else None}
if labelled:
g["sign_version"] = 2
r = J.read_group(g)
sig = KEYS[seed].sign(J.payload(r))
if bad_sig:
sig = bytes([sig[0] ^ 1]) + sig[1:]
g["sig"] = sig.hex()
return g
def link(g):
return J.link(J.read_group(g))
def gen(rng, i):
guard = rng.random() < 0.5
fresh = rng.choice([None, None, {"now": NOW, "max_age_s": 60, "max_skew_s": 5}])
genuine = {} # (seed, track) -> [groups]
emitted = []
out = []
tag = 1000 * i
for _ in range(rng.randint(1, 9)):
tag += 1
seed = rng.choice([0x11, 0x11, 0x22])
track = rng.choice([7, 7, 8])
labelled = rng.random() < 0.5
chain = genuine.setdefault((seed, track), [])
end = rng.choice([NOW] * 6 + ENDS)
kind = rng.choice(["next"] * 6 + ["replay", "replay_rewritten", "fork", "cross_track", "cross_key",
"genesis", "bad_sig", "random_parent"])
gid_next = (chain[-1]["group_id"] + 1) if chain else rng.choice([0, 0, 5])
gid = rng.choice([gid_next] * 5 + [max(0, gid_next - 1), max(0, gid_next - 2), gid_next + 3])
if kind == "replay" and emitted:
g = rng.choice(emitted)
elif kind == "replay_rewritten" and emitted:
g = dict(rng.choice(emitted))
tgt = rng.choice(emitted)
g["parent_receipt_hash"] = link(tgt).hex() # unsigned in a legacy group
elif kind == "fork" and len(chain) >= 2:
g = make(seed, track, gid, tag, link(rng.choice(chain[:-1])), end, labelled)
elif kind == "cross_track":
other = genuine.get((seed, 15 - track)) or []
g = make(seed, track, gid, tag, link(other[-1]) if other else None, end, labelled)
elif kind == "cross_key":
other = genuine.get((0x33 - seed, track)) or []
g = make(seed, track, gid, tag, link(other[-1]) if other else None, end, labelled)
elif kind == "genesis":
g = make(seed, track, gid, tag, None, end, labelled)
elif kind == "bad_sig":
g = make(seed, track, gid, tag, link(chain[-1]) if chain else None, end, labelled, bad_sig=True)
elif kind == "random_parent":
g = make(seed, track, gid, tag, J.blake3(b"nothing %d" % tag), end, labelled)
else:
g = make(seed, track, gid, tag, link(chain[-1]) if chain else None, end, labelled)
chain.append(g)
emitted.append(g)
out.append(g)
return {"name": f"chain_diff_{i}", "procedure": "chain",
"input": {"groups": out, "guard": guard, "freshness": fresh},
"expected": {"outcome": "chain", "results": []}}
def main(argv):
rust_bin = argv[1]
n = int(argv[argv.index("--n") + 1]) if "--n" in argv else 3000
seed = argv[argv.index("--seed") + 1] if "--seed" in argv else "1"
rng = random.Random("chains " + seed)
vs = [gen(rng, i) for i in range(n)]
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f:
json.dump({"vectors": vs}, f)
path = f.name
try:
rs = subprocess.run([rust_bin, "verdicts", path], capture_output=True, text=True, check=True).stdout
py = subprocess.run([sys.executable, os.path.join(HERE, "jwp_verify.py"), "verdicts", path],
capture_output=True, text=True, check=True).stdout
finally:
os.unlink(path)
r = dict(l.split("\t", 1) for l in rs.splitlines())
p = dict(l.split("\t", 1) for l in py.splitlines())
if len(r) != n or len(p) != n:
print(f"verdict counts differ: {n} chains, wai-rs {len(r)}, python {len(p)}")
return 1
differ = [k for k in r if r[k] != p.get(k)]
crashes = [k for k in r if r[k] == "error" or p.get(k) == "error"]
tally = {}
for v in r.values():
for one in v.split(": ", 1)[-1].split(" / "):
tally[one] = tally.get(one, 0) + 1
print(f"{n} chains (seed {seed}); per group: " + ", ".join(f"{k} x{c}" for k, c in sorted(tally.items())))
print(f"{len(differ)} differ, {len(crashes)} error")
for k in differ[:10]:
v = next(x for x in vs if x["name"] == k)
ends = [g["group_end"] for g in v["input"]["groups"]]
print(f" {k}: rust={r[k]!r}\n python={p.get(k)!r}\n freshness={v['input']['freshness']} ends={ends}")
return 1 if differ or crashes else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))