ECDH Vault Sharing
Vault sharing uses Elliptic-curve Diffie-Hellman (ECDH) key exchange to securely transfer vault keys without the server ever seeing them.
Overview
When Alice shares a vault with Bob:
- Alice and Bob each have an ECDH key pair
- Alice derives a shared secret using her private key + Bob's public key
- Alice encrypts the vault key with this shared secret
- Bob derives the same shared secret using his private key + Alice's public key
- Bob decrypts the vault key with the shared secret
The server only sees encrypted keys — it cannot derive the shared secret.
ECDH Key Exchange
The Math
Alice: private_a, public_A = private_a × G
Bob: private_b, public_B = private_b × G
Shared Secret (Alice computes): private_a × public_B = private_a × private_b × G
Shared Secret (Bob computes): private_b × public_A = private_b × private_a × G
Both arrive at: private_a × private_b × GImplementation
// Generate ECDH key pair
const keyPair = await crypto.subtle.generateKey(
{ name: "ECDH", namedCurve: "P-256" },
true,
["deriveKey"]
);
// Derive shared secret
const sharedSecret = await crypto.subtle.deriveKey(
{
name: "ECDH",
public: otherPartyPublicKey
},
myPrivateKey,
{ name: "AES-GCM", length: 256 },
false,
["wrapKey", "unwrapKey"]
);Sharing Flow
Step 1: Key Generation (First-time setup)
Each user generates an ECDH key pair when they first create an account:
// Generate key pair
const keyPair = await crypto.subtle.generateKey(
{ name: "ECDH", namedCurve: "P-256" },
true,
["deriveKey"]
);
// Export public key for server storage
const publicKey = await crypto.subtle.exportKey("spki", keyPair.publicKey);
// Encrypt private key with vault key for backup
const encryptedPrivateKey = await encryptWithVaultKey(
await crypto.subtle.exportKey("pkcs8", keyPair.privateKey),
vaultKey
);
// Store on server
await api.auth.sharingKeys.$post({
json: {
publicKey: base64Encode(publicKey),
encryptedPrivateKey: base64Encode(encryptedPrivateKey)
}
});Step 2: Create Invitation (Sender)
// 1. Get recipient's public key
const { publicKey: recipientPublicKeyRaw } = await api.auth.user[":email"]["public-key"].$get({
param: { email: recipientEmail }
});
// 2. Import recipient's public key
const recipientPublicKey = await crypto.subtle.importKey(
"spki",
base64Decode(recipientPublicKeyRaw),
{ name: "ECDH", namedCurve: "P-256" },
false,
[]
);
// 3. Derive shared secret
const sharedSecret = await crypto.subtle.deriveKey(
{ name: "ECDH", public: recipientPublicKey },
myPrivateKey,
{ name: "AES-GCM", length: 256 },
false,
["wrapKey"]
);
// 4. Wrap vault key with shared secret
const iv = crypto.getRandomValues(new Uint8Array(12));
const wrappedVaultKey = await crypto.subtle.wrapKey(
"raw",
vaultKey,
sharedSecret,
{ name: "AES-GCM", iv }
);
// 5. Create invitation
await api.vault[":name"].share.$post({
param: { name: vaultName },
json: {
email: recipientEmail,
role: "write",
wrappedKeyForRecipient: base64Encode(iv) + "." + base64Encode(wrappedVaultKey)
}
});Step 3: Accept Invitation (Recipient)
// 1. Get invitation details
const invitation = await api.invitations[":id"].$get({
param: { id: invitationId }
});
// 2. Get sender's public key
const { publicKey: senderPublicKeyRaw } = await api.auth.user[":email"]["public-key"].$get({
param: { email: invitation.ownerEmail }
});
// 3. Import sender's public key
const senderPublicKey = await crypto.subtle.importKey(
"spki",
base64Decode(senderPublicKeyRaw),
{ name: "ECDH", namedCurve: "P-256" },
false,
[]
);
// 4. Derive same shared secret
const sharedSecret = await crypto.subtle.deriveKey(
{ name: "ECDH", public: senderPublicKey },
myPrivateKey,
{ name: "AES-GCM", length: 256 },
false,
["unwrapKey"]
);
// 5. Unwrap vault key
const [ivB64, wrappedKeyB64] = invitation.wrappedKey.split(".");
const vaultKey = await crypto.subtle.unwrapKey(
"raw",
base64Decode(wrappedKeyB64),
sharedSecret,
{ name: "AES-GCM", iv: base64Decode(ivB64) },
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
// 6. Accept invitation
await api.invitations[":id"].accept.$post({
param: { id: invitationId }
});
// Now can decrypt shared vault!Security Properties
Zero-Knowledge
The server sees:
- ✅ Public keys (cannot derive shared secret)
- ✅ Wrapped vault key (cannot decrypt without shared secret)
- ❌ Private keys (encrypted with user's vault key)
- ❌ Shared secret (never transmitted)
- ❌ Plaintext vault key
Forward Secrecy
- Each sharing relationship uses a unique shared secret
- Compromising one shared secret doesn't expose others
- Revoking access doesn't expose past communications
Key Compromise
If Alice's ECDH private key is compromised:
- Attacker can derive shared secrets with anyone Alice has shared with
- Attacker can decrypt vault keys shared with Alice
- Mitigation: Private key encrypted with vault key, which requires master password
Access Control
Roles
| Role | Read | Write | Delete | Re-share |
|---|---|---|---|---|
read | ✅ | ❌ | ❌ | ❌ |
write | ✅ | ✅ | ❌ | ❌ |
admin | ✅ | ✅ | ✅ | ✅ |
Revocation
Revoking access:
- Owner calls revoke endpoint
- Server removes recipient's access record
- Recipient can no longer fetch vault data
Note: Revocation doesn't re-encrypt. If recipient had access, they may have copied data.
Cryptographic Details
Curve
- P-256 (secp256r1): NIST standard curve
- Key size: 256-bit
- Security level: ~128-bit
Key Derivation
ECDH raw shared secret is passed through HKDF internally by Web Crypto API when using deriveKey.
Encryption
Wrapped keys use AES-256-GCM:
- Key: Derived from ECDH
- IV: Random 12 bytes per wrap
- Tag: 128-bit authentication tag
Code Reference
Sharing utilities in @pwm/shared:
import {
generateSharingKeyPair,
deriveSharedSecret,
wrapKeyForRecipient,
unwrapKeyFromSender
} from "@pwm/shared";Related
- Zero-Knowledge Security - Overall security model
- Vault Sharing (CLI) - CLI sharing commands
- Sharing API - API endpoints