Commit graph

40 commits

Author SHA1 Message Date
Rob
8c4d5506ed Address feedback issue #4 — clarify header_type/IFAC, canonical msgpack, ratchet-less interop, re-announce cadence
Per issue #4 (interop feedback from JS webclient implementer):

- §2.1: header_type is a 2-bit field, values 2/3 reserved, bit 7 not
  shared with IFAC. IFAC is a trailing field, not a flag bit.
- §5.6.1 (new): name umsgpack as canonical encoder for signing inputs;
  table of per-type encoding rules (str/bin/int width/float64).
- §7.3.5 (new): ratchet-less announces (context_flag=0) are accepted
  by every RNS 1.x receiver; trade-off is forward-secrecy only.
- §9.7: concrete RECOMMENDED re-announce ranges by deployment type;
  AVOID <60s and >30min thresholds with rationale.

Items #3 (§8.3 truncated) and #4 (§10.1 truncated) in the issue are
based on a stale fetch — both sections are fully present.
Items #2 (re-announce interval) and #7 (clockless senders) were already
covered (§7.5/§9.7 and §9.6 respectively); reply on the issue points
the reporter at them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:05:07 -04:00
Rob
0576f04c36 todo: mark microReticulum random_hash issue filed (#48)
Filed as attermann/microReticulum#48 on 2026-05-04.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:16:40 -04:00
Rob
68afe192e5 SPEC.md: collapsible ToC + collapse §11.6 NomadNet specifics
Adds a per-section table of contents at the top of the doc, wrapped
in <details> so it's collapsed by default (the spec body is visible
as soon as the file opens; click "Contents" to navigate). Every H2
and H3 heading is linked, including the unnumbered §14 failure-mode
categories.

Also wraps §11.6 (NomadNet specifics) in <details> — it's already
flagged "informational, not normative" and is the longest H3 sub-tree
in the document. Readers implementing only the §11 wire layer can
skim past it; readers implementing a NomadNet client one click away.

tools/_gen_toc.py regenerates the ToC by re-extracting headings.
Run it after adding/removing/renaming any H2 or H3.

Picked this over the per-layer-file split previously listed in todo
because the split would have broken ~37 cross-references (flow docs,
verifier docstrings, agent.md, README) for marginal reader benefit
at the document's current size (~3300 lines).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:05:17 -04:00
Rob
038e39401f Bootstrap test-vectors/{announces,lxmf,links}.json + regenerators
Three deterministic vector files complete the test-vectors/ bootstrap.
Each regenerator pins every random source so output is byte-identical
across runs against a fixed upstream RNS / LXMF version.

- announces.json: two vectors (no-ratchet + with-ratchet) signed by
  Alice. Determinism via patched Identity.get_random_hash + module-
  local time.time shim inside RNS.Destination.

- lxmf.json: two opportunistic-LXMF vectors Alice -> Bob, captures
  full plaintext (S5.2 layout) plus Token-encrypted ciphertext (S3).
  Determinism via fixed LXMessage.timestamp, ephemeral X25519 priv,
  and Token CBC IV.

- links.json: full Link handshake — LINKREQUEST + LRPROOF wire bytes,
  derived link_id, ECDH shared secret, and HKDF-derived session key
  that both initiator and responder MUST agree on. Determinism via
  three queued ephemeral priv-key blobs (initiator X25519, initiator
  Ed25519, responder X25519) consumed in source-call order at
  RNS/Link.py:285, :286, :278.

Status table in test-vectors/README.md and tools/README.md updated to
reflect the completed bootstrap. todo.md cleaned up to reflect actual
state (the previous "Open ⚠️ items needing a runtime verifier" section
was stale — all three verifiers were completed earlier).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:56:44 -04:00
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
Rob
61bfc03413 Resolve issue #1 — five §7.2/§7.3 gaps from clean-room JS implementation
Reporter implemented §7.2.6 minimum-leaf path-request responder + §7.3
ratchet rotation in thatSFguy/reticulum-lora-webclient and surfaced
five small gaps. Each is fixed below; the first is a real spec
correction backed by a new runtime verifier.

#### 1. §7.3 dedup-mechanism claim was wrong (verified)

Earlier §7.3 claimed transit nodes dedup on '(destination_hash,
ratchet_pub)' tuples. Reporter pointed out this can't be right:
upstream's RATCHET_INTERVAL = 30 min × ANNOUNCE_INTERVAL = 5-15 min
means most upstream announces share a ratchet across 2-6 emissions.
If relays really dropped on ratchet_pub equality, upstream wouldn't
function.

Confirmed by new tools/verify_ratchet_dedup.py: builds two announces
with same ratchet_pub but distinct random_hash[:5], walks the
upstream replay-defence machinery (Transport.py:1707,1732,1745
'not random_blob in random_blobs' check) by hand. Both announces
ACCEPTED — dedup is keyed on random_blob, not on ratchet_pub.

§7.3 rewritten:
  - Drops the wrong dedup claim with an explicit ⚠️ Spec correction
    callout naming the bug.
  - Reframes ratchet rotation as forward-secrecy hygiene, not a
    mesh-visibility requirement.
  - Points at §4.5 step 6.3 / §4.1 for the actual replay-defence
    mechanism.
  - Documents upstream's at-most-every-30-min rotation cadence
    (rotate_ratchets is a no-op if RATCHET_INTERVAL hasn't elapsed).
  - Says clean-room MAY rotate per-announce or follow upstream's
    cadence — either is interop-correct.

#### 2. Path-response ratchet rotation guidance — §7.3.4 (new)

Added explicit guidance: path-response announces SHOULD reuse the
current ratchet rather than rotate. Burst-rotating on identical-target
path? requests would burn ratchet-ring slots without forward-secrecy
benefit. Upstream's no-op-if-recent gate enforces this implicitly.

#### 3. Leaf dedup-table size — §7.2.6 step 4

Added: 'A leaf-appropriate cap is 128–256 entries with FIFO eviction;
the upstream max_pr_tags = 32000 is sized for a transit node.'

#### 4. PR_TAG_WINDOW body cache for leaves — §7.2.6 trailing

Added: 'Leaves may skip the §7.2.5 PR_TAG_WINDOW body cache' with
explanation that step 4's dedup table already collapses identical-tag
retransmits and a leaf isn't fanning to multiple downstream relays.

#### 5. PLAIN destination recipe link — §7.2.1

Added: 'The path-request destination is a PLAIN destination ... per
the PLAIN/GROUP recipe in §1.4.3 (the identity == None branch).'
Surfaces the connection that's currently buried in §1.4 titled 'GROUP
destinations' but actually covers PLAIN too.

agent.md §5 audit table updated — §7.3 entry corrected to note the
prior 'verified' claim was actually mis-attributed; the test result
came from incidental random_hash rotation, not ratchet rotation.

13 of 13 verifiers in tools/ now pass.

Closes #1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:38:01 -04:00
Rob
366825c7a0 Add §17 implementation taxonomy: who needs which sections
Surfaced from real interop testing: a clean-room implementation
hit the §2.3 HEADER_1->HEADER_2 conversion bug, while category-1
clients (Sideband, MeshChat, NomadNet) couldn't have hit it
because they inherit upstream Python RNS's Transport.outbound
which does the conversion automatically.

The spec previously left this implicit. Without explicit guidance,
a reader could over-engineer (re-implementing things their
category-1 platform already handles) or under-engineer (assuming
'Sideband works, so my clean-room implementation works the same
way' when in fact Sideband works *because* upstream RNS handles
§2.3).

Four sub-sections:

  §17.1  The three categories of Reticulum app:
           Cat 1: Upstream-RNS-based (import RNS) — Sideband,
                  NomadNet, MeshChat, rncp, rnsh, rnstatus
           Cat 2: Wrappers / language bindings via FFI or
                  subprocess to upstream
           Cat 3: Clean-room implementations — microReticulum,
                  the Faketec repeater, anything from-scratch in
                  C++/Rust/JS/Kotlin/Swift

  §17.2  Section-relevance table by category. Wire formats are
         reference for cat 1/2, must-implement for cat 3.
         Behavioural guidance (§7, §12, §13, §14, §15, §16) is
         critical for cat 3, mostly informational for cat 1/2.

  §17.3  §2.3 worked example. Cat 1/2: don't write §2.3 code —
         upstream's Transport.outbound at line 1074-1083 does
         it automatically. Cat 3: implement it yourself or your
         packets get silently dropped by transit relays per
         line 1497 (which only forwards HEADER_2 with matching
         transport_id).

  §17.4  Pragmatic implication: a quick .claude/settings.local.json:      "Bash(python -c \"import RNS, LXMF; print\\('RNS:', RNS.__version__\\); print\\('LXMF:', LXMF.__version__\\)\")",
SPEC.md:| **1: Upstream-RNS-based** | Python application that does `import RNS` and uses upstream's `Reticulum` / `Transport` / `Identity` / `Destination` / `Packet` / `Link` directly. Inherits all wire-level behavior from upstream. | Sideband (Mark Qvist's flagship), NomadNet, [`liamcottle/reticulum-meshchat`](https://github.com/liamcottle/reticulum-meshchat), `rncp`, `rnsh`, `rnstatus`, anything in `pip show rns` example code |
SPEC.md:If you're not sure which category you're in: `grep -r "import RNS" your_codebase` is a quick check. Any hit means cat 1 (or cat 2 if it's behind an FFI wall). No hits means cat 3.
tools/regen_identities.py:import RNS
tools/verify_announce_app_data.py:import RNS
tools/verify_announce_app_data.py:import RNS.vendor.umsgpack as umsgpack
tools/verify_announce_roundtrip.py:import RNS
tools/verify_destination_hash.py:import RNS
tools/verify_link_handshake.py:import RNS
tools/verify_lxmf_opportunistic.py:import RNS
tools/verify_msgpack_quirk.py:import RNS
tools/verify_msgpack_quirk.py:import RNS.vendor.umsgpack as umsgpack
tools/verify_packet_header.py:import RNS
tools/verify_path_request.py:import RNS
tools/verify_proof_packet.py:import RNS
tools/verify_stamps.py:import RNS
tools/verify_token_crypto.py:import RNS
         tells you which category you're in. cat 1/2 readers can
         skip the implementation-depth sections; cat 3 readers
         need everything plus the verifiers as a regression suite.

Test vectors moves to §18, Source map to §19.

The provisional understanding is that this categorisation is
correct; if real-world testing reveals a category boundary that
doesn't hold the way described, the section gets revised or
removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:02:59 -04:00
Rob
512ae058e7 Add tools/verify_stamps.py — runtime-lock §5.7
12th verifier in the suite. Locks in the LXMF stamps and tickets
spec (§5.7) by exercising LXMF.LXMStamper directly:

  - Workblock construction: deterministic for a given material;
    confirms exactly 768 KiB at the WORKBLOCK_EXPAND_ROUNDS = 3000
    default (matches spec §5.7.2 documented size).
  - PoW search-and-validate: brute-force search at target_cost = 4
    bits (fast — usually 1-16 iterations). Confirms stamp_valid and
    stamp_value round-trip; tampers a byte and confirms rejection.
  - LXMessage.validate_stamp end-to-end: accepts a valid PoW stamp
    on a synthesized LXMessage; rejects a tampered one.
  - Ticket shortcut: builds stamp = SHA256(ticket || message_id)
    by hand, confirms validate_stamp(target_cost, tickets=[...])
    accepts with the matching ticket and rejects with a wrong one.

target_cost = 4 keeps the test fast; real interop uses 8-16 bits.
The verifier doesn't claim to be a stamp-cost benchmark — that's a
separate use case.

12 of 12 verifiers in tools/ now pass against RNS 1.2.0 / LXMF 0.9.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:13:59 -04:00
Rob
d6a83a525f Add §16 bounded-state inventory (dev-experience #6)
Single-table reference for every memory-bounded structure across
§1-§15, organised by scope: per-node, per-interface, per-destination,
per-Link, per-Resource, identity caches, LXMF, Channel.

Eight sub-tables cover ~30 named structures with their caps and
pointers to the explanatory section. Notable entries:

  - path_table, link_table, reverse_table, tunnels — unbounded;
    drained by TTL eviction in Transport.jobs
  - MAX_RANDOM_BLOBS = 32 (per-destination replay defence)
  - max_pr_tags = 32000 (path-request dedup)
  - hashlist_maxsize = 1,000,000 (packet dedup ring; half-purged
    on overflow)
  - MAX_HELD_ANNOUNCES = 256 per interface
  - RATCHET_COUNT = 512 per destination
  - WINDOW_MAX_FAST = 75 per Resource
  - known_destinations — UNBOUNDED in upstream; the main growth
    vector for embedded clients to manage explicitly

Closes with §16.9 'What this means for embedded targets' — explicit
guidance for ~64KB-RAM class clients (Faketec, RAK4631 stock) on
what to bound (known_destinations to 50-200 entries), what to
reject (Resource ADVs whose advertised n exceeds memory budget),
what to skip (transport-mode operation entirely), and what to
constrain (Resource WINDOW_MAX to SLOW=10 not FAST=75). Notes
that desktop rnsd typically settles around 50-200 MB.

Source map renumbered to §18; Test vectors stays at §17.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:12:23 -04:00
Rob
cf30d9b7b3 Add §8.6 AutoInterface multicast discovery (dev-experience #5)
LAN auto-detect protocol — drop a Reticulum node on any IPv6-capable
network, configure AutoInterface, peers find each other with zero
manual config.

Seven sub-sections:

  §8.6.1  IPv6 multicast group derivation: address built from
          SHA256(group_id) with multicast_address_type (4 bits,
          permanent/temporary) and discovery_scope (4 bits, link/
          admin/site/org/global). Default group_id = 'reticulum'.

  §8.6.2  UDP ports: 29716 discovery (multicast announces),
          29717 unicast probe (interface disambiguation),
          42671 data (Reticulum packets after peering).

  §8.6.3  Cadence: ANNOUNCE_INTERVAL = 1.6s, PEERING_TIMEOUT = 22s,
          PEER_JOB_INTERVAL = 4s, MCAST_ECHO_TIMEOUT = 6.5s.

  §8.6.4  Discovery announce body — msgpack with group_hash +
          MTU + optional IFAC seal. Peers from different groups
          on the same link don't accidentally peer.

  §8.6.5  Post-discovery: plain UDP datagrams on the data port,
          one full Reticulum packet per datagram, HW_MTU = 1196
          (Ethernet-MTU-friendly).

  §8.6.6  IFAC integration — peers with mismatched IFAC keys see
          each other's discovery but can't decode each other's
          data.

  §8.6.7  Source map.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:09:09 -04:00
Rob
7703cee748 Add §6.8 Channel mode (dev-experience #4)
Channel mode (CHANNEL = 0x0E context) is a continuous, bi-directional,
message-typed stream on top of an established Link. Distinct from
§11 REQUEST/RESPONSE (single-shot, client-server) and §10 Resources
(large unidirectional). NomadNet uses it for the live-channel API
beyond simple page fetches.

Six sub-sections:

  §6.8.1  Wire form: 6-byte big-endian fixed-prefix header
          (msgtype(2) || sequence(2) || length(2)) followed by
          payload, Token-encrypted by the link session key.

  §6.8.2  Reserved system types: SMT_STREAM_DATA = 0xff00.
          Application-defined types stay in 0x0000..0xfeff.

  §6.8.3  MSGTYPE registration: both endpoints must register
          matching message classes via register_message_type
          before sending/receiving that type.

  §6.8.4  Reliable delivery via §6.5 PROOF + sliding window with
          the same window-growth dynamics as §10 Resources.

  §6.8.5  Decision matrix: REQUEST/RESPONSE for one-shot RPC,
          Resources for one-shot large transfers, Channel for
          continuous bi-directional streams.

  §6.8.6  Source map across Channel.py.

Old §6.8 Source moved to §6.9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:07:24 -04:00
Rob
f13985846c Add §15 time/clock requirements roundup (dev-experience #3)
Consolidates time-sensitive behaviour scattered across §4.1, §5.3,
§5.7.3, §5.8.5, §6.7.1, §7.1, §7.2, §7.5, §9.6, §10, §13.4 into
one reference for what kind of clock you need where.

Seven sub-sections:

  §15.1  Three clock kinds (wall time, boot-relative monotonic,
         hi-res monotonic). Embedded clean-rooms must be careful
         which call site needs which.

  §15.2  Required: monotonic seconds. Seven specific use sites
         that break a single-clock implementation if missing.
         All can be satisfied by boot-relative seconds.

  §15.3  random_hash timestamp encoding strategy for no-RTC
         devices: emit boot-relative seconds (look stale, lose
         path-replace ties — that's correct). Don't emit fully-
         random bytes (the §9.10 microReticulum bug — locks you
         in as 'latest' forever).

  §15.4  Wall-time-required (LXMF body timestamp, ticket expiry,
         propagation timebase). Tickets can't substitute —
         no-RTC devices must use PoW stamps instead.

  §15.5  Optional hi-res monotonic for diagnostics.

  §15.6  Explicit fails-vs-works inventory for a no-RTC,
         no-NTP-sync device. Net: opportunistic LXMF, propagated
         LXMF retrieval, and Links all work; only ticket-based
         shortcuts fail. A one-time clock sync flips most of the
         ⚠️ items to .

  §15.7  Source map across all sections that touch time.

Test vectors stays at §16; Source map renumbered to §17.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:05:47 -04:00
Rob
2be4830231 Add §14 failure-mode cheatsheet (dev-experience #2)
Symptom-keyed inverse index of §9. Eight tables organised by
problem domain — Identity/announce, Token crypto / opportunistic
LXMF, Link establishment / proofs, Resource transfers, Path
discovery, Transport / framing, LXMF specifics, Concurrency —
each mapping observable symptoms to root-cause sections and
relevant tools/verify_*.py scripts.

Closes Tier 2 #16 of the dev-experience todo. Section now serves
as the fault-finding entry point for new implementers: 'I see
symptom X' -> table row -> direct link to §N.M with full
explanation -> verifier that locks it in.

Worked-example entries for the high-cost interop bugs we caught
during the spec sweep (§1.3 byte order, §6.2 LRPROOF body,
§9.10 microReticulum random_hash, §6.5 implicit/explicit proof
length-dispatch, etc) so future readers get the diagnosis instantly.

Test vectors and Source map renumbered to §15 and §16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:03:05 -04:00
Rob
70a24060b5 Add §13 threading/concurrency model (dev-experience #1)
The wire spec is silent on threading, but a clean-room client built
single-threaded mostly works for opportunistic LXMF and starts
breaking on Resource transfers and Link keepalives. This is the #1
cause of 'my client compiles and almost works but is flaky'.

Five sub-sections:

  §13.1  Long-running threads — Transport.jobloop (every 250ms,
         runs all maintenance), count_traffic_loop (every 1s
         bandwidth snapshots), per-Link Link.__watchdog_job
         (RTT-driven keepalive emission and STALE→CLOSED
         transitions), per-Resource Resource.__watchdog_job
         (retransmit timeouts), announce-handler callbacks fire
         on FRESH daemon threads per inbound announce, per-interface
         RX thread, process_announce_queue chained one-shot timers.

  §13.2  Lock inventory — 18 named Transport / Identity / Link /
         Resource / Destination locks. jobs_lock is the most
         aggressive: held for the entire jobs() body so parallel
         job invocations can't pile up.

  §13.3  Callback-thread guarantees: packet/link/receipt callbacks
         all run synchronously on the receive thread; only
         announce-handler callbacks run on fresh threads. Critical
         design implications:
           - Don't block the receive thread (queue-and-return).
           - Announce handlers race; lock shared state.
           - link_closed can fire from two paths (watchdog OR peer
             LINKCLOSE); make idempotent.

  §13.4  Implementation-private timing constants —
         job_interval = 250ms, links_check_interval = 1s,
         tables_cull_interval = 5s, hashlist_maxsize = 1M,
         WATCHDOG_MAX_SLEEP, PROCESSING_GRACE, SENDER_GRACE_TIME,
         etc. Don't scale below 100ms job_interval.

  §13.5  Source map.

Test vectors and Source map renumbered to §14 and §15. Other
section numbers unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:59:24 -04:00
Rob
e575da7d17 todo: add developer-experience gaps for follow-on work
Six items not strictly wire-format but high-value for clean-room
implementers, in priority order:

  Top three (most debugging-hour savings):
    §15 Threading / concurrency model — which loops run when,
        which callbacks fire on which thread, lock inventory
    §16 Failure-mode -> root-cause cheatsheet — symptom-keyed
        inverse index of §9 with worked examples
    §17 Time / clock requirements roundup — consolidates random_hash
        timestamps, LXMF clockless senders, ticket expiry, keepalive
        RTT, re-announce cadence into one no-RTC reference

  Medium:
    §6.x Channel mode (CHANNEL = 0x0E) — multiplexed app data over
        Link, used by NomadNet beyond page fetches
    §8.x AutoInterface multicast discovery — UDP magic for LAN
        peer auto-detect

  Appendix:
    Bounded-state inventory — single table of every memory-bounded
        structure for embedded implementers

Plus:
  - Marked the 'last-verified-against-rns' polish item done
    (already added to SPEC.md frontmatter in commit abf66b9).
  - Added a tools/verify_stamps.py todo to runtime-lock §5.7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:35:05 -04:00
Rob
abf66b9cef Add four more verifiers + receive-propagated flow + frontmatter version
Verifiers:
  tools/verify_proof_packet.py — locks in §6.5. Toggles
    Reticulum.__use_implicit_proof to test both modes; confirms
    Identity.prove emits 64B (implicit) or 96B (explicit) proof
    body; PacketReceipt.validate_proof accepts both lengths and
    rejects an 80B body.
  tools/verify_link_handshake.py — locks in §6.1, §6.2, §6.3, §6.6.
    Most importantly verifies the previously-corrected §6.2 LRPROOF
    body order (signature(64) || responder_X25519_pub(32) ||
    [signalling]) and §6.3 link_id offsets (N=2 for HEADER_1) by
    actually building a Link initiator-side, capturing the
    LINKREQUEST raw bytes, computing link_id by the spec recipe,
    running validate_request inline (since the upstream wrapper
    swallows exceptions), and confirming the responder's LRPROOF
    bytes match the spec layout. This was the single most
    interop-critical correction we made.
  tools/verify_rnode_split.py — locks in §8.3. Pure-function
    re-implementation of the canonical TX and RX state machines
    from RNode_Firmware.ino:359-446 + 716-742; tests header-byte
    layout, single-frame TX, split-frame TX (300B → 254+46 with
    shared header byte), all four RX state-machine cases (a/b/c/d
    from the spec table), and end-to-end TX/RX round-trip at
    sizes 50, 254, 255, 300, 508.
  tools/verify_msgpack_quirk.py — locks in §9.3. Confirms umsgpack
    distinguishes str (fixstr/0xa5) from bytes (bin8/0xc4); confirms
    LXMF.display_name_from_app_data parses bytes-encoded display
    names correctly and silently returns None (not crash) on
    str-encoded ones, matching the bug-tolerance documented in §9.3.

All 11 verifiers pass against RNS 1.2.0 / LXMF 0.9.6.

Plus:
  - SPEC.md frontmatter: 'Last verified against' line per agent.md §7.
  - flows/receive-propagated-lxmf.md: closing half of the propagated
    LXMF lifecycle. /get listing query, fetch query, ack-and-purge
    via the have_ids slot, message-bundle unpack and dispatch
    through lxmf_delivery.
  - tools/README.md status table refreshed; flows/README.md flips
    receive-propagated-lxmf.md to .

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:54:34 -04:00
Rob
75169b0631 Add three high-value verifiers: token crypto, announce, LXMF opportunistic
tools/verify_token_crypto.py — locks in §3:
    - Opportunistic Token encrypt/decrypt round-trip with full
      ephemeral_pub(32) || iv(16) || aes(...) || hmac(32) layout check.
    - HKDF salt = recipient.identity_hash verified by re-deriving
      the key by hand and confirming decrypt succeeds.
    - Link-derived Token form (no eph_pub prefix) round-trip.
    - HMAC-then-AES order proven by tampering each region: HMAC
      mismatch raises before AES decrypt.
    - PKCS#7 padding boundaries (1B and 16B plaintexts).

  tools/verify_announce_roundtrip.py — locks in §4 + §4.5:
    - Build via upstream Destination.announce(send=False).
    - Body layout walk with context_flag branching for the optional
      ratchet slot.
    - signed_data reconstruction per §4.2 with empty-bytes-not-absent
      ratchet rule.
    - dest_hash recompute per §1.2.
    - random_hash[5:10] is a recent unix_seconds timestamp per §4.1
      (corrected — confirms upstream emits the timestamp half).
    - Upstream validate_announce accepts.
    - Tamper detection: bit-flips in signature, public_key, name_hash,
      random_hash, app_data are all rejected.

  tools/verify_lxmf_opportunistic.py — locks in §5.1, §5.2, §5.5, §5.6
  plus §3 layered correctly:
    - Two identities (Alice, Bob) with mutual discovery.
    - LXMessage build with title, content, fields.
    - Body layout: dest(16) || src(16) || sig(64) || msgpack.
    - Opportunistic-form strip of leading dest_hash before encryption.
    - Encrypt to Bob via Token, decrypt as Bob, byte-identical
      round-trip.
    - Re-prepend dest_hash and run unpack_from_bytes; confirms
      signature_validated=True and title/content/fields preserved.

All three pass against RNS 1.2.0 / LXMF 0.9.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:41:20 -04:00
Rob
282d5d59eb Add five companion flow docs
- flows/receive-resource.md: inverse of send-resource. ADV
    ingestion, accept/reject decision, request_next loop,
    receive_part insertion, assemble + decrypt + hash-validate,
    RESOURCE_PRF emission, multi-segment continuation.

  - flows/receive-link-lxmf.md: responder side of the link
    handshake plus inbound LXMF DATA handling. validate_request
    -> handshake -> prove (LRPROOF emission) -> link_established
    callback wires delivery_packet. PACKET-form inbound runs
    delivery_packet directly; RESOURCE-form inbound runs through
    delivery_resource_advertised + delivery_resource_concluded
    pipeline.

  - flows/send-announce.md: random_hash construction (5B random +
    5B BE-uint40 timestamp), optional ratchet rotation, signed_data
    assembly, sign + pack, the broadcast emission. Notes that
    ANNOUNCE packets are NOT encrypted (Packet.pack special-cases
    line 189-191) and the periodic re-announce loop drives 5-15min
    cadence.

  - flows/forward-announce.md: relay-side rebroadcast for
    transport-mode nodes. Eligibility checks (transport_enabled,
    not PATH_RESPONSE, not rate_blocked), announce_table queue,
    Transport.jobs drain with PATH_REQUEST_GRACE = 0.4s,
    per-interface announce_queue with ANNOUNCE_CAP = 2.0% airtime
    enforcement, lowest-hop-count-first emission order, hops byte
    increment, local-rebroadcast counter for loop break.

  - flows/send-propagated-lxmf.md: PROPAGATED method end to end.
    LXMessage.pack with body encrypted to recipient (propagation
    node never decrypts), Link establishment to the propagation
    node, optional propagation stamp (1000 PoW rounds vs 3000 for
    regular stamps), submission via Link DATA or Resource,
    state goes to SENT (not DELIVERED — recipient pulls via /get
    later per §5.8.3).

flows/README.md status table updated; receive-propagated-lxmf.md
added as the only remaining  flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:21:05 -04:00
Rob
ee5ba48802 Add §12 transport-relay behaviour (Tier 3 — TIER 3 COMPLETE)
Closes Tier 3 in a single consolidated section because all five items
share state (path_table, announce_table, link_table, reverse_table,
tunnels) and are emergent behaviours of the same Transport.inbound
dispatch logic.

Seven sub-sections:

  §12.1  transport_enabled toggle — leaf clients populate path_table
         only for destinations they personally need; transport-mode
         nodes populate it for everything they hear about.

  §12.2  DATA forwarding rules — three-case branch on remaining_hops
         (>1 forward as HEADER_2 with new transport_id; ==1 strip
         transport_id and forward as HEADER_1 broadcast; ==0 local).
         LINKREQUEST forwarding extras (link_table entry + §6.6 MTU
         clamp). Non-LINKREQUEST gets a reverse_table entry.

  §12.3  ANNOUNCE rebroadcasting — announce_table retransmit queue,
         per-interface ANNOUNCE_CAP airtime budget, announce_queue
         drain order (lowest-hop-count first), random_blob replay
         defence with MAX_RANDOM_BLOBS sliding window, and the
         PATH_RESPONSE short-circuit (path-responses go on a
         specific interface, not broadcast).

  §12.4  Path table management — entry shape (IDX_PT_* indexes),
         three TTLs by interface mode (AP_PATH_TIME 1h, ROAMING_PATH_TIME
         4h, PATHFINDER_E 30 days), stale-paths eviction, persistence
         to storagepath/paths.

  §12.5  Reverse-table link transport — LRPROOF forwarding via
         link_table validation against the destination's known
         long-term Ed25519 pub, Link DATA forwarding once
         link_table[IDX_LT_VALIDATED] is set, PROOF receipt
         forwarding via reverse_table (one-shot pop on use,
         REVERSE_TIMEOUT bound for memory).

  §12.6  Tunnels and shared-instance protocol — discovery_path_requests
         recursive search (15s timeout), tunnels[] persistence across
         interface flap, shared-instance protocol (regular Reticulum
         packets over TCP loopback; the 'sharing' is Transport state,
         not wire format).

  §12.7  Source map.

Old §12 Test vectors -> §13; old §13 Source map -> §14. Section
order preserves protocol content before appendices.

TIER 3 COMPLETE. All Tier 1, 2, and 3 spec gaps closed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:16:26 -04:00
Rob
5f67277b62 Add §5.8 propagation node protocol (Tier 2 #8 — TIER 2 COMPLETE)
Closes Tier 2. Six sub-sections covering store-and-forward LXMF:

  §5.8.1  The lxmf.propagation destination, well-known name_hash
          e03a09b77ac21b22258e, four registered request handlers
          (/offer, /get, /stats, /sync) all reached via §11
          REQUEST/RESPONSE protocol on an active Link.

  §5.8.2  Peer-to-peer sync via /offer:
            data = [peering_key(32), [transient_id_1, ...]]
          Three response shapes: False (peer has all), True (peer
          wants all), [list] (peer wants subset). Wanted messages
          are bundled into a Resource carrying the full encrypted
          LXMF bodies — propagation nodes never decrypt.

  §5.8.3  Client retrieval via /get:
            data = [wanted_ids, have_ids, optional_limit_kb]
          Listing query (both None), fetch query (wanted_ids set),
          purge query (have_ids set). The propagation node only
          returns messages keyed to the requester's
          destination_hash — structural defense against
          mis-routing.

  §5.8.4  Peering keys: PoW with 25 rounds of workblock expansion
          (~6 KiB), amortized once per peering relationship.
          peering_id = self_identity_hash || remote_identity_hash.

  §5.8.5  Propagation node announce app_data: distinct 7-element
          msgpack array (vs §4.3's 2-element form for lxmf.delivery).
          Element [5] is a 3-list of [stamp_cost,
          stamp_cost_flexibility, peering_cost] — most common
          interop break is misparsing as a single integer.

  §5.8.6  Source map across LXMRouter, LXMPeer, LXStamper, LXMF.

Old §5.8 'Source' renamed to §5.9.

Tier 2 complete: 8 of 8 done. Moving to Tier 3 (transport-relay
specs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:12:21 -04:00
Rob
c5fe9c13de Add §11 REQUEST/RESPONSE protocol (Tier 2 #7)
NomadNet pages aren't a separate wire format — they ride on a generic
Reticulum REQUEST/RESPONSE protocol that's also used by LXMF
propagation /get and any custom RPC. Spec covers:

  §11.1  REQUEST wire form. Single Link DATA packet (ctx=0x09)
         carrying msgpack [timestamp, path_hash(16), data] when
         it fits in link.mdu, or a Resource transfer with
         is_response=False otherwise.

  §11.2  RESPONSE wire form. Single Link DATA packet (ctx=0x0A)
         carrying msgpack [request_id(16), response] when it fits,
         or a Resource transfer with is_response=True. File-handle
         responses ride through the §10 Resource pipeline with
         optional metadata.

  §11.3  Path hash collision avoidance — paths hashed to 16 bytes
         (2^128 collision space, negligible in practice). The path
         string itself is not on the wire.

  §11.4  Authorization modes: ALLOW_NONE / ALLOW_LIST / ALLOW_ALL.
         ALLOW_LIST requires the requester to have called
         link.identify() first (LINKIDENTIFY ctx=0xFB).

  §11.5  RequestReceipt callback machinery on the initiator side.

  §11.6  NomadNet conventions (informational): paths like
         /page/foo.mu, msgpack form-field request data, file-handle
         responses for downloads. None of this is wire-spec.

Old §11 Test vectors -> §12; old §12 Source map -> §13. Sections
renumbered to keep protocol content before the appendix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:08:30 -04:00
Rob
e47e32cf8c Add §5.7 LXMF stamps + tickets (Tier 2 #6)
Five sub-sections covering the proof-of-work-or-ticket spam control
mechanism. Wire form (32B optional 5th element of msgpack body),
stamp generation algorithm (3000 rounds of HKDF expand → 768 KiB
workblock → SHA256 search for target_cost leading zeros), tickets
(16B pre-shared shortcut: stamp = SHA256(ticket || message_id)[:32]),
the FIELD_TICKET = 0x0C exchange format ([expires, ticket_bytes]),
stamp_cost field in announce app_data (§4.3) and the receiver-side
_enforce_stamps drop policy.

Minimum interop: implement PoW for outbound (so peers with
stamp_cost set will accept your messages), tolerate-but-not-validate
inbound (your peers won't refuse to talk to you for not enforcing
their own anti-spam). Full ticket support is a Tier-3 nice-to-have.

Old §5.7 'Source' moved to §5.8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:05:25 -04:00
Rob
d27f01946e Add §1.4 GROUP destinations (Tier 2 #4)
GROUP destinations use a pre-shared 64-byte symmetric key (32B
signing + 32B encryption split) with the same Token wire format as
Link-derived encryption — iv(16) || aes_ciphertext || hmac(32), no
eph_pub prefix. dest_hash recipe matches SINGLE with identity
optional. Spec covers key gen via Token.generate_key, wire form,
dest_hash variants, on-disk format (raw bytes, no header), and a
why-rarely-used note (no forward secrecy, key distribution
unsolved at the protocol layer).

Most LXMF interop clients can ignore GROUPs entirely but should
still recognize the 0x01 type byte to gracefully reject inbound
packets they can't decrypt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:03:31 -04:00
Rob
7931eb1d8c Add §8.4 RNode KISS handshake + §8.5 airtime caps (Tier 2 #3+#5)
§8.4: full bring-up sequence for a host driving an RNode over KISS.
Five sub-sections — command-byte inventory (28 commands cited from
RNode_Firmware/Framing.h:24-95), 12-step bring-up recipe, the
CMD_DETECT/DETECT_REQ(0x73)/DETECT_RESP(0x46) handshake with
asynchronous CMD_FW_VERSION/CMD_PLATFORM/CMD_MCU replies,
4-byte big-endian numerics for FREQUENCY/BANDWIDTH (subject to KISS
escape), and the RX sidecar format (RSSI = byte − 157,
SNR = signed Q6.2 / 4 dB).

§8.5: airtime caps via CMD_ST_ALOCK (0x0B) and CMD_LT_ALOCK (0x0C),
encoded as 2-byte big-endian uint16 of (limit_percent × 100).
Reticulum.ANNOUNCE_CAP = 2.0 is the upstream default. Pre-TX
carrier-sense is firmware-private — host-side clients driving an
RNode don't implement their own LBT, but native LoRa clients need
the airtime_bins algorithm from RNode_Firmware.ino:683-712.

Closes Tier 2 #3 and #5 (CSMA/airtime). Tier 2: 4 of 8 done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:01:52 -04:00
Rob
22ee7636ef Add §6.7 KEEPALIVE / link teardown (Tier 2 #1+#2)
Documents the link control plane that's required for any client
that wants links to survive idle periods. Five sub-sections:

  §6.7.1  KEEPALIVE wire form: context = 0xFA, initiator-originated
          0xFF ping body → responder 0xFE pong reply body, both
          Token-encrypted by the link session key. Cadence formula
          RTT × (KEEPALIVE_MAX/KEEPALIVE_MAX_RTT) = RTT × 205.7,
          clamped to [5s, 360s]. Initial value is 360s before RTT
          is measured by validate_proof.

  §6.7.2  STALE → CLOSED transition. Watchdog moves link to STALE
          when last_inbound + 2*keepalive elapses, then on next
          watchdog pass emits LINKCLOSE and goes to CLOSED.
          teardown_reason = TIMEOUT.

  §6.7.3  LINKCLOSE wire form: context = 0xFC, body = 16-byte
          link_id Token-encrypted. Receiver MUST verify
          plaintext == link_id before accepting the close. After
          accepting, link.shared_key/derived_key zeroed for forward
          secrecy.

  §6.7.4  Teardown reason codes: TIMEOUT(0x01), INITIATOR_CLOSED
          (0x02), DESTINATION_CLOSED(0x03). Local-state values, not
          on the wire.

  §6.7.5  Six-step minimum-receiver-responsibility recipe.

Also marks Tier 2 implicit/explicit proof item done — already
covered as part of §6.5's Tier 1 #3 expansion.

Old §6.7 "Source" renumbered to §6.8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:59:13 -04:00
Rob
537b1e8182 Fix and expand §1.3 — on-disk identity format (real spec bug!)
Closes Tier 1 #6 and the entire Tier 1 sweep. Previous §1.3 said the
on-disk byte order was Ed25519_priv(32) || X25519_priv(32) ("opposite
of the public_key concatenation"). That was WRONG.

Verified empirically against RNS 1.2.0 by round-tripping the existing
test vectors through Identity.to_file and reading the bytes back:

    disk = X25519_priv(32) || Ed25519_priv(32)    # same as public_key

This matches Identity.get_private_key() at RNS/Identity.py:694-698:
   return self.prv_bytes + self.sig_prv_bytes
where prv_bytes is X25519 (line 679) and sig_prv_bytes is Ed25519
(line 682). It also matches load_private_key at line 706-717.

Implementations following the prior spec wording would have written
identity files that fail to load on upstream RNS — a real interop
break that would have been very hard to debug because the failure is
in keypair-loading, before any signature operation runs.

§1.3 rewritten and expanded:

  - Correct byte order with citation to upstream code.
  - 64-byte raw-blob format with explicit "no header / no version /
    no checksum / no encryption".
  - File-system facts: no chmod, expected to live in OS-protected
    storage, filename is caller-controlled.
  - from_bytes HAZARD note: feeding raw random bytes skips the
    `cryptography` library's keypair-generation invariants
    (X25519 RFC 7748 §5 scalar clamping etc).
  - Cross-implementation portability follows automatically because
    there's nothing in the file but the bytes.
  - ⚠️ Spec correction callout warning future readers about the
    previous wording so the bug history is on record.

tools/verify_destination_hash.py extended with a §1.3 to_file /
from_file round-trip section. For each test vector it now:
  - writes the identity via to_file
  - asserts the on-disk file is exactly 64 bytes
  - asserts disk[:32] hex == expected x25519_priv_hex
  - asserts disk[32:64] hex == expected ed25519_priv_hex
  - reloads via from_file and asserts identity_hash invariance

This is what would have caught the bug if it had been there from the
start. tools/README.md updated to reflect §1.3 coverage.

Cumulative Tier 1 status: 6 of 6 done. A from-scratch client built
from §1-§9 + §10 + §11 + flows/ can now interop with upstream
Reticulum / LXMF / RNode for identity, announce, opportunistic LXMF
DATA, Resource fragmentation, regular PROOF receipts, link
handshakes with MTU/mode signalling, path-? discovery, and
KISS/HDLC/RNode-air-frame framing. Tiers 2 and 3 remain open in the
todo for follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:54:54 -04:00
Rob
0bf03d924d Expand §7.2 + add path-discovery flow
Closes Tier 1 #5. The previous §7.2 was four bullet points naming the
"answer with an announce" rule but missing every wire detail —
implementation-time the SF mobile client got steps 4 (dedup) and 5
(local-destination check) wrong on its first cut and the bug only
surfaced as "I can message my own destination but no one else can
reply".

§7.2 is now six sub-sections:

  §7.2.1  Path-request packet parse rules. The handler's slice
          recipe with branching on payload length (32B = leaf form
          target||tag; 48B+ = transport form target||transport_id||
          tag); tag cap at 16B; tagless-request rejection.

  §7.2.2  Tag-based dedup via Transport.discovery_pr_tags. The
          unique_tag = dest_hash || tag construction, the 32000-
          entry cap, why missing this turns a leaf into a broadcast-
          storm amplifier on retransmits.

  §7.2.3  The five-way dispatch in Transport.path_request:
          local-destination / transit-knows-path / local-client-
          forward / discovery-recursive / drop. Branches 1 and 5
          are the only ones a leaf needs.

  §7.2.4  Path-response announce wire format. Body byte-identical
          to a regular announce (§4.1); only the outer packet
          context byte differs (NONE → PATH_RESPONSE 0x0B).
          PR_TAG_WINDOW=30s body-cache that serves identical wire
          bytes to racing relays so transit dedup converges.

  §7.2.5  Timing constants: PATH_REQUEST_GRACE = 0.4s, +
          PATH_REQUEST_RG = 1.5s for roaming-mode interfaces.
          Local-destination and local-client originator branches
          bypass the grace.

  §7.2.6  Minimum responsibility for a non-transport leaf — the
          six-step protocol-level recipe.

flows/path-discovery.md: 9-step chronology covering both
single-hop leaf-owns-target and two-hop transit-relay-knows-path
cases. Wire-byte ladder diagrams for both. Notes the ingress-limit
bypass for path-responses (Transport.py:1632-1639), the
receive_path_responses opt-in for handler dispatch
(Transport.py:1989-1991), and the timeout/escalation path through
LXMRouter.process_outbound's MAX_PATHLESS_TRIES retry counter.

flows/README.md status table updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:50:10 -04:00
Rob
dc0a1438e6 Add §6.6 for the 3-byte MTU/mode signalling field
Closes Tier 1 #4. Without this, a clean-room Link implementation that
either always emits the signalling slot or always omits it will fail
handshakes against the opposite-config peer because the LRPROOF
signed_data either includes or excludes the 3 bytes — and the
signature verifies against exactly one of those forms.

§6.6 covers six sub-sections:

  §6.6.1  Wire layout. 24-bit big-endian packed value: top 3 bits of
          byte 0 = mode, low 21 bits = mtu. Citations to encoder at
          RNS/Link.py:147-151 and decoders at :154+, :171+.

  §6.6.2  Mode field. 3 bits, values 0..7. Currently only
          MODE_AES256_CBC = 0x01 is in ENABLED_MODES; six others are
          reserved (AES-128, AES-256-GCM, OTP, four PQ slots).
          Sender-side signalling_bytes() raises on disabled modes;
          receiver-side mode_from_lr_packet returns the raw integer
          without validation. handshake() at line 353 enforces.

  §6.6.3  MTU field. 21 bits, max 2,097,151. Forward-looking width;
          real interfaces are way smaller. Initiator emits its
          next-hop HW_MTU; responder clamps to min(its-view,
          requested) by rewriting the LINKREQUEST data buffer in
          place at RNS/Transport.py:2042-2051 BEFORE
          Destination.receive runs, so the eventual LRPROOF carries
          the clamped value. The clamp also leaves link_id invariant
          because §6.3's hashable_part strips trailing signalling.

  §6.6.4  Presence detection — purely by body length. Lengths 64 vs
          67 for LINKREQUEST, 96 vs 99 for LRPROOF. No flag bit.

  §6.6.5  Signed_data inclusion rule (the interop break) — the LRPROOF
          signs over the signalling bytes when present. A peer that
          omits them when present (or includes them when absent)
          gets a signed_data mismatch and the link never establishes.

  §6.6.6  link_mtu_discovery = No config option. Disables emit on
          the initiator side; receivers don't need a parallel switch
          (length-dispatch handles it).

§6.1 and §6.2 inline references updated to point at §6.6 for the bit
layout instead of the previous "[signalling(3)]" placeholder. The
existing §6.6 "Source" entry renumbered to §6.7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:36:51 -04:00
Rob
fa014d21e6 Document microReticulum random_hash interop bug (§4.1 callout + §9.10)
Real interop bug found while checking what the
thatSFguy/reticulum-lora-repeater stack does with the random_hash
field. The repeater is a thin wrapper around attermann/microReticulum,
which emits 10 fully-random bytes for random_hash rather than the
upstream Python form of 5 random bytes + 5 bytes of big-endian uint40
unix_seconds. The Python form is preserved as a comment in
microReticulum src/Destination.cpp:270-272, with a "CBA TODO add in
time to random hash" next to the random-only implementation.

Effect: Python RNS receivers parse random_hash[5:10] as an emission
timestamp via Transport.timebase_from_random_blob (RNS/Transport.py:
3100-3101), and use it for path-table replacement decisions in the
equal-or-greater-hop branch (RNS/Transport.py:1721-1745). A
uniformly-random uint40 has median ~5.5e11 ≈ year 19403 AD, so
microReticulum announces look "far-future" to Python receivers and
permanently win replay-ordering comparisons until the path TTL
expires.

First-contact path-table population is unaffected — the bug only
surfaces on path replacement, which makes it a quiet failure mode
in mixed-vendor meshes (microReticulum repeater + Python rnsd).

Symmetry: microReticulum receivers don't consult the timestamp half,
so microReticulum-to-microReticulum traffic is unaffected. The
asymmetry is what makes the symptom show up only when a Python
relay is also in the mesh.

The repeater's pre_build.py aggressively patches FIVE other
microReticulum protocol bugs (ratchet announce parsing, identity
hash length 16→32, validate_announce/announce diagnostics, DATA/
PROOF forwarding for transport-mode, path-table write dedup) — but
not this one. Filed as an outreach todo to upstream the fix to
attermann/microReticulum.

  SPEC.md §4.1   — adds an UNVERIFIED callout naming the deviation,
                   citing the exact source location and explaining
                   the propagation path through Python's path-table
                   logic.
  SPEC.md §9.10  — gotcha entry making the bug findable from the
                   gotchas list, with a suggested clean-room
                   workaround (emit the timestamp half yourself,
                   even just seconds-since-boot).
  todo.md        — outreach entry to file an issue on
                   attermann/microReticulum proposing the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:27:03 -04:00
Rob
ab66e4040f Expand §6.5 with full PROOF body wire spec (explicit vs implicit)
Closes Tier 1 #3. The previous §6.5 was one paragraph that named
"a PROOF packet" without specifying its body shape, signing input,
or explicit/implicit choice — exactly the level of vagueness that
caused the SF mobile client to ship the wrong proof shape on its
first cut.

New §6.5 has six sub-sections:

  §6.5.1  Two body formats:
            explicit = packet_hash(32) || signature(64) = 96B
            implicit =                    signature(64) = 64B
          Distinguished purely by length at the receiver per
          PacketReceipt.validate_proof (RNS/Packet.py:497-548).

  §6.5.2  Sender-side policy. Opportunistic DATA proofs default to
          the IMPLICIT form (Reticulum.__use_implicit_proof = True
          at RNS/Reticulum.py:259), only switching to explicit when
          the operator's config sets use_implicit_proof = No. Link
          DATA proofs are hardcoded explicit on both emit
          (Link.prove_packet at RNS/Link.py:383-394) and validate
          (validate_link_proof at RNS/Packet.py:449-494, with the
          implicit branch commented out).

  §6.5.3  Where the proof is addressed:
          opportunistic -> packet_hash[:16] as a synthetic
                           ProofDestination
          link          -> link.link_id

  §6.5.4  Wire summary with byte-position ladders for both forms.

  §6.5.5  Receiver tolerance: validators MUST accept both 64- and
          96-byte bodies for opportunistic DATA proofs since the
          upstream default differs from what most non-RNS clients
          assume.

  §6.5.6  Restates the Link-DATA mandatory-receipt rule with
          context-byte clarification.

Side fix: §2.5 contexts table description for LINKPROOF (0xFD)
corrected. The constant is defined upstream but NOT actually emitted
by either Identity.prove or Link.prove_packet — both build their
proof packets with packet_type = PROOF and context = NONE (0x00).
LINKPROOF (0xFD) is reserved but unused in RNS 1.2.0; the proof-ness
of a packet is conveyed by packet_type, not context.

todo.md gets a new "tools/verify_proof_packet.py" entry under the
runtime-verifier section to lock the explicit/implicit dispatch in
with a runtime test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:18:56 -04:00
Rob
95823ad840 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>
2026-05-03 11:08:40 -04:00
Rob
a1ec6ce7fd 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
Rob
c18cff533c todo: spec gaps for a functional client, tiered
Captures the full Tier 1/2/3 list of missing protocol specification
needed for a from-scratch client to interoperate with upstream
Reticulum / LXMF / RNode-firmware.

Each item carries the source-citation hooks I gathered while answering
the question, so whoever picks the work up doesn't have to re-research
where the upstream code lives. Highlights:

  Tier 1 (barebones interop): receive-announce flow + §4.5 validation
  rules, Resource fragmentation §12, regular PROOF body §6.5 expansion,
  3-byte MTU/mode signalling field §6, path-response context 0x0B
  distinction, identity on-disk format §1.3 expansion.

  Tier 2 (useful in the wild): propagation node protocol, KEEPALIVE
  and link teardown §6, LXMF stamps + tickets, NomadNet page protocol
  §13, GROUP destinations, CSMA / airtime tracking, RNode KISS
  configuration handshake §8.5, implicit vs explicit proof mode.

  Tier 3 (transport / relay): DATA forwarding rules §7.7, ANNOUNCE
  rebroadcasting §4.6, path table management §7.8, tunnels and
  shared-instance protocol §7.9, reverse-table link transport §6.x.

Folds the previous "Document the Reticulum Resource fragmentation
protocol" and "Document the Propagation /get pull protocol" entries
from the lower polishing section into Tier 1 / Tier 2 respectively
so they're tracked at the right priority.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:47:23 -04:00
Rob
588dcc9982 Expand §8.3 with the full RNode air-frame split-packet protocol
The previous one-sentence §8.3 was wrong about scope: it said KISS hosts
treat the 1-byte header as opaque pass-through, which is misleading —
the byte lives between RNodes on the LoRa air-frame, not on the KISS
channel. Hosts (RNS, Sideband, etc.) never see it. Any alternative
implementation that talks LoRa to an RNode must construct/parse it
bit-exactly, or its TX is invisible and its RX mistakes the header for
the first payload byte.

New text covers:
  - Header byte layout: bit 7..4 random seq nibble, bit 0 FLAG_SPLIT,
    SEQ_UNSET=0xFF sentinel (Framing.h:105-108).
  - TX rules: header = random(256) & 0xF0 | (FLAG_SPLIT iff
    payload > 254). Both halves of a split share the same byte byte-
    for-byte. Split at 255 bytes total per LoRa frame; max reassembled
    payload 508. (RNode_Firmware.ino:716-742; Config.h:59-61.)
  - RX state machine: at most one buffered first-half keyed by seq
    nibble; four cases for inbound frames (RNode_Firmware.ino:359-446).
  - Reassembly timeout: upstream firmware has none (relies on
    subsequent traffic to evict). The clean-room repeater adds a 500ms
    defensive timeout (reticulum-lora-repeater/src/Radio.cpp:189-194)
    — implementation-private, not part of the wire spec.
  - Sequence-collision ceiling: 4 random bits = 1/16 collision per
    overlapping split-packet pair from the same sender. Don't burst.
  - Note that a "header rotates between transmissions" memory of this
    protocol is a fading recall of the per-TX random seq nibble — there
    is no retransmit-driven byte rotation or rechunk. LoRa TX is
    fire-and-forget; higher-layer retransmit just re-runs the TX path
    and gets a fresh random seq.

todo.md gets an entry for tools/verify_rnode_split.py to lock the
new §8.3 in with a runtime test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:34:18 -04:00
Rob
b43d735d97 Add flows/ docs: receive-opportunistic and send-link
receive-opportunistic-lxmf.md mirrors the send flow on the recipient
side: KISS/HDLC deframe -> Transport.inbound -> packet_filter dedup ->
DATA/SINGLE branch -> Destination.receive -> Identity.decrypt with the
ratchet ring + long-term-key fallback -> LXMRouter.delivery_packet (which
fires the PROOF receipt before parsing) -> LXMessage.unpack_from_bytes
with msgpack stamp-strip -> ticket/stamp/dedup checks -> __delivery_callback
to the app. Notes upstream's narrower variant tolerance vs SPEC.md §5.6
and the missing clockless-sender fix-up vs §9.6.

send-link-lxmf.md walks the DIRECT method end-to-end: process_outbound
DIRECT branch decides reuse-vs-establish, RNS.Link.__init__ builds the
unencrypted LINKREQUEST body (initiator_X25519_pub || initiator_Ed25519_pub
|| optional signalling), link_id derived from get_hashable_part, LRPROOF
arrives back and validate_proof verifies signature against the responder's
long-term Ed25519 pub recalled from a prior announce, handshake() does
ECDH+HKDF over the shared secret with salt=link_id, lxmessage.send sends
the full LXMF body (with dest_hash, per §5.2) over the link with Token
encryption that omits the eph_pub prefix per §3.1, mandatory PROOF
receipts per §6.5 resolve the PacketReceipt. Sketches the RESOURCE
representation for oversize bodies and the backchannel-identify trick
that makes the link bidirectional.

flows/README.md status table updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:24:24 -04:00
Rob
8480555320 Correct SPEC.md §6.2 LRPROOF body order and §6.3 link_id offsets
Two source-cited corrections found while drafting the link send flow:

§6.2 — the LRPROOF body is signature(64) || responder_X25519_pub(32) ||
[signalling], not link_id || responder_X25519_pub || signature ||
[signalling]. The link_id appears in the packet header (dest_hash
position) per RNS/Packet.py:182-184 when context==LRPROOF, not in the
body. The responder's long-term Ed25519 pub is also NOT on the wire —
both sides know it from a prior announce, and it is included only in the
signature input. Citations: RNS/Link.py:373 (signer), :376 (proof_data),
:417 (validator).

§6.3 — get_hashable_part offsets N are 2 for HEADER_1 and 18 for HEADER_2
(skip flags+hops, and additionally skip transport_id for HEADER_2),
producing the same hashable_part on both sides regardless of relay
conversion. Previously listed as 18/34, which would have stripped the
dest_hash. Citation: RNS/Packet.py:354-361.

Both corrections are direct upstream source citations (criterion #2 from
agent.md §1) so they are recorded as verified. todo.md adds an entry to
write tools/verify_link_handshake.py to lock them in with a runtime test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:24:07 -04:00
Rob
ac898a414d Add flows/ directory with opportunistic-LXMF send sequence
flows/ documents end-to-end chronological narratives for common Reticulum
operations, complementing SPEC.md (which is organized by protocol layer).
Each step cross-references the SPEC.md section that defines the wire
bytes, so the directory introduces no new normative claims.

First flow: send-opportunistic-lxmf.md walks the 13-step sequence from
LXMRouter.handle_outbound through LXMessage.pack, the path-request
preamble, Token encryption, Transport.outbound HEADER_1→HEADER_2
conversion, and per-interface KISS/HDLC framing. Pinned against RNS 1.2.0
/ LXMF 0.9.6 with file+line citations for each step.

README.md updated to advertise flows/ and tools/ alongside SPEC.md and
test-vectors/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:15:03 -04:00
Rob
cf169b2a9e Verify §2.3, §4.3, §7.1, §7.4 against upstream RNS 1.2.0 / LXMF 0.9.6
Adds tools/ verifier scripts that exercise upstream RNS / LXMF and confirm
(or correct) the SPEC.md callouts:

- §2.3 HEADER_1→HEADER_2 conversion: verified by stubbing Transport.transmit
  and seeding a multi-hop path_table entry.
- §4.3 app_data 3-element variant: producer in LXMF 0.9.6 actually emits
  2 elements only (supported_functionality at LXMRouter.py:999 is dead
  code); parser tolerates 1/2/3-element + raw UTF-8.
- §7.1 path? always-precedes claim: actually conditional on
  not has_path() AND method==OPPORTUNISTIC.
- §7.4 ratchet ring default 8: actually Destination.RATCHET_COUNT = 512
  at RNS/Destination.py:85.

Also fixes a documentation bug in §1.2: the rnstransport.path.request row
of the well-known-hash table had the dest-hash prefix where the name_hash
should be (correct name_hash is 7926bbe7dd7f9aba88b0).

Seeds test-vectors/identities.json (Alice + Bob) with a regenerator
(tools/regen_identities.py) and verifier (tools/verify_destination_hash.py)
covering §1.1 and §1.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:14:51 -04:00
Rob
6435c0a0a0 Add todo.md with outstanding work list
Captures the four next-task buckets:
- Outreach (file issue on markqvist/Reticulum)
- Test infrastructure (bootstrap test-vectors/, write priority verifier scripts)
- Open UNVERIFIED items in SPEC.md to resolve
- Spec polishing (split SPEC.md, document Resource and Propagation /get)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:42:56 -04:00
Rob
cafb2889ab Initial bootstrap: README, LICENSE, SPEC.md, agent.md, scaffolding
Bootstrapped from the working notes of two reverse-engineering efforts:
- reticulum-lora-webclient (web/Capacitor)
- reticulum-mobile-app (Kotlin Multiplatform / Android)

SPEC.md consolidates byte-level wire format findings that aren't in the
upstream Reticulum manual. Each section grounded in upstream Python
source citations (file + line) where possible.

agent.md establishes the verification rules:
- Every claim is verified, unverified, or speculation; markers required
- Verification means a runnable script or a source citation
- PRs that quietly remove markers get rejected

tools/ and test-vectors/ are placeholder scaffolding with READMEs
describing the work needed.

Sections in SPEC.md flagged as currently UNVERIFIED:
- §2.3 Originator HEADER_1 -> HEADER_2 conversion
- §4.3 app_data 3-element variant with capabilities
- §7.1 path? always precedes LXMF (vs only on stale paths)
- §7.4 ratchet ring count default = 8

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:38:46 -04:00