From 4a14dca3a49896945edeaf16bcecc672f8fd8fe1 Mon Sep 17 00:00:00 2001 From: John Poole Date: Mon, 8 Jun 2026 17:50:52 -0700 Subject: [PATCH] Completed the transport-relayed Link three-tier unit. Key findings: Valid established-Link traffic uses HEADER_1 and link_table. HEADER_2 Link traffic can cross the addressed relay, then is dropped by the next node. Relay forwarding requires correct interface and hop count. Relay forwarding does not require IDX_LT_VALIDATED or destination_type=LINK. Endpoint Link delivery does require destination_type=LINK. Link-addressed PROOF uses link_table; ordinary DATA proofs use reverse_table. Added: Tier 1 audit Transport-Link flow Verifier Deterministic vectors Updated SPEC.md, playbook.md, README files, and existing Link flow documentation. Verification: Deterministic vector regeneration: identical SHA-256 Full pinned suite: 21 passed, 0 failed git diff --check: passed No commit created. --- README.md | 3 + SPEC.md | 34 ++- agent.md | 2 +- audits/transport-link-tier1-rns-1.2.4.md | 81 ++++++ flows/send-link-lxmf.md | 4 +- flows/transport-link.md | 49 ++++ playbook.md | 6 +- test-vectors/README.md | 5 +- test-vectors/transport-link.json | 29 ++ tools/regen_transport_link.py | 96 +++++++ tools/verify_transport_link.py | 344 +++++++++++++++++++++++ 11 files changed, 633 insertions(+), 20 deletions(-) create mode 100644 audits/transport-link-tier1-rns-1.2.4.md create mode 100644 flows/transport-link.md create mode 100644 test-vectors/transport-link.json create mode 100644 tools/regen_transport_link.py create mode 100644 tools/verify_transport_link.py diff --git a/README.md b/README.md index ae0f801..f3b51e3 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,9 @@ As content grows, `SPEC.md` will be split into per-layer files (packet header, i Errata that may invalidate code built against an earlier revision of `SPEC.md`. Newest first. Feature additions and ordinary edits live in `git log` — this section is reserved for cases where the spec said one thing, that turned out to be wrong, and an implementer who pulled the bad version needs to fix their code. +- **2026-06-08 — §12.5 transport-relayed Link forwarding gates and proof routing.** + Earlier prose implied that established-Link forwarding begins only after LRPROOF validation, that relay lookup requires `destination_type == LINK`, and that Link proofs use `reverse_table`. Upstream RNS 1.2.4 forwards matching link-id traffic based on interface and hop count without checking `IDX_LT_VALIDATED` or destination type; endpoints still require LINK destination type for active-Link DATA dispatch. Link-addressed proofs use `link_table`, while `reverse_table` is for ordinary destination-routed DATA proofs. Corrected and runtime-locked by `tools/verify_transport_link.py`. + - **2026-06-08 — §5.8 propagated-LXMF transient IDs, `/get` framing, and error constants.** Earlier §5.8 text described transient IDs as 16-byte truncated hashes; upstream LXMF 0.9.7 uses the full 32-byte `SHA256(lxmf_data)`. It also incorrectly described `/get` responses as propagation bundles shaped `[time, [messages]]`; the `/get` handler actually returns a plain message list carried by the generic `[request_id, response]` Link RESPONSE. Accepted submission and peer-transfer entries always append a required 32-byte propagation stamp, including at cost zero. The section also incorrectly placed operator handlers on the public propagation destination, described the announce parser as exact/strict, and stated a 30-minute peer throttle; operator handlers use `lxmf.propagation.control`, the parser is deliberately permissive, and `PN_STAMP_THROTTLE` is 180 seconds. Finally, `ERROR_THROTTLED` is `0xf6`, `ERROR_NOT_FOUND` is `0xfd`, and `0xf5` is `ERROR_INVALID_STAMP`. Corrected and runtime-locked by `tools/verify_propagated_lxmf.py` and `tools/verify_propagation_peer.py`. diff --git a/SPEC.md b/SPEC.md index a01d8ce..298d36f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -109,7 +109,7 @@ Source citations refer to the standard `pip install rns lxmf` install layout (`R - [12.2 DATA forwarding rules](#122-data-forwarding-rules) - [12.3 ANNOUNCE rebroadcasting](#123-announce-rebroadcasting) - [12.4 Path table management](#124-path-table-management) - - [12.5 Reverse-table link transport](#125-reverse-table-link-transport) + - [12.5 Link-table and reverse-table transport](#125-link-table-and-reverse-table-transport) - [12.6 Tunnels and shared-instance protocol](#126-tunnels-and-shared-instance-protocol) - [12.7 Source map for §12](#127-source-map-for-12) - [13. Threading and concurrency model](#13-threading-and-concurrency-model) @@ -1406,7 +1406,7 @@ new_raw += packet.raw[2:] Transport.transmit(outbound_interface, new_raw) ``` -A HEADER_2 link DATA packet would therefore arrive at the destination with `transport_id` intact, where the receiver's `Transport.packet_filter` (`RNS/Transport.py:1283-1285`) drops it as "for another transport instance" because the embedded `transport_id` is the relay's identity, not the receiver's: +A HEADER_2 link DATA packet addressed to the first relay can cross that relay with `transport_id` intact. The following node's `Transport.packet_filter` (`RNS/Transport.py:1283-1285`) then drops it as "for another transport instance", because the embedded `transport_id` still names the prior relay: ```python if packet.transport_id != None and packet.packet_type != RNS.Packet.ANNOUNCE: @@ -3218,9 +3218,9 @@ new_raw += packet.raw[18:] # original dest_hash + ctx + bo This is the inverse of the §2.3 originator HEADER_1→HEADER_2 conversion: the relay strips the transport_id when the packet has reached its last hop. -#### 12.2.3 `remaining_hops == 0` — local destination, just bump hops +#### 12.2.3 `remaining_hops == 0` — zero-hop path entry, just bump hops -The destination is registered on the relay itself (it's both our path-table next-hop AND a local destination). Just increment hops and pass through unchanged for local processing — the standard `Destination.receive` path takes over from there. +The relay preserves the existing header layout, increments hops, and transmits on the path entry's recorded interface. This case is used for zero-hop path entries such as destinations behind a connected local client; it is still an outbound transmit, not direct local `Destination.receive` dispatch. #### 12.2.4 LINKREQUEST forwarding extras @@ -3228,7 +3228,7 @@ When the forwarded packet is a `LINKREQUEST`, the relay also writes a `link_tabl ``` [ now, # 0 IDX_LT_TIMESTAMP - next_hop, # 1 IDX_LT_NH_ID — next-hop transport_id + next_hop, # 1 IDX_LT_NH_TRID — next-hop transport_id outbound_interface, # 2 IDX_LT_NH_IF remaining_hops, # 3 IDX_LT_REM_HOPS packet.receiving_interface, # 4 IDX_LT_RCVD_IF @@ -3320,9 +3320,9 @@ A relay also evicts path entries whose underlying interface has been removed (`r If `[reticulum] persist_paths = Yes`, the path_table is serialized to `{storagepath}/paths` (a pickled dict in upstream RNS) so it survives restarts. The repeater repo's `pre_build.py` adds a "skip redundant path writes" patch to avoid hammering the on-board flash on nRF52 — for clean-room implementations, the persistence cadence is implementation-private. -### 12.5 Reverse-table link transport +### 12.5 Link-table and reverse-table transport -Once a Link's LINKREQUEST has been forwarded by a relay (§12.2.4 wrote the `link_table` entry), every subsequent Link packet — DATA, KEEPALIVE, PROOF, LINKCLOSE — must be forwarded by the same relay in the appropriate direction. `Transport.inbound` uses `link_table` and `reverse_table` for this: +Once a Link's LINKREQUEST has been forwarded by a relay (§12.2.4 wrote the `link_table` entry), every subsequent Link packet — DATA, KEEPALIVE, PROOF, LINKCLOSE — follows `link_table` in the appropriate direction. Separately, proofs for ordinary destination-routed DATA follow `reverse_table`. #### 12.5.1 LRPROOF forwarding @@ -3334,17 +3334,21 @@ Transport.transmit(link_entry[IDX_LT_RCVD_IF], new_raw) link_table[packet.destination_hash][IDX_LT_VALIDATED] = True ``` -After validation, the link_table entry is marked `validated`, and from now on the relay forwards Link DATA in both directions transparently. +After validation, the link_table entry is marked `validated`. This flag controls stale-entry handling in `Transport.jobs`; it is **not** checked by the established-Link forwarding branch. Matching link-id traffic can be forwarded before LRPROOF validation. -#### 12.5.2 Link DATA forwarding +#### 12.5.2 Established-Link forwarding -For a `DATA` packet with `destination_type == LINK` whose `dest_hash` is in `link_table`, the relay forwards on the appropriate direction's interface. The link_table entry remembers both sides via `IDX_LT_NH_IF` (toward initiator end) and `IDX_LT_RCVD_IF` (toward responder end); the relay picks based on which interface the inbound packet arrived on. +For any non-ANNOUNCE, non-LINKREQUEST packet whose context is not LRPROOF and whose `dest_hash` is in `link_table`, the relay forwards on the appropriate direction's interface when the incremented hop count also matches. The link_table entry remembers both sides via `IDX_LT_NH_IF` (toward responder) and `IDX_LT_RCVD_IF` (toward initiator); the relay picks based on ingress interface and the direction's expected hop count. -Unlike the path_table forwarding in §12.2 — which strips `transport_id` (`HEADER_2` → `HEADER_1`) at the last hop — link_table forwarding does NOT touch the header. The relay bumps `hops` and emits `packet.raw[0:1] + struct.pack("!B", packet.hops) + packet.raw[2:]` (`Transport.py:1618-1620`). Whatever header bytes the initiator emitted reach the destination verbatim. This is why the originator's wire conventions for post-handshake link DATA are constrained — see §6.4.3: senders MUST emit `HEADER_1` with no `transport_id` for every link-addressed packet, because a `HEADER_2` link packet would arrive at the destination with `transport_id` intact and be dropped by the destination's `packet_filter` as "for another transport instance". +Link-table forwarding preserves the existing header layout and only rewrites the hops byte: `packet.raw[0:1] + struct.pack("!B", packet.hops) + packet.raw[2:]` (`Transport.py:1618-1620`). Valid post-handshake Link traffic is HEADER_1. An artificial HEADER_2 Link packet addressed to the first relay can cross that relay unchanged apart from hops, but the following node rejects it because the retained `transport_id` still names the prior relay (§6.4.3). -#### 12.5.3 PROOF receipt forwarding via `reverse_table` +The relay branch keys on `dest_hash`, interface, and hop count; it does not require `destination_type == LINK` and does not check `IDX_LT_VALIDATED`. Endpoints do require `destination_type == LINK` before dispatching local DATA to an active Link. A malformed destination type can therefore cross a relay and still be silently ignored at the endpoint. -`Transport.py:2199-2208`. When a PROOF arrives whose `dest_hash` is in `reverse_table` (i.e. an opportunistic-DATA proof being routed back to its originator), the relay pops the entry, checks the proof arrived on the correct outbound interface (`receiving_interface == reverse_entry[IDX_RT_OUTB_IF]`), and forwards on the originally-receiving interface: +#### 12.5.3 Link PROOF versus ordinary PROOF forwarding + +A PROOF addressed to `link_id` follows the same bidirectional `link_table` branch as Link DATA. It does not consume `reverse_table`. + +For an ordinary destination-routed DATA proof, `Transport.py:2199-2208` uses `reverse_table`. The relay pops the entry, checks the proof arrived on the correct outbound interface (`receiving_interface == reverse_entry[IDX_RT_OUTB_IF]`), and forwards on the originally-receiving interface: ```python new_raw = packet.raw[0:1] + struct.pack("!B", packet.hops) + packet.raw[2:] @@ -3404,6 +3408,7 @@ The wire protocol for shared-instance loopback is just the same Reticulum packet | `RNS/Transport.py:1500-1580` | DATA forwarding (HEADER_1↔HEADER_2 conversion for relay) | | `RNS/Transport.py:1556-1565` | `link_table` entry shape | | `RNS/Transport.py:1570-1574` | `reverse_table` entry shape | +| `RNS/Transport.py:1587-1622` | Bidirectional established-Link forwarding via `link_table` | | `RNS/Transport.py:1810-1975` | ANNOUNCE rebroadcast queue and per-interface dispatch | | `RNS/Transport.py:2110-2145` | LRPROOF forwarding via `link_table` | | `RNS/Transport.py:2199-2208` | PROOF receipt forwarding via `reverse_table` | @@ -3555,7 +3560,8 @@ A client running on a constrained device (less RAM, slower CPU) can scale all of | Sender sees DATA bursts repeatedly retransmitted, link dies | §6.5 — receiver isn't emitting the mandatory PROOF receipt for each CTX_NONE Link DATA packet | `tools/verify_proof_packet.py` | | Some peers work, others reject every PROOF I send | §6.5.2 — wrong proof body length. Upstream default emits 64-byte implicit proofs (`signature` only) but your peer expects 96-byte explicit (`packet_hash \|\| signature`). Validator dispatches on length | `tools/verify_proof_packet.py` | | Initiator and responder complete LRPROOF but every Resource ADV / link DATA the initiator sends is silently dropped at the responder | §6.4.2 — initiator never emitted LRRTT after LRPROOF. Responder stays in `HANDSHAKE`, `link_established` callback never fires, LXMF's `set_resource_strategy(ACCEPT_APP)` never installs, and `Link.receive` hits the silent `ACCEPT_NONE` branch on every RESOURCE_ADV | §6.4.2 | -| Single-hop link works, but the same flow over a multi-hop link silently drops every link DATA / Resource part at the destination | §6.4.3 — link-addressed packets emitted as `HEADER_2` with `transport_id` set to the next-hop relay. link_table forwarding doesn't strip `transport_id`, so the destination's `packet_filter` rejects it as "for another transport instance" (LOG_EXTREME). Use `HEADER_1` and `transport_id=None` regardless of hop count | §6.4.3 | +| Single-hop link works, but the same flow over a multi-hop link silently drops every link DATA / Resource part | §6.4.3 — link-addressed packets emitted as `HEADER_2`. The addressed relay can forward it, but the following node rejects the retained `transport_id` as naming another transport instance. Use `HEADER_1` and `transport_id=None` regardless of hop count | `tools/verify_transport_link.py` | +| Relay capture shows Link DATA forwarded, but endpoint callback never runs | §12.5.2 — verify `destination_type == LINK` and the active Link's attached interface. Relay link-table lookup does not itself require LINK destination type | `tools/verify_transport_link.py` | ### Resource transfers (large bodies) diff --git a/agent.md b/agent.md index bb9dab7..41c99a0 100644 --- a/agent.md +++ b/agent.md @@ -170,7 +170,7 @@ Initial confidence assessment (subjective, not authoritative — re-do this audi | §10 Resource fragmentation | Source-audited against RNS 1.2.4 and runtime-verified by `tools/verify_resource.py`, including deterministic vectors, receiver assembly/proof, multi-segment sizing, and negative cases. | | §11 REQUEST/RESPONSE | Source-audited against RNS 1.2.4 and runtime-verified by `tools/verify_request_response.py`, including packet/Resource forms, request-ID domains, correlation, and authorization constants. | | §18 Test vectors | Populated with identities, announces, opportunistic LXMF, Link establishment, link-delivered LXMF, Resource, and REQUEST/RESPONSE. Future work should add broader negative vectors. | -| §12 Source map | High | +| §12 Transport | Source-audited against RNS 1.2.4. Transport-relayed Link establishment, bidirectional traffic, and failure cases are runtime-verified by `tools/verify_transport_link.py`; broader announce/path/tunnel behavior remains source-cited. | **Historical bootstrap tasks from the initial audit, now mostly complete:** diff --git a/audits/transport-link-tier1-rns-1.2.4.md b/audits/transport-link-tier1-rns-1.2.4.md new file mode 100644 index 0000000..3d8e2b5 --- /dev/null +++ b/audits/transport-link-tier1-rns-1.2.4.md @@ -0,0 +1,81 @@ +# Tier 1 Audit: Transport-Relayed Links + +Question: How does an RNS 1.2.4 transport node forward LINKREQUEST, LRPROOF, +established-Link traffic, and proofs, and which failure conditions are hidden +by direct-Link tests? + +Evidence baseline: + +- RNS package: `rns==1.2.4` +- Primary source: `RNS/Transport.py` +- Audit date: 2026-06-08 + +Tier 2 evidence is `tools/verify_transport_link.py` and +`test-vectors/transport-link.json`. Confirmed findings are promoted into +`SPEC.md`, `flows/transport-link.md`, and `playbook.md`. + +## Confirmed Model + +1. LINKREQUEST is destination-routed through `path_table`. At a last-hop + relay, HEADER_2 is rewritten to HEADER_1 and a `link_table` entry is + created. The link ID remains invariant because its hashable part excludes + mutable transport-header bytes. + +2. A relay's link-table entry records both interfaces and two expected hop + counts. Established-Link traffic is forwarded only when its ingress + interface and incremented hop count identify a valid direction. + +3. LRPROOF has a stricter branch than established-Link traffic. It must arrive + from `IDX_LT_NH_IF`, match `IDX_LT_REM_HOPS`, and carry a valid responder + signature before it is forwarded and `IDX_LT_VALIDATED` becomes true. + +4. The established-Link forwarding branch does **not** check + `IDX_LT_VALIDATED`. Matching link-id traffic can therefore be forwarded + while the entry is still awaiting LRPROOF. The flag controls stale-entry + handling in `Transport.jobs`; it is not a forwarding authorization gate. + +5. The established-Link forwarding branch also does **not** require + `destination_type == LINK`; it keys on destination hash, packet kind/context + exclusions, interface, and hop count. A wrong destination type can cross a + relay, but the endpoint's local DATA dispatch only delivers + `destination_type == LINK` to an active Link. + +6. Link DATA, Resource traffic, and Link-addressed PROOF packets follow + `link_table`, not `reverse_table`. `reverse_table` is used by ordinary + destination-routed DATA and its returning proof. + +7. Valid post-handshake Link packets are HEADER_1. Link-table forwarding + preserves the existing header layout and only changes the hops byte. An + artificial HEADER_2 Link packet addressed to the first relay can cross that + relay, but the following node rejects it because its `transport_id` still + names the prior relay. + +## Corrections to Bootstrap Prose + +- §12.5 was titled "Reverse-table link transport", conflating two distinct + mechanisms. Link traffic uses `link_table`; ordinary DATA proofs use + `reverse_table`. +- §12.5 stated that forwarding begins after LRPROOF validation. The runtime + forwards matching traffic before validation. +- The playbook stated that omitting `dest_type=LINK` prevents relay lookup. + Relay lookup still occurs; endpoint Link dispatch is where that malformed + DATA is not delivered. +- The link-table constant at entry index 1 is `IDX_LT_NH_TRID`, not + `IDX_LT_NH_ID`. + +## Tier 2 Scope + +`tools/verify_transport_link.py` drives upstream `Transport.inbound` and +verifies: + +1. Last-hop LINKREQUEST HEADER_2-to-HEADER_1 conversion and link-table shape. +2. LRPROOF success plus bad signature, wrong-interface, and wrong-hop drops. +3. Established-Link DATA forwarding in both directions. +4. Wrong-hop and wrong-interface drops. +5. Forwarding while `IDX_LT_VALIDATED` is false. +6. Relay forwarding of a wrong-destination-type packet, distinguishing relay + lookup from endpoint dispatch. +7. Link-addressed PROOF forwarding without `reverse_table`. +8. HEADER_2 Link traffic crossing the addressed relay and being rejected by + the next node's packet filter. + diff --git a/flows/send-link-lxmf.md b/flows/send-link-lxmf.md index 5ff26b1..f1be17d 100644 --- a/flows/send-link-lxmf.md +++ b/flows/send-link-lxmf.md @@ -143,7 +143,9 @@ so the LXMessage advances `SENT → DELIVERED` when the recipient's PROOF for th ### 6. Wire bytes leave (KISS / HDLC framing) -Same as `send-opportunistic-lxmf.md` step 9 — the framed link DATA packet leaves the interface. Note that, in contrast to opportunistic DATA, **`Transport.outbound` does NOT apply the HEADER_1→HEADER_2 conversion for link-addressed packets**, regardless of how many transport hops the link traverses. The HEADER_1→HEADER_2 path is keyed on a `path_table` lookup against `dest_hash`; a link-addressed packet's `dest_hash` is the `link_id`, which lives in `link_table`, not `path_table`. Relays then forward it via `link_table` forwarding — which preserves the header bytes verbatim — so emitting HEADER_2 with `transport_id` set on a link-addressed packet would have it dropped at the destination's `packet_filter` as "for another transport instance" (SPEC.md §6.4.3). The next-hop interface for a link is cached on the link object (`link.attached_interface`) so all subsequent traffic uses that same interface without further path-table lookup. +Same as `send-opportunistic-lxmf.md` step 9 — the framed link DATA packet leaves the interface. Note that, in contrast to opportunistic DATA, **`Transport.outbound` does NOT apply the HEADER_1→HEADER_2 conversion for link-addressed packets**, regardless of how many transport hops the link traverses. The HEADER_1→HEADER_2 path is keyed on a `path_table` lookup against `dest_hash`; a link-addressed packet's `dest_hash` is the `link_id`, which lives in `link_table`, not `path_table`. Relays forward it via `link_table`, preserving the header layout and changing only hops. If a clean-room sender emits HEADER_2, the addressed relay can forward it, but the next node drops it because the retained `transport_id` still names the prior relay (SPEC.md §6.4.3). The next-hop interface for a link is cached on the link object (`link.attached_interface`) so all subsequent traffic uses that same interface without further path-table lookup. + +See [`transport-link.md`](transport-link.md) for direction-specific relay checks and failure diagnostics. ### 7. PROOF receipt arrives → `__mark_delivered` fires diff --git a/flows/transport-link.md b/flows/transport-link.md new file mode 100644 index 0000000..a821a68 --- /dev/null +++ b/flows/transport-link.md @@ -0,0 +1,49 @@ +# Flow: Transport-Relayed Link + +This flow follows a Link across one transport relay, pinned to RNS 1.2.4. +It focuses on behavior that direct-Link testing cannot expose. + +## Establishment + +1. The initiator sends LINKREQUEST toward the responder's destination hash. + As normal destination-routed traffic, a multi-hop LINKREQUEST can be + HEADER_2 with `transport_id` naming the relay. +2. The relay matches that `transport_id` and the destination's `path_table` + entry. On the last hop it strips the transport slot, emits HEADER_1 toward + the responder, and creates a `link_table[link_id]` entry. +3. The responder emits LRPROOF as HEADER_1 toward `link_id`. +4. The relay accepts LRPROOF only from the responder-side interface at the + recorded remaining-hop count and only after validating the responder's + signature. It forwards LRPROOF toward the initiator and marks the table + entry validated. + +## Established Traffic + +Post-handshake packets use HEADER_1 and are addressed to `link_id`. + +- Initiator to responder: packet arrives on `IDX_LT_RCVD_IF`; after inbound + hop increment it must equal `IDX_LT_HOPS`; relay sends on `IDX_LT_NH_IF`. +- Responder to initiator: packet arrives on `IDX_LT_NH_IF`; after inbound hop + increment it must equal `IDX_LT_REM_HOPS`; relay sends on + `IDX_LT_RCVD_IF`. +- The relay preserves every byte except the hops byte. +- DATA, Resource packets, control packets, and Link-addressed PROOF packets all + use this link-table path. + +The forwarding branch is not gated by `IDX_LT_VALIDATED`. Validation affects +entry lifetime, while interface and hop-count checks determine whether a +packet is forwarded. + +## Failure Diagnostics + +| Observation | Meaning | +|---|---| +| LINKREQUEST crosses relay, LRPROOF does not | Check LRPROOF signature, ingress interface, and expected hop count. | +| Traffic works direct but not through relay | Check that post-handshake packets are HEADER_1 and use `link_id`; then check relay interface/hop direction. | +| Relay forwards DATA but endpoint silently ignores it | Confirm `destination_type == LINK` and that the endpoint Link is attached to the receiving interface. | +| First relay forwards HEADER_2 Link DATA, next node drops it | The stale `transport_id` still names the first relay; established-Link traffic must be HEADER_1. | +| Link traffic forwards before LRPROOF | Expected upstream behavior; `IDX_LT_VALIDATED` is not checked by the forwarding branch. | + +Executable evidence: `tools/verify_transport_link.py` and +`test-vectors/transport-link.json`. + diff --git a/playbook.md b/playbook.md index 0ebf355..c490422 100644 --- a/playbook.md +++ b/playbook.md @@ -228,10 +228,10 @@ Each entry: date, one-line symptom, spec section that governs it, one-line fix, ### 2026-04 — Link DATA addressed without dest_type=LINK silently dropped -- **Symptom:** Outbound link DATA from the mobile-app arrives at the relay but is never forwarded to the responder. -- **Spec section:** §12.5.2. Packets addressed to a `link_id` MUST have `dest_type = LINK`; otherwise the relay's `link_table` lookup never fires and the packet is dropped. +- **Symptom:** Outbound link DATA from the mobile-app crosses the relay but is never delivered to the responder's active Link. +- **Spec section:** §12.5.2. Packets addressed to a `link_id` MUST have `dest_type = LINK`. Upstream relay lookup still keys on the link ID and can forward a wrong-type packet, but endpoint DATA dispatch only hands `dest_type = LINK` to active Links. - **Fix:** Set `dest_type = DEST_LINK` on all post-handshake link-bound packets. -- **Lesson:** Look at every field the spec mandates, not just the ones that "look right." +- **Lesson:** Distinguish relay forwarding from endpoint dispatch; a relay capture alone does not prove delivery. ### 2026-03 — REQUEST path_hash truncation diff --git a/test-vectors/README.md b/test-vectors/README.md index 60d80c2..613c33f 100644 --- a/test-vectors/README.md +++ b/test-vectors/README.md @@ -15,8 +15,9 @@ Populated against RNS 1.2.4 / LXMF 0.9.7: - ✅ `request-response.json` — Link REQUEST/RESPONSE packet and Resource forms with deterministic correlation IDs (regenerator: `../tools/regen_request_response.py`, verifier: `../tools/verify_request_response.py`). - ✅ `propagated-lxmf.json` — PROPAGATED LXMF submission packet/Resource boundary, full transient IDs, and `/get` framing (regenerator: `../tools/regen_propagated_lxmf.py`, verifier: `../tools/verify_propagated_lxmf.py`). - ✅ `propagation-peer.json` — propagation-node announce, directional peering key, `/offer`, and peer-sync Resource plaintext (regenerator: `../tools/regen_propagation_peer.py`, verifier: `../tools/verify_propagation_peer.py`). +- ✅ `transport-link.json` — one-relay LINKREQUEST, LRPROOF, established-Link DATA, and invalid HEADER_2 fixtures (regenerator: `../tools/regen_transport_link.py`, verifier: `../tools/verify_transport_link.py`). -All nine files are byte-deterministic across runs: regenerators pin every random source (ephemeral keys, IVs, `random_hash` values, timestamps) so the output is reproducible against a fixed upstream RNS / LXMF version. +All ten files are byte-deterministic across runs: regenerators pin every random source (ephemeral keys, IVs, `random_hash` values, timestamps) so the output is reproducible against a fixed upstream RNS / LXMF version. See [`../agent.md`](../agent.md) §3 and [`../todo.md`](../todo.md) for the evidence model and remaining task list. @@ -33,6 +34,7 @@ Each vector lives in a per-domain JSON file, e.g.: - `request-response.json` — Link REQUEST/RESPONSE packet and Resource forms - `propagated-lxmf.json` — PROPAGATED submission and `/get` response forms - `propagation-peer.json` — propagation-node announce and peer-sync forms +- `transport-link.json` — transport-relayed Link establishment and traffic forms Each entry should include: @@ -62,5 +64,6 @@ For the spec to claim "an implementation that passes all test vectors interopera 8. **REQUEST/RESPONSE** — packet and Resource RPC forms, request-ID derivation, and response correlation. 9. **Propagated LXMF** — recipient-encrypted submission, full transient-ID derivation, packet/Resource selection, and `/get` framing. 10. **Propagation peer sync** — node announce, directional peering key, `/offer` selection, and stamped Resource transfer. +11. **Transport-relayed Link** — LINKREQUEST/LRPROOF relay state, bidirectional established-Link forwarding, and invalid-header failure behavior. A separate vector set for FAILURE cases is also useful: malformed announces, expired ratchets, mismatched signatures. An implementation should reject those as a regression-prevention measure. diff --git a/test-vectors/transport-link.json b/test-vectors/transport-link.json new file mode 100644 index 0000000..25f08d2 --- /dev/null +++ b/test-vectors/transport-link.json @@ -0,0 +1,29 @@ +{ + "_about": "Transport-relayed Link fixtures derived from links.json and link-lxmf.json. The runtime verifier exercises upstream Transport.inbound with a one-relay path.", + "inputs": { + "link_vector_label": "alice_to_bob_aes256cbc", + "link_lxmf_vector_label": "alice_to_bob_direct_packet_boundary", + "relay_identity_label": "alice", + "relay_identity_hash_hex": "28d43a11abc1094301a59ed3b44f127b", + "next_hop_transport_id_hex": "202122232425262728292a2b2c2d2e2f" + }, + "expected": { + "link_id_hex": "7ee5fe3e4952c9ac4519b537f6278474", + "destination_hash_hex": "8c670c64308e0325ea0fd7c72787449d", + "incoming_header2_linkrequest_raw_hex": "520028d43a11abc1094301a59ed3b44f127b8c670c64308e0325ea0fd7c72787449d007b4e909bbe7ffe44c465a220037d608ee35897d31ef972f07f74892cb0f73f13a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f02001f4", + "forwarded_header1_linkrequest_raw_hex": "02018c670c64308e0325ea0fd7c72787449d007b4e909bbe7ffe44c465a220037d608ee35897d31ef972f07f74892cb0f73f13a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f02001f4", + "incoming_lrproof_raw_hex": "0f007ee5fe3e4952c9ac4519b537f6278474ff1de2168a36a816163aec0bb0749ff6792f78eb4f7b39156f8ee5c8693e83ebd67439ac28d9e4603334428713154edd04395b0b8acec2f703c05c3d38af133e0c7b0d47d93427f8311160781c7c733fd89f88970aef490d8aa0ee19a4cb8a1b142001f4", + "forwarded_lrproof_raw_hex": "0f017ee5fe3e4952c9ac4519b537f6278474ff1de2168a36a816163aec0bb0749ff6792f78eb4f7b39156f8ee5c8693e83ebd67439ac28d9e4603334428713154edd04395b0b8acec2f703c05c3d38af133e0c7b0d47d93427f8311160781c7c733fd89f88970aef490d8aa0ee19a4cb8a1b142001f4", + "incoming_link_data_raw_hex": "0c007ee5fe3e4952c9ac4519b537f6278474005152535455565758595a5b5c5d5e5f6013989c7e9cb3ad3d547b14028cb2c8cb6d253f5e0de613cfa9ee588413147738ab5810b8e24c5174bf4d2f8e06a127c29703cd7ab276e020bdef730d1df6aac088518a1be128b927401a3998454207ff967f64bbe2cf58e8aea7759e3dd8d197b086d70ba654fe656bb7196b825f080c0edbe793d0b34c188fe0f56ea4f9c6912c01647c78fefe1f9b5cde08a322b7a5c86570018b7f4ea55ed8a07f1f4d6b34dbe75f3009965de388286f3971558fb7d0f0baae1ebafe0423aab50742d83d6042a25781553d808228f16a479c5d41fa23fee3cd01ab88b4d36c0fe9546baf6954c9a393026b04d0397692221f931548ec48afe7e6553c9e2f3c142eb38e692cb1cecaa880fe7372fd494d4dc9bd4852b172755074aff59634959e45338e90d3d7066480ab410a70dafaaaf579e953698dd7dab6ec2968e51e4f77550a1b7392c731066d8b53625cc163a3155f71b4a2201ffb7c7fc31d397e9140402afd7d722e447ef2e723e158c37ebf92828f0383a8eed02f233e1996a07d9d2ca572a1dbf49d58780d15c829fd3d53cc27b5016ce4fdd696c384b8d0ef71d1aba605c14320b5b0e0c20899e65e80cb35e80fa20fe5947055b2ad173bd1e58297affb8d4af5f1c1b27e1959b17e27c53625ddd264", + "forwarded_link_data_raw_hex": "0c017ee5fe3e4952c9ac4519b537f6278474005152535455565758595a5b5c5d5e5f6013989c7e9cb3ad3d547b14028cb2c8cb6d253f5e0de613cfa9ee588413147738ab5810b8e24c5174bf4d2f8e06a127c29703cd7ab276e020bdef730d1df6aac088518a1be128b927401a3998454207ff967f64bbe2cf58e8aea7759e3dd8d197b086d70ba654fe656bb7196b825f080c0edbe793d0b34c188fe0f56ea4f9c6912c01647c78fefe1f9b5cde08a322b7a5c86570018b7f4ea55ed8a07f1f4d6b34dbe75f3009965de388286f3971558fb7d0f0baae1ebafe0423aab50742d83d6042a25781553d808228f16a479c5d41fa23fee3cd01ab88b4d36c0fe9546baf6954c9a393026b04d0397692221f931548ec48afe7e6553c9e2f3c142eb38e692cb1cecaa880fe7372fd494d4dc9bd4852b172755074aff59634959e45338e90d3d7066480ab410a70dafaaaf579e953698dd7dab6ec2968e51e4f77550a1b7392c731066d8b53625cc163a3155f71b4a2201ffb7c7fc31d397e9140402afd7d722e447ef2e723e158c37ebf92828f0383a8eed02f233e1996a07d9d2ca572a1dbf49d58780d15c829fd3d53cc27b5016ce4fdd696c384b8d0ef71d1aba605c14320b5b0e0c20899e65e80cb35e80fa20fe5947055b2ad173bd1e58297affb8d4af5f1c1b27e1959b17e27c53625ddd264", + "invalid_header2_link_data_raw_hex": "5c0028d43a11abc1094301a59ed3b44f127b7ee5fe3e4952c9ac4519b537f6278474005152535455565758595a5b5c5d5e5f6013989c7e9cb3ad3d547b14028cb2c8cb6d253f5e0de613cfa9ee588413147738ab5810b8e24c5174bf4d2f8e06a127c29703cd7ab276e020bdef730d1df6aac088518a1be128b927401a3998454207ff967f64bbe2cf58e8aea7759e3dd8d197b086d70ba654fe656bb7196b825f080c0edbe793d0b34c188fe0f56ea4f9c6912c01647c78fefe1f9b5cde08a322b7a5c86570018b7f4ea55ed8a07f1f4d6b34dbe75f3009965de388286f3971558fb7d0f0baae1ebafe0423aab50742d83d6042a25781553d808228f16a479c5d41fa23fee3cd01ab88b4d36c0fe9546baf6954c9a393026b04d0397692221f931548ec48afe7e6553c9e2f3c142eb38e692cb1cecaa880fe7372fd494d4dc9bd4852b172755074aff59634959e45338e90d3d7066480ab410a70dafaaaf579e953698dd7dab6ec2968e51e4f77550a1b7392c731066d8b53625cc163a3155f71b4a2201ffb7c7fc31d397e9140402afd7d722e447ef2e723e158c37ebf92828f0383a8eed02f233e1996a07d9d2ca572a1dbf49d58780d15c829fd3d53cc27b5016ce4fdd696c384b8d0ef71d1aba605c14320b5b0e0c20899e65e80cb35e80fa20fe5947055b2ad173bd1e58297affb8d4af5f1c1b27e1959b17e27c53625ddd264" + }, + "rns_version_at_generation": "1.2.4", + "generator_script": "tools/regen_transport_link.py", + "verifies_spec_sections": [ + "6.3", + "6.4.3", + "12.2.4", + "12.5" + ] +} diff --git a/tools/regen_transport_link.py b/tools/regen_transport_link.py new file mode 100644 index 0000000..cf565ad --- /dev/null +++ b/tools/regen_transport_link.py @@ -0,0 +1,96 @@ +""" +Regenerator for test-vectors/transport-link.json. + +Derives deterministic relay-facing Link packets from the existing handshake +and DIRECT-LXMF vectors. Runtime forwarding behavior is verified separately by +tools/verify_transport_link.py against upstream RNS 1.2.4. +""" + +from __future__ import annotations + +import json +import os + +import RNS + + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +LINKS_PATH = os.path.join(REPO_ROOT, "test-vectors", "links.json") +LINK_LXMF_PATH = os.path.join(REPO_ROOT, "test-vectors", "link-lxmf.json") +IDS_PATH = os.path.join(REPO_ROOT, "test-vectors", "identities.json") +OUT_PATH = os.path.join(REPO_ROOT, "test-vectors", "transport-link.json") + +NEXT_HOP_ID = bytes.fromhex("202122232425262728292a2b2c2d2e2f") + + +def load_json(path: str): + with open(path, "r", encoding="utf-8") as input_file: + return json.load(input_file) + + +def header_two(raw: bytes, transport_id: bytes) -> bytes: + flags = ( + (RNS.Packet.HEADER_2 << 6) + | (RNS.Transport.TRANSPORT << 4) + | (raw[0] & 0x0F) + ) + return bytes([flags, raw[1]]) + transport_id + raw[2:] + + +def bump_hops(raw: bytes) -> bytes: + return raw[:1] + bytes([raw[1] + 1]) + raw[2:] + + +def main() -> None: + links = load_json(LINKS_PATH)["vectors"][0] + link_lxmf = load_json(LINK_LXMF_PATH)["vectors"][0] + identities = load_json(IDS_PATH)["vectors"] + alice = next(item for item in identities if item["label"] == "alice") + relay_id = bytes.fromhex(alice["expected"]["identity_hash_hex"]) + linkrequest = bytes.fromhex(links["expected"]["linkrequest_raw_hex"]) + lrproof = bytes.fromhex(links["expected"]["lrproof_raw_hex"]) + link_data = bytes.fromhex(link_lxmf["expected"]["link_packet_raw_hex"]) + + incoming_linkrequest = header_two(linkrequest, relay_id) + forwarded_linkrequest = bump_hops(linkrequest) + forwarded_lrproof = bump_hops(lrproof) + forwarded_link_data = bump_hops(link_data) + header_two_link_data = header_two(link_data, relay_id) + + vector = { + "_about": ( + "Transport-relayed Link fixtures derived from links.json and " + "link-lxmf.json. The runtime verifier exercises upstream " + "Transport.inbound with a one-relay path." + ), + "inputs": { + "link_vector_label": links["label"], + "link_lxmf_vector_label": link_lxmf["label"], + "relay_identity_label": "alice", + "relay_identity_hash_hex": relay_id.hex(), + "next_hop_transport_id_hex": NEXT_HOP_ID.hex(), + }, + "expected": { + "link_id_hex": links["expected"]["link_id_hex"], + "destination_hash_hex": linkrequest[2:18].hex(), + "incoming_header2_linkrequest_raw_hex": incoming_linkrequest.hex(), + "forwarded_header1_linkrequest_raw_hex": forwarded_linkrequest.hex(), + "incoming_lrproof_raw_hex": lrproof.hex(), + "forwarded_lrproof_raw_hex": forwarded_lrproof.hex(), + "incoming_link_data_raw_hex": link_data.hex(), + "forwarded_link_data_raw_hex": forwarded_link_data.hex(), + "invalid_header2_link_data_raw_hex": header_two_link_data.hex(), + }, + "rns_version_at_generation": RNS.__version__, + "generator_script": "tools/regen_transport_link.py", + "verifies_spec_sections": ["6.3", "6.4.3", "12.2.4", "12.5"], + } + + with open(OUT_PATH, "w", encoding="utf-8") as output_file: + json.dump(vector, output_file, indent=2) + output_file.write("\n") + print(f"Wrote {OUT_PATH}") + + +if __name__ == "__main__": + main() diff --git a/tools/verify_transport_link.py b/tools/verify_transport_link.py new file mode 100644 index 0000000..1190d2f --- /dev/null +++ b/tools/verify_transport_link.py @@ -0,0 +1,344 @@ +""" +Verifier for transport-relayed Link establishment and established-Link traffic. + +Exercises upstream RNS 1.2.4 Transport.inbound with a synthetic one-relay +topology. This catches behavior that direct-Link and self-round-trip tests do +not expose. +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +import time + +import RNS +from RNS import Transport +from RNS.Transport import ( + IDX_LT_DSTHASH, + IDX_LT_HOPS, + IDX_LT_NH_IF, + IDX_LT_NH_TRID, + IDX_LT_PROOF_TMO, + IDX_LT_RCVD_IF, + IDX_LT_REM_HOPS, + IDX_LT_VALIDATED, + IDX_PT_EXPIRES, + IDX_PT_HOPS, + IDX_PT_NEXT_HOP, + IDX_PT_PACKET, + IDX_PT_RANDBLOBS, + IDX_PT_RVCD_IF, + IDX_PT_TIMESTAMP, +) + + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +VECTORS_PATH = os.path.join(REPO_ROOT, "test-vectors", "transport-link.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 load_json(path: str): + with open(path, "r", encoding="utf-8") as input_file: + return json.load(input_file) + + +def init_minimal_rns(): + config_dir = tempfile.mkdtemp(prefix="rns-verify-transport-link-") + 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) + + +class FakeInterface: + OUT = True + IN = True + HW_MTU = RNS.Reticulum.MTU + AUTOCONFIGURE_MTU = True + FIXED_MTU = True + bitrate = 1_000_000 + mode = RNS.Interfaces.Interface.Interface.MODE_FULL + + def __init__(self, name: str): + self.name = name + + def __str__(self): + return self.name + + +def clear_transport_state() -> None: + Transport.packet_hashlist = set() + Transport.packet_hashlist_prev = set() + Transport.path_table.clear() + Transport.link_table.clear() + Transport.reverse_table.clear() + Transport.destinations_map.clear() + Transport.pending_links.clear() + Transport.active_links.clear() + Transport.local_client_interfaces.clear() + + +def assert_forward(captured: list, interface, raw: bytes, label: str) -> None: + if len(captured) != 1: + fail(f"{label}: expected one forwarded packet, got {len(captured)}") + if captured[0][0] is not interface: + fail(f"{label}: forwarded on wrong interface") + if captured[0][1] != raw: + fail(f"{label}: forwarded bytes mismatch") + + +def assert_drop(captured: list, label: str) -> None: + if captured: + fail(f"{label}: expected drop, got {len(captured)} forwarded packet(s)") + + +def seed_path(destination_hash: bytes, next_hop: bytes, outbound_interface) -> None: + Transport.path_table[destination_hash] = [ + time.time(), next_hop, 1, time.time() + 60, [], + outbound_interface, None, + ] + + +def make_link_entry(next_hop: bytes, responder_if, initiator_if, + destination_hash: bytes, validated: bool = False) -> list: + return [ + time.time(), next_hop, responder_if, 1, initiator_if, 1, + destination_hash, validated, time.time() + 60, + ] + + +def verify_linkrequest(vector: dict, initiator_if, responder_if, captured: list) -> list: + expected = vector["expected"] + destination_hash = bytes.fromhex(expected["destination_hash_hex"]) + link_id = bytes.fromhex(expected["link_id_hex"]) + next_hop = bytes.fromhex(vector["inputs"]["next_hop_transport_id_hex"]) + seed_path(destination_hash, next_hop, responder_if) + + Transport.inbound( + bytes.fromhex(expected["incoming_header2_linkrequest_raw_hex"]), + initiator_if, + ) + assert_forward( + captured, responder_if, + bytes.fromhex(expected["forwarded_header1_linkrequest_raw_hex"]), + "LINKREQUEST last-hop forwarding", + ) + if link_id not in Transport.link_table: + fail("LINKREQUEST did not create link_table entry") + entry = Transport.link_table[link_id] + if entry[IDX_LT_NH_TRID] != next_hop: + fail("link_table next-hop transport ID mismatch") + if entry[IDX_LT_NH_IF] is not responder_if or entry[IDX_LT_RCVD_IF] is not initiator_if: + fail("link_table interfaces do not preserve Link direction") + if entry[IDX_LT_REM_HOPS] != 1 or entry[IDX_LT_HOPS] != 1: + fail("link_table expected-hop values mismatch") + if entry[IDX_LT_DSTHASH] != destination_hash or entry[IDX_LT_VALIDATED]: + fail("link_table destination/initial validation state mismatch") + if entry[IDX_LT_PROOF_TMO] <= time.time(): + fail("link_table proof timeout is not in the future") + print("PASS S12.2.4 relayed LINKREQUEST strips HEADER_2 and creates directional link_table state") + return entry + + +def verify_lrproof(vector: dict, bob_identity, initiator_if, responder_if, + captured: list) -> None: + expected = vector["expected"] + link_id = bytes.fromhex(expected["link_id_hex"]) + destination_hash = bytes.fromhex(expected["destination_hash_hex"]) + next_hop = bytes.fromhex(vector["inputs"]["next_hop_transport_id_hex"]) + RNS.Identity.remember(bytes(32), destination_hash, bob_identity.get_public_key(), None) + + captured.clear() + Transport.inbound(bytes.fromhex(expected["incoming_lrproof_raw_hex"]), responder_if) + assert_forward( + captured, initiator_if, bytes.fromhex(expected["forwarded_lrproof_raw_hex"]), + "valid LRPROOF", + ) + if not Transport.link_table[link_id][IDX_LT_VALIDATED]: + fail("valid LRPROOF did not validate link_table entry") + + for label, raw, interface in [ + ("wrong-interface LRPROOF", bytes.fromhex(expected["incoming_lrproof_raw_hex"]), initiator_if), + ("wrong-hop LRPROOF", bytes.fromhex(expected["incoming_lrproof_raw_hex"])[:1] + b"\x01" + bytes.fromhex(expected["incoming_lrproof_raw_hex"])[2:], responder_if), + ]: + captured.clear() + Transport.link_table[link_id] = make_link_entry(next_hop, responder_if, initiator_if, destination_hash) + Transport.inbound(raw, interface) + assert_drop(captured, label) + if Transport.link_table[link_id][IDX_LT_VALIDATED]: + fail(f"{label}: invalid proof marked link validated") + + tampered = bytearray.fromhex(expected["incoming_lrproof_raw_hex"]) + tampered[-4] ^= 0x01 + captured.clear() + Transport.link_table[link_id] = make_link_entry(next_hop, responder_if, initiator_if, destination_hash) + Transport.inbound(bytes(tampered), responder_if) + assert_drop(captured, "bad-signature LRPROOF") + print("PASS S12.5.1 LRPROOF requires valid signature, direction, and hop count") + + +def verify_established_traffic(vector: dict, initiator_if, responder_if, + captured: list) -> None: + expected = vector["expected"] + link_id = bytes.fromhex(expected["link_id_hex"]) + destination_hash = bytes.fromhex(expected["destination_hash_hex"]) + next_hop = bytes.fromhex(vector["inputs"]["next_hop_transport_id_hex"]) + incoming = bytes.fromhex(expected["incoming_link_data_raw_hex"]) + forwarded = bytes.fromhex(expected["forwarded_link_data_raw_hex"]) + + for label, ingress, egress, validated in [ + ("initiator-to-responder", initiator_if, responder_if, True), + ("responder-to-initiator", responder_if, initiator_if, True), + ("pre-LRPROOF forwarding", initiator_if, responder_if, False), + ]: + captured.clear() + Transport.packet_hashlist.clear() + Transport.packet_hashlist_prev.clear() + Transport.link_table[link_id] = make_link_entry( + next_hop, responder_if, initiator_if, destination_hash, validated + ) + Transport.inbound(incoming, ingress) + assert_forward(captured, egress, forwarded, label) + + for label, raw, ingress in [ + ("wrong-hop Link DATA", incoming[:1] + b"\x01" + incoming[2:], initiator_if), + ("wrong-interface Link DATA", incoming, FakeInterface("unrelated")), + ]: + captured.clear() + Transport.packet_hashlist.clear() + Transport.packet_hashlist_prev.clear() + Transport.link_table[link_id] = make_link_entry( + next_hop, responder_if, initiator_if, destination_hash, True + ) + Transport.inbound(raw, ingress) + assert_drop(captured, label) + + wrong_type = bytes([(incoming[0] & ~0x0C) | (RNS.Destination.SINGLE << 2)]) + incoming[1:] + captured.clear() + Transport.packet_hashlist.clear() + Transport.packet_hashlist_prev.clear() + Transport.link_table[link_id] = make_link_entry( + next_hop, responder_if, initiator_if, destination_hash, True + ) + Transport.inbound(wrong_type, initiator_if) + assert_forward(captured, responder_if, wrong_type[:1] + b"\x01" + wrong_type[2:], "non-LINK destination type") + + class EndpointLink: + def __init__(self): + self.link_id = link_id + self.attached_interface = responder_if + self.received = [] + + def receive(self, packet): + self.received.append(packet) + + endpoint_link = EndpointLink() + Transport.link_table.clear() + Transport.active_links.append(endpoint_link) + Transport.packet_hashlist.clear() + Transport.packet_hashlist_prev.clear() + Transport.inbound(wrong_type, responder_if) + if endpoint_link.received: + fail("endpoint delivered non-LINK destination type to active Link") + Transport.packet_hashlist.clear() + Transport.packet_hashlist_prev.clear() + Transport.inbound(incoming, responder_if) + if len(endpoint_link.received) != 1: + fail("endpoint did not deliver LINK destination type to active Link") + Transport.active_links.clear() + + proof = bytes([(incoming[0] & ~0x03) | RNS.Packet.PROOF]) + incoming[1:35] + captured.clear() + Transport.packet_hashlist.clear() + Transport.packet_hashlist_prev.clear() + Transport.link_table[link_id] = make_link_entry( + next_hop, responder_if, initiator_if, destination_hash, True + ) + Transport.inbound(proof, responder_if) + assert_forward(captured, initiator_if, proof[:1] + b"\x01" + proof[2:], "Link PROOF") + if Transport.reverse_table: + fail("Link PROOF unexpectedly used reverse_table") + + print("PASS S12.5.2 link_table forwards both directions and Link PROOF without reverse_table") + print("PASS S12.5.2 relay lookup ignores VALIDATED/dest_type; endpoint Link dispatch requires dest_type=LINK") + + +def verify_invalid_header_two(vector: dict, initiator_if, responder_if, + captured: list, original_identity) -> None: + expected = vector["expected"] + link_id = bytes.fromhex(expected["link_id_hex"]) + destination_hash = bytes.fromhex(expected["destination_hash_hex"]) + next_hop = bytes.fromhex(vector["inputs"]["next_hop_transport_id_hex"]) + raw = bytes.fromhex(expected["invalid_header2_link_data_raw_hex"]) + + captured.clear() + Transport.packet_hashlist.clear() + Transport.packet_hashlist_prev.clear() + Transport.link_table[link_id] = make_link_entry( + next_hop, responder_if, initiator_if, destination_hash, True + ) + Transport.inbound(raw, initiator_if) + assert_forward(captured, responder_if, raw[:1] + b"\x01" + raw[2:], "HEADER_2 Link DATA first relay") + + packet = RNS.Packet(None, captured[0][1]) + if not packet.unpack(): + fail("could not unpack forwarded HEADER_2 Link DATA") + Transport.packet_hashlist.clear() + Transport.packet_hashlist_prev.clear() + Transport.identity = RNS.Identity() + if Transport.packet_filter(packet): + fail("next node accepted HEADER_2 Link DATA addressed to prior relay") + Transport.identity = original_identity + print("PASS S6.4.3 HEADER_2 Link DATA survives first link_table relay but is rejected by the next node") + + +def main() -> None: + print(f"verify_transport_link.py against RNS {RNS.__version__}") + init_minimal_rns() + vector = load_json(VECTORS_PATH) + identities = load_json(IDS_PATH)["vectors"] + alice = next(item for item in identities if item["label"] == "alice") + bob = next(item for item in identities if item["label"] == "bob") + relay_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"])) + initiator_if = FakeInterface("initiator-side") + responder_if = FakeInterface("responder-side") + captured: list[tuple[object, bytes]] = [] + original_transmit = Transport.transmit + original_transport_enabled = RNS.Reticulum.transport_enabled + original_identity = Transport.identity + + try: + clear_transport_state() + if relay_identity.hash.hex() != vector["inputs"]["relay_identity_hash_hex"]: + fail("pinned relay identity hash mismatch") + Transport.identity = relay_identity + RNS.Reticulum.transport_enabled = staticmethod(lambda: True) + Transport.transmit = staticmethod(lambda interface, raw: captured.append((interface, raw))) + + verify_linkrequest(vector, initiator_if, responder_if, captured) + verify_lrproof(vector, bob_identity, initiator_if, responder_if, captured) + verify_established_traffic(vector, initiator_if, responder_if, captured) + verify_invalid_header_two(vector, initiator_if, responder_if, captured, relay_identity) + print("ALL PASS") + finally: + Transport.transmit = original_transmit + RNS.Reticulum.transport_enabled = original_transport_enabled + Transport.identity = original_identity + clear_transport_state() + try: + RNS.Reticulum.exit_handler() + except Exception: + pass + + +if __name__ == "__main__": + main()