The Multi-Device E2EE Nightmare: Secure Key Exchange with ECDH
You've built a secure vault. Your user's data is encrypted locally using AES-GCM. But what happens when that user logs in on a new laptop? How does their phone securely send the AES master key to their laptop without your server intercepting it?
The "Symmetric" Trap
Standard AES encryption is symmetric. The exact same password used to lock the vault is required to unlock it. If you want to sync data across devices, you might be tempted to just encrypt the Master AES key with the user's login password and store it on the server.
This is a massive vulnerability. If the user uses a weak password, a server breach allows attackers to brute-force that password, unwrap the Master Key, and decrypt the entire vault.
The Solution: ECDH Key Exchange
To securely transmit the Master AES key from Device A (Phone) to Device B (Laptop), we must use Asymmetric Cryptography. Specifically, we use Elliptic Curve Diffie-Hellman (ECDH). This is the exact same architectural foundation that powers WhatsApp, Signal, and iMessage.
The Magic of ECDH: If Device A combines its Private Key with Device B's Public Key, it generates a unique "Shared Secret". If Device B combines its Private Key with Device A's Public Key, it generates the exact same Shared Secret. The server only sees the Public keys.
Step 1: Generating the Keypair (Device B)
When the user logs in on their new Laptop, the browser generates an ECDH keypair. It keeps the Private Key hidden in IndexedDB and uploads the Public Key to your server.
// Device B (Laptop) generates an Asymmetric Key Pair
async function generateDeviceKeyPair() {
const keyPair = await window.crypto.subtle.generateKey(
{
name: "ECDH",
namedCurve: "P-384"
},
true,
["deriveKey"]
);
// Export the Public Key to save to your database (e.g., Supabase)
const publicKeyJwk = await window.crypto.subtle.exportKey("jwk", keyPair.publicKey);
await uploadPublicKeyToServer(publicKeyJwk);
return keyPair.privateKey; // Save this locally! NEVER upload it.
}
Step 2: Deriving the Shared Secret (Device A)
The Phone (Device A), which already holds the Master AES Key, fetches the Laptop's Public Key from the server. It then uses its own Private Key to derive the Shared Secret AES Key.
// Device A (Phone) derives the Shared Secret
async function deriveSharedKey(deviceAPrivateKey, deviceBPublicKeyJwk) {
// Import Device B's public key
const deviceBPublicKey = await window.crypto.subtle.importKey(
"jwk",
deviceBPublicKeyJwk,
{ name: "ECDH", namedCurve: "P-384" },
true,
[]
);
// Derive a symmetric AES-GCM key using the ECDH math magic
return await window.crypto.subtle.deriveKey(
{
name: "ECDH",
public: deviceBPublicKey // Combine with B's public key
},
deviceAPrivateKey, // Combine with A's private key
{ name: "AES-GCM", length: 256 },
false,
["wrapKey", "unwrapKey"]
);
}
Step 3: Wrapping and Syncing
Now that Device A has a Shared Key that only Device B can calculate, it uses wrapKey to encrypt the Master AES Vault Key and sends it to the server. Device B downloads the wrapped key, derives the exact same Shared Secret, and uses unwrapKey to extract the Master Vault Key.
The Server is completely blind. It only routed a wrapped key and public keys. It mathematically cannot decrypt the vault.
Don't Want to Build This Yourself?
Key management, wrapping, and public key exchanges are incredibly easy to mess up. Let us handle the cryptography for you.