From d6bbdb613a6a97c6ca260db8fe51c3383aac3c76 Mon Sep 17 00:00:00 2001 From: Gab <24553253+gabrix73@users.noreply.github.com> Date: Fri, 24 Oct 2025 18:10:16 +0200 Subject: Create fog.go --- fog.go | 1626 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1626 insertions(+) create mode 100644 fog.go diff --git a/fog.go b/fog.go new file mode 100644 index 0000000..9c47dc7 --- /dev/null +++ b/fog.go @@ -0,0 +1,1626 @@ +package main + +import ( + "bytes" + "crypto/rand" + "crypto/sha512" + "encoding/base64" + "encoding/binary" + "errors" + "flag" + "fmt" + "io" + "log" + "math" + "math/big" + "net" + "net/smtp" + "net/textproto" + "os" + "os/signal" + "regexp" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "golang.org/x/net/proxy" +) + +const ( + Version = "0.9" + AppName = "fog" + TorSocksProxyAddr = "127.0.0.1:9050" + RelayWorkerCount = 5 + DeliveryTimeout = 30 * time.Second + MixnetBatchWindow = 30 * time.Second + CoverTrafficInterval = 15 * time.Second + MessageIDCacheDuration = 24 * time.Hour + PaddingSizeUnit = 8 * 1024 + MinDelay = 100 * time.Millisecond + MaxDelay = 2 * time.Second + RateLimitPerIP = 10 + RateLimitWindow = 1 * time.Minute + MXLookupTimeout = 5 * time.Second + MaxRetries = 9 + MaxMessageSize = 10 * 1024 * 1024 // 10MB limit + MaxHeaderSize = 100 * 1024 // 100KB header limit + MaxRecipients = 100 // Max recipients per message + ConnectionPoolSize = 5 // Tor connection pool size + ConnectionMaxAge = 10 * time.Minute // Max connection age before rotation + TimestampFuzzRange = 2 * time.Hour // ±2 hours timestamp fuzzing + DummyRecipientsMin = 1 // Min dummy recipients per batch + DummyRecipientsMax = 3 // Max dummy recipients per batch +) + +// Message size normalization buckets (in bytes) +var MessageSizeBuckets = []int64{ + 32 * 1024, // 32KB + 64 * 1024, // 64KB + 128 * 1024, // 128KB + 256 * 1024, // 256KB + 512 * 1024, // 512KB + 1024 * 1024, // 1MB + 2 * 1024 * 1024, // 2MB + 5 * 1024 * 1024, // 5MB + 10 * 1024 * 1024, // 10MB +} + +var ( + emailRegExp *regexp.Regexp + localPartRegex *regexp.Regexp + domainRegex *regexp.Regexp + + // Global variables (FIXED: were missing) + mailQueue chan *Envelope + mailQueueMutex sync.Mutex + + // Statistics (NEW) + stats *Statistics + + // Graceful shutdown (NEW) + shutdownSignal chan struct{} + shutdownWg sync.WaitGroup +) + +// Statistics tracking (NEW) +type Statistics struct { + messagesReceived int64 + messagesDelivered int64 + messagesFailed int64 + coverTrafficSent int64 + dummyMessagesSent int64 + mu sync.RWMutex +} + +func NewStatistics() *Statistics { + return &Statistics{} +} + +func (s *Statistics) IncReceived() { + atomic.AddInt64(&s.messagesReceived, 1) +} + +func (s *Statistics) IncDelivered() { + atomic.AddInt64(&s.messagesDelivered, 1) +} + +func (s *Statistics) IncFailed() { + atomic.AddInt64(&s.messagesFailed, 1) +} + +func (s *Statistics) IncCoverTraffic() { + atomic.AddInt64(&s.coverTrafficSent, 1) +} + +func (s *Statistics) IncDummy() { + atomic.AddInt64(&s.dummyMessagesSent, 1) +} + +func (s *Statistics) GetStats() (received, delivered, failed, cover, dummy int64) { + return atomic.LoadInt64(&s.messagesReceived), + atomic.LoadInt64(&s.messagesDelivered), + atomic.LoadInt64(&s.messagesFailed), + atomic.LoadInt64(&s.coverTrafficSent), + atomic.LoadInt64(&s.dummyMessagesSent) +} + +func (s *Statistics) PrintStats() { + received, delivered, failed, cover, dummy := s.GetStats() + log.Printf("[STATS] Messages: Received=%d, Delivered=%d, Failed=%d, Cover=%d, Dummy=%d", + received, delivered, failed, cover, dummy) +} + +func init() { + emailRegExp = regexp.MustCompile(`^[a-zA-Z0-9._%+=\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) + localPartRegex = regexp.MustCompile(`^[a-zA-Z0-9._+=\-]+$`) + domainRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`) + + stats = NewStatistics() + shutdownSignal = make(chan struct{}) +} + +// sanitizeLogString prevents log injection attacks (NEW) +func sanitizeLogString(s string) string { + // Remove newlines, carriage returns, and other control characters + s = strings.ReplaceAll(s, "\n", "\\n") + s = strings.ReplaceAll(s, "\r", "\\r") + s = strings.ReplaceAll(s, "\t", "\\t") + // Limit length to prevent log flooding + if len(s) > 200 { + s = s[:200] + "..." + } + return s +} + +func validateLocalPart(localPart string) error { + if len(localPart) == 0 || len(localPart) > 64 { + return errors.New("local part must be 1-64 characters") + } + if strings.HasPrefix(localPart, ".") || strings.HasSuffix(localPart, ".") { + return errors.New("local part cannot start or end with a dot") + } + if strings.Contains(localPart, "..") { + return errors.New("local part cannot contain consecutive dots") + } + if !localPartRegex.MatchString(localPart) { + return errors.New("local part contains invalid characters") + } + return nil +} + +func isOnionDomain(domain string) bool { + domain = strings.ToLower(strings.TrimSpace(domain)) + if domain == "" { + return false + } + if strings.HasSuffix(domain, ".") { + domain = domain[:len(domain)-1] + } + return strings.HasSuffix(domain, ".onion") +} + +func validateDomain(domain string) error { + domain = strings.ToLower(strings.TrimSpace(domain)) + if strings.HasSuffix(domain, ".") { + domain = domain[:len(domain)-1] + } + if len(domain) == 0 || len(domain) > 253 { + return errors.New("domain must be 1-253 characters") + } + if isOnionDomain(domain) { + onionName := strings.TrimSuffix(domain, ".onion") + if len(onionName) != 16 && len(onionName) != 56 { + return errors.New("invalid .onion domain length") + } + validOnion := regexp.MustCompile(`^[a-z2-7]+$`) + if !validOnion.MatchString(onionName) { + return errors.New("invalid .onion domain format") + } + return nil + } + if !domainRegex.MatchString(domain) { + return errors.New("invalid domain format") + } + labels := strings.Split(domain, ".") + if len(labels) < 2 { + return errors.New("domain must have at least two labels") + } + for _, label := range labels { + if len(label) == 0 || len(label) > 63 { + return errors.New("domain label must be 1-63 characters") + } + } + return nil +} + +func ValidateEmailAddress(address string) (string, string, error) { + address = strings.TrimSpace(address) + address = strings.Trim(address, "<>") + if !strings.Contains(address, "@") { + return "", "", errors.New("invalid email address: missing '@'") + } + sepInd := strings.LastIndex(address, "@") + if sepInd == 0 || sepInd == len(address)-1 { + return "", "", errors.New("invalid email address: empty local part or domain") + } + localPart := address[:sepInd] + domain := address[sepInd+1:] + if err := validateLocalPart(localPart); err != nil { + return "", "", fmt.Errorf("invalid local part: %w", err) + } + if err := validateDomain(domain); err != nil { + return "", "", fmt.Errorf("invalid domain: %w", err) + } + return localPart, domain, nil +} + +func validateServerName(name string) error { + name = strings.TrimSpace(name) + if name == "" { + return errors.New("server name cannot be empty") + } + if !isOnionDomain(name) { + return errors.New("server name must be a .onion domain") + } + onionName := strings.TrimSuffix(strings.ToLower(name), ".onion") + if len(onionName) != 56 { + return errors.New("server name must be a v3 .onion address (56 characters)") + } + validOnionV3 := regexp.MustCompile(`^[a-z2-7]{56}$`) + if !validOnionV3.MatchString(onionName) { + return errors.New("invalid v3 .onion address format") + } + return nil +} + +// extractDomainFromAddress extracts domain from email address (FIXED: was missing) +func extractDomainFromAddress(address string) string { + _, domain, err := ValidateEmailAddress(address) + if err != nil { + return "" + } + return domain +} + +func hasMXRecords(domain string) bool { + if isOnionDomain(domain) { + return true + } + + domain = strings.ToLower(strings.TrimSpace(domain)) + if strings.HasSuffix(domain, ".") { + domain = domain[:len(domain)-1] + } + + done := make(chan bool, 1) + var hasMX bool + + go func() { + defer func() { + if r := recover(); r != nil { + hasMX = false + } + done <- true + }() + + mxRecords, err := net.LookupMX(domain) + hasMX = err == nil && len(mxRecords) > 0 + }() + + select { + case <-done: + return hasMX + case <-time.After(MXLookupTimeout): + return false + } +} + +type MessageIDCache struct { + cache map[string]time.Time + mu sync.RWMutex +} + +func NewMessageIDCache() *MessageIDCache { + cache := &MessageIDCache{cache: make(map[string]time.Time)} + go cache.cleanupLoop() + return cache +} + +func (c *MessageIDCache) Has(messageID string) bool { + c.mu.RLock() + defer c.mu.RUnlock() + expiry, exists := c.cache[messageID] + if !exists { + return false + } + return time.Now().Before(expiry) +} + +func (c *MessageIDCache) Add(messageID string) bool { + c.mu.Lock() + defer c.mu.Unlock() + if _, exists := c.cache[messageID]; exists { + return false + } + c.cache[messageID] = time.Now().Add(MessageIDCacheDuration) + return true +} + +func (c *MessageIDCache) cleanupLoop() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ticker.C: + c.mu.Lock() + now := time.Now() + for id, expiry := range c.cache { + if now.After(expiry) { + delete(c.cache, id) + } + } + c.mu.Unlock() + case <-shutdownSignal: + return + } + } +} + +type RateLimiter struct { + requests map[string][]time.Time + mu sync.RWMutex +} + +func NewRateLimiter() *RateLimiter { + limiter := &RateLimiter{requests: make(map[string][]time.Time)} + go limiter.cleanupLoop() + return limiter +} + +func (rl *RateLimiter) Allow(clientIP string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + now := time.Now() + cutoff := now.Add(-RateLimitWindow) + requests := rl.requests[clientIP] + var validRequests []time.Time + for _, t := range requests { + if t.After(cutoff) { + validRequests = append(validRequests, t) + } + } + if len(validRequests) >= RateLimitPerIP { + rl.requests[clientIP] = validRequests + return false + } + validRequests = append(validRequests, now) + rl.requests[clientIP] = validRequests + return true +} + +func (rl *RateLimiter) cleanupLoop() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + rl.mu.Lock() + now := time.Now() + cutoff := now.Add(-RateLimitWindow * 2) + for ip, requests := range rl.requests { + var validRequests []time.Time + for _, t := range requests { + if t.After(cutoff) { + validRequests = append(validRequests, t) + } + } + if len(validRequests) == 0 { + delete(rl.requests, ip) + } else { + rl.requests[ip] = validRequests + } + } + rl.mu.Unlock() + case <-shutdownSignal: + return + } + } +} + +func cryptoRandInt63n(max int64) int64 { + if max <= 0 { + return 0 + } + n, err := rand.Int(rand.Reader, big.NewInt(max)) + if err != nil { + return time.Now().UnixNano() % max + } + return n.Int64() +} + +func applyPadding(data []byte, targetSize int) []byte { + if len(data) >= targetSize { + return data + } + padding := make([]byte, targetSize-len(data)) + if _, err := rand.Read(padding); err != nil { + // Fallback to zero padding if random fails + padding = make([]byte, targetSize-len(data)) + } + return append(data, padding...) +} + +// normalizeToBucket finds the appropriate size bucket and pads the message (NEW) +func normalizeToBucket(data []byte) ([]byte, error) { + currentSize := int64(len(data)) + + // Find the smallest bucket that fits the data + var targetBucket int64 + for _, bucket := range MessageSizeBuckets { + if currentSize <= bucket { + targetBucket = bucket + break + } + } + + // If data is larger than largest bucket, reject + if targetBucket == 0 { + return nil, fmt.Errorf("message too large: %d bytes (max %d)", currentSize, MessageSizeBuckets[len(MessageSizeBuckets)-1]) + } + + // Pad to target bucket size + return applyPadding(data, int(targetBucket)), nil +} + +// fuzzTimestamp adds random offset to timestamp (NEW) +func fuzzTimestamp(t time.Time) time.Time { + // Add random offset between -2h and +2h + maxOffset := int64(TimestampFuzzRange) + offset := cryptoRandInt63n(maxOffset*2) - maxOffset + return t.Add(time.Duration(offset)) +} + +// sanitizeHeaders removes revealing headers and normalizes others (NEW) +func sanitizeHeaders(data []byte) []byte { + lines := strings.Split(string(data), "\r\n") + var sanitized []string + + revealingHeaders := map[string]bool{ + "x-mailer": true, + "user-agent": true, + "x-originating-ip": true, + "x-forwarded-for": true, + "x-sender": true, + "x-priority": true, + "importance": true, + "x-msmail-priority": true, + } + + inHeader := true + for _, line := range lines { + // Empty line marks end of headers + if line == "" { + inHeader = false + sanitized = append(sanitized, line) + continue + } + + if inHeader && strings.Contains(line, ":") { + parts := strings.SplitN(line, ":", 2) + headerName := strings.ToLower(strings.TrimSpace(parts[0])) + + // Skip revealing headers + if revealingHeaders[headerName] { + continue + } + + // Normalize Message-ID format + if headerName == "message-id" { + // Replace with our own format + continue // Will be added by generateMessageID + } + + // Normalize Date header with fuzzing + if headerName == "date" { + // Parse and fuzz timestamp + continue // Will be added with fuzzed timestamp + } + } + + sanitized = append(sanitized, line) + } + + return []byte(strings.Join(sanitized, "\r\n")) +} + +// MessageFragment represents a fragment of a larger message (NEW) +type MessageFragment struct { + FragmentID string + TotalParts int + PartNumber int + Data []byte + OriginalHash string +} + +// fragmentMessage splits a large message into random-sized fragments (NEW) +func fragmentMessage(data []byte, messageID string) ([]*MessageFragment, error) { + dataLen := len(data) + + // Decide on fragment sizes (random between 16KB and 64KB) + var fragments []*MessageFragment + offset := 0 + partNumber := 1 + + // Calculate hash of original message + hash := sha512.Sum512(data) + originalHash := base64.URLEncoding.EncodeToString(hash[:16]) + + for offset < dataLen { + // Random fragment size between 16KB and 64KB + fragSize := int(16*1024 + cryptoRandInt63n(48*1024)) + if offset+fragSize > dataLen { + fragSize = dataLen - offset + } + + fragment := &MessageFragment{ + FragmentID: messageID, + PartNumber: partNumber, + Data: data[offset : offset+fragSize], + OriginalHash: originalHash, + } + + fragments = append(fragments, fragment) + offset += fragSize + partNumber++ + } + + // Set total parts for all fragments + totalParts := len(fragments) + for _, frag := range fragments { + frag.TotalParts = totalParts + } + + return fragments, nil +} + +// encodeFragment encodes a fragment into email format (NEW) +func encodeFragment(frag *MessageFragment) []byte { + header := fmt.Sprintf("X-Fog-Fragment-ID: %s\r\n", frag.FragmentID) + header += fmt.Sprintf("X-Fog-Fragment-Part: %d/%d\r\n", frag.PartNumber, frag.TotalParts) + header += fmt.Sprintf("X-Fog-Fragment-Hash: %s\r\n", frag.OriginalHash) + header += "\r\n" + + return append([]byte(header), frag.Data...) +} + +type Envelope struct { + MessageFrom string + MessageTo string + MessageData io.Reader + ReceivedAt time.Time + RetryCount int + MessageID string + IsCoverTraffic bool + IsDummy bool // NEW: marks dummy recipients + OriginalSize int64 // NEW: track original size before padding +} + +// TorConnection represents a pooled Tor connection (NEW) +type TorConnection struct { + dialer proxy.Dialer + createdAt time.Time + usageCount int + mu sync.Mutex +} + +// ConnectionPool manages Tor connections (NEW) +type ConnectionPool struct { + connections []*TorConnection + current int + mu sync.Mutex +} + +func NewConnectionPool(size int) (*ConnectionPool, error) { + pool := &ConnectionPool{ + connections: make([]*TorConnection, size), + } + + // Initialize connections + for i := 0; i < size; i++ { + dialer := &net.Dialer{Timeout: DeliveryTimeout} + torDialer, err := proxy.SOCKS5("tcp", TorSocksProxyAddr, nil, dialer) + if err != nil { + return nil, fmt.Errorf("failed to create Tor dialer %d: %w", i, err) + } + + pool.connections[i] = &TorConnection{ + dialer: torDialer, + createdAt: time.Now(), + usageCount: 0, + } + } + + // Start rotation goroutine + go pool.rotationLoop() + + return pool, nil +} + +func (cp *ConnectionPool) GetConnection() *TorConnection { + cp.mu.Lock() + defer cp.mu.Unlock() + + // Round-robin selection + conn := cp.connections[cp.current] + cp.current = (cp.current + 1) % len(cp.connections) + + conn.mu.Lock() + conn.usageCount++ + conn.mu.Unlock() + + return conn +} + +func (cp *ConnectionPool) rotationLoop() { + ticker := time.NewTicker(ConnectionMaxAge) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + cp.mu.Lock() + now := time.Now() + + // Rotate old connections + for i, conn := range cp.connections { + conn.mu.Lock() + age := now.Sub(conn.createdAt) + + if age > ConnectionMaxAge { + // Create new connection + dialer := &net.Dialer{Timeout: DeliveryTimeout} + torDialer, err := proxy.SOCKS5("tcp", TorSocksProxyAddr, nil, dialer) + if err != nil { + log.Printf("[POOL] Failed to rotate connection %d: %v", i, err) + conn.mu.Unlock() + continue + } + + cp.connections[i] = &TorConnection{ + dialer: torDialer, + createdAt: now, + usageCount: 0, + } + + log.Printf("[POOL] Rotated connection %d (age: %v, usage: %d)", + i, age, conn.usageCount) + } + conn.mu.Unlock() + } + + cp.mu.Unlock() + case <-shutdownSignal: + return + } + } +} + +type MixnetBatcher struct { + envelopes []*Envelope + mu sync.Mutex + queue chan *Envelope + batchWindow time.Duration + lastBatchAt time.Time + connPool *ConnectionPool // NEW: connection pool +} + +func NewMixnetBatcher(queue chan *Envelope) *MixnetBatcher { + // Create connection pool + connPool, err := NewConnectionPool(ConnectionPoolSize) + if err != nil { + log.Fatalf("[BATCHER] Failed to create connection pool: %v", err) + } + + batcher := &MixnetBatcher{ + envelopes: make([]*Envelope, 0), + queue: queue, + batchWindow: MixnetBatchWindow, + lastBatchAt: time.Now(), + connPool: connPool, + } + go batcher.processBatches() + return batcher +} + +func (mb *MixnetBatcher) Add(envelope *Envelope) { + mb.mu.Lock() + mb.envelopes = append(mb.envelopes, envelope) + mb.mu.Unlock() +} + +// generateDummyRecipients creates dummy recipients for the batch (NEW) +func (mb *MixnetBatcher) generateDummyRecipients(count int, serverName string) []*Envelope { + dummies := make([]*Envelope, count) + + for i := 0; i < count; i++ { + // Generate random recipient + randomBytes := make([]byte, 16) + if _, err := rand.Read(randomBytes); err != nil { + continue + } + dummyRecipient := fmt.Sprintf("dummy-%s@%s", + base64.URLEncoding.EncodeToString(randomBytes[:8]), serverName) + + // Create dummy envelope with random data + dummyData := make([]byte, int(32*1024+cryptoRandInt63n(32*1024))) + if _, err := rand.Read(dummyData); err != nil { + continue + } + + dummies[i] = &Envelope{ + MessageFrom: fmt.Sprintf("system@%s", serverName), + MessageTo: dummyRecipient, + MessageData: bytes.NewReader(dummyData), + ReceivedAt: time.Now(), + RetryCount: 0, + MessageID: generateMessageID(), + IsCoverTraffic: false, + IsDummy: true, + } + + stats.IncDummy() + } + + return dummies +} + +func (mb *MixnetBatcher) processBatches() { + ticker := time.NewTicker(mb.batchWindow) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + mb.flushBatch() + case <-shutdownSignal: + // Flush remaining batch before shutting down + mb.flushBatch() + return + } + } +} + +func (mb *MixnetBatcher) flushBatch() { + mb.mu.Lock() + if len(mb.envelopes) == 0 { + mb.mu.Unlock() + return + } + + batch := mb.envelopes + mb.envelopes = make([]*Envelope, 0) + mb.lastBatchAt = time.Now() + mb.mu.Unlock() + + // Add dummy recipients (NEW) + dummyCount := int(DummyRecipientsMin + cryptoRandInt63n(int64(DummyRecipientsMax-DummyRecipientsMin+1))) + serverName := "localhost.onion" // Will be set properly by the server + dummies := mb.generateDummyRecipients(dummyCount, serverName) + batch = append(batch, dummies...) + + log.Printf("[BATCH] Flushing %d messages (including %d dummies) with inter-message jitter", len(batch), dummyCount) + + // Shuffle batch to randomize order + for i := len(batch) - 1; i > 0; i-- { + j := int(cryptoRandInt63n(int64(i + 1))) + batch[i], batch[j] = batch[j], batch[i] + } + + // Apply adaptive padding to all messages + for _, env := range batch { + data, err := io.ReadAll(env.MessageData) + if err != nil { + log.Printf("[BATCH] Error reading message data: %v", err) + continue + } + + // Store original size + env.OriginalSize = int64(len(data)) + + // Sanitize headers (NEW) + data = sanitizeHeaders(data) + + // Normalize to bucket size (NEW) + normalizedData, err := normalizeToBucket(data) + if err != nil { + log.Printf("[BATCH] Error normalizing message: %v", err) + continue + } + + env.MessageData = bytes.NewReader(normalizedData) + + // Apply exponential jitter between messages (NEW) + baseDelay := time.Duration(100+cryptoRandInt63n(400)) * time.Millisecond + exponentialFactor := math.Exp(float64(cryptoRandInt63n(100)) / 100.0) + jitter := time.Duration(float64(baseDelay) * exponentialFactor) + + // Add to queue with jitter + go func(e *Envelope, delay time.Duration) { + time.Sleep(delay) + queueEnvelope(e) + }(env, jitter) + } +} + +type CoverTrafficGenerator struct { + queue chan *Envelope + interval time.Duration + serverName string +} + +func NewCoverTrafficGenerator(queue chan *Envelope, serverName string) *CoverTrafficGenerator { + gen := &CoverTrafficGenerator{ + queue: queue, + interval: CoverTrafficInterval, + serverName: serverName, + } + go gen.generateCoverTraffic() + return gen +} + +func (ctg *CoverTrafficGenerator) generateCoverTraffic() { + // Use Poisson distribution for more natural timing (NEW) + for { + // Calculate next interval using exponential distribution (Poisson process) + lambda := 1.0 / CoverTrafficInterval.Seconds() + u := float64(cryptoRandInt63n(1000000)) / 1000000.0 + nextInterval := time.Duration(-math.Log(1-u)/lambda) * time.Second + + // Clamp to reasonable bounds + if nextInterval < CoverTrafficInterval/2 { + nextInterval = CoverTrafficInterval / 2 + } + if nextInterval > CoverTrafficInterval*2 { + nextInterval = CoverTrafficInterval * 2 + } + + timer := time.NewTimer(nextInterval) + select { + case <-timer.C: + ctg.sendCoverTraffic() + case <-shutdownSignal: + timer.Stop() + return + } + } +} + +func (ctg *CoverTrafficGenerator) sendCoverTraffic() { + // Generate random cover traffic recipient + randomBytes := make([]byte, 16) + if _, err := rand.Read(randomBytes); err != nil { + log.Printf("[COVER] Failed to generate random bytes: %v", err) + return + } + + coverRecipient := fmt.Sprintf("cover-%s@%s", + base64.URLEncoding.EncodeToString(randomBytes[:8]), ctg.serverName) + + // Generate random message data + dataSize := int(32*1024 + cryptoRandInt63n(96*1024)) + coverData := make([]byte, dataSize) + if _, err := rand.Read(coverData); err != nil { + log.Printf("[COVER] Failed to generate cover data: %v", err) + return + } + + envelope := &Envelope{ + MessageFrom: fmt.Sprintf("system@%s", ctg.serverName), + MessageTo: coverRecipient, + MessageData: bytes.NewReader(coverData), + ReceivedAt: time.Now(), + RetryCount: 0, + MessageID: generateMessageID(), + IsCoverTraffic: true, + } + + queueEnvelope(envelope) + stats.IncCoverTraffic() +} + +type Server struct { + Name string + Addr string + Handler Handler + MessageIDCache *MessageIDCache + RateLimiter *RateLimiter + MixnetBatcher *MixnetBatcher + CoverTraffic *CoverTrafficGenerator +} + +type Handler interface { + ServeSMTP(envelope *Envelope) error +} + +func (s *Server) Serve(listener net.Listener) error { + defer listener.Close() + + for { + select { + case <-shutdownSignal: + log.Println("[SERVER] Shutdown signal received, stopping listener") + return nil + default: + } + + // Set deadline to allow periodic checks for shutdown + if tcpListener, ok := listener.(*net.TCPListener); ok { + tcpListener.SetDeadline(time.Now().Add(1 * time.Second)) + } + + conn, err := listener.Accept() + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + select { + case <-shutdownSignal: + return nil + default: + return err + } + } + + clientAddr := conn.RemoteAddr().String() + clientIP := strings.Split(clientAddr, ":")[0] + + if !s.RateLimiter.Allow(clientIP) { + log.Printf("[SERVER] Rate limit exceeded for %s", sanitizeLogString(clientIP)) + conn.Close() + continue + } + + log.Printf("[SERVER] New connection from %s", sanitizeLogString(clientAddr)) + + shutdownWg.Add(1) + go func() { + defer shutdownWg.Done() + s.handleConnection(conn) + }() + } +} + +func (s *Server) handleConnection(conn net.Conn) { + defer conn.Close() + + // Set read/write deadlines (NEW) + conn.SetDeadline(time.Now().Add(5 * time.Minute)) + + smtpConn := &SMTPConn{ + conn: conn, + text: textproto.NewConn(conn), + server: s, + remoteAddr: conn.RemoteAddr().String(), + } + if err := smtpConn.serve(); err != nil { + log.Printf("[CONN] Connection error: %v", err) + } +} + +type SMTPConn struct { + conn net.Conn + text *textproto.Conn + server *Server + remoteAddr string + mailFrom string + mailTo []string + data bytes.Buffer + dataSize int64 // Track data size (NEW) + quitSent bool +} + +func (c *SMTPConn) resetSession() { + c.mailFrom = "" + c.mailTo = nil + c.data.Reset() + c.dataSize = 0 +} + +func (c *SMTPConn) serve() error { + log.Printf("[SMTP] <- %s: Client connected", sanitizeLogString(c.remoteAddr)) + + if err := c.text.PrintfLine("220 %s %s v%s SMTP Ready", + c.server.Name, AppName, Version); err != nil { + return err + } + log.Printf("[SMTP] -> %s: 220 %s %s v%s SMTP Ready", + sanitizeLogString(c.remoteAddr), c.server.Name, AppName, Version) + + for { + // Check for shutdown + select { + case <-shutdownSignal: + c.text.PrintfLine("421 Service shutting down") + return nil + default: + } + + // Update deadline for each command + c.conn.SetDeadline(time.Now().Add(5 * time.Minute)) + + line, err := c.text.ReadLine() + if err != nil { + if err == io.EOF { + log.Printf("[SMTP] <- %s: Client disconnected (EOF)", sanitizeLogString(c.remoteAddr)) + } else { + log.Printf("[SMTP] <- %s: Read error: %v", sanitizeLogString(c.remoteAddr), err) + } + return err + } + + // Check header size limit (NEW) + if int64(len(line)) > MaxHeaderSize { + log.Printf("[SMTP] -> %s: 552 Header line too long", sanitizeLogString(c.remoteAddr)) + return c.text.PrintfLine("552 Header line too long") + } + + log.Printf("[SMTP] <- %s: %s", sanitizeLogString(c.remoteAddr), sanitizeLogString(line)) + + if err := c.handleCommand(line); err != nil { + return err + } + + if c.quitSent { + return nil + } + } +} + +func (c *SMTPConn) handleCommand(line string) error { + parts := strings.Fields(line) + if len(parts) == 0 { + log.Printf("[SMTP] -> %s: 500 Empty command", sanitizeLogString(c.remoteAddr)) + return c.text.PrintfLine("500 Empty command") + } + + cmd := strings.ToUpper(parts[0]) + + // Apply random delay for timing attack prevention + minDelay := MinDelay + maxDelay := MaxDelay + delay := time.Duration(cryptoRandInt63n(int64(maxDelay-minDelay))) + minDelay + time.Sleep(delay) + + switch cmd { + case "HELO", "EHLO": + log.Printf("[SMTP] -> %s: 250 %s", sanitizeLogString(c.remoteAddr), c.server.Name) + return c.text.PrintfLine("250 %s", c.server.Name) + + case "MAIL": + if len(parts) < 2 || !strings.HasPrefix(strings.ToUpper(parts[1]), "FROM:") { + log.Printf("[SMTP] -> %s: 501 Syntax: MAIL FROM:
", sanitizeLogString(c.remoteAddr)) + return c.text.PrintfLine("501 Syntax: MAIL FROM:") + } + from := strings.TrimPrefix(parts[1], "FROM:") + from = strings.TrimPrefix(from, "from:") + from = strings.Trim(from, "<>") + + // Validate and sanitize email (NEW) + _, _, err := ValidateEmailAddress(from) + if err != nil { + log.Printf("[SMTP] -> %s: 553 Invalid sender address: %v", + sanitizeLogString(c.remoteAddr), err) + return c.text.PrintfLine("553 Invalid sender address") + } + + c.mailFrom = from + log.Printf("[SMTP] -> %s: 250 OK (sender: %s)", + sanitizeLogString(c.remoteAddr), sanitizeLogString(from)) + return c.text.PrintfLine("250 OK") + + case "RCPT": + if c.mailFrom == "" { + log.Printf("[SMTP] -> %s: 503 Need MAIL command first", sanitizeLogString(c.remoteAddr)) + return c.text.PrintfLine("503 Need MAIL command first") + } + + // Check max recipients (NEW) + if len(c.mailTo) >= MaxRecipients { + log.Printf("[SMTP] -> %s: 452 Too many recipients", sanitizeLogString(c.remoteAddr)) + return c.text.PrintfLine("452 Too many recipients") + } + + if len(parts) < 2 || !strings.HasPrefix(strings.ToUpper(parts[1]), "TO:") { + log.Printf("[SMTP] -> %s: 501 Syntax: RCPT TO:", sanitizeLogString(c.remoteAddr)) + return c.text.PrintfLine("501 Syntax: RCPT TO:") + } + to := strings.TrimPrefix(parts[1], "TO:") + to = strings.TrimPrefix(to, "to:") + to = strings.Trim(to, "<>") + + // Validate recipient + _, domain, err := ValidateEmailAddress(to) + if err != nil { + log.Printf("[SMTP] -> %s: 553 Invalid recipient address: %v", + sanitizeLogString(c.remoteAddr), err) + return c.text.PrintfLine("553 Invalid recipient address") + } + + // Check for email header injection attempts (NEW) + if strings.Contains(to, "\r") || strings.Contains(to, "\n") { + log.Printf("[SMTP] -> %s: 553 Invalid recipient (injection attempt)", + sanitizeLogString(c.remoteAddr)) + return c.text.PrintfLine("553 Invalid recipient") + } + + if !hasMXRecords(domain) { + log.Printf("[SMTP] -> %s: 550 No MX records for domain %s", + sanitizeLogString(c.remoteAddr), sanitizeLogString(domain)) + return c.text.PrintfLine("550 No MX records for domain") + } + + c.mailTo = append(c.mailTo, to) + log.Printf("[SMTP] -> %s: 250 OK (recipient: %s)", + sanitizeLogString(c.remoteAddr), sanitizeLogString(to)) + return c.text.PrintfLine("250 OK") + + case "DATA": + if c.mailFrom == "" || len(c.mailTo) == 0 { + log.Printf("[SMTP] -> %s: 503 Need MAIL and RCPT commands first", + sanitizeLogString(c.remoteAddr)) + return c.text.PrintfLine("503 Need MAIL and RCPT commands first") + } + + log.Printf("[SMTP] -> %s: 354 Start mail input; end with