reticiulum-specification/flows/receive-opportunistic-lxmf.md
Rob cfd0d8249b Re-anchor against RNS 1.2.4 / LXMF 0.9.7 + track upstream distribution shift
Upstream RNS 1.2.4 (2026-05-07) announces it is "probably the last
release that is also published to GitHub" — pip continues until rnpkg
is complete and RNS is self-hosting. All 13 verifiers pass against
1.2.4 / 0.9.7; no wire-format, signing, or protocol behavior changed
between 1.2.0 and 1.2.4, so the changes here are purely currency:

- Pin tools/requirements.txt to rns==1.2.4 / lxmf==0.9.7 so the
  verifier stays reproducible if upstream stops mirroring to PyPI
  before the migration is ready.
- Add an "Upstream distribution shift" watch-list to todo.md (local
  Reticulum node, repo destination hash, rnpkg install/upgrade
  commands, rsg signature verification, mirroring source citations).
- Bump SPEC.md frontmatter and re-anchor ~50 line citations across
  Identity.py, Transport.py, Resource.py, Link.py, Reticulum.py,
  Packet.py, and LXMF/* (Identity.py drift was the heaviest at +13
  to +31 lines; Transport.py was variable). Fix one numeric
  (MAX_RANDOM_BLOBS = 32 → 64) and one semantic (§6.6.3 LRPROOF MTU
  clamp citation pointed at the wrong location — corrected to point
  at the transit-relay clamp at Transport.py:1539-1556).
- Update §10.4 decompression-bomb hazard to note upstream's 1.1.9 cap
  adoption, with citations to Resource.py:686-691 and Buffer.py:95-97
  plus a "do not use one-shot bz2.decompress()" warning.
- Re-anchor 11 flows/ files (version pins + ~30 line citations).
- Bump version labels in tools/README.md, test-vectors/README.md, and
  4 verifier docstrings + 2 hardcoded print strings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 07:42:25 -04:00

15 KiB
Raw Permalink Blame History

Flow: receive a single-packet opportunistic LXMF message

The inverse of 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.4 / LXMF 0.9.7. 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:340, 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 §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:1330. 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-1390. 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:1394-1398:

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:1400-1420).

5. Hop fix-up for shared-instance and local-client interfaces

RNS/Transport.py:1422-1425. 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:1427-1447. 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:2090-2106:

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

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:340 to the router's delivery_packet — see step 9.

8. Destination.decryptIdentity.decrypt — Token decode with ratchet ring

RNS/Destination.py:611-645RNS/Identity.py:849-905. The packet body is the Token form from SPEC.md §3.1: ephemeral_pub(32) || iv(16) || aes_ciphertext || hmac_sha256(32).

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:1822-1850:

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-810. 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.7 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-1810:

  • 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:1812-1820. 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