Tier 1 audit: `link-lxmf-tier1-rns-1.2.4-lxmf-0.9.7.md` Tier 2 vectors/verifier: link-lxmf.json, regen_link_lxmf.py, and verify_link_lxmf.py Tier 3 promotion: updated SPEC.md, flows, status, and documentation Key correction: the 319/320 boundary uses upstream’s computed LXMF content_size, not simply raw message content length. Also corrected stale flow descriptions for KEEPALIVE (0xFA) and encrypted LINKCLOSE teardown (0xFC). Verification: Deterministic vector regeneration: identical SHA-256 Portable-path and formatting checks: pass Full pinned suite: 17 passed, 0 failed
268 lines
9.7 KiB
Python
268 lines
9.7 KiB
Python
"""
|
|
Regenerator for test-vectors/link-lxmf.json.
|
|
|
|
Builds deterministic DIRECT LXMF vectors at the PACKET/RESOURCE boundary:
|
|
|
|
- computed LXMF content_size 319 -> one encrypted Link DATA packet
|
|
- computed LXMF content_size 320 -> one Resource over the same Link
|
|
|
|
The vectors reuse the deterministic Link session key from links.json and the
|
|
Alice/Bob identities from identities.json.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
|
|
import LXMF
|
|
import RNS
|
|
from LXMF.LXMessage import LXMessage
|
|
from RNS.Cryptography.Token import Token
|
|
from RNS.Resource import Resource, ResourceAdvertisement
|
|
|
|
|
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
OUT_PATH = os.path.join(REPO_ROOT, "test-vectors", "link-lxmf.json")
|
|
IDS_PATH = os.path.join(REPO_ROOT, "test-vectors", "identities.json")
|
|
LINKS_PATH = os.path.join(REPO_ROOT, "test-vectors", "links.json")
|
|
|
|
FIXED_TIMESTAMP = 1700000000.0
|
|
PACKET_IV = bytes.fromhex("5152535455565758595a5b5c5d5e5f60")
|
|
RESOURCE_IV = bytes.fromhex("6162636465666768696a6b6c6d6e6f70")
|
|
RESOURCE_PREFIX = bytes.fromhex("71727374")
|
|
RESOURCE_RANDOM_HASH = bytes.fromhex("81828384")
|
|
|
|
|
|
def fail(message: str) -> None:
|
|
print(f"FAIL: {message}")
|
|
sys.exit(1)
|
|
|
|
|
|
def init_minimal_rns():
|
|
config_dir = tempfile.mkdtemp(prefix="rns-regen-link-lxmf-")
|
|
config_path = os.path.join(config_dir, "config")
|
|
with open(config_path, "w", encoding="utf-8") as config:
|
|
config.write("[reticulum]\nenable_transport = No\nshare_instance = No\n")
|
|
return RNS.Reticulum(configdir=config_dir, loglevel=0)
|
|
|
|
|
|
def load_inputs():
|
|
with open(IDS_PATH, "r", encoding="utf-8") as identities_file:
|
|
identities = json.load(identities_file)["vectors"]
|
|
with open(LINKS_PATH, "r", encoding="utf-8") as links_file:
|
|
link_vector = json.load(links_file)["vectors"][0]
|
|
|
|
alice = next(vector for vector in identities if vector["label"] == "alice")
|
|
bob = next(vector for vector in identities if vector["label"] == "bob")
|
|
alice_identity = RNS.Identity.from_bytes(bytes.fromhex(alice["inputs"]["private_key_hex"]))
|
|
bob_identity = RNS.Identity.from_bytes(bytes.fromhex(bob["inputs"]["private_key_hex"]))
|
|
derived_key = bytes.fromhex(link_vector["expected"]["derived_key_hex"])
|
|
link_id = bytes.fromhex(link_vector["expected"]["link_id_hex"])
|
|
return alice_identity, bob_identity, derived_key, link_id
|
|
|
|
|
|
class FakeLink:
|
|
"""Active Link surface needed by LXMessage, Packet, and Resource."""
|
|
|
|
def __init__(self, derived_key: bytes, link_id: bytes):
|
|
self.type = RNS.Destination.LINK
|
|
self.status = RNS.Link.ACTIVE
|
|
self.hash = link_id
|
|
self.link_id = link_id
|
|
self.mtu = RNS.Reticulum.MTU
|
|
self.mdu = RNS.Link.MDU
|
|
self.rtt = 0.1
|
|
self.traffic_timeout_factor = 1
|
|
self.last_outbound = 0
|
|
self.tx = 0
|
|
self.txbytes = 0
|
|
self._token = Token(derived_key)
|
|
|
|
def encrypt(self, data: bytes) -> bytes:
|
|
return self._token.encrypt(data)
|
|
|
|
def decrypt(self, data: bytes) -> bytes:
|
|
return self._token.decrypt(data)
|
|
|
|
|
|
def make_message(destination, source, content_size: int) -> LXMessage:
|
|
message = LXMessage(
|
|
destination=destination,
|
|
source=source,
|
|
title=b"",
|
|
content=b"x" * content_size,
|
|
fields={},
|
|
desired_method=LXMessage.DIRECT,
|
|
)
|
|
message.timestamp = FIXED_TIMESTAMP
|
|
message.pack()
|
|
computed_content_size = (
|
|
len(message.packed[2 * LXMessage.DESTINATION_LENGTH + LXMessage.SIGNATURE_LENGTH :])
|
|
- LXMessage.TIMESTAMP_SIZE
|
|
- LXMessage.STRUCT_OVERHEAD
|
|
)
|
|
if computed_content_size != content_size:
|
|
fail(f"fixture computed content_size {computed_content_size}, want {content_size}")
|
|
return message
|
|
|
|
|
|
def patch_token_iv(iv: bytes):
|
|
token_mod = sys.modules["RNS.Cryptography.Token"]
|
|
real_urandom = token_mod.os.urandom
|
|
|
|
def fixed_urandom(length: int) -> bytes:
|
|
if length == 16:
|
|
return iv
|
|
return real_urandom(length)
|
|
|
|
token_mod.os.urandom = fixed_urandom
|
|
return token_mod, real_urandom
|
|
|
|
|
|
def build_packet_vector(message: LXMessage, link: FakeLink) -> dict:
|
|
message.set_delivery_destination(link)
|
|
token_mod, real_urandom = patch_token_iv(PACKET_IV)
|
|
try:
|
|
packet = message._LXMessage__as_packet()
|
|
packet.pack()
|
|
finally:
|
|
token_mod.os.urandom = real_urandom
|
|
|
|
return {
|
|
"label": "alice_to_bob_direct_packet_boundary",
|
|
"inputs": {
|
|
"src_identity_label": "alice",
|
|
"dst_identity_label": "bob",
|
|
"content_size": 319,
|
|
"lxmf_timestamp": FIXED_TIMESTAMP,
|
|
"link_vector_label": "alice_to_bob_aes256cbc",
|
|
"token_iv_hex": PACKET_IV.hex(),
|
|
},
|
|
"expected": {
|
|
"method": message.method,
|
|
"representation": message.representation,
|
|
"lxmf_packed_hex": message.packed.hex(),
|
|
"link_packet_ciphertext_hex": packet.ciphertext.hex(),
|
|
"link_packet_raw_hex": packet.raw.hex(),
|
|
"link_id_hex": link.link_id.hex(),
|
|
},
|
|
"rns_version_at_generation": RNS.__version__,
|
|
"lxmf_version_at_generation": LXMF.__version__,
|
|
"generator_script": "tools/regen_link_lxmf.py",
|
|
"verifies_spec_sections": ["5.2", "5.5", "5.6", "6.4.3", "10.1"],
|
|
}
|
|
|
|
|
|
def build_resource_vector(message: LXMessage, link: FakeLink) -> dict:
|
|
message.set_delivery_destination(link)
|
|
message.auto_compress = False
|
|
token_mod, real_urandom = patch_token_iv(RESOURCE_IV)
|
|
real_get_random_hash = RNS.Identity.get_random_hash
|
|
real_advertise = Resource.advertise
|
|
hashes = [
|
|
RESOURCE_PREFIX + bytes(28),
|
|
RESOURCE_RANDOM_HASH + bytes(28),
|
|
]
|
|
|
|
def fixed_random_hash() -> bytes:
|
|
if not hashes:
|
|
raise RuntimeError("unexpected extra Resource random-hash request")
|
|
return hashes.pop(0)
|
|
|
|
RNS.Identity.get_random_hash = staticmethod(fixed_random_hash)
|
|
Resource.advertise = lambda self: None
|
|
try:
|
|
resource = message._LXMessage__as_resource()
|
|
finally:
|
|
token_mod.os.urandom = real_urandom
|
|
RNS.Identity.get_random_hash = staticmethod(real_get_random_hash)
|
|
Resource.advertise = real_advertise
|
|
|
|
return {
|
|
"label": "alice_to_bob_direct_resource_boundary",
|
|
"inputs": {
|
|
"src_identity_label": "alice",
|
|
"dst_identity_label": "bob",
|
|
"content_size": 320,
|
|
"lxmf_timestamp": FIXED_TIMESTAMP,
|
|
"link_vector_label": "alice_to_bob_aes256cbc",
|
|
"token_iv_hex": RESOURCE_IV.hex(),
|
|
"throwaway_prefix_hex": RESOURCE_PREFIX.hex(),
|
|
"resource_random_hash_hex": RESOURCE_RANDOM_HASH.hex(),
|
|
"auto_compress": False,
|
|
},
|
|
"expected": {
|
|
"method": message.method,
|
|
"representation": message.representation,
|
|
"lxmf_packed_hex": message.packed.hex(),
|
|
"resource_hash_hex": resource.hash.hex(),
|
|
"resource_expected_proof_hex": resource.expected_proof.hex(),
|
|
"resource_advertisement_plaintext_hex": ResourceAdvertisement(resource).pack().hex(),
|
|
"resource_parts": [
|
|
{
|
|
"body_hex": part.data.hex(),
|
|
"map_hash_hex": part.map_hash.hex(),
|
|
"raw_hex": part.raw.hex(),
|
|
}
|
|
for part in resource.parts
|
|
],
|
|
"link_id_hex": link.link_id.hex(),
|
|
},
|
|
"rns_version_at_generation": RNS.__version__,
|
|
"lxmf_version_at_generation": LXMF.__version__,
|
|
"generator_script": "tools/regen_link_lxmf.py",
|
|
"verifies_spec_sections": ["5.2", "5.5", "5.6", "10.1", "10.2", "10.4"],
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
print(f"regen_link_lxmf.py against RNS {RNS.__version__} / LXMF {LXMF.__version__}")
|
|
init_minimal_rns()
|
|
try:
|
|
alice_identity, bob_identity, derived_key, link_id = load_inputs()
|
|
alice = RNS.Destination(
|
|
alice_identity, RNS.Destination.IN, RNS.Destination.SINGLE, "lxmf", "delivery"
|
|
)
|
|
bob = RNS.Destination(
|
|
bob_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery"
|
|
)
|
|
RNS.Identity.remember(bytes(32), alice.hash, alice_identity.get_public_key(), None)
|
|
RNS.Identity.remember(bytes(32), bob.hash, bob_identity.get_public_key(), None)
|
|
link = FakeLink(derived_key, link_id)
|
|
|
|
packet_message = make_message(bob, alice, 319)
|
|
resource_message = make_message(bob, alice, 320)
|
|
if packet_message.representation != LXMessage.PACKET:
|
|
fail("content_size 319 did not select PACKET")
|
|
if resource_message.representation != LXMessage.RESOURCE:
|
|
fail("content_size 320 did not select RESOURCE")
|
|
|
|
payload = {
|
|
"_about": (
|
|
"DIRECT LXMF vectors at the exact upstream PACKET/RESOURCE boundary. "
|
|
"Both carry the complete canonical LXMF body over the deterministic "
|
|
"Link session from links.json. `content_size` is upstream's computed "
|
|
"LXMF size, not necessarily the raw content field length."
|
|
),
|
|
"vectors": [
|
|
build_packet_vector(packet_message, link),
|
|
build_resource_vector(resource_message, link),
|
|
],
|
|
}
|
|
with open(OUT_PATH, "w", encoding="utf-8", newline="\n") as output:
|
|
json.dump(payload, output, indent=2, sort_keys=False)
|
|
output.write("\n")
|
|
print(f"Wrote {OUT_PATH} with 2 vectors")
|
|
print("ALL PASS")
|
|
finally:
|
|
try:
|
|
RNS.Reticulum.exit_handler()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|