A production-ready, fully End-to-End Encrypted (E2EE) real-time chat system built with Python (Flask), Java (WebSocket), and vanilla JavaScript β powered by ECDH + AES-GCM cryptography via the browser's native WebCrypto API. Features real-time voice & video calling (WebRTC), call history, typing indicators, unread badges, message delivery ticks, and a complete friend system β all following a clean MVC architecture.
- Overview
- Features
- Architecture
- Frontend MVC Structure
- WebRTC Calling
- Message Status Ticks
- Friend System
- Cryptographic Algorithms
- Full Message Encryption Flow
- Tech Stack
- Prerequisites
- Installation & Setup
- Running the App
- MongoDB Collections
- API Reference
- WebSocket Events
- Security Design Decisions
- Known Limitations
NexChat is a fully E2EE chat application where:
- No one β not even the server β can read your messages.
- All encryption and decryption happens exclusively on the client (browser).
- The server only stores and relays opaque ciphertext it cannot decode.
- Authentication uses industry-standard JWT (HMAC-SHA256) tokens.
- Passwords are stored using bcrypt with salting.
- Users must be friends before they can chat or call β enforced at both the frontend and Java server level.
- Live real-time voice & video calls via WebRTC β peer-to-peer media, Java handles signalling only.
- Live real-time notifications: typing indicator, unread badges, message ticks, friend events.
- The client follows a strict MVC pattern β state, DOM, and logic are fully separated.
| Feature | Description |
|---|---|
| π E2EE Messaging | ECDH P-256 key exchange + AES-GCM 256-bit encryption in the browser |
| π Voice Calls | Real-time P2P audio via WebRTC (STUN negotiation) |
| πΉ Video Calls | Real-time P2P video with local PiP + full-screen remote |
| π Call History | WhatsApp-style inline call log (Outgoing / Incoming / Missed + duration) |
| ββ Message Ticks | Single β sent β Double ββ grey delivered β Double ββ blue seen |
| β Message Timestamps | HH:MM shown on every message bubble |
| π¬ Typing Indicator | Animated 3-dot bounce in chat + mini dots in sidebar |
| π΄ Unread Badge | Accent-coloured count badge on friend in sidebar |
| π₯ Friend System | Add / accept / reject / unfriend with live WS notifications |
| π Online Status | Green/grey dot on every user β updated on connect/disconnect |
| π Cross-Device Keys | Private key encrypted with PBKDF2 and backed up to server |
| ποΈ MVC Architecture | Strict model / view / controller separation (13 files, no framework) |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β CLIENT (Browser β MVC) β
β β
β ββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββββ β
β β auth + friend REST β β chat + social + call WebSocket β β
β β (FriendController) β β (SocketController) β β
β ββββββββββββ¬ββββββββββββ ββββββββββββββββββ¬ββββββββββββββββββ β
βββββββββββββββΌβββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββ
β HTTP (REST) β WebSocket (ws://)
βΌ βΌ
βββββββββββββββββββββββββββ βββββββββββββββββββββββββββββββββββββββ
β Python / Flask β β Java WebSocket Server β
β (Port 5000) β β (Port 5001) β
β β β β
β /auth/register β β JWT verification on connect β
β /auth/login β β E2EE message relay β
β /friends/status β β Friend guard (areFriends check) β
β /friends/request β β WebRTC signalling relay β
β /friends/accept β β Typing indicator relay β
β /friends/reject β β Message delivery/seen relay β
β /friends/remove β β Call history recording β
β Serves client files β β Social event relay β
ββββββββββββββββ¬βββββββββββ ββββββββββββββββ¬βββββββββββββββββββββββ
β β
ββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββΌβββββββββββββββββββββββ
β MongoDB β
β (Port 27017) β
β Collections: β
β β’ users (auth + keys) β
β β’ messages (encrypted blobs) β
β β’ friend_requests (social graph) β
β β’ calls (call history log) β
βββββββββββββββββββββββββββββββββββββββββββββ
| Layer | Technology | Port | Role |
|---|---|---|---|
| Auth + Friend API | Python / Flask | 5000 | Register, Login, JWT, Friend CRUD |
| Chat + Relay | Java / WebSocket | 5001 | E2EE relay, WebRTC signalling, ticks, typing |
| Database | MongoDB | 27017 | Users, encrypted messages, friendships, calls |
| Client | Vanilla JS / WebCrypto | (served by Flask) | MVC app β Crypto, UI, WS relay |
The entire client is split across 13 files following strict MVC separation.
client/
index.html β HTML shell (no logic)
style.css β Warm glassmorphism design system
crypto.js β Global WebCrypto helpers (ECDH, AES-GCM, PBKDF2)
app.js β Entry point: instantiate + wire (β70 lines)
models/ ββ PURE STATE, no DOM, no fetch, no WebSocket
AuthModel.js β token, username, isLoggedIn, authHeader getter
UserModel.js β friends[], pendingSent[], unreadCounts{}, typingUsers set
ChatModel.js β activeChatUser, sharedKeys{}, ECDH keypair, reqId map
views/ ββ DOM ONLY, no business logic
AuthView.js β login/register form, showChat/showAuth transitions
SidebarView.js β renderFriendsList (with badges + typing dots), renderAllUsers
ChatView.js β appendMessage (with ticks + timestamps), updateTicks, typing
ToastView.js β show(message, type) with rAF fade animation
CallView.js β Incoming banner, active call overlay, PiP, controls
controllers/ ββ ORCHESTRATION: use models + views + APIs
SocketController.js β WebSocket lifecycle, send(), event dispatcher
FriendController.js β 5 REST actions, WS relay, 5s polling safety net
ChatController.js β initCrypto, key exchange, selectUser, sendMessage, ticks
AuthController.js β login, register, logout, sidebar collapse
CallController.js β WebRTC lifecycle: offer/answer/ICE, media, hang-up
AuthController
ββββββββ¬βββββββ¬ββββββββββββββ
βΌ βΌ βΌ β
SocketCtrl ChatCtrl FriendCtrl β
β ββββββββ ββββββββββββββ
β CallCtrl βββ wired into SocketCtrl
β βββ share: AuthModel, UserModel, ChatModel
β βββ share: AuthView, SidebarView, ChatView, ToastView, CallView
βΌ
(WebSocket β Java Server)
Voice and video calls are peer-to-peer β only signalling messages (offer/answer/ICE) go through the Java server. Media streams directly between browsers.
Caller (Browser A) Java WS Server Callee (Browser B)
β β β
βββ call_offer βββββββββββΊ β ββ call_offer ββββββββββΊ β
β β (banner shows + ring)
βββββββββββββββββ call_answer βββββββββββββββββββββββββ
βββ call_ice ββββββββββββββΊβ ββ call_ice βββββββββββββΊβ
ββββββββββββββββββββββ call_ice βββββββββββββββββββββββ
ββββββββββββββββ WebRTC P2P media stream ββββββββββββββ
βββ call_ended ββββββββββββΊβ ββ call_ended βββββββββββΊβ
{
"caller": "alice",
"callee": "bob",
"type": "voice",
"status": "completed",
"startedAt": 1714234567890,
"answeredAt": 1714234572000,
"endedAt": 1714234692890,
"duration": 120
}| Status | Meaning |
|---|---|
ringing |
Offer sent, callee hasn't answered |
in_progress |
Call answered by callee |
completed |
Call ended after being answered (duration recorded) |
missed |
Ended before callee answered |
- Incoming banner β slides in from top with ring pulse animation + caller name
- Active overlay β dark full-screen with remote video (full) + local PiP (bottom-right)
- Controls β Mute ποΈ / Camera π· / End call π΅ (glassmorphic frosted bar)
- Inline call record β appears in chat immediately after the call ends (no reload needed)
Every sent message shows a WhatsApp-style tick that upgrades in real time:
| Tick | Colour | Meaning |
|---|---|---|
β single |
Grey | Sent β message reached the server |
ββ double |
Grey | Delivered β recipient's browser received it |
ββ double |
Blue π΅ | Seen β recipient opened the chat |
Alice sends msg β β grey (single)
Bob receives it online β Java relays message_delivered β Alice's ββ grey
Bob opens the chat β Java relays message_seen β Alice's ββ BLUE (+ pop animation)
- History messages load with double grey ticks (already delivered).
- Ticks upgrade without page reload via WS events.
Step 1 β Alice clicks "+ Add" on Bob
ββ Frontend β POST /friends/request (Python saves status: "pending")
ββ Java relays: new_friend_request β Bob (live notification panel)
Step 2 β Bob clicks "Accept"
ββ Frontend β POST /friends/accept (Python sets status: "accepted")
ββ Java fires friendship_activated to BOTH simultaneously β
Step 3 β Chatting
ββ Java ChatServer.areFriends() checks MongoDB before every message
β
Friends β message relayed
β Not friends β { type: "error" } blocked live
Step 4 β Unfriend
ββ Frontend β POST /friends/remove (Python deletes document)
ββ Java relays: friend_removed β Bob (UI closes chat instantly) β
Safety Net: Every 5 seconds FriendController polls /friends/status
ββ If state drifted (missed WS event), auto-syncs and re-renders silently
{
"_id": "ObjectId(...)",
"sender": "alice",
"receiver": "bob",
"status": "pending"
}Status transitions: pending β accepted | rejected
On unfriend: document is deleted (not soft-deleted).
const ecdhParams = { name: "ECDH", namedCurve: "P-256" };
const keyPair = await crypto.subtle.generateKey(ecdhParams, true, ["deriveKey", "deriveBits"]);- Shared secret:
Alice_priv + Bob_pub = Bob_priv + Alice_pubβ becomes the AES-GCM key.
const iv = crypto.getRandomValues(new Uint8Array(12)); // fresh per message
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, sharedKey, encoded);- Fresh 12-byte random IV per message β prevents IV-reuse attacks.
token = jwt.encode({ 'username': ..., 'exp': now + 10h }, JWT_SECRET, algorithm='HS256')hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')const aesKey = await crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: enc.encode(username + "_salt"), iterations: 100000, hash: "SHA-256" },
passwordKey,
{ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]
);- Private key encrypted client-side before upload β server cannot decrypt it.
[Alice β Browser] [Java Server] [Bob β Browser]
β β β
βββ login (REST) βββββββββββββΊβ β
βββ JWT + encrypted priv key ββ β
βββ ChatController.initCrypto() β
β restore ECDH keys from localStorage / server β
β β β
βββ WS connect ?token=JWT ββββΊβ β
βββ join { publicKey } βββββββΊβββ store Alice_pub in DB β
β ββββ Bob does the same ββββββ
β β β
βββ request_public_key(Bob) βββΊβ β
βββ Bob_pub βββββββββββββββββββ β
βββ deriveSharedKey(Alice_priv, Bob_pub) β aesKey β
βββ encryptMessage(aesKey, "Hello") β { ciphertext, iv } β
βββ send_message βββββββββββββΊβββ areFriends? β
β
β βββ save to MongoDB β
β βββ relay to Bob ββββββββββββΊβ
β β decryptMessage(aesKey, ciphertext, iv)
β β β "Hello" β
β ββββ message_seen βββββββββββ (Bob opens chat)
βββ message_seen (from: bob) ββ β
β updateTicks("bob", "seen") β ββ BLUE β
| Component | Technology |
|---|---|
| Auth + Friend API | Python 3, Flask, Flask-CORS, PyMongo, PyJWT, bcrypt |
| Chat + Relay | Java 11, Java-WebSocket (TooTallNate), auth0 java-jwt, MongoDB Java Driver |
| Real-Time Calling | WebRTC (RTCPeerConnection, getUserMedia), STUN: stun.l.google.com:19302 |
| Database | MongoDB 6+ |
| Client Architecture | Vanilla JS MVC (13 files, no framework) |
| Cryptography | Browser WebCrypto API (crypto.subtle) |
| UI | Warm Glassmorphism (Inter font, Phosphor Icons) |
| Build | Apache Maven + maven-shade-plugin |
| Tool | Version |
|---|---|
| Python | 3.8+ |
| Java | 11+ |
| Apache Maven | 3.6+ |
| MongoDB | 6+ running on localhost:27017 |
cd server-python
pip install -r requirements.txt.env file (create in server-python/):
MONGO_URI=mongodb://localhost:27017/securechat
JWT_SECRET=your-secret-key-herecd server-java
mvn clean package -DskipTests
β οΈ Always rebuild and restart after editingChatServer.javaβ the running JAR will not pick up source changes automatically. Use the shaded JAR:target/server-java-1.0-SNAPSHOT-shaded.jar
mongoshuse securechat
db.friend_requests.createIndex({ sender: 1, receiver: 1 })
db.friend_requests.createIndex({ receiver: 1, status: 1 })
db.messages.createIndex({ sender: 1, receiver: 1, timestamp: 1 })
db.calls.createIndex({ caller: 1, callee: 1, startedAt: 1 })cd server-python
python app.py
# β http://localhost:5000cd server-java
java -jar target\server-java-1.0-SNAPSHOT-shaded.jar
# β ws://localhost:5001Navigate to http://localhost:5000
{
"username": "alice",
"password": "<bcrypt hash>",
"publicKey": { "<ECDH P-256 JWK>" },
"encryptedPrivateKey": { "<PBKDF2-encrypted JWK>" }
}{
"sender": "alice",
"receiver": "bob",
"ciphertext": [12, 34, ...],
"iv": [56, 78, ...],
"timestamp": 1714234567890
}{
"sender": "alice",
"receiver": "bob",
"status": "pending | accepted | rejected"
}{
"caller": "alice",
"callee": "bob",
"type": "voice | video",
"status": "ringing | in_progress | completed | missed",
"startedAt": 1714234567890,
"answeredAt": 1714234572000,
"endedAt": 1714234692890,
"duration": 120
}| Method | Endpoint | Body | Response |
|---|---|---|---|
POST |
/auth/register |
{ username, password, publicKey, encryptedPrivateKey } |
{ msg } |
POST |
/auth/login |
{ username, password } |
{ token, username, publicKey, encryptedPrivateKey } |
| Method | Endpoint | Body | Description |
|---|---|---|---|
GET |
/friends/status |
β | Returns friends[], pending_sent[], pending_received[] |
POST |
/friends/request |
{ receiver } |
Send a friend request |
POST |
/friends/accept |
{ sender } |
Accept a pending request |
POST |
/friends/reject |
{ sender } |
Decline a pending request |
POST |
/friends/remove |
{ target } |
Unfriend β deletes from MongoDB |
Connect: ws://localhost:5001/?token=<JWT>
| Type | Payload | Description |
|---|---|---|
join |
{ publicKey: JWK } |
Register session + public key |
request_public_key |
{ receiver } |
Fetch ECDH public key |
send_message |
{ receiver, ciphertext[], iv[] } |
Send encrypted message |
fetch_history |
{ withUser } |
Retrieve chat history |
fetch_call_history |
{ withUser } |
Retrieve call log |
typing |
{ to, isTyping } |
Typing indicator relay |
message_delivered |
{ to } |
Notify sender: message received |
message_seen |
{ to } |
Notify sender: message seen |
call_offer |
{ to, offer, withVideo } |
WebRTC offer (friends-only) |
call_answer |
{ to, answer } |
WebRTC answer |
call_ice |
{ to, candidate } |
ICE candidate |
call_ended |
{ to } |
Hang-up / decline |
friend_request_sent |
{ to } |
Relay: ping receiver |
friend_request_accepted |
{ to } |
Relay: fire to both users |
friend_removed_notify |
{ to } |
Relay: ping target |
| Type | Payload | Triggered by |
|---|---|---|
user_list |
["alice", ...] |
Any connect/disconnect |
online_users |
["alice", ...] |
Any connect/disconnect |
public_key_response |
{ publicKey } |
request_public_key |
receive_message |
{ sender, ciphertext[], iv[], timestamp } |
send_message |
history_response |
[{ sender, ciphertext[], iv[], timestamp }] |
fetch_history |
call_history_response |
[{ caller, callee, type, status, duration, startedAt }] |
fetch_call_history |
typing |
{ from, isTyping } |
Typing relay |
message_delivered |
{ from } |
Delivery receipt relay |
message_seen |
{ from } |
Seen receipt relay |
call_offer |
{ from, offer, withVideo } |
WebRTC signalling |
call_answer |
{ from, answer } |
WebRTC signalling |
call_ice |
{ from, candidate } |
WebRTC signalling |
call_ended |
{ from } |
Hang-up / cancel |
new_friend_request |
{ from } |
π Live request alert |
friendship_activated |
{ with } |
β Both UIs add each other |
friend_removed |
{ from } |
β Live unfriend notification |
error |
{ message } |
Friend guard block or offline peer |
| Design Choice | Reason |
|---|---|
| ECDH over RSA | Smaller keys, faster operations, equivalent security |
| AES-GCM over AES-CBC | AEAD β encryption + authentication in one primitive; prevents tampering |
| Fresh IV per message | Prevents IV-reuse attacks that break AES-GCM security entirely |
| PBKDF2 private key backup | PBKDF2(password) β AES-GCM wraps private key β server never holds plaintext key |
| bcrypt with gensalt() | Salted, computationally expensive β resists rainbow tables and brute force |
| JWT expiry (10 hours) | Limits token exposure window if intercepted |
| Browser WebCrypto API | Native, audited, hardware-accelerated β no third-party crypto library needed |
| Friend guard in Java (server-side) | Cannot be bypassed by the client β friendship verified in MongoDB before every relay |
| WebRTC P2P for calls | Media never touches the server β no call recording possible by the server |
| STUN for NAT traversal | Uses Google's public STUN servers; add TURN for symmetric NAT environments |
| 5s polling safety net | Eventual-consistency guarantee β catches missed WS relay events silently |
| MVC separation | State lives only in Models β Views never mutate state; zero spaghetti logic |
- No Perfect Forward Secrecy (PFS): Keys are persisted. A compromised private key could decrypt stored ciphertext. Production would use ephemeral keys per session.
- TOFU model only: Key fingerprints are displayed but not enforced β no out-of-band verification flow.
- TURN server not included: WebRTC calls work on LAN/same-network. For calls across the internet (symmetric NAT), a TURN relay server must be added to
CallController._iceConfig. - No group calls or group chats: One-to-one only.
- No message deletion or editing.
- Single server: No horizontal scaling for the Java WebSocket server.
- Offline notifications: Users who are offline when a friend request or message is sent receive it only after next login (the 5s poll catches it immediately on reconnect). Call history still saves to MongoDB regardless.
This project is for educational and demonstration purposes.
Built with β€οΈ by Ram-sah19
NexChat β Encrypted Β· Social Β· Real-Time Β· WebRTC Β· MVC