314 lines
11 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|