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 /send.php | |
| 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.
Diffstat (limited to 'send.php')
| -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"); |
