/** * extension/shared/sharing-crypto.js * * ECDH P-256 cryptography for zero-knowledge item sharing — extension port. * Mirrors app/static/js/sharing.js exactly but uses `crypto.subtle` * (no `window.`) so it works in both the popup and the service worker. * * Only the primitives needed by the extension are included: * importPublicKey, decryptPrivateKey, deriveSharedKey, decryptShare, decryptName */ const ExtSharingCrypto = (() => { const subtle = crypto.subtle; // ── Helpers ──────────────────────────────────────────────────────────────── function base64ToBytes(b64) { const bin = atob(b64); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } // ── Key import ───────────────────────────────────────────────────────────── /** * Import a remote user's ECDH public key from base64 raw bytes (65-byte * uncompressed P-256 point). */ async function importPublicKey(base64Raw) { return subtle.importKey( 'raw', base64ToBytes(base64Raw), { name: 'ECDH', namedCurve: 'P-256' }, false, [], // public keys have no usages in WebCrypto ECDH ); } /** * Decrypt the user's ECDH private key JWK (fetched from server as * private_key_enc / private_key_iv) using the vault AES-256-GCM 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'], ); } // ── 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'], ); } // ── Decrypt ──────────────────────────────────────────────────────────────── /** * Decrypt a shared item payload (JSON object) using the ECDH shared key. */ 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)); } /** * Decrypt an encrypted item display name. * Returns null on failure (e.g. legacy share without 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 { importPublicKey, decryptPrivateKey, deriveSharedKey, decryptShare, decryptName, }; })();