E2EE ensures only the sender and recipient can read messages. Here is how to implement it with the Web Crypto API.
Key Generation
// lib/crypto/keys.ts
// Generate an ECDH key pair for key exchange
export async function generateKeyPair(): Promise<CryptoKeyPair> {
return crypto.subtle.generateKey(
{ name: "ECDH", namedCurve: "P-256" },
true, // extractable β needed for export
["deriveKey", "deriveBits"],
);
}
// Export a public key for sharing
export async function exportPublicKey(key: CryptoKey): Promise<string> {
const raw = await crypto.subtle.exportKey("raw", key);
return btoa(String.fromCharCode(...new Uint8Array(raw)));
}
// Import a received public key
export async function importPublicKey(base64: string): Promise<CryptoKey> {
const raw = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
return crypto.subtle.importKey(
"raw",
raw,
{ name: "ECDH", namedCurve: "P-256" },
true,
[],
);
}
Shared Secret Derivation
// lib/crypto/shared-secret.ts
// Derive a shared AES key from our private key and their public key
export async function deriveSharedKey(
privateKey: CryptoKey,
publicKey: CryptoKey,
): Promise<CryptoKey> {
return crypto.subtle.deriveKey(
{ name: "ECDH", public: publicKey },
privateKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
Message Encryption and Decryption
// lib/crypto/messages.ts
interface EncryptedMessage {
ciphertext: string;
iv: string;
}
export async function encryptMessage(
plaintext: string,
sharedKey: CryptoKey,
): Promise<EncryptedMessage> {
const encoder = new TextEncoder();
const data = encoder.encode(plaintext);
// Generate a unique IV for each message
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
sharedKey,
data,
);
return {
ciphertext: btoa(String.fromCharCode(...new Uint8Array(ciphertext))),
iv: btoa(String.fromCharCode(...iv)),
};
}
export async function decryptMessage(
encrypted: EncryptedMessage,
sharedKey: CryptoKey,
): Promise<string> {
const ciphertext = Uint8Array.from(atob(encrypted.ciphertext), (c) =>
c.charCodeAt(0),
);
const iv = Uint8Array.from(atob(encrypted.iv), (c) => c.charCodeAt(0));
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
sharedKey,
ciphertext,
);
return new TextDecoder().decode(decrypted);
}
Key Storage With IndexedDB
// lib/crypto/storage.ts
const DB_NAME = "e2ee-keys";
const STORE_NAME = "key-pairs";
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = () => {
request.result.createObjectStore(STORE_NAME, { keyPath: "id" });
};
});
}
export async function storeKeyPair(
userId: string,
keyPair: CryptoKeyPair,
): Promise<void> {
const db = await openDB();
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
// Export keys for storage
const publicKey = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
const privateKey = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
store.put({ id: userId, publicKey, privateKey });
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function loadKeyPair(
userId: string,
): Promise<CryptoKeyPair | null> {
const db = await openDB();
const tx = db.transaction(STORE_NAME, "readonly");
const store = tx.objectStore(STORE_NAME);
return new Promise((resolve, reject) => {
const request = store.get(userId);
request.onerror = () => reject(request.error);
request.onsuccess = async () => {
const data = request.result;
if (!data) {
resolve(null);
return;
}
const publicKey = await crypto.subtle.importKey(
"jwk",
data.publicKey,
{ name: "ECDH", namedCurve: "P-256" },
true,
[],
);
const privateKey = await crypto.subtle.importKey(
"jwk",
data.privateKey,
{ name: "ECDH", namedCurve: "P-256" },
true,
["deriveKey", "deriveBits"],
);
resolve({ publicKey, privateKey });
};
});
}
E2EE Hook
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { generateKeyPair, exportPublicKey, importPublicKey } from "@/lib/crypto/keys";
import { deriveSharedKey } from "@/lib/crypto/shared-secret";
import { encryptMessage, decryptMessage } from "@/lib/crypto/messages";
import { storeKeyPair, loadKeyPair } from "@/lib/crypto/storage";
interface UseE2EEOptions {
userId: string;
onPublicKey: (publicKey: string) => void; // share via server
}
export function useE2EE({ userId, onPublicKey }: UseE2EEOptions) {
const [ready, setReady] = useState(false);
const keyPairRef = useRef<CryptoKeyPair | null>(null);
const sharedKeysRef = useRef<Map<string, CryptoKey>>(new Map());
// Initialize key pair
useEffect(() => {
async function init() {
let keyPair = await loadKeyPair(userId);
if (!keyPair) {
keyPair = await generateKeyPair();
await storeKeyPair(userId, keyPair);
}
keyPairRef.current = keyPair;
const publicKeyString = await exportPublicKey(keyPair.publicKey);
onPublicKey(publicKeyString);
setReady(true);
}
init();
}, [userId, onPublicKey]);
// Add a peer's public key and derive shared secret
const addPeer = useCallback(async (peerId: string, peerPublicKey: string) => {
if (!keyPairRef.current) return;
const importedKey = await importPublicKey(peerPublicKey);
const sharedKey = await deriveSharedKey(
keyPairRef.current.privateKey,
importedKey,
);
sharedKeysRef.current.set(peerId, sharedKey);
}, []);
// Encrypt a message for a peer
const encrypt = useCallback(async (peerId: string, plaintext: string) => {
const sharedKey = sharedKeysRef.current.get(peerId);
if (!sharedKey) throw new Error("No shared key with peer");
return encryptMessage(plaintext, sharedKey);
}, []);
// Decrypt a message from a peer
const decrypt = useCallback(async (peerId: string, ciphertext: { ciphertext: string; iv: string }) => {
const sharedKey = sharedKeysRef.current.get(peerId);
if (!sharedKey) throw new Error("No shared key with peer");
return decryptMessage(ciphertext, sharedKey);
}, []);
return { ready, addPeer, encrypt, decrypt };
}
Usage in a Chat Component
"use client";
import { useE2EE } from "@/hooks/use-e2ee";
import { useState, useCallback } from "react";
export function EncryptedChat({ userId, peerId }: { userId: string; peerId: string }) {
const [messages, setMessages] = useState<{ text: string; from: string }[]>([]);
const [input, setInput] = useState("");
const { ready, addPeer, encrypt, decrypt } = useE2EE({
userId,
onPublicKey: (key) => {
// Send public key to server for peer to receive
fetch("/api/keys", {
method: "POST",
body: JSON.stringify({ userId, publicKey: key }),
headers: { "Content-Type": "application/json" },
});
},
});
const handleSend = useCallback(async () => {
if (!input.trim()) return;
const encrypted = await encrypt(peerId, input.trim());
// Send encrypted message via your transport
await fetch("/api/messages", {
method: "POST",
body: JSON.stringify({ to: peerId, ...encrypted }),
headers: { "Content-Type": "application/json" },
});
setMessages((prev) => [...prev, { text: input.trim(), from: "me" }]);
setInput("");
}, [input, peerId, encrypt]);
if (!ready) return <div>Setting up encryption...</div>;
return (
<div className="flex flex-col h-96 border rounded-lg">
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{messages.map((msg, i) => (
<div
key={i}
className={`text-sm px-3 py-2 rounded-lg max-w-xs ${
msg.from === "me"
? "bg-primary text-primary-foreground ml-auto"
: "bg-muted"
}`}
>
{msg.text}
</div>
))}
</div>
<div className="flex gap-2 p-3 border-t">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
className="flex-1 px-3 py-2 border rounded-md text-sm"
placeholder="Encrypted message..."
/>
<button
onClick={handleSend}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm"
>
Send
</button>
</div>
</div>
);
}
Need Secure Communication Features?
We build encrypted communication systems for sensitive industries. Contact us to discuss your requirements.