241 lines
15 KiB
Markdown
241 lines
15 KiB
Markdown
|
|
# 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` |
|