bunshin/RNS.md
randogoth af1d31be83 feat: add RNS integration plan for Smol Mail 1.2 compatibility
Add comprehensive plan for integrating Reticulum Network Stack transport
into bunshin including:
- Transport abstraction architecture
- FFI with microReticulum approach
- Python IPC alternative
- Phase 1: Transport abstraction (no dependencies)
- Phase 2: RNS implementation via FFI
- CLI commands for RNS
- NixOS module updates
- Implementation priority

Key finding: No official Rust RNS crate exists; microReticulum (C++) is
the official implementation and must be used via FFI.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-28 11:22:02 +03:00

37 KiB

RNS Integration Plan for Bunshin (Smol Mail 1.2 Compatibility)

Overview

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.

Key Finding: No Official Rust RNS Crate

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 by attermann (most mature, production-ready)
  • C++: Community-maintained ports

Therefore, we have three options for Rust integration:

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:

# 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

┌─────────────────────────────────────────────────────────┐
│                      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           │ │
│  └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘

Implementation Phases

Phase 1: Transport Abstraction (Do First - No Dependencies)

This phase makes the codebase ready for any transport implementation.

1.1 Create src/transport.rs

/// 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<u8>)>;

    /// 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;
}

1.2 Refactor channel.rs to src/tcp_transport.rs

Move existing Noise/TCP logic into a TCP transport implementation:

use std::net::TcpStream;
use snow::TransportState;

use crate::proto::{NOISE_PAYLOAD, PROLOGUE};
use crate::transport::TransportBindValues;

pub struct TcpTransport {
    stream: TcpStream,
    transport: TransportState,
    buf: Vec<u8>,
    handshake_hash: Vec<u8>,
    server_static: [u8; 32],
    peer_ip: String,
}

impl TcpTransport {
    pub fn new(
        stream: TcpStream,
        transport: TransportState,
        handshake_hash: Vec<u8>,
        server_static: [u8; 32],
        peer_ip: String,
    ) -> Self {
        Self {
            stream,
            transport,
            buf: Vec::new(),
            handshake_hash,
            server_static,
            peer_ip,
        }
    }
}

impl Transport for TcpTransport {
    fn read_frame(&mut self) -> anyhow::Result<(u8, Vec<u8>)> {
        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<u8> = self.buf[4..4 + length].to_vec();
        self.buf.drain(..4 + length);
        Ok((frame[0], frame[1..].to_vec()))
    }

    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(())
    }

    fn peer_id(&self) -> &str {
        &self.peer_ip
    }

    fn bind_values(&self) -> TransportBindValues {
        TransportBindValues {
            h: self.handshake_hash.clone().try_into().unwrap(),
            server_static: self.server_static,
        }
    }
}

impl TcpTransport {
    fn read_noise(&mut self) -> anyhow::Result<Vec<u8>> {
        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)
    }

    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(())
    }
}

1.3 Update session.rs

Replace handshake_hash: Vec<u8> with bind_values: TransportBindValues:

use crate::transport::TransportBindValues;

pub struct Session<'a> {
    config: &'a ServerConfig,
    store: Store,
    peer_ip: String,
    bind_values: TransportBindValues,  // NEW: from transport
    username: Option<String>,
}

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 }
    }

    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()?;

        if sync > 1 || (sync == 0 && count != 0) {
            return Ok((MALFORMED, Vec::new()));
        }

        let bound = self.store.identity_of(&username)?;
        if bound.as_deref() != Some(identity.as_slice()) {
            return Ok((AUTH_FAILED, Vec::new()));
        }

        // 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()));
        }

        if sync == 1 {
            if count > self.config.max_tokens as usize {
                return Ok((TOO_LARGE, Vec::new()));
            }
            self.store.set_tokens(&username, &tokens)?;
        }

        let accepted = self.store.token_count(&username)?;
        self.username = Some(username);
        Ok((OK, accepted.to_be_bytes().to_vec()))
    }

    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()?;

        if !valid_username(&username) {
            return Ok((MALFORMED, Vec::new()));
        }

        // 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()));
        }

        // ... rest unchanged ...
    }
}

1.4 Update server.rs

use crate::tcp_transport::TcpTransport;
use crate::transport::Transport;

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)))?;

    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(());
        }
    };

    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);

    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(());
            }
        };

        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(());
        }
    }
}

Also update ServerConfig to remove server_static (now transport-specific):

pub struct ServerConfig {
    pub max_envelope: usize,
    pub main_quota: i64,
    pub requests_quota: i64,
    pub max_tokens: u16,
    pub invite_token: Option<Vec<u8>>,
    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:

// 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

# 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

// 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<String>,
        #[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<String>,
        #[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

//! 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

//! 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<Self> {
        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<Self> {
        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<Vec<u8>> {
        // 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<Self> {
        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

//! 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<ffi::Destination>,
    request_tx: mpsc::Sender<RnsRequest>,
    request_rx: mpsc::Receiver<RnsRequest>,
    response_tx: mpsc::Sender<RnsResponse>,
    peer_id: String,
    link_id: [u8; 16],
}

struct RnsRequest {
    op: u8,
    body: Vec<u8>,
}

struct RnsResponse {
    status: u8,
    payload: Vec<u8>,
}

impl RnsTransport {
    pub fn new(identity: ffi::Identity, max_request_size: usize) -> anyhow::Result<Self> {
        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<u8>)> {
        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

//! 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<String>,
    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

# 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