From 69a75acea56bbff39fa1c81d4813ebcb4a617f06 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Nov 2025 10:07:55 +0000 Subject: Refactor: modular architecture with shared packages Major code reorganization to eliminate duplication and improve maintainability: - Created pkg/crypto: shared cryptographic functions (padding, AES-GCM, ECDH, HMAC auth) - Created pkg/protocol: unified message structures and JSON serialization - Created pkg/identity: persistent identity management (load/save/generate) - Created pkg/tor: Tor SOCKS5 proxy connection utilities - Refactored server to use shared packages (cmd/server) - Refactored CLI client with shared packages (cmd/cli-client) - Refactored GUI client with shared packages (cmd/gui-client) - Refactored web client with embedded HTML template (cmd/web-client) Security improvements: - Replaced math/rand with crypto/rand for randomDelay() - Added proper error handling for crypto operations Code reduction: eliminated ~500+ lines of duplicated code --- pkg/crypto/crypto.go | 319 +++++++++++++++++++++++++++++++++++++++++++++++ pkg/identity/identity.go | 254 +++++++++++++++++++++++++++++++++++++ pkg/protocol/messages.go | 206 ++++++++++++++++++++++++++++++ pkg/tor/tor.go | 221 ++++++++++++++++++++++++++++++++ 4 files changed, 1000 insertions(+) create mode 100644 pkg/crypto/crypto.go create mode 100644 pkg/identity/identity.go create mode 100644 pkg/protocol/messages.go create mode 100644 pkg/tor/tor.go (limited to 'pkg') diff --git a/pkg/crypto/crypto.go b/pkg/crypto/crypto.go new file mode 100644 index 0000000..92cc459 --- /dev/null +++ b/pkg/crypto/crypto.go @@ -0,0 +1,319 @@ +// Package crypto provides shared cryptographic functions for NoshiTalk. +// Includes message padding, AES-GCM encryption/decryption, and traffic obfuscation. +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/hmac" + cryptorand "crypto/rand" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "net" + "time" + + "github.com/awnumar/memguard" +) + +const ( + // MessageBlockSize is the padding block size for traffic analysis resistance + MessageBlockSize = 256 + + // MinRandomDelay is the minimum delay in milliseconds for timing attack mitigation + MinRandomDelay = 50 + + // MaxRandomDelay is the maximum delay in milliseconds for timing attack mitigation + MaxRandomDelay = 200 + + // NonceSize is the size of the GCM nonce + NonceSize = 12 + + // KeySize is the size of the AES-256 key + KeySize = 32 + + // ChallengeSize is the size of HMAC challenge/response + ChallengeSize = 32 + + // HandshakeTimeout is the timeout for key exchange and authentication + HandshakeTimeout = 30 * time.Second +) + +// PadMessage pads a plaintext message to fixed block size to prevent traffic analysis. +// Format: [2 bytes length][plaintext][random padding] +func PadMessage(plaintext []byte) ([]byte, error) { + currentLen := len(plaintext) + paddedLen := ((currentLen / MessageBlockSize) + 1) * MessageBlockSize + padLen := paddedLen - currentLen + + result := make([]byte, 2+paddedLen) + binary.BigEndian.PutUint16(result[0:2], uint16(currentLen)) + copy(result[2:], plaintext) + + if padLen > 0 { + padding := make([]byte, padLen) + if _, err := cryptorand.Read(padding); err != nil { + return nil, fmt.Errorf("failed to generate random padding: %w", err) + } + copy(result[2+currentLen:], padding) + } + + return result, nil +} + +// UnpadMessage removes padding from a padded message. +func UnpadMessage(paddedData []byte) ([]byte, error) { + if len(paddedData) < 2 { + return nil, fmt.Errorf("padded data too short") + } + + originalLen := binary.BigEndian.Uint16(paddedData[0:2]) + if int(originalLen) > len(paddedData)-2 { + return nil, fmt.Errorf("invalid padding length") + } + + return paddedData[2 : 2+originalLen], nil +} + +// RandomDelay introduces a random delay for timing attack mitigation. +// Uses crypto/rand for secure randomness. +func RandomDelay() error { + var b [1]byte + if _, err := cryptorand.Read(b[:]); err != nil { + return err + } + // Map byte value to delay range + delay := MinRandomDelay + int(b[0])%(MaxRandomDelay-MinRandomDelay) + time.Sleep(time.Duration(delay) * time.Millisecond) + return nil +} + +// DeriveIdentity derives a user identity string from a public key. +// Format: "anon_" + first 8 bytes of SHA256(publicKey) in hex +func DeriveIdentity(publicKey []byte) string { + hash := sha256.Sum256(publicKey) + return fmt.Sprintf("anon_%s", hex.EncodeToString(hash[:8])) +} + +// DeriveFingerprint derives a fingerprint from a public key. +// Returns first 16 bytes of SHA256 hash in hex. +func DeriveFingerprint(publicKey []byte) string { + hash := sha256.Sum256(publicKey) + return hex.EncodeToString(hash[:16]) +} + +// SetupAESGCM creates an AES-GCM cipher from a shared secret. +func SetupAESGCM(sharedSecret []byte) (cipher.AEAD, error) { + if len(sharedSecret) < KeySize { + return nil, fmt.Errorf("shared secret too short: need %d bytes, got %d", KeySize, len(sharedSecret)) + } + + block, err := aes.NewCipher(sharedSecret[:KeySize]) + if err != nil { + return nil, fmt.Errorf("AES cipher creation failed: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("GCM creation failed: %w", err) + } + + return gcm, nil +} + +// Encrypt encrypts a message using AES-GCM with random nonce. +// Returns: [12-byte nonce][ciphertext] +func Encrypt(gcm cipher.AEAD, plaintext []byte) ([]byte, error) { + nonce := make([]byte, NonceSize) + if _, err := io.ReadFull(cryptorand.Reader, nonce); err != nil { + return nil, fmt.Errorf("nonce generation failed: %w", err) + } + + ciphertext := gcm.Seal(nil, nonce, plaintext, nil) + return append(nonce, ciphertext...), nil +} + +// Decrypt decrypts a message encrypted with Encrypt. +// Expects: [12-byte nonce][ciphertext] +func Decrypt(gcm cipher.AEAD, data []byte) ([]byte, error) { + if len(data) < NonceSize { + return nil, fmt.Errorf("data too short") + } + + nonce := data[:NonceSize] + ciphertext := data[NonceSize:] + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("decryption failed: %w", err) + } + + return plaintext, nil +} + +// EncryptMessage encrypts a string message with padding and traffic obfuscation. +func EncryptMessage(gcm cipher.AEAD, message string) ([]byte, error) { + // Apply random delay for timing attack mitigation + if err := RandomDelay(); err != nil { + return nil, err + } + + // Pad the message + padded, err := PadMessage([]byte(message)) + if err != nil { + return nil, err + } + + // Encrypt + return Encrypt(gcm, padded) +} + +// DecryptMessage decrypts and unpads an encrypted message. +func DecryptMessage(gcm cipher.AEAD, data []byte) (string, error) { + // Decrypt + padded, err := Decrypt(gcm, data) + if err != nil { + return "", err + } + + // Unpad + plaintext, err := UnpadMessage(padded) + if err != nil { + return "", fmt.Errorf("unpad failed: %w", err) + } + + return string(plaintext), nil +} + +// GenerateX25519KeyPair generates a new X25519 key pair for ECDH. +func GenerateX25519KeyPair() (*ecdh.PrivateKey, error) { + curve := ecdh.X25519() + return curve.GenerateKey(cryptorand.Reader) +} + +// PerformECDH performs ECDH key exchange with a peer's public key. +func PerformECDH(privateKey *ecdh.PrivateKey, peerPublicKeyBytes []byte) ([]byte, error) { + curve := ecdh.X25519() + peerPublicKey, err := curve.NewPublicKey(peerPublicKeyBytes) + if err != nil { + return nil, fmt.Errorf("invalid peer public key: %w", err) + } + + sharedSecret, err := privateKey.ECDH(peerPublicKey) + if err != nil { + return nil, fmt.Errorf("ECDH failed: %w", err) + } + + return sharedSecret, nil +} + +// SecureBuffer wraps sensitive data in memguard for protection. +func SecureBuffer(data []byte) *memguard.Enclave { + buf := memguard.NewBufferFromBytes(data) + enclave := buf.Seal() + return enclave +} + +// PerformClientAuth performs client-side mutual authentication. +// Protocol: +// 1. Receive server challenge (32 bytes) +// 2. Compute HMAC response +// 3. Generate client challenge +// 4. Send response + challenge +// 5. Verify server response +func PerformClientAuth(conn net.Conn, sharedSecret []byte) error { + conn.SetDeadline(time.Now().Add(HandshakeTimeout)) + defer conn.SetDeadline(time.Time{}) + + // Receive server challenge + serverChallenge := make([]byte, ChallengeSize) + if _, err := io.ReadFull(conn, serverChallenge); err != nil { + return fmt.Errorf("receive server challenge failed: %w", err) + } + + // Compute HMAC response + h := hmac.New(sha256.New, sharedSecret) + h.Write(serverChallenge) + clientResponse := h.Sum(nil) + + // Generate client challenge + clientChallenge := make([]byte, ChallengeSize) + if _, err := cryptorand.Read(clientChallenge); err != nil { + return fmt.Errorf("challenge generation failed: %w", err) + } + + // Send response + challenge + if _, err := conn.Write(clientResponse); err != nil { + return fmt.Errorf("send client response failed: %w", err) + } + if _, err := conn.Write(clientChallenge); err != nil { + return fmt.Errorf("send client challenge failed: %w", err) + } + + // Receive and verify server response + serverResponse := make([]byte, ChallengeSize) + if _, err := io.ReadFull(conn, serverResponse); err != nil { + return fmt.Errorf("receive server response failed: %w", err) + } + + h.Reset() + h.Write(clientChallenge) + expectedServerMAC := h.Sum(nil) + + if !hmac.Equal(serverResponse, expectedServerMAC) { + return fmt.Errorf("server authentication failed - HMAC mismatch") + } + + return nil +} + +// PerformServerAuth performs server-side mutual authentication. +func PerformServerAuth(conn net.Conn, sharedSecret []byte) error { + conn.SetDeadline(time.Now().Add(HandshakeTimeout)) + defer conn.SetDeadline(time.Time{}) + + // Generate and send server challenge + serverChallenge := make([]byte, ChallengeSize) + if _, err := cryptorand.Read(serverChallenge); err != nil { + return fmt.Errorf("challenge generation failed: %w", err) + } + + if _, err := conn.Write(serverChallenge); err != nil { + return fmt.Errorf("challenge send failed: %w", err) + } + + // Receive client response + clientResponse := make([]byte, ChallengeSize) + if _, err := io.ReadFull(conn, clientResponse); err != nil { + return fmt.Errorf("client response read failed: %w", err) + } + + // Receive client challenge + clientChallenge := make([]byte, ChallengeSize) + if _, err := io.ReadFull(conn, clientChallenge); err != nil { + return fmt.Errorf("client challenge read failed: %w", err) + } + + // Verify client response + h := hmac.New(sha256.New, sharedSecret) + h.Write(serverChallenge) + expectedClientMAC := h.Sum(nil) + + if !hmac.Equal(clientResponse, expectedClientMAC) { + return fmt.Errorf("client authentication failed") + } + + // Send server response + h.Reset() + h.Write(clientChallenge) + serverResponse := h.Sum(nil) + + if _, err := conn.Write(serverResponse); err != nil { + return fmt.Errorf("server response send failed: %w", err) + } + + return nil +} diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go new file mode 100644 index 0000000..be48a44 --- /dev/null +++ b/pkg/identity/identity.go @@ -0,0 +1,254 @@ +// Package identity provides persistent cryptographic identity management for NoshiTalk. +package identity + +import ( + "crypto/ecdh" + cryptorand "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "noshitalk/pkg/crypto" +) + +const ( + // DefaultKeyDir is the default directory for storing keys + DefaultKeyDir = ".noshitalk" + + // IdentityVersion is the current identity file format version + IdentityVersion = "1.0" +) + +// Identity represents a user's cryptographic identity. +type Identity struct { + PrivateKey *ecdh.PrivateKey + PublicKey *ecdh.PublicKey + Username string + Fingerprint string +} + +// IdentityFile represents the on-disk format for storing identity. +type IdentityFile struct { + Version string `json:"version"` + Username string `json:"username"` + Fingerprint string `json:"fingerprint"` + SignKeyPair KeyPair `json:"signKeyPair,omitempty"` + EncryptKeyPair KeyPair `json:"encryptKeyPair,omitempty"` + PrivateKey string `json:"privateKey,omitempty"` // For simple format +} + +// KeyPair represents a public/private key pair in hex encoding. +type KeyPair struct { + PrivateKey string `json:"privateKey"` + PublicKey string `json:"publicKey"` +} + +// GetDefaultKeyPath returns the default path for storing identity keys. +func GetDefaultKeyPath(filename string) (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(homeDir, DefaultKeyDir, filename), nil +} + +// New generates a new random identity. +func New(usernamePrefix string) (*Identity, error) { + privateKey, err := crypto.GenerateX25519KeyPair() + if err != nil { + return nil, fmt.Errorf("failed to generate key pair: %w", err) + } + + publicKey := privateKey.PublicKey() + fingerprint := crypto.DeriveFingerprint(publicKey.Bytes()) + username := fmt.Sprintf("%s_%s", usernamePrefix, fingerprint[:8]) + + return &Identity{ + PrivateKey: privateKey, + PublicKey: publicKey, + Username: username, + Fingerprint: fingerprint, + }, nil +} + +// LoadOrCreate loads an existing identity from file or creates a new one. +func LoadOrCreate(keyPath, usernamePrefix string) (*Identity, error) { + // Try to load existing + if _, err := os.Stat(keyPath); err == nil { + return Load(keyPath) + } + + // Generate new + identity, err := New(usernamePrefix) + if err != nil { + return nil, err + } + + // Save it + if err := identity.Save(keyPath); err != nil { + return nil, fmt.Errorf("failed to save new identity: %w", err) + } + + return identity, nil +} + +// Load loads an identity from a file. +// Supports both simple format (just private key bytes) and JSON format. +func Load(keyPath string) (*Identity, error) { + data, err := os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("failed to read identity file: %w", err) + } + + curve := ecdh.X25519() + + // Try JSON format first + var idFile IdentityFile + if err := json.Unmarshal(data, &idFile); err == nil { + // JSON format - check for different key storage formats + var privateKeyBytes []byte + + if idFile.EncryptKeyPair.PrivateKey != "" { + // GUI client format + privateKeyBytes, err = hex.DecodeString(idFile.EncryptKeyPair.PrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to decode encrypt private key: %w", err) + } + } else if idFile.SignKeyPair.PrivateKey != "" { + // Alternative format + privateKeyBytes, err = hex.DecodeString(idFile.SignKeyPair.PrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to decode sign private key: %w", err) + } + } else if idFile.PrivateKey != "" { + // Simple JSON format + privateKeyBytes, err = hex.DecodeString(idFile.PrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to decode private key: %w", err) + } + } else { + return nil, fmt.Errorf("no private key found in identity file") + } + + privateKey, err := curve.NewPrivateKey(privateKeyBytes) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + + publicKey := privateKey.PublicKey() + fingerprint := crypto.DeriveFingerprint(publicKey.Bytes()) + + username := idFile.Username + if username == "" { + username = crypto.DeriveIdentity(publicKey.Bytes()) + } + + return &Identity{ + PrivateKey: privateKey, + PublicKey: publicKey, + Username: username, + Fingerprint: fingerprint, + }, nil + } + + // Try raw binary format (CLI client format) + if len(data) == 32 { + privateKey, err := curve.NewPrivateKey(data) + if err != nil { + return nil, fmt.Errorf("failed to parse raw private key: %w", err) + } + + publicKey := privateKey.PublicKey() + fingerprint := crypto.DeriveFingerprint(publicKey.Bytes()) + username := crypto.DeriveIdentity(publicKey.Bytes()) + + return &Identity{ + PrivateKey: privateKey, + PublicKey: publicKey, + Username: username, + Fingerprint: fingerprint, + }, nil + } + + return nil, fmt.Errorf("unrecognized identity file format") +} + +// Save saves the identity to a file in JSON format. +func (id *Identity) Save(keyPath string) error { + // Create directory if needed + dir := filepath.Dir(keyPath) + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + idFile := IdentityFile{ + Version: IdentityVersion, + Username: id.Username, + Fingerprint: id.Fingerprint, + EncryptKeyPair: KeyPair{ + PrivateKey: hex.EncodeToString(id.PrivateKey.Bytes()), + PublicKey: hex.EncodeToString(id.PublicKey.Bytes()), + }, + } + + data, err := json.MarshalIndent(idFile, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal identity: %w", err) + } + + if err := os.WriteFile(keyPath, data, 0600); err != nil { + return fmt.Errorf("failed to write identity file: %w", err) + } + + return nil +} + +// SaveRaw saves only the private key bytes (for CLI client compatibility). +func (id *Identity) SaveRaw(keyPath string) error { + dir := filepath.Dir(keyPath) + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + if err := os.WriteFile(keyPath, id.PrivateKey.Bytes(), 0600); err != nil { + return fmt.Errorf("failed to write key file: %w", err) + } + + return nil +} + +// DeriveSharedIdentity derives a deterministic identity from a seed. +// This is useful for testing or when you want reproducible identities. +func DeriveSharedIdentity(seed string, usernamePrefix string) (*Identity, error) { + // Hash the seed to get 32 bytes + hash := sha256.Sum256([]byte(seed)) + + curve := ecdh.X25519() + privateKey, err := curve.NewPrivateKey(hash[:]) + if err != nil { + return nil, fmt.Errorf("failed to derive private key: %w", err) + } + + publicKey := privateKey.PublicKey() + fingerprint := crypto.DeriveFingerprint(publicKey.Bytes()) + username := fmt.Sprintf("%s_%s", usernamePrefix, fingerprint[:8]) + + return &Identity{ + PrivateKey: privateKey, + PublicKey: publicKey, + Username: username, + Fingerprint: fingerprint, + }, nil +} + +// GenerateChallenge generates a random challenge for authentication. +func GenerateChallenge() ([]byte, error) { + challenge := make([]byte, 32) + if _, err := cryptorand.Read(challenge); err != nil { + return nil, fmt.Errorf("failed to generate challenge: %w", err) + } + return challenge, nil +} diff --git a/pkg/protocol/messages.go b/pkg/protocol/messages.go new file mode 100644 index 0000000..c8f6398 --- /dev/null +++ b/pkg/protocol/messages.go @@ -0,0 +1,206 @@ +// Package protocol defines the message structures and types for NoshiTalk communication. +package protocol + +import ( + "encoding/json" + "time" +) + +// Message types +const ( + TypeMessage = "message" + TypePrivate = "private" + TypeSystem = "system" + TypeError = "error" + TypeUserList = "user_list" + TypeUserJoin = "user_join" + TypeUserLeave = "user_leave" + TypeHandshake = "handshake" + TypePublic = "public" + TypeKeyRotation = "key_rotation" + TypeFileOffer = "file_offer" + TypeFileChunk = "file_chunk" + TypeFileAccept = "file_accept" + TypeFileReject = "file_reject" + TypeFileComplete = "file_complete" +) + +// Message represents a chat message. +type Message struct { + From string `json:"from"` + To string `json:"to,omitempty"` + Content string `json:"content"` + Time string `json:"time"` + Type string `json:"type"` +} + +// UserListMessage represents a list of online users. +type UserListMessage struct { + Type string `json:"type"` + Users []string `json:"users"` + Time string `json:"time"` +} + +// FileOfferMessage represents a file transfer offer. +type FileOfferMessage struct { + Type string `json:"type"` + From string `json:"from"` + To string `json:"to"` + FileID string `json:"file_id"` + Filename string `json:"filename"` + Size int64 `json:"size"` + TotalChunks int `json:"total_chunks"` + SHA256 string `json:"sha256"` + Compressed bool `json:"compressed"` + Time string `json:"time"` +} + +// FileChunkMessage represents a chunk of file data. +type FileChunkMessage struct { + Type string `json:"type"` + FileID string `json:"file_id"` + ChunkIndex int `json:"chunk_index"` + TotalChunks int `json:"total_chunks"` + Data string `json:"data"` + Time string `json:"time"` +} + +// FileResponseMessage represents a response to a file transfer. +type FileResponseMessage struct { + Type string `json:"type"` + FileID string `json:"file_id"` + From string `json:"from"` + Status string `json:"status"` + Message string `json:"message"` + SHA256 string `json:"sha256,omitempty"` + Time string `json:"time"` +} + +// KeyRotationMessage represents a key rotation request. +type KeyRotationMessage struct { + Type string `json:"type"` + PublicKey []byte `json:"public_key"` + Nonce []byte `json:"nonce"` + Signature []byte `json:"signature"` + Time string `json:"time"` +} + +// NewMessage creates a new message with the current timestamp. +func NewMessage(from, to, content, msgType string) *Message { + return &Message{ + From: from, + To: to, + Content: content, + Time: time.Now().Format("15:04:05"), + Type: msgType, + } +} + +// NewSystemMessage creates a new system message. +func NewSystemMessage(content string) *Message { + return NewMessage("System", "", content, TypeSystem) +} + +// NewErrorMessage creates a new error message. +func NewErrorMessage(content string) *Message { + return NewMessage("System", "", content, TypeError) +} + +// NewUserListMessage creates a new user list message. +func NewUserListMessage(users []string) *UserListMessage { + return &UserListMessage{ + Type: TypeUserList, + Users: users, + Time: time.Now().Format("15:04:05"), + } +} + +// ToJSON marshals the message to JSON. +func (m *Message) ToJSON() ([]byte, error) { + return json.Marshal(m) +} + +// ToJSON marshals the user list message to JSON. +func (m *UserListMessage) ToJSON() ([]byte, error) { + return json.Marshal(m) +} + +// ToJSON marshals the file offer message to JSON. +func (m *FileOfferMessage) ToJSON() ([]byte, error) { + return json.Marshal(m) +} + +// ToJSON marshals the file chunk message to JSON. +func (m *FileChunkMessage) ToJSON() ([]byte, error) { + return json.Marshal(m) +} + +// ToJSON marshals the file response message to JSON. +func (m *FileResponseMessage) ToJSON() ([]byte, error) { + return json.Marshal(m) +} + +// ToJSON marshals the key rotation message to JSON. +func (m *KeyRotationMessage) ToJSON() ([]byte, error) { + return json.Marshal(m) +} + +// ParseMessage parses a JSON message and returns its type and raw data. +func ParseMessage(data []byte) (string, map[string]interface{}, error) { + var generic map[string]interface{} + if err := json.Unmarshal(data, &generic); err != nil { + return "", nil, err + } + + msgType, ok := generic["type"].(string) + if !ok { + msgType = TypeMessage + } + + return msgType, generic, nil +} + +// ParseAsMessage parses JSON data as a Message. +func ParseAsMessage(data []byte) (*Message, error) { + var msg Message + if err := json.Unmarshal(data, &msg); err != nil { + return nil, err + } + return &msg, nil +} + +// ParseAsUserList parses JSON data as a UserListMessage. +func ParseAsUserList(data []byte) (*UserListMessage, error) { + var msg UserListMessage + if err := json.Unmarshal(data, &msg); err != nil { + return nil, err + } + return &msg, nil +} + +// ParseAsFileOffer parses JSON data as a FileOfferMessage. +func ParseAsFileOffer(data []byte) (*FileOfferMessage, error) { + var msg FileOfferMessage + if err := json.Unmarshal(data, &msg); err != nil { + return nil, err + } + return &msg, nil +} + +// ParseAsFileChunk parses JSON data as a FileChunkMessage. +func ParseAsFileChunk(data []byte) (*FileChunkMessage, error) { + var msg FileChunkMessage + if err := json.Unmarshal(data, &msg); err != nil { + return nil, err + } + return &msg, nil +} + +// ParseAsFileResponse parses JSON data as a FileResponseMessage. +func ParseAsFileResponse(data []byte) (*FileResponseMessage, error) { + var msg FileResponseMessage + if err := json.Unmarshal(data, &msg); err != nil { + return nil, err + } + return &msg, nil +} diff --git a/pkg/tor/tor.go b/pkg/tor/tor.go new file mode 100644 index 0000000..c1f67fc --- /dev/null +++ b/pkg/tor/tor.go @@ -0,0 +1,221 @@ +// Package tor provides Tor SOCKS5 proxy connection utilities for NoshiTalk. +package tor + +import ( + "fmt" + "net" + "net/url" + "regexp" + "strings" + "time" + + "golang.org/x/net/proxy" +) + +const ( + // DefaultConnectionTimeout is the timeout for establishing connections + DefaultConnectionTimeout = 90 * time.Second + + // DefaultKeepAlive is the keep-alive interval for connections + DefaultKeepAlive = 30 * time.Second + + // ProxyTestTimeout is the timeout for testing proxy availability + ProxyTestTimeout = 2 * time.Second +) + +var ( + // DefaultProxyAddresses are the default Tor SOCKS5 proxy addresses to try + DefaultProxyAddresses = []string{ + "socks5://127.0.0.1:9050", // Standard Tor daemon + "socks5://localhost:9050", // Alternative + "socks5://127.0.0.1:9150", // Tor Browser + } + + // OnionRegex validates v3 .onion addresses + OnionRegex = regexp.MustCompile(`^[a-z2-7]{56}\.onion(:[0-9]{1,5})?$`) +) + +// ValidateOnionAddress validates that an address is a proper v3 .onion address. +func ValidateOnionAddress(addr string) error { + if addr == "" { + return fmt.Errorf("address cannot be empty") + } + + if !strings.Contains(addr, ".onion") { + return fmt.Errorf("only Tor .onion addresses allowed - no clearnet connections") + } + + if !OnionRegex.MatchString(addr) { + return fmt.Errorf("invalid .onion address format (must be v3: 56 chars + .onion:port)") + } + + return nil +} + +// TestProxyAvailable tests if a Tor proxy is available at the given address. +func TestProxyAvailable(proxyAddr string) error { + // Extract host:port from proxy URL + u, err := url.Parse(proxyAddr) + if err != nil { + return fmt.Errorf("invalid proxy URL: %w", err) + } + + host := u.Host + if host == "" { + host = "127.0.0.1:9050" + } + + conn, err := net.DialTimeout("tcp", host, ProxyTestTimeout) + if err != nil { + return fmt.Errorf("proxy not responding: %w", err) + } + conn.Close() + + return nil +} + +// Connect establishes a connection to a .onion address through Tor. +// It tries multiple proxy addresses and returns the first successful connection. +func Connect(serverAddr string, proxyAddresses []string) (net.Conn, error) { + if err := ValidateOnionAddress(serverAddr); err != nil { + return nil, err + } + + if len(proxyAddresses) == 0 { + proxyAddresses = DefaultProxyAddresses + } + + var conn net.Conn + var lastErr error + + for _, proxyURL := range proxyAddresses { + torProxyUrl, err := url.Parse(proxyURL) + if err != nil { + lastErr = err + continue + } + + baseDialer := &net.Dialer{ + Timeout: DefaultConnectionTimeout, + KeepAlive: DefaultKeepAlive, + } + + dialer, err := proxy.FromURL(torProxyUrl, baseDialer) + if err != nil { + lastErr = err + continue + } + + conn, err = dialer.Dial("tcp", serverAddr) + if err != nil { + lastErr = fmt.Errorf("connection via %s failed: %w", proxyURL, err) + continue + } + + // Success + return conn, nil + } + + if lastErr != nil { + return nil, fmt.Errorf("all Tor proxy attempts failed: %w", lastErr) + } + + return nil, fmt.Errorf("no proxy addresses configured") +} + +// ConnectWithCallback establishes a connection with progress callbacks. +func ConnectWithCallback(serverAddr string, proxyAddresses []string, onProgress func(string)) (net.Conn, error) { + if err := ValidateOnionAddress(serverAddr); err != nil { + return nil, err + } + + if len(proxyAddresses) == 0 { + proxyAddresses = DefaultProxyAddresses + } + + var conn net.Conn + var lastErr error + + for _, proxyURL := range proxyAddresses { + if onProgress != nil { + onProgress(fmt.Sprintf("Trying proxy %s...", proxyURL)) + } + + torProxyUrl, err := url.Parse(proxyURL) + if err != nil { + lastErr = err + continue + } + + baseDialer := &net.Dialer{ + Timeout: DefaultConnectionTimeout, + KeepAlive: DefaultKeepAlive, + } + + dialer, err := proxy.FromURL(torProxyUrl, baseDialer) + if err != nil { + lastErr = err + continue + } + + if onProgress != nil { + onProgress(fmt.Sprintf("Connecting to %s...", serverAddr)) + } + + conn, err = dialer.Dial("tcp", serverAddr) + if err != nil { + lastErr = fmt.Errorf("connection via %s failed: %w", proxyURL, err) + if onProgress != nil { + onProgress(fmt.Sprintf("Failed: %v", err)) + } + continue + } + + if onProgress != nil { + onProgress(fmt.Sprintf("Connected via %s", proxyURL)) + } + return conn, nil + } + + if lastErr != nil { + return nil, fmt.Errorf("all Tor proxy attempts failed: %w", lastErr) + } + + return nil, fmt.Errorf("no proxy addresses configured") +} + +// Dialer wraps a Tor SOCKS5 dialer with additional configuration. +type Dialer struct { + ProxyAddresses []string + Timeout time.Duration + KeepAlive time.Duration +} + +// NewDialer creates a new Tor dialer with default settings. +func NewDialer() *Dialer { + return &Dialer{ + ProxyAddresses: DefaultProxyAddresses, + Timeout: DefaultConnectionTimeout, + KeepAlive: DefaultKeepAlive, + } +} + +// Dial connects to a .onion address through Tor. +func (d *Dialer) Dial(serverAddr string) (net.Conn, error) { + return Connect(serverAddr, d.ProxyAddresses) +} + +// DialWithCallback connects with progress callbacks. +func (d *Dialer) DialWithCallback(serverAddr string, onProgress func(string)) (net.Conn, error) { + return ConnectWithCallback(serverAddr, d.ProxyAddresses, onProgress) +} + +// IsAvailable checks if at least one Tor proxy is available. +func (d *Dialer) IsAvailable() bool { + for _, addr := range d.ProxyAddresses { + if err := TestProxyAvailable(addr); err == nil { + return true + } + } + return false +} -- cgit v1.2.3