diff --git a/SPEC.md b/SPEC.md index 6cf9c3b..b8ed244 100644 --- a/SPEC.md +++ b/SPEC.md @@ -106,6 +106,7 @@ Single byte after the destination hash (offset 18 for HEADER_1, offset 34 for HE | `0x00` | CTX_NONE | Default; opportunistic LXMF DATA, regular packets | | `0x09` | CTX_REQUEST | Link REQUEST (NomadNet page fetch, propagation /get) | | `0x0a` | CTX_RESPONSE | Link RESPONSE matching a REQUEST | +| `0x0b` | PATH_RESPONSE | An `ANNOUNCE` packet emitted in response to a `path?` request — distinguishes it from a periodic re-announce. Receivers handle the two paths differently (see §7.2 and §4.5) | | `0xfd` | CTX_KEEPALIVE | Link keepalive | | `0xff` | LRPROOF | Link request proof | @@ -163,7 +164,17 @@ The Reticulum packet header (HEADER_1, packet_type=ANNOUNCE, dest_type=SINGLE, t public_key(64) || name_hash(10) || random_hash(10) || [ratchet_pub(32) if context_flag] || signature(64) || app_data(...) ``` -The 64-byte `public_key` is the X25519 || Ed25519 concat described in section 1.1. `random_hash` is 10 random bytes that vary per emission to keep the packet hash unique even across rapid re-announces. The optional 32-byte `ratchet_pub` (an X25519 public key) is present iff the packet header's `context_flag` bit is 1. Indexing through this layout accordingly is mandatory; see `RNS/Identity.py::validate_announce` for the canonical parser. +The 64-byte `public_key` is the X25519 || Ed25519 concat described in section 1.1. + +`random_hash` is **NOT** 10 random bytes — only the first 5 bytes are random; the trailing 5 bytes carry the emission timestamp as a big-endian unsigned 40-bit Unix-seconds integer (`RNS/Destination.py:282`): + +```python +random_hash = RNS.Identity.get_random_hash()[0:5] + int(time.time()).to_bytes(5, "big") +``` + +Transit relays read the timestamp portion via `Transport.timebase_from_random_blob(random_blob) = int.from_bytes(random_blob[5:10], "big")` (`RNS/Transport.py:3100-3101`) to make ordering decisions when an inbound announce carries a higher hop count than the cached path: only newer-emitted announces can refresh the path table (see §4.5). 5 bytes of seconds covers ~34,000 years, so wraparound is not a near-term concern. Implementations MUST emit this exact format, including a clock value that's monotonically non-decreasing across announces from the same destination — clockless sender devices (per §9.6) may end up locked out of long-range path table updates. + +The optional 32-byte `ratchet_pub` (an X25519 public key) is present iff the packet header's `context_flag` bit is 1. Indexing through this layout accordingly is mandatory; see `RNS/Identity.py::validate_announce` for the canonical parser. ### 4.2 Signed data @@ -211,6 +222,103 @@ When ingesting an announce, clients should distinguish by `name_hash`: Treating every announce as a contact (the naive default) populates the UI with hundreds of irrelevant rows. +### 4.5 Announce validation rules (receive side) + +These are the MUST rules a receiver applies to every inbound announce before considering the announced destination "known". The canonical implementation is `RNS/Identity.py::validate_announce` (line 496-598 in RNS 1.2.0); the dispatch site that calls it is `RNS/Transport.py::inbound` line 1623-1650. + +#### 1. Body parse — branch on `context_flag` + +The `context_flag` bit (bit 5 of the packet's 1-byte flag field, §2.1) selects between two body layouts. Slice offsets, with `keysize = 64`, `name_hash_len = 10`, `random_hash_len = 10`, `ratchet_size = 32`, `sig_len = 64`: + +``` +context_flag == 1 (ratchet present): + public_key = data[ 0 : 64] + name_hash = data[ 64 : 74] + random_hash = data[ 74 : 84] + ratchet_pub = data[ 84 : 116] + signature = data[116 : 180] + app_data = data[180 : ] # may be empty + +context_flag == 0 (no ratchet): + public_key = data[ 0 : 64] + name_hash = data[ 64 : 74] + random_hash = data[ 74 : 84] + signature = data[ 84 : 148] + app_data = data[148 : ] # may be empty +``` + +A client that uses a fixed offset for `signature` regardless of the flag (a real bug from the SF webclient's first cut) silently rejects every ratchet-bearing announce as having a bad signature. + +#### 2. Signature verification + +Reconstruct the signed_data exactly per §4.2: + +``` +signed_data = destination_hash || public_key || name_hash || random_hash || ratchet || app_data +``` + +Where `ratchet` is `b""` (empty, **not** absent) when `context_flag == 0`, and `app_data` is `b""` when not present in the packet. `destination_hash` comes from the **outer packet header**, NOT from the announce body — re-using the body bytes as the dest_hash would let a sender forge announces for arbitrary destinations. + +Verify the 64-byte signature with the announced public_key's Ed25519 half (last 32 bytes). Reject on failure. + +#### 3. `destination_hash` recomputation + +Recompute the dest_hash from the announced inputs: + +``` +identity_hash = SHA256(public_key)[:16] +expected_hash = SHA256(name_hash || identity_hash)[:16] +``` + +Reject the announce iff `expected_hash != packet.destination_hash` (the value from the outer header). This catches both random hash collisions and active spoofing attempts that pair a valid signature with an unrelated dest_hash. (`RNS/Identity.py:548-551`). + +#### 4. Public-key collision rejection + +If the receiver already has a different public_key cached for this `destination_hash` (from a prior announce), the new announce MUST be rejected with a critical-severity log even if the signature is otherwise valid. Per the upstream comment: "In reality, this should never occur, but in the odd case that someone manages a hash collision, we reject the announce" (`RNS/Identity.py:554-560`). + +This rule means: **first-announcer-wins for any given destination_hash** within a receiver's lifetime. A peer who loses their identity material and regenerates with the same display name + app_name will produce a different identity_hash → different destination_hash → no collision. A peer who tries to *replace* their announced public key under the same destination_hash, however, gets rejected — the real defense against this class of attack. + +#### 5. Blackhole list check + +Before everything else, check `RNS.Transport.blackholed_identities`. An identity_hash on the blackhole list is dropped silently regardless of signature validity (`RNS/Identity.py:538-541`). This is operator-controlled state, not a wire feature. + +#### 6. Caching the announce contents + +On a fully validated announce, the receiver MUST update its caches in this order: + +1. **`known_destinations[destination_hash]`** ← `[recv_time, packet_hash, public_key, app_data, last_used]` — populates the table that `RNS.Identity.recall(dest_hash)` reads when constructing outbound destinations (`RNS/Identity.py::remember`, line 100-112). Without this, every subsequent outbound message to this peer fails because no public key is available for Token encryption. +2. **`known_ratchets[destination_hash]`** ← `ratchet_pub` (only if `context_flag == 1` and `ratchet_pub != b""`) — `Identity._remember_ratchet`, line 395-428. The ratchet is also persisted to disk under `{storagepath}/ratchets/{hexhash}` for use across restarts. +3. **`path_table`** entry update or insertion (see §4.6 — TBD when the relay rebroadcast spec lands), gated by: + - `random_blob` (= `random_hash`) not in the cached `random_blobs` history for this destination — cheap replay defence (`RNS/Transport.py:1707, 1732, 1745`). + - Hop count comparison against any existing entry: equal-or-fewer hops always win; more hops win only if the cached path has expired or the new announce's emission timestamp (from `random_hash[5:10]`) is more recent than every cached blob's timestamp (`RNS/Transport.py:1700-1745`). + +#### 7. `PATH_RESPONSE` distinction + +An announce whose outer packet `context == PATH_RESPONSE (0x0B)` is the responder's reply to a recent `path?` request, not a periodic re-announce. Validation is identical (rules 1-6 above), but listener dispatch differs: + +- The default behavior of `Transport.announce_handlers` registered via `RNS.Transport.register_announce_handler` is to **skip** path-response announces unless the handler sets `receive_path_responses = True` on itself (`RNS/Transport.py:1989-1991`). +- The path table population path is the same either way — both regular and path-response announces refresh the path entry — so a leaf client that ignores PATH_RESPONSE entirely at the application layer still benefits from the path-table side effect. + +#### 8. Implementation-private behavior (SHOULD) + +These are not wire-spec MUST rules but most working clients implement them; without them the implementation will misbehave in busy meshes: + +- **Per-interface ingress rate limiting.** When the inbound announce rate on an interface exceeds `IC_BURST_FREQ_NEW = 6 Hz` (interfaces less than 2 hours old) or `IC_BURST_FREQ = 35 Hz` (older), and the announced destination is **not** in `path_table` and **not** in `path_requests`, the announce is held in the interface's `held_announces` dict for later release rather than processed immediately. Released later in lowest-hop-count-first order. (`RNS/Interfaces/Interface.py:60-200`.) Without this, a flood of unknown-destination announces can drown out everything else. +- **`random_blob` history cap.** The cached `random_blobs` list per destination is bounded by `Transport.MAX_RANDOM_BLOBS` to keep the path table from growing without bound under a long-lived destination's announce stream (`RNS/Transport.py:1820`). +- **Self-announce filter.** §9.5 — drop announces where `destination_hash` matches one of the receiver's own destinations to avoid populating its own contact list with itself. + +#### 9. Source map for §4.5 + +| File | What it pins down | +|---|---| +| `RNS/Identity.py:496-598` | `validate_announce` — body parse, signed_data, sig verify, dest_hash recompute, collision check | +| `RNS/Identity.py:100-112` | `Identity.remember` — `known_destinations` update | +| `RNS/Identity.py:395-428` | `_remember_ratchet` — ratchet persistence | +| `RNS/Transport.py:1623-2024` | inbound dispatch for `packet_type == ANNOUNCE`: quick sig check, ingress limiting, path table population, handler dispatch | +| `RNS/Transport.py:3100-3117` | `timebase_from_random_blob`, `announce_emitted` | +| `RNS/Interfaces/Interface.py:60-200` | ingress-limit constants, `should_ingress_limit`, `hold_announce`, `process_held_announces` | +| `RNS/Packet.py:83` | `PATH_RESPONSE = 0x0B` context constant | + --- ## 5. LXMF wire format diff --git a/flows/README.md b/flows/README.md index ffeab48..0669042 100644 --- a/flows/README.md +++ b/flows/README.md @@ -11,10 +11,13 @@ The two views are complementary: SPEC.md tells you what each piece looks like; t | [`send-opportunistic-lxmf.md`](send-opportunistic-lxmf.md) | ✅ | | [`receive-opportunistic-lxmf.md`](receive-opportunistic-lxmf.md) | ✅ | | [`send-link-lxmf.md`](send-link-lxmf.md) (DIRECT method, over a Reticulum Link) | ✅ | +| [`receive-announce.md`](receive-announce.md) | ✅ | | `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) | ⏳ | -| `announce.md` (build, sign, transmit, ratchet rotation) | ⏳ | -| `path-discovery.md` (request, response, path-table population) | ⏳ | +| `send-announce.md` (build, sign, transmit, ratchet rotation, periodic re-announce) | ⏳ | +| `forward-announce.md` (transport-node rebroadcast logic, announce_cap, queue) | ⏳ | +| `path-discovery.md` (path? request, path-response wire detail, path-table population) | ⏳ | +| `send-resource.md` (Resource fragmentation over a Link) | ⏳ | ## Conventions diff --git a/flows/receive-announce.md b/flows/receive-announce.md new file mode 100644 index 0000000..75f26c8 --- /dev/null +++ b/flows/receive-announce.md @@ -0,0 +1,242 @@ +# 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 | diff --git a/todo.md b/todo.md index dc75b84..3fda781 100644 --- a/todo.md +++ b/todo.md @@ -112,23 +112,16 @@ re-research. ### Tier 1 — required for a barebones leaf LXMF client to interop -- [ ] **`flows/receive-announce.md` + SPEC.md §4.5 announce validation - rules.** Without this, a client can't learn that any peers exist; - `known_destinations` and `known_ratchets` stay empty and every - outbound message fails at `recall(dest_hash)`. Hooks: - `RNS/Identity.py::validate_announce` at line 496 (full body - parse with branching on `context_flag` for the optional ratchet - slot, signed_data construction - `dest_hash || pub || name_hash || random_hash || ratchet || app_data`, - blackhole check, dest_hash recomputation, public-key collision - rejection, ratchet ingestion via `_remember_ratchet`); - `RNS/Transport.py:1623-2024` (the announce dispatch path — - signature-only quick check that calls `interface.received_announce()`, - ingress-rate limiting via `should_ingress_limit`, path-table - population, `random_blob` replay/loop dedup, and the - `announce_handlers` callback fan-out with `aspect_filter` matching - and `PATH_RESPONSE = 0x0B` context distinction — see - `RNS/Packet.py:83`). +- [x] **`flows/receive-announce.md` + SPEC.md §4.5 announce validation + rules.** Done. SPEC.md §4.5 covers the MUST validation rules + (body parse with `context_flag` branch, signed_data + reconstruction, signature verification, dest_hash recomputation, + public-key collision rejection, blackhole list, cache update + order, PATH_RESPONSE handling). `flows/receive-announce.md` walks + the chronology end-to-end. Side fixes: SPEC.md §4.1 corrected + (`random_hash` is 5 random bytes + 5 bytes big-endian uint40 + unix_seconds, not 10 random bytes); SPEC.md §2.5 contexts table + now lists `0x0B PATH_RESPONSE`. - [ ] **SPEC.md §12 / `flows/send-resource.md`: Reticulum Resource fragmentation.** Any LXMF body larger than `LINK_PACKET_MAX_CONTENT` ≈ 360 B is sent as an `RNS.Resource`, not a single Link DATA