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} +{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 (