diff options
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | about.html | 2 | ||||
| -rw-r--r-- | index.php | 52 | ||||
| -rw-r--r-- | ingress/README.md | 5 | ||||
| -rw-r--r-- | ingress/src/main.rs | 46 | ||||
| -rw-r--r-- | yamn/encoder/encoder.go | 57 | ||||
| -rw-r--r-- | yamn/encoder/encoder_test.go | 69 |
7 files changed, 215 insertions, 21 deletions
@@ -120,6 +120,9 @@ The ingress is installed separately from `deploy/yamn-nym-ingress.service`. Its environment file must contain the reviewed current YAMN Entry allowlist. Public YAMN exits deliver to the clearnet Mail-to-News endpoint; they must not be configured with an Onion-only destination they cannot resolve or reach. +The ingress retries transient Tor connection failures only before beginning +the SMTP transaction, avoiding both immediate message loss and unsafe retry +after an ambiguous SMTP handoff. ## Delivery semantics @@ -136,6 +139,8 @@ Nym payloads in logs. ## Security notes - All form input is validated before encoding. +- Usenet `References` accepts a bounded chain of Message-IDs and generates + `In-Reply-To` from the last ID; `Reply-To` is validated as an email address. - The YAMN encoder reads only the local reviewed public keyring. - The Nym recipient and Mail-to-News address are deployment configuration, never browser-controlled destinations. @@ -218,7 +218,7 @@ <article class="card"> <h3>Email or Usenet</h3> <p>For email, the selected recipient is placed inside the encrypted message. For Usenet, the selected newsgroup is preserved and the server-configured Mail-to-News recipient performs SMTP-to-NNTP conversion.</p> - <p><strong>References</strong> can link an article to an existing thread. It does not enable fetching or replies.</p> + <p><strong>References</strong> accepts one or more Message-IDs and links the article to an existing Usenet thread. The last Message-ID is also emitted as <strong>In-Reply-To</strong>. The separate <strong>Reply-To</strong> field is an optional email response address; neither field enables message retrieval in this send-only service.</p> </article> </div> </section> @@ -86,6 +86,43 @@ if (empty($_SESSION['csrf_token'])) { $currentCsrfToken = $_SESSION['csrf_token']; /** + * Return remailer names whose public keys are valid today. + * + * @return array<string, bool> Name lookup table. + */ +function getUsableRemailerKeyNames(): array { + static $usableNames = null; + if (is_array($usableNames)) { + return $usableNames; + } + + $usableNames = []; + $keyring = yamnConfig('YAMN_PUBRING', '/opt/yamn-master/pubring.mix'); + if (!is_readable($keyring)) { + return $usableNames; + } + + $today = gmdate('Y-m-d'); + foreach (file($keyring, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { + $parts = preg_split('/\s+/', trim($line)); + if (count($parts) !== 7) { + continue; + } + [$name, , , , , $validFrom, $validUntil] = $parts; + if (!preg_match('/^[a-z0-9_-]+$/', $name) + || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $validFrom) + || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $validUntil)) { + continue; + } + if ($validFrom <= $today && $today <= $validUntil) { + $usableNames[$name] = true; + } + } + + return $usableNames; +} + +/** * Parse remailers from file and return array by type * Entry and Exit can use ANY remailer * Middle should use remailers with specific flags @@ -95,6 +132,7 @@ $currentCsrfToken = $_SESSION['csrf_token']; */ function getRemailers($type) { $remailers = ['*']; // Always include Random option + $usableKeyNames = getUsableRemailerKeyNames(); // Try multiple file locations $files = [ @@ -140,6 +178,7 @@ function getRemailers($type) { // Validate name: lowercase letters, numbers, hyphens only if (!preg_match('/^[a-z0-9-]+$/', $remailerName)) continue; + if (!isset($usableKeyNames[$remailerName])) continue; // Check if last field is 'D' (middle capability flag) $lastField = end($parts); @@ -185,6 +224,9 @@ function resolveRemailer($remailer, $availableRemailers) { $randomIndex = array_rand($candidates); return $candidates[$randomIndex]; } + if (!in_array($remailer, $availableRemailers, true)) { + throw new Exception("Selected remailer is no longer available. Reload the page and choose again."); + } return $remailer; } @@ -246,11 +288,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $middleRemailer = isset($_POST['middle_remailer']) ? filter_var($_POST['middle_remailer'], FILTER_SANITIZE_STRING) : ''; $exitRemailer = isset($_POST['exit_remailer']) ? filter_var($_POST['exit_remailer'], FILTER_SANITIZE_STRING) : ''; $from = isset($_POST['from']) ? filter_var($_POST['from'], FILTER_SANITIZE_STRING) : ''; - $replyTo = isset($_POST['reply_to']) ? filter_var($_POST['reply_to'], FILTER_SANITIZE_STRING) : ''; + $replyTo = isset($_POST['reply_to']) && is_string($_POST['reply_to']) + ? trim($_POST['reply_to']) + : ''; $to = isset($_POST['to']) ? filter_var($_POST['to'], FILTER_SANITIZE_EMAIL) : ''; $subject = isset($_POST['subject']) ? filter_var($_POST['subject'], FILTER_SANITIZE_STRING) : ''; $newsgroups = isset($_POST['newsgroups']) ? filter_var($_POST['newsgroups'], FILTER_SANITIZE_STRING) : ''; - $references = isset($_POST['references']) ? filter_var($_POST['references'], FILTER_SANITIZE_STRING) : ''; + $references = isset($_POST['references']) && is_string($_POST['references']) + ? trim($_POST['references']) + : ''; $data = isset($_POST['data']) ? $_POST['data'] : ''; // Keep original formatting $copies = isset($_POST['copies']) ? intval($_POST['copies']) : 1; @@ -766,7 +812,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { <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="<message-id@example.org>"><small>Optional. Links the article to an existing thread.</small></div> + <div class="form-group"><label for="references">References</label><input type="text" name="references" id="references" maxlength="900" placeholder="<message-id@example.org>"><small>Optional. Enter one or more space-separated Message-IDs, without the <code>References:</code> label. The last ID becomes <code>In-Reply-To</code>.</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> diff --git a/ingress/README.md b/ingress/README.md index d4a8338..e9c5295 100644 --- a/ingress/README.md +++ b/ingress/README.md @@ -3,6 +3,11 @@ This service receives an envelope over the Nym mixnet and delivers the already encrypted YAMN packet to an allowlisted SMTP entry through Tor. +Transient Tor connection failures are retried three times with bounded +backoff before the SMTP transaction begins. Errors after SMTP commands start +are not retried automatically because the remote acceptance state may be +ambiguous. + 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. diff --git a/ingress/src/main.rs b/ingress/src/main.rs index 21da30f..39047ee 100644 --- a/ingress/src/main.rs +++ b/ingress/src/main.rs @@ -11,6 +11,7 @@ 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); +const TOR_CONNECT_ATTEMPTS: u32 = 3; #[derive(Debug, Deserialize)] struct Envelope { @@ -123,13 +124,29 @@ async fn deliver(entry_address: &str, payload: &[u8]) -> Result<()> { 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 attempt = 1; + let stream = loop { + let connection = tokio::time::timeout( + SMTP_TIMEOUT, + Socks5Stream::connect((proxy_host, proxy_port), target.as_str()), + ) + .await; + match connection { + Ok(Ok(stream)) => break stream, + Ok(Err(_)) if attempt < TOR_CONNECT_ATTEMPTS => { + eprintln!("Tor SMTP connection attempt {attempt} failed; retrying"); + } + Err(_) if attempt < TOR_CONNECT_ATTEMPTS => { + eprintln!("Tor SMTP connection attempt {attempt} timed out; retrying"); + } + Ok(Err(error)) => { + return Err(error).context("connect to YAMN SMTP entry through Tor"); + } + Err(error) => return Err(error).context("Tor SMTP connection timed out"), + } + tokio::time::sleep(tor_retry_delay(attempt)).await; + attempt += 1; + }; let mut reader = BufReader::new(stream); let greeting = smtp_read_response(&mut reader).await?; if greeting / 100 != 2 { @@ -189,6 +206,10 @@ async fn deliver(entry_address: &str, payload: &[u8]) -> Result<()> { Ok(()) } +fn tor_retry_delay(failed_attempt: u32) -> Duration { + Duration::from_secs(5 * u64::from(failed_attempt)) +} + async fn run() -> Result<()> { let disconnected = MixnetClientBuilder::new_with_default_storage(storage_paths()?) .await @@ -243,3 +264,14 @@ async fn main() { std::process::exit(1); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tor_retry_delay_uses_bounded_linear_backoff() { + assert_eq!(tor_retry_delay(1), Duration::from_secs(5)); + assert_eq!(tor_retry_delay(2), Duration::from_secs(10)); + } +} diff --git a/yamn/encoder/encoder.go b/yamn/encoder/encoder.go index 3b595b1..6c730c9 100644 --- a/yamn/encoder/encoder.go +++ b/yamn/encoder/encoder.go @@ -24,13 +24,15 @@ import ( ) const ( - maxChainLength = 10 - headerBytes = 256 - encHeadBytes = 160 - bodyBytes = 17920 - maxPlainBytes = 17910 - messageBytes = maxChainLength*headerBytes + bodyBytes - armorVersion = "0.2.7" + maxChainLength = 10 + headerBytes = 256 + encHeadBytes = 160 + bodyBytes = 17920 + maxPlainBytes = 17910 + maxReferencesBytes = 900 + maxReferenceIDs = 20 + messageBytes = maxChainLength*headerBytes + bodyBytes + armorVersion = "0.2.7" ) var ( @@ -137,6 +139,14 @@ func Validate(r Request) error { return fmt.Errorf("%w: invalid header value", ErrInvalidRequest) } } + if strings.TrimSpace(r.ReplyTo) != "" { + if _, err := mail.ParseAddress(r.ReplyTo); err != nil { + return fmt.Errorf("%w: invalid Reply-To address", ErrInvalidRequest) + } + } + if !validReferences(r.References) { + return fmt.Errorf("%w: invalid References message ID", ErrInvalidRequest) + } return nil } @@ -158,8 +168,9 @@ func composeMessage(r Request) ([]byte, error) { if r.Newsgroup != "" { b.WriteString("Newsgroups: " + r.Newsgroup + "\n") } - if r.References != "" { - b.WriteString("References: " + r.References + "\n") + if references := strings.Fields(r.References); len(references) > 0 { + b.WriteString("References: " + strings.Join(references, " ") + "\n") + b.WriteString("In-Reply-To: " + references[len(references)-1] + "\n") } b.WriteString("\n") b.WriteString(r.Body) @@ -167,6 +178,34 @@ func composeMessage(r Request) ([]byte, error) { } func validHeaderValue(value string) bool { return !strings.ContainsAny(value, "\r\n\x00") } +func validReferences(value string) bool { + if value == "" { + return true + } + if len(value) > maxReferencesBytes { + return false + } + references := strings.Fields(value) + if len(references) == 0 || len(references) > maxReferenceIDs { + return false + } + for _, reference := range references { + if len(reference) < 5 || reference[0] != '<' || reference[len(reference)-1] != '>' { + return false + } + messageID := reference[1 : len(reference)-1] + if strings.Count(messageID, "@") != 1 || strings.HasPrefix(messageID, "@") || strings.HasSuffix(messageID, "@") { + return false + } + for _, character := range messageID { + if character < 33 || character > 126 || character == '<' || character == '>' { + return false + } + } + } + return true +} + func isRemailerName(s string) bool { if s == "" { return false diff --git a/yamn/encoder/encoder_test.go b/yamn/encoder/encoder_test.go index 3509070..333b704 100644 --- a/yamn/encoder/encoder_test.go +++ b/yamn/encoder/encoder_test.go @@ -57,16 +57,83 @@ func TestValidateUsenetRequiresGatewayRecipient(t *testing.T) { func TestComposeUsenetGatewayHeaders(t *testing.T) { plain, err := composeMessage(Request{ Kind: Usenet, To: "mail2news@example.org", Subject: "test", Newsgroup: "misc.test", Body: "hello", + References: "<parent@example.org>", }) 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") { + if !strings.Contains(text, "To: mail2news@example.org\n") || + !strings.Contains(text, "Newsgroups: misc.test\n") || + !strings.Contains(text, "References: <parent@example.org>\n") || + !strings.Contains(text, "In-Reply-To: <parent@example.org>\n") { t.Fatalf("missing Usenet delivery headers: %q", text) } } +func TestComposeReferencesChainUsesLastIDAsParent(t *testing.T) { + plain, err := composeMessage(Request{ + Kind: Usenet, To: "mail2news@example.org", Subject: "test", Newsgroup: "misc.test", Body: "hello", + References: " <root@example.org> <parent@example.org> ", + }) + if err != nil { + t.Fatal(err) + } + text := string(plain) + if !strings.Contains(text, "References: <root@example.org> <parent@example.org>\n") || + !strings.Contains(text, "In-Reply-To: <parent@example.org>\n") { + t.Fatalf("thread headers were not normalized: %q", text) + } +} + +func TestValidateReferences(t *testing.T) { + tests := []struct { + name string + references string + valid bool + }{ + {name: "empty", valid: true}, + {name: "whitespace only", references: " "}, + {name: "parent", references: "<parent@example.org>", valid: true}, + {name: "thread chain", references: "<root@example.org> <parent@example.org>", valid: true}, + {name: "missing brackets", references: "parent@example.org"}, + {name: "missing local part", references: "<@example.org>"}, + {name: "multiple at signs", references: "<parent@example@org>"}, + {name: "embedded newline", references: "<root@example.org>\n<parent@example.org>"}, + {name: "header label", references: "References: <parent@example.org>"}, + {name: "too long", references: "<" + strings.Repeat("a", maxReferencesBytes) + "@example.org>"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := Request{ + Kind: Email, PublicKeyring: "/tmp/pubring.mix", Entry: "entry", Chain: []string{"entry"}, + To: "user@example.org", Body: "hello", References: test.references, + } + err := Validate(request) + if test.valid && err != nil { + t.Fatalf("expected valid References, got %v", err) + } + if !test.valid && err == nil { + t.Fatal("expected invalid References to be rejected") + } + }) + } +} + +func TestValidateReplyTo(t *testing.T) { + request := Request{ + Kind: Email, PublicKeyring: "/tmp/pubring.mix", Entry: "entry", Chain: []string{"entry"}, + To: "user@example.org", Body: "hello", ReplyTo: "Pseudonym <reply@example.org>", + } + if err := Validate(request); err != nil { + t.Fatalf("expected valid Reply-To, got %v", err) + } + request.ReplyTo = "not an address" + if err := Validate(request); err == nil { + t.Fatal("expected invalid Reply-To to be rejected") + } +} + func TestEncodeProducesYAMNArmor(t *testing.T) { keyring := testKeyring(t) result, err := Encode(Request{ |
