A single-binary, terminal-based chat app in Go. One user (the admin) configures and spawns a server on a chosen port with a passphrase. Anyone with the address + passphrase joins from their own terminal into a shared IRC-style room. All messages on the wire are encrypted with a key derived from the passphrase. Heavy emphasis on a fancy, fully themeable TUI — the admin brands the server (name, logo, colors) and every connected client renders that branding.
Same build style as ZTF666/Vanadinite: Go + Bubble Tea + Lip Gloss.
| Concern | Library |
|---|---|
| TUI loop | github.com/charmbracelet/bubbletea |
| Styling | github.com/charmbracelet/lipgloss |
| Components (textinput, viewport, list) | github.com/charmbracelet/bubbles |
| KDF | golang.org/x/crypto/argon2 |
| AEAD cipher | golang.org/x/crypto/chacha20poly1305 (use XChaCha20-Poly1305) |
| Config | github.com/BurntSushi/toml |
| Emoji shortcodes | github.com/enescakir/emoji |
| ASCII banner | github.com/common-nighthawk/go-figure |
| Runewidth (alignment) | github.com/mattn/go-runewidth |
Do not hand-roll any crypto. Use only the vetted primitives above.
Two modes in one binary:
shellchat serve # admin: run setup wizard (if no config) then start server
shellchat join # client: prompt for host:port + passphrase + username, connect
- Transport: plain TCP. Server
net.Listen, goroutine per client, in-memory broadcast hub (channel fan-out to all connected clients). - Framing: length-prefixed frames — 4-byte big-endian
uint32length, then payload. Every payload after the handshake is a ciphertext blob. - Relay model: server receives an encrypted frame from one client and forwards the same ciphertext to all others. Clients decrypt locally.
- The passphrase-derived key is a shared symmetric key. It protects messages against network eavesdroppers and anyone who lacks the passphrase.
- It is not true per-peer E2E: the admin (server operator) holds the same key and could decrypt traffic. The relay code forwards ciphertext without decrypting, but the threat model is "confidential against outsiders," not "server can't read." Document this plainly — no false promises.
- There is no forward secrecy. A leaked passphrase exposes past captured traffic. Acceptable for v1; a future Noise-protocol upgrade path can add it.
Salt is public and sent in the clear; the passphrase never crosses the wire.
- Client opens TCP connection.
- Server → client (plaintext frame): server metadata as JSON —
{ server_name, motd, theme, banner, argon2_salt (base64), challenge (32 random bytes, base64) }. The theme/banner travel here so the client renders the admin's branding. - Client prompts for passphrase (unless supplied via flag), derives
key = Argon2id(passphrase, salt, t=3, m=64MB, p=4, keyLen=32). - Client → server (encrypted frame): XChaCha20-Poly1305 seal of
{ challenge, username }. Knowing the key is the proof of passphrase. - Server opens the frame with its own derived key. If the AEAD tag verifies and the returned challenge matches → authenticated; register client, broadcast a join system-message. Otherwise close the connection (optionally after a fixed delay to blunt guessing).
Every chat frame is an XChaCha20-Poly1305 sealed JSON object:
{ "type": "msg|join|leave|system", "user": "ztf", "body": "hey :tada:", "ts": 1737600000 }- Nonce: 24-byte random per message, prepended to the ciphertext (XChaCha's 192-bit nonce makes random generation collision-safe).
- Server relays
type:"msg"ciphertext untouched. Server generatesjoin/leave/systemframes itself (sealed with the same key) so all clients render them consistently. - Enforce
max_message_length(config) client-side before send and server-side on receive.
Single config.toml next to the binary. Two ways to produce it:
- First-run setup wizard (TUI) when
shellchat servefinds no config — walks the admin through every option below, writes the file. - Hand-edit for power users.
config.toml shape:
[server]
name = "ZTF's Deck"
motd = "welcome to the wire, choom"
port = 6667
passphrase = "correct-horse-battery" # admin-set; used to derive the key at boot
max_message_length = 2000
scrollback = 500
[display]
timestamps = true
clock = "24h" # "12h" | "24h"
join_leave_msgs = true
username_colors = "hash" # "hash" | "fixed"
prompt_glyph = "❯"
[theme]
preset = "meatspace" # "meatspace" | "vanadinite" | "custom"
# when preset = "custom", these override:
bg = "#0a0a0f"
fg = "#663399"
accent = "#39ff14"
username = "#00f5ff"
system_msg = "#cc99ff"
timestamp = "#ffcc00"
error = "#ff2d78"
success = "#39ff14"
border = "#663399"
border_style = "rounded" # rounded | thick | double | normal | hidden
[banner]
mode = "figlet" # "figlet" (generate from name) | "file"
file = "banner.txt" # used when mode = "file"; ANSI/ASCII splash
[emoji]
custom_file = "emoji.toml" # optional custom shortcode → glyph aliasesEvery color is a Lip Gloss style resolved from [theme]. Ship two built-in presets; custom reads the explicit hex values.
Preset: meatspace (default)
bg #0a0a0f fg #663399 accent #39ff14 username #00f5ff
system #cc99ff timestamp #ffcc00 error #ff2d78 border #663399
Preset: vanadinite
accent #cc0000 secondary #ff2b47 (dark bg, red-forward — mirror the Vanadinite TUI palette)
border_stylemaps to Lip Gloss's built-in border sets.username_colors = "hash"→ hash each username to a stable color from the palette (classic IRC look);"fixed"→ everyone usestheme.username.- Server pushes the resolved theme in the handshake so all clients see the admin's branding, not their local defaults.
- Shortcodes: run every outgoing/rendered
bodythrough the emoji lib so:tada:→ 🎉. Restrict to a single-glyph set — strip/skip any multi-codepoint or ZWJ sequences (👨👩👧, flags, skin-tone joins) to keep terminal column math correct. :autocomplete: when the user types:in the input, show a filtered Bubbleslistpopup of matching shortcodes; Tab/Enter inserts.- Custom aliases: optional
emoji.toml—shrug = "¯\\_(ツ)_/¯",ztf = ""(Nerd Font glyphs welcome). Merge over the built-in map at load. Custom entries must also resolve to single-width/single-glyph output. - Use
go-runewidtheverywhere widths matter (alignment, truncation, input cursor).
Setup wizard (serve, first run): stepped screens — branding (name/motd/banner) → port → passphrase → theme preset picker (live preview pane) → display toggles → write config → start server.
Chat screen (join & serve host view):
┌─ <banner / server_name> ───────────────────────────┐
│ <scrollable message viewport> │
│ 12:04 ztf hey :tada: │
│ 12:04 » maroua joined │
│ │
├─────────────────────────────────────────────────────┤
│ ❯ <textinput> [users: 3] │
└─────────────────────────────────────────────────────┘
- Bordered layout via Lip Gloss (
border_stylefrom theme). - Viewport = scrollback (config-capped), timestamps per
[display]. - Input line with prompt glyph + emoji autocomplete popup.
- Small user-count / connected indicator.
- Ctrl+C quits (send
leave, close cleanly); PgUp/PgDn scroll.
- Config + TOML load/write + setup wizard.
- TCP server/hub + client dial, plaintext handshake JSON.
- Argon2id KDF + XChaCha20-Poly1305 seal/open + challenge auth.
- Encrypted message relay, join/leave/system frames.
- Bubble Tea chat UI (viewport + input + borders).
- Theming: presets, custom overrides, server-pushed theme.
- Emoji: shortcodes, single-glyph filter,
:autocomplete, custom aliases. - Banner (figlet + file mode), polish, README with the honest security note.
- No inline images (Kitty/Sixel) — banner is static ANSI only.
- No true per-peer E2E / forward secrecy (documented Noise upgrade path later).
- No multi-room/channels — one room per server.
- No message persistence — in-memory scrollback only, nothing written to disk.
- No file transfer, no DMs.
Single Go module, go build → one binary. README.md covering install, serve/join usage, config reference, and the honest security model from §3.1.