Serverless LAN chat with authenticated end-to-end encryption. Peers find each other over UDP broadcast, authenticate with long-term Ed25519 identity keys, and exchange messages over an AES-256-GCM channel keyed by an ephemeral X25519 exchange. No server, no accounts, no bootstrap node.
$ p2pchat --username ilker
· you are ilker — fingerprint 34B3 9D0C 9237 34EA
listening on tcp/6001, announcing on udp/6000
· ayse appeared on the network (192.168.1.24)
· connected to ayse (192.168.1.24) — encrypted
first contact; remembering fingerprint B572 1B92 D5B4 9353
session code 076717 — compare it with them
[14:32] ayse: protokolü bitirdin mi?
[ayse] > _
git clone https://github.com/IlkerGcr/p2p-chat.git
cd p2p-chat
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e .
p2pchat --username ilkerRun it on two machines on the same network. They will discover each other within a
few seconds. /chat <user> opens a session, then type to send.
| Command | |
|---|---|
/users |
peers discovered on the network |
/chat <user> |
open an encrypted session and make it active |
/msg <user> <text> |
send one message without switching chats |
/verify [user] |
show the session code and fingerprints |
/trust <user> |
accept a changed fingerprint after checking it |
/sessions |
list open sessions |
/history <user> |
show stored history (requires --save-history) |
/whoami |
your own fingerprint |
Useful flags: --chat-port, --discovery-port, --broadcast, --state-dir,
--save-history, -v. Every setting also reads from a P2PCHAT_* environment
variable.
Everything runs on a single asyncio event loop. There are no locks anywhere in the codebase because there is no preemption to guard against.
flowchart TD
CLI["cli.py<br/>prompt_toolkit UI"]
NODE["node.py<br/>ChatNode"]
DISC["discovery.py<br/>UDP announce + listen"]
REG["peers.py<br/>PeerRegistry"]
SESS["session.py<br/>handshake + Session"]
PROTO["protocol.py<br/>length-prefixed frames"]
CRYPTO["crypto.py<br/>Ed25519 · X25519 · HKDF · AES-GCM"]
TRUST["trust.py<br/>TOFU fingerprints"]
HIST["history.py<br/>optional JSONL log"]
CLI -->|commands| NODE
NODE -->|events| CLI
NODE --> DISC
DISC --> REG
NODE --> REG
NODE --> SESS
NODE --> TRUST
NODE --> HIST
SESS --> PROTO
SESS --> CRYPTO
Discovery — every peer broadcasts {username, chat_port, fingerprint} on
udp/6000 every few seconds. The socket sets SO_REUSEADDR (and SO_REUSEPORT where
it exists) so several instances can share a port on one machine. Announcements are
unauthenticated and treated only as a hint about where to connect.
Framing — TCP is a byte stream, so every message is prefixed with a 4-byte
big-endian length and capped at 1 MiB. The reader uses readexactly, which means a
message split across segments or two messages in one segment both work.
Sessions — a connection is opened once, authenticated once, then carries traffic in both directions until either side hangs up. A background task per session reads frames as they arrive, so replies show up while you are typing.
Three messages, a SIGMA-style authenticated exchange:
initiator responder
| HELLO id_a, eph_a, nonce_a, username |
| -------------------------------------------------> |
| HELLO_ACK id_b, eph_b, nonce_b, sig_b |
| <------------------------------------------------- |
| HELLO_FIN sig_a |
| -------------------------------------------------> |
id_xis a long-term Ed25519 public key, generated once and stored in the state directory. Its SHA-256 prefix is the fingerprint users compare.eph_xis a per-session X25519 public key. Compromising an identity key later does not decrypt traffic recorded today — forward secrecy.sig_xsigns the transcript: a SHA-256 hash covering both identities, both ephemeral keys and both nonces, each length-prefixed so no two different inputs can hash alike. Binding the ephemerals is what stops a relay from splicing two handshakes together.- Traffic keys come from
HKDF-SHA256(X25519_output, salt=transcript), split into one key per direction. Records are sealed with AES-256-GCM, nonce = a strictly increasing counter, AAD = the transcript. The receiver rejects any sequence number it has already accepted, so replays fail closed.
Both sides also derive a six-digit session code from the transcript. If the codes match when read aloud, nothing relayed the handshake.
Honest about the boundaries, because "encrypted" on its own means very little.
Protected against
- Passive eavesdropping on the LAN — traffic is AES-256-GCM sealed.
- Tampering and replay — GCM authentication plus per-direction sequence numbers.
- Retroactive decryption after identity-key theft — ephemeral X25519 per session.
- Impersonating a contact you have talked to before — the trust store records the fingerprint on first use and warns loudly if it ever changes.
- A hostile host flooding malformed announcements or frames — every field is validated and oversized frames are refused before allocation.
Not protected against
- First-contact MITM. Nothing binds a fingerprint to a human the first time you meet. Compare the session code or fingerprint out of band; that is what those features are for. This is the key distribution problem, and it is not solvable inside the program.
- Traffic analysis. Message sizes and timing are visible. There is no padding.
- Denial of service. Anyone on the LAN can spam broadcasts or open connections.
- A compromised endpoint. History, if enabled, is written in plaintext, and the
identity key sits on disk unencrypted (
0600where the OS honours it). - Anything beyond the local network. Discovery is broadcast-based by design.
pip install -e ".[dev]"
pytest # 101 tests
ruff check src tests
mypy # strictThe suite covers framing against split and coalesced TCP segments, transcript binding, replay and tamper rejection, forged-signature rejection, malformed announcements, trust-store verdicts, and two live nodes exchanging messages over loopback — including that one stalled client cannot block the listener.
MIT