diff options
| author | Claude <noreply@anthropic.com> | 2025-11-23 10:07:55 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2025-11-23 10:07:55 +0000 |
| commit | 69a75acea56bbff39fa1c81d4813ebcb4a617f06 (patch) | |
| tree | ee1239320191bae0df39bdbe0d058069c27a0107 | |
| parent | 9b4caf3e5ffad219ea55ea6db7ad19940c5f83d9 (diff) | |
| download | noshitalk-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
| -rw-r--r-- | cmd/cli-client/main.go | 529 | ||||
| -rw-r--r-- | cmd/gui-client/main.go | 537 | ||||
| -rw-r--r-- | cmd/server/main.go | 722 | ||||
| -rw-r--r-- | cmd/web-client/main.go | 360 | ||||
| -rw-r--r-- | cmd/web-client/template.html | 329 | ||||
| -rw-r--r-- | go.mod | 10 | ||||
| -rw-r--r-- | pkg/crypto/crypto.go | 319 | ||||
| -rw-r--r-- | pkg/identity/identity.go | 254 | ||||
| -rw-r--r-- | pkg/protocol/messages.go | 206 | ||||
| -rw-r--r-- | pkg/tor/tor.go | 221 |
10 files changed, 3484 insertions, 3 deletions
diff --git a/cmd/cli-client/main.go b/cmd/cli-client/main.go new file mode 100644 index 0000000..7bc9b93 --- /dev/null +++ b/cmd/cli-client/main.go @@ -0,0 +1,529 @@ +package main + +import ( + "bufio" + "crypto/cipher" + "fmt" + "io" + "net" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "github.com/awnumar/memguard" + + "noshitalk/pkg/crypto" + "noshitalk/pkg/identity" + "noshitalk/pkg/protocol" + "noshitalk/pkg/tor" +) + +// CLIClient represents the command-line chat client. +type CLIClient struct { + conn net.Conn + gcm cipher.AEAD + sharedSecret *memguard.Enclave + connected bool + serverAddr string + quit chan struct{} + mu sync.Mutex + autoReconnect bool + lastServer string + onlineUsers []string + identity *identity.Identity +} + +var version = "2.0-refactored" + +func main() { + memguard.CatchInterrupt() + defer memguard.Purge() + + fmt.Printf("NoshiTalk CLI Client v%s\n", version) + fmt.Printf("Lightweight Command Line Interface\n") + fmt.Printf("Tor-Only Mode - No Clearnet Connections\n") + fmt.Printf("ECDH + HMAC + AES-GCM Encryption\n\n") + + client := &CLIClient{ + quit: make(chan struct{}), + autoReconnect: true, + onlineUsers: []string{}, + } + + // Initialize persistent identity + keyPath, err := identity.GetDefaultKeyPath("cli-identity.noshikey") + if err != nil { + fmt.Printf("Failed to get key path: %v\n", err) + } else { + client.identity, err = identity.LoadOrCreate(keyPath, "cli") + if err != nil { + fmt.Printf("Failed to initialize identity: %v\n", err) + fmt.Printf("Continuing with temporary identity...\n\n") + } else { + fmt.Printf("Identity: %s\n", client.identity.Username) + fmt.Printf("Fingerprint: %s\n\n", client.identity.Fingerprint) + } + } + + // Setup signal handling + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigChan + fmt.Printf("\nShutting down securely...\n") + client.disconnect() + memguard.Purge() + os.Exit(0) + }() + + // Start auto-reconnect goroutine + go client.autoReconnectLoop() + + client.run() +} + +func (c *CLIClient) run() { + scanner := bufio.NewScanner(os.Stdin) + + fmt.Printf("Enter Tor Hidden Service address (.onion only)\n") + fmt.Printf("Example: abcd1234...xyz9876.onion:8083\n") + fmt.Printf("Clearnet addresses blocked by design\n\n") + + for { + if !c.connected { + fmt.Print(".onion address: ") + if !scanner.Scan() { + break + } + c.serverAddr = strings.TrimSpace(scanner.Text()) + + if c.serverAddr == "" { + fmt.Printf("Address required\n\n") + continue + } + + if err := tor.ValidateOnionAddress(c.serverAddr); err != nil { + fmt.Printf("Invalid address: %v\n\n", err) + continue + } + + c.lastServer = c.serverAddr + fmt.Printf("Connecting to %s via Tor...\n", c.serverAddr) + + if err := c.connect(); err != nil { + fmt.Printf("Connection failed: %v\n", err) + fmt.Printf("Will retry automatically if auto-reconnect is enabled\n\n") + continue + } + } + + fmt.Print("> ") + if !scanner.Scan() { + break + } + + message := strings.TrimSpace(scanner.Text()) + if message == "" { + continue + } + + if c.handleCommand(message) { + continue + } + + // Send regular message + if !c.connected { + fmt.Printf("Not connected. Use /reconnect or enter server address\n") + continue + } + + if err := c.sendMessage(message); err != nil { + fmt.Printf("Send error: %v\n", err) + c.disconnect() + } + } + + c.disconnect() +} + +func (c *CLIClient) handleCommand(message string) bool { + switch message { + case "/quit", "/exit", "/q": + c.autoReconnect = false + c.disconnect() + fmt.Println("Goodbye!") + os.Exit(0) + return true + case "/disconnect", "/d": + c.disconnect() + return true + case "/help", "/h", "/?": + c.showHelp() + return true + case "/status", "/s": + c.showStatus() + return true + case "/reconnect", "/r": + if !c.connected && c.lastServer != "" { + c.serverAddr = c.lastServer + fmt.Printf("Reconnecting to %s...\n", c.serverAddr) + c.connect() + } + return true + case "/auto on": + c.autoReconnect = true + fmt.Printf("Auto-reconnect enabled\n") + return true + case "/auto off": + c.autoReconnect = false + fmt.Printf("Auto-reconnect disabled\n") + return true + case "/clear", "/cls": + fmt.Print("\033[H\033[2J") + return true + case "/users", "/u": + c.showUsers() + return true + } + + // Handle /pm command + if len(message) > 4 && message[:4] == "/pm " { + if !c.connected { + fmt.Printf("Not connected. Use /reconnect or enter server address\n") + return true + } + if err := c.sendMessage(message); err != nil { + fmt.Printf("Send error: %v\n", err) + c.disconnect() + } + return true + } + + return false +} + +func (c *CLIClient) connect() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.connected { + return nil + } + + fmt.Printf("Routing through Tor network...\n") + + // Test Tor proxy + torDialer := tor.NewDialer() + if !torDialer.IsAvailable() { + return fmt.Errorf("Tor proxy not available on port 9050") + } + fmt.Printf("Tor proxy detected and responsive\n") + + // Connect through Tor + conn, err := torDialer.DialWithCallback(c.serverAddr, func(msg string) { + fmt.Printf("%s\n", msg) + }) + if err != nil { + return err + } + + c.conn = conn + return c.setupEncryption() +} + +func (c *CLIClient) setupEncryption() error { + fmt.Printf("Establishing end-to-end encryption...\n") + + // Use persistent identity or generate temporary + var privateKey = c.identity.PrivateKey + if c.identity != nil && c.identity.PrivateKey != nil { + fmt.Printf("Using persistent identity: %s\n", c.identity.Username) + } else { + var err error + privateKey, err = crypto.GenerateX25519KeyPair() + if err != nil { + c.conn.Close() + return fmt.Errorf("key generation failed: %w", err) + } + fmt.Printf("Using temporary identity\n") + } + + // Send public key + publicKeyBytes := privateKey.PublicKey().Bytes() + c.conn.SetWriteDeadline(time.Now().Add(crypto.HandshakeTimeout)) + if _, err := c.conn.Write(publicKeyBytes); err != nil { + c.conn.Close() + return fmt.Errorf("failed to send public key: %w", err) + } + c.conn.SetWriteDeadline(time.Time{}) + + // Receive server public key + serverPubKeyBytes := make([]byte, 32) + if _, err := io.ReadFull(c.conn, serverPubKeyBytes); err != nil { + c.conn.Close() + return fmt.Errorf("failed to receive server public key: %w", err) + } + + // Calculate shared secret + sharedSecret, err := crypto.PerformECDH(privateKey, serverPubKeyBytes) + if err != nil { + c.conn.Close() + return fmt.Errorf("ECDH failed: %w", err) + } + + // Mutual authentication + fmt.Printf("Starting HMAC mutual authentication...\n") + if err := crypto.PerformClientAuth(c.conn, sharedSecret); err != nil { + c.conn.Close() + return fmt.Errorf("authentication failed: %w", err) + } + fmt.Printf("Mutual authentication successful\n") + + // Setup AES-GCM + gcm, err := crypto.SetupAESGCM(sharedSecret) + if err != nil { + c.conn.Close() + return err + } + + c.gcm = gcm + c.sharedSecret = crypto.SecureBuffer(sharedSecret) + c.connected = true + + fmt.Printf("Connected to %s\n", c.serverAddr) + fmt.Printf("End-to-end encryption active (AES-256-GCM)\n") + fmt.Printf("Type /help for commands\n\n") + + // Start receiving messages + go c.receiveMessages() + + return nil +} + +func (c *CLIClient) disconnect() { + c.mu.Lock() + defer c.mu.Unlock() + + if !c.connected { + return + } + + c.sendEncrypted("/quit") + time.Sleep(100 * time.Millisecond) + + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + + if c.sharedSecret != nil { + buf, _ := c.sharedSecret.Open() + if buf != nil { + buf.Destroy() + } + c.sharedSecret = nil + } + c.gcm = nil + c.connected = false + + fmt.Printf("\nDisconnected from server\n") + fmt.Printf("Keys wiped from memory\n") + + if c.autoReconnect { + fmt.Printf("Auto-reconnect is enabled\n") + } + fmt.Printf("\n") +} + +func (c *CLIClient) sendMessage(message string) error { + return c.sendEncrypted(message) +} + +func (c *CLIClient) sendEncrypted(message string) error { + c.mu.Lock() + defer c.mu.Unlock() + + if !c.connected || c.gcm == nil || c.conn == nil { + return fmt.Errorf("not connected") + } + + data, err := crypto.EncryptMessage(c.gcm, message) + if err != nil { + return err + } + + _, err = c.conn.Write(data) + return err +} + +func (c *CLIClient) receiveMessages() { + buf := make([]byte, 8192) + + for { + c.mu.Lock() + if !c.connected { + c.mu.Unlock() + return + } + conn := c.conn + gcm := c.gcm + c.mu.Unlock() + + conn.SetReadDeadline(time.Time{}) + + n, err := conn.Read(buf) + if err != nil { + if err == io.EOF { + fmt.Printf("\nServer closed connection\n") + } else { + fmt.Printf("\nConnection lost: %v\n", err) + } + + c.mu.Lock() + c.connected = false + c.mu.Unlock() + return + } + + if n < 12 { + continue + } + + message, err := crypto.DecryptMessage(gcm, buf[:n]) + if err != nil { + fmt.Printf("\nFailed to decrypt message\n") + continue + } + + // Try to parse as UserListMessage + if userList, err := protocol.ParseAsUserList([]byte(message)); err == nil && userList.Type == protocol.TypeUserList { + c.mu.Lock() + c.onlineUsers = userList.Users + c.mu.Unlock() + fmt.Printf("\rOnline users (%d): %s\n> ", len(userList.Users), strings.Join(userList.Users, ", ")) + continue + } + + // Try to parse as Message + if msg, err := protocol.ParseAsMessage([]byte(message)); err == nil { + timestamp := msg.Time + if timestamp == "" { + timestamp = time.Now().Format("15:04:05") + } + + switch msg.Type { + case protocol.TypeSystem: + fmt.Printf("\r[%s] %s\n> ", timestamp, msg.Content) + case protocol.TypePrivate: + fmt.Printf("\r[%s] PM from %s: %s\n> ", timestamp, msg.From, msg.Content) + case protocol.TypeError: + fmt.Printf("\r[%s] Error: %s\n> ", timestamp, msg.Content) + default: + if msg.From != "" { + fmt.Printf("\r[%s] %s: %s\n> ", timestamp, msg.From, msg.Content) + } else { + fmt.Printf("\r[%s] %s\n> ", timestamp, msg.Content) + } + } + } else { + timestamp := time.Now().Format("15:04:05") + fmt.Printf("\r[%s] %s\n> ", timestamp, message) + } + } +} + +func (c *CLIClient) autoReconnectLoop() { + lastAttempt := time.Now() + + for { + time.Sleep(5 * time.Second) + + c.mu.Lock() + shouldReconnect := !c.connected && c.autoReconnect && c.lastServer != "" + c.mu.Unlock() + + if shouldReconnect { + if time.Since(lastAttempt) < 10*time.Second { + continue + } + lastAttempt = time.Now() + + fmt.Printf("\nAuto-reconnecting to %s...\n", c.lastServer) + c.serverAddr = c.lastServer + if err := c.connect(); err != nil { + fmt.Printf("Auto-reconnect failed: %v\n", err) + fmt.Printf("Will retry in 10 seconds...\n") + } + } + } +} + +func (c *CLIClient) showStatus() { + c.mu.Lock() + defer c.mu.Unlock() + + fmt.Printf("\nConnection Status:\n") + fmt.Printf(" Connected: %v\n", c.connected) + if c.connected { + fmt.Printf(" Server: %s\n", c.serverAddr) + fmt.Printf(" Mode: Tor Hidden Service (Anonymous)\n") + fmt.Printf(" Encryption: AES-256-GCM\n") + fmt.Printf(" Key Exchange: ECDH-X25519\n") + fmt.Printf(" Authentication: HMAC-SHA256\n") + } + if c.identity != nil { + fmt.Printf(" Identity: %s\n", c.identity.Username) + } + fmt.Printf(" Auto-Reconnect: %v\n", c.autoReconnect) + fmt.Printf(" Version: %s\n\n", version) +} + +func (c *CLIClient) showHelp() { + fmt.Printf(` +NoshiTalk CLI Commands v%s + +Connection: + /reconnect, /r - Reconnect to last server + /disconnect, /d - Disconnect from server + /status, /s - Show connection status + +Messaging: + /users, /u - Show online users + /pm user message - Send private message + +Settings: + /auto on - Enable auto-reconnect + /auto off - Disable auto-reconnect + +Interface: + /clear, /cls - Clear screen + /help, /h, /? - Show this help + /quit, /exit, /q - Exit application + +Tips: + - Only .onion addresses accepted (Tor-only mode) + - All messages are end-to-end encrypted + - ECDH + HMAC + AES-GCM protection + - Press Ctrl+C for emergency shutdown + +`, version) +} + +func (c *CLIClient) showUsers() { + c.mu.Lock() + users := c.onlineUsers + c.mu.Unlock() + + if len(users) == 0 { + fmt.Printf("No users online (or not yet received user list)\n") + return + } + + fmt.Printf("Online users (%d):\n", len(users)) + for _, user := range users { + fmt.Printf(" - %s\n", user) + } +} diff --git a/cmd/gui-client/main.go b/cmd/gui-client/main.go new file mode 100644 index 0000000..2319e83 --- /dev/null +++ b/cmd/gui-client/main.go @@ -0,0 +1,537 @@ +package main + +import ( + "crypto/cipher" + "fmt" + "io" + "net" + "strings" + "time" + + "github.com/awnumar/memguard" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "noshitalk/pkg/crypto" + "noshitalk/pkg/identity" + "noshitalk/pkg/protocol" + "noshitalk/pkg/tor" +) + +// ChatClient represents the GUI chat client. +type ChatClient struct { + conn net.Conn + gcm cipher.AEAD + sharedSecret *memguard.Enclave + app fyne.App + window fyne.Window + messages *widget.List + messageList []string + input *widget.Entry + status *widget.Label + connectBtn *widget.Button + serverEntry *widget.Entry + debugLog *widget.Entry + connected bool + autoReconnect bool + lastServer string + userList *widget.List + onlineUsers []string + identity *identity.Identity +} + +var version = "2.0-refactored" + +func main() { + memguard.CatchInterrupt() + defer memguard.Purge() + + chatApp := app.NewWithID("noshitalk-gui") + client := &ChatClient{ + app: chatApp, + messageList: []string{}, + autoReconnect: true, + onlineUsers: []string{}, + } + + // Initialize persistent identity + keyPath, err := identity.GetDefaultKeyPath("gui-identity.noshikey") + if err == nil { + client.identity, err = identity.LoadOrCreate(keyPath, "gui") + if err != nil { + fmt.Printf("Identity initialization failed: %v\n", err) + fmt.Printf("Continuing with temporary identity\n\n") + } + } + + client.createMainWindow() + client.enableAutoReconnect() + client.window.ShowAndRun() +} + +func (c *ChatClient) createMainWindow() { + c.window = c.app.NewWindow(fmt.Sprintf("NoshiTalk GUI v%s", version)) + c.window.SetIcon(theme.ComputerIcon()) + c.window.Resize(fyne.NewSize(1200, 800)) + + c.status = widget.NewLabel("Disconnected") + c.status.TextStyle.Bold = true + + c.serverEntry = widget.NewEntry() + c.serverEntry.SetPlaceHolder("abc...xyz.onion:8080") + + c.connectBtn = widget.NewButton("Connect", c.connectToServer) + c.connectBtn.Importance = widget.HighImportance + + c.messages = widget.NewList( + func() int { return len(c.messageList) }, + func() fyne.CanvasObject { return widget.NewLabel("Template") }, + func(i widget.ListItemID, o fyne.CanvasObject) { + o.(*widget.Label).SetText(c.messageList[i]) + }, + ) + + c.userList = widget.NewList( + func() int { return len(c.onlineUsers) }, + func() fyne.CanvasObject { return widget.NewButton("", nil) }, + func(i widget.ListItemID, o fyne.CanvasObject) { + if i < len(c.onlineUsers) { + btn := o.(*widget.Button) + username := c.onlineUsers[i] + btn.SetText(username) + btn.OnTapped = func() { c.onUserClick(username) } + } + }, + ) + + c.input = widget.NewEntry() + c.input.SetPlaceHolder("Message...") + c.input.Disable() + c.input.OnSubmitted = c.sendMessage + + autoReconnectCheck := widget.NewCheck("Auto-reconnect", func(checked bool) { + c.autoReconnect = checked + }) + autoReconnectCheck.SetChecked(true) + + panicBtn := widget.NewButton("PANIC", c.panic) + panicBtn.Importance = widget.DangerImportance + + identityInfo := widget.NewLabel("Identity: Loading...") + if c.identity != nil { + identityInfo.SetText(fmt.Sprintf("ID: %s", c.identity.Fingerprint[:16])) + } + + securityPanel := widget.NewCard("Security", "", + container.NewVBox( + widget.NewLabel("Tor only\nE2E encrypted\nEphemeral messages\nmemguard protected"), + autoReconnectCheck, + identityInfo, + panicBtn, + )) + + c.debugLog = widget.NewEntry() + c.debugLog.MultiLine = true + c.debugLog.Wrapping = fyne.TextWrapWord + c.debugLog.SetText("Ready\n") + + debugScroll := container.NewScroll(c.debugLog) + debugScroll.SetMinSize(fyne.NewSize(350, 300)) + + debugPanel := widget.NewCard("Debug", "", debugScroll) + + clearLogBtn := widget.NewButton("Clear", func() { + c.debugLog.SetText("Log cleared\n") + }) + + rightPanel := container.NewVBox(securityPanel, debugPanel, clearLogBtn) + + connectionPanel := container.NewVBox( + widget.NewLabel("Server:"), + c.serverEntry, + c.connectBtn, + c.status, + ) + + userListScroll := container.NewScroll(c.userList) + userListScroll.SetMinSize(fyne.NewSize(200, 0)) + + userListPanel := container.NewBorder( + widget.NewCard("Online", "", widget.NewLabel("")), + nil, nil, nil, + userListScroll, + ) + + leftSplit := container.NewHSplit(userListPanel, c.messages) + leftSplit.SetOffset(0.20) + + chatArea := container.NewHSplit(leftSplit, rightPanel) + chatArea.SetOffset(0.70) + + bottomBar := container.NewBorder( + nil, nil, + widget.NewLabel("Input: "), + widget.NewButton("Send", func() { c.sendMessage(c.input.Text) }), + c.input, + ) + + content := container.NewBorder(connectionPanel, bottomBar, nil, nil, chatArea) + c.window.SetContent(content) +} + +func (c *ChatClient) enableAutoReconnect() { + go func() { + lastAttempt := time.Now() + for { + time.Sleep(5 * time.Second) + if !c.connected && c.autoReconnect && c.lastServer != "" { + if time.Since(lastAttempt) < 10*time.Second { + continue + } + lastAttempt = time.Now() + c.addDebugLog("Auto-reconnecting to " + c.lastServer) + fyne.Do(func() { + c.serverEntry.SetText(c.lastServer) + c.connectToServer() + }) + } + } + }() +} + +func (c *ChatClient) connectToServer() { + if c.connected { + c.disconnect() + return + } + + serverAddr := c.serverEntry.Text + if serverAddr == "" { + c.showError("Error", "Enter .onion address") + return + } + + if err := tor.ValidateOnionAddress(serverAddr); err != nil { + c.showError("Invalid Address", err.Error()) + return + } + + c.lastServer = serverAddr + fyne.Do(func() { c.debugLog.SetText("Connecting\n") }) + c.addDebugLog("Target: " + serverAddr) + + fyne.Do(func() { + c.status.SetText("Connecting...") + c.connectBtn.Disable() + }) + + progress := dialog.NewCustom("Connecting", "Cancel", + widget.NewProgressBarInfinite(), c.window) + progress.Show() + + go func() { + defer fyne.Do(func() { progress.Hide() }) + + torDialer := tor.NewDialer() + conn, err := torDialer.DialWithCallback(serverAddr, func(msg string) { + c.addDebugLog(msg) + }) + if err != nil { + c.showError("Connection Error", err.Error()) + fyne.Do(func() { + c.connectBtn.Enable() + c.status.SetText("Connection failed") + }) + return + } + + c.conn = conn + fyne.Do(func() { c.status.SetText("Encrypting...") }) + c.addDebugLog("Starting ECDH") + + // Use persistent identity or generate temporary + var privateKey = c.identity.PrivateKey + if c.identity != nil && c.identity.PrivateKey != nil { + c.addDebugLog("Using persistent identity: " + c.identity.Fingerprint) + } else { + privateKey, err = crypto.GenerateX25519KeyPair() + if err != nil { + c.showError("Crypto Error", err.Error()) + c.disconnect() + return + } + c.addDebugLog("Using temporary identity") + } + + // Send public key + publicKeyBytes := privateKey.PublicKey().Bytes() + c.addDebugLog("Sending public key") + + c.conn.SetWriteDeadline(time.Now().Add(crypto.HandshakeTimeout)) + if _, err := c.conn.Write(publicKeyBytes); err != nil { + c.showError("Connection Error", err.Error()) + c.disconnect() + return + } + c.conn.SetWriteDeadline(time.Time{}) + + // Receive server public key + c.addDebugLog("Receiving server key") + serverPubKeyBytes := make([]byte, 32) + if _, err := io.ReadFull(conn, serverPubKeyBytes); err != nil { + c.showError("Connection Error", err.Error()) + c.disconnect() + return + } + + // Calculate shared secret + c.addDebugLog("Calculating shared secret") + sharedSecret, err := crypto.PerformECDH(privateKey, serverPubKeyBytes) + if err != nil { + c.showError("Crypto Error", err.Error()) + c.disconnect() + return + } + + // Mutual authentication + c.addDebugLog("Starting HMAC auth") + if err := crypto.PerformClientAuth(c.conn, sharedSecret); err != nil { + c.showError("Auth Error", err.Error()) + c.disconnect() + return + } + c.addDebugLog("Auth successful") + + // Setup AES-GCM + c.addDebugLog("Setting up AES-GCM") + gcm, err := crypto.SetupAESGCM(sharedSecret) + if err != nil { + c.showError("Crypto Error", err.Error()) + c.disconnect() + return + } + + c.sharedSecret = crypto.SecureBuffer(sharedSecret) + c.gcm = gcm + c.connected = true + + fyne.Do(func() { + c.input.Enable() + c.connectBtn.SetText("Disconnect") + c.connectBtn.OnTapped = c.disconnect + c.connectBtn.Enable() + c.status.SetText("Connected (E2E)") + }) + + c.addMessage("System", "Connected via Tor") + c.addMessage("System", "E2E encryption active") + c.addDebugLog("Session established") + + go c.receiveMessages() + }() +} + +func (c *ChatClient) disconnect() { + if !c.connected { + return + } + + c.connected = false + c.addDebugLog("Disconnecting") + + if c.conn != nil { + c.sendEncrypted("/quit") + time.Sleep(100 * time.Millisecond) + c.conn.Close() + c.conn = nil + } + + if c.sharedSecret != nil { + buf, _ := c.sharedSecret.Open() + if buf != nil { + buf.Destroy() + } + c.sharedSecret = nil + } + c.gcm = nil + + fyne.Do(func() { + c.input.Disable() + c.connectBtn.SetText("Connect") + c.connectBtn.OnTapped = c.connectToServer + c.connectBtn.Enable() + c.status.SetText("Disconnected") + }) + + c.addMessage("System", "Disconnected") + c.addDebugLog("Keys wiped") +} + +func (c *ChatClient) panic() { + c.addDebugLog("PANIC MODE") + + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + + if c.sharedSecret != nil { + buf, _ := c.sharedSecret.Open() + if buf != nil { + buf.Destroy() + } + c.sharedSecret = nil + } + + c.identity = nil + c.gcm = nil + c.connected = false + + fyne.Do(func() { + c.input.Disable() + c.connectBtn.SetText("Connect") + c.connectBtn.OnTapped = c.connectToServer + c.connectBtn.Enable() + c.status.SetText("PANIC - All keys destroyed") + }) + + c.addMessage("System", "PANIC: Keys destroyed") + c.addDebugLog("Memory wiped") + + memguard.Purge() +} + +func (c *ChatClient) sendMessage(message string) { + if !c.connected || message == "" { + return + } + + c.addDebugLog("Sending: " + message) + + if err := c.sendEncrypted(message); err != nil { + c.addDebugLog("Send error: " + err.Error()) + c.showError("Send Error", err.Error()) + return + } + + c.addMessage("You", message) + fyne.Do(func() { c.input.SetText("") }) +} + +func (c *ChatClient) sendEncrypted(message string) error { + if c.gcm == nil || c.conn == nil { + return fmt.Errorf("not connected") + } + + data, err := crypto.EncryptMessage(c.gcm, message) + if err != nil { + return err + } + + _, err = c.conn.Write(data) + return err +} + +func (c *ChatClient) receiveMessages() { + buf := make([]byte, 8192) + c.addDebugLog("Starting receive loop") + + for c.connected { + c.conn.SetReadDeadline(time.Time{}) + + n, err := c.conn.Read(buf) + if err != nil { + if err == io.EOF { + c.addDebugLog("Server closed connection") + } else { + c.addDebugLog("Read error: " + err.Error()) + } + if c.connected { + c.addMessage("System", "Connection lost") + c.disconnect() + } + return + } + + if n == 0 { + continue + } + + c.addDebugLog(fmt.Sprintf("Received %d bytes", n)) + + message, err := crypto.DecryptMessage(c.gcm, buf[:n]) + if err != nil { + c.addDebugLog("Decrypt error: " + err.Error()) + continue + } + + // Try to parse as UserListMessage + if userList, err := protocol.ParseAsUserList([]byte(message)); err == nil && userList.Type == protocol.TypeUserList { + c.onlineUsers = userList.Users + fyne.Do(func() { c.userList.Refresh() }) + c.addMessage("System", fmt.Sprintf("Online: %d users", len(userList.Users))) + continue + } + + // Try to parse as Message + if msg, err := protocol.ParseAsMessage([]byte(message)); err == nil { + switch msg.Type { + case protocol.TypeSystem: + c.addMessage("System", msg.Content) + case protocol.TypePrivate: + c.addMessage("PM-"+msg.From, msg.Content) + case protocol.TypeError: + c.addMessage("System", "Error: "+msg.Content) + default: + c.addMessage(msg.From, msg.Content) + } + } else { + c.addMessage("Server", message) + } + } + + c.addDebugLog("Receive loop ended") +} + +func (c *ChatClient) onUserClick(username string) { + fyne.Do(func() { + c.input.SetText("/pm " + username + " ") + c.input.CursorColumn = len(c.input.Text) + c.window.Canvas().Focus(c.input) + }) + c.addDebugLog("Ready to PM " + username) +} + +func (c *ChatClient) addMessage(sender, message string) { + timestamp := time.Now().Format("15:04:05") + formatted := fmt.Sprintf("[%s] %s: %s", timestamp, sender, message) + + c.messageList = append(c.messageList, formatted) + fyne.Do(func() { + c.messages.Refresh() + c.messages.ScrollToBottom() + }) +} + +func (c *ChatClient) addDebugLog(message string) { + timestamp := time.Now().Format("15:04:05") + logEntry := fmt.Sprintf("[%s] %s\n", timestamp, message) + + fyne.Do(func() { + c.debugLog.SetText(c.debugLog.Text + logEntry) + c.debugLog.CursorRow = len(strings.Split(c.debugLog.Text, "\n")) - 1 + }) +} + +func (c *ChatClient) showError(title, message string) { + fyne.Do(func() { + dialog.ShowError(fmt.Errorf(message), c.window) + }) + c.addMessage("System", title+": "+message) + c.addDebugLog(title + ": " + message) +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..6ee4aa5 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,722 @@ +package main + +import ( + "bytes" + "compress/zlib" + "crypto/cipher" + "crypto/ecdh" + "encoding/json" + "fmt" + "io" + "net" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/awnumar/memguard" + + "noshitalk/pkg/crypto" + "noshitalk/pkg/protocol" +) + +// Client represents a connected chat client. +type Client struct { + conn net.Conn + identity string + gcm cipher.AEAD + sharedSecret *memguard.Enclave + quit chan struct{} + lastActivity time.Time + mu sync.Mutex + + // Visibility control + isVisible bool + + // Key rotation + currentKeyPair *ecdh.PrivateKey + nextKeyPair *ecdh.PrivateKey + sessionKeys [][]byte + keyRotationCount uint32 + lastRotation time.Time + messageCount uint32 +} + +// FileTransfer tracks an ongoing file transfer. +type FileTransfer struct { + FileID string + Sender *Client + Receiver *Client + StartTime time.Time + TotalChunks int + Received int + Completed bool +} + +var ( + clients = make(map[net.Conn]*Client) + clientsMux sync.RWMutex + fileTransfers = make(map[string]*FileTransfer) + transfersMux sync.RWMutex + version = "2.0-refactored" +) + +const ( + protocolVersion = 2 + fileChunkSize = 256 * 1024 // 256KB chunks + maxFileSize = 500 * 1024 * 1024 // 500MB max + maxConcurrentTransfers = 3 + rotateAfterMessages = 100 + rotateAfterTime = 5 * time.Minute +) + +func main() { + fmt.Printf("NoshiTalk Server v%s\n", version) + fmt.Printf("Zero logs, zero traces, auto-wipe post-session\n") + fmt.Printf("Designed for Tor Hidden Service - localhost only\n") + fmt.Printf("Traffic obfuscation: Padding + Random delays\n") + fmt.Printf("Key rotation enabled (every %d messages or %v)\n", rotateAfterMessages, rotateAfterTime) + fmt.Printf("Ghost mode enabled by default\n\n") + + memguard.CatchInterrupt() + defer secureShutdown("Session completed") + + listenAddr := "127.0.0.1:8083" + fmt.Printf("Listening on: %s (Tor Hidden Service backend)\n", listenAddr) + fmt.Printf("Server NOT exposed directly - Tor proxy required\n\n") + + listener, err := net.Listen("tcp", listenAddr) + if err != nil { + fmt.Printf("Listen error: %v\n", err) + secureShutdown(fmt.Sprintf("Listen error: %v", err)) + } + + fmt.Printf("Server started successfully\n") + fmt.Printf("Waiting for connections...\n\n") + + // Signal handling + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + go func() { + <-stop + fmt.Printf("\nReceived shutdown signal\n") + secureShutdown("Operator requested shutdown") + }() + + // Monitor goroutine + go monitorLoop() + + // Accept connections + for { + conn, err := listener.Accept() + if err != nil { + fmt.Printf("Accept error: %v\n", err) + continue + } + + fmt.Printf("New connection from %s\n", conn.RemoteAddr()) + go handleClient(conn) + } +} + +func monitorLoop() { + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + + for range ticker.C { + clientsMux.RLock() + activeCount := len(clients) + visibleCount := 0 + for _, client := range clients { + if client.isVisible { + visibleCount++ + } + } + if activeCount > 0 { + fmt.Printf("Active connections: %d (visible: %d, ghost: %d)\n", + activeCount, visibleCount, activeCount-visibleCount) + } + clientsMux.RUnlock() + + cleanupOldTransfers() + } +} + +func handleClient(conn net.Conn) { + defer func() { + fmt.Printf("Connection handler ending\n") + conn.Close() + removeClient(conn) + }() + + // Generate server key pair + privateKey, err := crypto.GenerateX25519KeyPair() + if err != nil { + fmt.Printf("Key generation failed: %v\n", err) + return + } + + // Receive client public key + fmt.Printf("Waiting for client public key...\n") + clientPubKeyBytes := make([]byte, 32) + + conn.SetReadDeadline(time.Now().Add(crypto.HandshakeTimeout)) + n, err := io.ReadFull(conn, clientPubKeyBytes) + if err != nil { + fmt.Printf("Error reading client public key: %v\n", err) + return + } + conn.SetReadDeadline(time.Time{}) + + identity := crypto.DeriveIdentity(clientPubKeyBytes) + fmt.Printf("[%s] Received client public key (%d bytes)\n", identity, n) + + // Send server public key + fmt.Printf("[%s] Sending server public key...\n", identity) + if _, err := conn.Write(privateKey.PublicKey().Bytes()); err != nil { + fmt.Printf("[%s] Error sending server public key: %v\n", identity, err) + return + } + + // Calculate shared secret + fmt.Printf("[%s] Calculating shared secret...\n", identity) + sharedSecret, err := crypto.PerformECDH(privateKey, clientPubKeyBytes) + if err != nil { + fmt.Printf("[%s] ECDH failed: %v\n", identity, err) + return + } + + // Mutual authentication + fmt.Printf("[%s] Starting mutual authentication...\n", identity) + if err := crypto.PerformServerAuth(conn, sharedSecret); err != nil { + fmt.Printf("[%s] Authentication failed: %v\n", identity, err) + return + } + + // Setup AES-GCM + fmt.Printf("[%s] Setting up AES-GCM...\n", identity) + gcm, err := crypto.SetupAESGCM(sharedSecret) + if err != nil { + fmt.Printf("[%s] %v\n", identity, err) + return + } + + // Create client + client := &Client{ + conn: conn, + identity: identity, + gcm: gcm, + sharedSecret: crypto.SecureBuffer(sharedSecret), + quit: make(chan struct{}), + lastActivity: time.Now(), + isVisible: false, // Ghost mode by default + currentKeyPair: privateKey, + lastRotation: time.Now(), + messageCount: 0, + } + + addClient(conn, client) + fmt.Printf("[%s] Client initialized (ghost mode)\n", identity) + + // Send welcome message + welcomeMsg := protocol.NewSystemMessage( + fmt.Sprintf("Connected as %s (ghost mode). Use /reveal to become visible, /ghost to hide.", identity)) + client.sendMessage(welcomeMsg) + + // Send personal user list + sendPersonalUserList(client) + + fmt.Printf("[%s] Starting message handling...\n", identity) + client.receiveMessages() + + fmt.Printf("[%s] Client disconnected\n", identity) + + if client.isVisible { + broadcastSystemMessage(fmt.Sprintf("%s disconnected", identity)) + } +} + +func addClient(conn net.Conn, client *Client) { + clientsMux.Lock() + clients[conn] = client + totalClients := len(clients) + clientsMux.Unlock() + + fmt.Printf("[%s] Added to clients map. Total clients: %d\n", client.identity, totalClients) +} + +func removeClient(conn net.Conn) { + clientsMux.Lock() + client, exists := clients[conn] + if exists && client.isVisible { + delete(clients, conn) + clientsMux.Unlock() + broadcastUserListToVisible() + } else { + delete(clients, conn) + clientsMux.Unlock() + } +} + +func (c *Client) sendMessage(msg *protocol.Message) error { + data, err := msg.ToJSON() + if err != nil { + return err + } + return c.sendEncrypted(string(data)) +} + +func (c *Client) sendEncrypted(message string) error { + if c.conn == nil || c.gcm == nil { + return fmt.Errorf("not ready") + } + + data, err := crypto.EncryptMessage(c.gcm, message) + if err != nil { + return err + } + + _, err = c.conn.Write(data) + return err +} + +func (c *Client) receiveMessages() { + defer func() { + if r := recover(); r != nil { + fmt.Printf("[%s] PANIC: %v\n", c.identity, r) + } + close(c.quit) + }() + + buf := make([]byte, 512*1024) + + for { + n, err := c.conn.Read(buf) + if err != nil { + if err == io.EOF { + fmt.Printf("[%s] Connection closed\n", c.identity) + } else { + fmt.Printf("[%s] Read error: %v\n", c.identity, err) + } + return + } + + if n == 0 { + continue + } + + c.mu.Lock() + c.lastActivity = time.Now() + c.messageCount++ + c.mu.Unlock() + + message, err := crypto.DecryptMessage(c.gcm, buf[:n]) + if err != nil { + fmt.Printf("[%s] Decrypt error: %v\n", c.identity, err) + continue + } + + c.checkKeyRotation() + + if c.handleCommand(message) { + continue + } + + // Handle JSON messages + msgType, _, err := protocol.ParseMessage([]byte(message)) + if err == nil { + switch msgType { + case protocol.TypeFileOffer: + if fileOffer, err := protocol.ParseAsFileOffer([]byte(message)); err == nil { + routeFileOffer(c, fileOffer) + continue + } + case protocol.TypeFileChunk: + if fileChunk, err := protocol.ParseAsFileChunk([]byte(message)); err == nil { + routeFileChunk(c, fileChunk) + continue + } + case protocol.TypeFileAccept, protocol.TypeFileReject, protocol.TypeFileComplete: + if fileResp, err := protocol.ParseAsFileResponse([]byte(message)); err == nil { + routeFileResponse(c, fileResp) + continue + } + } + } + + // Handle private messages + if len(message) > 4 && message[:4] == "/pm " { + parts := splitPrivateMessage(message) + if len(parts) < 2 { + errorMsg := protocol.NewErrorMessage("Usage: /pm username message") + c.sendMessage(errorMsg) + continue + } + sendPrivateMessage(c, parts[0], parts[1]) + continue + } + + // Broadcast if visible + if c.isVisible { + broadcastUserMessage(c, message) + } else { + errorMsg := protocol.NewErrorMessage("You are in ghost mode. Use /reveal to send messages.") + c.sendMessage(errorMsg) + } + } +} + +func (c *Client) handleCommand(message string) bool { + switch message { + case "/quit": + return true + case "/reveal": + c.mu.Lock() + wasVisible := c.isVisible + c.isVisible = true + c.mu.Unlock() + + if !wasVisible { + fmt.Printf("[%s] Became visible\n", c.identity) + broadcastSystemMessage(fmt.Sprintf("%s joined the chat", c.identity)) + broadcastUserListToVisible() + } + return true + case "/ghost": + c.mu.Lock() + wasVisible := c.isVisible + c.isVisible = false + c.mu.Unlock() + + if wasVisible { + fmt.Printf("[%s] Went ghost\n", c.identity) + broadcastSystemMessage(fmt.Sprintf("%s went invisible", c.identity)) + broadcastUserListToVisible() + } + return true + case "/users": + sendPersonalUserList(c) + return true + } + return false +} + +func (c *Client) checkKeyRotation() { + c.mu.Lock() + defer c.mu.Unlock() + + needRotation := false + if c.messageCount >= rotateAfterMessages { + needRotation = true + fmt.Printf("[%s] Key rotation triggered (message count: %d)\n", c.identity, c.messageCount) + } + if time.Since(c.lastRotation) > rotateAfterTime { + needRotation = true + fmt.Printf("[%s] Key rotation triggered (time elapsed: %v)\n", c.identity, time.Since(c.lastRotation)) + } + + if needRotation { + c.performKeyRotation() + } +} + +func (c *Client) performKeyRotation() { + newKeyPair, err := crypto.GenerateX25519KeyPair() + if err != nil { + fmt.Printf("[%s] Key rotation failed: %v\n", c.identity, err) + return + } + + c.nextKeyPair = newKeyPair + c.keyRotationCount++ + c.messageCount = 0 + c.lastRotation = time.Now() + + rotMsg := protocol.KeyRotationMessage{ + Type: protocol.TypeKeyRotation, + PublicKey: newKeyPair.PublicKey().Bytes(), + Nonce: make([]byte, 12), + Time: time.Now().Format("15:04:05"), + } + + if data, err := rotMsg.ToJSON(); err == nil { + c.sendEncrypted(string(data)) + } + + fmt.Printf("[%s] Key rotation completed (rotation #%d)\n", c.identity, c.keyRotationCount) +} + +func sendPersonalUserList(client *Client) { + clientsMux.RLock() + visibleUsers := []string{} + for _, c := range clients { + if c.isVisible || c == client { + visibleUsers = append(visibleUsers, c.identity) + } + } + clientsMux.RUnlock() + + userListMsg := protocol.NewUserListMessage(visibleUsers) + if data, err := userListMsg.ToJSON(); err == nil { + client.sendEncrypted(string(data)) + } +} + +func broadcastUserListToVisible() { + clientsMux.RLock() + defer clientsMux.RUnlock() + + visibleUsers := []string{} + for _, c := range clients { + if c.isVisible { + visibleUsers = append(visibleUsers, c.identity) + } + } + + userListMsg := protocol.NewUserListMessage(visibleUsers) + data, err := userListMsg.ToJSON() + if err != nil { + return + } + + for _, client := range clients { + if client.isVisible { + client.sendEncrypted(string(data)) + } + } +} + +func broadcastSystemMessage(message string) { + clientsMux.RLock() + defer clientsMux.RUnlock() + + systemMsg := protocol.NewSystemMessage(message) + data, err := systemMsg.ToJSON() + if err != nil { + return + } + + for _, client := range clients { + if client.isVisible { + client.sendEncrypted(string(data)) + } + } +} + +func broadcastUserMessage(sender *Client, message string) { + clientsMux.RLock() + defer clientsMux.RUnlock() + + userMsg := protocol.NewMessage(sender.identity, "", message, protocol.TypeMessage) + data, err := userMsg.ToJSON() + if err != nil { + return + } + + for _, client := range clients { + if client != sender && client.isVisible { + client.sendEncrypted(string(data)) + } + } +} + +func sendPrivateMessage(sender *Client, targetUsername, message string) { + clientsMux.RLock() + defer clientsMux.RUnlock() + + var targetClient *Client + for _, client := range clients { + if client.identity == targetUsername { + targetClient = client + break + } + } + + if targetClient == nil { + errorMsg := protocol.NewErrorMessage(fmt.Sprintf("User '%s' not found", targetUsername)) + sender.sendMessage(errorMsg) + return + } + + privateMsg := protocol.NewMessage(sender.identity, targetUsername, message, protocol.TypePrivate) + data, err := privateMsg.ToJSON() + if err != nil { + return + } + + fmt.Printf("[%s -> %s] Private message\n", sender.identity, targetUsername) + targetClient.sendEncrypted(string(data)) + sender.sendEncrypted(string(data)) +} + +func splitPrivateMessage(msg string) []string { + content := msg[4:] + spaceIdx := -1 + for i, ch := range content { + if ch == ' ' { + spaceIdx = i + break + } + } + + if spaceIdx == -1 { + return []string{content} + } + + return []string{content[:spaceIdx], content[spaceIdx+1:]} +} + +// File transfer functions +func routeFileOffer(sender *Client, offer *protocol.FileOfferMessage) { + transfersMux.RLock() + activeTransfers := 0 + for _, transfer := range fileTransfers { + if !transfer.Completed && transfer.Sender == sender { + activeTransfers++ + } + } + transfersMux.RUnlock() + + if activeTransfers >= maxConcurrentTransfers { + errorMsg := protocol.NewErrorMessage(fmt.Sprintf("Max concurrent transfers (%d) reached", maxConcurrentTransfers)) + sender.sendMessage(errorMsg) + return + } + + clientsMux.RLock() + var targetClient *Client + for _, client := range clients { + if client.identity == offer.To { + targetClient = client + break + } + } + clientsMux.RUnlock() + + if targetClient == nil { + errorMsg := protocol.NewErrorMessage(fmt.Sprintf("User '%s' not found", offer.To)) + sender.sendMessage(errorMsg) + return + } + + transfer := &FileTransfer{ + FileID: offer.FileID, + Sender: sender, + Receiver: targetClient, + StartTime: time.Now(), + TotalChunks: offer.TotalChunks, + } + + transfersMux.Lock() + fileTransfers[offer.FileID] = transfer + transfersMux.Unlock() + + offer.From = sender.identity + offer.Time = time.Now().Format("15:04:05") + + fmt.Printf("[%s -> %s] File offer: %s (%d bytes, %d chunks)\n", + sender.identity, offer.To, offer.Filename, offer.Size, offer.TotalChunks) + + if data, err := offer.ToJSON(); err == nil { + targetClient.sendEncrypted(string(data)) + } +} + +func routeFileChunk(sender *Client, chunk *protocol.FileChunkMessage) { + transfersMux.RLock() + transfer, exists := fileTransfers[chunk.FileID] + transfersMux.RUnlock() + + if !exists || transfer.Sender != sender { + return + } + + chunk.Time = time.Now().Format("15:04:05") + + transfersMux.Lock() + transfer.Received++ + if transfer.Received >= transfer.TotalChunks { + transfer.Completed = true + fmt.Printf("[%s] File transfer completed in %v\n", sender.identity, time.Since(transfer.StartTime)) + } + transfersMux.Unlock() + + if data, err := chunk.ToJSON(); err == nil { + transfer.Receiver.sendEncrypted(string(data)) + } +} + +func routeFileResponse(sender *Client, resp *protocol.FileResponseMessage) { + transfersMux.RLock() + transfer, exists := fileTransfers[resp.FileID] + transfersMux.RUnlock() + + if !exists { + return + } + + resp.From = sender.identity + resp.Time = time.Now().Format("15:04:05") + + data, err := resp.ToJSON() + if err != nil { + return + } + + if sender == transfer.Receiver { + transfer.Sender.sendEncrypted(string(data)) + } else { + transfer.Receiver.sendEncrypted(string(data)) + } + + if resp.Status == "rejected" || resp.Status == "completed" { + transfersMux.Lock() + delete(fileTransfers, resp.FileID) + transfersMux.Unlock() + } +} + +func cleanupOldTransfers() { + transfersMux.Lock() + defer transfersMux.Unlock() + + now := time.Now() + for fileID, transfer := range fileTransfers { + if now.Sub(transfer.StartTime) > 30*time.Minute { + fmt.Printf("Cleaning up old transfer: %s\n", fileID[:8]) + delete(fileTransfers, fileID) + } + } +} + +func compressData(data []byte) ([]byte, error) { + var buf bytes.Buffer + w := zlib.NewWriter(&buf) + _, err := w.Write(data) + w.Close() + return buf.Bytes(), err +} + +func decompressData(data []byte) ([]byte, error) { + r, err := zlib.NewReader(bytes.NewReader(data)) + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) +} + +func secureShutdown(reason string) { + fmt.Printf("\nServer shutdown: %s\n", reason) + + clientsMux.Lock() + for _, client := range clients { + client.conn.Close() + } + clients = make(map[net.Conn]*Client) + clientsMux.Unlock() + + memguard.Purge() + os.Exit(0) +} diff --git a/cmd/web-client/main.go b/cmd/web-client/main.go new file mode 100644 index 0000000..d5322a3 --- /dev/null +++ b/cmd/web-client/main.go @@ -0,0 +1,360 @@ +package main + +import ( + "crypto/rand" + _ "embed" + "encoding/hex" + "encoding/json" + "log" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +//go:embed template.html +var htmlTemplate string + +// Message types +const ( + MsgTypeHandshake = "handshake" + MsgTypePublic = "public" + MsgTypePrivate = "private" + MsgTypeSystem = "system" + MsgTypeUserJoin = "user_join" + MsgTypeUserLeave = "user_leave" + MsgTypeUsersList = "users_list" +) + +// User represents a connected user. +type User struct { + ID string + Name string + Avatar string + Fingerprint string + Conn *websocket.Conn + LastSeen time.Time + mu sync.Mutex +} + +// Message represents a chat message. +type Message struct { + Type string `json:"type"` + From string `json:"from,omitempty"` + FromName string `json:"from_name,omitempty"` + FromAvatar string `json:"from_avatar,omitempty"` + To string `json:"to,omitempty"` + Text string `json:"text,omitempty"` + Timestamp time.Time `json:"timestamp,omitempty"` +} + +// UserInfo for user list. +type UserInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Avatar string `json:"avatar"` + Status string `json:"status"` +} + +// Server represents the chat server. +type Server struct { + users map[string]*User + upgrader websocket.Upgrader + mu sync.RWMutex +} + +var server *Server + +func init() { + server = &Server{ + users: make(map[string]*User), + upgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + ReadBufferSize: 1024, + WriteBufferSize: 1024, + }, + } +} + +// NewUser creates a new user. +func NewUser(id, name, fingerprint string, conn *websocket.Conn) *User { + avatar := "" + if len(name) > 0 { + avatar = string(name[0]) + } + + return &User{ + ID: id, + Name: name, + Avatar: avatar, + Fingerprint: fingerprint, + Conn: conn, + LastSeen: time.Now(), + } +} + +// SendMessage sends a message to the user. +func (u *User) SendMessage(msg Message) error { + u.mu.Lock() + defer u.mu.Unlock() + return u.Conn.WriteJSON(msg) +} + +// BroadcastPublic broadcasts a message to all users except the sender. +func (s *Server) BroadcastPublic(msg Message) { + s.mu.RLock() + defer s.mu.RUnlock() + + for fingerprint, user := range s.users { + if fingerprint != msg.From { + go user.SendMessage(msg) + } + } +} + +// SendPrivate sends a private message to a specific user. +func (s *Server) SendPrivate(msg Message) error { + s.mu.RLock() + defer s.mu.RUnlock() + + recipient, exists := s.users[msg.To] + if !exists { + return nil + } + + return recipient.SendMessage(msg) +} + +// AddUser adds a user to the server. +func (s *Server) AddUser(user *User) { + s.mu.Lock() + s.users[user.Fingerprint] = user + s.mu.Unlock() + + log.Printf("User added: %s (fingerprint: %s, total: %d)", user.Name, user.Fingerprint, len(s.users)) + + joinMsg := Message{ + Type: MsgTypeUserJoin, + From: user.Fingerprint, + FromName: user.Name, + FromAvatar: user.Avatar, + Text: user.Name + " joined the chat", + Timestamp: time.Now(), + } + s.BroadcastPublic(joinMsg) + + s.SendUsersList(user) + s.BroadcastUsersList() +} + +// RemoveUser removes a user from the server. +func (s *Server) RemoveUser(fingerprint string) { + s.mu.Lock() + user, exists := s.users[fingerprint] + if exists { + delete(s.users, fingerprint) + } + s.mu.Unlock() + + if exists { + log.Printf("User removed: %s (fingerprint: %s, remaining: %d)", user.Name, fingerprint, len(s.users)) + + leaveMsg := Message{ + Type: MsgTypeUserLeave, + From: fingerprint, + FromName: user.Name, + Text: user.Name + " left the chat", + Timestamp: time.Now(), + } + s.BroadcastPublic(leaveMsg) + s.BroadcastUsersList() + } +} + +// SendUsersList sends the user list to a specific user. +func (s *Server) SendUsersList(user *User) { + s.mu.RLock() + defer s.mu.RUnlock() + + users := make([]UserInfo, 0, len(s.users)) + for _, u := range s.users { + if u.Fingerprint != user.Fingerprint { + users = append(users, UserInfo{ + ID: u.Fingerprint, + Name: u.Name, + Avatar: u.Avatar, + Status: "Encrypted", + }) + } + } + + usersData, _ := json.Marshal(map[string]interface{}{ + "type": MsgTypeUsersList, + "users": users, + }) + + user.mu.Lock() + user.Conn.WriteMessage(websocket.TextMessage, usersData) + user.mu.Unlock() +} + +// BroadcastUsersList broadcasts the user list to all users. +func (s *Server) BroadcastUsersList() { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, user := range s.users { + go s.SendUsersList(user) + } +} + +// HandleWebSocket handles WebSocket connections. +func HandleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := server.upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("WebSocket upgrade error: %v", err) + return + } + + var handshake struct { + Type string `json:"type"` + Name string `json:"name"` + ID string `json:"id"` + Fingerprint string `json:"fingerprint"` + } + + if err := conn.ReadJSON(&handshake); err != nil { + log.Printf("Handshake error: %v", err) + conn.Close() + return + } + + if handshake.Type != MsgTypeHandshake { + log.Printf("Invalid handshake type: %s", handshake.Type) + conn.Close() + return + } + + if handshake.Fingerprint == "" || len(handshake.Fingerprint) < 8 { + log.Printf("Invalid fingerprint") + conn.Close() + return + } + + server.mu.RLock() + _, exists := server.users[handshake.Fingerprint] + server.mu.RUnlock() + + if exists { + errorMsg := Message{ + Type: MsgTypeSystem, + Text: "Identity already connected. Only one session per identity allowed.", + } + conn.WriteJSON(errorMsg) + conn.Close() + return + } + + user := NewUser(handshake.ID, handshake.Name, handshake.Fingerprint, conn) + server.AddUser(user) + defer server.RemoveUser(user.Fingerprint) + + log.Printf("User connected: %s (%s)", user.Name, user.Fingerprint) + + welcomeMsg := Message{ + Type: MsgTypeSystem, + Text: "Welcome to NoshiTalk! Your identity is cryptographically protected.", + Timestamp: time.Now(), + } + user.SendMessage(welcomeMsg) + + for { + var msg Message + if err := conn.ReadJSON(&msg); err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("WebSocket error: %v", err) + } + break + } + + msg.From = user.Fingerprint + msg.FromName = user.Name + msg.FromAvatar = user.Avatar + msg.Timestamp = time.Now() + + switch msg.Type { + case MsgTypePublic: + server.BroadcastPublic(msg) + log.Printf("Public message from %s: %s", user.Name, msg.Text) + + case MsgTypePrivate: + if err := server.SendPrivate(msg); err != nil { + log.Printf("Private message error: %v", err) + } else { + log.Printf("Private message from %s to %s", user.Name, msg.To) + } + } + + user.LastSeen = time.Now() + } +} + +// HandleIndex serves the main HTML page. +func HandleIndex(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + + w.Write([]byte(htmlTemplate)) +} + +// generateChallenge generates a random challenge. +func generateChallenge() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// CleanupInactiveUsers removes inactive users periodically. +func (s *Server) CleanupInactiveUsers() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + s.mu.Lock() + now := time.Now() + for fingerprint, user := range s.users { + if now.Sub(user.LastSeen) > 5*time.Minute { + log.Printf("Removing inactive user: %s", user.Name) + delete(s.users, fingerprint) + user.Conn.Close() + } + } + s.mu.Unlock() + } +} + +func main() { + go server.CleanupInactiveUsers() + + http.HandleFunc("/", HandleIndex) + http.HandleFunc("/ws", HandleWebSocket) + + port := ":8080" + log.Printf("===========================================") + log.Printf(" NoshiTalk Web Server v2.0-refactored") + log.Printf("===========================================") + log.Printf(" Port: %s", port) + log.Printf(" WebSocket: ws://localhost%s/ws", port) + log.Printf(" Security: E2E Encrypted + Ephemeral") + log.Printf(" Storage: Zero (RAM only)") + log.Printf("===========================================") + + if err := http.ListenAndServe(port, nil); err != nil { + log.Fatal("Server error: ", err) + } +} diff --git a/cmd/web-client/template.html b/cmd/web-client/template.html new file mode 100644 index 0000000..22c6039 --- /dev/null +++ b/cmd/web-client/template.html @@ -0,0 +1,329 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>NoshiTalk - Anonymous Encrypted Chat</title> + <style> + * { margin: 0; padding: 0; box-sizing: border-box; } + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e0e0e0; height: 100vh; overflow: hidden; } + .header { background: linear-gradient(135deg, #1a1a1a 0%, #0f0f0f 100%); padding: 1rem 1.5rem; border-bottom: 2px solid #00f2fe; display: flex; justify-content: space-between; align-items: center; } + .logo { font-size: 1.3rem; font-weight: 700; color: #00f2fe; } + .header-controls { display: flex; gap: 1rem; align-items: center; } + .connection-status { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; } + .status-dot { width: 10px; height: 10px; border-radius: 50%; background: #51cf66; animation: pulse 2s infinite; } + .status-dot.disconnected { background: #ff6b6b; } + @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } + .panic-button { background: #ff6b6b; color: white; border: none; padding: 0.6rem 1.2rem; border-radius: 6px; font-weight: 700; cursor: pointer; } + .panic-button:hover { background: #ff5252; } + .logout-button { background: #333; color: #e0e0e0; border: none; padding: 0.6rem 1.2rem; border-radius: 6px; font-weight: 600; cursor: pointer; } + .login-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(10, 10, 10, 0.98); display: flex; align-items: center; justify-content: center; z-index: 1000; } + .login-box { background: #1a1a1a; border: 2px solid #00f2fe; border-radius: 8px; padding: 2.5rem; max-width: 500px; width: 90%; } + .login-box h2 { color: #00f2fe; margin-bottom: 0.5rem; text-align: center; } + .login-subtitle { text-align: center; color: #888; font-size: 0.9rem; margin-bottom: 2rem; } + .security-info { background: #0f0f0f; border-left: 3px solid #51cf66; padding: 1rem; margin-bottom: 1.5rem; font-size: 0.85rem; } + .warning-info { background: #2a1a1a; border-left: 3px solid #ff6b6b; padding: 1rem; margin-bottom: 1.5rem; font-size: 0.85rem; color: #ffb3b3; } + .tab-buttons { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; } + .tab-button { flex: 1; background: #0f0f0f; border: 1px solid #333; color: #888; padding: 0.8rem; border-radius: 6px; cursor: pointer; font-weight: 600; } + .tab-button.active { background: #00f2fe; color: #0a0a0a; border-color: #00f2fe; } + .tab-content { display: none; } + .tab-content.active { display: block; } + .login-box input { width: 100%; background: #0f0f0f; border: 1px solid #333; color: #e0e0e0; padding: 1rem; border-radius: 6px; font-size: 1rem; margin-bottom: 1rem; } + .login-box input:focus { outline: none; border-color: #00f2fe; } + .login-box button.primary { width: 100%; background: linear-gradient(135deg, #00f2fe 0%, #00d4e8 100%); color: #0a0a0a; border: none; padding: 1rem; border-radius: 6px; font-weight: 700; cursor: pointer; } + .login-box button.primary:hover { box-shadow: 0 5px 15px rgba(0, 242, 254, 0.4); } + .spinner { border: 3px solid #333; border-top: 3px solid #00f2fe; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 1rem auto; } + @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } + .identity-info { background: #0f0f0f; padding: 1rem; border-radius: 6px; margin-top: 1rem; font-size: 0.85rem; } + .identity-info .label { color: #888; margin-bottom: 0.3rem; } + .identity-info .value { color: #00f2fe; font-family: monospace; word-break: break-all; } + .save-keys-button { width: 100%; background: #333; color: #e0e0e0; border: 1px solid #00f2fe; padding: 0.8rem; border-radius: 6px; font-weight: 600; cursor: pointer; margin-top: 1rem; } + .container { display: flex; height: calc(100vh - 70px); } + .sidebar { width: 250px; background: #0f0f0f; border-right: 1px solid #333; display: flex; flex-direction: column; } + .events-panel { background: #1a1a1a; border-bottom: 1px solid #333; padding: 1rem; max-height: 150px; overflow-y: auto; } + .events-panel h3 { font-size: 0.85rem; color: #00f2fe; margin-bottom: 0.5rem; } + .event-item { font-size: 0.75rem; color: #888; padding: 0.3rem 0; border-bottom: 1px solid #222; } + .users-panel { flex: 1; padding: 1rem; overflow-y: auto; } + .users-panel h3 { font-size: 0.9rem; color: #00f2fe; margin-bottom: 1rem; } + .user-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; margin-bottom: 0.3rem; background: #1a1a1a; border-radius: 4px; cursor: pointer; } + .user-item:hover { background: #222; } + .user-avatar { width: 32px; height: 32px; border-radius: 50%; background: linear-gradient(135deg, #00f2fe 0%, #00d4e8 100%); display: flex; align-items: center; justify-content: center; font-weight: 700; color: #0a0a0a; } + .main-chat { flex: 1; display: flex; flex-direction: column; } + .chat-header { padding: 1rem 1.5rem; background: #0f0f0f; border-bottom: 1px solid #333; } + .chat-header h2 { font-size: 1.1rem; color: #00f2fe; } + .messages-area { flex: 1; padding: 1.5rem; overflow-y: auto; } + .message { display: flex; gap: 1rem; margin-bottom: 1.5rem; animation: fadeIn 0.3s; } + @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + .message-avatar { width: 40px; height: 40px; border-radius: 50%; background: linear-gradient(135deg, #00f2fe 0%, #00d4e8 100%); display: flex; align-items: center; justify-content: center; font-weight: 700; color: #0a0a0a; } + .message-content { flex: 1; } + .message-header { display: flex; gap: 0.5rem; margin-bottom: 0.3rem; } + .message-author { font-weight: 600; color: #00f2fe; } + .message-time { font-size: 0.75rem; color: #666; } + .message-text { color: #c0c0c0; line-height: 1.5; } + .input-area { padding: 1.5rem; background: #0f0f0f; border-top: 1px solid #333; } + .input-container { display: flex; gap: 1rem; } + .message-input { flex: 1; background: #1a1a1a; border: 1px solid #333; color: #e0e0e0; padding: 0.8rem; border-radius: 6px; font-size: 0.95rem; } + .message-input:focus { outline: none; border-color: #00f2fe; } + .send-button { background: linear-gradient(135deg, #00f2fe 0%, #00d4e8 100%); color: #0a0a0a; border: none; padding: 0.8rem 2rem; border-radius: 6px; font-weight: 600; cursor: pointer; } + .system-message { text-align: center; color: #666; font-size: 0.85rem; padding: 0.5rem; margin: 1rem 0; } + ::-webkit-scrollbar { width: 8px; } + ::-webkit-scrollbar-track { background: #0a0a0a; } + ::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; } + </style> +</head> +<body> + <div class="login-overlay" id="login-overlay"> + <div class="login-box"> + <h2>NoshiTalk Secure Entry</h2> + <p class="login-subtitle">Cryptographic Identity Required</p> + <div class="security-info"> + <strong>Key generation protects:</strong> + <ul style="margin: 0.5rem 0 0 1.5rem;"> + <li>Your IDENTITY (cryptographic fingerprint)</li> + <li>Your LOGIN (challenge-response auth)</li> + </ul> + </div> + <div class="warning-info"> + EPHEMERAL: All messages exist only in RAM. Close tab = everything deleted. + </div> + <div class="tab-buttons"> + <button class="tab-button active" onclick="switchLoginTab('new')">New Identity</button> + <button class="tab-button" onclick="switchLoginTab('load')">Load Identity</button> + </div> + <div id="new-identity-tab" class="tab-content active"> + <input type="text" id="username-input" placeholder="Choose your name..." autocomplete="off"> + <button class="primary" onclick="generateKeysAndConnect()" id="generate-btn">Generate Keys & Enter</button> + <div id="generating-status" style="display: none;"> + <div style="text-align: center; padding: 1rem; color: #00f2fe;"><div class="spinner"></div>Generating keys...</div> + </div> + <div id="identity-display" style="display: none;"> + <div class="identity-info"> + <div class="label">Your Fingerprint:</div> + <div class="value" id="fingerprint-display"></div> + </div> + <button class="save-keys-button" onclick="saveKeys()">Save Keys (.noshikey)</button> + <button class="primary" onclick="connectToServer()" style="margin-top: 1rem;">Enter Chat</button> + </div> + </div> + <div id="load-identity-tab" class="tab-content"> + <input type="file" id="key-file-input" accept=".noshikey"> + <button class="primary" onclick="loadKeysAndConnect()">Load Keys & Enter</button> + </div> + </div> + </div> + <header class="header" style="display: none;" id="main-header"> + <div class="logo">NoshiTalk</div> + <div class="header-controls"> + <div class="connection-status"> + <span class="status-dot" id="status-dot"></span> + <span id="status-text">Connecting...</span> + </div> + <button class="logout-button" onclick="logout()">Logout</button> + <button class="panic-button" onclick="panic()">PANIC</button> + </div> + </header> + <div class="container" style="display: none;" id="main-container"> + <aside class="sidebar"> + <div class="events-panel"> + <h3>Events</h3> + <div id="events-list"></div> + </div> + <div class="users-panel"> + <h3>Online <span id="user-count">(0)</span></h3> + <div id="users-list"></div> + </div> + </aside> + <main class="main-chat"> + <div class="chat-header"> + <h2>Public Chat</h2> + <p style="font-size: 0.8rem; color: #888;">E2E Encrypted - Ephemeral - Zero Storage</p> + </div> + <div class="messages-area" id="messages-area"></div> + <div class="input-area"> + <div class="input-container"> + <input type="text" class="message-input" id="message-input" placeholder="Type your message..." autocomplete="off"> + <button class="send-button" onclick="sendMessage()">Send</button> + </div> + </div> + </main> + </div> + <script src="https://cdnjs.cloudflare.com/ajax/libs/js-sha256/0.9.0/sha256.min.js"></script> + <script> + var ws = null; + var state = { currentUser: '', fingerprint: '', users: [], keys: null }; + + function switchLoginTab(tab) { + document.querySelectorAll('.tab-button').forEach(function(btn) { btn.classList.remove('active'); }); + document.querySelectorAll('.tab-content').forEach(function(content) { content.classList.remove('active'); }); + if (tab === 'new') { + document.querySelector('.tab-button').classList.add('active'); + document.getElementById('new-identity-tab').classList.add('active'); + } else { + document.querySelectorAll('.tab-button')[1].classList.add('active'); + document.getElementById('load-identity-tab').classList.add('active'); + } + } + + async function generateKeysAndConnect() { + var username = document.getElementById('username-input').value.trim(); + if (!username) { alert('Please enter a name'); return; } + document.getElementById('generate-btn').disabled = true; + document.getElementById('generating-status').style.display = 'block'; + try { + var signKeyPair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); + var encryptKeyPair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveKey"]); + var pubKey = await crypto.subtle.exportKey("raw", signKeyPair.publicKey); + var fingerprint = sha256(new Uint8Array(pubKey)).substring(0, 16); + state.keys = { sign: signKeyPair, encrypt: encryptKeyPair }; + state.fingerprint = fingerprint; + state.currentUser = username; + document.getElementById('generating-status').style.display = 'none'; + document.getElementById('identity-display').style.display = 'block'; + document.getElementById('fingerprint-display').textContent = fingerprint; + setTimeout(connectToServer, 1000); + } catch (e) { + alert('Key generation failed'); + document.getElementById('generate-btn').disabled = false; + document.getElementById('generating-status').style.display = 'none'; + } + } + + async function saveKeys() { + try { + var signPriv = await crypto.subtle.exportKey("jwk", state.keys.sign.privateKey); + var signPub = await crypto.subtle.exportKey("jwk", state.keys.sign.publicKey); + var encPriv = await crypto.subtle.exportKey("jwk", state.keys.encrypt.privateKey); + var encPub = await crypto.subtle.exportKey("jwk", state.keys.encrypt.publicKey); + var data = { version: "1.0", name: state.currentUser, fingerprint: state.fingerprint, signKeyPair: { privateKey: signPriv, publicKey: signPub }, encryptKeyPair: { privateKey: encPriv, publicKey: encPub } }; + var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = state.currentUser.toLowerCase() + '.noshikey'; + a.click(); + } catch (e) { alert('Failed to save keys'); } + } + + async function loadKeysAndConnect() { + var file = document.getElementById('key-file-input').files[0]; + if (!file) { alert('Please select a file'); return; } + try { + var data = JSON.parse(await file.text()); + var signPriv = await crypto.subtle.importKey("jwk", data.signKeyPair.privateKey, { name: "ECDSA", namedCurve: "P-256" }, true, ["sign"]); + var signPub = await crypto.subtle.importKey("jwk", data.signKeyPair.publicKey, { name: "ECDSA", namedCurve: "P-256" }, true, ["verify"]); + var encPriv = await crypto.subtle.importKey("jwk", data.encryptKeyPair.privateKey, { name: "ECDH", namedCurve: "P-256" }, true, ["deriveKey"]); + var encPub = await crypto.subtle.importKey("jwk", data.encryptKeyPair.publicKey, { name: "ECDH", namedCurve: "P-256" }, true, []); + state.keys = { sign: { privateKey: signPriv, publicKey: signPub }, encrypt: { privateKey: encPriv, publicKey: encPub } }; + state.fingerprint = data.fingerprint; + state.currentUser = data.name; + connectToServer(); + } catch (e) { alert('Failed to load keys'); } + } + + function connectToServer() { + document.getElementById('login-overlay').style.display = 'none'; + document.getElementById('main-header').style.display = 'flex'; + document.getElementById('main-container').style.display = 'flex'; + var protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + ws = new WebSocket(protocol + '//' + location.host + '/ws'); + ws.onopen = function() { + updateStatus(true); + ws.send(JSON.stringify({ type: 'handshake', name: state.currentUser, id: state.fingerprint, fingerprint: state.fingerprint })); + addEvent('Connected'); + }; + ws.onmessage = function(e) { handleMessage(JSON.parse(e.data)); }; + ws.onerror = function() { updateStatus(false); }; + ws.onclose = function() { updateStatus(false); addEvent('Disconnected'); }; + } + + function panic() { + if (!confirm('PANIC MODE - Destroy all keys and disconnect?')) return; + state = { currentUser: '', fingerprint: '', users: [], keys: null }; + if (ws) { ws.close(); ws = null; } + document.getElementById('main-header').style.display = 'none'; + document.getElementById('main-container').style.display = 'none'; + document.getElementById('login-overlay').style.display = 'flex'; + document.getElementById('identity-display').style.display = 'none'; + document.getElementById('username-input').value = ''; + document.getElementById('generate-btn').disabled = false; + document.getElementById('messages-area').innerHTML = ''; + console.clear(); + } + + function logout() { + if (!confirm('Logout?')) return; + if (ws) { ws.close(); ws = null; } + document.getElementById('main-header').style.display = 'none'; + document.getElementById('main-container').style.display = 'none'; + document.getElementById('login-overlay').style.display = 'flex'; + switchLoginTab('load'); + } + + function updateStatus(connected) { + var dot = document.getElementById('status-dot'); + var text = document.getElementById('status-text'); + dot.className = connected ? 'status-dot' : 'status-dot disconnected'; + text.textContent = connected ? 'Connected' : 'Disconnected'; + } + + function handleMessage(msg) { + switch(msg.type) { + case 'users_list': state.users = msg.users; renderUsers(); break; + case 'public': addChatMessage(msg.from_name, msg.text, msg.from_avatar); break; + case 'private': addChatMessage(msg.from_name + ' (PM)', msg.text, msg.from_avatar); break; + case 'system': addSystemMessage(msg.text); break; + case 'user_join': case 'user_leave': addEvent(msg.text); break; + } + } + + function addEvent(text) { + var el = document.getElementById('events-list'); + var time = new Date().toLocaleTimeString().slice(0,5); + el.innerHTML += '<div class="event-item">' + time + ' - ' + escapeHtml(text) + '</div>'; + while (el.children.length > 10) el.removeChild(el.firstChild); + el.scrollTop = el.scrollHeight; + } + + function renderUsers() { + var el = document.getElementById('users-list'); + el.innerHTML = ''; + state.users.forEach(function(u) { + el.innerHTML += '<div class="user-item" onclick="startPM(\'' + u.id + '\')"><div class="user-avatar">' + escapeHtml(u.avatar) + '</div><div>' + escapeHtml(u.name) + '</div></div>'; + }); + document.getElementById('user-count').textContent = '(' + state.users.length + ')'; + } + + function startPM(id) { document.getElementById('message-input').value = '/pm ' + id + ' '; document.getElementById('message-input').focus(); } + + function sendMessage() { + var input = document.getElementById('message-input'); + var text = input.value.trim(); + if (!text || !ws) return; + var msg = { type: 'public', text: text }; + if (text.startsWith('/pm ')) { + var parts = text.slice(4).split(' '); + msg = { type: 'private', to: parts[0], text: parts.slice(1).join(' ') }; + } + ws.send(JSON.stringify(msg)); + addChatMessage(state.currentUser, text, state.currentUser[0]); + input.value = ''; + } + + function addChatMessage(author, text, avatar) { + var el = document.getElementById('messages-area'); + var time = new Date().toLocaleTimeString().slice(0,5); + el.innerHTML += '<div class="message"><div class="message-avatar">' + escapeHtml(avatar || '?') + '</div><div class="message-content"><div class="message-header"><span class="message-author">' + escapeHtml(author) + '</span><span class="message-time">' + time + '</span></div><div class="message-text">' + escapeHtml(text) + '</div></div></div>'; + el.scrollTop = el.scrollHeight; + } + + function addSystemMessage(text) { + var el = document.getElementById('messages-area'); + el.innerHTML += '<div class="system-message">' + escapeHtml(text) + '</div>'; + el.scrollTop = el.scrollHeight; + } + + function escapeHtml(text) { var d = document.createElement('div'); d.textContent = text; return d.innerHTML; } + + document.getElementById('message-input').addEventListener('keypress', function(e) { if (e.key === 'Enter') sendMessage(); }); + </script> +</body> +</html> @@ -5,11 +5,16 @@ go 1.24.0 toolchain go1.24.7 require ( - fyne.io/fyne/v2 v2.6.3 // indirect + fyne.io/fyne/v2 v2.6.3 + github.com/awnumar/memguard v0.23.0 + github.com/gorilla/websocket v1.5.3 + golang.org/x/net v0.44.0 +) + +require ( fyne.io/systray v1.11.0 // indirect github.com/BurntSushi/toml v1.4.0 // indirect github.com/awnumar/memcall v0.4.0 // indirect - github.com/awnumar/memguard v0.23.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fredbi/uri v1.1.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -36,7 +41,6 @@ require ( github.com/yuin/goldmark v1.7.8 // indirect golang.org/x/crypto v0.42.0 // indirect golang.org/x/image v0.24.0 // indirect - golang.org/x/net v0.44.0 // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.29.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect 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 +} |
