bunshin/RNS.md

14 KiB

RNS Transport (Smol Mail 1.2)

Implemented behind the rns cargo feature (protocol 1.2; the default build still speaks TCP + Noise_NX only, protocol 1.1). Upstream spec: smolmail RNS.md. The carrier rides on microReticulum, pinned at commit 40fa628 (2026-07-20).

1. Protocol deltas

Item TCP (1.1) RNS (1.2)
Carrier Noise_NX over TCP, prologue smolmail/1 Reticulum Link to a SINGLE/IN destination
Address host:1961 + pinned server static key 16-byte destination hash
URL smol://user@host[:port] smol+rns://user@<32 hex>[/<b32 identity>], no port, host part MUST be exactly 32 hex chars
Destination — ("smolmail", "server") → smolmail.server
Request path — smolmail/1
Framing len u32 || op u8 || body op u8 || body — Reticulum delimits, no length prefix
Response status u8 || payload status u8 || payload (unchanged)
Ops, status codes, envelope §6, §12 identical
AUTH / REGISTER binding Noise handshake hash, server static key derived, see §2
Announce — at startup and every 2 h; interfaces rate-limit to ≈1/hour, which is the ceiling
Abuse control per-IP rate limits per-link request + byte limits, concurrent-link cap

One wire-format detail the upstream spec leaves implicit: microReticulum splices the request payload and the response into their msgpack envelopes verbatim, so both directions carry the smolmail payload as a msgpack binary. The shim unpacks on the way in and packs on the way out; the Rust side only ever sees op u8 || body and status u8 || payload.

2. Bind values

Only AUTH and REGISTER differ between transports. Both substitutes are 32 bytes; neither input is length-prefixed, and both are fixed-width, so concatenation stays unambiguous.

h              = SHA-256("smolmail/1 bind" || destination || link_id)   # 16 || 16
server_static  = SHA-256("smolmail/1 bind" || destination)              # 16
Signature Message TCP source RNS source
AUTH "smolmail/1 auth" || h noise.get_handshake_hash() h above
REGISTER "smolmail/1 register" || server_static || username || identity X25519 public key server_static above

3. Sizing

max_request = max(max_envelope + 34, 1 + 1 + 63 + 32 + 64 + 1 + 2 + 32*max_tokens)

The second term is AUTH carrying a full token set: ≈32 KiB at the 1024-token default. Enforced in the RNS request handler (src/rns.rs); oversized requests answer TOO_LARGE. RNS defaults: max_envelope 32 KiB, fetch_budget 32 KiB.

Store::pending applies the fetch budget per batch and always returns at least one message, so an envelope larger than the budget is still delivered rather than blocking the mailbox head. Over a shared database this means a 768 KiB envelope accepted on TCP will be returned whole to an RNS FETCH. Operators whose users are mesh-first should apply the smaller cap to the whole mailbox (upstream spec 13.7) rather than per transport.

4. Implementation choice

Upstream maintains a Community Implementations list; the bar is wire-compatibility, sensible security practice, and ≥18 months of active development.

Implementation Language Listed upstream Verdict
RNS (markqvist) Python reference fallback, §7.3
microReticulum C++17 yes chosen — Apache-2.0, created 2023-10, native CMake build
Reticulum-Go Go yes wrong language for in-process use
rns-core/rns-net, reticulum Rust no not listed; parity is self-asserted. Reconsider only on listing

5. microReticulum surface

Verified against master @ 40fa628 (2026-07-20).

Need API Header
Identity RNS::Identity, raw 64-byte private via load_private_key/get_private_key, file persistence under -DRNS_USE_FS src/microReticulum/Identity.h
Destination RNS::Destination(identity, IN, SINGLE, "smolmail", "server") Destination.h
Accept links accepts_links(bool) Destination.h:161
Request handler register_request_handler(path, generator, allow, allowed_list, auto_compress) Destination.h:151
Link RNS::Link, Link::link_id(), set_link_closed_callback Link.h

response_generator is a plain function pointer with no userdata argument (Destination.h:43), so link_id arrives as a handler argument and bind values are derived per request; nothing needs mutable per-link setup in C++.

Build: root CMakeLists.txt, C++17. Dependencies (ArduinoJson, MsgPack, rweather/Crypto, microStore, ArxContainer, ArxTypeTraits, DebugLog) are pulled with FetchContent, each overridable via RNS_<DEP>_SOURCE_DIR — which the Nix build uses to vendor everything.

Two divergences from the Python reference were found and worked around:

  • Curve25519::eval does not clamp the scalar (rweather/Crypto), so an independent Rust derivation of the identity's x25519 public key — and therefore of the destination hash — produces a different hash than microReticulum itself. rns-keygen prints the hash computed by the shim (smolmail_rns_destination_hash), never a Rust-side rederivation.
  • Upstream master needs #include <cstdint> added to Utilities/Memory.h under current libstdc++/libc++; build.rs patches its copy of the source, since the input may be a read-only store path.

6. Bind abstraction

src/bind.rs holds TransportBindValues { h, server_static } with two constructors: tcp(handshake_hash, server_static) and rns(destination, link_id). Session stores the struct instead of a handshake hash; AUTH verifies over bind.h, REGISTER over bind.server_static. ServerConfig carries fetch_budget (TCP sets the old FETCH_BUDGET const as its default; the RNS carrier sets it from --rns-fetch-budget) and no longer carries server_static — each transport puts its own value in the bind struct.

7. RNS transport

7.1 Files

File Contents
shim/smolmail_rns.cpp, shim/smolmail_rns.h C ABI over the C++ API: smolmail_rns_start (identity, UDP endpoints, storage dir), smolmail_rns_destination_hash, the request handler, link callbacks, and the loop thread driving Reticulum::loop() at 10 ms
shim/udp_interface.{h,cpp} UDP interface adapted from microReticulum's Apache-2.0 example, with caller-supplied endpoints; with no forward target it sends to the source address of the last datagram received
build.rs copies the microReticulum source into OUT_DIR (patched), configures and builds it with cmake, compiles the shim with cc, and links both archives
src/rns.rs RnsState behind a static OnceLock, request dispatch, per-link session map, rns-keygen identity generation
Cargo.toml [features] rns = ["dep:cc"]; all RNS items behind #[cfg(feature = "rns")]

The shim ABI: smolmail_rns_start takes the 64-byte identity by pointer (raw bytes, not a C string — a NUL inside the key is valid), and returns the destination hash by pointer. The Rust callbacks (smolmail_rns_on_request, _take_response, _on_link_opened, _on_link_closed) fire on the shim's loop thread; the response travels through a single-slot buffer that the shim copies out before the next request.

7.2 Constraints, as resolved

Constraint Resolution
response_generator is a bare function pointer with no userdata Rust state lives in static OnceLock<RnsState>; link_id is the map key
Handler is called on microReticulum's thread the shim owns a dedicated loop thread; RnsState holds Mutex<HashMap<[u8;16], Session>>, one Store per link
Session.username must live for the link, not the request session inserted on first request, evicted on link close
Link lifecycle Destination::set_link_established_callback installs Link::set_link_closed_callback per link; both use Link::link_id() as the map key
Over-cap teardown would re-enter transport processing links over --rns-max-links are deferred to a queue the loop thread drains after Reticulum::loop() returns
No hex dependency data_encoding::HEXLOWER, already a dependency
One process, both carriers serve --rns starts the RNS carrier on the shim's thread and then runs the TCP loop on the main thread; the purge loop stays in server::run
Identity rns-keygen writes 64 random bytes itself — the exact layout Identity::to_file writes and load_private_key expects

ALLOW_ALL (microReticulum Type::Destination::request_policies, 0x01) admits every requester, matching the Python reference's Destination.ALLOW_ALL; registration stays open by design and invite tokens gate it at the operation level, not the link level.

7.3 Fallback

If the shim stalls, run the reference smolmaild_rns.py as a sidecar against the same SQLite file. Store::open sets WAL and a 10 s busy timeout, so concurrent writers are safe. Cost: a Python runtime in the closure and two implementations of the session rules. Not needed so far.

7.4 CLI

One process serves both carriers: shared Store, shared config, one systemd unit, no cross-UID file sharing.

Flag Default Notes
--rns off enable the RNS carrier on serve
--rns-key server.rns.key RNS identity, 0600, generated by rns-keygen
--rns-max-envelope 32768 independent of the TCP --max-envelope
--rns-fetch-budget 32768 feeds ServerConfig.fetch_budget for RNS sessions
--rns-max-links 100 concurrent links — refused past the cap
--rns-rate-link-requests 60 per link per minute
--rns-rate-link-bytes 1048576 per link per minute, via ByteRateLimiter
--rns-link-idle 300 seconds of silence before a link is reaped; 0 disables the reaper
--rns-udp 127.0.0.1:4242 UDP interface to listen on, host[:port]
--rns-udp-forward unset optional forward target; without it the interface replies to the last datagram's source

--rns-udp/--rns-udp-forward extend the original plan: microReticulum ships no interfaces at all (only examples), so the server needs at least one programmatically. The UDP interface is wire-compatible with the Python reference's UDPInterface; TCP and I2P interfaces remain open work.

The per-link request limiter reuses RateLimiter keyed by the link hex. The per-IP send limiter has no analogue (upstream spec 13.8); it is disabled for RNS sessions, and the accept-token limiter stays on because a token never needed a peer identity.

8. Nix

  • Every microReticulum dependency is a pinned flake = false input, passed to build.rs through RNS_<DEP>_SOURCE_DIR, so the sandboxed build never fetches.
  • packages.default builds without the feature; packages.rns sets buildFeatures = [ "rns" ] and adds cmake/ninja/gcc to nativeBuildInputs. dontUseNinja* keeps ninja's setup hook from claiming the build phase in place of cargo.
  • The dev shell exports the same source variables, so cargo build --features rns works in it without a network.
  • services.bunshin.rns.* mirrors §7.4; the single bunshin systemd unit gains the --rns flags, since a second DynamicUser unit gets a different UID and cannot open the same mail.db.
  • Firewall: only the RNS UDP interface's port (openFirewall opens it); RNS reaches the mesh through a configured interface, not a listening TCP port.

9. Remaining open items

  1. Cross-transport envelope size: per-transport caps are enforced; a 768 KiB TCP envelope is still delivered whole to an RNS FETCH. Operators should apply the smaller cap mailbox-wide when in doubt (upstream spec 13.7).
  2. Upstream microReticulum deadlock: a client dying mid-resource-transfer wedges the single transport loop forever — Resource::request_next sends its RESOURCE_REQ through Transport::outbound from inside the jobs() resource tick, and outbound busy-waits on _jobs_running, a flag jobs() itself holds. No announces, no new links, mesh dead until restart. The vendored source is a local fork carrying the fix (same-thread reentrant calls skip the cycle wait); the equivalent report is filed upstream.
  3. Interfaces beyond UDP (RNS-compatible TCP hub/client, I2P): microReticulum ships none; the UDP interface is the first supported one.
  4. The reference test_interop/run_request_response.sh harness was not rerun against the shim; its PlatformIO build step is unavailable here. The equivalent interop was exercised directly against smolmail_rns.py (§10.6).

Formerly open items resolved during implementation: raw identity bytes exist (load_private_key); the shim ABI and run-loop drive pattern are §7.1/§7.2; ALLOW_ALL matches the reference; announcing at startup + every 2 h, unconditional even when invite-only (the invite gates REGISTER, not the link); the interface question is §7.4.

10. Verification, as run

  1. cargo test — 16 tests pass without the feature, 17 with it.
  2. TransportBindValues::rns against independently computed SHA-256 vectors — pass.
  3. AUTH and REGISTER accept a signature made over the derived values and reject one made over the other transport's, in both directions — pass (src/session.rs tests).
  4. TCP regression: the 1.1 reference client registers, sends, fetches and deletes against a 1.2 server unchanged — pass.
  5. See §9.3 for the upstream interop harness.
  6. End-to-end against smolmail_rns.py over a local UDP Reticulum testnet: register (two accounts), auth, send, fetch, delete (implicit in the client's fetch-acknowledge) — pass.
  7. Same database, both carriers: send over TCP fetched over RNS, and send over RNS fetched over TCP, with the same mailbox identity authenticating over both — pass.
  8. Per-link limits: past --rns-rate-link-requests the link's requests answer RATE_LIMITED (8); past --rns-max-links the extra link is torn down — pass.
  9. nix build .#rns and .#default in a sandboxed build with all dependencies vendored — pass; nix flake check --no-build passes; the nix-built .#rns binary serves both carriers.