Add flows/ docs: receive-opportunistic and send-link
receive-opportunistic-lxmf.md mirrors the send flow on the recipient side: KISS/HDLC deframe -> Transport.inbound -> packet_filter dedup -> DATA/SINGLE branch -> Destination.receive -> Identity.decrypt with the ratchet ring + long-term-key fallback -> LXMRouter.delivery_packet (which fires the PROOF receipt before parsing) -> LXMessage.unpack_from_bytes with msgpack stamp-strip -> ticket/stamp/dedup checks -> __delivery_callback to the app. Notes upstream's narrower variant tolerance vs SPEC.md §5.6 and the missing clockless-sender fix-up vs §9.6. send-link-lxmf.md walks the DIRECT method end-to-end: process_outbound DIRECT branch decides reuse-vs-establish, RNS.Link.__init__ builds the unencrypted LINKREQUEST body (initiator_X25519_pub || initiator_Ed25519_pub || optional signalling), link_id derived from get_hashable_part, LRPROOF arrives back and validate_proof verifies signature against the responder's long-term Ed25519 pub recalled from a prior announce, handshake() does ECDH+HKDF over the shared secret with salt=link_id, lxmessage.send sends the full LXMF body (with dest_hash, per §5.2) over the link with Token encryption that omits the eph_pub prefix per §3.1, mandatory PROOF receipts per §6.5 resolve the PacketReceipt. Sketches the RESOURCE representation for oversize bodies and the backchannel-identify trick that makes the link bidirectional. flows/README.md status table updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8480555320
commit
b43d735d97
3 changed files with 467 additions and 2 deletions
|
|
@ -9,9 +9,10 @@ The two views are complementary: SPEC.md tells you what each piece looks like; t
|
|||
| Flow | Status |
|
||||
|---|---|
|
||||
| [`send-opportunistic-lxmf.md`](send-opportunistic-lxmf.md) | ✅ |
|
||||
| `send-link-lxmf.md` (DIRECT method, over a Reticulum Link) | ⏳ |
|
||||
| [`receive-opportunistic-lxmf.md`](receive-opportunistic-lxmf.md) | ✅ |
|
||||
| [`send-link-lxmf.md`](send-link-lxmf.md) (DIRECT method, over a Reticulum Link) | ✅ |
|
||||
| `receive-link-lxmf.md` (inverse of send-link-lxmf, including responder side of the handshake) | ⏳ |
|
||||
| `send-propagated-lxmf.md` (PROPAGATED method, via a propagation node) | ⏳ |
|
||||
| `receive-opportunistic-lxmf.md` (the inverse of the opportunistic-send flow) | ⏳ |
|
||||
| `announce.md` (build, sign, transmit, ratchet rotation) | ⏳ |
|
||||
| `path-discovery.md` (request, response, path-table population) | ⏳ |
|
||||
|
||||
|
|
|
|||
241
flows/receive-opportunistic-lxmf.md
Normal file
241
flows/receive-opportunistic-lxmf.md
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
# Flow: receive a single-packet opportunistic LXMF message
|
||||
|
||||
The inverse of [`send-opportunistic-lxmf.md`](send-opportunistic-lxmf.md). What happens chronologically on the recipient when wire bytes for an opportunistic LXMF DATA packet arrive at one of its interfaces.
|
||||
|
||||
Pinned against **RNS 1.2.0 / LXMF 0.9.6**. Line numbers below are from those versions.
|
||||
|
||||
Out of scope: receiving a packet over an established Reticulum Link (DIRECT method), receiving propagated messages from a propagation node, and receiving an announce / path-request / link-request. Each gets its own flow document.
|
||||
|
||||
---
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Recipient has an `RNS.Identity` with the X25519 + Ed25519 private keys, plus a `lxmf.delivery` `RNS.Destination` registered with `LXMRouter.register_delivery_identity` — that registration calls `delivery_destination.set_packet_callback(self.delivery_packet)` at `LXMF/LXMRouter.py:341`, which is the hand-off point in step 7 below.
|
||||
- Recipient has, at some point, been the target of one or more announces from the sender, so `RNS.Identity.known_destinations` knows the sender's full `public_key` (X25519 || Ed25519) under their `dest_hash`. Without this, signature validation in step 11 will fail with `unverified_reason = SOURCE_UNKNOWN`.
|
||||
|
||||
---
|
||||
|
||||
## Sequence
|
||||
|
||||
### 1. Wire bytes arrive at the interface
|
||||
|
||||
Per [`../SPEC.md`](../SPEC.md) §8, the bytes arrive escape-encoded inside KISS frames (BLE / serial / RNode), HDLC frames (TCP), or whatever framing the interface uses. The interface driver deframes and passes the raw Reticulum packet bytes to `RNS.Transport.inbound`.
|
||||
|
||||
For RNode KISS specifically, `CMD_STAT_RSSI = 0x23` and `CMD_STAT_SNR = 0x24` sidecar frames received just before the `CMD_DATA = 0x00` payload populate `interface.r_stat_rssi` and `interface.r_stat_snr`; these are attached to the packet at step 4 below.
|
||||
|
||||
### 2. `Transport.inbound(raw, interface)` entry point
|
||||
|
||||
`RNS/Transport.py:1327`. The single entry point for any inbound packet on any interface. The function is gated by `Transport.ready` — packets arriving before transport startup are dropped with a warning.
|
||||
|
||||
### 3. IFAC unmask (Interface Authentication Codes)
|
||||
|
||||
`RNS/Transport.py:1338-1387`. If the interface has an `ifac_identity` configured, the high bit of `raw[0]` must be set; the IFAC bytes at `raw[2:2+ifac_size]` are then used to derive an HKDF mask, the rest of the packet is unmasked in place, and the IFAC is verified against `ifac_identity.sign(unmasked_raw)[-ifac_size:]`. Mismatch drops the packet silently.
|
||||
|
||||
If the interface has no IFAC and the high bit IS set, the packet is dropped (an unexpected IFAC).
|
||||
|
||||
### 4. Packet parse and physical-layer stats
|
||||
|
||||
`RNS/Transport.py:1391-1395`:
|
||||
|
||||
```python
|
||||
packet = RNS.Packet(None, raw)
|
||||
if not packet.unpack(): return
|
||||
packet.receiving_interface = interface
|
||||
packet.hops += 1
|
||||
```
|
||||
|
||||
`packet.unpack` reads the header byte fields per SPEC.md §2.1, sets `packet.header_type`, `packet.packet_type`, `packet.destination_type`, `packet.destination_hash`, `packet.context`, and slices `packet.data` from the remainder. Importantly, **hops is incremented by 1 here**, so even on a leaf-endpoint receive the local `packet.hops` is one more than what flew on the wire — flow logic that treats `packet.hops == 0` as "originator on this interface" must use the wire byte before this increment.
|
||||
|
||||
RSSI / SNR / Q link-quality stats are attached to `packet` if the interface exposed them (`RNS/Transport.py:1397-1417`).
|
||||
|
||||
### 5. Hop fix-up for shared-instance and local-client interfaces
|
||||
|
||||
`RNS/Transport.py:1419-1422`. If the receiving interface is to a local shared instance or a local-client TCP socket, the +1 increment from step 4 is undone — the shared-instance path doesn't count as a real network hop.
|
||||
|
||||
### 6. Dedup, then dispatch by packet_type / destination_type
|
||||
|
||||
`RNS/Transport.py:1424-1444`. `Transport.packet_filter` checks `packet.packet_hash` against `Transport.packet_hashlist` to drop replays. Hashes are added to the dedup list except for two cases that must be deferred:
|
||||
|
||||
- packet whose `destination_hash` is in `Transport.link_table` — the dedup decision is left to the link itself,
|
||||
- LRPROOF packets — these may legitimately arrive on multiple interfaces during routing-fork chaos and the dedup list is updated only after the LRPROOF is validated.
|
||||
|
||||
Then the function fans out by `(packet_type, destination_type)`. For an opportunistic LXMF DATA packet — `packet_type == DATA`, `destination_type == SINGLE` — control reaches `RNS/Transport.py:2087-2103`:
|
||||
|
||||
```python
|
||||
destination = Transport.destinations_map.get(packet.destination_hash)
|
||||
if destination and destination.type == packet.destination_type:
|
||||
packet.destination = destination
|
||||
if destination.receive(packet):
|
||||
if destination.proof_strategy == RNS.Destination.PROVE_ALL:
|
||||
packet.prove() # see step 12 below
|
||||
elif destination.proof_strategy == RNS.Destination.PROVE_APP:
|
||||
if destination.callbacks.proof_requested:
|
||||
if destination.callbacks.proof_requested(packet):
|
||||
packet.prove()
|
||||
```
|
||||
|
||||
If `destination_hash` does not match any locally-registered destination, this branch is not taken; the packet may still be **forwarded** by other branches in `Transport.inbound` (the routing-relay path) but that's a separate flow.
|
||||
|
||||
### 7. `Destination.receive(packet)` — decrypt and run packet callback
|
||||
|
||||
`RNS/Destination.py:403-418`:
|
||||
|
||||
```python
|
||||
def receive(self, packet):
|
||||
if packet.packet_type == RNS.Packet.LINKREQUEST:
|
||||
self.incoming_link_request(packet.data, packet) # see send-link-lxmf.md
|
||||
else:
|
||||
plaintext = self.decrypt(packet.data)
|
||||
packet.ratchet_id = self.latest_ratchet_id
|
||||
if plaintext == None: return False
|
||||
if packet.packet_type == RNS.Packet.DATA:
|
||||
self.callbacks.packet(plaintext, packet)
|
||||
return True
|
||||
```
|
||||
|
||||
For `lxmf.delivery` destinations, `self.callbacks.packet` was set at `LXMF/LXMRouter.py:341` to the router's `delivery_packet` — see step 9.
|
||||
|
||||
### 8. `Destination.decrypt` → `Identity.decrypt` — Token decode with ratchet ring
|
||||
|
||||
`RNS/Destination.py:611-643` → `RNS/Identity.py:818-872`. The packet body is the Token form from SPEC.md §3.1: `ephemeral_pub(32) || iv(16) || aes_ciphertext || hmac_sha256(32)`.
|
||||
|
||||
```python
|
||||
peer_pub_bytes = ciphertext_token[:32] # sender's ephemeral X25519 pub
|
||||
ciphertext = ciphertext_token[32:] # iv || aes || hmac
|
||||
|
||||
if ratchets:
|
||||
for ratchet in ratchets: # most-recent-first
|
||||
ratchet_prv = X25519PrivateKey.from_private_bytes(ratchet)
|
||||
shared_key = ratchet_prv.exchange(peer_pub)
|
||||
plaintext = self.__decrypt(shared_key, ciphertext) # HMAC-then-AES via Token
|
||||
if plaintext: break
|
||||
|
||||
if plaintext is None and not enforce_ratchets: # fallback to long-term key
|
||||
shared_key = self.prv.exchange(peer_pub)
|
||||
plaintext = self.__decrypt(shared_key, ciphertext)
|
||||
```
|
||||
|
||||
`Token.decrypt` (`RNS/Cryptography/Token.py`) verifies the HMAC **before** AES decryption per SPEC.md §3.3 — a bad HMAC simply means "wrong key, try the next one in the ring" rather than a padding-oracle exposure. If all ratchet keys + the long-term key fail, `plaintext` is `None` and the packet is silently dropped.
|
||||
|
||||
The matching ratchet's id is stored on the packet via `ratchet_id_receiver.latest_ratchet_id`, so `delivery_packet` can later annotate the LXMessage with which ratchet it arrived under.
|
||||
|
||||
If the destination has ratchets enabled but on-disk state has gone stale, `Destination.decrypt` retries once after `_reload_ratchets` (`RNS/Destination.py:631`), then gives up.
|
||||
|
||||
### 9. `LXMRouter.delivery_packet(data, packet)` — proof first, then async parse
|
||||
|
||||
`LXMF/LXMRouter.py:1819-1847`:
|
||||
|
||||
```python
|
||||
def delivery_packet(self, data, packet):
|
||||
packet.prove() # see step 12
|
||||
if packet.destination_type != RNS.Destination.LINK:
|
||||
method = LXMessage.OPPORTUNISTIC
|
||||
lxmf_data = packet.destination.hash + data # re-prepend stripped dest_hash
|
||||
else:
|
||||
method = LXMessage.DIRECT
|
||||
lxmf_data = data
|
||||
|
||||
threading.Thread(target=lambda: self.lxmf_delivery(lxmf_data, ...), daemon=True).start()
|
||||
```
|
||||
|
||||
The PROOF receipt fires **before** the message is parsed — the sender's delivery callback resolves as soon as the bytes are decrypted and authenticated by the destination, not after the LXMF body is validated. This means a malformed LXMF body still produces a successful Reticulum-level delivery proof; LXMF-level rejection only manifests as the message never appearing in the recipient's inbox.
|
||||
|
||||
The opportunistic-form re-prepends `packet.destination.hash` because step 6 of the send flow sliced it off — SPEC.md §5.1 vs §5.2. After this prepend, both opportunistic and Link-delivered LXMessages share the same fixed-position body: `dest_hash || src_hash || signature || msgpack_payload`, so step 10 can use one parser.
|
||||
|
||||
### 10. `LXMessage.unpack_from_bytes(lxmf_data)` — body parse and signature validation
|
||||
|
||||
`LXMF/LXMessage.py:736-807`. Field slicing:
|
||||
|
||||
```
|
||||
destination_hash = lxmf_data[ 0:16]
|
||||
source_hash = lxmf_data[16:32]
|
||||
signature = lxmf_data[32:96]
|
||||
packed_payload = lxmf_data[96:]
|
||||
```
|
||||
|
||||
`packed_payload` is unpacked with `RNS.vendor.umsgpack` (the bundled msgpack — bin/str behavior locked per SPEC.md §9.3). The first 4 elements are `[timestamp_double, title_bytes, content_bytes, fields_dict]`; an optional 5th element is the LXMF stamp. **If a 5th element is present**, it is split off, `packed_payload` is **re-encoded** from the first 4 elements, and the new `packed_payload` is what's used to compute `message_hash` and feed signature validation. If only 4 elements are present, the as-received bytes are used unchanged.
|
||||
|
||||
```
|
||||
hashed_part = destination_hash || source_hash || (re-encoded-or-raw) packed_payload
|
||||
message_hash = SHA256(hashed_part)
|
||||
signed_part = hashed_part || message_hash
|
||||
```
|
||||
|
||||
Signature validation calls `source.identity.validate(signature, signed_part)`. The sender's identity is recalled from `RNS.Identity.known_destinations` via `RNS.Identity.recall(source_hash)` at line 765 — keyed by the sender's **destination hash**, not their identity hash, per SPEC.md §5.4 and §9.1. If the sender is unknown locally (no announce ever received), `unverified_reason = SOURCE_UNKNOWN` and `signature_validated = False`; the message is still surfaced to the app callback in step 12, but downstream UI should mark it untrusted.
|
||||
|
||||
Note: `unpack_from_bytes` does the stamp-strip-and-reencode variant but does **not** also try the as-received `packed_payload` if validation fails. SPEC.md §5.6 documents both raw and stripped-reencoded as valid receiver behavior; upstream LXMF 0.9.6 only does the stripped form (or the raw form when no stamp was present). The spec's stronger receiver tolerance is a recommendation for non-upstream implementers, not a description of upstream.
|
||||
|
||||
### 11. Stamp / ticket / dedup checks
|
||||
|
||||
`LXMF/LXMRouter.py:1741-1803`:
|
||||
|
||||
- **Ticket** (`message.fields[FIELD_TICKET]`): if present and non-expired, cached for outbound use.
|
||||
- **Stamp**: if the local destination has a `stamp_cost`, `message.validate_stamp(required_cost, tickets=...)` runs; a missing/invalid stamp causes the message to be dropped when `_enforce_stamps` is true.
|
||||
- **Phy stats**: RSSI / SNR / Q copied onto the LXMessage from `phy_stats`.
|
||||
- **Ignore list**: messages from `source_hash in self.ignored_list` are dropped silently.
|
||||
- **Dedup**: `self.has_message(message.hash)` checks `locally_delivered_transient_ids`; duplicates are dropped (the previously-seen `message.hash` was added on first delivery at line 1803).
|
||||
|
||||
### 12. `__delivery_callback` fires — message reaches the app
|
||||
|
||||
`LXMF/LXMRouter.py:1805-1812`. The router's caller (Sideband, NomadNet, MeshChat, …) sets `__delivery_callback` via `register_delivery_callback(...)`. It receives the validated `LXMessage` object and decides what to show in the inbox.
|
||||
|
||||
Important: the recipient's app should apply the SPEC.md §9.6 clockless-sender heuristic at this point — `if message.timestamp < 1577836800: message.timestamp = local_now()` — to keep clockless LoRa devices out of January 1970 in the inbox. Upstream `LXMessage.unpack_from_bytes` does **not** do this fix-up.
|
||||
|
||||
### 13. PROOF receipt back to the sender
|
||||
|
||||
Already triggered by `packet.prove()` at the top of step 9. `RNS/Packet.py::prove` constructs a PROOF packet whose body is `SHA256(received_packet.get_hashable_part())` (32 bytes), signed by the receiving destination's Ed25519 long-term private key (32 bytes signature appended in non-implicit mode). The PROOF travels back along the reverse path and resolves the sender's `PacketReceipt`, completing the SENT → DELIVERED transition on the sender side (send flow step 12).
|
||||
|
||||
For a destination with `proof_strategy == PROVE_ALL` this happens unconditionally; for `PROVE_APP` an app callback decides per-packet (step 6 above). Default `proof_strategy` is `PROVE_NONE`, but `LXMRouter.delivery_packet` calls `packet.prove()` directly so it works regardless of the destination's strategy.
|
||||
|
||||
### 14. Self-announce-echo guard (cross-cutting, not per-message)
|
||||
|
||||
SPEC.md §9.5: if the operator runs both an originator and a transport node on the same machine, the recipient may receive its own packets. For DATA packets specifically the `Transport.destinations_map` lookup in step 6 still resolves to a local destination, so the message is processed normally — opportunistic-LXMF delivered to oneself works. For announces (a different flow) the self-echo can populate the contacts list with one's own destination; that's what §9.5 warns about.
|
||||
|
||||
---
|
||||
|
||||
## Wire-byte summary (mirror of the send-flow summary)
|
||||
|
||||
What arrives at the recipient before deframing — assumes a 0-hop direct send (HEADER_1) or a >1-hop relayed packet that arrives as HEADER_2 with a transport_id at offset 2:
|
||||
|
||||
```
|
||||
HEADER_1:
|
||||
[ 1B flags ][ 1B hops ][ 16B dest_hash ][ 1B context=0x00 ]
|
||||
[ 32B sender_ephemeral_X25519_pub ][ 16B iv ]
|
||||
[ N×16B aes_ciphertext ][ 32B hmac_sha256 ]
|
||||
|
||||
HEADER_2 (after relay):
|
||||
[ 1B flags ][ 1B hops ][ 16B transport_id ][ 16B dest_hash ][ 1B context=0x00 ]
|
||||
[ 32B sender_ephemeral_X25519_pub ][ 16B iv ]
|
||||
[ N×16B aes_ciphertext ][ 32B hmac_sha256 ]
|
||||
```
|
||||
|
||||
After AES-CBC decryption with the matching ratchet (or long-term) key, the plaintext is the opportunistic LXMF body **without** the recipient's dest_hash (SPEC.md §5.1):
|
||||
|
||||
```
|
||||
[ 16B src_hash ][ 64B Ed25519_signature ][ msgpack_payload ]
|
||||
```
|
||||
|
||||
Step 9 re-prepends `packet.destination.hash` so step 10 can parse with the same field offsets used for Link-delivered LXMF (SPEC.md §5.2).
|
||||
|
||||
---
|
||||
|
||||
## Source map for this flow
|
||||
|
||||
| Step | File | Function / line |
|
||||
|---|---|---|
|
||||
| 1 | `RNS/Interfaces/*.py` | per-interface KISS / HDLC deframer |
|
||||
| 2 | `RNS/Transport.py` | `inbound`, line 1327 |
|
||||
| 3 | `RNS/Transport.py` | IFAC unmask, line 1338 |
|
||||
| 4 | `RNS/Transport.py` | parse + hops, line 1391 |
|
||||
| 4 | `RNS/Packet.py` | `unpack` |
|
||||
| 5 | `RNS/Transport.py` | hop fix-up, line 1419 |
|
||||
| 6 | `RNS/Transport.py` | dedup + dispatch, line 1424; DATA/SINGLE branch line 2087 |
|
||||
| 7 | `RNS/Destination.py` | `receive`, line 403 |
|
||||
| 8 | `RNS/Destination.py` | `decrypt`, line 611 |
|
||||
| 8 | `RNS/Identity.py` | `decrypt`, line 818 |
|
||||
| 8 | `RNS/Cryptography/Token.py` | `Token.decrypt` |
|
||||
| 9 | `LXMF/LXMRouter.py` | `delivery_packet`, line 1819 |
|
||||
| 10 | `LXMF/LXMessage.py` | `unpack_from_bytes`, line 736 |
|
||||
| 11 | `LXMF/LXMRouter.py` | `lxmf_delivery`, line 1732 |
|
||||
| 12 | `LXMF/LXMRouter.py` | delivery callback dispatch, line 1805 |
|
||||
| 13 | `RNS/Packet.py` | `prove` |
|
||||
223
flows/send-link-lxmf.md
Normal file
223
flows/send-link-lxmf.md
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
# Flow: send an LXMF message over a Reticulum Link (DIRECT method)
|
||||
|
||||
What happens chronologically when an app calls `LXMRouter.handle_outbound(lxm)` for an `LXMessage` whose `desired_method == DIRECT` (or whose payload exceeds the opportunistic single-packet content limit and is downgraded from `OPPORTUNISTIC` to `DIRECT` at pack time). The `DIRECT` method runs the LXMF body over an established Reticulum Link rather than a single Reticulum DATA packet.
|
||||
|
||||
Pinned against **RNS 1.2.0 / LXMF 0.9.6**. Line numbers below are from those versions.
|
||||
|
||||
Out of scope: opportunistic delivery (see [`send-opportunistic-lxmf.md`](send-opportunistic-lxmf.md)), propagation-node delivery (`PROPAGATED`), and paper messages (`PAPER`).
|
||||
|
||||
---
|
||||
|
||||
## When DIRECT runs
|
||||
|
||||
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.
|
||||
|
||||
Within DIRECT there are two **representations** decided at pack time (`LXMF/LXMessage.py:414-421`):
|
||||
|
||||
- **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 currently NOT in [`../SPEC.md`](../SPEC.md); see [`../todo.md`](../todo.md). This flow document covers PACKET in full and only sketches RESOURCE.
|
||||
|
||||
---
|
||||
|
||||
## Sequence
|
||||
|
||||
### 1. App constructs `LXMessage` and submits it
|
||||
|
||||
Same as steps 1-2 of `send-opportunistic-lxmf.md`. `LXMessage.pack()` builds the same `dest_hash || src_hash || signature || msgpack_payload` bytes per [`../SPEC.md`](../SPEC.md) §5.5; the difference is that for DIRECT delivery the **recipient's `dest_hash` is NOT stripped** before transmission (SPEC.md §5.2 vs §5.1) — the link payload includes it. `LXMRouter.handle_outbound` adds the message to `pending_outbound` and starts `process_outbound` in a thread.
|
||||
|
||||
### 2. `process_outbound` enters the DIRECT branch
|
||||
|
||||
`LXMF/LXMRouter.py:2599`. Logic for an `LXMessage` whose `method == DIRECT`:
|
||||
|
||||
```python
|
||||
delivery_destination_hash = lxmessage.get_destination().hash
|
||||
direct_link = self.direct_links.get(delivery_destination_hash) \
|
||||
or self.backchannel_links.get(delivery_destination_hash)
|
||||
|
||||
if direct_link is not None:
|
||||
if direct_link.status == RNS.Link.ACTIVE:
|
||||
# Step 5 below — link is up, transfer the LXM
|
||||
lxmessage.set_delivery_destination(direct_link)
|
||||
lxmessage.send()
|
||||
elif direct_link.status == RNS.Link.CLOSED:
|
||||
# Drop link, request_path, retry
|
||||
...
|
||||
else:
|
||||
# PENDING — wait for ACTIVE or CLOSED via established_callback
|
||||
...
|
||||
else:
|
||||
# No link exists; establish one IF a path is known, else request_path first.
|
||||
if RNS.Transport.has_path(delivery_destination_hash):
|
||||
delivery_link = RNS.Link(lxmessage.get_destination()) # step 3
|
||||
delivery_link.set_link_established_callback(self.process_outbound)
|
||||
self.direct_links[delivery_destination_hash] = delivery_link
|
||||
else:
|
||||
RNS.Transport.request_path(delivery_destination_hash) # step 0 (path?)
|
||||
lxmessage.next_delivery_attempt = time.time() + LXMRouter.PATH_REQUEST_WAIT
|
||||
```
|
||||
|
||||
Setting `process_outbound` itself as the link's established-callback is the trick that connects steps 3-4 to step 5: when the LRPROOF arrives and the link transitions to ACTIVE, the callback re-enters `process_outbound`, finds the now-active link in `self.direct_links`, and proceeds to the transfer branch.
|
||||
|
||||
### 3. `RNS.Link(destination)` builds and sends a LINKREQUEST
|
||||
|
||||
`RNS/Link.py:233-327`. The `Link` constructor on the **initiator** side generates a fresh ephemeral X25519 + Ed25519 keypair (`pub_bytes` and `sig_pub_bytes`) and constructs the LINKREQUEST body (`RNS/Link.py:308-324`):
|
||||
|
||||
```
|
||||
request_data = initiator_X25519_pub(32) || initiator_Ed25519_pub(32) || [signalling(3)]
|
||||
```
|
||||
|
||||
Both initiator-side keys are **fresh-ephemeral**, used only for this link. The optional 3-byte `signalling` field, present iff `Reticulum.link_mtu_discovery()` returns true and the next-hop interface advertises an HW MTU, encodes the path-MTU and link-mode hints (SPEC.md §6.1; encode/decode helpers at `RNS/Link.py:148`).
|
||||
|
||||
```python
|
||||
self.packet = RNS.Packet(destination, self.request_data, packet_type=RNS.Packet.LINKREQUEST)
|
||||
self.packet.pack()
|
||||
self.set_link_id(self.packet) # = SHA256(get_hashable_part)[:16]
|
||||
RNS.Transport.register_link(self)
|
||||
self.start_watchdog() # 60s establishment_timeout per hop
|
||||
self.packet.send()
|
||||
```
|
||||
|
||||
The LINKREQUEST is a **regular Reticulum DATA-routed packet** — `packet_type = LINKREQUEST (2)`, `destination_type = SINGLE`, addressed to the recipient's `lxmf.delivery` `dest_hash`. Per `RNS/Packet.py::pack` (`RNS/Packet.py:192-194`) `LINKREQUEST` packets are **not encrypted** — the body is `request_data` verbatim — because the responder needs to decode the public keys to perform the handshake.
|
||||
|
||||
The link_id is set immediately on the initiator side via `set_link_id` (SPEC.md §6.3): the SHA-256-truncated-to-16 hash of `get_hashable_part(LINKREQUEST_packet)`, with trailing signalling bytes stripped via `link_id_from_lr_packet`. Since `get_hashable_part` is invariant under HEADER_1↔HEADER_2 conversion (`RNS/Packet.py:354-361`), the responder will arrive at the same link_id even if the LINKREQUEST passed through one or more relays.
|
||||
|
||||
The LINKREQUEST then goes through the same `Transport.outbound` path as any other DATA packet (steps 7-9 of `send-opportunistic-lxmf.md`), which means it can itself be subject to the path-table miss → path-request preamble before it leaves.
|
||||
|
||||
### 4. Wait for LRPROOF; verify; complete handshake
|
||||
|
||||
The initiator's link sits in `Link.PENDING` while it waits for the responder's LRPROOF. The responder's side of this exchange (entering `Destination.receive` with `packet_type == LINKREQUEST` → `incoming_link_request` → `Link.validate_request` → `Link.handshake` → `Link.prove`, all at `RNS/Link.py:186-227, 353-381`) is its own flow document; this flow describes only what the initiator sees.
|
||||
|
||||
The LRPROOF arrives back at `Transport.inbound` with `packet_type = PROOF`, `context = LRPROOF (0xff)`, `dest_hash = link_id`. `Transport.inbound` looks up the link by its `link_id` and dispatches to `Link.validate_proof` (`RNS/Link.py:396`). For an initiator with link still in `PENDING`:
|
||||
|
||||
```
|
||||
proof_data layout (RNS/Link.py:376):
|
||||
signature(64) || responder_X25519_pub(32) || [signalling(3)]
|
||||
```
|
||||
|
||||
Validation (`RNS/Link.py:410-422`):
|
||||
|
||||
1. Parse `signature = packet.data[:64]`, `peer_pub_bytes = packet.data[64:96]`.
|
||||
2. Read the responder's long-term Ed25519 public key from the `Destination.identity` we already had cached (from a prior announce — line 412: `self.destination.identity.get_public_key()[ECPUBSIZE//2:ECPUBSIZE]`). The Ed25519 pub is **not** sent in the LRPROOF body; it must be known locally already.
|
||||
3. Derive `signed_data = link_id || responder_X25519_pub || responder_long_term_Ed25519_pub || [signalling]` (line 417).
|
||||
4. Verify the Ed25519 signature against `signed_data` using the responder's long-term Ed25519 pub.
|
||||
5. On success: `link.handshake()` runs (`RNS/Link.py:353-368`) — ECDH with the responder's fresh X25519 pub, then HKDF over the shared secret with `salt = link_id`, derives `signing_key(32) || encrypt_key(32)` per SPEC.md §6.4. Link state transitions to `Link.ACTIVE`.
|
||||
6. The initiator's `established_callback` registered at step 2 — `LXMRouter.process_outbound` — fires, re-entering step 2 with the link now ACTIVE.
|
||||
|
||||
A confirmed-MTU hint in the LRPROOF (length is `64+32+3 = 99` instead of `64+32 = 96`) updates `link.mtu` to the smaller of the responder's hint and the initiator's view (`RNS/Link.py:404-408`). An RTT report message follows automatically (`RNS/Link.py:441`, packet with `context = LRRTT`).
|
||||
|
||||
### 5. Transfer the LXM body over the active link
|
||||
|
||||
Back in `process_outbound` (`LXMF/LXMRouter.py:2618-2625`), with `direct_link.status == ACTIVE`:
|
||||
|
||||
```python
|
||||
lxmessage.set_delivery_destination(direct_link) # __delivery_destination = link
|
||||
lxmessage.send() # LXMessage.send branches on representation
|
||||
```
|
||||
|
||||
`LXMessage.send` for DIRECT/PACKET (`LXMF/LXMessage.py:471-484`) calls `self.__as_packet()` which constructs:
|
||||
|
||||
```python
|
||||
RNS.Packet(self.__delivery_destination, self.packed) # full LXMF body, dest_hash included
|
||||
```
|
||||
|
||||
i.e. the destination is the **Link object**, and the data is the full `dest_hash || src_hash || signature || msgpack_payload` (SPEC.md §5.2 — Link delivery does NOT strip dest_hash from the body, in contrast to opportunistic delivery).
|
||||
|
||||
When `RNS.Packet.pack()` runs for this packet (`RNS/Packet.py:176+`), the destination type is `LINK`, so:
|
||||
|
||||
- The header is `flags(1) || hops(1) || link_id(16) || context=0x00(1)`. The `link_id` occupies the dest_hash position.
|
||||
- `destination.encrypt(plaintext)` for a Link calls into the link's session-key Token form (`RNS/Cryptography/Token.py`). Per SPEC.md §3.1 the Link-derived Token form omits the `ephemeral_pub` prefix because both sides already share the session key from the handshake — the wire body is `iv(16) || aes_ciphertext || hmac(32)`.
|
||||
|
||||
`Packet.send()` returns a `PacketReceipt`; LXMF binds:
|
||||
|
||||
```python
|
||||
receipt.set_delivery_callback(self.__mark_delivered)
|
||||
receipt.set_timeout_callback(self.__link_packet_timed_out)
|
||||
```
|
||||
|
||||
so the LXMessage advances `SENT → DELIVERED` when the recipient's PROOF for this DATA packet arrives (SPEC.md §6.5 — every CTX_NONE DATA packet on a link gets a mandatory PROOF receipt).
|
||||
|
||||
### 6. Wire bytes leave (KISS / HDLC framing)
|
||||
|
||||
Same as `send-opportunistic-lxmf.md` step 9 — the framed link DATA packet leaves the interface, with `Transport.outbound` applying the HEADER_1→HEADER_2 conversion if the link's next hop is more than 1 transport hop away. The next-hop interface for a link is cached on the link object (`link.attached_interface`) so the hop count comes from the link establishment time, not a fresh path-table lookup.
|
||||
|
||||
### 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`.
|
||||
|
||||
### 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):
|
||||
|
||||
```python
|
||||
RNS.Resource(self.packed, self.__delivery_destination, callback=..., progress_callback=..., auto_compress=...)
|
||||
```
|
||||
|
||||
The `RNS.Resource` machinery handles fragmentation, ordering, retransmission, and progress reporting on top of the link's DATA channel. Each fragment is its own DATA packet on the link with its own PROOF receipt. The Resource fragmentation protocol — block sizes, sequence numbers, the resource-proof message — is **not in SPEC.md as of this writing** (see `../todo.md`); reading `RNS/Resource.py` is currently the only authoritative source.
|
||||
|
||||
When the resource concludes successfully, the same `__mark_delivered` path runs as for PACKET.
|
||||
|
||||
### 9. Backchannel identification (optional, after first successful DIRECT delivery)
|
||||
|
||||
`LXMF/LXMRouter.py:2532-2545`. After a DIRECT delivery completes (`lxmessage.state == DELIVERED`), if the link doesn't yet have a backchannel identity associated, the initiator's `LXMRouter` calls `direct_link.identify(backchannel_identity)`:
|
||||
|
||||
- `direct_link.identify` sends an IDENTIFY packet on the link bound to one of the **sender's** local `lxmf.delivery` destinations.
|
||||
- The receiving side's `delivery_link_established` callback (set at `LXMRouter.py:1849-1856`) installs `delivery_packet` on the link's packet callback, so subsequent inbound DATA on this link reaches the LXMF parser.
|
||||
- The link is now usable in **both directions** without each side having to open its own Link to the other. The receiver can now reply over this same link.
|
||||
|
||||
This enables an interactive conversation over a single link rather than each message opening a new link.
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Wire-byte summary
|
||||
|
||||
The handshake in three packets:
|
||||
|
||||
```
|
||||
LINKREQUEST (initiator → responder), unencrypted body:
|
||||
[ 1B flags ][ 1B hops ][ 16B responder_dest_hash ][ 1B context=0x00 ]
|
||||
[ 32B initiator_X25519_pub ][ 32B initiator_Ed25519_pub ][ optional 3B signalling ]
|
||||
|
||||
LRPROOF (responder → initiator), unencrypted body:
|
||||
[ 1B flags ][ 1B hops ][ 16B link_id ][ 1B context=0xff ]
|
||||
[ 64B Ed25519_signature ][ 32B responder_X25519_pub ][ optional 3B signalling ]
|
||||
|
||||
DATA on link (either direction), Token-encrypted (no eph_pub prefix):
|
||||
[ 1B flags ][ 1B hops ][ 16B link_id ][ 1B context=0x00 ]
|
||||
[ 16B iv ][ N×16B aes_ciphertext ][ 32B hmac_sha256 ]
|
||||
(plaintext = full LXMF body: dest_hash || src_hash || signature || msgpack_payload)
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
## Source map for this flow
|
||||
|
||||
| Step | File | Function / line |
|
||||
|---|---|---|
|
||||
| 1 | `LXMF/LXMessage.py` | `pack`, line 352 |
|
||||
| 2 | `LXMF/LXMRouter.py` | `process_outbound` DIRECT branch, line 2599 |
|
||||
| 3 | `RNS/Link.py` | `Link.__init__` initiator branch, line 308 |
|
||||
| 3 | `RNS/Packet.py` | `pack` LINKREQUEST not-encrypted, line 192 |
|
||||
| 3 | `RNS/Link.py` | `set_link_id` / `link_id_from_lr_packet`, line 340 |
|
||||
| 3 | `RNS/Packet.py` | `get_hashable_part`, line 354 |
|
||||
| 4 | `RNS/Link.py` | `validate_proof`, line 396 |
|
||||
| 4 | `RNS/Link.py` | `handshake`, line 353 |
|
||||
| 5 | `LXMF/LXMessage.py` | `send` DIRECT branch, line 471 |
|
||||
| 5 | `LXMF/LXMessage.py` | `__as_packet` DIRECT, line 632 |
|
||||
| 5 | `RNS/Packet.py` | Link-destination encrypt path |
|
||||
| 5 | `RNS/Cryptography/Token.py` | Token encrypt (no eph_pub prefix for Link) |
|
||||
| 6 | `RNS/Transport.py` | `outbound`, line 1031 |
|
||||
| 7 | `RNS/Packet.py` | `prove` |
|
||||
| 8 | `RNS/Resource.py` | RESOURCE machinery (not yet in SPEC.md) |
|
||||
| 9 | `LXMF/LXMRouter.py` | backchannel identify, line 2532 |
|
||||
| 10 | `RNS/Link.py` | watchdog / teardown |
|
||||
Loading…
Add table
Add a link
Reference in a new issue