commit d1fe6717046982de5ebaf7f066fb223590d3b84d Author: Atridad Lahiji Date: Sun Dec 28 00:30:12 2025 -0700 O_O diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55c61cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.ssh +teachat +teachat.db +*.db diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1dbc088 --- /dev/null +++ b/go.mod @@ -0,0 +1,45 @@ +module teachat + +go 1.25.5 + +require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 + github.com/charmbracelet/wish v1.4.7 + github.com/mattn/go-sqlite3 v1.14.32 + golang.org/x/crypto v0.46.0 +) + +require ( + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/keygen v0.5.4 // indirect + github.com/charmbracelet/log v0.4.2 // indirect + github.com/charmbracelet/x/ansi v0.11.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.14 // indirect + github.com/charmbracelet/x/conpty v0.2.0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/clipperhouse/displaywidth v0.6.2 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/creack/pty v1.1.24 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-logfmt/logfmt v0.6.1 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cc38c3d --- /dev/null +++ b/go.sum @@ -0,0 +1,86 @@ +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA= +github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= +github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= +github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc= +github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE= +github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc= +github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14= +github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI= +github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI= +github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= +github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= +github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY= +github.com/charmbracelet/x/conpty v0.2.0/go.mod h1:fexgUnVrZgw8scD49f6VSi0Ggj9GWYIrpedRthAwW/8= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= +github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= +github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/broadcaster.go b/internal/broadcaster.go new file mode 100644 index 0000000..ec43783 --- /dev/null +++ b/internal/broadcaster.go @@ -0,0 +1,42 @@ +package internal + +import "sync" + +type broadcaster struct { + clients map[chan bool]bool + mu sync.RWMutex +} + +func newBroadcaster() *broadcaster { + return &broadcaster{ + clients: make(map[chan bool]bool), + } +} + +func (b *broadcaster) subscribe() chan bool { + b.mu.Lock() + defer b.mu.Unlock() + ch := make(chan bool, 10) + b.clients[ch] = true + return ch +} + +func (b *broadcaster) unsubscribe(ch chan bool) { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.clients, ch) + close(ch) +} + +func (b *broadcaster) broadcast() { + b.mu.RLock() + defer b.mu.RUnlock() + for ch := range b.clients { + select { + case ch <- true: + default: + } + } +} + +var updates = newBroadcaster() diff --git a/internal/crypto.go b/internal/crypto.go new file mode 100644 index 0000000..8412522 --- /dev/null +++ b/internal/crypto.go @@ -0,0 +1,154 @@ +package internal + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "io" + + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/curve25519" +) + +const ( + saltSize = 16 + keySize = 32 + nonceSize = 12 +) + +// IdentityKeyPair represents a user's long-term identity key +type IdentityKeyPair struct { + PublicKey [32]byte + PrivateKey [32]byte +} + +// PrekeyBundle contains public keys for establishing a session +type PrekeyBundle struct { + IdentityKey [32]byte + Prekey [32]byte + PrekeySignature []byte +} + +// generateIdentityKeyPair creates a new Curve25519 key pair for identity +func generateIdentityKeyPair() (*IdentityKeyPair, error) { + var privateKey [32]byte + if _, err := io.ReadFull(rand.Reader, privateKey[:]); err != nil { + return nil, err + } + + var publicKey [32]byte + curve25519.ScalarBaseMult(&publicKey, &privateKey) + + return &IdentityKeyPair{ + PublicKey: publicKey, + PrivateKey: privateKey, + }, nil +} + +// generateSignedPrekey creates a prekey signed with an Ed25519 signing key +func generateSignedPrekey() ([32]byte, []byte, error) { + var prekey [32]byte + var prekeyPriv [32]byte + if _, err := io.ReadFull(rand.Reader, prekeyPriv[:]); err != nil { + return prekey, nil, err + } + curve25519.ScalarBaseMult(&prekey, &prekeyPriv) + + // Generate signing key + signingPub, signingPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return prekey, nil, err + } + + // Sign the prekey + signature := ed25519.Sign(signingPriv, prekey[:]) + + // In practice, we'd store signingPub to verify later. For now, we include it in signature + combinedSig := append(signingPub, signature...) + + return prekey, combinedSig, nil +} + +// performDH does an ECDH key exchange +func performDH(privateKey, publicKey [32]byte) ([32]byte, error) { + var sharedSecret [32]byte + curve25519.ScalarMult(&sharedSecret, &privateKey, &publicKey) + return sharedSecret, nil +} + +// deriveSharedSecret combines multiple DH outputs to create a shared secret (simplified X3DH) +func deriveSharedSecret(dh1, dh2, dh3 [32]byte) []byte { + // KDF: hash all DH outputs together + h := sha256.New() + h.Write(dh1[:]) + h.Write(dh2[:]) + h.Write(dh3[:]) + return h.Sum(nil) +} + +// encryptUserKey encrypts a user's private key with their passphrase +func encryptUserKey(privateKey [32]byte, passphrase string) ([]byte, []byte, []byte, error) { + salt := make([]byte, saltSize) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return nil, nil, nil, err + } + + key := deriveKey(passphrase, salt) + ciphertext, nonce, err := encryptMsg(string(privateKey[:]), key) + if err != nil { + return nil, nil, nil, err + } + + return ciphertext, nonce, salt, nil +} + +// decryptUserKey decrypts a user's private key with their passphrase +func decryptUserKey(ciphertext, nonce, salt []byte, passphrase string) ([32]byte, error) { + var privateKey [32]byte + key := deriveKey(passphrase, salt) + plain, err := decryptMsg(ciphertext, nonce, key) + if err != nil { + return privateKey, err + } + copy(privateKey[:], plain) + return privateKey, nil +} + +func deriveKey(passphrase string, salt []byte) []byte { + return argon2.IDKey([]byte(passphrase), salt, 1, 64*1024, 4, keySize) +} + +func encryptMsg(plain string, key []byte) ([]byte, []byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, nil, err + } + ciphertext := gcm.Seal(nil, nonce, []byte(plain), nil) + return ciphertext, nonce, nil +} + +func decryptMsg(ciphertext, nonce, key []byte) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + plain, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + return string(plain), nil +} diff --git a/internal/database.go b/internal/database.go new file mode 100644 index 0000000..5756eef --- /dev/null +++ b/internal/database.go @@ -0,0 +1,268 @@ +package internal + +import ( + "crypto/rand" + "database/sql" + "encoding/base64" + "io" + "log" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +const dbFile = "teachat.db" + +var db *sql.DB + +func InitDB() { + var err error + db, err = sql.Open("sqlite3", dbFile) + if err != nil { + log.Fatal(err) + } + + query := ` + CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, val TEXT); + CREATE TABLE IF NOT EXISTS users ( + pubkey TEXT PRIMARY KEY, + username TEXT UNIQUE, + identity_key BLOB, + prekey BLOB, + prekey_signature BLOB, + created_at DATETIME + ); + CREATE TABLE IF NOT EXISTS rooms ( + id TEXT PRIMARY KEY, + name TEXT, + creator TEXT, + is_dm BOOLEAN, + created_at DATETIME + ); + CREATE TABLE IF NOT EXISTS room_members ( + room_id TEXT, + username TEXT, + shared_secret BLOB, + joined_at DATETIME, + PRIMARY KEY (room_id, username), + FOREIGN KEY (room_id) REFERENCES rooms(id), + FOREIGN KEY (username) REFERENCES users(username) + ); + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY, + room_id TEXT, + timestamp DATETIME, + sender TEXT, + ciphertext BLOB, + nonce BLOB, + FOREIGN KEY (room_id) REFERENCES rooms(id) + ); + ` + if _, err := db.Exec(query); err != nil { + log.Fatal(err) + } + + // Migration for older DBs + db.Exec("ALTER TABLE rooms ADD COLUMN creator TEXT") +} + +func ensureSalt() []byte { + row := db.QueryRow("SELECT val FROM config WHERE key='global_salt'") + var b64 string + if err := row.Scan(&b64); err == sql.ErrNoRows { + salt := make([]byte, saltSize) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + log.Fatal(err) + } + b64 = base64.StdEncoding.EncodeToString(salt) + db.Exec("INSERT INTO config (key, val) VALUES ('global_salt', ?)", b64) + return salt + } else if err != nil { + log.Fatal(err) + } + salt, _ := base64.StdEncoding.DecodeString(b64) + return salt +} + +func authorizeUser(pubkey, username string, identityKey, prekey, prekeySignature []byte) error { + _, err := db.Exec("INSERT INTO users (pubkey, username, identity_key, prekey, prekey_signature, created_at) VALUES (?, ?, ?, ?, ?, ?)", + pubkey, username, identityKey, prekey, prekeySignature, time.Now()) + return err +} + +func getUsername(pubkey string) (string, error) { + var username string + err := db.QueryRow("SELECT username FROM users WHERE pubkey = ?", pubkey).Scan(&username) + return username, err +} + +func getUserKeys(username string) (identityKey, prekey, prekeySignature []byte, err error) { + err = db.QueryRow("SELECT identity_key, prekey, prekey_signature FROM users WHERE username = ?", username).Scan(&identityKey, &prekey, &prekeySignature) + return +} + +func listUsers() ([]string, error) { + rows, err := db.Query("SELECT username FROM users ORDER BY username ASC") + if err != nil { + return nil, err + } + defer rows.Close() + + var users []string + for rows.Next() { + var username string + if err := rows.Scan(&username); err != nil { + continue + } + users = append(users, username) + } + return users, nil +} + +func createRoom(roomID, name, creator string, isDM bool) error { + _, err := db.Exec("INSERT INTO rooms (id, name, creator, is_dm, created_at) VALUES (?, ?, ?, ?, ?)", + roomID, name, creator, isDM, time.Now()) + return err +} + +func getRoomIDByName(name string) (string, error) { + var id string + err := db.QueryRow("SELECT id FROM rooms WHERE name = ? AND is_dm = 0 LIMIT 1", name).Scan(&id) + return id, err +} + +// getAnyRoomSecret gets the shared secret from ANY member of the room. +// In a real decentralized system this wouldn't be possible, but here the server acts as the key distributor. +func getAnyRoomSecret(roomID string) ([]byte, error) { + var secret []byte + err := db.QueryRow("SELECT shared_secret FROM room_members WHERE room_id = ? LIMIT 1", roomID).Scan(&secret) + return secret, err +} + +func deleteRoom(roomID string) error { + tx, err := db.Begin() + if err != nil { + return err + } + if _, err := tx.Exec("DELETE FROM messages WHERE room_id = ?", roomID); err != nil { + tx.Rollback() + return err + } + if _, err := tx.Exec("DELETE FROM room_members WHERE room_id = ?", roomID); err != nil { + tx.Rollback() + return err + } + if _, err := tx.Exec("DELETE FROM rooms WHERE id = ?", roomID); err != nil { + tx.Rollback() + return err + } + return tx.Commit() +} + +func leaveRoom(roomID, username string) error { + _, err := db.Exec("DELETE FROM room_members WHERE room_id = ? AND username = ?", roomID, username) + return err +} + +func joinRoom(roomID, username string, sharedSecret []byte) error { + // Check if already joined + var count int + db.QueryRow("SELECT COUNT(*) FROM room_members WHERE room_id = ? AND username = ?", roomID, username).Scan(&count) + if count > 0 { + return nil + } + _, err := db.Exec("INSERT INTO room_members (room_id, username, shared_secret, joined_at) VALUES (?, ?, ?, ?)", + roomID, username, sharedSecret, time.Now()) + return err +} + +func getMemberJoinedAt(roomID, username string) (time.Time, error) { + var joinedAt time.Time + err := db.QueryRow("SELECT joined_at FROM room_members WHERE room_id = ? AND username = ?", roomID, username).Scan(&joinedAt) + return joinedAt, err +} + +func listUserRooms(username string) ([]struct { + ID string + Name string + Creator string + IsDM bool +}, error) { + rows, err := db.Query(` + SELECT r.id, r.name, COALESCE(r.creator, ''), r.is_dm + FROM rooms r + JOIN room_members rm ON r.id = rm.room_id + WHERE rm.username = ? + ORDER BY r.created_at DESC`, username) + if err != nil { + return nil, err + } + defer rows.Close() + + var rooms []struct { + ID string + Name string + Creator string + IsDM bool + } + for rows.Next() { + var room struct { + ID string + Name string + Creator string + IsDM bool + } + if err := rows.Scan(&room.ID, &room.Name, &room.Creator, &room.IsDM); err != nil { + continue + } + rooms = append(rooms, room) + } + return rooms, nil +} + +func getRoomSharedSecret(roomID, username string) ([]byte, error) { + var secret []byte + err := db.QueryRow("SELECT shared_secret FROM room_members WHERE room_id = ? AND username = ?", roomID, username).Scan(&secret) + return secret, err +} + +func saveMessage(roomID, sender string, ciphertext, nonce []byte) error { + _, err := db.Exec("INSERT INTO messages (room_id, timestamp, sender, ciphertext, nonce) VALUES (?, ?, ?, ?, ?)", + roomID, time.Now(), sender, ciphertext, nonce) + return err +} + +func loadMessages(roomID string) ([]struct { + Timestamp time.Time + Sender string + Ciphertext []byte + Nonce []byte +}, error) { + rows, err := db.Query("SELECT timestamp, sender, ciphertext, nonce FROM messages WHERE room_id = ? ORDER BY timestamp ASC", roomID) + if err != nil { + return nil, err + } + defer rows.Close() + + var messages []struct { + Timestamp time.Time + Sender string + Ciphertext []byte + Nonce []byte + } + + for rows.Next() { + var msg struct { + Timestamp time.Time + Sender string + Ciphertext []byte + Nonce []byte + } + if err := rows.Scan(&msg.Timestamp, &msg.Sender, &msg.Ciphertext, &msg.Nonce); err != nil { + continue + } + messages = append(messages, msg) + } + + return messages, nil +} diff --git a/internal/model.go b/internal/model.go new file mode 100644 index 0000000..87ebb85 --- /dev/null +++ b/internal/model.go @@ -0,0 +1,609 @@ +package internal + +import ( + "crypto/rand" + "database/sql" + "encoding/base64" + "fmt" + "io" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/ssh" +) + +var ( + msgStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")).PaddingLeft(1) + senderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true) + errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) + sysStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("117")).Italic(true) + roomStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("86")).Bold(true) + dmStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("141")).Bold(true) + noteStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("228")).Bold(true) + commandStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Italic(true) + timeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("246")) + lockedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Italic(true) +) + +type chatMsg struct { + Timestamp time.Time + Sender string + Content string +} + +type model struct { + sshSession ssh.Session + pubkey string + username string + authorized bool + needsRegistration bool + + state int + passphrase string + identityKey *IdentityKeyPair + encryptedPrivKey []byte + privKeySalt []byte + privKeyNonce []byte + + currentRoomID string + currentRoomKey []byte + currentRoomJoinedAt time.Time + rooms []struct { + ID string + Name string + Creator string + IsDM bool + } + availableUsers []string + + err error + + input textinput.Model + viewport viewport.Model + messages []chatMsg + updateChan chan bool +} + +func initialModel(s ssh.Session) model { + pk := s.PublicKey() + var pubkeyStr string + var auth bool + var user string + var needsRegistration bool + + if pk == nil { + auth = false + user = "" + pubkeyStr = "" + } else { + pubkeyStr = fmt.Sprintf("%s %s", pk.Type(), base64.StdEncoding.EncodeToString(pk.Marshal())) + + username, err := getUsername(pubkeyStr) + if err == sql.ErrNoRows { + needsRegistration = true + auth = true + user = "" + } else { + auth = true + needsRegistration = false + user = username + } + } + + ti := textinput.New() + if needsRegistration { + ti.Placeholder = "Choose a username..." + } else { + ti.Placeholder = "Enter Passphrase..." + ti.EchoMode = textinput.EchoPassword + } + ti.Focus() + ti.CharLimit = 156 + ti.Width = 50 + + vp := viewport.New(80, 20) + + return model{ + sshSession: s, + pubkey: pubkeyStr, + username: user, + authorized: auth, + needsRegistration: needsRegistration, + state: 0, + input: ti, + viewport: vp, + updateChan: updates.subscribe(), + } +} + +func (m model) Init() tea.Cmd { + return tea.Batch(textinput.Blink, m.waitForUpdate()) +} + +func (m model) waitForUpdate() tea.Cmd { + return func() tea.Msg { + <-m.updateChan + return struct{}{} + } +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - 4 + return m, tea.ClearScreen + + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit + case tea.KeyEnter: + if !m.authorized { + return m, tea.Quit + } + + if m.needsRegistration { + username := m.input.Value() + if len(username) > 0 { + identityKey, err := generateIdentityKeyPair() + if err != nil { + m.err = err + return m, nil + } + + prekey, prekeySignature, err := generateSignedPrekey() + if err != nil { + m.err = err + return m, nil + } + + if err := authorizeUser(m.pubkey, username, identityKey.PublicKey[:], prekey[:], prekeySignature); err != nil { + m.err = err + } else { + m.username = username + m.identityKey = identityKey + m.needsRegistration = false + + m.input.Reset() + m.input.Placeholder = "Enter Passphrase..." + m.input.EchoMode = textinput.EchoPassword + m.viewport.SetContent("") + return m, tea.ClearScreen + } + } + return m, nil + } + + if m.state == 0 { + pass := m.input.Value() + if len(pass) > 0 { + if m.identityKey != nil { + ciphertext, nonce, salt, err := encryptUserKey(m.identityKey.PrivateKey, pass) + if err != nil { + m.err = err + return m, nil + } + m.encryptedPrivKey = ciphertext + m.privKeyNonce = nonce + m.privKeySalt = salt + } + + m.passphrase = pass + m.state = 1 + + m.input.Reset() + m.input.EchoMode = textinput.EchoNormal + m.input.Placeholder = "Loading rooms..." + m.viewport.SetContent("") + + m.loadRooms() + return m, tea.ClearScreen + } + } else if m.state == 1 { + val := m.input.Value() + if len(val) > 0 { + m.handleRoomListInput(val) + m.input.Reset() + if m.state == 2 { + return m, tea.ClearScreen + } + } + } else if m.state == 2 { + val := m.input.Value() + if len(val) > 0 { + if val == "/back" { + m.exitChat() + return m, tea.ClearScreen + } + + if val == "/leave" { + leaveRoom(m.currentRoomID, m.username) + m.exitChat() + return m, tea.ClearScreen + } + + if val == "/delete" { + isCreator := false + for _, r := range m.rooms { + if r.ID == m.currentRoomID { + if r.Creator == m.username { + isCreator = true + } + break + } + } + if isCreator { + deleteRoom(m.currentRoomID) + m.exitChat() + return m, tea.ClearScreen + } else { + m.saveMessage("System", "Error: Only the creator can delete this room.") + m.input.Reset() + return m, nil + } + } + m.handleChatInput(val) + m.input.Reset() + } + } + } + + case struct{}: + if m.state == 2 { + m.loadMessages() + return m, m.waitForUpdate() + } + } + + m.input, cmd = m.input.Update(msg) + return m, cmd +} + +func (m *model) exitChat() { + m.state = 1 + m.currentRoomID = "" + m.currentRoomKey = nil + m.messages = nil + m.viewport.SetContent("") + m.loadRooms() + m.input.Reset() +} + +func (m *model) loadRooms() { + rooms, err := listUserRooms(m.username) + if err != nil { + m.err = err + return + } + m.rooms = rooms + m.viewport.SetContent("") + m.input.SetValue("") + m.input.Placeholder = "Enter room # or: /join , /dm , /list" +} + +func (m *model) handleRoomListInput(text string) { + if text == "/list" { + m.loadRooms() + return + } + + if strings.HasPrefix(text, "/join ") { + roomName := strings.TrimPrefix(text, "/join ") + m.handleJoinRoom(roomName) + return + } + // Keep /new as an alias for /join + if strings.HasPrefix(text, "/new ") { + roomName := strings.TrimPrefix(text, "/new ") + m.handleJoinRoom(roomName) + return + } + + if strings.HasPrefix(text, "/dm ") { + targetUser := strings.TrimPrefix(text, "/dm ") + m.handleSelectUserForDM(targetUser) + return + } + + if idx := parseInt(text); idx > 0 && idx <= len(m.rooms) { + room := m.rooms[idx-1] + m.enterRoom(room.ID, room.Name) + } +} + +func (m *model) handleJoinRoom(roomName string) { + // Check if room exists + existingID, err := getRoomIDByName(roomName) + + // Room Exists: Join it + if err == nil && existingID != "" { + // Need key + secret, err := getAnyRoomSecret(existingID) + if err != nil { + m.err = fmt.Errorf("could not join: room key unreachable") + return + } + + if err := joinRoom(existingID, m.username, secret); err != nil { + m.err = err + return + } + m.enterRoom(existingID, roomName) + return + } + + // Room Does Not Exist: Create it + roomID := generateRoomID() + if err := createRoom(roomID, roomName, m.username, false); err != nil { + m.err = err + return + } + + sharedSecret := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, sharedSecret); err != nil { + m.err = err + return + } + + if err := joinRoom(roomID, m.username, sharedSecret); err != nil { + m.err = err + return + } + + m.enterRoom(roomID, roomName) +} + +func (m *model) handleSelectUserForDM(username string) { + if username == "/cancel" { + m.state = 1 + m.input.Placeholder = "Enter room # or: /join , /dm , /list" + return + } + + if username == m.username { + m.createNoteToSelf() + return + } + + identityKey, prekey, prekeySignature, err := getUserKeys(username) + if err != nil { + m.err = fmt.Errorf("user not found: %w", err) + return + } + + var theirIdentityKey, theirPrekey [32]byte + copy(theirIdentityKey[:], identityKey) + copy(theirPrekey[:], prekey) + + ephemeralKey, err := generateIdentityKeyPair() + if err != nil { + m.err = err + return + } + + dh1, _ := performDH(m.identityKey.PrivateKey, theirPrekey) + dh2, _ := performDH(ephemeralKey.PrivateKey, theirIdentityKey) + dh3, _ := performDH(ephemeralKey.PrivateKey, theirPrekey) + + sharedSecret := deriveSharedSecret(dh1, dh2, dh3) + + roomID := generateRoomID() + roomName := fmt.Sprintf("DM: %s <-> %s", m.username, username) + + if err := createRoom(roomID, roomName, m.username, true); err != nil { + m.err = err + return + } + + if err := joinRoom(roomID, m.username, sharedSecret); err != nil { + m.err = err + return + } + + m.enterRoom(roomID, roomName) + _ = prekeySignature +} + +func (m *model) createNoteToSelf() { + roomID := generateRoomID() + roomName := "[Note to Self]" + + sharedSecret := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, sharedSecret); err != nil { + m.err = err + return + } + + if err := createRoom(roomID, roomName, m.username, true); err != nil { + m.err = err + return + } + + if err := joinRoom(roomID, m.username, sharedSecret); err != nil { + m.err = err + return + } + + m.enterRoom(roomID, roomName) +} + +func (m *model) enterRoom(roomID, roomName string) { + secret, err := getRoomSharedSecret(roomID, m.username) + if err != nil { + m.err = err + return + } + + joinedAt, err := getMemberJoinedAt(roomID, m.username) + if err != nil { + joinedAt = time.Now() // Fallback + } + + m.currentRoomID = roomID + m.currentRoomKey = secret + m.currentRoomJoinedAt = joinedAt + m.state = 2 + m.input.Placeholder = fmt.Sprintf("[%s] /back to menu, /leave to quit room", roomName) + m.loadMessages() +} + +func (m *model) handleChatInput(text string) { + m.saveMessage(m.username, text) +} + +func parseInt(s string) int { + var num int + fmt.Sscanf(s, "%d", &num) + return num +} + +func generateRoomID() string { + b := make([]byte, 16) + io.ReadFull(rand.Reader, b) + return base64.URLEncoding.EncodeToString(b) +} + +func (m *model) saveMessage(sender, text string) { + ct, nonce, err := encryptMsg(text, m.currentRoomKey) + if err != nil { + m.err = err + return + } + + err = saveMessage(m.currentRoomID, sender, ct, nonce) + if err != nil { + m.err = err + return + } + + go updates.broadcast() +} + +func (m *model) loadMessages() { + if m.currentRoomID == "" { + return + } + + dbMessages, err := loadMessages(m.currentRoomID) + if err != nil { + m.err = err + return + } + + var msgs []chatMsg + for _, dbMsg := range dbMessages { + plain, err := decryptMsg(dbMsg.Ciphertext, dbMsg.Nonce, m.currentRoomKey) + if err != nil { + plain = "[Decryption Failed]" + } + msgs = append(msgs, chatMsg{ + Timestamp: dbMsg.Timestamp, + Sender: dbMsg.Sender, + Content: plain, + }) + } + m.messages = msgs + m.updateViewport() +} + +func (m *model) updateViewport() { + var b strings.Builder + for _, msg := range m.messages { + dateStr := msg.Timestamp.Format("Jan 2 15:04") + + // Mask history before join time + // Add 1 second buffer to avoid hiding immediate first messages + if msg.Timestamp.Before(m.currentRoomJoinedAt.Add(-1 * time.Second)) { + b.WriteString(fmt.Sprintf("%s %s\n", + timeStyle.Render(dateStr), + lockedStyle.Render("[Encrypted message - History hidden]"), + )) + } else { + b.WriteString(fmt.Sprintf("%s %s: %s\n", + timeStyle.Render(dateStr), + senderStyle.Render(msg.Sender), + msgStyle.Render(msg.Content), + )) + } + } + m.viewport.SetContent(b.String()) + m.viewport.GotoBottom() +} + +func (m model) View() string { + if !m.authorized { + return fmt.Sprintf(` +%s +ACCESS DENIED +No public key authentication provided. + +(Press Ctrl+C to quit) +`, errStyle.Render("SECURITY ALERT")) + } + + if m.needsRegistration { + return fmt.Sprintf(` +%s +Welcome! You're a new user. +Please choose a username to register. + +%s +`, sysStyle.Render("SECURE TUI CHAT - REGISTRATION"), m.input.View()) + } + + if m.state == 0 { + return fmt.Sprintf(` +%s +Welcome back, %s. +This environment is encrypted at rest. +Please enter your passphrase to unlock your keys. + +%s +`, sysStyle.Render("SECURE TUI CHAT"), senderStyle.Render(m.username), m.input.View()) + } + + if m.state == 1 { + var b strings.Builder + b.WriteString(sysStyle.Render("=== YOUR ROOMS ===") + "\n\n") + if len(m.rooms) == 0 { + b.WriteString(sysStyle.Render("No rooms yet.") + "\n") + b.WriteString(commandStyle.Render(" /join ") + " - Join/Create room\n") + b.WriteString(commandStyle.Render(" /dm ") + " - Start a DM\n") + } else { + for i, room := range m.rooms { + var style lipgloss.Style + var prefix string + if strings.Contains(room.Name, "Note to Self") { + style = noteStyle + prefix = "[*]" + } else if room.IsDM { + style = dmStyle + prefix = "[DM]" + } else { + style = roomStyle + prefix = "[#]" + } + b.WriteString(fmt.Sprintf("%s %s %s\n", + senderStyle.Render(fmt.Sprintf("%d.", i+1)), + prefix, + style.Render(room.Name))) + } + b.WriteString("\n" + sysStyle.Render("Commands: ") + commandStyle.Render("/join /dm /list") + "\n") + } + b.WriteString("\n" + m.input.View()) + return b.String() + } + + if m.state == 2 { + return fmt.Sprintf("%s\n%s", m.viewport.View(), m.input.View()) + } + + return m.input.View() +} diff --git a/internal/server.go b/internal/server.go new file mode 100644 index 0000000..79e2248 --- /dev/null +++ b/internal/server.go @@ -0,0 +1,45 @@ +package internal + +import ( + "fmt" + "log" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/ssh" + "github.com/charmbracelet/wish" + lm "github.com/charmbracelet/wish/logging" +) + +func NewServer(host string, port int) (*ssh.Server, error) { + srv, err := wish.NewServer( + wish.WithAddress(fmt.Sprintf("%s:%d", host, port)), + wish.WithHostKeyPath(".ssh/term_info_ed25519"), + wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { + return true + }), + wish.WithMiddleware( + appMiddleware, + lm.Middleware(), + ), + ) + return srv, err +} + +func appMiddleware(sh ssh.Handler) ssh.Handler { + return func(s ssh.Session) { + _, _, active := s.Pty() + if !active { + wish.Fatalln(s, "no active terminal, skipping") + return + } + + m := initialModel(s) + p := tea.NewProgram(m, tea.WithInput(s), tea.WithOutput(s), tea.WithAltScreen()) + + if _, err := p.Run(); err != nil { + log.Println("Error running program:", err) + } + + updates.unsubscribe(m.updateChan) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..2f3b936 --- /dev/null +++ b/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "context" + "errors" + "log" + "os" + "os/signal" + "syscall" + "time" + + "teachat/internal" + + "github.com/charmbracelet/ssh" +) + +const ( + port = 23234 + host = "0.0.0.0" +) + +func main() { + internal.InitDB() + + srv, err := internal.NewServer(host, port) + if err != nil { + log.Fatalln(err) + } + + done := make(chan os.Signal, 1) + signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + + log.Printf("Starting SSH server on %s:%d", host, port) + go func() { + if err = srv.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { + log.Fatalln(err) + } + }() + + <-done + log.Println("Stopping server...") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Fatalln(err) + } +}