Find a file
Rob 1e375e52ea
§10/§11 NomadNet + Resource pipeline corrections (supersedes #2) (#3)
* §11/§10: NomadNet conventions + REQUEST/RESPONSE security clarifications

Expands the NomadNet-specific conventions documented in the spec
based on bytes-on-the-wire findings from a clean-room Kotlin port.
Three motivating bug classes from upstream interop:

1. Element [2] of the REQUEST envelope. Pre-clarification text said
   "application-defined bytes (often msgpack itself, or None)" which
   reads as "you can pre-msgpack-encode a dict and pass the bytes."
   Doing so produces a wire envelope where decode yields a `bytes`
   object for [2], not the dict the upstream `Node.py:109` /
   `LXMRouter.__get_handler` `isinstance(data, dict)` /
   `isinstance(data, list)` checks expect. Result: silent no-op
   on every form submission and every propagation /get round.
   §11.1 now spells out that the whole envelope is msgpacked once
   with `data` as a native msgpack value, with a worked example.

2. RESPONSE element [0] verification. The spec already documented
   request_id correlation but didn't flag it as a MUST for security.
   Without the check, a misbehaving / compromised transit relay can
   replay a stale RESPONSE from a prior request and the initiator
   accepts it as the answer to whatever's currently pending. Latent
   today on implementations that drive one in-flight request per
   link, but a real footgun the moment they add link reuse,
   partials, or pipelining. §11.2 now calls this out as a security
   requirement.

3. Resource size cap (§10.4). Today implementations pre-allocate
   buffers from `t` / `d` and have no cap on bz2 decompression
   output. A small (~tens of KB) compressed payload can legitimately
   expand to gigabytes. The HASHMAP_MAX_LEN chunk-count limit
   bounds raw on-wire chunks but does NOT bound post-decompression.
   §10.4 now recommends a per-application cap and notes that
   decompressors MUST also abort if the running output total
   exceeds the cap (defense in depth — a sender that lies about
   `d` would otherwise bypass the parse-time check).

Substantially expands §11.6 NomadNet specifics from a 4-bullet
informational paragraph to eight sub-sections covering:

- §11.6.1 Paths and the `nomadnetwork.node` aspect.
- §11.6.2 Form-data dict shape: `field_<name>` (widget values) and
  `var_<name>` (URL-query-style link parameters), both mapped to
  env vars by `Node.py:109-111`. Includes checkbox semantics
  (omit-unchecked, comma-join multi-select).
- §11.6.3 Link target syntax: same-node `/path`, cross-node
  `<32hex>:/path`, bare-hash default, `nnn@`/`lxmf@` shorthands
  with the `expand_shorthands` table. Notes hash-hex case
  normalization and rejection of separator-laden variants.
- §11.6.4 Page-level header conventions: `#!c=N` cache-TTL,
  `#!bg=` / `#!fg=` colors.
- §11.6.5 File downloads via `/file/...` returning
  `(file_handle, metadata_dict)`.
- §11.6.6 ALLOW_ALL vs ALLOW_LIST + the LINKIDENTIFY (0xFB)
  precondition for ALLOW_LIST pages, plus a privacy note that
  identify on every link pins long-term identity to the page
  operator.
- §11.6.7 Partial pages (server-side includes via `` `{path} ``).
- §11.6.8 Source map of NomadNet ↔ wire concept references.

All citations are to upstream `markqvist/NomadNet` master fetched
2026-05-04; the spec text is informational (not normative) since
the wire layer is §11 itself. The expansion is for clean-room
implementers who'd otherwise need to read several thousand lines
of `Browser.py` + `Node.py` to know what wire shape a NomadNet
server expects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* §10/§11.1/§11.2: Resource pipeline + request_id corrections

Found by sideloading a clean-room Kotlin port (`thatSFguy/reticulum-mobile-app`)
on a phone and watching multi-packet NomadNet pages fail to load.
Each correction below was the difference between "spec implementer
got it wrong silently" and "page actually loads."

§11.1 — request_id formula precision:
  Prior text "16-byte truncated hash of `packed_request`" reads
  ambiguously. Several implementations (including the v0.1.54
  build of the Kotlin port) hashed the inner plaintext bytes —
  formula matches nothing the server sent, every RESPONSE drops.
  Upstream `Link.handle_request:1286` is `packet.getTruncatedHash()`
  i.e. SHA-256 of the on-the-wire packet hashable_part:
  `(raw[0] & 0x0F) || raw[2:]` (HEADER_1) / `... || raw[18:]` (HEADER_2).
  For Resource REQUESTs, the request_id IS plaintext-derived
  (carried in adv.q, set by initiator in `Resource.__init__:478`)
  because there's no single packet to hash. Updated text spells
  out both forms explicitly.

§11.2 — security note matched to corrected formula:
  Same fix in the implementer-gotcha box. Was telling clean-room
  ports to compute the wrong thing.

§10.6 — chunks are not individually encrypted:
  Per §10.2 step 4 the entire `random_hash || data` blob is link-
  encrypted ONCE, then sliced at step 6. Each wire chunk is just
  `outerToken[i*sdu : (i+1)*sdu]` with no per-chunk Token header.
  Receivers MUST hand chunks to the hashmap match without per-
  chunk decrypt. Spec text "parts are link-encrypted" reads
  ambiguously enough that decrypting per-chunk feels reasonable —
  added an explicit callout with the upstream slice loop and a
  warning that per-chunk `link.decrypt(chunk)` will HMAC-fail on
  every packet.

§10.8 — random_hash prefix is stripped, NOT compared to adv.r:
  Sender at `Resource.py:567` uses
  `RNS.Identity.get_random_hash()[:4]` for the prefix — a fresh
  random call, deliberately distinct from `self.random_hash`
  (the value `r` carries). A receiver that does
  `assert prefix == adv.r` rejects every legitimate Resource
  as corrupt. Step 3 of the assemble flow now says "strip and
  discard"; integrity is proven exclusively by step 5's
  `SHA256(data || r) == h`.

§11.6 (NomadNet specifics) and §10.4 (Resource size cap) carried
over from the closed PR #2 unchanged — those parts of #2 were
correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:35:31 -04:00
flows Add four more verifiers + receive-propagated flow + frontmatter version 2026-05-03 12:54:34 -04:00
test-vectors Verify §2.3, §4.3, §7.1, §7.4 against upstream RNS 1.2.0 / LXMF 0.9.6 2026-05-03 10:14:51 -04:00
tools Resolve issue #1 — five §7.2/§7.3 gaps from clean-room JS implementation 2026-05-03 20:38:01 -04:00
agent.md Resolve issue #1 — five §7.2/§7.3 gaps from clean-room JS implementation 2026-05-03 20:38:01 -04:00
LICENSE Initial bootstrap: README, LICENSE, SPEC.md, agent.md, scaffolding 2026-05-03 09:38:46 -04:00
README.md Add flows/ directory with opportunistic-LXMF send sequence 2026-05-03 10:15:03 -04:00
SPEC.md §10/§11 NomadNet + Resource pipeline corrections (supersedes #2) (#3) 2026-05-04 20:35:31 -04:00
todo.md Add tools/verify_stamps.py — runtime-lock §5.7 2026-05-03 15:13:59 -04:00

Reticulum Specifications

Byte-level interoperability specifications for the Reticulum Network Stack and LXMF — the parts that aren't in the upstream manuals but are needed to build a working client from scratch.

Upstream Reticulum has excellent operator-facing documentation (config, deployment, design philosophy). What's missing — and what every alternative implementation has had to reverse-engineer from the Python source — is an authoritative wire-level spec: header bit layouts, msgpack field types, signature input formats, the exact behavior of Transport.outbound, and the long list of "would never guess from reading the manual" gotchas that cost hours of debugging each.

This repo collects those findings in one place. The hope is that future client authors (Kotlin, Swift, Rust, Go, embedded C — pick your stack) can read this instead of re-deriving everything from RNS/Transport.py.

Status

Early days, contributions welcome. Current content was bootstrapped from the working notes of two reverse-engineering efforts:

Each finding is grounded in upstream source citations (file + line) so it can be re-verified as RNS evolves.

What's here

  • SPEC.md — the single combined spec document, organized by protocol layer
  • flows/ — chronological end-to-end narratives (e.g. "send a message"), cross-referencing SPEC.md sections
  • tools/ — self-contained Python verifier scripts that test SPEC.md claims against upstream RNS / LXMF
  • test-vectors/ — known-good byte sequences each implementation should be able to round-trip (intent: grow into a compliance suite)

As content grows, SPEC.md will be split into per-layer files (packet header, identity, announce, token-crypto, LXMF, link, resource, transport).

Scope

In scope:

  • Wire formats: byte layouts, field encodings, framing
  • Signing inputs and what's hashed where
  • Cross-cutting behaviors required for interop (path requests, ratchet rotation, retransmit semantics)
  • "Gotchas" — things upstream code does that aren't obvious from the manual or RFC-style sketches
  • Test vectors that any implementation must be able to round-trip

Out of scope:

  • Operator/user documentation — see the official manual
  • API design choices for any specific implementation
  • Networking layer config (interfaces, transport modes) — already well documented

Source citations

Where a finding cites upstream Python code, the path is relative to a standard pip install rns lxmf installation, e.g. RNS/Transport.py, LXMF/LXMF.py. Where the bundled umsgpack is referenced, the path is RNS/vendor/umsgpack.py.

When upstream code changes such that a citation no longer matches, file an issue or PR — the goal is to track the de-facto wire spec as it actually behaves, not as it was at any single snapshot.

Contributing

If you've debugged a Reticulum interop problem and the answer wasn't in the upstream docs, please add it. Format:

### N.M Short description of the finding

**Symptom:** what you observed that prompted the investigation.

**What's happening:** the actual mechanism, ideally with upstream source citation (file + line).

**Implication / fix:** what an implementation must do to interop.

**Source:** upstream file paths and approximate line numbers.

Add a worked test vector to test-vectors/ if the finding is byte-level.

License

CC BY 4.0 — use freely, attribution appreciated.