reticiulum-specification/tools/verify_propagated_lxmf.py
John Poole a396794c89 Completed the propagated-LXMF three-tier work unit.
Key corrections:

Transient IDs are full 32-byte hashes, not truncated.
Accepted submissions always include a 32-byte propagation stamp, even at cost zero.
/get returns a plain message list inside the generic Link RESPONSE envelope.
Corrected propagation error constants.
Corrected recipient and sender flow documentation.
Added:

Tier 1 audit
Deterministic vectors
Vector regenerator
Runtime verifier
Verification:

Vector regeneration is byte-identical across runs.
Full pinned suite: 19 passed, 0 failed.
git diff --check passes.
No commit was created. The next logical work unit is propagation-node announce and peer-to-peer /offer synchronization.
2026-06-08 17:23:33 -07:00

205 lines
8.2 KiB
Python

"""
Verifier for SPEC.md S5.8 PROPAGATED LXMF submission, storage, and /get.
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import LXMF
import RNS
from LXMF.LXMRouter import LXMRouter
from LXMF.LXMessage import LXMessage
from LXMF.LXMPeer import LXMPeer
from LXMF import LXStamper
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", "propagated-lxmf.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 init_minimal_rns():
cfg_dir = tempfile.mkdtemp(prefix="rns-verify-propagated-")
cfg_path = os.path.join(cfg_dir, "config")
with open(cfg_path, "w", encoding="utf-8") as config:
config.write("[reticulum]\nenable_transport = No\nshare_instance = No\n")
return RNS.Reticulum(configdir=cfg_dir, loglevel=0)
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_submission(vector: dict, bob_dest, wrong_identity, expected_representation: int) -> None:
lxmf_data = bytes.fromhex(vector["lxmf_data_hex"])
submitted_entry = bytes.fromhex(vector["submitted_entry_hex"])
propagation_stamp = bytes.fromhex(vector["propagation_stamp_hex"])
transient_id = bytes.fromhex(vector["transient_id_hex"])
propagation_packed = bytes.fromhex(vector["propagation_packed_hex"])
decoded = umsgpack.unpackb(propagation_packed)
if decoded[1] != [submitted_entry] or submitted_entry != lxmf_data + propagation_stamp:
fail("S5.8 submission bundle is not [time, [lxmf_data || propagation_stamp]]")
if transient_id != RNS.Identity.full_hash(lxmf_data) or len(transient_id) != 32:
fail("S5.8 transient_id is not the full 32-byte SHA-256 of lxmf_data")
validated_id, validated_data, _, validated_stamp = LXStamper.validate_pn_stamp(
submitted_entry, 0
)
if (
validated_id != transient_id
or validated_data != lxmf_data
or validated_stamp != propagation_stamp
):
fail("S5.8 propagation stamp did not validate and strip from submitted entry")
if len(propagation_packed) != vector["propagation_packed_length"]:
fail("S5.8 propagation_packed length mismatch")
expected = (
LXMessage.PACKET
if len(propagation_packed) <= LXMessage.LINK_PACKET_MAX_CONTENT
else LXMessage.RESOURCE
)
if expected != expected_representation:
fail("S5.8 propagation representation boundary mismatch")
decrypted = bob_dest.decrypt(lxmf_data[LXMessage.DESTINATION_LENGTH :])
if decrypted is None:
fail("S5.8 recipient could not decrypt propagated body")
if wrong_identity.decrypt(lxmf_data[LXMessage.DESTINATION_LENGTH :]) is not None:
fail("S5.8 non-recipient identity decrypted propagated body")
parsed = LXMessage.unpack_from_bytes(lxmf_data[:LXMessage.DESTINATION_LENGTH] + decrypted)
if not parsed.signature_validated:
fail("S5.8 recipient-decrypted propagated LXMF signature did not validate")
def verify_get_handler(vector: dict, bob_id) -> None:
lxmf_data = bytes.fromhex(vector["boundary"]["largest_packet"]["lxmf_data_hex"])
transient_id = RNS.Identity.full_hash(lxmf_data)
stamp = bytes.fromhex(vector["inputs"]["propagation_stamp_hex"])
with tempfile.NamedTemporaryFile() as message_file:
message_file.write(lxmf_data + stamp)
message_file.flush()
class FakeRouter:
propagation_entries = {
transient_id: [
RNS.Destination.hash_from_name_and_identity(
"lxmf.delivery", bob_id
),
message_file.name,
]
}
client_propagation_messages_served = 0
@staticmethod
def identity_allowed(identity):
return True
listing = LXMRouter.message_get_request(
FakeRouter(), "/get", [None, None], bytes(16), bob_id, 0
)
if listing != [transient_id]:
fail("S5.8 /get listing did not return recipient transient IDs")
response = LXMRouter.message_get_request(
FakeRouter(), "/get", [[transient_id], [], 64], bytes(16), bob_id, 0
)
if response != [lxmf_data]:
fail("S5.8 /get did not return a plain list with storage stamp stripped")
if umsgpack.packb(response).hex() != vector["get"]["handler_response_hex"]:
fail("S5.8 /get handler response vector mismatch")
if LXMRouter.message_get_request(
FakeRouter(), "/get", [None, None], bytes(16), None, 0
) != LXMPeer.ERROR_NO_IDENTITY:
fail("S5.8 unidentified /get did not return ERROR_NO_IDENTITY")
class DenyingRouter(FakeRouter):
@staticmethod
def identity_allowed(identity):
return False
if LXMRouter.message_get_request(
DenyingRouter(), "/get", [None, None], bytes(16), bob_id, 0
) != LXMPeer.ERROR_NO_ACCESS:
fail("S5.8 unauthorized /get did not return ERROR_NO_ACCESS")
def verify_get_framing(vector: dict) -> None:
get_vector = vector["get"]
listing = umsgpack.unpackb(bytes.fromhex(get_vector["listing_request_data_hex"]))
fetch = umsgpack.unpackb(bytes.fromhex(get_vector["fetch_request_data_hex"]))
response = umsgpack.unpackb(bytes.fromhex(get_vector["generic_response_plaintext_hex"]))
lxmf_data = bytes.fromhex(vector["boundary"]["largest_packet"]["lxmf_data_hex"])
if listing != [None, None] or fetch[0] != [RNS.Identity.full_hash(lxmf_data)]:
fail("S5.8 /get request vector mismatch")
if response[0].hex() != vector["inputs"]["get_request_id_hex"] or response[1] != [lxmf_data]:
fail("S5.8 /get generic RESPONSE is not [request_id, [lxmf_data]]")
if isinstance(response[1][0], list):
fail("S5.8 /get response was confused with propagation submission bundle")
def verify_constants() -> None:
actual = (
LXMPeer.ERROR_NO_IDENTITY,
LXMPeer.ERROR_NO_ACCESS,
LXMPeer.ERROR_INVALID_KEY,
LXMPeer.ERROR_INVALID_DATA,
LXMPeer.ERROR_INVALID_STAMP,
LXMPeer.ERROR_THROTTLED,
LXMPeer.ERROR_NOT_FOUND,
LXMPeer.ERROR_TIMEOUT,
)
expected = (0xF0, 0xF1, 0xF3, 0xF4, 0xF5, 0xF6, 0xFD, 0xFE)
if actual != expected:
fail(f"S5.8 propagation error constants changed: {actual!r}")
def main() -> None:
print(f"verify_propagated_lxmf.py against RNS {RNS.__version__} / LXMF {LXMF.__version__}")
vector = load_json(VECTORS_PATH)
init_minimal_rns()
try:
alice_id, bob_id = load_identities()
alice_dest = RNS.Destination(
alice_id, RNS.Destination.IN, RNS.Destination.SINGLE, "lxmf", "delivery"
)
bob_dest = RNS.Destination(
bob_id, RNS.Destination.IN, RNS.Destination.SINGLE, "lxmf", "delivery"
)
RNS.Identity.remember(b"\x00" * 32, alice_dest.hash, alice_id.get_public_key(), None)
verify_submission(vector["boundary"]["largest_packet"], bob_dest, alice_id, LXMessage.PACKET)
verify_submission(vector["boundary"]["first_resource"], bob_dest, alice_id, LXMessage.RESOURCE)
print("PASS S5.8 PROPAGATED bundle, full transient ID, boundary, decrypt, and signature")
verify_get_handler(vector, bob_id)
verify_get_framing(vector)
print("PASS S5.8 /get listing, recipient filtering, stamp stripping, and RESPONSE framing")
verify_constants()
print("PASS S5.8 propagation error constants")
finally:
try:
RNS.Reticulum.exit_handler()
except Exception:
pass
print("ALL PASS")
if __name__ == "__main__":
main()