Application

Chat & Spaces

Forward-secret messaging for groups, where the server routes ciphertext it cannot open and history survives without ever being readable.

Role
Sole architect and engineer
Years
2026
Stack
Double Ratchet · X3DH · CRDT · IndexedDB
Live
chat.muhkoo.dev

The problem

Forward-secret messaging between two people is well understood — the Double Ratchet solved it. Groups are where the difficulty actually lives. Naive fan-out encrypts every message once per recipient, which is fine for four people and untenable for four hundred. And history is worse: someone who joins on Tuesday either sees Monday's messages, which means the keys leaked, or they don't, which means the product feels broken.

How it works

Direct messages use per-peer Double Ratchet sessions, so each conversation has its own forward-secret chain. Groups are modelled as a Space with a shared group key that is distributed through those existing per-peer channels — the expensive per-recipient encryption happens once, on key distribution, not once per message.

The server stores and orders encrypted payloads, which is enough to give a late joiner real history once they hold the group key, and never enough to read any of it. Offline support layers a CRDT with hybrid logical clocks over IndexedDB, so edits made on a plane converge on reconnect without a server arbitrating who won.

An encrypted channel with messages, a member list, and a markdown composer The chat client on a phone

What I'd do differently

I should have namespaced message envelopes from the first commit. The SDK unwraps any object carrying a body key, so an envelope that happened to use the same field name got silently unwrapped into the wrong shape. It produced a class of bug that was hard to see, easy to reintroduce, and obvious in hindsight — exactly the kind a naming convention prevents for free.

Group membership changes also deserved an explicit key-rotation policy written down before the first group existed. What I had instead was a policy implied by whatever the code happened to do on join and leave, which is not a policy — it is a guess you have to reverse-engineer later.