diff --git a/backend/app/api/v1/orders.py b/backend/app/api/v1/orders.py index 6220e8a..aeec0ec 100755 --- a/backend/app/api/v1/orders.py +++ b/backend/app/api/v1/orders.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_current_user, get_db_session +from app.core.deps import get_current_trader_user, get_current_user, get_db_session from app.core.exceptions import NotFoundException, ValidationException from app.core.security import decrypt_api_key from app.exchange.factory import factory as exchange_factory @@ -26,12 +26,13 @@ router = APIRouter(prefix="/orders", tags=["orders"]) async def place_order( req: OrderRequest, db: AsyncSession = Depends(get_db_session), - current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_trader_user), ) -> OrderData: """Place an order on a connected exchange. - Uses the user's saved API credentials for the exchange. - The exchange name is inferred from the symbol or passed explicitly. + Uses the user's saved API credentials for the exchange. If ``req.exchange`` + is given, that exchange's credential is used; otherwise falls back to the + user's only (or first) active credential. """ # Currently only market orders are supported via this endpoint if req.order_type not in ("market", "limit"): @@ -41,27 +42,33 @@ async def place_order( if req.amount <= 0: raise ValidationException(detail="Amount must be positive") - # Infer exchange name from symbol prefix heuristics, or default to mexc - # In a more advanced setup the user would specify exchange_id in the request - exchange_name = "mexc" - - # Find the user's active credential for this exchange - result = await db.execute( + # Find the user's active credential — filter by requested exchange if given, + # otherwise fall back to whichever active credential the user has. + query = ( select(ExchangeCredential) .join(Exchange, Exchange.id == ExchangeCredential.exchange_id) .where( ExchangeCredential.user_id == current_user.id, - Exchange.name == exchange_name, ExchangeCredential.is_active == True, ) ) - cred = result.scalar_one_or_none() + if req.exchange: + query = query.where(Exchange.name == req.exchange.lower()) + else: + query = query.order_by(ExchangeCredential.created_at.desc()) + + result = await db.execute(query) + cred = result.scalars().first() if cred is None: + target = req.exchange or "any exchange" raise NotFoundException( - detail=f"No active API key found for {exchange_name}. " + detail=f"No active API key found for {target}. " f"Go to Profile → API Keys to add one." ) + exchange_result = await db.execute(select(Exchange).where(Exchange.id == cred.exchange_id)) + exchange_name = exchange_result.scalar_one().name + # Decrypt the stored API key/secret try: # api_key is stored as plaintext (masked in responses), api_secret is encrypted diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 92ba581..5687d1c 100755 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -16,7 +16,9 @@ from fastapi import HTTPException from jose import JWTError, jwt from jose.exceptions import ExpiredSignatureError from passlib.context import CryptContext +from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.backends import default_backend from app.config import settings @@ -259,11 +261,22 @@ def decode_token(token: str) -> dict: # --------------------------------------------------------------------------- -# AES-256-CBC encryption (for API key storage) +# AES encryption (for API key storage) +# +# Current scheme: AES-256-GCM (authenticated encryption — tamper-evident). +# Legacy scheme: AES-256-CBC (no integrity check), kept read-only so API +# keys encrypted before this migration can still be decrypted. +# +# The two schemes are told apart by the stored IV/nonce length: GCM nonces +# are 12 bytes (24 hex chars), legacy CBC IVs are 16 bytes (32 hex chars). +# New/updated credentials are always re-encrypted with GCM going forward. # --------------------------------------------------------------------------- _BACKEND = default_backend() +_GCM_NONCE_LENGTH = 12 # bytes +_CBC_IV_LENGTH = 16 # bytes + def generate_encryption_key() -> str: """Generate a random 32-byte (256-bit) hex-encoded encryption key. @@ -290,22 +303,17 @@ def encrypt_api_key( api_key: str, key_hex: Optional[str] = None, ) -> Tuple[str, str]: - """Encrypt an API key with AES-256-CBC. + """Encrypt an API key with AES-256-GCM (authenticated encryption). - Returns ``(ciphertext_hex, iv_hex)``. + Returns ``(ciphertext_hex, nonce_hex)``. The returned ciphertext includes + the 16-byte GCM authentication tag appended by the library, so a + corrupted/tampered value fails to decrypt instead of silently returning + garbage plaintext (as plain CBC would). """ key = _resolve_key(key_hex) - iv = uuid.uuid4().bytes # 16 random bytes - cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=_BACKEND) - encryptor = cipher.encryptor() - - # Pad plaintext to AES block size (16 bytes) using PKCS7 - plaintext_bytes = api_key.encode("utf-8") - pad_len = 16 - (len(plaintext_bytes) % 16) - padded = plaintext_bytes + bytes([pad_len] * pad_len) - - ciphertext = encryptor.update(padded) + encryptor.finalize() - return ciphertext.hex(), iv.hex() + nonce = uuid.uuid4().bytes[:_GCM_NONCE_LENGTH] # 12 random bytes + ciphertext = AESGCM(key).encrypt(nonce, api_key.encode("utf-8"), None) + return ciphertext.hex(), nonce.hex() def decrypt_api_key( @@ -313,14 +321,38 @@ def decrypt_api_key( iv_hex: str, key_hex: Optional[str] = None, ) -> str: - """Decrypt an AES-256-CBC encrypted API key. + """Decrypt an API key encrypted with either scheme above. - Returns the original plaintext string. + Dispatches on the stored IV/nonce length so credentials encrypted before + the AES-GCM migration keep working without a data migration. """ key = _resolve_key(key_hex) - ciphertext = bytes.fromhex(ciphertext_hex) iv = bytes.fromhex(iv_hex) + if len(iv) == _GCM_NONCE_LENGTH: + ciphertext = bytes.fromhex(ciphertext_hex) + try: + plaintext = AESGCM(key).decrypt(iv, ciphertext, None) + except InvalidTag: + raise HTTPException( + status_code=500, + detail="Decryption failed: authentication tag mismatch (wrong key or corrupted data)", + ) + return plaintext.decode("utf-8") + + if len(iv) == _CBC_IV_LENGTH: + return _decrypt_api_key_cbc_legacy(ciphertext_hex, iv, key) + + raise HTTPException( + status_code=500, + detail=f"Decryption failed: unrecognized IV length ({len(iv)} bytes)", + ) + + +def _decrypt_api_key_cbc_legacy(ciphertext_hex: str, iv: bytes, key: bytes) -> str: + """Decrypt a pre-migration AES-256-CBC ciphertext. Read-only legacy path.""" + ciphertext = bytes.fromhex(ciphertext_hex) + cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=_BACKEND) decryptor = cipher.decryptor() padded = decryptor.update(ciphertext) + decryptor.finalize() diff --git a/backend/app/exchange/types.py b/backend/app/exchange/types.py index 8f033eb..3a4b7a7 100755 --- a/backend/app/exchange/types.py +++ b/backend/app/exchange/types.py @@ -58,6 +58,7 @@ class OrderRequest(BaseModel): price: Optional[Decimal] = None # required for limit orders reduce_only: bool = False position_side: Optional[str] = None # "long" or "short" (for futures) + exchange: Optional[str] = None # target exchange name; defaults to user's active credential class OrderData(BaseModel): diff --git a/backend/app/main_api.py b/backend/app/main_api.py index d80c497..355304b 100644 --- a/backend/app/main_api.py +++ b/backend/app/main_api.py @@ -82,11 +82,18 @@ app = FastAPI( lifespan=lifespan, ) -# CORS +# CORS — fail closed: an unset/empty CORS_ORIGINS must never fall back to "*" +# (a wildcard origin combined with allow_credentials=True is a real browser +# exploitation vector, not just a lint warning). Deny all cross-origin +# requests by default and log loudly so the misconfiguration is obvious. if settings.CORS_ORIGINS: origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()] else: - origins = ["*"] + origins = [] + logger.warning( + "CORS_ORIGINS is not set — denying all cross-origin requests by default. " + "Set CORS_ORIGINS in the environment to allow your frontend's origin(s)." + ) app.add_middleware(CORSMiddleware, allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) diff --git a/docker-compose.yml b/docker-compose.yml index 01de87c..152e77a 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,7 +37,7 @@ services: db: condition: service_healthy environment: - DATABASE_URL: postgresql+asyncpg://trading:trading_secret@db:5432/trading_portal + DATABASE_URL: postgresql+asyncpg://trading:${DB_PASSWORD:-trading_secret}@db:5432/trading_portal PORT: 8001 ENCRYPTION_KEY: ${ENCRYPTION_KEY} CORS_ORIGINS: http://localhost,http://localhost:5173,http://localhost:3000 @@ -76,7 +76,7 @@ services: db: condition: service_healthy environment: - DATABASE_URL: postgresql+asyncpg://trading:trading_secret@db:5432/trading_portal + DATABASE_URL: postgresql+asyncpg://trading:${DB_PASSWORD:-trading_secret}@db:5432/trading_portal ENCRYPTION_KEY: ${ENCRYPTION_KEY} LOG_LEVEL: INFO JWT_PRIVATE_KEY_PATH: /run/secrets/jwt_private.pem diff --git a/frontend/src/features/dashboard/DashboardPage.tsx b/frontend/src/features/dashboard/DashboardPage.tsx index 5171c58..5c01ab5 100755 --- a/frontend/src/features/dashboard/DashboardPage.tsx +++ b/frontend/src/features/dashboard/DashboardPage.tsx @@ -348,6 +348,7 @@ export default function DashboardPage() { ) : ( state.auth); const defaultTradeSize = user?.preferences?.trade_size || 10; const [currentTime, setCurrentTime] = useState(new Date()); @@ -97,6 +98,7 @@ export default function OrderPanel({ symbol, lastPrice, balance, isConnected }: method: 'POST', body: JSON.stringify({ symbol, + exchange, side, order_type: 'market', amount: amt,