Resolve issue #6 — LRRTT and HEADER_1 for link-addressed DATA (§6.4.2, §6.4.3)
Upstream RNS enforces two requirements in code that SPEC.md left implicit;
both caused silent message loss in a clean-room Go LXMF service against
upstream Python rns 1.2.4 / lxmf 0.9.7.
§6.4.2 LRRTT — initiator's link-activation packet
- HEADER_1, DATA, dest_type=LINK (0x03), ctx=0xfe; body is
`umsgpack.packb(rtt_seconds)` encrypted with the link's session keys.
- The responder transitions HANDSHAKE→ACTIVE only on LRRTT receipt
(Link.py:534-553), which is also what fires the link_established
callback. LXMF's set_resource_strategy(ACCEPT_APP) is installed
from that callback; without it, every RESOURCE_ADV the initiator
sends hits the silent ACCEPT_NONE branch at Link.py:1087.
§6.4.3 Header type for post-handshake DATA and Resource
- Link-addressed packets are routed via link_table, which forwards
header bytes verbatim (Transport.py:1587-1622). HEADER_2 with a
relay's transport_id therefore arrives at the destination intact
and is dropped by packet_filter (Transport.py:1283-1285) as
"for another transport instance".
- Mandates HEADER_1 with no transport_id for all post-handshake
link DATA / Resource / control packets regardless of hop count.
- Asymmetry with LINKREQUEST (which IS path_table-routed and so
HEADER_2-eligible) is spelled out.
Companion changes:
- §6.4 renamed to "Session keys and link activation"; existing
HKDF content moved into §6.4.1.
- §2.5 LRRTT context-byte entry points at §6.4.2.
- §12.5.2 (Link DATA forwarding) cross-references §6.4.3.
- §14 failure-modes table: two new entries for the silent-drop
chains documented above.
- flows/send-link-lxmf.md step 4 strengthened (LRRTT is mandatory,
not informational); step 6 corrected (Transport.outbound does NOT
apply HEADER_1→HEADER_2 for link DATA — that conversion is
path_table-keyed, link DATA is link_table-keyed).
- test-vectors/links.json extended with an LRRTT entry: pinned
rtt_seconds=0.05 + pinned 16-byte IV produces deterministic
wire bytes for the encrypted body.
- tools/regen_links.py drives the LRRTT generation with an
os.urandom patch for the Token IV.
- tools/verify_link_lrrtt.py (new) locks the wire claims:
HEADER_1, ctx=0xfe, dest=link_id, body decrypts under
derived_key to msgpack float64 matching rtt_seconds.
Citations all verified against installed RNS 1.2.4 / LXMF 0.9.7.
All 14 verifiers PASS.
This commit is contained in:
parent
5574d3bed3
commit
073203abae
7 changed files with 276 additions and 11 deletions
|
|
@ -31,6 +31,7 @@ Populated against RNS 1.2.4 / LXMF 0.9.7:
|
|||
| `verify_lxmf_opportunistic.py` | §5.1, §5.2, §5.5, §5.6 — full identity → encrypt → decrypt → parse round-trip | ✅ |
|
||||
| `verify_proof_packet.py` | §6.5 — implicit (64B) and explicit (96B) proof body forms, validator length-dispatch | ✅ |
|
||||
| `verify_link_handshake.py` | §6.1, §6.2, §6.3, §6.6 — LINKREQUEST/LRPROOF body order, link_id derivation, signalling | ✅ |
|
||||
| `verify_link_lrrtt.py` | §6.4.2, §6.4.3 — LRRTT wire form, HEADER_1 header, dest_type=LINK, ctx=0xfe, link-form Token body, msgpack float64 plaintext | ✅ |
|
||||
| `verify_path_request.py` | §1.2 well-known hashes, §7.1 LXMF path-preamble gating | ✅ |
|
||||
| `verify_rnode_split.py` | §8.3 — RNode air-frame split-packet TX/RX state machines | ✅ |
|
||||
| `verify_msgpack_quirk.py` | §9.3 — encoding name as bytes vs str affects upstream parsing | ✅ |
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import tempfile
|
|||
|
||||
import RNS
|
||||
from RNS.Link import Link
|
||||
from RNS.vendor import umsgpack
|
||||
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
|
@ -61,6 +62,14 @@ INITIATOR_X25519_PRIV = bytes.fromhex("11"*32)
|
|||
INITIATOR_ED25519_PRIV = bytes.fromhex("22"*32)
|
||||
RESPONDER_X25519_PRIV = bytes.fromhex("33"*32)
|
||||
|
||||
# Pinned LRRTT inputs. The IV here gets injected via os.urandom while
|
||||
# packing the LRRTT packet so the wire bytes are reproducible. RTT
|
||||
# value is the initiator's measured LRREQ->LRPROOF time per S6.4.2;
|
||||
# the exact value is non-load-bearing (responder takes max with its
|
||||
# own measurement) but pinning it keeps the wire bytes deterministic.
|
||||
LRRTT_RTT_SECONDS = 0.05
|
||||
LRRTT_IV = bytes.fromhex("44"*16)
|
||||
|
||||
|
||||
def fail(msg: str) -> None:
|
||||
print(f"FAIL: {msg}")
|
||||
|
|
@ -212,6 +221,33 @@ def main():
|
|||
if initiator.link_id != responder.link_id:
|
||||
fail(f"link_id disagree: ini={initiator.link_id.hex()} res={responder.link_id.hex()}")
|
||||
|
||||
# 4. Build the initiator's LRRTT packet (S6.4.2). The initiator
|
||||
# emits this immediately after LRPROOF validation, before any
|
||||
# application DATA. We pin os.urandom so the Token IV is
|
||||
# deterministic; the rest of the wire form falls out of the
|
||||
# link's derived_key (already populated above).
|
||||
lrrtt_plaintext = umsgpack.packb(LRRTT_RTT_SECONDS)
|
||||
token_mod = sys.modules["RNS.Cryptography.Token"]
|
||||
real_urandom = token_mod.os.urandom
|
||||
|
||||
def fake_urandom(n):
|
||||
if n == 16: return LRRTT_IV
|
||||
return real_urandom(n)
|
||||
|
||||
token_mod.os.urandom = fake_urandom
|
||||
try:
|
||||
lrrtt_packet = RNS.Packet(initiator, lrrtt_plaintext,
|
||||
context=RNS.Packet.LRRTT)
|
||||
lrrtt_packet.pack()
|
||||
finally:
|
||||
token_mod.os.urandom = real_urandom
|
||||
|
||||
# Sanity round-trip: the responder side should decrypt
|
||||
# the captured ciphertext and recover the same float.
|
||||
if responder.decrypt(lrrtt_packet.ciphertext) != lrrtt_plaintext:
|
||||
fail("LRRTT round-trip failed: responder.decrypt did not "
|
||||
"recover the pinned plaintext.")
|
||||
|
||||
# Slice fields per S6.1 / S6.2 for human inspection
|
||||
lr_data = captured_lr["data"]
|
||||
ini_x25519_pub = lr_data[:32]
|
||||
|
|
@ -254,10 +290,18 @@ def main():
|
|||
"derived_key_hex": initiator.derived_key.hex(),
|
||||
"mtu": initiator.mtu,
|
||||
"mode": initiator.mode,
|
||||
"lrrtt": {
|
||||
"rtt_seconds": LRRTT_RTT_SECONDS,
|
||||
"iv_hex": LRRTT_IV.hex(),
|
||||
"plaintext_hex": lrrtt_plaintext.hex(),
|
||||
"raw_hex": lrrtt_packet.raw.hex(),
|
||||
"body_hex": lrrtt_packet.ciphertext.hex(),
|
||||
},
|
||||
},
|
||||
"rns_version_at_generation": RNS.__version__,
|
||||
"generator_script": "tools/regen_links.py",
|
||||
"verifies_spec_sections": ["6.1", "6.2", "6.3", "6.6"],
|
||||
"verifies_spec_sections": ["6.1", "6.2", "6.3", "6.4.1",
|
||||
"6.4.2", "6.6"],
|
||||
}
|
||||
|
||||
payload = {
|
||||
|
|
@ -276,7 +320,11 @@ def main():
|
|||
"responder, with bob's identity Ed25519 sig over `link_id || "
|
||||
"responder_X25519_pub || responder_long_term_Ed25519_pub || "
|
||||
"signalling`, and matching `lrproof_raw_hex`; (d) running "
|
||||
"ECDH+HKDF on either side and matching `derived_key_hex`. "
|
||||
"ECDH+HKDF on either side and matching `derived_key_hex`; "
|
||||
"(e) building an LRRTT packet (S6.4.2) addressed to the "
|
||||
"link_id with `context=LRRTT (0xfe)` and an encrypted body "
|
||||
"of `umsgpack.packb(lrrtt.rtt_seconds)`, using `lrrtt.iv_hex` "
|
||||
"as the Token IV, and matching `lrrtt.raw_hex` / `lrrtt.body_hex`. "
|
||||
"Regenerate with `generator_script`."
|
||||
),
|
||||
"vectors": [vector],
|
||||
|
|
|
|||
128
tools/verify_link_lrrtt.py
Normal file
128
tools/verify_link_lrrtt.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""
|
||||
Verifier for SPEC.md S6.4.2 — LRRTT, the initiator's link-activation packet.
|
||||
|
||||
Consumes `test-vectors/links.json`. Asserts that the recorded LRRTT
|
||||
vector decomposes as documented in SPEC.md S6.4.2:
|
||||
|
||||
- Wire header is HEADER_1 with no transport_id (per S6.4.3).
|
||||
- packet_type = DATA (0x00).
|
||||
- destination_type = LINK (0x03), dest_hash = link_id.
|
||||
- context = LRRTT (0xfe).
|
||||
- Body is link-form Token encryption (S3.1, no eph_pub prefix):
|
||||
IV(16) || AES256_CBC(plaintext, key, iv) || HMAC(32)
|
||||
keyed off the link's derived_key.
|
||||
- Plaintext decodes via umsgpack as a single float64 matching
|
||||
the recorded rtt_seconds.
|
||||
|
||||
Run from repo root:
|
||||
|
||||
python tools/verify_link_lrrtt.py
|
||||
|
||||
Prints PASS lines and exits 0 if every assertion holds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import RNS
|
||||
from RNS.Cryptography.Token import Token
|
||||
from RNS.vendor import umsgpack
|
||||
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
VEC_PATH = os.path.join(REPO_ROOT, "test-vectors", "links.json")
|
||||
|
||||
|
||||
def fail(msg: str) -> None:
|
||||
print(f"FAIL: {msg}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with open(VEC_PATH, "r", encoding="utf-8") as f:
|
||||
vectors = json.load(f)["vectors"]
|
||||
|
||||
for v in vectors:
|
||||
label = v["label"]
|
||||
expected = v["expected"]
|
||||
if "lrrtt" not in expected:
|
||||
print(f"SKIP {label}: no lrrtt field")
|
||||
continue
|
||||
|
||||
lrrtt = expected["lrrtt"]
|
||||
link_id = bytes.fromhex(expected["link_id_hex"])
|
||||
derived_key = bytes.fromhex(expected["derived_key_hex"])
|
||||
rtt_seconds = lrrtt["rtt_seconds"]
|
||||
iv = bytes.fromhex(lrrtt["iv_hex"])
|
||||
plaintext = bytes.fromhex(lrrtt["plaintext_hex"])
|
||||
raw = bytes.fromhex(lrrtt["raw_hex"])
|
||||
body = bytes.fromhex(lrrtt["body_hex"])
|
||||
|
||||
# 1. Header decomposition (S2.1 + S6.4.2 + S6.4.3).
|
||||
if len(raw) < 19:
|
||||
fail(f"{label}: raw is shorter than the 19-byte HEADER_1 frame")
|
||||
|
||||
flags = raw[0]
|
||||
hops = raw[1]
|
||||
dest_hash = raw[2:18]
|
||||
context = raw[18]
|
||||
ciphertext = raw[19:]
|
||||
|
||||
header_type = (flags & 0b01000000) >> 6
|
||||
context_flag = (flags & 0b00100000) >> 5
|
||||
transport_type = (flags & 0b00010000) >> 4
|
||||
destination_type = (flags & 0b00001100) >> 2
|
||||
packet_type = (flags & 0b00000011)
|
||||
|
||||
if header_type != 0:
|
||||
fail(f"{label}: header_type = {header_type}, expected HEADER_1 (0) per S6.4.3")
|
||||
if packet_type != 0:
|
||||
fail(f"{label}: packet_type = {packet_type}, expected DATA (0) per S6.4.2")
|
||||
if destination_type != 3:
|
||||
fail(f"{label}: destination_type = {destination_type}, expected LINK (3) per S6.4.2")
|
||||
if context != 0xfe:
|
||||
fail(f"{label}: context = 0x{context:02x}, expected LRRTT (0xfe) per S6.4.2")
|
||||
if dest_hash != link_id:
|
||||
fail(f"{label}: dest_hash = {dest_hash.hex()}, expected link_id = {link_id.hex()}")
|
||||
if hops != 0:
|
||||
fail(f"{label}: hops = {hops}, originator emits 0")
|
||||
|
||||
print(f"PASS {label} S6.4.2 LRRTT header: HEADER_1 + DATA + LINK + ctx=0xfe + dest_hash=link_id")
|
||||
print(f" (context_flag={context_flag}, transport_type={transport_type})")
|
||||
|
||||
# 2. Body length and IV match the link-form Token layout (S3.1).
|
||||
if ciphertext != body:
|
||||
fail(f"{label}: raw[19:] != body — vector self-inconsistency")
|
||||
if ciphertext[:16] != iv:
|
||||
fail(f"{label}: body IV {ciphertext[:16].hex()} != recorded iv_hex {iv.hex()}")
|
||||
|
||||
print(f"PASS {label} S3.1 link-form Token: 16B IV || ciphertext || 32B HMAC, no eph_pub prefix")
|
||||
|
||||
# 3. Token decrypt round-trip using the link's derived_key.
|
||||
recovered = Token(derived_key).decrypt(body)
|
||||
if recovered != plaintext:
|
||||
fail(f"{label}: Token decrypt did not recover plaintext\n"
|
||||
f" got: {recovered.hex() if recovered else None}\n"
|
||||
f" exp: {plaintext.hex()}")
|
||||
print(f"PASS {label} S6.4.2 body decrypts under derived_key to recorded plaintext")
|
||||
|
||||
# 4. Plaintext is umsgpack(float) matching rtt_seconds.
|
||||
decoded = umsgpack.unpackb(plaintext)
|
||||
if not isinstance(decoded, float):
|
||||
fail(f"{label}: plaintext umsgpack-decoded to {type(decoded).__name__}, expected float")
|
||||
if decoded != rtt_seconds:
|
||||
fail(f"{label}: decoded float {decoded!r} != recorded rtt_seconds {rtt_seconds!r}")
|
||||
# msgpack float64 tag is 0xcb plus 8 IEEE 754 bytes — total 9 bytes.
|
||||
if len(plaintext) != 9 or plaintext[0] != 0xcb:
|
||||
fail(f"{label}: plaintext is not a 9-byte msgpack float64 (tag 0xcb)")
|
||||
print(f"PASS {label} S6.4.2 plaintext = umsgpack(float64) rtt_seconds={rtt_seconds!r} (9B, tag 0xcb)")
|
||||
|
||||
print("ALL PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue