feat: implement RNS transport (Smol Mail 1.2) over microReticulum
This commit is contained in:
parent
af1d31be83
commit
7203556186
19 changed files with 1939 additions and 1141 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -155,6 +155,7 @@ name = "bunshin"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cc",
|
||||
"clap",
|
||||
"data-encoding",
|
||||
"ed25519-dalek",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -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.
|
||||
|
|
|
|||
182
build.rs
Normal file
182
build.rs
Normal 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),
|
||||
©,
|
||||
&["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(©)
|
||||
.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
146
flake.lock
generated
|
|
@ -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
164
flake.nix
|
|
@ -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
178
shim/smolmail_rns.cpp
Normal 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
55
shim/smolmail_rns.h
Normal 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
198
shim/udp_interface.cpp
Normal 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
64
shim/udp_interface.h
Normal 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
90
src/bind.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
//! Transport-supplied values that AUTH and REGISTER signatures bind to.
|
||||
//!
|
||||
//! Both carriers prove the same thing — that the peer holds the identity key
|
||||
//! and is talking to this server, not a replayed capture of another — but the
|
||||
//! inputs differ (RNS.md sec 2), so the session layer consumes this struct
|
||||
//! instead of a Noise handshake hash.
|
||||
|
||||
#[cfg(any(test, feature = "rns"))]
|
||||
use crate::crypto::sha256;
|
||||
use crate::proto::KEY_LEN;
|
||||
|
||||
// Used only by the RNS carrier and the bind-value tests.
|
||||
#[cfg(any(test, feature = "rns"))]
|
||||
pub const LABEL_BIND: &[u8] = b"smolmail/1 bind";
|
||||
|
||||
pub struct TransportBindValues {
|
||||
pub h: [u8; 32],
|
||||
pub server_static: [u8; 32],
|
||||
}
|
||||
|
||||
impl TransportBindValues {
|
||||
/// Noise binds to the handshake hash and the server's real static key.
|
||||
pub fn tcp(handshake_hash: &[u8], server_static: &[u8; KEY_LEN]) -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
h: handshake_hash
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("handshake hash not 32 bytes"))?,
|
||||
server_static: *server_static,
|
||||
})
|
||||
}
|
||||
|
||||
/// RNS has no static key of ours on the wire, so both values are derived
|
||||
/// from the destination; link_id keeps one link's AUTH from replaying on
|
||||
/// another (RNS.md sec 2). Neither input is length-prefixed, and both are
|
||||
/// fixed-width, so concatenation stays unambiguous.
|
||||
#[cfg(any(test, feature = "rns"))]
|
||||
pub fn rns(destination: &[u8; 16], link_id: &[u8; 16]) -> Self {
|
||||
Self {
|
||||
h: sha256(&[LABEL_BIND, destination, link_id]),
|
||||
server_static: sha256(&[LABEL_BIND, destination]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Vectors computed independently over the spec 13.6 formula
|
||||
// SHA-256("smolmail/1 bind" || destination || link_id).
|
||||
const DEST: [u8; 16] = [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
||||
0x0f,
|
||||
];
|
||||
const LINK: [u8; 16] = [
|
||||
0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae,
|
||||
0xaf,
|
||||
];
|
||||
const H_HEX: &str = "06d6437dc63250ff51d609e12b488ff22b4fa02620f35000b3b2ed1c8789d45c";
|
||||
const SERVER_STATIC_HEX: &str =
|
||||
"da6fe109dc4da2878fa4810ffa0e08cef6b68a8b524126aa05bb14181b20d25a";
|
||||
|
||||
#[test]
|
||||
fn rns_matches_reference_vectors() {
|
||||
let bind = TransportBindValues::rns(&DEST, &LINK);
|
||||
assert_eq!(data_encoding::HEXLOWER.encode(&bind.h), H_HEX);
|
||||
assert_eq!(
|
||||
data_encoding::HEXLOWER.encode(&bind.server_static),
|
||||
SERVER_STATIC_HEX
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rns_h_differs_per_link_but_server_static_does_not() {
|
||||
let other = [0xc0u8; 16];
|
||||
let a = TransportBindValues::rns(&DEST, &LINK);
|
||||
let b = TransportBindValues::rns(&DEST, &other);
|
||||
assert_ne!(a.h, b.h);
|
||||
assert_eq!(a.server_static, b.server_static);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_rejects_short_handshake_hash() {
|
||||
let static_key = [7u8; KEY_LEN];
|
||||
assert!(TransportBindValues::tcp(&[1u8; 31], &static_key).is_err());
|
||||
let bind = TransportBindValues::tcp(&[2u8; 32], &static_key).unwrap();
|
||||
assert_eq!(bind.h, [2u8; 32]);
|
||||
assert_eq!(bind.server_static, static_key);
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,16 @@ pub fn message_id(envelope: &[u8]) -> [u8; ID_LEN] {
|
|||
out
|
||||
}
|
||||
|
||||
/// Multi-part SHA-256; `message_id` spelled generally for the RNS bind values.
|
||||
#[cfg(any(test, feature = "rns"))]
|
||||
pub fn sha256(parts: &[&[u8]]) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
for part in parts {
|
||||
hasher.update(part);
|
||||
}
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// Generates the server's static X25519 keypair (transport identity, distinct
|
||||
/// from any user's Ed25519 identity). Returns (private, public) raw bytes.
|
||||
pub fn generate_static_key() -> ([u8; 32], [u8; 32]) {
|
||||
|
|
|
|||
156
src/main.rs
156
src/main.rs
|
|
@ -1,9 +1,12 @@
|
|||
//! Smol Mail server.
|
||||
|
||||
mod bind;
|
||||
mod channel;
|
||||
mod crypto;
|
||||
mod proto;
|
||||
mod ratelimit;
|
||||
#[cfg(feature = "rns")]
|
||||
mod rns;
|
||||
mod server;
|
||||
mod session;
|
||||
mod store;
|
||||
|
|
@ -65,7 +68,45 @@ enum Command {
|
|||
rate_sends: u32,
|
||||
#[arg(long = "rate-tokens", default_value_t = 30)]
|
||||
rate_tokens: u32,
|
||||
#[cfg(feature = "rns")]
|
||||
#[command(flatten)]
|
||||
rns: RnsServeArgs,
|
||||
},
|
||||
#[cfg(feature = "rns")]
|
||||
/// Generate the server's Reticulum identity
|
||||
RnsKeygen {
|
||||
#[arg(long, default_value = "server.rns.key")]
|
||||
key: String,
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// RNS carrier flags (RNS.md sec 7.4), only present with the rns feature.
|
||||
#[cfg(feature = "rns")]
|
||||
#[derive(clap::Args)]
|
||||
struct RnsServeArgs {
|
||||
/// Serve the RNS carrier alongside TCP
|
||||
#[arg(long = "rns")]
|
||||
enabled: bool,
|
||||
#[arg(long = "rns-key", default_value = "server.rns.key")]
|
||||
rns_key: String,
|
||||
#[arg(long = "rns-max-envelope", default_value_t = 32 << 10)]
|
||||
rns_max_envelope: usize,
|
||||
#[arg(long = "rns-fetch-budget", default_value_t = 32 << 10)]
|
||||
rns_fetch_budget: usize,
|
||||
#[arg(long = "rns-max-links", default_value_t = 100)]
|
||||
rns_max_links: usize,
|
||||
#[arg(long = "rns-rate-link-requests", default_value_t = 60)]
|
||||
rns_rate_link_requests: u32,
|
||||
#[arg(long = "rns-rate-link-bytes", default_value_t = 1 << 20)]
|
||||
rns_rate_link_bytes: u64,
|
||||
/// UDP interface to listen on, host[:port]
|
||||
#[arg(long = "rns-udp", default_value = "127.0.0.1:4242")]
|
||||
rns_udp: String,
|
||||
/// Optional UDP forward target, host[:port]
|
||||
#[arg(long = "rns-udp-forward")]
|
||||
rns_udp_forward: Option<String>,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
|
|
@ -82,6 +123,9 @@ fn main() -> anyhow::Result<()> {
|
|||
|
||||
match cli.command {
|
||||
Command::Keygen { key, force } => cmd_keygen(&key, force),
|
||||
#[cfg(feature = "rns")]
|
||||
Command::RnsKeygen { key, force } => cmd_rns_keygen(&key, force),
|
||||
#[cfg(not(feature = "rns"))]
|
||||
Command::Serve {
|
||||
key,
|
||||
db,
|
||||
|
|
@ -113,6 +157,92 @@ fn main() -> anyhow::Result<()> {
|
|||
rate_sends,
|
||||
rate_tokens,
|
||||
}),
|
||||
#[cfg(feature = "rns")]
|
||||
Command::Serve {
|
||||
key,
|
||||
db,
|
||||
host,
|
||||
port,
|
||||
max_envelope,
|
||||
quota,
|
||||
requests_quota,
|
||||
retention_days,
|
||||
requests_retention_days,
|
||||
max_tokens,
|
||||
invite_token,
|
||||
rate_connections,
|
||||
rate_sends,
|
||||
rate_tokens,
|
||||
rns,
|
||||
} => {
|
||||
// One process serves both carriers (RNS.md sec 7.4): shared
|
||||
// Store, shared config, single purge loop in server::run.
|
||||
if rns.enabled {
|
||||
let (listen_host, listen_port) = split_host_port(&rns.rns_udp, 4242)?;
|
||||
let (forward_host, forward_port) = match &rns.rns_udp_forward {
|
||||
Some(addr) => {
|
||||
let (host, port) = split_host_port(addr, 4242)?;
|
||||
(Some(host), port)
|
||||
}
|
||||
None => (None, 4242),
|
||||
};
|
||||
let storage_dir = std::path::Path::new(&db)
|
||||
.parent()
|
||||
.map(|p| p.join("rns-storage"))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("rns-storage"));
|
||||
std::fs::create_dir_all(&storage_dir)?;
|
||||
let dest = rns::start(rns::RnsArgs {
|
||||
key_path: rns.rns_key,
|
||||
db_path: db.clone(),
|
||||
storage_dir: storage_dir.to_string_lossy().into_owned(),
|
||||
max_envelope: rns.rns_max_envelope,
|
||||
fetch_budget: rns.rns_fetch_budget,
|
||||
max_links: rns.rns_max_links,
|
||||
rate_link_requests: rns.rns_rate_link_requests,
|
||||
rate_link_bytes: rns.rns_rate_link_bytes,
|
||||
udp_listen_host: listen_host,
|
||||
udp_listen_port: listen_port,
|
||||
udp_forward_host: forward_host,
|
||||
udp_forward_port: forward_port,
|
||||
max_tokens,
|
||||
main_quota: quota,
|
||||
requests_quota,
|
||||
invite_token: invite_token.clone().map(String::into_bytes),
|
||||
})?;
|
||||
log::info!("RNS carrier: smolmail.server destination {dest}");
|
||||
}
|
||||
server::run(server::ServeArgs {
|
||||
key_path: key,
|
||||
db_path: db,
|
||||
host,
|
||||
port,
|
||||
max_envelope,
|
||||
quota,
|
||||
requests_quota,
|
||||
retention_days,
|
||||
requests_retention_days,
|
||||
max_tokens,
|
||||
invite_token,
|
||||
rate_connections,
|
||||
rate_sends,
|
||||
rate_tokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits host[:port], keeping `default_port` when none is given.
|
||||
#[cfg(feature = "rns")]
|
||||
fn split_host_port(addr: &str, default_port: u16) -> anyhow::Result<(String, u16)> {
|
||||
match addr.rsplit_once(':') {
|
||||
Some((host, port)) => {
|
||||
anyhow::ensure!(
|
||||
!host.contains(':') || host.starts_with('['),
|
||||
"unsupported address {addr}"
|
||||
);
|
||||
Ok((host.to_string(), port.parse().unwrap_or(default_port)))
|
||||
}
|
||||
None => Ok((addr.to_string(), default_port)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -137,3 +267,29 @@ fn cmd_keygen(key_path: &str, force: bool) -> anyhow::Result<()> {
|
|||
println!("Publish the public key through a trusted channel; clients pin it (SPEC.md sec 4).");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RNS has no server static key to pin: the destination hash is the address
|
||||
/// and the pin (upstream spec 13.4), so it is what gets published.
|
||||
#[cfg(feature = "rns")]
|
||||
fn cmd_rns_keygen(key_path: &str, force: bool) -> anyhow::Result<()> {
|
||||
if std::path::Path::new(key_path).exists() && !force {
|
||||
anyhow::bail!("{key_path} exists; refusing to overwrite (use --force)");
|
||||
}
|
||||
|
||||
let key = rns::generate_identity();
|
||||
let dest = data_encoding::HEXLOWER.encode(&rns::destination_hash(&key)?);
|
||||
|
||||
// Written 0600 before any bytes land, so the key is never briefly readable.
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
opts.mode(0o600);
|
||||
let mut file = opts.open(key_path)?;
|
||||
file.write_all(&key)?;
|
||||
|
||||
println!("identity: {key_path}");
|
||||
println!("destination: {dest}");
|
||||
println!();
|
||||
println!("smol+rns://<user>@{dest} is the server's mesh address (RNS.md sec 1).");
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ pub const MAX_FRAME: usize = 1 << 20; // application frame ceiling
|
|||
pub const NOISE_MAX: usize = 65535; // Noise message ceiling
|
||||
pub const NOISE_PAYLOAD: usize = NOISE_MAX - 16; // minus the AEAD tag
|
||||
pub const FETCH_BUDGET: usize = 512 * 1024; // must stay under MAX_FRAME
|
||||
// AUTH with a maximal username and a full token set, op byte included
|
||||
// (RNS.md sec 3): len 1 + username 63 + identity 32 + sig 64 + sync 1 +
|
||||
// count 2; callers add 32 per token.
|
||||
#[cfg(feature = "rns")]
|
||||
pub const AUTH_FULL_TOKENS: usize = 164;
|
||||
pub const IDLE_TIMEOUT_SECS: u64 = 120;
|
||||
pub const PURGE_INTERVAL_SECS: u64 = 60;
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,58 @@ impl RateLimiter {
|
|||
}
|
||||
}
|
||||
|
||||
/// Fixed-window byte counter for per-link transfer budgets: `RateLimiter`
|
||||
/// counts events, this counts bytes, so it gets its own small type.
|
||||
#[cfg(feature = "rns")]
|
||||
pub struct ByteRateLimiter {
|
||||
limit: u64,
|
||||
window: Duration,
|
||||
hits: Mutex<HashMap<String, ByteWindow>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "rns")]
|
||||
struct ByteWindow {
|
||||
start: Instant,
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
#[cfg(feature = "rns")]
|
||||
impl ByteRateLimiter {
|
||||
pub fn new(limit: u64) -> Self {
|
||||
ByteRateLimiter {
|
||||
limit,
|
||||
window: Duration::from_secs(60),
|
||||
hits: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Records `bytes` against `key` if the window still has room for them.
|
||||
pub fn allow(&self, key: &str, bytes: usize) -> bool {
|
||||
if self.limit == 0 {
|
||||
return true;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let mut hits = self.hits.lock().unwrap();
|
||||
let entry = hits.entry(key.to_string()).or_insert(ByteWindow {
|
||||
start: now,
|
||||
bytes: 0,
|
||||
});
|
||||
if now.duration_since(entry.start) >= self.window {
|
||||
entry.start = now;
|
||||
entry.bytes = 0;
|
||||
}
|
||||
if entry.bytes + bytes as u64 > self.limit {
|
||||
return false;
|
||||
}
|
||||
entry.bytes += bytes as u64;
|
||||
if hits.len() > 4096 {
|
||||
let window = self.window;
|
||||
hits.retain(|_, w| now.duration_since(w.start) < window);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
312
src/rns.rs
Normal file
312
src/rns.rs
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
//! RNS carrier (Smol Mail 1.2, RNS.md sec 7): Reticulum Links to the
|
||||
//! `smolmail.server` IN/SINGLE destination, dispatched through the same
|
||||
//! `Session` as TCP.
|
||||
//!
|
||||
//! microReticulum's request handler is a bare function pointer with no
|
||||
//! userdata, so this module's state lives in a `static OnceLock` and the
|
||||
//! shim reaches it through the `smolmail_rns_on_*` C callbacks below. The
|
||||
//! link identifier is the session key: bind values are derived from
|
||||
//! destination || link_id (RNS.md sec 2), so AUTH on one link cannot replay
|
||||
//! on another.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ffi::CString;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use crate::bind::TransportBindValues;
|
||||
use crate::proto::{AUTH_FULL_TOKENS, INTERNAL_ERROR, MALFORMED, RATE_LIMITED, TOO_LARGE};
|
||||
use crate::ratelimit::{ByteRateLimiter, RateLimiter};
|
||||
use crate::session::{ServerConfig, Session};
|
||||
use crate::store::Store;
|
||||
|
||||
mod ffi {
|
||||
use std::os::raw::{c_char, c_int};
|
||||
|
||||
extern "C" {
|
||||
// shim/smolmail_rns.cpp
|
||||
pub fn smolmail_rns_start(
|
||||
identity: *const u8,
|
||||
storage_dir: *const c_char,
|
||||
udp_listen_host: *const c_char,
|
||||
udp_listen_port: u16,
|
||||
udp_forward_host: *const c_char,
|
||||
udp_forward_port: u16,
|
||||
destination_hash_out: *mut u8,
|
||||
) -> c_int;
|
||||
|
||||
pub fn smolmail_rns_destination_hash(
|
||||
identity: *const u8,
|
||||
destination_hash_out: *mut u8,
|
||||
) -> c_int;
|
||||
}
|
||||
}
|
||||
|
||||
/// The identity file is 64 raw bytes: x25519 private (32) || ed25519 private
|
||||
/// (32), the layout microReticulum's `Identity::to_file` writes and
|
||||
/// `load_private_key` expects.
|
||||
pub const RNS_KEY_LEN: usize = 64;
|
||||
|
||||
pub struct RnsArgs {
|
||||
pub key_path: String,
|
||||
pub db_path: String,
|
||||
pub storage_dir: String,
|
||||
pub max_envelope: usize,
|
||||
pub fetch_budget: usize,
|
||||
pub max_links: usize,
|
||||
pub rate_link_requests: u32,
|
||||
pub rate_link_bytes: u64,
|
||||
pub udp_listen_host: String,
|
||||
pub udp_listen_port: u16,
|
||||
pub udp_forward_host: Option<String>,
|
||||
pub udp_forward_port: u16,
|
||||
pub max_tokens: u16,
|
||||
pub main_quota: i64,
|
||||
pub requests_quota: i64,
|
||||
pub invite_token: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
struct RnsState {
|
||||
config: &'static ServerConfig,
|
||||
db_path: String,
|
||||
destination: [u8; 16],
|
||||
sessions: Mutex<HashMap<[u8; 16], Session<'static>>>,
|
||||
links: Mutex<HashSet<[u8; 16]>>,
|
||||
request_limiter: RateLimiter,
|
||||
byte_limiter: ByteRateLimiter,
|
||||
max_links: usize,
|
||||
response: Mutex<Option<Vec<u8>>>,
|
||||
}
|
||||
|
||||
static STATE: OnceLock<RnsState> = OnceLock::new();
|
||||
|
||||
/// Loads the Reticulum identity, starts the carrier on the shim's background
|
||||
/// loop thread, and returns the destination hash as the 32 hex characters of
|
||||
/// the `smol+rns://` address host part (upstream spec 13.2).
|
||||
pub fn start(args: RnsArgs) -> anyhow::Result<String> {
|
||||
let key = std::fs::read(&args.key_path)
|
||||
.map_err(|e| anyhow::anyhow!("cannot read RNS identity {}: {e}", args.key_path))?;
|
||||
anyhow::ensure!(
|
||||
key.len() == RNS_KEY_LEN,
|
||||
"RNS identity must be {RNS_KEY_LEN} raw bytes, got {}",
|
||||
key.len()
|
||||
);
|
||||
|
||||
// Ask the shim first: bind values derive from the destination hash, so
|
||||
// the session state must exist before the shim's loop thread can fire
|
||||
// the first request.
|
||||
let destination = destination_hash(key.as_slice().try_into().unwrap())?;
|
||||
|
||||
// RateLimiter keyed by link hex covers per-link abuse control; the
|
||||
// per-IP send limit has no analogue (upstream spec 13.8), and the accept
|
||||
// token limiter stays on because a token never needed a peer identity.
|
||||
let config: &'static ServerConfig = Box::leak(Box::new(ServerConfig {
|
||||
max_envelope: args.max_envelope,
|
||||
fetch_budget: args.fetch_budget,
|
||||
main_quota: args.main_quota,
|
||||
requests_quota: args.requests_quota,
|
||||
max_tokens: args.max_tokens,
|
||||
invite_token: args.invite_token.clone(),
|
||||
conn_limiter: RateLimiter::new(0),
|
||||
send_limiter: RateLimiter::new(0),
|
||||
token_limiter: RateLimiter::new(30),
|
||||
}));
|
||||
|
||||
let state = RnsState {
|
||||
config,
|
||||
db_path: args.db_path.clone(),
|
||||
destination,
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
links: Mutex::new(HashSet::new()),
|
||||
request_limiter: RateLimiter::new(args.rate_link_requests),
|
||||
byte_limiter: ByteRateLimiter::new(args.rate_link_bytes),
|
||||
max_links: args.max_links,
|
||||
response: Mutex::new(None),
|
||||
};
|
||||
STATE.get_or_init(|| state);
|
||||
|
||||
let storage_dir = CString::new(args.storage_dir).unwrap();
|
||||
let listen_host = CString::new(args.udp_listen_host).unwrap();
|
||||
let forward_host = args.udp_forward_host.map(|h| CString::new(h).unwrap());
|
||||
|
||||
let mut dest_hash = [0u8; 16];
|
||||
let rc = unsafe {
|
||||
ffi::smolmail_rns_start(
|
||||
key.as_ptr(),
|
||||
storage_dir.as_ptr(),
|
||||
listen_host.as_ptr(),
|
||||
args.udp_listen_port,
|
||||
forward_host
|
||||
.as_ref()
|
||||
.map(|h| h.as_ptr())
|
||||
.unwrap_or(std::ptr::null()),
|
||||
args.udp_forward_port,
|
||||
dest_hash.as_mut_ptr(),
|
||||
)
|
||||
};
|
||||
anyhow::ensure!(rc == 0, "RNS shim failed to start (code {rc})");
|
||||
anyhow::ensure!(
|
||||
dest_hash == destination,
|
||||
"destination hash changed between shim calls"
|
||||
);
|
||||
Ok(data_encoding::HEXLOWER.encode(&dest_hash))
|
||||
}
|
||||
|
||||
/// The `smolmail.server` destination hash for a 64-byte identity, computed
|
||||
/// by microReticulum itself: its Curve25519 `eval` does not clamp the scalar
|
||||
/// (a divergence from RFC 7748), so an independent Rust derivation would
|
||||
/// produce a different x25519 public key and therefore a different hash.
|
||||
pub fn destination_hash(key: &[u8; RNS_KEY_LEN]) -> anyhow::Result<[u8; 16]> {
|
||||
let mut out = [0u8; 16];
|
||||
let rc = unsafe { ffi::smolmail_rns_destination_hash(key.as_ptr(), out.as_mut_ptr()) };
|
||||
anyhow::ensure!(rc == 0, "shim rejected the RNS identity (code {rc})");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Generates the 64-byte Reticulum identity: random x25519 || ed25519
|
||||
/// private halves.
|
||||
pub fn generate_identity() -> [u8; RNS_KEY_LEN] {
|
||||
use rand_core::{OsRng, RngCore};
|
||||
let mut key = [0u8; RNS_KEY_LEN];
|
||||
OsRng.fill_bytes(&mut key);
|
||||
key
|
||||
}
|
||||
|
||||
fn response(status: u8) -> Vec<u8> {
|
||||
vec![status]
|
||||
}
|
||||
|
||||
fn handle_request(request: &[u8], link_id: &[u8; 16]) -> Vec<u8> {
|
||||
let Some(state) = STATE.get() else {
|
||||
return response(INTERNAL_ERROR);
|
||||
};
|
||||
let link = data_encoding::HEXLOWER.encode(link_id);
|
||||
|
||||
// Bounded request size (RNS.md sec 3): an envelope plus its overhead, or
|
||||
// an AUTH carrying a full token set, whichever is larger.
|
||||
let max_request = (state.config.max_envelope + 34)
|
||||
.max(AUTH_FULL_TOKENS + 32 * state.config.max_tokens as usize);
|
||||
if request.len() > max_request {
|
||||
log::warn!(
|
||||
"request of {} bytes over {} byte cap",
|
||||
request.len(),
|
||||
max_request
|
||||
);
|
||||
return response(TOO_LARGE);
|
||||
}
|
||||
if !state.request_limiter.allow(&link) || !state.byte_limiter.allow(&link, request.len()) {
|
||||
return response(RATE_LIMITED);
|
||||
}
|
||||
|
||||
let Some(op) = request.first() else {
|
||||
return response(MALFORMED);
|
||||
};
|
||||
let body = &request[1..];
|
||||
|
||||
let mut sessions = state.sessions.lock().unwrap();
|
||||
let session = match sessions.entry(*link_id) {
|
||||
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
// The link is the session (upstream spec 13.6): a Store per link,
|
||||
// bind values derived from this destination and link.
|
||||
match Store::open(&state.db_path) {
|
||||
Ok(store) => e.insert(Session::new(
|
||||
state.config,
|
||||
store,
|
||||
link.clone(),
|
||||
TransportBindValues::rns(&state.destination, link_id),
|
||||
)),
|
||||
Err(err) => {
|
||||
log::error!("cannot open store for link {link}: {err}");
|
||||
return response(INTERNAL_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (status, payload) = session.dispatch(*op, body);
|
||||
let mut out = Vec::with_capacity(1 + payload.len());
|
||||
out.push(status);
|
||||
out.extend_from_slice(&payload);
|
||||
out
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C" fn smolmail_rns_on_request(
|
||||
request: *const u8,
|
||||
request_len: usize,
|
||||
link_id: *const u8,
|
||||
) -> usize {
|
||||
// The shim calls this on its single loop thread, so the slot never sees
|
||||
// concurrent writers; panics must not cross the FFI boundary.
|
||||
let result = std::panic::catch_unwind(|| unsafe {
|
||||
let request = std::slice::from_raw_parts(request, request_len);
|
||||
let link_id: &[u8; 16] = std::slice::from_raw_parts(link_id, 16).try_into().unwrap();
|
||||
handle_request(request, link_id)
|
||||
});
|
||||
let Ok(response) = result else {
|
||||
log::error!("panic in RNS request handler");
|
||||
return 0;
|
||||
};
|
||||
let len = response.len();
|
||||
*STATE.get().unwrap().response.lock().unwrap() = Some(response);
|
||||
len
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C" fn smolmail_rns_take_response(out: *mut u8, cap: usize) -> usize {
|
||||
let slot = &mut STATE.get().unwrap().response.lock().unwrap();
|
||||
match slot.take() {
|
||||
Some(response) if response.len() <= cap => {
|
||||
unsafe { std::ptr::copy_nonoverlapping(response.as_ptr(), out, response.len()) };
|
||||
response.len()
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C" fn smolmail_rns_on_link_opened(link_id: *const u8) -> i32 {
|
||||
let Some(state) = STATE.get() else {
|
||||
return 1;
|
||||
};
|
||||
let link: [u8; 16] = unsafe { std::slice::from_raw_parts(link_id, 16).try_into().unwrap() };
|
||||
let mut links = state.links.lock().unwrap();
|
||||
if !links.contains(&link) && links.len() >= state.max_links {
|
||||
log::warn!(
|
||||
"refusing link {}, {} link(s) open",
|
||||
data_encoding::HEXLOWER.encode(&link),
|
||||
links.len()
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
links.insert(link);
|
||||
0
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C" fn smolmail_rns_on_link_closed(link_id: *const u8) {
|
||||
if let Some(state) = STATE.get() {
|
||||
let link: [u8; 16] = unsafe { std::slice::from_raw_parts(link_id, 16).try_into().unwrap() };
|
||||
state.links.lock().unwrap().remove(&link);
|
||||
state.sessions.lock().unwrap().remove(&link);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Cross-checked against microReticulum itself: the destination hash a
|
||||
/// native probe prints for the identity 0x00..0x3f.
|
||||
#[test]
|
||||
fn destination_hash_matches_microreticulum() {
|
||||
let mut key = [0u8; RNS_KEY_LEN];
|
||||
for (i, byte) in key.iter_mut().enumerate() {
|
||||
*byte = i as u8;
|
||||
}
|
||||
assert_eq!(
|
||||
data_encoding::HEXLOWER.encode(&destination_hash(&key).unwrap()),
|
||||
"799855f4955f1b09fd20a13cd84f4e71"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,9 +4,10 @@ use std::net::TcpStream;
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::bind::TransportBindValues;
|
||||
use crate::channel::{handshake, Channel};
|
||||
use crate::crypto::{b32, derive_public};
|
||||
use crate::proto::{IDLE_TIMEOUT_SECS, KEY_LEN, MALFORMED, PURGE_INTERVAL_SECS};
|
||||
use crate::proto::{FETCH_BUDGET, IDLE_TIMEOUT_SECS, KEY_LEN, MALFORMED, PURGE_INTERVAL_SECS};
|
||||
use crate::ratelimit::RateLimiter;
|
||||
use crate::session::{ServerConfig, Session};
|
||||
use crate::store::Store;
|
||||
|
|
@ -29,8 +30,8 @@ pub struct ServeArgs {
|
|||
}
|
||||
|
||||
pub fn run(args: ServeArgs) -> anyhow::Result<()> {
|
||||
let static_key =
|
||||
std::fs::read(&args.key_path).map_err(|e| anyhow::anyhow!("cannot read server key: {e}"))?;
|
||||
let static_key = std::fs::read(&args.key_path)
|
||||
.map_err(|e| anyhow::anyhow!("cannot read server key: {e}"))?;
|
||||
anyhow::ensure!(
|
||||
static_key.len() == KEY_LEN,
|
||||
"server key must be {KEY_LEN} raw bytes, got {}",
|
||||
|
|
@ -42,11 +43,11 @@ pub fn run(args: ServeArgs) -> anyhow::Result<()> {
|
|||
|
||||
let config = Arc::new(ServerConfig {
|
||||
max_envelope: args.max_envelope,
|
||||
fetch_budget: FETCH_BUDGET,
|
||||
main_quota: args.quota,
|
||||
requests_quota: args.requests_quota,
|
||||
max_tokens: args.max_tokens,
|
||||
invite_token: args.invite_token.map(String::into_bytes),
|
||||
server_static,
|
||||
conn_limiter: RateLimiter::new(args.rate_connections),
|
||||
send_limiter: RateLimiter::new(args.rate_sends),
|
||||
token_limiter: RateLimiter::new(args.rate_tokens),
|
||||
|
|
@ -55,7 +56,9 @@ pub fn run(args: ServeArgs) -> anyhow::Result<()> {
|
|||
let main_retention_secs = args.retention_days * 86400;
|
||||
let requests_retention_secs = args.requests_retention_days * 86400;
|
||||
let purge_db_path = args.db_path.clone();
|
||||
std::thread::spawn(move || purge_loop(purge_db_path, main_retention_secs, requests_retention_secs));
|
||||
std::thread::spawn(move || {
|
||||
purge_loop(purge_db_path, main_retention_secs, requests_retention_secs)
|
||||
});
|
||||
|
||||
let listener = std::net::TcpListener::bind((args.host.as_str(), args.port))?;
|
||||
log::info!("listening on {}:{}", args.host, args.port);
|
||||
|
|
@ -80,7 +83,7 @@ pub fn run(args: ServeArgs) -> anyhow::Result<()> {
|
|||
}
|
||||
|
||||
let config = Arc::clone(&config);
|
||||
let static_key = static_key.clone();
|
||||
let static_key = key_array;
|
||||
let db_path = args.db_path.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = handle_connection(stream, &config, &static_key, &db_path, &peer_ip) {
|
||||
|
|
@ -94,7 +97,7 @@ pub fn run(args: ServeArgs) -> anyhow::Result<()> {
|
|||
fn handle_connection(
|
||||
mut stream: TcpStream,
|
||||
config: &ServerConfig,
|
||||
static_key: &[u8],
|
||||
static_key: &[u8; KEY_LEN],
|
||||
db_path: &str,
|
||||
peer_ip: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
|
|
@ -109,7 +112,9 @@ fn handle_connection(
|
|||
};
|
||||
|
||||
let store = Store::open(db_path)?;
|
||||
let mut session = Session::new(config, store, peer_ip.to_string(), handshake_hash);
|
||||
let server_static = derive_public(static_key);
|
||||
let bind = TransportBindValues::tcp(&handshake_hash, &server_static)?;
|
||||
let mut session = Session::new(config, store, peer_ip.to_string(), bind);
|
||||
let mut channel = Channel::new(stream, transport);
|
||||
|
||||
loop {
|
||||
|
|
|
|||
195
src/session.rs
195
src/session.rs
|
|
@ -1,23 +1,24 @@
|
|||
//! Per-connection dispatch and the six wire operations.
|
||||
|
||||
use crate::bind::TransportBindValues;
|
||||
use crate::crypto::{b32, ct_eq, hmac_sha256, message_id, verify};
|
||||
use crate::proto::{
|
||||
valid_username, ProtocolError, Reader, AUTH_FAILED, AUTH_REQUIRED, BAD_VERSION, CERT_LEN,
|
||||
ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, FETCH_BUDGET, ID_LEN, KEY_LEN, LABEL_AUTH,
|
||||
LABEL_MAC, LABEL_REGISTER, LABEL_ROTATE, MAC_LEN, MALFORMED, MAX_CHAIN, NOT_PERMITTED, OK,
|
||||
OP_AUTH, OP_DELETE, OP_FETCH, OP_REGISTER, OP_RESOLVE, OP_SEND, QUOTA_EXCEEDED, RATE_LIMITED,
|
||||
TOKEN_LEN, TOO_LARGE, UNKNOWN_USER,
|
||||
ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, ID_LEN, KEY_LEN, LABEL_AUTH, LABEL_MAC,
|
||||
LABEL_REGISTER, LABEL_ROTATE, MAC_LEN, MALFORMED, MAX_CHAIN, NOT_PERMITTED, OK, OP_AUTH,
|
||||
OP_DELETE, OP_FETCH, OP_REGISTER, OP_RESOLVE, OP_SEND, QUOTA_EXCEEDED, RATE_LIMITED, TOKEN_LEN,
|
||||
TOO_LARGE, UNKNOWN_USER,
|
||||
};
|
||||
use crate::ratelimit::RateLimiter;
|
||||
use crate::store::Store;
|
||||
|
||||
pub struct ServerConfig {
|
||||
pub max_envelope: usize,
|
||||
pub fetch_budget: usize,
|
||||
pub main_quota: i64,
|
||||
pub requests_quota: i64,
|
||||
pub max_tokens: u16,
|
||||
pub invite_token: Option<Vec<u8>>,
|
||||
pub server_static: [u8; KEY_LEN],
|
||||
pub conn_limiter: RateLimiter,
|
||||
pub send_limiter: RateLimiter,
|
||||
pub token_limiter: RateLimiter,
|
||||
|
|
@ -47,17 +48,22 @@ pub struct Session<'a> {
|
|||
config: &'a ServerConfig,
|
||||
store: Store,
|
||||
peer_ip: String,
|
||||
handshake_hash: Vec<u8>,
|
||||
bind: TransportBindValues,
|
||||
username: Option<String>,
|
||||
}
|
||||
|
||||
impl<'a> Session<'a> {
|
||||
pub fn new(config: &'a ServerConfig, store: Store, peer_ip: String, handshake_hash: Vec<u8>) -> Self {
|
||||
pub fn new(
|
||||
config: &'a ServerConfig,
|
||||
store: Store,
|
||||
peer_ip: String,
|
||||
bind: TransportBindValues,
|
||||
) -> Self {
|
||||
Session {
|
||||
config,
|
||||
store,
|
||||
peer_ip,
|
||||
handshake_hash,
|
||||
bind,
|
||||
username: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -125,7 +131,7 @@ impl<'a> Session<'a> {
|
|||
return Ok((AUTH_FAILED, Vec::new()));
|
||||
}
|
||||
let mut msg = LABEL_AUTH.to_vec();
|
||||
msg.extend_from_slice(&self.handshake_hash);
|
||||
msg.extend_from_slice(&self.bind.h);
|
||||
if !verify(&identity, &signature, &msg) {
|
||||
return Ok((AUTH_FAILED, Vec::new()));
|
||||
}
|
||||
|
|
@ -231,9 +237,12 @@ impl<'a> Session<'a> {
|
|||
r.done()?;
|
||||
let username = self.username.as_ref().expect("AUTH_REQUIRED gate above");
|
||||
let keys = self.store.keys_of(username)?;
|
||||
let records = self
|
||||
.store
|
||||
.pending(&keys, after_received_at, &after_id, FETCH_BUDGET)?;
|
||||
let records = self.store.pending(
|
||||
&keys,
|
||||
after_received_at,
|
||||
&after_id,
|
||||
self.config.fetch_budget,
|
||||
)?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&(records.len() as u16).to_be_bytes());
|
||||
|
|
@ -286,7 +295,7 @@ impl<'a> Session<'a> {
|
|||
// (SPEC.md sec 6.1). Binding server_static stops the attestation from
|
||||
// being replayed against another server.
|
||||
let mut pop_msg = LABEL_REGISTER.to_vec();
|
||||
pop_msg.extend_from_slice(&self.config.server_static);
|
||||
pop_msg.extend_from_slice(&self.bind.server_static);
|
||||
pop_msg.extend_from_slice(username.as_bytes());
|
||||
pop_msg.extend_from_slice(&identity);
|
||||
if !verify(&identity, &signature, &pop_msg) {
|
||||
|
|
@ -343,3 +352,163 @@ impl<'a> Session<'a> {
|
|||
Ok((OK, Vec::new()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crypto::sha256;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
fn test_config() -> ServerConfig {
|
||||
ServerConfig {
|
||||
max_envelope: 1024,
|
||||
fetch_budget: 512,
|
||||
main_quota: 1 << 20,
|
||||
requests_quota: 1 << 20,
|
||||
max_tokens: 16,
|
||||
invite_token: None,
|
||||
conn_limiter: RateLimiter::new(0),
|
||||
send_limiter: RateLimiter::new(0),
|
||||
token_limiter: RateLimiter::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_store(name: &str) -> Store {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("bunshin-session-{name}-{}.db", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
Store::open(path.to_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn str_field(s: &str) -> Vec<u8> {
|
||||
let mut out = vec![s.len() as u8];
|
||||
out.extend_from_slice(s.as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn register_body(username: &str, identity: &[u8], signature: &[u8]) -> Vec<u8> {
|
||||
let mut body = str_field(username);
|
||||
body.extend_from_slice(identity);
|
||||
body.extend_from_slice(signature);
|
||||
body.push(0); // invite token, none configured
|
||||
body.push(0); // cert, plain registration
|
||||
body
|
||||
}
|
||||
|
||||
fn auth_body(username: &str, identity: &[u8], signature: &[u8]) -> Vec<u8> {
|
||||
let mut body = str_field(username);
|
||||
body.extend_from_slice(identity);
|
||||
body.extend_from_slice(signature);
|
||||
body.push(0); // sync = 0, stored tokens untouched
|
||||
body.extend_from_slice(&0u16.to_be_bytes());
|
||||
body
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rns_bind_signatures_accepted_and_tcp_bind_signatures_rejected() {
|
||||
let config = test_config();
|
||||
let key = SigningKey::from_bytes(&[3u8; 32]);
|
||||
let identity = key.verifying_key().as_bytes().to_vec();
|
||||
|
||||
let bind = TransportBindValues::rns(&[0x11; 16], &[0x22; 16]);
|
||||
|
||||
let mut pop = LABEL_REGISTER.to_vec();
|
||||
pop.extend_from_slice(&bind.server_static);
|
||||
pop.extend_from_slice(b"alice");
|
||||
pop.extend_from_slice(&identity);
|
||||
let good_register = register_body("alice", &identity, &key.sign(&pop).to_bytes());
|
||||
|
||||
// A TCP client would sign over the real X25519 static key instead.
|
||||
let tcp_static = crate::crypto::derive_public(&[9u8; 32]);
|
||||
let mut tcp_pop = LABEL_REGISTER.to_vec();
|
||||
tcp_pop.extend_from_slice(&tcp_static);
|
||||
tcp_pop.extend_from_slice(b"alice");
|
||||
tcp_pop.extend_from_slice(&identity);
|
||||
let tcp_register = register_body("alice", &identity, &key.sign(&tcp_pop).to_bytes());
|
||||
|
||||
let mut auth_msg = LABEL_AUTH.to_vec();
|
||||
auth_msg.extend_from_slice(&bind.h);
|
||||
let good_auth = auth_body("alice", &identity, &key.sign(&auth_msg).to_bytes());
|
||||
|
||||
// A Noise client would sign over the handshake hash instead.
|
||||
let handshake_hash = sha256(&[b"handshake"]);
|
||||
let mut tcp_auth = LABEL_AUTH.to_vec();
|
||||
tcp_auth.extend_from_slice(&handshake_hash);
|
||||
let tcp_auth = auth_body("alice", &identity, &key.sign(&tcp_auth).to_bytes());
|
||||
|
||||
let mut session = Session::new(&config, temp_store("rns-bind"), "rns".into(), bind);
|
||||
assert_eq!(session.dispatch(OP_REGISTER, &good_register).0, OK);
|
||||
assert_eq!(session.dispatch(OP_AUTH, &good_auth).0, OK);
|
||||
|
||||
let mut session = Session::new(
|
||||
&config,
|
||||
temp_store("rns-bind-reject"),
|
||||
"rns".into(),
|
||||
TransportBindValues::rns(&[0x11; 16], &[0x22; 16]),
|
||||
);
|
||||
assert_eq!(session.dispatch(OP_REGISTER, &good_register).0, OK);
|
||||
assert_eq!(session.dispatch(OP_AUTH, &tcp_auth).0, AUTH_FAILED);
|
||||
|
||||
let mut session = Session::new(
|
||||
&config,
|
||||
temp_store("rns-bind-tcp-register"),
|
||||
"rns".into(),
|
||||
TransportBindValues::rns(&[0x11; 16], &[0x22; 16]),
|
||||
);
|
||||
assert_eq!(session.dispatch(OP_REGISTER, &tcp_register).0, AUTH_FAILED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_bind_signatures_accepted_and_rns_bind_signatures_rejected() {
|
||||
let config = test_config();
|
||||
let key = SigningKey::from_bytes(&[4u8; 32]);
|
||||
let identity = key.verifying_key().as_bytes().to_vec();
|
||||
|
||||
let server_static = crate::crypto::derive_public(&[9u8; 32]);
|
||||
let handshake_hash = sha256(&[b"handshake"]);
|
||||
let bind = TransportBindValues::tcp(&handshake_hash, &server_static).unwrap();
|
||||
|
||||
let mut pop = LABEL_REGISTER.to_vec();
|
||||
pop.extend_from_slice(&server_static);
|
||||
pop.extend_from_slice(b"alice");
|
||||
pop.extend_from_slice(&identity);
|
||||
let good_register = register_body("alice", &identity, &key.sign(&pop).to_bytes());
|
||||
|
||||
let mut auth_msg = LABEL_AUTH.to_vec();
|
||||
auth_msg.extend_from_slice(&handshake_hash);
|
||||
let good_auth = auth_body("alice", &identity, &key.sign(&auth_msg).to_bytes());
|
||||
|
||||
// An RNS client would sign over the derived bind values instead.
|
||||
let rns_bind = TransportBindValues::rns(&[0x11; 16], &[0x22; 16]);
|
||||
let mut rns_pop = LABEL_REGISTER.to_vec();
|
||||
rns_pop.extend_from_slice(&rns_bind.server_static);
|
||||
rns_pop.extend_from_slice(b"alice");
|
||||
rns_pop.extend_from_slice(&identity);
|
||||
let rns_register = register_body("alice", &identity, &key.sign(&rns_pop).to_bytes());
|
||||
|
||||
let mut rns_auth = LABEL_AUTH.to_vec();
|
||||
rns_auth.extend_from_slice(&rns_bind.h);
|
||||
let rns_auth = auth_body("alice", &identity, &key.sign(&rns_auth).to_bytes());
|
||||
|
||||
let mut session = Session::new(&config, temp_store("tcp-bind"), "tcp".into(), bind);
|
||||
assert_eq!(session.dispatch(OP_REGISTER, &good_register).0, OK);
|
||||
assert_eq!(session.dispatch(OP_AUTH, &good_auth).0, OK);
|
||||
|
||||
let mut session = Session::new(
|
||||
&config,
|
||||
temp_store("tcp-bind-reject"),
|
||||
"tcp".into(),
|
||||
TransportBindValues::tcp(&handshake_hash, &server_static).unwrap(),
|
||||
);
|
||||
assert_eq!(session.dispatch(OP_REGISTER, &good_register).0, OK);
|
||||
assert_eq!(session.dispatch(OP_AUTH, &rns_auth).0, AUTH_FAILED);
|
||||
|
||||
let mut session = Session::new(
|
||||
&config,
|
||||
temp_store("tcp-bind-rns-register"),
|
||||
"tcp".into(),
|
||||
TransportBindValues::tcp(&handshake_hash, &server_static).unwrap(),
|
||||
);
|
||||
assert_eq!(session.dispatch(OP_REGISTER, &rns_register).0, AUTH_FAILED);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue