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:
parent
7433063bfb
commit
7ffbb0ef5e
12 changed files with 671 additions and 13 deletions
17
SPEC.md
17
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.
|
||||
|
||||
|
|
|
|||
2
agent.md
2
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:**
|
||||
|
|
|
|||
75
audits/link-lxmf-tier1-rns-1.2.4-lxmf-0.9.7.md
Normal file
75
audits/link-lxmf-tier1-rns-1.2.4-lxmf-0.9.7.md
Normal 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.
|
||||
|
|
@ -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`):
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
80
test-vectors/link-lxmf.json
Normal file
80
test-vectors/link-lxmf.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
6
todo.md
6
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
|
||||
|
|
|
|||
|
|
@ -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
268
tools/regen_link_lxmf.py
Normal 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
205
tools/verify_link_lxmf.py
Normal 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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue