A terminal chat where only the sender and receiver can read a message. A relay server routes traffic between clients but holds no key and never sees plaintext. Key exchange is an authenticated ephemeral X25519 Diffie-Hellman signed with long-term Ed25519 identities; messages are encrypted with AES-256-GCM. No cloud provider, no external service, no telemetry.
Scope: educational, defensive + offensive. This project is for learning applied end-to-end cryptography and the honest limits of it. Run it only on your own machines and lab networks. The
offensive/module (a malicious-relay eavesdrop demo and an active man-in-the-middle demo) exists to prove what the design does and does not protect. Attacking traffic you do not own is illegal.
- Real end-to-end encryption: the relay routes ciphertext it cannot read. An integration test and the eavesdrop demo both assert that not one plaintext byte reaches the server.
- Authenticated key exchange: ephemeral X25519 for forward secrecy, signed by Ed25519 identities so an active relay cannot silently swap keys, plus Signal-style safety numbers for out-of-band human verification.
- Honest threat modelling: the README and the eavesdrop demo state plainly the metadata the relay still sees (who, when, how much) — the real limit of E2E.
- A measurable security posture: an eval harness proves, in numbers on your machine, 100% handshake agreement, 100% MITM detection, and message throughput.
python3 -m venv .venv
PYTHONPATH= .venv/bin/pip install -r requirements.txt # runtime
# or, to run the tests too:
PYTHONPATH= .venv/bin/pip install -r requirements-dev.txtThe only runtime dependency is cryptography (X25519,
Ed25519, HKDF-SHA256, AES-256-GCM). Networking is stdlib asyncio.
Note on
PYTHONPATH: if you have a ROS (or similar) environment sourced in your shell, it can leak site-packages and a broken pytest plugin into the venv. Run Python and pip with a cleanPYTHONPATH, as shown throughout this README.
Open three terminals. First the relay:
PYTHONPATH= .venv/bin/python chat.py serve # 127.0.0.1:8765Then two clients in the same room:
PYTHONPATH= .venv/bin/python chat.py chat --name alice --room lab
PYTHONPATH= .venv/bin/python chat.py chat --name bob --room labEach client prints, once a session is established, a safety number. Alice and Bob compare their safety numbers over another channel (read them aloud). If the two strings match, no man-in-the-middle sat between them. Then type and send:
* secure session with bob [a1b2 c3d4 ...]
* verify safety number out-of-band: 4e323 7c584 5fd21 30123 2bcc8 1c76e 33
alice: hello bob
bob: hi alice
In-chat commands: /peers (list peers and their safety numbers), /quit.
Your long-term identity key is created on first run at ~/.e2echat/identity.key
(mode 0600). Override the location with --identity PATH or $E2ECHAT_HOME.
Print your fingerprint any time:
PYTHONPATH= .venv/bin/python chat.py id alice (client) relay (server) bob (client)
┌───────────┐ register/join ┌──────────┐ register/join ┌───────────┐
│ identity │ ────────────────► │ rooms + │ ◄──────────────── │ identity │
│ (Ed25519) │ │ presence │ │ (Ed25519) │
│ │ signed X25519 │ (routes │ signed X25519 │ │
│ handshake │ ═══ offer (HS) ══►│ opaque │◄══ offer (HS) ════│ handshake │
│ │ │ frames, │ │ │
│ session │ AES-256-GCM │ holds │ AES-256-GCM │ session │
│ (per peer)│ ─── MSG blob ────►│ no key) │──── MSG blob ────►│ (per peer)│
└───────────┘ └──────────┘ └───────────┘
the relay sees: identities, fingerprints, rooms, sizes, timing
the relay never sees: plaintext, session keys, ephemeral privates
| Directory | Concern |
|---|---|
crypto/ |
AEAD (AES-256-GCM), Ed25519 identity, signed X25519 handshake, session. |
protocol/ |
The wire contract: length-prefixed framing and the JSON envelope types. |
server/ |
The relay: register, rooms, presence, route ciphertext. Never decrypts. |
client/ |
Connect, orchestrate handshakes, per-peer sessions, console UI. |
offensive/ |
Educational eavesdrop (malicious relay) and MITM (active attacker) demos. |
cli/ |
argparse front end: serve, chat, id. |
evals/ |
Periodic protocol evals with pass thresholds. |
tests/ |
Gate tests (deterministic, free, < 2s). |
- Each side generates a fresh ephemeral X25519 keypair for the session.
- Each sends
HS{ identity_pub, ephemeral_pub, signature }, where the signature is Ed25519 over("e2echat/hs/v1", identity_pub, ephemeral_pub, room). The signature binds the ephemeral key to the identity and to the room. - Each verifies the peer's signature. A relay that substitutes an ephemeral key
it controls cannot re-sign it without the peer's identity private key, so the
swap is rejected (
HandshakeError). Seeoffensive/mitm.py, attack A. shared = X25519(own_ephemeral_priv, peer_ephemeral_pub).HKDF-SHA256(shared)→ 64 bytes → two 32-byte directional keys. The peer with the lower-sorting identity key sends with key A and receives with key B; the other mirrors it. Both sides agree with no negotiation, and send-key is never equal to recv-key.- The ephemeral private keys are dropped, giving the session forward secrecy: a later compromise of a long-term identity key does not decrypt past traffic.
If instead the attacker uses its own identity and re-signs everything
(impersonation), the cryptography accepts it — but the safety number the two
humans compare now differs, so the impersonation is caught out-of-band. See
offensive/mitm.py, attack B. That is the whole reason the number exists.
A room member keeps a separate end-to-end session with every other member. A message is encrypted once per recipient and the relay routes each ciphertext to its recipient. This is true end-to-end (no shared group key the relay could ever help distribute), at a cost of O(n) encryptions per message — fine at lab scale. A production group chat would use sender keys (as Signal does) to get O(1); the tradeoff is a more involved key-rotation-on-membership-change protocol. This project keeps the honest, simple model.
What e2echat protects: the confidentiality and integrity of message
content. A malicious or compromised relay, or anyone who taps the link, sees
only AES-256-GCM ciphertext. Any tampering, wrong key, or reordering makes the
recipient's decrypt fail loudly (InvalidTag / replay rejected) rather than
returning corrupt data. An active key-substitution MITM is rejected by the
signature; an impersonation MITM is caught by the safety-number comparison.
What it does NOT protect against:
- Metadata. The relay learns who is online, display names, identity
fingerprints, room membership, message sizes, and timing. E2E hides content,
not the fact and shape of a conversation.
offensive/eavesdrop.pyshows exactly this. If your metadata is itself sensitive, this is the wrong tool. - Skipping safety-number verification. If the two humans never compare safety numbers, an impersonating relay that self-signs can sit in the middle. The cryptography cannot catch that alone — verification is a required step.
- A compromised endpoint. Malware or a keylogger on a client reads the plaintext as it is typed or displayed. No transport crypto can prevent this.
- Long-term persistence / deniability / post-compromise recovery. This is a teaching implementation of authenticated E2E, not a full Double Ratchet. Keys are per-session, not per-message; there is no automatic ratcheting.
# A malicious relay records everything it can see; prove no plaintext leaks.
PYTHONPATH= .venv/bin/python -m offensive.eavesdrop
# An active MITM on the key exchange; prove the signature and safety number stop it.
PYTHONPATH= .venv/bin/python -m offensive.mitmThe eavesdrop demo drives two real clients through a real relay and dumps the relay's-eye view: metadata is visible, every secret string is absent. The MITM demo runs both attack shapes and shows each is detected.
# Gate lane: deterministic, free, < 2s. Run on every change.
PYTHONPATH= .venv/bin/python -m pytest -q
# Periodic protocol evals: measured thresholds, exits non-zero on failure.
PYTHONPATH= .venv/bin/python evals/run_crypto.pyThe eval harness checks five properties and writes a JSON report to
evals/reports/protocol_eval.json:
- Handshake agreement — 200 random pairs, 100% derive matching keys.
- MITM detection — forged signatures and swapped ephemerals 100% rejected, impersonation 100% shifts the safety number.
- AEAD fuzz — hundreds of random plaintexts round-trip; every single-byte tamper on a sample is rejected.
- Forward secrecy — the session retains no ephemeral private key, and fresh sessions between the same identities derive distinct keys.
- Message latency — a representative run measured ~0.006 ms per encrypt+decrypt roundtrip (~160,000 messages/sec) on a laptop.
Educational project. No warranty. You are responsible for how you use it, and for only ever pointing the offensive tooling at systems you own.