whspbrd-final/main.go
2026-05-02 22:09:19 +02:00

2258 lines
53 KiB
Go

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 <base64-pubkey>")
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 <path>")
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 <name> <pubkey|fingerprint>")
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 <name|fingerprint> <new-name>")
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 <name|fingerprint>")
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 <pubkey>")
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 <path>")
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)
}