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

@ -17,6 +17,8 @@ from sqlalchemy import (
ForeignKey,
Index,
Integer,
LargeBinary,
SmallInteger,
String,
Text,
UniqueConstraint,
@ -179,6 +181,31 @@ class User(Base):
)
class PortfolioSync(Base):
"""Opt-in encrypted backup of a user's pie. Stored as opaque bytes:
the client encrypts the pie with a PIN-derived key (AES-GCM), and the
server wraps that ciphertext again with a per-user key derived from
PORTFOLIO_SYNC_PEPPER + user_id (also AES-GCM). A DB-only leak yields
nothing usable without the env-only pepper; a pepper-only leak still
leaves the attacker brute-forcing the PIN through PBKDF2(600k).
One row per user. Absent row = sync disabled for that user. The
fetch_window_* fields drive a sliding-window rate limit on GET so the
pepper-leak threat model can't degenerate into an unthrottled brute
force against the inner PBKDF2."""
__tablename__ = "portfolio_sync"
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True,
)
outer_ciphertext: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
outer_nonce: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
version: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
fetch_window_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
fetch_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
class Referral(Base):
"""One row per captured (referrer, referred) pair. Created at signup
when the new user supplied a valid `?ref=<code>`. The conversion