2258 lines
53 KiB
Go
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)
|
|
}
|