package main import ( "bufio" "crypto/aes" "crypto/cipher" "crypto/ecdh" "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "encoding/binary" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net" "os" "path/filepath" "sort" "strings" "sync" "time" "github.com/integrii/flaggy" "github.com/jroimartin/gocui" "golang.org/x/crypto/hkdf" _ "modernc.org/sqlite" ) const ( protocolVersion = 1 msgTypeHello = "hello" msgTypeMsg = "msg" msgTypeError = "error" maxFrameSize = 32 * 1024 * 1024 ) type wireEnvelope struct { Type string `json:"type"` Body json.RawMessage `json:"body"` } type helloBody struct { Version int `json:"version"` SenderFingerprint string `json:"sender_fingerprint"` SenderPubKey string `json:"sender_pubkey"` } type msgBody struct { SenderFingerprint string `json:"sender_fingerprint"` SenderPubKey string `json:"sender_pubkey"` Recipient string `json:"recipient"` EphemeralPubKey string `json:"ephemeral_pubkey"` Nonce string `json:"nonce"` Ciphertext string `json:"ciphertext"` IsBinary bool `json:"is_binary"` Filename string `json:"filename,omitempty"` } type errorBody struct { Message string `json:"message"` } type messageStore struct { DB *sql.DB } type contact struct { Name string `json:"name"` Fingerprint string `json:"fingerprint"` PubKey string `json:"pubkey"` LastUsed string `json:"last_used"` } type contactStore struct { Path string mu sync.Mutex Contacts []contact } type configPaths struct { ConfigDir string DataDir string } type clientConn struct { conn net.Conn mu sync.Mutex } func main() { var mode string var listenAddr string var connectAddr string var keyPath string var peerFingerprint string var peerPubKey string var configDir string var dataDir string var showIdentity bool var selfTest bool var tui bool flaggy.SetName("whspbrd") flaggy.SetDescription("Simple end-to-end encrypted terminal chat") flaggy.String(&mode, "m", "mode", "server or client") flaggy.String(&listenAddr, "l", "listen", "listen address for server") flaggy.String(&connectAddr, "c", "connect", "server address for client") flaggy.String(&keyPath, "k", "key", "path to long-term private key (optional)") flaggy.String(&peerFingerprint, "p", "peer", "recipient fingerprint (client)") flaggy.String(&peerPubKey, "", "peer-key", "recipient pubkey (base64) to trust") flaggy.String(&configDir, "", "config", "override config directory") flaggy.String(&dataDir, "", "data", "override data directory") flaggy.Bool(&showIdentity, "", "show-identity", "print fingerprint and public key") flaggy.Bool(&selfTest, "", "selftest", "run local relay self-test") flaggy.Bool(&tui, "", "tui", "start interactive TUI client") flaggy.Parse() if selfTest { runSelfTest() return } if mode == "" { fatal("mode is required: server or client") } paths, err := resolvePaths(configDir, dataDir) if err != nil { fatal(err.Error()) } switch mode { case "server": if listenAddr == "" { listenAddr = ":9090" } runServer(listenAddr) case "client": if tui { runTUI(connectAddr, peerFingerprint, peerPubKey, keyPath, paths, showIdentity) return } runClient(connectAddr, peerFingerprint, peerPubKey, keyPath, paths, showIdentity) default: fatal("mode must be server or client") } } func runServer(listenAddr string) { ln, err := net.Listen("tcp", listenAddr) if err != nil { fatal(fmt.Sprintf("listen failed: %v", err)) } defer ln.Close() fmt.Printf("whspbrd server listening on %s\n", listenAddr) var ( mu sync.Mutex sessions = make(map[string]*clientConn) ) for { conn, err := ln.Accept() if err != nil { fmt.Printf("accept error: %v\n", err) continue } remote := conn.RemoteAddr().String() fmt.Printf("client connected: %s\n", remote) go func(c net.Conn) { defer c.Close() fp, err := readHelloAndRegister(c, &mu, sessions) if err != nil { sendError(c, err.Error()) fmt.Printf("handshake failed from %s: %v\n", remote, err) return } fmt.Printf("client registered: %s (%s)\n", fp, remote) defer func() { mu.Lock() delete(sessions, fp) mu.Unlock() fmt.Printf("client disconnected: %s (%s)\n", fp, remote) }() for { env, err := readEnvelope(c) if err != nil { if !errors.Is(err, io.EOF) { fmt.Printf("read error: %v\n", err) } return } if env.Type != msgTypeMsg { sendError(c, "unsupported message type") continue } var body msgBody if err := json.Unmarshal(env.Body, &body); err != nil { sendError(c, "invalid msg body") continue } mu.Lock() target, ok := sessions[body.Recipient] mu.Unlock() if !ok { sendError(c, "recipient not connected") fmt.Printf("relay miss: from %s to %s\n", fp, body.Recipient) continue } target.mu.Lock() err = writeEnvelope(target.conn, env) target.mu.Unlock() if err != nil { fmt.Printf("relay error: %v\n", err) continue } fmt.Printf("relayed message: %s -> %s\n", fp, body.Recipient) } }(conn) } } func runClient(connectAddr, peerFingerprint, peerPubKey, keyOverride string, paths configPaths, showIdentity bool) { if err := ensureDir(paths.ConfigDir); err != nil { fatal(err.Error()) } if err := ensureDir(paths.DataDir); err != nil { fatal(err.Error()) } keyPath := keyOverride if keyPath == "" { keyPath = filepath.Join(paths.ConfigDir, "identity.key") } priv, pub, err := loadOrCreateKey(keyPath) if err != nil { fatal(err.Error()) } fingerprint := fingerprintFor(pub) pubB64 := base64.StdEncoding.EncodeToString(pub.Bytes()) if showIdentity { fmt.Printf("fingerprint: %s\n", fingerprint) fmt.Printf("pubkey: %s\n", pubB64) return } if connectAddr == "" { fatal("client requires --connect") } if peerFingerprint == "" { fatal("client requires --peer") } contacts, err := loadContactStore(filepath.Join(paths.ConfigDir, "contacts.json")) if err != nil { fatal(err.Error()) } store, err := openMessageStore(filepath.Join(paths.DataDir, "messages.db")) if err != nil { fatal(err.Error()) } defer store.Close() if peerPubKey != "" { fp, err := contacts.add(nameFromFingerprint(peerFingerprint), normalizePubKey(peerPubKey)) if err != nil { fatal(err.Error()) } if peerFingerprint != "" && fp != peerFingerprint { fatal("peer fingerprint does not match provided key") } fmt.Printf("trusted peer %s\n", fp) } conn, err := net.Dial("tcp", connectAddr) if err != nil { fatal(fmt.Sprintf("connect failed: %v", err)) } defer conn.Close() if err := sendHello(conn, fingerprint, pub); err != nil { fatal(err.Error()) } fmt.Printf("connected. you are %s\n", fingerprint) recvDone := make(chan struct{}) go func() { defer close(recvDone) for { env, err := readEnvelope(conn) if err != nil { return } if env.Type == msgTypeError { var eb errorBody _ = json.Unmarshal(env.Body, &eb) if eb.Message != "" { fmt.Printf("server error: %s\n", eb.Message) } continue } if env.Type != msgTypeMsg { continue } var body msgBody if err := json.Unmarshal(env.Body, &body); err != nil { continue } plaintext, err := decryptMessage(priv, contacts, body) if err != nil { fmt.Printf("decrypt failed: %v\n", err) continue } if body.IsBinary { outPath := filepath.Join(paths.DataDir, fmt.Sprintf("recv_%d_%s", time.Now().UnixNano(), safeFilename(body.Filename))) if err := os.WriteFile(outPath, plaintext, 0o600); err != nil { fmt.Printf("write file failed: %v\n", err) continue } _ = store.SaveMessage(messageRecord{ Direction: "in", Peer: body.SenderFingerprint, IsBinary: true, Filename: body.Filename, Body: outPath, CreatedAt: time.Now().UTC(), }) fmt.Printf("received file from %s: %s\n", body.SenderFingerprint, outPath) } else { _ = store.SaveMessage(messageRecord{ Direction: "in", Peer: body.SenderFingerprint, IsBinary: false, Body: string(plaintext), CreatedAt: time.Now().UTC(), }) fmt.Printf("%s: %s\n", body.SenderFingerprint, string(plaintext)) } } }() reader := bufio.NewReader(os.Stdin) for { fmt.Print("> ") line, err := reader.ReadString('\n') if err != nil { break } line = strings.TrimSpace(line) if line == "" { continue } if line == "/quit" { break } if line == "/whoami" { fmt.Printf("%s\n", fingerprint) continue } if line == "/pubkey" { fmt.Printf("%s\n", pubB64) continue } if line == "/list" { if err := printMessages(store, 50); err != nil { fmt.Printf("list failed: %v\n", err) } continue } if strings.HasPrefix(line, "/trust ") { value := strings.TrimSpace(strings.TrimPrefix(line, "/trust ")) if value == "" { fmt.Println("usage: /trust ") continue } fp, err := contacts.add(nameFromFingerprint(""), normalizePubKey(value)) if err != nil { fmt.Printf("trust failed: %v\n", err) continue } fmt.Printf("trusted peer %s\n", fp) continue } if strings.HasPrefix(line, "/sendfile ") { path := strings.TrimSpace(strings.TrimPrefix(line, "/sendfile ")) if path == "" { fmt.Println("usage: /sendfile ") continue } payload, filename, err := readFilePayload(path) if err != nil { fmt.Printf("file read failed: %v\n", err) continue } recipientPubKey, err := contacts.getPubKey(peerFingerprint) if err != nil { fmt.Println(err.Error()) continue } msg, err := encryptMessage(recipientPubKey, fingerprint, pubB64, peerFingerprint, payload, true, filename) if err != nil { fmt.Printf("encrypt failed: %v\n", err) continue } if err := writeEnvelope(conn, msg); err != nil { fmt.Printf("send failed: %v\n", err) continue } _ = store.SaveMessage(messageRecord{ Direction: "out", Peer: peerFingerprint, IsBinary: true, Filename: filename, Body: path, CreatedAt: time.Now().UTC(), }) fmt.Printf("sent file: %s\n", filename) continue } recipientPubKey, err := contacts.getPubKey(peerFingerprint) if err != nil { fmt.Println(err.Error()) continue } msg, err := encryptMessage(recipientPubKey, fingerprint, pubB64, peerFingerprint, []byte(line), false, "") if err != nil { fmt.Printf("encrypt failed: %v\n", err) continue } if err := writeEnvelope(conn, msg); err != nil { fmt.Printf("send failed: %v\n", err) continue } _ = store.SaveMessage(messageRecord{ Direction: "out", Peer: peerFingerprint, IsBinary: false, Body: line, CreatedAt: time.Now().UTC(), }) } conn.Close() <-recvDone } func sendHello(conn net.Conn, fingerprint string, pub *ecdh.PublicKey) error { hello := helloBody{ Version: protocolVersion, SenderFingerprint: fingerprint, SenderPubKey: base64.StdEncoding.EncodeToString(pub.Bytes()), } env, err := wrapEnvelope(msgTypeHello, hello) if err != nil { return err } return writeEnvelope(conn, env) } func readHelloAndRegister(conn net.Conn, mu *sync.Mutex, sessions map[string]*clientConn) (string, error) { env, err := readEnvelope(conn) if err != nil { return "", err } if env.Type != msgTypeHello { return "", errors.New("expected hello") } var body helloBody if err := json.Unmarshal(env.Body, &body); err != nil { return "", errors.New("invalid hello body") } if body.Version != protocolVersion { return "", errors.New("protocol version mismatch") } if body.SenderFingerprint == "" || body.SenderPubKey == "" { return "", errors.New("invalid hello fields") } pubBytes, err := base64.StdEncoding.DecodeString(body.SenderPubKey) if err != nil { return "", errors.New("invalid sender pubkey") } pub, err := ecdh.X25519().NewPublicKey(pubBytes) if err != nil { return "", errors.New("invalid sender pubkey") } if fingerprintFor(pub) != body.SenderFingerprint { return "", errors.New("sender fingerprint mismatch") } mu.Lock() if _, exists := sessions[body.SenderFingerprint]; exists { mu.Unlock() return "", errors.New("sender already connected") } sessions[body.SenderFingerprint] = &clientConn{conn: conn} mu.Unlock() return body.SenderFingerprint, nil } func wrapEnvelope(kind string, body any) (wireEnvelope, error) { payload, err := json.Marshal(body) if err != nil { return wireEnvelope{}, err } return wireEnvelope{Type: kind, Body: payload}, nil } func readEnvelope(r io.Reader) (wireEnvelope, error) { var lenBuf [4]byte if _, err := io.ReadFull(r, lenBuf[:]); err != nil { return wireEnvelope{}, err } n := binary.BigEndian.Uint32(lenBuf[:]) if n == 0 || n > maxFrameSize { return wireEnvelope{}, errors.New("invalid frame size") } data := make([]byte, n) if _, err := io.ReadFull(r, data); err != nil { return wireEnvelope{}, err } var env wireEnvelope if err := json.Unmarshal(data, &env); err != nil { return wireEnvelope{}, err } return env, nil } func writeEnvelope(w io.Writer, env wireEnvelope) error { data, err := json.Marshal(env) if err != nil { return err } if len(data) > maxFrameSize { return errors.New("frame too large") } var lenBuf [4]byte binary.BigEndian.PutUint32(lenBuf[:], uint32(len(data))) if _, err := w.Write(lenBuf[:]); err != nil { return err } _, err = w.Write(data) return err } func encryptMessage(recipientPubKey string, senderFingerprint, senderPubKey, recipient string, plaintext []byte, isBinary bool, filename string) (wireEnvelope, error) { recipientBytes, err := base64.StdEncoding.DecodeString(recipientPubKey) if err != nil { return wireEnvelope{}, err } recipientPub, err := ecdh.X25519().NewPublicKey(recipientBytes) if err != nil { return wireEnvelope{}, err } ephPriv, ephPub, err := generateKeypair() if err != nil { return wireEnvelope{}, err } shared, err := ephPriv.ECDH(recipientPub) if err != nil { return wireEnvelope{}, err } aead, err := deriveAEAD(shared, senderFingerprint, recipient) if err != nil { return wireEnvelope{}, err } nonce := make([]byte, aead.NonceSize()) if _, err := rand.Read(nonce); err != nil { return wireEnvelope{}, err } adata := aad(senderFingerprint, recipient) ciphertext := aead.Seal(nil, nonce, plaintext, adata) body := msgBody{ SenderFingerprint: senderFingerprint, SenderPubKey: senderPubKey, Recipient: recipient, EphemeralPubKey: base64.StdEncoding.EncodeToString(ephPub.Bytes()), Nonce: base64.StdEncoding.EncodeToString(nonce), Ciphertext: base64.StdEncoding.EncodeToString(ciphertext), IsBinary: isBinary, Filename: filename, } return wrapEnvelope(msgTypeMsg, body) } func decryptMessage(priv *ecdh.PrivateKey, contacts *contactStore, body msgBody) ([]byte, error) { if body.SenderPubKey == "" { return nil, errors.New("missing sender pubkey") } senderPubBytes, err := base64.StdEncoding.DecodeString(body.SenderPubKey) if err != nil { return nil, err } senderPub, err := ecdh.X25519().NewPublicKey(senderPubBytes) if err != nil { return nil, err } if fingerprintFor(senderPub) != body.SenderFingerprint { return nil, errors.New("sender fingerprint mismatch") } if err := contacts.verifyOrAdd(body.SenderFingerprint, body.SenderPubKey); err != nil { return nil, err } ephBytes, err := base64.StdEncoding.DecodeString(body.EphemeralPubKey) if err != nil { return nil, err } ephPub, err := ecdh.X25519().NewPublicKey(ephBytes) if err != nil { return nil, err } shared, err := priv.ECDH(ephPub) if err != nil { return nil, err } aead, err := deriveAEAD(shared, body.SenderFingerprint, body.Recipient) if err != nil { return nil, err } nonce, err := base64.StdEncoding.DecodeString(body.Nonce) if err != nil { return nil, err } ciphertext, err := base64.StdEncoding.DecodeString(body.Ciphertext) if err != nil { return nil, err } return aead.Open(nil, nonce, ciphertext, aad(body.SenderFingerprint, body.Recipient)) } func deriveAEAD(shared []byte, sender, recipient string) (cipher.AEAD, error) { info := []byte("whspbrd-msg-v1|" + sender + "|" + recipient) hkdfReader := hkdf.New(sha256.New, shared, nil, info) key := make([]byte, 32) if _, err := io.ReadFull(hkdfReader, key); err != nil { return nil, err } block, err := aes.NewCipher(key) if err != nil { return nil, err } return cipher.NewGCM(block) } func aad(sender, recipient string) []byte { return []byte(sender + "|" + recipient) } func generateKeypair() (*ecdh.PrivateKey, *ecdh.PublicKey, error) { priv, err := ecdh.X25519().GenerateKey(rand.Reader) if err != nil { return nil, nil, err } pub := priv.PublicKey() return priv, pub, nil } func fingerprintFor(pub *ecdh.PublicKey) string { sum := sha256.Sum256(pub.Bytes()) return hex.EncodeToString(sum[:]) } func loadOrCreateKey(path string) (*ecdh.PrivateKey, *ecdh.PublicKey, error) { data, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { if err := ensureDir(filepath.Dir(path)); err != nil { return nil, nil, err } priv, pub, err := generateKeypair() if err != nil { return nil, nil, err } if err := os.WriteFile(path, priv.Bytes(), 0o600); err != nil { return nil, nil, err } return priv, pub, nil } return nil, nil, err } priv, err := ecdh.X25519().NewPrivateKey(data) if err != nil { return nil, nil, errors.New("invalid private key") } return priv, priv.PublicKey(), nil } func resolvePaths(configOverride, dataOverride string) (configPaths, error) { var paths configPaths if configOverride != "" { paths.ConfigDir = configOverride } else { cfg, err := os.UserConfigDir() if err != nil { return paths, err } paths.ConfigDir = filepath.Join(cfg, "whspbrd") } if dataOverride != "" { paths.DataDir = dataOverride } else { dataHome := os.Getenv("XDG_DATA_HOME") if dataHome == "" { home, err := os.UserHomeDir() if err != nil { return paths, err } dataHome = filepath.Join(home, ".local", "share") } paths.DataDir = filepath.Join(dataHome, "whspbrd") } return paths, nil } func ensureDir(path string) error { return os.MkdirAll(path, 0o700) } func readFilePayload(path string) ([]byte, string, error) { data, err := os.ReadFile(path) if err != nil { return nil, "", err } return data, filepath.Base(path), nil } func safeFilename(name string) string { cleaned := filepath.Base(name) cleaned = strings.ReplaceAll(cleaned, string(os.PathSeparator), "_") if cleaned == "." || cleaned == "" { return "file" } return cleaned } func sendError(conn net.Conn, msg string) { env, err := wrapEnvelope(msgTypeError, errorBody{Message: msg}) if err != nil { return } _ = writeEnvelope(conn, env) } func runSelfTest() { fmt.Println("running selftest...") ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { fatal(err.Error()) } addr := ln.Addr().String() ln.Close() serverDone := make(chan struct{}) go func() { defer close(serverDone) runServer(addr) }() // wait for server to start deadline := time.Now().Add(2 * time.Second) for { conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) if err == nil { _ = conn.Close() break } if time.Now().After(deadline) { fatal("selftest: server did not start") } time.Sleep(50 * time.Millisecond) } // create two in-memory identities _, pubA, err := generateKeypair() if err != nil { fatal(err.Error()) } privB, pubB, err := generateKeypair() if err != nil { fatal(err.Error()) } pubAB64 := base64.StdEncoding.EncodeToString(pubA.Bytes()) pubBB64 := base64.StdEncoding.EncodeToString(pubB.Bytes()) peerA := &contactStore{} peerB := &contactStore{} _, _ = peerA.add("peerb", pubBB64) _, _ = peerB.add("peera", pubAB64) connA, err := net.Dial("tcp", addr) if err != nil { fatal(err.Error()) } defer connA.Close() connB, err := net.Dial("tcp", addr) if err != nil { fatal(err.Error()) } defer connB.Close() fpA := fingerprintFor(pubA) fpB := fingerprintFor(pubB) if err := sendHello(connA, fpA, pubA); err != nil { fatal(err.Error()) } if err := sendHello(connB, fpB, pubB); err != nil { fatal(err.Error()) } msgText := []byte("selftest: hello") env, err := encryptMessage(pubBB64, fpA, pubAB64, fpB, msgText, false, "") if err != nil { fatal(err.Error()) } if err := writeEnvelope(connA, env); err != nil { fatal(err.Error()) } // read until B gets message connB.SetReadDeadline(time.Now().Add(2 * time.Second)) for { got, err := readEnvelope(connB) if err != nil { fatal("selftest: no message received") } if got.Type != msgTypeMsg { continue } var body msgBody if err := json.Unmarshal(got.Body, &body); err != nil { fatal("selftest: invalid message body") } plaintext, err := decryptMessage(privB, peerB, body) if err != nil { fatal("selftest: decrypt failed") } if string(plaintext) != string(msgText) { fatal("selftest: message mismatch") } break } fmt.Println("selftest OK") } type incomingEvent struct { Peer string Body string IsBinary bool Filename string CreatedAt time.Time } type tuiState struct { g *gocui.Gui contacts *contactStore store *messageStore conn net.Conn priv *ecdh.PrivateKey pub *ecdh.PublicKey fingerprint string pubB64 string selectedIdx int contactList []contact recvCh chan incomingEvent statusLine string currentPeer string inputEd *inputEditor logger *tuiLogger chatAutoscroll bool peerLastSeen map[string]time.Time shutdownOnce sync.Once } type inputEditor struct { state *tuiState } func (e *inputEditor) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { if e == nil || e.state == nil { gocui.DefaultEditor.Edit(v, key, ch, mod) return } if mod == gocui.ModNone { if key == gocui.KeyCtrlU || ch == 21 { e.state.logf("input editor ctrl+u key=%v ch=%d view=%s", key, ch, viewName(v)) _ = e.state.scrollChatPageUpGlobal(e.state.g, v) return } if key == gocui.KeyCtrlD || ch == 4 { e.state.logf("input editor ctrl+d key=%v ch=%d view=%s", key, ch, viewName(v)) _ = e.state.scrollChatPageDownGlobal(e.state.g, v) return } } gocui.DefaultEditor.Edit(v, key, ch, mod) } func runTUI(connectAddr, peerFingerprint, peerPubKey, keyOverride string, paths configPaths, showIdentity bool) { if err := ensureDir(paths.ConfigDir); err != nil { fatal(err.Error()) } if err := ensureDir(paths.DataDir); err != nil { fatal(err.Error()) } keyPath := keyOverride if keyPath == "" { keyPath = filepath.Join(paths.ConfigDir, "identity.key") } priv, pub, err := loadOrCreateKey(keyPath) if err != nil { fatal(err.Error()) } fingerprint := fingerprintFor(pub) pubB64 := base64.StdEncoding.EncodeToString(pub.Bytes()) if showIdentity { fmt.Printf("fingerprint: %s\n", fingerprint) fmt.Printf("pubkey: %s\n", pubB64) return } if connectAddr == "" { fatal("client requires --connect") } store, err := openMessageStore(filepath.Join(paths.DataDir, "messages.db")) if err != nil { fatal(err.Error()) } defer store.Close() contacts, err := loadContactStore(filepath.Join(paths.ConfigDir, "contacts.json")) if err != nil { fatal(err.Error()) } if peerPubKey != "" { fp, err := contacts.add(nameFromFingerprint(peerFingerprint), normalizePubKey(peerPubKey)) if err != nil { fatal(err.Error()) } if peerFingerprint != "" && fp != peerFingerprint { fatal("peer fingerprint does not match provided key") } if peerFingerprint == "" { peerFingerprint = fp } } conn, err := net.Dial("tcp", connectAddr) if err != nil { fatal(fmt.Sprintf("connect failed: %v", err)) } if err := sendHello(conn, fingerprint, pub); err != nil { _ = conn.Close() fatal(err.Error()) } logger := newTuiLogger(filepath.Join(paths.DataDir, "tui.log")) state := &tuiState{ contacts: contacts, store: store, conn: conn, priv: priv, pub: pub, fingerprint: fingerprint, pubB64: pubB64, statusLine: "connected", logger: logger, chatAutoscroll: true, peerLastSeen: make(map[string]time.Time), } gui, err := gocui.NewGui(gocui.OutputNormal) if err != nil { fatal(err.Error()) } defer gui.Close() defer logger.Close() state.g = gui state.inputEd = &inputEditor{state: state} gui.SetManagerFunc(state.layout) state.logf("tui started") if err := state.bindKeys(); err != nil { fatal(err.Error()) } state.refreshContacts() if peerFingerprint != "" { state.selectPeer(peerFingerprint) } go state.receiveLoop(paths.DataDir) if err := gui.MainLoop(); err != nil && !errors.Is(err, gocui.ErrQuit) { fatal(err.Error()) } } func (s *tuiState) layout(g *gocui.Gui) error { maxX, maxY := g.Size() usersWidth := maxX / 4 if usersWidth < 20 { usersWidth = 20 } if usersWidth > 32 { usersWidth = 32 } inputTop := maxY - 3 if inputTop < 0 { inputTop = 0 } contentBottom := inputTop - 1 if contentBottom < 2 { contentBottom = maxY - 4 } if v, err := g.SetView("users", 0, 0, usersWidth, contentBottom); err != nil { if !errors.Is(err, gocui.ErrUnknownView) { return err } v.Title = "Users" v.Highlight = true v.SelFgColor = TuiColors.SelFg v.SelBgColor = TuiColors.SelBg v.Frame = true v.Wrap = false v.Autoscroll = false v.FgColor = TuiColors.UserFg } if v, err := g.SetView("chat", usersWidth+1, 0, maxX-1, contentBottom); err != nil { if !errors.Is(err, gocui.ErrUnknownView) { return err } v.Title = "Chat" v.Wrap = true v.Autoscroll = false v.Frame = true v.FgColor = TuiColors.ChatFg } if v, err := g.SetView("input", 0, inputTop, maxX-1, maxY-1); err != nil { if !errors.Is(err, gocui.ErrUnknownView) { return err } v.Frame = true v.Title = "Type message" v.Editable = true v.Editor = s.inputEd v.FgColor = TuiColors.InputFg if _, err := g.SetCurrentView("input"); err != nil { return err } } s.renderUsers() return nil } func (s *tuiState) bindKeys() error { if err := s.g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, s.quit); err != nil { return err } if err := s.g.SetKeybinding("", gocui.KeyTab, gocui.ModNone, s.nextView); err != nil { return err } if err := s.g.SetKeybinding("", gocui.KeyArrowUp, gocui.ModNone, s.moveUp); err != nil { return err } if err := s.g.SetKeybinding("", gocui.KeyArrowDown, gocui.ModNone, s.moveDown); err != nil { return err } if err := s.g.SetKeybinding("input", gocui.KeyEnter, gocui.ModNone, s.sendInput); err != nil { return err } if err := s.g.SetKeybinding("users", gocui.KeyEnter, gocui.ModNone, s.activateSelection); err != nil { return err } if err := s.g.SetKeybinding("chat", gocui.KeyArrowUp, gocui.ModNone, s.scrollChatLineUp); err != nil { return err } if err := s.g.SetKeybinding("chat", gocui.KeyArrowDown, gocui.ModNone, s.scrollChatLineDown); err != nil { return err } if err := s.g.SetKeybinding("chat", gocui.KeyPgup, gocui.ModNone, s.scrollChatPageUp); err != nil { return err } if err := s.g.SetKeybinding("chat", gocui.KeyPgdn, gocui.ModNone, s.scrollChatPageDown); err != nil { return err } if err := s.g.SetKeybinding("chat", gocui.KeyCtrlU, gocui.ModNone, s.scrollChatPageUp); err != nil { return err } if err := s.g.SetKeybinding("chat", gocui.KeyCtrlD, gocui.ModNone, s.scrollChatPageDown); err != nil { return err } if err := s.g.SetKeybinding("chat", gocui.KeyHome, gocui.ModNone, s.scrollChatTop); err != nil { return err } if err := s.g.SetKeybinding("chat", gocui.KeyEnd, gocui.ModNone, s.scrollChatBottom); err != nil { return err } if err := s.g.SetKeybinding("", gocui.KeyPgup, gocui.ModNone, s.scrollChatPageUpGlobal); err != nil { return err } if err := s.g.SetKeybinding("", gocui.KeyPgdn, gocui.ModNone, s.scrollChatPageDownGlobal); err != nil { return err } if err := s.g.SetKeybinding("", gocui.KeyCtrlU, gocui.ModNone, s.scrollChatPageUpGlobal); err != nil { return err } if err := s.g.SetKeybinding("", gocui.KeyCtrlD, gocui.ModNone, s.scrollChatPageDownGlobal); err != nil { return err } if err := s.g.SetKeybinding("", gocui.KeyHome, gocui.ModNone, s.scrollChatTopGlobal); err != nil { return err } if err := s.g.SetKeybinding("", gocui.KeyEnd, gocui.ModNone, s.scrollChatBottomGlobal); err != nil { return err } if err := s.g.SetKeybinding("users", gocui.KeyCtrlU, gocui.ModNone, s.scrollChatPageUpGlobal); err != nil { return err } if err := s.g.SetKeybinding("users", gocui.KeyCtrlD, gocui.ModNone, s.scrollChatPageDownGlobal); err != nil { return err } return nil } func (s *tuiState) receiveLoop(dataDir string) { for { env, err := readEnvelope(s.conn) if err != nil { s.pushStatus("disconnected") return } if env.Type == msgTypeError { var eb errorBody _ = json.Unmarshal(env.Body, &eb) if eb.Message != "" { s.pushStatus("server error: " + eb.Message) } continue } if env.Type != msgTypeMsg { continue } var body msgBody if err := json.Unmarshal(env.Body, &body); err != nil { continue } plaintext, err := decryptMessage(s.priv, s.contacts, body) if err != nil { s.pushStatus("decrypt failed") continue } created := time.Now().UTC() s.peerLastSeen[body.SenderFingerprint] = created _ = s.contacts.upsertFingerprint(body.SenderFingerprint, body.SenderPubKey) _ = s.contacts.updateLastUsed(body.SenderFingerprint, created) s.refreshContacts() if body.IsBinary { outPath := filepath.Join(dataDir, fmt.Sprintf("recv_%d_%s", time.Now().UnixNano(), safeFilename(body.Filename))) if err := os.WriteFile(outPath, plaintext, 0o600); err != nil { s.pushStatus("write file failed") continue } _ = s.store.SaveMessage(messageRecord{ Direction: "in", Peer: body.SenderFingerprint, IsBinary: true, Filename: body.Filename, Body: outPath, CreatedAt: created, }) name := s.displayName(body.SenderFingerprint) if name == "" { name = shortFingerprint(body.SenderFingerprint) } if s.currentPeer == body.SenderFingerprint { s.appendMessage(name, created, outPath, true, body.Filename) } } else { _ = s.store.SaveMessage(messageRecord{ Direction: "in", Peer: body.SenderFingerprint, IsBinary: false, Body: string(plaintext), CreatedAt: created, }) name := s.displayName(body.SenderFingerprint) if name == "" { name = shortFingerprint(body.SenderFingerprint) } if s.currentPeer == body.SenderFingerprint { s.appendMessage(name, created, string(plaintext), false, "") } } } } func (s *tuiState) quit(g *gocui.Gui, v *gocui.View) error { s.shutdownOnce.Do(func() { _ = s.conn.Close() }) return gocui.ErrQuit } func (s *tuiState) nextView(g *gocui.Gui, v *gocui.View) error { order := []string{"input", "users", "chat"} current := "input" if v != nil { current = v.Name() } idx := 0 for i, name := range order { if name == current { idx = i break } } next := order[(idx+1)%len(order)] _, err := g.SetCurrentView(next) return err } func (s *tuiState) moveUp(g *gocui.Gui, v *gocui.View) error { if v == nil || v.Name() != "users" { return nil } if s.selectedIdx > 0 { s.selectedIdx-- } s.renderUsers() s.selectCurrent() return nil } func (s *tuiState) moveDown(g *gocui.Gui, v *gocui.View) error { if v == nil || v.Name() != "users" { return nil } if s.selectedIdx < len(s.contactList)-1 { s.selectedIdx++ } s.renderUsers() s.selectCurrent() return nil } func (s *tuiState) activateSelection(g *gocui.Gui, v *gocui.View) error { s.selectCurrent() _, _ = g.SetCurrentView("input") return nil } func (s *tuiState) selectCurrent() { if len(s.contactList) == 0 || s.selectedIdx >= len(s.contactList) { return } peer := s.contactList[s.selectedIdx].Fingerprint s.selectPeer(peer) } func (s *tuiState) selectPeer(peer string) { s.currentPeer = peer _ = s.contacts.updateLastUsed(peer, time.Now().UTC()) s.refreshContacts() s.renderChat(peer) s.renderStatus() } func (s *tuiState) sendInput(g *gocui.Gui, v *gocui.View) error { line := strings.TrimSpace(v.Buffer()) v.Clear() v.SetCursor(0, 0) if line == "" { return nil } if strings.HasPrefix(line, "/") { return s.handleCommand(line) } if s.currentPeer == "" { s.pushStatus("no peer selected") return nil } recipientPubKey, err := s.contacts.getPubKey(s.currentPeer) if err != nil { s.pushStatus(err.Error()) return nil } msg, err := encryptMessage(recipientPubKey, s.fingerprint, s.pubB64, s.currentPeer, []byte(line), false, "") if err != nil { s.pushStatus("encrypt failed") return nil } if err := writeEnvelope(s.conn, msg); err != nil { s.pushStatus("send failed") return nil } created := time.Now().UTC() _ = s.contacts.updateLastUsed(s.currentPeer, created) _ = s.store.SaveMessage(messageRecord{ Direction: "out", Peer: s.currentPeer, IsBinary: false, Body: line, CreatedAt: created, }) s.appendMessage("You", created, line, false, "") return nil } func (s *tuiState) handleCommand(line string) error { fields := strings.Fields(line) if len(fields) == 0 { return nil } cmd := fields[0] switch cmd { case "/whoami": s.pushStatus("you: " + s.fingerprint) case "/pubkey": s.pushStatus("pubkey: " + s.pubB64) case "/help": s.pushStatus("commands: /add /rename /remove /trust /sendfile /whoami /pubkey") case "/add": if len(fields) < 3 { s.pushStatus("usage: /add ") return nil } name := fields[1] value := normalizePubKey(fields[2]) if isFingerprint(value) { fp, err := s.contacts.addByFingerprint(name, value) if err != nil { s.pushStatus(err.Error()) return nil } s.refreshContacts() s.selectPeer(fp) s.pushStatus("added contact without pubkey: " + name) return nil } fp, err := s.contacts.add(name, value) if err != nil { s.pushStatus(err.Error()) return nil } _, _ = s.contacts.add(nameFromFingerprint(""), normalizePubKey(value)) s.refreshContacts() s.selectPeer(fp) s.pushStatus("added contact: " + name + " " + fp) case "/rename": if len(fields) < 3 { s.pushStatus("usage: /rename ") return nil } if err := s.contacts.rename(fields[1], fields[2]); err != nil { s.pushStatus(err.Error()) return nil } s.refreshContacts() case "/remove": if len(fields) < 2 { s.pushStatus("usage: /remove ") return nil } if err := s.contacts.remove(fields[1]); err != nil { s.pushStatus(err.Error()) return nil } s.refreshContacts() case "/trust": if len(fields) < 2 { s.pushStatus("usage: /trust ") return nil } value := normalizePubKey(fields[1]) if isFingerprint(value) { s.pushStatus("trust requires pubkey") return nil } fp, err := s.contacts.add(nameFromFingerprint(""), normalizePubKey(value)) if err != nil { s.pushStatus(err.Error()) return nil } _ = s.contacts.upsertFingerprint(fp, value) s.refreshContacts() s.pushStatus("trusted " + fp) case "/sendfile": if len(fields) < 2 { s.pushStatus("usage: /sendfile ") return nil } if s.currentPeer == "" { s.pushStatus("no peer selected") return nil } payload, filename, err := readFilePayload(fields[1]) if err != nil { s.pushStatus("file read failed") return nil } recipientPubKey, err := s.contacts.getPubKey(s.currentPeer) if err != nil { s.pushStatus(err.Error()) return nil } msg, err := encryptMessage(recipientPubKey, s.fingerprint, s.pubB64, s.currentPeer, payload, true, filename) if err != nil { s.pushStatus("encrypt failed") return nil } if err := writeEnvelope(s.conn, msg); err != nil { s.pushStatus("send failed") return nil } created := time.Now().UTC() _ = s.store.SaveMessage(messageRecord{ Direction: "out", Peer: s.currentPeer, IsBinary: true, Filename: filename, Body: fields[1], CreatedAt: created, }) s.appendMessage("You", created, filename, true, fields[1]) default: s.pushStatus("unknown command") } return nil } func (s *tuiState) scrollChatLineUp(g *gocui.Gui, v *gocui.View) error { if v == nil { return nil } s.chatAutoscroll = false v.Autoscroll = false ox, oy := v.Origin() if oy > 0 { v.SetOrigin(ox, oy-1) } return nil } func (s *tuiState) scrollChatLineDown(g *gocui.Gui, v *gocui.View) error { if v == nil { return nil } s.chatAutoscroll = false v.Autoscroll = false ox, oy := v.Origin() max := s.maxOrigin(v) if oy < max { v.SetOrigin(ox, oy+1) } return nil } func (s *tuiState) scrollChatPageUp(g *gocui.Gui, v *gocui.View) error { if v == nil { return nil } s.chatAutoscroll = false v.Autoscroll = false ox, oy := v.Origin() _, height := v.Size() if height < 1 { return nil } total := s.totalLines(v) s.logf("scroll page up view=%s origin=%d height=%d total=%d", viewName(v), oy, height, total) newY := oy - height/2 if newY < 0 { newY = 0 } v.SetOrigin(ox, newY) return nil } func (s *tuiState) scrollChatPageDown(g *gocui.Gui, v *gocui.View) error { if v == nil { return nil } s.chatAutoscroll = false v.Autoscroll = false ox, oy := v.Origin() _, height := v.Size() if height < 1 { return nil } total := s.totalLines(v) newY := oy + height/2 max := s.maxOrigin(v) s.logf("scroll page down view=%s origin=%d height=%d total=%d max=%d", viewName(v), oy, height, total, max) if newY > max { newY = max } v.SetOrigin(ox, newY) return nil } func (s *tuiState) scrollChatTop(g *gocui.Gui, v *gocui.View) error { if v == nil { return nil } s.chatAutoscroll = false v.Autoscroll = false v.SetOrigin(0, 0) return nil } func (s *tuiState) scrollChatBottom(g *gocui.Gui, v *gocui.View) error { if v == nil { return nil } s.chatAutoscroll = true v.Autoscroll = true s.scrollToBottom(v) return nil } func (s *tuiState) scrollChatPageUpGlobal(g *gocui.Gui, v *gocui.View) error { s.logf("scroll page up global from view=%s", viewName(v)) return s.withChatView(g, func(chat *gocui.View) error { return s.scrollChatPageUp(g, chat) }) } func (s *tuiState) scrollChatPageDownGlobal(g *gocui.Gui, v *gocui.View) error { s.logf("scroll page down global from view=%s", viewName(v)) return s.withChatView(g, func(chat *gocui.View) error { return s.scrollChatPageDown(g, chat) }) } func (s *tuiState) scrollChatTopGlobal(g *gocui.Gui, v *gocui.View) error { return s.withChatView(g, func(chat *gocui.View) error { return s.scrollChatTop(g, chat) }) } func (s *tuiState) scrollChatBottomGlobal(g *gocui.Gui, v *gocui.View) error { return s.withChatView(g, func(chat *gocui.View) error { return s.scrollChatBottom(g, chat) }) } func (s *tuiState) withChatView(g *gocui.Gui, fn func(*gocui.View) error) error { if g == nil { return nil } chat, err := g.View("chat") if err != nil || chat == nil { s.logf("withChatView missing chat view err=%v", err) return nil } return fn(chat) } func viewName(v *gocui.View) string { if v == nil { return "" } return v.Name() } type tuiLogger struct { mu sync.Mutex file *os.File } func newTuiLogger(path string) *tuiLogger { if path == "" { return nil } if err := ensureDir(filepath.Dir(path)); err != nil { return nil } f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { return nil } return &tuiLogger{file: f} } func (l *tuiLogger) Close() { if l == nil || l.file == nil { return } _ = l.file.Close() } func (l *tuiLogger) Logf(format string, args ...any) { if l == nil || l.file == nil { return } l.mu.Lock() defer l.mu.Unlock() ts := time.Now().Format(time.RFC3339Nano) _, _ = fmt.Fprintf(l.file, "%s "+format+"\n", append([]any{ts}, args...)...) } func (s *tuiState) logf(format string, args ...any) { if s == nil || s.logger == nil { return } s.logger.Logf(format, args...) } func (s *tuiState) pushStatus(msg string) { s.statusLine = msg } func (s *tuiState) renderStatus() { // status view removed } func (s *tuiState) renderUsers() { v, err := s.g.View("users") if err != nil { return } v.Clear() if len(s.contactList) == 0 { fmt.Fprintln(v, "(no contacts)") return } for i, c := range s.contactList { name := s.displayName(c.Fingerprint) if name == "" { name = shortFingerprint(c.Fingerprint) } prefix := " " if i == s.selectedIdx { prefix = "> " } fmt.Fprintf(v, "%s%s\n", prefix, name) } } func (s *tuiState) renderChat(peer string) { v, err := s.g.View("chat") if err != nil { return } ox, oy := v.Origin() v.Clear() messages, err := s.store.ListMessages(peer, 2000) if err != nil { s.logf("renderChat list failed peer=%s err=%v", peer, err) s.statusLine = "messages unavailable" return } for _, msg := range messages { name := s.displayName(msg.Peer) if msg.Direction == "out" { name = "You" } s.appendMessageToView(v, name, msg.CreatedAt, msg.Body, msg.IsBinary, msg.Filename) } v.Autoscroll = s.chatAutoscroll if s.chatAutoscroll { s.scrollToBottom(v) return } max := s.maxOrigin(v) if oy > max { oy = max } _ = v.SetOrigin(ox, oy) } func (s *tuiState) appendMessage(sender string, created time.Time, body string, isBinary bool, filename string) { if s.g == nil { return } s.chatAutoscroll = true s.g.Update(func(g *gocui.Gui) error { v, err := g.View("chat") if err != nil { return nil } s.appendMessageToView(v, sender, created, body, isBinary, filename) v.Autoscroll = true s.scrollToBottom(v) return nil }) } func (s *tuiState) appendMessageToView(v *gocui.View, sender string, created time.Time, body string, isBinary bool, filename string) { ts := created.Local().Format("2006-01-02 15:04") label := body if isBinary { label = fmt.Sprintf("[file] %s", filename) } senderColor := Colors.Base03 if sender == "You" { senderColor = Colors.Base01 } namePart := senderColor.Text() + sender + Colors.Reset timePart := Colors.Base02.Text() + ts + Colors.Reset fmt.Fprintf(v, "%s (%s):\n%s\n\n", namePart, timePart, label) } func (s *tuiState) scrollToBottom(v *gocui.View) { _, height := v.Size() if height < 1 { return } total := s.totalLines(v) if total <= height { v.SetOrigin(0, 0) return } v.SetOrigin(0, total-height) } func (s *tuiState) isAtBottom(v *gocui.View) bool { _, height := v.Size() if height < 1 { return true } _, oy := v.Origin() total := s.totalLines(v) return oy+height >= total } func (s *tuiState) totalLines(v *gocui.View) int { lines := v.ViewBufferLines() if len(lines) == 0 { return 0 } return len(lines) } func (s *tuiState) maxOrigin(v *gocui.View) int { _, height := v.Size() total := s.totalLines(v) max := total - height if max < 0 { return 0 } return max } func (s *tuiState) refreshContacts() { prevPeer := s.currentPeer _ = s.contacts.reload() s.contactList = s.contacts.list() s.sortContacts() if len(s.contactList) == 0 { s.selectedIdx = 0 s.currentPeer = "" } else { idx := s.indexOfPeer(s.currentPeer) if idx >= 0 { s.selectedIdx = idx } else { s.selectedIdx = 0 s.currentPeer = s.contactList[0].Fingerprint } } if s.g != nil { s.g.Update(func(g *gocui.Gui) error { s.renderUsers() if s.currentPeer != "" && s.currentPeer != prevPeer { s.renderChat(s.currentPeer) } return nil }) } } func (s *tuiState) displayName(fingerprint string) string { if fingerprint == "" { return "" } if c, ok := s.contacts.byFingerprint(fingerprint); ok { if c.Name != "" { return c.Name } } return "" } func shortFingerprint(fp string) string { if len(fp) <= 8 { return fp } return fp[:8] } func (s *tuiState) sortContacts() { sorted := make([]contact, len(s.contactList)) copy(sorted, s.contactList) sort.Slice(sorted, func(i, j int) bool { ti := parseLastUsed(sorted[i].LastUsed) tj := parseLastUsed(sorted[j].LastUsed) if !ti.IsZero() || !tj.IsZero() { if ti.Equal(tj) { return sorted[i].Fingerprint < sorted[j].Fingerprint } return ti.After(tj) } li := strings.ToLower(sorted[i].Name) lj := strings.ToLower(sorted[j].Name) if li == "" { li = shortFingerprint(sorted[i].Fingerprint) } if lj == "" { lj = shortFingerprint(sorted[j].Fingerprint) } return li < lj }) s.contactList = sorted } func (s *tuiState) peerStatus(peer string) string { if peer == "" { return "" } last, ok := s.peerLastSeen[peer] if !ok || last.IsZero() { return "offline" } if time.Since(last) <= 2*time.Minute { return "online" } return fmt.Sprintf("offline (last seen %s)", last.Local().Format("15:04")) } func normalizePubKey(input string) string { trimmed := strings.TrimSpace(input) trimmed = strings.TrimPrefix(trimmed, "pubkey:") trimmed = strings.TrimPrefix(trimmed, "pubkey") return strings.TrimSpace(trimmed) } func nameFromFingerprint(fp string) string { if fp == "" { return "" } return shortFingerprint(fp) } func isFingerprint(value string) bool { if len(value) != 64 { return false } for _, r := range value { if (r < '0' || r > '9') && (r < 'a' || r > 'f') { return false } } return true } func loadContactStore(path string) (*contactStore, error) { store := &contactStore{Path: path} data, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return store, nil } return nil, err } if err := json.Unmarshal(data, &store.Contacts); err != nil { return nil, errors.New("invalid contacts file") } return store, nil } func (cs *contactStore) reload() error { if cs.Path == "" { return nil } data, err := os.ReadFile(cs.Path) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil } return err } cs.mu.Lock() defer cs.mu.Unlock() return json.Unmarshal(data, &cs.Contacts) } func (cs *contactStore) save() error { data, err := json.MarshalIndent(cs.Contacts, "", " ") if err != nil { return err } if err := ensureDir(filepath.Dir(cs.Path)); err != nil { return err } return os.WriteFile(cs.Path, data, 0o600) } func (cs *contactStore) list() []contact { cs.mu.Lock() defer cs.mu.Unlock() items := make([]contact, len(cs.Contacts)) copy(items, cs.Contacts) return items } func (cs *contactStore) byFingerprint(fp string) (contact, bool) { cs.mu.Lock() defer cs.mu.Unlock() for _, c := range cs.Contacts { if c.Fingerprint == fp { return c, true } } return contact{}, false } func (cs *contactStore) byName(name string) (contact, int, bool) { cs.mu.Lock() defer cs.mu.Unlock() for i, c := range cs.Contacts { if strings.EqualFold(c.Name, name) { return c, i, true } } return contact{}, -1, false } func (cs *contactStore) add(name, pubkey string) (string, error) { pubBytes, err := base64.StdEncoding.DecodeString(pubkey) if err != nil { return "", errors.New("invalid pubkey") } pub, err := ecdh.X25519().NewPublicKey(pubBytes) if err != nil { return "", errors.New("invalid pubkey") } fingerprint := fingerprintFor(pub) cs.mu.Lock() defer cs.mu.Unlock() for i, c := range cs.Contacts { if c.Fingerprint == fingerprint { cs.Contacts[i].Name = name cs.Contacts[i].PubKey = pubkey return fingerprint, cs.save() } if strings.EqualFold(c.Name, name) { cs.Contacts[i].Fingerprint = fingerprint cs.Contacts[i].PubKey = pubkey return fingerprint, cs.save() } } cs.Contacts = append(cs.Contacts, contact{Name: name, Fingerprint: fingerprint, PubKey: pubkey, LastUsed: ""}) return fingerprint, cs.save() } func (cs *contactStore) addByFingerprint(name, fingerprint string) (string, error) { cs.mu.Lock() defer cs.mu.Unlock() for i, c := range cs.Contacts { if c.Fingerprint == fingerprint { cs.Contacts[i].Name = name return fingerprint, cs.save() } } cs.Contacts = append(cs.Contacts, contact{Name: name, Fingerprint: fingerprint, LastUsed: ""}) return fingerprint, cs.save() } func (cs *contactStore) upsertFingerprint(fingerprint, pubkey string) error { cs.mu.Lock() defer cs.mu.Unlock() for i, c := range cs.Contacts { if c.Fingerprint == fingerprint { if cs.Contacts[i].PubKey == "" { cs.Contacts[i].PubKey = pubkey return cs.save() } return nil } } cs.Contacts = append(cs.Contacts, contact{Name: shortFingerprint(fingerprint), Fingerprint: fingerprint, PubKey: pubkey, LastUsed: ""}) return cs.save() } func (cs *contactStore) verifyOrAdd(fingerprint, pubKey string) error { cs.mu.Lock() defer cs.mu.Unlock() for i, c := range cs.Contacts { if c.Fingerprint == fingerprint { if c.PubKey != pubKey { return errors.New("peer key mismatch") } cs.Contacts[i].LastUsed = time.Now().UTC().Format(time.RFC3339Nano) return cs.save() } } cs.Contacts = append(cs.Contacts, contact{ Name: shortFingerprint(fingerprint), Fingerprint: fingerprint, PubKey: pubKey, LastUsed: time.Now().UTC().Format(time.RFC3339Nano), }) return cs.save() } func (cs *contactStore) getPubKey(fingerprint string) (string, error) { cs.mu.Lock() defer cs.mu.Unlock() for _, c := range cs.Contacts { if c.Fingerprint == fingerprint { if c.PubKey == "" { return "", errors.New("peer has no pubkey: trust with /add or /trust") } return c.PubKey, nil } } return "", errors.New("unknown peer: add with /add or /trust") } func (cs *contactStore) updateLastUsed(fingerprint string, t time.Time) error { if fingerprint == "" { return nil } cs.mu.Lock() defer cs.mu.Unlock() for i, c := range cs.Contacts { if c.Fingerprint == fingerprint { cs.Contacts[i].LastUsed = t.UTC().Format(time.RFC3339Nano) return cs.save() } } cs.Contacts = append(cs.Contacts, contact{Name: shortFingerprint(fingerprint), Fingerprint: fingerprint, LastUsed: t.UTC().Format(time.RFC3339Nano)}) return cs.save() } func parseLastUsed(value string) time.Time { if value == "" { return time.Time{} } if ts, err := time.Parse(time.RFC3339Nano, value); err == nil { return ts } return time.Time{} } func (s *tuiState) indexOfPeer(fp string) int { if fp == "" { return -1 } for i, c := range s.contactList { if c.Fingerprint == fp { return i } } return -1 } func (cs *contactStore) rename(target, newName string) error { cs.mu.Lock() defer cs.mu.Unlock() for i, c := range cs.Contacts { if c.Fingerprint == target || strings.EqualFold(c.Name, target) { cs.Contacts[i].Name = newName return cs.save() } } return errors.New("contact not found") } func (cs *contactStore) remove(target string) error { cs.mu.Lock() defer cs.mu.Unlock() for i, c := range cs.Contacts { if c.Fingerprint == target || strings.EqualFold(c.Name, target) { cs.Contacts = append(cs.Contacts[:i], cs.Contacts[i+1:]...) return cs.save() } } return errors.New("contact not found") } type messageRecord struct { Direction string Peer string IsBinary bool Filename string Body string CreatedAt time.Time } func openMessageStore(path string) (*messageStore, error) { if err := ensureDir(filepath.Dir(path)); err != nil { return nil, err } db, err := sql.Open("sqlite", path) if err != nil { return nil, err } if _, err := db.Exec(` CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, direction TEXT NOT NULL, peer TEXT NOT NULL, is_binary INTEGER NOT NULL, filename TEXT, body TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at); `); err != nil { _ = db.Close() return nil, err } return &messageStore{DB: db}, nil } func (ms *messageStore) SaveMessage(rec messageRecord) error { if ms == nil || ms.DB == nil { return nil } _, err := ms.DB.Exec( "INSERT INTO messages (direction, peer, is_binary, filename, body, created_at) VALUES (?, ?, ?, ?, ?, ?)", rec.Direction, rec.Peer, boolToInt(rec.IsBinary), rec.Filename, rec.Body, rec.CreatedAt.UTC().Format(time.RFC3339Nano), ) return err } func (ms *messageStore) ListMessages(peer string, limit int) ([]messageRecord, error) { if ms == nil || ms.DB == nil { return nil, errors.New("message store not available") } rows, err := ms.DB.Query( "SELECT direction, peer, is_binary, filename, body, created_at FROM messages WHERE peer = ? ORDER BY id DESC LIMIT ?", peer, limit, ) if err != nil { return nil, err } defer rows.Close() var items []messageRecord for rows.Next() { var dir string var p string var isBinary int var filename string var body string var created string if err := rows.Scan(&dir, &p, &isBinary, &filename, &body, &created); err != nil { return nil, err } ts, err := time.Parse(time.RFC3339Nano, created) if err != nil { ts = time.Now().UTC() } items = append(items, messageRecord{ Direction: dir, Peer: p, IsBinary: isBinary == 1, Filename: filename, Body: body, CreatedAt: ts, }) } if err := rows.Err(); err != nil { return nil, err } for i, j := 0, len(items)-1; i < j; i, j = i+1, j-1 { items[i], items[j] = items[j], items[i] } return items, nil } func (ms *messageStore) Close() { if ms == nil || ms.DB == nil { return } _ = ms.DB.Close() } func printMessages(ms *messageStore, limit int) error { if ms == nil || ms.DB == nil { return errors.New("message store not available") } rows, err := ms.DB.Query( "SELECT direction, peer, is_binary, filename, body, created_at FROM messages ORDER BY id DESC LIMIT ?", limit, ) if err != nil { return err } defer rows.Close() for rows.Next() { var dir string var peer string var isBinary int var filename string var body string var created string if err := rows.Scan(&dir, &peer, &isBinary, &filename, &body, &created); err != nil { return err } if isBinary == 1 { fmt.Printf("%s %s %s [file] %s\n", created, dir, peer, body) } else { fmt.Printf("%s %s %s %s\n", created, dir, peer, body) } } return rows.Err() } func boolToInt(v bool) int { if v { return 1 } return 0 } func fatal(msg string) { fmt.Fprintf(os.Stderr, "error: %s\n", msg) os.Exit(1) }