read.markets/app/static/js/portfolio-sync.js
Giorgio Gilestro f326b41a08 sync: encrypted cloud backup for portfolios + settings UX rework
Adds opt-in client-side-encrypted portfolio sync (paid). Browser
PBKDF2(PIN) → AES-GCM, server HKDF(pepper, user_id) outer wrap;
server stores opaque bytes only. Sliding-window rate limit on GET.

  - new portfolio_sync table (migration 0015)
  - POST/GET/DELETE /api/portfolio/sync + /status
  - app/services/portfolio_sync.py crypto + rate limit
  - app/routers/sync.py paid-gated
  - app/static/js/portfolio-sync.js WebCrypto wrapper
  - settings page: enable/disable + PIN modal
  - PORTFOLIO_SYNC_PEPPER setting (warn on startup if missing)

Settings + import rework:

  - /upload merged into /settings#import (legacy route 302s)
  - drop CSV → auto-parse → preview → Import only / Import & sync
  - nav slimmed to Dashboard / News / Log
  - Settings + Logout moved to a user dropdown
  - brand logo links to /

Collateral fixes:

  - settings 500: re-fetch User in current session before mutating
    referral_code (assign_code_if_missing was refreshing a User
    loaded in the auth dep's now-closed session)
  - csv_import: distinct error for unfunded T212 pies (all qty=0)
  - db.py: drop pool_pre_ping (aiomysql 0.3.2 incompat); pin
    isolation_level=READ COMMITTED to avoid gap-lock deadlocks
  - alembic env: disable_existing_loggers=False so in-process
    migrations don't silence uvicorn's loggers
  - docker-compose.override.yml: dev-only volume mount + --reload

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:15:54 +02:00

280 lines
9.2 KiB
JavaScript

/* 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,
};
})();