bunshin/src/rns.rs

314 lines
11 KiB
Rust

//! 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<String>,
pub udp_forward_port: u16,
pub max_tokens: u16,
pub main_quota: i64,
pub requests_quota: i64,
pub invite_token: Option<Vec<u8>>,
}
struct RnsState {
config: &'static ServerConfig,
db_path: String,
destination: [u8; 16],
sessions: Mutex<HashMap<[u8; 16], Session<'static>>>,
links: Mutex<HashSet<[u8; 16]>>,
request_limiter: RateLimiter,
byte_limiter: ByteRateLimiter,
max_links: usize,
response: Mutex<Option<Vec<u8>>>,
}
static STATE: OnceLock<RnsState> = 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<String> {
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<u8> {
vec![status]
}
fn handle_request(request: &[u8], link_id: &[u8; 16]) -> Vec<u8> {
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"
);
}
}