Completed the full link-delivered LXMF unit:

Tier 1 audit: `link-lxmf-tier1-rns-1.2.4-lxmf-0.9.7.md`
Tier 2 vectors/verifier: link-lxmf.json, regen_link_lxmf.py, and verify_link_lxmf.py
Tier 3 promotion: updated SPEC.md, flows, status, and documentation
Key correction: the 319/320 boundary uses upstream’s computed LXMF content_size, not simply raw message content length.
Also corrected stale flow descriptions for KEEPALIVE (0xFA) and encrypted LINKCLOSE teardown (0xFC).
Verification:
Deterministic vector regeneration: identical SHA-256
Portable-path and formatting checks: pass
Full pinned suite: 17 passed, 0 failed
This commit is contained in:
John Poole 2026-06-08 13:54:27 -07:00
commit 7ffbb0ef5e
12 changed files with 671 additions and 13 deletions

17
SPEC.md
View file

@ -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.

View file

@ -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:**

View file

@ -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.

View file

@ -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`):

View file

@ -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`.
---

View file

@ -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.

View file

@ -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"
]
}
]
}

View file

@ -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

View file

@ -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.

268
tools/regen_link_lxmf.py Normal file
View file

@ -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()

205
tools/verify_link_lxmf.py Normal file
View file

@ -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()

View file

@ -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: