reticiulum-specification/tools/verify_propagation_peer.py
John Poole 5aa3920b76 Completed the propagation-node announce and peer-sync three-tier unit.
Added:
Tier 1 audit
Peer-sync flow
Deterministic vectors
Regenerator
Verifier
Corrected §5.8 regarding:
Directional peering-key identity ordering.
Public versus control destination handlers.
Permissive announce parser behavior.
Autopeer rules.
Peer Resource framing and admission.
PN_STAMP_THROTTLE = 180 seconds.
Two documented LXMF 0.9.7 hazards.
Verification: deterministic regeneration passed; full pinned suite passed 20/20; git diff --check passed. No commit created.
2026-06-08 17:32:55 -07:00

276 lines
11 KiB
Python

"""
Verifier for SPEC.md S5.8 propagation-node announces and peer synchronization.
"""
from __future__ import annotations
import json
import os
import sys
import LXMF
import RNS
from LXMF import LXStamper
from LXMF.Handlers import LXMFPropagationAnnounceHandler
from LXMF.LXMF import PN_META_NAME, pn_announce_data_is_valid, pn_name_from_app_data, pn_stamp_cost_from_app_data
from LXMF.LXMRouter import LXMRouter
from LXMF.LXMPeer import LXMPeer
from RNS.vendor import umsgpack
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
VECTORS_PATH = os.path.join(REPO_ROOT, "test-vectors", "propagation-peer.json")
IDS_PATH = os.path.join(REPO_ROOT, "test-vectors", "identities.json")
def fail(message: str) -> None:
print(f"FAIL: {message}")
sys.exit(1)
def load_json(path: str):
with open(path, "r", encoding="utf-8") as input_file:
return json.load(input_file)
def load_identities():
vectors = load_json(IDS_PATH)["vectors"]
alice = next(vector for vector in vectors if vector["label"] == "alice")
bob = next(vector for vector in vectors if vector["label"] == "bob")
return (
RNS.Identity.from_bytes(bytes.fromhex(alice["inputs"]["private_key_hex"])),
RNS.Identity.from_bytes(bytes.fromhex(bob["inputs"]["private_key_hex"])),
)
def verify_announce(vector: dict) -> None:
announce = bytes.fromhex(vector["announce"]["app_data_hex"])
if not pn_announce_data_is_valid(announce):
fail("S5.8.5 canonical propagation announce was rejected")
decoded = umsgpack.unpackb(announce)
expected = vector["announce"]["decoded"]
if (
decoded[:6] != [
expected["legacy_support"],
expected["timebase"],
expected["propagation_enabled"],
expected["transfer_limit_kb"],
expected["sync_limit_kb"],
expected["stamp_costs"],
]
or decoded[6][PN_META_NAME].decode("utf-8") != expected["name_utf8"]
):
fail("S5.8.5 propagation announce fields mismatch")
if pn_name_from_app_data(announce) != expected["name_utf8"] or pn_stamp_cost_from_app_data(announce) != 16:
fail("S5.8.5 announce helper parsing mismatch")
# Upstream 0.9.7 accepts >=7 elements, ignores element 0, coerces numeric
# fields with int(), and accepts 0/1 because they compare equal to bools.
permissive = [b"ignored", "1700000120", 1, "256", 10240.0, ["16", 3.0, "18"], {}, b"extra"]
if not pn_announce_data_is_valid(umsgpack.packb(permissive)):
fail("S5.8.5 parser no longer accepts documented permissive variant")
for malformed in [
umsgpack.packb([False] * 6),
umsgpack.packb([False, 1, 2, 3, 4, [5, 6, 7], {}]),
umsgpack.packb([False, 1, True, 3, 4, [5, 6], {}]),
]:
if pn_announce_data_is_valid(malformed):
fail("S5.8.5 malformed announce was accepted")
print("PASS S5.8.5 announce producer fields, helpers, and parser acceptance boundary")
def verify_announce_handler(vector: dict) -> None:
announce = bytes.fromhex(vector["announce"]["app_data_hex"])
destination_hash = bytes.fromhex("11" * 16)
class FakeRouter:
propagation_node = True
static_peers = []
autopeer = True
autopeer_maxdepth = 4
peers = {}
pending_outbound = []
outbound_processing_lock = type("Lock", (), {"locked": lambda self: False})()
calls = []
@staticmethod
def get_outbound_propagation_node():
return None
def peer(self, **kwargs):
self.calls.append(("peer", kwargs))
def unpeer(self, destination_hash, timestamp):
self.calls.append(("unpeer", destination_hash, timestamp))
router = FakeRouter()
handler = LXMFPropagationAnnounceHandler(router)
real_hops_to = RNS.Transport.hops_to
RNS.Transport.hops_to = lambda destination: 4
try:
handler.received_announce(destination_hash, None, announce, bytes(32), False)
if len(router.calls) != 1 or router.calls[0][0] != "peer":
fail("S5.8.5 eligible direct announce did not trigger autopeer")
handler.received_announce(destination_hash, None, announce, bytes(32), True)
if len(router.calls) != 1:
fail("S5.8.5 path response incorrectly triggered autopeer")
disabled = umsgpack.unpackb(announce)
disabled[2] = False
handler.received_announce(destination_hash, None, umsgpack.packb(disabled), bytes(32), False)
if router.calls[-1][0] != "unpeer":
fail("S5.8.5 disabled node announce did not trigger unpeer")
finally:
RNS.Transport.hops_to = real_hops_to
print("PASS S5.8.5 announce handler autopeer, path-response, and disabled-node behavior")
def make_offer_router(identity, peering_cost: int, entries: dict):
class FakeRouter:
throttled_peers = {}
from_static_only = False
static_peers = []
validated_peer_links = {}
propagation_entries = entries
router = FakeRouter()
router.identity = identity
router.peering_cost = peering_cost
return router
def verify_offer(vector: dict, alice_id, bob_id) -> None:
peer = vector["peering"]
peering_id = bytes.fromhex(peer["peering_id_hex"])
peering_key = bytes.fromhex(peer["peering_key_hex"])
transient_ids = [bytes.fromhex(value) for value in peer["offered_transient_ids_hex"]]
offer_data = umsgpack.unpackb(bytes.fromhex(peer["offer_data_hex"]))
if offer_data != [peering_key, transient_ids]:
fail("S5.8.2 /offer vector decode mismatch")
expected_id = bob_id.hash + alice_id.hash
if peering_id != expected_id or not LXStamper.validate_peering_key(peering_id, peering_key, vector["inputs"]["peering_cost"]):
fail("S5.8.4 directional peering key did not validate")
if LXStamper.validate_peering_key(alice_id.hash + bob_id.hash, peering_key, vector["inputs"]["peering_cost"]):
fail("S5.8.4 peering key unexpectedly validated in reverse direction")
link_id = bytes.fromhex("22" * 16)
cases = [
({}, True),
({transient_ids[0]: [], transient_ids[1]: []}, False),
({transient_ids[0]: []}, [transient_ids[1]]),
]
for entries, expected in cases:
router = make_offer_router(bob_id, vector["inputs"]["peering_cost"], entries)
response = LXMRouter.offer_request(router, "/offer", offer_data, bytes(16), link_id, alice_id, 0)
if response != expected or router.validated_peer_links.get(link_id) is not True:
fail(f"S5.8.2 /offer response selection mismatch: {response!r}")
invalid_key_offer = [bytes(reversed(peering_key)), transient_ids]
router = make_offer_router(bob_id, vector["inputs"]["peering_cost"], {})
if LXMRouter.offer_request(router, "/offer", invalid_key_offer, bytes(16), link_id, alice_id, 0) != LXMPeer.ERROR_INVALID_KEY:
fail("S5.8.2 invalid peering key was not rejected")
if LXMRouter.offer_request(router, "/offer", offer_data, bytes(16), link_id, None, 0) != LXMPeer.ERROR_NO_IDENTITY:
fail("S5.8.2 unidentified /offer was not rejected")
# Version-specific hazard: malformed one-element lists pass the guard and
# then return None from the exception handler instead of INVALID_DATA.
malformed_result = LXMRouter.offer_request(router, "/offer", [peering_key], bytes(16), link_id, alice_id, 0)
if malformed_result is not None:
fail("S5.8.2 malformed /offer behavior changed; update hazard documentation")
print("PASS S5.8.2/S5.8.4 directional peering key and /offer response behavior")
def verify_sync_resource(vector: dict) -> None:
peer = vector["peering"]
decoded = umsgpack.unpackb(bytes.fromhex(peer["sync_resource_plaintext_hex"]))
entries = [bytes.fromhex(value) for value in peer["submitted_entries_hex"]]
if decoded != [vector["inputs"]["sync_time"], entries]:
fail("S5.8.2 peer-sync Resource plaintext mismatch")
if not all(LXStamper.validate_pn_stamp(entry, 0)[0] is not None for entry in entries):
fail("S5.8.2 peer-sync entries did not carry valid propagation stamps")
print("PASS S5.8.2 peer-sync Resource is [time, stamped_entry_list]")
def verify_unvalidated_transfer_limit(vector: dict) -> None:
entries = [bytes.fromhex(value) for value in vector["peering"]["submitted_entries_hex"]]
class Data:
def __init__(self, payload):
self.payload = payload
def read(self):
return self.payload
class Link:
link_id = bytes.fromhex("33" * 16)
def __init__(self):
self.torn_down = False
@staticmethod
def get_remote_identity():
return None
def teardown(self):
self.torn_down = True
class Resource:
status = RNS.Resource.COMPLETE
def __init__(self, messages):
self.link = Link()
self.data = Data(umsgpack.packb([vector["inputs"]["sync_time"], messages]))
class FakeRouter:
propagation_stamp_cost = 0
propagation_stamp_cost_flexibility = 0
def __init__(self):
self.validated_peer_links = {}
self.client_propagation_messages_received = 0
self.received = []
def lxmf_propagation(self, lxmf_data, **kwargs):
self.received.append(lxmf_data)
single_router = FakeRouter()
single = Resource(entries[:1])
LXMRouter.propagation_resource_concluded(single_router, single)
if len(single_router.received) != 1 or single.link.torn_down:
fail("S5.8.2 unvalidated single-message transfer was not accepted")
multi_router = FakeRouter()
multi = Resource(entries)
LXMRouter.propagation_resource_concluded(multi_router, multi)
if multi_router.received or not multi.link.torn_down:
fail("S5.8.2 unvalidated multi-message transfer was not rejected")
print("PASS S5.8.2 unvalidated links may transfer one message, not multiple")
def verify_constants() -> None:
if (
LXMRouter.AUTOPEER,
LXMRouter.AUTOPEER_MAXDEPTH,
LXMRouter.MAX_PEERS,
LXMRouter.PEERING_COST,
LXMRouter.MAX_PEERING_COST,
LXMRouter.PN_STAMP_THROTTLE,
) != (True, 4, 20, 18, 26, 180):
fail("S5.8 peer constants changed")
print("PASS S5.8 peer defaults and throttle interval")
def main() -> None:
print(f"verify_propagation_peer.py against RNS {RNS.__version__} / LXMF {LXMF.__version__}")
vector = load_json(VECTORS_PATH)
alice_id, bob_id = load_identities()
verify_announce(vector)
verify_announce_handler(vector)
verify_offer(vector, alice_id, bob_id)
verify_sync_resource(vector)
verify_unvalidated_transfer_limit(vector)
verify_constants()
print("ALL PASS")
if __name__ == "__main__":
main()