diff options
| author | Gab <24553253+gabrix73@users.noreply.github.com> | 2025-11-13 17:11:46 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-11-13 17:11:46 +0100 |
| commit | dfed1655238922bf9f85e96838756ac196ccf5cd (patch) | |
| tree | 668dc961a28d715361a44fc9baf9fd6c6b7e6f35 | |
| parent | c7d183d4ae08dc1b0a15a5b8af76a32e620347fe (diff) | |
| download | m2usenet-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.php | 568 |
1 files changed, 325 insertions, 243 deletions
@@ -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"); |
