diff options
| author | Gab <24553253+gabrix73@users.noreply.github.com> | 2025-12-07 20:24:37 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-12-07 20:24:37 +0100 |
| commit | 0e0c69f6ef3ee84e9b327befc381ff194d868959 (patch) | |
| tree | 7c82d3dd3351abf49b6ec1c0725598c074c14e89 | |
| parent | 1b6180bbf886c6a2d1d215347d0a57cd5df0dfd8 (diff) | |
| download | fog-0e0c69f6ef3ee84e9b327befc381ff194d868959.tar.gz fog-0e0c69f6ef3ee84e9b327befc381ff194d868959.tar.xz fog-0e0c69f6ef3ee84e9b327befc381ff194d868959.zip | |
Update fmt.Println message from 'Hello' to 'Goodbye'
| -rw-r--r-- | fog.go | 1899 |
1 files changed, 971 insertions, 928 deletions
@@ -1,5 +1,5 @@ -// fog v2.0.1 - Anonymous SMTP Relay with Sphinx Mixnet -// Fixed padding, no PKI server needed, debug logs +// fog v2.1.1 - Anonymous SMTP Relay with Sphinx Mixnet + Header Sanitization +// Features: delay pool, exit node header sanitization (RFC compliant) // Copyright 2025 - fog Project package main @@ -7,23 +7,25 @@ package main import ( "bufio" "bytes" - "context" "crypto/aes" "crypto/cipher" "crypto/hmac" "crypto/rand" "crypto/sha256" + "database/sql" "encoding/base64" "encoding/binary" + "encoding/hex" "encoding/json" "errors" "flag" "fmt" - "io" "log" + "math" "math/big" "net" "net/smtp" + "net/textproto" "os" "os/signal" "path/filepath" @@ -33,13 +35,14 @@ import ( "syscall" "time" + _ "github.com/mattn/go-sqlite3" "golang.org/x/crypto/curve25519" "golang.org/x/crypto/hkdf" "golang.org/x/net/proxy" ) const ( - Version = "2.0.1" + Version = "2.1.1" TorSocks = "127.0.0.1:9050" DefaultPort = "2525" @@ -53,6 +56,7 @@ const ( HealthInterval = 3 * time.Minute StatsInterval = 60 * time.Second + PoolInterval = 1 * time.Minute MaxMsgSize = 10 << 20 MaxRecipient = 50 @@ -68,11 +72,49 @@ const ( NonceSize = 12 HMACSize = 32 - // Padding bucket for Sphinx packets (not message content) - PaddedPayloadSize = 64 * 1024 // 64KB fixed size for all Sphinx payloads + PaddedPayloadSize = 64 * 1024 + + DefaultMinPoolDelay = 1 * time.Hour + DefaultMaxPoolDelay = 24 * time.Hour ) // ============================================================================ +// DELAY STRATEGIES +// ============================================================================ + +type DelayStrategy int + +const ( + DelayExponential DelayStrategy = iota + DelayConstant + DelayPoisson +) + +func (d DelayStrategy) String() string { + switch d { + case DelayExponential: + return "exponential" + case DelayConstant: + return "constant" + case DelayPoisson: + return "poisson" + default: + return "unknown" + } +} + +func parseDelayStrategy(s string) DelayStrategy { + switch strings.ToLower(s) { + case "constant": + return DelayConstant + case "poisson": + return DelayPoisson + default: + return DelayExponential + } +} + +// ============================================================================ // TYPES // ============================================================================ @@ -84,6 +126,16 @@ type Message struct { Time time.Time } +type QueuedMessage struct { + ID string + From string + To string + Data []byte + EnqueueTime time.Time + SendAfter time.Time + Attempts int +} + type Node struct { ID string `json:"node_id"` PubKey string `json:"public_key"` @@ -106,22 +158,25 @@ type PKI struct { } type LocalNode struct { - ID string - Private []byte - Public []byte - Address string - mu sync.RWMutex + ID string + Private []byte + Public []byte + Address string + ShortName string + mu sync.RWMutex } type Stats struct { - Start time.Time - Recv int64 - Sent int64 - Failed int64 - Sphinx int64 - Direct int64 - MixRecv int64 - MixFwd int64 + Start time.Time + Recv int64 + Sent int64 + Failed int64 + Sphinx int64 + Direct int64 + MixRecv int64 + MixFwd int64 + Queued int64 + Delayed int64 } type ReplayCache struct { @@ -148,9 +203,16 @@ type SphinxPacket struct { } type RoutingInfo struct { - Next string `json:"next"` - From string `json:"from,omitempty"` - To string `json:"to,omitempty"` + NextHop string + IsExit bool +} + +type DelayPool struct { + db *sql.DB + minDelay time.Duration + maxDelay time.Duration + strategy DelayStrategy + mu sync.RWMutex } // ============================================================================ @@ -158,742 +220,721 @@ type RoutingInfo struct { // ============================================================================ var ( - local *LocalNode - pki *PKI - stats *Stats - replay *ReplayCache - batch *Batch - queue chan *Message - torDialer proxy.Dialer - hostname string - pkiFile string - dataDir string - useSphinx atomic.Bool - debugMode bool - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup + pki = &PKI{Nodes: make([]*Node, 0)} + localNode = &LocalNode{} + stats = &Stats{Start: time.Now()} + replayCache = &ReplayCache{cache: make(map[string]time.Time)} + batch = &Batch{packets: make([]*SphinxPacket, 0)} + delayPool *DelayPool + + enableSphinx bool + enableDelay bool + debug bool ) // ============================================================================ -// DEBUG LOGGING +// DELAY POOL - SQLITE DATABASE // ============================================================================ -func dbg(format string, args ...interface{}) { - if debugMode { - log.Printf("[DEBUG] "+format, args...) +func NewDelayPool(dbPath string, minDelay, maxDelay time.Duration, strategy DelayStrategy) (*DelayPool, error) { + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + + schema := ` + CREATE TABLE IF NOT EXISTS message_queue ( + id TEXT PRIMARY KEY, + from_addr TEXT NOT NULL, + to_addr TEXT NOT NULL, + data BLOB NOT NULL, + enqueue_time INTEGER NOT NULL, + send_after INTEGER NOT NULL, + attempts INTEGER DEFAULT 0, + created_at INTEGER DEFAULT (strftime('%s', 'now')) + ); + CREATE INDEX IF NOT EXISTS idx_send_after ON message_queue(send_after); + ` + + if _, err := db.Exec(schema); err != nil { + db.Close() + return nil, fmt.Errorf("create schema: %w", err) } -} -// ============================================================================ -// CRYPTO -// ============================================================================ + pool := &DelayPool{ + db: db, + minDelay: minDelay, + maxDelay: maxDelay, + strategy: strategy, + } -func secureDelay() time.Duration { - b := make([]byte, 8) - rand.Read(b) - val := binary.BigEndian.Uint64(b) - // Map to range [MinDelay, MaxDelay] - rangeMs := uint64((MaxDelay - MinDelay).Milliseconds()) - delayMs := MinDelay.Milliseconds() + int64(val%rangeMs) - return time.Duration(delayMs) * time.Millisecond + return pool, nil } -func secureShuffle[T any](s []T) { - for i := len(s) - 1; i > 0; i-- { - jBig, _ := rand.Int(rand.Reader, big.NewInt(int64(i+1))) - j := int(jBig.Int64()) - s[i], s[j] = s[j], s[i] - } +func (p *DelayPool) Close() error { + return p.db.Close() } -func deriveKeys(secret []byte) (enc, mac []byte, err error) { - kdf := hkdf.New(sha256.New, secret, nil, []byte("fog-v2")) - enc = make([]byte, AESKeySize) - mac = make([]byte, AESKeySize) - if _, err = io.ReadFull(kdf, enc); err != nil { - return +func (p *DelayPool) calculateDelay() time.Duration { + p.mu.RLock() + defer p.mu.RUnlock() + + switch p.strategy { + case DelayConstant: + return p.minDelay + time.Duration(secureRandInt64(int64(p.maxDelay-p.minDelay))) + + case DelayPoisson: + lambda := float64(p.minDelay+p.maxDelay) / 2.0 + delay := time.Duration(poissonRandom(lambda)) + if delay < p.minDelay { + delay = p.minDelay + } + if delay > p.maxDelay { + delay = p.maxDelay + } + return delay + + case DelayExponential: + fallthrough + default: + mean := float64(p.minDelay+p.maxDelay) / 2.0 + lambda := 1.0 / mean + + u := secureRandFloat64() + delay := time.Duration(-math.Log(1.0-u) / lambda) + + if delay < p.minDelay { + delay = p.minDelay + } + if delay > p.maxDelay { + delay = p.maxDelay + } + return delay } - _, err = io.ReadFull(kdf, mac) - return } -func encrypt(plain, key []byte) ([]byte, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, err +func (p *DelayPool) Enqueue(msg *Message) error { + delay := p.calculateDelay() + sendAfter := time.Now().Add(delay) + + for _, to := range msg.To { + _, err := p.db.Exec(` + INSERT INTO message_queue (id, from_addr, to_addr, data, enqueue_time, send_after) + VALUES (?, ?, ?, ?, ?, ?)`, + msg.ID+"-"+to, + msg.From, + to, + msg.Data, + msg.Time.Unix(), + sendAfter.Unix(), + ) + if err != nil { + return fmt.Errorf("enqueue: %w", err) + } } - nonce := make([]byte, gcm.NonceSize()) - if _, err := rand.Read(nonce); err != nil { - return nil, err + + atomic.AddInt64(&stats.Queued, int64(len(msg.To))) + + if debug { + log.Printf("[POOL] Enqueued %s: %d recipients, delay=%v, send_after=%s", + msg.ID, len(msg.To), delay, sendAfter.Format("15:04:05")) } - return gcm.Seal(nonce, nonce, plain, nil), nil + + return nil } -func decrypt(ciphertext, key []byte) ([]byte, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - gcm, err := cipher.NewGCM(block) +func (p *DelayPool) GetReady() ([]*QueuedMessage, error) { + now := time.Now().Unix() + + rows, err := p.db.Query(` + SELECT id, from_addr, to_addr, data, enqueue_time, send_after, attempts + FROM message_queue + WHERE send_after <= ? + ORDER BY send_after + LIMIT 100`, + now, + ) if err != nil { return nil, err } - if len(ciphertext) < gcm.NonceSize() { - return nil, errors.New("too short") + defer rows.Close() + + var messages []*QueuedMessage + for rows.Next() { + var m QueuedMessage + var enqT, sendT int64 + + err := rows.Scan(&m.ID, &m.From, &m.To, &m.Data, &enqT, &sendT, &m.Attempts) + if err != nil { + log.Printf("[POOL] Scan error: %v", err) + continue + } + + m.EnqueueTime = time.Unix(enqT, 0) + m.SendAfter = time.Unix(sendT, 0) + messages = append(messages, &m) } - nonce := ciphertext[:gcm.NonceSize()] - return gcm.Open(nil, nonce, ciphertext[gcm.NonceSize():], nil) -} -func computeMAC(data, key []byte) []byte { - h := hmac.New(sha256.New, key) - h.Write(data) - return h.Sum(nil) + return messages, nil } -func verifyMAC(data, mac, key []byte) bool { - return hmac.Equal(computeMAC(data, key), mac) +func (p *DelayPool) Delete(id string) error { + _, err := p.db.Exec("DELETE FROM message_queue WHERE id = ?", id) + if err != nil { + return fmt.Errorf("delete: %w", err) + } + atomic.AddInt64(&stats.Queued, -1) + atomic.AddInt64(&stats.Delayed, 1) + return nil } -// ============================================================================ -// PADDING - FIXED: Preserves original message -// Format: [4 bytes original length][original data][random padding] -// ============================================================================ +func (p *DelayPool) IncrementAttempts(id string) error { + _, err := p.db.Exec("UPDATE message_queue SET attempts = attempts + 1 WHERE id = ?", id) + return err +} -func padPayload(data []byte) []byte { - origLen := len(data) - - // Create padded payload with length prefix - // Format: [4 bytes len][data][random padding to PaddedPayloadSize] - padded := make([]byte, PaddedPayloadSize) - - // First 4 bytes: original length - binary.BigEndian.PutUint32(padded[:4], uint32(origLen)) - - // Copy original data - copy(padded[4:], data) - - // Fill rest with random bytes - rand.Read(padded[4+origLen:]) - - dbg("Padded payload: %d -> %d bytes", origLen, PaddedPayloadSize) - return padded +func (p *DelayPool) Count() (int64, error) { + var count int64 + err := p.db.QueryRow("SELECT COUNT(*) FROM message_queue").Scan(&count) + return count, err } -func unpadPayload(padded []byte) ([]byte, error) { - if len(padded) < 4 { - return nil, errors.New("padded data too short") - } - - origLen := binary.BigEndian.Uint32(padded[:4]) - - if int(origLen) > len(padded)-4 { - return nil, fmt.Errorf("invalid length: %d > %d", origLen, len(padded)-4) +func (p *DelayPool) Stats() (total, ready int64, oldest time.Time, err error) { + err = p.db.QueryRow("SELECT COUNT(*), MIN(send_after) FROM message_queue").Scan(&total, &oldest) + if err != nil { + return } - data := make([]byte, origLen) - copy(data, padded[4:4+origLen]) - - dbg("Unpadded payload: %d -> %d bytes", len(padded), origLen) - return data, nil + now := time.Now().Unix() + err = p.db.QueryRow("SELECT COUNT(*) FROM message_queue WHERE send_after <= ?", now).Scan(&ready) + return } // ============================================================================ -// REPLAY CACHE +// SECURE RANDOM UTILITIES // ============================================================================ -func newReplayCache() *ReplayCache { - return &ReplayCache{cache: make(map[string]time.Time)} +func secureRandInt64(max int64) int64 { + if max <= 0 { + return 0 + } + n, err := rand.Int(rand.Reader, big.NewInt(max)) + if err != nil { + return 0 + } + return n.Int64() } -func (r *ReplayCache) Check(id string) bool { - r.mu.Lock() - defer r.mu.Unlock() +func secureRandFloat64() float64 { + n := secureRandInt64(1 << 53) + return float64(n) / float64(1<<53) +} - if _, exists := r.cache[id]; exists { - dbg("Replay detected: %s", id) - return false +func poissonRandom(lambda float64) float64 { + L := math.Exp(-lambda) + k := 0.0 + p := 1.0 + + for p > L { + k++ + p *= secureRandFloat64() } + + return k - 1.0 +} - r.cache[id] = time.Now() +func secureRandDelay(min, max time.Duration) time.Duration { + if max <= min { + return min + } + delta := int64(max - min) + return min + time.Duration(secureRandInt64(delta)) +} - if len(r.cache) > CacheSize { - now := time.Now() - for k, v := range r.cache { - if now.Sub(v) > CacheTTL { - delete(r.cache, k) - } - } +func secureRandBytes(n int) ([]byte, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return nil, err } - return true + return b, nil +} + +func secureRandHex(n int) string { + b, _ := secureRandBytes(n) + return hex.EncodeToString(b) } // ============================================================================ -// LOCAL NODE +// HEADER SANITIZATION (v2.1.1) // ============================================================================ -func initNode(addr string) { - keyFile := filepath.Join(dataDir, "node.key") +func sanitizeHeaders(payload []byte) []byte { + // Parse message + msg, err := textproto.NewReader(bufio.NewReader(bytes.NewReader(payload))).ReadMIMEHeader() + if err != nil { + return payload // Can't parse, return as-is + } + + // Find headers/body boundary + headerEnd := bytes.Index(payload, []byte("\r\n\r\n")) + if headerEnd == -1 { + headerEnd = bytes.Index(payload, []byte("\n\n")) + if headerEnd == -1 { + return payload + } + headerEnd += 2 + } else { + headerEnd += 4 + } + + body := payload[headerEnd:] + + // Build sanitized headers + sanitized := make(textproto.MIMEHeader) + + // REPLACE: From + sanitized.Set("From", fmt.Sprintf("Anonymous <anonymous@%s.fog>", localNode.ShortName)) + + // REPLACE: Message-ID + sanitized.Set("Message-ID", fmt.Sprintf("<%s@%s.fog>", secureRandHex(32), localNode.ShortName)) - var priv []byte - if data, err := os.ReadFile(keyFile); err == nil && len(data) == 32 { - priv = data - log.Printf("[NODE] Loaded existing key") + // REPLACE: Date (randomize ±1-2h) + if origDate := msg.Get("Date"); origDate != "" { + if t, err := time.Parse(time.RFC1123Z, origDate); err == nil { + offset := secureRandInt64(7200) - 3600 // ±1h in seconds + newDate := t.Add(time.Duration(offset) * time.Second) + sanitized.Set("Date", newDate.Format(time.RFC1123Z)) + } else { + sanitized.Set("Date", time.Now().Format(time.RFC1123Z)) + } } else { - priv = make([]byte, 32) - rand.Read(priv) - os.WriteFile(keyFile, priv, 0600) - log.Printf("[NODE] Generated new key") + sanitized.Set("Date", time.Now().Format(time.RFC1123Z)) + } + + // KEEP: Essential headers + keepHeaders := []string{ + "To", "Newsgroups", "Subject", + "Content-Type", "Content-Transfer-Encoding", "MIME-Version", + "References", "In-Reply-To", // Threading (RFC 5536) } - pub, _ := curve25519.X25519(priv, curve25519.Basepoint) - id := fmt.Sprintf("%x", sha256.Sum256(pub)) + for _, h := range keepHeaders { + if v := msg.Get(h); v != "" { + sanitized.Set(h, v) + } + } + + // Rebuild message + var buf bytes.Buffer + for key, values := range sanitized { + for _, value := range values { + buf.WriteString(fmt.Sprintf("%s: %s\r\n", key, value)) + } + } + buf.WriteString("\r\n") + buf.Write(body) - local = &LocalNode{ - ID: id, - Private: priv, - Public: pub, - Address: addr, + if debug { + log.Printf("[SANITIZE] Headers cleaned: From=anonymous@%s.fog", localNode.ShortName) } - log.Printf("[NODE] ID: %s", id[:16]) - log.Printf("[NODE] Address: %s", addr) - dbg("Full ID: %s", id) - dbg("PubKey: %x", pub) + return buf.Bytes() } // ============================================================================ -// PKI - File based, no server needed +// PKI MANAGEMENT // ============================================================================ -func newPKI() *PKI { - return &PKI{Nodes: make([]*Node, 0)} -} - func (p *PKI) Load(path string) error { data, err := os.ReadFile(path) if err != nil { - return err + return fmt.Errorf("read pki: %w", err) } - p.mu.Lock() - defer p.mu.Unlock() - if err := json.Unmarshal(data, p); err != nil { - return err + return fmt.Errorf("parse pki: %w", err) } - // Mark all as healthy initially - for _, n := range p.Nodes { - n.Healthy = true - n.LastOK = time.Now() - dbg("Loaded node: %s (%s) at %s", n.Name, n.ID[:16], n.Address) + p.mu.Lock() + for _, node := range p.Nodes { + node.Healthy = false } + p.mu.Unlock() log.Printf("[PKI] Loaded %d nodes from %s", len(p.Nodes), path) return nil } -func (p *PKI) GetAllOther() []*Node { +func (p *PKI) GetHealthy() []*Node { p.mu.RLock() defer p.mu.RUnlock() - var nodes []*Node + var healthy []*Node for _, n := range p.Nodes { - if local != nil && n.ID == local.ID { - continue + if n.Healthy && n.ID != localNode.ID { + healthy = append(healthy, n) } - nodes = append(nodes, n) } - return nodes + return healthy } -func (p *PKI) GetHealthy() []*Node { +func (p *PKI) GetNode(id string) *Node { p.mu.RLock() defer p.mu.RUnlock() - var nodes []*Node for _, n := range p.Nodes { - if !n.Healthy { - continue - } - if local != nil && n.ID == local.ID { - continue + if n.ID == id { + return n } - nodes = append(nodes, n) } - return nodes + return nil } -func (p *PKI) HealthyCount() int { - return len(p.GetHealthy()) +func (p *PKI) RandomPath(hops int) ([]*Node, error) { + healthy := p.GetHealthy() + + if len(healthy) < hops { + return nil, fmt.Errorf("insufficient nodes: need %d, have %d", hops, len(healthy)) + } + + shuffled := make([]*Node, len(healthy)) + copy(shuffled, healthy) + + for i := len(shuffled) - 1; i > 0; i-- { + j := int(secureRandInt64(int64(i + 1))) + shuffled[i], shuffled[j] = shuffled[j], shuffled[i] + } + + return shuffled[:hops], nil } // ============================================================================ -// BATCH +// LOCAL NODE // ============================================================================ -func newBatch() *Batch { - return &Batch{ - packets: make([]*SphinxPacket, 0, BatchSize), - start: time.Now(), +func (n *LocalNode) Init(name, shortName string) error { + n.mu.Lock() + defer n.mu.Unlock() + + private := make([]byte, 32) + if _, err := rand.Read(private); err != nil { + return err } -} -func (b *Batch) Add(p *SphinxPacket) { - b.mu.Lock() - defer b.mu.Unlock() - b.packets = append(b.packets, p) - dbg("Batch: added packet, now %d", len(b.packets)) + public, err := curve25519.X25519(private, curve25519.Basepoint) + if err != nil { + return err + } + + hash := sha256.Sum256(public) + n.ID = base64.RawURLEncoding.EncodeToString(hash[:]) + + n.Private = private + n.Public = public + n.Address = name + n.ShortName = shortName + + log.Printf("[NODE] ID=%s Address=%s Name=%s", n.ID[:16], name, shortName) + return nil } -func (b *Batch) Ready() bool { - b.mu.Lock() - defer b.mu.Unlock() - return len(b.packets) >= BatchSize || (len(b.packets) > 0 && time.Since(b.start) > BatchWindow) +func (n *LocalNode) ExportInfo(name, shortName string) error { + n.mu.RLock() + defer n.mu.RUnlock() + + info := map[string]interface{}{ + "version": Version, + "updated": time.Now().UTC().Format(time.RFC3339), + "nodes": []map[string]string{ + { + "node_id": n.ID, + "public_key": base64.StdEncoding.EncodeToString(n.Public), + "address": name + ":" + NodePort, + "name": shortName, + "version": Version, + }, + }, + } + + data, err := json.MarshalIndent(info, "", " ") + if err != nil { + return err + } + + return os.WriteFile("nodes.json", data, 0644) } -func (b *Batch) Flush() []*SphinxPacket { - b.mu.Lock() - defer b.mu.Unlock() +// ============================================================================ +// REPLAY CACHE +// ============================================================================ - pkts := b.packets - b.packets = make([]*SphinxPacket, 0, BatchSize) - b.start = time.Now() +func (r *ReplayCache) Check(id string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + _, exists := r.cache[id] + return exists +} - secureShuffle(pkts) - dbg("Batch: flushed %d packets (shuffled)", len(pkts)) - return pkts +func (r *ReplayCache) Add(id string) { + r.mu.Lock() + defer r.mu.Unlock() + r.cache[id] = time.Now() } -func batchWorker() { - defer wg.Done() - ticker := time.NewTicker(time.Second) +func (r *ReplayCache) Cleanup() { + ticker := time.NewTicker(time.Hour) defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - if batch.Ready() { - pkts := batch.Flush() - log.Printf("[BATCH] Processing %d packets", len(pkts)) - for _, p := range pkts { - go processPacket(p) - } + for range ticker.C { + r.mu.Lock() + now := time.Now() + for id, t := range r.cache { + if now.Sub(t) > CacheTTL { + delete(r.cache, id) } } + r.mu.Unlock() } } // ============================================================================ -// SPHINX PACKET +// SPHINX PACKET FORMAT // ============================================================================ -func createPacket(msg *Message, route []*Node) (*SphinxPacket, error) { - if len(route) != SphinxHops { - return nil, fmt.Errorf("need %d hops, got %d", SphinxHops, len(route)) +func buildSphinxPacket(path []*Node, payload []byte) (*SphinxPacket, error) { + if len(path) == 0 { + return nil, errors.New("empty path") } - dbg("Creating Sphinx packet for %s", msg.ID) - dbg("Route: %s -> %s -> %s", route[0].Name, route[1].Name, route[2].Name) - - // Build routing info - routings := make([]RoutingInfo, SphinxHops) - for i := 0; i < SphinxHops-1; i++ { - routings[i] = RoutingInfo{Next: route[i+1].Address} - dbg("Hop %d: forward to %s", i, route[i+1].Address) + paddedPayload := make([]byte, PaddedPayloadSize) + binary.BigEndian.PutUint32(paddedPayload[0:4], uint32(len(payload))) + copy(paddedPayload[4:], payload) + + if len(payload)+4 < PaddedPayloadSize { + rand.Read(paddedPayload[4+len(payload):]) } - // Last hop: EXIT with delivery info - var validTo []string - for _, to := range msg.To { - to = strings.TrimSpace(to) - if to != "" { - validTo = append(validTo, to) + routing := make([]byte, 0, len(path)*64) + for i, node := range path { + isExit := (i == len(path)-1) + + info := &RoutingInfo{ + NextHop: node.Address, + IsExit: isExit, } + + infoBytes, _ := json.Marshal(info) + routing = append(routing, infoBytes...) + routing = append(routing, 0) } - if len(validTo) == 0 { - return nil, errors.New("no recipients") - } - - routings[SphinxHops-1] = RoutingInfo{ - Next: "EXIT", - From: msg.From, - To: strings.Join(validTo, ","), - } - dbg("Hop %d: EXIT, deliver to %s", SphinxHops-1, validTo) - - // PAD the message payload BEFORE encryption - paddedMsg := padPayload(msg.Data) - dbg("Message padded: %d -> %d bytes", len(msg.Data), len(paddedMsg)) - // Encrypt layers (reverse: exit -> entry) - payload := paddedMsg - var ephKeys [][32]byte - var secrets [][]byte + ephPrivate := make([]byte, 32) + rand.Read(ephPrivate) + ephPublic, _ := curve25519.X25519(ephPrivate, curve25519.Basepoint) - for i := len(route) - 1; i >= 0; i-- { - node := route[i] - dbg("Encrypting layer %d for %s", i, node.Name) + encrypted := paddedPayload + sharedSecrets := make([][]byte, 0) - // Ephemeral key - ephPriv := make([]byte, 32) - rand.Read(ephPriv) - ephPub, _ := curve25519.X25519(ephPriv, curve25519.Basepoint) - - // Shared secret - nodePub, err := node.GetPubKey() + for i := len(path) - 1; i >= 0; i-- { + nodePubKey, err := path[i].GetPubKey() if err != nil { - return nil, fmt.Errorf("decode pubkey for %s: %w", node.Name, err) + return nil, err } - shared, _ := curve25519.X25519(ephPriv, nodePub) - - encKey, _, _ := deriveKeys(shared) - // Routing JSON - routeData, _ := json.Marshal(routings[i]) - dbg("Layer %d routing: %s", i, string(routeData)) + shared, err := curve25519.X25519(ephPrivate, nodePubKey) + if err != nil { + return nil, err + } + sharedSecrets = append([][]byte{shared}, sharedSecrets...) - // Combine: [4-byte routing length][routing][payload] - combined := make([]byte, 4+len(routeData)+len(payload)) - binary.BigEndian.PutUint32(combined[:4], uint32(len(routeData))) - copy(combined[4:], routeData) - copy(combined[4+len(routeData):], payload) + kdf := hkdf.New(sha256.New, shared, nil, []byte("fog-sphinx-v2")) + key := make([]byte, AESKeySize) + kdf.Read(key) - // Encrypt - encrypted, err := encrypt(combined, encKey) + block, err := aes.NewCipher(key) if err != nil { - return nil, fmt.Errorf("encrypt layer %d: %w", i, err) + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err } - dbg("Layer %d: %d -> %d bytes (encrypted)", i, len(combined), len(encrypted)) - payload = encrypted + nonce := make([]byte, NonceSize) + rand.Read(nonce) - var ek [32]byte - copy(ek[:], ephPub) - ephKeys = append([][32]byte{ek}, ephKeys...) - secrets = append([][]byte{shared}, secrets...) + encrypted = gcm.Seal(nonce, nonce, encrypted, nil) } - // Build header header := &SphinxHeader{ Version: 2, - EphKey: ephKeys[0], + Routing: routing, } + copy(header.EphKey[:], ephPublic) - // Remaining ephemeral keys - for _, k := range ephKeys[1:] { - header.Routing = append(header.Routing, k[:]...) - } + mac := hmac.New(sha256.New, sharedSecrets[0]) + mac.Write(encrypted) + copy(header.MAC[:], mac.Sum(nil)) - // HMAC - _, macKey, _ := deriveKeys(secrets[0]) - buf := new(bytes.Buffer) - buf.WriteByte(header.Version) - buf.Write(header.EphKey[:]) - binary.Write(buf, binary.BigEndian, uint32(len(header.Routing))) - buf.Write(header.Routing) - copy(header.MAC[:], computeMAC(buf.Bytes(), macKey)) - - dbg("Sphinx packet created: header=%d bytes, payload=%d bytes", - 1+32+4+len(header.Routing)+32, len(payload)) - - return &SphinxPacket{Header: header, Payload: payload}, nil + return &SphinxPacket{ + Header: header, + Payload: encrypted, + }, nil } -func serializeHeader(h *SphinxHeader) []byte { - buf := new(bytes.Buffer) - buf.WriteByte(h.Version) - buf.Write(h.EphKey[:]) - binary.Write(buf, binary.BigEndian, uint32(len(h.Routing))) - buf.Write(h.Routing) - buf.Write(h.MAC[:]) - return buf.Bytes() -} - -func deserializeHeader(data []byte) (*SphinxHeader, error) { - if len(data) < 37 { - return nil, errors.New("header too short") +func processSphinxPacket(packet *SphinxPacket) (*RoutingInfo, []byte, error) { + shared, err := curve25519.X25519(localNode.Private, packet.Header.EphKey[:]) + if err != nil { + return nil, nil, err } - h := &SphinxHeader{Version: data[0]} - copy(h.EphKey[:], data[1:33]) + kdf := hkdf.New(sha256.New, shared, nil, []byte("fog-sphinx-v2")) + key := make([]byte, AESKeySize) + kdf.Read(key) - routeLen := binary.BigEndian.Uint32(data[33:37]) - if routeLen > 10240 { - return nil, errors.New("routing too large") + mac := hmac.New(sha256.New, shared) + mac.Write(packet.Payload) + if !hmac.Equal(packet.Header.MAC[:], mac.Sum(nil)[:HMACSize]) { + return nil, nil, errors.New("HMAC verification failed") } - if len(data) < 37+int(routeLen)+32 { - return nil, errors.New("incomplete header") - } - - h.Routing = data[37 : 37+routeLen] - copy(h.MAC[:], data[37+routeLen:37+routeLen+32]) - return h, nil -} - -// ============================================================================ -// SPHINX PROCESSING -// ============================================================================ - -func processPacket(pkt *SphinxPacket) { - atomic.AddInt64(&stats.MixRecv, 1) - - dbg("Processing Sphinx packet") - dbg("EphKey: %x", pkt.Header.EphKey[:8]) - - // Random delay - delay := secureDelay() - dbg("Delay: %s", delay) - time.Sleep(delay) - - // Derive shared secret - shared, err := curve25519.X25519(local.Private, pkt.Header.EphKey[:]) + block, err := aes.NewCipher(key) if err != nil { - log.Printf("[SPHINX] shared secret failed: %v", err) - return + return nil, nil, err } - dbg("Shared secret derived") - - encKey, macKey, _ := deriveKeys(shared) - - // Verify HMAC if present - isZeroMAC := true - for _, b := range pkt.Header.MAC { - if b != 0 { - isZeroMAC = false - break - } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, nil, err } - if !isZeroMAC { - buf := new(bytes.Buffer) - buf.WriteByte(pkt.Header.Version) - buf.Write(pkt.Header.EphKey[:]) - binary.Write(buf, binary.BigEndian, uint32(len(pkt.Header.Routing))) - buf.Write(pkt.Header.Routing) - - if !verifyMAC(buf.Bytes(), pkt.Header.MAC[:], macKey) { - log.Printf("[SPHINX] HMAC verification FAILED") - return - } - dbg("HMAC verified OK") - } else { - dbg("No HMAC (forwarded packet)") + if len(packet.Payload) < NonceSize { + return nil, nil, errors.New("payload too short") } - // Decrypt payload - plain, err := decrypt(pkt.Payload, encKey) - if err != nil { - log.Printf("[SPHINX] decrypt failed: %v", err) - return - } - dbg("Decrypted payload: %d bytes", len(plain)) + nonce := packet.Payload[:NonceSize] + ciphertext := packet.Payload[NonceSize:] - if len(plain) < 4 { - log.Printf("[SPHINX] payload too short") - return + decrypted, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, nil, err } - routeLen := binary.BigEndian.Uint32(plain[:4]) - if len(plain) < 4+int(routeLen) { - log.Printf("[SPHINX] invalid route length") - return + routingParts := bytes.Split(packet.Header.Routing, []byte{0}) + if len(routingParts) == 0 { + return nil, nil, errors.New("no routing info") } - routeData := plain[4 : 4+routeLen] - innerPayload := plain[4+routeLen:] - - var routing RoutingInfo - if err := json.Unmarshal(routeData, &routing); err != nil { - log.Printf("[SPHINX] route parse failed: %v", err) - return + var info RoutingInfo + if err := json.Unmarshal(routingParts[0], &info); err != nil { + return nil, nil, err } - dbg("Routing: next=%s", routing.Next) - - if routing.Next == "EXIT" { - log.Printf("[SPHINX] EXIT node - delivering message") - dbg("From: %s, To: %s", routing.From, routing.To) - - // UNPAD the payload to get original message - originalMsg, err := unpadPayload(innerPayload) - if err != nil { - log.Printf("[SPHINX] unpad failed: %v", err) - atomic.AddInt64(&stats.Failed, 1) - return - } - dbg("Unpadded message: %d bytes", len(originalMsg)) - - for _, to := range strings.Split(routing.To, ",") { - to = strings.TrimSpace(to) - if to == "" { - continue - } - if err := deliver(routing.From, to, originalMsg); err != nil { - log.Printf("[SPHINX] delivery FAILED %s: %v", to, err) - atomic.AddInt64(&stats.Failed, 1) - } else { - log.Printf("[SPHINX] SUCCESS delivered to %s", to) - atomic.AddInt64(&stats.Sent, 1) - } - } - } else { - log.Printf("[SPHINX] Forwarding to %s", routing.Next) - - // Extract remaining ephemeral keys - var ephKeys [][32]byte - for i := 0; i+32 <= len(pkt.Header.Routing); i += 32 { - var k [32]byte - copy(k[:], pkt.Header.Routing[i:i+32]) - ephKeys = append(ephKeys, k) - } - dbg("Remaining ephemeral keys: %d", len(ephKeys)) - if len(ephKeys) == 0 { - log.Printf("[SPHINX] no more ephemeral keys") - return - } - - newHeader := &SphinxHeader{ - Version: 2, - EphKey: ephKeys[0], + if info.IsExit { + if len(decrypted) < 4 { + return nil, nil, errors.New("invalid exit payload") } - - if len(ephKeys) > 1 { - for _, k := range ephKeys[1:] { - newHeader.Routing = append(newHeader.Routing, k[:]...) - } + + originalLen := binary.BigEndian.Uint32(decrypted[0:4]) + if originalLen > uint32(len(decrypted)-4) { + return nil, nil, errors.New("invalid length") } - - newPkt := &SphinxPacket{Header: newHeader, Payload: innerPayload} - - if err := sendPacket(routing.Next, newPkt); err != nil { - log.Printf("[SPHINX] forward FAILED: %v", err) - } else { - log.Printf("[SPHINX] Forwarded to %s", routing.Next) - atomic.AddInt64(&stats.MixFwd, 1) + + original := decrypted[4 : 4+originalLen] + + if debug { + log.Printf("[SPHINX] Exit node: extracted %d bytes from %d padded", + originalLen, len(decrypted)) } + + return &info, original, nil } -} -func sendPacket(addr string, pkt *SphinxPacket) error { - dbg("Connecting to %s via Tor", addr) - - conn, err := torDialer.Dial("tcp", addr) - if err != nil { - return fmt.Errorf("connect: %w", err) + newHeader := &SphinxHeader{ + Version: packet.Header.Version, + EphKey: packet.Header.EphKey, + Routing: bytes.Join(routingParts[1:], []byte{0}), } - defer conn.Close() - - dbg("Connected, sending packet") + copy(newHeader.MAC[:], packet.Header.MAC[:]) - // Send padded header - hdr := serializeHeader(pkt.Header) - padded := make([]byte, HeaderSize) - copy(padded, hdr) - - if _, err := conn.Write(padded); err != nil { - return fmt.Errorf("write header: %w", err) - } - if _, err := conn.Write(pkt.Payload); err != nil { - return fmt.Errorf("write payload: %w", err) + newPacket := &SphinxPacket{ + Header: newHeader, + Payload: decrypted, } - dbg("Packet sent: header=%d, payload=%d", HeaderSize, len(pkt.Payload)) - return nil + var buf bytes.Buffer + json.NewEncoder(&buf).Encode(newPacket) + + return &info, buf.Bytes(), nil } // ============================================================================ -// NODE SERVER (receives Sphinx packets) +// BATCH MIXING // ============================================================================ -func startNodeServer(addr string) error { - ln, err := net.Listen("tcp", addr) - if err != nil { - return err - } +func (b *Batch) Add(packet *SphinxPacket) { + b.mu.Lock() + defer b.mu.Unlock() - log.Printf("[NODE] Sphinx server on %s", addr) + if len(b.packets) == 0 { + b.start = time.Now() + } - go func() { - defer wg.Done() - for { - conn, err := ln.Accept() - if err != nil { - select { - case <-ctx.Done(): - return - default: - continue - } - } - go handleNode(conn) - } - }() + b.packets = append(b.packets, packet) - return nil + if len(b.packets) >= BatchSize { + go b.Flush() + } } -func handleNode(conn net.Conn) { - defer conn.Close() - - remote := conn.RemoteAddr().String() - dbg("Node connection from %s", remote) - - hdrBuf := make([]byte, HeaderSize) - if _, err := io.ReadFull(conn, hdrBuf); err != nil { - if err != io.EOF { - dbg("Header read failed from %s: %v", remote, err) - } +func (b *Batch) Flush() { + b.mu.Lock() + if len(b.packets) == 0 { + b.mu.Unlock() return } - hdr, err := deserializeHeader(hdrBuf) - if err != nil { - log.Printf("[NODE] header parse failed: %v", err) - return + toSend := b.packets + b.packets = make([]*SphinxPacket, 0) + b.mu.Unlock() + + for i := len(toSend) - 1; i > 0; i-- { + j := int(secureRandInt64(int64(i + 1))) + toSend[i], toSend[j] = toSend[j], toSend[i] } - payload, err := io.ReadAll(conn) - if err != nil { - log.Printf("[NODE] payload read failed: %v", err) - return + for _, packet := range toSend { + go forwardSphinxPacket(packet) } - dbg("Received packet: header parsed, payload=%d bytes", len(payload)) - batch.Add(&SphinxPacket{Header: hdr, Payload: payload}) - log.Printf("[NODE] Packet received and batched") + atomic.AddInt64(&stats.MixFwd, int64(len(toSend))) +} + +func (b *Batch) AutoFlush() { + ticker := time.NewTicker(BatchWindow) + defer ticker.Stop() + + for range ticker.C { + b.Flush() + } } // ============================================================================ -// SMTP SERVER (entry point) +// SMTP SERVER // ============================================================================ -func startSMTP(addr string) error { +func smtpServer(addr string) { ln, err := net.Listen("tcp", addr) if err != nil { - return err + log.Fatal(err) } + defer ln.Close() log.Printf("[SMTP] Listening on %s", addr) for { conn, err := ln.Accept() if err != nil { - select { - case <-ctx.Done(): - return nil - default: - continue - } + continue } go handleSMTP(conn) } @@ -908,11 +949,11 @@ func handleSMTP(conn net.Conn) { reader := bufio.NewReader(conn) writer := bufio.NewWriter(conn) - fmt.Fprintf(writer, "220 %s fog/%s\r\n", hostname, Version) + writer.WriteString("220 fog SMTP Ready\r\n") writer.Flush() var from string - var to []string + var recipients []string var data bytes.Buffer for { @@ -920,521 +961,523 @@ func handleSMTP(conn net.Conn) { if err != nil { return } - line = strings.TrimRight(line, "\r\n") - dbg("SMTP <- %s: %s", remote, line) - parts := strings.SplitN(line, " ", 2) - cmd := strings.ToUpper(parts[0]) + line = strings.TrimSpace(line) + cmd := strings.ToUpper(strings.Fields(line)[0]) + + if debug { + log.Printf("[SMTP] <- %s: %s", remote, line) + } switch cmd { case "EHLO", "HELO": - fmt.Fprintf(writer, "250-%s\r\n250-SIZE %d\r\n250 8BITMIME\r\n", hostname, MaxMsgSize) - dbg("SMTP -> EHLO response") + writer.WriteString("250 Hello\r\n") case "MAIL": - if !strings.HasPrefix(strings.ToUpper(line), "MAIL FROM:") { - fmt.Fprintf(writer, "501 Syntax\r\n") - writer.Flush() - continue - } - addr := strings.TrimSpace(strings.SplitN(line, ":", 2)[1]) - if idx := strings.Index(addr, " "); idx != -1 { - addr = addr[:idx] - } - from = strings.Trim(addr, "<>") - to = nil - fmt.Fprintf(writer, "250 OK\r\n") - dbg("MAIL FROM: %s", from) + from = extractEmail(line) + writer.WriteString("250 OK\r\n") case "RCPT": - if from == "" { - fmt.Fprintf(writer, "503 MAIL first\r\n") - writer.Flush() - continue - } - if len(to) >= MaxRecipient { - fmt.Fprintf(writer, "452 Too many\r\n") - writer.Flush() - continue - } - if !strings.HasPrefix(strings.ToUpper(line), "RCPT TO:") { - fmt.Fprintf(writer, "501 Syntax\r\n") - writer.Flush() - continue - } - addr := strings.Trim(strings.TrimSpace(strings.SplitN(line, ":", 2)[1]), "<>") - if addr == "" { - fmt.Fprintf(writer, "501 Invalid\r\n") - writer.Flush() - continue - } - to = append(to, addr) - fmt.Fprintf(writer, "250 OK\r\n") - dbg("RCPT TO: %s", addr) + to := extractEmail(line) + recipients = append(recipients, to) + writer.WriteString("250 OK\r\n") case "DATA": - if from == "" || len(to) == 0 { - fmt.Fprintf(writer, "503 MAIL/RCPT first\r\n") - writer.Flush() - continue - } - - fmt.Fprintf(writer, "354 End with .\r\n") + writer.WriteString("354 End data with <CR><LF>.<CR><LF>\r\n") writer.Flush() - data.Reset() for { line, err := reader.ReadString('\n') if err != nil { return } - // FIX: Accept both .\r\n and .\n - if line == ".\r\n" || line == ".\n" { + if line == ".\r\n" { break } - if strings.HasPrefix(line, "..") { - line = line[1:] - } data.WriteString(line) - if data.Len() > MaxMsgSize { - fmt.Fprintf(writer, "552 Too large\r\n") - writer.Flush() - return - } } - id := genID() msg := &Message{ - ID: id, + ID: generateMessageID(), From: from, - To: to, + To: recipients, Data: data.Bytes(), Time: time.Now(), } - if !replay.Check(id) { - fmt.Fprintf(writer, "550 Duplicate\r\n") - writer.Flush() - continue - } - - select { - case queue <- msg: + if enableDelay { + if err := delayPool.Enqueue(msg); err != nil { + log.Printf("[POOL] Enqueue error: %v", err) + writer.WriteString("451 Queue error\r\n") + } else { + writer.WriteString("250 Queued for delayed delivery\r\n") + atomic.AddInt64(&stats.Recv, 1) + } + } else { + go sendMessage(msg) + writer.WriteString("250 OK\r\n") atomic.AddInt64(&stats.Recv, 1) - fmt.Fprintf(writer, "250 OK: %s\r\n", id) - log.Printf("[SMTP] Queued %s from %s to %d rcpt (%d bytes)", - id, from, len(to), data.Len()) - default: - fmt.Fprintf(writer, "452 Queue full\r\n") } from = "" - to = nil - - case "RSET": - from = "" - to = nil - fmt.Fprintf(writer, "250 OK\r\n") + recipients = nil + data.Reset() case "QUIT": - fmt.Fprintf(writer, "221 Bye\r\n") + writer.WriteString("221 Bye\r\n") writer.Flush() return default: - fmt.Fprintf(writer, "502 Unknown\r\n") + writer.WriteString("500 Unknown command\r\n") } writer.Flush() } } -func genID() string { - b := make([]byte, 16) - rand.Read(b) - return fmt.Sprintf("%x", b) +func extractEmail(line string) string { + start := strings.Index(line, "<") + end := strings.Index(line, ">") + if start >= 0 && end > start { + return line[start+1 : end] + } + parts := strings.Fields(line) + if len(parts) >= 2 { + return parts[1] + } + return "" +} + +func generateMessageID() string { + return fmt.Sprintf("%s@fog", secureRandHex(16)) } // ============================================================================ -// DELIVERY +// MESSAGE SENDING // ============================================================================ -func deliver(from, to string, data []byte) error { - parts := strings.Split(to, "@") - if len(parts) != 2 { - return fmt.Errorf("invalid email: %s", to) +func sendMessage(msg *Message) { + if enableSphinx { + sendViaSphinx(msg) + } else { + sendDirect(msg) } +} - server := parts[1] + ":25" - dbg("Delivering to %s via %s", to, server) - - conn, err := torDialer.Dial("tcp", server) +func sendViaSphinx(msg *Message) { + path, err := pki.RandomPath(SphinxHops) if err != nil { - return fmt.Errorf("connect: %w", err) + log.Printf("[SPHINX] Path error: %v", err) + atomic.AddInt64(&stats.Failed, 1) + return } - defer conn.Close() - client, err := smtp.NewClient(conn, hostname) + packet, err := buildSphinxPacket(path, msg.Data) if err != nil { - return fmt.Errorf("smtp client: %w", err) + log.Printf("[SPHINX] Build error: %v", err) + atomic.AddInt64(&stats.Failed, 1) + return } - defer client.Close() - if err := client.Hello(hostname); err != nil { - return fmt.Errorf("EHLO: %w", err) - } - if err := client.Mail(from); err != nil { - return fmt.Errorf("MAIL: %w", err) - } - if err := client.Rcpt(to); err != nil { - return fmt.Errorf("RCPT: %w", err) + time.Sleep(secureRandDelay(MinDelay, MaxDelay)) + + batch.Add(packet) + atomic.AddInt64(&stats.Sphinx, 1) + + if debug { + log.Printf("[SPHINX] Queued %s via %s → %s → %s", + msg.ID, path[0].Name, path[1].Name, path[2].Name) } +} - wc, err := client.Data() +func forwardSphinxPacket(packet *SphinxPacket) { + info, payload, err := processSphinxPacket(packet) if err != nil { - return fmt.Errorf("DATA: %w", err) - } - - if _, err := wc.Write(data); err != nil { - wc.Close() - return fmt.Errorf("write: %w", err) - } - - if err := wc.Close(); err != nil { - return fmt.Errorf("close: %w", err) + log.Printf("[SPHINX] Process error: %v", err) + atomic.AddInt64(&stats.Failed, 1) + return } - dbg("Delivered %d bytes to %s", len(data), to) - return client.Quit() + if info.IsExit { + // Sanitize headers before delivery + sanitizedPayload := sanitizeHeaders(payload) + + for _, to := range extractRecipients(sanitizedPayload) { + if err := deliverSMTP(to, sanitizedPayload); err != nil { + log.Printf("[SPHINX] Delivery failed %s: %v", to, err) + atomic.AddInt64(&stats.Failed, 1) + } else { + if debug { + log.Printf("[SPHINX] Delivered to %s", to) + } + atomic.AddInt64(&stats.Sent, 1) + } + } + } else { + forwardToNode(info.NextHop, payload) + } } -// ============================================================================ -// WORKERS -// ============================================================================ - -func worker(id int) { - defer wg.Done() - log.Printf("[WORKER %d] Started", id) - - for { - select { - case <-ctx.Done(): - log.Printf("[WORKER %d] Stopped", id) - return - case msg := <-queue: - processMsg(id, msg) +func sendDirect(msg *Message) { + for _, to := range msg.To { + if err := deliverSMTP(to, msg.Data); err != nil { + log.Printf("[DIRECT] Delivery failed %s: %v", to, err) + atomic.AddInt64(&stats.Failed, 1) + } else { + if debug { + log.Printf("[DIRECT] Delivered to %s", to) + } + atomic.AddInt64(&stats.Sent, 1) } } + atomic.AddInt64(&stats.Direct, 1) } -func processMsg(wid int, msg *Message) { - delay := secureDelay() - log.Printf("[WORKER %d] Processing %s (delay %s)", wid, msg.ID, delay.Round(time.Millisecond)) - time.Sleep(delay) +func deliverSMTP(to string, data []byte) error { + parts := strings.SplitN(to, "@", 2) + if len(parts) != 2 { + return errors.New("invalid recipient") + } + domain := parts[1] + + host := domain + ":25" - healthy := pki.HealthyCount() - dbg("Healthy nodes: %d (need %d for Sphinx)", healthy, SphinxHops) + var conn net.Conn + var err error - if useSphinx.Load() && healthy >= SphinxHops { - if err := sphinxRoute(msg); err != nil { - log.Printf("[WORKER %d] Sphinx failed: %v, using direct", wid, err) - atomic.AddInt64(&stats.Direct, 1) - directRoute(msg) - } else { - atomic.AddInt64(&stats.Sphinx, 1) + if strings.HasSuffix(domain, ".onion") { + dialer, err := proxy.SOCKS5("tcp", TorSocks, nil, proxy.Direct) + if err != nil { + return err } + conn, err = dialer.Dial("tcp", host) } else { - if useSphinx.Load() { - log.Printf("[WORKER %d] Not enough nodes (%d/%d), using direct", wid, healthy, SphinxHops) - } - atomic.AddInt64(&stats.Direct, 1) - directRoute(msg) + conn, err = net.DialTimeout("tcp", host, 30*time.Second) } -} -func sphinxRoute(msg *Message) error { - healthy := pki.GetHealthy() - if len(healthy) < SphinxHops { - return fmt.Errorf("need %d nodes, have %d", SphinxHops, len(healthy)) + if err != nil { + return err } + defer conn.Close() - // Secure shuffle and select - secureShuffle(healthy) - route := healthy[:SphinxHops] - - log.Printf("[SPHINX] Route: %s -> %s -> %s", route[0].Name, route[1].Name, route[2].Name) - - pkt, err := createPacket(msg, route) + client, err := smtp.NewClient(conn, domain) if err != nil { - return fmt.Errorf("create packet: %w", err) + return err } + defer client.Quit() - if err := sendPacket(route[0].Address, pkt); err != nil { - return fmt.Errorf("send to entry: %w", err) + if err := client.Mail("fog@anonymous.invalid"); err != nil { + return err + } + if err := client.Rcpt(to); err != nil { + return err } - atomic.AddInt64(&stats.Sent, 1) - log.Printf("[SPHINX] SUCCESS: %s via %s", msg.ID, route[0].Name) - return nil + w, err := client.Data() + if err != nil { + return err + } + defer w.Close() + + _, err = w.Write(data) + return err } -func directRoute(msg *Message) { - for _, to := range msg.To { - to = strings.TrimSpace(to) - if to == "" { - continue - } - if err := deliver(msg.From, to, msg.Data); err != nil { - log.Printf("[RELAY] FAILED %s -> %s: %v", msg.ID, to, err) - atomic.AddInt64(&stats.Failed, 1) - } else { - log.Printf("[RELAY] SUCCESS %s -> %s", msg.ID, to) - atomic.AddInt64(&stats.Sent, 1) +func extractRecipients(data []byte) []string { + lines := bytes.Split(data, []byte("\r\n")) + for _, line := range lines { + if bytes.HasPrefix(bytes.ToLower(line), []byte("to:")) { + addr := strings.TrimSpace(string(line[3:])) + return []string{addr} } } + return []string{} } // ============================================================================ -// HEALTH CHECK - Checks ALL nodes, not just healthy ones +// SPHINX NODE SERVER // ============================================================================ -func healthChecker() { - defer wg.Done() - - // Initial check - time.Sleep(5 * time.Second) - doHealthCheck() - - ticker := time.NewTicker(HealthInterval) - defer ticker.Stop() +func sphinxNodeServer(addr string) { + ln, err := net.Listen("tcp", addr) + if err != nil { + log.Fatal(err) + } + defer ln.Close() + + log.Printf("[NODE] Listening on %s", addr) for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - doHealthCheck() + conn, err := ln.Accept() + if err != nil { + continue } + go handleSphinxNode(conn) } } -func doHealthCheck() { - nodes := pki.GetAllOther() - log.Printf("[HEALTH] Checking %d nodes", len(nodes)) +func handleSphinxNode(conn net.Conn) { + defer conn.Close() - var wgCheck sync.WaitGroup - for _, n := range nodes { - wgCheck.Add(1) - go func(node *Node) { - defer wgCheck.Done() - checkNode(node) - }(n) + var packet SphinxPacket + if err := json.NewDecoder(conn).Decode(&packet); err != nil { + return } - wgCheck.Wait() - - log.Printf("[HEALTH] Done. %d nodes healthy", pki.HealthyCount()) + + atomic.AddInt64(&stats.MixRecv, 1) + + batch.Add(&packet) } -func checkNode(n *Node) { - dbg("Checking %s at %s", n.Name, n.Address) - - conn, err := torDialer.Dial("tcp", n.Address) +func forwardToNode(addr string, payload []byte) { + dialer, err := proxy.SOCKS5("tcp", TorSocks, nil, proxy.Direct) if err != nil { - pki.mu.Lock() - n.Healthy = false - pki.mu.Unlock() - log.Printf("[HEALTH] %s DOWN: %v", n.Name, err) return } - conn.Close() - pki.mu.Lock() - n.Healthy = true - n.LastOK = time.Now() - pki.mu.Unlock() - log.Printf("[HEALTH] %s OK", n.Name) + conn, err := dialer.Dial("tcp", addr) + if err != nil { + return + } + defer conn.Close() + + conn.Write(payload) } // ============================================================================ -// STATS +// HEALTH CHECKER // ============================================================================ -func statsMonitor() { - defer wg.Done() - ticker := time.NewTicker(StatsInterval) +func healthChecker() { + ticker := time.NewTicker(HealthInterval) defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - up := time.Since(stats.Start).Round(time.Second) - log.Printf("[STATS] Up:%s R:%d S:%d F:%d | Sphinx:%d Direct:%d | Mix R:%d F:%d | Healthy:%d", - up, - atomic.LoadInt64(&stats.Recv), - atomic.LoadInt64(&stats.Sent), - atomic.LoadInt64(&stats.Failed), - atomic.LoadInt64(&stats.Sphinx), - atomic.LoadInt64(&stats.Direct), - atomic.LoadInt64(&stats.MixRecv), - atomic.LoadInt64(&stats.MixFwd), - pki.HealthyCount()) + for range ticker.C { + healthy := pki.GetHealthy() + log.Printf("[HEALTH] Checking %d nodes", len(pki.Nodes)-1) + + for _, node := range pki.Nodes { + if node.ID == localNode.ID { + continue + } + + go func(n *Node) { + if checkNode(n.Address) { + pki.mu.Lock() + n.Healthy = true + n.LastOK = time.Now() + pki.mu.Unlock() + + if debug { + log.Printf("[HEALTH] %s OK", n.Name) + } + } else { + pki.mu.Lock() + n.Healthy = false + pki.mu.Unlock() + + log.Printf("[HEALTH] %s FAILED", n.Name) + } + }(node) } + + time.Sleep(5 * time.Second) + healthy = pki.GetHealthy() + log.Printf("[HEALTH] Done. %d nodes healthy", len(healthy)) + } +} + +func checkNode(addr string) bool { + dialer, err := proxy.SOCKS5("tcp", TorSocks, nil, proxy.Direct) + if err != nil { + return false + } + + conn, err := dialer.Dial("tcp", addr) + if err != nil { + return false } + conn.Close() + return true } // ============================================================================ -// MAIN +// DELAY POOL SCHEDULER // ============================================================================ -func main() { - name := flag.String("name", "fog.local", "Hostname (your .onion address)") - shortName := flag.String("short-name", "", "Short name for logs (e.g. kvara, news, mail)") - smtpAddr := flag.String("smtp", "127.0.0.1:2525", "SMTP listen address") - nodeAddr := flag.String("node", "127.0.0.1:9999", "Node listen address") - sphinx := flag.Bool("sphinx", false, "Enable Sphinx mixnet") - pkiFlag := flag.String("pki-file", "", "Path to nodes.json") - dataFlag := flag.String("data-dir", "./fog-data", "Data directory") - debug := flag.Bool("debug", false, "Enable debug logging") - export := flag.Bool("export-node-info", false, "Export node info for nodes.json") - version := flag.Bool("version", false, "Show version") +func delayPoolScheduler() { + ticker := time.NewTicker(PoolInterval) + defer ticker.Stop() - flag.Parse() + log.Printf("[POOL] Scheduler started (check every %v)", PoolInterval) - if *version { - fmt.Printf("fog v%s\n\n", Version) - fmt.Println("Security features:") - fmt.Println(" ✓ Curve25519 ECDH + AES-256-GCM") - fmt.Println(" ✓ 3-hop Sphinx mixnet") - fmt.Println(" ✓ crypto/rand everywhere (no math/rand)") - fmt.Println(" ✓ Traffic analysis resistance (batching)") - fmt.Println(" ✓ Timing attack resistance (random delays)") - fmt.Println(" ✓ Size correlation resistance (fixed payload)") - fmt.Println(" ✓ Replay protection (24h cache)") - fmt.Println(" ✓ No metadata retention") - fmt.Println("\nNo PKI server needed - uses local nodes.json") - os.Exit(0) - } - - debugMode = *debug - dataDir = *dataFlag - os.MkdirAll(dataDir, 0700) + for range ticker.C { + messages, err := delayPool.GetReady() + if err != nil { + log.Printf("[POOL] GetReady error: %v", err) + continue + } - if *export { - initNode(*nodeAddr) - - // Use short name if provided, otherwise use hostname - displayName := *shortName - if displayName == "" { - displayName = *name + if len(messages) == 0 { + continue } - - // Create properly formatted nodes.json - nodeInfo := map[string]interface{}{ - "version": Version, - "updated": time.Now().UTC().Format(time.RFC3339), - "nodes": []map[string]string{ - { - "node_id": local.ID, - "public_key": base64.StdEncoding.EncodeToString(local.Public), - "address": *name + ":9999", - "name": displayName, - "version": Version, - }, - }, + + log.Printf("[POOL] Processing %d ready messages", len(messages)) + + for _, qmsg := range messages { + go func(m *QueuedMessage) { + msg := &Message{ + ID: m.ID, + From: m.From, + To: []string{m.To}, + Data: m.Data, + Time: m.EnqueueTime, + } + + sendMessage(msg) + + if err := delayPool.Delete(m.ID); err != nil { + log.Printf("[POOL] Delete error: %v", err) + } else { + if debug { + waited := time.Since(m.EnqueueTime) + log.Printf("[POOL] Sent %s after %v delay", m.ID, waited.Round(time.Second)) + } + } + }(qmsg) } + } +} + +// ============================================================================ +// STATS REPORTER +// ============================================================================ + +func statsReporter() { + ticker := time.NewTicker(StatsInterval) + defer ticker.Stop() + + for range ticker.C { + uptime := time.Since(stats.Start) + healthy := len(pki.GetHealthy()) - data, _ := json.MarshalIndent(nodeInfo, "", " ") - - // Write to nodes.json - if err := os.WriteFile("nodes.json", data, 0600); err != nil { - log.Fatalf("Failed to write nodes.json: %v", err) + var queued int64 + if enableDelay { + queued = atomic.LoadInt64(&stats.Queued) } - - fmt.Println(string(data)) - log.Printf("Written to nodes.json") - os.Exit(0) - } - log.Printf("[FOG] Starting v%s", Version) - if debugMode { - log.Printf("[FOG] Debug mode ENABLED") + log.Printf("[STATS] Up:%v R:%d S:%d F:%d | Sphinx:%d Direct:%d | Mix R:%d F:%d | Q:%d D:%d | Healthy:%d", + uptime.Round(time.Second), + atomic.LoadInt64(&stats.Recv), + atomic.LoadInt64(&stats.Sent), + atomic.LoadInt64(&stats.Failed), + atomic.LoadInt64(&stats.Sphinx), + atomic.LoadInt64(&stats.Direct), + atomic.LoadInt64(&stats.MixRecv), + atomic.LoadInt64(&stats.MixFwd), + queued, + atomic.LoadInt64(&stats.Delayed), + healthy, + ) } +} - ctx, cancel = context.WithCancel(context.Background()) - defer cancel() - - hostname = *name - pkiFile = *pkiFlag +// ============================================================================ +// MAIN +// ============================================================================ - // Init - stats = &Stats{Start: time.Now()} - replay = newReplayCache() - batch = newBatch() - queue = make(chan *Message, QueueSize) - pki = newPKI() +func main() { + var ( + name = flag.String("name", "", "Node address (.onion:port)") + shortName = flag.String("short-name", "", "Short name for logs") + smtpAddr = flag.String("smtp", "127.0.0.1:"+DefaultPort, "SMTP listen address") + nodeAddr = flag.String("node", "127.0.0.1:"+NodePort, "Sphinx node address") + pkiFile = flag.String("pki-file", "", "PKI nodes.json file") + dataDir = flag.String("data-dir", "fog-data", "Data directory") + + minPoolDelay = flag.Duration("min-delay", DefaultMinPoolDelay, "Minimum delay pool time") + maxPoolDelay = flag.Duration("max-delay", DefaultMaxPoolDelay, "Maximum delay pool time") + delayStrat = flag.String("delay-strategy", "exponential", "Delay strategy: exponential, constant, poisson") + + sphinx = flag.Bool("sphinx", false, "Enable Sphinx routing") + delay = flag.Bool("delay", false, "Enable delay pool") + showDebug = flag.Bool("debug", false, "Debug logging") + exportNode = flag.Bool("export-node-info", false, "Export node info and exit") + ) + + flag.Parse() - initNode(*nodeAddr) + enableSphinx = *sphinx + enableDelay = *delay + debug = *showDebug - // Tor - dialer, err := proxy.SOCKS5("tcp", TorSocks, nil, proxy.Direct) - if err != nil { - log.Fatalf("[TOR] Failed: %v", err) + if *name == "" { + log.Fatal("Error: -name required") } - torDialer = dialer - log.Printf("[TOR] Connected to %s", TorSocks) - useSphinx.Store(*sphinx) + if *shortName == "" { + log.Fatal("Error: -short-name required") + } - if *sphinx { - log.Printf("[FOG] Sphinx mode ENABLED") + if err := localNode.Init(*name, *shortName); err != nil { + log.Fatal(err) + } - if pkiFile == "" { - log.Printf("[FOG] WARNING: No -pki-file specified, Sphinx will use direct relay") - } else { - if err := pki.Load(pkiFile); err != nil { - log.Printf("[PKI] Load failed: %v", err) - } + if *exportNode { + if err := localNode.ExportInfo(*name, *shortName); err != nil { + log.Fatal(err) } + log.Printf("[EXPORT] Written to nodes.json") + return + } - wg.Add(1) - go healthChecker() + os.MkdirAll(*dataDir, 0700) - wg.Add(1) - if err := startNodeServer(*nodeAddr); err != nil { - log.Fatalf("[NODE] Failed: %v", err) + if enableDelay { + strategy := parseDelayStrategy(*delayStrat) + dbPath := filepath.Join(*dataDir, "messages.db") + + var err error + delayPool, err = NewDelayPool(dbPath, *minPoolDelay, *maxPoolDelay, strategy) + if err != nil { + log.Fatalf("[POOL] Init error: %v", err) } - - wg.Add(1) - go batchWorker() - } else { - log.Printf("[FOG] Direct relay mode (no Sphinx)") + defer delayPool.Close() + + log.Printf("[POOL] Enabled: min=%v max=%v strategy=%s", + *minPoolDelay, *maxPoolDelay, strategy) + + go delayPoolScheduler() } - // Workers - for i := 0; i < Workers; i++ { - wg.Add(1) - go worker(i) + if enableSphinx { + if *pkiFile == "" { + log.Fatal("Error: -pki-file required with -sphinx") + } + + if err := pki.Load(*pkiFile); err != nil { + log.Fatal(err) + } + + go healthChecker() + go batch.AutoFlush() + go sphinxNodeServer(*nodeAddr) } - wg.Add(1) - go statsMonitor() + go replayCache.Cleanup() + go statsReporter() - // Signals - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) go func() { - <-sig - log.Printf("[FOG] Shutdown signal received") - cancel() + <-sigCh + log.Println("[SHUTDOWN] Flushing batch...") + batch.Flush() + time.Sleep(2 * time.Second) + os.Exit(0) }() - if err := startSMTP(*smtpAddr); err != nil { - log.Fatalf("[SMTP] Failed: %v", err) - } - - wg.Wait() - log.Printf("[FOG] Shutdown complete") + smtpServer(*smtpAddr) } |
