reticiulum-specification/flows/receive-announce.md

242 lines
15 KiB
Markdown
Raw Normal View History

Add receive-announce flow + SPEC §4.5 validation rules Closes the highest-priority Tier 1 gap. Without this, a from-scratch client can't learn any peers exist; known_destinations stays empty and every outbound message fails at recall(dest_hash). SPEC.md §4.5 (new): announce validation rules with full citations to RNS/Identity.py::validate_announce (line 496) and the dispatch path in RNS/Transport.py:1623-2024. Covers the body parse with context_flag branch, signed_data reconstruction (including the empty-bytes-not-absent ratchet rule), Ed25519 signature verification, dest_hash recomputation, public-key collision rejection, blackhole list, cache update order (known_destinations -> known_ratchets -> path_table), PATH_RESPONSE distinction, and the implementation-private SHOULD rules around ingress rate limiting, random_blob history caps, and self-announce filtering. flows/receive-announce.md: chronological walk through 9 steps from deframing to handler dispatch, with the cheap-pre-filter design (signature-checked-then-counted) called out, the burst-active ingress limiter explained against IC_BURST_FREQ_NEW=6Hz / IC_BURST_FREQ=35Hz, the path-table decision tree, and the announce_handlers fan-out with aspect_filter and PATH_RESPONSE filtering. Ends with a wire-byte diagram and a per-step source map. Two side fixes found while drafting: - SPEC.md §4.1 had random_hash described as "10 random bytes". It's actually random_hash = get_random_hash()[0:5] + int(time.time()).to_bytes(5, "big") per RNS/Destination.py:282. Transit relays parse the trailing 5 bytes via timebase_from_random_blob (RNS/Transport.py:3100) for replay-ordering decisions. - SPEC.md §2.5 contexts table was missing PATH_RESPONSE = 0x0B (RNS/Packet.py:83). flows/README.md status table updated; the priority-ordered todo list also gets a few new entries spun off from the work (send-announce, forward-announce, send-resource, path-discovery flows). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:56:11 -04:00
# Flow: receive an announce
What happens chronologically on a node when an `ANNOUNCE` packet arrives on one of its interfaces. Without this flow working, nothing else does — no peer is ever known, every outbound message fails at `Identity.recall(dest_hash)`, and no path table entry exists for routing.
Pinned against **RNS 1.2.0**. Line numbers below are from that version.
This flow covers the **receive + ingest** path that every Reticulum node runs (leaf clients and transport nodes alike). The **rebroadcast** logic that transport nodes additionally run is covered separately in `forward-announce.md` (TODO — Tier 3 in `../todo.md`).
---
## Preconditions
- Receiver is initialised: `Transport.ready == True`, `Transport.identity` set.
- Receiver has at least one interface attached (otherwise `Transport.inbound` drops the packet at the IFAC check).
- Receiver may or may not have any prior knowledge of the announcing destination — the validation flow below populates `known_destinations` on first contact and refreshes / replay-checks on subsequent contacts.
---
## Sequence
### 1. Wire bytes arrive at the interface, deframe, classify
Steps 1-6 of [`receive-opportunistic-lxmf.md`](receive-opportunistic-lxmf.md) apply unchanged:
1. KISS / HDLC deframer (and RNode 2-frame split-packet reassembly per [`../SPEC.md`](../SPEC.md) §8.3 if applicable) hands the raw Reticulum packet bytes to `RNS.Transport.inbound(raw, interface)` (`RNS/Transport.py:1327`).
2. IFAC unmask if the interface has `ifac_identity` configured.
3. `packet = RNS.Packet(None, raw); packet.unpack(); packet.hops += 1`.
4. RSSI / SNR / Q stats are attached to the packet.
5. `Transport.packet_filter(packet)` dedup check; the packet's hash is added to `Transport.packet_hashlist` unless it belongs to a link or is an LRPROOF.
6. Dispatch by `packet.packet_type`. For an announce: `packet_type == ANNOUNCE (1)`, `destination_type == SINGLE (0)`, `transport_type == BROADCAST (0)`. Control reaches `RNS/Transport.py:1628`:
```python
if packet.packet_type == RNS.Packet.ANNOUNCE:
...
```
### 2. Quick signature-only validation (cheap pre-filter)
`RNS/Transport.py:1629`:
```python
if interface != None and RNS.Identity.validate_announce(packet, only_validate_signature=True):
interface.received_announce()
```
`validate_announce(only_validate_signature=True)` runs the full body-parse and Ed25519 signature verification (steps 1-2 of SPEC.md §4.5) but **skips** the destination_hash recomputation and the cache updates. If the signature is valid the call:
- Returns `True` so the wider dispatch can proceed.
- Calls `interface.received_announce()`, which appends `time.time()` to the per-interface `ia_freq_deque` (`RNS/Interfaces/Interface.py:202-205`). This deque feeds `incoming_announce_frequency()` and is what drives ingress-limit decisions in step 3.
If the signature fails, the announce is silently dropped here without the deque update — a malformed sender can't burn through the receiver's ingress budget by spamming bad-sig announces. This is a sharp design choice and worth replicating in any clean-room implementation: **signature-checked-then-counted**, not "counted-then-validated".
### 3. Ingress rate limiting (defer-and-hold)
`RNS/Transport.py:1632-1643`:
```python
if not packet.destination_hash in Transport.path_table:
if (packet.destination_hash in Transport.path_requests
or packet.destination_hash in Transport.discovery_path_requests):
pass # already-pending path? — bypass ingress limit
elif interface.should_ingress_limit():
interface.hold_announce(packet)
return # <-- announce is paused; will be released later
```
The check runs only for **unknown-destination** announces (already-known destinations are throttled by their own re-announce rate limiter, not the ingress limiter).
`should_ingress_limit()` (`RNS/Interfaces/Interface.py:119-139`) trips into a "burst-active" state when `incoming_announce_frequency()` exceeds `IC_BURST_FREQ_NEW = 6 Hz` (interfaces younger than 2 hours, `IC_NEW_TIME`) or `IC_BURST_FREQ = 35 Hz` (older interfaces). Once in burst mode, every unknown-destination announce gets parked in the interface's `held_announces` dict until the rate drops below the threshold AND `IC_BURST_HOLD = 60s` has elapsed.
Held announces are released later by `Interface.process_held_announces()` (`RNS/Interfaces/Interface.py:177-200`), which fires every `IC_HELD_RELEASE_INTERVAL = 2s`, picks the **lowest-hop-count** held announce, and re-injects it via `Transport.inbound(announce_packet.raw, announce_packet.receiving_interface)`. The re-injection re-enters this flow at step 1, so the rate-limiter doesn't get bypassed; the held announce takes its turn in line.
`held_announces` is bounded by `MAX_HELD_ANNOUNCES = 256` per interface; overflow simply drops the new announce instead of evicting an older one (`hold_announce` at line 171-175).
### 4. Local-destination short-circuit
`RNS/Transport.py:1645-1648`:
```python
local_destination = None
with Transport.destinations_map_lock:
if packet.destination_hash in Transport.destinations_map:
local_destination = Transport.destinations_map[packet.destination_hash]
```
If the announce's `destination_hash` matches one of our own registered destinations, this is our own announce echo — line 1650 gates the full validation pass on `local_destination == None`, so we skip both the validation and the path-table updates for self-announces. (SPEC.md §9.5 — the operator-runs-both-originator-and-transport scenario.)
### 5. Full announce validation
`RNS/Transport.py:1650`:
```python
if local_destination == None and RNS.Identity.validate_announce(packet):
...
```
`validate_announce(only_validate_signature=False)` runs the full sequence per SPEC.md §4.5:
1. Branch body parse on `context_flag` to slice `public_key`, `name_hash`, `random_hash`, optional `ratchet_pub`, `signature`, `app_data`.
2. Reconstruct `signed_data = dest_hash || pub || name_hash || random_hash || ratchet || app_data` (with `ratchet = b""` when not present).
3. Verify the Ed25519 signature.
4. Blackhole-list check on the announced identity_hash.
5. Recompute `expected_hash = SHA256(name_hash || SHA256(public_key)[:16])[:16]` and compare to `packet.destination_hash`.
6. Public-key collision check against `known_destinations[dest_hash]`.
7. On success: `Identity.remember(packet_hash, dest_hash, public_key, app_data)` populates `known_destinations`; if `ratchet`, `Identity._remember_ratchet(dest_hash, ratchet)` populates `known_ratchets` and writes `{storagepath}/ratchets/{hexhash}` to disk.
If any check fails, the function returns `False` and the wider dispatch skips everything below. The cache updates happen **inside** `validate_announce` — by the time control returns to `Transport.inbound`, `known_destinations` and `known_ratchets` are already updated.
### 6. `random_blob` extraction and `received_from` resolution
`RNS/Transport.py:1651-1677`. The 10-byte `random_hash` is re-extracted as `random_blob` (same bytes, different name in the routing-table code):
```python
random_blob = packet.data[64+10 : 64+10+10] # bytes 74..84 of the announce body
```
The trailing 5 bytes of `random_blob` are the emission timestamp (SPEC.md §4.1). This blob is what the path table will store, dedupe against, and use for ordering decisions in step 7.
`received_from` is set to `packet.transport_id` if the announce arrived as `HEADER_2` with a transport_id (i.e. via at least one relay), else to `packet.destination_hash` itself.
### 7. Path table population
`RNS/Transport.py:1679-1969`. The full decision tree for whether and how to update `path_table[destination_hash]` is roughly:
```
local_and_hops_condition := (packet.hops < PATHFINDER_M+1) # default 128
AND (dest_hash NOT in destinations_map)
if not local_and_hops_condition: skip path-table update entirely
else:
announce_emitted = timebase_from_random_blob(random_blob) # [5:10] big-endian
existing = path_table.get(dest_hash)
if existing:
if packet.hops <= existing[HOPS]:
if random_blob is new AND announce_emitted > timebase(existing.random_blobs):
replace existing entry with this announce
else: # more hops than cached
if existing path expired OR announce_emitted > existing.timebase:
replace existing entry
else:
insert fresh entry
```
The new (or refreshed) `path_table[dest_hash]` entry is:
```
[ recv_time,
received_from, # next-hop transport_id or dest_hash itself
packet.hops,
recv_time + path_ttl,
random_blobs, # capped at MAX_RANDOM_BLOBS, sliding window
packet.receiving_interface,
packet.packet_hash ]
```
`path_ttl` is `Transport.AP_PATH_TIME` for access-point destinations, `ROAMING_PATH_TIME` for roaming, otherwise `DESTINATION_TIMEOUT`. The leaf-client read side (§7.1, §7.5) uses this entry to decide whether `has_path(dest_hash)` is true and to populate the next-hop transport_id when constructing HEADER_2 packets (§2.3).
A leaf client that **doesn't** maintain a path table can still send opportunistic LXMF — `LXMRouter.handle_outbound` will call `request_path` whenever `has_path()` returns false, then defer the message and retry — but the round-trip cost on every send is high enough that all serious clients maintain the table.
### 8. Announce handler dispatch
`RNS/Transport.py:1970-2024`. With path-table updated, the receiver fans the announce out to any application-level listeners that registered via `RNS.Transport.register_announce_handler`:
```python
for handler in Transport.announce_handlers:
announce_identity = RNS.Identity.recall(packet.destination_hash, _no_use=True)
if handler.aspect_filter == None:
execute_callback = True
else:
expected_hash = RNS.Destination.hash_from_name_and_identity(handler.aspect_filter, announce_identity)
execute_callback = (packet.destination_hash == expected_hash)
if packet.context == RNS.Packet.PATH_RESPONSE:
execute_callback = getattr(handler, "receive_path_responses", False) and execute_callback
if execute_callback:
threading.Thread(target=handler.received_announce, kwargs=...).start()
```
Three details that bite implementers:
1. **`aspect_filter` is a name string, not a name_hash.** A handler that wants only `lxmf.delivery` announces sets `self.aspect_filter = "lxmf.delivery"` and Reticulum recomputes the expected dest_hash from `(name, announced_identity)` for each candidate. So a single handler can match across all peers' `lxmf.delivery` destinations without enumerating them.
2. **`PATH_RESPONSE` is filtered OUT by default.** A handler must opt in via `handler.receive_path_responses = True` to see path-response announces; otherwise it sees only periodic re-announces. The path-table update in step 7 still happens regardless.
3. **The handler is called in a fresh daemon thread.** A slow handler doesn't block subsequent inbound packets, but it also can't assume serialised execution — two announces from the same destination arriving back-to-back will run two handler threads concurrently. Handlers that mutate shared state need their own locks. (`RNS/Transport.py:1995-2016`.)
LXMF registers two handlers at `LXMF/LXMRouter.py:207-208`:
```python
RNS.Transport.register_announce_handler(LXMFDeliveryAnnounceHandler(self)) # aspect = "lxmf.delivery"
RNS.Transport.register_announce_handler(LXMFPropagationAnnounceHandler(self)) # aspect = "lxmf.propagation"
```
so any client built on the LXMF router gets contacts/propagation-node discovery for free. Custom clients (NomadNet pages, telemetry beacons, application-specific destinations) register their own handlers with the relevant aspect_filter.
### 9. Optional rebroadcast (transport nodes only)
If `RNS.Reticulum.transport_enabled() == True`, the same `Transport.inbound` dispatch then walks an additional code path that constructs a rebroadcast announce, queues it on each suitable interface (`announce_queue`), and emits it later subject to the per-interface `announce_cap` (default 2% of airtime). This entire path is skipped on a leaf client.
The rebroadcast logic is the bulk of `RNS/Transport.py:1810-1969` and is documented separately in `forward-announce.md` (TODO).
---
## Wire-byte summary
What arrives — same outer Reticulum header as any DATA packet, but `packet_type = ANNOUNCE (1)`, `transport_type = BROADCAST (0)`, `destination_type = SINGLE (0)`. With `context_flag == 1` (most modern senders, ratchet present):
```
[ 1B flags ]
[ 1B hops ]
[ 16B dest_hash ] (HEADER_1; HEADER_2 inserts 16B transport_id before this)
[ 1B context: 0x00 normal / 0x0B PATH_RESPONSE ]
[ 64B public_key (X25519 || Ed25519) ]
[ 10B name_hash (SHA256(app_name)[:10]) ]
[ 10B random_hash (5B random || 5B big-endian uint40 unix_seconds) ]
[ 32B ratchet_pub (X25519) ] ← present iff context_flag bit set
[ 64B Ed25519 signature ]
[ N B app_data ] ← may be empty
```
Without ratchet (`context_flag == 0`) the 32-byte ratchet slot is absent and `signature` shifts up to start at offset `74 + 10 = 84` of the announce body.
---
## Source map
| Step | File | Function / line |
|---|---|---|
| 1 | `RNS/Transport.py` | `inbound`, line 1327 |
| 2 | `RNS/Identity.py` | `validate_announce(only_validate_signature=True)`, line 496 |
| 2 | `RNS/Interfaces/Interface.py` | `received_announce`, line 202 |
| 3 | `RNS/Interfaces/Interface.py` | `should_ingress_limit`, line 119; `hold_announce`, line 171; `process_held_announces`, line 177 |
| 4 | `RNS/Transport.py` | `destinations_map` lookup, line 1645 |
| 5 | `RNS/Identity.py` | `validate_announce` full pass, line 496-598 |
| 5 | `RNS/Identity.py` | `Identity.remember`, line 100; `_remember_ratchet`, line 395 |
| 6 | `RNS/Transport.py` | `random_blob` extraction, line 1691; `received_from` resolution, line 1651 |
| 7 | `RNS/Transport.py` | path table population decision tree, line 1679-1969 |
| 7 | `RNS/Transport.py` | `timebase_from_random_blob`, line 3100; `announce_emitted`, line 3113 |
| 8 | `RNS/Transport.py` | announce handler dispatch, line 1970-2024 |
| 8 | `LXMF/LXMRouter.py` | LXMF announce handler registration, line 207-208 |
| 9 | `RNS/Transport.py` | rebroadcast queue (TODO `forward-announce.md`), line 1810-1969 |