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"
dependencies = [
"anyhow",
"cc",
"clap",
"data-encoding",
"ed25519-dalek",

View file

@ -8,6 +8,11 @@ description = "Smol Mail server"
name = "bunshin"
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]
snow = "0.9"
rusqlite = { version = "0.32", features = ["bundled"] }
@ -20,3 +25,6 @@ clap = { version = "4", features = ["derive"] }
log = "0.4"
env_logger = "0.11"
anyhow = "1"
[build-dependencies]
cc = { version = "1", optional = true }

View file

@ -1,6 +1,6 @@
# 分身 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.
@ -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.
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`:
```
@ -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.
## 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
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
@ -65,3 +78,5 @@ bunshin serve --key server.key --db mail.db --host 0.0.0.0 --port 1961
## 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.
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"
}
},
"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": {
"locked": {
"lastModified": 1790323409,
@ -34,10 +51,137 @@
"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": {
"inputs": {
"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": {

164
flake.nix
View file

@ -4,32 +4,96 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
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:
let
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";
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = [ pkgs.pkg-config ];
nativeBuildInputs = [ pkgs.pkg-config ]
++ lib.optionals rns [ pkgs.cmake pkgs.ninja pkgs.gcc ];
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
{
packages.default = bunshin;
packages.default = bunshin { };
packages.rns = bunshin { rns = true; };
apps.default = flake-utils.lib.mkApp {
drv = bunshin;
drv = bunshin { };
name = "bunshin";
};
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, ... }:
@ -44,7 +108,10 @@
package = mkOption {
type = types.package;
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 {
@ -152,6 +219,72 @@
default = false;
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 {
@ -186,6 +319,20 @@
--rate-sends ${toString cfg.rateSends}
--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)
''args+=(--invite-token ${lib.escapeShellArg cfg.inviteToken})''}
${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 ];
};
};

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
}
/// Multi-part SHA-256; `message_id` spelled generally for the RNS bind values.
#[cfg(any(test, feature = "rns"))]
pub fn sha256(parts: &[&[u8]]) -> [u8; 32] {
let mut hasher = Sha256::new();
for part in parts {
hasher.update(part);
}
hasher.finalize().into()
}
/// Generates the server's static X25519 keypair (transport identity, distinct
/// from any user's Ed25519 identity). Returns (private, public) raw bytes.
pub fn generate_static_key() -> ([u8; 32], [u8; 32]) {

View file

@ -1,9 +1,12 @@
//! Smol Mail server.
mod bind;
mod channel;
mod crypto;
mod proto;
mod ratelimit;
#[cfg(feature = "rns")]
mod rns;
mod server;
mod session;
mod store;
@ -65,7 +68,45 @@ enum Command {
rate_sends: u32,
#[arg(long = "rate-tokens", default_value_t = 30)]
rate_tokens: u32,
#[cfg(feature = "rns")]
#[command(flatten)]
rns: RnsServeArgs,
},
#[cfg(feature = "rns")]
/// Generate the server's Reticulum identity
RnsKeygen {
#[arg(long, default_value = "server.rns.key")]
key: String,
#[arg(long)]
force: bool,
},
}
/// RNS carrier flags (RNS.md sec 7.4), only present with the rns feature.
#[cfg(feature = "rns")]
#[derive(clap::Args)]
struct RnsServeArgs {
/// Serve the RNS carrier alongside TCP
#[arg(long = "rns")]
enabled: bool,
#[arg(long = "rns-key", default_value = "server.rns.key")]
rns_key: String,
#[arg(long = "rns-max-envelope", default_value_t = 32 << 10)]
rns_max_envelope: usize,
#[arg(long = "rns-fetch-budget", default_value_t = 32 << 10)]
rns_fetch_budget: usize,
#[arg(long = "rns-max-links", default_value_t = 100)]
rns_max_links: usize,
#[arg(long = "rns-rate-link-requests", default_value_t = 60)]
rns_rate_link_requests: u32,
#[arg(long = "rns-rate-link-bytes", default_value_t = 1 << 20)]
rns_rate_link_bytes: u64,
/// UDP interface to listen on, host[:port]
#[arg(long = "rns-udp", default_value = "127.0.0.1:4242")]
rns_udp: String,
/// Optional UDP forward target, host[:port]
#[arg(long = "rns-udp-forward")]
rns_udp_forward: Option<String>,
}
fn main() -> anyhow::Result<()> {
@ -82,6 +123,9 @@ fn main() -> anyhow::Result<()> {
match cli.command {
Command::Keygen { key, force } => cmd_keygen(&key, force),
#[cfg(feature = "rns")]
Command::RnsKeygen { key, force } => cmd_rns_keygen(&key, force),
#[cfg(not(feature = "rns"))]
Command::Serve {
key,
db,
@ -113,6 +157,92 @@ fn main() -> anyhow::Result<()> {
rate_sends,
rate_tokens,
}),
#[cfg(feature = "rns")]
Command::Serve {
key,
db,
host,
port,
max_envelope,
quota,
requests_quota,
retention_days,
requests_retention_days,
max_tokens,
invite_token,
rate_connections,
rate_sends,
rate_tokens,
rns,
} => {
// One process serves both carriers (RNS.md sec 7.4): shared
// Store, shared config, single purge loop in server::run.
if rns.enabled {
let (listen_host, listen_port) = split_host_port(&rns.rns_udp, 4242)?;
let (forward_host, forward_port) = match &rns.rns_udp_forward {
Some(addr) => {
let (host, port) = split_host_port(addr, 4242)?;
(Some(host), port)
}
None => (None, 4242),
};
let storage_dir = std::path::Path::new(&db)
.parent()
.map(|p| p.join("rns-storage"))
.unwrap_or_else(|| std::path::PathBuf::from("rns-storage"));
std::fs::create_dir_all(&storage_dir)?;
let dest = rns::start(rns::RnsArgs {
key_path: rns.rns_key,
db_path: db.clone(),
storage_dir: storage_dir.to_string_lossy().into_owned(),
max_envelope: rns.rns_max_envelope,
fetch_budget: rns.rns_fetch_budget,
max_links: rns.rns_max_links,
rate_link_requests: rns.rns_rate_link_requests,
rate_link_bytes: rns.rns_rate_link_bytes,
udp_listen_host: listen_host,
udp_listen_port: listen_port,
udp_forward_host: forward_host,
udp_forward_port: forward_port,
max_tokens,
main_quota: quota,
requests_quota,
invite_token: invite_token.clone().map(String::into_bytes),
})?;
log::info!("RNS carrier: smolmail.server destination {dest}");
}
server::run(server::ServeArgs {
key_path: key,
db_path: db,
host,
port,
max_envelope,
quota,
requests_quota,
retention_days,
requests_retention_days,
max_tokens,
invite_token,
rate_connections,
rate_sends,
rate_tokens,
})
}
}
}
/// Splits host[:port], keeping `default_port` when none is given.
#[cfg(feature = "rns")]
fn split_host_port(addr: &str, default_port: u16) -> anyhow::Result<(String, u16)> {
match addr.rsplit_once(':') {
Some((host, port)) => {
anyhow::ensure!(
!host.contains(':') || host.starts_with('['),
"unsupported address {addr}"
);
Ok((host.to_string(), port.parse().unwrap_or(default_port)))
}
None => Ok((addr.to_string(), default_port)),
}
}
@ -137,3 +267,29 @@ fn cmd_keygen(key_path: &str, force: bool) -> anyhow::Result<()> {
println!("Publish the public key through a trusted channel; clients pin it (SPEC.md sec 4).");
Ok(())
}
/// RNS has no server static key to pin: the destination hash is the address
/// and the pin (upstream spec 13.4), so it is what gets published.
#[cfg(feature = "rns")]
fn cmd_rns_keygen(key_path: &str, force: bool) -> anyhow::Result<()> {
if std::path::Path::new(key_path).exists() && !force {
anyhow::bail!("{key_path} exists; refusing to overwrite (use --force)");
}
let key = rns::generate_identity();
let dest = data_encoding::HEXLOWER.encode(&rns::destination_hash(&key)?);
// Written 0600 before any bytes land, so the key is never briefly readable.
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
opts.mode(0o600);
let mut file = opts.open(key_path)?;
file.write_all(&key)?;
println!("identity: {key_path}");
println!("destination: {dest}");
println!();
println!("smol+rns://<user>@{dest} is the server's mesh address (RNS.md sec 1).");
Ok(())
}

View file

@ -45,6 +45,11 @@ pub const MAX_FRAME: usize = 1 << 20; // application frame ceiling
pub const NOISE_MAX: usize = 65535; // Noise message ceiling
pub const NOISE_PAYLOAD: usize = NOISE_MAX - 16; // minus the AEAD tag
pub const FETCH_BUDGET: usize = 512 * 1024; // must stay under MAX_FRAME
// AUTH with a maximal username and a full token set, op byte included
// (RNS.md sec 3): len 1 + username 63 + identity 32 + sig 64 + sync 1 +
// count 2; callers add 32 per token.
#[cfg(feature = "rns")]
pub const AUTH_FULL_TOKENS: usize = 164;
pub const IDLE_TIMEOUT_SECS: u64 = 120;
pub const PURGE_INTERVAL_SECS: u64 = 60;

View file

@ -52,6 +52,58 @@ impl RateLimiter {
}
}
/// Fixed-window byte counter for per-link transfer budgets: `RateLimiter`
/// counts events, this counts bytes, so it gets its own small type.
#[cfg(feature = "rns")]
pub struct ByteRateLimiter {
limit: u64,
window: Duration,
hits: Mutex<HashMap<String, ByteWindow>>,
}
#[cfg(feature = "rns")]
struct ByteWindow {
start: Instant,
bytes: u64,
}
#[cfg(feature = "rns")]
impl ByteRateLimiter {
pub fn new(limit: u64) -> Self {
ByteRateLimiter {
limit,
window: Duration::from_secs(60),
hits: Mutex::new(HashMap::new()),
}
}
/// Records `bytes` against `key` if the window still has room for them.
pub fn allow(&self, key: &str, bytes: usize) -> bool {
if self.limit == 0 {
return true;
}
let now = Instant::now();
let mut hits = self.hits.lock().unwrap();
let entry = hits.entry(key.to_string()).or_insert(ByteWindow {
start: now,
bytes: 0,
});
if now.duration_since(entry.start) >= self.window {
entry.start = now;
entry.bytes = 0;
}
if entry.bytes + bytes as u64 > self.limit {
return false;
}
entry.bytes += bytes as u64;
if hits.len() > 4096 {
let window = self.window;
hits.retain(|_, w| now.duration_since(w.start) < window);
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;

312
src/rns.rs Normal file
View file

@ -0,0 +1,312 @@
//! RNS carrier (Smol Mail 1.2, RNS.md sec 7): Reticulum Links to the
//! `smolmail.server` IN/SINGLE destination, dispatched through the same
//! `Session` as TCP.
//!
//! microReticulum's request handler is a bare function pointer with no
//! userdata, so this module's state lives in a `static OnceLock` and the
//! shim reaches it through the `smolmail_rns_on_*` C callbacks below. The
//! link identifier is the session key: bind values are derived from
//! destination || link_id (RNS.md sec 2), so AUTH on one link cannot replay
//! on another.
use std::collections::{HashMap, HashSet};
use std::ffi::CString;
use std::sync::{Mutex, OnceLock};
use crate::bind::TransportBindValues;
use crate::proto::{AUTH_FULL_TOKENS, INTERNAL_ERROR, MALFORMED, RATE_LIMITED, TOO_LARGE};
use crate::ratelimit::{ByteRateLimiter, RateLimiter};
use crate::session::{ServerConfig, Session};
use crate::store::Store;
mod ffi {
use std::os::raw::{c_char, c_int};
extern "C" {
// shim/smolmail_rns.cpp
pub fn smolmail_rns_start(
identity: *const u8,
storage_dir: *const c_char,
udp_listen_host: *const c_char,
udp_listen_port: u16,
udp_forward_host: *const c_char,
udp_forward_port: u16,
destination_hash_out: *mut u8,
) -> c_int;
pub fn smolmail_rns_destination_hash(
identity: *const u8,
destination_hash_out: *mut u8,
) -> c_int;
}
}
/// The identity file is 64 raw bytes: x25519 private (32) || ed25519 private
/// (32), the layout microReticulum's `Identity::to_file` writes and
/// `load_private_key` expects.
pub const RNS_KEY_LEN: usize = 64;
pub struct RnsArgs {
pub key_path: String,
pub db_path: String,
pub storage_dir: String,
pub max_envelope: usize,
pub fetch_budget: usize,
pub max_links: usize,
pub rate_link_requests: u32,
pub rate_link_bytes: u64,
pub udp_listen_host: String,
pub udp_listen_port: u16,
pub udp_forward_host: Option<String>,
pub udp_forward_port: u16,
pub max_tokens: u16,
pub main_quota: i64,
pub requests_quota: i64,
pub invite_token: Option<Vec<u8>>,
}
struct RnsState {
config: &'static ServerConfig,
db_path: String,
destination: [u8; 16],
sessions: Mutex<HashMap<[u8; 16], Session<'static>>>,
links: Mutex<HashSet<[u8; 16]>>,
request_limiter: RateLimiter,
byte_limiter: ByteRateLimiter,
max_links: usize,
response: Mutex<Option<Vec<u8>>>,
}
static STATE: OnceLock<RnsState> = OnceLock::new();
/// Loads the Reticulum identity, starts the carrier on the shim's background
/// loop thread, and returns the destination hash as the 32 hex characters of
/// the `smol+rns://` address host part (upstream spec 13.2).
pub fn start(args: RnsArgs) -> anyhow::Result<String> {
let key = std::fs::read(&args.key_path)
.map_err(|e| anyhow::anyhow!("cannot read RNS identity {}: {e}", args.key_path))?;
anyhow::ensure!(
key.len() == RNS_KEY_LEN,
"RNS identity must be {RNS_KEY_LEN} raw bytes, got {}",
key.len()
);
// Ask the shim first: bind values derive from the destination hash, so
// the session state must exist before the shim's loop thread can fire
// the first request.
let destination = destination_hash(key.as_slice().try_into().unwrap())?;
// RateLimiter keyed by link hex covers per-link abuse control; the
// per-IP send limit has no analogue (upstream spec 13.8), and the accept
// token limiter stays on because a token never needed a peer identity.
let config: &'static ServerConfig = Box::leak(Box::new(ServerConfig {
max_envelope: args.max_envelope,
fetch_budget: args.fetch_budget,
main_quota: args.main_quota,
requests_quota: args.requests_quota,
max_tokens: args.max_tokens,
invite_token: args.invite_token.clone(),
conn_limiter: RateLimiter::new(0),
send_limiter: RateLimiter::new(0),
token_limiter: RateLimiter::new(30),
}));
let state = RnsState {
config,
db_path: args.db_path.clone(),
destination,
sessions: Mutex::new(HashMap::new()),
links: Mutex::new(HashSet::new()),
request_limiter: RateLimiter::new(args.rate_link_requests),
byte_limiter: ByteRateLimiter::new(args.rate_link_bytes),
max_links: args.max_links,
response: Mutex::new(None),
};
STATE.get_or_init(|| state);
let storage_dir = CString::new(args.storage_dir).unwrap();
let listen_host = CString::new(args.udp_listen_host).unwrap();
let forward_host = args.udp_forward_host.map(|h| CString::new(h).unwrap());
let mut dest_hash = [0u8; 16];
let rc = unsafe {
ffi::smolmail_rns_start(
key.as_ptr(),
storage_dir.as_ptr(),
listen_host.as_ptr(),
args.udp_listen_port,
forward_host
.as_ref()
.map(|h| h.as_ptr())
.unwrap_or(std::ptr::null()),
args.udp_forward_port,
dest_hash.as_mut_ptr(),
)
};
anyhow::ensure!(rc == 0, "RNS shim failed to start (code {rc})");
anyhow::ensure!(
dest_hash == destination,
"destination hash changed between shim calls"
);
Ok(data_encoding::HEXLOWER.encode(&dest_hash))
}
/// The `smolmail.server` destination hash for a 64-byte identity, computed
/// by microReticulum itself: its Curve25519 `eval` does not clamp the scalar
/// (a divergence from RFC 7748), so an independent Rust derivation would
/// produce a different x25519 public key and therefore a different hash.
pub fn destination_hash(key: &[u8; RNS_KEY_LEN]) -> anyhow::Result<[u8; 16]> {
let mut out = [0u8; 16];
let rc = unsafe { ffi::smolmail_rns_destination_hash(key.as_ptr(), out.as_mut_ptr()) };
anyhow::ensure!(rc == 0, "shim rejected the RNS identity (code {rc})");
Ok(out)
}
/// Generates the 64-byte Reticulum identity: random x25519 || ed25519
/// private halves.
pub fn generate_identity() -> [u8; RNS_KEY_LEN] {
use rand_core::{OsRng, RngCore};
let mut key = [0u8; RNS_KEY_LEN];
OsRng.fill_bytes(&mut key);
key
}
fn response(status: u8) -> Vec<u8> {
vec![status]
}
fn handle_request(request: &[u8], link_id: &[u8; 16]) -> Vec<u8> {
let Some(state) = STATE.get() else {
return response(INTERNAL_ERROR);
};
let link = data_encoding::HEXLOWER.encode(link_id);
// Bounded request size (RNS.md sec 3): an envelope plus its overhead, or
// an AUTH carrying a full token set, whichever is larger.
let max_request = (state.config.max_envelope + 34)
.max(AUTH_FULL_TOKENS + 32 * state.config.max_tokens as usize);
if request.len() > max_request {
log::warn!(
"request of {} bytes over {} byte cap",
request.len(),
max_request
);
return response(TOO_LARGE);
}
if !state.request_limiter.allow(&link) || !state.byte_limiter.allow(&link, request.len()) {
return response(RATE_LIMITED);
}
let Some(op) = request.first() else {
return response(MALFORMED);
};
let body = &request[1..];
let mut sessions = state.sessions.lock().unwrap();
let session = match sessions.entry(*link_id) {
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
std::collections::hash_map::Entry::Vacant(e) => {
// The link is the session (upstream spec 13.6): a Store per link,
// bind values derived from this destination and link.
match Store::open(&state.db_path) {
Ok(store) => e.insert(Session::new(
state.config,
store,
link.clone(),
TransportBindValues::rns(&state.destination, link_id),
)),
Err(err) => {
log::error!("cannot open store for link {link}: {err}");
return response(INTERNAL_ERROR);
}
}
}
};
let (status, payload) = session.dispatch(*op, body);
let mut out = Vec::with_capacity(1 + payload.len());
out.push(status);
out.extend_from_slice(&payload);
out
}
#[no_mangle]
extern "C" fn smolmail_rns_on_request(
request: *const u8,
request_len: usize,
link_id: *const u8,
) -> usize {
// The shim calls this on its single loop thread, so the slot never sees
// concurrent writers; panics must not cross the FFI boundary.
let result = std::panic::catch_unwind(|| unsafe {
let request = std::slice::from_raw_parts(request, request_len);
let link_id: &[u8; 16] = std::slice::from_raw_parts(link_id, 16).try_into().unwrap();
handle_request(request, link_id)
});
let Ok(response) = result else {
log::error!("panic in RNS request handler");
return 0;
};
let len = response.len();
*STATE.get().unwrap().response.lock().unwrap() = Some(response);
len
}
#[no_mangle]
extern "C" fn smolmail_rns_take_response(out: *mut u8, cap: usize) -> usize {
let slot = &mut STATE.get().unwrap().response.lock().unwrap();
match slot.take() {
Some(response) if response.len() <= cap => {
unsafe { std::ptr::copy_nonoverlapping(response.as_ptr(), out, response.len()) };
response.len()
}
_ => 0,
}
}
#[no_mangle]
extern "C" fn smolmail_rns_on_link_opened(link_id: *const u8) -> i32 {
let Some(state) = STATE.get() else {
return 1;
};
let link: [u8; 16] = unsafe { std::slice::from_raw_parts(link_id, 16).try_into().unwrap() };
let mut links = state.links.lock().unwrap();
if !links.contains(&link) && links.len() >= state.max_links {
log::warn!(
"refusing link {}, {} link(s) open",
data_encoding::HEXLOWER.encode(&link),
links.len()
);
return 1;
}
links.insert(link);
0
}
#[no_mangle]
extern "C" fn smolmail_rns_on_link_closed(link_id: *const u8) {
if let Some(state) = STATE.get() {
let link: [u8; 16] = unsafe { std::slice::from_raw_parts(link_id, 16).try_into().unwrap() };
state.links.lock().unwrap().remove(&link);
state.sessions.lock().unwrap().remove(&link);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Cross-checked against microReticulum itself: the destination hash a
/// native probe prints for the identity 0x00..0x3f.
#[test]
fn destination_hash_matches_microreticulum() {
let mut key = [0u8; RNS_KEY_LEN];
for (i, byte) in key.iter_mut().enumerate() {
*byte = i as u8;
}
assert_eq!(
data_encoding::HEXLOWER.encode(&destination_hash(&key).unwrap()),
"799855f4955f1b09fd20a13cd84f4e71"
);
}
}

View file

@ -4,9 +4,10 @@ use std::net::TcpStream;
use std::sync::Arc;
use std::time::Duration;
use crate::bind::TransportBindValues;
use crate::channel::{handshake, Channel};
use crate::crypto::{b32, derive_public};
use crate::proto::{IDLE_TIMEOUT_SECS, KEY_LEN, MALFORMED, PURGE_INTERVAL_SECS};
use crate::proto::{FETCH_BUDGET, IDLE_TIMEOUT_SECS, KEY_LEN, MALFORMED, PURGE_INTERVAL_SECS};
use crate::ratelimit::RateLimiter;
use crate::session::{ServerConfig, Session};
use crate::store::Store;
@ -29,8 +30,8 @@ pub struct ServeArgs {
}
pub fn run(args: ServeArgs) -> anyhow::Result<()> {
let static_key =
std::fs::read(&args.key_path).map_err(|e| anyhow::anyhow!("cannot read server key: {e}"))?;
let static_key = std::fs::read(&args.key_path)
.map_err(|e| anyhow::anyhow!("cannot read server key: {e}"))?;
anyhow::ensure!(
static_key.len() == KEY_LEN,
"server key must be {KEY_LEN} raw bytes, got {}",
@ -42,11 +43,11 @@ pub fn run(args: ServeArgs) -> anyhow::Result<()> {
let config = Arc::new(ServerConfig {
max_envelope: args.max_envelope,
fetch_budget: FETCH_BUDGET,
main_quota: args.quota,
requests_quota: args.requests_quota,
max_tokens: args.max_tokens,
invite_token: args.invite_token.map(String::into_bytes),
server_static,
conn_limiter: RateLimiter::new(args.rate_connections),
send_limiter: RateLimiter::new(args.rate_sends),
token_limiter: RateLimiter::new(args.rate_tokens),
@ -55,7 +56,9 @@ pub fn run(args: ServeArgs) -> anyhow::Result<()> {
let main_retention_secs = args.retention_days * 86400;
let requests_retention_secs = args.requests_retention_days * 86400;
let purge_db_path = args.db_path.clone();
std::thread::spawn(move || purge_loop(purge_db_path, main_retention_secs, requests_retention_secs));
std::thread::spawn(move || {
purge_loop(purge_db_path, main_retention_secs, requests_retention_secs)
});
let listener = std::net::TcpListener::bind((args.host.as_str(), args.port))?;
log::info!("listening on {}:{}", args.host, args.port);
@ -80,7 +83,7 @@ pub fn run(args: ServeArgs) -> anyhow::Result<()> {
}
let config = Arc::clone(&config);
let static_key = static_key.clone();
let static_key = key_array;
let db_path = args.db_path.clone();
std::thread::spawn(move || {
if let Err(e) = handle_connection(stream, &config, &static_key, &db_path, &peer_ip) {
@ -94,7 +97,7 @@ pub fn run(args: ServeArgs) -> anyhow::Result<()> {
fn handle_connection(
mut stream: TcpStream,
config: &ServerConfig,
static_key: &[u8],
static_key: &[u8; KEY_LEN],
db_path: &str,
peer_ip: &str,
) -> anyhow::Result<()> {
@ -109,7 +112,9 @@ fn handle_connection(
};
let store = Store::open(db_path)?;
let mut session = Session::new(config, store, peer_ip.to_string(), handshake_hash);
let server_static = derive_public(static_key);
let bind = TransportBindValues::tcp(&handshake_hash, &server_static)?;
let mut session = Session::new(config, store, peer_ip.to_string(), bind);
let mut channel = Channel::new(stream, transport);
loop {

View file

@ -1,23 +1,24 @@
//! Per-connection dispatch and the six wire operations.
use crate::bind::TransportBindValues;
use crate::crypto::{b32, ct_eq, hmac_sha256, message_id, verify};
use crate::proto::{
valid_username, ProtocolError, Reader, AUTH_FAILED, AUTH_REQUIRED, BAD_VERSION, CERT_LEN,
ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, FETCH_BUDGET, ID_LEN, KEY_LEN, LABEL_AUTH,
LABEL_MAC, LABEL_REGISTER, LABEL_ROTATE, MAC_LEN, MALFORMED, MAX_CHAIN, NOT_PERMITTED, OK,
OP_AUTH, OP_DELETE, OP_FETCH, OP_REGISTER, OP_RESOLVE, OP_SEND, QUOTA_EXCEEDED, RATE_LIMITED,
TOKEN_LEN, TOO_LARGE, UNKNOWN_USER,
ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, ID_LEN, KEY_LEN, LABEL_AUTH, LABEL_MAC,
LABEL_REGISTER, LABEL_ROTATE, MAC_LEN, MALFORMED, MAX_CHAIN, NOT_PERMITTED, OK, OP_AUTH,
OP_DELETE, OP_FETCH, OP_REGISTER, OP_RESOLVE, OP_SEND, QUOTA_EXCEEDED, RATE_LIMITED, TOKEN_LEN,
TOO_LARGE, UNKNOWN_USER,
};
use crate::ratelimit::RateLimiter;
use crate::store::Store;
pub struct ServerConfig {
pub max_envelope: usize,
pub fetch_budget: usize,
pub main_quota: i64,
pub requests_quota: i64,
pub max_tokens: u16,
pub invite_token: Option<Vec<u8>>,
pub server_static: [u8; KEY_LEN],
pub conn_limiter: RateLimiter,
pub send_limiter: RateLimiter,
pub token_limiter: RateLimiter,
@ -47,17 +48,22 @@ pub struct Session<'a> {
config: &'a ServerConfig,
store: Store,
peer_ip: String,
handshake_hash: Vec<u8>,
bind: TransportBindValues,
username: Option<String>,
}
impl<'a> Session<'a> {
pub fn new(config: &'a ServerConfig, store: Store, peer_ip: String, handshake_hash: Vec<u8>) -> Self {
pub fn new(
config: &'a ServerConfig,
store: Store,
peer_ip: String,
bind: TransportBindValues,
) -> Self {
Session {
config,
store,
peer_ip,
handshake_hash,
bind,
username: None,
}
}
@ -125,7 +131,7 @@ impl<'a> Session<'a> {
return Ok((AUTH_FAILED, Vec::new()));
}
let mut msg = LABEL_AUTH.to_vec();
msg.extend_from_slice(&self.handshake_hash);
msg.extend_from_slice(&self.bind.h);
if !verify(&identity, &signature, &msg) {
return Ok((AUTH_FAILED, Vec::new()));
}
@ -231,9 +237,12 @@ impl<'a> Session<'a> {
r.done()?;
let username = self.username.as_ref().expect("AUTH_REQUIRED gate above");
let keys = self.store.keys_of(username)?;
let records = self
.store
.pending(&keys, after_received_at, &after_id, FETCH_BUDGET)?;
let records = self.store.pending(
&keys,
after_received_at,
&after_id,
self.config.fetch_budget,
)?;
let mut out = Vec::new();
out.extend_from_slice(&(records.len() as u16).to_be_bytes());
@ -286,7 +295,7 @@ impl<'a> Session<'a> {
// (SPEC.md sec 6.1). Binding server_static stops the attestation from
// being replayed against another server.
let mut pop_msg = LABEL_REGISTER.to_vec();
pop_msg.extend_from_slice(&self.config.server_static);
pop_msg.extend_from_slice(&self.bind.server_static);
pop_msg.extend_from_slice(username.as_bytes());
pop_msg.extend_from_slice(&identity);
if !verify(&identity, &signature, &pop_msg) {
@ -343,3 +352,163 @@ impl<'a> Session<'a> {
Ok((OK, Vec::new()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::sha256;
use ed25519_dalek::{Signer, SigningKey};
fn test_config() -> ServerConfig {
ServerConfig {
max_envelope: 1024,
fetch_budget: 512,
main_quota: 1 << 20,
requests_quota: 1 << 20,
max_tokens: 16,
invite_token: None,
conn_limiter: RateLimiter::new(0),
send_limiter: RateLimiter::new(0),
token_limiter: RateLimiter::new(0),
}
}
fn temp_store(name: &str) -> Store {
let path =
std::env::temp_dir().join(format!("bunshin-session-{name}-{}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
Store::open(path.to_str().unwrap()).unwrap()
}
fn str_field(s: &str) -> Vec<u8> {
let mut out = vec![s.len() as u8];
out.extend_from_slice(s.as_bytes());
out
}
fn register_body(username: &str, identity: &[u8], signature: &[u8]) -> Vec<u8> {
let mut body = str_field(username);
body.extend_from_slice(identity);
body.extend_from_slice(signature);
body.push(0); // invite token, none configured
body.push(0); // cert, plain registration
body
}
fn auth_body(username: &str, identity: &[u8], signature: &[u8]) -> Vec<u8> {
let mut body = str_field(username);
body.extend_from_slice(identity);
body.extend_from_slice(signature);
body.push(0); // sync = 0, stored tokens untouched
body.extend_from_slice(&0u16.to_be_bytes());
body
}
#[test]
fn rns_bind_signatures_accepted_and_tcp_bind_signatures_rejected() {
let config = test_config();
let key = SigningKey::from_bytes(&[3u8; 32]);
let identity = key.verifying_key().as_bytes().to_vec();
let bind = TransportBindValues::rns(&[0x11; 16], &[0x22; 16]);
let mut pop = LABEL_REGISTER.to_vec();
pop.extend_from_slice(&bind.server_static);
pop.extend_from_slice(b"alice");
pop.extend_from_slice(&identity);
let good_register = register_body("alice", &identity, &key.sign(&pop).to_bytes());
// A TCP client would sign over the real X25519 static key instead.
let tcp_static = crate::crypto::derive_public(&[9u8; 32]);
let mut tcp_pop = LABEL_REGISTER.to_vec();
tcp_pop.extend_from_slice(&tcp_static);
tcp_pop.extend_from_slice(b"alice");
tcp_pop.extend_from_slice(&identity);
let tcp_register = register_body("alice", &identity, &key.sign(&tcp_pop).to_bytes());
let mut auth_msg = LABEL_AUTH.to_vec();
auth_msg.extend_from_slice(&bind.h);
let good_auth = auth_body("alice", &identity, &key.sign(&auth_msg).to_bytes());
// A Noise client would sign over the handshake hash instead.
let handshake_hash = sha256(&[b"handshake"]);
let mut tcp_auth = LABEL_AUTH.to_vec();
tcp_auth.extend_from_slice(&handshake_hash);
let tcp_auth = auth_body("alice", &identity, &key.sign(&tcp_auth).to_bytes());
let mut session = Session::new(&config, temp_store("rns-bind"), "rns".into(), bind);
assert_eq!(session.dispatch(OP_REGISTER, &good_register).0, OK);
assert_eq!(session.dispatch(OP_AUTH, &good_auth).0, OK);
let mut session = Session::new(
&config,
temp_store("rns-bind-reject"),
"rns".into(),
TransportBindValues::rns(&[0x11; 16], &[0x22; 16]),
);
assert_eq!(session.dispatch(OP_REGISTER, &good_register).0, OK);
assert_eq!(session.dispatch(OP_AUTH, &tcp_auth).0, AUTH_FAILED);
let mut session = Session::new(
&config,
temp_store("rns-bind-tcp-register"),
"rns".into(),
TransportBindValues::rns(&[0x11; 16], &[0x22; 16]),
);
assert_eq!(session.dispatch(OP_REGISTER, &tcp_register).0, AUTH_FAILED);
}
#[test]
fn tcp_bind_signatures_accepted_and_rns_bind_signatures_rejected() {
let config = test_config();
let key = SigningKey::from_bytes(&[4u8; 32]);
let identity = key.verifying_key().as_bytes().to_vec();
let server_static = crate::crypto::derive_public(&[9u8; 32]);
let handshake_hash = sha256(&[b"handshake"]);
let bind = TransportBindValues::tcp(&handshake_hash, &server_static).unwrap();
let mut pop = LABEL_REGISTER.to_vec();
pop.extend_from_slice(&server_static);
pop.extend_from_slice(b"alice");
pop.extend_from_slice(&identity);
let good_register = register_body("alice", &identity, &key.sign(&pop).to_bytes());
let mut auth_msg = LABEL_AUTH.to_vec();
auth_msg.extend_from_slice(&handshake_hash);
let good_auth = auth_body("alice", &identity, &key.sign(&auth_msg).to_bytes());
// An RNS client would sign over the derived bind values instead.
let rns_bind = TransportBindValues::rns(&[0x11; 16], &[0x22; 16]);
let mut rns_pop = LABEL_REGISTER.to_vec();
rns_pop.extend_from_slice(&rns_bind.server_static);
rns_pop.extend_from_slice(b"alice");
rns_pop.extend_from_slice(&identity);
let rns_register = register_body("alice", &identity, &key.sign(&rns_pop).to_bytes());
let mut rns_auth = LABEL_AUTH.to_vec();
rns_auth.extend_from_slice(&rns_bind.h);
let rns_auth = auth_body("alice", &identity, &key.sign(&rns_auth).to_bytes());
let mut session = Session::new(&config, temp_store("tcp-bind"), "tcp".into(), bind);
assert_eq!(session.dispatch(OP_REGISTER, &good_register).0, OK);
assert_eq!(session.dispatch(OP_AUTH, &good_auth).0, OK);
let mut session = Session::new(
&config,
temp_store("tcp-bind-reject"),
"tcp".into(),
TransportBindValues::tcp(&handshake_hash, &server_static).unwrap(),
);
assert_eq!(session.dispatch(OP_REGISTER, &good_register).0, OK);
assert_eq!(session.dispatch(OP_AUTH, &rns_auth).0, AUTH_FAILED);
let mut session = Session::new(
&config,
temp_store("tcp-bind-rns-register"),
"tcp".into(),
TransportBindValues::tcp(&handshake_hash, &server_static).unwrap(),
);
assert_eq!(session.dispatch(OP_REGISTER, &rns_register).0, AUTH_FAILED);
}
}