Maxim Mamykin
← Back to timeline
Project 2024 Cryptography · Electron · Node.js

Three encryption layers, a blind relay, and zero plaintext on the server.

I built Chirpr, a decentralized end-to-end encrypted desktop messenger, to understand what Signal-style protocols actually demand from an implementation. X3DH key agreement, Double Ratchet sessions, a custom binary vault, and a relay server that never sees a single byte of plaintext.

Chirpr application architecture diagram

Most "E2EE" demos stop at AES-GCM and call it done. I wanted to go further: implement the full key-agreement and session-management machinery that real encrypted messengers rely on, and understand where the hard problems actually live. Chirpr is the result: an Electron desktop app talking to a Node.js WebSocket relay, where the server is intentionally blind. It stores prekey bundles and routes opaque payloads. All cryptography, key management, and data persistence happen client-side.

The protocol stack

Session establishment uses the Extended Triple Diffie-Hellman (X3DH) handshake. When Alice wants to message Bob, she fetches his prekey bundle from his relay (an identity key, a signed prekey, and one of five single-use one-time prekeys), then runs four ECDH computations on the P-256 curve to derive a shared secret. That secret seeds the Double Ratchet, which generates a unique AES-256-GCM key for every single message.

End-to-end message flow showing X3DH handshake and Double Ratchet session

Message flow: X3DH handshake into a Double Ratchet session.

Three layers of encryption

When Alice sends "Hello" to Bob, the plaintext passes through three independent encryption layers. The Double Ratchet derives a per-message key via HMAC chain advancement and encrypts with AES-256-GCM. That's the transport layer. Locally, store.js encrypts each saved record with a separate AES-256-GCM key derived from the user's login credentials. Then the entire profile blob is encrypted a third time and serialized into a custom binary vault: magic bytes CHRP, per-profile salt and IV, fresh IV on every save() for semantic security. No username is stored in plaintext; unlocking tries each profile until an AES-GCM auth tag validates.

The blind relay

The server is a single 613-line Node.js file. WebSocket auth, prekey storage in SQLite, offline message queuing (pruned after 30 days), and broadcast channel subscriptions. That's all it does. It cannot read message content. Rate limiting caps connections at 10 per IP per minute and messages at 100 per client per minute. The whole thing runs on a Raspberry Pi behind a systemd unit with NoNewPrivileges and ProtectSystem=full.

The topology is federated: each user runs their own relay. Cross-server messaging works because Alice authenticates directly on Bob's server to deliver his messages. No central authority, no federation protocol. Just direct WebSocket connections.

Chirpr chat interface showing encrypted conversation Vault unlock screen with emoji identity hash

Architecture tradeoffs

elliptic.js over WebCrypto for ECDH. The browser's native WebCrypto API can do ECDH on P-256, but it wraps the shared secret inside a non-exportable CryptoKey: you can't pull out the raw bytes needed to feed HKDF for a Double Ratchet KDF chain. elliptic.js runs curve math in JavaScript, which is slower, but gives full access to the raw shared point. I still use WebCrypto for AES-GCM and PBKDF2 where the key isolation is an advantage, not a limitation.

Custom binary vault over IndexedDB. IndexedDB would have been simpler to implement, but it lives in the browser's profile directory, tied to the Electron app's Chromium data path, hard to back up, and wiped on a reinstall. The CHRP vault is a single portable file that a user can copy to a USB stick. The cost is writing a binary parser and serializer by hand, and every parsing bug is a potential security bug. Worth it for the portability.

No bundler, no module system. Every client module attaches to window.* and scripts load in strict order in index.html. No Webpack, no ESM imports. That means zero build step for the client. Open DevTools and what you see is what's running. The tradeoff is manual dependency ordering and no tree-shaking, but for a ~12 file project the simplicity pays for itself in debugging speed.

The cipher is not the protocol, and the protocol is not the threat model. Pretending otherwise is how toy E2EE apps ship.

What I'm taking from this

Building Chirpr confirmed that the cryptographic primitives are the easy part. The hard problems are operational: ratchet state serialization across restarts, skipped-message key management (max 50 per gap, 500 total, pruned after 7 days), and out-of-order delivery over unreliable WebSockets. In a second pass I'd swap P-256 for X25519 to drop the NIST-curve baggage, and replace the CSIDH simulation with a real post-quantum KEM like ML-KEM. The protocol bones are solid. The key lifecycle needs hardening.