How I Built a Chat Server That Can't Read Your Messages
Most "encrypted" chat apps encrypt
the connection. The server still decrypts every message, reads it, then re-encrypts it for the recipient. You're trusting the server operator — and every employee, contractor, and attacker who ever gets access to that machine.
I wanted to build something different. A chat server that is mathematically incapable of reading your messages — not by policy, not by promise, but by design. If you hand the server's private keys to an attacker, they still get nothing.
The result is NoEyes — a terminal-based E2E encrypted chat tool with a blind-forwarder server.
👉 https://github.com/Ymsniper/NoEyes
The Core Idea: The Blind Forwarder
The server in NoEyes does exactly one thing: route packets. It reads a small plaintext JSON header at the front of each frame to find out where the packet should go, then forwards the encrypted payload verbatim without touching it.
┌──────────────────────────────────────────────────────┐
│ Alice ─────────────────────────────────── Bob │
│ │ Encrypted payload (opaque) │ │
│ │ │ │ │
│ └──────► SERVER ───┴◄────────────────────┘ │
│ │ │
│ Blind forwarder: │
│ reads routing header only │
│ { "type":"chat", "room":"general" } │
│ forwards encrypted bytes verbatim │
└──────────────────────────────────────────────────────┘
The server has no decryption keys. There are no calls to Fernet.decrypt anywhere in server.py. The encrypted payload is just bytes — the server doesn't know what's in it and has no way to find out.
The Wire Protocol
Each frame has a fixed 8-byte header followed by the routing JSON and the payload:
[4-byte header_len BE] [4-byte payload_len BE] [header JSON] [payload bytes]
The header JSON is plaintext — just enough for routing:
{ "type": "chat", "room": "general" }
{ "type": "privmsg", "to": "bob" }
{ "type": "dh_init", "to": "alice" }
The payload is always encrypted. The server never needs to look inside it.
Group Chat: Per-Room Key Isolation
The shared chat.key file is not used directly for encryption. It's a master secret from which per-room keys are derived via HKDF:
room_key = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=room_name.encode()
).derive(master_key)
This means a user in #general cannot decrypt messages from #ops even if they somehow get the encrypted bytes — the keys are completely different. Rooms are cryptographically isolated.
Private Messages: X25519 DH Handshake
When Alice sends her first /msg to Bob, neither of them has a shared pairwise key yet. NoEyes triggers a DH handshake automatically:
- Alice generates an X25519 ephemeral keypair
- Alice sends
dh_initcontaining her public key — encrypted inside the group Fernet key so the server sees only opaque bytes - Bob generates his own ephemeral keypair, derives the shared secret, and sends back
dh_respwith his public key - Alice derives the same shared secret from Bob's public key
- Both sides now hold an identical pairwise Fernet key — the server never saw either private key or the shared secret
- The original message is automatically re-sent encrypted with the new pairwise key
shared_secret = alice_private.exchange(bob_public)
pairwise_key = SHA256(shared_secret)
From this point on, all /msg traffic between Alice and Bob is encrypted with a key the server has never seen and cannot compute.
Identity and Signatures: Ed25519 + TOFU
Every client generates an Ed25519 keypair on first run, stored at ~/.noeyes/identity.key. Every private message payload is signed:
signature = ed25519_private_key.sign(message_bytes)
Recipients verify against the sender's public key. Public keys are announced to the room over the server, but — again — the server only routes the announcement, it doesn't process it.
TOFU (Trust On First Use): the first time Alice sees Bob's public key, it gets stored in ~/.noeyes/tofu_pubkeys.json. Every subsequent message from Bob is verified against that stored key. If Bob's key ever changes — reinstalled client, new machine — Alice sees a loud warning:
⚠ SECURITY WARNING
Key mismatch for bob
Expected: a3f9... | Got: 7c12...
Use /trust bob to accept the new key
This is the same trust model Signal uses. You're warned immediately if something changed.
The Bug That Taught Me the Most
The trickiest bug was in the /trust command. When Bob reconnected with a new identity, Alice would run /trust bob — but Bob's new key still never verified correctly.
The problem: when the TOFU mismatch was detected, the new key was added to a _tofu_mismatched set but not cached anywhere. The /trust command deleted the old key from storage but had nothing to save in its place. Bob's new key was just gone.
The fix was a _tofu_pending dict — when a mismatch fires, cache the new key immediately:
# On mismatch detection:
self._tofu_pending[username] = new_vk_hex
self._tofu_mismatched.add(username)
# On /trust:
if peer in self._tofu_pending:
new_key = self._tofu_pending.pop(peer)
self.tofu_store[peer] = new_key
save_tofu(self.tofu_store)
A simple fix. But finding it required understanding exactly when keys flow through the system and where they get dropped.
There was also a DH deadlock edge case: if Alice and Bob both send /msg to each other at the exact same millisecond, both trigger dh_init simultaneously. Without a tiebreaker, both wait for the other to respond and nobody ever does. The fix is lexicographic: whoever has the alphabetically lower username is the initiator, the other becomes the responder. Two lines of code, but the kind of thing you only think of after staring at a deadlock for an hour.
What the Server Actually Sees
To make this concrete — here's what a full packet capture of a NoEyes session reveals to the server:
| What the server sees | What the server cannot see |
|---|---|
| Usernames | Message content |
| Room names | File contents |
| Event types (join/leave) | Private message bodies |
| Frame byte length | DH key exchange values |
| Timestamp of events | Ed25519 signatures |
| Pairwise keys |
The server is a router. It knows who is talking to whom and when. It does not know what they are saying.
Running It
git clone https://github.com/Ymsniper/NoEyes
cd NoEyes
python setup.py # installs cryptography, optionally bore
# Generate a shared key — share this out of band
python noeyes.py --gen-key --key-file ./chat.key
# Start the server
python noeyes.py --server --port 5000
# Connect
python noeyes.py --connect SERVER_IP --port 5000 --username alice --key-file ./chat.key
One dependency. Pure Python. Works on Linux, macOS, Windows, Termux (Android), and iSH (iOS).
What I'd Love Feedback On
- The trust model — is TOFU the right default, or should there be a stricter option?
- The wire protocol — anything obviously wrong with the framing format?
- The DH handshake — I'm using ephemeral keys but not doing a full double ratchet. Is that a meaningful gap for this use case?
I'm self-taught and this is the most serious security-adjacent thing I've built. I'd genuinely appreciate eyes from people who know this space better than I do.

Top comments (0)