summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--README.md35
-rw-r--r--about.html93
-rw-r--r--deploy/yamn-nym-ingress.env.example6
-rw-r--r--deploy/yamn-nym-ingress.service32
-rw-r--r--download_remailers.php20
-rw-r--r--index.php392
-rw-r--r--ingress/Cargo.toml16
-rw-r--r--ingress/README.md32
-rw-r--r--ingress/src/main.rs245
-rw-r--r--katzenpost/README.md30
-rw-r--r--katzenpost/cmd/yamn-dispatcher/main.go115
-rw-r--r--katzenpost/cmd/yamn-submit/main.go73
-rw-r--r--katzenpost/dispatcher/codec.go29
-rw-r--r--katzenpost/dispatcher/envelope.go45
-rw-r--r--katzenpost/dispatcher/envelope_test.go21
-rw-r--r--katzenpost/dispatcher/frame.go84
-rw-r--r--katzenpost/dispatcher/frame_test.go46
-rw-r--r--katzenpost/dispatcher/reassembler.go91
-rw-r--r--katzenpost/go.mod35
-rw-r--r--katzenpost/go.sum63
-rw-r--r--katzenpost_sender.php32
-rw-r--r--nym/Cargo.toml14
-rw-r--r--nym/README.md22
-rw-r--r--nym/src/main.rs127
-rw-r--r--nym_sender.php115
-rw-r--r--yamn/README.md14
-rw-r--r--yamn/cmd/yamn-encode/main.go55
-rw-r--r--yamn/encoder/encoder.go400
-rw-r--r--yamn/encoder/encoder_test.go115
-rw-r--r--yamn/go.mod7
-rw-r--r--yamn/go.sum4
-rw-r--r--yamn_config.php15
-rw-r--r--yamn_encoder.php42
34 files changed, 2199 insertions, 269 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e625624
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/yamn/yamn-encode
+/katzenpost/yamn-dispatcher
+/katzenpost/yamn-submit
diff --git a/README.md b/README.md
index 1cbaf8f..dd4aca0 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,39 @@
-# Tor before Yamn Anonymous Remailers Network Gateway
+# YAMN Anonymous Remailers Network Gateway
![Status](https://img.shields.io/badge/status-operational-green)
![Security](https://img.shields.io/badge/security-hardened-red)
-![Tor](https://img.shields.io/badge/tor-mandatory-purple)
+![Transport](https://img.shields.io/badge/transport-Nym-blueviolet)
![License](https://img.shields.io/badge/license-MIT-blue)
-A hardened web interface for [YAMN (Yet Another Mix Network)](https://github.com/crooks/yamn) implementing military-grade operational security with mandatory Tor routing and zero metadata retention.
+A hardened web interface for [YAMN (Yet Another Mix Network)](https://github.com/crooks/yamn). The active message transport uses a local Rust sender built with the Nym SDK. Tor is retained only for downloading remailer statistics and public key material.
+
+## Current active path
+
+```text
+Browser -> PHP yamnweb -> Rust yamn-nym-submit -> Nym ingress service -> YAMN
+ ^
+ |
+ already encrypted YAMN envelope only
+```
+
+The Nym sender is send-only and receives only an already encrypted envelope.
+`YAMN_NYM_RECIPIENT` must identify a Nym ingress service that knows how to
+forward the envelope to YAMN. A normal SMTP address is not a valid replacement.
+The active YAMN encoder is maintained in `yamn/`. Katzenpost remains in
+`katzenpost/` as a separate standby PoC and is not on the active path.
+
+The YAMN encoder is built from `yamn/`:
+
+```bash
+cd yamn
+GOCACHE=/tmp/yamnweb-go-build go test ./...
+go build -o /usr/local/bin/yamn-encode ./cmd/yamn-encode
+```
+
+The PHP deployment must set `YAMN_ENCODER` and `YAMN_PUBRING`. The keyring is
+read locally and the encoder emits the standard YAMN armored envelope.
+
+The legacy Tor sending instructions below are retained as historical reference.
## 🎯 Mission Statement
@@ -757,4 +785,3 @@ Contributions welcome! Please:
---
**Status:** ✅ Operational | **Last Updated:** October 2025 | **Version:** 2.0
-
diff --git a/about.html b/about.html
new file mode 100644
index 0000000..fd7e52e
--- /dev/null
+++ b/about.html
@@ -0,0 +1,93 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="description" content="Architecture, privacy, and operation of the YAMN Web interface.">
+ <title>YAMN Web architecture and operation</title>
+ <style>
+ :root { color-scheme: dark; --bg: #0a0a0a; --panel: #171717; --text: #e0e0e0; --muted: #a0a0a0; --accent: #00ff00; --border: #333; --warning: #ffaa00; }
+ * { box-sizing: border-box; }
+ body { margin: 0; padding: 24px; background: var(--bg); color: var(--text); font: 16px/1.65 system-ui, sans-serif; }
+ main { max-width: 920px; margin: 0 auto; }
+ h1, h2 { color: var(--accent); line-height: 1.2; }
+ h1 { font-family: monospace; letter-spacing: 2px; }
+ h2 { margin-top: 2.2em; }
+ a { color: var(--accent); }
+ .panel { padding: 18px 22px; margin: 18px 0; background: var(--panel); border: 1px solid var(--border); border-radius: 6px; }
+ code, pre { font-family: monospace; }
+ pre { overflow-x: auto; padding: 16px; background: #050505; border: 1px solid var(--border); white-space: pre-wrap; }
+ li { margin: 6px 0; }
+ .warning { border-left: 4px solid var(--warning); }
+ .muted { color: var(--muted); }
+ </style>
+</head>
+<body>
+<main>
+ <p><a href="./">← Back to the interface</a></p>
+ <h1>YAMN Web architecture and operation</h1>
+ <p>YAMN Web is a send-only interface for preparing messages for delivery through the YAMN remailer network. The same composer handles email delivery and Usenet publication. The application is available through HTTPS and the existing Onion service.</p>
+
+ <section class="panel">
+ <h2>Complete route</h2>
+ <pre>Browser
+ ↓ HTTPS or Onion
+PHP YAMN Web
+ ↓ local request, without a temporary message file
+YAMN encoder
+ ↓ encrypted YAMN envelope
+Rust Nym sender
+ ↓ Nym mixnet
+Nym ingress on kvara
+ ↓ Tor, forwards only the envelope to the selected Entry
+Selected YAMN Entry
+ ↓ YAMN packet layers
+Selected YAMN Middle
+ ↓ YAMN packet layers
+Selected YAMN Exit
+ ↓ SMTP delivery
+mail2news@mail2news.tcpreset.net
+ ↓ Mail2News conversion
+news.tcpreset.net → misc.test</pre>
+ <p>The Nym sender does not receive the body, subject, or final recipient as separate parameters. It receives only the encrypted envelope produced by the YAMN encoder.</p>
+ </section>
+
+ <h2>HTTPS and Onion service</h2>
+ <p>The public HTTPS address remains <code>https://yamnweb.virebent.art</code>. The existing Onion address remains <code>http://ai63yqfo2wu2j3ey7nmatyel52jjvcmfg4e4kkxp2y7x3bc6nue6czyd.onion:4343/</code>, including its configured port. This interface neither generates nor changes the Onion address. Both access points serve the same application directory.</p>
+ <p>The HTTPS virtual host does not retain access logs. The application may emit technical PHP or service errors, but it does not intentionally log message contents.</p>
+
+ <h2>The YAMN chain</h2>
+ <p>The chain selected in the form has three roles:</p>
+ <ul>
+ <li><strong>Entry:</strong> receives the YAMN packet from the transport route and removes the first layer.</li>
+ <li><strong>Middle:</strong> processes the intermediate hop and forwards the packet.</li>
+ <li><strong>Exit:</strong> removes the final layer and delivers the message through SMTP. For Usenet articles, the SMTP recipient is the service-configured Mail-to-News gateway.</li>
+ </ul>
+ <p>The <code>*</code> option randomly selects an available remailer from the local list. This is intentional because remailer availability and reliability change over time.</p>
+
+ <h2>Destinations</h2>
+ <p>The composer uses the sender, optional Reply-To address, subject, and body for every message. Enter an email recipient for email delivery. To publish on Usenet, leave the email recipient empty and enter a <strong>Newsgroup</strong>; the application then adds the configured Mail-to-News recipient that converts the message leaving the remailer chain into an NNTP article.</p>
+ <p>Acceptance by the Nym transport confirms only that the route has started. Each remailer holds the packet in a queue for a variable delay, so delivery or Usenet publication may take several hours. This send-only interface does not read the final destination and cannot confirm publication itself.</p>
+ <p><strong>References</strong> is optional and contains one or more Message-IDs used to link a new article to an existing thread. It does not enable reply retrieval or message fetching.</p>
+
+ <h2>What is stored</h2>
+ <ul>
+ <li>The public YAMN keyring and remailer list are stored as technical service data.</li>
+ <li>The Nym client stores only the technical state required for its transport identity.</li>
+ <li>The message text and encrypted envelope are not written to temporary files by the submission route.</li>
+ <li>The web session stores the CSRF token and the minimum state required for the POST flow.</li>
+ <li>The browser may retain cookies or typed data according to its own settings.</li>
+ </ul>
+
+ <section class="panel warning">
+ <h2>Important limitations</h2>
+ <p>YAMN Web provides no inbox, fetch, message viewing, AEC, or reply retrieval. A message is accepted when the Nym sender accepts its transmission, but this is not a final delivery receipt.</p>
+ <p>During maintenance, the Nym ingress may operate in dry-run mode. In that mode it validates received packets without forwarding them to the SMTP remailer.</p>
+ </section>
+
+ <h2>Responsible use</h2>
+ <p>Check the recipient or newsgroup, chain, and References before submission. Do not include passwords, private keys, or information you do not want to transmit through the remailer network. Anonymity also depends on message content, timing, and sender behavior.</p>
+ <p><a href="./">Back to message composition</a></p>
+</main>
+</body>
+</html>
diff --git a/deploy/yamn-nym-ingress.env.example b/deploy/yamn-nym-ingress.env.example
new file mode 100644
index 0000000..029c506
--- /dev/null
+++ b/deploy/yamn-nym-ingress.env.example
@@ -0,0 +1,6 @@
+YAMN_NYM_INGRESS_STORAGE=/var/lib/yamnweb/nym-ingress
+YAMN_TOR_SOCKS=127.0.0.1:9050
+YAMN_INGRESS_FROM=<>
+YAMN_DRY_RUN=false
+# Replace this list with the current, reviewed YAMN entry addresses.
+YAMN_ALLOWED_ENTRY_ADDRESSES=yamn@virebent.art,yamn@mixmin.net,yamn2@mixmin.net,yamn3@mixmin.net,yamn4@mixmin.net,yamn@gronk.ch,yamn@eocto.net,yamn@middleman.remailer.online,yamn@milton.redmv.net,yamn@yamn.paranoici.org,yamn@shalo.ca,yamn@tnetconsulting.net,yamn@yeahno.net
diff --git a/deploy/yamn-nym-ingress.service b/deploy/yamn-nym-ingress.service
new file mode 100644
index 0000000..8e51a03
--- /dev/null
+++ b/deploy/yamn-nym-ingress.service
@@ -0,0 +1,32 @@
+[Unit]
+Description=YAMN Nym ingress
+Documentation=https://docs.rs/nym-sdk/latest/nym_sdk/
+Wants=network-online.target
+After=network-online.target tor.service
+
+[Service]
+Type=simple
+User=yamn-ingress
+Group=yamn-ingress
+EnvironmentFile=/etc/yamnweb/yamn-nym-ingress.env
+ExecStart=/usr/local/bin/yamn-nym-ingress
+Restart=on-failure
+RestartSec=10s
+NoNewPrivileges=true
+PrivateTmp=true
+PrivateDevices=true
+ProtectHome=true
+ProtectSystem=strict
+ReadWritePaths=/var/lib/yamnweb/nym-ingress
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+RestrictSUIDSGID=true
+LockPersonality=true
+MemoryDenyWriteExecute=true
+CapabilityBoundingSet=
+AmbientCapabilities=
+StandardOutput=journal
+StandardError=journal
+
+[Install]
+WantedBy=multi-user.target
+
diff --git a/download_remailers.php b/download_remailers.php
index 3b40607..d601ac6 100644
--- a/download_remailers.php
+++ b/download_remailers.php
@@ -61,9 +61,9 @@ class SecureRemailerDownloader {
/**
* Download remailer stats (mlist.txt)
*/
- private function downloadStats() {
+ private function downloadStats(bool $force = false) {
// Check if update is needed (with randomized interval)
- if (!$this->needsUpdate($this->remailersFile)) {
+ if (!$force && !$this->needsUpdate($this->remailersFile)) {
$this->log("Stats update not needed yet");
return true;
}
@@ -109,9 +109,9 @@ class SecureRemailerDownloader {
/**
* Download public keyring (pubring.mix)
*/
- private function downloadKeyring() {
+ private function downloadKeyring(bool $force = false) {
// Check if update is needed (with randomized interval)
- if (!$this->needsUpdate($this->pubringFile)) {
+ if (!$force && !$this->needsUpdate($this->pubringFile)) {
$this->log("Keyring update not needed yet");
return true;
}
@@ -538,8 +538,8 @@ class SecureRemailerDownloader {
* Force update of both stats and keyring
*/
public function forceUpdate() {
- $statsResult = $this->downloadStats();
- $keyringResult = $this->downloadKeyring();
+ $statsResult = $this->downloadStats(true);
+ $keyringResult = $this->downloadKeyring(true);
return $statsResult && $keyringResult;
}
@@ -572,12 +572,4 @@ class SecureRemailerDownloader {
}
}
-// Usage
-try {
- $downloader = new SecureRemailerDownloader();
- $downloader->downloadRemailers();
-} catch (Exception $e) {
- error_log("Remailer download failed: " . $e->getMessage());
-}
?>
-
diff --git a/index.php b/index.php
index 6b112c8..64c47d6 100644
--- a/index.php
+++ b/index.php
@@ -1,6 +1,6 @@
<?php
// yamnweb - YAMN Mixmaster Network Web Interface
-// Secure anonymous email interface with Tor integration
+// Tor is retained only for remailer-list downloads. Message transport uses Nym.
// Enable error logging (disable display for security)
error_reporting(E_ALL);
@@ -16,7 +16,11 @@ if (!defined('FILTER_SANITIZE_STRING')) {
// Configure session BEFORE starting it (critical for cookie-based sessions)
ini_set('session.cookie_httponly', '1');
ini_set('session.use_only_cookies', '1');
-ini_set('session.cookie_secure', '0'); // Set to '1' if using HTTPS
+// The public HTTPS endpoint gets a Secure cookie. The Onion service may use
+// HTTP, so detect the current request instead of forcing one mode globally.
+$requestIsHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
+ || (isset($_SERVER['SERVER_PORT']) && (int)$_SERVER['SERVER_PORT'] === 443);
+ini_set('session.cookie_secure', $requestIsHttps ? '1' : '0');
ini_set('session.cookie_samesite', 'Lax'); // Changed from 'Strict' to allow form submissions
ini_set('session.gc_maxlifetime', 7200); // 2 hours session lifetime
@@ -37,18 +41,15 @@ if ($_SESSION['session_test'] !== 'working') {
die("Error: Session is not persisting. Check session.save_path permissions.");
}
-// Load optional dependencies (don't fail if missing)
+// Tor is used only by the remailer-list downloader.
if (file_exists('download_remailers.php')) {
require_once 'download_remailers.php';
} else {
error_log("Warning: download_remailers.php not found");
}
-if (file_exists('tor_extension.php')) {
- require_once 'tor_extension.php';
-} else {
- error_log("Warning: tor_extension.php not found - Tor integration disabled");
-}
+require_once __DIR__ . '/nym_sender.php';
+require_once __DIR__ . '/yamn_encoder.php';
// Theme management via cookie
$theme = 'dark'; // Default
@@ -220,7 +221,7 @@ if (isset($_SESSION['flash_message'])) {
}
// Handle form submission
-if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// CSRF validation - IMPROVED with better error handling
if (!isset($_POST['csrf_token'])) {
// Save error in session and redirect
@@ -229,9 +230,6 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
header('Location: ' . $_SERVER['PHP_SELF']);
exit;
} elseif ($_POST['csrf_token'] !== $currentCsrfToken) {
- // DEBUG: Log the mismatch for troubleshooting
- error_log("CSRF Mismatch - POST: " . substr($_POST['csrf_token'], 0, 10) . "... SESSION: " . substr($currentCsrfToken, 0, 10) . "...");
-
// Save error in session and redirect
$_SESSION['flash_message'] = "Security token mismatch. Please try submitting again.";
$_SESSION['flash_type'] = 'error';
@@ -239,6 +237,24 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
exit;
} else {
try {
+ if (isset($_POST['update_remailers'])) {
+ if (!class_exists('SecureRemailerDownloader')) {
+ throw new Exception('Remailer list updater is not available.');
+ }
+
+ set_time_limit(0);
+ $downloader = new SecureRemailerDownloader();
+ if (!$downloader->forceUpdate()) {
+ throw new Exception('Remailer list update failed. The previous list was kept where possible.');
+ }
+
+ $_SESSION['flash_message'] = 'Remailer list and public keyring updated successfully.';
+ $_SESSION['flash_type'] = 'success';
+ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+ header('Location: ' . $_SERVER['PHP_SELF']);
+ exit;
+ }
+
// Get and sanitize form data with additional validation
$entryRemailer = isset($_POST['entry_remailer']) ? filter_var($_POST['entry_remailer'], FILTER_SANITIZE_STRING) : '';
$middleRemailer = isset($_POST['middle_remailer']) ? filter_var($_POST['middle_remailer'], FILTER_SANITIZE_STRING) : '';
@@ -257,15 +273,22 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
throw new Exception("Number of copies must be between 1 and 3.");
}
- // Validate required fields
- if (empty($to)) {
- throw new Exception("Recipient (To) is required.");
- }
-
if (empty($from)) {
throw new Exception("Sender (From) is required.");
}
+ $messageKind = trim($newsgroups) === '' ? 'email' : 'usenet';
+ if ($messageKind === 'email' && empty($to)) {
+ throw new Exception("Email recipient is required when Newsgroup is empty.");
+ }
+
+ if ($messageKind === 'usenet') {
+ $to = yamnConfig('YAMN_USENET_GATEWAY', 'mail2news@mail2news.tcpreset.net');
+ if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
+ throw new Exception("Usenet gateway is not configured correctly.");
+ }
+ }
+
// Validate remailer chain
if (empty($entryRemailer) || empty($middleRemailer) || empty($exitRemailer)) {
throw new Exception("All three remailers must be specified.");
@@ -276,125 +299,48 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$resolvedMiddle = resolveRemailer($middleRemailer, $middleRemailers);
$resolvedExit = resolveRemailer($exitRemailer, $exitRemailers);
- // Build remailer chain with resolved names
- $chain = "$resolvedEntry,$resolvedMiddle,$resolvedExit";
-
- // Log the resolved chain for debugging
- error_log("Resolved remailer chain: $chain (from: $entryRemailer,$middleRemailer,$exitRemailer)");
-
- // Build message headers
- $headers = "Content-Type: text/plain; charset=utf-8\n";
- $headers .= "Content-Transfer-Encoding: 8bit\n";
- $headers .= "MIME-Version: 1.0\n";
-
- if (!empty($references)) {
- $headers .= "References: $references\n";
+ $encoded = encodeYamnMessage([
+ 'kind' => $messageKind,
+ 'entry' => $resolvedEntry,
+ 'chain' => [$resolvedEntry, $resolvedMiddle, $resolvedExit],
+ 'from' => $from,
+ 'reply_to' => $replyTo,
+ 'to' => $to,
+ 'subject' => $subject,
+ 'newsgroup' => $newsgroups,
+ 'body' => $data,
+ 'references' => $references,
+ ]);
+ if (!$encoded['success']) {
+ throw new Exception($encoded['error'] ?? 'YAMN encoding failed');
}
-
- // Build complete message
- $messageContent = $headers . "From: $from\n";
-
- if (!empty($replyTo)) {
- $messageContent .= "Reply-To: $replyTo\n";
- }
-
- $messageContent .= "To: $to\nSubject: $subject\n";
-
- if (!empty($newsgroups)) {
- $messageContent .= "Newsgroups: $newsgroups\n";
- }
-
- $messageContent .= "\n$data";
-
- // Verify YAMN executable exists
- $yamnPath = '/opt/yamn-master/yamn';
- if (!file_exists($yamnPath)) {
- throw new Exception("YAMN executable not found at: $yamnPath");
- }
-
- if (!is_executable($yamnPath)) {
- throw new Exception("YAMN executable is not executable. Check permissions.");
- }
-
- // Verify YAMN config file exists
- $yamnConfig = '/opt/yamn-master/yamn.yml';
- if (!file_exists($yamnConfig)) {
- throw new Exception("YAMN config file not found at: $yamnConfig");
- }
-
- // Ensure temp directory exists and is writable
- $tempDir = '/var/www/yamnweb';
- if (!is_dir($tempDir)) {
- throw new Exception("Temp directory does not exist: $tempDir");
- }
-
- if (!is_writable($tempDir)) {
- throw new Exception("Temp directory is not writable: $tempDir");
- }
-
- // Ensure Maildir exists (required by YAMN)
- $maildirBase = '/var/www/yamnweb/Maildir';
- $maildirDirs = [
- $maildirBase,
- $maildirBase . '/tmp',
- $maildirBase . '/new',
- $maildirBase . '/cur'
- ];
-
- foreach ($maildirDirs as $dir) {
- if (!is_dir($dir)) {
- if (!@mkdir($dir, 0755, true)) {
- throw new Exception("Failed to create Maildir directory: $dir");
- }
- error_log("Created Maildir directory: $dir");
- }
- }
-
- // Write message to temp file (use single file as in original)
- $tempFile = $tempDir . '/message.txt';
- $writeResult = @file_put_contents($tempFile, $messageContent);
-
- if ($writeResult === false) {
- throw new Exception("Failed to write message to temporary file: $tempFile");
- }
-
- // Verify file was written
- if (!file_exists($tempFile)) {
- throw new Exception("Temp file was not created: $tempFile");
- }
-
- // Log to debug.log
- $debugLog = '/var/www/yamnweb/debug.log';
- file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Starting email send\n", FILE_APPEND);
- file_put_contents($debugLog, "To: $to | Chain: $chain | Copies: $copies\n", FILE_APPEND);
-
- // Use sendYamnEmail() from tor_extension.php
- if (function_exists('sendYamnEmail')) {
- $result = sendYamnEmail($chain, $copies, $tempFile, true);
-
- // Log result
- file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Result: " . ($result['success'] ? 'SUCCESS' : 'FAILED') . "\n", FILE_APPEND);
-
- // Clean up
- @unlink($tempFile);
-
- if ($result['success']) {
- // Success - save message in session and redirect (PRG pattern)
- $_SESSION['flash_message'] = "✓ Message sent successfully via YAMN network ($copies " . ($copies > 1 ? "copies" : "copy") . ")";
+ $responseFinished = false;
+ $onHandoff = null;
+ if (function_exists('fastcgi_finish_request')) {
+ $onHandoff = static function () use (&$responseFinished): void {
+ ignore_user_abort(true);
+ $_SESSION['flash_message'] = 'Message handed to the local Nym transport. The sender is completing delivery in the background; YAMN queues may take several hours, and this confirmation does not mean the message has been published or delivered yet.';
$_SESSION['flash_type'] = 'success';
-
- // Regenerate CSRF token after successful submission
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
-
- // Redirect to prevent form resubmission on page reload
+ session_write_close();
header('Location: ' . $_SERVER['PHP_SELF']);
- exit;
- } else {
- throw new Exception("YAMN send failed. Check debug.log for details.");
- }
- } else {
- throw new Exception("sendYamnEmail() function not found. Check tor_extension.php");
+ fastcgi_finish_request();
+ $responseFinished = true;
+ };
+ }
+
+ $result = sendNymEnvelope($encoded['envelope'], 1, $encoded['entry_address'], $onHandoff);
+ if ($responseFinished) {
+ exit;
}
+ if (!$result['success']) {
+ throw new Exception($result['error'] ?? 'Nym submission failed');
+ }
+ $_SESSION['flash_message'] = 'Message accepted by the Nym transport. Delivery through the YAMN queues may take several hours; this confirmation does not mean the message has been published or delivered yet.';
+ $_SESSION['flash_type'] = 'success';
+ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+ header('Location: ' . $_SERVER['PHP_SELF']);
+ exit;
} catch (Exception $e) {
// Save error in session and redirect (PRG pattern for errors too)
$_SESSION['flash_message'] = "✗ Error: " . $e->getMessage();
@@ -427,11 +373,11 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
}
?>
<!DOCTYPE html>
-<html lang="en">
+<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>YAMN Web Interface</title>
+ <title>YAMN Web, invio anonimo</title>
<style>
:root {
--transition-speed: 0.3s;
@@ -536,6 +482,13 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
color: var(--text-secondary);
}
+ .read-more {
+ color: var(--accent);
+ display: inline-block;
+ margin-top: 12px;
+ font-weight: bold;
+ }
+
.form-group {
margin-bottom: 20px;
}
@@ -592,6 +545,12 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
font-size: 0.9em;
}
+ small {
+ color: var(--text-secondary);
+ display: block;
+ margin-top: 6px;
+ }
+
button {
width: 100%;
padding: 15px;
@@ -779,19 +738,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
</a>
<h1>⚡ YAMN WEB INTERFACE ⚡</h1>
- <div class="subtitle">Tor before Yamn Remailer Network</div>
-
- <!-- DEBUG: Session check (can be removed after troubleshooting) -->
- <?php if (isset($_GET['debug'])): ?>
- <div class="info-box" style="font-size: 11px; font-family: monospace;">
- <strong>DEBUG INFO:</strong>
- Session ID: <?php echo substr(session_id(), 0, 16); ?>...<br>
- CSRF Token: <?php echo substr($_SESSION['csrf_token'], 0, 16); ?>...<br>
- PHP Version: <?php echo PHP_VERSION; ?><br>
- Session Save Path: <?php echo session_save_path(); ?><br>
- Cookie Params: <?php echo json_encode(session_get_cookie_params()); ?>
- </div>
- <?php endif; ?>
+ <div class="subtitle">YAMN composition, local encryption, send-only Nym transport</div>
<?php if (!empty($message)): ?>
<div class="message <?php echo $messageType; ?>">
@@ -800,128 +747,71 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
<?php endif; ?>
<div class="info-box">
- <strong>Security Features Active:</strong>
+ <strong>This interface prepares and submits YAMN packets.</strong>
<ul>
- <li>Tor/Onion network before Yamn Mix Network</li>
- <li>Onion Smtp Relay with traffic padding and timing randomization</li>
- <li>Forward secrecy and metadata protection</li>
- <li>No persistent message retention - No logs website</li>
+ <li>The YAMN chain consists of Entry, Middle, and Exit remailers.</li>
+ <li>The message is converted into an encrypted envelope before transport.</li>
+ <li>Nym transports only the encrypted envelope, not the individual message fields.</li>
+ <li>The service is send-only: it provides no inbox, fetch, reading, or reply retrieval.</li>
</ul>
+ <a class="read-more" href="about.html">Read more: detailed architecture and operation</a>
+ </div>
+
+ <div class="info-box">
+ <strong>Remailer data</strong>
+ <p>The primary statistics source is Victor's YAMN pinger at <code>echolot.virebent.art</code>. Fallback pingers are tried automatically if needed.</p>
+ <form method="POST" action="">
+ <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
+ <button type="submit" name="update_remailers" value="1">Update remailer list and keyring</button>
+ </form>
+ <small>This may take a few minutes because the update uses Tor and tries fallback sources if necessary.</small>
</div>
<form method="POST" action="" id="yamnForm">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token']); ?>">
<div class="form-group">
- <label>Remailer Chain <span class="required">*</span></label>
+ <label>Three-hop YAMN chain <span class="required">*</span></label>
<div class="remailer-chain">
- <div>
- <label for="entry_remailer">Entry Node</label>
- <select name="entry_remailer" id="entry_remailer" required>
- <option value="">-- Select Entry --</option>
- <?php foreach ($entryRemailers as $remailer): ?>
- <option value="<?php echo htmlspecialchars($remailer); ?>">
- <?php echo htmlspecialchars($remailer); ?>
- </option>
- <?php endforeach; ?>
- </select>
- </div>
- <div>
- <label for="middle_remailer">Middle Node</label>
- <select name="middle_remailer" id="middle_remailer" required>
- <option value="">-- Select Middle --</option>
- <?php foreach ($middleRemailers as $remailer): ?>
- <option value="<?php echo htmlspecialchars($remailer); ?>">
- <?php echo htmlspecialchars($remailer); ?>
- </option>
- <?php endforeach; ?>
- </select>
- </div>
- <div>
- <label for="exit_remailer">Exit Node</label>
- <select name="exit_remailer" id="exit_remailer" required>
- <option value="">-- Select Exit --</option>
- <?php foreach ($exitRemailers as $remailer): ?>
- <option value="<?php echo htmlspecialchars($remailer); ?>">
- <?php echo htmlspecialchars($remailer); ?>
- </option>
- <?php endforeach; ?>
- </select>
- </div>
+ <div><label for="entry_remailer">Entry</label><select name="entry_remailer" id="entry_remailer" required><?php foreach ($entryRemailers as $remailer): ?><option value="<?php echo htmlspecialchars($remailer); ?>"><?php echo htmlspecialchars($remailer); ?></option><?php endforeach; ?></select></div>
+ <div><label for="middle_remailer">Middle</label><select name="middle_remailer" id="middle_remailer" required><?php foreach ($middleRemailers as $remailer): ?><option value="<?php echo htmlspecialchars($remailer); ?>"><?php echo htmlspecialchars($remailer); ?></option><?php endforeach; ?></select></div>
+ <div><label for="exit_remailer">Exit</label><select name="exit_remailer" id="exit_remailer" required><?php foreach ($exitRemailers as $remailer): ?><option value="<?php echo htmlspecialchars($remailer); ?>"><?php echo htmlspecialchars($remailer); ?></option><?php endforeach; ?></select></div>
</div>
+ <small>Entry receives the YAMN packet, Middle forwards it through the chain, and Exit delivers the decoded message.</small>
</div>
-
- <div class="form-group">
- <label for="from">From Address <span class="required">*</span></label>
- <input type="text" name="from" id="from" placeholder="Anonymous <anonymous@anonymous.com>" required>
+ <div class="form-group"><label for="from">From <span class="required">*</span></label><input type="text" name="from" id="from" placeholder="Anonymous &lt;anon@example.org&gt;" required><small>Used as the From header.</small></div>
+ <div class="form-group"><label for="reply_to">Reply-To address</label><input type="text" name="reply_to" id="reply_to"><small>Optional. It is not used to retrieve replies.</small></div>
+ <div class="form-group"><label for="to">Email recipient</label><input type="email" name="to" id="to"><small>Required for email delivery. Leave empty when publishing to a newsgroup.</small></div>
+ <div class="form-group"><label for="subject">Subject <span class="required">*</span></label><input type="text" name="subject" id="subject" required></div>
+ <div class="form-group"><label for="newsgroups">Newsgroup</label><input type="text" name="newsgroups" id="newsgroups" placeholder="misc.test"><small>Optional. When set, the message is routed through the configured Mail-to-News gateway. Leave empty for email.</small></div>
+ <div class="form-group"><label for="references">References</label><input type="text" name="references" id="references" placeholder="&lt;message-id@example.org&gt;"><small>Optional. Links the article to an existing thread.</small></div>
+ <div class="form-group"><label for="data">Message body <span class="required">*</span></label><textarea name="data" id="data" required></textarea>
+ <small>The server creates the encrypted YAMN envelope in memory and sends only that packet through Nym. Remailer queues may take several hours.</small>
</div>
- <div class="form-group">
- <label for="reply_to">Reply-To Address</label>
- <input type="text" name="reply_to" id="reply_to" placeholder="No Reply <noreply@anonymous.com>">
- </div>
-
- <div class="form-group">
- <label for="to">To Address <span class="required">*</span></label>
- <input type="email" name="to" id="to" placeholder="recipient@example.com or user@example.onion" required>
- </div>
-
- <div class="form-group">
- <label for="subject">Subject</label>
- <input type="text" name="subject" id="subject" placeholder="Message subject">
- </div>
-
- <div class="form-group">
- <label for="newsgroups">Newsgroups (optional)</label>
- <input type="text" name="newsgroups" id="newsgroups" placeholder="alt.anonymous.messages">
- </div>
-
- <div class="form-group">
- <label for="references">References (optional)</label>
- <input type="text" name="references" id="references" placeholder="Message-ID for threading">
- </div>
-
- <div class="form-group">
- <label for="data">Message Body <span class="required">*</span></label>
- <textarea name="data" id="data" placeholder="Your anonymous message..." required></textarea>
- </div>
-
- <div class="form-group">
- <label for="copies">Number of Copies (1-3)</label>
- <input type="number" name="copies" id="copies" value="1" min="1" max="3" required>
- <small>Multiple copies increase reliability through redundancy</small>
- </div>
-
- <div class="form-group">
- <div class="checkbox-group">
- </div>
-
- <button type="submit">🚀 SEND </button>
+ <button type="submit">🚀 Submit message</button>
</form>
</div>
<script>
- // Auto-enable Tor checkbox for .onion addresses
- document.getElementById('to').addEventListener('input', function() {
- const torCheckbox = document.getElementById('use_tor');
- if (this.value.includes('.onion')) {
- torCheckbox.checked = true;
- }
- });
-
- // Prevent selecting same remailer multiple times
- document.querySelectorAll('.remailer-chain select').forEach(select => {
- select.addEventListener('change', function() {
- const selects = document.querySelectorAll('.remailer-chain select');
- const values = Array.from(selects).map(s => s.value).filter(v => v);
-
- selects.forEach(s => {
- Array.from(s.options).forEach(option => {
- if (option.value && values.includes(option.value) && option.value !== '*' && s !== select) {
- option.style.color = '#666';
- } else {
- option.style.color = 'var(--text-primary)';
- }
+ const to = document.getElementById('to');
+ const newsgroups = document.getElementById('newsgroups');
+ const updateDestinationValidation = () => {
+ to.required = newsgroups.value.trim() === '';
+ };
+ newsgroups.addEventListener('input', updateDestinationValidation);
+ updateDestinationValidation();
+
+ document.querySelectorAll('.remailer-chain select').forEach((select) => {
+ select.addEventListener('change', () => {
+ const selected = Array.from(document.querySelectorAll('.remailer-chain select'))
+ .map((item) => item.value)
+ .filter((value) => value !== '*');
+ document.querySelectorAll('.remailer-chain select').forEach((item) => {
+ Array.from(item.options).forEach((option) => {
+ option.disabled = option.value !== '*' &&
+ selected.includes(option.value) &&
+ option.value !== item.value;
});
});
});
diff --git a/ingress/Cargo.toml b/ingress/Cargo.toml
new file mode 100644
index 0000000..e5d4685
--- /dev/null
+++ b/ingress/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "yamn-nym-ingress"
+version = "0.1.0"
+edition = "2021"
+description = "Receive opaque YAMN envelopes over Nym and deliver them through Tor SMTP"
+license = "AGPL-3.0-or-later"
+
+[dependencies]
+anyhow = "1"
+base64 = "0.22"
+nym-sdk = "1.21.2"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "io-util", "net", "signal"] }
+tokio-socks = "0.5"
+
diff --git a/ingress/README.md b/ingress/README.md
new file mode 100644
index 0000000..d4a8338
--- /dev/null
+++ b/ingress/README.md
@@ -0,0 +1,32 @@
+# YAMN Nym ingress
+
+This service receives an envelope over the Nym mixnet and delivers the
+already encrypted YAMN packet to an allowlisted SMTP entry through Tor.
+
+The Nym address is generated by the persistent client identity and printed at
+startup. It must be copied to `YAMN_NYM_RECIPIENT` on the sending web service.
+
+Required environment:
+
+- `YAMN_ALLOWED_ENTRY_ADDRESSES`: comma-separated exact YAMN entry addresses.
+- `YAMN_NYM_INGRESS_STORAGE`: persistent directory for the Nym identity.
+
+Optional environment:
+
+- `YAMN_TOR_SOCKS`, default `127.0.0.1:9050`.
+- `YAMN_INGRESS_FROM`, default `<>`.
+- `YAMN_DRY_RUN`, default false. When enabled, valid envelopes are accepted but
+ not delivered to SMTP, for non-delivery integration tests.
+
+Input protocol, sent as the Nym message body:
+
+```json
+{"version":1,"entry_address":"yamn@example.org","payload":"<base64 raw SMTP envelope>"}
+```
+
+The service does not log payloads, sender identities, or recipient message
+contents. It has no reply, fetch, view, inbox, or fallback-direct-SMTP mode.
+
+An example systemd unit and environment file are in `deploy/`. The production
+environment file must be reviewed against the current YAMN remailer list and
+must not be committed if it contains deployment-specific secrets.
diff --git a/ingress/src/main.rs b/ingress/src/main.rs
new file mode 100644
index 0000000..4f8dff6
--- /dev/null
+++ b/ingress/src/main.rs
@@ -0,0 +1,245 @@
+use anyhow::{bail, Context, Result};
+use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
+use nym_sdk::mixnet::{MixnetClientBuilder, StoragePaths};
+use serde::Deserialize;
+use std::env;
+use std::path::PathBuf;
+use std::time::Duration;
+use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
+use tokio_socks::tcp::Socks5Stream;
+
+const MAX_REQUEST_BYTES: usize = 128 * 1024;
+const MAX_PAYLOAD_BYTES: usize = 96 * 1024;
+const SMTP_TIMEOUT: Duration = Duration::from_secs(60);
+
+#[derive(Debug, Deserialize)]
+struct Envelope {
+ version: u8,
+ entry_address: String,
+ payload: String,
+}
+
+fn storage_paths() -> Result<StoragePaths> {
+ let path = env::var_os("YAMN_NYM_INGRESS_STORAGE")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from("/var/lib/yamnweb/nym-ingress"));
+ StoragePaths::new_from_dir(&path).context("prepare Nym ingress storage")
+}
+
+fn allowed_entry(address: &str) -> bool {
+ env::var("YAMN_ALLOWED_ENTRY_ADDRESSES")
+ .ok()
+ .map(|list| list.split(',').any(|item| item.trim() == address))
+ .unwrap_or(false)
+}
+
+fn dry_run() -> bool {
+ matches!(
+ env::var("YAMN_DRY_RUN").as_deref(),
+ Ok("1") | Ok("true") | Ok("yes")
+ )
+}
+
+fn split_address(address: &str) -> Result<(&str, &str)> {
+ if address.len() > 320 || address.chars().any(|c| matches!(c, '\r' | '\n' | '\0' | ' ')) {
+ bail!("invalid entry address");
+ }
+ let (local, domain) = address
+ .rsplit_once('@')
+ .context("entry address has no domain")?;
+ if local.is_empty() || domain.is_empty() || domain.contains('@') {
+ bail!("invalid entry address");
+ }
+ Ok((local, domain))
+}
+
+fn validate_envelope(envelope: &Envelope) -> Result<(String, Vec<u8>)> {
+ if envelope.version != 1 {
+ bail!("unsupported envelope version");
+ }
+ split_address(&envelope.entry_address)?;
+ if !allowed_entry(&envelope.entry_address) {
+ bail!("entry address is not allowlisted");
+ }
+ let payload = BASE64
+ .decode(&envelope.payload)
+ .context("invalid base64 YAMN payload")?;
+ if payload.is_empty() || payload.len() > MAX_PAYLOAD_BYTES || payload.contains(&0) {
+ bail!("invalid YAMN payload size or contents");
+ }
+ Ok((envelope.entry_address.clone(), payload))
+}
+
+async fn smtp_read_response<R: AsyncRead + Unpin>(reader: &mut BufReader<R>) -> Result<u16> {
+ let mut line = String::new();
+ loop {
+ line.clear();
+ let read = reader.read_line(&mut line).await.context("read SMTP response")?;
+ if read == 0 {
+ bail!("SMTP connection closed");
+ }
+ if line.len() < 3 {
+ bail!("malformed SMTP response");
+ }
+ let parsed: u16 = line[..3].parse().context("invalid SMTP response code")?;
+ if line.as_bytes().get(3) != Some(&b'-') {
+ return Ok(parsed);
+ }
+ }
+}
+
+async fn smtp_command<S: AsyncRead + AsyncWrite + Unpin>(
+ reader: &mut BufReader<S>,
+ command: &[u8],
+ expected_class: u16,
+) -> Result<()> {
+ reader
+ .get_mut()
+ .write_all(command)
+ .await
+ .context("write SMTP command")?;
+ reader
+ .get_mut()
+ .flush()
+ .await
+ .context("flush SMTP command")?;
+ let code = smtp_read_response(reader).await?;
+ if code / 100 != expected_class {
+ bail!("SMTP command rejected with code {code}");
+ }
+ Ok(())
+}
+
+async fn deliver(entry_address: &str, payload: &[u8]) -> Result<()> {
+ let (_, host) = split_address(entry_address)?;
+ let proxy = env::var("YAMN_TOR_SOCKS").unwrap_or_else(|_| "127.0.0.1:9050".to_string());
+ let (proxy_host, proxy_port) = proxy
+ .rsplit_once(':')
+ .context("invalid YAMN_TOR_SOCKS")?;
+ let proxy_port: u16 = proxy_port.parse().context("invalid YAMN_TOR_SOCKS port")?;
+ let target = format!("{host}:25");
+ let stream = tokio::time::timeout(
+ SMTP_TIMEOUT,
+ Socks5Stream::connect((proxy_host, proxy_port), target.as_str()),
+ )
+ .await
+ .context("Tor SMTP connection timed out")?
+ .context("connect to YAMN SMTP entry through Tor")?;
+ let mut reader = BufReader::new(stream);
+ let greeting = smtp_read_response(&mut reader).await?;
+ if greeting / 100 != 2 {
+ bail!("YAMN SMTP greeting rejected with code {greeting}");
+ }
+
+ let from = env::var("YAMN_INGRESS_FROM").unwrap_or_else(|_| "<>".to_string());
+ if from.chars().any(|c| matches!(c, '\r' | '\n' | ' ')) {
+ bail!("invalid YAMN_INGRESS_FROM");
+ }
+ smtp_command(&mut reader, b"EHLO yamn-nym-ingress\r\n", 2).await?;
+ smtp_command(
+ &mut reader,
+ format!("MAIL FROM:{from}\r\n").as_bytes(),
+ 2,
+ )
+ .await?;
+ smtp_command(
+ &mut reader,
+ format!("RCPT TO:<{entry_address}>\r\n").as_bytes(),
+ 2,
+ )
+ .await?;
+ smtp_command(&mut reader, b"DATA\r\n", 3).await?;
+
+ for line in payload.split_inclusive(|byte| *byte == b'\n') {
+ if line.starts_with(b".") {
+ reader
+ .get_mut()
+ .write_all(b".")
+ .await
+ .context("dot-stuff SMTP payload")?;
+ }
+ reader
+ .get_mut()
+ .write_all(line)
+ .await
+ .context("write SMTP payload")?;
+ }
+ if !payload.ends_with(b"\n") {
+ reader
+ .get_mut()
+ .write_all(b"\r\n")
+ .await
+ .context("terminate SMTP payload")?;
+ }
+ reader
+ .get_mut()
+ .write_all(b".\r\n")
+ .await
+ .context("finish SMTP payload")?;
+ reader
+ .get_mut()
+ .flush()
+ .await
+ .context("flush SMTP payload")?;
+ let accepted = smtp_read_response(&mut reader).await?;
+ if accepted / 100 != 2 {
+ bail!("YAMN SMTP server rejected payload with code {accepted}");
+ }
+ let _ = smtp_command(&mut reader, b"QUIT\r\n", 2).await;
+ Ok(())
+}
+
+async fn run() -> Result<()> {
+ let disconnected = MixnetClientBuilder::new_with_default_storage(storage_paths()?)
+ .await
+ .context("prepare Nym client")?
+ .build()
+ .context("build Nym client")?;
+ let mut client = disconnected
+ .connect_to_mixnet()
+ .await
+ .context("connect ingress to Nym mixnet")?;
+ eprintln!("yamn-nym-ingress address: {}", client.nym_address());
+
+ while let Some(messages) = client.wait_for_messages().await {
+ for message in messages {
+ if message.message.len() > MAX_REQUEST_BYTES {
+ eprintln!("discarded oversized Nym message");
+ continue;
+ }
+ let envelope: Envelope = match serde_json::from_slice(&message.message) {
+ Ok(value) => value,
+ Err(_) => {
+ eprintln!("discarded malformed Nym envelope");
+ continue;
+ }
+ };
+ let (entry_address, payload) = match validate_envelope(&envelope) {
+ Ok(value) => value,
+ Err(error) => {
+ eprintln!("discarded invalid YAMN envelope: {error}");
+ continue;
+ }
+ };
+ if dry_run() {
+ eprintln!("accepted YAMN envelope in dry-run mode");
+ continue;
+ }
+ if let Err(error) = deliver(&entry_address, &payload).await {
+ eprintln!("YAMN delivery failed: {error:#}");
+ } else {
+ eprintln!("delivered YAMN envelope to allowlisted entry");
+ }
+ }
+ }
+ client.disconnect().await;
+ Ok(())
+}
+
+#[tokio::main]
+async fn main() {
+ if let Err(error) = run().await {
+ eprintln!("yamn-nym-ingress: {error:#}");
+ std::process::exit(1);
+ }
+}
diff --git a/katzenpost/README.md b/katzenpost/README.md
new file mode 100644
index 0000000..938c2b0
--- /dev/null
+++ b/katzenpost/README.md
@@ -0,0 +1,30 @@
+# Katzenpost YAMN dispatcher
+
+The dispatcher runs on `kvara`. It is a send-only Katzenpost service: it
+receives an already encrypted YAMN packet and forwards it to the selected YAMN
+entry remailer using SMTP. It never receives a browser request and must not
+implement fetch, view, AEC, replies, or message storage.
+
+`pietro` is responsible for building the YAMN packet and submitting the opaque
+envelope to the Katzenpost service through `kpclientd`.
+
+## Trust boundary
+
+The only data the dispatcher may see is:
+
+- the entry remailer SMTP address;
+- the complete, already encrypted YAMN envelope.
+
+It must reject all other recipient domains and must not write payloads,
+subjects, addresses, or SMTP transcripts to disk or logs.
+
+## Deployment shape
+
+```text
+pietro: yamnweb onion service -> local kpclientd -> Katzenpost
+ -> kvara dispatcher -> YAMN entry SMTP
+```
+
+The service capability name is `yamn-dispatch-v1`. It must be registered in
+the Katzenpost PKI before `pietro` can discover it. The code is intentionally
+not configured with live hosts, credentials, or service keys.
diff --git a/katzenpost/cmd/yamn-dispatcher/main.go b/katzenpost/cmd/yamn-dispatcher/main.go
new file mode 100644
index 0000000..8f10034
--- /dev/null
+++ b/katzenpost/cmd/yamn-dispatcher/main.go
@@ -0,0 +1,115 @@
+package main
+
+import (
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "git.virebent.art/virebent/yamnweb/katzenpost/dispatcher"
+ "github.com/katzenpost/katzenpost/core/log"
+ "github.com/katzenpost/katzenpost/server/cborplugin"
+)
+
+const capability = "yamn-dispatch-v1"
+
+type plugin struct {
+ write func(cborplugin.Command)
+ reassembler *dispatcher.Reassembler
+ allowedDomains map[string]struct{}
+}
+
+func (p *plugin) OnCommand(command cborplugin.Command) error {
+ request, ok := command.(*cborplugin.Request)
+ if !ok {
+ return errors.New("unexpected plugin command")
+ }
+ frame, err := dispatcher.DecodeFrame(request.Payload)
+ if err != nil {
+ return err
+ }
+ encoded, complete, err := p.reassembler.Add(frame)
+ if err != nil || !complete {
+ return err
+ }
+ envelope, err := dispatcher.DecodeEnvelope(encoded)
+ if err != nil {
+ return err
+ }
+ if err := envelope.Validate(p.allowedDomains); err != nil {
+ return err
+ }
+ // PoC sink: successful validation intentionally has no external side effect.
+ return nil
+}
+
+func (p *plugin) RegisterConsumer(server *cborplugin.Server) {
+ p.write = server.Write
+}
+
+func parseDomains(value string) (map[string]struct{}, error) {
+ domains := make(map[string]struct{})
+ for _, domain := range strings.Split(value, ",") {
+ domain = strings.ToLower(strings.TrimSpace(domain))
+ if domain == "" || strings.ContainsAny(domain, "@/\\: ") {
+ return nil, errors.New("invalid allowed domain")
+ }
+ domains[domain] = struct{}{}
+ }
+ if len(domains) == 0 {
+ return nil, errors.New("at least one allowed domain is required")
+ }
+ return domains, nil
+}
+
+func run() error {
+ var allowed string
+ var logDir string
+ var logLevel string
+ flag.StringVar(&allowed, "allowed-domains", "remailer.example", "comma-separated entry remailer domains")
+ flag.StringVar(&logDir, "log-dir", "/tmp", "operational log directory")
+ flag.StringVar(&logLevel, "log-level", "NOTICE", "Katzenpost log level")
+ flag.Parse()
+
+ domains, err := parseDomains(allowed)
+ if err != nil {
+ return err
+ }
+ info, err := os.Stat(logDir)
+ if err != nil || !info.IsDir() {
+ return errors.New("log directory is unavailable")
+ }
+ backend, err := log.New(filepath.Join(logDir, "yamn-dispatch.log"), logLevel, false)
+ if err != nil {
+ return fmt.Errorf("initialize logging: %w", err)
+ }
+ logger := backend.GetLogger("yamn_dispatch")
+
+ socketDir, err := os.MkdirTemp("", "yamn-dispatch-")
+ if err != nil {
+ return fmt.Errorf("create socket directory: %w", err)
+ }
+ defer os.RemoveAll(socketDir)
+ socketPath := filepath.Join(socketDir, "plugin.sock")
+ service := &plugin{
+ reassembler: dispatcher.NewReassembler(5 * time.Minute),
+ allowedDomains: domains,
+ }
+ server := cborplugin.NewServer(logger, socketPath, new(cborplugin.RequestFactory), service)
+ if _, err := fmt.Fprintln(os.Stdout, socketPath); err != nil {
+ return fmt.Errorf("publish socket path: %w", err)
+ }
+ server.Accept()
+ server.Wait()
+ return nil
+}
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintln(os.Stderr, "yamn-dispatcher failed")
+ os.Exit(1)
+ }
+}
diff --git a/katzenpost/cmd/yamn-submit/main.go b/katzenpost/cmd/yamn-submit/main.go
new file mode 100644
index 0000000..cde35f9
--- /dev/null
+++ b/katzenpost/cmd/yamn-submit/main.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "time"
+
+ "git.virebent.art/virebent/yamnweb/katzenpost/dispatcher"
+ "github.com/katzenpost/hpqc/hash"
+ clientconfig "github.com/katzenpost/katzenpost/client/config"
+ "github.com/katzenpost/katzenpost/client/thin"
+)
+
+const capability = "yamn-dispatch-v1"
+
+func run() error {
+ var configPath string
+ var settle time.Duration
+ flag.StringVar(&configPath, "config", "thinclient.toml", "thin-client configuration")
+ flag.DurationVar(&settle, "settle", 2*time.Second, "time allowed for kpclientd to queue frames")
+ flag.Parse()
+
+ input, err := io.ReadAll(io.LimitReader(os.Stdin, dispatcher.MaxEnvelopeBytes*2))
+ if err != nil {
+ return fmt.Errorf("read envelope: %w", err)
+ }
+ var envelope dispatcher.Envelope
+ if err := json.Unmarshal(input, &envelope); err != nil {
+ return errors.New("invalid envelope input")
+ }
+ encoded, err := dispatcher.EncodeEnvelope(envelope)
+ if err != nil {
+ return err
+ }
+ frames, err := dispatcher.SplitEnvelope(encoded)
+ if err != nil {
+ return err
+ }
+
+ config, err := thin.LoadFile(configPath)
+ if err != nil {
+ return fmt.Errorf("load thin-client config: %w", err)
+ }
+ client := thin.NewThinClient(config, &clientconfig.Logging{Level: "ERROR", Disable: true})
+ defer client.Close()
+ if err := client.Dial(); err != nil {
+ return fmt.Errorf("connect to kpclientd: %w", err)
+ }
+ service, err := client.GetService(capability)
+ if err != nil {
+ return fmt.Errorf("find dispatcher service: %w", err)
+ }
+ destination := hash.Sum256(service.MixDescriptor.IdentityKey)
+ for _, frame := range frames {
+ if err := client.SendMessageWithoutReply(frame, &destination, service.RecipientQueueID); err != nil {
+ return fmt.Errorf("send frame: %w", err)
+ }
+ }
+ time.Sleep(settle)
+ fmt.Fprintln(os.Stdout, "accepted")
+ return nil
+}
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintf(os.Stderr, "submission failed: %v\n", err)
+ os.Exit(1)
+ }
+}
diff --git a/katzenpost/dispatcher/codec.go b/katzenpost/dispatcher/codec.go
new file mode 100644
index 0000000..3053dde
--- /dev/null
+++ b/katzenpost/dispatcher/codec.go
@@ -0,0 +1,29 @@
+package dispatcher
+
+import (
+ "fmt"
+
+ "github.com/fxamacker/cbor/v2"
+)
+
+func EncodeEnvelope(envelope Envelope) ([]byte, error) {
+ encoded, err := cbor.Marshal(envelope)
+ if err != nil {
+ return nil, fmt.Errorf("encode envelope: %w", err)
+ }
+ if len(encoded) > MaxEnvelopeBytes {
+ return nil, fmt.Errorf("encoded envelope is too large")
+ }
+ return encoded, nil
+}
+
+func DecodeEnvelope(encoded []byte) (Envelope, error) {
+ if len(encoded) == 0 || len(encoded) > MaxEnvelopeBytes {
+ return Envelope{}, fmt.Errorf("invalid encoded envelope size")
+ }
+ var envelope Envelope
+ if err := cbor.Unmarshal(encoded, &envelope); err != nil {
+ return Envelope{}, fmt.Errorf("decode envelope: %w", err)
+ }
+ return envelope, nil
+}
diff --git a/katzenpost/dispatcher/envelope.go b/katzenpost/dispatcher/envelope.go
new file mode 100644
index 0000000..b9ff7b8
--- /dev/null
+++ b/katzenpost/dispatcher/envelope.go
@@ -0,0 +1,45 @@
+package dispatcher
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "net/mail"
+ "strings"
+)
+
+const MaxEnvelopeBytes = 64 * 1024
+
+// Envelope is the only application payload accepted by the dispatcher. Message
+// must already be a complete YAMN transport envelope, not user supplied mail.
+type Envelope struct {
+ EntryAddress string `json:"entry_address"`
+ Message []byte `json:"message"`
+}
+
+func (e Envelope) Validate(allowedDomains map[string]struct{}) error {
+ if len(e.Message) == 0 || len(e.Message) > MaxEnvelopeBytes {
+ return fmt.Errorf("invalid YAMN envelope size")
+ }
+ if bytes.Contains(e.Message, []byte("\r")) {
+ return errors.New("YAMN envelope must use LF line endings")
+ }
+ address, err := mail.ParseAddress(e.EntryAddress)
+ if err != nil || address.Address != e.EntryAddress {
+ return errors.New("invalid entry remailer address")
+ }
+ parts := strings.Split(address.Address, "@")
+ if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
+ return errors.New("invalid entry remailer address")
+ }
+ if _, ok := allowedDomains[strings.ToLower(parts[1])]; !ok {
+ return errors.New("entry remailer domain is not allowed")
+ }
+ if !bytes.HasPrefix(e.Message, []byte("To: "+e.EntryAddress+"\n")) {
+ return errors.New("YAMN envelope recipient does not match entry remailer")
+ }
+ if !bytes.Contains(e.Message, []byte("-----BEGIN REMAILER MESSAGE-----\n")) {
+ return errors.New("invalid YAMN envelope")
+ }
+ return nil
+}
diff --git a/katzenpost/dispatcher/envelope_test.go b/katzenpost/dispatcher/envelope_test.go
new file mode 100644
index 0000000..712071c
--- /dev/null
+++ b/katzenpost/dispatcher/envelope_test.go
@@ -0,0 +1,21 @@
+package dispatcher
+
+import "testing"
+
+func TestEnvelopeValidate(t *testing.T) {
+ allowed := map[string]struct{}{"remailer.example": {}}
+ valid := Envelope{
+ EntryAddress: "entry@remailer.example",
+ Message: []byte("To: entry@remailer.example\nFrom: mix@nowhere.invalid\n\n-----BEGIN REMAILER MESSAGE-----\nabc\n"),
+ }
+ if err := valid.Validate(allowed); err != nil {
+ t.Fatalf("expected valid envelope: %v", err)
+ }
+
+ invalidDomain := valid
+ invalidDomain.EntryAddress = "entry@other.example"
+ invalidDomain.Message = []byte("To: entry@other.example\n-----BEGIN REMAILER MESSAGE-----\n")
+ if err := invalidDomain.Validate(allowed); err == nil {
+ t.Fatal("expected unknown domain rejection")
+ }
+}
diff --git a/katzenpost/dispatcher/frame.go b/katzenpost/dispatcher/frame.go
new file mode 100644
index 0000000..43f547c
--- /dev/null
+++ b/katzenpost/dispatcher/frame.go
@@ -0,0 +1,84 @@
+package dispatcher
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+
+ "github.com/fxamacker/cbor/v2"
+)
+
+const (
+ ProtocolVersion = 1
+ MaxSphinxPayload = 2000
+ FrameDataBytes = 1500
+ MaxFrames = (MaxEnvelopeBytes + FrameDataBytes - 1) / FrameDataBytes
+)
+
+type Frame struct {
+ Version uint8 `cbor:"1,keyasint"`
+ ID [16]byte `cbor:"2,keyasint"`
+ Digest [32]byte `cbor:"3,keyasint"`
+ Index uint16 `cbor:"4,keyasint"`
+ Total uint16 `cbor:"5,keyasint"`
+ Data []byte `cbor:"6,keyasint"`
+}
+
+func SplitEnvelope(encoded []byte) ([][]byte, error) {
+ if len(encoded) == 0 || len(encoded) > MaxEnvelopeBytes {
+ return nil, errors.New("invalid encoded envelope size")
+ }
+
+ var id [16]byte
+ if _, err := rand.Read(id[:]); err != nil {
+ return nil, fmt.Errorf("generate message id: %w", err)
+ }
+ digest := sha256.Sum256(encoded)
+ total := (len(encoded) + FrameDataBytes - 1) / FrameDataBytes
+ if total > MaxFrames {
+ return nil, errors.New("encoded envelope requires too many frames")
+ }
+
+ frames := make([][]byte, 0, total)
+ for index, start := 0, 0; start < len(encoded); index, start = index+1, start+FrameDataBytes {
+ end := min(start+FrameDataBytes, len(encoded))
+ frame := Frame{
+ Version: ProtocolVersion,
+ ID: id,
+ Digest: digest,
+ Index: uint16(index),
+ Total: uint16(total),
+ Data: encoded[start:end],
+ }
+ payload, err := cbor.Marshal(frame)
+ if err != nil {
+ return nil, fmt.Errorf("encode frame %d: %w", index, err)
+ }
+ if len(payload) > MaxSphinxPayload {
+ return nil, fmt.Errorf("frame %d exceeds Sphinx payload limit", index)
+ }
+ frames = append(frames, payload)
+ }
+ return frames, nil
+}
+
+func DecodeFrame(payload []byte) (Frame, error) {
+ if len(payload) == 0 || len(payload) > MaxSphinxPayload {
+ return Frame{}, errors.New("invalid frame size")
+ }
+ var frame Frame
+ if err := cbor.Unmarshal(payload, &frame); err != nil {
+ return Frame{}, fmt.Errorf("decode frame: %w", err)
+ }
+ if frame.Version != ProtocolVersion {
+ return Frame{}, errors.New("unsupported frame version")
+ }
+ if frame.Total == 0 || int(frame.Total) > MaxFrames || frame.Index >= frame.Total {
+ return Frame{}, errors.New("invalid frame sequence")
+ }
+ if len(frame.Data) == 0 || len(frame.Data) > FrameDataBytes {
+ return Frame{}, errors.New("invalid frame data size")
+ }
+ return frame, nil
+}
diff --git a/katzenpost/dispatcher/frame_test.go b/katzenpost/dispatcher/frame_test.go
new file mode 100644
index 0000000..21c84ba
--- /dev/null
+++ b/katzenpost/dispatcher/frame_test.go
@@ -0,0 +1,46 @@
+package dispatcher
+
+import (
+ "bytes"
+ "testing"
+ "time"
+)
+
+func TestSplitAndReassembleEnvelope(t *testing.T) {
+ encoded := bytes.Repeat([]byte("yamn-envelope\n"), 900)
+ payloads, err := SplitEnvelope(encoded)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(payloads) < 2 {
+ t.Fatal("expected multiple frames")
+ }
+
+ reassembler := NewReassembler(time.Minute)
+ var result []byte
+ for _, payload := range payloads {
+ if len(payload) > MaxSphinxPayload {
+ t.Fatalf("payload exceeds limit: %d", len(payload))
+ }
+ frame, err := DecodeFrame(payload)
+ if err != nil {
+ t.Fatal(err)
+ }
+ assembled, complete, err := reassembler.Add(frame)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if complete {
+ result = assembled
+ }
+ }
+ if !bytes.Equal(result, encoded) {
+ t.Fatal("reassembled envelope differs")
+ }
+}
+
+func TestDecodeFrameRejectsOversizedPayload(t *testing.T) {
+ if _, err := DecodeFrame(make([]byte, MaxSphinxPayload+1)); err == nil {
+ t.Fatal("expected oversized payload rejection")
+ }
+}
diff --git a/katzenpost/dispatcher/reassembler.go b/katzenpost/dispatcher/reassembler.go
new file mode 100644
index 0000000..87af98c
--- /dev/null
+++ b/katzenpost/dispatcher/reassembler.go
@@ -0,0 +1,91 @@
+package dispatcher
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "errors"
+ "sync"
+ "time"
+)
+
+const MaxPendingMessages = 64
+
+type pendingMessage struct {
+ digest [32]byte
+ total uint16
+ parts map[uint16][]byte
+ created time.Time
+}
+
+type Reassembler struct {
+ mu sync.Mutex
+ pending map[[16]byte]*pendingMessage
+ ttl time.Duration
+ now func() time.Time
+}
+
+func NewReassembler(ttl time.Duration) *Reassembler {
+ return &Reassembler{
+ pending: make(map[[16]byte]*pendingMessage),
+ ttl: ttl,
+ now: time.Now,
+ }
+}
+
+func (r *Reassembler) Add(frame Frame) ([]byte, bool, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ r.expireLocked()
+ pending, ok := r.pending[frame.ID]
+ if !ok {
+ if len(r.pending) >= MaxPendingMessages {
+ return nil, false, errors.New("too many pending messages")
+ }
+ pending = &pendingMessage{
+ digest: frame.Digest,
+ total: frame.Total,
+ parts: make(map[uint16][]byte, frame.Total),
+ created: r.now(),
+ }
+ r.pending[frame.ID] = pending
+ }
+ if pending.total != frame.Total || pending.digest != frame.Digest {
+ delete(r.pending, frame.ID)
+ return nil, false, errors.New("inconsistent frame metadata")
+ }
+ if _, duplicate := pending.parts[frame.Index]; !duplicate {
+ pending.parts[frame.Index] = bytes.Clone(frame.Data)
+ }
+ if len(pending.parts) != int(pending.total) {
+ return nil, false, nil
+ }
+
+ var assembled bytes.Buffer
+ for index := uint16(0); index < pending.total; index++ {
+ part, exists := pending.parts[index]
+ if !exists {
+ return nil, false, nil
+ }
+ if assembled.Len()+len(part) > MaxEnvelopeBytes {
+ delete(r.pending, frame.ID)
+ return nil, false, errors.New("reassembled envelope is too large")
+ }
+ assembled.Write(part)
+ }
+ delete(r.pending, frame.ID)
+ result := assembled.Bytes()
+ if sha256.Sum256(result) != pending.digest {
+ return nil, false, errors.New("reassembled envelope digest mismatch")
+ }
+ return result, true, nil
+}
+
+func (r *Reassembler) expireLocked() {
+ cutoff := r.now().Add(-r.ttl)
+ for id, pending := range r.pending {
+ if pending.created.Before(cutoff) {
+ delete(r.pending, id)
+ }
+ }
+}
diff --git a/katzenpost/go.mod b/katzenpost/go.mod
new file mode 100644
index 0000000..09a9aca
--- /dev/null
+++ b/katzenpost/go.mod
@@ -0,0 +1,35 @@
+module git.virebent.art/virebent/yamnweb/katzenpost
+
+go 1.26.2
+
+require (
+ github.com/fxamacker/cbor/v2 v2.9.1
+ github.com/katzenpost/hpqc v0.0.84-0.20260603082753-be60338910ec
+ github.com/katzenpost/katzenpost v0.0.90
+)
+
+require (
+ codeberg.org/vula/highctidh v1.0.2025051200 // indirect
+ filippo.io/edwards25519 v1.2.0 // indirect
+ filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e // indirect
+ filippo.io/mlkem768 v0.0.0-20260214141301-2e7bebc7d88d // indirect
+ github.com/BurntSushi/toml v1.6.0 // indirect
+ github.com/agl/gcmsiv v0.0.0-20190418185415-e8dcd2f151dc // indirect
+ github.com/katzenpost/chacha20 v0.0.1 // indirect
+ github.com/katzenpost/chacha20poly1305 v0.0.1 // indirect
+ github.com/katzenpost/circl v1.3.8-0.20260413165442-e2d217fd59f5 // indirect
+ github.com/katzenpost/falcon v0.1.0 // indirect
+ github.com/katzenpost/sntrup4591761 v0.0.0-20231024131303-8755eb1986b8 // indirect
+ github.com/katzenpost/sphincsplus v0.0.2 // indirect
+ github.com/katzenpost/sqisign/bindings/go v0.0.0-20260520215053-47e926d8264d // indirect
+ github.com/mattn/go-pointer v0.0.1 // indirect
+ github.com/shurlinet/go-hqc v0.1.1 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
+ gitlab.com/yawning/aez.git v0.0.0-20211027044916-e49e68abd344 // indirect
+ gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec // indirect
+ golang.org/x/crypto v0.51.0 // indirect
+ golang.org/x/net v0.53.0 // indirect
+ golang.org/x/sys v0.44.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
+ gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473 // indirect
+)
diff --git a/katzenpost/go.sum b/katzenpost/go.sum
new file mode 100644
index 0000000..d8d6abd
--- /dev/null
+++ b/katzenpost/go.sum
@@ -0,0 +1,63 @@
+codeberg.org/vula/highctidh v1.0.2025051200 h1:4xG2pxCCYKvI2HICy5YQf+44d8pEibuOg4u86PvX4OM=
+codeberg.org/vula/highctidh v1.0.2025051200/go.mod h1:admvznk7GhsrDih/BMy7jbIv9Y86U9vj7S5FVoquF/g=
+filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
+filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
+filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU=
+filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e/go.mod h1:32qQ5yj3R24Eu03iWFWchdC3OB653wPvoepWejkefbY=
+filippo.io/mlkem768 v0.0.0-20260214141301-2e7bebc7d88d h1:YyLyABjdrdt2l/E6JAnku4BjhEDXhxQD2bPOnOvy8/M=
+filippo.io/mlkem768 v0.0.0-20260214141301-2e7bebc7d88d/go.mod h1:ym4egWKLpazdho3bHx0xuQlCq02ttP+vhxxKO8LgO9c=
+github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/agl/gcmsiv v0.0.0-20190418185415-e8dcd2f151dc h1:MB338WDPuyQ8qXRiSRK2NVTv0EmLYsHXZAevEiQO1+c=
+github.com/agl/gcmsiv v0.0.0-20190418185415-e8dcd2f151dc/go.mod h1:5joDAvk82M2Cx1X8mAL5Orvhy5lfW4BjrTCW65wbvRo=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
+github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/katzenpost/chacha20 v0.0.1 h1:Scu6Pqyginw083FhypKMIBmCI2gQTC1RFD6vqpWwI2Y=
+github.com/katzenpost/chacha20 v0.0.1/go.mod h1:/LIJK/8cUXVJrCh5NypZ8So3gDfQCoQT8lRvy1rYQZA=
+github.com/katzenpost/chacha20poly1305 v0.0.1 h1:yFPxgu/VSWsoCNQ2x15az8xBKKc/9GDY9Pz42MvY9xg=
+github.com/katzenpost/chacha20poly1305 v0.0.1/go.mod h1:5sv//C8pWaXrnnP3CNiJXqAlA1/yfUZOl1tR9xsL9bI=
+github.com/katzenpost/circl v1.3.8-0.20260413165442-e2d217fd59f5 h1:n+9aUwSmnz97MNS9duYeHSq+s42CG9wydFECOjqAI9A=
+github.com/katzenpost/circl v1.3.8-0.20260413165442-e2d217fd59f5/go.mod h1:9KxLMK17ZLjofnmpVp4Sm315uIF3BAWHVk32EFDRicU=
+github.com/katzenpost/falcon v0.1.0 h1:+WkID41Y6CaWAQE3ijCIfZHu7eHHy8DvAFWCnrW1XDw=
+github.com/katzenpost/falcon v0.1.0/go.mod h1:HtOnkNvwpvTJ3fuXmy4BNw4A8Jg2QetpmkvOPOnOwyU=
+github.com/katzenpost/hpqc v0.0.84-0.20260603082753-be60338910ec h1:6YnGxqJXmAXt9maeTudPdNmEjG4hLJkRj8ISpih9TJU=
+github.com/katzenpost/hpqc v0.0.84-0.20260603082753-be60338910ec/go.mod h1:C/MaI/ke/uHR5FCKuqB/GNa/JLoFk9PeBnFoP23dh6Q=
+github.com/katzenpost/katzenpost v0.0.90 h1:z3bBMpDK3eC65kqbXGwlIjIo1IccHTWytgCCtX+PL2M=
+github.com/katzenpost/katzenpost v0.0.90/go.mod h1:tKMbNtHwBxv2qk9uX5hfNueFnxpQPDvr6NjkW5AUchY=
+github.com/katzenpost/sntrup4591761 v0.0.0-20231024131303-8755eb1986b8 h1:TsKxH0x2RUwf5rBw67k15bqVM3oVbexA9oaTZQLIy3Y=
+github.com/katzenpost/sntrup4591761 v0.0.0-20231024131303-8755eb1986b8/go.mod h1:Hmcrwom7jcEmGdo0CsyuJNnldPeyS+M07FuCbo7I8fw=
+github.com/katzenpost/sphincsplus v0.0.2 h1:W1UWejLK62Lk0uK2R08H/sWEaQrRHWCaMEKO181SoOE=
+github.com/katzenpost/sphincsplus v0.0.2/go.mod h1:ChO9+ojgCH1yEuplGgW4mSI1FwZWtyEmEkG1xL3w264=
+github.com/katzenpost/sqisign/bindings/go v0.0.0-20260520215053-47e926d8264d h1:mjXmMwB8NRO0+NoUGj9zys2vm02Ia9on0lZE5gVr4X8=
+github.com/katzenpost/sqisign/bindings/go v0.0.0-20260520215053-47e926d8264d/go.mod h1:CcVAF5BHgQtHH3lFCmwl3t2KKcXih+8ZcjLbxg8z3D0=
+github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
+github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/schwarmco/go-cartesian-product v0.0.0-20230921023625-e02d1c150053 h1:h7EwPM2KjupG0zVAG+EYxbR2cHnbiP1d4DTAZ+G09LY=
+github.com/schwarmco/go-cartesian-product v0.0.0-20230921023625-e02d1c150053/go.mod h1:/TRiIlxvQQAtfnBXEqqbnYBYPmE6XT5iZxSx+hJ9zGw=
+github.com/shurlinet/go-hqc v0.1.1 h1:p0ZSHW+Spulp0pBnuyYTjgqmqm0CkILZWyOEZeKRXl0=
+github.com/shurlinet/go-hqc v0.1.1/go.mod h1:3pQdozY8+F4AcOpiyj/ztKR90TwMDMBjbiNHNbejPtw=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+gitlab.com/yawning/aez.git v0.0.0-20211027044916-e49e68abd344 h1:eICJMpqnDkO4nQv+GWtXcE45CFum/ni3jhcE+acBjQk=
+gitlab.com/yawning/aez.git v0.0.0-20211027044916-e49e68abd344/go.mod h1:/WDFxZLKGy+NQc+nqvQg2O0rW1HeWNHSelVv5fwEL8s=
+gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec h1:FpfFs4EhNehiVfzQttTuxanPIT43FtkkCFypIod8LHo=
+gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec/go.mod h1:BZ1RAoRPbCxum9Grlv5aeksu2H8BiKehBYooU2LFiOQ=
+golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
+golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
+golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
+golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
+golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
+golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473 h1:6D+BvnJ/j6e222UW8s2qTSe3wGBtvo0MbVQG/c5k8RE=
+gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473/go.mod h1:N1eN2tsCx0Ydtgjl4cqmbRCsY4/+z4cYDeqwZTk6zog=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/katzenpost_sender.php b/katzenpost_sender.php
new file mode 100644
index 0000000..88ce2ae
--- /dev/null
+++ b/katzenpost_sender.php
@@ -0,0 +1,32 @@
+<?php
+/** Send an already prepared YAMN payload through the local Katzenpost sender. */
+function sendKatzenpostEnvelope(string $entryAddress, string $payload, int $copies = 1): array
+{
+ $sender = getenv('YAMN_KP_SENDER') ?: '/usr/local/bin/yamn-submit';
+ $config = getenv('YAMN_KP_CONFIG') ?: '/etc/yamnweb/thinclient.toml';
+ if (!is_executable($sender)) {
+ return ['success' => false, 'error' => 'Katzenpost sender is not available'];
+ }
+ if (!filter_var($entryAddress, FILTER_VALIDATE_EMAIL) || !str_ends_with(strtolower($entryAddress), '@remailer.example')) {
+ return ['success' => false, 'error' => 'Invalid YAMN entry address'];
+ }
+ if ($payload === '' || strlen($payload) > 65536) {
+ return ['success' => false, 'error' => 'Invalid YAMN payload'];
+ }
+
+ $message = "To: {$entryAddress}\n" . $payload;
+ if (!str_contains($message, "-----BEGIN REMAILER MESSAGE-----\n")) {
+ $message = "To: {$entryAddress}\n\n-----BEGIN REMAILER MESSAGE-----\n" . $payload;
+ }
+ if ($copies !== 1) return ['success' => false, 'error' => 'Multiple copies are not enabled in the PoC'];
+ $input = json_encode(['entry_address' => $entryAddress, 'message' => $message], JSON_THROW_ON_ERROR);
+ $descriptor = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
+ $process = proc_open([$sender, '-config', $config, '-settle', '2s'], $descriptor, $pipes);
+ if (!is_resource($process)) return ['success' => false, 'error' => 'Unable to start Katzenpost sender'];
+ fwrite($pipes[0], $input);
+ fclose($pipes[0]);
+ $stdout = stream_get_contents($pipes[1]); fclose($pipes[1]);
+ $stderr = stream_get_contents($pipes[2]); fclose($pipes[2]);
+ $status = proc_close($process);
+ return $status === 0 ? ['success' => true] : ['success' => false, 'error' => 'Katzenpost submission failed'];
+}
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":"<raw encrypted YAMN SMTP envelope>"}
+```
+
+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<Recipient> {
+ let value = env::var("YAMN_NYM_RECIPIENT")
+ .context("YAMN_NYM_RECIPIENT is not configured")?;
+ value
+ .parse::<Recipient>()
+ .map_err(|_| anyhow::anyhow!("invalid configured Nym recipient"))
+}
+
+fn storage_paths() -> Result<StoragePaths> {
+ 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);
+ }
+ }
+}
diff --git a/nym_sender.php b/nym_sender.php
new file mode 100644
index 0000000..221de17
--- /dev/null
+++ b/nym_sender.php
@@ -0,0 +1,115 @@
+<?php
+declare(strict_types=1);
+
+require_once __DIR__ . '/yamn_config.php';
+
+/**
+ * Send an already encrypted YAMN envelope through the local Rust Nym sender.
+ *
+ * The handoff callback runs after the complete request has been written to
+ * the sender's stdin, but before waiting for the sender's bounded flush grace
+ * period. This lets the HTTP layer finish a FastCGI response while the lock
+ * remains held until the sender exits.
+ */
+function sendNymEnvelope(
+ string $payload,
+ int $copies = 1,
+ ?string $selectedEntryAddress = null,
+ ?callable $onHandoff = null
+): array
+{
+ $sender = yamnConfig('YAMN_NYM_SENDER', '/usr/local/bin/yamn-nym-submit');
+ $recipient = yamnConfig('YAMN_NYM_RECIPIENT');
+ $storage = yamnConfig('YAMN_NYM_STORAGE', '/var/lib/yamnweb/nym-client');
+ $lockPath = yamnConfig('YAMN_NYM_LOCK', '/var/lib/yamnweb/nym-submit.lock');
+ $entryAddress = $selectedEntryAddress ?? yamnConfig('YAMN_ENTRY_ADDRESS');
+
+ if (!is_executable($sender)) {
+ return ['success' => false, 'error' => 'Nym sender is not available'];
+ }
+ if ($recipient === '' || strlen($recipient) > 512 || preg_match('/\s/', $recipient)) {
+ return ['success' => false, 'error' => 'Nym recipient is not configured'];
+ }
+ if ($entryAddress === '' || strlen($entryAddress) > 320 || preg_match('/[\r\n\0\s]/', $entryAddress)) {
+ return ['success' => false, 'error' => 'YAMN entry address is not configured'];
+ }
+ if ($copies !== 1) {
+ return ['success' => false, 'error' => 'Multiple copies are not enabled for Nym'];
+ }
+ if ($payload === '' || strlen($payload) > 65536 || str_contains($payload, "\0")) {
+ return ['success' => false, 'error' => 'Invalid encrypted YAMN envelope'];
+ }
+
+ try {
+ $input = json_encode([
+ 'entry_address' => $entryAddress,
+ 'payload' => $payload,
+ ], JSON_THROW_ON_ERROR);
+ } catch (JsonException $exception) {
+ return ['success' => false, 'error' => 'Unable to encode Nym request'];
+ }
+
+ $lock = @fopen($lockPath, 'c');
+ if ($lock === false) {
+ return ['success' => false, 'error' => 'Nym sender lock is not available'];
+ }
+ if (!flock($lock, LOCK_EX | LOCK_NB)) {
+ fclose($lock);
+ return ['success' => false, 'error' => 'Nym sender is busy, please retry later'];
+ }
+
+ $descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
+ $environment = $_ENV;
+ $environment['YAMN_NYM_RECIPIENT'] = $recipient;
+ $environment['YAMN_NYM_STORAGE'] = $storage;
+ $process = proc_open([$sender], $descriptors, $pipes, null, $environment);
+ if (!is_resource($process)) {
+ flock($lock, LOCK_UN);
+ fclose($lock);
+ return ['success' => false, 'error' => 'Unable to start Nym sender'];
+ }
+
+ $inputLength = strlen($input);
+ $offset = 0;
+ while ($offset < $inputLength) {
+ $written = fwrite($pipes[0], substr($input, $offset));
+ if ($written === false || $written === 0) {
+ break;
+ }
+ $offset += $written;
+ }
+ if ($offset !== $inputLength) {
+ fclose($pipes[0]);
+ proc_terminate($process);
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ proc_close($process);
+ flock($lock, LOCK_UN);
+ fclose($lock);
+ return ['success' => false, 'error' => 'Unable to hand envelope to Nym sender'];
+ }
+ fclose($pipes[0]);
+
+ if ($onHandoff !== null) {
+ $onHandoff();
+ }
+
+ $stdout = stream_get_contents($pipes[1]);
+ fclose($pipes[1]);
+ $stderr = stream_get_contents($pipes[2]);
+ fclose($pipes[2]);
+ $status = proc_close($process);
+ flock($lock, LOCK_UN);
+ fclose($lock);
+
+ if ($status !== 0) {
+ return ['success' => false, 'error' => 'Nym submission failed'];
+ }
+
+ $response = json_decode($stdout, true);
+ if (!is_array($response) || ($response['success'] ?? false) !== true) {
+ return ['success' => false, 'error' => 'Invalid response from Nym sender'];
+ }
+
+ return ['success' => true];
+}
diff --git a/yamn/README.md b/yamn/README.md
new file mode 100644
index 0000000..24ce452
--- /dev/null
+++ b/yamn/README.md
@@ -0,0 +1,14 @@
+# YAMN encoder
+
+This module contains the active YAMN v2 encoder used by Yamnweb. It creates
+the encrypted YAMN envelope locally and does not perform network transport.
+
+Build and test it from this directory:
+
+```bash
+GOCACHE=/tmp/yamnweb-go-build go test ./...
+go build -o /usr/local/bin/yamn-encode ./cmd/yamn-encode
+```
+
+The Katzenpost standby proof of concept is maintained separately in the
+top-level `katzenpost/` module.
diff --git a/yamn/cmd/yamn-encode/main.go b/yamn/cmd/yamn-encode/main.go
new file mode 100644
index 0000000..70f6494
--- /dev/null
+++ b/yamn/cmd/yamn-encode/main.go
@@ -0,0 +1,55 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+
+ "git.virebent.art/virebent/yamnweb/yamn/encoder"
+)
+
+type request struct {
+ Kind encoder.Kind `json:"kind"`
+ PublicKeyring string `json:"public_keyring"`
+ Entry string `json:"entry"`
+ Chain []string `json:"chain"`
+ From string `json:"from"`
+ ReplyTo string `json:"reply_to"`
+ To string `json:"to"`
+ Subject string `json:"subject"`
+ Newsgroup string `json:"newsgroup"`
+ Body string `json:"body"`
+ References string `json:"references"`
+}
+
+type response struct {
+ Success bool `json:"success"`
+ EntryAddress string `json:"entry_address,omitempty"`
+ Envelope string `json:"envelope,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+func main() {
+ var input request
+ data, err := io.ReadAll(io.LimitReader(os.Stdin, 256*1024))
+ if err == nil {
+ err = json.Unmarshal(data, &input)
+ }
+ if err == nil {
+ result, encodeErr := encoder.Encode(encoder.Request{
+ Kind: input.Kind, PublicKeyring: input.PublicKeyring, Entry: input.Entry,
+ Chain: input.Chain, From: input.From, ReplyTo: input.ReplyTo, To: input.To,
+ Subject: input.Subject, Newsgroup: input.Newsgroup, Body: input.Body,
+ References: input.References,
+ })
+ if encodeErr == nil {
+ _ = json.NewEncoder(os.Stdout).Encode(response{Success: true, EntryAddress: result.EntryAddress, Envelope: string(result.Envelope)})
+ return
+ }
+ err = encodeErr
+ }
+ _ = json.NewEncoder(os.Stdout).Encode(response{Success: false, Error: "YAMN encoding failed"})
+ fmt.Fprintln(os.Stderr, "yamn-encode:", err)
+ os.Exit(1)
+}
diff --git a/yamn/encoder/encoder.go b/yamn/encoder/encoder.go
new file mode 100644
index 0000000..5eacdf3
--- /dev/null
+++ b/yamn/encoder/encoder.go
@@ -0,0 +1,400 @@
+// Package encoder implements the send-only YAMN v2 client encoder.
+package encoder
+
+import (
+ "bufio"
+ "bytes"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "net/mail"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/blake2s"
+ "golang.org/x/crypto/nacl/box"
+)
+
+const (
+ maxChainLength = 10
+ headerBytes = 256
+ encHeadBytes = 160
+ bodyBytes = 17920
+ maxPlainBytes = 17910
+ messageBytes = maxChainLength*headerBytes + bodyBytes
+ armorVersion = "0.2.7"
+)
+
+var (
+ ErrInvalidRequest = errors.New("invalid YAMN request")
+ ErrInvalidKeyring = errors.New("invalid YAMN public keyring")
+)
+
+type Kind string
+
+const (
+ Email Kind = "email"
+ Usenet Kind = "usenet"
+)
+
+// Request contains one plaintext message and an entry-to-exit chain.
+// PublicKeyring is a local pubring.mix path and is never fetched by Encode.
+type Request struct {
+ Kind Kind
+ PublicKeyring string
+ Entry string
+ Chain []string
+ From string
+ ReplyTo string
+ To string
+ Subject string
+ Newsgroup string
+ Body string
+ References string
+}
+
+type Result struct {
+ EntryAddress string
+ Envelope []byte
+}
+
+type remailerKey struct {
+ name, address string
+ keyID, publicKey []byte
+}
+
+// Encode creates an armored YAMN v2 packet. It performs no network I/O.
+func Encode(r Request) (Result, error) {
+ if err := Validate(r); err != nil {
+ return Result{}, err
+ }
+ keys, err := loadKeyring(r.PublicKeyring)
+ if err != nil {
+ return Result{}, err
+ }
+ chain := make([]remailerKey, len(r.Chain))
+ for i, name := range r.Chain {
+ key, ok := keys[name]
+ if !ok {
+ return Result{}, fmt.Errorf("remailer %q not found in keyring", name)
+ }
+ chain[i] = key
+ }
+ plain, err := composeMessage(r)
+ if err != nil {
+ return Result{}, err
+ }
+ packet, err := encodePacket(plain, chain)
+ if err != nil {
+ return Result{}, err
+ }
+ return Result{EntryAddress: chain[0].address, Envelope: armor(packet)}, nil
+}
+
+func Validate(r Request) error {
+ if r.Kind != Email && r.Kind != Usenet {
+ return fmt.Errorf("%w: unsupported message kind", ErrInvalidRequest)
+ }
+ if strings.TrimSpace(r.PublicKeyring) == "" {
+ return fmt.Errorf("%w: missing public keyring", ErrInvalidRequest)
+ }
+ if len(r.Chain) == 0 || len(r.Chain) > maxChainLength {
+ return fmt.Errorf("%w: invalid chain length", ErrInvalidRequest)
+ }
+ for _, hop := range r.Chain {
+ if !isRemailerName(hop) {
+ return fmt.Errorf("%w: invalid remailer name", ErrInvalidRequest)
+ }
+ }
+ if strings.TrimSpace(r.Entry) != "" && strings.TrimSpace(r.Entry) != r.Chain[0] {
+ return fmt.Errorf("%w: entry does not match chain", ErrInvalidRequest)
+ }
+ if strings.TrimSpace(r.Body) == "" || len([]byte(r.Body)) > maxPlainBytes {
+ return fmt.Errorf("%w: body is empty or too large", ErrInvalidRequest)
+ }
+ if r.Kind == Email {
+ if _, err := mail.ParseAddress(r.To); err != nil {
+ return fmt.Errorf("%w: invalid recipient address", ErrInvalidRequest)
+ }
+ } else {
+ if strings.TrimSpace(r.Newsgroup) == "" || !validHeaderValue(r.Newsgroup) {
+ return fmt.Errorf("%w: missing or invalid newsgroup", ErrInvalidRequest)
+ }
+ if _, err := mail.ParseAddress(r.To); err != nil {
+ return fmt.Errorf("%w: invalid Usenet gateway recipient", ErrInvalidRequest)
+ }
+ }
+ for _, value := range []string{r.From, r.ReplyTo, r.To, r.Subject, r.Newsgroup, r.References} {
+ if !validHeaderValue(value) {
+ return fmt.Errorf("%w: invalid header value", ErrInvalidRequest)
+ }
+ }
+ return nil
+}
+
+func composeMessage(r Request) ([]byte, error) {
+ var b strings.Builder
+ b.WriteString("Content-Type: text/plain; charset=utf-8\nContent-Transfer-Encoding: 8bit\nMIME-Version: 1.0\n")
+ if r.From != "" {
+ b.WriteString("From: " + r.From + "\n")
+ }
+ if r.ReplyTo != "" {
+ b.WriteString("Reply-To: " + r.ReplyTo + "\n")
+ }
+ if r.To != "" {
+ b.WriteString("To: " + r.To + "\n")
+ }
+ if r.Subject != "" {
+ b.WriteString("Subject: " + r.Subject + "\n")
+ }
+ if r.Newsgroup != "" {
+ b.WriteString("Newsgroups: " + r.Newsgroup + "\n")
+ }
+ if r.References != "" {
+ b.WriteString("References: " + r.References + "\n")
+ }
+ b.WriteString("\n")
+ b.WriteString(r.Body)
+ return []byte(b.String()), nil
+}
+
+func validHeaderValue(value string) bool { return !strings.ContainsAny(value, "\r\n\x00") }
+func isRemailerName(s string) bool {
+ if s == "" {
+ return false
+ }
+ for _, c := range s {
+ if !(c == '-' || c == '_' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9') {
+ return false
+ }
+ }
+ return true
+}
+
+func loadKeyring(path string) (map[string]remailerKey, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("open public keyring: %w", err)
+ }
+ defer f.Close()
+ keys := make(map[string]remailerKey)
+ s := bufio.NewScanner(f)
+ var current remailerKey
+ var phase int
+ for s.Scan() {
+ line := strings.TrimSpace(s.Text())
+ switch phase {
+ case 0:
+ parts := strings.Fields(line)
+ if len(parts) != 7 {
+ continue
+ }
+ id, e := hex.DecodeString(parts[2])
+ if e != nil || len(id) != 16 {
+ continue
+ }
+ from, e1 := time.Parse("2006-01-02", parts[5])
+ until, e2 := time.Parse("2006-01-02", parts[6])
+ if e1 != nil || e2 != nil {
+ continue
+ }
+ now := time.Now().UTC()
+ if now.Before(from) || now.After(until.Add(24*time.Hour)) {
+ continue
+ }
+ current = remailerKey{name: parts[0], address: parts[1], keyID: id}
+ phase = 1
+ case 1:
+ if line == "-----Begin Mix Key-----" {
+ phase = 2
+ }
+ case 2:
+ id, e := hex.DecodeString(line)
+ if e != nil || !bytes.Equal(id, current.keyID) {
+ phase = 0
+ } else {
+ phase = 3
+ }
+ case 3:
+ key, e := hex.DecodeString(line)
+ if e != nil || len(key) != 32 {
+ phase = 0
+ } else {
+ current.publicKey = key
+ phase = 4
+ }
+ case 4:
+ if line == "-----End Mix Key-----" {
+ keys[current.name] = current
+ }
+ phase = 0
+ }
+ }
+ if err := s.Err(); err != nil {
+ return nil, fmt.Errorf("read public keyring: %w", err)
+ }
+ if len(keys) == 0 {
+ return nil, ErrInvalidKeyring
+ }
+ return keys, nil
+}
+
+func encodePacket(plain []byte, chain []remailerKey) ([]byte, error) {
+ if len(chain) == 0 || len(chain) > maxChainLength || len(plain) > maxPlainBytes {
+ return nil, ErrInvalidRequest
+ }
+ payload := make([]byte, messageBytes)
+ if _, err := io.ReadFull(rand.Reader, payload); err != nil {
+ return nil, err
+ }
+ copy(payload[maxChainLength*headerBytes:], plain)
+ keys := make([][]byte, len(chain)-1)
+ ivs := make([][]byte, len(chain)-1)
+ for i := range keys {
+ keys[i] = randomBytes(32)
+ ivs[i] = randomBytes(12)
+ }
+ finalIV := randomBytes(16)
+ finalID := randomBytes(16)
+ exitAES := randomBytes(32)
+ final := make([]byte, 64)
+ copy(final, finalIV)
+ final[16], final[17] = 1, 1
+ copy(final[18:], finalID)
+ binary.LittleEndian.PutUint32(final[34:38], uint32(len(plain)))
+ copy(payload[maxChainLength*headerBytes:], aesCTR(payload[maxChainLength*headerBytes:], exitAES, finalIV))
+ shiftHeaders(payload)
+ if len(chain) > 1 {
+ deterministic(payload, keys, ivs, len(chain), 0)
+ }
+ exitData := slotData(1, finalID, exitAES, final, antiTag(payload))
+ copy(payload[:headerBytes], encryptedHeader(chain[len(chain)-1], exitData))
+ for hop := 0; hop < len(chain)-1; hop++ {
+ partial := ivs[hop]
+ next := chain[len(chain)-hop-1].address
+ info := make([]byte, 64)
+ copy(info, partial)
+ copy(info[12:], []byte(next))
+ encryptAll(payload, keys[hop], ivs[hop])
+ shiftHeaders(payload)
+ deterministic(payload, keys, ivs, len(chain), hop+1)
+ data := slotData(0, randomBytes(16), keys[hop], info, antiTag(payload))
+ copy(payload[:headerBytes], encryptedHeader(chain[len(chain)-hop-2], data))
+ }
+ return payload, nil
+}
+
+func slotData(kind byte, packetID, aesKey, info, tag []byte) []byte {
+ b := make([]byte, encHeadBytes)
+ b[0], b[1], b[2] = 2, kind, 0
+ copy(b[3:], packetID)
+ copy(b[19:], aesKey)
+ binary.LittleEndian.PutUint16(b[51:53], uint16(time.Now().UTC().Unix()/86400))
+ copy(b[53:], info)
+ copy(b[117:], tag)
+ return b
+}
+
+func encryptedHeader(key remailerKey, data []byte) []byte {
+ var recipient, sender [32]byte
+ copy(recipient[:], key.publicKey)
+ if _, err := io.ReadFull(rand.Reader, sender[:]); err != nil {
+ panic(err)
+ }
+ var nonce [24]byte
+ if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil {
+ panic(err)
+ }
+ sealed := box.Seal(nil, data, &nonce, &recipient, &sender)
+ b := make([]byte, headerBytes)
+ copy(b, key.keyID)
+ copy(b[16:], sender[:])
+ copy(b[48:], nonce[:])
+ copy(b[72:], sealed)
+ return b
+}
+
+func aesCTR(input, key, iv []byte) []byte {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ panic(err)
+ }
+ output := make([]byte, len(input))
+ cipher.NewCTR(block, iv).XORKeyStream(output, input)
+ return output
+}
+func randomBytes(n int) []byte {
+ b := make([]byte, n)
+ if _, err := io.ReadFull(rand.Reader, b); err != nil {
+ panic(err)
+ }
+ return b
+}
+func shiftHeaders(p []byte) {
+ copy(p[headerBytes:maxChainLength*headerBytes], p[:(maxChainLength-1)*headerBytes])
+}
+func antiTag(p []byte) []byte {
+ h, _ := blake2s.New256(nil)
+ h.Write(p[headerBytes:])
+ return h.Sum(nil)
+}
+func seqIV(partial []byte, slot int) []byte {
+ iv := make([]byte, 16)
+ copy(iv[:4], partial[:4])
+ binary.LittleEndian.PutUint32(iv[4:8], uint32(slot))
+ copy(iv[8:], partial[4:])
+ return iv
+}
+func encryptAll(p, key, partial []byte) {
+ for slot := 0; slot <= maxChainLength; slot++ {
+ start := slot * headerBytes
+ end := start + headerBytes
+ if slot == maxChainLength {
+ start = maxChainLength * headerBytes
+ end = len(p)
+ }
+ copy(p[start:end], aesCTR(p[start:end], key, seqIV(partial, slot)))
+ }
+}
+func deterministic(p []byte, keys, ivs [][]byte, chainLen, hop int) {
+ bottom := maxChainLength - 1
+ top := bottom - (chainLen - hop - 2)
+ for slot := top; slot <= bottom; slot++ {
+ fake := make([]byte, headerBytes)
+ use := bottom
+ for i := bottom - slot + hop; i >= 0; i-- {
+ copy(fake, aesCTR(fake, keys[i], seqIV(ivs[i], use)))
+ use--
+ }
+ copy(p[slot*headerBytes:(slot+1)*headerBytes], fake)
+ }
+}
+
+func armor(payload []byte) []byte {
+ h, _ := blake2s.New256(nil)
+ h.Write(payload)
+ var b bytes.Buffer
+ b.WriteString("::\nRemailer-Type: yamn-" + armorVersion + "\n\n-----BEGIN REMAILER MESSAGE-----\n")
+ b.WriteString(strconv.Itoa(len(payload)) + "\n")
+ b.WriteString(hex.EncodeToString(h.Sum(nil)) + "\n")
+ encoded := base64.StdEncoding.EncodeToString(payload)
+ for len(encoded) > 0 {
+ n := 64
+ if len(encoded) < n {
+ n = len(encoded)
+ }
+ b.WriteString(encoded[:n])
+ b.WriteByte('\n')
+ encoded = encoded[n:]
+ }
+ b.WriteString("\n-----END REMAILER MESSAGE-----\n")
+ return b.Bytes()
+}
diff --git a/yamn/encoder/encoder_test.go b/yamn/encoder/encoder_test.go
new file mode 100644
index 0000000..a6ea192
--- /dev/null
+++ b/yamn/encoder/encoder_test.go
@@ -0,0 +1,115 @@
+package encoder
+
+import (
+ "encoding/base64"
+ "os"
+ "strings"
+ "testing"
+)
+
+func testKeyring(t *testing.T) string {
+ t.Helper()
+ path := t.TempDir() + "/pubring.mix"
+ var content strings.Builder
+ for i, name := range []string{"entry", "middle", "exit"} {
+ keyID := strings.Repeat(string(rune('a'+i)), 32)
+ publicKey := strings.Repeat(string(rune('d'+i)), 64)
+ content.WriteString(name + " " + name + "@example.org " + keyID + " 4:0.2c E 2025-01-01 2099-12-31\n\n")
+ content.WriteString("-----Begin Mix Key-----\n" + keyID + "\n" + publicKey + "\n-----End Mix Key-----\n")
+ }
+ if err := os.WriteFile(path, []byte(content.String()), 0600); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func TestValidateEmail(t *testing.T) {
+ r := Request{Kind: Email, PublicKeyring: "/tmp/pubring.mix", Entry: "entry", Chain: []string{"entry"}, To: "user@example.org", Body: "hello"}
+ if err := Validate(r); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestValidateRejectsPlainIncompleteRequest(t *testing.T) {
+ r := Request{Kind: Email, PublicKeyring: "/tmp/pubring.mix", Entry: "entry", Chain: []string{"entry"}, To: "bad", Body: "hello"}
+ if err := Validate(r); err == nil {
+ t.Fatal("expected invalid recipient")
+ }
+}
+
+func TestValidateUsenetRequiresGatewayRecipient(t *testing.T) {
+ r := Request{Kind: Usenet, PublicKeyring: "/tmp/pubring.mix", Chain: []string{"entry"}, Newsgroup: "misc.test", Body: "hello"}
+ if err := Validate(r); err == nil {
+ t.Fatal("expected missing Usenet gateway recipient to be rejected")
+ }
+ r.To = "mail2news@example.org"
+ if err := Validate(r); err != nil {
+ t.Fatalf("expected valid Usenet request: %v", err)
+ }
+}
+
+func TestComposeUsenetGatewayHeaders(t *testing.T) {
+ plain, err := composeMessage(Request{
+ Kind: Usenet, To: "mail2news@example.org", Subject: "test", Newsgroup: "misc.test", Body: "hello",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ text := string(plain)
+ if !strings.Contains(text, "To: mail2news@example.org\n") || !strings.Contains(text, "Newsgroups: misc.test\n") {
+ t.Fatalf("missing Usenet delivery headers: %q", text)
+ }
+}
+
+func TestEncodeProducesYAMNArmor(t *testing.T) {
+ keyring := testKeyring(t)
+ result, err := Encode(Request{
+ Kind: Email, PublicKeyring: keyring, Entry: "entry", Chain: []string{"entry"},
+ From: "Anonymous <anon@example.org>", To: "user@example.org", Subject: "test", Body: "hello",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.EntryAddress != "entry@example.org" {
+ t.Fatalf("unexpected entry address: %q", result.EntryAddress)
+ }
+ text := string(result.Envelope)
+ if !strings.Contains(text, "-----BEGIN REMAILER MESSAGE-----") || !strings.Contains(text, "-----END REMAILER MESSAGE-----") {
+ t.Fatal("missing YAMN armor markers")
+ }
+ lines := strings.Split(text, "\n")
+ start := 0
+ for i, line := range lines {
+ if line == "-----BEGIN REMAILER MESSAGE-----" {
+ start = i + 3
+ break
+ }
+ }
+ var encoded strings.Builder
+ for _, line := range lines[start:] {
+ if line == "" || strings.HasPrefix(line, "-----END") {
+ break
+ }
+ encoded.WriteString(line)
+ }
+ packet, err := base64.StdEncoding.DecodeString(encoded.String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(packet) != messageBytes {
+ t.Fatalf("unexpected packet size: got %d, want %d", len(packet), messageBytes)
+ }
+}
+
+func TestEncodeMultiHop(t *testing.T) {
+ result, err := Encode(Request{
+ Kind: Email, PublicKeyring: testKeyring(t), Entry: "entry",
+ Chain: []string{"entry", "middle", "exit"}, To: "user@example.org", Body: "hello",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(result.Envelope) == 0 || result.EntryAddress != "entry@example.org" {
+ t.Fatal("multi-hop envelope was not produced")
+ }
+}
diff --git a/yamn/go.mod b/yamn/go.mod
new file mode 100644
index 0000000..04e0e46
--- /dev/null
+++ b/yamn/go.mod
@@ -0,0 +1,7 @@
+module git.virebent.art/virebent/yamnweb/yamn
+
+go 1.26.2
+
+require golang.org/x/crypto v0.51.0
+
+require golang.org/x/sys v0.44.0 // indirect
diff --git a/yamn/go.sum b/yamn/go.sum
new file mode 100644
index 0000000..c826b88
--- /dev/null
+++ b/yamn/go.sum
@@ -0,0 +1,4 @@
+golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
+golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
+golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
+golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
diff --git a/yamn_config.php b/yamn_config.php
new file mode 100644
index 0000000..2959736
--- /dev/null
+++ b/yamn_config.php
@@ -0,0 +1,15 @@
+<?php
+declare(strict_types=1);
+
+function yamnConfig(string $key, string $fallback = ''): string
+{
+ $path = getenv('YAMN_CONFIG_FILE') ?: '/etc/yamnweb/yamnweb.env';
+ if (!is_readable($path)) {
+ return getenv($key) !== false ? (string)getenv($key) : $fallback;
+ }
+ $values = parse_ini_file($path, false, INI_SCANNER_RAW);
+ if (!is_array($values) || !isset($values[$key]) || !is_string($values[$key])) {
+ return getenv($key) !== false ? (string)getenv($key) : $fallback;
+ }
+ return trim($values[$key]);
+}
diff --git a/yamn_encoder.php b/yamn_encoder.php
new file mode 100644
index 0000000..3503b54
--- /dev/null
+++ b/yamn_encoder.php
@@ -0,0 +1,42 @@
+<?php
+declare(strict_types=1);
+
+require_once __DIR__ . '/yamn_config.php';
+
+/** Encode a plaintext message with the local YAMN encoder binary. */
+function encodeYamnMessage(array $request): array
+{
+ $encoder = yamnConfig('YAMN_ENCODER', '/usr/local/bin/yamn-encode');
+ $keyring = yamnConfig('YAMN_PUBRING', '/opt/yamn-master/pubring.mix');
+ if (!is_executable($encoder)) {
+ return ['success' => false, 'error' => 'YAMN encoder is not available'];
+ }
+ $request['public_keyring'] = $keyring;
+ try {
+ $input = json_encode($request, JSON_THROW_ON_ERROR);
+ } catch (JsonException $exception) {
+ return ['success' => false, 'error' => 'Unable to encode YAMN request'];
+ }
+
+ $descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
+ $process = proc_open([$encoder], $descriptors, $pipes);
+ if (!is_resource($process)) {
+ return ['success' => false, 'error' => 'Unable to start YAMN encoder'];
+ }
+ fwrite($pipes[0], $input);
+ fclose($pipes[0]);
+ $stdout = stream_get_contents($pipes[1]);
+ fclose($pipes[1]);
+ $stderr = stream_get_contents($pipes[2]);
+ fclose($pipes[2]);
+ $status = proc_close($process);
+
+ $response = json_decode($stdout, true);
+ if ($status !== 0 || !is_array($response) || ($response['success'] ?? false) !== true) {
+ return ['success' => false, 'error' => 'YAMN encoding failed'];
+ }
+ if (!isset($response['entry_address'], $response['envelope']) || !is_string($response['envelope'])) {
+ return ['success' => false, 'error' => 'Invalid response from YAMN encoder'];
+ }
+ return ['success' => true, 'entry_address' => $response['entry_address'], 'envelope' => $response['envelope']];
+}