/** * crypto.js — Zero-knowledge cryptography layer * * All encryption/decryption runs in the browser using the Web Crypto API. * The vault key (AES-256-GCM) is derived from the master password client-side * and is NEVER sent to the server. The server only stores encrypted blobs. * * Key derivation chain: * authHash = PBKDF2(masterPassword, email, 100_000 iter, SHA-256) → sent to server for auth * vaultKey = PBKDF2(masterPassword, enc_key_salt, 600_000 iter, SHA-256) → stays in memory only */ const Crypto = (() => { const subtle = window.crypto.subtle; // ── Helpers ────────────────────────────────────────────────────────────── function strToBytes(str) { return new TextEncoder().encode(str); } 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); } // ── PBKDF2 key import ──────────────────────────────────────────────────── async function importPbkdf2Key(masterPassword) { return subtle.importKey( 'raw', strToBytes(masterPassword), 'PBKDF2', false, // not extractable ['deriveBits', 'deriveKey'] ); } // ── Auth hash (sent to server) ─────────────────────────────────────────── /** * Derive an auth token from the master password. * Used ONLY for server-side authentication — never for encryption. * Returns a base64 string safe to POST to /api/auth/login|register. */ async function deriveAuthHash(masterPassword, email) { const baseKey = await importPbkdf2Key(masterPassword); const bits = await subtle.deriveBits( { name: 'PBKDF2', salt: strToBytes(email.toLowerCase()), iterations: 100_000, hash: 'SHA-256', }, baseKey, 256 ); return bytesToBase64(new Uint8Array(bits)); } // ── Vault key (stays in memory) ────────────────────────────────────────── /** * Derive the AES-256-GCM vault key from the master password. * enc_key_salt is the base64-encoded 16-byte salt returned by the server on login. * The returned CryptoKey is marked extractable:false — raw bytes cannot be read back. */ async function deriveVaultKey(masterPassword, enc_key_salt) { const baseKey = await importPbkdf2Key(masterPassword); return subtle.deriveKey( { name: 'PBKDF2', salt: base64ToBytes(enc_key_salt), iterations: 600_000, hash: 'SHA-256', }, baseKey, { name: 'AES-GCM', length: 256 }, false, // extractable:false — key material cannot be exported ['encrypt', 'decrypt'] ); } // ── Encrypt / Decrypt ──────────────────────────────────────────────────── /** * Encrypt a plain JS object with the vault key. * A fresh random 12-byte IV is generated for every call (required for GCM). * Returns { enc_data: base64, iv: base64 } */ async function encryptItem(vaultKey, plaintextObject) { const iv = window.crypto.getRandomValues(new Uint8Array(12)); const plaintext = strToBytes(JSON.stringify(plaintextObject)); const ciphertext = await subtle.encrypt( { name: 'AES-GCM', iv }, vaultKey, plaintext ); return { enc_data: bytesToBase64(new Uint8Array(ciphertext)), iv: bytesToBase64(iv), }; } /** * Decrypt an encrypted vault item back to a JS object. * enc_data and iv must be the base64 strings stored on the server. */ async function decryptItem(vaultKey, enc_data, iv) { const plaintext = await subtle.decrypt( { name: 'AES-GCM', iv: base64ToBytes(iv) }, vaultKey, base64ToBytes(enc_data) ); return JSON.parse(new TextDecoder().decode(plaintext)); } /** * Generate a cryptographically random base64 string (for enc_key_salt). * byteLength defaults to 16 (128-bit salt). */ function generateSalt(byteLength = 16) { return bytesToBase64(window.crypto.getRandomValues(new Uint8Array(byteLength))); } // ── Public API ─────────────────────────────────────────────────────────── return { deriveAuthHash, deriveVaultKey, encryptItem, decryptItem, generateSalt }; })();