Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions backend/src/controllers/auth.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
12 changes: 9 additions & 3 deletions backend/src/controllers/message.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions backend/src/models/message.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
);
Expand Down
4 changes: 4 additions & 0 deletions backend/src/models/user.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ const userSchema = new mongoose.Schema({
default: null,
trim: true,
},
publicKey: {
type: String,
default: null,
},
}, { timestamps: true });

userSchema.index({ name: "text" });
Expand Down
3 changes: 2 additions & 1 deletion backend/src/routes/auth.route.js
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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);

Expand Down
9 changes: 8 additions & 1 deletion frontend/components/chat/ChatWindow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,14 @@ const mediaMessages = messages.filter(
</button>
<Avatar user={selectedUser} isOnline={isOnline} />
<div>
<p className="font-semibold text-sm">{selectedUser.name}</p>
<div className="flex items-center gap-1.5">
<p className="font-semibold text-sm">{selectedUser.name}</p>
{selectedUser.publicKey && authUser.publicKey ? (
<span className="badge badge-success badge-outline text-[10px] py-0 px-1 flex items-center gap-0.5" title="End-to-End Encrypted">
🔒 E2EE
</span>
) : null}
</div>
{selectedUser.statusMood ? (
<p className="text-xs text-base-content/60">
{getStatusMoodLabel(selectedUser.statusMood)}
Expand Down
166 changes: 166 additions & 0 deletions frontend/lib/crypto.js
Original file line number Diff line number Diff line change
@@ -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]";
}
};
17 changes: 17 additions & 0 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 (
<div className="min-h-screen flex items-center justify-center bg-base-200">
Expand Down
Loading