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"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"cc",
|
||||||
"clap",
|
"clap",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ description = "Smol Mail server"
|
||||||
name = "bunshin"
|
name = "bunshin"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# RNS carrier over microReticulum (RNS.md); needs MICRORETICULUM_SOURCE_DIR
|
||||||
|
# at build time, provided by the flake.
|
||||||
|
rns = ["dep:cc"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
snow = "0.9"
|
snow = "0.9"
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
|
@ -20,3 +25,6 @@ clap = { version = "4", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
cc = { version = "1", optional = true }
|
||||||
|
|
|
||||||
19
README.md
19
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# 分身 bunshin
|
# 分身 bunshin
|
||||||
|
|
||||||
A Rust implementation of the [Smol Mail](https://code.randogoth.com/randogoth/smolmail) server: a minimalist, end-to-end encrypted mail protocol over a Noise-secured TCP connection. `bunshin` implements the server side only — receiving, storing and serving sealed mail — not the client.
|
A Rust implementation of the [Smol Mail](https://code.randogoth.com/randogoth/smolmail) server: a minimalist, end-to-end encrypted mail protocol over a Noise-secured TCP connection, with an optional Reticulum (RNS) mesh carrier behind the `rns` build feature. `bunshin` implements the server side only — receiving, storing and serving sealed mail — not the client.
|
||||||
|
|
||||||
The server never sees plaintext, sender identities or any private key. It learns only which mailbox an envelope is for, its size, and when it arrived.
|
The server never sees plaintext, sender identities or any private key. It learns only which mailbox an envelope is for, its size, and when it arrived.
|
||||||
|
|
||||||
|
|
@ -34,6 +34,8 @@ Add bunshin as a flake input and import the module:
|
||||||
|
|
||||||
`services.bunshin` also takes `host`, `port`, `dataDir`, `maxEnvelope`, `quota`, `requestsQuota`, `retentionDays`, `requestsRetentionDays`, `maxTokens`, `rateConnections`, `rateSends` and `rateTokens`; see `flake.nix` for defaults. The module renders a `systemd` unit that runs `bunshin serve` under `DynamicUser`; it does not generate a key.
|
`services.bunshin` also takes `host`, `port`, `dataDir`, `maxEnvelope`, `quota`, `requestsQuota`, `retentionDays`, `requestsRetentionDays`, `maxTokens`, `rateConnections`, `rateSends` and `rateTokens`; see `flake.nix` for defaults. The module renders a `systemd` unit that runs `bunshin serve` under `DynamicUser`; it does not generate a key.
|
||||||
|
|
||||||
|
For the RNS carrier, build `packages.rns`, set `services.bunshin.package` to it, and enable `services.bunshin.rns` with its `keyFile`; see [RNS.md](RNS.md) for the protocol and the remaining options.
|
||||||
|
|
||||||
Before the first deploy, generate the server's static key once (from a dev shell or `nix run`) and place it at the configured `keyFile`:
|
Before the first deploy, generate the server's static key once (from a dev shell or `nix run`) and place it at the configured `keyFile`:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -42,9 +44,20 @@ nix run "https://code.randogoth.com/randogoth/bunshin" -- keygen --key server.ke
|
||||||
|
|
||||||
`keygen` writes the server's static X25519 key (used for the Noise handshake, distinct from any user's Ed25519 identity) and prints its public key in base32. Publish that public key through a trusted channel — clients pin it, and a mismatch aborts the handshake.
|
`keygen` writes the server's static X25519 key (used for the Noise handshake, distinct from any user's Ed25519 identity) and prints its public key in base32. Publish that public key through a trusted channel — clients pin it, and a mismatch aborts the handshake.
|
||||||
|
|
||||||
|
## RNS carrier
|
||||||
|
|
||||||
|
The `rns` feature (built by `packages.rns`, wired to the module through `services.bunshin.rns`) serves the same mailbox over the Reticulum Network Stack alongside TCP: `bunshin serve --rns` runs both carriers in one process against one database. The mesh address is the `smolmail.server` destination hash — there is no key to pin, the address is the pin.
|
||||||
|
|
||||||
|
```
|
||||||
|
bunshin rns-keygen --key server.rns.key
|
||||||
|
bunshin serve --rns --key server.key --rns-key server.rns.key --db mail.db --rns-udp 0.0.0.0:4242
|
||||||
|
```
|
||||||
|
|
||||||
|
`rns-keygen` writes the 64-byte Reticulum identity and prints the destination hash; publish `smol+rns://<user>@<hash>` as the server's address. RNS-specific limits (`--rns-max-envelope`, `--rns-fetch-budget`, `--rns-max-links`, `--rns-rate-link-requests`, `--rns-rate-link-bytes`) default to mesh-friendly 32 KiB caps; the UDP interface is the one supported transport interface so far. See [RNS.md](RNS.md).
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
Requires a Rust toolchain (stable, edition 2021) and a C compiler — `rusqlite`'s `bundled` feature compiles SQLite from source rather than linking a system copy, so no separate SQLite install is needed.
|
Requires a Rust toolchain (stable, edition 2021) and a C compiler — `rusqlite`'s `bundled` feature compiles SQLite from source rather than linking a system copy, so no separate SQLite install is needed. The `rns` feature additionally needs cmake, ninja and a microReticulum checkout pointed at by `MICRORETICULUM_SOURCE_DIR` — the flake's dev shell provides all of it.
|
||||||
|
|
||||||
```
|
```
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
@ -65,3 +78,5 @@ bunshin serve --key server.key --db mail.db --host 0.0.0.0 --port 1961
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Implements SPEC.md version 1.1 in full: `AUTH`, `RESOLVE`, `SEND`, `FETCH`, `DELETE` and `REGISTER`, including accept tokens and the main/requests tier split, fetch cursors, 32-byte message ids, `REGISTER` proof of possession, and dual-signed key rotation chains.
|
Implements SPEC.md version 1.1 in full: `AUTH`, `RESOLVE`, `SEND`, `FETCH`, `DELETE` and `REGISTER`, including accept tokens and the main/requests tier split, fetch cursors, 32-byte message ids, `REGISTER` proof of possession, and dual-signed key rotation chains.
|
||||||
|
|
||||||
|
With the `rns` feature, implements the version 1.2 RNS transport: the same six operations over Reticulum Links to an announced `smolmail.server` destination, with link-bound AUTH/REGISTER signatures, per-link request and byte limits, and a concurrent-link cap. One server process can serve both carriers over one store.
|
||||||
|
|
|
||||||
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"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"microReticulum": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1784575896,
|
||||||
|
"narHash": "sha256-cqo+BJ3tsmw4fD+SL0D3X9FKCgf+n0VQng2pSIuyK/8=",
|
||||||
|
"owner": "attermann",
|
||||||
|
"repo": "microReticulum",
|
||||||
|
"rev": "40fa628809d57140180c1c833559ab96fec992c1",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "attermann",
|
||||||
|
"repo": "microReticulum",
|
||||||
|
"rev": "40fa628809d57140180c1c833559ab96fec992c1",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1790323409,
|
"lastModified": 1790323409,
|
||||||
|
|
@ -34,10 +51,137 @@
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"rns-arduinojson": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1750405034,
|
||||||
|
"narHash": "sha256-MGspSk9zWdPHMFXm+Oi4sibznHYE1eKXSqi6QHmjVCk=",
|
||||||
|
"owner": "bblanchon",
|
||||||
|
"repo": "ArduinoJson",
|
||||||
|
"rev": "ed69feadb95182dc53ac978975d310122060a767",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "bblanchon",
|
||||||
|
"repo": "ArduinoJson",
|
||||||
|
"rev": "ed69feadb95182dc53ac978975d310122060a767",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rns-arxcontainer": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1718955529,
|
||||||
|
"narHash": "sha256-3XzdM1fNl7irhjWF6hnbn+iw+orOox4KY5ezk1SC0Lg=",
|
||||||
|
"owner": "hideakitai",
|
||||||
|
"repo": "ArxContainer",
|
||||||
|
"rev": "d6affcd0bc83219b863c20abf7c269214db8db2a",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hideakitai",
|
||||||
|
"repo": "ArxContainer",
|
||||||
|
"rev": "d6affcd0bc83219b863c20abf7c269214db8db2a",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rns-arxtypetraits": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1760475334,
|
||||||
|
"narHash": "sha256-rKW85Pt4NsbYDesnwJKSrx6F2bq4ZDSU+7DpRMvH1R0=",
|
||||||
|
"owner": "hideakitai",
|
||||||
|
"repo": "ArxTypeTraits",
|
||||||
|
"rev": "702de9cc59c7e047cdc169ae3547718b289d2c02",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hideakitai",
|
||||||
|
"repo": "ArxTypeTraits",
|
||||||
|
"rev": "702de9cc59c7e047cdc169ae3547718b289d2c02",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rns-crypto": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1779476496,
|
||||||
|
"narHash": "sha256-bv0Hr+1sp9nCERZSmhCYv69C2zn+Jk2N3pZVUXquRcw=",
|
||||||
|
"owner": "attermann",
|
||||||
|
"repo": "Crypto",
|
||||||
|
"rev": "984dc891330986c302a86c4e312d4f5abcc28359",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "attermann",
|
||||||
|
"repo": "Crypto",
|
||||||
|
"rev": "984dc891330986c302a86c4e312d4f5abcc28359",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rns-debuglog": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731116852,
|
||||||
|
"narHash": "sha256-vu4jfXY9ulaEFQzv1VJahzBN3ii+8F7AyOxsnKRzjv4=",
|
||||||
|
"owner": "hideakitai",
|
||||||
|
"repo": "DebugLog",
|
||||||
|
"rev": "b581f7dde6c276c5df684e2328f406d9754d2f46",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hideakitai",
|
||||||
|
"repo": "DebugLog",
|
||||||
|
"rev": "b581f7dde6c276c5df684e2328f406d9754d2f46",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rns-microstore": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1784473031,
|
||||||
|
"narHash": "sha256-gY/r5q4tPDfIj3I0E2ZVj0URPjo1tlQj37Ctg9NQoYQ=",
|
||||||
|
"owner": "attermann",
|
||||||
|
"repo": "microStore",
|
||||||
|
"rev": "0f28567fe00ab8ab14624a34c2e9e0a000a44c46",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "attermann",
|
||||||
|
"repo": "microStore",
|
||||||
|
"rev": "0f28567fe00ab8ab14624a34c2e9e0a000a44c46",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rns-msgpack": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1707898892,
|
||||||
|
"narHash": "sha256-Zn3cwUaW2RMvOyvpcA1JxHQk3f3X7m2yZ8ilRAwgxNI=",
|
||||||
|
"owner": "hideakitai",
|
||||||
|
"repo": "MsgPack",
|
||||||
|
"rev": "1f552c31b940d6e9063ee17a4b3fa10c47b27169",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hideakitai",
|
||||||
|
"repo": "MsgPack",
|
||||||
|
"rev": "1f552c31b940d6e9063ee17a4b3fa10c47b27169",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs"
|
"microReticulum": "microReticulum",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"rns-arduinojson": "rns-arduinojson",
|
||||||
|
"rns-arxcontainer": "rns-arxcontainer",
|
||||||
|
"rns-arxtypetraits": "rns-arxtypetraits",
|
||||||
|
"rns-crypto": "rns-crypto",
|
||||||
|
"rns-debuglog": "rns-debuglog",
|
||||||
|
"rns-microstore": "rns-microstore",
|
||||||
|
"rns-msgpack": "rns-msgpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"systems": {
|
"systems": {
|
||||||
|
|
|
||||||
164
flake.nix
164
flake.nix
|
|
@ -4,32 +4,96 @@
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
|
||||||
|
# RNS carrier sources (RNS.md sec 8): the build sandbox has no network,
|
||||||
|
# so every microReticulum FetchContent dependency is vendored as its own
|
||||||
|
# flake input and handed over via RNS_<DEP>_SOURCE_DIR. microReticulum
|
||||||
|
# is pinned at the commit RNS.md sec 5 verified against.
|
||||||
|
microReticulum = {
|
||||||
|
url = "github:attermann/microReticulum/40fa628809d57140180c1c833559ab96fec992c1";
|
||||||
|
flake = false;
|
||||||
|
};
|
||||||
|
rns-arduinojson = {
|
||||||
|
url = "github:bblanchon/ArduinoJson/ed69feadb95182dc53ac978975d310122060a767";
|
||||||
|
flake = false;
|
||||||
|
};
|
||||||
|
rns-msgpack = {
|
||||||
|
url = "github:hideakitai/MsgPack/1f552c31b940d6e9063ee17a4b3fa10c47b27169";
|
||||||
|
flake = false;
|
||||||
|
};
|
||||||
|
rns-arxcontainer = {
|
||||||
|
url = "github:hideakitai/ArxContainer/d6affcd0bc83219b863c20abf7c269214db8db2a";
|
||||||
|
flake = false;
|
||||||
|
};
|
||||||
|
rns-arxtypetraits = {
|
||||||
|
url = "github:hideakitai/ArxTypeTraits/702de9cc59c7e047cdc169ae3547718b289d2c02";
|
||||||
|
flake = false;
|
||||||
|
};
|
||||||
|
rns-debuglog = {
|
||||||
|
url = "github:hideakitai/DebugLog/b581f7dde6c276c5df684e2328f406d9754d2f46";
|
||||||
|
flake = false;
|
||||||
|
};
|
||||||
|
rns-crypto = {
|
||||||
|
url = "github:attermann/Crypto/984dc891330986c302a86c4e312d4f5abcc28359";
|
||||||
|
flake = false;
|
||||||
|
};
|
||||||
|
rns-microstore = {
|
||||||
|
url = "github:attermann/microStore/0f28567fe00ab8ab14624a34c2e9e0a000a44c46";
|
||||||
|
flake = false;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils }:
|
outputs = { self, nixpkgs, flake-utils, microReticulum, rns-arduinojson
|
||||||
|
, rns-msgpack, rns-arxcontainer, rns-arxtypetraits, rns-debuglog
|
||||||
|
, rns-crypto, rns-microstore }:
|
||||||
flake-utils.lib.eachDefaultSystem (system:
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
let
|
let
|
||||||
pkgs = import nixpkgs { inherit system; };
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
inherit (pkgs) lib;
|
||||||
|
|
||||||
bunshin = pkgs.rustPlatform.buildRustPackage {
|
# build.rs consumes these directly (RNS.md sec 7.1); with every
|
||||||
|
# dependency vendored, the cmake configure step never fetches.
|
||||||
|
rnsSourceEnv = {
|
||||||
|
MICRORETICULUM_SOURCE_DIR = "${microReticulum}";
|
||||||
|
RNS_ARDUINOJSON_SOURCE_DIR = "${rns-arduinojson}";
|
||||||
|
RNS_MSGPACK_SOURCE_DIR = "${rns-msgpack}";
|
||||||
|
RNS_ARXCONTAINER_SOURCE_DIR = "${rns-arxcontainer}";
|
||||||
|
RNS_ARXTYPETRAITS_SOURCE_DIR = "${rns-arxtypetraits}";
|
||||||
|
RNS_DEBUGLOG_SOURCE_DIR = "${rns-debuglog}";
|
||||||
|
RNS_CRYPTO_SOURCE_DIR = "${rns-crypto}";
|
||||||
|
RNS_MICROSTORE_SOURCE_DIR = "${rns-microstore}";
|
||||||
|
};
|
||||||
|
|
||||||
|
bunshin = { rns ? false }: pkgs.rustPlatform.buildRustPackage {
|
||||||
pname = "bunshin";
|
pname = "bunshin";
|
||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
src = ./.;
|
src = ./.;
|
||||||
cargoLock.lockFile = ./Cargo.lock;
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
nativeBuildInputs = [ pkgs.pkg-config ];
|
nativeBuildInputs = [ pkgs.pkg-config ]
|
||||||
|
++ lib.optionals rns [ pkgs.cmake pkgs.ninja pkgs.gcc ];
|
||||||
buildInputs = [ pkgs.sqlite ];
|
buildInputs = [ pkgs.sqlite ];
|
||||||
|
buildFeatures = lib.optionals rns [ "rns" ];
|
||||||
|
env = lib.optionalAttrs rns rnsSourceEnv;
|
||||||
|
# ninja's setup hook claims buildPhase before the cargo hooks
|
||||||
|
# can, which would run ninja against no build.ninja instead of
|
||||||
|
# cargo; ninja here is only for build.rs to invoke through cmake.
|
||||||
|
dontUseNinjaBuild = rns;
|
||||||
|
dontUseNinjaCheck = rns;
|
||||||
|
dontUseNinjaInstall = rns;
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
packages.default = bunshin;
|
packages.default = bunshin { };
|
||||||
|
packages.rns = bunshin { rns = true; };
|
||||||
|
|
||||||
apps.default = flake-utils.lib.mkApp {
|
apps.default = flake-utils.lib.mkApp {
|
||||||
drv = bunshin;
|
drv = bunshin { };
|
||||||
name = "bunshin";
|
name = "bunshin";
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
packages = [ pkgs.cargo pkgs.rustc pkgs.rustfmt pkgs.clippy pkgs.pkg-config pkgs.gcc pkgs.sqlite ];
|
packages = with pkgs; [ cargo rustc rustfmt clippy pkg-config gcc sqlite cmake ninja ];
|
||||||
|
env = rnsSourceEnv;
|
||||||
};
|
};
|
||||||
}) // {
|
}) // {
|
||||||
nixosModules.default = { config, lib, pkgs, ... }:
|
nixosModules.default = { config, lib, pkgs, ... }:
|
||||||
|
|
@ -44,7 +108,10 @@
|
||||||
package = mkOption {
|
package = mkOption {
|
||||||
type = types.package;
|
type = types.package;
|
||||||
default = self.packages.${pkgs.system}.default;
|
default = self.packages.${pkgs.system}.default;
|
||||||
description = "bunshin package to run.";
|
description = ''
|
||||||
|
bunshin package to run. Use `packages.rns` when the RNS
|
||||||
|
carrier is enabled: the default package is built without it.
|
||||||
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
host = mkOption {
|
host = mkOption {
|
||||||
|
|
@ -152,6 +219,72 @@
|
||||||
default = false;
|
default = false;
|
||||||
description = "Open the configured TCP port in the firewall.";
|
description = "Open the configured TCP port in the firewall.";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
rns = {
|
||||||
|
enable = mkEnableOption "the RNS carrier alongside TCP (needs an rns-built package)";
|
||||||
|
|
||||||
|
keyFile = mkOption {
|
||||||
|
type = types.path;
|
||||||
|
description = ''
|
||||||
|
Path to the server's Reticulum identity (64 raw bytes,
|
||||||
|
x25519 || ed25519 private halves, generated with
|
||||||
|
`bunshin rns-keygen`). RNS writes the private key
|
||||||
|
unencrypted, so protect the file like any long-term key.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
maxEnvelope = mkOption {
|
||||||
|
type = types.ints.positive;
|
||||||
|
default = 32768;
|
||||||
|
description = "Maximum accepted envelope size over RNS, in bytes (RNS.md sec 3).";
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchBudget = mkOption {
|
||||||
|
type = types.ints.positive;
|
||||||
|
default = 32768;
|
||||||
|
description = "Bytes one FETCH response may total over RNS.";
|
||||||
|
};
|
||||||
|
|
||||||
|
maxLinks = mkOption {
|
||||||
|
type = types.ints.positive;
|
||||||
|
default = 100;
|
||||||
|
description = "Maximum concurrent Reticulum links; further links are refused.";
|
||||||
|
};
|
||||||
|
|
||||||
|
rateLinkRequests = mkOption {
|
||||||
|
type = types.ints.positive;
|
||||||
|
default = 60;
|
||||||
|
description = "Max requests per minute, per link.";
|
||||||
|
};
|
||||||
|
|
||||||
|
rateLinkBytes = mkOption {
|
||||||
|
type = types.ints.positive;
|
||||||
|
default = 1048576;
|
||||||
|
description = "Max request bytes per minute, per link.";
|
||||||
|
};
|
||||||
|
|
||||||
|
udpListenPort = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 4242;
|
||||||
|
description = ''
|
||||||
|
UDP port the Reticulum interface listens on. RNS reaches
|
||||||
|
the mesh through a configured interface rather than a
|
||||||
|
listening TCP port; only this UDP port needs a firewall
|
||||||
|
opening.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
udpForward = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Optional host[:port] the interface forwards every packet
|
||||||
|
to. Without it, replies go to the source address of the
|
||||||
|
last datagram received, which suits a listen-only
|
||||||
|
point-to-point setup.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
config = mkIf cfg.enable {
|
||||||
|
|
@ -186,6 +319,20 @@
|
||||||
--rate-sends ${toString cfg.rateSends}
|
--rate-sends ${toString cfg.rateSends}
|
||||||
--rate-tokens ${toString cfg.rateTokens}
|
--rate-tokens ${toString cfg.rateTokens}
|
||||||
)
|
)
|
||||||
|
${lib.optionalString cfg.rns.enable ''
|
||||||
|
args+=(
|
||||||
|
--rns
|
||||||
|
--rns-key ${cfg.rns.keyFile}
|
||||||
|
--rns-max-envelope ${toString cfg.rns.maxEnvelope}
|
||||||
|
--rns-fetch-budget ${toString cfg.rns.fetchBudget}
|
||||||
|
--rns-max-links ${toString cfg.rns.maxLinks}
|
||||||
|
--rns-rate-link-requests ${toString cfg.rns.rateLinkRequests}
|
||||||
|
--rns-rate-link-bytes ${toString cfg.rns.rateLinkBytes}
|
||||||
|
--rns-udp 0.0.0.0:${toString cfg.rns.udpListenPort}
|
||||||
|
)
|
||||||
|
''}
|
||||||
|
${lib.optionalString (cfg.rns.enable && cfg.rns.udpForward != null)
|
||||||
|
''args+=(--rns-udp-forward ${lib.escapeShellArg cfg.rns.udpForward})''}
|
||||||
${lib.optionalString (cfg.inviteToken != null)
|
${lib.optionalString (cfg.inviteToken != null)
|
||||||
''args+=(--invite-token ${lib.escapeShellArg cfg.inviteToken})''}
|
''args+=(--invite-token ${lib.escapeShellArg cfg.inviteToken})''}
|
||||||
${lib.optionalString (cfg.inviteTokenFile != null)
|
${lib.optionalString (cfg.inviteTokenFile != null)
|
||||||
|
|
@ -201,6 +348,9 @@
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# RNS needs no TCP port; only its UDP interface may be opened.
|
||||||
|
networking.firewall.allowedUDPPorts =
|
||||||
|
mkIf (cfg.rns.enable && cfg.openFirewall) [ cfg.rns.udpListenPort ];
|
||||||
networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ];
|
networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
178
shim/smolmail_rns.cpp
Normal file
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
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Multi-part SHA-256; `message_id` spelled generally for the RNS bind values.
|
||||||
|
#[cfg(any(test, feature = "rns"))]
|
||||||
|
pub fn sha256(parts: &[&[u8]]) -> [u8; 32] {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
for part in parts {
|
||||||
|
hasher.update(part);
|
||||||
|
}
|
||||||
|
hasher.finalize().into()
|
||||||
|
}
|
||||||
|
|
||||||
/// Generates the server's static X25519 keypair (transport identity, distinct
|
/// Generates the server's static X25519 keypair (transport identity, distinct
|
||||||
/// from any user's Ed25519 identity). Returns (private, public) raw bytes.
|
/// from any user's Ed25519 identity). Returns (private, public) raw bytes.
|
||||||
pub fn generate_static_key() -> ([u8; 32], [u8; 32]) {
|
pub fn generate_static_key() -> ([u8; 32], [u8; 32]) {
|
||||||
|
|
|
||||||
156
src/main.rs
156
src/main.rs
|
|
@ -1,9 +1,12 @@
|
||||||
//! Smol Mail server.
|
//! Smol Mail server.
|
||||||
|
|
||||||
|
mod bind;
|
||||||
mod channel;
|
mod channel;
|
||||||
mod crypto;
|
mod crypto;
|
||||||
mod proto;
|
mod proto;
|
||||||
mod ratelimit;
|
mod ratelimit;
|
||||||
|
#[cfg(feature = "rns")]
|
||||||
|
mod rns;
|
||||||
mod server;
|
mod server;
|
||||||
mod session;
|
mod session;
|
||||||
mod store;
|
mod store;
|
||||||
|
|
@ -65,7 +68,45 @@ enum Command {
|
||||||
rate_sends: u32,
|
rate_sends: u32,
|
||||||
#[arg(long = "rate-tokens", default_value_t = 30)]
|
#[arg(long = "rate-tokens", default_value_t = 30)]
|
||||||
rate_tokens: u32,
|
rate_tokens: u32,
|
||||||
|
#[cfg(feature = "rns")]
|
||||||
|
#[command(flatten)]
|
||||||
|
rns: RnsServeArgs,
|
||||||
},
|
},
|
||||||
|
#[cfg(feature = "rns")]
|
||||||
|
/// Generate the server's Reticulum identity
|
||||||
|
RnsKeygen {
|
||||||
|
#[arg(long, default_value = "server.rns.key")]
|
||||||
|
key: String,
|
||||||
|
#[arg(long)]
|
||||||
|
force: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RNS carrier flags (RNS.md sec 7.4), only present with the rns feature.
|
||||||
|
#[cfg(feature = "rns")]
|
||||||
|
#[derive(clap::Args)]
|
||||||
|
struct RnsServeArgs {
|
||||||
|
/// Serve the RNS carrier alongside TCP
|
||||||
|
#[arg(long = "rns")]
|
||||||
|
enabled: bool,
|
||||||
|
#[arg(long = "rns-key", default_value = "server.rns.key")]
|
||||||
|
rns_key: String,
|
||||||
|
#[arg(long = "rns-max-envelope", default_value_t = 32 << 10)]
|
||||||
|
rns_max_envelope: usize,
|
||||||
|
#[arg(long = "rns-fetch-budget", default_value_t = 32 << 10)]
|
||||||
|
rns_fetch_budget: usize,
|
||||||
|
#[arg(long = "rns-max-links", default_value_t = 100)]
|
||||||
|
rns_max_links: usize,
|
||||||
|
#[arg(long = "rns-rate-link-requests", default_value_t = 60)]
|
||||||
|
rns_rate_link_requests: u32,
|
||||||
|
#[arg(long = "rns-rate-link-bytes", default_value_t = 1 << 20)]
|
||||||
|
rns_rate_link_bytes: u64,
|
||||||
|
/// UDP interface to listen on, host[:port]
|
||||||
|
#[arg(long = "rns-udp", default_value = "127.0.0.1:4242")]
|
||||||
|
rns_udp: String,
|
||||||
|
/// Optional UDP forward target, host[:port]
|
||||||
|
#[arg(long = "rns-udp-forward")]
|
||||||
|
rns_udp_forward: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
|
|
@ -82,6 +123,9 @@ fn main() -> anyhow::Result<()> {
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Keygen { key, force } => cmd_keygen(&key, force),
|
Command::Keygen { key, force } => cmd_keygen(&key, force),
|
||||||
|
#[cfg(feature = "rns")]
|
||||||
|
Command::RnsKeygen { key, force } => cmd_rns_keygen(&key, force),
|
||||||
|
#[cfg(not(feature = "rns"))]
|
||||||
Command::Serve {
|
Command::Serve {
|
||||||
key,
|
key,
|
||||||
db,
|
db,
|
||||||
|
|
@ -113,6 +157,92 @@ fn main() -> anyhow::Result<()> {
|
||||||
rate_sends,
|
rate_sends,
|
||||||
rate_tokens,
|
rate_tokens,
|
||||||
}),
|
}),
|
||||||
|
#[cfg(feature = "rns")]
|
||||||
|
Command::Serve {
|
||||||
|
key,
|
||||||
|
db,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
max_envelope,
|
||||||
|
quota,
|
||||||
|
requests_quota,
|
||||||
|
retention_days,
|
||||||
|
requests_retention_days,
|
||||||
|
max_tokens,
|
||||||
|
invite_token,
|
||||||
|
rate_connections,
|
||||||
|
rate_sends,
|
||||||
|
rate_tokens,
|
||||||
|
rns,
|
||||||
|
} => {
|
||||||
|
// One process serves both carriers (RNS.md sec 7.4): shared
|
||||||
|
// Store, shared config, single purge loop in server::run.
|
||||||
|
if rns.enabled {
|
||||||
|
let (listen_host, listen_port) = split_host_port(&rns.rns_udp, 4242)?;
|
||||||
|
let (forward_host, forward_port) = match &rns.rns_udp_forward {
|
||||||
|
Some(addr) => {
|
||||||
|
let (host, port) = split_host_port(addr, 4242)?;
|
||||||
|
(Some(host), port)
|
||||||
|
}
|
||||||
|
None => (None, 4242),
|
||||||
|
};
|
||||||
|
let storage_dir = std::path::Path::new(&db)
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.join("rns-storage"))
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("rns-storage"));
|
||||||
|
std::fs::create_dir_all(&storage_dir)?;
|
||||||
|
let dest = rns::start(rns::RnsArgs {
|
||||||
|
key_path: rns.rns_key,
|
||||||
|
db_path: db.clone(),
|
||||||
|
storage_dir: storage_dir.to_string_lossy().into_owned(),
|
||||||
|
max_envelope: rns.rns_max_envelope,
|
||||||
|
fetch_budget: rns.rns_fetch_budget,
|
||||||
|
max_links: rns.rns_max_links,
|
||||||
|
rate_link_requests: rns.rns_rate_link_requests,
|
||||||
|
rate_link_bytes: rns.rns_rate_link_bytes,
|
||||||
|
udp_listen_host: listen_host,
|
||||||
|
udp_listen_port: listen_port,
|
||||||
|
udp_forward_host: forward_host,
|
||||||
|
udp_forward_port: forward_port,
|
||||||
|
max_tokens,
|
||||||
|
main_quota: quota,
|
||||||
|
requests_quota,
|
||||||
|
invite_token: invite_token.clone().map(String::into_bytes),
|
||||||
|
})?;
|
||||||
|
log::info!("RNS carrier: smolmail.server destination {dest}");
|
||||||
|
}
|
||||||
|
server::run(server::ServeArgs {
|
||||||
|
key_path: key,
|
||||||
|
db_path: db,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
max_envelope,
|
||||||
|
quota,
|
||||||
|
requests_quota,
|
||||||
|
retention_days,
|
||||||
|
requests_retention_days,
|
||||||
|
max_tokens,
|
||||||
|
invite_token,
|
||||||
|
rate_connections,
|
||||||
|
rate_sends,
|
||||||
|
rate_tokens,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits host[:port], keeping `default_port` when none is given.
|
||||||
|
#[cfg(feature = "rns")]
|
||||||
|
fn split_host_port(addr: &str, default_port: u16) -> anyhow::Result<(String, u16)> {
|
||||||
|
match addr.rsplit_once(':') {
|
||||||
|
Some((host, port)) => {
|
||||||
|
anyhow::ensure!(
|
||||||
|
!host.contains(':') || host.starts_with('['),
|
||||||
|
"unsupported address {addr}"
|
||||||
|
);
|
||||||
|
Ok((host.to_string(), port.parse().unwrap_or(default_port)))
|
||||||
|
}
|
||||||
|
None => Ok((addr.to_string(), default_port)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,3 +267,29 @@ fn cmd_keygen(key_path: &str, force: bool) -> anyhow::Result<()> {
|
||||||
println!("Publish the public key through a trusted channel; clients pin it (SPEC.md sec 4).");
|
println!("Publish the public key through a trusted channel; clients pin it (SPEC.md sec 4).");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// RNS has no server static key to pin: the destination hash is the address
|
||||||
|
/// and the pin (upstream spec 13.4), so it is what gets published.
|
||||||
|
#[cfg(feature = "rns")]
|
||||||
|
fn cmd_rns_keygen(key_path: &str, force: bool) -> anyhow::Result<()> {
|
||||||
|
if std::path::Path::new(key_path).exists() && !force {
|
||||||
|
anyhow::bail!("{key_path} exists; refusing to overwrite (use --force)");
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = rns::generate_identity();
|
||||||
|
let dest = data_encoding::HEXLOWER.encode(&rns::destination_hash(&key)?);
|
||||||
|
|
||||||
|
// Written 0600 before any bytes land, so the key is never briefly readable.
|
||||||
|
let mut opts = std::fs::OpenOptions::new();
|
||||||
|
opts.write(true).create(true).truncate(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
opts.mode(0o600);
|
||||||
|
let mut file = opts.open(key_path)?;
|
||||||
|
file.write_all(&key)?;
|
||||||
|
|
||||||
|
println!("identity: {key_path}");
|
||||||
|
println!("destination: {dest}");
|
||||||
|
println!();
|
||||||
|
println!("smol+rns://<user>@{dest} is the server's mesh address (RNS.md sec 1).");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,11 @@ pub const MAX_FRAME: usize = 1 << 20; // application frame ceiling
|
||||||
pub const NOISE_MAX: usize = 65535; // Noise message ceiling
|
pub const NOISE_MAX: usize = 65535; // Noise message ceiling
|
||||||
pub const NOISE_PAYLOAD: usize = NOISE_MAX - 16; // minus the AEAD tag
|
pub const NOISE_PAYLOAD: usize = NOISE_MAX - 16; // minus the AEAD tag
|
||||||
pub const FETCH_BUDGET: usize = 512 * 1024; // must stay under MAX_FRAME
|
pub const FETCH_BUDGET: usize = 512 * 1024; // must stay under MAX_FRAME
|
||||||
|
// AUTH with a maximal username and a full token set, op byte included
|
||||||
|
// (RNS.md sec 3): len 1 + username 63 + identity 32 + sig 64 + sync 1 +
|
||||||
|
// count 2; callers add 32 per token.
|
||||||
|
#[cfg(feature = "rns")]
|
||||||
|
pub const AUTH_FULL_TOKENS: usize = 164;
|
||||||
pub const IDLE_TIMEOUT_SECS: u64 = 120;
|
pub const IDLE_TIMEOUT_SECS: u64 = 120;
|
||||||
pub const PURGE_INTERVAL_SECS: u64 = 60;
|
pub const PURGE_INTERVAL_SECS: u64 = 60;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,58 @@ impl RateLimiter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fixed-window byte counter for per-link transfer budgets: `RateLimiter`
|
||||||
|
/// counts events, this counts bytes, so it gets its own small type.
|
||||||
|
#[cfg(feature = "rns")]
|
||||||
|
pub struct ByteRateLimiter {
|
||||||
|
limit: u64,
|
||||||
|
window: Duration,
|
||||||
|
hits: Mutex<HashMap<String, ByteWindow>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "rns")]
|
||||||
|
struct ByteWindow {
|
||||||
|
start: Instant,
|
||||||
|
bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "rns")]
|
||||||
|
impl ByteRateLimiter {
|
||||||
|
pub fn new(limit: u64) -> Self {
|
||||||
|
ByteRateLimiter {
|
||||||
|
limit,
|
||||||
|
window: Duration::from_secs(60),
|
||||||
|
hits: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records `bytes` against `key` if the window still has room for them.
|
||||||
|
pub fn allow(&self, key: &str, bytes: usize) -> bool {
|
||||||
|
if self.limit == 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut hits = self.hits.lock().unwrap();
|
||||||
|
let entry = hits.entry(key.to_string()).or_insert(ByteWindow {
|
||||||
|
start: now,
|
||||||
|
bytes: 0,
|
||||||
|
});
|
||||||
|
if now.duration_since(entry.start) >= self.window {
|
||||||
|
entry.start = now;
|
||||||
|
entry.bytes = 0;
|
||||||
|
}
|
||||||
|
if entry.bytes + bytes as u64 > self.limit {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
entry.bytes += bytes as u64;
|
||||||
|
if hits.len() > 4096 {
|
||||||
|
let window = self.window;
|
||||||
|
hits.retain(|_, w| now.duration_since(w.start) < window);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
312
src/rns.rs
Normal file
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::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::bind::TransportBindValues;
|
||||||
use crate::channel::{handshake, Channel};
|
use crate::channel::{handshake, Channel};
|
||||||
use crate::crypto::{b32, derive_public};
|
use crate::crypto::{b32, derive_public};
|
||||||
use crate::proto::{IDLE_TIMEOUT_SECS, KEY_LEN, MALFORMED, PURGE_INTERVAL_SECS};
|
use crate::proto::{FETCH_BUDGET, IDLE_TIMEOUT_SECS, KEY_LEN, MALFORMED, PURGE_INTERVAL_SECS};
|
||||||
use crate::ratelimit::RateLimiter;
|
use crate::ratelimit::RateLimiter;
|
||||||
use crate::session::{ServerConfig, Session};
|
use crate::session::{ServerConfig, Session};
|
||||||
use crate::store::Store;
|
use crate::store::Store;
|
||||||
|
|
@ -29,8 +30,8 @@ pub struct ServeArgs {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(args: ServeArgs) -> anyhow::Result<()> {
|
pub fn run(args: ServeArgs) -> anyhow::Result<()> {
|
||||||
let static_key =
|
let static_key = std::fs::read(&args.key_path)
|
||||||
std::fs::read(&args.key_path).map_err(|e| anyhow::anyhow!("cannot read server key: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("cannot read server key: {e}"))?;
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
static_key.len() == KEY_LEN,
|
static_key.len() == KEY_LEN,
|
||||||
"server key must be {KEY_LEN} raw bytes, got {}",
|
"server key must be {KEY_LEN} raw bytes, got {}",
|
||||||
|
|
@ -42,11 +43,11 @@ pub fn run(args: ServeArgs) -> anyhow::Result<()> {
|
||||||
|
|
||||||
let config = Arc::new(ServerConfig {
|
let config = Arc::new(ServerConfig {
|
||||||
max_envelope: args.max_envelope,
|
max_envelope: args.max_envelope,
|
||||||
|
fetch_budget: FETCH_BUDGET,
|
||||||
main_quota: args.quota,
|
main_quota: args.quota,
|
||||||
requests_quota: args.requests_quota,
|
requests_quota: args.requests_quota,
|
||||||
max_tokens: args.max_tokens,
|
max_tokens: args.max_tokens,
|
||||||
invite_token: args.invite_token.map(String::into_bytes),
|
invite_token: args.invite_token.map(String::into_bytes),
|
||||||
server_static,
|
|
||||||
conn_limiter: RateLimiter::new(args.rate_connections),
|
conn_limiter: RateLimiter::new(args.rate_connections),
|
||||||
send_limiter: RateLimiter::new(args.rate_sends),
|
send_limiter: RateLimiter::new(args.rate_sends),
|
||||||
token_limiter: RateLimiter::new(args.rate_tokens),
|
token_limiter: RateLimiter::new(args.rate_tokens),
|
||||||
|
|
@ -55,7 +56,9 @@ pub fn run(args: ServeArgs) -> anyhow::Result<()> {
|
||||||
let main_retention_secs = args.retention_days * 86400;
|
let main_retention_secs = args.retention_days * 86400;
|
||||||
let requests_retention_secs = args.requests_retention_days * 86400;
|
let requests_retention_secs = args.requests_retention_days * 86400;
|
||||||
let purge_db_path = args.db_path.clone();
|
let purge_db_path = args.db_path.clone();
|
||||||
std::thread::spawn(move || purge_loop(purge_db_path, main_retention_secs, requests_retention_secs));
|
std::thread::spawn(move || {
|
||||||
|
purge_loop(purge_db_path, main_retention_secs, requests_retention_secs)
|
||||||
|
});
|
||||||
|
|
||||||
let listener = std::net::TcpListener::bind((args.host.as_str(), args.port))?;
|
let listener = std::net::TcpListener::bind((args.host.as_str(), args.port))?;
|
||||||
log::info!("listening on {}:{}", args.host, args.port);
|
log::info!("listening on {}:{}", args.host, args.port);
|
||||||
|
|
@ -80,7 +83,7 @@ pub fn run(args: ServeArgs) -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = Arc::clone(&config);
|
let config = Arc::clone(&config);
|
||||||
let static_key = static_key.clone();
|
let static_key = key_array;
|
||||||
let db_path = args.db_path.clone();
|
let db_path = args.db_path.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
if let Err(e) = handle_connection(stream, &config, &static_key, &db_path, &peer_ip) {
|
if let Err(e) = handle_connection(stream, &config, &static_key, &db_path, &peer_ip) {
|
||||||
|
|
@ -94,7 +97,7 @@ pub fn run(args: ServeArgs) -> anyhow::Result<()> {
|
||||||
fn handle_connection(
|
fn handle_connection(
|
||||||
mut stream: TcpStream,
|
mut stream: TcpStream,
|
||||||
config: &ServerConfig,
|
config: &ServerConfig,
|
||||||
static_key: &[u8],
|
static_key: &[u8; KEY_LEN],
|
||||||
db_path: &str,
|
db_path: &str,
|
||||||
peer_ip: &str,
|
peer_ip: &str,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
|
|
@ -109,7 +112,9 @@ fn handle_connection(
|
||||||
};
|
};
|
||||||
|
|
||||||
let store = Store::open(db_path)?;
|
let store = Store::open(db_path)?;
|
||||||
let mut session = Session::new(config, store, peer_ip.to_string(), handshake_hash);
|
let server_static = derive_public(static_key);
|
||||||
|
let bind = TransportBindValues::tcp(&handshake_hash, &server_static)?;
|
||||||
|
let mut session = Session::new(config, store, peer_ip.to_string(), bind);
|
||||||
let mut channel = Channel::new(stream, transport);
|
let mut channel = Channel::new(stream, transport);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
|
|
||||||
195
src/session.rs
195
src/session.rs
|
|
@ -1,23 +1,24 @@
|
||||||
//! Per-connection dispatch and the six wire operations.
|
//! Per-connection dispatch and the six wire operations.
|
||||||
|
|
||||||
|
use crate::bind::TransportBindValues;
|
||||||
use crate::crypto::{b32, ct_eq, hmac_sha256, message_id, verify};
|
use crate::crypto::{b32, ct_eq, hmac_sha256, message_id, verify};
|
||||||
use crate::proto::{
|
use crate::proto::{
|
||||||
valid_username, ProtocolError, Reader, AUTH_FAILED, AUTH_REQUIRED, BAD_VERSION, CERT_LEN,
|
valid_username, ProtocolError, Reader, AUTH_FAILED, AUTH_REQUIRED, BAD_VERSION, CERT_LEN,
|
||||||
ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, FETCH_BUDGET, ID_LEN, KEY_LEN, LABEL_AUTH,
|
ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, ID_LEN, KEY_LEN, LABEL_AUTH, LABEL_MAC,
|
||||||
LABEL_MAC, LABEL_REGISTER, LABEL_ROTATE, MAC_LEN, MALFORMED, MAX_CHAIN, NOT_PERMITTED, OK,
|
LABEL_REGISTER, LABEL_ROTATE, MAC_LEN, MALFORMED, MAX_CHAIN, NOT_PERMITTED, OK, OP_AUTH,
|
||||||
OP_AUTH, OP_DELETE, OP_FETCH, OP_REGISTER, OP_RESOLVE, OP_SEND, QUOTA_EXCEEDED, RATE_LIMITED,
|
OP_DELETE, OP_FETCH, OP_REGISTER, OP_RESOLVE, OP_SEND, QUOTA_EXCEEDED, RATE_LIMITED, TOKEN_LEN,
|
||||||
TOKEN_LEN, TOO_LARGE, UNKNOWN_USER,
|
TOO_LARGE, UNKNOWN_USER,
|
||||||
};
|
};
|
||||||
use crate::ratelimit::RateLimiter;
|
use crate::ratelimit::RateLimiter;
|
||||||
use crate::store::Store;
|
use crate::store::Store;
|
||||||
|
|
||||||
pub struct ServerConfig {
|
pub struct ServerConfig {
|
||||||
pub max_envelope: usize,
|
pub max_envelope: usize,
|
||||||
|
pub fetch_budget: usize,
|
||||||
pub main_quota: i64,
|
pub main_quota: i64,
|
||||||
pub requests_quota: i64,
|
pub requests_quota: i64,
|
||||||
pub max_tokens: u16,
|
pub max_tokens: u16,
|
||||||
pub invite_token: Option<Vec<u8>>,
|
pub invite_token: Option<Vec<u8>>,
|
||||||
pub server_static: [u8; KEY_LEN],
|
|
||||||
pub conn_limiter: RateLimiter,
|
pub conn_limiter: RateLimiter,
|
||||||
pub send_limiter: RateLimiter,
|
pub send_limiter: RateLimiter,
|
||||||
pub token_limiter: RateLimiter,
|
pub token_limiter: RateLimiter,
|
||||||
|
|
@ -47,17 +48,22 @@ pub struct Session<'a> {
|
||||||
config: &'a ServerConfig,
|
config: &'a ServerConfig,
|
||||||
store: Store,
|
store: Store,
|
||||||
peer_ip: String,
|
peer_ip: String,
|
||||||
handshake_hash: Vec<u8>,
|
bind: TransportBindValues,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Session<'a> {
|
impl<'a> Session<'a> {
|
||||||
pub fn new(config: &'a ServerConfig, store: Store, peer_ip: String, handshake_hash: Vec<u8>) -> Self {
|
pub fn new(
|
||||||
|
config: &'a ServerConfig,
|
||||||
|
store: Store,
|
||||||
|
peer_ip: String,
|
||||||
|
bind: TransportBindValues,
|
||||||
|
) -> Self {
|
||||||
Session {
|
Session {
|
||||||
config,
|
config,
|
||||||
store,
|
store,
|
||||||
peer_ip,
|
peer_ip,
|
||||||
handshake_hash,
|
bind,
|
||||||
username: None,
|
username: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -125,7 +131,7 @@ impl<'a> Session<'a> {
|
||||||
return Ok((AUTH_FAILED, Vec::new()));
|
return Ok((AUTH_FAILED, Vec::new()));
|
||||||
}
|
}
|
||||||
let mut msg = LABEL_AUTH.to_vec();
|
let mut msg = LABEL_AUTH.to_vec();
|
||||||
msg.extend_from_slice(&self.handshake_hash);
|
msg.extend_from_slice(&self.bind.h);
|
||||||
if !verify(&identity, &signature, &msg) {
|
if !verify(&identity, &signature, &msg) {
|
||||||
return Ok((AUTH_FAILED, Vec::new()));
|
return Ok((AUTH_FAILED, Vec::new()));
|
||||||
}
|
}
|
||||||
|
|
@ -231,9 +237,12 @@ impl<'a> Session<'a> {
|
||||||
r.done()?;
|
r.done()?;
|
||||||
let username = self.username.as_ref().expect("AUTH_REQUIRED gate above");
|
let username = self.username.as_ref().expect("AUTH_REQUIRED gate above");
|
||||||
let keys = self.store.keys_of(username)?;
|
let keys = self.store.keys_of(username)?;
|
||||||
let records = self
|
let records = self.store.pending(
|
||||||
.store
|
&keys,
|
||||||
.pending(&keys, after_received_at, &after_id, FETCH_BUDGET)?;
|
after_received_at,
|
||||||
|
&after_id,
|
||||||
|
self.config.fetch_budget,
|
||||||
|
)?;
|
||||||
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
out.extend_from_slice(&(records.len() as u16).to_be_bytes());
|
out.extend_from_slice(&(records.len() as u16).to_be_bytes());
|
||||||
|
|
@ -286,7 +295,7 @@ impl<'a> Session<'a> {
|
||||||
// (SPEC.md sec 6.1). Binding server_static stops the attestation from
|
// (SPEC.md sec 6.1). Binding server_static stops the attestation from
|
||||||
// being replayed against another server.
|
// being replayed against another server.
|
||||||
let mut pop_msg = LABEL_REGISTER.to_vec();
|
let mut pop_msg = LABEL_REGISTER.to_vec();
|
||||||
pop_msg.extend_from_slice(&self.config.server_static);
|
pop_msg.extend_from_slice(&self.bind.server_static);
|
||||||
pop_msg.extend_from_slice(username.as_bytes());
|
pop_msg.extend_from_slice(username.as_bytes());
|
||||||
pop_msg.extend_from_slice(&identity);
|
pop_msg.extend_from_slice(&identity);
|
||||||
if !verify(&identity, &signature, &pop_msg) {
|
if !verify(&identity, &signature, &pop_msg) {
|
||||||
|
|
@ -343,3 +352,163 @@ impl<'a> Session<'a> {
|
||||||
Ok((OK, Vec::new()))
|
Ok((OK, Vec::new()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::crypto::sha256;
|
||||||
|
use ed25519_dalek::{Signer, SigningKey};
|
||||||
|
|
||||||
|
fn test_config() -> ServerConfig {
|
||||||
|
ServerConfig {
|
||||||
|
max_envelope: 1024,
|
||||||
|
fetch_budget: 512,
|
||||||
|
main_quota: 1 << 20,
|
||||||
|
requests_quota: 1 << 20,
|
||||||
|
max_tokens: 16,
|
||||||
|
invite_token: None,
|
||||||
|
conn_limiter: RateLimiter::new(0),
|
||||||
|
send_limiter: RateLimiter::new(0),
|
||||||
|
token_limiter: RateLimiter::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_store(name: &str) -> Store {
|
||||||
|
let path =
|
||||||
|
std::env::temp_dir().join(format!("bunshin-session-{name}-{}.db", std::process::id()));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
Store::open(path.to_str().unwrap()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn str_field(s: &str) -> Vec<u8> {
|
||||||
|
let mut out = vec![s.len() as u8];
|
||||||
|
out.extend_from_slice(s.as_bytes());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_body(username: &str, identity: &[u8], signature: &[u8]) -> Vec<u8> {
|
||||||
|
let mut body = str_field(username);
|
||||||
|
body.extend_from_slice(identity);
|
||||||
|
body.extend_from_slice(signature);
|
||||||
|
body.push(0); // invite token, none configured
|
||||||
|
body.push(0); // cert, plain registration
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auth_body(username: &str, identity: &[u8], signature: &[u8]) -> Vec<u8> {
|
||||||
|
let mut body = str_field(username);
|
||||||
|
body.extend_from_slice(identity);
|
||||||
|
body.extend_from_slice(signature);
|
||||||
|
body.push(0); // sync = 0, stored tokens untouched
|
||||||
|
body.extend_from_slice(&0u16.to_be_bytes());
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rns_bind_signatures_accepted_and_tcp_bind_signatures_rejected() {
|
||||||
|
let config = test_config();
|
||||||
|
let key = SigningKey::from_bytes(&[3u8; 32]);
|
||||||
|
let identity = key.verifying_key().as_bytes().to_vec();
|
||||||
|
|
||||||
|
let bind = TransportBindValues::rns(&[0x11; 16], &[0x22; 16]);
|
||||||
|
|
||||||
|
let mut pop = LABEL_REGISTER.to_vec();
|
||||||
|
pop.extend_from_slice(&bind.server_static);
|
||||||
|
pop.extend_from_slice(b"alice");
|
||||||
|
pop.extend_from_slice(&identity);
|
||||||
|
let good_register = register_body("alice", &identity, &key.sign(&pop).to_bytes());
|
||||||
|
|
||||||
|
// A TCP client would sign over the real X25519 static key instead.
|
||||||
|
let tcp_static = crate::crypto::derive_public(&[9u8; 32]);
|
||||||
|
let mut tcp_pop = LABEL_REGISTER.to_vec();
|
||||||
|
tcp_pop.extend_from_slice(&tcp_static);
|
||||||
|
tcp_pop.extend_from_slice(b"alice");
|
||||||
|
tcp_pop.extend_from_slice(&identity);
|
||||||
|
let tcp_register = register_body("alice", &identity, &key.sign(&tcp_pop).to_bytes());
|
||||||
|
|
||||||
|
let mut auth_msg = LABEL_AUTH.to_vec();
|
||||||
|
auth_msg.extend_from_slice(&bind.h);
|
||||||
|
let good_auth = auth_body("alice", &identity, &key.sign(&auth_msg).to_bytes());
|
||||||
|
|
||||||
|
// A Noise client would sign over the handshake hash instead.
|
||||||
|
let handshake_hash = sha256(&[b"handshake"]);
|
||||||
|
let mut tcp_auth = LABEL_AUTH.to_vec();
|
||||||
|
tcp_auth.extend_from_slice(&handshake_hash);
|
||||||
|
let tcp_auth = auth_body("alice", &identity, &key.sign(&tcp_auth).to_bytes());
|
||||||
|
|
||||||
|
let mut session = Session::new(&config, temp_store("rns-bind"), "rns".into(), bind);
|
||||||
|
assert_eq!(session.dispatch(OP_REGISTER, &good_register).0, OK);
|
||||||
|
assert_eq!(session.dispatch(OP_AUTH, &good_auth).0, OK);
|
||||||
|
|
||||||
|
let mut session = Session::new(
|
||||||
|
&config,
|
||||||
|
temp_store("rns-bind-reject"),
|
||||||
|
"rns".into(),
|
||||||
|
TransportBindValues::rns(&[0x11; 16], &[0x22; 16]),
|
||||||
|
);
|
||||||
|
assert_eq!(session.dispatch(OP_REGISTER, &good_register).0, OK);
|
||||||
|
assert_eq!(session.dispatch(OP_AUTH, &tcp_auth).0, AUTH_FAILED);
|
||||||
|
|
||||||
|
let mut session = Session::new(
|
||||||
|
&config,
|
||||||
|
temp_store("rns-bind-tcp-register"),
|
||||||
|
"rns".into(),
|
||||||
|
TransportBindValues::rns(&[0x11; 16], &[0x22; 16]),
|
||||||
|
);
|
||||||
|
assert_eq!(session.dispatch(OP_REGISTER, &tcp_register).0, AUTH_FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tcp_bind_signatures_accepted_and_rns_bind_signatures_rejected() {
|
||||||
|
let config = test_config();
|
||||||
|
let key = SigningKey::from_bytes(&[4u8; 32]);
|
||||||
|
let identity = key.verifying_key().as_bytes().to_vec();
|
||||||
|
|
||||||
|
let server_static = crate::crypto::derive_public(&[9u8; 32]);
|
||||||
|
let handshake_hash = sha256(&[b"handshake"]);
|
||||||
|
let bind = TransportBindValues::tcp(&handshake_hash, &server_static).unwrap();
|
||||||
|
|
||||||
|
let mut pop = LABEL_REGISTER.to_vec();
|
||||||
|
pop.extend_from_slice(&server_static);
|
||||||
|
pop.extend_from_slice(b"alice");
|
||||||
|
pop.extend_from_slice(&identity);
|
||||||
|
let good_register = register_body("alice", &identity, &key.sign(&pop).to_bytes());
|
||||||
|
|
||||||
|
let mut auth_msg = LABEL_AUTH.to_vec();
|
||||||
|
auth_msg.extend_from_slice(&handshake_hash);
|
||||||
|
let good_auth = auth_body("alice", &identity, &key.sign(&auth_msg).to_bytes());
|
||||||
|
|
||||||
|
// An RNS client would sign over the derived bind values instead.
|
||||||
|
let rns_bind = TransportBindValues::rns(&[0x11; 16], &[0x22; 16]);
|
||||||
|
let mut rns_pop = LABEL_REGISTER.to_vec();
|
||||||
|
rns_pop.extend_from_slice(&rns_bind.server_static);
|
||||||
|
rns_pop.extend_from_slice(b"alice");
|
||||||
|
rns_pop.extend_from_slice(&identity);
|
||||||
|
let rns_register = register_body("alice", &identity, &key.sign(&rns_pop).to_bytes());
|
||||||
|
|
||||||
|
let mut rns_auth = LABEL_AUTH.to_vec();
|
||||||
|
rns_auth.extend_from_slice(&rns_bind.h);
|
||||||
|
let rns_auth = auth_body("alice", &identity, &key.sign(&rns_auth).to_bytes());
|
||||||
|
|
||||||
|
let mut session = Session::new(&config, temp_store("tcp-bind"), "tcp".into(), bind);
|
||||||
|
assert_eq!(session.dispatch(OP_REGISTER, &good_register).0, OK);
|
||||||
|
assert_eq!(session.dispatch(OP_AUTH, &good_auth).0, OK);
|
||||||
|
|
||||||
|
let mut session = Session::new(
|
||||||
|
&config,
|
||||||
|
temp_store("tcp-bind-reject"),
|
||||||
|
"tcp".into(),
|
||||||
|
TransportBindValues::tcp(&handshake_hash, &server_static).unwrap(),
|
||||||
|
);
|
||||||
|
assert_eq!(session.dispatch(OP_REGISTER, &good_register).0, OK);
|
||||||
|
assert_eq!(session.dispatch(OP_AUTH, &rns_auth).0, AUTH_FAILED);
|
||||||
|
|
||||||
|
let mut session = Session::new(
|
||||||
|
&config,
|
||||||
|
temp_store("tcp-bind-rns-register"),
|
||||||
|
"tcp".into(),
|
||||||
|
TransportBindValues::tcp(&handshake_hash, &server_static).unwrap(),
|
||||||
|
);
|
||||||
|
assert_eq!(session.dispatch(OP_REGISTER, &rns_register).0, AUTH_FAILED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue