/** * sharing.js — ECDH P-256 cryptography for zero-knowledge item sharing * * Each user has a P-256 keypair: * - Public key : stored on server as base64 raw bytes (65-byte uncompressed point) * - Private key : stored on server as JWK, AES-256-GCM encrypted with the user's vault key * * When Alice shares with Bob: * 1. Alice fetches Bob's public key from the server * 2. Alice derives an ECDH shared secret: ECDH(Alice_priv, Bob_pub) * 3. Alice imports that shared secret as an AES-256-GCM key * 4. Alice encrypts the item's plaintext → (enc_data, iv) * 5. Alice POST /api/sharing with the ciphertext — server stores the blob * * When Bob decrypts: * 1. Bob fetches Alice's public key from the server (returned in inbox response) * 2. Bob derives the same ECDH shared secret: ECDH(Bob_priv, Alice_pub) ← commutative! * 3. Bob decrypts enc_data with the derived key * * The server only ever sees ciphertext. Zero-knowledge. */ const SharingCrypto = (() => { const subtle = window.crypto.subtle; // ── Helpers (same encoding as crypto.js) ───────────────────────────────── function base64ToBytes(b64) { const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes; } function bytesToBase64(bytes) { let bin = ''; bytes.forEach(b => (bin += String.fromCharCode(b))); return btoa(bin); } // ── Key generation ──────────────────────────────────────────────────────── /** * Generate a fresh ECDH P-256 keypair. * Both keys are extractable so they can be exported/stored. */ async function generateKeyPair() { return subtle.generateKey( { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits'], ); } /** * Export the public key as raw bytes (uncompressed point, 65 bytes) → base64. * This is what gets stored on the server and shared with other users. */ async function exportPublicKey(publicKey) { const raw = await subtle.exportKey('raw', publicKey); return bytesToBase64(new Uint8Array(raw)); } /** * Encrypt the private key JWK with the user's vault key (AES-256-GCM). * The resulting ciphertext is stored on the server — only the user can decrypt it. */ async function encryptPrivateKey(vaultKey, privateKey) { const jwk = await subtle.exportKey('jwk', privateKey); const iv = window.crypto.getRandomValues(new Uint8Array(12)); const plaintext = new TextEncoder().encode(JSON.stringify(jwk)); const ciphertext = await subtle.encrypt({ name: 'AES-GCM', iv }, vaultKey, plaintext); return { private_key_enc: bytesToBase64(new Uint8Array(ciphertext)), private_key_iv: bytesToBase64(iv), }; } /** * Decrypt the private key JWK (fetched from server) using the user's vault key. * Returns a CryptoKey usable for ECDH deriveBits. */ async function decryptPrivateKey(vaultKey, private_key_enc, private_key_iv) { const plaintext = await subtle.decrypt( { name: 'AES-GCM', iv: base64ToBytes(private_key_iv) }, vaultKey, base64ToBytes(private_key_enc), ); const jwk = JSON.parse(new TextDecoder().decode(plaintext)); return subtle.importKey( 'jwk', jwk, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits'], ); } /** * Import a remote user's public key from its base64 raw representation. */ async function importPublicKey(base64Raw) { return subtle.importKey( 'raw', base64ToBytes(base64Raw), { name: 'ECDH', namedCurve: 'P-256' }, false, [], // public keys have no usages in WebCrypto ECDH ); } // ── Shared-secret derivation ────────────────────────────────────────────── /** * Derive an AES-256-GCM CryptoKey from the ECDH shared secret. * ECDH is commutative: ECDH(A_priv, B_pub) === ECDH(B_priv, A_pub). */ async function deriveSharedKey(myPrivateKey, theirPublicKey) { const bits = await subtle.deriveBits( { name: 'ECDH', public: theirPublicKey }, myPrivateKey, 256, ); return subtle.importKey( 'raw', bits, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'], ); } // ── Encrypt / Decrypt with shared key ──────────────────────────────────── async function encryptForShare(sharedKey, plaintextObject) { const iv = window.crypto.getRandomValues(new Uint8Array(12)); const plaintext = new TextEncoder().encode(JSON.stringify(plaintextObject)); const ciphertext = await subtle.encrypt({ name: 'AES-GCM', iv }, sharedKey, plaintext); return { enc_data: bytesToBase64(new Uint8Array(ciphertext)), iv: bytesToBase64(iv), }; } async function decryptShare(sharedKey, enc_data, iv) { const plaintext = await subtle.decrypt( { name: 'AES-GCM', iv: base64ToBytes(iv) }, sharedKey, base64ToBytes(enc_data), ); return JSON.parse(new TextDecoder().decode(plaintext)); } /** * Encrypt a display name string with the ECDH shared key. * Uses the same AES-256-GCM primitive as encryptForShare but operates on * a plain string rather than a JSON-serialised object. */ async function encryptName(sharedKey, name) { const iv = window.crypto.getRandomValues(new Uint8Array(12)); const plaintext = new TextEncoder().encode(name); const ciphertext = await subtle.encrypt({ name: 'AES-GCM', iv }, sharedKey, plaintext); return { enc_name: bytesToBase64(new Uint8Array(ciphertext)), iv_name: bytesToBase64(iv), }; } /** * Decrypt an encrypted display name. * Returns null on failure (e.g. legacy share with no enc_name). */ async function decryptName(sharedKey, enc_name, iv_name) { try { const plaintext = await subtle.decrypt( { name: 'AES-GCM', iv: base64ToBytes(iv_name) }, sharedKey, base64ToBytes(enc_name), ); return new TextDecoder().decode(plaintext); } catch { return null; } } // ── Public API ──────────────────────────────────────────────────────────── return { generateKeyPair, exportPublicKey, encryptPrivateKey, decryptPrivateKey, importPublicKey, deriveSharedKey, encryptForShare, decryptShare, encryptName, decryptName, }; })(); /** * SharingSession — holds the decrypted ECDH private key for the tab lifetime. * Similar to VaultSession; cleared on sign-out. */ const SharingSession = (() => { let _privateKey = null; function setKey(key) { _privateKey = key; } function getKey() { return _privateKey; } function clear() { _privateKey = null; } function isReady() { return _privateKey !== null; } return { setKey, getKey, clear, isReady }; })();