feat: implement RNS transport (Smol Mail 1.2) over microReticulum

This commit is contained in:
randogoth 2026-09-28 15:52:37 +03:00
parent af1d31be83
commit 7203556186
19 changed files with 1939 additions and 1141 deletions

90
src/bind.rs Normal file
View file

@ -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<Self> {
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);
}
}

View file

@ -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]) {

View file

@ -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<String>,
}
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://<user>@{dest} is the server's mesh address (RNS.md sec 1).");
Ok(())
}

View file

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

View file

@ -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<HashMap<String, ByteWindow>>,
}
#[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::*;

312
src/rns.rs Normal file
View file

@ -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<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);
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"
);
}
}

View file

@ -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 {

View file

@ -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<Vec<u8>>,
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<u8>,
bind: TransportBindValues,
username: Option<String>,
}
impl<'a> Session<'a> {
pub fn new(config: &'a ServerConfig, store: Store, peer_ip: String, handshake_hash: Vec<u8>) -> 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<u8> {
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<u8> {
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<u8> {
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);
}
}