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>
76 lines
2.4 KiB
Python
76 lines
2.4 KiB
Python
"""Crypto-layer tests for app.services.portfolio_sync.
|
|
|
|
These exercise only the pure wrap/unwrap helpers — no DB, no network.
|
|
The DB-touching helpers (`upsert`, `fetch`, `consume_fetch_budget`) need
|
|
sqlite+aiosqlite and live in the API integration tests so we don't
|
|
duplicate the test-app scaffolding.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import pytest
|
|
|
|
from app.services import portfolio_sync as svc
|
|
|
|
|
|
def test_wrap_unwrap_round_trip():
|
|
inner = b"the inner ciphertext doesn't matter to the outer wrap"
|
|
ct, nonce = svc.wrap(user_id=42, inner_blob=inner)
|
|
assert ct != inner # we actually encrypted
|
|
out = svc.unwrap(42, ct, nonce)
|
|
assert out == inner
|
|
|
|
|
|
def test_wrap_is_nondeterministic():
|
|
"""Same plaintext, same user, two writes — outer ciphertexts must
|
|
differ because the nonce is random. A deterministic outer wrap would
|
|
leak 'this user re-uploaded the same pie' across snapshots."""
|
|
inner = b"identical inner blob across writes"
|
|
ct1, nonce1 = svc.wrap(7, inner)
|
|
ct2, nonce2 = svc.wrap(7, inner)
|
|
assert ct1 != ct2
|
|
assert nonce1 != nonce2
|
|
|
|
|
|
def test_unwrap_rejects_cross_user_blob():
|
|
"""A blob wrapped for user A must not decrypt under user B's key —
|
|
HKDF binds the user_id into the salt."""
|
|
inner = b"alice's pie"
|
|
ct, nonce = svc.wrap(1, inner)
|
|
with pytest.raises(svc.SyncCryptoError):
|
|
svc.unwrap(2, ct, nonce)
|
|
|
|
|
|
def test_unwrap_rejects_tampered_ciphertext():
|
|
inner = b"don't flip my bits"
|
|
ct, nonce = svc.wrap(99, inner)
|
|
bad = bytearray(ct)
|
|
bad[0] ^= 0x01
|
|
with pytest.raises(svc.SyncCryptoError):
|
|
svc.unwrap(99, bytes(bad), nonce)
|
|
|
|
|
|
def test_unwrap_rejects_wrong_nonce():
|
|
inner = b"nonce is part of the auth envelope"
|
|
ct, _nonce = svc.wrap(5, inner)
|
|
with pytest.raises(svc.SyncCryptoError):
|
|
svc.unwrap(5, ct, os.urandom(12))
|
|
|
|
|
|
def test_server_key_changes_with_pepper(monkeypatch):
|
|
"""Rotating the pepper must produce a different key for the same
|
|
user — that's how we'd invalidate all sync rows in a credential
|
|
breach."""
|
|
from app.config import get_settings
|
|
|
|
get_settings.cache_clear()
|
|
monkeypatch.setenv("PORTFOLIO_SYNC_PEPPER", "first-pepper-value")
|
|
k1 = svc._server_key(11)
|
|
|
|
get_settings.cache_clear()
|
|
monkeypatch.setenv("PORTFOLIO_SYNC_PEPPER", "second-pepper-value")
|
|
k2 = svc._server_key(11)
|
|
|
|
assert k1 != k2
|
|
get_settings.cache_clear() # don't poison other tests
|