/* Cassandra — client-side encrypted portfolio sync. * * The server only ever sees opaque ciphertext. The browser: * 1. Derives an AES-GCM key from the user's PIN with PBKDF2 (600k SHA-256). * 2. Encrypts the pie JSON, packs salt+nonce+ct into one blob. * 3. POSTs the blob to /api/portfolio/sync. * 4. On pull, fetches the blob, reverses the steps with the PIN. * * The derived key is cached in sessionStorage so the user enters the PIN * at most once per browser session (cleared on tab close / logout). The * server-side outer wrap (see app/services/portfolio_sync.py) hardens the * stored ciphertext against a DB-only leak. * * Packed inner-blob format (all bytes, then base64-url for transport): * byte 0: version (currently 1) * bytes 1..4: PBKDF2 iteration count, uint32 big-endian * bytes 5..20: salt (16 bytes) * bytes 21..32: nonce (12 bytes) * bytes 33..: AES-GCM ciphertext (includes 16-byte tag suffix) */ (function () { 'use strict'; const VERSION = 1; const ITERATIONS = 600_000; const SALT_LEN = 16; const NONCE_LEN = 12; const HEADER_LEN = 1 + 4 + SALT_LEN + NONCE_LEN; // = 33 const SESSION_KEY_STORAGE = 'cassandra.sync.key.v1'; const SESSION_SALT_STORAGE = 'cassandra.sync.salt.v1'; // --- byte helpers ---------------------------------------------------- function u8concat(parts) { let n = 0; for (const p of parts) n += p.length; const out = new Uint8Array(n); let i = 0; for (const p of parts) { out.set(p, i); i += p.length; } return out; } function b64urlEncode(bytes) { let s = ''; const chunk = 0x8000; for (let i = 0; i < bytes.length; i += chunk) { s += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); } return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } function b64urlDecode(s) { const norm = s.replace(/-/g, '+').replace(/_/g, '/'); const padded = norm + '='.repeat((4 - norm.length % 4) % 4); const bin = atob(padded); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } // --- WebCrypto ------------------------------------------------------- async function pbkdf2Derive(pin, salt, iterations) { const baseKey = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(pin), { name: 'PBKDF2' }, false, ['deriveKey'], ); return crypto.subtle.deriveKey( { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, baseKey, { name: 'AES-GCM', length: 256 }, true, // extractable: we cache raw bytes in sessionStorage ['encrypt', 'decrypt'], ); } async function exportKey(key) { const raw = await crypto.subtle.exportKey('raw', key); return new Uint8Array(raw); } async function importKey(raw) { return crypto.subtle.importKey( 'raw', raw, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'], ); } // --- session cache --------------------------------------------------- // We cache both the raw key AND the salt that produced it, so a push // after upload can rebuild the same packed-blob header without // re-prompting for a PIN. Lives in sessionStorage so it dies with the // tab. function cacheKey(rawKey, salt) { try { sessionStorage.setItem(SESSION_KEY_STORAGE, b64urlEncode(rawKey)); sessionStorage.setItem(SESSION_SALT_STORAGE, b64urlEncode(salt)); } catch (e) { console.warn('cassandra.sync: sessionStorage write failed', e); } } async function getCachedKeyAndSalt() { const rk = sessionStorage.getItem(SESSION_KEY_STORAGE); const sk = sessionStorage.getItem(SESSION_SALT_STORAGE); if (!rk || !sk) return null; return { key: await importKey(b64urlDecode(rk)), salt: b64urlDecode(sk), }; } function clearCachedKey() { sessionStorage.removeItem(SESSION_KEY_STORAGE); sessionStorage.removeItem(SESSION_SALT_STORAGE); } // --- pack / unpack --------------------------------------------------- function packBlob(salt, nonce, ct, iterations) { const header = new Uint8Array(HEADER_LEN); header[0] = VERSION; new DataView(header.buffer).setUint32(1, iterations, false); // big-endian header.set(salt, 5); header.set(nonce, 5 + SALT_LEN); return u8concat([header, new Uint8Array(ct)]); } function unpackBlob(bytes) { if (bytes.length < HEADER_LEN + 16) { throw new Error('blob too small'); } const version = bytes[0]; if (version !== VERSION) { throw new Error('unknown sync blob version: ' + version); } const iterations = new DataView(bytes.buffer, bytes.byteOffset, HEADER_LEN) .getUint32(1, false); const salt = bytes.slice(5, 5 + SALT_LEN); const nonce = bytes.slice(5 + SALT_LEN, HEADER_LEN); const ct = bytes.slice(HEADER_LEN); return { version, iterations, salt, nonce, ct }; } // --- encrypt / decrypt ---------------------------------------------- /** * Encrypt a pie object with `pin`. Returns the packed blob as a * base64url string ready for POST /api/portfolio/sync. Also caches * the derived key in sessionStorage so subsequent pushes don't need * the PIN. */ async function encryptPie(pie, pin) { const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN)); const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN)); const key = await pbkdf2Derive(pin, salt, ITERATIONS); const plaintext = new TextEncoder().encode(JSON.stringify(pie)); const ct = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: nonce }, key, plaintext, ); cacheKey(await exportKey(key), salt); return b64urlEncode(packBlob(salt, nonce, ct, ITERATIONS)); } /** * Re-encrypt with a cached key (no PIN needed). Re-uses the cached * salt so the blob remains decryptable with the same PIN later. * Returns null if no key is cached. */ async function encryptPieWithCachedKey(pie) { const cached = await getCachedKeyAndSalt(); if (!cached) return null; const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN)); const plaintext = new TextEncoder().encode(JSON.stringify(pie)); const ct = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: nonce }, cached.key, plaintext, ); return b64urlEncode(packBlob(cached.salt, nonce, ct, ITERATIONS)); } /** * Decrypt a server blob with `pin`. Throws BadPinError on auth failure. * Caches the derived key on success. */ class BadPinError extends Error { constructor() { super('Incorrect PIN'); this.name = 'BadPinError'; } } async function decryptBlob(blobB64, pin) { const bytes = b64urlDecode(blobB64); const { iterations, salt, nonce, ct } = unpackBlob(bytes); const key = await pbkdf2Derive(pin, salt, iterations); let plaintext; try { plaintext = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: nonce }, key, ct, ); } catch (_e) { throw new BadPinError(); } cacheKey(await exportKey(key), salt); return JSON.parse(new TextDecoder().decode(plaintext)); } // --- network --------------------------------------------------------- async function getStatus() { const r = await fetch('/api/portfolio/sync/status', { credentials: 'same-origin', headers: { 'Accept': 'application/json' }, }); if (r.status === 402) return { exists: false, paid: false }; if (!r.ok) throw new Error('sync status: HTTP ' + r.status); const body = await r.json(); return { exists: !!body.exists, updated_at: body.updated_at, paid: true }; } async function pushSync(pie, pin) { // If a cached key exists, re-use it; otherwise derive from the PIN. let blob = await encryptPieWithCachedKey(pie); if (!blob) { if (!pin) throw new Error('PIN required to enable sync'); blob = await encryptPie(pie, pin); } const r = await fetch('/api/portfolio/sync', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blob }), }); if (!r.ok) { const body = await r.json().catch(() => ({})); throw new Error(body.detail || ('sync push: HTTP ' + r.status)); } return r.json(); } async function pullSync(pin) { const r = await fetch('/api/portfolio/sync', { credentials: 'same-origin', headers: { 'Accept': 'application/json' }, }); if (r.status === 404) return null; if (!r.ok) { const body = await r.json().catch(() => ({})); // 429 → server already throttling; bubble the message up unchanged. throw new Error(body.detail || ('sync pull: HTTP ' + r.status)); } const { blob } = await r.json(); return decryptBlob(blob, pin); } async function disableSync() { const r = await fetch('/api/portfolio/sync', { method: 'DELETE', credentials: 'same-origin', }); if (!r.ok) throw new Error('sync delete: HTTP ' + r.status); clearCachedKey(); return r.json(); } window.CassandraSync = { getStatus, encryptPie, decryptBlob, pushSync, pullSync, disableSync, clearCachedKey, BadPinError, // Exposed for tests / debugging: _packBlob: packBlob, _unpackBlob: unpackBlob, }; })();