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 sliding-window rate limits, concurrent-connection cap | 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::evaldoes 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-keygenprints the hash computed by the shim (smolmail_rns_destination_hash), never a Rust-side rederivation.- Upstream master needs
#include <cstdint>added toUtilities/Memory.hunder current libstdc++/libc++;build.rspatches 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 = falseinput, passed to build.rs throughRNS_<DEP>_SOURCE_DIR, so the sandboxed build never fetches. packages.defaultbuilds without the feature;packages.rnssetsbuildFeatures = [ "rns" ]and adds cmake/ninja/gcc tonativeBuildInputs.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 rnsworks in it without a network. services.bunshin.rns.*mirrors §7.4; the singlebunshinsystemd unit gains the--rnsflags, since a secondDynamicUserunit gets a different UID and cannot open the samemail.db.- Firewall: only the RNS UDP interface's port (
openFirewallopens it); RNS reaches the mesh through a configured interface, not a listening TCP port.
9. Remaining open items
- 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).
- Upstream
microReticulumdeadlock: a client dying mid-resource-transfer wedges the single transport loop forever —Resource::request_nextsends itsRESOURCE_REQthroughTransport::outboundfrom inside thejobs()resource tick, andoutboundbusy-waits on_jobs_running, a flagjobs()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. - Interfaces beyond UDP (RNS-compatible TCP hub/client, I2P): microReticulum ships none; the UDP interface is the first supported one.
- The reference
test_interop/run_request_response.shharness was not rerun against the shim; its PlatformIO build step is unavailable here. The equivalent interop was exercised directly againstsmolmail_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
cargo test— 16 tests pass without the feature, 17 with it.TransportBindValues::rnsagainst independently computed SHA-256 vectors — pass.- 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.rstests). - TCP regression: the 1.1 reference client registers, sends, fetches and deletes against a 1.2 server unchanged — pass.
- See §9.3 for the upstream interop harness.
- End-to-end against
smolmail_rns.pyover a local UDP Reticulum testnet: register (two accounts), auth, send, fetch, delete (implicit in the client's fetch-acknowledge) — pass. - 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.
- Per-link limits: past
--rns-rate-link-requeststhe link's requests answer RATE_LIMITED (8); past--rns-max-linksthe extra link is torn down — pass. nix build .#rnsand.#defaultin a sandboxed build with all dependencies vendored — pass;nix flake check --no-buildpasses; the nix-built.#rnsbinary serves both carriers.