summaryrefslogtreecommitdiffstats
path: root/cmd/gui-client
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2025-11-23 10:07:55 +0000
committerClaude <noreply@anthropic.com>2025-11-23 10:07:55 +0000
commit69a75acea56bbff39fa1c81d4813ebcb4a617f06 (patch)
treeee1239320191bae0df39bdbe0d058069c27a0107 /cmd/gui-client
parent9b4caf3e5ffad219ea55ea6db7ad19940c5f83d9 (diff)
downloadnoshitalk-69a75acea56bbff39fa1c81d4813ebcb4a617f06.tar.gz
noshitalk-69a75acea56bbff39fa1c81d4813ebcb4a617f06.tar.xz
noshitalk-69a75acea56bbff39fa1c81d4813ebcb4a617f06.zip
Refactor: modular architecture with shared packages
Major code reorganization to eliminate duplication and improve maintainability: - Created pkg/crypto: shared cryptographic functions (padding, AES-GCM, ECDH, HMAC auth) - Created pkg/protocol: unified message structures and JSON serialization - Created pkg/identity: persistent identity management (load/save/generate) - Created pkg/tor: Tor SOCKS5 proxy connection utilities - Refactored server to use shared packages (cmd/server) - Refactored CLI client with shared packages (cmd/cli-client) - Refactored GUI client with shared packages (cmd/gui-client) - Refactored web client with embedded HTML template (cmd/web-client) Security improvements: - Replaced math/rand with crypto/rand for randomDelay() - Added proper error handling for crypto operations Code reduction: eliminated ~500+ lines of duplicated code
Diffstat (limited to 'cmd/gui-client')
-rw-r--r--cmd/gui-client/main.go537
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)
+}