Skip to content

bastian-red/e2echat

Repository files navigation

e2echat — End-to-End Encrypted Chat

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.


What it demonstrates

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

Install

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

The 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 clean PYTHONPATH, as shown throughout this README.


Usage

Open three terminals. First the relay:

PYTHONPATH= .venv/bin/python chat.py serve                 # 127.0.0.1:8765

Then 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 lab

Each 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

Architecture

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

The handshake (why it is safe)

  1. Each side generates a fresh ephemeral X25519 keypair for the session.
  2. 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.
  3. 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). See offensive/mitm.py, attack A.
  4. shared = X25519(own_ephemeral_priv, peer_ephemeral_pub).
  5. 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.
  6. 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.

Rooms: pairwise mesh

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.


Threat model — read this

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.py shows 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.

Offensive demos (own-lab only)

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

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


Tests and evals

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

The eval harness checks five properties and writes a JSON report to evals/reports/protocol_eval.json:

  1. Handshake agreement — 200 random pairs, 100% derive matching keys.
  2. MITM detection — forged signatures and swapped ephemerals 100% rejected, impersonation 100% shifts the safety number.
  3. AEAD fuzz — hundreds of random plaintexts round-trip; every single-byte tamper on a sample is rejected.
  4. Forward secrecy — the session retains no ephemeral private key, and fresh sessions between the same identities derive distinct keys.
  5. Message latency — a representative run measured ~0.006 ms per encrypt+decrypt roundtrip (~160,000 messages/sec) on a laptop.

License / disclaimer

Educational project. No warranty. You are responsible for how you use it, and for only ever pointing the offensive tooling at systems you own.

About

End-to-end encrypted terminal chat: authenticated X25519 handshake, Ed25519 identities, AES-256-GCM, and a relay that never sees plaintext. Educational security project with offensive demos, tests, and evals.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages