Add §10 Resource fragmentation + send-resource flow
Closes Tier 1 #2. Without this, a client can't send any LXMF body larger than LINK_PACKET_MAX_CONTENT ≈ 360 B, can't receive a NomadNet page that doesn't fit in one MTU, and can't transfer files via rncp. SPEC.md §10 (new): full Resource fragmentation protocol with citations to RNS/Resource.py. 13 sub-sections covering preparation pipeline (metadata prefix → optional bz2 → random_hash prefix → SHA-256 over data||random_hash → link.encrypt of the WHOLE blob → part-split into SDU-sized chunks → 4-byte map_hash hashmap with collision guard within COLLISION_GUARD_SIZE = 2*WINDOW_MAX + HASHMAP_MAX_LEN), wire context inventory (RESOURCE_ADV / RESOURCE / RESOURCE_REQ / RESOURCE_HMU / RESOURCE_PRF / RESOURCE_ICL / RESOURCE_RCL), the msgpack dict for the advertisement (t/d/n/h/r/o/i/l/q/f/m), the request payload format with the hashmap_exhausted sentinel, the lazy-hashmap RESOURCE_HMU continuation that lets large hashmaps avoid breaking small-MTU links, the proof body resource_hash(32) || full_proof = SHA256(data||hash) (32) returned in a PROOF-type packet, the sliding window dynamics (WINDOW=4 → WINDOW_MAX_FAST=75 / WINDOW_MAX_VERY_SLOW=4 with rate detection), multi-segment cutover at MAX_EFFICIENT_SIZE = 1 MiB - 1 with the lazy `__prepare_next_segment` pattern, and the encryption-before-split layering that means a missing part can't be decrypted in isolation. flows/send-resource.md: 10-step chronology from RNS.Resource() construction through advertise → req/parts loop → HMU continuation → final RESOURCE_PRF → multi-segment fan-out, with a wire-byte ladder diagram and a per-step source map. Side fixes found while drafting: - SPEC.md §2.5 contexts table was wildly incomplete and had a real bug: KEEPALIVE was listed as 0xFD; upstream is 0xFA per RNS/Packet.py:87. 0xFD is actually LINKPROOF (the regular DATA-receipt context, §6.5). Replaced with the full upstream context inventory: NONE, RESOURCE_*, CACHE_REQUEST, REQUEST, RESPONSE, PATH_RESPONSE, COMMAND, COMMAND_STATUS, CHANNEL, KEEPALIVE, LINKIDENTIFY, LINKCLOSE, LINKPROOF, LRRTT, LRPROOF. - SPEC.md §6.5 reworded: "send back a PROOF packet (no context byte specifics)" → "send back a PROOF-type packet with context = LINKPROOF (0xFD)" for clarity. - The previously-numbered §10 "Test vectors" and §11 "Source map" are renumbered to §11 / §12 so the new Resource section lands in its correct protocol-stack position. agent.md §5 audit table updated accordingly. flows/README.md status table updated; receive-resource.md added as the next pending flow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a1ec6ce7fd
commit
95823ad840
5 changed files with 460 additions and 22 deletions
243
SPEC.md
243
SPEC.md
|
|
@ -101,16 +101,31 @@ Byte 1 is `hops`, an 8-bit counter that each transit relay increments by 1. `0`
|
|||
|
||||
Single byte after the destination hash (offset 18 for HEADER_1, offset 34 for HEADER_2). Common values:
|
||||
|
||||
Full context inventory from `RNS/Packet.py:72-92` (RNS 1.2.0):
|
||||
|
||||
| Hex | Name | Used for |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
|
||||
Other context values exist (per `RNS/Packet.py`) — these are the most-used in the LXMF-via-Link path.
|
||||
| `0x00` | NONE | Generic / opportunistic DATA packet |
|
||||
| `0x01` | RESOURCE | One part (chunk) of a Resource transfer (§10) |
|
||||
| `0x02` | RESOURCE_ADV | Resource advertisement |
|
||||
| `0x03` | RESOURCE_REQ | Resource part request (from receiver to sender) |
|
||||
| `0x04` | RESOURCE_HMU | Resource hashmap update (next-segment hashmap) |
|
||||
| `0x05` | RESOURCE_PRF | Resource proof (a PROOF-type packet using this context) |
|
||||
| `0x06` | RESOURCE_ICL | Resource cancel from the initiator |
|
||||
| `0x07` | RESOURCE_RCL | Resource cancel from the receiver / reject of an advertisement |
|
||||
| `0x08` | CACHE_REQUEST | Cache lookup over a Link |
|
||||
| `0x09` | REQUEST | Link REQUEST (NomadNet page fetch, propagation `/get`) |
|
||||
| `0x0A` | RESPONSE | Link RESPONSE matching a REQUEST |
|
||||
| `0x0B` | PATH_RESPONSE | An ANNOUNCE 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) |
|
||||
| `0x0C` | COMMAND | Channel-style remote-execution command |
|
||||
| `0x0D` | COMMAND_STATUS | Status reply for a COMMAND |
|
||||
| `0x0E` | CHANNEL | Link channel multiplexed payload |
|
||||
| `0xFA` | KEEPALIVE | Link keepalive (sent periodically while a Link is idle) |
|
||||
| `0xFB` | LINKIDENTIFY | Backchannel-identify proof on an established Link (§5 backchannel) |
|
||||
| `0xFC` | LINKCLOSE | Link teardown notification |
|
||||
| `0xFD` | LINKPROOF | Receipt for a CTX_NONE Link DATA packet (§6.5) |
|
||||
| `0xFE` | LRRTT | Link RTT measurement reply |
|
||||
| `0xFF` | LRPROOF | Link request proof (§6.2) |
|
||||
|
||||
### 2.6 Source
|
||||
|
||||
|
|
@ -449,7 +464,7 @@ Subsequent DATA packets on the link use the Link-derived-key Token format (secti
|
|||
|
||||
### 6.5 Mandatory packet receipts
|
||||
|
||||
After processing each `CTX_NONE` DATA packet on an active link, the receiver MUST send back a `PROOF` packet (no context byte specifics) whose payload is the 32-byte SHA-256 of the received packet's hashable part. Without this, the sender's retransmit queue fires and the same packet arrives repeatedly, eventually exceeding the link's KEEPALIVE budget and tearing down the link. This is `Packet.prove_packet` upstream — non-optional for any client that wants to receive content over a Link without spamming the sender.
|
||||
After processing each `NONE` DATA packet on an active link, the receiver MUST send back a `PROOF`-type packet with `context = LINKPROOF (0xFD)` whose body is the 32-byte SHA-256 of the received packet's hashable part. Without this, the sender's retransmit queue fires and the same packet arrives repeatedly, eventually exceeding the link's KEEPALIVE budget and tearing down the link. This is `Packet.prove_packet` upstream — non-optional for any client that wants to receive content over a Link without spamming the sender.
|
||||
|
||||
### 6.6 Source
|
||||
|
||||
|
|
@ -721,7 +736,213 @@ logged before any filtering converts hours of "messages aren't arriving" debuggi
|
|||
|
||||
---
|
||||
|
||||
## 10. Test vectors
|
||||
## 10. Resource fragmentation protocol
|
||||
|
||||
A **Resource** transfers a payload that exceeds the per-packet content limit of an established Reticulum Link. It is the only way to carry an LXMF body, NomadNet page, or file larger than ~360 bytes (`LINK_PACKET_MAX_CONTENT`) over a Link. Resource is built **on top of** an active Link — it relies on the Link's session key for encryption (§3.1 link-derived form) and on the Link's bidirectional DATA channel for control traffic.
|
||||
|
||||
The complete reference is `RNS/Resource.py` (1383 lines in RNS 1.2.0); `RNS/Packet.py:72-78` defines the context constants. This section describes the wire-level invariants a clean-room implementation must respect; many implementation choices (window sizing heuristics, watchdog timers, EIFR computation) are private and listed only when their absence would cause an interop break.
|
||||
|
||||
### 10.1 When Resource runs
|
||||
|
||||
Three triggers in upstream:
|
||||
|
||||
1. **`LXMessage.send()` for `DIRECT` method with `representation == RESOURCE`.** Set automatically when the encrypted-form LXMF body exceeds `LINK_PACKET_MAX_CONTENT` (`LXMF/LXMessage.py:415-421`).
|
||||
2. **NomadNet page request fulfillment** — a server returning a page whose body exceeds the link MTU.
|
||||
3. **Direct file transfers** via `rncp` and similar utilities.
|
||||
|
||||
### 10.2 Initiator-side preparation
|
||||
|
||||
Given input data and an `RNS.Link` in `ACTIVE` state (`RNS/Resource.py:248-478`):
|
||||
|
||||
1. **Optional metadata prefix.** If the caller supplied a `metadata` dict, msgpack-pack it and prepend `length(3 bytes, big-endian uint24) || packed_metadata` to the body. The `has_metadata` (`x`) flag in the advertisement signals this. Receivers strip the prefix during reassembly (line 699-707).
|
||||
2. **Optional bz2 compression.** If `auto_compress` is true and the data fits within `auto_compress_limit` (default 64 MiB), the body is bz2-compressed and the `compressed` (`c`) flag is set. If compression doesn't shrink the data, the uncompressed form is sent and `c` is cleared.
|
||||
3. **Random hash prefix.** A 4-byte (`Resource.RANDOM_HASH_SIZE`) random hash is prepended to the (compressed-or-not) body. This is the `r` field in the advertisement and is part of the input to `hash` and `expected_proof`.
|
||||
4. **Link encryption.** The full `random_hash || (compressed?) data` blob is encrypted using `link.encrypt(...)` — i.e. the link-derived Token form (§3.1), no ephemeral_pub prefix. The `encrypted` (`e`) flag is set.
|
||||
5. **Hash and proof material.**
|
||||
- `data_with_random = random_hash || (compressed?) plaintext`
|
||||
- `hash = SHA256(data_with_random || random_hash)` (32 bytes)
|
||||
- `truncated_hash = hash[:16]`
|
||||
- `expected_proof = SHA256(data_with_random || hash)` (32 bytes) — what the receiver will eventually return in the RESOURCE_PRF packet.
|
||||
6. **Part split.** The encrypted body is sliced into parts of size `SDU = link.mtu - HEADER_MAXSIZE - IFAC_MIN_SIZE`. Each part becomes a packed `RNS.Packet(link, part_data, context=RESOURCE)`; the packed wire bytes are stored in `parts[i]` for later sending.
|
||||
7. **Hashmap.** Each part is fingerprinted to `MAPHASH_LEN = 4 bytes`. The full hashmap is `b"".join(map_hashes)`. **Hash collisions within the COLLISION_GUARD_SIZE = 2 × WINDOW_MAX + HASHMAP_MAX_LEN window are detected at construction time** — if two parts hash to the same 4-byte map_hash within that window, the random hash is regenerated and the whole hashmap is recomputed. Without this guard, the receiver can't disambiguate which part it just received from a part-request that named a colliding map_hash.
|
||||
|
||||
After preparation: `total_parts = ceil(size / SDU)`; `total_size` includes metadata; `total_segments = ceil(total_size / MAX_EFFICIENT_SIZE)` where `MAX_EFFICIENT_SIZE = 1 MiB - 1 = 1_048_575`.
|
||||
|
||||
### 10.3 Wire packet contexts used during a Resource transfer
|
||||
|
||||
All of these are sent on the established Link and use the Link's session key for encryption (or are unencrypted PROOF-type, depending on context):
|
||||
|
||||
| Context | Direction | Type | Body |
|
||||
|---|---|---|---|
|
||||
| `RESOURCE_ADV (0x02)` | initiator → receiver | DATA | msgpack dict (§10.4) |
|
||||
| `RESOURCE (0x01)` | initiator → receiver | DATA | one part of the encrypted body, raw |
|
||||
| `RESOURCE_REQ (0x03)` | receiver → initiator | DATA | request bytes (§10.5) |
|
||||
| `RESOURCE_HMU (0x04)` | initiator → receiver | DATA | hashmap continuation (§10.7) |
|
||||
| `RESOURCE_PRF (0x05)` | receiver → initiator | PROOF | `resource_hash(32) || full_proof(32)` |
|
||||
| `RESOURCE_ICL (0x06)` | initiator → receiver | DATA | resource_hash(32) — initiator cancel |
|
||||
| `RESOURCE_RCL (0x07)` | receiver → initiator | DATA | resource_hash(32) — receiver reject/cancel |
|
||||
|
||||
### 10.4 RESOURCE_ADV — the advertisement
|
||||
|
||||
The first packet in the transfer. Body is `umsgpack.packb(dict)` with these keys (`RNS/Resource.py:1336-1358`):
|
||||
|
||||
| Key | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `t` | int | **Transfer size** — encrypted byte length on the wire |
|
||||
| `d` | int | **Data size** — original uncompressed plaintext byte length |
|
||||
| `n` | int | **Number of parts** in this segment |
|
||||
| `h` | bytes(32) | **Resource hash** — `SHA256(data || random_hash)` |
|
||||
| `r` | bytes(4) | **Random hash** prefix |
|
||||
| `o` | bytes(32) | **Original hash** of the first segment (= `h` if single-segment) |
|
||||
| `i` | int | **Segment index** (1-based) |
|
||||
| `l` | int | **Total segments** |
|
||||
| `q` | bytes(?) or None | **Request id** if this Resource carries the response to a Link REQUEST |
|
||||
| `f` | int | **Flags byte** (see below) |
|
||||
| `m` | bytes | **Hashmap fragment** for THIS advertisement segment — up to `HASHMAP_MAX_LEN = ⌊(LINK_MDU - 134)/4⌋` 4-byte map_hashes |
|
||||
|
||||
The flags byte `f` packs six booleans (`Resource.py:1310, 1377-1382`):
|
||||
|
||||
```
|
||||
bit 0 : e — encrypted
|
||||
bit 1 : c — compressed
|
||||
bit 2 : s — split (multi-segment)
|
||||
bit 3 : u — is_request (this Resource is the body of a Link REQUEST)
|
||||
bit 4 : p — is_response (this Resource is the body of a Link RESPONSE)
|
||||
bit 5 : x — has_metadata
|
||||
```
|
||||
|
||||
`HASHMAP_MAX_LEN` matters: the entire hashmap may not fit in one ADV. If `n > HASHMAP_MAX_LEN`, the receiver reconstructs subsequent map segments via RESOURCE_HMU packets after exhausting the first slice (§10.7).
|
||||
|
||||
The advertisement is sent once on `Resource.advertise()`; if no part requests arrive within the watchdog timeout, it is retransmitted up to `MAX_ADV_RETRIES = 4` times before the resource is cancelled (`Resource.py:573-590`).
|
||||
|
||||
### 10.5 RESOURCE_REQ — receiver requests parts
|
||||
|
||||
Sent by the receiver to ask for a window's worth of specific parts (`Resource.py:934-983`). Body layout:
|
||||
|
||||
```
|
||||
hashmap_exhausted_flag(1) || [last_map_hash(4) if exhausted]
|
||||
|| resource_hash(32)
|
||||
|| requested_map_hashes(N × 4 bytes)
|
||||
```
|
||||
|
||||
Where:
|
||||
|
||||
- `hashmap_exhausted_flag` is `0x00 (HASHMAP_IS_NOT_EXHAUSTED)` if the receiver still has unrequested map_hashes from the most-recently-known hashmap segment, or `0xFF (HASHMAP_IS_EXHAUSTED)` if it has consumed all of them and needs the next hashmap segment.
|
||||
- If `exhausted == 0xFF`, the request continues with the **last** map_hash the receiver knows from the current segment (4 bytes). The sender uses this to determine which segment of the hashmap to send back via RESOURCE_HMU.
|
||||
- `resource_hash` is the 32-byte `h` from the advertisement.
|
||||
- The trailing `requested_map_hashes` is a concatenation of `N` × 4-byte map_hashes the receiver wants delivered. `N` is at most `WINDOW` (initial 4, dynamically grown — see §10.10).
|
||||
|
||||
Receivers who already have the part for a requested map_hash don't issue requests for it; the request is constructed only from `parts[search_start:search_start+window]` where `parts[i] is None` (`Resource.py:944-960`).
|
||||
|
||||
### 10.6 RESOURCE part packets
|
||||
|
||||
For each map_hash in a RESOURCE_REQ, the sender locates the matching pre-packed part within `parts[receiver_min_consecutive_height : receiver_min_consecutive_height + COLLISION_GUARD_SIZE]` and emits it as a regular Link DATA packet with `context = RESOURCE (0x01)` (`Resource.py:1011-1023`). The body is just the part's encrypted data — no metadata, no sequence number. The receiver matches the inbound part to its hashmap by recomputing its 4-byte map_hash and inserting it into `parts[i]` at the position where `hashmap[i]` matches (`Resource.py:866-885`).
|
||||
|
||||
Two interop traps:
|
||||
|
||||
1. **Map_hashes are not guaranteed unique across the whole resource** — only within `COLLISION_GUARD_SIZE` of any sliding-window position. A receiver that searches the entire hashmap for a matching part-hash can mis-place a part if two distant parts collide. The reference receiver searches only `hashmap[consecutive_completed_height : consecutive_completed_height + window]`.
|
||||
2. **Parts are link-encrypted but otherwise opaque** — the receiver has no way to validate a part beyond its 4-byte map_hash until the whole resource assembles and the SHA-256 over the reassembled data matches `h`.
|
||||
|
||||
### 10.7 RESOURCE_HMU — hashmap update
|
||||
|
||||
When the sender receives a RESOURCE_REQ with `exhausted == 0xFF` and a `last_map_hash`, it locates the position of `last_map_hash` in its full hashmap, advances to the **next** `HASHMAP_MAX_LEN` window, and emits the hashmap continuation (`Resource.py:1030-1064`):
|
||||
|
||||
```
|
||||
body = resource_hash(32) || umsgpack.packb([segment_index(int), hashmap_segment_bytes])
|
||||
```
|
||||
|
||||
The segment_index is `part_index // HASHMAP_MAX_LEN`. The receiver applies this with `Resource.hashmap_update(segment, hashmap)` to extend its known hashmap and continues issuing RESOURCE_REQ for the new range.
|
||||
|
||||
If the part_index doesn't land on a `HASHMAP_MAX_LEN` boundary, the sender treats it as a sequencing error and cancels the resource (`Resource.py:1043-1046`).
|
||||
|
||||
### 10.8 RESOURCE_PRF — final proof
|
||||
|
||||
When the receiver has assembled the full resource (`received_count == total_parts`), it runs `assemble()` (`Resource.py:672-726`):
|
||||
|
||||
1. Concatenate `parts[0..n]` to a single buffer.
|
||||
2. `link.decrypt(...)` to plaintext.
|
||||
3. Strip the 4-byte `random_hash` prefix.
|
||||
4. If `compressed`: bz2-decompress.
|
||||
5. Recompute `SHA256(plaintext_with_random || random_hash)` and compare to `h`.
|
||||
6. If match: peel off metadata if `x` is set, write `data` to the destination; status = `COMPLETE`.
|
||||
7. If mismatch: status = `CORRUPT`; cancel.
|
||||
|
||||
On `COMPLETE`, the receiver emits the proof:
|
||||
|
||||
```
|
||||
proof_data = resource_hash(32) || full_proof(32)
|
||||
where full_proof = SHA256(data_with_random || resource_hash)
|
||||
```
|
||||
|
||||
sent as `RNS.Packet(link, proof_data, packet_type=PROOF, context=RESOURCE_PRF)` (`Resource.py:755-766`). The `full_proof` is exactly what the initiator pre-computed as `expected_proof` in §10.2 step 5 — it can validate the proof bytewise without re-running the SHA-256.
|
||||
|
||||
The initiator's `validate_proof` (`Resource.py:785-824`) checks `proof_data[32:] == self.expected_proof` and transitions status to `COMPLETE`. If the resource is multi-segment (`s == True`), the next segment's advertisement is sent immediately upon proof of the current segment.
|
||||
|
||||
### 10.9 RESOURCE_ICL / RESOURCE_RCL — cancellation
|
||||
|
||||
Either side can cancel; the body is just `resource_hash(32)`:
|
||||
|
||||
- **`RESOURCE_ICL (0x06)`** — initiator cancel. Sent when the initiator decides to abort (e.g. the user kills the upload, the link MTU shrinks below the resource's pre-packed parts, the watchdog gives up after `MAX_RETRIES = 16`).
|
||||
- **`RESOURCE_RCL (0x07)`** — receiver reject / cancel. Sent on advertisement reject (`Resource.reject(adv_packet)` at line 155-163, e.g. resource too large per app callback) or on receiver-side abort.
|
||||
|
||||
Either form transitions the resource to `FAILED`, releases the parts, and notifies the link's resource-concluded callback.
|
||||
|
||||
### 10.10 Sliding window and rate adaptation
|
||||
|
||||
The receiver controls request-pacing via a sliding window:
|
||||
|
||||
```
|
||||
WINDOW = 4 # initial outstanding requests
|
||||
WINDOW_MIN = 2
|
||||
WINDOW_MAX_SLOW = 10 # default cap
|
||||
WINDOW_MAX_FAST = 75 # cap once link is observed to be fast
|
||||
WINDOW_MAX_VERY_SLOW = 4
|
||||
WINDOW_FLEXIBILITY = 4
|
||||
```
|
||||
|
||||
After each successful round (every requested part arrived), `window += 1` up to `window_max`; `window_min += 1` once `window - window_min > WINDOW_FLEXIBILITY - 1` (`Resource.py:902-906`). The window cap is promoted to `WINDOW_MAX_FAST` after `FAST_RATE_THRESHOLD` consecutive rounds at observed throughput > `RATE_FAST = 50 kbps / 8`, and demoted to `WINDOW_MAX_VERY_SLOW` after `VERY_SLOW_RATE_THRESHOLD = 2` rounds below `RATE_VERY_SLOW = 2 kbps / 8` (`Resource.py:917-927`). These are receiver-private — they're not negotiated, so two implementations with different rate-detection cutoffs interop fine but may emerge with different effective throughput on the same channel.
|
||||
|
||||
### 10.11 Multi-segment resources
|
||||
|
||||
For payloads larger than `MAX_EFFICIENT_SIZE = 1 MiB - 1`, the resource is split into multiple segments at `MAX_EFFICIENT_SIZE` boundaries (`Resource.py:299-314`). Each segment is its own Resource with its own RESOURCE_ADV; the `i` (segment_index) and `l` (total_segments) fields disambiguate. The `o` (original_hash) field carries the first segment's `h` so the receiver can correlate segments belonging to the same logical transfer.
|
||||
|
||||
The sender doesn't pre-prepare every segment up front — it builds segment N+1 in `__prepare_next_segment` while segment N is still being delivered, and sends segment N+1's advertisement only after it has received the proof for segment N (`Resource.py:768-783, 822-824`). This caps memory usage; a 100 MiB transfer doesn't materialize 100 segments simultaneously.
|
||||
|
||||
The 3-byte big-endian uint24 metadata length encoding (§10.2 step 1) is what limits per-resource metadata to `METADATA_MAX_SIZE = 16 MiB - 1`.
|
||||
|
||||
### 10.12 Compression and encryption layering
|
||||
|
||||
Encryption layering is **outermost** — the wire bytes look like:
|
||||
|
||||
```
|
||||
plaintext = data_with_random || random_hash # SHA-256 input
|
||||
data_with_random = random_hash(4) || maybe_compressed_body
|
||||
maybe_compressed = compressed_body iff `c` flag, else uncompressed
|
||||
parts[i] = link.encrypt( data_with_random[i*SDU : (i+1)*SDU] )
|
||||
```
|
||||
|
||||
Critically, **the link encryption is applied to the WHOLE concatenated data first, then sliced into parts** — not to each part individually. This means part boundaries don't align with cipher block boundaries; a missing part can't be decrypted in isolation. The receiver must accumulate all parts before calling `link.decrypt()` (`Resource.py:676-679`).
|
||||
|
||||
This also means swapping in a new link session key mid-transfer would break decryption — the encryption happened with the link's key as it was when the resource was constructed.
|
||||
|
||||
### 10.13 Source map for §10
|
||||
|
||||
| File | What it pins down |
|
||||
|---|---|
|
||||
| `RNS/Resource.py:43-156` | Class header, constants, state machine values, `reject` / `accept` |
|
||||
| `RNS/Resource.py:248-478` | `Resource.__init__` — preparation, hashmap construction, collision guard |
|
||||
| `RNS/Resource.py:520-596` | `__advertise_job`, watchdog, advertisement retransmit |
|
||||
| `RNS/Resource.py:672-726` | `assemble` — receiver reassembly, decrypt, decompress, hash-match |
|
||||
| `RNS/Resource.py:755-829` | `prove` and `validate_proof` |
|
||||
| `RNS/Resource.py:831-932` | `receive_part` — receiver-side part insertion + window adjust |
|
||||
| `RNS/Resource.py:934-983` | `request_next` — receiver-side RESOURCE_REQ construction |
|
||||
| `RNS/Resource.py:985-1064` | `request` — initiator-side fulfillment + RESOURCE_HMU emission |
|
||||
| `RNS/Resource.py:1237-1383` | `ResourceAdvertisement` — pack/unpack of the ADV msgpack dict |
|
||||
| `RNS/Packet.py:72-78` | RESOURCE_* context constants |
|
||||
|
||||
---
|
||||
|
||||
## 11. Test vectors
|
||||
|
||||
See [`test-vectors/`](test-vectors/). Currently populated:
|
||||
|
||||
|
|
@ -733,7 +954,7 @@ An implementation that round-trips every test vector — both directions — sho
|
|||
|
||||
---
|
||||
|
||||
## 11. Source map
|
||||
## 12. Source map
|
||||
|
||||
Upstream Python sources, in rough order of frequency-of-reference:
|
||||
|
||||
|
|
|
|||
5
agent.md
5
agent.md
|
|
@ -100,8 +100,9 @@ Initial confidence assessment (subjective, not authoritative — re-do this audi
|
|||
| §7.6 `TCPServerInterface.OUT` override | Source-cited; matches behavior observed in the mobile-app's local-transport experiments. |
|
||||
| §8 KISS / HDLC framing | High — both work in production on the reference clients |
|
||||
| §9.1–§9.8 Implementation gotchas | Each was a real bug that bit a real implementation. High confidence each is real; some lack formal test scripts. |
|
||||
| §10 Test vectors | The vectors themselves are verified; the test-vectors/ directory needs to be populated in this repo (currently empty placeholder). |
|
||||
| §11 Source map | High |
|
||||
| §10 Resource fragmentation | Source-cited from `RNS/Resource.py` against RNS 1.2.0; not yet runtime-verified in this repo's `tools/`. |
|
||||
| §11 Test vectors | The vectors themselves are verified; the test-vectors/ directory needs to be populated in this repo (currently partially populated). |
|
||||
| §12 Source map | High |
|
||||
|
||||
**Concrete next-task list** for the agent picking this up:
|
||||
|
||||
|
|
|
|||
|
|
@ -12,12 +12,13 @@ The two views are complementary: SPEC.md tells you what each piece looks like; t
|
|||
| [`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) | ✅ |
|
||||
| [`send-resource.md`](send-resource.md) (Resource fragmentation over a Link) | ✅ |
|
||||
| `receive-resource.md` (inverse of send-resource: ADV ingestion, part assembly, proof emission) | ⏳ |
|
||||
| `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) | ⏳ |
|
||||
| `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
|
||||
|
||||
|
|
|
|||
207
flows/send-resource.md
Normal file
207
flows/send-resource.md
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
# Flow: send a Resource (large body) over a Link
|
||||
|
||||
What happens chronologically when an LXMF DIRECT message, NomadNet page, or `rncp` file transfer too big to fit in one Link DATA packet is sent as an `RNS.Resource`. Builds on top of an established Reticulum Link — a Link must already be `ACTIVE` before this flow starts (see [`send-link-lxmf.md`](send-link-lxmf.md) steps 3-4 for how the Link gets there).
|
||||
|
||||
Pinned against **RNS 1.2.0**. Wire-level details are in [`../SPEC.md`](../SPEC.md) §10; this document covers chronology and step ordering.
|
||||
|
||||
Out of scope: the receive side (`receive-resource.md` — TODO), Resource cancellation paths beyond a brief mention in step 9, and the watchdog / RTT estimation machinery (implementation-private).
|
||||
|
||||
---
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The sender holds an `RNS.Link` to the recipient with `link.status == ACTIVE`. The Link's session key (§3.1 Link-derived form) is what encrypts the Resource body.
|
||||
- The body to send is a `bytes` object (small payloads), or a file-like with a `.read()` method (large or streaming payloads — the constructor will spool to a temp file if needed).
|
||||
- The Link's MTU is stable for the duration of the transfer. Resource part sizes are baked in at advertisement time; if the Link's MTU shrinks mid-transfer, the resource is cancelled rather than re-fragmented.
|
||||
|
||||
---
|
||||
|
||||
## Sequence
|
||||
|
||||
### 1. Caller constructs the `RNS.Resource`
|
||||
|
||||
For LXMF DIRECT/RESOURCE, this happens in `LXMessage.__as_resource` (`LXMF/LXMessage.py:651`):
|
||||
|
||||
```python
|
||||
RNS.Resource(self.packed, self.__delivery_destination,
|
||||
callback=self.__resource_concluded,
|
||||
progress_callback=self.__update_transfer_progress,
|
||||
auto_compress=self.auto_compress)
|
||||
```
|
||||
|
||||
For other callers (NomadNet page server, `rncp`) the call site differs but the constructor signature is the same.
|
||||
|
||||
### 2. `Resource.__init__` runs the preparation pipeline
|
||||
|
||||
`RNS/Resource.py:248-478`. In order:
|
||||
|
||||
1. **Metadata prefix** if `metadata=` was given: prepend `length(3 bytes BE uint24) || msgpack(metadata)` to the body. Sets `has_metadata = True`, `x` flag in the advertisement.
|
||||
2. **Choose data backing.** If `data` is `bytes` and `len(data) + metadata_size > MAX_EFFICIENT_SIZE = 1 MiB - 1`, the constructor spools it to a `tempfile.TemporaryFile()` and switches to file-backed mode. Bytes-backed mode is single-segment; file-backed mode may be multi-segment per [`../SPEC.md`](../SPEC.md) §10.11.
|
||||
3. **Optional bz2 compression.** If `auto_compress` and the body fits within `auto_compress_limit` (default 64 MiB): `bz2.compress(uncompressed)`. Keep the smaller of the two; set the `c` flag accordingly.
|
||||
4. **Random hash prefix.** Prepend 4 random bytes (`Resource.RANDOM_HASH_SIZE`) to whatever body was selected.
|
||||
5. **Compute hashes.**
|
||||
- `hash = SHA256(data || random_hash)` (note the random_hash appears at both the start of `data` and at the end of the SHA-256 input — this is intentional; the same random_hash bytes serve as both a per-resource salt for the wire and as a tail to break SHA-256 length-extension correlations between similar resources).
|
||||
- `truncated_hash = hash[:16]`
|
||||
- `expected_proof = SHA256(data || hash)` — what the receiver will return as the second 32 bytes of the RESOURCE_PRF body.
|
||||
6. **Link encryption.** `data = link.encrypt(data)` — wraps the whole `random_hash || maybe_compressed_body` in the Link-derived Token form (§3.1: `iv(16) || ciphertext || hmac(32)`, no ephemeral_pub prefix). Sets `e = True`. The encryption is over the **whole** payload at once, not per-part — see SPEC.md §10.12.
|
||||
7. **Part split.** Slice the encrypted blob into parts of `SDU = link.mtu - HEADER_MAXSIZE - IFAC_MIN_SIZE` bytes each. Each part is built into a `RNS.Packet(link, part_data, context=RESOURCE)` and pre-packed; the wire bytes live in `parts[i].raw`.
|
||||
8. **Hashmap.** For each part, `map_hash = get_map_hash(part_data)` (4 bytes). The `hashmap` is the concatenation of all 4-byte map_hashes.
|
||||
9. **Collision guard.** Walk the hashmap with a sliding `COLLISION_GUARD_SIZE = 2 * WINDOW_MAX + HASHMAP_MAX_LEN` window. If any duplicate map_hash appears within that window, **regenerate `random_hash`, recompute `hash` / `expected_proof`, re-pack every part, and rebuild the hashmap.** Loop until clean. This is what guarantees the receiver can search a windowed slice of the hashmap to disambiguate inbound parts (SPEC.md §10.6).
|
||||
|
||||
After step 9, `total_parts = len(parts)`, `total_segments = ceil((data_size + metadata_size) / MAX_EFFICIENT_SIZE)`, `total_size = data_size + metadata_size`, and the resource is ready to advertise.
|
||||
|
||||
### 3. `Resource.advertise()` sends RESOURCE_ADV
|
||||
|
||||
`RNS/Resource.py:508-541`. Runs in a daemon thread because the build above can take a noticeable time on a large resource.
|
||||
|
||||
```python
|
||||
adv_packet = RNS.Packet(self.link,
|
||||
ResourceAdvertisement(self).pack(),
|
||||
context=RNS.Packet.RESOURCE_ADV)
|
||||
adv_packet.send()
|
||||
self.status = Resource.ADVERTISED
|
||||
self.adv_sent = time.time()
|
||||
```
|
||||
|
||||
`ResourceAdvertisement(self).pack()` returns msgpack bytes of the dict in SPEC.md §10.4 — the `m` field carries up to `HASHMAP_MAX_LEN ≈ ⌊(LINK_MDU - 134)/4⌋` map_hashes (i.e. enough for a moderate transfer; multi-segment hashmaps continue via RESOURCE_HMU in step 7 below).
|
||||
|
||||
The advertisement packet is sent as a regular Link DATA packet (so it is Token-encrypted by the Link's session key) and gets a Reticulum-level PROOF receipt back per SPEC.md §6.5.
|
||||
|
||||
### 4. Watchdog: retry advertisement up to MAX_ADV_RETRIES
|
||||
|
||||
`RNS/Resource.py:573-590`. While `status == ADVERTISED` and no RESOURCE_REQ has arrived, the watchdog retransmits the advertisement up to `MAX_ADV_RETRIES = 4` times. After the 4th retry without a request, the resource is cancelled with status `FAILED` and a callback to the caller.
|
||||
|
||||
Reasons the advertisement might not solicit a response:
|
||||
- The receiver's app rejected it via `Resource.reject(adv_packet)` (returns `RESOURCE_RCL`).
|
||||
- The Link's RTT is so long that the watchdog's `PROOF_TIMEOUT_FACTOR = 3` window expires before any reply.
|
||||
- The Link torn down between advertisement and first request.
|
||||
|
||||
### 5. First RESOURCE_REQ arrives → fulfillment loop
|
||||
|
||||
`RNS/Resource.py:985-1064`. The receiver has parsed the advertisement, accepted it, and now requests an initial window's worth of parts via a RESOURCE_REQ packet:
|
||||
|
||||
```
|
||||
body = hashmap_exhausted_flag(1) [|| last_map_hash(4) if exhausted]
|
||||
|| resource_hash(32)
|
||||
|| requested_map_hashes(N × 4 bytes)
|
||||
```
|
||||
|
||||
Per SPEC.md §10.5. The initiator's `request(request_data)` handler:
|
||||
|
||||
1. Checks `request_data[0]` — exhausted flag.
|
||||
2. Pad past the optional `last_map_hash` and the `resource_hash` to extract the variable-length tail of requested map_hashes.
|
||||
3. **Search scope:** look only inside `parts[receiver_min_consecutive_height : receiver_min_consecutive_height + COLLISION_GUARD_SIZE]`. This is what makes the collision guard meaningful — the search range is bounded so the per-window uniqueness guarantee from step 2.9 is sufficient.
|
||||
4. For each requested map_hash, find the matching part and call `part.send()` (or `part.resend()` if it was sent previously). Each fulfilled part is a single Link DATA packet with `context = RESOURCE (0x01)`.
|
||||
5. Update `self.last_part_sent` and `self.last_activity` for the watchdog.
|
||||
|
||||
Each part packet is itself a Link DATA packet, so it gets a Reticulum-level PROOF receipt back from the receiver per SPEC.md §6.5 — meaning every part involves two round-trips: one for the part itself and one for its Reticulum-level proof.
|
||||
|
||||
### 6. Receiver requests next window
|
||||
|
||||
After the receiver has placed all the requested parts into its `parts[]` array, it sends another RESOURCE_REQ for the next window. The window may have grown (`window += 1` per successful round, capped at `window_max` per SPEC.md §10.10). This continues until the receiver has consumed every map_hash in the current hashmap segment.
|
||||
|
||||
### 7. RESOURCE_HMU — feeding the next hashmap segment
|
||||
|
||||
If the resource has more parts than fit in the original advertisement's `m` field (`n > HASHMAP_MAX_LEN`), the receiver eventually exhausts the known hashmap. It then sends a RESOURCE_REQ with `exhausted = 0xFF` and `last_map_hash = ` the last known map_hash.
|
||||
|
||||
The initiator's `request()` handler at `RNS/Resource.py:1030-1064`:
|
||||
|
||||
1. Locate the `last_map_hash` in `self.hashmap`. The position must land on a `HASHMAP_MAX_LEN` boundary; if not, treat it as a sequencing error and cancel the resource.
|
||||
2. Compute `segment = part_index // HASHMAP_MAX_LEN`.
|
||||
3. Slice the next hashmap segment: `hashmap[segment*HASHMAP_MAX_LEN : (segment+1)*HASHMAP_MAX_LEN]`.
|
||||
4. Pack as `body = resource_hash(32) || msgpack([segment, hashmap_segment_bytes])`.
|
||||
5. Send as `RNS.Packet(link, body, context=RESOURCE_HMU)`.
|
||||
6. Receiver appends the segment to its known hashmap and resumes requesting parts.
|
||||
|
||||
This "lazy hashmap delivery" is what keeps the Resource format usable on small-MTU links — a 100k-part resource has a 400 KiB hashmap, which couldn't possibly fit in one ADV.
|
||||
|
||||
### 8. RESOURCE_PRF — final proof
|
||||
|
||||
After the receiver has assembled all parts (`received_count == total_parts`), it runs `assemble()` and on a successful hash check sends back:
|
||||
|
||||
```
|
||||
body = resource_hash(32) || full_proof(32)
|
||||
```
|
||||
|
||||
as `RNS.Packet(link, proof_data, packet_type=PROOF, context=RESOURCE_PRF)` — a PROOF-type packet, not DATA.
|
||||
|
||||
The initiator's `validate_proof(proof_data)` (`RNS/Resource.py:785-829`):
|
||||
|
||||
1. Checks `len(proof_data) == 64` and `proof_data[32:] == self.expected_proof`.
|
||||
2. On match, transitions `status = COMPLETE` and fires the resource callback.
|
||||
3. If this is a multi-segment resource and `segment_index < total_segments`, **immediately advertise the next segment** — its preparation has been running in the background since step 6 of segment N (`__prepare_next_segment` at line 768).
|
||||
|
||||
If the proof is malformed or doesn't match, the resource is **NOT** retried — `validate_proof` simply returns and the watchdog will eventually time out and call `cancel()`.
|
||||
|
||||
### 9. Cancellation paths
|
||||
|
||||
Either side can cancel at any point with body = `resource_hash(32)`:
|
||||
|
||||
- **`RESOURCE_ICL (0x06)`** — initiator cancel. Causes: `MAX_RETRIES = 16` consecutive request rounds with no progress; Link torn down; explicit `Resource.cancel()` call.
|
||||
- **`RESOURCE_RCL (0x07)`** — receiver cancel. Causes: app callback rejected the advertisement (`Resource.reject(adv_packet)`); `Resource.cancel()` from receiver side.
|
||||
|
||||
Both transition `status = FAILED` and notify `link.resource_concluded(self)` so the Link can free its tracking entry.
|
||||
|
||||
### 10. Watchdog and recovery
|
||||
|
||||
`RNS/Resource.py:564-642`. The Resource owns a watchdog thread that runs through the lifecycle and adjusts timeouts based on observed link RTT. Key points for interop:
|
||||
|
||||
- **Per-part timeout:** `PART_TIMEOUT_FACTOR = 4` × (link RTT) before any part has arrived; drops to `PART_TIMEOUT_FACTOR_AFTER_RTT = 2` once RTT is calibrated.
|
||||
- **Proof timeout:** `PROOF_TIMEOUT_FACTOR = 3` × link RTT after all parts have been sent.
|
||||
- **HMU wait factor:** `HMU_WAIT_FACTOR = 3.5` × link RTT after sending RESOURCE_HMU before assuming it was lost.
|
||||
- **Sender grace:** `SENDER_GRACE_TIME = 10s` — extra wait at end-of-transfer before declaring failure if some parts haven't been requested.
|
||||
|
||||
These are not negotiated — both sides use their own constants. Two implementations with different watchdog tuning interop fine, but may diverge in how aggressively they cancel a struggling transfer.
|
||||
|
||||
---
|
||||
|
||||
## Wire-byte summary
|
||||
|
||||
Three round-trips compose a Resource's lifetime on the wire (single-segment, hashmap-fits-in-ADV case):
|
||||
|
||||
```
|
||||
Initiator Receiver
|
||||
1. RESOURCE_ADV (DATA, ctx=0x02) ──────────────────────────►
|
||||
(msgpack dict, hashmap fragment,
|
||||
encrypted via Link Token) (parses ADV, accepts)
|
||||
|
||||
PROOF (LINKPROOF, ctx=0xFD) ◄────────────────────────── (Reticulum-level
|
||||
ack of ADV)
|
||||
|
||||
2. RESOURCE_REQ (DATA, ctx=0x03) ◄────────────────────────── (asks for parts)
|
||||
(exhausted_flag || resource_hash
|
||||
|| requested_map_hashes)
|
||||
|
||||
PROOF (LINKPROOF, ctx=0xFD) ──────────────────────────►
|
||||
|
||||
3. RESOURCE × N (DATA, ctx=0x01) ──────────────────────────► (N = window size)
|
||||
(one part each, encrypted via
|
||||
Link Token)
|
||||
|
||||
PROOF × N (LINKPROOF, ctx=0xFD) ◄──────────────────────────
|
||||
|
||||
... loop steps 2-3 until all parts delivered ...
|
||||
|
||||
4. RESOURCE_PRF (PROOF, ctx=0x05) ◄────────────────────────── (resource complete)
|
||||
(resource_hash || full_proof)
|
||||
```
|
||||
|
||||
Multi-segment resources insert a fresh RESOURCE_ADV after step 4 for each new segment. Multi-hashmap-segment resources insert a `RESOURCE_REQ` with `exhausted=0xFF` followed by a `RESOURCE_HMU` reply between steps 2 and 3 whenever the receiver runs out of hashmap.
|
||||
|
||||
---
|
||||
|
||||
## Source map
|
||||
|
||||
| Step | File | Function / line |
|
||||
|---|---|---|
|
||||
| 1 | `LXMF/LXMessage.py` | `__as_resource`, line 651 (LXMF caller) |
|
||||
| 2 | `RNS/Resource.py` | `Resource.__init__`, line 248-478 |
|
||||
| 3 | `RNS/Resource.py` | `Resource.advertise`, line 508; `__advertise_job`, line 520 |
|
||||
| 3 | `RNS/Resource.py` | `ResourceAdvertisement.pack`, line 1336 |
|
||||
| 4 | `RNS/Resource.py` | watchdog ADVERTISED branch, line 573-590 |
|
||||
| 5 | `RNS/Resource.py` | `request`, line 985-1064 |
|
||||
| 6 | `RNS/Resource.py` | `receive_part` window grow, line 902-906 |
|
||||
| 7 | `RNS/Resource.py` | hashmap update emit, line 1030-1064 |
|
||||
| 8 | `RNS/Resource.py` | `validate_proof`, line 785-829 |
|
||||
| 9 | `RNS/Resource.py` | `Resource.reject`, line 155-163; `Resource.cancel` (search) |
|
||||
| 10 | `RNS/Resource.py` | watchdog timeouts, line 126-137 |
|
||||
24
todo.md
24
todo.md
|
|
@ -122,14 +122,22 @@ re-research.
|
|||
(`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
|
||||
packet. Without this you can't send or receive long messages,
|
||||
attachments, or NomadNet pages > 1 MTU. `flows/send-link-lxmf.md`
|
||||
currently flags this as a known gap. Authoritative source:
|
||||
`RNS/Resource.py` (block sizes, sequence numbers, resource-proof
|
||||
message). Cross-flow: `LXMF/LXMessage.py::__as_resource` line 651.
|
||||
- [x] **SPEC.md §10 / `flows/send-resource.md`: Reticulum Resource
|
||||
fragmentation.** Done. SPEC.md §10 covers the wire-level MUST
|
||||
rules: 13 sub-sections from "when Resource runs" through wire
|
||||
contexts (ADV / REQ / RESOURCE / HMU / PRF / ICL / RCL),
|
||||
hashmap collision-guard, sliding window, multi-segment cutover
|
||||
at MAX_EFFICIENT_SIZE = 1 MiB - 1, and the encryption-then-split
|
||||
layering. `flows/send-resource.md` walks the chronology in 10
|
||||
steps with a wire-byte ladder diagram. Side fixes during the
|
||||
drafting: SPEC.md §2.5 contexts table now lists ALL upstream
|
||||
contexts (was missing all RESOURCE_*, REQUEST/RESPONSE,
|
||||
COMMAND, CHANNEL, LINKIDENTIFY, LINKCLOSE, LRRTT entries) and
|
||||
corrects KEEPALIVE from 0xFD (which is actually LINKPROOF) to
|
||||
0xFA per `RNS/Packet.py:87`. SPEC.md §6.5 wording updated to
|
||||
use the correct LINKPROOF context name. The previously-existing
|
||||
§10 "Test vectors" and §11 "Source map" were renumbered to §11
|
||||
and §12 to put §10 in the protocol-stack flow.
|
||||
- [ ] **SPEC.md §6.5 expansion: regular (non-LRPROOF) PROOF body.** The
|
||||
mandatory PROOF receipt for every CTX_NONE Link DATA packet. Body
|
||||
is `packet_hash(32) || signature(64)` (`RNS/Link.py::prove_packet`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue