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.
- Key agreement: ECDH on P-256 via elliptic.js, with an optional post-quantum CSIDH-512 layer concatenated before HKDF derivation.
- Session encryption: Double Ratchet with per-message keys derived from HMAC chain keys, AES-256-GCM ciphertext, and DH ratchet steps on every reply direction change.
- Authentication: ECDSA challenge-response on WebSocket connect: the server sends a 32-byte nonce, the client signs it, and only verified public keys get routed messages.
- Vault encryption: PBKDF2 with 600,000 iterations of SHA-256 derives the master key from the user's password. No password is ever stored.
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.
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.