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.
276 lines
11 KiB
Python
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()
|