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 /cmd/gui-client | |
| 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
Diffstat (limited to 'cmd/gui-client')
| -rw-r--r-- | cmd/gui-client/main.go | 537 |
1 files changed, 537 insertions, 0 deletions
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) +} |
