Added deterministic `resources.json` and `regen_resources.py`. Extended `verify_resource.py` with receiver assembly/proof and requested negative cases. Updated specification, audit, status, and tool documentation. Fixed an unrelated nondeterministic wrong-ticket test in verify_stamps.py. Confirmed vector regeneration is byte-identical. Confirmed no tracked reliance on specenv or user-specific paths. git diff --check: pass. Complete pinned suite: 16 passed, 0 failed.
454 lines
18 KiB
Python
454 lines
18 KiB
Python
"""
|
|
Verifier for SPEC.md S10 (Resource fragmentation protocol).
|
|
|
|
Exercises upstream RNS 1.2.4 Resource construction with a deterministic fake
|
|
Link and verifies:
|
|
|
|
1. Default direct-LXMF single-packet content limit is 319 bytes.
|
|
2. Resource encrypts one complete stream, then slices that ciphertext into
|
|
RESOURCE packet bodies without packet-level re-encryption.
|
|
3. Resource hash, expected proof, and map-hash formulas.
|
|
4. RESOURCE_ADV fields and flags.
|
|
5. Multi-segment advertisement `d` is total logical-resource size.
|
|
6. An exhausted RESOURCE_REQ may carry part requests; upstream fulfils the
|
|
requested parts and emits RESOURCE_HMU.
|
|
7. RESOURCE_RCL is emitted for advertisement rejection, while an ordinary
|
|
receiver-side cancel is local-only.
|
|
8. Deterministic Resource vector bytes round-trip.
|
|
9. Receiver assembly decrypts once, strips the prefix, handles metadata,
|
|
validates integrity, and emits RESOURCE_PRF.
|
|
10. Malformed ADV, wrong-r, corrupt-part, invalid-HMU-boundary,
|
|
oversized-decompression, and incorrect-proof cases are rejected.
|
|
|
|
Exit code 0 on PASS, non-zero on FAIL.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
import LXMF
|
|
import RNS
|
|
from LXMF.LXMessage import LXMessage
|
|
from RNS.Cryptography.Token import Token
|
|
from RNS.Resource import Resource, ResourceAdvertisement
|
|
|
|
|
|
FIXED_TOKEN_KEY = bytes(range(64))
|
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
VECTORS_PATH = os.path.join(REPO_ROOT, "test-vectors", "resources.json")
|
|
|
|
|
|
def fail(msg: str) -> None:
|
|
print(f"FAIL: {msg}")
|
|
sys.exit(1)
|
|
|
|
|
|
class FakeLink:
|
|
"""Minimum Link surface needed by Resource and Packet construction."""
|
|
|
|
def __init__(self, mtu: int = RNS.Reticulum.MTU):
|
|
self.type = RNS.Destination.LINK
|
|
self.status = RNS.Link.ACTIVE
|
|
self.mtu = mtu
|
|
self.mdu = RNS.Link.MDU
|
|
self.hash = bytes.fromhex("00112233445566778899aabbccddeeff")
|
|
self.link_id = self.hash
|
|
self.rtt = 0.1
|
|
self.traffic_timeout_factor = 1
|
|
self.last_outbound = 0
|
|
self.tx = 0
|
|
self.txbytes = 0
|
|
self._token = Token(FIXED_TOKEN_KEY)
|
|
self.cancelled_incoming = []
|
|
self.cancelled_outgoing = []
|
|
self.concluded = []
|
|
self.torn_down = False
|
|
|
|
def encrypt(self, data: bytes) -> bytes:
|
|
return self._token.encrypt(data)
|
|
|
|
def decrypt(self, data: bytes) -> bytes:
|
|
return self._token.decrypt(data)
|
|
|
|
def cancel_incoming_resource(self, resource) -> None:
|
|
self.cancelled_incoming.append(resource)
|
|
|
|
def cancel_outgoing_resource(self, resource) -> None:
|
|
self.cancelled_outgoing.append(resource)
|
|
|
|
def resource_concluded(self, resource) -> None:
|
|
self.concluded.append(resource)
|
|
|
|
def teardown(self) -> None:
|
|
self.torn_down = True
|
|
|
|
|
|
def verify_default_lxmf_threshold() -> None:
|
|
expected = RNS.Link.MDU - LXMessage.LXMF_OVERHEAD
|
|
if LXMessage.LINK_PACKET_MAX_CONTENT != expected:
|
|
fail(
|
|
"S10 direct-LXMF threshold formula mismatch: "
|
|
f"{LXMessage.LINK_PACKET_MAX_CONTENT} != {expected}"
|
|
)
|
|
if expected != 319:
|
|
fail(f"S10 default direct-LXMF threshold changed: got {expected}, want 319")
|
|
print("PASS S10 default direct-LXMF Resource threshold: content > 319 bytes")
|
|
|
|
|
|
def verify_preparation_and_advertisement() -> None:
|
|
link = FakeLink()
|
|
plaintext = (b"resource-verifier-" * 70) + bytes(range(64))
|
|
resource = Resource(plaintext, link, advertise=False, auto_compress=False)
|
|
|
|
stream = b"".join(part.data for part in resource.parts)
|
|
decrypted = link.decrypt(stream)
|
|
prefix = decrypted[:Resource.RANDOM_HASH_SIZE]
|
|
recovered = decrypted[Resource.RANDOM_HASH_SIZE:]
|
|
|
|
if recovered != plaintext:
|
|
fail("S10.2 whole-stream decrypt did not recover original plaintext")
|
|
if len(prefix) != Resource.RANDOM_HASH_SIZE:
|
|
fail("S10.2 throwaway prefix has wrong length")
|
|
|
|
for index, part in enumerate(resource.parts):
|
|
if part.context != RNS.Packet.RESOURCE:
|
|
fail(f"S10.3 part {index} has context {part.context:#x}, want RESOURCE")
|
|
if part.raw[19:] != part.data:
|
|
fail(f"S10.6 part {index} was packet-level encrypted or altered")
|
|
|
|
expected_hash = RNS.Identity.full_hash(plaintext + resource.random_hash)
|
|
expected_proof = RNS.Identity.full_hash(plaintext + expected_hash)
|
|
if resource.hash != expected_hash:
|
|
fail("S10.2 resource hash formula mismatch")
|
|
if resource.expected_proof != expected_proof:
|
|
fail("S10.2 expected proof formula mismatch")
|
|
|
|
for index, part in enumerate(resource.parts):
|
|
expected_map_hash = RNS.Identity.full_hash(part.data + resource.random_hash)[:Resource.MAPHASH_LEN]
|
|
if part.map_hash != expected_map_hash:
|
|
fail(f"S10.2 map-hash formula mismatch for part {index}")
|
|
|
|
adv = ResourceAdvertisement.unpack(ResourceAdvertisement(resource).pack())
|
|
if adv.t != len(stream):
|
|
fail(f"S10.4 ADV t mismatch: {adv.t} != {len(stream)}")
|
|
if adv.d != len(plaintext):
|
|
fail(f"S10.4 ADV d mismatch for single segment: {adv.d} != {len(plaintext)}")
|
|
if adv.n != len(resource.parts):
|
|
fail(f"S10.4 ADV n mismatch: {adv.n} != {len(resource.parts)}")
|
|
if adv.h != resource.hash or adv.r != resource.random_hash:
|
|
fail("S10.4 ADV hash or random-hash field mismatch")
|
|
if not adv.e or adv.c or adv.s or adv.u or adv.p or adv.x:
|
|
fail(f"S10.4 ADV flags unexpected: {adv.f:#x}")
|
|
|
|
print(
|
|
"PASS S10.2/S10.4/S10.6 whole-stream encryption, ciphertext slicing, "
|
|
"hash formulas, and ADV fields"
|
|
)
|
|
|
|
|
|
def verify_multisegment_total_size() -> None:
|
|
link = FakeLink()
|
|
logical_size = Resource.MAX_EFFICIENT_SIZE + 257
|
|
resource = Resource(b"M" * logical_size, link, advertise=False, auto_compress=False)
|
|
adv = ResourceAdvertisement.unpack(ResourceAdvertisement(resource).pack())
|
|
|
|
if resource.total_segments != 2 or not resource.split:
|
|
fail("S10.11 expected a two-segment Resource")
|
|
if adv.d != logical_size:
|
|
fail(f"S10.4 multi-segment ADV d is {adv.d}, want total logical size {logical_size}")
|
|
if resource.uncompressed_size >= logical_size:
|
|
fail("S10.4 first-segment plaintext unexpectedly equals total logical size")
|
|
|
|
print(
|
|
"PASS S10.4/S10.11 multi-segment ADV d is total logical-resource size, "
|
|
"not current-segment plaintext size"
|
|
)
|
|
|
|
|
|
def verify_exhausted_request_with_parts() -> None:
|
|
link = FakeLink()
|
|
payload_size = ResourceAdvertisement.HASHMAP_MAX_LEN * (link.mtu - RNS.Reticulum.HEADER_MAXSIZE - RNS.Reticulum.IFAC_MIN_SIZE)
|
|
resource = Resource(b"R" * payload_size, link, advertise=False, auto_compress=False)
|
|
if len(resource.parts) <= ResourceAdvertisement.HASHMAP_MAX_LEN:
|
|
fail("S10.7 fixture did not produce a multi-segment hashmap")
|
|
|
|
resource.status = Resource.TRANSFERRING
|
|
resource.adv_sent = time.time()
|
|
resource.rtt = 0.1
|
|
|
|
requested_part = resource.parts[0]
|
|
last_known_index = ResourceAdvertisement.HASHMAP_MAX_LEN - 1
|
|
last_known_hash = resource.parts[last_known_index].map_hash
|
|
request_data = (
|
|
bytes([Resource.HASHMAP_IS_EXHAUSTED])
|
|
+ last_known_hash
|
|
+ resource.hash
|
|
+ requested_part.map_hash
|
|
)
|
|
|
|
captured: list[RNS.Packet] = []
|
|
real_outbound = RNS.Transport.outbound
|
|
|
|
def fake_outbound(packet):
|
|
captured.append(packet)
|
|
return True
|
|
|
|
RNS.Transport.outbound = staticmethod(fake_outbound)
|
|
try:
|
|
resource.request(request_data)
|
|
finally:
|
|
RNS.Transport.outbound = real_outbound
|
|
|
|
contexts = [packet.context for packet in captured]
|
|
if RNS.Packet.RESOURCE not in contexts:
|
|
fail("S10.7 exhausted RESOURCE_REQ did not fulfil bundled part request")
|
|
if RNS.Packet.RESOURCE_HMU not in contexts:
|
|
fail("S10.7 exhausted RESOURCE_REQ did not emit RESOURCE_HMU")
|
|
|
|
print("PASS S10.7 exhausted RESOURCE_REQ fulfils bundled parts and emits RESOURCE_HMU")
|
|
|
|
|
|
def verify_receiver_reject_vs_cancel() -> None:
|
|
link = FakeLink()
|
|
outgoing = Resource(b"reject-me", link, advertise=False, auto_compress=False)
|
|
advertisement_plaintext = ResourceAdvertisement(outgoing).pack()
|
|
|
|
class AdvertisementPacket:
|
|
plaintext = advertisement_plaintext
|
|
link = None
|
|
|
|
AdvertisementPacket.link = link
|
|
|
|
captured: list[RNS.Packet] = []
|
|
real_outbound = RNS.Transport.outbound
|
|
|
|
def fake_outbound(packet):
|
|
captured.append(packet)
|
|
return True
|
|
|
|
RNS.Transport.outbound = staticmethod(fake_outbound)
|
|
try:
|
|
Resource.reject(AdvertisementPacket)
|
|
if [packet.context for packet in captured] != [RNS.Packet.RESOURCE_RCL]:
|
|
fail("S10.9 advertisement rejection did not emit exactly one RESOURCE_RCL")
|
|
|
|
captured.clear()
|
|
incoming = Resource(None, link)
|
|
incoming.status = Resource.TRANSFERRING
|
|
incoming.initiator = False
|
|
incoming.callback = None
|
|
incoming.cancel()
|
|
if captured:
|
|
fail("S10.9 ordinary receiver-side cancel unexpectedly emitted a packet")
|
|
if incoming not in link.cancelled_incoming:
|
|
fail("S10.9 ordinary receiver-side cancel did not remove incoming Resource")
|
|
finally:
|
|
RNS.Transport.outbound = real_outbound
|
|
|
|
print("PASS S10.9 RESOURCE_RCL rejects advertisements; ordinary receiver cancel is local-only")
|
|
|
|
|
|
def load_vector() -> dict:
|
|
try:
|
|
with open(VECTORS_PATH, "r", encoding="utf-8") as vector_file:
|
|
return json.load(vector_file)["vectors"][0]
|
|
except (OSError, KeyError, IndexError, json.JSONDecodeError) as exc:
|
|
fail(f"S10 Resource vector could not be loaded: {exc}")
|
|
|
|
|
|
def verify_deterministic_vector() -> None:
|
|
vector = load_vector()
|
|
inputs = vector["inputs"]
|
|
expected = vector["expected"]
|
|
plaintext = bytes.fromhex(inputs["plaintext_hex"])
|
|
prefix = bytes.fromhex(inputs["throwaway_prefix_hex"])
|
|
random_hash = bytes.fromhex(inputs["random_hash_hex"])
|
|
encrypted_stream = bytes.fromhex(expected["encrypted_stream_hex"])
|
|
|
|
token = Token(bytes.fromhex(inputs["token_key_hex"]))
|
|
if token.decrypt(encrypted_stream) != prefix + plaintext:
|
|
fail("S10 vector ciphertext did not decrypt to prefix || plaintext")
|
|
|
|
resource_hash = RNS.Identity.full_hash(plaintext + random_hash)
|
|
proof = RNS.Identity.full_hash(plaintext + resource_hash)
|
|
if resource_hash.hex() != expected["resource_hash_hex"]:
|
|
fail("S10 vector resource hash mismatch")
|
|
if proof.hex() != expected["expected_proof_hex"]:
|
|
fail("S10 vector expected proof mismatch")
|
|
if (resource_hash + proof).hex() != expected["resource_prf_body_hex"]:
|
|
fail("S10 vector RESOURCE_PRF body mismatch")
|
|
|
|
part_bodies = [bytes.fromhex(part["body_hex"]) for part in expected["parts"]]
|
|
if b"".join(part_bodies) != encrypted_stream:
|
|
fail("S10 vector part bodies do not concatenate to encrypted stream")
|
|
map_hashes = b"".join(
|
|
RNS.Identity.full_hash(body + random_hash)[:Resource.MAPHASH_LEN]
|
|
for body in part_bodies
|
|
)
|
|
if map_hashes.hex() != expected["hashmap_hex"]:
|
|
fail("S10 vector hashmap mismatch")
|
|
|
|
adv = ResourceAdvertisement.unpack(bytes.fromhex(expected["advertisement_plaintext_hex"]))
|
|
if adv.h != resource_hash or adv.r != random_hash or adv.m != map_hashes:
|
|
fail("S10 vector advertisement hash, random-hash, or hashmap mismatch")
|
|
if adv.n != len(part_bodies) or adv.d != len(plaintext):
|
|
fail("S10 vector advertisement part count or logical size mismatch")
|
|
|
|
print("PASS S10.2/S10.4/S10.8 deterministic Resource vector round-trip")
|
|
|
|
|
|
def make_incoming(outgoing: Resource, link: FakeLink, directory: str, parts=None) -> Resource:
|
|
incoming = Resource(None, link)
|
|
incoming.status = Resource.TRANSFERRING
|
|
incoming.initiator = False
|
|
incoming.callback = None
|
|
incoming.parts = parts if parts is not None else [part.data for part in outgoing.parts]
|
|
incoming.encrypted = outgoing.encrypted
|
|
incoming.compressed = outgoing.compressed
|
|
incoming.random_hash = outgoing.random_hash
|
|
incoming.hash = outgoing.hash
|
|
incoming.has_metadata = outgoing.has_metadata
|
|
incoming.segment_index = outgoing.segment_index
|
|
incoming.total_segments = outgoing.total_segments
|
|
incoming.storagepath = os.path.join(directory, "resource.data")
|
|
incoming.meta_storagepath = os.path.join(directory, "resource.meta")
|
|
incoming.max_decompressed_size = Resource.AUTO_COMPRESS_MAX_SIZE
|
|
incoming.data = None
|
|
|
|
class AdvertisementPacket:
|
|
pass
|
|
|
|
AdvertisementPacket.link = link
|
|
AdvertisementPacket.plaintext = ResourceAdvertisement(outgoing).pack()
|
|
incoming.advertisement_packet = AdvertisementPacket
|
|
return incoming
|
|
|
|
|
|
def verify_receiver_assembly_and_proof() -> None:
|
|
link = FakeLink()
|
|
payload = b"receiver-assembly-" * 160
|
|
metadata = {"name": "fixture.bin", "kind": "resource"}
|
|
outgoing = Resource(payload, link, metadata=metadata, advertise=False, auto_compress=True)
|
|
captured: list[RNS.Packet] = []
|
|
callback_result = {}
|
|
real_outbound = RNS.Transport.outbound
|
|
real_cache = RNS.Transport.cache
|
|
|
|
def fake_outbound(packet):
|
|
captured.append(packet)
|
|
return True
|
|
|
|
with tempfile.TemporaryDirectory(prefix="verify-resource-") as directory:
|
|
incoming = make_incoming(outgoing, link, directory)
|
|
|
|
def assembled(resource):
|
|
callback_result["data"] = resource.data.read()
|
|
callback_result["metadata"] = resource.metadata
|
|
|
|
incoming.callback = assembled
|
|
RNS.Transport.outbound = staticmethod(fake_outbound)
|
|
RNS.Transport.cache = staticmethod(lambda packet, force_cache=False: None)
|
|
try:
|
|
incoming.assemble()
|
|
finally:
|
|
RNS.Transport.outbound = real_outbound
|
|
RNS.Transport.cache = real_cache
|
|
|
|
proofs = [packet for packet in captured if packet.context == RNS.Packet.RESOURCE_PRF]
|
|
if incoming.status != Resource.COMPLETE:
|
|
fail(f"S10.8 receiver assembly status is {incoming.status}, want COMPLETE")
|
|
if callback_result != {"data": payload, "metadata": metadata}:
|
|
fail("S10.8 receiver assembly did not recover payload and metadata")
|
|
if len(proofs) != 1 or proofs[0].data != outgoing.hash + outgoing.expected_proof:
|
|
fail("S10.8 receiver did not emit the expected RESOURCE_PRF body")
|
|
|
|
outgoing.status = Resource.AWAITING_PROOF
|
|
outgoing.validate_proof(bytes(64))
|
|
if outgoing.status != Resource.AWAITING_PROOF:
|
|
fail("S10.8 initiator accepted an incorrect Resource proof")
|
|
outgoing.validate_proof(outgoing.hash + outgoing.expected_proof)
|
|
if outgoing.status != Resource.COMPLETE:
|
|
fail("S10.8 initiator rejected the correct Resource proof")
|
|
|
|
print("PASS S10.8 receiver assembly, metadata recovery, PRF emission, and proof validation")
|
|
|
|
|
|
def verify_negative_cases() -> None:
|
|
try:
|
|
ResourceAdvertisement.unpack(b"\x81\xa1t\x01")
|
|
except Exception:
|
|
pass
|
|
else:
|
|
fail("S10.4 malformed Resource advertisement was accepted")
|
|
|
|
link = FakeLink()
|
|
outgoing = Resource(b"integrity-check-" * 100, link, advertise=False, auto_compress=False)
|
|
real_outbound = RNS.Transport.outbound
|
|
captured: list[RNS.Packet] = []
|
|
RNS.Transport.outbound = staticmethod(lambda packet: captured.append(packet) or True)
|
|
try:
|
|
with tempfile.TemporaryDirectory(prefix="verify-resource-negative-") as directory:
|
|
wrong_r = make_incoming(outgoing, link, directory)
|
|
wrong_r.random_hash = bytes(Resource.RANDOM_HASH_SIZE)
|
|
wrong_r.assemble()
|
|
if wrong_r.status != Resource.CORRUPT:
|
|
fail("S10.8 Resource with wrong r was not marked CORRUPT")
|
|
|
|
with tempfile.TemporaryDirectory(prefix="verify-resource-negative-") as directory:
|
|
corrupt_parts = [part.data for part in outgoing.parts]
|
|
corrupt_parts[0] = bytes([corrupt_parts[0][0] ^ 1]) + corrupt_parts[0][1:]
|
|
corrupt = make_incoming(outgoing, link, directory, corrupt_parts)
|
|
corrupt.assemble()
|
|
if corrupt.status != Resource.CORRUPT:
|
|
fail("S10.8 Resource with corrupt part was not marked CORRUPT")
|
|
|
|
compressed = Resource(b"Z" * 5000, link, advertise=False, auto_compress=True)
|
|
with tempfile.TemporaryDirectory(prefix="verify-resource-negative-") as directory:
|
|
oversized = make_incoming(compressed, link, directory)
|
|
oversized.max_decompressed_size = 128
|
|
oversized.assemble()
|
|
if oversized.status != Resource.CORRUPT or not link.torn_down:
|
|
fail("S10.8 oversized decompression was not rejected and link torn down")
|
|
|
|
payload_size = ResourceAdvertisement.HASHMAP_MAX_LEN * outgoing.sdu
|
|
boundary = Resource(b"B" * payload_size, link, advertise=False, auto_compress=False)
|
|
boundary.status = Resource.TRANSFERRING
|
|
boundary.adv_sent = time.time()
|
|
boundary.rtt = 0.1
|
|
invalid_request = (
|
|
bytes([Resource.HASHMAP_IS_EXHAUSTED])
|
|
+ boundary.parts[0].map_hash
|
|
+ boundary.hash
|
|
)
|
|
captured.clear()
|
|
boundary.request(invalid_request)
|
|
if boundary.status != Resource.FAILED:
|
|
fail("S10.7 invalid HMU boundary did not cancel the Resource")
|
|
if any(packet.context == RNS.Packet.RESOURCE_HMU for packet in captured):
|
|
fail("S10.7 invalid HMU boundary emitted RESOURCE_HMU")
|
|
finally:
|
|
RNS.Transport.outbound = real_outbound
|
|
|
|
print("PASS S10.4/S10.7/S10.8 malformed and corrupt Resource cases are rejected")
|
|
|
|
|
|
def main() -> None:
|
|
print(f"verify_resource.py against RNS {RNS.__version__} / LXMF {LXMF.__version__}")
|
|
verify_default_lxmf_threshold()
|
|
verify_preparation_and_advertisement()
|
|
verify_multisegment_total_size()
|
|
verify_exhausted_request_with_parts()
|
|
verify_receiver_reject_vs_cancel()
|
|
verify_deterministic_vector()
|
|
verify_receiver_assembly_and_proof()
|
|
verify_negative_cases()
|
|
print("ALL PASS")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|