summaryrefslogtreecommitdiffstats
path: root/cmd
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
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')
-rw-r--r--cmd/cli-client/main.go529
-rw-r--r--cmd/gui-client/main.go537
-rw-r--r--cmd/server/main.go722
-rw-r--r--cmd/web-client/main.go360
-rw-r--r--cmd/web-client/template.html329
5 files changed, 2477 insertions, 0 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>