summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md5
-rw-r--r--about.html2
-rw-r--r--index.php52
-rw-r--r--ingress/README.md5
-rw-r--r--ingress/src/main.rs46
-rw-r--r--yamn/encoder/encoder.go57
-rw-r--r--yamn/encoder/encoder_test.go69
7 files changed, 215 insertions, 21 deletions
diff --git a/README.md b/README.md
index 745c373..da2acb2 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/about.html b/about.html
index 766e12e..a3fa187 100644
--- a/about.html
+++ b/about.html
@@ -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>
diff --git a/index.php b/index.php
index af646a6..bbab642 100644
--- a/index.php
+++ b/index.php
@@ -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="&lt;message-id@example.org&gt;"><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="&lt;message-id@example.org&gt;"><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{