Fault P3 from the crash-refund review, reproduced by a5851fba3: a standing grant's MintLedger could not be rebuilt from anything the enforcement point kept. A firing sealed no receipt, a child never exercised left no trace, and the type's doc named default() as the genesis, so a ledger reading fewer fires than happened looked like one that fired less. A restart from a ledger persisted after the first of three firings fired two more past max_fires. Each firing is now a decision sealed as a receipt. It is an allow on the standing grant with action mint, cost 0, the mint policy's id as resource and the trigger event's payload id as payload_hash, and it states minted_uj, the budget the child carries: one new signed member of the jcp/2 core. ReceiptLog::append_mint seals it, and the enforcement point records it before the child leaves (SPEC §4.6, §4.11). A firing debits nothing and accrues nothing, and group accounting does not count it as an action; the child's own receipts debit what its work spends. jcp_receipt::replay_mints lists a stream's firings, and MintLedger::of_firings rebuilds the ledger from them: one fire each, and their minted_uj summed. MintLedger::restore applies a stored copy beside it, keeping the higher count and the higher spend, so neither a stale copy nor a short stream hands a firing back. A refusal stating minted_uj fires nothing (C3). No receipt sealed before this changes its bytes or how it reads. SPEC §8.1 states that streams sealed before minted_uj hold no record of firings, so an enforcement point restoring one keeps the ledger it stored beside the rebuilt one, or holds the standing grant as fired out. §8.2 leaves firings out of group accounting, and §8 lists the member. Conformance: replay.json gains three entries at its end, two firings of one standing grant with a call by their child between them, per-entry mints figures and a mints_after total, all taken from the MintLedger the firings advanced, not from the receipts. The 23 existing entries and the 5 legacy receipts keep their bytes. verify_replay.py reads firings, checks mints_after, and refuses a changed minted_uj, changed totals and four more planted reader faults. C20 now seals each firing and checks that a restart from a stale ledger, restored beside the stream, stays rate-limited. replay_pack reads the firings through the library. The P3 test moves from jcp-grant, which cannot seal receipts, to jcp-receipt's replay_debits.rs. It keeps its name and its fault assertion, its restart now restores from the stream as well as the persisted ledger, and it no longer carries should_panic. jcp-grant keeps a unit test of the restore direction. Changelog: none Co-Authored-By: Claude Opus 5.5 <[email protected]>
577 lines
24 KiB
Python
577 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""A second reader of the published replay log, conformance/v1/replay.json (SPEC §8.1).
|
|
|
|
It shares no code with the reference implementation, and needs no package: det-CBOR,
|
|
Ed25519 (RFC 8032) and BLAKE3 are written out below from their specifications. For every
|
|
entry of the log it checks that
|
|
|
|
1. the envelope is a COSE_Sign1 under the published steward key, over a canonical core
|
|
equal to the published wire form;
|
|
2. `seq` counts from zero and `prev` is the BLAKE3 of the previous envelope;
|
|
3. what SPEC §8.1's rules read from the core is what the runtime's ledgers moved by:
|
|
the joules debited, the money debited, and what a tainted sub-budget accrued. A hold
|
|
is read against the whole log: settled by a later entry it restores nothing, and
|
|
settled by none it restores its `held_uj`, as a restart holds it. A firing of a
|
|
standing grant debits nothing, and counts one fire and its `minted_uj` in the
|
|
standing grant's mint ledger (SPEC §4.6).
|
|
|
|
Then the sums must be the ledgers the runtime and the firings ended with, and each
|
|
receipt in `legacy` must verify, state none of the member it was sealed before, and read
|
|
as published.
|
|
|
|
verify_replay.py [--selftest] [DIR]
|
|
|
|
DIR defaults to conformance/v1 beside this file. --selftest also checks that the reader
|
|
fails: a changed debit, a dropped or moved entry, a changed total, and a reader with each
|
|
planted fault must all be refused. Exit 0 when everything holds, 1 otherwise.
|
|
"""
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import struct
|
|
import sys
|
|
|
|
# The two deny reasons the debit point seals (SPEC §4.9, §4.12).
|
|
DEBIT_POINT_REASONS = ("caveat-unsatisfied", "window-exceeded")
|
|
|
|
|
|
class Refused(Exception):
|
|
pass
|
|
|
|
|
|
# ---- det-CBOR (RFC 8949 §4.2.1), the subset a JCR-1 receipt uses ------------------------
|
|
|
|
def cbor_read(b, i=0):
|
|
head = b[i]
|
|
major, info = head >> 5, head & 31
|
|
i += 1
|
|
if info < 24:
|
|
n = info
|
|
elif info <= 27:
|
|
width = 1 << (info - 24)
|
|
n = int.from_bytes(b[i:i + width], "big")
|
|
i += width
|
|
else:
|
|
raise Refused(f"CBOR additional info {info}")
|
|
if major == 0:
|
|
return n, i
|
|
if major == 1:
|
|
return -1 - n, i
|
|
if major == 2:
|
|
return b[i:i + n], i + n
|
|
if major == 3:
|
|
return b[i:i + n].decode(), i + n
|
|
if major == 4:
|
|
out = []
|
|
for _ in range(n):
|
|
v, i = cbor_read(b, i)
|
|
out.append(v)
|
|
return out, i
|
|
if major == 5:
|
|
out = {}
|
|
for _ in range(n):
|
|
k, i = cbor_read(b, i)
|
|
out[k], i = cbor_read(b, i)
|
|
return out, i
|
|
if major == 6:
|
|
return cbor_read(b, i) # the tag itself is checked by the caller
|
|
raise Refused(f"CBOR major type {major}")
|
|
|
|
|
|
def cbor_head(major, n):
|
|
if n < 24:
|
|
return bytes([major << 5 | n])
|
|
for info, width in ((24, 1), (25, 2), (26, 4), (27, 8)):
|
|
if n < 1 << (8 * width):
|
|
return bytes([major << 5 | info]) + n.to_bytes(width, "big")
|
|
raise Refused("CBOR length past 64 bits")
|
|
|
|
|
|
def cbor_write(v):
|
|
if isinstance(v, int):
|
|
return cbor_head(0, v) if v >= 0 else cbor_head(1, -1 - v)
|
|
if isinstance(v, bytes):
|
|
return cbor_head(2, len(v)) + v
|
|
if isinstance(v, str):
|
|
e = v.encode()
|
|
return cbor_head(3, len(e)) + e
|
|
if isinstance(v, list):
|
|
return cbor_head(4, len(v)) + b"".join(cbor_write(x) for x in v)
|
|
if isinstance(v, dict):
|
|
keys = sorted(v, key=lambda k: (len(k.encode()), k.encode()))
|
|
return cbor_head(5, len(v)) + b"".join(cbor_write(k) + cbor_write(v[k]) for k in keys)
|
|
raise Refused(f"no CBOR form for {type(v)}")
|
|
|
|
|
|
# ---- Ed25519 verification (RFC 8032 §5.1) -----------------------------------------------
|
|
|
|
P = 2**255 - 19
|
|
L = 2**252 + 27742317777372353535851937790883648493
|
|
D = -121665 * pow(121666, P - 2, P) % P
|
|
SQRT_M1 = pow(2, (P - 1) // 4, P)
|
|
|
|
|
|
def point_add(a, b):
|
|
x1, y1, z1, t1 = a
|
|
x2, y2, z2, t2 = b
|
|
e = (y1 - x1) * (y2 - x2) % P
|
|
f = (y1 + x1) * (y2 + x2) % P
|
|
g = 2 * t1 * t2 * D % P
|
|
h = 2 * z1 * z2 % P
|
|
i, j, k, m = f - e, h - g, h + g, f + e
|
|
return (i * j % P, k * m % P, j * k % P, i * m % P)
|
|
|
|
|
|
def scalar_mul(s, p):
|
|
q = (0, 1, 1, 0)
|
|
while s:
|
|
if s & 1:
|
|
q = point_add(q, p)
|
|
p = point_add(p, p)
|
|
s >>= 1
|
|
return q
|
|
|
|
|
|
def decompress(b):
|
|
if len(b) != 32:
|
|
return None
|
|
y = int.from_bytes(b, "little")
|
|
sign = y >> 255
|
|
y &= (1 << 255) - 1
|
|
if y >= P:
|
|
return None
|
|
u = (y * y - 1) * pow(D * y * y + 1, P - 2, P) % P
|
|
x = pow(u, (P + 3) // 8, P)
|
|
if (x * x - u) % P:
|
|
x = x * SQRT_M1 % P
|
|
if (x * x - u) % P:
|
|
return None
|
|
if x == 0 and sign:
|
|
return None
|
|
if x & 1 != sign:
|
|
x = P - x
|
|
return (x, y, 1, x * y % P)
|
|
|
|
|
|
BASE = decompress((4 * pow(5, P - 2, P) % P).to_bytes(32, "little"))
|
|
|
|
|
|
def same_point(a, b):
|
|
x1, y1, z1, _ = a
|
|
x2, y2, z2, _ = b
|
|
return (x1 * z2 - x2 * z1) % P == 0 and (y1 * z2 - y2 * z1) % P == 0
|
|
|
|
|
|
def ed25519_verify(key, message, signature):
|
|
a, r = decompress(key), decompress(signature[:32])
|
|
s = int.from_bytes(signature[32:], "little")
|
|
if a is None or r is None or s >= L:
|
|
return False
|
|
h = int.from_bytes(hashlib.sha512(signature[:32] + key + message).digest(), "little") % L
|
|
return same_point(scalar_mul(s, BASE), point_add(r, scalar_mul(h, a)))
|
|
|
|
|
|
# ---- BLAKE3, hash mode ------------------------------------------------------------------
|
|
|
|
IV = [0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19]
|
|
PERMUTATION = [2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8]
|
|
CHUNK_START, CHUNK_END, PARENT, ROOT = 1, 2, 4, 8
|
|
MASK = 0xFFFFFFFF
|
|
|
|
|
|
def rotr(x, n):
|
|
return (x >> n | x << (32 - n)) & MASK
|
|
|
|
|
|
def mix(s, a, b, c, d, x, y):
|
|
s[a] = (s[a] + s[b] + x) & MASK
|
|
s[d] = rotr(s[d] ^ s[a], 16)
|
|
s[c] = (s[c] + s[d]) & MASK
|
|
s[b] = rotr(s[b] ^ s[c], 12)
|
|
s[a] = (s[a] + s[b] + y) & MASK
|
|
s[d] = rotr(s[d] ^ s[a], 8)
|
|
s[c] = (s[c] + s[d]) & MASK
|
|
s[b] = rotr(s[b] ^ s[c], 7)
|
|
|
|
|
|
def compress(node):
|
|
cv, words, counter, block_len, flags = node
|
|
s = cv[:8] + IV[:4] + [counter & MASK, counter >> 32 & MASK, block_len, flags]
|
|
m = list(words)
|
|
for _ in range(7):
|
|
for i, (a, b, c, d) in enumerate(((0, 4, 8, 12), (1, 5, 9, 13), (2, 6, 10, 14), (3, 7, 11, 15),
|
|
(0, 5, 10, 15), (1, 6, 11, 12), (2, 7, 8, 13), (3, 4, 9, 14))):
|
|
mix(s, a, b, c, d, m[2 * i], m[2 * i + 1])
|
|
m = [m[i] for i in PERMUTATION]
|
|
return [s[i] ^ s[i + 8] for i in range(8)]
|
|
|
|
|
|
def chunk_node(chunk, index):
|
|
blocks = [chunk[i:i + 64] for i in range(0, len(chunk), 64)] or [b""]
|
|
cv = IV[:]
|
|
for i, block in enumerate(blocks):
|
|
flags = (CHUNK_START if i == 0 else 0) | (CHUNK_END if i == len(blocks) - 1 else 0)
|
|
words = struct.unpack("<16I", block.ljust(64, b"\0"))
|
|
if i == len(blocks) - 1:
|
|
return (cv, words, index, len(block), flags)
|
|
cv = compress((cv, words, index, 64, flags))
|
|
|
|
|
|
def tree_node(chunks, lo, hi):
|
|
if hi - lo == 1:
|
|
return chunk_node(chunks[lo], lo)
|
|
left = 1 << ((hi - lo - 1).bit_length() - 1)
|
|
words = compress(tree_node(chunks, lo, lo + left)) + compress(tree_node(chunks, lo + left, hi))
|
|
return (IV[:], words, 0, 64, PARENT)
|
|
|
|
|
|
def blake3(data):
|
|
chunks = [data[i:i + 1024] for i in range(0, len(data), 1024)] or [b""]
|
|
cv, words, counter, block_len, flags = tree_node(chunks, 0, len(chunks))
|
|
return struct.pack("<8I", *compress((cv, words, counter, block_len, flags | ROOT)))
|
|
|
|
|
|
# ---- The receipt ------------------------------------------------------------------------
|
|
|
|
def open_envelope(envelope, steward, key):
|
|
"""The signed core of a COSE_Sign1 envelope, or Refused."""
|
|
if envelope[:1] != b"\xd2":
|
|
raise Refused("not a tagged COSE_Sign1")
|
|
(protected, unprotected, payload, signature), end = cbor_read(envelope)
|
|
header, _ = cbor_read(protected)
|
|
if end != len(envelope) or unprotected != {}:
|
|
raise Refused("not the COSE_Sign1 shape")
|
|
if header.get(1) != -8 or header.get(4) != steward.encode():
|
|
raise Refused("not this steward's EdDSA envelope")
|
|
if not ed25519_verify(key, cbor_write(["Signature1", protected, b"", payload]), signature):
|
|
raise Refused("the signature does not verify")
|
|
core, end = cbor_read(payload)
|
|
if end != len(payload) or cbor_write(core) != payload:
|
|
raise Refused("the core is not canonical")
|
|
return core
|
|
|
|
|
|
def is_hold(core):
|
|
"""An allow stating `held_uj` (SPEC §8.1). A refusal debits nothing, so it holds nothing."""
|
|
return "held_uj" in core and core["verdict"] == "allow"
|
|
|
|
|
|
def is_mint(core):
|
|
"""A firing of a standing grant: an allow stating `minted_uj` (SPEC §4.6, §8.1). A refusal
|
|
mints nothing, so it fires nothing."""
|
|
return "minted_uj" in core and core["verdict"] == "allow"
|
|
|
|
|
|
def read_debits(core):
|
|
"""What this receipt debited, by SPEC §8.1's rules: [(grant, uj)]. A hold debits nothing
|
|
itself: its work is the settling entry's, or, settled by none, read_held's. A firing
|
|
debits nothing: it runs no work, and its child's receipts debit what the child spends."""
|
|
grants = core.get("chain_ids", [core["grant_id"]])
|
|
if is_hold(core) or is_mint(core):
|
|
return []
|
|
if "chain_debited_uj" in core:
|
|
if len(core["chain_debited_uj"]) != len(grants):
|
|
raise Refused("chain_debited_uj does not match chain_ids")
|
|
return list(zip(grants, core["chain_debited_uj"]))
|
|
allowed = core["verdict"] == "allow"
|
|
if "debited_uj" in core:
|
|
uj = core["debited_uj"]
|
|
elif allowed or core.get("deny_reason") in DEBIT_POINT_REASONS:
|
|
uj = core["cost_uj"]
|
|
else:
|
|
uj = 0
|
|
return [] if uj == 0 and not allowed else [(g, uj) for g in grants]
|
|
|
|
|
|
def read_money(core):
|
|
"""What this receipt debited in money, by SPEC §8.1's rules: [(grant, minor)].
|
|
|
|
Only a stated figure counts, and a grant debited none is left out."""
|
|
grants = core.get("chain_ids", [core["grant_id"]])
|
|
if "chain_debited_minor" in core:
|
|
per_link = core["chain_debited_minor"]
|
|
if len(per_link) != len(grants):
|
|
raise Refused("chain_debited_minor does not match chain_ids")
|
|
elif "debited_minor" in core:
|
|
per_link = [core["debited_minor"]] * len(grants)
|
|
else:
|
|
return []
|
|
return [(g, m) for g, m in zip(grants, per_link) if m]
|
|
|
|
|
|
def read_tainted(core):
|
|
"""What this receipt accrued against a tainted sub-budget, by SPEC §8.1: [(grant, uj)].
|
|
|
|
`tainted_uj` where stated; otherwise a core with a flow block that debited accrued its
|
|
cost_uj, and any other core nothing."""
|
|
grants = core.get("chain_ids", [core["grant_id"]])
|
|
if "tainted_uj" in core:
|
|
uj = core["tainted_uj"]
|
|
elif "flow" in core and read_debits(core):
|
|
uj = core["cost_uj"]
|
|
else:
|
|
uj = 0
|
|
return [(g, uj) for g in grants] if uj else []
|
|
|
|
|
|
def read_mints(core):
|
|
"""What this receipt adds to a standing grant's mint ledger, by SPEC §8.1: one fire and its
|
|
`minted_uj`, for the standing grant it names. [(grant, fires, uj)]."""
|
|
return [(core["grant_id"], 1, core["minted_uj"])] if is_mint(core) else []
|
|
|
|
|
|
def read_settled(cores):
|
|
"""The ids of the entries some entry settles (SPEC §8.1): each `settles` member."""
|
|
return {c["settles"] for c in cores if "settles" in c}
|
|
|
|
|
|
def read_held(core, unresolved):
|
|
"""What a hold restores, by SPEC §8.1: nothing when an entry settles it; otherwise its
|
|
`held_uj` from every grant it names, and as much against each one's tainted
|
|
sub-budget where it carries a flow block. ([(grant, uj)], [(grant, uj)])."""
|
|
if not unresolved or not is_hold(core):
|
|
return [], []
|
|
held = [(g, core["held_uj"]) for g in core.get("chain_ids", [core["grant_id"]])]
|
|
return held, (held if "flow" in core else [])
|
|
|
|
|
|
# The dimensions a log is read in: the entry's list, its unit, and the total it sums to.
|
|
DIMENSIONS = (("debits", "uj", "spent_after"), ("money", "minor", "money_after"), ("tainted", "uj", "tainted_after"))
|
|
|
|
|
|
def published(entry, dim, unit):
|
|
return [(d["grant_hex"], d[unit]) for d in entry[dim]]
|
|
|
|
|
|
def published_mints(entry):
|
|
return [(d["grant_hex"], d["fires"], d["uj"]) for d in entry["mints"]]
|
|
|
|
|
|
def check(doc, steward, key, read=read_debits, money=read_money, tainted=read_tainted, held=read_held,
|
|
settled=read_settled, mints=read_mints):
|
|
"""Refused unless the log reads as published. Returns the entry count."""
|
|
readers = (read, money, tainted)
|
|
prev = "0" * 64
|
|
cores, ids = [], []
|
|
for i, entry in enumerate(doc["entries"]):
|
|
envelope = bytes.fromhex(entry["envelope_hex"])
|
|
core = open_envelope(envelope, steward, key)
|
|
if core != entry["wire"]:
|
|
raise Refused(f"entry {i}: the signed core is not the published wire form")
|
|
if core["seq"] != i or core["prev"] != prev:
|
|
raise Refused(f"entry {i}: the chain is broken here")
|
|
prev = blake3(envelope).hex()
|
|
cores.append(core)
|
|
ids.append(prev)
|
|
done = settled(cores)
|
|
ledgers = [{}, {}, {}]
|
|
fired = {}
|
|
for i, (entry, core, own) in enumerate(zip(doc["entries"], cores, ids)):
|
|
held_uj, held_tainted = held(core, own not in done)
|
|
reads = (read(core) + held_uj, money(core), tainted(core) + held_tainted)
|
|
for got, (dim, unit, _), ledger in zip(reads, DIMENSIONS, ledgers):
|
|
want = published(entry, dim, unit)
|
|
if got != want:
|
|
raise Refused(f"entry {i}: read {dim} {got}, the ledger moved by {want}")
|
|
for grant, n in got:
|
|
ledger[grant] = ledger.get(grant, 0) + n
|
|
got = mints(core)
|
|
if got != published_mints(entry):
|
|
raise Refused(f"entry {i}: read mints {got}, the mint ledger moved by {published_mints(entry)}")
|
|
for grant, fires, uj in got:
|
|
was = fired.get(grant, {"fires": 0, "uj": 0})
|
|
fired[grant] = {"fires": was["fires"] + fires, "uj": was["uj"] + uj}
|
|
for (_, _, total), ledger in zip(DIMENSIONS, ledgers):
|
|
if ledger != doc[total]:
|
|
raise Refused(f"the log replays to {ledger}, not {total} {doc[total]}")
|
|
if fired != doc["mints_after"]:
|
|
raise Refused(f"the log's firings rebuild {fired}, not mints_after {doc['mints_after']}")
|
|
for i, entry in enumerate(doc["legacy"]):
|
|
core = open_envelope(bytes.fromhex(entry["envelope_hex"]), steward, key)
|
|
if core != entry["wire"] or entry["sealed_before"] in core:
|
|
raise Refused(f"legacy {i}: not a receipt without {entry['sealed_before']}")
|
|
for reader, (dim, unit, _) in zip(readers, DIMENSIONS):
|
|
if reader(core) != published(entry, dim, unit):
|
|
raise Refused(f"legacy {i}: read {dim} {reader(core)}")
|
|
if mints(core) != published_mints(entry):
|
|
raise Refused(f"legacy {i}: read mints {mints(core)}")
|
|
return len(doc["entries"])
|
|
|
|
|
|
def check_coverage(doc):
|
|
"""Refused unless the log still holds each form the rules distinguish."""
|
|
wires = [e["wire"] for e in doc["entries"]]
|
|
denials = [w for w in wires if w["verdict"] == "deny"]
|
|
ids = [blake3(bytes.fromhex(e["envelope_hex"])).hex() for e in doc["entries"]]
|
|
done = read_settled(wires)
|
|
open_holds = [i for i, w in enumerate(wires) if is_hold(w) and ids[i] not in done]
|
|
forms = {
|
|
"an allow stating nothing": any(w["verdict"] == "allow" and "debited_uj" not in w for w in wires),
|
|
"a denial that debited": any(w.get("debited_uj", 0) > 0 for w in denials),
|
|
"a refusal stating its zero": any(w.get("debited_uj") == 0 for w in denials),
|
|
"a refusal stating nothing": any("debited_uj" not in w for w in denials),
|
|
"a debit below its cost": any(w.get("debited_uj", w["cost_uj"]) < w["cost_uj"] and w["debited_uj"] for w in wires),
|
|
"a chained receipt": any("chain_ids" in w for w in wires),
|
|
"links debited differently": any("chain_debited_uj" in w for w in wires),
|
|
"a money debit": any("debited_minor" in w for w in wires),
|
|
"links debited money differently": any("chain_debited_minor" in w for w in wires),
|
|
"an accrual its flow block says": any("flow" in w and "tainted_uj" not in w and read_tainted(w) for w in wires),
|
|
"a tainted refusal with a cost": any("flow" in w and w["verdict"] == "deny" and w["cost_uj"] > 0 for w in wires),
|
|
"an accrual stated": any("tainted_uj" in w and "flow" not in w for w in wires),
|
|
"a hold an entry settles": any(is_hold(w) and ids[i] in done for i, w in enumerate(wires)),
|
|
"a hold nothing settles": any("flow" not in wires[i] for i in open_holds),
|
|
"a tainted hold nothing settles": any("flow" in wires[i] for i in open_holds),
|
|
"an entry after an unsettled hold, not settling it": any(i + 1 < len(wires) for i in open_holds),
|
|
"a firing of a standing grant": any(is_mint(w) for w in wires),
|
|
"two firings of one standing grant": any(
|
|
sum(is_mint(v) and v["grant_id"] == w["grant_id"] for v in wires) > 1 for w in wires if is_mint(w)),
|
|
"a call under a child a firing minted": any(
|
|
not is_mint(w) and w.get("chain_ids", [None])[0] in {v["grant_id"] for v in wires if is_mint(v)}
|
|
for w in wires),
|
|
"a legacy receipt for each member": {e["sealed_before"] for e in doc["legacy"]}
|
|
== {"debited_uj", "debited_minor", "tainted_uj", "settles"},
|
|
}
|
|
missing = [name for name, held in forms.items() if not held]
|
|
if missing:
|
|
raise Refused(f"the log no longer holds: {', '.join(missing)}")
|
|
|
|
|
|
# ---- Self-test: the reader must be able to fail -----------------------------------------
|
|
|
|
def refuses(name, doc, steward, key, **readers):
|
|
try:
|
|
check(doc, steward, key, **readers)
|
|
except Refused:
|
|
return
|
|
raise SystemExit(f"self-test: {name} was accepted")
|
|
|
|
|
|
def with_changed_member(doc, at, member):
|
|
"""`doc` with entry `at`'s stated `member` changed in place, wire form to match."""
|
|
out = json.loads(json.dumps(doc))
|
|
entry = out["entries"][at]
|
|
key = cbor_write(member)
|
|
envelope = bytearray.fromhex(entry["envelope_hex"])
|
|
start = envelope.index(key) + len(key)
|
|
value, end = cbor_read(bytes(envelope), start)
|
|
# An integer's low bit; a hex id's last digit.
|
|
changed = value ^ 1 if isinstance(value, int) else value[:-1] + ("1" if value[-1] == "0" else "0")
|
|
envelope[start:end] = cbor_write(changed)
|
|
entry["envelope_hex"] = envelope.hex()
|
|
entry["wire"][member] = changed
|
|
return out
|
|
|
|
|
|
def selftest(doc, steward, key):
|
|
if blake3(b"").hex() != "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262":
|
|
raise SystemExit("self-test: BLAKE3 of nothing")
|
|
if blake3(bytes(i % 251 for i in range(1025))).hex() != "d00278ae47eb27b34faecf67b4fe263f82d5412916c1ffd97c8cb7fb814b8444":
|
|
raise SystemExit("self-test: BLAKE3 past one chunk")
|
|
|
|
for member in ("debited_uj", "debited_minor", "tainted_uj", "held_uj", "settles", "minted_uj"):
|
|
stated = [i for i, e in enumerate(doc["entries"]) if member in e["wire"]]
|
|
if not stated:
|
|
raise SystemExit(f"self-test: no entry states {member}")
|
|
for at in stated:
|
|
refuses(f"{member} changed on signed entry {at}", with_changed_member(doc, at, member), steward, key)
|
|
|
|
breach = next(i for i, e in enumerate(doc["entries"]) if e["wire"].get("debited_uj", 0) > 0 and e["wire"]["verdict"] == "deny")
|
|
dropped = json.loads(json.dumps(doc))
|
|
del dropped["entries"][breach]
|
|
refuses("a breach dropped from the log", dropped, steward, key)
|
|
|
|
moved = json.loads(json.dumps(doc))
|
|
moved["entries"][0], moved["entries"][1] = moved["entries"][1], moved["entries"][0]
|
|
refuses("two entries swapped", moved, steward, key)
|
|
|
|
for _, _, name in DIMENSIONS:
|
|
for delta in (1, -1):
|
|
total = json.loads(json.dumps(doc))
|
|
grant = next(iter(total[name]))
|
|
total[name][grant] += delta
|
|
refuses(f"{name} changed by {delta}", total, steward, key)
|
|
for figure in ("fires", "uj"):
|
|
for delta in (1, -1):
|
|
total = json.loads(json.dumps(doc))
|
|
total["mints_after"][next(iter(total["mints_after"]))][figure] += delta
|
|
refuses(f"mints_after {figure} changed by {delta}", total, steward, key)
|
|
|
|
faults = {
|
|
"a reader dropping a breach": lambda c: read_debits(c) if c["verdict"] == "allow" else [],
|
|
"a reader counting a refusal": lambda c: [(g, c["cost_uj"]) for g in c.get("chain_ids", [c["grant_id"]])],
|
|
"a reader losing an ancestor's debit": lambda c: read_debits(c)[-1:],
|
|
"a reader taking one figure for every link": lambda c: [(g, c.get("debited_uj", c["cost_uj"])) for g, _ in read_debits(c)],
|
|
"a reader reading an old denial as zero": lambda c: read_debits(c) if "debited_uj" in c or c["verdict"] == "allow" else [],
|
|
}
|
|
for name, read in faults.items():
|
|
refuses(name, doc, steward, key, read=read)
|
|
|
|
def grants(c):
|
|
return c.get("chain_ids", [c["grant_id"]])
|
|
|
|
money_faults = {
|
|
"a reader restoring no money": lambda c: [],
|
|
"a reader taking one money figure for every link":
|
|
lambda c: [(g, c["debited_minor"]) for g in grants(c)] if c.get("debited_minor") else [],
|
|
}
|
|
for name, money in money_faults.items():
|
|
refuses(name, doc, steward, key, money=money)
|
|
|
|
tainted_faults = {
|
|
"a reader restoring no accrual": lambda c: [],
|
|
"a reader ignoring a flow block": lambda c: [(g, c["tainted_uj"]) for g in grants(c)] if c.get("tainted_uj") else [],
|
|
"a reader ignoring a stated accrual": lambda c: [] if "tainted_uj" in c else read_tainted(c),
|
|
"a reader counting a tainted refusal": lambda c: [(g, c["cost_uj"]) for g in grants(c)] if "flow" in c else read_tainted(c),
|
|
}
|
|
for name, tainted in tainted_faults.items():
|
|
refuses(name, doc, steward, key, tainted=tainted)
|
|
|
|
held_faults = {
|
|
"a reader ignoring a hold nothing settles": lambda c, unresolved: ([], []),
|
|
"a reader counting a settled hold": lambda c, unresolved: read_held(c, True),
|
|
"a reader ignoring a hold's flow block": lambda c, unresolved: (read_held(c, unresolved)[0], []),
|
|
"a reader holding a hold's cost_uj": lambda c, unresolved:
|
|
([(g, c["cost_uj"]) for g, _ in read_held(c, unresolved)[0]], read_held(c, unresolved)[1]),
|
|
}
|
|
for name, held in held_faults.items():
|
|
refuses(name, doc, steward, key, held=held)
|
|
refuses("a reader taking the next entry as a hold's settle", doc, steward, key,
|
|
settled=lambda cores: {c["prev"] for c in cores})
|
|
|
|
refuses("a reader reading a firing as a call", doc, steward, key,
|
|
read=lambda c: [(g, c["cost_uj"]) for g in grants(c)] if is_mint(c) else read_debits(c))
|
|
mint_faults = {
|
|
"a reader counting no firing": lambda c: [],
|
|
"a reader counting a fire but not what it minted": lambda c: [(g, f, 0) for g, f, _ in read_mints(c)],
|
|
"a reader counting a chained call as a firing":
|
|
lambda c: read_mints(c) or ([(c["chain_ids"][0], 1, 0)] if "chain_ids" in c else []),
|
|
}
|
|
for name, mints in mint_faults.items():
|
|
refuses(name, doc, steward, key, mints=mints)
|
|
|
|
|
|
def main():
|
|
args = sys.argv[1:]
|
|
run_selftest = "--selftest" in args
|
|
args = [a for a in args if a != "--selftest"]
|
|
pack = args[0] if args else os.path.join(os.path.dirname(os.path.abspath(__file__)), "v1")
|
|
with open(os.path.join(pack, "meta.json"), encoding="utf-8") as f:
|
|
meta = json.load(f)
|
|
with open(os.path.join(pack, "replay.json"), encoding="utf-8") as f:
|
|
doc = json.load(f)
|
|
steward, key = meta["steward"], bytes.fromhex(meta["pubkey_hex"])
|
|
try:
|
|
count = check(doc, steward, key)
|
|
check_coverage(doc)
|
|
except Refused as e:
|
|
print(f"verify_replay: {e}", file=sys.stderr)
|
|
return 1
|
|
if run_selftest:
|
|
selftest(doc, steward, key)
|
|
print(f"verify_replay: {count} entries and {len(doc['legacy'])} legacy receipts read as published"
|
|
+ (", and every fault was refused" if run_selftest else ""))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|