feat: implement RNS transport (Smol Mail 1.2) over microReticulum
This commit is contained in:
parent
af1d31be83
commit
7203556186
19 changed files with 1939 additions and 1141 deletions
90
src/bind.rs
Normal file
90
src/bind.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]) {
|
||||
|
|
|
|||
156
src/main.rs
156
src/main.rs
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
312
src/rns.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
195
src/session.rs
195
src/session.rs
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue