End-to-end encrypted messaging. Messages are encrypted on your device before leaving the browser — the server stores and forwards ciphertext only.
- Architecture
- Encryption Flow
- Key Management
- Security Trade-offs
- Known Limitations
- Getting Started
- Tech Stack
- Running Tests
┌─────────────────────────────────────────────────────────────┐
│ Browser (Next.js) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Auth Modal │ │ Chat View │ │ Crypto Layer│ │
│ │ (register / │ │ (sidebar + │ │ (Web Crypto │ │
│ │ login) │ │ thread) │ │ SubtleCrypto│ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ ┌──────▼───────────────────▼───────────────────▼───────┐ │
│ │ TanStack Query + Session Context │ │
│ │ (access token, user profile, private key) │ │
│ └───────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌─────────────┴─────────────┐ │
│ │ │ │
│ ┌──────▼──────┐ ┌────────▼──────┐ │
│ │ REST Client│ │ WebSocket │ │
│ │ (fetch) │ │ (real-time) │ │
│ └──────┬──────┘ └────────┬──────┘ │
└────────────┼──────────────────────────┼────────────────────┘
│ │
│ HTTPS / WSS │
▼ ▼
┌────────────────────────────────────────────────────────────┐
│ Whisperbox API (koyeb.app) │
│ │
│ /auth/register /auth/login /auth/refresh │
│ /users/search /conversations /messages │
│ /ws (WebSocket — delivers ciphertext frames) │
│ │
│ Stores: user profiles, wrapped private keys, ciphertext │
│ Never sees: plaintext messages or unwrapped private keys │
└────────────────────────────────────────────────────────────┘
Data flow summary:
| Direction | What travels over the wire |
|---|---|
| Register | Public key (SPKI), wrapped private key (AES-GCM blob), PBKDF2 salt, password hash (server-side) |
| Send message | { ciphertext, iv, encryptedKey, encryptedKeyForSelf } — all opaque to the server |
| Receive message | Same encrypted envelope — decrypted entirely in the browser |
Password ──┐
├─► PBKDF2 (100k iterations, SHA-256, 16-byte salt)
16-byte │ │
random │ ▼
salt ────┘ AES-256-GCM wrap key
│
RSA-OAEP 2048 ──► PKCS#8 private key bytes
key pair gen │
├─► AES-GCM encrypt ──► [12-byte IV ‖ ciphertext]
│ (random IV) │
│ ▼
│ wrapped_private_key ──► server
│
└─► public key (SPKI base64) ──────────────► server
Plaintext message
│
▼
AES-256-GCM encrypt ◄── ephemeral AES key (generated per message)
│ │
│ ┌─────────┴─────────┐
│ │ │
│ RSA-OAEP encrypt RSA-OAEP encrypt
│ (recipient pub key) (sender pub key)
│ │ │
▼ ▼ ▼
ciphertext encryptedKey encryptedKeyForSelf
│ │ │
└──────────────┴───────────────────┘
│
▼
EncryptedPayload JSON
{ ciphertext, iv, encryptedKey,
encryptedKeyForSelf }
│
▼
WebSocket ──► server ──► recipient
The ephemeral AES key is wrapped twice — once for the recipient so they can decrypt, and once for the sender so their own sent messages remain readable in the thread.
EncryptedPayload from server
│
├── encryptedKey (or encryptedKeyForSelf if reading own sent)
│ │
│ RSA-OAEP decrypt ◄── in-memory private key
│ │
│ raw AES-256 key bytes
│ │
├── ciphertext + iv
│ │
│ AES-GCM decrypt ◄── recovered AES key
│ │
▼
Plaintext message
| Key material | At rest | In memory | Notes |
|---|---|---|---|
| RSA-2048 public key | Server (SPKI base64) | CryptoKey object |
Public — safe to store on server |
| RSA-2048 private key (wrapped) | Server (AES-GCM blob + PBKDF2 salt) | Never | Server cannot decrypt it without your password |
| RSA-2048 private key (unwrapped) | Never | React state only | Lost on page reload; re-derived from password on next login |
| PBKDF2 salt | Server (pbkdf2_salt on user profile) |
Loaded at login | Needed to re-derive the AES wrap key from password |
| Ephemeral AES message keys | Never | Generated and discarded per-message | Only the RSA-wrapped copies are stored in the message payload |
First visit (register)
generate RSA key pair
derive AES wrap key from password + fresh salt
wrap private key → send to server
private key stays live in memory ──► session active
Page reload / new tab
access token restored from localStorage
private key is GONE (was memory-only)
┌─► SessionUnlockGate modal appears
│ user re-enters password
│ fetch wrapped key + salt from server profile
│ re-derive AES wrap key
│ unwrap private key back into memory
└─► session active again
Sign out
clear localStorage (tokens)
private key dropped from memory
Password is the root of trust. The same password used to log into the account is used to unwrap the private key. This means:
- A weak password directly weakens the encryption protecting the private key.
- The server stores the wrapped private key and PBKDF2 salt, so an attacker who exfiltrates the database can run an offline dictionary attack against a user's password. PBKDF2 at 100k iterations raises the cost of that attack but does not eliminate it.
Server-stored wrapped key is a trade-off for usability. Storing the wrapped private key on the server allows seamless multi-session restore (unlock with password) without the user managing key files. The alternative — never storing the key — would require manual key export/import for every new device or browser.
No perfect forward secrecy. The same long-lived RSA key pair is used for all messages. If a private key is ever compromised, all past messages encrypted with the corresponding public key can be retrospectively decrypted. A Signal-style double-ratchet would provide per-message forward secrecy but is significantly more complex to implement correctly.
RSA-OAEP 2048 is functional but not future-proof. 2048-bit RSA is widely considered secure for the near term but is not post-quantum. NIST post-quantum algorithms (ML-KEM / Kyber) are not yet available in the Web Crypto API, so this is a platform constraint rather than a design choice.
The server can observe metadata. The server cannot read message content, but it knows who talks to whom, when, and how frequently. This conversation graph is unavoidably visible to the backend.
-
Private key is session-bound. Reloading the page requires re-entering your password to unwrap the private key. There is no persistent decryption capability without this step.
-
No cross-device key sync. Each registration generates a new key pair. Logging into the same account on a second device reuses the server-stored wrapped key but requires re-entering the password on that device. Both devices end up with the same identity key, which is fine for decryption but means all devices share a single cryptographic identity.
-
No message deletion or expiry. Messages persist on the server indefinitely. There is no mechanism to retract a message or set a TTL.
-
No offline delivery guarantee. Messages are sent over WebSocket. If the recipient is offline, delivery depends on the backend queuing the message for later retrieval via the REST conversation endpoint.
-
No group messaging. The envelope scheme encrypts for exactly one recipient and one sender. Group chats would require encrypting the AES key for each member separately, which is not currently implemented.
-
Username enumeration. The
/users/searchendpoint allows searching by username. This is necessary for starting conversations but means usernames can be discovered.
Prerequisites: Node.js 18+, pnpm (or npm/yarn)
# 1. Install dependencies
pnpm install
# 2. Configure environment
cp .env.example .env.local
# Edit .env.local and set NEXT_PUBLIC_WHISPERBOX_URL
# 3. Start the dev server
pnpm devOpen http://localhost:3000. Register a new account — the key pair is generated in your browser on registration.
| Variable | Description |
|---|---|
NEXT_PUBLIC_WHISPERBOX_URL |
Base URL of the Whisperbox backend API (e.g. https://whisperbox.koyeb.app) |
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) |
| UI | React 19, Tailwind CSS v4, shadcn/ui |
| Crypto | Web Crypto API (SubtleCrypto) — RSA-OAEP 2048, AES-256-GCM, PBKDF2 |
| Data fetching | TanStack Query v5 |
| Real-time | Native WebSocket |
| Testing | Vitest |
| Backend | Whisperbox API (external — REST + WebSocket) |
pnpm testThe test suite covers base64 round-trips, RSA-OAEP key generation, PKCS#8 private key wrap/unwrap, wrong-password rejection, and the full envelope encrypt/decrypt cycle.