Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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:

  1. Alice and Bob each have an ECDH key pair
  2. Alice derives a shared secret using her private key + Bob's public key
  3. Alice encrypts the vault key with this shared secret
  4. Bob derives the same shared secret using his private key + Alice's public key
  5. 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 × G

Implementation

// 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

RoleReadWriteDeleteRe-share
read
write
admin

Revocation

Revoking access:

  1. Owner calls revoke endpoint
  2. Server removes recipient's access record
  3. 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