bunshin/RNS.md

165 lines
14 KiB
Markdown
Raw Permalink Normal View History

# 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>[/<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](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_<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.