From 7ffbb0ef5ee518b09ac31fe941d6bc0fe888e030 Mon Sep 17 00:00:00 2001 From: John Poole Date: Mon, 8 Jun 2026 13:54:27 -0700 Subject: [PATCH] =?UTF-8?q?Completed=20the=20full=20link-delivered=20LXMF?= =?UTF-8?q?=20unit:=20Tier=201=20audit:=20`link-lxmf-tier1-rns-1.2.4-lxmf-?= =?UTF-8?q?0.9.7.md`=20Tier=202=20vectors/verifier:=20link-lxmf.json,=20re?= =?UTF-8?q?gen=5Flink=5Flxmf.py,=20and=20verify=5Flink=5Flxmf.py=20Tier=20?= =?UTF-8?q?3=20promotion:=20updated=20SPEC.md,=20flows,=20status,=20and=20?= =?UTF-8?q?documentation=20Key=20correction:=20the=20319/320=20boundary=20?= =?UTF-8?q?uses=20upstream=E2=80=99s=20computed=20LXMF=20content=5Fsize,?= =?UTF-8?q?=20not=20simply=20raw=20message=20content=20length.=20Also=20co?= =?UTF-8?q?rrected=20stale=20flow=20descriptions=20for=20KEEPALIVE=20(0xFA?= =?UTF-8?q?)=20and=20encrypted=20LINKCLOSE=20teardown=20(0xFC).=20Verifica?= =?UTF-8?q?tion:=20Deterministic=20vector=20regeneration:=20identical=20SH?= =?UTF-8?q?A-256=20Portable-path=20and=20formatting=20checks:=20pass=20Ful?= =?UTF-8?q?l=20pinned=20suite:=2017=20passed,=200=20failed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SPEC.md | 17 +- agent.md | 2 +- .../link-lxmf-tier1-rns-1.2.4-lxmf-0.9.7.md | 75 +++++ flows/receive-link-lxmf.md | 5 + flows/send-link-lxmf.md | 16 +- test-vectors/README.md | 6 +- test-vectors/link-lxmf.json | 80 ++++++ todo.md | 6 + tools/README.md | 2 + tools/regen_link_lxmf.py | 268 ++++++++++++++++++ tools/verify_link_lxmf.py | 205 ++++++++++++++ tools/verify_resource.py | 2 +- 12 files changed, 671 insertions(+), 13 deletions(-) create mode 100644 audits/link-lxmf-tier1-rns-1.2.4-lxmf-0.9.7.md create mode 100644 test-vectors/link-lxmf.json create mode 100644 tools/regen_link_lxmf.py create mode 100644 tools/verify_link_lxmf.py diff --git a/SPEC.md b/SPEC.md index e6e4eb0..5f5ed90 100644 --- a/SPEC.md +++ b/SPEC.md @@ -686,6 +686,16 @@ destination_hash(16) || source_hash(16) || signature(64) || msgpack_payload(...) Full layout. The Link's session key encrypts the whole blob. +For DIRECT delivery, upstream computes +`content_size = len(msgpack_payload) - TIMESTAMP_SIZE - STRUCT_OVERHEAD` and +selects a single Link DATA packet when `content_size <= +LINK_PACKET_MAX_CONTENT`; otherwise it sends the complete body as a Resource. +With default RNS 1.2.4 / LXMF 0.9.7 parameters the boundary is 319/320. The +threshold applies to this computed LXMF content size, not simply the raw +`content` field and not the complete signed body. At the 319 boundary, the +complete canonical body is 431 bytes (`Link.MDU`) and the Link-encrypted wire +packet is 499 bytes. (verified by `tools/verify_link_lxmf.py`) + ### 5.3 `msgpack_payload` A msgpack array of 4 elements (5th optional): @@ -2411,7 +2421,7 @@ The repeater repo's `pre_build.py` patches several other microReticulum protocol ## 10. Resource fragmentation protocol -A **Resource** is the standard RNS mechanism for transferring a payload that exceeds the per-packet content limit of an established Reticulum Link. LXMF, Link REQUEST/RESPONSE, NomadNet, and file-transfer utilities use it for large bodies; an application can also define its own sequencing protocol or use Channel, so Resource is not the only possible large-data mechanism. With default RNS 1.2.4 / LXMF 0.9.7 parameters, DIRECT LXMF selects Resource when content exceeds **319 bytes** (`LINK_PACKET_MAX_CONTENT = Link.MDU(431) - LXMF_OVERHEAD(112)`, verified by `tools/verify_resource.py`). Resource is built **on top of** an active Link — it relies on the Link's session key for encryption (§3.1 link-derived form) and on the Link's bidirectional DATA channel for control traffic. +A **Resource** is the standard RNS mechanism for transferring a payload that exceeds the per-packet content limit of an established Reticulum Link. LXMF, Link REQUEST/RESPONSE, NomadNet, and file-transfer utilities use it for large bodies; an application can also define its own sequencing protocol or use Channel, so Resource is not the only possible large-data mechanism. With default RNS 1.2.4 / LXMF 0.9.7 parameters, DIRECT LXMF selects Resource when its **computed LXMF content size** exceeds **319 bytes** (`LINK_PACKET_MAX_CONTENT = Link.MDU(431) - LXMF_OVERHEAD(112)`, verified by `tools/verify_link_lxmf.py`). This is `len(msgpack_payload) - TIMESTAMP_SIZE - STRUCT_OVERHEAD`, not necessarily `len(content)` and not the complete signed body. Resource is built **on top of** an active Link — it relies on the Link's session key for encryption (§3.1 link-derived form) and on the Link's bidirectional DATA channel for control traffic. The complete reference is `RNS/Resource.py` (1380 lines in RNS 1.2.4); `RNS/Packet.py:74-79` defines the context constants. This section describes the wire-level invariants a clean-room implementation must respect; many implementation choices (window sizing heuristics, watchdog timers, EIFR computation) are private and listed only when their absence would cause an interop break. @@ -2419,7 +2429,7 @@ The complete reference is `RNS/Resource.py` (1380 lines in RNS 1.2.4); `RNS/Pack Three triggers in upstream: -1. **`LXMessage.send()` for `DIRECT` method with `representation == RESOURCE`.** Set automatically when the encrypted-form LXMF body exceeds `LINK_PACKET_MAX_CONTENT` (`LXMF/LXMessage.py:415-421`). +1. **`LXMessage.send()` for `DIRECT` method with `representation == RESOURCE`.** Set automatically when the computed LXMF `content_size` exceeds `LINK_PACKET_MAX_CONTENT` (`LXMF/LXMessage.py:405-421`; verified by `tools/verify_link_lxmf.py`). 2. **NomadNet page request fulfillment** — a server returning a page whose body exceeds the link MTU. 3. **Direct file transfers** via `rncp` and similar utilities. @@ -3770,8 +3780,9 @@ See [`test-vectors/`](test-vectors/). Currently populated: - **`lxmf.json`** — deterministic opportunistic LXMF plaintext and Token ciphertext vectors. Verified by `tools/verify_lxmf_opportunistic.py`; regenerated by `tools/regen_lxmf.py`. Covers SPEC.md §3 and §5. - **`links.json`** — Link handshake and LRRTT vectors, including LINKREQUEST, LRPROOF, derived keys, and the activation packet. Verified by `tools/verify_link_handshake.py` and `tools/verify_link_lrrtt.py`; regenerated by `tools/regen_links.py`. Covers SPEC.md §6.1-§6.4 and §6.6. - **`resources.json`** — deterministic multi-part Resource ciphertext, part packets, hashmap, advertisement, and proof body. Verified by `tools/verify_resource.py`; regenerated by `tools/regen_resources.py`. Covers SPEC.md §10.2, §10.4, §10.8, and §10.12. +- **`link-lxmf.json`** — deterministic DIRECT LXMF vectors at the exact PACKET/RESOURCE boundary, using the session key from `links.json`. Verified by `tools/verify_link_lxmf.py`; regenerated by `tools/regen_link_lxmf.py`. Covers SPEC.md §5.2, §5.5, §5.6, §6.4.3, and §10.1. -Remaining vector work should focus on link-delivered LXMF bodies and broader negative/rejection cases rather than the original bootstrap categories. +Remaining vector work should focus on broader negative/rejection cases rather than the original bootstrap categories. An implementation that round-trips every test vector — both directions — should be wire-compatible with upstream Reticulum and LXMF for the covered operations. diff --git a/agent.md b/agent.md index aff8b1b..cf63228 100644 --- a/agent.md +++ b/agent.md @@ -168,7 +168,7 @@ Initial confidence assessment (subjective, not authoritative — re-do this audi | §8 KISS / HDLC framing | High — both work in production on the reference clients | | §9.1–§9.8 Implementation gotchas | Each was a real bug that bit a real implementation. High confidence each is real; some lack formal test scripts. | | §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 Test vectors | Historical bootstrap item. `test-vectors/` is now populated with identities, announces, opportunistic LXMF, and Link vectors. Future work should add negative vectors and link-delivered LXMF coverage. | +| §11 Test vectors | Historical bootstrap item. `test-vectors/` now covers identities, announces, opportunistic LXMF, Link establishment, link-delivered LXMF, and Resource. Future work should add broader negative vectors. | | §12 Source map | High | **Historical bootstrap tasks from the initial audit, now mostly complete:** diff --git a/audits/link-lxmf-tier1-rns-1.2.4-lxmf-0.9.7.md b/audits/link-lxmf-tier1-rns-1.2.4-lxmf-0.9.7.md new file mode 100644 index 0000000..08d9d4e --- /dev/null +++ b/audits/link-lxmf-tier1-rns-1.2.4-lxmf-0.9.7.md @@ -0,0 +1,75 @@ +# Tier 1 Audit: Link-Delivered LXMF + +Question: How does upstream LXMF 0.9.7 select, emit, receive, and acknowledge +DIRECT LXMF over an established RNS 1.2.4 Link? + +Evidence baseline: + +- RNS package: `rns==1.2.4` +- LXMF package: `lxmf==0.9.7` +- Sources: `LXMF/LXMessage.py`, `LXMF/LXMRouter.py`, `RNS/Packet.py`, + `RNS/Link.py`, and `RNS/Resource.py` +- Audit date: 2026-06-08 + +The Tier 2 evidence is `tools/verify_link_lxmf.py` and the deterministic +`test-vectors/link-lxmf.json`. Confirmed findings are promoted into `SPEC.md` +and the Link-LXMF flow documents. + +## Confirmed Model + +1. `LXMessage.pack()` computes: + + ``` + content_size = len(packed_payload) - TIMESTAMP_SIZE - STRUCT_OVERHEAD + ``` + + DIRECT selects PACKET when `content_size <= LINK_PACKET_MAX_CONTENT` + and Resource otherwise (`LXMessage.py:405-421`). The threshold applies to + this computed value, not simply `len(content)` and not the complete signed + LXMF body. + +2. With default RNS 1.2.4 / LXMF 0.9.7 parameters, + `LINK_PACKET_MAX_CONTENT = 319`. The deterministic boundary fixtures use + empty title/fields, so their raw content lengths also happen to be 319 and + 320. The PACKET fixture's complete canonical LXMF body is 431 bytes, equal + to `Link.MDU`; after Link Token framing its complete wire packet is 499 + bytes. + +3. DIRECT/PACKET passes the complete canonical body + `destination_hash || source_hash || signature || msgpack_payload` to + `RNS.Packet(link, packed)` (`LXMessage.py:627-635`). Packet packing emits + HEADER_1 DATA/LINK with context NONE and Link-derived Token encryption + (`Packet.py:176-219`). + +4. DIRECT/RESOURCE passes that same complete canonical body to + `RNS.Resource(packed, link, ...)` (`LXMessage.py:643-653`). Resource then + applies its own whole-stream Link encryption and fragmentation. + +5. On receive, `LXMRouter.delivery_packet()` proves the Link DATA packet, + classifies `destination_type == LINK` as DIRECT, and passes the decrypted + body unchanged to `lxmf_delivery()` (`LXMRouter.py:1822-1850`). Unlike the + opportunistic path, it does not prepend a destination hash. + +6. `delivery_resource_concluded()` passes the assembled Resource body to + `lxmf_delivery(..., method=DIRECT)` (`LXMRouter.py:1876-1885`). + +7. PACKET completion is the regular Link DATA proof callback; Resource + completion is the Resource callback. Both converge on + `LXMessage.__mark_delivered()` (`LXMessage.py:471-490, 594-603`). + +## Tier 2 Scope + +`tools/verify_link_lxmf.py` verifies: + +1. Exact computed-content boundary: 319 selects PACKET, 320 selects Resource. +2. Deterministic Link DATA bytes using the session key from `links.json`. +3. DIRECT/PACKET decrypts to the complete canonical LXMF body and validates + Alice's signature. +4. DIRECT/RESOURCE decrypts and reassembles to the same canonical form. +5. A wrong Link key cannot decrypt the DIRECT/PACKET ciphertext. +6. `LXMRouter.delivery_packet()` proves Link DATA, classifies it DIRECT, and + forwards the full plaintext unchanged. + +This work unit does not run a live threaded Link exchange. Link establishment, +LRRTT activation, generic Link proof format, and generic Resource behavior are +covered by their existing focused verifiers. diff --git a/flows/receive-link-lxmf.md b/flows/receive-link-lxmf.md index 985aea7..646691b 100644 --- a/flows/receive-link-lxmf.md +++ b/flows/receive-link-lxmf.md @@ -70,6 +70,11 @@ A regular DATA packet on the link (`context = NONE`, Token-encrypted with link s The handler calls `packet.prove()` immediately (mandatory PROOF receipt per §6.5), then dispatches the body to `LXMessage.unpack_from_bytes` and `lxmf_delivery` exactly like the opportunistic flow's steps 10-12. +`delivery_packet` passes Link plaintext through unchanged and marks the method +DIRECT; it does **not** prepend a destination hash. This distinction and the +319/320 PACKET/Resource boundary are verified by +`tools/verify_link_lxmf.py`. + ### 6. Inbound LXMF DATA — Resource representation A larger LXMF body arrives as a Resource transfer per `flows/receive-resource.md`. The Link's `resource_strategy = ACCEPT_APP` triggers `delivery_resource_advertised(resource)` (`LXMF/LXMRouter.py:1867-1874`): diff --git a/flows/send-link-lxmf.md b/flows/send-link-lxmf.md index 4bc5899..5ff26b1 100644 --- a/flows/send-link-lxmf.md +++ b/flows/send-link-lxmf.md @@ -13,9 +13,9 @@ Out of scope: opportunistic delivery (see [`send-opportunistic-lxmf.md`](send-op DIRECT is reached two ways: 1. **App-requested:** `LXMessage(desired_method=LXMessage.DIRECT, …)`. Used for messages too large for one packet, or for sessions where the app wants the link's full-duplex DATA channel (e.g. an interactive chat). The router opens or reuses an `RNS.Link` to the recipient and sends the message over it. -2. **Auto-downgrade from OPPORTUNISTIC:** `LXMessage.pack` at `LXMF/LXMessage.py:394-398` falls back to `DIRECT` if the encrypted-form content size exceeds `ENCRYPTED_PACKET_MAX_CONTENT`. The originator may or may not surface this transition to the user; it's silent at the protocol layer. +2. **Auto-downgrade from OPPORTUNISTIC:** `LXMessage.pack` at `LXMF/LXMessage.py:394-398` falls back to `DIRECT` if the computed LXMF `content_size` exceeds `ENCRYPTED_PACKET_MAX_CONTENT`. The originator may or may not surface this transition to the user; it's silent at the protocol layer. -Within DIRECT there are two **representations** decided at pack time (`LXMF/LXMessage.py:414-421`): +Within DIRECT there are two **representations** decided at pack time (`LXMF/LXMessage.py:405-421`). Upstream compares its computed `content_size = len(msgpack_payload) - TIMESTAMP_SIZE - STRUCT_OVERHEAD`, not simply `len(content)` or the full signed body: - **PACKET** — the body fits in a single `LINK_PACKET_MAX_CONTENT`-sized DATA packet on the link. - **RESOURCE** — the body is larger than that and must be sent as an `RNS.Resource` (multi-packet, fragmented, with its own checksum / progress / retransmit machinery). The Resource fragmentation protocol is documented in [`../SPEC.md`](../SPEC.md) §10 and the dedicated send/receive Resource flows. This flow document covers PACKET in full and links to those details for RESOURCE. @@ -147,11 +147,11 @@ Same as `send-opportunistic-lxmf.md` step 9 — the framed link DATA packet leav ### 7. PROOF receipt arrives → `__mark_delivered` fires -The recipient (per the receive flow — TODO `receive-link-lxmf.md`) decrypts the link DATA, parses the LXMF body via `LXMessage.unpack_from_bytes`, validates the signature, and emits a PROOF for this packet (`Packet.prove` from inside `LXMRouter.delivery_packet` at line 1820, same as the opportunistic receive). The PROOF travels back along the link, `PacketReceipt.proven` resolves on the sender, and `__mark_delivered` puts the LXMessage in `DELIVERED`. +The recipient (per [`receive-link-lxmf.md`](receive-link-lxmf.md)) decrypts the link DATA, parses the LXMF body via `LXMessage.unpack_from_bytes`, validates the signature, and emits a PROOF for this packet (`Packet.prove` from inside `LXMRouter.delivery_packet` at line 1820, same as the opportunistic receive). The PROOF travels back along the link, `PacketReceipt.proven` resolves on the sender, and `__mark_delivered` puts the LXMessage in `DELIVERED`. ### 8. (Optional) RESOURCE representation for large bodies -If the packed body exceeds `LINK_PACKET_MAX_CONTENT` (`LXMF/LXMessage.py:415-421`), `representation` is set to `RESOURCE` and step 5 instead constructs an `RNS.Resource` (`__as_resource`, line 651): +If computed `content_size` exceeds `LINK_PACKET_MAX_CONTENT` (`LXMF/LXMessage.py:405-421`), `representation` is set to `RESOURCE` and step 5 instead constructs an `RNS.Resource` (`__as_resource`, line 651): ```python RNS.Resource(self.packed, self.__delivery_destination, callback=..., progress_callback=..., auto_compress=...) @@ -173,9 +173,9 @@ This enables an interactive conversation over a single link rather than each mes ### 10. Link teardown -A link stays in `ACTIVE` until either side calls `link.teardown()`, the watchdog times out (no inbound activity within `Link.STALE_GRACE` after the keepalive interval), or the next-hop interface goes down. `RNS/Link.py` keepalives are `CTX_KEEPALIVE` (0xfd) packets; the cadence is `Link.KEEPALIVE` seconds. +A link stays in `ACTIVE` until either side calls `link.teardown()`, the watchdog times out, or the next-hop interface goes down. `RNS/Link.py` keepalives use `KEEPALIVE (0xFA)`; their cadence is dynamically clamped between `KEEPALIVE_MIN = 5s` and `KEEPALIVE_MAX = 360s`. -Teardown sends `RNS.Packet(link, b"", context=Link.PROOF, ...)` informing the peer; once teardown completes, the link is removed from `Transport.active_links` and from `LXMRouter.direct_links`, and the next DIRECT message to the same destination has to repeat the full handshake from step 3. +Teardown sends an encrypted `LINKCLOSE (0xFC)` packet whose plaintext body is `link_id`; once teardown completes, the link is removed from `Transport.active_links` and from `LXMRouter.direct_links`, and the next DIRECT message to the same destination has to repeat the full handshake from step 3. --- @@ -198,6 +198,10 @@ DATA on link (either direction), Token-encrypted (no eph_pub prefix): (plaintext = full LXMF body: dest_hash || src_hash || signature || msgpack_payload) ``` +At the default 319-byte computed-content boundary, the full LXMF plaintext is +431 bytes and the complete Link DATA wire packet is 499 bytes. At 320, +upstream switches to Resource. See `test-vectors/link-lxmf.json`. + Per SPEC.md §6.5 the receiver of any CTX_NONE DATA packet on the link MUST emit a PROOF receipt back; this is the mandatory `Packet.prove_packet` step on the receiving side, and is what resolves the sender's `PacketReceipt`. --- diff --git a/test-vectors/README.md b/test-vectors/README.md index d1202f6..ee5af70 100644 --- a/test-vectors/README.md +++ b/test-vectors/README.md @@ -11,8 +11,9 @@ Populated against RNS 1.2.4 / LXMF 0.9.7: - ✅ `lxmf.json` — two opportunistic-LXMF vectors Alice → Bob (regenerator: `../tools/regen_lxmf.py`, verifier: `../tools/verify_lxmf_opportunistic.py`). - ✅ `links.json` — full Link handshake vector (LINKREQUEST + LRPROOF + derived session key) Alice → Bob, plus an LRRTT packet (§6.4.2) emitted from the initiator with pinned IV and `rtt_seconds = 0.05` (regenerator: `../tools/regen_links.py`, verifiers: `../tools/verify_link_handshake.py`, `../tools/verify_link_lrrtt.py`). - ✅ `resources.json` — deterministic multi-part Resource ciphertext, part packets, hashmap, advertisement, and proof body (regenerator: `../tools/regen_resources.py`, verifier: `../tools/verify_resource.py`). +- ✅ `link-lxmf.json` — DIRECT LXMF PACKET and Resource vectors at the exact 319/320 computed-content boundary (regenerator: `../tools/regen_link_lxmf.py`, verifier: `../tools/verify_link_lxmf.py`). -All five 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 six 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. @@ -25,6 +26,7 @@ Each vector lives in a per-domain JSON file, e.g.: - `lxmf.json` — sender + recipient identity, plaintext, expected ciphertext bytes - `links.json` — LINKREQUEST + LRPROOF + derived session keys - `resources.json` — Resource plaintext, encrypted stream, parts, hashmap, advertisement, and proof +- `link-lxmf.json` — DIRECT LXMF Link DATA and Resource boundary forms Each entry should include: @@ -49,7 +51,7 @@ For the spec to claim "an implementation that passes all test vectors interopera 3. **Token encrypt + decrypt** — bidirectional, with both ratchet and long-term keys. 4. **Opportunistic LXMF** — full plaintext → ciphertext → plaintext round-trip, signature valid both ways. 5. **Link handshake** — LINKREQUEST built by client A, LRPROOF computed by upstream as B, both arrive at the same `link_id` and session keys. -6. **Link-delivered LXMF** — body packed by client, decrypted + parsed by upstream. +6. **Link-delivered LXMF** — body packed by client, decrypted + parsed by upstream. Covered by `link-lxmf.json`. 7. **Resource transfer** — encrypt once, split into parts, validate ADV/hashmap, assemble, and emit the expected proof. 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/link-lxmf.json b/test-vectors/link-lxmf.json new file mode 100644 index 0000000..c4eed5e --- /dev/null +++ b/test-vectors/link-lxmf.json @@ -0,0 +1,80 @@ +{ + "_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": [ + { + "label": "alice_to_bob_direct_packet_boundary", + "inputs": { + "src_identity_label": "alice", + "dst_identity_label": "bob", + "content_size": 319, + "lxmf_timestamp": 1700000000.0, + "link_vector_label": "alice_to_bob_aes256cbc", + "token_iv_hex": "5152535455565758595a5b5c5d5e5f60" + }, + "expected": { + "method": 2, + "representation": 1, + "lxmf_packed_hex": "9695d17f22fa6e45d2b0cd3439a7ca7ec33c40a5b030596d95617dc4ca163aae05036402d951b1c68131b516673bcc04206ca5adb586ac7da3e63226e0febd19de6563326d055adbeb519e1d3418f887e0ef5d0ef5099c980e5e2b603555180094cb41d954fc40000000c400c5013f7878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787880", + "link_packet_ciphertext_hex": "5152535455565758595a5b5c5d5e5f6013989c7e9cb3ad3d547b14028cb2c8cb6d253f5e0de613cfa9ee588413147738ab5810b8e24c5174bf4d2f8e06a127c29703cd7ab276e020bdef730d1df6aac088518a1be128b927401a3998454207ff967f64bbe2cf58e8aea7759e3dd8d197b086d70ba654fe656bb7196b825f080c0edbe793d0b34c188fe0f56ea4f9c6912c01647c78fefe1f9b5cde08a322b7a5c86570018b7f4ea55ed8a07f1f4d6b34dbe75f3009965de388286f3971558fb7d0f0baae1ebafe0423aab50742d83d6042a25781553d808228f16a479c5d41fa23fee3cd01ab88b4d36c0fe9546baf6954c9a393026b04d0397692221f931548ec48afe7e6553c9e2f3c142eb38e692cb1cecaa880fe7372fd494d4dc9bd4852b172755074aff59634959e45338e90d3d7066480ab410a70dafaaaf579e953698dd7dab6ec2968e51e4f77550a1b7392c731066d8b53625cc163a3155f71b4a2201ffb7c7fc31d397e9140402afd7d722e447ef2e723e158c37ebf92828f0383a8eed02f233e1996a07d9d2ca572a1dbf49d58780d15c829fd3d53cc27b5016ce4fdd696c384b8d0ef71d1aba605c14320b5b0e0c20899e65e80cb35e80fa20fe5947055b2ad173bd1e58297affb8d4af5f1c1b27e1959b17e27c53625ddd264", + "link_packet_raw_hex": "0c007ee5fe3e4952c9ac4519b537f6278474005152535455565758595a5b5c5d5e5f6013989c7e9cb3ad3d547b14028cb2c8cb6d253f5e0de613cfa9ee588413147738ab5810b8e24c5174bf4d2f8e06a127c29703cd7ab276e020bdef730d1df6aac088518a1be128b927401a3998454207ff967f64bbe2cf58e8aea7759e3dd8d197b086d70ba654fe656bb7196b825f080c0edbe793d0b34c188fe0f56ea4f9c6912c01647c78fefe1f9b5cde08a322b7a5c86570018b7f4ea55ed8a07f1f4d6b34dbe75f3009965de388286f3971558fb7d0f0baae1ebafe0423aab50742d83d6042a25781553d808228f16a479c5d41fa23fee3cd01ab88b4d36c0fe9546baf6954c9a393026b04d0397692221f931548ec48afe7e6553c9e2f3c142eb38e692cb1cecaa880fe7372fd494d4dc9bd4852b172755074aff59634959e45338e90d3d7066480ab410a70dafaaaf579e953698dd7dab6ec2968e51e4f77550a1b7392c731066d8b53625cc163a3155f71b4a2201ffb7c7fc31d397e9140402afd7d722e447ef2e723e158c37ebf92828f0383a8eed02f233e1996a07d9d2ca572a1dbf49d58780d15c829fd3d53cc27b5016ce4fdd696c384b8d0ef71d1aba605c14320b5b0e0c20899e65e80cb35e80fa20fe5947055b2ad173bd1e58297affb8d4af5f1c1b27e1959b17e27c53625ddd264", + "link_id_hex": "7ee5fe3e4952c9ac4519b537f6278474" + }, + "rns_version_at_generation": "1.2.4", + "lxmf_version_at_generation": "0.9.7", + "generator_script": "tools/regen_link_lxmf.py", + "verifies_spec_sections": [ + "5.2", + "5.5", + "5.6", + "6.4.3", + "10.1" + ] + }, + { + "label": "alice_to_bob_direct_resource_boundary", + "inputs": { + "src_identity_label": "alice", + "dst_identity_label": "bob", + "content_size": 320, + "lxmf_timestamp": 1700000000.0, + "link_vector_label": "alice_to_bob_aes256cbc", + "token_iv_hex": "6162636465666768696a6b6c6d6e6f70", + "throwaway_prefix_hex": "71727374", + "resource_random_hash_hex": "81828384", + "auto_compress": false + }, + "expected": { + "method": 2, + "representation": 2, + "lxmf_packed_hex": "9695d17f22fa6e45d2b0cd3439a7ca7ec33c40a5b030596d95617dc4ca163aae1c7919c2ec56920071ec83d67bc4eecf89164eb35d124c70d083ea76c5f8439154586dcbbdfb9ea6a7697085a2d0ddb07f735a849c8267adc157064ed9cd8b0294cb41d954fc40000000c400c50140787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787880", + "resource_hash_hex": "28adad95727aa613d7728c82e3e9a6027c407b51396677d6f6378ed1e0f44b64", + "resource_expected_proof_hex": "9ec0ee7a5aaae728c1480bbe2b449e07328ef223caed65f386b84209749efe4d", + "resource_advertisement_plaintext_hex": "8ba174cd01f0a164cd01b0a16e02a168c42028adad95727aa613d7728c82e3e9a6027c407b51396677d6f6378ed1e0f44b64a172c40481828384a16fc42028adad95727aa613d7728c82e3e9a6027c407b51396677d6f6378ed1e0f44b64a16901a16c01a171c0a16601a16dc40837cd98014cd2769c", + "resource_parts": [ + { + "body_hex": "6162636465666768696a6b6c6d6e6f70951cef742577ef844c26d338a9b873db7510b7733b592bf353089ef09d06dceccf084587a257101f5db226c1270441f50c799b7e79d09a8dce6ed3b6dc40447607e56756b9bb6164abb87f7c0d9fa51cc46f8ff7dc99e76b3adddf6d045e2b3ffc575ccc97123b9bf7d206eb7b67d6f3c9f8575a796a5401813ce5f0a66269dfc3693e8edc4b749a845cb0ae6e12bbc6a2e0dc00f448fec88998303ae77dc0b4d53e3b8bb4912ddff0a4f456df3c5ca3f227f7e764bb5e264211a224929cc8da96160b5589f366ab2ee08f10d3851f94b88fcd3de232665b1c1d1bd55cec99665dd9b667c72b8fe2908bffa6d5264fd21a7f3809c70cbb7342536fab74fb855103b7375630b724d4abd6a3f36864ddf15f4c6e6bb6f554a75fdb4f286cc089455589021320abef585e80caee8ea94f0c705ce928b8088055e1f98c441238bcac024eea36bc7728e7b81d34fa3f2d1d0c722dc77d234e271c9ece67361400db88775899039de948548f3826f6fed969915f041d404a42c3e56bcdb0f2d8e57f1be27e128ad73a4011745dca50ef042cd9eb853e9fe3d7cc1ab9b8809ff073700dabd9c14a71fb08980750b68d970ec9853cc37d8b179c436abf26bbbf7477891e", + "map_hash_hex": "37cd9801", + "raw_hex": "0c007ee5fe3e4952c9ac4519b537f6278474016162636465666768696a6b6c6d6e6f70951cef742577ef844c26d338a9b873db7510b7733b592bf353089ef09d06dceccf084587a257101f5db226c1270441f50c799b7e79d09a8dce6ed3b6dc40447607e56756b9bb6164abb87f7c0d9fa51cc46f8ff7dc99e76b3adddf6d045e2b3ffc575ccc97123b9bf7d206eb7b67d6f3c9f8575a796a5401813ce5f0a66269dfc3693e8edc4b749a845cb0ae6e12bbc6a2e0dc00f448fec88998303ae77dc0b4d53e3b8bb4912ddff0a4f456df3c5ca3f227f7e764bb5e264211a224929cc8da96160b5589f366ab2ee08f10d3851f94b88fcd3de232665b1c1d1bd55cec99665dd9b667c72b8fe2908bffa6d5264fd21a7f3809c70cbb7342536fab74fb855103b7375630b724d4abd6a3f36864ddf15f4c6e6bb6f554a75fdb4f286cc089455589021320abef585e80caee8ea94f0c705ce928b8088055e1f98c441238bcac024eea36bc7728e7b81d34fa3f2d1d0c722dc77d234e271c9ece67361400db88775899039de948548f3826f6fed969915f041d404a42c3e56bcdb0f2d8e57f1be27e128ad73a4011745dca50ef042cd9eb853e9fe3d7cc1ab9b8809ff073700dabd9c14a71fb08980750b68d970ec9853cc37d8b179c436abf26bbbf7477891e" + }, + { + "body_hex": "ffa6985bcc6b2709ea244b5951c90828c7758f63bf7ebf9c92d029aec4c32b24", + "map_hash_hex": "4cd2769c", + "raw_hex": "0c007ee5fe3e4952c9ac4519b537f627847401ffa6985bcc6b2709ea244b5951c90828c7758f63bf7ebf9c92d029aec4c32b24" + } + ], + "link_id_hex": "7ee5fe3e4952c9ac4519b537f6278474" + }, + "rns_version_at_generation": "1.2.4", + "lxmf_version_at_generation": "0.9.7", + "generator_script": "tools/regen_link_lxmf.py", + "verifies_spec_sections": [ + "5.2", + "5.5", + "5.6", + "10.1", + "10.2", + "10.4" + ] + } + ] +} diff --git a/todo.md b/todo.md index 03ea9c4..1e9277d 100644 --- a/todo.md +++ b/todo.md @@ -51,6 +51,12 @@ Outstanding work for the spec repo. `verify_stamps.py`, `verify_ratchet_dedup.py`). Status table lives in `tools/README.md`. +- [x] **Deterministic link-delivered LXMF vectors.** Added + `test-vectors/link-lxmf.json`, `tools/regen_link_lxmf.py`, and + `tools/verify_link_lxmf.py`. Covers the exact computed-content + PACKET/Resource boundary, Link decrypt/parse, wrong-key rejection, and + DIRECT receive dispatch. + ## Open `⚠️ UNVERIFIED` items in SPEC.md These need either a runtime test or a stronger upstream source citation diff --git a/tools/README.md b/tools/README.md index 2c8090c..71bbf3d 100644 --- a/tools/README.md +++ b/tools/README.md @@ -51,6 +51,7 @@ Populated against RNS 1.2.4 / LXMF 0.9.7: | `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_link_lxmf.py` | §5.2, §5.5, §5.6, §6.4.3, §10.1 — DIRECT PACKET/Resource boundary, Link decrypt/parse, wrong-key rejection, receive dispatch | ✅ | | `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 | ✅ | @@ -61,6 +62,7 @@ Populated against RNS 1.2.4 / LXMF 0.9.7: | `regen_announces.py` | regenerates `test-vectors/announces.json` (deterministic announce wire bytes, with and without ratchet) | ✅ | | `regen_lxmf.py` | regenerates `test-vectors/lxmf.json` (deterministic opportunistic-LXMF plaintext + Token ciphertext) | ✅ | | `regen_links.py` | regenerates `test-vectors/links.json` (deterministic LINKREQUEST + LRPROOF + derived session key) | ✅ | +| `regen_link_lxmf.py` | regenerates `test-vectors/link-lxmf.json` (deterministic DIRECT PACKET and Resource boundary vectors) | ✅ | | `regen_resources.py` | regenerates `test-vectors/resources.json` (deterministic Resource ciphertext, parts, ADV, and PRF body) | ✅ | See [`../agent.md`](../agent.md) §3 and [`../todo.md`](../todo.md) for the evidence model and remaining priority order. diff --git a/tools/regen_link_lxmf.py b/tools/regen_link_lxmf.py new file mode 100644 index 0000000..c202a7c --- /dev/null +++ b/tools/regen_link_lxmf.py @@ -0,0 +1,268 @@ +""" +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() diff --git a/tools/verify_link_lxmf.py b/tools/verify_link_lxmf.py new file mode 100644 index 0000000..44fbbe6 --- /dev/null +++ b/tools/verify_link_lxmf.py @@ -0,0 +1,205 @@ +""" +Verifier for DIRECT LXMF delivery over an established Reticulum Link. + +Validates deterministic PACKET and Resource vectors, the exact upstream +content_size boundary (319/320), full-body DIRECT parsing, wrong-link-key +rejection, and LXMRouter's DIRECT receive dispatch. +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile + +import LXMF +import RNS +from LXMF.LXMessage import LXMessage +from LXMF.LXMRouter import LXMRouter +from RNS.Cryptography.Token import Token +from RNS.Resource import Resource, ResourceAdvertisement + + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +VECTORS_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") + + +def fail(message: str) -> None: + print(f"FAIL: {message}") + sys.exit(1) + + +def init_minimal_rns(): + config_dir = tempfile.mkdtemp(prefix="rns-verify-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_json(path: str): + with open(path, "r", encoding="utf-8") as input_file: + return json.load(input_file) + + +def content_size(packed: bytes) -> int: + payload = packed[2 * LXMessage.DESTINATION_LENGTH + LXMessage.SIGNATURE_LENGTH :] + return len(payload) - LXMessage.TIMESTAMP_SIZE - LXMessage.STRUCT_OVERHEAD + + +def parse_and_check(packed: bytes, expected_size: int) -> LXMessage: + parsed = LXMessage.unpack_from_bytes(packed) + if not parsed.signature_validated: + fail(f"DIRECT LXMF signature invalid at content_size {expected_size}") + if len(parsed.content) != expected_size or parsed.content != b"x" * expected_size: + fail(f"DIRECT LXMF content mismatch at content_size {expected_size}") + if parsed.method != LXMessage.UNKNOWN: + fail("unpack_from_bytes unexpectedly assigned a delivery method") + return parsed + + +def verify_vectors(derived_key: bytes, link_id: bytes) -> tuple[bytes, bytes]: + vectors = load_json(VECTORS_PATH)["vectors"] + packet_vector = next(vector for vector in vectors if vector["expected"]["representation"] == LXMessage.PACKET) + resource_vector = next(vector for vector in vectors if vector["expected"]["representation"] == LXMessage.RESOURCE) + token = Token(derived_key) + + packet_packed = bytes.fromhex(packet_vector["expected"]["lxmf_packed_hex"]) + packet_raw = bytes.fromhex(packet_vector["expected"]["link_packet_raw_hex"]) + packet_ciphertext = bytes.fromhex(packet_vector["expected"]["link_packet_ciphertext_hex"]) + if content_size(packet_packed) != 319 or len(packet_packed) != RNS.Link.MDU: + fail("319 boundary vector does not fill the Link MDU exactly") + if len(packet_raw) != 19 + len(packet_ciphertext) or len(packet_raw) > RNS.Reticulum.MTU: + fail("319 boundary vector has an invalid Link packet wire length") + if packet_raw[:2] != bytes([RNS.Destination.LINK << 2, 0]): + fail("DIRECT PACKET vector flags/hops are not HEADER_1 DATA LINK") + if packet_raw[2:18] != link_id or packet_raw[18] != RNS.Packet.NONE: + fail("DIRECT PACKET vector link_id/context mismatch") + if packet_raw[19:] != packet_ciphertext: + fail("DIRECT PACKET raw body differs from recorded ciphertext") + if token.decrypt(packet_ciphertext) != packet_packed: + fail("DIRECT PACKET did not decrypt to the complete LXMF body") + parse_and_check(packet_packed, 319) + + resource_packed = bytes.fromhex(resource_vector["expected"]["lxmf_packed_hex"]) + parts = [bytes.fromhex(part["body_hex"]) for part in resource_vector["expected"]["resource_parts"]] + stream = b"".join(parts) + decrypted = token.decrypt(stream) + prefix = bytes.fromhex(resource_vector["inputs"]["throwaway_prefix_hex"]) + if decrypted[: Resource.RANDOM_HASH_SIZE] != prefix: + fail("DIRECT Resource throwaway prefix mismatch") + if decrypted[Resource.RANDOM_HASH_SIZE :] != resource_packed: + fail("DIRECT Resource did not decrypt to the complete LXMF body") + if content_size(resource_packed) != 320: + fail("Resource boundary vector computed content_size is not 320") + parse_and_check(resource_packed, 320) + + adv = ResourceAdvertisement.unpack( + bytes.fromhex(resource_vector["expected"]["resource_advertisement_plaintext_hex"]) + ) + if adv.d != len(resource_packed) or adv.h.hex() != resource_vector["expected"]["resource_hash_hex"]: + fail("DIRECT Resource advertisement size/hash mismatch") + + try: + Token(bytes(reversed(derived_key))).decrypt(packet_ciphertext) + except Exception: + pass + else: + fail("DIRECT packet decrypted under the wrong Link key") + + print("PASS S5.2/S10.1 DIRECT PACKET/RESOURCE boundary vectors: 319 -> PACKET, 320 -> RESOURCE") + print("PASS S3/S5.5/S5.6 DIRECT Link decrypt and signed full-body parse; wrong Link key rejected") + return packet_packed, resource_packed + + +def verify_upstream_boundary(alice_identity, bob_identity) -> None: + alice = RNS.Destination( + alice_identity, RNS.Destination.IN, RNS.Destination.SINGLE, "verify_link_lxmf", "alice" + ) + bob = RNS.Destination( + bob_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "verify_link_lxmf", "bob" + ) + for size, expected in [(319, LXMessage.PACKET), (320, LXMessage.RESOURCE)]: + message = LXMessage(bob, alice, b"x" * size, b"", {}, LXMessage.DIRECT) + message.timestamp = 1700000000.0 + message.pack() + if content_size(message.packed) != size or message.representation != expected: + fail(f"upstream boundary mismatch at {size}") + print("PASS S10.1 upstream selection uses computed LXMF content_size at the 319/320 boundary") + + +def verify_router_direct_dispatch(packet_packed: bytes) -> None: + router = object.__new__(LXMRouter) + captured = {} + router.lxmf_delivery = lambda data, destination_type, **kwargs: captured.update( + data=data, destination_type=destination_type, method=kwargs.get("method") + ) + + class ImmediateThread: + def __init__(self, target, daemon=None): + self.target = target + + def start(self): + self.target() + + class FakePacket: + destination_type = RNS.Destination.LINK + rssi = -80 + snr = 7 + q = 90 + ratchet_id = bytes.fromhex("00112233445566778899aabbccddeeff") + proved = False + + def prove(self): + self.proved = True + + packet = FakePacket() + router_mod = sys.modules["LXMF.LXMRouter"] + real_thread = router_mod.threading.Thread + router_mod.threading.Thread = ImmediateThread + try: + router.delivery_packet(packet_packed, packet) + finally: + router_mod.threading.Thread = real_thread + + if not packet.proved: + fail("LXMRouter.delivery_packet did not prove DIRECT Link DATA") + if captured.get("data") != packet_packed: + fail("LXMRouter.delivery_packet altered or prepended DIRECT LXMF data") + if captured.get("destination_type") != RNS.Destination.LINK: + fail("LXMRouter.delivery_packet lost LINK destination type") + if captured.get("method") != LXMessage.DIRECT: + fail("LXMRouter.delivery_packet did not classify LINK data as DIRECT") + print("PASS S5.2 DIRECT receive dispatch proves DATA and passes full LXMF body unchanged") + + +def main() -> None: + print(f"verify_link_lxmf.py against RNS {RNS.__version__} / LXMF {LXMF.__version__}") + init_minimal_rns() + try: + identities = load_json(IDS_PATH)["vectors"] + links = load_json(LINKS_PATH)["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"])) + RNS.Identity.remember(bytes(32), bytes.fromhex(alice["expected"]["destination_hash_hex"]), alice_identity.get_public_key(), None) + derived_key = bytes.fromhex(links["expected"]["derived_key_hex"]) + link_id = bytes.fromhex(links["expected"]["link_id_hex"]) + + packet_packed, _ = verify_vectors(derived_key, link_id) + verify_upstream_boundary(alice_identity, bob_identity) + verify_router_direct_dispatch(packet_packed) + print("ALL PASS") + finally: + try: + RNS.Reticulum.exit_handler() + except Exception: + pass + + +if __name__ == "__main__": + main() diff --git a/tools/verify_resource.py b/tools/verify_resource.py index 5c572bb..7bc4b1d 100644 --- a/tools/verify_resource.py +++ b/tools/verify_resource.py @@ -97,7 +97,7 @@ def verify_default_lxmf_threshold() -> None: ) 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") + print("PASS S10 default direct-LXMF Resource threshold: computed content_size > 319") def verify_preparation_and_advertisement() -> None: