summaryrefslogtreecommitdiffstats
path: root/cmd/cli-client/main.go
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/cli-client/main.go')
-rw-r--r--cmd/cli-client/main.go529
1 files changed, 529 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)
+ }
+}