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>
15 KiB
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.Identitywith the X25519 + Ed25519 private keys, plus alxmf.deliveryRNS.Destinationregistered withLXMRouter.register_delivery_identity— that registration callsdelivery_destination.set_packet_callback(self.delivery_packet)atLXMF/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_destinationsknows the sender's fullpublic_key(X25519 || Ed25519) under theirdest_hash. Without this, signature validation in step 11 will fail withunverified_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_hashis inTransport.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.decrypt → Identity.decrypt — Token decode with ratchet ring
RNS/Destination.py:611-645 → RNS/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_stampsis true. - Phy stats: RSSI / SNR / Q copied onto the LXMessage from
phy_stats. - Ignore list: messages from
source_hash in self.ignored_listare dropped silently. - Dedup:
self.has_message(message.hash)checkslocally_delivered_transient_ids; duplicates are dropped (the previously-seenmessage.hashwas 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 |