Skip to content

adiraj66132/local_network_chat

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

terminal_chat

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.


Table of contents


Why this exists

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 summary

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

Architecture

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 │
                                   └──────────────────┘

Module responsibilities

  • main.rs — Entry point. Resolves the database path (XDG/~/.local/share), boots the local X25519 identity, parses the CLI subcommand (new / join / default open), 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. listener accepts inbound TCP; discovery sends and receives UDP beacons; background_sync periodically reconnects to known and desired peers; session performs the handshake + trust decision + handoff to sync::run_sync.
  • sync.rs — The encrypted session loop. After the handshake, peers exchange Hello (with the set of known event ids), Events (missing events), and Ack frames, 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_delete append new events.
  • storage.rs — SQLite access. Owns the schema and all queries against the append-only event log, the peers table, and delivery_receipts.
  • ui.rs — The ratatui TUI: 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, the Wire frame enum, MessageView (UI projection), and the event_id content hash.

Concurrency model

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.


Cryptography & threat model

Primitives

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.

Chat code

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.

What is protected

  • 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_id that does not hash from the presenter's actual public key, and trust for outbound connections is gated by an explicit join code.

What is NOT protected (read before deploying)

  • 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 new auto-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 run new on a network where you expect your partner, or exchange codes both ways. (An explicit :trust confirmation 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.

Wire protocol

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.


Data model

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).

Append-only event log

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 }

Two-phase delete

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).

Other tables

  • 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 (QueuedSendingDeliveredAcknowledged).
  • 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.


Build & run

Requirements

  • Rust toolchain 1.74+ (edition 2021). Install via rustup.
  • A Unix-like OS (Linux/macOS). The TUI uses crossterm and expects a true-color terminal.

Build

git clone <this-repo> terminal_chat
cd terminal_chat
cargo build --release          # optimized binary in target/release/terminal_chat
cargo build                     # debug build

rusqlite is built with the bundled feature, so you need a C toolchain for the first compile (SQLite is compiled in). No system SQLite required.

Run

# 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.


Usage

  1. Person A runs terminal_chat new (or just open). Their chat code appears in the header.
  2. 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.
  3. Both type messages. They sync live over TCP; if one goes offline, messages are queued locally and reconciled on reconnect.

Discovery vs. manual join

  • 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.

Commands

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.


Security posture

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_id must equal blake3(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.

Tests that lock these in

cargo test
  • pairing_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 spoofed device_id fails 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).

Known limitations

  • 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 + crossterm require a real terminal; it will not run inside a non-TTY (e.g. piped CI without a pty).
  • No file/media transfer — text only.
  • UI is the only reader of display state; there is no separate API surface.

Project layout

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

Key constants (types.rs)

Constant Value Meaning
DEFAULT_PORT 7742 TCP listen / sync port
DELETE_TTL_SECS 15 * 60 delete-request validity window

Testing

cargo test            # all unit + integration tests
cargo clippy --all-targets   # lint (zero errors expected)
cargo build --release  # optimized build

The 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.


Future work

  • Explicit :trust confirmation. 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 EventType enum is extensible; a MessageCreated { kind: Media } variant would slot in without breaking sync.

License

See repository license file. (Local, end-to-end encrypted, no telemetry, no phoning home — by design.)

About

chat in terminal (only for powerusers)

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages