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

1
Cargo.lock generated
View file

@ -155,6 +155,7 @@ name = "bunshin"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cc",
"clap", "clap",
"data-encoding", "data-encoding",
"ed25519-dalek", "ed25519-dalek",

View file

@ -8,6 +8,11 @@ description = "Smol Mail server"
name = "bunshin" name = "bunshin"
path = "src/main.rs" path = "src/main.rs"
[features]
# RNS carrier over microReticulum (RNS.md); needs MICRORETICULUM_SOURCE_DIR
# at build time, provided by the flake.
rns = ["dep:cc"]
[dependencies] [dependencies]
snow = "0.9" snow = "0.9"
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
@ -20,3 +25,6 @@ clap = { version = "4", features = ["derive"] }
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
anyhow = "1" anyhow = "1"
[build-dependencies]
cc = { version = "1", optional = true }

View file

@ -1,6 +1,6 @@
# 分身 bunshin # 分身 bunshin
A Rust implementation of the [Smol Mail](https://code.randogoth.com/randogoth/smolmail) server: a minimalist, end-to-end encrypted mail protocol over a Noise-secured TCP connection. `bunshin` implements the server side only — receiving, storing and serving sealed mail — not the client. A Rust implementation of the [Smol Mail](https://code.randogoth.com/randogoth/smolmail) server: a minimalist, end-to-end encrypted mail protocol over a Noise-secured TCP connection, with an optional Reticulum (RNS) mesh carrier behind the `rns` build feature. `bunshin` implements the server side only — receiving, storing and serving sealed mail — not the client.
The server never sees plaintext, sender identities or any private key. It learns only which mailbox an envelope is for, its size, and when it arrived. The server never sees plaintext, sender identities or any private key. It learns only which mailbox an envelope is for, its size, and when it arrived.
@ -34,6 +34,8 @@ Add bunshin as a flake input and import the module:
`services.bunshin` also takes `host`, `port`, `dataDir`, `maxEnvelope`, `quota`, `requestsQuota`, `retentionDays`, `requestsRetentionDays`, `maxTokens`, `rateConnections`, `rateSends` and `rateTokens`; see `flake.nix` for defaults. The module renders a `systemd` unit that runs `bunshin serve` under `DynamicUser`; it does not generate a key. `services.bunshin` also takes `host`, `port`, `dataDir`, `maxEnvelope`, `quota`, `requestsQuota`, `retentionDays`, `requestsRetentionDays`, `maxTokens`, `rateConnections`, `rateSends` and `rateTokens`; see `flake.nix` for defaults. The module renders a `systemd` unit that runs `bunshin serve` under `DynamicUser`; it does not generate a key.
For the RNS carrier, build `packages.rns`, set `services.bunshin.package` to it, and enable `services.bunshin.rns` with its `keyFile`; see [RNS.md](RNS.md) for the protocol and the remaining options.
Before the first deploy, generate the server's static key once (from a dev shell or `nix run`) and place it at the configured `keyFile`: Before the first deploy, generate the server's static key once (from a dev shell or `nix run`) and place it at the configured `keyFile`:
``` ```
@ -42,9 +44,20 @@ nix run "https://code.randogoth.com/randogoth/bunshin" -- keygen --key server.ke
`keygen` writes the server's static X25519 key (used for the Noise handshake, distinct from any user's Ed25519 identity) and prints its public key in base32. Publish that public key through a trusted channel — clients pin it, and a mismatch aborts the handshake. `keygen` writes the server's static X25519 key (used for the Noise handshake, distinct from any user's Ed25519 identity) and prints its public key in base32. Publish that public key through a trusted channel — clients pin it, and a mismatch aborts the handshake.
## RNS carrier
The `rns` feature (built by `packages.rns`, wired to the module through `services.bunshin.rns`) serves the same mailbox over the Reticulum Network Stack alongside TCP: `bunshin serve --rns` runs both carriers in one process against one database. The mesh address is the `smolmail.server` destination hash — there is no key to pin, the address is the pin.
```
bunshin rns-keygen --key server.rns.key
bunshin serve --rns --key server.key --rns-key server.rns.key --db mail.db --rns-udp 0.0.0.0:4242
```
`rns-keygen` writes the 64-byte Reticulum identity and prints the destination hash; publish `smol+rns://<user>@<hash>` as the server's address. RNS-specific limits (`--rns-max-envelope`, `--rns-fetch-budget`, `--rns-max-links`, `--rns-rate-link-requests`, `--rns-rate-link-bytes`) default to mesh-friendly 32 KiB caps; the UDP interface is the one supported transport interface so far. See [RNS.md](RNS.md).
## Building ## Building
Requires a Rust toolchain (stable, edition 2021) and a C compiler — `rusqlite`'s `bundled` feature compiles SQLite from source rather than linking a system copy, so no separate SQLite install is needed. Requires a Rust toolchain (stable, edition 2021) and a C compiler — `rusqlite`'s `bundled` feature compiles SQLite from source rather than linking a system copy, so no separate SQLite install is needed. The `rns` feature additionally needs cmake, ninja and a microReticulum checkout pointed at by `MICRORETICULUM_SOURCE_DIR` — the flake's dev shell provides all of it.
``` ```
cargo build --release cargo build --release
@ -65,3 +78,5 @@ bunshin serve --key server.key --db mail.db --host 0.0.0.0 --port 1961
## Status ## Status
Implements SPEC.md version 1.1 in full: `AUTH`, `RESOLVE`, `SEND`, `FETCH`, `DELETE` and `REGISTER`, including accept tokens and the main/requests tier split, fetch cursors, 32-byte message ids, `REGISTER` proof of possession, and dual-signed key rotation chains. Implements SPEC.md version 1.1 in full: `AUTH`, `RESOLVE`, `SEND`, `FETCH`, `DELETE` and `REGISTER`, including accept tokens and the main/requests tier split, fetch cursors, 32-byte message ids, `REGISTER` proof of possession, and dual-signed key rotation chains.
With the `rns` feature, implements the version 1.2 RNS transport: the same six operations over Reticulum Links to an announced `smolmail.server` destination, with link-bound AUTH/REGISTER signatures, per-link request and byte limits, and a concurrent-link cap. One server process can serve both carriers over one store.

1224
RNS.md

File diff suppressed because it is too large Load diff

182
build.rs Normal file
View file

@ -0,0 +1,182 @@
//! Builds the microReticulum carrier shim when the `rns` feature is on.
//!
//! The library source is not vendored into this repository: the environment
//! must point at a checkout via MICRORETICULUM_SOURCE_DIR (the Nix flake and
//! the dev shell both set it). The source tree is copied into OUT_DIR and
//! patched there — a one-line `<cstdint>` include the upstream master needs
//! under current libstdc++/libc++ — because the input may be read-only and
//! both build paths should compile identical sources.
#[cfg(feature = "rns")]
fn main() {
println!("cargo:rerun-if-changed=shim/smolmail_rns.cpp");
println!("cargo:rerun-if-changed=shim/udp_interface.cpp");
println!("cargo:rerun-if-env-changed=MICRORETICULUM_SOURCE_DIR");
for dep in DEP_VARS {
println!("cargo:rerun-if-env-changed={dep}");
}
let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
let source = std::env::var("MICRORETICULUM_SOURCE_DIR")
.expect("the rns feature requires MICRORETICULUM_SOURCE_DIR (a microReticulum checkout)");
// Copy the parts cmake needs: the root CMakeLists.txt and src/.
let copy = out_dir.join("microReticulum");
copy_tree(
std::path::Path::new(&source),
&copy,
&["CMakeLists.txt", "src", "LICENSE"],
);
// Upstream master (2026-07-20) relies on transitive <cstdint> includes
// that libstdc++ 15 dropped; add the include itself.
let memory_h = copy.join("src/microReticulum/Utilities/Memory.h");
let text = std::fs::read_to_string(&memory_h).expect("Memory.h readable");
if !text.contains("#include <cstdint>") {
let patched = text.replacen(
"#include <memory>",
"#include <memory>\n#include <cstdint>",
1,
);
std::fs::write(&memory_h, patched).expect("Memory.h writable");
}
// Configure + build the library. Dependency overrides (RNS_*_SOURCE_DIR)
// are passed straight through so a sandboxed build never fetches.
let build = out_dir.join("rns-build");
let mut configure = std::process::Command::new("cmake");
configure
.arg("-S")
.arg(&copy)
.arg("-B")
.arg(&build)
.arg("-DCMAKE_BUILD_TYPE=Release")
// The NixOS gcc wrapper injects -Werror=format-security, which
// errors once the project's -Wno-format disables -Wformat.
.arg("-DCMAKE_C_FLAGS=-Wno-error=format-security")
.arg("-DCMAKE_CXX_FLAGS=-Wno-error=format-security")
.arg("-DRNS_BUILD_TESTS=OFF")
.arg("-DRNS_BUILD_EXAMPLES=OFF")
.arg("-DRNS_BUILD_INTEROP=OFF")
.arg("-DRNS_USE_FS=ON")
.arg("-DRNS_PERSIST_PATHS=OFF")
.arg("-DRNS_PERSIST_KNOWN_DESTINATIONS=OFF")
.arg("-DRNS_PERSIST_HASHLIST=OFF");
for dep in DEP_VARS {
if let Ok(path) = std::env::var(dep) {
configure.arg(format!("-D{dep}={path}"));
}
}
let status = configure.status().expect("cmake configure");
assert!(status.success(), "cmake configure failed");
let status = std::process::Command::new("cmake")
.arg("--build")
.arg(&build)
.arg("--target")
.arg("microReticulum")
.status()
.expect("cmake build");
assert!(status.success(), "cmake build failed");
// The same include roots and defines the library's CMake target exports
// publicly, mirrored so the shim TU sees identical macros (the upstream
// CMakeLists warns about ODR divergence otherwise). A vendored dep (env
// override) lives at its checkout root; a fetched one under _deps.
let deps = build.join("_deps");
let mut includes: Vec<std::path::PathBuf> = vec![copy.join("src")];
for (env_var, fetch_name, sub) in DEP_INCLUDES {
includes.push(match std::env::var(env_var) {
Ok(root) => std::path::Path::new(&root).join(sub),
Err(_) => deps.join(format!("{fetch_name}-src")).join(sub),
});
}
let mut build_cc = cc::Build::new();
build_cc
.cpp(true)
.std("c++17")
.define("NATIVE", None)
.define("USTORE_USE_UNIVERSALFS", None)
.define("RNS_USE_FS", None)
.file("shim/smolmail_rns.cpp")
.file("shim/udp_interface.cpp");
for include in &includes {
build_cc.include(include);
}
build_cc.compile("smolmail_rns_shim");
println!("cargo:rustc-link-search=native={}", build.display());
println!("cargo:rustc-link-lib=static=microReticulum");
println!("cargo:rustc-link-lib=static=CryptoLib");
}
// microReticulum's own local-checkout override variables.
#[cfg(feature = "rns")]
const DEP_VARS: &[&str] = &[
"RNS_ARDUINOJSON_SOURCE_DIR",
"RNS_MSGPACK_SOURCE_DIR",
"RNS_CRYPTO_SOURCE_DIR",
"RNS_MICROSTORE_SOURCE_DIR",
"RNS_ARXCONTAINER_SOURCE_DIR",
"RNS_ARXTYPETRAITS_SOURCE_DIR",
"RNS_DEBUGLOG_SOURCE_DIR",
];
// (override env var, FetchContent name, include subdir within the dep root).
#[cfg(feature = "rns")]
const DEP_INCLUDES: &[(&str, &str, &str)] = &[
("RNS_ARDUINOJSON_SOURCE_DIR", "arduinojson", "src"),
("RNS_MSGPACK_SOURCE_DIR", "msgpack", ""),
("RNS_ARXCONTAINER_SOURCE_DIR", "arxcontainer", ""),
("RNS_ARXTYPETRAITS_SOURCE_DIR", "arxtypetraits", ""),
("RNS_DEBUGLOG_SOURCE_DIR", "debuglog", ""),
("RNS_CRYPTO_SOURCE_DIR", "crypto", ""),
("RNS_MICROSTORE_SOURCE_DIR", "microstore", "include"),
];
#[cfg(feature = "rns")]
fn copy_tree(src: &std::path::Path, dst: &std::path::Path, entries: &[&str]) {
std::fs::create_dir_all(dst).expect("OUT_DIR writable");
for entry in entries {
let from = src.join(entry);
if !from.exists() {
continue;
}
let to = dst.join(entry);
if from.is_dir() {
copy_dir(&from, &to);
} else {
copy_file(&from, &to);
}
}
}
#[cfg(feature = "rns")]
fn copy_dir(src: &std::path::Path, dst: &std::path::Path) {
std::fs::create_dir_all(dst).expect("OUT_DIR writable");
for entry in std::fs::read_dir(src).expect("readable source dir") {
let entry = entry.expect("readable source dir").path();
let to = dst.join(entry.file_name().unwrap());
if entry.is_dir() {
copy_dir(&entry, &to);
} else {
copy_file(&entry, &to);
}
}
}
/// fs::copy preserves the source's mode, and a Nix store checkout is
/// read-only, so the copy must be made writable before the patch step.
#[cfg(feature = "rns")]
fn copy_file(from: &std::path::Path, to: &std::path::Path) {
std::fs::copy(from, to).expect("copy file");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(to, std::fs::Permissions::from_mode(0o644))
.expect("copied file writable");
}
}
#[cfg(not(feature = "rns"))]
fn main() {}

146
flake.lock generated
View file

@ -18,6 +18,23 @@
"type": "github" "type": "github"
} }
}, },
"microReticulum": {
"flake": false,
"locked": {
"lastModified": 1784575896,
"narHash": "sha256-cqo+BJ3tsmw4fD+SL0D3X9FKCgf+n0VQng2pSIuyK/8=",
"owner": "attermann",
"repo": "microReticulum",
"rev": "40fa628809d57140180c1c833559ab96fec992c1",
"type": "github"
},
"original": {
"owner": "attermann",
"repo": "microReticulum",
"rev": "40fa628809d57140180c1c833559ab96fec992c1",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1790323409, "lastModified": 1790323409,
@ -34,10 +51,137 @@
"type": "github" "type": "github"
} }
}, },
"rns-arduinojson": {
"flake": false,
"locked": {
"lastModified": 1750405034,
"narHash": "sha256-MGspSk9zWdPHMFXm+Oi4sibznHYE1eKXSqi6QHmjVCk=",
"owner": "bblanchon",
"repo": "ArduinoJson",
"rev": "ed69feadb95182dc53ac978975d310122060a767",
"type": "github"
},
"original": {
"owner": "bblanchon",
"repo": "ArduinoJson",
"rev": "ed69feadb95182dc53ac978975d310122060a767",
"type": "github"
}
},
"rns-arxcontainer": {
"flake": false,
"locked": {
"lastModified": 1718955529,
"narHash": "sha256-3XzdM1fNl7irhjWF6hnbn+iw+orOox4KY5ezk1SC0Lg=",
"owner": "hideakitai",
"repo": "ArxContainer",
"rev": "d6affcd0bc83219b863c20abf7c269214db8db2a",
"type": "github"
},
"original": {
"owner": "hideakitai",
"repo": "ArxContainer",
"rev": "d6affcd0bc83219b863c20abf7c269214db8db2a",
"type": "github"
}
},
"rns-arxtypetraits": {
"flake": false,
"locked": {
"lastModified": 1760475334,
"narHash": "sha256-rKW85Pt4NsbYDesnwJKSrx6F2bq4ZDSU+7DpRMvH1R0=",
"owner": "hideakitai",
"repo": "ArxTypeTraits",
"rev": "702de9cc59c7e047cdc169ae3547718b289d2c02",
"type": "github"
},
"original": {
"owner": "hideakitai",
"repo": "ArxTypeTraits",
"rev": "702de9cc59c7e047cdc169ae3547718b289d2c02",
"type": "github"
}
},
"rns-crypto": {
"flake": false,
"locked": {
"lastModified": 1779476496,
"narHash": "sha256-bv0Hr+1sp9nCERZSmhCYv69C2zn+Jk2N3pZVUXquRcw=",
"owner": "attermann",
"repo": "Crypto",
"rev": "984dc891330986c302a86c4e312d4f5abcc28359",
"type": "github"
},
"original": {
"owner": "attermann",
"repo": "Crypto",
"rev": "984dc891330986c302a86c4e312d4f5abcc28359",
"type": "github"
}
},
"rns-debuglog": {
"flake": false,
"locked": {
"lastModified": 1731116852,
"narHash": "sha256-vu4jfXY9ulaEFQzv1VJahzBN3ii+8F7AyOxsnKRzjv4=",
"owner": "hideakitai",
"repo": "DebugLog",
"rev": "b581f7dde6c276c5df684e2328f406d9754d2f46",
"type": "github"
},
"original": {
"owner": "hideakitai",
"repo": "DebugLog",
"rev": "b581f7dde6c276c5df684e2328f406d9754d2f46",
"type": "github"
}
},
"rns-microstore": {
"flake": false,
"locked": {
"lastModified": 1784473031,
"narHash": "sha256-gY/r5q4tPDfIj3I0E2ZVj0URPjo1tlQj37Ctg9NQoYQ=",
"owner": "attermann",
"repo": "microStore",
"rev": "0f28567fe00ab8ab14624a34c2e9e0a000a44c46",
"type": "github"
},
"original": {
"owner": "attermann",
"repo": "microStore",
"rev": "0f28567fe00ab8ab14624a34c2e9e0a000a44c46",
"type": "github"
}
},
"rns-msgpack": {
"flake": false,
"locked": {
"lastModified": 1707898892,
"narHash": "sha256-Zn3cwUaW2RMvOyvpcA1JxHQk3f3X7m2yZ8ilRAwgxNI=",
"owner": "hideakitai",
"repo": "MsgPack",
"rev": "1f552c31b940d6e9063ee17a4b3fa10c47b27169",
"type": "github"
},
"original": {
"owner": "hideakitai",
"repo": "MsgPack",
"rev": "1f552c31b940d6e9063ee17a4b3fa10c47b27169",
"type": "github"
}
},
"root": { "root": {
"inputs": { "inputs": {
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs" "microReticulum": "microReticulum",
"nixpkgs": "nixpkgs",
"rns-arduinojson": "rns-arduinojson",
"rns-arxcontainer": "rns-arxcontainer",
"rns-arxtypetraits": "rns-arxtypetraits",
"rns-crypto": "rns-crypto",
"rns-debuglog": "rns-debuglog",
"rns-microstore": "rns-microstore",
"rns-msgpack": "rns-msgpack"
} }
}, },
"systems": { "systems": {

164
flake.nix
View file

@ -4,32 +4,96 @@
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
# RNS carrier sources (RNS.md sec 8): the build sandbox has no network,
# so every microReticulum FetchContent dependency is vendored as its own
# flake input and handed over via RNS_<DEP>_SOURCE_DIR. microReticulum
# is pinned at the commit RNS.md sec 5 verified against.
microReticulum = {
url = "github:attermann/microReticulum/40fa628809d57140180c1c833559ab96fec992c1";
flake = false;
};
rns-arduinojson = {
url = "github:bblanchon/ArduinoJson/ed69feadb95182dc53ac978975d310122060a767";
flake = false;
};
rns-msgpack = {
url = "github:hideakitai/MsgPack/1f552c31b940d6e9063ee17a4b3fa10c47b27169";
flake = false;
};
rns-arxcontainer = {
url = "github:hideakitai/ArxContainer/d6affcd0bc83219b863c20abf7c269214db8db2a";
flake = false;
};
rns-arxtypetraits = {
url = "github:hideakitai/ArxTypeTraits/702de9cc59c7e047cdc169ae3547718b289d2c02";
flake = false;
};
rns-debuglog = {
url = "github:hideakitai/DebugLog/b581f7dde6c276c5df684e2328f406d9754d2f46";
flake = false;
};
rns-crypto = {
url = "github:attermann/Crypto/984dc891330986c302a86c4e312d4f5abcc28359";
flake = false;
};
rns-microstore = {
url = "github:attermann/microStore/0f28567fe00ab8ab14624a34c2e9e0a000a44c46";
flake = false;
};
}; };
outputs = { self, nixpkgs, flake-utils }: outputs = { self, nixpkgs, flake-utils, microReticulum, rns-arduinojson
, rns-msgpack, rns-arxcontainer, rns-arxtypetraits, rns-debuglog
, rns-crypto, rns-microstore }:
flake-utils.lib.eachDefaultSystem (system: flake-utils.lib.eachDefaultSystem (system:
let let
pkgs = import nixpkgs { inherit system; }; pkgs = import nixpkgs { inherit system; };
inherit (pkgs) lib;
bunshin = pkgs.rustPlatform.buildRustPackage { # build.rs consumes these directly (RNS.md sec 7.1); with every
# dependency vendored, the cmake configure step never fetches.
rnsSourceEnv = {
MICRORETICULUM_SOURCE_DIR = "${microReticulum}";
RNS_ARDUINOJSON_SOURCE_DIR = "${rns-arduinojson}";
RNS_MSGPACK_SOURCE_DIR = "${rns-msgpack}";
RNS_ARXCONTAINER_SOURCE_DIR = "${rns-arxcontainer}";
RNS_ARXTYPETRAITS_SOURCE_DIR = "${rns-arxtypetraits}";
RNS_DEBUGLOG_SOURCE_DIR = "${rns-debuglog}";
RNS_CRYPTO_SOURCE_DIR = "${rns-crypto}";
RNS_MICROSTORE_SOURCE_DIR = "${rns-microstore}";
};
bunshin = { rns ? false }: pkgs.rustPlatform.buildRustPackage {
pname = "bunshin"; pname = "bunshin";
version = "0.1.0"; version = "0.1.0";
src = ./.; src = ./.;
cargoLock.lockFile = ./Cargo.lock; cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = [ pkgs.pkg-config ]; nativeBuildInputs = [ pkgs.pkg-config ]
++ lib.optionals rns [ pkgs.cmake pkgs.ninja pkgs.gcc ];
buildInputs = [ pkgs.sqlite ]; buildInputs = [ pkgs.sqlite ];
buildFeatures = lib.optionals rns [ "rns" ];
env = lib.optionalAttrs rns rnsSourceEnv;
# ninja's setup hook claims buildPhase before the cargo hooks
# can, which would run ninja against no build.ninja instead of
# cargo; ninja here is only for build.rs to invoke through cmake.
dontUseNinjaBuild = rns;
dontUseNinjaCheck = rns;
dontUseNinjaInstall = rns;
}; };
in in
{ {
packages.default = bunshin; packages.default = bunshin { };
packages.rns = bunshin { rns = true; };
apps.default = flake-utils.lib.mkApp { apps.default = flake-utils.lib.mkApp {
drv = bunshin; drv = bunshin { };
name = "bunshin"; name = "bunshin";
}; };
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
packages = [ pkgs.cargo pkgs.rustc pkgs.rustfmt pkgs.clippy pkgs.pkg-config pkgs.gcc pkgs.sqlite ]; packages = with pkgs; [ cargo rustc rustfmt clippy pkg-config gcc sqlite cmake ninja ];
env = rnsSourceEnv;
}; };
}) // { }) // {
nixosModules.default = { config, lib, pkgs, ... }: nixosModules.default = { config, lib, pkgs, ... }:
@ -44,7 +108,10 @@
package = mkOption { package = mkOption {
type = types.package; type = types.package;
default = self.packages.${pkgs.system}.default; default = self.packages.${pkgs.system}.default;
description = "bunshin package to run."; description = ''
bunshin package to run. Use `packages.rns` when the RNS
carrier is enabled: the default package is built without it.
'';
}; };
host = mkOption { host = mkOption {
@ -152,6 +219,72 @@
default = false; default = false;
description = "Open the configured TCP port in the firewall."; description = "Open the configured TCP port in the firewall.";
}; };
rns = {
enable = mkEnableOption "the RNS carrier alongside TCP (needs an rns-built package)";
keyFile = mkOption {
type = types.path;
description = ''
Path to the server's Reticulum identity (64 raw bytes,
x25519 || ed25519 private halves, generated with
`bunshin rns-keygen`). RNS writes the private key
unencrypted, so protect the file like any long-term key.
'';
};
maxEnvelope = mkOption {
type = types.ints.positive;
default = 32768;
description = "Maximum accepted envelope size over RNS, in bytes (RNS.md sec 3).";
};
fetchBudget = mkOption {
type = types.ints.positive;
default = 32768;
description = "Bytes one FETCH response may total over RNS.";
};
maxLinks = mkOption {
type = types.ints.positive;
default = 100;
description = "Maximum concurrent Reticulum links; further links are refused.";
};
rateLinkRequests = mkOption {
type = types.ints.positive;
default = 60;
description = "Max requests per minute, per link.";
};
rateLinkBytes = mkOption {
type = types.ints.positive;
default = 1048576;
description = "Max request bytes per minute, per link.";
};
udpListenPort = mkOption {
type = types.port;
default = 4242;
description = ''
UDP port the Reticulum interface listens on. RNS reaches
the mesh through a configured interface rather than a
listening TCP port; only this UDP port needs a firewall
opening.
'';
};
udpForward = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Optional host[:port] the interface forwards every packet
to. Without it, replies go to the source address of the
last datagram received, which suits a listen-only
point-to-point setup.
'';
};
};
}; };
config = mkIf cfg.enable { config = mkIf cfg.enable {
@ -186,6 +319,20 @@
--rate-sends ${toString cfg.rateSends} --rate-sends ${toString cfg.rateSends}
--rate-tokens ${toString cfg.rateTokens} --rate-tokens ${toString cfg.rateTokens}
) )
${lib.optionalString cfg.rns.enable ''
args+=(
--rns
--rns-key ${cfg.rns.keyFile}
--rns-max-envelope ${toString cfg.rns.maxEnvelope}
--rns-fetch-budget ${toString cfg.rns.fetchBudget}
--rns-max-links ${toString cfg.rns.maxLinks}
--rns-rate-link-requests ${toString cfg.rns.rateLinkRequests}
--rns-rate-link-bytes ${toString cfg.rns.rateLinkBytes}
--rns-udp 0.0.0.0:${toString cfg.rns.udpListenPort}
)
''}
${lib.optionalString (cfg.rns.enable && cfg.rns.udpForward != null)
''args+=(--rns-udp-forward ${lib.escapeShellArg cfg.rns.udpForward})''}
${lib.optionalString (cfg.inviteToken != null) ${lib.optionalString (cfg.inviteToken != null)
''args+=(--invite-token ${lib.escapeShellArg cfg.inviteToken})''} ''args+=(--invite-token ${lib.escapeShellArg cfg.inviteToken})''}
${lib.optionalString (cfg.inviteTokenFile != null) ${lib.optionalString (cfg.inviteTokenFile != null)
@ -201,6 +348,9 @@
}; };
}; };
# RNS needs no TCP port; only its UDP interface may be opened.
networking.firewall.allowedUDPPorts =
mkIf (cfg.rns.enable && cfg.openFirewall) [ cfg.rns.udpListenPort ];
networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ]; networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ];
}; };
}; };

178
shim/smolmail_rns.cpp Normal file
View file

@ -0,0 +1,178 @@
/*
* microReticulum bridge for the bunshin RNS carrier (RNS.md sec 7).
*
* Owns the Reticulum instance, the smolmail.server IN/SINGLE destination
* with its request handler, and the loop thread that drives
* Reticulum::loop(). Every Rust callback fires on that thread; per-link
* teardowns decided inside a callback are deferred until after the current
* loop iteration so they never re-enter transport processing.
*/
#include "smolmail_rns.h"
#include "udp_interface.h"
#include <microStore/FileSystem.h>
#include <microStore/Adapters/UniversalFileSystem.h>
#include <MsgPack.h>
#include <microReticulum.h>
#include <cstring>
#include <mutex>
#include <thread>
#include <vector>
// Anchored in src/rns.rs.
static const char* APP_NAME = "smolmail";
static const char* APP_ASPECT = "server";
static const char* REQ_PATH = "smolmail/1";
// Interfaces rate-limit announces to roughly one an hour (RNS.md sec 1);
// every two hours stays well under that ceiling (upstream spec 13.1).
static const double ANNOUNCE_INTERVAL_SECS = 2.0 * 3600.0;
static const double LOOP_SLEEP_SECS = 0.01;
static RNS::Reticulum reticulum({RNS::Type::NONE});
static RNS::Interface udp_interface({RNS::Type::NONE});
static RNS::Destination destination({RNS::Type::NONE});
static microStore::FileSystem filesystem{microStore::Adapters::UniversalFileSystem()};
static std::mutex teardown_mutex;
static std::vector<RNS::Link> pending_teardowns;
static volatile bool running = false;
static void on_link_closed(RNS::Link& link) {
const RNS::Bytes& id = link.link_id();
smolmail_rns_on_link_closed(id.data());
}
static void on_link_established(RNS::Link& link) {
// link_id is the map key on the Rust side; the closed callback evicts.
link.set_link_closed_callback(on_link_closed);
const RNS::Bytes& id = link.link_id();
if (smolmail_rns_on_link_opened(id.data()) != 0) {
std::lock_guard<std::mutex> lock(teardown_mutex);
pending_teardowns.push_back(link);
}
}
// The request data arrives msgpack-bin-wrapped: Link::handle_request splices
// the generator's return value verbatim into the response envelope, so both
// directions must carry the smolmail payload as a msgpack binary.
static RNS::Bytes handle_smolmail_request(const RNS::Bytes& path, const RNS::Bytes& data,
const RNS::Bytes& request_id, const RNS::Bytes& link_id,
const RNS::Identity& remote_identity, double requested_at) {
(void)path; (void)request_id; (void)remote_identity; (void)requested_at;
RNS::Bytes request;
{
MsgPack::Unpacker u;
u.feed(data.data(), data.size());
if (u.isBin()) {
MsgPack::bin_t<uint8_t> bin;
u.deserialize(bin);
request = RNS::Bytes(bin.data(), bin.size());
}
}
size_t len = 0;
if (request) {
len = smolmail_rns_on_request(request.data(), request.size(), link_id.data());
}
RNS::Bytes response(len);
if (len > 0) {
smolmail_rns_take_response(response.writable(len), len);
}
else {
// The request was not a msgpack binary, or Rust produced nothing;
// always answer (upstream spec 13.5) with MALFORMED.
response = RNS::Bytes(1);
response.writable(1)[0] = 1;
}
MsgPack::Packer p;
p.packBinary(response.data(), response.size());
return RNS::Bytes(p.data(), p.size());
}
static void loop_thread_main() {
double last_announce = RNS::Utilities::OS::time();
destination.announce();
while (running) {
reticulum.loop();
std::vector<RNS::Link> teardowns;
{
std::lock_guard<std::mutex> lock(teardown_mutex);
teardowns.swap(pending_teardowns);
}
for (RNS::Link& link : teardowns) {
link.teardown();
}
double now = RNS::Utilities::OS::time();
if (now - last_announce >= ANNOUNCE_INTERVAL_SECS) {
destination.announce();
last_announce = now;
}
RNS::Utilities::OS::sleep(LOOP_SLEEP_SECS);
}
}
extern "C" int smolmail_rns_destination_hash(const uint8_t* identity,
uint8_t* destination_hash_out) {
RNS::Identity rns_identity(false);
if (!rns_identity.load_private_key(RNS::Bytes(identity, 64))) {
return -1;
}
const RNS::Bytes& hash = RNS::Destination::hash(rns_identity, APP_NAME, APP_ASPECT);
memcpy(destination_hash_out, hash.data(), 16);
return 0;
}
extern "C" int smolmail_rns_start(const uint8_t* identity,
const char* storage_dir,
const char* udp_listen_host, uint16_t udp_listen_port,
const char* udp_forward_host, uint16_t udp_forward_port,
uint8_t* destination_hash_out) {
if (running) {
return -1;
}
// Registered before anything else, as the interop examples do, so
// persistence and identity writes have a filesystem to go through.
filesystem.init();
RNS::Utilities::OS::register_filesystem(filesystem);
RNS::Reticulum::storagepath(storage_dir);
udp_interface = new UDPInterface("smolmail_rns_udp",
udp_listen_host, udp_listen_port,
udp_forward_host ? udp_forward_host : "",
udp_forward_port);
udp_interface.mode(RNS::Type::Interface::MODE_GATEWAY);
RNS::Transport::register_interface(udp_interface);
if (!udp_interface.start()) {
return -2;
}
reticulum = RNS::Reticulum();
reticulum.transport_enabled(false);
reticulum.start();
RNS::Identity rns_identity(false);
if (!rns_identity.load_private_key(RNS::Bytes(identity, 64))) {
return -3;
}
destination = RNS::Destination(rns_identity, RNS::Type::Destination::IN,
RNS::Type::Destination::SINGLE, APP_NAME, APP_ASPECT);
destination.accepts_links(true);
destination.register_request_handler(RNS::Bytes(REQ_PATH), handle_smolmail_request,
RNS::Type::Destination::ALLOW_ALL);
destination.set_link_established_callback(on_link_established);
memcpy(destination_hash_out, destination.hash().data(), 16);
running = true;
std::thread(loop_thread_main).detach();
return 0;
}

55
shim/smolmail_rns.h Normal file
View file

@ -0,0 +1,55 @@
/*
* C ABI between bunshin (Rust) and the microReticulum carrier shim.
*
* The shim owns the Reticulum run loop on a dedicated thread; every callback
* below fires on that thread. link_id is always 16 bytes.
*/
#ifndef SMOLMAIL_RNS_H
#define SMOLMAIL_RNS_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Implemented in smolmail_rns.cpp, called from src/rns.rs.
* identity: 64 bytes, x25519 private (32) || ed25519 private (32), the same
* layout Identity::to_file writes and load_private_key expects.
* udp_forward_host may be NULL: with no forward target the interface sends
* to the source address of the last datagram received.
* Returns 0 on success, negative on failure. */
int smolmail_rns_start(const uint8_t *identity,
const char *storage_dir,
const char *udp_listen_host, uint16_t udp_listen_port,
const char *udp_forward_host, uint16_t udp_forward_port,
uint8_t *destination_hash_out);
/* Pure computation, no Reticulum state: the 16-byte destination hash of
* smolmail.server under this identity. microReticulum derives the identity's
* x25519 public key with an unclamped scalar multiplication, which differs
* from RFC 7748, so the hash must come from the library itself rather than
* an independent Rust derivation. */
int smolmail_rns_destination_hash(const uint8_t *identity,
uint8_t *destination_hash_out);
/* Implemented in src/rns.rs, called from the shim on the Reticulum loop
* thread. A request is `op u8 || body` (RNS.md sec 1); the response placed
* into the slot is `status u8 || payload`. on_request returns the response
* length and leaves the bytes in the slot; the shim must copy them out with
* take_response before the next on_request call. */
size_t smolmail_rns_on_request(const uint8_t *request, size_t request_len,
const uint8_t *link_id);
size_t smolmail_rns_take_response(uint8_t *out, size_t cap);
/* 0 = link admitted, 1 = over the concurrent-link cap (the shim tears the
* link down after the current loop iteration). */
int smolmail_rns_on_link_opened(const uint8_t *link_id);
void smolmail_rns_on_link_closed(const uint8_t *link_id);
#ifdef __cplusplus
}
#endif
#endif /* SMOLMAIL_RNS_H */

198
shim/udp_interface.cpp Normal file
View file

@ -0,0 +1,198 @@
#include "udp_interface.h"
#include <microReticulum/Transport.h>
#include <microReticulum/Log.h>
#ifndef ARDUINO
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#endif
using namespace RNS;
UDPInterface::UDPInterface(const char* name,
const std::string& local_host, int local_port,
const std::string& remote_host, int remote_port)
: RNS::InterfaceImpl(name) {
_IN = true;
_OUT = true;
_bitrate = BITRATE_GUESS;
_HW_MTU = 1064;
_local_host = local_host;
_local_port = local_port;
if (!remote_host.empty()) {
_forward_configured = true;
_remote_host = remote_host;
_remote_port = remote_port;
}
}
/*virtual*/ UDPInterface::~UDPInterface() {
stop();
}
/*virtual*/ bool UDPInterface::start() {
_online = false;
#ifdef ARDUINO
udp.begin(_local_port);
#else
// resolve local host
struct in_addr local_addr;
if (inet_aton(_local_host.c_str(), &local_addr) == 0) {
struct hostent* host_ent = gethostbyname(_local_host.c_str());
if (host_ent == nullptr || host_ent->h_addr_list[0] == nullptr) {
ERRORF("Unable to resolve local host %s", _local_host.c_str());
return false;
}
_local_address = *((in_addr_t*)(host_ent->h_addr_list[0]));
}
else {
_local_address = local_addr.s_addr;
}
_remote_address = INADDR_NONE;
if (_forward_configured) {
struct in_addr remote_addr;
if (inet_aton(_remote_host.c_str(), &remote_addr) == 0) {
struct hostent* host_ent = gethostbyname(_remote_host.c_str());
if (host_ent == nullptr || host_ent->h_addr_list[0] == nullptr) {
ERRORF("Unable to resolve remote host %s", _remote_host.c_str());
return false;
}
_remote_address = *((in_addr_t*)(host_ent->h_addr_list[0]));
}
else {
_remote_address = remote_addr.s_addr;
}
}
_socket = socket( PF_INET, SOCK_DGRAM, 0 );
if (_socket < 0) {
ERRORF("Unable to create socket with error %d", errno);
return false;
}
int broadcast = 1;
setsockopt(_socket, SOL_SOCKET, SO_BROADCAST, &broadcast, sizeof(broadcast));
int reuse = 1;
setsockopt(_socket, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
#ifdef SO_REUSEPORT
setsockopt(_socket, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse));
#endif
INFOF("Binding UDP socket %d to %s:%d", _socket, _local_host.c_str(), _local_port);
sockaddr_in bind_addr;
memset(&bind_addr, 0, sizeof(bind_addr));
bind_addr.sin_family = AF_INET;
bind_addr.sin_addr.s_addr = _local_address;
bind_addr.sin_port = htons(_local_port);
if (bind(_socket, (struct sockaddr*)&bind_addr, sizeof(bind_addr)) == -1) {
ERRORF("Unable to bind socket with error %d", errno);
close(_socket);
_socket = -1;
return false;
}
#endif
_online = true;
return true;
}
/*virtual*/ void UDPInterface::stop() {
#ifndef ARDUINO
if (_socket > -1) {
close(_socket);
_socket = -1;
}
#endif
_online = false;
}
/*virtual*/ void UDPInterface::loop() {
if (!_online) {
return;
}
#ifdef ARDUINO
udp.parsePacket();
size_t len = udp.read(_buffer.writable(Type::Reticulum::MTU), Type::Reticulum::MTU);
if (len > 0) {
_buffer.resize(len);
on_incoming(_buffer);
}
#else
// One datagram per recvfrom() with MSG_DONTWAIT, looping until the
// kernel queue is empty — the same drain pattern as the microReticulum
// example, which avoids stale/zero FIONREAD counts on some platforms.
while (true) {
sockaddr_in src_addr{};
socklen_t src_addr_len = sizeof(src_addr);
ssize_t len = recvfrom(_socket,
_buffer.writable(_HW_MTU),
_HW_MTU,
MSG_DONTWAIT,
(struct sockaddr*)&src_addr,
&src_addr_len);
if (len <= 0) {
break;
}
_buffer.resize(static_cast<size_t>(len));
if (!_forward_configured) {
_last_src_addr = src_addr;
_have_src = true;
}
on_incoming(_buffer);
}
#endif
}
/*virtual*/ bool UDPInterface::send_outgoing(const RNS::Bytes& data) {
bool success = true;
try {
if (_online) {
#ifdef ARDUINO
udp.beginPacket(_remote_host.c_str(), _remote_port);
udp.write(data.data(), data.size());
udp.endPacket();
#else
sockaddr_in sock_addr;
if (_forward_configured) {
memset(&sock_addr, 0, sizeof(sock_addr));
sock_addr.sin_family = AF_INET;
sock_addr.sin_addr.s_addr = _remote_address;
sock_addr.sin_port = htons(_remote_port);
}
else if (_have_src) {
// No forward target: reply to whoever spoke to us last.
sock_addr = _last_src_addr;
}
else {
WARNING("UDPInterface: no forward target and no peer heard from yet, dropping outgoing packet");
return false;
}
ssize_t sent = sendto(_socket, data.data(), data.size(), 0, (struct sockaddr*)&sock_addr, sizeof(sock_addr));
if (sent != (ssize_t)data.size()) {
WARNINGF("Failed sending %d bytes via UDP", (int)data.size());
success = false;
}
#endif
}
InterfaceImpl::handle_outgoing(data);
}
catch (const std::exception& e) {
ERRORF("Could not transmit on %s. The contained exception was: %s", toString().c_str(), e.what());
success = false;
}
return success;
}
void UDPInterface::on_incoming(const RNS::Bytes& data) {
InterfaceImpl::handle_incoming(data);
}

64
shim/udp_interface.h Normal file
View file

@ -0,0 +1,64 @@
/*
* UDP interface for the bunshin RNS shim, adapted from microReticulum's
* examples/common/udp_interface (Apache-2.0). The example version hardcodes
* compile-time defaults; this one takes its endpoints from the caller, and
* with no forward target configured it sends to the source address of the
* last datagram received, so a listen-only server can answer any client.
*/
#pragma once
#include <microReticulum/Interface.h>
#include <microReticulum/Bytes.h>
#include <microReticulum/Type.h>
#ifdef ARDUINO
#include <WiFi.h>
#include <WiFiUdp.h>
#else
#include <netinet/in.h>
#endif
#include <stdint.h>
#include <string>
class UDPInterface : public RNS::InterfaceImpl {
public:
static const uint32_t BITRATE_GUESS = 10*1000*1000;
UDPInterface(const char* name,
const std::string& local_host, int local_port,
const std::string& remote_host, int remote_port);
virtual ~UDPInterface();
virtual bool start();
virtual void stop();
virtual void loop();
virtual inline std::string toString() const { return "UDPInterface[" + _name + "/" + _local_host + ":" + std::to_string(_local_port) + "]"; }
protected:
virtual bool send_outgoing(const RNS::Bytes& data);
void on_incoming(const RNS::Bytes& data);
private:
RNS::Bytes _buffer;
std::string _local_host;
int _local_port;
std::string _remote_host;
int _remote_port;
bool _forward_configured = false;
#ifdef ARDUINO
WiFiUDP udp;
#else
int _socket = -1;
in_addr_t _local_address = INADDR_ANY;
in_addr_t _remote_address = INADDR_NONE;
sockaddr_in _last_src_addr = {};
bool _have_src = false;
#endif
};

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 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 /// Generates the server's static X25519 keypair (transport identity, distinct
/// from any user's Ed25519 identity). Returns (private, public) raw bytes. /// from any user's Ed25519 identity). Returns (private, public) raw bytes.
pub fn generate_static_key() -> ([u8; 32], [u8; 32]) { pub fn generate_static_key() -> ([u8; 32], [u8; 32]) {

View file

@ -1,9 +1,12 @@
//! Smol Mail server. //! Smol Mail server.
mod bind;
mod channel; mod channel;
mod crypto; mod crypto;
mod proto; mod proto;
mod ratelimit; mod ratelimit;
#[cfg(feature = "rns")]
mod rns;
mod server; mod server;
mod session; mod session;
mod store; mod store;
@ -65,7 +68,45 @@ enum Command {
rate_sends: u32, rate_sends: u32,
#[arg(long = "rate-tokens", default_value_t = 30)] #[arg(long = "rate-tokens", default_value_t = 30)]
rate_tokens: u32, 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<()> { fn main() -> anyhow::Result<()> {
@ -82,6 +123,9 @@ fn main() -> anyhow::Result<()> {
match cli.command { match cli.command {
Command::Keygen { key, force } => cmd_keygen(&key, force), 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 { Command::Serve {
key, key,
db, db,
@ -113,6 +157,92 @@ fn main() -> anyhow::Result<()> {
rate_sends, rate_sends,
rate_tokens, 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)."); println!("Publish the public key through a trusted channel; clients pin it (SPEC.md sec 4).");
Ok(()) 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_MAX: usize = 65535; // Noise message ceiling
pub const NOISE_PAYLOAD: usize = NOISE_MAX - 16; // minus the AEAD tag pub const NOISE_PAYLOAD: usize = NOISE_MAX - 16; // minus the AEAD tag
pub const FETCH_BUDGET: usize = 512 * 1024; // must stay under MAX_FRAME 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 IDLE_TIMEOUT_SECS: u64 = 120;
pub const PURGE_INTERVAL_SECS: u64 = 60; 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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::sync::Arc;
use std::time::Duration; use std::time::Duration;
use crate::bind::TransportBindValues;
use crate::channel::{handshake, Channel}; use crate::channel::{handshake, Channel};
use crate::crypto::{b32, derive_public}; 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::ratelimit::RateLimiter;
use crate::session::{ServerConfig, Session}; use crate::session::{ServerConfig, Session};
use crate::store::Store; use crate::store::Store;
@ -29,8 +30,8 @@ pub struct ServeArgs {
} }
pub fn run(args: ServeArgs) -> anyhow::Result<()> { pub fn run(args: ServeArgs) -> anyhow::Result<()> {
let static_key = let static_key = std::fs::read(&args.key_path)
std::fs::read(&args.key_path).map_err(|e| anyhow::anyhow!("cannot read server key: {e}"))?; .map_err(|e| anyhow::anyhow!("cannot read server key: {e}"))?;
anyhow::ensure!( anyhow::ensure!(
static_key.len() == KEY_LEN, static_key.len() == KEY_LEN,
"server key must be {KEY_LEN} raw bytes, got {}", "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 { let config = Arc::new(ServerConfig {
max_envelope: args.max_envelope, max_envelope: args.max_envelope,
fetch_budget: FETCH_BUDGET,
main_quota: args.quota, main_quota: args.quota,
requests_quota: args.requests_quota, requests_quota: args.requests_quota,
max_tokens: args.max_tokens, max_tokens: args.max_tokens,
invite_token: args.invite_token.map(String::into_bytes), invite_token: args.invite_token.map(String::into_bytes),
server_static,
conn_limiter: RateLimiter::new(args.rate_connections), conn_limiter: RateLimiter::new(args.rate_connections),
send_limiter: RateLimiter::new(args.rate_sends), send_limiter: RateLimiter::new(args.rate_sends),
token_limiter: RateLimiter::new(args.rate_tokens), 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 main_retention_secs = args.retention_days * 86400;
let requests_retention_secs = args.requests_retention_days * 86400; let requests_retention_secs = args.requests_retention_days * 86400;
let purge_db_path = args.db_path.clone(); 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))?; let listener = std::net::TcpListener::bind((args.host.as_str(), args.port))?;
log::info!("listening on {}:{}", args.host, 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 config = Arc::clone(&config);
let static_key = static_key.clone(); let static_key = key_array;
let db_path = args.db_path.clone(); let db_path = args.db_path.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
if let Err(e) = handle_connection(stream, &config, &static_key, &db_path, &peer_ip) { 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( fn handle_connection(
mut stream: TcpStream, mut stream: TcpStream,
config: &ServerConfig, config: &ServerConfig,
static_key: &[u8], static_key: &[u8; KEY_LEN],
db_path: &str, db_path: &str,
peer_ip: &str, peer_ip: &str,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
@ -109,7 +112,9 @@ fn handle_connection(
}; };
let store = Store::open(db_path)?; 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); let mut channel = Channel::new(stream, transport);
loop { loop {

View file

@ -1,23 +1,24 @@
//! Per-connection dispatch and the six wire operations. //! 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::crypto::{b32, ct_eq, hmac_sha256, message_id, verify};
use crate::proto::{ use crate::proto::{
valid_username, ProtocolError, Reader, AUTH_FAILED, AUTH_REQUIRED, BAD_VERSION, CERT_LEN, 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, ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, ID_LEN, KEY_LEN, LABEL_AUTH, LABEL_MAC,
LABEL_MAC, LABEL_REGISTER, LABEL_ROTATE, MAC_LEN, MALFORMED, MAX_CHAIN, NOT_PERMITTED, OK, LABEL_REGISTER, LABEL_ROTATE, MAC_LEN, MALFORMED, MAX_CHAIN, NOT_PERMITTED, OK, OP_AUTH,
OP_AUTH, OP_DELETE, OP_FETCH, OP_REGISTER, OP_RESOLVE, OP_SEND, QUOTA_EXCEEDED, RATE_LIMITED, OP_DELETE, OP_FETCH, OP_REGISTER, OP_RESOLVE, OP_SEND, QUOTA_EXCEEDED, RATE_LIMITED, TOKEN_LEN,
TOKEN_LEN, TOO_LARGE, UNKNOWN_USER, TOO_LARGE, UNKNOWN_USER,
}; };
use crate::ratelimit::RateLimiter; use crate::ratelimit::RateLimiter;
use crate::store::Store; use crate::store::Store;
pub struct ServerConfig { pub struct ServerConfig {
pub max_envelope: usize, pub max_envelope: usize,
pub fetch_budget: usize,
pub main_quota: i64, pub main_quota: i64,
pub requests_quota: i64, pub requests_quota: i64,
pub max_tokens: u16, pub max_tokens: u16,
pub invite_token: Option<Vec<u8>>, pub invite_token: Option<Vec<u8>>,
pub server_static: [u8; KEY_LEN],
pub conn_limiter: RateLimiter, pub conn_limiter: RateLimiter,
pub send_limiter: RateLimiter, pub send_limiter: RateLimiter,
pub token_limiter: RateLimiter, pub token_limiter: RateLimiter,
@ -47,17 +48,22 @@ pub struct Session<'a> {
config: &'a ServerConfig, config: &'a ServerConfig,
store: Store, store: Store,
peer_ip: String, peer_ip: String,
handshake_hash: Vec<u8>, bind: TransportBindValues,
username: Option<String>, username: Option<String>,
} }
impl<'a> Session<'a> { 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 { Session {
config, config,
store, store,
peer_ip, peer_ip,
handshake_hash, bind,
username: None, username: None,
} }
} }
@ -125,7 +131,7 @@ impl<'a> Session<'a> {
return Ok((AUTH_FAILED, Vec::new())); return Ok((AUTH_FAILED, Vec::new()));
} }
let mut msg = LABEL_AUTH.to_vec(); 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) { if !verify(&identity, &signature, &msg) {
return Ok((AUTH_FAILED, Vec::new())); return Ok((AUTH_FAILED, Vec::new()));
} }
@ -231,9 +237,12 @@ impl<'a> Session<'a> {
r.done()?; r.done()?;
let username = self.username.as_ref().expect("AUTH_REQUIRED gate above"); let username = self.username.as_ref().expect("AUTH_REQUIRED gate above");
let keys = self.store.keys_of(username)?; let keys = self.store.keys_of(username)?;
let records = self let records = self.store.pending(
.store &keys,
.pending(&keys, after_received_at, &after_id, FETCH_BUDGET)?; after_received_at,
&after_id,
self.config.fetch_budget,
)?;
let mut out = Vec::new(); let mut out = Vec::new();
out.extend_from_slice(&(records.len() as u16).to_be_bytes()); 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 // (SPEC.md sec 6.1). Binding server_static stops the attestation from
// being replayed against another server. // being replayed against another server.
let mut pop_msg = LABEL_REGISTER.to_vec(); 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(username.as_bytes());
pop_msg.extend_from_slice(&identity); pop_msg.extend_from_slice(&identity);
if !verify(&identity, &signature, &pop_msg) { if !verify(&identity, &signature, &pop_msg) {
@ -343,3 +352,163 @@ impl<'a> Session<'a> {
Ok((OK, Vec::new())) 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);
}
}