summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGab <24553253+gabrix73@users.noreply.github.com>2025-11-13 17:11:46 +0100
committerGitHub <noreply@github.com>2025-11-13 17:11:46 +0100
commitdfed1655238922bf9f85e96838756ac196ccf5cd (patch)
tree668dc961a28d715361a44fc9baf9fd6c6b7e6f35
parentc7d183d4ae08dc1b0a15a5b8af76a32e620347fe (diff)
downloadm2usenet-and-mail2news-dfed1655238922bf9f85e96838756ac196ccf5cd.tar.gz
m2usenet-and-mail2news-dfed1655238922bf9f85e96838756ac196ccf5cd.tar.xz
m2usenet-and-mail2news-dfed1655238922bf9f85e96838756ac196ccf5cd.zip
Upgrade m2usenet Gateway Handler to v2.1.0
Updated m2usenet Gateway Handler to version 2.1.0 with multiple privacy fixes, including disabling padding, consistent hashcash normalization, and improved relay selection strategy.
-rw-r--r--send.php568
1 files changed, 325 insertions, 243 deletions
diff --git a/send.php b/send.php
index 59a82b6..05e6b21 100644
--- a/send.php
+++ b/send.php
@@ -1,8 +1,12 @@
<?php
// /var/www/m2usenet/send.php
-// m2usenet Gateway Handler v3.1.0 - PHP Native SOCKS5 + Random Fog Sphinx Relay Selection
-// Maximum security: no external dependencies, constant-time operations, anti-analysis
-// Security-hardened implementation with all threat mitigations
+// m2usenet Gateway Handler v2.1.0 - PRIVACY FIX RELEASE
+// FIX 1: Padding DISABLED (was corrupting Usenet messages)
+// FIX 2: Hashcash normalization now consistent
+// FIX 3: X-Hashcash header REMOVED (timestamp leak)
+// FIX 4: Date header jitter ±30s (anti-timing correlation)
+// FIX 5: Dot stuffing verified correct (RFC 5321)
+// FIX 3: Dot stuffing verified correct
// PRODUCTION SECURITY
ini_set('display_errors', 0);
@@ -10,42 +14,34 @@ ini_set('display_startup_errors', 0);
ini_set('log_errors', 1);
error_reporting(0);
-// SMTP Relay Configuration - Fog Sphinx Network (Random Selection)
-define('FOG_SPHINX_RELAYS', [
- [
- 'host' => 'dgayvmsxvvofpdxsas22fo7eu5tous6aavzjs6eun6jnouluwqflz7ad.onion',
- 'port' => 2525,
- 'mail2news' => 'mail2news@xilb7y4kj6u6qfo45o3yk2kilfv54ffukzei3puonuqlncy7cn2afwyd.onion',
- 'name' => 'news'
- ],
- [
- 'host' => 'iycr4wfrdzieogdfeo7uxrj77w2vjlrhlrv3jg2ve62oe5aceqsqu7ad.onion',
- 'port' => 2525,
- 'mail2news' => 'mail2news@xilb7y4kj6u6qfo45o3yk2kilfv54ffukzei3puonuqlncy7cn2afwyd.onion',
- 'name' => 'mail'
- ],
- [
- 'host' => 'ztavxfthfr2fgovxzfg3sudl2ajtbo6db4iw5cx37nzr5jc6q7ma6ryd.onion',
- 'port' => 2525,
- 'mail2news' => 'mail2news@xilb7y4kj6u6qfo45o3yk2kilfv54ffukzei3puonuqlncy7cn2afwyd.onion',
- 'name' => 'kvara'
- ],
- [
- 'host' => 'hqahdugpxz7jrmsfxqav5nfl452cvc5rsdhcpyixvcbcpfsopwnznlqd.onion',
- 'port' => 2525,
- 'mail2news' => 'mail2news@xilb7y4kj6u6qfo45o3yk2kilfv54ffukzei3puonuqlncy7cn2afwyd.onion',
- 'name' => 'mct8'
- ]
+set_time_limit(180);
+ini_set('max_execution_time', 180);
+
+// SMTP Relay Configuration
+define('PRIMARY_RELAY', [
+ 'host' => '4uwpi53u524xdphjw2dv5kywsxmyjxtk4facb76jgl3sc3nda3sz4fqd.onion',
+ 'port' => 25,
+ 'mail2news' => 'mail2news@xilb7y4kj6u6qfo45o3yk2kilfv54ffukzei3puonuqlncy7cn2afwyd.onion',
+ 'name' => 'fog-primary'
+]);
+
+define('FALLBACK_RELAY', [
+ 'host' => 'xilb7y4kj6u6qfo45o3yk2kilfv54ffukzei3puonuqlncy7cn2afwyd.onion',
+ 'port' => 25,
+ 'mail2news' => 'mail2news@xilb7y4kj6u6qfo45o3yk2kilfv54ffukzei3puonuqlncy7cn2afwyd.onion',
+ 'name' => 'smtp-fallback'
]);
// Security Configuration
define('LOG_FILE', '/var/log/m2usenet/send.log');
define('RATE_LIMIT_FILE', '/var/www/m2usenet/rate_limits.json');
define('HASHCASH_CACHE', '/var/www/m2usenet/hashcash_cache.json');
+define('SUBMISSION_LOCK_DIR', '/var/www/m2usenet/locks');
+define('SUBMISSION_LOCK_TTL', 120);
define('MAX_NEWSGROUPS', 3);
define('MAX_MESSAGE_SIZE', 65536);
define('MIN_MESSAGE_SIZE', 10);
-define('PADDING_ENABLED', true);
+define('PADDING_ENABLED', false); // FIX: DISABLED - was corrupting messages
define('PADDING_BOUNDARY', 1024);
define('RATE_LIMIT_REQUESTS', 10);
define('RATE_LIMIT_WINDOW', 3600);
@@ -53,31 +49,26 @@ define('HASHCASH_MIN_BITS', 20);
define('HASHCASH_CACHE_TTL', 172800);
define('QUIET_MODE', false);
-// Message-ID Generator Configuration (NEW)
define('MESSAGE_ID_TIMEZONE', 'UTC');
define('MESSAGE_ID_USE_MD5', true);
define('MESSAGE_ID_DOMAIN', 'm2usenet.local');
define('MESSAGE_ID_JITTER_MIN', -30);
define('MESSAGE_ID_JITTER_MAX', 30);
-// Timing obfuscation - anti-analysis
define('BASE_TIMEOUT', 120);
define('MAX_JITTER_MS', 5000);
define('MIN_RANDOM_DELAY_MS', 100);
define('MAX_RANDOM_DELAY_MS', 3000);
-// SOCKS5 Configuration
define('SOCKS5_PROXY', '127.0.0.1');
define('SOCKS5_PORT', 9050);
define('SOCKS5_TIMEOUT', 120);
define('SMTP_READ_TIMEOUT', 30);
+define('SMTP_FINAL_TIMEOUT', 30);
-// Network security
define('MAX_SMTP_RESPONSE_SIZE', 8192);
-define('MAX_RETRIES', 2);
-define('RETRY_DELAY_BASE', 5);
+define('CONNECTION_RETRY_DELAY', 3);
-// Session security
if (session_status() === PHP_SESSION_NONE) {
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1);
@@ -92,7 +83,6 @@ function secureLog($message, $level = 'INFO') {
}
$timestamp = gmdate('Y-m-d H:i:s');
- // Sanitize: remove emails, message-ids, sensitive data
$sanitized = preg_replace('/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', '[email]', $message);
$sanitized = preg_replace('/<[^>]+@[^>]+>/', '[message-id]', $sanitized);
$sanitized = preg_replace('/\b[a-z2-7]{56}\.onion\b/', '[onion]', $sanitized);
@@ -125,18 +115,47 @@ function cryptoRandInt($min, $max) {
}
}
-function selectRandomRelay() {
- // Cryptographically secure random relay selection
- $relays = FOG_SPHINX_RELAYS;
- $index = cryptoRandInt(0, count($relays) - 1);
+function sendMessage($data) {
+ secureLog("=== Simplified 2-relay strategy: PRIMARY → FALLBACK ===");
+
+ $relay = PRIMARY_RELAY;
+ secureLog("Attempting delivery via PRIMARY relay: {$relay['name']} ({$relay['host']}:{$relay['port']})");
+
+ $result = sendViaNativePHPSMTP(
+ $data,
+ $relay['host'],
+ $relay['port'],
+ $relay['mail2news']
+ );
+
+ if (!$result['success']) {
+ secureLog("PRIMARY relay failed, switching to FALLBACK relay", 'WARNING');
+
+ $relay = FALLBACK_RELAY;
+ secureLog("Attempting delivery via FALLBACK relay: {$relay['name']} ({$relay['host']}:{$relay['port']})");
+
+ $backoff = cryptoRandInt(2, 5);
+ secureLog("Waiting {$backoff}s before fallback attempt");
+ sleep($backoff);
+
+ $result = sendViaNativePHPSMTP(
+ $data,
+ $relay['host'],
+ $relay['port'],
+ $relay['mail2news']
+ );
+ }
- secureLog("Selected Fog Sphinx relay: " . $relays[$index]['name']);
+ if ($result['success']) {
+ secureLog("Message delivered successfully via {$relay['name']} relay");
+ } else {
+ secureLog("Message delivery failed on both PRIMARY and FALLBACK relays", 'ERROR');
+ }
- return $relays[$index];
+ return $result;
}
function constantTimeCompare($a, $b) {
- // Constant-time string comparison to prevent timing attacks
if (function_exists('hash_equals')) {
return hash_equals($a, $b);
}
@@ -154,20 +173,16 @@ function constantTimeCompare($a, $b) {
}
function randomDelay($baseSeconds = 0) {
- // Anti-timing analysis: randomized delays
$jitterMs = cryptoRandInt(0, MAX_JITTER_MS);
$totalUs = ($baseSeconds * 1000000) + ($jitterMs * 1000);
usleep($totalUs);
}
function generateSecureMessageID($domain = null, $useMD5 = null, $timezone = null) {
- // Advanced Message-ID generator based on mid.go
- // Use config defaults if not specified
$domain = $domain ?? MESSAGE_ID_DOMAIN;
$useMD5 = $useMD5 ?? MESSAGE_ID_USE_MD5;
$timezone = $timezone ?? MESSAGE_ID_TIMEZONE;
- // Create timezone-aware datetime
try {
$dateTime = new DateTime('now', new DateTimeZone($timezone));
} catch (Exception $e) {
@@ -175,23 +190,17 @@ function generateSecureMessageID($domain = null, $useMD5 = null, $timezone = nul
$dateTime = new DateTime('now', new DateTimeZone('UTC'));
}
- // Add random jitter to timestamp (anti-timing analysis)
$jitterSeconds = cryptoRandInt(MESSAGE_ID_JITTER_MIN, MESSAGE_ID_JITTER_MAX);
$dateTime->modify("{$jitterSeconds} seconds");
- // Date component: YYYYMMDD.HHMMSS format
$dateComponent = $dateTime->format('Ymd.His');
-
- // Random component: 8 hex chars (32 bits entropy)
$randomComponent = bin2hex(cryptoRandBytes(4));
if ($useMD5) {
- // MD5 format: hash the timestamp+random to obscure timing patterns
$combined = $dateComponent . '.' . $randomComponent;
$hash = md5($combined);
$messageId = sprintf("<%s@%s>", $hash, $domain);
} else {
- // Standard format: timestamp.random@domain
$messageId = sprintf("<%s.%s@%s>", $dateComponent, $randomComponent, $domain);
}
@@ -213,7 +222,6 @@ function verifyCSRFToken($token) {
}
function checkRateLimit() {
- // IP-based rate limiting with time-window hashing
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$ipHash = hash('sha256', $ip . date('Y-m-d-H'));
@@ -226,7 +234,6 @@ function checkRateLimit() {
}
$now = time();
- // Cleanup expired entries
foreach ($limits as $hash => $data) {
if ($now - $data['first_request'] > RATE_LIMIT_WINDOW) {
unset($limits[$hash]);
@@ -246,21 +253,19 @@ function checkRateLimit() {
if ($limits[$ipHash]['count'] > RATE_LIMIT_REQUESTS) {
secureLog("Rate limit exceeded for hashed IP", 'WARNING');
- randomDelay(cryptoRandInt(2, 5)); // Anti-timing: delay before rejection
+ randomDelay(cryptoRandInt(2, 5));
return false;
}
return true;
}
+// FIX: Hashcash normalization now strips ALL whitespace consistently
function normalizeHashcashToken($token) {
- // Normalize whitespace to prevent cache bypass
- $normalized = preg_replace('/\s+/', '', $token);
- return trim($normalized);
+ return preg_replace('/\s+/', '', trim($token));
}
-function checkHashcashReplay($token) {
- // Replay attack protection with expiring cache
+function checkHashcashReplay($tokenHash) {
if (!file_exists(HASHCASH_CACHE)) {
return false;
}
@@ -268,7 +273,6 @@ function checkHashcashReplay($token) {
$cache = json_decode(@file_get_contents(HASHCASH_CACHE), true) ?? [];
$now = time();
- // Cleanup expired tokens (prevent cache growth)
$cleaned = false;
foreach ($cache as $hash => $timestamp) {
if ($now - $timestamp > HASHCASH_CACHE_TTL) {
@@ -281,39 +285,96 @@ function checkHashcashReplay($token) {
@file_put_contents(HASHCASH_CACHE, json_encode($cache), LOCK_EX);
}
- $normalizedToken = normalizeHashcashToken($token);
- $tokenHash = hash('sha256', $normalizedToken);
-
if (isset($cache[$tokenHash])) {
secureLog("Replay attack detected - token hash: " . substr($tokenHash, 0, 16), 'WARNING');
- randomDelay(cryptoRandInt(1, 3)); // Anti-timing
+ randomDelay(cryptoRandInt(1, 3));
return true;
}
return false;
}
-function markHashcashUsed($token) {
- // Atomically mark token as used
+function markHashcashUsed($tokenHash) {
$cache = [];
if (file_exists(HASHCASH_CACHE)) {
$cache = json_decode(@file_get_contents(HASHCASH_CACHE), true) ?? [];
}
- $normalizedToken = normalizeHashcashToken($token);
- $tokenHash = hash('sha256', $normalizedToken);
$cache[$tokenHash] = time();
-
@file_put_contents(HASHCASH_CACHE, json_encode($cache), LOCK_EX);
}
-function verifyHashcash($token, $fromEmail) {
- // Constant-time hashcash verification
- $token = normalizeHashcashToken($token);
+function acquireSubmissionLock($tokenHash) {
+ if (!is_dir(SUBMISSION_LOCK_DIR)) {
+ @mkdir(SUBMISSION_LOCK_DIR, 0700, true);
+ }
+
+ $lockFile = SUBMISSION_LOCK_DIR . '/' . $tokenHash . '.lock';
+
+ if (file_exists($lockFile)) {
+ $lockAge = time() - filemtime($lockFile);
+
+ if ($lockAge < SUBMISSION_LOCK_TTL) {
+ secureLog("Duplicate submission detected - lock exists (age: {$lockAge}s)", 'WARNING');
+ return false;
+ } else {
+ @unlink($lockFile);
+ secureLog("Expired lock removed (age: {$lockAge}s)", 'INFO');
+ }
+ }
+
+ if (@file_put_contents($lockFile, time(), LOCK_EX) === false) {
+ secureLog("Failed to create submission lock", 'ERROR');
+ return false;
+ }
- $parts = explode(':', $token);
+ secureLog("Submission lock acquired: " . substr($tokenHash, 0, 16), 'INFO');
+ return $lockFile;
+}
+
+function releaseSubmissionLock($lockFile) {
+ if ($lockFile && file_exists($lockFile)) {
+ @unlink($lockFile);
+ secureLog("Submission lock released", 'INFO');
+ }
+}
+
+function cleanupExpiredLocks() {
+ if (!is_dir(SUBMISSION_LOCK_DIR)) {
+ return;
+ }
+
+ $now = time();
+ $files = @scandir(SUBMISSION_LOCK_DIR);
+
+ if (!$files) {
+ return;
+ }
+
+ $cleaned = 0;
+ foreach ($files as $file) {
+ if ($file === '.' || $file === '..') {
+ continue;
+ }
+
+ $lockFile = SUBMISSION_LOCK_DIR . '/' . $file;
+ $lockAge = $now - @filemtime($lockFile);
+
+ if ($lockAge > SUBMISSION_LOCK_TTL) {
+ @unlink($lockFile);
+ $cleaned++;
+ }
+ }
+
+ if ($cleaned > 0) {
+ secureLog("Cleaned up $cleaned expired locks", 'INFO');
+ }
+}
+
+function verifyHashcash($normalizedToken, $fromEmail) {
+ $parts = explode(':', $normalizedToken);
if (count($parts) !== 7) {
- randomDelay(0); // Constant-time: same delay on all paths
+ randomDelay(0);
return false;
}
@@ -321,22 +382,24 @@ function verifyHashcash($token, $fromEmail) {
$valid = true;
- // Constant-time checks
$valid = $valid && ($version === '1');
$valid = $valid && ((int)$bits >= HASHCASH_MIN_BITS);
- $valid = $valid && (strcasecmp(trim($resource), trim($fromEmail)) === 0);
+
+ // FIX: Normalize both sides for comparison
+ $normalizedResource = preg_replace('/\s+/', '', trim($resource));
+ $normalizedEmail = preg_replace('/\s+/', '', trim($fromEmail));
+ $valid = $valid && (strcasecmp($normalizedResource, $normalizedEmail) === 0);
if (!$valid) {
- randomDelay(0); // Constant-time
+ randomDelay(0);
return false;
}
- // Verify proof-of-work
- $hash = sha1($token);
+ $hash = sha1($normalizedToken);
$requiredZeros = str_repeat('0', (int)((int)$bits / 4));
if (strpos($hash, $requiredZeros) !== 0) {
- randomDelay(0); // Constant-time
+ randomDelay(0);
return false;
}
@@ -346,24 +409,21 @@ function verifyHashcash($token, $fromEmail) {
function validateInput($data) {
$errors = [];
- // Required fields check
$required = ['from', 'newsgroups', 'subject', 'xhashcash', 'message', 'csrf_token'];
foreach ($required as $field) {
if (!isset($data[$field]) || trim($data[$field]) === '') {
$errors[] = "Missing required field: $field";
- randomDelay(0); // Constant-time
+ randomDelay(0);
return $errors;
}
}
- // CSRF token verification (constant-time)
if (!verifyCSRFToken($data['csrf_token'])) {
$errors[] = "Invalid request token";
- randomDelay(0); // Constant-time
+ randomDelay(0);
return $errors;
}
- // From header validation
if (!preg_match('/^.+\s*<[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}>$/', $data['from'])) {
$errors[] = "Invalid sender format";
randomDelay(0);
@@ -372,7 +432,6 @@ function validateInput($data) {
preg_match('/<([^>]+)>/', $data['from'], $matches);
$fromEmail = $matches[1] ?? '';
- // Newsgroups validation
$newsgroups = array_map('trim', explode(',', $data['newsgroups']));
if (count($newsgroups) > MAX_NEWSGROUPS) {
$errors[] = "Too many newsgroups";
@@ -385,7 +444,6 @@ function validateInput($data) {
}
}
- // Message size validation
$msgSize = strlen($data['message']);
if ($msgSize < MIN_MESSAGE_SIZE || $msgSize > MAX_MESSAGE_SIZE) {
@@ -393,23 +451,31 @@ function validateInput($data) {
randomDelay(0);
}
- // Hashcash verification (constant-time)
- if (empty($errors) && !verifyHashcash($data['xhashcash'], $fromEmail)) {
- $errors[] = "Invalid hashcash token";
- randomDelay(0);
- }
-
- // Replay check (only if hashcash valid)
- if (empty($errors) && checkHashcashReplay($data['xhashcash'])) {
- $errors[] = "Token already used";
- randomDelay(0);
+ // FIX: Normalize token once and use hash for all checks
+ if (empty($errors)) {
+ $normalizedToken = normalizeHashcashToken($data['xhashcash']);
+ $tokenHash = hash('sha256', $normalizedToken);
+
+ if (!verifyHashcash($normalizedToken, $fromEmail)) {
+ $errors[] = "Invalid hashcash token";
+ randomDelay(0);
+ }
+
+ if (empty($errors) && checkHashcashReplay($tokenHash)) {
+ $errors[] = "Token already used";
+ randomDelay(0);
+ }
+
+ // Store hash for later use
+ $data['_tokenHash'] = $tokenHash;
+ $data['_normalizedToken'] = $normalizedToken;
}
return $errors;
}
+// FIX: Padding disabled - was corrupting Usenet messages
function addAdaptivePadding($message) {
- // Size correlation attack prevention with adaptive padding
if (!PADDING_ENABLED) {
return $message;
}
@@ -417,23 +483,19 @@ function addAdaptivePadding($message) {
$msgLen = strlen($message);
$boundary = PADDING_BOUNDARY;
- // Don't pad if already near max size
if ($msgLen >= MAX_MESSAGE_SIZE * 0.95) {
return $message;
}
- // Round up to next boundary
$targetLen = (intdiv($msgLen, $boundary) + 1) * $boundary;
$paddingNeeded = $targetLen - $msgLen;
- // Add random jitter to prevent exact boundary detection
$paddingNeeded += cryptoRandInt(-50, 50);
$paddingNeeded = max(0, min($paddingNeeded, MAX_MESSAGE_SIZE - $msgLen));
if ($paddingNeeded > 0) {
- // Use varied whitespace characters for padding
$paddingChars = [' ', "\t", "\n"];
- $padding = "\n\n"; // Start with double newline
+ $padding = "\n\n";
for ($i = 0; $i < $paddingNeeded - 2; $i++) {
$padding .= $paddingChars[cryptoRandInt(0, count($paddingChars) - 1)];
@@ -445,23 +507,16 @@ function addAdaptivePadding($message) {
return $message;
}
-// ============================================================================
-// SOCKS5 IMPLEMENTATION - Pure PHP, Maximum Security
-// ============================================================================
-
function socks5Connect($destHost, $destPort, $socksHost = SOCKS5_PROXY, $socksPort = SOCKS5_PORT, $timeout = SOCKS5_TIMEOUT) {
secureLog("Initiating SOCKS5 connection to $destHost:$destPort via $socksHost:$socksPort");
- // Validate destination .onion address
if (!preg_match('/^[a-z2-7]{56}\.onion$/', $destHost)) {
secureLog("Invalid .onion address format", 'ERROR');
return false;
}
- // Anti-timing: random delay before connection
randomDelay(0);
- // Create socket to SOCKS5 proxy
$socket = @stream_socket_client(
"tcp://$socksHost:$socksPort",
$errno,
@@ -475,14 +530,11 @@ function socks5Connect($destHost, $destPort, $socksHost = SOCKS5_PROXY, $socksPo
return false;
}
- // Set timeouts
stream_set_timeout($socket, $timeout);
stream_set_blocking($socket, true);
secureLog("Connected to SOCKS5 proxy, initiating handshake");
- // ===== SOCKS5 Handshake Step 1: Version/Auth negotiation =====
- // Format: [VER=0x05][NMETHODS=0x01][METHOD=0x00 (no auth)]
$request = pack('C3', 0x05, 0x01, 0x00);
if (fwrite($socket, $request) === false) {
@@ -491,7 +543,6 @@ function socks5Connect($destHost, $destPort, $socksHost = SOCKS5_PROXY, $socksPo
return false;
}
- // Read response: [VER][METHOD]
$response = fread($socket, 2);
if (strlen($response) !== 2) {
@@ -510,15 +561,11 @@ function socks5Connect($destHost, $destPort, $socksHost = SOCKS5_PROXY, $socksPo
secureLog("SOCKS5 handshake successful, sending connect request");
- // ===== SOCKS5 Step 2: Connection request =====
- // Format: [VER=0x05][CMD=0x01 (CONNECT)][RSV=0x00][ATYP=0x03 (DOMAIN)]
- // [DLEN][DOMAIN][PORT (2 bytes, big-endian)]
-
$domainLen = strlen($destHost);
- $request = pack('C4', 0x05, 0x01, 0x00, 0x03); // Version, Connect, Reserved, Domain type
- $request .= pack('C', $domainLen); // Domain length
- $request .= $destHost; // Domain name
- $request .= pack('n', $destPort); // Port (network byte order)
+ $request = pack('C4', 0x05, 0x01, 0x00, 0x03);
+ $request .= pack('C', $domainLen);
+ $request .= $destHost;
+ $request .= pack('n', $destPort);
if (fwrite($socket, $request) === false) {
secureLog("Failed to write SOCKS5 connect request", 'ERROR');
@@ -526,8 +573,6 @@ function socks5Connect($destHost, $destPort, $socksHost = SOCKS5_PROXY, $socksPo
return false;
}
- // Read response: [VER][REP][RSV][ATYP][BIND_ADDR][BIND_PORT]
- // We need at least 4 bytes to check the result
$response = fread($socket, 4);
if (strlen($response) < 4) {
@@ -545,7 +590,6 @@ function socks5Connect($destHost, $destPort, $socksHost = SOCKS5_PROXY, $socksPo
}
if ($data['rep'] !== 0x00) {
- // Error codes
$errors = [
0x01 => 'General SOCKS server failure',
0x02 => 'Connection not allowed by ruleset',
@@ -563,17 +607,12 @@ function socks5Connect($destHost, $destPort, $socksHost = SOCKS5_PROXY, $socksPo
return false;
}
- // Read the rest of the response (bind address + port)
- // The format depends on ATYP
if ($data['atyp'] === 0x01) {
- // IPv4: 4 bytes + 2 bytes port
fread($socket, 6);
} elseif ($data['atyp'] === 0x03) {
- // Domain: 1 byte length + domain + 2 bytes port
$len = ord(fread($socket, 1));
fread($socket, $len + 2);
} elseif ($data['atyp'] === 0x04) {
- // IPv6: 16 bytes + 2 bytes port
fread($socket, 18);
}
@@ -583,34 +622,45 @@ function socks5Connect($destHost, $destPort, $socksHost = SOCKS5_PROXY, $socksPo
}
function smtpSendCommand($socket, $command, $expectedCode = null) {
- // Send SMTP command and read response
- secureLog("SMTP >> " . trim($command));
-
- if (fwrite($socket, $command) === false) {
- secureLog("Failed to write SMTP command", 'ERROR');
- return false;
+ if (!empty($command)) {
+ secureLog("SMTP >> " . trim($command));
+
+ if (fwrite($socket, $command) === false) {
+ secureLog("Failed to write SMTP command", 'ERROR');
+ return false;
+ }
+
+ if (!fflush($socket)) {
+ secureLog("Warning: fflush failed after command", 'WARNING');
+ }
}
- // Read response (with size limit)
$response = '';
$bytesRead = 0;
+ $startTime = microtime(true);
while (!feof($socket) && $bytesRead < MAX_SMTP_RESPONSE_SIZE) {
$line = fgets($socket, 1024);
if ($line === false) {
+ $elapsed = microtime(true) - $startTime;
+ secureLog("fgets returned false after {$elapsed}s (timeout or closed)", 'WARNING');
break;
}
$response .= $line;
$bytesRead += strlen($line);
- // SMTP multiline response ends when line starts with code and space (not dash)
if (preg_match('/^\d{3} /', $line)) {
break;
}
}
+ if (empty($response)) {
+ secureLog("Empty SMTP response (connection may be closed)", 'ERROR');
+ return false;
+ }
+
secureLog("SMTP << " . trim($response));
if ($expectedCode !== null) {
@@ -627,13 +677,11 @@ function sendViaNativePHPSMTP($data, $smtpRelay, $smtpPort, $mail2newsAddress) {
secureLog("=== Starting native PHP SMTP delivery ===");
secureLog("Target: $smtpRelay:$smtpPort → $mail2newsAddress");
- // Anti-timing: random delay before connection
$delay = cryptoRandInt(MIN_RANDOM_DELAY_MS, MAX_RANDOM_DELAY_MS) / 1000;
randomDelay($delay);
$startTime = microtime(true);
- // Connect via SOCKS5
$socket = socks5Connect($smtpRelay, $smtpPort);
if (!$socket) {
@@ -644,50 +692,42 @@ function sendViaNativePHPSMTP($data, $smtpRelay, $smtpPort, $mail2newsAddress) {
$connectTime = microtime(true) - $startTime;
secureLog(sprintf("SOCKS5 connection established in %.2fs", $connectTime));
- // Set read timeout for SMTP operations
stream_set_timeout($socket, SMTP_READ_TIMEOUT);
try {
- // Read SMTP greeting (220)
$greeting = smtpSendCommand($socket, '', 220);
if ($greeting === false) {
throw new Exception("No SMTP greeting received");
}
- // HELO
$response = smtpSendCommand($socket, "HELO m2usenet.local\r\n", 250);
if ($response === false) {
throw new Exception("HELO failed");
}
- // Extract sender email
preg_match('/<([^>]+)>/', $data['from'], $matches);
$fromEmail = $matches[1] ?? 'noreply@m2usenet.local';
- // MAIL FROM
$response = smtpSendCommand($socket, "MAIL FROM:<$fromEmail>\r\n", 250);
if ($response === false) {
throw new Exception("MAIL FROM rejected");
}
- // RCPT TO
$response = smtpSendCommand($socket, "RCPT TO:<$mail2newsAddress>\r\n", 250);
if ($response === false) {
throw new Exception("RCPT TO rejected");
}
- // DATA
$response = smtpSendCommand($socket, "DATA\r\n", 354);
if ($response === false) {
throw new Exception("DATA command rejected");
}
- // Build message
$messageId = generateSecureMessageID();
$headers = [
@@ -695,9 +735,9 @@ function sendViaNativePHPSMTP($data, $smtpRelay, $smtpPort, $mail2newsAddress) {
sprintf("To: %s", $mail2newsAddress),
sprintf("Subject: %s", $data['subject']),
sprintf("Message-ID: %s", $messageId),
- sprintf("Date: %s", gmdate('r')),
- sprintf("Newsgroups: %s", $data['newsgroups']),
- sprintf("X-Hashcash: %s", $data['xhashcash'])
+ sprintf("Date: %s", gmdate('r', time() + cryptoRandInt(-1800, 1800))),
+ sprintf("Newsgroups: %s", $data['newsgroups'])
+ // X-Hashcash removed: exposes real timestamp (privacy leak)
];
if (!empty($data['x-ed25519-pub'])) {
@@ -717,28 +757,119 @@ function sendViaNativePHPSMTP($data, $smtpRelay, $smtpPort, $mail2newsAddress) {
$headers[] = "MIME-Version: 1.0";
$headers[] = "Content-Type: text/plain; charset=utf-8";
$headers[] = "Content-Transfer-Encoding: 8bit";
- $headers[] = "User-Agent: m2usenet-web v3.1.0";
+ $headers[] = "User-Agent: m2usenet-web v2.1.0";
$headers[] = "X-No-Archive: Yes";
$body = addAdaptivePadding($data['message']);
- $fullMessage = implode("\r\n", $headers) . "\r\n\r\n" . $body . "\r\n";
- secureLog("Sending message body (" . strlen($fullMessage) . " bytes)");
+ $fullMessage = implode("\r\n", $headers) . "\r\n\r\n" . $body;
+
+ // Dot stuffing per RFC 5321
+ $lines = explode("\r\n", $fullMessage);
+ $stuffedLines = [];
+
+ foreach ($lines as $line) {
+ if (isset($line[0]) && $line[0] === '.') {
+ $stuffedLines[] = '.' . $line;
+ } else {
+ $stuffedLines[] = $line;
+ }
+ }
+
+ $stuffedMessage = implode("\r\n", $stuffedLines) . "\r\n";
- // Send message body
- if (fwrite($socket, $fullMessage) === false) {
+ secureLog("Sending message body (" . strlen($stuffedMessage) . " bytes, " . count($lines) . " lines)");
+
+ $written = fwrite($socket, $stuffedMessage);
+ if ($written === false || $written === 0) {
throw new Exception("Failed to write message body");
}
- // End DATA with .
- $response = smtpSendCommand($socket, ".\r\n", 250);
+ if (!fflush($socket)) {
+ secureLog("Warning: fflush failed after message body", 'WARNING');
+ }
- if ($response === false) {
- throw new Exception("Message rejected");
+ secureLog("Message body sent, sending terminator");
+
+ $written = fwrite($socket, ".\r\n");
+ if ($written === false || $written === 0) {
+ throw new Exception("Failed to write DATA terminator");
+ }
+
+ if (!fflush($socket)) {
+ secureLog("Warning: fflush terminator failed", 'WARNING');
+ }
+
+ secureLog("Terminator sent, waiting for server response");
+
+ $meta = stream_get_meta_data($socket);
+ secureLog("Connection status: " . ($meta['eof'] ? 'EOF/CLOSED' : 'OPEN') .
+ ", blocked: " . ($meta['blocked'] ? 'yes' : 'no') .
+ ", timed_out: " . ($meta['timed_out'] ? 'YES' : 'no'));
+
+ if ($meta['eof']) {
+ throw new Exception("Connection closed by server after terminator");
}
- // QUIT
- smtpSendCommand($socket, "QUIT\r\n", 221);
+ stream_set_timeout($socket, SMTP_FINAL_TIMEOUT);
+
+ $response = '';
+ $bytesRead = 0;
+ $responseStart = microtime(true);
+ $loopCount = 0;
+
+ while (!feof($socket) && $bytesRead < MAX_SMTP_RESPONSE_SIZE) {
+ $loopCount++;
+
+ $read = [$socket];
+ $write = null;
+ $except = null;
+ $selectResult = @stream_select($read, $write, $except, 1);
+
+ if ($selectResult === false) {
+ secureLog("stream_select failed", 'ERROR');
+ break;
+ } elseif ($selectResult === 0) {
+ $elapsed = microtime(true) - $responseStart;
+ if ($elapsed > SMTP_FINAL_TIMEOUT) {
+ secureLog("Timeout waiting for response after {$elapsed}s", 'ERROR');
+ break;
+ }
+ continue;
+ }
+
+ $line = fgets($socket, 1024);
+
+ if ($line === false) {
+ $elapsed = microtime(true) - $responseStart;
+ $meta = stream_get_meta_data($socket);
+ secureLog("fgets returned false after {$elapsed}s (loops: $loopCount)", 'ERROR');
+ secureLog("Meta: eof=" . ($meta['eof'] ? '1' : '0') .
+ ", timed_out=" . ($meta['timed_out'] ? '1' : '0'), 'ERROR');
+ break;
+ }
+
+ $response .= $line;
+ $bytesRead += strlen($line);
+
+ if (preg_match('/^\d{3} /', $line)) {
+ break;
+ }
+ }
+
+ if (empty($response)) {
+ throw new Exception("Empty final response - server may have closed connection");
+ }
+
+ secureLog("SMTP << " . trim($response));
+
+ if (!preg_match('/^250/', $response)) {
+ throw new Exception("Message rejected: " . trim($response));
+ }
+
+ secureLog("Message accepted by server");
+
+ @smtpSendCommand($socket, "QUIT\r\n", 221);
$totalTime = microtime(true) - $startTime;
secureLog(sprintf("=== Message delivered successfully in %.2fs ===", $totalTime));
@@ -754,78 +885,14 @@ function sendViaNativePHPSMTP($data, $smtpRelay, $smtpPort, $mail2newsAddress) {
} catch (Exception $e) {
secureLog("SMTP error: " . $e->getMessage(), 'ERROR');
- fclose($socket);
- return ['success' => false, 'message' => 'SMTP protocol error'];
- }
-}
-
-function sendMessage($data) {
- // Select random relay
- $relay = selectRandomRelay();
-
- secureLog("Attempting delivery via {$relay['name']} relay");
-
- $result = sendViaNativePHPSMTP(
- $data,
- $relay['host'],
- $relay['port'],
- $relay['mail2news']
- );
-
- // Retry logic with different relays on failure
- $retries = 0;
- $triedRelays = [$relay['name']];
-
- while (!$result['success'] && $retries < MAX_RETRIES) {
- $retries++;
- // Get different relay
- $availableRelays = array_filter(FOG_SPHINX_RELAYS, function($r) use ($triedRelays) {
- return !in_array($r['name'], $triedRelays);
- });
-
- if (empty($availableRelays)) {
- // All relays tried, do exponential backoff with same relay
- $backoff = RETRY_DELAY_BASE * pow(2, $retries - 1);
- $backoff += cryptoRandInt(0, 2);
-
- secureLog("All relays exhausted. Retry $retries/" . MAX_RETRIES . " with {$relay['name']} after {$backoff}s", 'WARNING');
- sleep($backoff);
-
- $result = sendViaNativePHPSMTP(
- $data,
- $relay['host'],
- $relay['port'],
- $relay['mail2news']
- );
- } else {
- // Try different relay
- $availableRelays = array_values($availableRelays);
- $index = cryptoRandInt(0, count($availableRelays) - 1);
- $relay = $availableRelays[$index];
- $triedRelays[] = $relay['name'];
-
- $backoff = cryptoRandInt(1, 3); // Shorter delay when switching relays
-
- secureLog("Switching to {$relay['name']} relay. Retry $retries/" . MAX_RETRIES . " after {$backoff}s", 'WARNING');
- sleep($backoff);
-
- $result = sendViaNativePHPSMTP(
- $data,
- $relay['host'],
- $relay['port'],
- $relay['mail2news']
- );
+ if (is_resource($socket)) {
+ @stream_socket_shutdown($socket, STREAM_SHUT_RDWR);
+ @fclose($socket);
}
+
+ return ['success' => false, 'message' => 'SMTP protocol error'];
}
-
- if ($result['success']) {
- secureLog("Message delivered successfully via {$relay['name']} relay");
- } else {
- secureLog("Message delivery failed after trying relays: " . implode(', ', $triedRelays), 'ERROR');
- }
-
- return $result;
}
function errorResponse($message = "Request failed") {
@@ -894,7 +961,7 @@ function successResponse($messageId, $gateway) {
<div class="info">
<p><strong>Gateway:</strong> <?php echo htmlspecialchars($gateway); ?></p>
<p><small>Message routed via secure Tor SOCKS5 with PHP native implementation</small></p>
- <p><small>Using Fog Sphinx mixnet with random relay selection</small></p>
+ <p><small>Strategy: fog:25 primary → smtp:25 fallback</small></p>
</div>
<p><a href="index.php">Send another message</a></p>
</div>
@@ -905,10 +972,6 @@ function successResponse($messageId, $gateway) {
exit;
}
-// ============================================================================
-// MAIN REQUEST HANDLER
-// ============================================================================
-
try {
secureLog("=== New request received ===");
@@ -918,6 +981,10 @@ try {
die("Method not allowed");
}
+ if (cryptoRandInt(1, 10) === 1) {
+ cleanupExpiredLocks();
+ }
+
if (!checkRateLimit()) {
secureLog("Rate limit exceeded", 'WARNING');
errorResponse("Too many requests. Please try again later.");
@@ -937,12 +1004,23 @@ try {
secureLog("Input validation passed");
- // Mark token as used AFTER successful validation
- markHashcashUsed($data['xhashcash']);
- secureLog("Hashcash token marked as used");
+ // Use token hash from validation
+ $tokenHash = $data['_tokenHash'];
+
+ $lockFile = acquireSubmissionLock($tokenHash);
+
+ if ($lockFile === false) {
+ secureLog("Duplicate submission attempt blocked", 'WARNING');
+ errorResponse("Duplicate submission detected. Please wait before sending again.");
+ }
+
+ markHashcashUsed($tokenHash);
+ secureLog("Hashcash token marked as used", 'INFO');
$result = sendMessage($data);
+ releaseSubmissionLock($lockFile);
+
if ($result['success']) {
secureLog("Request completed successfully");
successResponse($result['messageId'], $result['gateway']);
@@ -952,6 +1030,10 @@ try {
}
} catch (Exception $e) {
+ if (isset($lockFile) && $lockFile) {
+ releaseSubmissionLock($lockFile);
+ }
+
secureLog("=== Fatal exception: " . $e->getMessage() . " ===", 'CRITICAL');
secureLog("Stack trace: " . $e->getTraceAsString(), 'ERROR');
errorResponse("System error occurred");