Whisper

Back to work

Whisper is an end-to-end encrypted chat app I've been building. It runs on iOS, Android and the web from one Expo codebase. You can try the web version at whisper.gaganbiswas.com, and the code is on GitHub.

Whisper's welcome screen on a phone, with an illustration of a padlock over a chatAn open conversation in Whisper, with sent and received messages showing delivered and seen ticks. The contact's email is blurred

Why

Pretty much every chat app says it's end-to-end encrypted, and I'd always just taken that on trust. I wanted to know what actually happens to a message between two phones, and building one seemed like the best way to find out. So I read Signal's specs for X3DH and the Double Ratchet and implemented both.

This is a learning project. "Don't roll your own crypto" is good advice and I mostly followed it: I didn't write any of the primitives, those come from the noble libraries. What I wrote is the protocol on top of them. I wouldn't use Whisper over Signal for anything that matters.

How it works

When you sign in, your device generates an Ed25519 identity key, a signed prekey and 100 one-time prekeys. Only the public halves get uploaded. The private keys stay on the device, in the Keychain or Keystore on mobile and in IndexedDB on the web.

To message someone, your device fetches their key bundle, checks the signature on their prekey and runs X3DH to agree on a shared secret, even if they're offline. That secret seeds a Double Ratchet, so every message gets its own key and the keys rotate whenever the conversation changes direction. Each device has its own session, so a message is encrypted separately for every device the other person is logged in on (and your own other devices too).

Messages are encrypted with XChaCha20-Poly1305. The spec suggests AES-CBC with HMAC, but XChaCha's 24 byte nonce is big enough that I can just generate one at random and not worry about reuse.

The server is Bun, Express and a WebSocket relay. It never sees plaintext. Accounts and public keys live in SQLite, and encrypted messages sit in Redis until the recipient's device picks them up and acks them. Anything undelivered expires after 30 days. You log in with a 6 digit code sent to your email, and the auth routes are rate limited per IP and per email so nobody can spam codes.

What doesn't work yet

What's next

It's still under active development, so expect rough edges. If something breaks, or you spot a mistake in how I've done the crypto, I'd honestly like to hear about it.