DEV Community

Ymsniper
Ymsniper

Posted on

I Built a Chat Server That Cannot Read Your Messages — Here's How

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         │
└──────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

The header JSON is plaintext — just enough for routing:

{ "type": "chat", "room": "general" }
{ "type": "privmsg", "to": "bob" }
{ "type": "dh_init", "to": "alice" }
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. Alice generates an X25519 ephemeral keypair
  2. Alice sends dh_init containing her public key — encrypted inside the group Fernet key so the server sees only opaque bytes
  3. Bob generates his own ephemeral keypair, derives the shared secret, and sends back dh_resp with his public key
  4. Alice derives the same shared secret from Bob's public key
  5. Both sides now hold an identical pairwise Fernet key — the server never saw either private key or the shared secret
  6. 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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

GitHub: https://github.com/Ymsniper/NoEyes

Top comments (0)