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>
This commit is contained in:
Giorgio Gilestro 2026-05-23 16:15:54 +02:00
parent 89632e9937
commit f326b41a08
23 changed files with 1637 additions and 95 deletions

View file

@ -130,6 +130,7 @@ def parse_t212_csv(content: str | bytes) -> ParsedPie:
positions: list[ParsedPosition] = []
total: ParsedPosition | None = None
pie_name: str | None = None
zero_qty_slices = 0 # real slice rows skipped for missing/zero quantity
for row_num, row in enumerate(reader, start=2):
if not row or not any(cell.strip() for cell in row):
@ -167,6 +168,8 @@ def parse_t212_csv(content: str | bytes) -> ParsedPie:
qty = record.get("quantity")
if qty is None or qty == 0:
# Position row with no usable quantity — skip rather than fail.
# Counted so an all-zero (unfunded) pie yields a precise error.
zero_qty_slices += 1
continue
positions.append(ParsedPosition(
@ -182,6 +185,16 @@ def parse_t212_csv(content: str | bytes) -> ParsedPie:
))
if not positions:
# Distinguish an unfunded pie (slices present, all 0 quantity)
# from a genuinely unreadable file — the two need very different
# user action, and the generic message misleads people into
# debugging the file format.
if zero_qty_slices:
raise CSVImportError(
f"This pie holds no shares — all {zero_qty_slices} "
f"slice(s) have an Owned quantity of 0. Export the pie from "
f"Trading 212 after it has been funded."
)
raise CSVImportError(
"CSV contained no parseable position rows. "
"Expected at least one row with a Slice code and quantity."

View file

@ -0,0 +1,178 @@
"""Encrypted-pie cloud-sync service.
The client encrypts the pie locally with a PIN-derived AES-GCM key we
never see that key. We add a second AES-GCM layer with a per-user key
derived from the env-only PORTFOLIO_SYNC_PEPPER, so a DB-only leak yields
nothing usable. Stored bytes are opaque from the inside (the inner
ciphertext) and from the outside (the outer wrap).
Threat-model summary:
- DB leaks, env intact: safe (outer key still secret).
- env leaks, DB intact: safe (no rows to decrypt).
- Both leak: attacker still brute-forces PBKDF2(600k)
over the PIN; the fetch endpoint is
rate-limited to bound online attempts.
- PIN forgotten: unrecoverable. Re-upload the CSV.
The service is pure: no FastAPI deps, no logging. The router wires it up.
"""
from __future__ import annotations
import os
from datetime import datetime, timedelta, timezone
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models import PortfolioSync
# AES-GCM standard nonce is 12 bytes; AES-256 needs a 32-byte key.
_NONCE_LEN = 12
_KEY_LEN = 32
# Sliding-window rate limit on GET (the brute-force vector). 6 / 60s ≈
# 100k attempts/year — slow enough that a 6-digit PIN would take a decade
# even if the pepper leaked.
RATE_LIMIT_WINDOW = timedelta(seconds=60)
RATE_LIMIT_MAX = 6
class SyncCryptoError(Exception):
"""Outer-wrap decryption failed — usually a pepper change or
bit-rotted row. The router maps this to a 500."""
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _pepper_bytes() -> bytes:
"""Returns the configured pepper as bytes. Empty in dev / tests; that
weakens the outer wrap (key is now derived from user_id alone) but
keeps everything functional."""
return get_settings().PORTFOLIO_SYNC_PEPPER.encode("utf-8")
def _server_key(user_id: int) -> bytes:
"""Per-user 32-byte AES key derived from PORTFOLIO_SYNC_PEPPER. HKDF
binds the user_id into the `salt` so two users with the same pepper
get independent keys, and includes a versioned info string so we can
rotate the derivation later without breaking old rows."""
return HKDF(
algorithm=hashes.SHA256(),
length=_KEY_LEN,
salt=str(user_id).encode("utf-8"),
info=b"portfolio-sync-v1",
).derive(_pepper_bytes())
def wrap(user_id: int, inner_blob: bytes) -> tuple[bytes, bytes]:
"""Encrypt the client-side ciphertext (`inner_blob`) for storage.
Returns (outer_ct, outer_nonce). The nonce is random per write."""
nonce = os.urandom(_NONCE_LEN)
ct = AESGCM(_server_key(user_id)).encrypt(nonce, inner_blob, None)
return ct, nonce
def unwrap(user_id: int, outer_ct: bytes, outer_nonce: bytes) -> bytes:
"""Inverse of wrap(). Raises SyncCryptoError if the GCM tag fails."""
try:
return AESGCM(_server_key(user_id)).decrypt(outer_ct, outer_nonce, None)
except Exception as exc: # InvalidTag, malformed ciphertext, etc.
raise SyncCryptoError("outer wrap unwrap failed") from exc
async def upsert(session: AsyncSession, user_id: int, inner_blob: bytes) -> datetime:
"""Insert or replace this user's sync row. Returns the new updated_at."""
outer_ct, outer_nonce = wrap(user_id, inner_blob)
now = _utcnow()
row = await session.get(PortfolioSync, user_id)
if row is None:
row = PortfolioSync(
user_id=user_id,
outer_ciphertext=outer_ct,
outer_nonce=outer_nonce,
version=1,
created_at=now,
updated_at=now,
)
session.add(row)
else:
row.outer_ciphertext = outer_ct
row.outer_nonce = outer_nonce
row.updated_at = now
# Bump version field forward if we ever change the wrap scheme.
row.version = 1
await session.commit()
return now
async def fetch_status(
session: AsyncSession, user_id: int,
) -> tuple[bool, datetime | None]:
"""Cheap existence check — does NOT decrypt. Used by the dashboard to
decide whether to show the restore prompt."""
row = await session.get(PortfolioSync, user_id)
if row is None:
return False, None
return True, row.updated_at
async def fetch(
session: AsyncSession, user_id: int,
) -> tuple[bytes, datetime] | None:
"""Returns (inner_blob, updated_at) or None if sync disabled.
Raises SyncCryptoError if the row exists but the outer wrap is
unreadable (typically: pepper was rotated without re-encrypting).
"""
row = await session.get(PortfolioSync, user_id)
if row is None:
return None
inner = unwrap(user_id, row.outer_ciphertext, row.outer_nonce)
return inner, row.updated_at
async def delete(session: AsyncSession, user_id: int) -> bool:
"""Returns True if a row was deleted, False if none existed."""
row = await session.get(PortfolioSync, user_id)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
async def consume_fetch_budget(session: AsyncSession, user_id: int) -> bool:
"""Sliding-window rate-limiter for GET. Returns True if the call is
within budget (caller proceeds), False if over (caller returns 429).
Window state lives on the row itself no need for Redis. On no-row,
the GET handler will 404 anyway; we return True so it can fall
through to that handler.
"""
row = await session.get(PortfolioSync, user_id)
if row is None:
return True
now = _utcnow()
start = row.fetch_window_start
# Normalise: aiomysql sometimes returns naive datetimes.
if start is not None and start.tzinfo is None:
start = start.replace(tzinfo=timezone.utc)
if start is None or now - start >= RATE_LIMIT_WINDOW:
row.fetch_window_start = now
row.fetch_count = 1
await session.commit()
return True
if row.fetch_count >= RATE_LIMIT_MAX:
# Don't bump — over budget. Window expires on its own; no commit.
return False
row.fetch_count += 1
await session.commit()
return True

View file

@ -55,20 +55,31 @@ def normalise_code(raw: str | None) -> str | None:
async def assign_code_if_missing(session: AsyncSession, user: User) -> User:
"""Generate + persist a referral code on `user` if they don't have
one yet. Retries on the (very rare) collision."""
one yet. Retries on the (very rare) collision.
The `user` argument is the User attached to the auth-dependency
session, which has since been closed so it is detached from our
`session`. We re-fetch it here before mutating so SQLAlchemy doesn't
refuse with 'not persistent within this Session'.
"""
if user.referral_code:
return user
db_user = await session.get(User, user.id)
if db_user is None:
raise RuntimeError(f"referral_service: user {user.id} vanished mid-request")
if db_user.referral_code:
# Raced with another request — accept their code.
return db_user
for _ in range(8):
code = generate_code()
existing = (await session.execute(
select(User.id).where(User.referral_code == code)
)).scalar_one_or_none()
if existing is None:
user.referral_code = code
db_user.referral_code = code
await session.commit()
await session.refresh(user)
log.info("referral.code_assigned", user_id=user.id, code=code)
return user
log.info("referral.code_assigned", user_id=db_user.id, code=code)
return db_user
# 8 collisions in a row would be a statistical event we'd want to
# know about.
raise RuntimeError("referral_service: exhausted code-collision retries")