diff options
Diffstat (limited to 'm2usenet.go')
| -rw-r--r-- | m2usenet.go | 476 |
1 files changed, 370 insertions, 106 deletions
diff --git a/m2usenet.go b/m2usenet.go index 011d16a..2de71b5 100644 --- a/m2usenet.go +++ b/m2usenet.go @@ -1,21 +1,22 @@ // /home/m2usenet/m2usenet.go -// Versione production-ready di Mail2Usenet in Go. -// Legge un'email da STDIN, verifica il token hashcash (inclusi controlli di formattazione, data, resource e hash), -// registra il token usato in un database JSON e invia il messaggio a un server NNTP tramite Tor (utilizzando un dialer SOCKS5). +// Production-ready Mail2Usenet with enhanced security, privacy and anti-analysis features. +// Reads email from STDIN, verifies hashcash token, registers it in JSON DB, sends to NNTP via Tor. package main import ( "bufio" "bytes" + "crypto/rand" "crypto/sha1" + "crypto/subtle" + "encoding/hex" "encoding/json" "errors" "fmt" - "golang.org/x/net/proxy" "io" "log" - "math/rand" + "math/big" "net/mail" "os" "path/filepath" @@ -23,24 +24,33 @@ import ( "strings" "sync" "time" + + "golang.org/x/net/proxy" ) var ( - // Configurazioni prelevate dalle variabili di ambiente (con valori di default per produzione) + // Configuration from environment variables NNTPServer = getEnv("NNTP_SERVER", "peannyjkqwqfynd24p6dszvtchkq7hfkwymi5by5y332wmosy5dwfaqd.onion") nntpPort, _ = strconv.Atoi(getEnv("NNTP_PORT", "119")) torProxyHost = getEnv("TOR_PROXY_HOST", "127.0.0.1") torProxyPort, _ = strconv.Atoi(getEnv("TOR_PROXY_PORT", "9050")) maxPostSize, _ = strconv.Atoi(getEnv("MAX_POST_SIZE", "10240")) + minPostSize, _ = strconv.Atoi(getEnv("MIN_POST_SIZE", "512")) delayCrossPost, _ = strconv.Atoi(getEnv("DELAY_CROSSPOST", "2")) - timeWindowSec, _ = strconv.Atoi(getEnv("TIME_WINDOW_SEC", "1800")) // 30 minuti + timeWindowSec, _ = strconv.Atoi(getEnv("TIME_WINDOW_SEC", "1800")) hashcashMinBits, _ = strconv.Atoi(getEnv("HASHCASH_MIN_BITS", "24")) dbPath = getEnv("DB_PATH", "/home/m2usenet/hashcash.json") + tokenTTL, _ = strconv.Atoi(getEnv("TOKEN_TTL_HOURS", "48")) + nntpTimeout, _ = strconv.Atoi(getEnv("NNTP_TIMEOUT_SEC", "30")) + maxRetries, _ = strconv.Atoi(getEnv("MAX_RETRIES", "3")) + paddingEnabled = getEnv("ENABLE_PADDING", "false") == "true" // Disabled for Usenet text tokenMutex sync.Mutex + + // Suppress detailed logging in production + quietMode = getEnv("QUIET_MODE", "false") == "true" ) -// getEnv restituisce il valore della variabile d'ambiente o il default se non impostata. func getEnv(key, def string) string { if v, ok := os.LookupEnv(key); ok { return v @@ -48,10 +58,48 @@ func getEnv(key, def string) string { return def } -// TokenDB rappresenta la struttura JSON per la memorizzazione dei token usati. -type TokenDB map[string]string +// TokenEntry stores token with expiration +type TokenEntry struct { + UsedAt string `json:"used_at"` + Expires string `json:"expires"` +} + +type TokenDB map[string]TokenEntry + +// secureLog logs only if not in quiet mode, avoiding detailed error leakage +func secureLog(format string, v ...interface{}) { + if !quietMode { + log.Printf(format, v...) + } +} -// tokenAlreadySpent verifica se il token è già stato registrato. +// cryptoRandInt generates cryptographically secure random integer in [min, max] +func cryptoRandInt(min, max int64) (int64, error) { + if max <= min { + return 0, errors.New("invalid range") + } + diff := max - min + 1 + n, err := rand.Int(rand.Reader, big.NewInt(diff)) + if err != nil { + return 0, err + } + return n.Int64() + min, nil +} + +// randomDelay adds jitter to prevent timing analysis +func randomDelay(baseSeconds int) { + if baseSeconds <= 0 { + return + } + jitter, err := cryptoRandInt(0, int64(baseSeconds)*500) + if err != nil { + jitter = 0 + } + delay := time.Duration(baseSeconds)*time.Second + time.Duration(jitter)*time.Millisecond + time.Sleep(delay) +} + +// tokenAlreadySpent checks if token exists (now with atomic operation) func tokenAlreadySpent(token string) bool { tokenMutex.Lock() defer tokenMutex.Unlock() @@ -60,11 +108,25 @@ func tokenAlreadySpent(token string) bool { if err != nil { return false } - _, exists := db[token] - return exists + + entry, exists := db[token] + if !exists { + return false + } + + // Check if token is expired + expires, err := time.Parse(time.RFC3339, entry.Expires) + if err == nil && time.Now().UTC().After(expires) { + // Token expired, can be reused + delete(db, token) + saveTokenDB(db) + return false + } + + return true } -// markTokenSpent registra il token nel file JSON, salvando anche il timestamp. +// markTokenSpent registers token atomically with expiration func markTokenSpent(token string) error { tokenMutex.Lock() defer tokenMutex.Unlock() @@ -73,11 +135,50 @@ func markTokenSpent(token string) error { if err != nil { db = make(TokenDB) } - db[token] = time.Now().UTC().Format(time.RFC3339) + + // Double-check within lock + if _, exists := db[token]; exists { + return errors.New("token already spent") + } + + now := time.Now().UTC() + db[token] = TokenEntry{ + UsedAt: now.Format(time.RFC3339), + Expires: now.Add(time.Duration(tokenTTL) * time.Hour).Format(time.RFC3339), + } + return saveTokenDB(db) } -// loadTokenDB carica il database dei token, se esistente. +// cleanupExpiredTokens removes expired entries (no persistent metadata retention) +func cleanupExpiredTokens() error { + tokenMutex.Lock() + defer tokenMutex.Unlock() + + db, err := loadTokenDB() + if err != nil { + return err + } + + now := time.Now().UTC() + cleaned := 0 + + for token, entry := range db { + expires, err := time.Parse(time.RFC3339, entry.Expires) + if err != nil || now.After(expires) { + delete(db, token) + cleaned++ + } + } + + if cleaned > 0 { + secureLog("Cleaned %d expired tokens", cleaned) + return saveTokenDB(db) + } + + return nil +} + func loadTokenDB() (TokenDB, error) { db := make(TokenDB) if _, err := os.Stat(dbPath); os.IsNotExist(err) { @@ -96,20 +197,18 @@ func loadTokenDB() (TokenDB, error) { return db, nil } -// saveTokenDB salva il database dei token sul file system. func saveTokenDB(db TokenDB) error { dir := filepath.Dir(dbPath) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0700); err != nil { return err } data, err := json.MarshalIndent(db, "", " ") if err != nil { return err } - return os.WriteFile(dbPath, data, 0644) + return os.WriteFile(dbPath, data, 0600) } -// parseHashcashDate interpreta la data dal token, accettando formati a 10 o 12 cifre. func parseHashcashDate(dateStr string) (time.Time, error) { layouts := []string{"0601021504", "060102150405"} var t time.Time @@ -120,180 +219,342 @@ func parseHashcashDate(dateStr string) (time.Time, error) { return t, nil } } - return t, errors.New("formato data non valido") + return t, errors.New("invalid date format") } -// verifyHashcashToken effettua i controlli sul token: -// - Formato (7 campi) -// - Versione "1" -// - Numero di bit almeno hashcashMinBits e multiplo di 4 -// - La resource deve corrispondere all'indirizzo del mittente (case-insensitive) -// - La data deve essere entro la finestra temporale ±timeWindowSec -// - Il digest SHA-1 deve avere i necessari zero iniziali +// verifyHashcashToken with constant-time comparisons where possible func verifyHashcashToken(token, fromAddr string) bool { parts := strings.Split(token, ":") if len(parts) != 7 { - log.Printf("Formato token non valido: %s", token) + secureLog("Invalid token format") return false } - version, bitsStr, dateStr, resource, ext, randPart, counter := parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6] - if version != "1" { - log.Printf("Versione token non valida: %s", version) + + version, bitsStr, dateStr, resource, ext, randPart, counter := + parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6] + + // Use constant-time comparison for version + if subtle.ConstantTimeCompare([]byte(version), []byte("1")) != 1 { + secureLog("Invalid token version") return false } + bits, err := strconv.Atoi(bitsStr) if err != nil || bits < hashcashMinBits || bits%4 != 0 { - log.Printf("Bits non validi: %s", bitsStr) + secureLog("Invalid bits") return false } - if strings.TrimSpace(strings.ToLower(resource)) != strings.TrimSpace(strings.ToLower(fromAddr)) { - log.Printf("Resource mismacth: %s vs %s", resource, fromAddr) + + // Normalize addresses for comparison + normalizedResource := strings.TrimSpace(strings.ToLower(resource)) + normalizedFrom := strings.TrimSpace(strings.ToLower(fromAddr)) + + if subtle.ConstantTimeCompare([]byte(normalizedResource), []byte(normalizedFrom)) != 1 { + secureLog("Resource mismatch") return false } + tokenTime, err := parseHashcashDate(dateStr) if err != nil { - log.Printf("Errore nella data del token: %v", err) + secureLog("Invalid token date") return false } + now := time.Now().UTC() - if now.Sub(tokenTime) > time.Duration(timeWindowSec)*time.Second || - tokenTime.Sub(now) > time.Duration(timeWindowSec)*time.Second { - log.Printf("Token fuori finestra temporale: %v", now.Sub(tokenTime)) + timeDiff := now.Sub(tokenTime) + if timeDiff < 0 { + timeDiff = -timeDiff + } + + if timeDiff > time.Duration(timeWindowSec)*time.Second { + secureLog("Token outside time window") return false } - assembled := fmt.Sprintf("%s:%s:%s:%s:%s:%s:%s", version, bitsStr, dateStr, resource, ext, randPart, counter) + + // Verify hash + assembled := fmt.Sprintf("%s:%s:%s:%s:%s:%s:%s", + version, bitsStr, dateStr, resource, ext, randPart, counter) hash := sha1.Sum([]byte(assembled)) shaHex := fmt.Sprintf("%x", hash) target := strings.Repeat("0", bits/4) + if !strings.HasPrefix(shaHex, target) { - log.Printf("Verifica hash fallita: %s non inizia con %s", shaHex, target) + secureLog("Hash verification failed") return false } + return true } -// sendViaTor stabilisce una connessione al server NNTP tramite Tor usando un dialer SOCKS5, invia il comando IHAVE e il messaggio. +// validateOnionAddress ensures NNTP server is a valid .onion address +func validateOnionAddress(addr string) bool { + if !strings.HasSuffix(addr, ".onion") { + return false + } + parts := strings.Split(addr, ".") + if len(parts) != 2 { + return false + } + // v3 onion addresses are 56 chars + return len(parts[0]) == 56 +} + +// addAdaptivePadding adds random padding to prevent size correlation +func addAdaptivePadding(message string) string { + if !paddingEnabled { + return message + } + + currentSize := len([]byte(message)) + + // Pad to next 1KB boundary + targetSize := ((currentSize / 1024) + 1) * 1024 + if targetSize > maxPostSize { + targetSize = maxPostSize + } + + paddingNeeded := targetSize - currentSize + if paddingNeeded <= 0 { + return message + } + + // Add random padding as comments + padding := make([]byte, paddingNeeded) + rand.Read(padding) + paddingHex := hex.EncodeToString(padding) + + // Truncate to needed length + if len(paddingHex) > paddingNeeded-20 { + paddingHex = paddingHex[:paddingNeeded-20] + } + + return message + "\r\n\r\n[padding:" + paddingHex + "]" +} + +// sendViaTor with timeout, retry logic and exponential backoff func sendViaTor(server string, port int, message, messageID string) (bool, error) { + if !validateOnionAddress(server) { + return false, errors.New("invalid onion address") + } + + var lastErr error + + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + // Exponential backoff with jitter + backoff := (1 << attempt) // 2^attempt seconds + randomDelay(backoff) + secureLog("Retry attempt %d/%d", attempt+1, maxRetries) + } + + success, err := attemptSend(server, port, message, messageID) + if err == nil && success { + return true, nil + } + lastErr = err + + // Don't retry on certain errors + if err != nil && strings.Contains(err.Error(), "IHAVE not accepted") { + return false, err + } + } + + return false, fmt.Errorf("failed after %d attempts: %v", maxRetries, lastErr) +} + +func attemptSend(server string, port int, message, messageID string) (bool, error) { torAddr := fmt.Sprintf("%s:%d", torProxyHost, torProxyPort) dialer, err := proxy.SOCKS5("tcp", torAddr, nil, proxy.Direct) if err != nil { - return false, fmt.Errorf("Errore nel dialer SOCKS5: %v", err) + return false, fmt.Errorf("SOCKS5 dialer error: %v", err) } + + // Add timeout conn, err := dialer.Dial("tcp", fmt.Sprintf("%s:%d", server, port)) if err != nil { - return false, fmt.Errorf("Connessione NNTP fallita: %v", err) + return false, fmt.Errorf("NNTP connection failed: %v", err) } defer conn.Close() - + + // Set deadline + deadline := time.Now().Add(time.Duration(nntpTimeout) * time.Second) + conn.SetDeadline(deadline) + reader := bufio.NewReader(conn) + + // Read welcome welcome, err := reader.ReadString('\n') if err != nil { - return false, err + return false, fmt.Errorf("welcome read error: %v", err) } - log.Printf("Connesso a NNTP: %s", strings.TrimSpace(welcome)) - + secureLog("Connected to NNTP") + + if !strings.HasPrefix(welcome, "200") && !strings.HasPrefix(welcome, "201") { + return false, errors.New("unexpected welcome response") + } + + // Send IHAVE ihaveCmd := fmt.Sprintf("IHAVE %s\r\n", messageID) if _, err := conn.Write([]byte(ihaveCmd)); err != nil { return false, err } + ihaveResp, err := reader.ReadString('\n') if err != nil { return false, err } - log.Printf("Risposta IHAVE: %s", strings.TrimSpace(ihaveResp)) + if !strings.HasPrefix(ihaveResp, "335") { - return false, errors.New("IHAVE non accettato") + return false, errors.New("IHAVE not accepted") } + + // Send message fullMessage := message + "\r\n.\r\n" if _, err := conn.Write([]byte(fullMessage)); err != nil { return false, err } + postResp, err := reader.ReadString('\n') if err != nil { return false, err } - log.Printf("Risposta posting: %s", strings.TrimSpace(postResp)) + + secureLog("Post response received") + conn.Write([]byte("QUIT\r\n")) + return strings.HasPrefix(postResp, "235"), nil } +// safeParseFrom safely extracts email address from From header +func safeParseFrom(fromHeader string) (string, error) { + if fromHeader == "" { + return "", errors.New("empty From header") + } + + // Try parsing as RFC 5322 address + addr, err := mail.ParseAddress(fromHeader) + if err == nil { + return addr.Address, nil + } + + // Fallback to manual parsing + if strings.Contains(fromHeader, "<") && strings.Contains(fromHeader, ">") { + start := strings.Index(fromHeader, "<") + end := strings.Index(fromHeader, ">") + if end > start { + return strings.TrimSpace(fromHeader[start+1 : end]), nil + } + } + + return strings.TrimSpace(fromHeader), nil +} + +// generateSecureMessageID creates unpredictable Message-ID +func generateSecureMessageID() (string, error) { + timestamp := time.Now().Unix() + + // 16 bytes of crypto random data + randomBytes := make([]byte, 16) + if _, err := rand.Read(randomBytes); err != nil { + return "", err + } + randomHex := hex.EncodeToString(randomBytes) + + return fmt.Sprintf("<%d.%s@mail2usenet.local>", timestamp, randomHex), nil +} + func main() { - // Lettura dell'intera email da STDIN + // Cleanup expired tokens on startup + if err := cleanupExpiredTokens(); err != nil { + secureLog("Token cleanup warning: %v", err) + } + + // Read email from STDIN input, err := io.ReadAll(os.Stdin) if err != nil { - log.Fatalf("Errore lettura STDIN: %v", err) + log.Fatalf("Failed to read input") } + msg, err := mail.ReadMessage(bytes.NewReader(input)) if err != nil { - log.Fatalf("Errore nel parsing dell'email: %v", err) + log.Fatalf("Failed to parse email") } + header := msg.Header - + fromHeader := header.Get("From") - if fromHeader == "" { - log.Fatalf("Header From mancante") - } - fromAddr := fromHeader - if strings.Contains(fromHeader, "<") && strings.Contains(fromHeader, ">") { - fromAddr = strings.TrimSpace(strings.Split(strings.Split(fromHeader, "<")[1], ">")[0]) + fromAddr, err := safeParseFrom(fromHeader) + if err != nil { + log.Fatalf("Invalid From header") } + newsgroups := header.Get("Newsgroups") if newsgroups == "" { - log.Fatalf("Header Newsgroups mancante") + log.Fatalf("Missing Newsgroups header") } + subject := header.Get("Subject") if subject == "" { subject = "(No subject)" } + xhashcash := header.Get("X-Hashcash") if xhashcash == "" { - log.Fatalf("Header X-Hashcash mancante") + log.Fatalf("Missing X-Hashcash header") } - - // Log degli header Ed25519 per debugging - log.Printf("X-Ed25519-Pub header: %s", header.Get("X-Ed25519-Pub")) - log.Printf("X-Ed25519-Sig header: %s", header.Get("X-Ed25519-Sig")) - + + // Read body bodyBuf := new(bytes.Buffer) - _, err = io.Copy(bodyBuf, msg.Body) - if err != nil { - log.Fatalf("Errore lettura corpo email: %v", err) + if _, err = io.Copy(bodyBuf, msg.Body); err != nil { + log.Fatalf("Failed to read body") } body := strings.TrimSpace(bodyBuf.String()) if body == "" { - log.Fatalf("Corpo del messaggio vuoto") + log.Fatalf("Empty message body") } - - // Prevenzione riutilizzo token + + // ATOMIC token check and mark + tokenMutex.Lock() if tokenAlreadySpent(xhashcash) { - log.Fatalf("Token Hashcash già utilizzato") + tokenMutex.Unlock() + log.Fatalf("Token already used") } - // Verifica del token if !verifyHashcashToken(xhashcash, fromAddr) { - log.Fatalf("Token Hashcash non valido") + tokenMutex.Unlock() + log.Fatalf("Invalid hashcash token") } if err := markTokenSpent(xhashcash); err != nil { - log.Printf("Errore nella registrazione del token: %v", err) + tokenMutex.Unlock() + log.Fatalf("Failed to register token") } - - // Gestione dei newsgroup: limita a 3 gruppi + tokenMutex.Unlock() + + // Limit newsgroups groups := strings.Split(newsgroups, ",") + for i := range groups { + groups[i] = strings.TrimSpace(groups[i]) + } if len(groups) > 3 { groups = groups[:3] - log.Printf("Limite newsgroups raggiunto, utilizzati: %v", groups) + secureLog("Limited to 3 newsgroups") } limitedGroups := strings.Join(groups, ", ") - - // Ritardo per cross-posting, se necessario + + // Randomized delay for cross-posting if len(groups) > 1 { - time.Sleep(time.Duration(delayCrossPost) * time.Second) + randomDelay(delayCrossPost) } - - // Generazione di un Message-ID univoco e formattazione data - messageID := fmt.Sprintf("<%d.%d@mail2usenet.local>", time.Now().Unix(), randInt(1000, 9999)) + + // Generate secure Message-ID + messageID, err := generateSecureMessageID() + if err != nil { + log.Fatalf("Failed to generate Message-ID") + } + dateHeader := time.Now().UTC().Format(time.RFC1123Z) - - // Composizione del messaggio Usenet in formato RFC-compliant + + // Build message headers := []string{ fmt.Sprintf("Message-ID: %s", messageID), fmt.Sprintf("Date: %s", dateHeader), @@ -307,39 +568,42 @@ func main() { "Mime-Version: 1.0", "Content-Type: text/plain; charset=UTF-8", "Content-Transfer-Encoding: 7bit", - "User-Agent: m2usenet-go v0.1.0", + "User-Agent: m2usenet-go v1.0.0", } - - // Aggiunta degli header Ed25519 se presenti + + // Add Ed25519 headers if present if ed25519pub := header.Get("X-Ed25519-Pub"); ed25519pub != "" { headers = append(headers, fmt.Sprintf("X-Ed25519-Pub: %s", ed25519pub)) } if ed25519sig := header.Get("X-Ed25519-Sig"); ed25519sig != "" { headers = append(headers, fmt.Sprintf("X-Ed25519-Sig: %s", ed25519sig)) } - if ref := header.Get("References"); ref != "" { headers = append(headers, fmt.Sprintf("References: %s", ref)) } + usenetPost := strings.Join(headers, "\r\n") + "\r\n\r\n" + body - if len([]byte(usenetPost)) > maxPostSize { - log.Fatalf("Messaggio supera il limite di %d byte", maxPostSize) - } - + + // Add adaptive padding to prevent size correlation + usenetPost = addAdaptivePadding(usenetPost) + + postSize := len([]byte(usenetPost)) + if postSize > maxPostSize { + log.Fatalf("Message exceeds size limit") + } + if postSize < minPostSize && paddingEnabled { + log.Fatalf("Message too small even with padding") + } + success, err := sendViaTor(NNTPServer, nntpPort, usenetPost, messageID) if err != nil { - log.Fatalf("Errore nell'invio del messaggio: %v", err) + log.Fatalf("Send failed: %v", err) } + if success { - log.Println("Messaggio inviato correttamente") + secureLog("Message sent successfully") os.Exit(0) } else { - log.Fatalln("Invio del messaggio fallito") + log.Fatalln("Message delivery failed") } } - -// randInt genera un intero casuale nel range [min, max]. -func randInt(min, max int) int { - rand.Seed(time.Now().UnixNano()) - return rand.Intn(max-min+1) + min -} |
