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.
205 lines
8.2 KiB
Python
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()
|