summaryrefslogtreecommitdiffstats
path: root/pkg
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2025-11-23 10:07:55 +0000
committerClaude <noreply@anthropic.com>2025-11-23 10:07:55 +0000
commit69a75acea56bbff39fa1c81d4813ebcb4a617f06 (patch)
treeee1239320191bae0df39bdbe0d058069c27a0107 /pkg
parent9b4caf3e5ffad219ea55ea6db7ad19940c5f83d9 (diff)
downloadnoshitalk-69a75acea56bbff39fa1c81d4813ebcb4a617f06.tar.gz
noshitalk-69a75acea56bbff39fa1c81d4813ebcb4a617f06.tar.xz
noshitalk-69a75acea56bbff39fa1c81d4813ebcb4a617f06.zip
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
Diffstat (limited to 'pkg')
-rw-r--r--pkg/crypto/crypto.go319
-rw-r--r--pkg/identity/identity.go254
-rw-r--r--pkg/protocol/messages.go206
-rw-r--r--pkg/tor/tor.go221
4 files changed, 1000 insertions, 0 deletions
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
+}