From 994071243fc80990cf09e820b671006e9bd31c76 Mon Sep 17 00:00:00 2001 From: Gab <24553253+gabrix73@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:04:30 +0200 Subject: Separate active YAMN code from Katzenpost PoC --- nym/Cargo.toml | 14 +++++++ nym/README.md | 22 ++++++++++ nym/src/main.rs | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 nym/Cargo.toml create mode 100644 nym/README.md create mode 100644 nym/src/main.rs (limited to 'nym') diff --git a/nym/Cargo.toml b/nym/Cargo.toml new file mode 100644 index 0000000..dfe2666 --- /dev/null +++ b/nym/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "yamn-nym-submit" +version = "0.1.0" +edition = "2021" +description = "Send opaque YAMN envelopes through the Nym mixnet" +license = "AGPL-3.0-or-later" + +[dependencies] +anyhow = "1" +base64 = "0.22" +nym-sdk = "1.21.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } diff --git a/nym/README.md b/nym/README.md new file mode 100644 index 0000000..0cd4f10 --- /dev/null +++ b/nym/README.md @@ -0,0 +1,22 @@ +# YAMN Nym sender + +This binary sends an already encrypted YAMN envelope through the Nym mixnet +using the Rust SDK. It is intentionally send-only: it does not receive +messages, expose replies, fetch remailer data, or perform YAMN encoding. + +Configuration is supplied through the environment: + +- `YAMN_NYM_RECIPIENT`: fixed Nym address of the YAMN ingress service. +- `YAMN_ENTRY_ADDRESS`: exact allowlisted YAMN entry address used by the ingress. +- `YAMN_NYM_STORAGE`: persistent client storage directory. Defaults to + `/var/lib/yamnweb/nym-client`. + +The request is one JSON object on standard input: + +```json +{"entry_address":"yamn@example.org","payload":""} +``` + +The binary never logs the request or its contents. The Nym recipient must be +an ingress service that knows how to hand the envelope to YAMN. A normal SMTP +address is not a valid replacement for `YAMN_NYM_RECIPIENT`. diff --git a/nym/src/main.rs b/nym/src/main.rs new file mode 100644 index 0000000..b906fc6 --- /dev/null +++ b/nym/src/main.rs @@ -0,0 +1,127 @@ +use anyhow::{bail, Context, Result}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use nym_sdk::mixnet::{MixnetClientBuilder, MixnetMessageSender, Recipient, StoragePaths}; +use serde::{Deserialize, Serialize}; +use std::env; +use std::io::{self, Read}; +use std::path::PathBuf; +use std::time::Duration; + +const MAX_PAYLOAD_BYTES: usize = 64 * 1024; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(120); +const SEND_TIMEOUT: Duration = Duration::from_secs(60); +// send_plain_message queues the message for the background mixnet task. Keep +// the client alive long enough for that task to flush the message before the +// process disconnects. +const FLUSH_GRACE: Duration = Duration::from_secs(180); + +#[derive(Debug, Deserialize)] +struct Request { + entry_address: String, + payload: String, +} + +#[derive(Debug, Serialize)] +struct Response<'a> { + success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option<&'a str>, +} + +fn respond(response: Response<'_>) -> Result<()> { + serde_json::to_writer(io::stdout(), &response).context("encode response")?; + println!(); + Ok(()) +} + +fn configured_recipient() -> Result { + let value = env::var("YAMN_NYM_RECIPIENT") + .context("YAMN_NYM_RECIPIENT is not configured")?; + value + .parse::() + .map_err(|_| anyhow::anyhow!("invalid configured Nym recipient")) +} + +fn storage_paths() -> Result { + let directory = env::var_os("YAMN_NYM_STORAGE") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/var/lib/yamnweb/nym-client")); + StoragePaths::new_from_dir(&directory).context("prepare Nym client storage") +} + +async fn send(request: Request) -> Result<()> { + if request.payload.is_empty() || request.payload.len() > MAX_PAYLOAD_BYTES { + bail!("invalid envelope size"); + } + if request.payload.as_bytes().contains(&0) + || request + .entry_address + .chars() + .any(|c| matches!(c, '\r' | '\n' | '\0')) + { + bail!("envelope contains a NUL byte"); + } + + let raw_payload = BASE64 + .encode(request.payload.as_bytes()); + let request_payload = serde_json::json!({ + "version": 1, + "entry_address": request.entry_address, + "payload": raw_payload, + }); + let request_payload = serde_json::to_string(&request_payload).context("encode ingress envelope")?; + + let recipient = configured_recipient()?; + let paths = storage_paths()?; + let disconnected = MixnetClientBuilder::new_with_default_storage(paths) + .await + .context("prepare Nym client")? + .build() + .context("build Nym client")?; + let mut client = tokio::time::timeout(CONNECT_TIMEOUT, disconnected.connect_to_mixnet()) + .await + .context("Nym connection timed out")? + .context("connect to Nym mixnet")?; + + tokio::time::timeout( + SEND_TIMEOUT, + client.send_plain_message(recipient, request_payload), + ) + .await + .context("Nym send timed out")? + .context("send envelope through Nym")?; + + tokio::time::sleep(FLUSH_GRACE).await; + client.disconnect().await; + Ok(()) +} + +#[tokio::main] +async fn main() { + let result = async { + let mut input = String::new(); + io::stdin() + .read_to_string(&mut input) + .context("read request")?; + let request: Request = serde_json::from_str(&input).context("decode request")?; + send(request).await + } + .await; + + match result { + Ok(()) => { + let _ = respond(Response { + success: true, + error: None, + }); + } + Err(error) => { + eprintln!("yamn-nym-submit: {error:#}"); + let _ = respond(Response { + success: false, + error: Some("Nym submission failed"), + }); + std::process::exit(1); + } + } +} -- cgit v1.2.3