Skip to content

devv-leo/e2ee-app

Repository files navigation

ChatApp

End-to-end encrypted messaging. Messages are encrypted on your device before leaving the browser — the server stores and forwards ciphertext only.


Table of Contents


Architecture

┌─────────────────────────────────────────────────────────────┐
│                        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

Encryption Flow

Registration — key generation

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

Sending a message

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.

Receiving a message

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 Management

Where keys live

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

Session lifecycle

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

Security Trade-offs

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.


Known Limitations

  • 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/search endpoint allows searching by username. This is necessary for starting conversations but means usernames can be discovered.


Getting Started

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 dev

Open http://localhost:3000. Register a new account — the key pair is generated in your browser on registration.

Environment variables

Variable Description
NEXT_PUBLIC_WHISPERBOX_URL Base URL of the Whisperbox backend API (e.g. https://whisperbox.koyeb.app)

Tech Stack

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)

Running Tests

pnpm test

The 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.

About

A secure messaging application that uses End-to-End Encryption (E2EE).

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors