A local, end-to-end encrypted terminal chat client for trusted peers on a LAN.
terminal_chat is a single-binary Rust TUI application that lets two (or more)
people exchange messages over a local network with end-to-end encryption,
content-addressed sync, and crash-safe append-only storage. There is no
server, no account, and no cloud — peers discover each other on the LAN and sync
directly over TCP. Every message is encrypted per-message with
ChaCha20-Poly1305 using a key
derived from an X25519 key exchange.
- Why this exists
- Feature summary
- Architecture
- Cryptography & threat model
- Wire protocol
- Data model
- Build & run
- Usage
- Commands
- Security posture
- Known limitations
- Project layout
- Testing
- Future work
Most "encrypted chat" apps are centralized: messages pass through a server that
terminates TLS, so the provider (or anyone who compromises it) can read
everything. terminal_chat is deliberately the opposite:
- No server. Peers talk directly over TCP on the LAN.
- No accounts. Identity is a locally generated X25519 keypair; "your chat code" is just the hash of your public key, shown in the UI.
- End-to-end encryption by construction. The plaintext is encrypted on the sender with a key only the two endpoints share; the relay layer never sees it.
- Verifiable history. Every state change is an immutable, content-addressed event. Sync is idempotent and convergent — two peers that have seen the same events always reconstruct the same conversation.
It is built for the narrow, well-understood case of two people who already trust each other, on a network they control (home WiFi, a wired VLAN, etc.).
| Feature | Notes |
|---|---|
| End-to-end encryption | X25519 ECDH → ChaCha20-Poly1305, per message |
| LAN discovery | UDP broadcast beacons; peers auto-populate an endpoint table |
| Direct sync | TCP session on port 7742 (configurable constant) |
| Editor-style TUI | ratatui + crossterm; bubble layout, scrollback, persistent help bar |
| Edit / delete messages | Edit in place; delete is a two-phase, peer-approved operation |
| Crash-safe storage | SQLite with an append-only event log; history is replayed, never mutated |
| Offline-first | Messages are stored locally; peers reconcile on the next connection |
| Per-message delivery tracking | Outbox tracks which peers have received/acknowledged each message |
The binary is organized as a small set of cohesive modules. State flows in one direction: user input / network events → event log (SQLite) → replay → UI.
┌─────────────────────────────────────────────┐
│ main.rs │
│ CLI parsing, identity bootstrap, task spawn │
└──────────────┬──────────────┬───────────────┘
│ │
┌─────────────▼───┐ ┌──────▼──────────────┐
│ net.rs │ │ ui.rs │
│ listener/ │ │ TUI event loop, │
│ discovery/ │◄───┤ input handling, │
│ background_sync │ │ rendering, cmds │
└────────┬────────┘ └──────┬──────────────┘
│ │
┌─────────▼─────────┐ ┌──────▼──────────────┐
│ sync.rs │ │ engine.rs │
│ encrypted frame │ │ replay(), send_, │
│ exchange (Hello/ │ │ edit_, delete_* │
│ Events/Ack/Bye) │ │ approval logic │
└─────────┬─────────┘ └──────┬──────────────┘
│ │
┌──────────▼──────────┐ ┌──────▼──────────────┐
│ crypto.rs │ │ storage.rs │
│ X25519 identity, │ │ SQLite schema, │
│ session key, codes │ │ event log, peers, │
│ │ │ delivery receipts │
└──────────┬──────────┘ └──────┬──────────────┘
│ │
└─────────┬──────────┘
┌────────▼─────────┐
│ types.rs │
│ EventType, Wire, │
│ MessageView, IDs │
└──────────────────┘
main.rs— Entry point. Resolves the database path (XDG/~/.local/share), boots the local X25519 identity, parses the CLI subcommand (new/join/ defaultopen), and spawns the three network tasks plus the UI task.crypto.rs— All key material. Generates and persists the X25519 keypair, derives the per-peer session key via ECDH, and encodes/decodes the human-shareable chat code (blake3(pubkey)).net.rs— Networking.listeneraccepts inbound TCP;discoverysends and receives UDP beacons;background_syncperiodically reconnects to known and desired peers;sessionperforms the handshake + trust decision + handoff tosync::run_sync.sync.rs— The encrypted session loop. After the handshake, peers exchangeHello(with the set of known event ids),Events(missing events), andAckframes, all encrypted with the session key.engine.rs— Conversation logic.replay()reconstructs the visible message list from the event log;send_message/edit_message/request_delete/approve_delete/reject_deleteappend new events.storage.rs— SQLite access. Owns the schema and all queries against the append-only event log, thepeerstable, anddelivery_receipts.ui.rs— TheratatuiTUI: input line, message bubble list (own messages right-aligned/cyan, peer messages left-aligned/green), scrollback, and a persistent command-help bar.types.rs— Shared types:EventType(the canonical event enum),StoredEvent, theWireframe enum,MessageView(UI projection), and theevent_idcontent hash.
The app uses a single owner of the database guarded by an Arc<Mutex<AppDb>>.
The async network tasks (listener, discovery, background_sync) and the
blocking UI task (ui::run on a spawn_blocking) all share this handle. The UI
task is the only one that reads for display; network tasks write events as they
arrive. This keeps SQLite access single-threaded (SQLite is not used in
SQLITE_CONFIG_MULTITHREAD mode here) and avoids the classic "database locked"
races.
| Purpose | Primitive | Source |
|---|---|---|
| Long-term identity | X25519 keypair (Curve25519) | x25519-dalek |
| Key agreement | X25519 ECDH → 32-byte shared secret | crypto::Identity::session_key |
| Message encryption | ChaCha20-Poly1305 (AEAD) | chacha20poly1305 |
| Identity / event fingerprint | BLAKE3 | blake3 |
The session key for a peer is H where H = scalarmult(my_secret, peer_pub),
the standard X25519 shared secret. Each message body is encrypted as
ChaCha20Poly1305(key, nonce, plaintext) with a unique nonce. The AEAD tag
authenticates ciphertext and the associated context (sender, message id),
so a message cannot be silently altered or replayed across conversations.
Your "chat code" is blake3(public_key) rendered as hex groups. Because it is a
hash of the public key, the code is bound to the key: anyone who can present
a valid code also holds (or can derive) the corresponding public key, and the
session verifies that the device_id claimed in the handshake equals
blake3(peer_pub). A peer cannot claim an identity it does not own the key for.
- Confidentiality. Message bodies are encrypted end-to-end; the relay/network only ever sees ciphertext + AEAD tags.
- Integrity & authenticity. ChaCha20-Poly1305 rejects any tampered or forged message. The associated data binds each ciphertext to its sender and message id.
- Replay / reordering. Events are content-addressed; re-delivering an event
is a no-op (same
event_id⇒ already stored). - History tampering. The event log is append-only; "edits" and "deletes" are new events, not mutations. Replaying the same log always yields the same conversation.
- Identity spoofing. The handshake rejects any
device_idthat does not hash from the presenter's actual public key, and trust for outbound connections is gated by an explicit join code.
- Transport metadata. IP addresses, ports, and the set of event ids you know are visible to anyone monitoring the LAN. This is metadata, not content.
- First-connector on open WiFi. A host running
newauto-trusts the first inbound peer when it has zero trusted peers. On a hostile/open network an attacker who connects first becomes that peer. Mitigation: only runnewon a network where you expect your partner, or exchange codes both ways. (An explicit:trustconfirmation step would close this entirely — see Future work.) - Endpoint spoofing in beacons. Discovery beacons are unauthenticated. A
trusted peer's endpoint (IP) is only overwritten from a beacon that matches
an already-trusted
device_id; an untrusted broadcaster cannot hijack routing. - Forward secrecy. The session key is static per peer pair (derived from long-term keys). If a private key is later compromised, historical messages encrypted under that key can be decrypted. Rotating identity (deleting the local DB) is the current remedy.
After TCP connect, both sides exchange a plaintext PlainHello (device id +
public key + listen port) for key agreement only — this is not encrypted
because no shared key exists yet, and it carries no message content. Once the
session key is derived, all further frames are Wire messages encrypted with
ChaCha20-Poly1305.
Peer A ── PlainHello{id_A, pub_A} ───────────────► Peer B
Peer A ◄── PlainHello{id_B, pub_B} ──────────────── Peer B
(both derive session_key = ECDH(secret, peer_pub))
Peer A ── [encrypted] Hello{event_ids_A} ────────► Peer B
Peer A ◄── [encrypted] Events{missing for A} ────── Peer B
Peer A ── [encrypted] Ack{received ids} ──────────► Peer B
... repeated as new events arrive ...
Peer A ── [encrypted] Bye ────────────────────────► Peer B
Wire frame shapes (see types.rs):
enum Wire {
Hello { device_id, pubkey, listen_port, event_ids }, // what I already have
Events { events: Vec<StoredEvent> }, // here is what you're missing
Ack { event_ids }, // got these, thanks
Bye,
}Sync is idempotent: each side sends only the events whose event_id the
other side does not yet list in its Hello. Re-sending an event is harmless.
All durable state lives in a single SQLite file at
$XDG_DATA_HOME/terminal_chat/terminal_chat.sqlite3
(~/.local/share/terminal_chat/terminal_chat.sqlite3 by default).
The source of truth is the events table. Every mutation to the conversation is
an immutable row:
| Column | Meaning |
|---|---|
event_id |
blake3(canonical_payload) — content address, primary key |
author_id |
device id of the peer who emitted the event |
created_at |
unix seconds |
payload |
bincode-serialized EventType |
EventType variants:
MessageCreated { message_id, author_id, body, created_at }MessageEdited { message_id, body, edited_at }DeleteRequested { message_id, requester, created_at }DeleteApproved { message_id, approver, at }DeleteRejected { message_id, rejecter, at }MessageDeleted { message_id, at }
Deleting a message is not immediate. The author emits DeleteRequested;
peers may DeleteApproved or DeleteRejected. A message is actually marked
deleted only when at least one peer other than the requester has approved
and the request has not expired (DELETE_TTL_SECS = 15 min). This prevents a
single peer from unilaterally erasing shared history, and it is what makes the
delete converge correctly across offline peers (see the
two_phase_delete_converges test).
peers(device_id, public_key, shared_key, trusted_at)— trusted identities and their derived session keys.delivery_receipts(message_id, peer_id, received_at)— which peers have received a given message (outbox tracking).pending_deliveries(message_id, peer_id, state, updated_at)— per-peer delivery state machine (Queued→Sending→Delivered→Acknowledged).connection_log(...)— audit trail of connection attempts and trust decisions (including rejections), useful for spotting spoofing attempts.
The UI never reads mutable state directly; it always calls engine::replay(),
which folds the event log into a Vec<MessageView>. This makes the displayed
conversation a pure function of the log.
- Rust toolchain 1.74+ (edition 2021). Install via rustup.
- A Unix-like OS (Linux/macOS). The TUI uses
crosstermand expects a true-color terminal.
git clone <this-repo> terminal_chat
cd terminal_chat
cargo build --release # optimized binary in target/release/terminal_chat
cargo build # debug buildrusqlite is built with the bundled feature, so you need a C toolchain for
the first compile (SQLite is compiled in). No system SQLite required.
# Open an existing (or create a fresh) session and show your chat code:
cargo run -- open
# or just:
cargo run
# Create a new session (generates a fresh identity + chat code):
cargo run -- new
# Join someone by their chat code (optionally with ip:port):
cargo run -- join <CHAT_CODE> [ip:port]The chat code is also displayed in the TUI header once running, so the other person can copy it.
- Person A runs
terminal_chat new(or justopen). Their chat code appears in the header. - Person B runs
terminal_chat join <A's code>. On the same LAN, discovery will also surface A automatically; the code pins B's trust to A's key. - Both type messages. They sync live over TCP; if one goes offline, messages are queued locally and reconciled on reconnect.
- On a shared LAN, UDP beacons let peers find each other without exchanging addresses. Beacons are unauthenticated (they carry no content), so they only populate a candidate endpoint list — trust still happens over the authenticated TCP session.
join <code> [ip:port]is the explicit, key-pinned path and is the recommended way to connect across subnets or when you want to be certain about who you are talking to.
Inside the TUI, type a message and press Enter to send. Commands start with
: and take a 1-based message index n (the number shown next to each message):
| Command | Effect |
|---|---|
:d n |
Request deletion of message n (peer-approved, see above) |
:appr n |
Approve a pending delete request for message n |
:rej n |
Reject a pending delete request for message n |
:e n <text> |
Edit message n in place (broadcasts a MessageEdited) |
Ctrl-C |
Quit |
Navigation: ↑ / ↓ scroll the message history. A persistent help bar at the bottom shows these bindings.
This project aims for demonstrable, minimal, correct E2E encryption rather than a formally verified protocol. The following properties hold by construction and are covered by tests:
- Identity ↔ key binding. A peer's claimed
device_idmust equalblake3(peer_pub)or the session is rejected (id_matches_key). - Outbound trust is code-gated. Dialling out with no join code and no known peers is rejected; only an inbound first-connection auto-trusts (LAN pairing convenience).
- No silent trust of beacons. A discovery beacon cannot overwrite a trusted peer's routing endpoint.
- Strict hex parsing. Malformed chat codes / keys error out instead of being coerced to zero bytes.
- Immutable history. Edits/deletes are new events; replay is deterministic.
cargo testpairing_and_message_sync— two peers pair (one via join code) and exchange a message that converges on both sides.two_phase_delete_converges— a delete request + approval converges to "deleted" on both peers, including after offline replay.chat_code_roundtrip— the chat code encodes/decodes the public key exactly.id_key_binding_rejects_mismatch— a spoofeddevice_idfails the key check.outbound_without_join_target_rejects_stranger— outbound auto-trust is blocked; inbound first-pairing is still permitted.join_target_gates_outbound_trust— a join code correctly gates outbound trust to the matching id.date_conversion_*— the message timestamp formatter is correct across leap years and century boundaries (regression-guards a prior off-by-one).
- LAN-only by design. No NAT traversal, relay, or public routing. Use a VPN or tailnet if peers are not on the same broadcast domain.
- No forward secrecy (see threat model).
- Host-side first-connector trust on open networks (mitigated, not removed).
- Single local identity per data directory. To be a different person, point at a different DB / machine.
ratatui+crosstermrequire a real terminal; it will not run inside a non-TTY (e.g. pipedCIwithout a pty).- No file/media transfer — text only.
- UI is the only reader of display state; there is no separate API surface.
terminal_chat/
├── Cargo.toml # deps: tokio, rusqlite(bundled), ratatui, crossterm,
│ # x25519-dalek, chacha20poly1305, blake3, bincode
├── src/
│ ├── main.rs # CLI, identity bootstrap, task spawning, integration tests
│ ├── crypto.rs # X25519 identity, session key, chat-code encode/decode
│ ├── net.rs # listener / discovery / background_sync / session / trust
│ ├── sync.rs # encrypted frame exchange loop
│ ├── engine.rs # replay + send/edit/delete/approval logic
│ ├── storage.rs # SQLite schema + queries
│ ├── ui.rs # ratatui TUI, input handling, rendering, timestamp fmt
│ └── types.rs # EventType, Wire, MessageView, event_id, constants
└── README.md
| Constant | Value | Meaning |
|---|---|---|
DEFAULT_PORT |
7742 |
TCP listen / sync port |
DELETE_TTL_SECS |
15 * 60 |
delete-request validity window |
cargo test # all unit + integration tests
cargo clippy --all-targets # lint (zero errors expected)
cargo build --release # optimized buildThe integration tests spin up two in-memory AppStates over tokio::io::duplex
streams, so the full handshake → trust → sync → replay path is exercised without
real sockets or a TTY.
- Explicit
:trustconfirmation. Replace host-side first-connector auto-trust with an interactive approval, closing the open-WiFi gap completely. - Forward secrecy. Ephemeral per-session X25519 keys (Double-Ratchet style) so compromised long-term keys cannot decrypt history.
- Group chat. Move from pairwise session keys to a group key or a sender-keys scheme; the event-log model already supports N peers.
- Deniable / rotating identities. One-click "new identity" that re-keys and rotates the local DB.
- Encrypted at rest. Optionally encrypt the SQLite file or the event payloads on disk.
- Media & rich text. The
EventTypeenum is extensible; aMessageCreated { kind: Media }variant would slot in without breaking sync.
See repository license file. (Local, end-to-end encrypted, no telemetry, no phoning home — by design.)