# 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](https://code.randogoth.com/randogoth/smolmail/src/branch/main/RNS.md). The carrier rides on [microReticulum](https://github.com/attermann/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>[/]`, 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](https://github.com/markqvist/Reticulum#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](https://github.com/attermann/microReticulum) | C++17 | yes | **chosen** — Apache-2.0, created 2023-10, native CMake build | | [Reticulum-Go](https://reticulum-go.quad4.io/) | 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__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 ` 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`; `link_id` is the map key | | Handler is called on microReticulum's thread | the shim owns a dedicated loop thread; `RnsState` holds `Mutex>`, 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__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.