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:
parent
89632e9937
commit
f326b41a08
23 changed files with 1637 additions and 95 deletions
|
|
@ -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."
|
||||
|
|
|
|||
178
app/services/portfolio_sync.py
Normal file
178
app/services/portfolio_sync.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue