//! 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); log::debug!("link {} opened ({} open)", data_encoding::HEXLOWER.encode(&link), links.len()); 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); log::debug!("link {} closed", data_encoding::HEXLOWER.encode(&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" ); } }