diff --git a/Cargo.lock b/Cargo.lock index 38547e7..3463679 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,6 +155,7 @@ name = "bunshin" version = "0.1.0" dependencies = [ "anyhow", + "cc", "clap", "data-encoding", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index 7c3416a..d2b2c08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,11 @@ description = "Smol Mail server" name = "bunshin" path = "src/main.rs" +[features] +# RNS carrier over microReticulum (RNS.md); needs MICRORETICULUM_SOURCE_DIR +# at build time, provided by the flake. +rns = ["dep:cc"] + [dependencies] snow = "0.9" rusqlite = { version = "0.32", features = ["bundled"] } @@ -20,3 +25,6 @@ clap = { version = "4", features = ["derive"] } log = "0.4" env_logger = "0.11" anyhow = "1" + +[build-dependencies] +cc = { version = "1", optional = true } diff --git a/README.md b/README.md index 2281e76..a540f9d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 分身 bunshin -A Rust implementation of the [Smol Mail](https://code.randogoth.com/randogoth/smolmail) server: a minimalist, end-to-end encrypted mail protocol over a Noise-secured TCP connection. `bunshin` implements the server side only — receiving, storing and serving sealed mail — not the client. +A Rust implementation of the [Smol Mail](https://code.randogoth.com/randogoth/smolmail) server: a minimalist, end-to-end encrypted mail protocol over a Noise-secured TCP connection, with an optional Reticulum (RNS) mesh carrier behind the `rns` build feature. `bunshin` implements the server side only — receiving, storing and serving sealed mail — not the client. The server never sees plaintext, sender identities or any private key. It learns only which mailbox an envelope is for, its size, and when it arrived. @@ -34,6 +34,8 @@ Add bunshin as a flake input and import the module: `services.bunshin` also takes `host`, `port`, `dataDir`, `maxEnvelope`, `quota`, `requestsQuota`, `retentionDays`, `requestsRetentionDays`, `maxTokens`, `rateConnections`, `rateSends` and `rateTokens`; see `flake.nix` for defaults. The module renders a `systemd` unit that runs `bunshin serve` under `DynamicUser`; it does not generate a key. +For the RNS carrier, build `packages.rns`, set `services.bunshin.package` to it, and enable `services.bunshin.rns` with its `keyFile`; see [RNS.md](RNS.md) for the protocol and the remaining options. + Before the first deploy, generate the server's static key once (from a dev shell or `nix run`) and place it at the configured `keyFile`: ``` @@ -42,9 +44,20 @@ nix run "https://code.randogoth.com/randogoth/bunshin" -- keygen --key server.ke `keygen` writes the server's static X25519 key (used for the Noise handshake, distinct from any user's Ed25519 identity) and prints its public key in base32. Publish that public key through a trusted channel — clients pin it, and a mismatch aborts the handshake. +## RNS carrier + +The `rns` feature (built by `packages.rns`, wired to the module through `services.bunshin.rns`) serves the same mailbox over the Reticulum Network Stack alongside TCP: `bunshin serve --rns` runs both carriers in one process against one database. The mesh address is the `smolmail.server` destination hash — there is no key to pin, the address is the pin. + +``` +bunshin rns-keygen --key server.rns.key +bunshin serve --rns --key server.key --rns-key server.rns.key --db mail.db --rns-udp 0.0.0.0:4242 +``` + +`rns-keygen` writes the 64-byte Reticulum identity and prints the destination hash; publish `smol+rns://@` as the server's address. RNS-specific limits (`--rns-max-envelope`, `--rns-fetch-budget`, `--rns-max-links`, `--rns-rate-link-requests`, `--rns-rate-link-bytes`) default to mesh-friendly 32 KiB caps; the UDP interface is the one supported transport interface so far. See [RNS.md](RNS.md). + ## Building -Requires a Rust toolchain (stable, edition 2021) and a C compiler — `rusqlite`'s `bundled` feature compiles SQLite from source rather than linking a system copy, so no separate SQLite install is needed. +Requires a Rust toolchain (stable, edition 2021) and a C compiler — `rusqlite`'s `bundled` feature compiles SQLite from source rather than linking a system copy, so no separate SQLite install is needed. The `rns` feature additionally needs cmake, ninja and a microReticulum checkout pointed at by `MICRORETICULUM_SOURCE_DIR` — the flake's dev shell provides all of it. ``` cargo build --release @@ -65,3 +78,5 @@ bunshin serve --key server.key --db mail.db --host 0.0.0.0 --port 1961 ## Status Implements SPEC.md version 1.1 in full: `AUTH`, `RESOLVE`, `SEND`, `FETCH`, `DELETE` and `REGISTER`, including accept tokens and the main/requests tier split, fetch cursors, 32-byte message ids, `REGISTER` proof of possession, and dual-signed key rotation chains. + +With the `rns` feature, implements the version 1.2 RNS transport: the same six operations over Reticulum Links to an announced `smolmail.server` destination, with link-bound AUTH/REGISTER signatures, per-link request and byte limits, and a concurrent-link cap. One server process can serve both carriers over one store. diff --git a/RNS.md b/RNS.md index 49220d8..29eb159 100644 --- a/RNS.md +++ b/RNS.md @@ -1,1158 +1,162 @@ -# RNS Integration Plan for Bunshin (Smol Mail 1.2 Compatibility) +# RNS Transport (Smol Mail 1.2) -## Overview +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). -This document outlines how to integrate Reticulum Network Stack (RNS) transport into bunshin to achieve Smol Mail protocol version 1.2 compatibility. The current bunshin implementation supports only TCP with Noise (v1.1). RNS provides an alternative transport that works over mesh networks (LoRa, packet radio, serial links, I2P, TCP) without requiring DNS, IP addresses, or fixed topology. +## 1. Protocol deltas -## Key Finding: No Official Rust RNS Crate +| 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 | -After research, **there is no officially recognized Rust crate for Reticulum**. The official implementations are: -- **Python**: `RNS` module (reference implementation used by smolmail) -- **C++**: [`microReticulum`](https://github.com/attermann/microReticulum) by attermann (most mature, production-ready) -- **C++**: Community-maintained ports +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`. -Therefore, we have three options for Rust integration: +## 2. Bind values -| Option | Approach | Pros | Cons | -|--------|----------|------|------| -| **A. FFI with microReticulum** | Call C++ from Rust via FFI | Official implementation, full compliance | Complex FFI, C API may be unstable | -| **B. Python IPC** | Run `smolmaild_rns.py` as subprocess, share DB | Fast to implement, known-good | Two processes, Python dependency | -| **C. Wait for Rust crate** | Track `reticulum-rs`/`rns-core` | Native Rust, clean integration | Unknown timeline, may never be official | - -**Recommendation: Option A (FFI) for production, Option B (IPC) for quick validation** - ---- - -## Understanding the Python RNS API - -From `smolmaild_rns.py`, the server implementation uses: - -```python -# Identity and destination -identity = RNS.Identity.from_file("server.rns.key") -destination = RNS.Destination(identity, RNS.Destination.IN, RNS.Destination.SINGLE, "smolmail", "server") -dest_hash = RNS.Destination.hash(identity, "smolmail", "server") # 16 bytes - -# Request handling on path "smolmail/1" -destination.register_request_handler( - "smolmail/1", - response_generator=handle_request, - allow=RNS.Destination.ALLOW_ALL -) - -# Link lifecycle -link.link_id # 16 bytes, ephemeral per-link -link.set_link_established_callback(on_established) -link.set_link_closed_callback(on_closed) -``` - -The **message format** is simply: `u8 type || body` (no length prefix, RNS delimits) - ---- - -## Architecture +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. ``` -┌─────────────────────────────────────────────────────────┐ -│ bunshin server │ -├─────────────────────────────────────────────────────────┤ -│ ┌─────────────────┐ ┌─────────────────────────────┐ │ -│ │ TCP Transport │ │ RNS Transport │ │ -│ │ (tcp_transport) │ │ (rns/transport + FFI) │ │ -│ └──────┬──────────┘ └──────────┬──────────────────┘ │ -│ │ │ │ -│ ▼ ▼ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Session (modified) │ │ -│ │ - Uses TransportBindValues instead of │ │ -│ │ handshake_hash + server_static │ │ -│ └────────────────────┬───────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Store (unchanged) │ │ -│ │ SQLite, shared between transports │ │ -│ └─────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────┘ +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 | -## Implementation Phases +## 3. Sizing -### Phase 1: Transport Abstraction (Do First - No Dependencies) - -This phase makes the codebase ready for any transport implementation. - -#### 1.1 Create `src/transport.rs` - -```rust -/// Transport-agnostic session binding values -/// From RNS.md S13.6: these substitute the Noise handshake hash and static key -pub struct TransportBindValues { - /// Replaces Noise handshake hash for AUTH signature - pub h: [u8; 32], - /// Replaces Noise server_static for REGISTER signature - pub server_static: [u8; 32], -} - -/// Trait that all transports must implement -pub trait Transport { - /// Read one frame: (op, body) - fn read_frame(&mut self) -> anyhow::Result<(u8, Vec)>; - - /// Write one frame - fn write_frame(&mut self, op: u8, body: &[u8]) -> anyhow::Result<()>; - - /// Peer identifier for rate limiting (IP address or link_id hash) - fn peer_id(&self) -> &str; - - /// Get binding values for this session - fn bind_values(&self) -> TransportBindValues; -} +``` +max_request = max(max_envelope + 34, 1 + 1 + 63 + 32 + 64 + 1 + 2 + 32*max_tokens) ``` -#### 1.2 Refactor `channel.rs` to `src/tcp_transport.rs` +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. -Move existing Noise/TCP logic into a TCP transport implementation: +`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. -```rust -use std::net::TcpStream; -use snow::TransportState; +## 4. Implementation choice -use crate::proto::{NOISE_PAYLOAD, PROLOGUE}; -use crate::transport::TransportBindValues; +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. -pub struct TcpTransport { - stream: TcpStream, - transport: TransportState, - buf: Vec, - handshake_hash: Vec, - server_static: [u8; 32], - peer_ip: String, -} +| 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 | -impl TcpTransport { - pub fn new( - stream: TcpStream, - transport: TransportState, - handshake_hash: Vec, - server_static: [u8; 32], - peer_ip: String, - ) -> Self { - Self { - stream, - transport, - buf: Vec::new(), - handshake_hash, - server_static, - peer_ip, - } - } -} +## 5. microReticulum surface -impl Transport for TcpTransport { - fn read_frame(&mut self) -> anyhow::Result<(u8, Vec)> { - while self.buf.len() < 5 { - let chunk = self.read_noise()?; - self.buf.extend_from_slice(&chunk); - } - let length = u32::from_be_bytes(self.buf[..4].try_into().unwrap()) as usize; - if length < 1 || length > crate::proto::MAX_FRAME { - return Err(crate::proto::ProtocolError::new(format!("frame length {} out of range", length)).into()); - } - while self.buf.len() < 4 + length { - let chunk = self.read_noise()?; - self.buf.extend_from_slice(&chunk); - } - let frame: Vec = self.buf[4..4 + length].to_vec(); - self.buf.drain(..4 + length); - Ok((frame[0], frame[1..].to_vec())) - } +Verified against `master` @ `40fa628` (2026-07-20). - fn write_frame(&mut self, op: u8, body: &[u8]) -> anyhow::Result<()> { - let mut frame = Vec::with_capacity(5 + body.len()); - frame.extend_from_slice(&((1 + body.len()) as u32).to_be_bytes()); - frame.push(op); - frame.extend_from_slice(body); - for chunk in frame.chunks(NOISE_PAYLOAD) { - self.write_noise(chunk)?; - } - Ok(()) - } +| 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` | - fn peer_id(&self) -> &str { - &self.peer_ip - } +`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++. - fn bind_values(&self) -> TransportBindValues { - TransportBindValues { - h: self.handshake_hash.clone().try_into().unwrap(), - server_static: self.server_static, - } - } -} +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. -impl TcpTransport { - fn read_noise(&mut self) -> anyhow::Result> { - let len = read_u16_len(&mut self.stream)?; - let mut ciphertext = vec![0u8; len]; - read_exact_into(&mut self.stream, &mut ciphertext)?; - let mut plaintext = vec![0u8; len]; - let n = self.transport.read_message(&ciphertext, &mut plaintext)?; - plaintext.truncate(n); - Ok(plaintext) - } +Two divergences from the Python reference were found and worked around: - fn write_noise(&mut self, payload: &[u8]) -> anyhow::Result<()> { - let mut packet = vec![0u8; payload.len() + 16]; - let n = self.transport.write_message(payload, &mut packet)?; - packet.truncate(n); - write_u16_len(&mut self.stream, &packet)?; - Ok(()) - } -} -``` +- `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. -#### 1.3 Update `session.rs` +## 6. Bind abstraction -Replace `handshake_hash: Vec` with `bind_values: TransportBindValues`: +`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. -```rust -use crate::transport::TransportBindValues; +## 7. RNS transport -pub struct Session<'a> { - config: &'a ServerConfig, - store: Store, - peer_ip: String, - bind_values: TransportBindValues, // NEW: from transport - username: Option, -} +### 7.1 Files -impl<'a> Session<'a> { - pub fn new( - config: &'a ServerConfig, - store: Store, - peer_ip: String, - bind_values: TransportBindValues, // NEW parameter - ) -> Self { - Session { config, store, peer_ip, bind_values, username: None } - } +| 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")]` | - fn op_auth(&mut self, r: &mut Reader) -> OpResult { - let username = Self::read_str(r)?; - let identity = r.take(KEY_LEN)?.to_vec(); - let signature = r.take(64)?.to_vec(); - let sync = r.u8()?; - let count = r.u16()? as usize; - let mut tokens = Vec::with_capacity(count); - for _ in 0..count { - tokens.push(r.take(TOKEN_LEN)?.to_vec()); - } - r.done()?; +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. - if sync > 1 || (sync == 0 && count != 0) { - return Ok((MALFORMED, Vec::new())); - } +### 7.2 Constraints, as resolved - let bound = self.store.identity_of(&username)?; - if bound.as_deref() != Some(identity.as_slice()) { - return Ok((AUTH_FAILED, Vec::new())); - } +| 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 | - // CHANGED: use bind_values.h instead of self.handshake_hash - let mut msg = LABEL_AUTH.to_vec(); - msg.extend_from_slice(&self.bind_values.h); - if !verify(&identity, &signature, &msg) { - return Ok((AUTH_FAILED, Vec::new())); - } +`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. - if sync == 1 { - if count > self.config.max_tokens as usize { - return Ok((TOO_LARGE, Vec::new())); - } - self.store.set_tokens(&username, &tokens)?; - } +### 7.3 Fallback - let accepted = self.store.token_count(&username)?; - self.username = Some(username); - Ok((OK, accepted.to_be_bytes().to_vec())) - } +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. - fn op_register(&mut self, r: &mut Reader) -> OpResult { - let username = Self::read_str(r)?; - let identity = r.take(KEY_LEN)?.to_vec(); - let signature = r.take(64)?.to_vec(); - let token_len = r.u8()? as usize; - let token = r.take(token_len)?.to_vec(); - let cert_len = r.u8()? as usize; - let cert = r.take(cert_len)?.to_vec(); - r.done()?; +### 7.4 CLI - if !valid_username(&username) { - return Ok((MALFORMED, Vec::new())); - } +One process serves both carriers: shared `Store`, shared config, one systemd unit, no cross-UID file sharing. - // CHANGED: use bind_values.server_static instead of self.config.server_static - let mut pop_msg = LABEL_REGISTER.to_vec(); - pop_msg.extend_from_slice(&self.bind_values.server_static); - pop_msg.extend_from_slice(username.as_bytes()); - pop_msg.extend_from_slice(&identity); - if !verify(&identity, &signature, &pop_msg) { - return Ok((AUTH_FAILED, Vec::new())); - } +| 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-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 | - // ... rest unchanged ... - } -} -``` +`--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. -#### 1.4 Update `server.rs` +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. -```rust -use crate::tcp_transport::TcpTransport; -use crate::transport::Transport; +## 8. Nix -fn handle_connection( - mut stream: TcpStream, - config: &ServerConfig, - static_key: &[u8], - db_path: &str, - peer_ip: &str, -) -> anyhow::Result<()> { - stream.set_read_timeout(Some(Duration::from_secs(IDLE_TIMEOUT_SECS)))?; +- 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. - let (transport_state, handshake_hash) = match handshake(&mut stream, static_key) { - Ok(v) => v, - Err(e) => { - log::info!("handshake failed from {peer_ip}: {e}"); - return Ok(()); - } - }; +## 9. Remaining open items - let store = Store::open(db_path)?; - - // Create TCP transport - let mut tcp_transport = TcpTransport::new( - stream, - transport_state, - handshake_hash, - config.server_static, - peer_ip.to_string(), - ); - - // Get bind values from transport - let bind_values = tcp_transport.bind_values(); - let mut session = Session::new(config, store, peer_ip.to_string(), bind_values); +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. Interfaces beyond UDP (RNS-compatible TCP hub/client, I2P): microReticulum ships none; the UDP interface is the first supported one. +3. 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). - loop { - let (op, body) = match tcp_transport.read_frame() { - Ok(v) => v, - Err(e) => { - if is_eof_like(&e) { - return Ok(()); - } - log::info!("bad frame from {peer_ip}: {e}"); - let _ = tcp_transport.write_frame(0, &[MALFORMED]); - return Ok(()); - } - }; +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. - let (status, payload) = session.dispatch(op, &body); - let mut response = Vec::with_capacity(1 + payload.len()); - response.push(status); - response.extend_from_slice(&payload); - if tcp_transport.write_frame(op, &response).is_err() { - return Ok(()); - } - } -} -``` +## 10. Verification, as run -Also update `ServerConfig` to remove `server_static` (now transport-specific): - -```rust -pub struct ServerConfig { - pub max_envelope: usize, - pub main_quota: i64, - pub requests_quota: i64, - pub max_tokens: u16, - pub invite_token: Option>, - pub conn_limiter: RateLimiter, - pub send_limiter: RateLimiter, - pub token_limiter: RateLimiter, - // server_static removed - now per-transport -} -``` - -#### 1.5 Update `main.rs` - -Remove `server_static` from config building in `cmd_keygen` and `run`: - -```rust -// In run() function, remove server_static from ServerConfig -let config = Arc::new(ServerConfig { - max_envelope: args.max_envelope, - main_quota: args.quota, - requests_quota: args.requests_quota, - max_tokens: args.max_tokens, - invite_token: args.invite_token.map(String::into_bytes), - conn_limiter: RateLimiter::new(args.rate_connections), - send_limiter: RateLimiter::new(args.rate_sends), - token_limiter: RateLimiter::new(args.rate_tokens), - // server_static no longer here -}); -``` - ---- - -### Phase 2: FFI with microReticulum (For Native RNS) - -#### 2.1 Add microReticulum to Nix - -```nix -# In flake.nix -{ - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - flake-utils.url = "github:numtide/flake-utils"; - microReticulum.url = "github:attermann/microReticulum"; - }; - - outputs = { self, nixpkgs, flake-utils, microReticulum, ... }: - flake-utils.lib.eachDefaultSystem (system: - let - pkgs = import nixpkgs { inherit system; }; - in - { - packages.default = pkgs.rustPlatform.buildRustPackage { - pname = "bunshin"; - version = "0.1.0"; - src = ./.; - cargoLock.lockFile = ./Cargo.lock; - nativeBuildInputs = [ pkgs.pkg-config ]; - buildInputs = [ pkgs.sqlite ]; - # Add microReticulum when RNS feature is enabled - propagatedBuildInputs = pkgs.lib.optional (self?.inputs?.microReticulum != null) - (pkgs.callPackage microReticulum { }); - }; - } - ); -} -``` - -#### 2.2 Add RNS CLI Commands - -```rust -// In main.rs -#[derive(Parser)] -#[command(about = "Smol Mail server")] -struct Cli { - #[arg(short, long, global = true)] - verbose: bool, - - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - /// Generate the server's static X25519 key (TCP transport) - Keygen { - #[arg(long, default_value = "server.key")] - key: String, - #[arg(long)] - force: bool, - }, - /// Generate the server's RNS identity key - #[cfg(feature = "rns")] - RnsKeygen { - #[arg(long, default_value = "server.rns.key")] - key: String, - #[arg(long)] - force: bool, - }, - /// Run the mailbox server over TCP - Serve { - #[arg(long, default_value = "server.key")] - key: String, - #[arg(long, default_value = "mail.db")] - db: String, - #[arg(long, default_value = "127.0.0.1")] - host: String, - #[arg(long, default_value_t = DEFAULT_PORT)] - port: u16, - #[arg(long = "max-envelope", default_value_t = 768 << 10)] - max_envelope: usize, - #[arg(long, default_value_t = 64 << 20)] - quota: i64, - #[arg(long = "requests-quota", default_value_t = 2 << 20)] - requests_quota: i64, - #[arg(long = "retention-days", default_value_t = 30)] - retention_days: i64, - #[arg(long = "requests-retention-days", default_value_t = 7)] - requests_retention_days: i64, - #[arg(long = "max-tokens", default_value_t = 1024)] - max_tokens: u16, - #[arg(long = "invite-token")] - invite_token: Option, - #[arg(long = "rate-connections", default_value_t = 120)] - rate_connections: u32, - #[arg(long = "rate-sends", default_value_t = 60)] - rate_sends: u32, - #[arg(long = "rate-tokens", default_value_t = 30)] - rate_tokens: u32, - }, - /// Run the mailbox server over RNS - #[cfg(feature = "rns")] - RnsServe { - #[arg(long, default_value = "server.rns.key")] - key: String, - #[arg(long, default_value = "mail.db")] - db: String, - #[arg(long = "max-envelope", default_value_t = 32 << 10)] // 32 KiB - max_envelope: usize, - #[arg(long = "fetch-budget", default_value_t = 32 << 10)] // 32 KiB - fetch_budget: usize, - #[arg(long = "quota", default_value_t = 64 << 20)] - quota: i64, - #[arg(long = "requests-quota", default_value_t = 2 << 20)] - requests_quota: i64, - #[arg(long = "retention-days", default_value_t = 30)] - retention_days: i64, - #[arg(long = "requests-retention-days", default_value_t = 7)] - requests_retention_days: i64, - #[arg(long = "max-tokens", default_value_t = 1024)] - max_tokens: u16, - #[arg(long = "invite-token")] - invite_token: Option, - #[arg(long = "max-links", default_value_t = 100)] - max_links: u32, - #[arg(long = "rate-link-requests", default_value_t = 60)] - rate_link_requests: u32, - #[arg(long = "rate-link-bytes", default_value_t = 1048576)] // 1 MiB/min - rate_link_bytes: u64, - }, -} - -fn main() -> anyhow::Result<()> { - let cli = Cli::parse(); - - env_logger::Builder::new() - .filter_level(if cli.verbose { - log::LevelFilter::Debug - } else { - log::LevelFilter::Info - }) - .format_timestamp_secs() - .init(); - - match cli.command { - Command::Keygen { key, force } => cmd_keygen(&key, force), - #[cfg(feature = "rns")] - Command::RnsKeygen { key, force } => rns::cmd_keygen(&key, force), - Command::Serve { .. } => server::run(server::ServeArgs { .. }), - #[cfg(feature = "rns")] - Command::RnsServe { .. } => rns_server::run(rns_server::RnsServeArgs { .. }), - } -} -``` - -#### 2.3 Create `src/rns/mod.rs` - -```rust -//! RNS transport support via microReticulum FFI - -pub mod ffi; -pub mod transport; - -use std::io::Write; - -pub fn cmd_keygen(key_path: &str, force: bool) -> anyhow::Result<()> { - if std::path::Path::new(key_path).exists() && !force { - anyhow::bail!("{key_path} exists; refusing to overwrite (use --force)"); - } - - // Call into microReticulum to generate identity - let identity = ffi::Identity::generate()?; - - // Written 0600 before any bytes land, so the key is never briefly readable. - let mut opts = std::fs::OpenOptions::new(); - opts.write(true).create(true).truncate(true); - #[cfg(unix)] - opts.mode(0o600); - let mut file = opts.open(key_path)?; - file.write_all(&identity.save_to_bytes()?)?; - - let dest_hash = identity.destination_hash("smolmail", "server")?; - - println!("RNS identity key: {key_path}"); - println!("Destination hash: {}", hex::encode(&dest_hash)); - println!(); - println!("Address: smol+rns://@{}", hex::encode(&dest_hash)); - println!("Publish the destination hash; clients use it as the address."); - Ok(()) -} -``` - -#### 2.4 Create `src/rns/ffi.rs` - -```rust -//! FFI bindings to microReticulum C API - -use std::os::raw::{c_char, c_int, c_uchar, c_void}; -use std::ffi::CString; -use std::ptr; - -// Constants -pub const RNS_DESTINATION_IN: c_int = 0; -pub const RNS_DESTINATION_OUT: c_int = 1; -pub const RNS_DESTINATION_SINGLE: c_int = 0; - -// Opaque types -extern "C" { - pub type rns_identity_t; - pub type rns_destination_t; -} - -// Identity functions -extern "C" { - fn rns_identity_create() -> *mut rns_identity_t; - fn rns_identity_destroy(identity: *mut rns_identity_t); - fn rns_identity_save(identity: *mut rns_identity_t, path: *const c_char) -> c_int; - fn rns_identity_load(path: *const c_char) -> *mut rns_identity_t; - fn rns_identity_get_destination_hash( - identity: *mut rns_identity_t, - app_name: *const c_char, - aspect: *const c_char, - out_hash: *mut c_uchar, - ) -> c_int; -} - -// Destination functions -extern "C" { - fn rns_destination_create( - identity: *mut rns_identity_t, - direction: c_int, - mode: c_int, - app_name: *const c_char, - aspect: *const c_char, - ) -> *mut rns_destination_t; - fn rns_destination_destroy(dest: *mut rns_destination_t); - fn rns_destination_get_hash(dest: *mut rns_destination_t, out: *mut c_uchar) -> c_int; - fn rns_destination_set_max_request_size(dest: *mut rns_destination_t, size: usize) -> c_int; - fn rns_destination_start(dest: *mut rns_destination_t) -> c_int; - fn rns_destination_stop(dest: *mut rns_destination_t); - fn rns_destination_set_request_handler( - dest: *mut rns_destination_t, - path: *const c_char, - callback: extern "C" fn(userdata: *mut c_void, data: *const c_uchar, data_len: usize, link_id: *const c_uchar), - userdata: *mut c_void, - ) -> c_int; -} - -// Safe wrappers -pub struct Identity { - ptr: *mut rns_identity_t, -} - -impl Identity { - pub fn generate() -> anyhow::Result { - let ptr = unsafe { rns_identity_create() }; - if ptr.is_null() { - anyhow::bail!("Failed to create RNS identity"); - } - Ok(Self { ptr }) - } - - pub fn from_file(path: &str) -> anyhow::Result { - let c_path = CString::new(path)?; - let ptr = unsafe { rns_identity_load(c_path.as_ptr()) }; - if ptr.is_null() { - anyhow::bail!("Failed to load RNS identity from {}", path); - } - Ok(Self { ptr }) - } - - pub fn save_to_bytes(&self) -> anyhow::Result> { - // In microReticulum, identities are typically saved to files - // For FFI, we might need a custom function to export the raw bytes - // This is a placeholder - actual implementation depends on microReticulum API - anyhow::bail!("save_to_bytes not yet implemented - depends on microReticulum API"); - } - - pub fn destination_hash(&self, app_name: &str, aspect: &str) -> anyhow::Result<[u8; 16]> { - let c_app = CString::new(app_name)?; - let c_aspect = CString::new(aspect)?; - let mut out = [0u8; 16]; - let result = unsafe { - rns_identity_get_destination_hash(self.ptr, c_app.as_ptr(), c_aspect.as_ptr(), out.as_mut_ptr()) - }; - if result != 0 { - anyhow::bail!("Failed to get destination hash"); - } - Ok(out) - } -} - -impl Drop for Identity { - fn drop(&mut self) { - unsafe { rns_identity_destroy(self.ptr) }; - } -} - -pub struct Destination { - ptr: *mut rns_destination_t, - identity: Identity, -} - -impl Destination { - pub fn create( - identity: Identity, - direction: c_int, - mode: c_int, - app_name: &str, - aspect: &str, - ) -> anyhow::Result { - let c_app = CString::new(app_name)?; - let c_aspect = CString::new(aspect)?; - let ptr = unsafe { - rns_destination_create( - identity.ptr, - direction, - mode, - c_app.as_ptr(), - c_aspect.as_ptr(), - ) - }; - if ptr.is_null() { - anyhow::bail!("Failed to create RNS destination"); - } - Ok(Self { ptr, identity }) - } - - pub fn hash(&self) -> anyhow::Result<[u8; 16]> { - let mut out = [0u8; 16]; - let result = unsafe { rns_destination_get_hash(self.ptr, out.as_mut_ptr()) }; - if result != 0 { - anyhow::bail!("Failed to get destination hash"); - } - Ok(out) - } - - pub fn start(&self) -> anyhow::Result<()> { - let result = unsafe { rns_destination_start(self.ptr) }; - if result != 0 { - anyhow::bail!("Failed to start RNS destination"); - } - Ok(()) - } - - pub fn set_max_request_size(&self, size: usize) -> anyhow::Result<()> { - let result = unsafe { rns_destination_set_max_request_size(self.ptr, size) }; - if result != 0 { - anyhow::bail!("Failed to set max request size"); - } - Ok(()) - } -} - -impl Drop for Destination { - fn drop(&mut self) { - unsafe { rns_destination_stop(self.ptr) }; - unsafe { rns_destination_destroy(self.ptr) }; - } -} -``` - -#### 2.5 Create `src/rns/transport.rs` - -```rust -//! RNS transport implementation - -use std::sync::mpsc; -use std::sync::Arc; -use crate::transport::{Transport, TransportBindValues}; -use crate::proto::MAC_LEN; -use sha2::{Sha256, Digest}; - -use super::ffi; - -pub struct RnsTransport { - destination: Arc, - request_tx: mpsc::Sender, - request_rx: mpsc::Receiver, - response_tx: mpsc::Sender, - peer_id: String, - link_id: [u8; 16], -} - -struct RnsRequest { - op: u8, - body: Vec, -} - -struct RnsResponse { - status: u8, - payload: Vec, -} - -impl RnsTransport { - pub fn new(identity: ffi::Identity, max_request_size: usize) -> anyhow::Result { - let destination = Arc::new(ffi::Destination::create( - identity, - ffi::RNS_DESTINATION_IN, - ffi::RNS_DESTINATION_SINGLE, - "smolmail", - "server", - )?); - - let dest_hash = destination.hash()?; - destination.set_max_request_size(max_request_size)?; - - // Start destination - destination.start()?; - - // Set up channels - let (request_tx, request_rx) = mpsc::channel(); - let (response_tx, _response_rx) = mpsc::channel(); - - // TODO: Set up request handler with FFI callback - // This would call into C to register a handler, which then - // sends requests to request_tx - - let peer_id = format!("rns:{}", hex::encode(&dest_hash)); - - Ok(Self { - destination, - request_tx, - request_rx, - response_tx, - peer_id, - link_id: [0u8; 16], // Will be set per-link - }) - } - - pub fn set_link_id(&mut self, link_id: [u8; 16]) { - self.link_id = link_id; - } - - fn compute_bind_values(&self) -> TransportBindValues { - let dest_hash = self.destination.hash().expect("Failed to get destination hash"); - TransportBindValues { - h: bind_h(&dest_hash, &self.link_id), - server_static: bind_server_static(&dest_hash), - } - } -} - -impl Transport for RnsTransport { - fn read_frame(&mut self) -> anyhow::Result<(u8, Vec)> { - let request = self.request_rx.recv()?; - Ok((request.op, request.body)) - } - - fn write_frame(&mut self, op: u8, body: &[u8]) -> anyhow::Result<()> { - // In RNS, responses go back through the link - // This would be handled by the FFI callback - Ok(()) - } - - fn peer_id(&self) -> &str { - &self.peer_id - } - - fn bind_values(&self) -> TransportBindValues { - self.compute_bind_values() - } -} - -fn bind_h(destination: &[u8; 16], link_id: &[u8; 16]) -> [u8; 32] { - let mut h = Sha256::new(); - h.update(b"smolmail/1 bind"); - h.update(destination); - h.update(link_id); - h.finalize().into() -} - -fn bind_server_static(destination: &[u8; 16]) -> [u8; 32] { - let mut h = Sha256::new(); - h.update(b"smolmail/1 bind"); - h.update(destination); - h.finalize().into() -} -``` - -#### 2.6 Create `src/rns_server.rs` - -```rust -//! RNS server implementation - -use std::sync::Arc; -use std::time::Duration; - -use crate::rns::transport::RnsTransport; -use crate::session::{ServerConfig, Session}; -use crate::store::Store; -use crate::transport::Transport; - -pub struct RnsServeArgs { - pub key_path: String, - pub db_path: String, - pub max_envelope: usize, - pub fetch_budget: usize, - pub quota: i64, - pub requests_quota: i64, - pub retention_days: i64, - pub requests_retention_days: i64, - pub max_tokens: u16, - pub invite_token: Option, - pub max_links: u32, - pub rate_link_requests: u32, - pub rate_link_bytes: u64, -} - -pub fn run(args: RnsServeArgs) -> anyhow::Result<()> { - // Load RNS identity - let identity = crate::rns::ffi::Identity::from_file(&args.key_path)?; - let dest_hash = identity.destination_hash("smolmail", "server")?; - - log::info!("RNS server destination: {}", hex::encode(&dest_hash)); - - // Compute max request size per RNS.md S13.5 - let max_request_size = max_request_bytes(args.max_envelope, args.max_tokens); - - // Create transport - let transport = RnsTransport::new(identity, max_request_size)?; - - // Build config (without IP-based rate limiting) - let config = Arc::new(ServerConfig { - max_envelope: args.max_envelope, - main_quota: args.quota, - requests_quota: args.requests_quota, - max_tokens: args.max_tokens, - invite_token: args.invite_token.map(String::into_bytes), - // RNS uses per-link rate limiting instead of per-IP - conn_limiter: crate::ratelimit::RateLimiter::new(args.max_links as u32), - send_limiter: crate::ratelimit::RateLimiter::new(args.rate_link_requests), - token_limiter: crate::ratelimit::RateLimiter::new(args.rate_link_requests), - }); - - // Start purge loop - let purge_db_path = args.db_path.clone(); - let main_retention_secs = args.retention_days * 86400; - let requests_retention_secs = args.requests_retention_days * 86400; - std::thread::spawn(move || { - crate::server::purge_loop(purge_db_path, main_retention_secs, requests_retention_secs) - }); - - // Main loop - handle incoming links - // In practice, microReticulum would call us via FFI callback - // This is a placeholder for the actual implementation - loop { - // Wait for link establishment via FFI callback - // For each link: - // 1. Get link_id - // 2. Create session with bind_values for this link - // 3. Process requests on the link - // 4. Clean up on link close - - std::thread::sleep(Duration::from_secs(1)); - } -} - -fn max_request_bytes(max_envelope: usize, max_accepted: u16) -> usize { - // From RNS.md S13.5: greater of envelope+34 or AUTH with full tokens - let send_max = 1 + 1 + 32 + max_envelope; // type + mac_len + mac + envelope - let auth_max = 1 + 1 + 63 + 32 + 64 + 1 + 2 + 32 * max_accepted as usize; - std::cmp::max(send_max, auth_max) -} -``` - ---- - -### Phase 3: Update NixOS Module - -```nix -# In flake.nix nixosModules.default -{ - options.services.bunshin = { - # ... existing options ... - - enableRns = mkOption { - type = types.bool; - default = false; - description = "Enable RNS transport support"; - }; - - rnsKeyFile = mkOption { - type = types.path; - description = '' - Path to the server's RNS identity key file. - Generated with `bunshin rns keygen`. - ''; - }; - - rnsMaxEnvelope = mkOption { - type = types.ints.positive; - default = 32768; - description = "Max envelope size for RNS transport (bytes). Recommended: 32 KiB"; - }; - - rnsFetchBudget = mkOption { - type = types.ints.positive; - default = 32768; - description = "Fetch budget for RNS transport (bytes). Recommended: 32 KiB"; - }; - - rnsMaxLinks = mkOption { - type = types.ints.positive; - default = 100; - description = "Maximum concurrent RNS links"; - }; - - rnsRateLinkRequests = mkOption { - type = types.ints.positive; - default = 60; - description = "Max requests per link per minute"; - }; - - rnsRateLinkBytes = mkOption { - type = types.ints.positive; - default = 1048576; # 1 MiB - description = "Max bytes per link per minute"; - }; - }; - - config = mkIf cfg.enable { - # ... existing config ... - - # Add RNS service if enabled - systemd.services.bunshin-rns = mkIf cfg.enableRns { - description = "bunshin Smol Mail server (RNS transport)"; - wantedBy = [ "multi-user.target" ]; - after = [ "network.target" ]; - - serviceConfig = { - ExecStart = pkgs.writeShellScript "bunshin-rns-serve" '' - args=( - rns-serve - --key ${cfg.rnsKeyFile} - --db ${cfg.dataDir}/mail.db - --max-envelope ${toString cfg.rnsMaxEnvelope} - --fetch-budget ${toString cfg.rnsFetchBudget} - --quota ${toString cfg.quota} - --requests-quota ${toString cfg.requestsQuota} - --retention-days ${toString cfg.retentionDays} - --requests-retention-days ${toString cfg.requestsRetentionDays} - --max-tokens ${toString cfg.maxTokens} - --max-links ${toString cfg.rnsMaxLinks} - --rate-link-requests ${toString cfg.rnsRateLinkRequests} - --rate-link-bytes ${toString cfg.rnsRateLinkBytes} - ) - ${lib.optionalString (cfg.inviteToken != null) - ''args+=(--invite-token ${lib.escapeShellArg cfg.inviteToken})''} - exec ${cfg.package}/bin/bunshin "''${args[@]}" - ''; - DynamicUser = true; - Restart = "on-failure"; - }; - }; - }; -} -``` - ---- - -## Implementation Priority - -| Priority | Phase | Task | Dependencies | -|----------|-------|------|--------------| -| High | 1 | Transport trait abstraction | None | -| High | 1 | Refactor TCP transport | None | -| High | 1 | Update session.rs to use bind_values | None | -| High | 1 | Test Phase 1 | None | -| Medium | 2 | Add RNS CLI commands | None | -| Medium | 2 | Create FFI module structure | microReticulum | -| Medium | 2 | Implement RNS transport | microReticulum | -| Medium | 2 | Create RNS server | microReticulum | -| Low | 3 | Update NixOS module | Phase 2 | -| Low | 3 | Documentation | All above | - ---- - -## Testing Strategy - -### Phase 1 Tests -- ✅ All existing tests still pass -- ✅ TCP transport produces same results as before - -### Phase 2+ Tests -- ✅ Bind value computation matches RNS.md specification -- ✅ Interoperability with `smolmail_rns.py` reference client -- ✅ Shared store between TCP and RNS transports -- ✅ Rate limiting per link -- ✅ Destination hash validation - ---- - -## Compatibility Notes - -### Backward Compatibility -- ✅ TCP transport (v1.1) remains fully functional -- ✅ A server can offer both transports simultaneously -- ✅ Same database works for both transports (RNS.md S15) -- ✅ v1.1 clients work with v1.2 servers over TCP (no negotiation needed) - -### Forward Compatibility -- ✅ v1.2 adds the `smol+rns://` scheme -- ✅ RNS transport uses the same operations and status codes -- ✅ Only AUTH and REGISTER signature bindings differ between transports - ---- - -## File Changes Summary - -| File | Action | Phase | -|------|--------|-------| -| `src/transport.rs` | Create | 1 | -| `src/tcp_transport.rs` | Create (from channel.rs) | 1 | -| `src/session.rs` | Modify | 1 | -| `src/server.rs` | Modify | 1 | -| `src/main.rs` | Modify | 1-2 | -| `src/channel.rs` | Deprecate/Delete | 1 | -| `Cargo.toml` | Modify | 2 | -| `flake.nix` | Modify | 2-3 | -| `src/rns/mod.rs` | Create | 2 | -| `src/rns/ffi.rs` | Create | 2 | -| `src/rns/transport.rs` | Create | 2 | -| `src/rns_server.rs` | Create | 2 | - ---- - -## Open Questions - -1. **microReticulum C API stability**: Need to verify the C API exists and is stable enough for FFI -2. **Callback handling**: How to safely handle C callbacks that invoke Rust code -3. **Threading model**: microReticulum uses its own threads; need to ensure thread safety -4. **Error handling**: Map microReticulum error codes to Rust error types - ---- - -## Next Steps - -1. **Immediate**: Implement Phase 1 (transport abstraction) - no dependencies, high value -2. **Parallel**: Investigate microReticulum C API documentation and stability -3. **Decision**: Choose RNS integration approach (FFI vs IPC vs wait) -4. **Phase 2**: Implement chosen approach -5. **Test**: Verify with reference Python implementation - ---- - -## References - -- [Reticulum Network Stack](https://reticulum.network/) -- [microReticulum (C++)](https://github.com/attermann/microReticulum) -- [Smol Mail RNS Specification (RNS.md)](https://code.randogoth.com/randogoth/smolmail/src/branch/main/RNS.md) -- [smolmaild_rns.py - Reference RNS server](https://code.randogoth.com/randogoth/smolmail/src/branch/main/smolmaild_rns.py) -- [smolmail_rns.py - Reference RNS client](https://code.randogoth.com/randogoth/smolmail/src/branch/main/smolmail_rns.py) +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. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..9f5f2af --- /dev/null +++ b/build.rs @@ -0,0 +1,182 @@ +//! Builds the microReticulum carrier shim when the `rns` feature is on. +//! +//! The library source is not vendored into this repository: the environment +//! must point at a checkout via MICRORETICULUM_SOURCE_DIR (the Nix flake and +//! the dev shell both set it). The source tree is copied into OUT_DIR and +//! patched there — a one-line `` include the upstream master needs +//! under current libstdc++/libc++ — because the input may be read-only and +//! both build paths should compile identical sources. + +#[cfg(feature = "rns")] +fn main() { + println!("cargo:rerun-if-changed=shim/smolmail_rns.cpp"); + println!("cargo:rerun-if-changed=shim/udp_interface.cpp"); + println!("cargo:rerun-if-env-changed=MICRORETICULUM_SOURCE_DIR"); + for dep in DEP_VARS { + println!("cargo:rerun-if-env-changed={dep}"); + } + + let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let source = std::env::var("MICRORETICULUM_SOURCE_DIR") + .expect("the rns feature requires MICRORETICULUM_SOURCE_DIR (a microReticulum checkout)"); + + // Copy the parts cmake needs: the root CMakeLists.txt and src/. + let copy = out_dir.join("microReticulum"); + copy_tree( + std::path::Path::new(&source), + ©, + &["CMakeLists.txt", "src", "LICENSE"], + ); + + // Upstream master (2026-07-20) relies on transitive includes + // that libstdc++ 15 dropped; add the include itself. + let memory_h = copy.join("src/microReticulum/Utilities/Memory.h"); + let text = std::fs::read_to_string(&memory_h).expect("Memory.h readable"); + if !text.contains("#include ") { + let patched = text.replacen( + "#include ", + "#include \n#include ", + 1, + ); + std::fs::write(&memory_h, patched).expect("Memory.h writable"); + } + + // Configure + build the library. Dependency overrides (RNS_*_SOURCE_DIR) + // are passed straight through so a sandboxed build never fetches. + let build = out_dir.join("rns-build"); + let mut configure = std::process::Command::new("cmake"); + configure + .arg("-S") + .arg(©) + .arg("-B") + .arg(&build) + .arg("-DCMAKE_BUILD_TYPE=Release") + // The NixOS gcc wrapper injects -Werror=format-security, which + // errors once the project's -Wno-format disables -Wformat. + .arg("-DCMAKE_C_FLAGS=-Wno-error=format-security") + .arg("-DCMAKE_CXX_FLAGS=-Wno-error=format-security") + .arg("-DRNS_BUILD_TESTS=OFF") + .arg("-DRNS_BUILD_EXAMPLES=OFF") + .arg("-DRNS_BUILD_INTEROP=OFF") + .arg("-DRNS_USE_FS=ON") + .arg("-DRNS_PERSIST_PATHS=OFF") + .arg("-DRNS_PERSIST_KNOWN_DESTINATIONS=OFF") + .arg("-DRNS_PERSIST_HASHLIST=OFF"); + for dep in DEP_VARS { + if let Ok(path) = std::env::var(dep) { + configure.arg(format!("-D{dep}={path}")); + } + } + let status = configure.status().expect("cmake configure"); + assert!(status.success(), "cmake configure failed"); + let status = std::process::Command::new("cmake") + .arg("--build") + .arg(&build) + .arg("--target") + .arg("microReticulum") + .status() + .expect("cmake build"); + assert!(status.success(), "cmake build failed"); + + // The same include roots and defines the library's CMake target exports + // publicly, mirrored so the shim TU sees identical macros (the upstream + // CMakeLists warns about ODR divergence otherwise). A vendored dep (env + // override) lives at its checkout root; a fetched one under _deps. + let deps = build.join("_deps"); + let mut includes: Vec = vec![copy.join("src")]; + for (env_var, fetch_name, sub) in DEP_INCLUDES { + includes.push(match std::env::var(env_var) { + Ok(root) => std::path::Path::new(&root).join(sub), + Err(_) => deps.join(format!("{fetch_name}-src")).join(sub), + }); + } + + let mut build_cc = cc::Build::new(); + build_cc + .cpp(true) + .std("c++17") + .define("NATIVE", None) + .define("USTORE_USE_UNIVERSALFS", None) + .define("RNS_USE_FS", None) + .file("shim/smolmail_rns.cpp") + .file("shim/udp_interface.cpp"); + for include in &includes { + build_cc.include(include); + } + build_cc.compile("smolmail_rns_shim"); + + println!("cargo:rustc-link-search=native={}", build.display()); + println!("cargo:rustc-link-lib=static=microReticulum"); + println!("cargo:rustc-link-lib=static=CryptoLib"); +} + +// microReticulum's own local-checkout override variables. +#[cfg(feature = "rns")] +const DEP_VARS: &[&str] = &[ + "RNS_ARDUINOJSON_SOURCE_DIR", + "RNS_MSGPACK_SOURCE_DIR", + "RNS_CRYPTO_SOURCE_DIR", + "RNS_MICROSTORE_SOURCE_DIR", + "RNS_ARXCONTAINER_SOURCE_DIR", + "RNS_ARXTYPETRAITS_SOURCE_DIR", + "RNS_DEBUGLOG_SOURCE_DIR", +]; + +// (override env var, FetchContent name, include subdir within the dep root). +#[cfg(feature = "rns")] +const DEP_INCLUDES: &[(&str, &str, &str)] = &[ + ("RNS_ARDUINOJSON_SOURCE_DIR", "arduinojson", "src"), + ("RNS_MSGPACK_SOURCE_DIR", "msgpack", ""), + ("RNS_ARXCONTAINER_SOURCE_DIR", "arxcontainer", ""), + ("RNS_ARXTYPETRAITS_SOURCE_DIR", "arxtypetraits", ""), + ("RNS_DEBUGLOG_SOURCE_DIR", "debuglog", ""), + ("RNS_CRYPTO_SOURCE_DIR", "crypto", ""), + ("RNS_MICROSTORE_SOURCE_DIR", "microstore", "include"), +]; + +#[cfg(feature = "rns")] +fn copy_tree(src: &std::path::Path, dst: &std::path::Path, entries: &[&str]) { + std::fs::create_dir_all(dst).expect("OUT_DIR writable"); + for entry in entries { + let from = src.join(entry); + if !from.exists() { + continue; + } + let to = dst.join(entry); + if from.is_dir() { + copy_dir(&from, &to); + } else { + copy_file(&from, &to); + } + } +} + +#[cfg(feature = "rns")] +fn copy_dir(src: &std::path::Path, dst: &std::path::Path) { + std::fs::create_dir_all(dst).expect("OUT_DIR writable"); + for entry in std::fs::read_dir(src).expect("readable source dir") { + let entry = entry.expect("readable source dir").path(); + let to = dst.join(entry.file_name().unwrap()); + if entry.is_dir() { + copy_dir(&entry, &to); + } else { + copy_file(&entry, &to); + } + } +} + +/// fs::copy preserves the source's mode, and a Nix store checkout is +/// read-only, so the copy must be made writable before the patch step. +#[cfg(feature = "rns")] +fn copy_file(from: &std::path::Path, to: &std::path::Path) { + std::fs::copy(from, to).expect("copy file"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(to, std::fs::Permissions::from_mode(0o644)) + .expect("copied file writable"); + } +} + +#[cfg(not(feature = "rns"))] +fn main() {} diff --git a/flake.lock b/flake.lock index c4536ba..4880ea0 100644 --- a/flake.lock +++ b/flake.lock @@ -18,6 +18,23 @@ "type": "github" } }, + "microReticulum": { + "flake": false, + "locked": { + "lastModified": 1784575896, + "narHash": "sha256-cqo+BJ3tsmw4fD+SL0D3X9FKCgf+n0VQng2pSIuyK/8=", + "owner": "attermann", + "repo": "microReticulum", + "rev": "40fa628809d57140180c1c833559ab96fec992c1", + "type": "github" + }, + "original": { + "owner": "attermann", + "repo": "microReticulum", + "rev": "40fa628809d57140180c1c833559ab96fec992c1", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1790323409, @@ -34,10 +51,137 @@ "type": "github" } }, + "rns-arduinojson": { + "flake": false, + "locked": { + "lastModified": 1750405034, + "narHash": "sha256-MGspSk9zWdPHMFXm+Oi4sibznHYE1eKXSqi6QHmjVCk=", + "owner": "bblanchon", + "repo": "ArduinoJson", + "rev": "ed69feadb95182dc53ac978975d310122060a767", + "type": "github" + }, + "original": { + "owner": "bblanchon", + "repo": "ArduinoJson", + "rev": "ed69feadb95182dc53ac978975d310122060a767", + "type": "github" + } + }, + "rns-arxcontainer": { + "flake": false, + "locked": { + "lastModified": 1718955529, + "narHash": "sha256-3XzdM1fNl7irhjWF6hnbn+iw+orOox4KY5ezk1SC0Lg=", + "owner": "hideakitai", + "repo": "ArxContainer", + "rev": "d6affcd0bc83219b863c20abf7c269214db8db2a", + "type": "github" + }, + "original": { + "owner": "hideakitai", + "repo": "ArxContainer", + "rev": "d6affcd0bc83219b863c20abf7c269214db8db2a", + "type": "github" + } + }, + "rns-arxtypetraits": { + "flake": false, + "locked": { + "lastModified": 1760475334, + "narHash": "sha256-rKW85Pt4NsbYDesnwJKSrx6F2bq4ZDSU+7DpRMvH1R0=", + "owner": "hideakitai", + "repo": "ArxTypeTraits", + "rev": "702de9cc59c7e047cdc169ae3547718b289d2c02", + "type": "github" + }, + "original": { + "owner": "hideakitai", + "repo": "ArxTypeTraits", + "rev": "702de9cc59c7e047cdc169ae3547718b289d2c02", + "type": "github" + } + }, + "rns-crypto": { + "flake": false, + "locked": { + "lastModified": 1779476496, + "narHash": "sha256-bv0Hr+1sp9nCERZSmhCYv69C2zn+Jk2N3pZVUXquRcw=", + "owner": "attermann", + "repo": "Crypto", + "rev": "984dc891330986c302a86c4e312d4f5abcc28359", + "type": "github" + }, + "original": { + "owner": "attermann", + "repo": "Crypto", + "rev": "984dc891330986c302a86c4e312d4f5abcc28359", + "type": "github" + } + }, + "rns-debuglog": { + "flake": false, + "locked": { + "lastModified": 1731116852, + "narHash": "sha256-vu4jfXY9ulaEFQzv1VJahzBN3ii+8F7AyOxsnKRzjv4=", + "owner": "hideakitai", + "repo": "DebugLog", + "rev": "b581f7dde6c276c5df684e2328f406d9754d2f46", + "type": "github" + }, + "original": { + "owner": "hideakitai", + "repo": "DebugLog", + "rev": "b581f7dde6c276c5df684e2328f406d9754d2f46", + "type": "github" + } + }, + "rns-microstore": { + "flake": false, + "locked": { + "lastModified": 1784473031, + "narHash": "sha256-gY/r5q4tPDfIj3I0E2ZVj0URPjo1tlQj37Ctg9NQoYQ=", + "owner": "attermann", + "repo": "microStore", + "rev": "0f28567fe00ab8ab14624a34c2e9e0a000a44c46", + "type": "github" + }, + "original": { + "owner": "attermann", + "repo": "microStore", + "rev": "0f28567fe00ab8ab14624a34c2e9e0a000a44c46", + "type": "github" + } + }, + "rns-msgpack": { + "flake": false, + "locked": { + "lastModified": 1707898892, + "narHash": "sha256-Zn3cwUaW2RMvOyvpcA1JxHQk3f3X7m2yZ8ilRAwgxNI=", + "owner": "hideakitai", + "repo": "MsgPack", + "rev": "1f552c31b940d6e9063ee17a4b3fa10c47b27169", + "type": "github" + }, + "original": { + "owner": "hideakitai", + "repo": "MsgPack", + "rev": "1f552c31b940d6e9063ee17a4b3fa10c47b27169", + "type": "github" + } + }, "root": { "inputs": { "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" + "microReticulum": "microReticulum", + "nixpkgs": "nixpkgs", + "rns-arduinojson": "rns-arduinojson", + "rns-arxcontainer": "rns-arxcontainer", + "rns-arxtypetraits": "rns-arxtypetraits", + "rns-crypto": "rns-crypto", + "rns-debuglog": "rns-debuglog", + "rns-microstore": "rns-microstore", + "rns-msgpack": "rns-msgpack" } }, "systems": { diff --git a/flake.nix b/flake.nix index c8a13f6..2ff3679 100644 --- a/flake.nix +++ b/flake.nix @@ -4,32 +4,96 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; + + # RNS carrier sources (RNS.md sec 8): the build sandbox has no network, + # so every microReticulum FetchContent dependency is vendored as its own + # flake input and handed over via RNS__SOURCE_DIR. microReticulum + # is pinned at the commit RNS.md sec 5 verified against. + microReticulum = { + url = "github:attermann/microReticulum/40fa628809d57140180c1c833559ab96fec992c1"; + flake = false; + }; + rns-arduinojson = { + url = "github:bblanchon/ArduinoJson/ed69feadb95182dc53ac978975d310122060a767"; + flake = false; + }; + rns-msgpack = { + url = "github:hideakitai/MsgPack/1f552c31b940d6e9063ee17a4b3fa10c47b27169"; + flake = false; + }; + rns-arxcontainer = { + url = "github:hideakitai/ArxContainer/d6affcd0bc83219b863c20abf7c269214db8db2a"; + flake = false; + }; + rns-arxtypetraits = { + url = "github:hideakitai/ArxTypeTraits/702de9cc59c7e047cdc169ae3547718b289d2c02"; + flake = false; + }; + rns-debuglog = { + url = "github:hideakitai/DebugLog/b581f7dde6c276c5df684e2328f406d9754d2f46"; + flake = false; + }; + rns-crypto = { + url = "github:attermann/Crypto/984dc891330986c302a86c4e312d4f5abcc28359"; + flake = false; + }; + rns-microstore = { + url = "github:attermann/microStore/0f28567fe00ab8ab14624a34c2e9e0a000a44c46"; + flake = false; + }; }; - outputs = { self, nixpkgs, flake-utils }: + outputs = { self, nixpkgs, flake-utils, microReticulum, rns-arduinojson + , rns-msgpack, rns-arxcontainer, rns-arxtypetraits, rns-debuglog + , rns-crypto, rns-microstore }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; }; + inherit (pkgs) lib; - bunshin = pkgs.rustPlatform.buildRustPackage { + # build.rs consumes these directly (RNS.md sec 7.1); with every + # dependency vendored, the cmake configure step never fetches. + rnsSourceEnv = { + MICRORETICULUM_SOURCE_DIR = "${microReticulum}"; + RNS_ARDUINOJSON_SOURCE_DIR = "${rns-arduinojson}"; + RNS_MSGPACK_SOURCE_DIR = "${rns-msgpack}"; + RNS_ARXCONTAINER_SOURCE_DIR = "${rns-arxcontainer}"; + RNS_ARXTYPETRAITS_SOURCE_DIR = "${rns-arxtypetraits}"; + RNS_DEBUGLOG_SOURCE_DIR = "${rns-debuglog}"; + RNS_CRYPTO_SOURCE_DIR = "${rns-crypto}"; + RNS_MICROSTORE_SOURCE_DIR = "${rns-microstore}"; + }; + + bunshin = { rns ? false }: pkgs.rustPlatform.buildRustPackage { pname = "bunshin"; version = "0.1.0"; src = ./.; cargoLock.lockFile = ./Cargo.lock; - nativeBuildInputs = [ pkgs.pkg-config ]; + nativeBuildInputs = [ pkgs.pkg-config ] + ++ lib.optionals rns [ pkgs.cmake pkgs.ninja pkgs.gcc ]; buildInputs = [ pkgs.sqlite ]; + buildFeatures = lib.optionals rns [ "rns" ]; + env = lib.optionalAttrs rns rnsSourceEnv; + # ninja's setup hook claims buildPhase before the cargo hooks + # can, which would run ninja against no build.ninja instead of + # cargo; ninja here is only for build.rs to invoke through cmake. + dontUseNinjaBuild = rns; + dontUseNinjaCheck = rns; + dontUseNinjaInstall = rns; }; in { - packages.default = bunshin; + packages.default = bunshin { }; + packages.rns = bunshin { rns = true; }; apps.default = flake-utils.lib.mkApp { - drv = bunshin; + drv = bunshin { }; name = "bunshin"; }; devShells.default = pkgs.mkShell { - packages = [ pkgs.cargo pkgs.rustc pkgs.rustfmt pkgs.clippy pkgs.pkg-config pkgs.gcc pkgs.sqlite ]; + packages = with pkgs; [ cargo rustc rustfmt clippy pkg-config gcc sqlite cmake ninja ]; + env = rnsSourceEnv; }; }) // { nixosModules.default = { config, lib, pkgs, ... }: @@ -44,7 +108,10 @@ package = mkOption { type = types.package; default = self.packages.${pkgs.system}.default; - description = "bunshin package to run."; + description = '' + bunshin package to run. Use `packages.rns` when the RNS + carrier is enabled: the default package is built without it. + ''; }; host = mkOption { @@ -152,6 +219,72 @@ default = false; description = "Open the configured TCP port in the firewall."; }; + + rns = { + enable = mkEnableOption "the RNS carrier alongside TCP (needs an rns-built package)"; + + keyFile = mkOption { + type = types.path; + description = '' + Path to the server's Reticulum identity (64 raw bytes, + x25519 || ed25519 private halves, generated with + `bunshin rns-keygen`). RNS writes the private key + unencrypted, so protect the file like any long-term key. + ''; + }; + + maxEnvelope = mkOption { + type = types.ints.positive; + default = 32768; + description = "Maximum accepted envelope size over RNS, in bytes (RNS.md sec 3)."; + }; + + fetchBudget = mkOption { + type = types.ints.positive; + default = 32768; + description = "Bytes one FETCH response may total over RNS."; + }; + + maxLinks = mkOption { + type = types.ints.positive; + default = 100; + description = "Maximum concurrent Reticulum links; further links are refused."; + }; + + rateLinkRequests = mkOption { + type = types.ints.positive; + default = 60; + description = "Max requests per minute, per link."; + }; + + rateLinkBytes = mkOption { + type = types.ints.positive; + default = 1048576; + description = "Max request bytes per minute, per link."; + }; + + udpListenPort = mkOption { + type = types.port; + default = 4242; + description = '' + UDP port the Reticulum interface listens on. RNS reaches + the mesh through a configured interface rather than a + listening TCP port; only this UDP port needs a firewall + opening. + ''; + }; + + udpForward = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Optional host[:port] the interface forwards every packet + to. Without it, replies go to the source address of the + last datagram received, which suits a listen-only + point-to-point setup. + ''; + }; + }; }; config = mkIf cfg.enable { @@ -186,6 +319,20 @@ --rate-sends ${toString cfg.rateSends} --rate-tokens ${toString cfg.rateTokens} ) + ${lib.optionalString cfg.rns.enable '' + args+=( + --rns + --rns-key ${cfg.rns.keyFile} + --rns-max-envelope ${toString cfg.rns.maxEnvelope} + --rns-fetch-budget ${toString cfg.rns.fetchBudget} + --rns-max-links ${toString cfg.rns.maxLinks} + --rns-rate-link-requests ${toString cfg.rns.rateLinkRequests} + --rns-rate-link-bytes ${toString cfg.rns.rateLinkBytes} + --rns-udp 0.0.0.0:${toString cfg.rns.udpListenPort} + ) + ''} + ${lib.optionalString (cfg.rns.enable && cfg.rns.udpForward != null) + ''args+=(--rns-udp-forward ${lib.escapeShellArg cfg.rns.udpForward})''} ${lib.optionalString (cfg.inviteToken != null) ''args+=(--invite-token ${lib.escapeShellArg cfg.inviteToken})''} ${lib.optionalString (cfg.inviteTokenFile != null) @@ -201,6 +348,9 @@ }; }; + # RNS needs no TCP port; only its UDP interface may be opened. + networking.firewall.allowedUDPPorts = + mkIf (cfg.rns.enable && cfg.openFirewall) [ cfg.rns.udpListenPort ]; networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ]; }; }; diff --git a/shim/smolmail_rns.cpp b/shim/smolmail_rns.cpp new file mode 100644 index 0000000..847fd25 --- /dev/null +++ b/shim/smolmail_rns.cpp @@ -0,0 +1,178 @@ +/* + * microReticulum bridge for the bunshin RNS carrier (RNS.md sec 7). + * + * Owns the Reticulum instance, the smolmail.server IN/SINGLE destination + * with its request handler, and the loop thread that drives + * Reticulum::loop(). Every Rust callback fires on that thread; per-link + * teardowns decided inside a callback are deferred until after the current + * loop iteration so they never re-enter transport processing. + */ +#include "smolmail_rns.h" +#include "udp_interface.h" + +#include +#include +#include + +#include + +#include +#include +#include +#include + +// Anchored in src/rns.rs. +static const char* APP_NAME = "smolmail"; +static const char* APP_ASPECT = "server"; +static const char* REQ_PATH = "smolmail/1"; + +// Interfaces rate-limit announces to roughly one an hour (RNS.md sec 1); +// every two hours stays well under that ceiling (upstream spec 13.1). +static const double ANNOUNCE_INTERVAL_SECS = 2.0 * 3600.0; +static const double LOOP_SLEEP_SECS = 0.01; + +static RNS::Reticulum reticulum({RNS::Type::NONE}); +static RNS::Interface udp_interface({RNS::Type::NONE}); +static RNS::Destination destination({RNS::Type::NONE}); +static microStore::FileSystem filesystem{microStore::Adapters::UniversalFileSystem()}; + +static std::mutex teardown_mutex; +static std::vector pending_teardowns; +static volatile bool running = false; + +static void on_link_closed(RNS::Link& link) { + const RNS::Bytes& id = link.link_id(); + smolmail_rns_on_link_closed(id.data()); +} + +static void on_link_established(RNS::Link& link) { + // link_id is the map key on the Rust side; the closed callback evicts. + link.set_link_closed_callback(on_link_closed); + const RNS::Bytes& id = link.link_id(); + if (smolmail_rns_on_link_opened(id.data()) != 0) { + std::lock_guard lock(teardown_mutex); + pending_teardowns.push_back(link); + } +} + +// The request data arrives msgpack-bin-wrapped: Link::handle_request splices +// the generator's return value verbatim into the response envelope, so both +// directions must carry the smolmail payload as a msgpack binary. +static RNS::Bytes handle_smolmail_request(const RNS::Bytes& path, const RNS::Bytes& data, + const RNS::Bytes& request_id, const RNS::Bytes& link_id, + const RNS::Identity& remote_identity, double requested_at) { + (void)path; (void)request_id; (void)remote_identity; (void)requested_at; + + RNS::Bytes request; + { + MsgPack::Unpacker u; + u.feed(data.data(), data.size()); + if (u.isBin()) { + MsgPack::bin_t bin; + u.deserialize(bin); + request = RNS::Bytes(bin.data(), bin.size()); + } + } + + size_t len = 0; + if (request) { + len = smolmail_rns_on_request(request.data(), request.size(), link_id.data()); + } + RNS::Bytes response(len); + if (len > 0) { + smolmail_rns_take_response(response.writable(len), len); + } + else { + // The request was not a msgpack binary, or Rust produced nothing; + // always answer (upstream spec 13.5) with MALFORMED. + response = RNS::Bytes(1); + response.writable(1)[0] = 1; + } + + MsgPack::Packer p; + p.packBinary(response.data(), response.size()); + return RNS::Bytes(p.data(), p.size()); +} + +static void loop_thread_main() { + double last_announce = RNS::Utilities::OS::time(); + destination.announce(); + while (running) { + reticulum.loop(); + + std::vector teardowns; + { + std::lock_guard lock(teardown_mutex); + teardowns.swap(pending_teardowns); + } + for (RNS::Link& link : teardowns) { + link.teardown(); + } + + double now = RNS::Utilities::OS::time(); + if (now - last_announce >= ANNOUNCE_INTERVAL_SECS) { + destination.announce(); + last_announce = now; + } + RNS::Utilities::OS::sleep(LOOP_SLEEP_SECS); + } +} + +extern "C" int smolmail_rns_destination_hash(const uint8_t* identity, + uint8_t* destination_hash_out) { + RNS::Identity rns_identity(false); + if (!rns_identity.load_private_key(RNS::Bytes(identity, 64))) { + return -1; + } + const RNS::Bytes& hash = RNS::Destination::hash(rns_identity, APP_NAME, APP_ASPECT); + memcpy(destination_hash_out, hash.data(), 16); + return 0; +} + +extern "C" int smolmail_rns_start(const uint8_t* identity, + const char* storage_dir, + const char* udp_listen_host, uint16_t udp_listen_port, + const char* udp_forward_host, uint16_t udp_forward_port, + uint8_t* destination_hash_out) { + if (running) { + return -1; + } + + // Registered before anything else, as the interop examples do, so + // persistence and identity writes have a filesystem to go through. + filesystem.init(); + RNS::Utilities::OS::register_filesystem(filesystem); + RNS::Reticulum::storagepath(storage_dir); + + udp_interface = new UDPInterface("smolmail_rns_udp", + udp_listen_host, udp_listen_port, + udp_forward_host ? udp_forward_host : "", + udp_forward_port); + udp_interface.mode(RNS::Type::Interface::MODE_GATEWAY); + RNS::Transport::register_interface(udp_interface); + if (!udp_interface.start()) { + return -2; + } + + reticulum = RNS::Reticulum(); + reticulum.transport_enabled(false); + reticulum.start(); + + RNS::Identity rns_identity(false); + if (!rns_identity.load_private_key(RNS::Bytes(identity, 64))) { + return -3; + } + + destination = RNS::Destination(rns_identity, RNS::Type::Destination::IN, + RNS::Type::Destination::SINGLE, APP_NAME, APP_ASPECT); + destination.accepts_links(true); + destination.register_request_handler(RNS::Bytes(REQ_PATH), handle_smolmail_request, + RNS::Type::Destination::ALLOW_ALL); + destination.set_link_established_callback(on_link_established); + + memcpy(destination_hash_out, destination.hash().data(), 16); + + running = true; + std::thread(loop_thread_main).detach(); + return 0; +} diff --git a/shim/smolmail_rns.h b/shim/smolmail_rns.h new file mode 100644 index 0000000..6752b94 --- /dev/null +++ b/shim/smolmail_rns.h @@ -0,0 +1,55 @@ +/* + * C ABI between bunshin (Rust) and the microReticulum carrier shim. + * + * The shim owns the Reticulum run loop on a dedicated thread; every callback + * below fires on that thread. link_id is always 16 bytes. + */ +#ifndef SMOLMAIL_RNS_H +#define SMOLMAIL_RNS_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Implemented in smolmail_rns.cpp, called from src/rns.rs. + * identity: 64 bytes, x25519 private (32) || ed25519 private (32), the same + * layout Identity::to_file writes and load_private_key expects. + * udp_forward_host may be NULL: with no forward target the interface sends + * to the source address of the last datagram received. + * Returns 0 on success, negative on failure. */ +int smolmail_rns_start(const uint8_t *identity, + const char *storage_dir, + const char *udp_listen_host, uint16_t udp_listen_port, + const char *udp_forward_host, uint16_t udp_forward_port, + uint8_t *destination_hash_out); + +/* Pure computation, no Reticulum state: the 16-byte destination hash of + * smolmail.server under this identity. microReticulum derives the identity's + * x25519 public key with an unclamped scalar multiplication, which differs + * from RFC 7748, so the hash must come from the library itself rather than + * an independent Rust derivation. */ +int smolmail_rns_destination_hash(const uint8_t *identity, + uint8_t *destination_hash_out); + +/* Implemented in src/rns.rs, called from the shim on the Reticulum loop + * thread. A request is `op u8 || body` (RNS.md sec 1); the response placed + * into the slot is `status u8 || payload`. on_request returns the response + * length and leaves the bytes in the slot; the shim must copy them out with + * take_response before the next on_request call. */ +size_t smolmail_rns_on_request(const uint8_t *request, size_t request_len, + const uint8_t *link_id); +size_t smolmail_rns_take_response(uint8_t *out, size_t cap); + +/* 0 = link admitted, 1 = over the concurrent-link cap (the shim tears the + * link down after the current loop iteration). */ +int smolmail_rns_on_link_opened(const uint8_t *link_id); +void smolmail_rns_on_link_closed(const uint8_t *link_id); + +#ifdef __cplusplus +} +#endif + +#endif /* SMOLMAIL_RNS_H */ diff --git a/shim/udp_interface.cpp b/shim/udp_interface.cpp new file mode 100644 index 0000000..a2c5a4a --- /dev/null +++ b/shim/udp_interface.cpp @@ -0,0 +1,198 @@ +#include "udp_interface.h" + +#include +#include + +#ifndef ARDUINO +#include +#include +#include +#include +#include +#include +#endif + +using namespace RNS; + +UDPInterface::UDPInterface(const char* name, + const std::string& local_host, int local_port, + const std::string& remote_host, int remote_port) + : RNS::InterfaceImpl(name) { + + _IN = true; + _OUT = true; + _bitrate = BITRATE_GUESS; + _HW_MTU = 1064; + + _local_host = local_host; + _local_port = local_port; + if (!remote_host.empty()) { + _forward_configured = true; + _remote_host = remote_host; + _remote_port = remote_port; + } +} + +/*virtual*/ UDPInterface::~UDPInterface() { + stop(); +} + +/*virtual*/ bool UDPInterface::start() { + _online = false; + +#ifdef ARDUINO + udp.begin(_local_port); +#else + // resolve local host + struct in_addr local_addr; + if (inet_aton(_local_host.c_str(), &local_addr) == 0) { + struct hostent* host_ent = gethostbyname(_local_host.c_str()); + if (host_ent == nullptr || host_ent->h_addr_list[0] == nullptr) { + ERRORF("Unable to resolve local host %s", _local_host.c_str()); + return false; + } + _local_address = *((in_addr_t*)(host_ent->h_addr_list[0])); + } + else { + _local_address = local_addr.s_addr; + } + + _remote_address = INADDR_NONE; + if (_forward_configured) { + struct in_addr remote_addr; + if (inet_aton(_remote_host.c_str(), &remote_addr) == 0) { + struct hostent* host_ent = gethostbyname(_remote_host.c_str()); + if (host_ent == nullptr || host_ent->h_addr_list[0] == nullptr) { + ERRORF("Unable to resolve remote host %s", _remote_host.c_str()); + return false; + } + _remote_address = *((in_addr_t*)(host_ent->h_addr_list[0])); + } + else { + _remote_address = remote_addr.s_addr; + } + } + + _socket = socket( PF_INET, SOCK_DGRAM, 0 ); + if (_socket < 0) { + ERRORF("Unable to create socket with error %d", errno); + return false; + } + + int broadcast = 1; + setsockopt(_socket, SOL_SOCKET, SO_BROADCAST, &broadcast, sizeof(broadcast)); + + int reuse = 1; + setsockopt(_socket, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); +#ifdef SO_REUSEPORT + setsockopt(_socket, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse)); +#endif + + INFOF("Binding UDP socket %d to %s:%d", _socket, _local_host.c_str(), _local_port); + sockaddr_in bind_addr; + memset(&bind_addr, 0, sizeof(bind_addr)); + bind_addr.sin_family = AF_INET; + bind_addr.sin_addr.s_addr = _local_address; + bind_addr.sin_port = htons(_local_port); + if (bind(_socket, (struct sockaddr*)&bind_addr, sizeof(bind_addr)) == -1) { + ERRORF("Unable to bind socket with error %d", errno); + close(_socket); + _socket = -1; + return false; + } +#endif + + _online = true; + return true; +} + +/*virtual*/ void UDPInterface::stop() { +#ifndef ARDUINO + if (_socket > -1) { + close(_socket); + _socket = -1; + } +#endif + _online = false; +} + +/*virtual*/ void UDPInterface::loop() { + if (!_online) { + return; + } +#ifdef ARDUINO + udp.parsePacket(); + size_t len = udp.read(_buffer.writable(Type::Reticulum::MTU), Type::Reticulum::MTU); + if (len > 0) { + _buffer.resize(len); + on_incoming(_buffer); + } +#else + // One datagram per recvfrom() with MSG_DONTWAIT, looping until the + // kernel queue is empty — the same drain pattern as the microReticulum + // example, which avoids stale/zero FIONREAD counts on some platforms. + while (true) { + sockaddr_in src_addr{}; + socklen_t src_addr_len = sizeof(src_addr); + ssize_t len = recvfrom(_socket, + _buffer.writable(_HW_MTU), + _HW_MTU, + MSG_DONTWAIT, + (struct sockaddr*)&src_addr, + &src_addr_len); + if (len <= 0) { + break; + } + _buffer.resize(static_cast(len)); + if (!_forward_configured) { + _last_src_addr = src_addr; + _have_src = true; + } + on_incoming(_buffer); + } +#endif +} + +/*virtual*/ bool UDPInterface::send_outgoing(const RNS::Bytes& data) { + bool success = true; + try { + if (_online) { +#ifdef ARDUINO + udp.beginPacket(_remote_host.c_str(), _remote_port); + udp.write(data.data(), data.size()); + udp.endPacket(); +#else + sockaddr_in sock_addr; + if (_forward_configured) { + memset(&sock_addr, 0, sizeof(sock_addr)); + sock_addr.sin_family = AF_INET; + sock_addr.sin_addr.s_addr = _remote_address; + sock_addr.sin_port = htons(_remote_port); + } + else if (_have_src) { + // No forward target: reply to whoever spoke to us last. + sock_addr = _last_src_addr; + } + else { + WARNING("UDPInterface: no forward target and no peer heard from yet, dropping outgoing packet"); + return false; + } + ssize_t sent = sendto(_socket, data.data(), data.size(), 0, (struct sockaddr*)&sock_addr, sizeof(sock_addr)); + if (sent != (ssize_t)data.size()) { + WARNINGF("Failed sending %d bytes via UDP", (int)data.size()); + success = false; + } +#endif + } + InterfaceImpl::handle_outgoing(data); + } + catch (const std::exception& e) { + ERRORF("Could not transmit on %s. The contained exception was: %s", toString().c_str(), e.what()); + success = false; + } + return success; +} + +void UDPInterface::on_incoming(const RNS::Bytes& data) { + InterfaceImpl::handle_incoming(data); +} diff --git a/shim/udp_interface.h b/shim/udp_interface.h new file mode 100644 index 0000000..84c68bd --- /dev/null +++ b/shim/udp_interface.h @@ -0,0 +1,64 @@ +/* + * UDP interface for the bunshin RNS shim, adapted from microReticulum's + * examples/common/udp_interface (Apache-2.0). The example version hardcodes + * compile-time defaults; this one takes its endpoints from the caller, and + * with no forward target configured it sends to the source address of the + * last datagram received, so a listen-only server can answer any client. + */ +#pragma once + +#include +#include +#include + +#ifdef ARDUINO +#include +#include +#else +#include +#endif + +#include +#include + +class UDPInterface : public RNS::InterfaceImpl { + +public: + static const uint32_t BITRATE_GUESS = 10*1000*1000; + + UDPInterface(const char* name, + const std::string& local_host, int local_port, + const std::string& remote_host, int remote_port); + virtual ~UDPInterface(); + + virtual bool start(); + virtual void stop(); + virtual void loop(); + + virtual inline std::string toString() const { return "UDPInterface[" + _name + "/" + _local_host + ":" + std::to_string(_local_port) + "]"; } + +protected: + virtual bool send_outgoing(const RNS::Bytes& data); + void on_incoming(const RNS::Bytes& data); + +private: + RNS::Bytes _buffer; + + std::string _local_host; + int _local_port; + std::string _remote_host; + int _remote_port; + + bool _forward_configured = false; + +#ifdef ARDUINO + WiFiUDP udp; +#else + int _socket = -1; + in_addr_t _local_address = INADDR_ANY; + in_addr_t _remote_address = INADDR_NONE; + sockaddr_in _last_src_addr = {}; + bool _have_src = false; +#endif + +}; diff --git a/src/bind.rs b/src/bind.rs new file mode 100644 index 0000000..2d86412 --- /dev/null +++ b/src/bind.rs @@ -0,0 +1,90 @@ +//! Transport-supplied values that AUTH and REGISTER signatures bind to. +//! +//! Both carriers prove the same thing — that the peer holds the identity key +//! and is talking to this server, not a replayed capture of another — but the +//! inputs differ (RNS.md sec 2), so the session layer consumes this struct +//! instead of a Noise handshake hash. + +#[cfg(any(test, feature = "rns"))] +use crate::crypto::sha256; +use crate::proto::KEY_LEN; + +// Used only by the RNS carrier and the bind-value tests. +#[cfg(any(test, feature = "rns"))] +pub const LABEL_BIND: &[u8] = b"smolmail/1 bind"; + +pub struct TransportBindValues { + pub h: [u8; 32], + pub server_static: [u8; 32], +} + +impl TransportBindValues { + /// Noise binds to the handshake hash and the server's real static key. + pub fn tcp(handshake_hash: &[u8], server_static: &[u8; KEY_LEN]) -> anyhow::Result { + Ok(Self { + h: handshake_hash + .try_into() + .map_err(|_| anyhow::anyhow!("handshake hash not 32 bytes"))?, + server_static: *server_static, + }) + } + + /// RNS has no static key of ours on the wire, so both values are derived + /// from the destination; link_id keeps one link's AUTH from replaying on + /// another (RNS.md sec 2). Neither input is length-prefixed, and both are + /// fixed-width, so concatenation stays unambiguous. + #[cfg(any(test, feature = "rns"))] + pub fn rns(destination: &[u8; 16], link_id: &[u8; 16]) -> Self { + Self { + h: sha256(&[LABEL_BIND, destination, link_id]), + server_static: sha256(&[LABEL_BIND, destination]), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Vectors computed independently over the spec 13.6 formula + // SHA-256("smolmail/1 bind" || destination || link_id). + const DEST: [u8; 16] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, + ]; + const LINK: [u8; 16] = [ + 0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, + 0xaf, + ]; + const H_HEX: &str = "06d6437dc63250ff51d609e12b488ff22b4fa02620f35000b3b2ed1c8789d45c"; + const SERVER_STATIC_HEX: &str = + "da6fe109dc4da2878fa4810ffa0e08cef6b68a8b524126aa05bb14181b20d25a"; + + #[test] + fn rns_matches_reference_vectors() { + let bind = TransportBindValues::rns(&DEST, &LINK); + assert_eq!(data_encoding::HEXLOWER.encode(&bind.h), H_HEX); + assert_eq!( + data_encoding::HEXLOWER.encode(&bind.server_static), + SERVER_STATIC_HEX + ); + } + + #[test] + fn rns_h_differs_per_link_but_server_static_does_not() { + let other = [0xc0u8; 16]; + let a = TransportBindValues::rns(&DEST, &LINK); + let b = TransportBindValues::rns(&DEST, &other); + assert_ne!(a.h, b.h); + assert_eq!(a.server_static, b.server_static); + } + + #[test] + fn tcp_rejects_short_handshake_hash() { + let static_key = [7u8; KEY_LEN]; + assert!(TransportBindValues::tcp(&[1u8; 31], &static_key).is_err()); + let bind = TransportBindValues::tcp(&[2u8; 32], &static_key).unwrap(); + assert_eq!(bind.h, [2u8; 32]); + assert_eq!(bind.server_static, static_key); + } +} diff --git a/src/crypto.rs b/src/crypto.rs index b2981cd..7c8e84e 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -31,6 +31,16 @@ pub fn message_id(envelope: &[u8]) -> [u8; ID_LEN] { out } +/// Multi-part SHA-256; `message_id` spelled generally for the RNS bind values. +#[cfg(any(test, feature = "rns"))] +pub fn sha256(parts: &[&[u8]]) -> [u8; 32] { + let mut hasher = Sha256::new(); + for part in parts { + hasher.update(part); + } + hasher.finalize().into() +} + /// Generates the server's static X25519 keypair (transport identity, distinct /// from any user's Ed25519 identity). Returns (private, public) raw bytes. pub fn generate_static_key() -> ([u8; 32], [u8; 32]) { diff --git a/src/main.rs b/src/main.rs index 5bccf32..34ab05c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,12 @@ //! Smol Mail server. +mod bind; mod channel; mod crypto; mod proto; mod ratelimit; +#[cfg(feature = "rns")] +mod rns; mod server; mod session; mod store; @@ -65,7 +68,45 @@ enum Command { rate_sends: u32, #[arg(long = "rate-tokens", default_value_t = 30)] rate_tokens: u32, + #[cfg(feature = "rns")] + #[command(flatten)] + rns: RnsServeArgs, }, + #[cfg(feature = "rns")] + /// Generate the server's Reticulum identity + RnsKeygen { + #[arg(long, default_value = "server.rns.key")] + key: String, + #[arg(long)] + force: bool, + }, +} + +/// RNS carrier flags (RNS.md sec 7.4), only present with the rns feature. +#[cfg(feature = "rns")] +#[derive(clap::Args)] +struct RnsServeArgs { + /// Serve the RNS carrier alongside TCP + #[arg(long = "rns")] + enabled: bool, + #[arg(long = "rns-key", default_value = "server.rns.key")] + rns_key: String, + #[arg(long = "rns-max-envelope", default_value_t = 32 << 10)] + rns_max_envelope: usize, + #[arg(long = "rns-fetch-budget", default_value_t = 32 << 10)] + rns_fetch_budget: usize, + #[arg(long = "rns-max-links", default_value_t = 100)] + rns_max_links: usize, + #[arg(long = "rns-rate-link-requests", default_value_t = 60)] + rns_rate_link_requests: u32, + #[arg(long = "rns-rate-link-bytes", default_value_t = 1 << 20)] + rns_rate_link_bytes: u64, + /// UDP interface to listen on, host[:port] + #[arg(long = "rns-udp", default_value = "127.0.0.1:4242")] + rns_udp: String, + /// Optional UDP forward target, host[:port] + #[arg(long = "rns-udp-forward")] + rns_udp_forward: Option, } fn main() -> anyhow::Result<()> { @@ -82,6 +123,9 @@ fn main() -> anyhow::Result<()> { match cli.command { Command::Keygen { key, force } => cmd_keygen(&key, force), + #[cfg(feature = "rns")] + Command::RnsKeygen { key, force } => cmd_rns_keygen(&key, force), + #[cfg(not(feature = "rns"))] Command::Serve { key, db, @@ -113,6 +157,92 @@ fn main() -> anyhow::Result<()> { rate_sends, rate_tokens, }), + #[cfg(feature = "rns")] + Command::Serve { + key, + db, + host, + port, + max_envelope, + quota, + requests_quota, + retention_days, + requests_retention_days, + max_tokens, + invite_token, + rate_connections, + rate_sends, + rate_tokens, + rns, + } => { + // One process serves both carriers (RNS.md sec 7.4): shared + // Store, shared config, single purge loop in server::run. + if rns.enabled { + let (listen_host, listen_port) = split_host_port(&rns.rns_udp, 4242)?; + let (forward_host, forward_port) = match &rns.rns_udp_forward { + Some(addr) => { + let (host, port) = split_host_port(addr, 4242)?; + (Some(host), port) + } + None => (None, 4242), + }; + let storage_dir = std::path::Path::new(&db) + .parent() + .map(|p| p.join("rns-storage")) + .unwrap_or_else(|| std::path::PathBuf::from("rns-storage")); + std::fs::create_dir_all(&storage_dir)?; + let dest = rns::start(rns::RnsArgs { + key_path: rns.rns_key, + db_path: db.clone(), + storage_dir: storage_dir.to_string_lossy().into_owned(), + max_envelope: rns.rns_max_envelope, + fetch_budget: rns.rns_fetch_budget, + max_links: rns.rns_max_links, + rate_link_requests: rns.rns_rate_link_requests, + rate_link_bytes: rns.rns_rate_link_bytes, + udp_listen_host: listen_host, + udp_listen_port: listen_port, + udp_forward_host: forward_host, + udp_forward_port: forward_port, + max_tokens, + main_quota: quota, + requests_quota, + invite_token: invite_token.clone().map(String::into_bytes), + })?; + log::info!("RNS carrier: smolmail.server destination {dest}"); + } + server::run(server::ServeArgs { + key_path: key, + db_path: db, + host, + port, + max_envelope, + quota, + requests_quota, + retention_days, + requests_retention_days, + max_tokens, + invite_token, + rate_connections, + rate_sends, + rate_tokens, + }) + } + } +} + +/// Splits host[:port], keeping `default_port` when none is given. +#[cfg(feature = "rns")] +fn split_host_port(addr: &str, default_port: u16) -> anyhow::Result<(String, u16)> { + match addr.rsplit_once(':') { + Some((host, port)) => { + anyhow::ensure!( + !host.contains(':') || host.starts_with('['), + "unsupported address {addr}" + ); + Ok((host.to_string(), port.parse().unwrap_or(default_port))) + } + None => Ok((addr.to_string(), default_port)), } } @@ -137,3 +267,29 @@ fn cmd_keygen(key_path: &str, force: bool) -> anyhow::Result<()> { println!("Publish the public key through a trusted channel; clients pin it (SPEC.md sec 4)."); Ok(()) } + +/// RNS has no server static key to pin: the destination hash is the address +/// and the pin (upstream spec 13.4), so it is what gets published. +#[cfg(feature = "rns")] +fn cmd_rns_keygen(key_path: &str, force: bool) -> anyhow::Result<()> { + if std::path::Path::new(key_path).exists() && !force { + anyhow::bail!("{key_path} exists; refusing to overwrite (use --force)"); + } + + let key = rns::generate_identity(); + let dest = data_encoding::HEXLOWER.encode(&rns::destination_hash(&key)?); + + // Written 0600 before any bytes land, so the key is never briefly readable. + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + opts.mode(0o600); + let mut file = opts.open(key_path)?; + file.write_all(&key)?; + + println!("identity: {key_path}"); + println!("destination: {dest}"); + println!(); + println!("smol+rns://@{dest} is the server's mesh address (RNS.md sec 1)."); + Ok(()) +} diff --git a/src/proto.rs b/src/proto.rs index 1b52e1a..f080386 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -45,6 +45,11 @@ pub const MAX_FRAME: usize = 1 << 20; // application frame ceiling pub const NOISE_MAX: usize = 65535; // Noise message ceiling pub const NOISE_PAYLOAD: usize = NOISE_MAX - 16; // minus the AEAD tag pub const FETCH_BUDGET: usize = 512 * 1024; // must stay under MAX_FRAME + // AUTH with a maximal username and a full token set, op byte included + // (RNS.md sec 3): len 1 + username 63 + identity 32 + sig 64 + sync 1 + + // count 2; callers add 32 per token. +#[cfg(feature = "rns")] +pub const AUTH_FULL_TOKENS: usize = 164; pub const IDLE_TIMEOUT_SECS: u64 = 120; pub const PURGE_INTERVAL_SECS: u64 = 60; diff --git a/src/ratelimit.rs b/src/ratelimit.rs index 2117149..3963ae4 100644 --- a/src/ratelimit.rs +++ b/src/ratelimit.rs @@ -52,6 +52,58 @@ impl RateLimiter { } } +/// Fixed-window byte counter for per-link transfer budgets: `RateLimiter` +/// counts events, this counts bytes, so it gets its own small type. +#[cfg(feature = "rns")] +pub struct ByteRateLimiter { + limit: u64, + window: Duration, + hits: Mutex>, +} + +#[cfg(feature = "rns")] +struct ByteWindow { + start: Instant, + bytes: u64, +} + +#[cfg(feature = "rns")] +impl ByteRateLimiter { + pub fn new(limit: u64) -> Self { + ByteRateLimiter { + limit, + window: Duration::from_secs(60), + hits: Mutex::new(HashMap::new()), + } + } + + /// Records `bytes` against `key` if the window still has room for them. + pub fn allow(&self, key: &str, bytes: usize) -> bool { + if self.limit == 0 { + return true; + } + let now = Instant::now(); + let mut hits = self.hits.lock().unwrap(); + let entry = hits.entry(key.to_string()).or_insert(ByteWindow { + start: now, + bytes: 0, + }); + if now.duration_since(entry.start) >= self.window { + entry.start = now; + entry.bytes = 0; + } + if entry.bytes + bytes as u64 > self.limit { + return false; + } + entry.bytes += bytes as u64; + if hits.len() > 4096 { + let window = self.window; + hits.retain(|_, w| now.duration_since(w.start) < window); + } + true + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/rns.rs b/src/rns.rs new file mode 100644 index 0000000..ac4eb6a --- /dev/null +++ b/src/rns.rs @@ -0,0 +1,312 @@ +//! RNS carrier (Smol Mail 1.2, RNS.md sec 7): Reticulum Links to the +//! `smolmail.server` IN/SINGLE destination, dispatched through the same +//! `Session` as TCP. +//! +//! microReticulum's request handler is a bare function pointer with no +//! userdata, so this module's state lives in a `static OnceLock` and the +//! shim reaches it through the `smolmail_rns_on_*` C callbacks below. The +//! link identifier is the session key: bind values are derived from +//! destination || link_id (RNS.md sec 2), so AUTH on one link cannot replay +//! on another. + +use std::collections::{HashMap, HashSet}; +use std::ffi::CString; +use std::sync::{Mutex, OnceLock}; + +use crate::bind::TransportBindValues; +use crate::proto::{AUTH_FULL_TOKENS, INTERNAL_ERROR, MALFORMED, RATE_LIMITED, TOO_LARGE}; +use crate::ratelimit::{ByteRateLimiter, RateLimiter}; +use crate::session::{ServerConfig, Session}; +use crate::store::Store; + +mod ffi { + use std::os::raw::{c_char, c_int}; + + extern "C" { + // shim/smolmail_rns.cpp + pub fn smolmail_rns_start( + identity: *const u8, + storage_dir: *const c_char, + udp_listen_host: *const c_char, + udp_listen_port: u16, + udp_forward_host: *const c_char, + udp_forward_port: u16, + destination_hash_out: *mut u8, + ) -> c_int; + + pub fn smolmail_rns_destination_hash( + identity: *const u8, + destination_hash_out: *mut u8, + ) -> c_int; + } +} + +/// The identity file is 64 raw bytes: x25519 private (32) || ed25519 private +/// (32), the layout microReticulum's `Identity::to_file` writes and +/// `load_private_key` expects. +pub const RNS_KEY_LEN: usize = 64; + +pub struct RnsArgs { + pub key_path: String, + pub db_path: String, + pub storage_dir: String, + pub max_envelope: usize, + pub fetch_budget: usize, + pub max_links: usize, + pub rate_link_requests: u32, + pub rate_link_bytes: u64, + pub udp_listen_host: String, + pub udp_listen_port: u16, + pub udp_forward_host: Option, + pub udp_forward_port: u16, + pub max_tokens: u16, + pub main_quota: i64, + pub requests_quota: i64, + pub invite_token: Option>, +} + +struct RnsState { + config: &'static ServerConfig, + db_path: String, + destination: [u8; 16], + sessions: Mutex>>, + links: Mutex>, + request_limiter: RateLimiter, + byte_limiter: ByteRateLimiter, + max_links: usize, + response: Mutex>>, +} + +static STATE: OnceLock = OnceLock::new(); + +/// Loads the Reticulum identity, starts the carrier on the shim's background +/// loop thread, and returns the destination hash as the 32 hex characters of +/// the `smol+rns://` address host part (upstream spec 13.2). +pub fn start(args: RnsArgs) -> anyhow::Result { + let key = std::fs::read(&args.key_path) + .map_err(|e| anyhow::anyhow!("cannot read RNS identity {}: {e}", args.key_path))?; + anyhow::ensure!( + key.len() == RNS_KEY_LEN, + "RNS identity must be {RNS_KEY_LEN} raw bytes, got {}", + key.len() + ); + + // Ask the shim first: bind values derive from the destination hash, so + // the session state must exist before the shim's loop thread can fire + // the first request. + let destination = destination_hash(key.as_slice().try_into().unwrap())?; + + // RateLimiter keyed by link hex covers per-link abuse control; the + // per-IP send limit has no analogue (upstream spec 13.8), and the accept + // token limiter stays on because a token never needed a peer identity. + let config: &'static ServerConfig = Box::leak(Box::new(ServerConfig { + max_envelope: args.max_envelope, + fetch_budget: args.fetch_budget, + main_quota: args.main_quota, + requests_quota: args.requests_quota, + max_tokens: args.max_tokens, + invite_token: args.invite_token.clone(), + conn_limiter: RateLimiter::new(0), + send_limiter: RateLimiter::new(0), + token_limiter: RateLimiter::new(30), + })); + + let state = RnsState { + config, + db_path: args.db_path.clone(), + destination, + sessions: Mutex::new(HashMap::new()), + links: Mutex::new(HashSet::new()), + request_limiter: RateLimiter::new(args.rate_link_requests), + byte_limiter: ByteRateLimiter::new(args.rate_link_bytes), + max_links: args.max_links, + response: Mutex::new(None), + }; + STATE.get_or_init(|| state); + + let storage_dir = CString::new(args.storage_dir).unwrap(); + let listen_host = CString::new(args.udp_listen_host).unwrap(); + let forward_host = args.udp_forward_host.map(|h| CString::new(h).unwrap()); + + let mut dest_hash = [0u8; 16]; + let rc = unsafe { + ffi::smolmail_rns_start( + key.as_ptr(), + storage_dir.as_ptr(), + listen_host.as_ptr(), + args.udp_listen_port, + forward_host + .as_ref() + .map(|h| h.as_ptr()) + .unwrap_or(std::ptr::null()), + args.udp_forward_port, + dest_hash.as_mut_ptr(), + ) + }; + anyhow::ensure!(rc == 0, "RNS shim failed to start (code {rc})"); + anyhow::ensure!( + dest_hash == destination, + "destination hash changed between shim calls" + ); + Ok(data_encoding::HEXLOWER.encode(&dest_hash)) +} + +/// The `smolmail.server` destination hash for a 64-byte identity, computed +/// by microReticulum itself: its Curve25519 `eval` does not clamp the scalar +/// (a divergence from RFC 7748), so an independent Rust derivation would +/// produce a different x25519 public key and therefore a different hash. +pub fn destination_hash(key: &[u8; RNS_KEY_LEN]) -> anyhow::Result<[u8; 16]> { + let mut out = [0u8; 16]; + let rc = unsafe { ffi::smolmail_rns_destination_hash(key.as_ptr(), out.as_mut_ptr()) }; + anyhow::ensure!(rc == 0, "shim rejected the RNS identity (code {rc})"); + Ok(out) +} + +/// Generates the 64-byte Reticulum identity: random x25519 || ed25519 +/// private halves. +pub fn generate_identity() -> [u8; RNS_KEY_LEN] { + use rand_core::{OsRng, RngCore}; + let mut key = [0u8; RNS_KEY_LEN]; + OsRng.fill_bytes(&mut key); + key +} + +fn response(status: u8) -> Vec { + vec![status] +} + +fn handle_request(request: &[u8], link_id: &[u8; 16]) -> Vec { + let Some(state) = STATE.get() else { + return response(INTERNAL_ERROR); + }; + let link = data_encoding::HEXLOWER.encode(link_id); + + // Bounded request size (RNS.md sec 3): an envelope plus its overhead, or + // an AUTH carrying a full token set, whichever is larger. + let max_request = (state.config.max_envelope + 34) + .max(AUTH_FULL_TOKENS + 32 * state.config.max_tokens as usize); + if request.len() > max_request { + log::warn!( + "request of {} bytes over {} byte cap", + request.len(), + max_request + ); + return response(TOO_LARGE); + } + if !state.request_limiter.allow(&link) || !state.byte_limiter.allow(&link, request.len()) { + return response(RATE_LIMITED); + } + + let Some(op) = request.first() else { + return response(MALFORMED); + }; + let body = &request[1..]; + + let mut sessions = state.sessions.lock().unwrap(); + let session = match sessions.entry(*link_id) { + std::collections::hash_map::Entry::Occupied(e) => e.into_mut(), + std::collections::hash_map::Entry::Vacant(e) => { + // The link is the session (upstream spec 13.6): a Store per link, + // bind values derived from this destination and link. + match Store::open(&state.db_path) { + Ok(store) => e.insert(Session::new( + state.config, + store, + link.clone(), + TransportBindValues::rns(&state.destination, link_id), + )), + Err(err) => { + log::error!("cannot open store for link {link}: {err}"); + return response(INTERNAL_ERROR); + } + } + } + }; + + let (status, payload) = session.dispatch(*op, body); + let mut out = Vec::with_capacity(1 + payload.len()); + out.push(status); + out.extend_from_slice(&payload); + out +} + +#[no_mangle] +extern "C" fn smolmail_rns_on_request( + request: *const u8, + request_len: usize, + link_id: *const u8, +) -> usize { + // The shim calls this on its single loop thread, so the slot never sees + // concurrent writers; panics must not cross the FFI boundary. + let result = std::panic::catch_unwind(|| unsafe { + let request = std::slice::from_raw_parts(request, request_len); + let link_id: &[u8; 16] = std::slice::from_raw_parts(link_id, 16).try_into().unwrap(); + handle_request(request, link_id) + }); + let Ok(response) = result else { + log::error!("panic in RNS request handler"); + return 0; + }; + let len = response.len(); + *STATE.get().unwrap().response.lock().unwrap() = Some(response); + len +} + +#[no_mangle] +extern "C" fn smolmail_rns_take_response(out: *mut u8, cap: usize) -> usize { + let slot = &mut STATE.get().unwrap().response.lock().unwrap(); + match slot.take() { + Some(response) if response.len() <= cap => { + unsafe { std::ptr::copy_nonoverlapping(response.as_ptr(), out, response.len()) }; + response.len() + } + _ => 0, + } +} + +#[no_mangle] +extern "C" fn smolmail_rns_on_link_opened(link_id: *const u8) -> i32 { + let Some(state) = STATE.get() else { + return 1; + }; + let link: [u8; 16] = unsafe { std::slice::from_raw_parts(link_id, 16).try_into().unwrap() }; + let mut links = state.links.lock().unwrap(); + if !links.contains(&link) && links.len() >= state.max_links { + log::warn!( + "refusing link {}, {} link(s) open", + data_encoding::HEXLOWER.encode(&link), + links.len() + ); + return 1; + } + links.insert(link); + 0 +} + +#[no_mangle] +extern "C" fn smolmail_rns_on_link_closed(link_id: *const u8) { + if let Some(state) = STATE.get() { + let link: [u8; 16] = unsafe { std::slice::from_raw_parts(link_id, 16).try_into().unwrap() }; + state.links.lock().unwrap().remove(&link); + state.sessions.lock().unwrap().remove(&link); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Cross-checked against microReticulum itself: the destination hash a + /// native probe prints for the identity 0x00..0x3f. + #[test] + fn destination_hash_matches_microreticulum() { + let mut key = [0u8; RNS_KEY_LEN]; + for (i, byte) in key.iter_mut().enumerate() { + *byte = i as u8; + } + assert_eq!( + data_encoding::HEXLOWER.encode(&destination_hash(&key).unwrap()), + "799855f4955f1b09fd20a13cd84f4e71" + ); + } +} diff --git a/src/server.rs b/src/server.rs index 31bc548..49a33de 100644 --- a/src/server.rs +++ b/src/server.rs @@ -4,9 +4,10 @@ use std::net::TcpStream; use std::sync::Arc; use std::time::Duration; +use crate::bind::TransportBindValues; use crate::channel::{handshake, Channel}; use crate::crypto::{b32, derive_public}; -use crate::proto::{IDLE_TIMEOUT_SECS, KEY_LEN, MALFORMED, PURGE_INTERVAL_SECS}; +use crate::proto::{FETCH_BUDGET, IDLE_TIMEOUT_SECS, KEY_LEN, MALFORMED, PURGE_INTERVAL_SECS}; use crate::ratelimit::RateLimiter; use crate::session::{ServerConfig, Session}; use crate::store::Store; @@ -29,8 +30,8 @@ pub struct ServeArgs { } pub fn run(args: ServeArgs) -> anyhow::Result<()> { - let static_key = - std::fs::read(&args.key_path).map_err(|e| anyhow::anyhow!("cannot read server key: {e}"))?; + let static_key = std::fs::read(&args.key_path) + .map_err(|e| anyhow::anyhow!("cannot read server key: {e}"))?; anyhow::ensure!( static_key.len() == KEY_LEN, "server key must be {KEY_LEN} raw bytes, got {}", @@ -42,11 +43,11 @@ pub fn run(args: ServeArgs) -> anyhow::Result<()> { let config = Arc::new(ServerConfig { max_envelope: args.max_envelope, + fetch_budget: FETCH_BUDGET, main_quota: args.quota, requests_quota: args.requests_quota, max_tokens: args.max_tokens, invite_token: args.invite_token.map(String::into_bytes), - server_static, conn_limiter: RateLimiter::new(args.rate_connections), send_limiter: RateLimiter::new(args.rate_sends), token_limiter: RateLimiter::new(args.rate_tokens), @@ -55,7 +56,9 @@ pub fn run(args: ServeArgs) -> anyhow::Result<()> { let main_retention_secs = args.retention_days * 86400; let requests_retention_secs = args.requests_retention_days * 86400; let purge_db_path = args.db_path.clone(); - std::thread::spawn(move || purge_loop(purge_db_path, main_retention_secs, requests_retention_secs)); + std::thread::spawn(move || { + purge_loop(purge_db_path, main_retention_secs, requests_retention_secs) + }); let listener = std::net::TcpListener::bind((args.host.as_str(), args.port))?; log::info!("listening on {}:{}", args.host, args.port); @@ -80,7 +83,7 @@ pub fn run(args: ServeArgs) -> anyhow::Result<()> { } let config = Arc::clone(&config); - let static_key = static_key.clone(); + let static_key = key_array; let db_path = args.db_path.clone(); std::thread::spawn(move || { if let Err(e) = handle_connection(stream, &config, &static_key, &db_path, &peer_ip) { @@ -94,7 +97,7 @@ pub fn run(args: ServeArgs) -> anyhow::Result<()> { fn handle_connection( mut stream: TcpStream, config: &ServerConfig, - static_key: &[u8], + static_key: &[u8; KEY_LEN], db_path: &str, peer_ip: &str, ) -> anyhow::Result<()> { @@ -109,7 +112,9 @@ fn handle_connection( }; let store = Store::open(db_path)?; - let mut session = Session::new(config, store, peer_ip.to_string(), handshake_hash); + let server_static = derive_public(static_key); + let bind = TransportBindValues::tcp(&handshake_hash, &server_static)?; + let mut session = Session::new(config, store, peer_ip.to_string(), bind); let mut channel = Channel::new(stream, transport); loop { diff --git a/src/session.rs b/src/session.rs index 6c81ec9..680a293 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,23 +1,24 @@ //! Per-connection dispatch and the six wire operations. +use crate::bind::TransportBindValues; use crate::crypto::{b32, ct_eq, hmac_sha256, message_id, verify}; use crate::proto::{ valid_username, ProtocolError, Reader, AUTH_FAILED, AUTH_REQUIRED, BAD_VERSION, CERT_LEN, - ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, FETCH_BUDGET, ID_LEN, KEY_LEN, LABEL_AUTH, - LABEL_MAC, LABEL_REGISTER, LABEL_ROTATE, MAC_LEN, MALFORMED, MAX_CHAIN, NOT_PERMITTED, OK, - OP_AUTH, OP_DELETE, OP_FETCH, OP_REGISTER, OP_RESOLVE, OP_SEND, QUOTA_EXCEEDED, RATE_LIMITED, - TOKEN_LEN, TOO_LARGE, UNKNOWN_USER, + ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, ID_LEN, KEY_LEN, LABEL_AUTH, LABEL_MAC, + LABEL_REGISTER, LABEL_ROTATE, MAC_LEN, MALFORMED, MAX_CHAIN, NOT_PERMITTED, OK, OP_AUTH, + OP_DELETE, OP_FETCH, OP_REGISTER, OP_RESOLVE, OP_SEND, QUOTA_EXCEEDED, RATE_LIMITED, TOKEN_LEN, + TOO_LARGE, UNKNOWN_USER, }; use crate::ratelimit::RateLimiter; use crate::store::Store; pub struct ServerConfig { pub max_envelope: usize, + pub fetch_budget: usize, pub main_quota: i64, pub requests_quota: i64, pub max_tokens: u16, pub invite_token: Option>, - pub server_static: [u8; KEY_LEN], pub conn_limiter: RateLimiter, pub send_limiter: RateLimiter, pub token_limiter: RateLimiter, @@ -47,17 +48,22 @@ pub struct Session<'a> { config: &'a ServerConfig, store: Store, peer_ip: String, - handshake_hash: Vec, + bind: TransportBindValues, username: Option, } impl<'a> Session<'a> { - pub fn new(config: &'a ServerConfig, store: Store, peer_ip: String, handshake_hash: Vec) -> Self { + pub fn new( + config: &'a ServerConfig, + store: Store, + peer_ip: String, + bind: TransportBindValues, + ) -> Self { Session { config, store, peer_ip, - handshake_hash, + bind, username: None, } } @@ -125,7 +131,7 @@ impl<'a> Session<'a> { return Ok((AUTH_FAILED, Vec::new())); } let mut msg = LABEL_AUTH.to_vec(); - msg.extend_from_slice(&self.handshake_hash); + msg.extend_from_slice(&self.bind.h); if !verify(&identity, &signature, &msg) { return Ok((AUTH_FAILED, Vec::new())); } @@ -231,9 +237,12 @@ impl<'a> Session<'a> { r.done()?; let username = self.username.as_ref().expect("AUTH_REQUIRED gate above"); let keys = self.store.keys_of(username)?; - let records = self - .store - .pending(&keys, after_received_at, &after_id, FETCH_BUDGET)?; + let records = self.store.pending( + &keys, + after_received_at, + &after_id, + self.config.fetch_budget, + )?; let mut out = Vec::new(); out.extend_from_slice(&(records.len() as u16).to_be_bytes()); @@ -286,7 +295,7 @@ impl<'a> Session<'a> { // (SPEC.md sec 6.1). Binding server_static stops the attestation from // being replayed against another server. let mut pop_msg = LABEL_REGISTER.to_vec(); - pop_msg.extend_from_slice(&self.config.server_static); + pop_msg.extend_from_slice(&self.bind.server_static); pop_msg.extend_from_slice(username.as_bytes()); pop_msg.extend_from_slice(&identity); if !verify(&identity, &signature, &pop_msg) { @@ -343,3 +352,163 @@ impl<'a> Session<'a> { Ok((OK, Vec::new())) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::sha256; + use ed25519_dalek::{Signer, SigningKey}; + + fn test_config() -> ServerConfig { + ServerConfig { + max_envelope: 1024, + fetch_budget: 512, + main_quota: 1 << 20, + requests_quota: 1 << 20, + max_tokens: 16, + invite_token: None, + conn_limiter: RateLimiter::new(0), + send_limiter: RateLimiter::new(0), + token_limiter: RateLimiter::new(0), + } + } + + fn temp_store(name: &str) -> Store { + let path = + std::env::temp_dir().join(format!("bunshin-session-{name}-{}.db", std::process::id())); + let _ = std::fs::remove_file(&path); + Store::open(path.to_str().unwrap()).unwrap() + } + + fn str_field(s: &str) -> Vec { + let mut out = vec![s.len() as u8]; + out.extend_from_slice(s.as_bytes()); + out + } + + fn register_body(username: &str, identity: &[u8], signature: &[u8]) -> Vec { + let mut body = str_field(username); + body.extend_from_slice(identity); + body.extend_from_slice(signature); + body.push(0); // invite token, none configured + body.push(0); // cert, plain registration + body + } + + fn auth_body(username: &str, identity: &[u8], signature: &[u8]) -> Vec { + let mut body = str_field(username); + body.extend_from_slice(identity); + body.extend_from_slice(signature); + body.push(0); // sync = 0, stored tokens untouched + body.extend_from_slice(&0u16.to_be_bytes()); + body + } + + #[test] + fn rns_bind_signatures_accepted_and_tcp_bind_signatures_rejected() { + let config = test_config(); + let key = SigningKey::from_bytes(&[3u8; 32]); + let identity = key.verifying_key().as_bytes().to_vec(); + + let bind = TransportBindValues::rns(&[0x11; 16], &[0x22; 16]); + + let mut pop = LABEL_REGISTER.to_vec(); + pop.extend_from_slice(&bind.server_static); + pop.extend_from_slice(b"alice"); + pop.extend_from_slice(&identity); + let good_register = register_body("alice", &identity, &key.sign(&pop).to_bytes()); + + // A TCP client would sign over the real X25519 static key instead. + let tcp_static = crate::crypto::derive_public(&[9u8; 32]); + let mut tcp_pop = LABEL_REGISTER.to_vec(); + tcp_pop.extend_from_slice(&tcp_static); + tcp_pop.extend_from_slice(b"alice"); + tcp_pop.extend_from_slice(&identity); + let tcp_register = register_body("alice", &identity, &key.sign(&tcp_pop).to_bytes()); + + let mut auth_msg = LABEL_AUTH.to_vec(); + auth_msg.extend_from_slice(&bind.h); + let good_auth = auth_body("alice", &identity, &key.sign(&auth_msg).to_bytes()); + + // A Noise client would sign over the handshake hash instead. + let handshake_hash = sha256(&[b"handshake"]); + let mut tcp_auth = LABEL_AUTH.to_vec(); + tcp_auth.extend_from_slice(&handshake_hash); + let tcp_auth = auth_body("alice", &identity, &key.sign(&tcp_auth).to_bytes()); + + let mut session = Session::new(&config, temp_store("rns-bind"), "rns".into(), bind); + assert_eq!(session.dispatch(OP_REGISTER, &good_register).0, OK); + assert_eq!(session.dispatch(OP_AUTH, &good_auth).0, OK); + + let mut session = Session::new( + &config, + temp_store("rns-bind-reject"), + "rns".into(), + TransportBindValues::rns(&[0x11; 16], &[0x22; 16]), + ); + assert_eq!(session.dispatch(OP_REGISTER, &good_register).0, OK); + assert_eq!(session.dispatch(OP_AUTH, &tcp_auth).0, AUTH_FAILED); + + let mut session = Session::new( + &config, + temp_store("rns-bind-tcp-register"), + "rns".into(), + TransportBindValues::rns(&[0x11; 16], &[0x22; 16]), + ); + assert_eq!(session.dispatch(OP_REGISTER, &tcp_register).0, AUTH_FAILED); + } + + #[test] + fn tcp_bind_signatures_accepted_and_rns_bind_signatures_rejected() { + let config = test_config(); + let key = SigningKey::from_bytes(&[4u8; 32]); + let identity = key.verifying_key().as_bytes().to_vec(); + + let server_static = crate::crypto::derive_public(&[9u8; 32]); + let handshake_hash = sha256(&[b"handshake"]); + let bind = TransportBindValues::tcp(&handshake_hash, &server_static).unwrap(); + + let mut pop = LABEL_REGISTER.to_vec(); + pop.extend_from_slice(&server_static); + pop.extend_from_slice(b"alice"); + pop.extend_from_slice(&identity); + let good_register = register_body("alice", &identity, &key.sign(&pop).to_bytes()); + + let mut auth_msg = LABEL_AUTH.to_vec(); + auth_msg.extend_from_slice(&handshake_hash); + let good_auth = auth_body("alice", &identity, &key.sign(&auth_msg).to_bytes()); + + // An RNS client would sign over the derived bind values instead. + let rns_bind = TransportBindValues::rns(&[0x11; 16], &[0x22; 16]); + let mut rns_pop = LABEL_REGISTER.to_vec(); + rns_pop.extend_from_slice(&rns_bind.server_static); + rns_pop.extend_from_slice(b"alice"); + rns_pop.extend_from_slice(&identity); + let rns_register = register_body("alice", &identity, &key.sign(&rns_pop).to_bytes()); + + let mut rns_auth = LABEL_AUTH.to_vec(); + rns_auth.extend_from_slice(&rns_bind.h); + let rns_auth = auth_body("alice", &identity, &key.sign(&rns_auth).to_bytes()); + + let mut session = Session::new(&config, temp_store("tcp-bind"), "tcp".into(), bind); + assert_eq!(session.dispatch(OP_REGISTER, &good_register).0, OK); + assert_eq!(session.dispatch(OP_AUTH, &good_auth).0, OK); + + let mut session = Session::new( + &config, + temp_store("tcp-bind-reject"), + "tcp".into(), + TransportBindValues::tcp(&handshake_hash, &server_static).unwrap(), + ); + assert_eq!(session.dispatch(OP_REGISTER, &good_register).0, OK); + assert_eq!(session.dispatch(OP_AUTH, &rns_auth).0, AUTH_FAILED); + + let mut session = Session::new( + &config, + temp_store("tcp-bind-rns-register"), + "tcp".into(), + TransportBindValues::tcp(&handshake_hash, &server_static).unwrap(), + ); + assert_eq!(session.dispatch(OP_REGISTER, &rns_register).0, AUTH_FAILED); + } +}