From 792bfbb4cb19895a4a5018a8966fb49bb4953c4f Mon Sep 17 00:00:00 2001 From: knoxiboy Date: Fri, 12 Jun 2026 22:56:49 +0530 Subject: [PATCH 1/2] feat: End-to-End Message Encryption (E2EE) via Web Crypto API (#567) From 4a7ced5eb97e5a6f9f4f74cfd69aa72b18618e13 Mon Sep 17 00:00:00 2001 From: knoxiboy Date: Fri, 12 Jun 2026 23:35:09 +0530 Subject: [PATCH 2/2] feat(crypto): implement end-to-end encryption (E2EE) with ECDH Curve P-256 and AES-GCM closes #567 --- backend/src/controllers/auth.controller.js | 19 ++ backend/src/controllers/message.controller.js | 12 +- backend/src/models/message.model.js | 3 + backend/src/models/user.model.js | 4 + backend/src/routes/auth.route.js | 3 +- frontend/components/chat/ChatWindow.jsx | 9 +- frontend/lib/crypto.js | 166 ++++++++++++++++++ frontend/src/App.jsx | 17 ++ frontend/src/store/useChatStore.js | 99 ++++++++--- 9 files changed, 301 insertions(+), 31 deletions(-) create mode 100644 frontend/lib/crypto.js diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 9abeb65..ad9543d 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -247,4 +247,23 @@ export const subscribeToPush = catchAsync(async (req, res) => { }).select("-password -__v"); res.status(200).json({ message: "Push subscription saved" }); +}); + +export const updatePublicKey = catchAsync(async (req, res) => { + const { publicKey } = req.body; + if (!publicKey) { + return res.status(400).json({ message: "Public key is required" }); + } + + const user = await User.findByIdAndUpdate( + req.userId, + { publicKey }, + { new: true } + ).select("-password -__v"); + + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + res.status(200).json(user); }); \ No newline at end of file diff --git a/backend/src/controllers/message.controller.js b/backend/src/controllers/message.controller.js index 6376d58..d0d7c88 100644 --- a/backend/src/controllers/message.controller.js +++ b/backend/src/controllers/message.controller.js @@ -164,6 +164,7 @@ export const getUsers = catchAsync(async (req, res) => { profilePicture: partner.profilePicture, lastSeen: partner.lastSeen, statusMood: partner.statusMood || null, + publicKey: partner.publicKey || null, lastMessage: { _id: lastMessage._id, message: lastMessage.message, @@ -197,7 +198,7 @@ export const searchUsers = catchAsync(async (req, res) => { _id: { $ne: req.userId }, name: { $regex: safeQuery, $options: "i" }, }) - .select("_id name email profilePicture lastSeen") + .select("_id name email profilePicture lastSeen publicKey") .limit(10); res.status(200).json(users); @@ -301,13 +302,16 @@ export const sendMessage = catchAsync(async (req, res) => { if (senderId === receiverId) { return res.status(400).json({ message: "You cannot send messages to yourself" }); } - const { message, image, audio, replyTo } = req.body; + const { message, image, audio, replyTo, iv, isEncrypted } = req.body; if (replyTo && !mongoose.Types.ObjectId.isValid(replyTo)) { return res.status(400).json({ message: "Invalid replyTo ID format" }); } - let sanitizedMessage = message ? xss(message.trim()) : ""; + let sanitizedMessage = message; + if (!isEncrypted) { + sanitizedMessage = message ? xss(message.trim()) : ""; + } if (!sanitizedMessage && !image && !audio) { return res.status(400).json({ message: "Message content cannot be empty" }); @@ -353,6 +357,8 @@ export const sendMessage = catchAsync(async (req, res) => { audio: audioUrl, replyTo: replyTo || undefined, status, + iv: isEncrypted ? iv : null, + isEncrypted: !!isEncrypted, }); if (receiverSocketIds.length > 0) { diff --git a/backend/src/models/message.model.js b/backend/src/models/message.model.js index ece7e5f..3b914b3 100644 --- a/backend/src/models/message.model.js +++ b/backend/src/models/message.model.js @@ -19,6 +19,9 @@ const messageSchema = new mongoose.Schema( } ], status: { type: String, enum: ["sent", "delivered", "seen"], default: "sent" }, + // E2EE fields + iv: { type: String, default: null }, + isEncrypted: { type: Boolean, default: false }, }, { timestamps: true } ); diff --git a/backend/src/models/user.model.js b/backend/src/models/user.model.js index 5f6aead..2227fc2 100644 --- a/backend/src/models/user.model.js +++ b/backend/src/models/user.model.js @@ -52,6 +52,10 @@ const userSchema = new mongoose.Schema({ default: null, trim: true, }, + publicKey: { + type: String, + default: null, + }, }, { timestamps: true }); userSchema.index({ name: "text" }); diff --git a/backend/src/routes/auth.route.js b/backend/src/routes/auth.route.js index 3db8be9..a2c1757 100644 --- a/backend/src/routes/auth.route.js +++ b/backend/src/routes/auth.route.js @@ -1,7 +1,7 @@ import express from "express"; import protectRoute from "../middleware/auth.middleware.js"; import { validateSignup, validateLogin } from "../middleware/validate.js"; -import { signup, login, logout, googleAuth, updateProfile, updateProfilePicture, checkAuth, subscribeToPush } from "../controllers/auth.controller.js"; +import { signup, login, logout, googleAuth, updateProfile, updateProfilePicture, checkAuth, subscribeToPush, updatePublicKey } from "../controllers/auth.controller.js"; const router = express.Router(); @@ -11,6 +11,7 @@ router.post("/google", googleAuth); router.post("/logout", logout); router.put("/update-profile", protectRoute, updateProfile); router.put("/update-profile-picture", protectRoute, updateProfilePicture); +router.put("/update-public-key", protectRoute, updatePublicKey); router.get("/check", protectRoute, checkAuth); router.post("/push-subscribe", protectRoute, subscribeToPush); diff --git a/frontend/components/chat/ChatWindow.jsx b/frontend/components/chat/ChatWindow.jsx index c6c275b..1965a81 100644 --- a/frontend/components/chat/ChatWindow.jsx +++ b/frontend/components/chat/ChatWindow.jsx @@ -538,7 +538,14 @@ const mediaMessages = messages.filter(
-

{selectedUser.name}

+
+

{selectedUser.name}

+ {selectedUser.publicKey && authUser.publicKey ? ( + + 🔒 E2EE + + ) : null} +
{selectedUser.statusMood ? (

{getStatusMoodLabel(selectedUser.statusMood)} diff --git a/frontend/lib/crypto.js b/frontend/lib/crypto.js new file mode 100644 index 0000000..7da26ef --- /dev/null +++ b/frontend/lib/crypto.js @@ -0,0 +1,166 @@ +/** + * crypto.js — Client-side End-to-End Encryption (E2EE) using Web Crypto API + * + * Implements ECDH key exchange (Curve P-256) and AES-GCM (256-bit) encryption. + * Keys are persisted securely in IndexedDB so they survive page reloads. + */ + +const DB_NAME = "e2ee-keys-db"; +const STORE_NAME = "keypairs"; + +// Helper to open IndexedDB +const openDB = () => { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = (e) => { + const db = e.target.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + request.onsuccess = (e) => resolve(e.target.result); + request.onerror = (e) => reject(e.target.error); + }); +}; + +// Save key pair to IndexedDB +const saveKeyPair = async (userId, keyPair) => { + const db = await openDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, "readwrite"); + const store = transaction.objectStore(STORE_NAME); + const request = store.put(keyPair, userId); + request.onsuccess = () => resolve(); + request.onerror = (e) => reject(e.target.error); + }); +}; + +// Retrieve key pair from IndexedDB +const getKeyPair = async (userId) => { + const db = await openDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, "readonly"); + const store = transaction.objectStore(STORE_NAME); + const request = store.get(userId); + request.onsuccess = (e) => resolve(e.target.result); + request.onerror = (e) => reject(e.target.error); + }); +}; + +// Helper: ArrayBuffer to Base64 +const bufferToBase64 = (buf) => { + const binstr = Array.prototype.map.call(new Uint8Array(buf), (ch) => String.fromCharCode(ch)).join(""); + return btoa(binstr); +}; + +// Helper: Base64 to ArrayBuffer +const base64ToBuffer = (base64) => { + const binstr = atob(base64); + const buf = new Uint8Array(binstr.length); + for (let i = 0; i < binstr.length; i++) { + buf[i] = binstr.charCodeAt(i); + } + return buf.buffer; +}; + +/** + * Initializes/Loads E2EE Keys for the user. + * Generates an ECDH (P-256) keypair if none exists. + * Returns the public key exported in SPKI format (Base64). + */ +export const initE2EEKeys = async (userId) => { + try { + let keyPair = await getKeyPair(userId); + if (!keyPair) { + // Generate ECDH P-256 keypair + keyPair = await window.crypto.subtle.generateKey( + { name: "ECDH", namedCurve: "P-256" }, + false, // Private key not extractable (highly secure) + ["deriveKey", "deriveBits"] + ); + await saveKeyPair(userId, keyPair); + } + + // Export public key as SPKI Base64 + const exportedPublic = await window.crypto.subtle.exportKey("spki", keyPair.publicKey); + return bufferToBase64(exportedPublic); + } catch (err) { + console.error("Failed to initialize E2EE keys:", err); + return null; + } +}; + +/** + * Derives a symmetric AES-GCM (256-bit) key from a local private key and remote public key. + */ +const deriveAesKey = async (userId, otherPublicKeySpkiBase64) => { + const keyPair = await getKeyPair(userId); + if (!keyPair) throw new Error("Local E2EE key pair not initialized"); + + const importedOtherPublic = await window.crypto.subtle.importKey( + "spki", + base64ToBuffer(otherPublicKeySpkiBase64), + { name: "ECDH", namedCurve: "P-256" }, + false, + [] + ); + + return await window.crypto.subtle.deriveKey( + { name: "ECDH", public: importedOtherPublic }, + keyPair.privateKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); +}; + +/** + * Encrypts plain text for a recipient. + */ +export const encryptMessage = async (userId, plainText, otherPublicKeySpkiBase64) => { + try { + if (!otherPublicKeySpkiBase64) { + throw new Error("Recipient does not have E2EE public key"); + } + + const aesKey = await deriveAesKey(userId, otherPublicKeySpkiBase64); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); // 12-byte IV for GCM + + const encryptedBuffer = await window.crypto.subtle.encrypt( + { name: "AES-GCM", iv: iv }, + aesKey, + new TextEncoder().encode(plainText) + ); + + return { + ciphertext: bufferToBase64(encryptedBuffer), + iv: bufferToBase64(iv), + }; + } catch (err) { + console.error("Encryption failed:", err); + throw err; + } +}; + +/** + * Decrypts a ciphertext using a sender's public key. + */ +export const decryptMessage = async (userId, ciphertextBase64, ivBase64, senderPublicKeySpkiBase64) => { + try { + if (!senderPublicKeySpkiBase64) { + throw new Error("Sender does not have E2EE public key"); + } + + const aesKey = await deriveAesKey(userId, senderPublicKeySpkiBase64); + const decryptedBuffer = await window.crypto.subtle.decrypt( + { name: "AES-GCM", iv: base64ToBuffer(ivBase64) }, + aesKey, + base64ToBuffer(ciphertextBase64) + ); + + return new TextDecoder().decode(decryptedBuffer); + } catch (err) { + console.error("Decryption failed:", err); + return "[Decryption Error: Key mismatch or tampered message]"; + } +}; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7800fce..69e429b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,6 +14,8 @@ import ConversationInsightsPage from "../pages/ConversationInsightsPage" import CallHandler from "../components/CallHandler" import useAuthStore from "./store/useAuthStore" import useCallStore from "./store/useCallStore" +import { initE2EEKeys } from "../lib/crypto" +import axiosInstance from "../lib/axios" const App = () => { const { authUser, checkAuth, isCheckingAuth } = useAuthStore() @@ -30,6 +32,21 @@ const App = () => { } }, [authUser, subscribeToCalls, unsubscribeFromCalls]) + useEffect(() => { + if (authUser) { + initE2EEKeys(authUser._id).then(async (pubKey) => { + if (pubKey && pubKey !== authUser.publicKey) { + try { + await axiosInstance.put("/auth/update-public-key", { publicKey: pubKey }); + useAuthStore.setState({ authUser: { ...authUser, publicKey: pubKey } }); + } catch (err) { + console.error("Failed to update public key on server", err); + } + } + }); + } + }, [authUser]); + if (isCheckingAuth) { return (

diff --git a/frontend/src/store/useChatStore.js b/frontend/src/store/useChatStore.js index 061820c..f797e6f 100644 --- a/frontend/src/store/useChatStore.js +++ b/frontend/src/store/useChatStore.js @@ -3,10 +3,28 @@ import toast from "react-hot-toast"; import axiosInstance from "../../lib/axios"; import { getSocket } from "../../lib/socket"; import useAuthStore from "./useAuthStore"; +import { encryptMessage, decryptMessage } from "../../lib/crypto"; // MEMORY LOCK QUEUE: Prevents async race conditions on rapid emoji clicks const reactionQueues = {}; +const decryptSingleMessage = async (msg, userId, otherPublicKey) => { + if (msg.isEncrypted && msg.message && msg.iv && otherPublicKey) { + try { + const decrypted = await decryptMessage(userId, msg.message, msg.iv, otherPublicKey); + return { ...msg, message: decrypted }; + } catch (err) { + console.error("Decryption failed for message", msg._id, err); + return { ...msg, message: "[Decryption Error: Key mismatch or missing key]" }; + } + } + return msg; +}; + +const decryptMessages = async (messagesList, userId, otherPublicKey) => { + return await Promise.all(messagesList.map(m => decryptSingleMessage(m, userId, otherPublicKey))); +}; + const useChatStore = create((set, get) => ({ users: [], selectedUser: null, @@ -43,8 +61,11 @@ const useChatStore = create((set, get) => ({ set({ isMessagesLoading: true }); try { const res = await axiosInstance.get(`/messages/${userId}`); + const { authUser } = useAuthStore.getState(); + const { selectedUser } = get(); + const decrypted = await decryptMessages(res.data.messages, authUser?._id, selectedUser?.publicKey); set({ - messages: res.data.messages, + messages: decrypted, hasMore: res.data.hasMore, }); } catch (error) { @@ -64,8 +85,11 @@ const useChatStore = create((set, get) => ({ const res = await axiosInstance.get( `/messages/${userId}?before=${oldestId}&limit=30` ); + const { authUser } = useAuthStore.getState(); + const { selectedUser } = get(); + const decrypted = await decryptMessages(res.data.messages, authUser?._id, selectedUser?.publicKey); set({ - messages: [...res.data.messages, ...messages], + messages: [...decrypted, ...messages], hasMore: res.data.hasMore, }); } catch (error) { @@ -80,7 +104,7 @@ const useChatStore = create((set, get) => ({ const { authUser } = useAuthStore.getState(); if (!selectedUser || !authUser) return; - // Optimistic UI Update + // Optimistic UI Update using plaintext message const tempId = "temp-" + Date.now(); const optimisticMsg = { _id: tempId, @@ -94,12 +118,28 @@ const useChatStore = create((set, get) => ({ set({ messages: [...messages, optimisticMsg] }); + let finalMessageData = { ...messageData }; + const hasE2EE = !!selectedUser.publicKey && !!authUser.publicKey; + + if (hasE2EE && messageData.message) { + try { + const encrypted = await encryptMessage(authUser._id, messageData.message, selectedUser.publicKey); + finalMessageData.message = encrypted.ciphertext; + finalMessageData.iv = encrypted.iv; + finalMessageData.isEncrypted = true; + } catch (err) { + console.error("E2EE Encryption failed, falling back to plaintext", err); + } + } + try { - const res = await axiosInstance.post(`/messages/send/${selectedUser._id}`, messageData); + const res = await axiosInstance.post(`/messages/send/${selectedUser._id}`, finalMessageData); - // Replace temporary message with the real one from the server + const decryptedMsg = await decryptSingleMessage(res.data, authUser._id, selectedUser.publicKey); + + // Replace temporary message with the real decrypted one from the server set((state) => ({ - messages: state.messages.map(m => m._id === tempId ? res.data : m) + messages: state.messages.map(m => m._id === tempId ? decryptedMsg : m) })); // Update sidebar: lastMessage for this user @@ -109,12 +149,12 @@ const useChatStore = create((set, get) => ({ ? { ...u, lastMessage: { - _id: res.data._id, - message: res.data.message, - image: !!res.data.image, - audio: !!res.data.audio, - senderId: res.data.senderId, - createdAt: res.data.createdAt, + _id: decryptedMsg._id, + message: decryptedMsg.message, + image: !!decryptedMsg.image, + audio: !!decryptedMsg.audio, + senderId: decryptedMsg.senderId, + createdAt: decryptedMsg.createdAt, }, } : u @@ -187,15 +227,25 @@ const useChatStore = create((set, get) => ({ const socket = getSocket(); if (!socket) return; - socket.on("newMessage", (message) => { + socket.on("newMessage", async (message) => { const { selectedUser, messages, users } = get(); const authUser = useAuthStore.getState().authUser; + if (!authUser) return; // Normalize all IDs to strings to avoid ObjectId vs string mismatches const msgSenderId = message.senderId?.toString(); const msgReceiverId = message.receiverId?.toString(); const selUserId = selectedUser?._id?.toString(); const authUserId2 = authUser?._id?.toString(); + const iSentThis = msgSenderId === authUserId2; + + // Get the appropriate public key to derive secret + const senderUser = users.find((u) => u._id?.toString() === msgSenderId); + const otherUserPublicKey = iSentThis + ? selectedUser?.publicKey + : senderUser?.publicKey; + + const decryptedMsg = await decryptSingleMessage(message, authUser._id, otherUserPublicKey); // If message is from the selected user OR sent by me to the selected user (from another device) const isFromSelectedUser = !!selUserId && msgSenderId === selUserId; @@ -203,9 +253,9 @@ const useChatStore = create((set, get) => ({ if (isFromSelectedUser || isToSelectedUser) { // Prevent duplicate optimistic messages on the sending device - const msgExists = messages.some(m => m._id?.toString() === message._id?.toString()); + const msgExists = messages.some(m => m._id?.toString() === decryptedMsg._id?.toString()); if (!msgExists) { - set({ messages: [...messages, message] }); + set({ messages: [...messages, decryptedMsg] }); } // Mark as seen if it's from them (and chat is open) @@ -216,9 +266,6 @@ const useChatStore = create((set, get) => ({ // --- Sidebar update --- // Identify the "other person" in the conversation regardless of who sent it. - // If I sent it (from another device), the other person is the receiver. - // If someone else sent it to me, the other person is the sender. - const iSentThis = msgSenderId === authUserId2; const otherUserId = iSentThis ? msgReceiverId : msgSenderId; const otherUserInSidebar = users.find((u) => u._id?.toString() === otherUserId); @@ -229,15 +276,15 @@ const useChatStore = create((set, get) => ({ ? { ...u, lastMessage: { - _id: message._id, - message: message.message, - image: !!message.image, - audio: !!message.audio, - senderId: message.senderId, - createdAt: message.createdAt, + _id: decryptedMsg._id, + message: decryptedMsg.message, + image: !!decryptedMsg.image, + audio: !!decryptedMsg.audio, + senderId: decryptedMsg.senderId, + createdAt: decryptedMsg.createdAt, }, // Increment unread only if: - // - I did NOT send this message (i.e. it came from the other person) + // - I did NOT send this message // - AND this is not the currently open chat unreadCount: iSentThis || state.selectedUser?._id?.toString() === otherUserId @@ -256,7 +303,7 @@ const useChatStore = create((set, get) => ({ if (document.visibilityState !== "visible" && Notification.permission === "granted") { const sender = users.find((u) => u._id?.toString() === msgSenderId); const senderName = sender?.name || "Someone"; - const body = message.message || (message.audio ? "🎤 Voice message" : "📷 Image"); + const body = decryptedMsg.message || (decryptedMsg.audio ? "🎤 Voice message" : "📷 Image"); const n = new Notification(`New message from ${senderName}`, { body, icon: "/favicon.png",