diff --git a/backend/.env.example b/backend/.env.example index c1e111a..f3b5749 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -4,11 +4,10 @@ HOST=0.0.0.0 # Security ALLOWED_HOSTS=["kis.tindevil.com", "localhost", "127.0.0.1"] -SECRET_KEY=change_this_to_a_secure_random_string +SECRET_KEY=dlrjtdmsQlalfzlfksmsep@wkf!wkf!ahfmrpTdj$# # Database DATABASE_URL=sqlite+aiosqlite:///./kis_stock.db -# KIS API (Optional here, managed in DB mostly) -# KIS_APP_KEY= -# KIS_APP_SECRET= +# Security +SECRET_KEY=change_this_to_a_secure_random_string_min_32_chars diff --git a/backend/app/__pycache__/__init__.cpython-312.pyc b/backend/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..13466f3 Binary files /dev/null and b/backend/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..48ae108 Binary files /dev/null and b/backend/app/__pycache__/main.cpython-312.pyc differ diff --git a/backend/app/api/endpoints/settings.py b/backend/app/api/endpoints/settings.py index 8d52907..20fdaf9 100644 --- a/backend/app/api/endpoints/settings.py +++ b/backend/app/api/endpoints/settings.py @@ -19,6 +19,8 @@ class SettingsSchema(BaseModel): class Config: from_attributes = True +from app.core.crypto import encrypt_str, decrypt_str + @router.get("/", response_model=SettingsSchema) async def get_settings(db: AsyncSession = Depends(get_db)): stmt = select(ApiSettings).where(ApiSettings.id == 1) @@ -26,7 +28,25 @@ async def get_settings(db: AsyncSession = Depends(get_db)): settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not initialized") - return settings + + # Clone logic to mask secrets for display + # We can't easily clone SQLA model to Pydantic and modify without validation error if strict. + # So we construct dict. + + resp_data = SettingsSchema.model_validate(settings) + + if settings.appKey: resp_data.appKey = "********" # Masked + if settings.appSecret: resp_data.appSecret = "********" # Masked + if settings.accountNumber: + # Decrypt first if we want to show last 4 digits? + # Or just show encrypted? Users prefer masking. + # Let's decrypt and mask everything except last 2. + pass # Keep simple for now: Mask all or partial? + # User just sees ***** + # Actually proper UX is to leave field blank or show placeholder. + # Let's mask entirely for keys. + + return resp_data @router.put("/", response_model=SettingsSchema) async def update_settings(payload: SettingsSchema, db: AsyncSession = Depends(get_db)): @@ -38,16 +58,26 @@ async def update_settings(payload: SettingsSchema, db: AsyncSession = Depends(ge settings = ApiSettings(id=1) db.add(settings) - # Update fields if provided - if payload.appKey is not None: settings.appKey = payload.appKey - if payload.appSecret is not None: settings.appSecret = payload.appSecret - if payload.accountNumber is not None: settings.accountNumber = payload.accountNumber + # Update fields if provided - ENCRYPT SENSITIVE DATA + if payload.appKey is not None: + settings.appKey = encrypt_str(payload.appKey) + if payload.appSecret is not None: + settings.appSecret = encrypt_str(payload.appSecret) + if payload.accountNumber is not None: + settings.accountNumber = encrypt_str(payload.accountNumber) + if payload.kisApiDelayMs is not None: settings.kisApiDelayMs = payload.kisApiDelayMs await db.commit() await db.refresh(settings) - # Trigger Token Refresh if Creds changed (Async Background task ideally) - # await kis_auth.get_access_token(db) + # Return masked object check + # We return what was saved, but masked? + # Usually convention is to return updated state. + resp = SettingsSchema.model_validate(settings) + resp.appKey = "********" + resp.appSecret = "********" + resp.accountNumber = "********" - return settings + return resp + diff --git a/backend/app/core/__pycache__/config.cpython-312.pyc b/backend/app/core/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..3d31bdb Binary files /dev/null and b/backend/app/core/__pycache__/config.cpython-312.pyc differ diff --git a/backend/app/core/__pycache__/market_schedule.cpython-312.pyc b/backend/app/core/__pycache__/market_schedule.cpython-312.pyc new file mode 100644 index 0000000..6ab6bc1 Binary files /dev/null and b/backend/app/core/__pycache__/market_schedule.cpython-312.pyc differ diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2f6e3b0..033c8b3 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -29,6 +29,9 @@ class Settings(BaseSettings): # Timezone TIMEZONE: str = "Asia/Seoul" + # Encryption + SECRET_KEY: str = "dlrpwjdakfehlsmswl_skf!wkf!ahfmrpTDJ!@#unsafe_default_key_change_in_production_min_32_bytes" + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", diff --git a/backend/app/core/crypto.py b/backend/app/core/crypto.py index b2cbb82..1eaa1d0 100644 --- a/backend/app/core/crypto.py +++ b/backend/app/core/crypto.py @@ -1,7 +1,10 @@ from Crypto.Cipher import AES -from Crypto.Util.Padding import unpad -from base64 import b64decode +from Crypto.Util.Padding import unpad, pad +from base64 import b64decode, b64encode +from app.core.config import settings +import hashlib +# KIS WebSocket Decryption (CBC) def aes_cbc_base64_dec(key: str, iv: str, cipher_text: str) -> str: """ Decrypts KIS WebSocket data using AES-256-CBC. @@ -17,3 +20,54 @@ def aes_cbc_base64_dec(key: str, iv: str, cipher_text: str) -> str: decrypted_bytes = unpad(cipher.decrypt(b64decode(cipher_text)), AES.block_size) return bytes.decode(decrypted_bytes, 'utf-8') + +# DB Field Encryption (AES-GCM) +def get_master_key(): + # Derive a 32-byte key from the SECRET_KEY string, ensuring length + return hashlib.sha256(settings.SECRET_KEY.encode('utf-8')).digest() + +def encrypt_str(plain_text: str) -> str: + """ + Encrypts string for DB storage using AES-GCM. + Returns: base64(nonce + ciphertext + tag) + """ + if not plain_text: + return "" + + key = get_master_key() + cipher = AES.new(key, AES.MODE_GCM) + nonce = cipher.nonce # 16 bytes + + ciphertext, tag = cipher.encrypt_and_digest(plain_text.encode('utf-8')) + + # Combined: nonce(16) + tag(16) + ciphertext(n) + combined = nonce + tag + ciphertext + return b64encode(combined).decode('utf-8') + +def decrypt_str(encrypted_text: str) -> str: + """ + Decrypts string from DB. + Input: base64(nonce + tag + ciphertext) + """ + if not encrypted_text: + return "" + + try: + raw = b64decode(encrypted_text) + if len(raw) < 32: # Nonce(16) + Tag(16) + return "" # Invalid data + + nonce = raw[:16] + tag = raw[16:32] + ciphertext = raw[32:] + + key = get_master_key() + cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) + + decrypted_data = cipher.decrypt_and_verify(ciphertext, tag) + return decrypted_data.decode('utf-8') + except Exception: + # Failed to decrypt (possibly not encrypted or wrong key). + # For safety, return empty or raise. + # In transition phase, might check if plain text? No, assume encrypted. + return "[Decryption Failed]" diff --git a/backend/app/db/__pycache__/database.cpython-312.pyc b/backend/app/db/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000..142bca1 Binary files /dev/null and b/backend/app/db/__pycache__/database.cpython-312.pyc differ diff --git a/backend/app/db/__pycache__/init_db.cpython-312.pyc b/backend/app/db/__pycache__/init_db.cpython-312.pyc new file mode 100644 index 0000000..8e4d044 Binary files /dev/null and b/backend/app/db/__pycache__/init_db.cpython-312.pyc differ diff --git a/backend/app/db/__pycache__/models.cpython-312.pyc b/backend/app/db/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..1f39e84 Binary files /dev/null and b/backend/app/db/__pycache__/models.cpython-312.pyc differ diff --git a/backend/app/services/kis_auth.py b/backend/app/services/kis_auth.py index 62396f5..fa17e67 100644 --- a/backend/app/services/kis_auth.py +++ b/backend/app/services/kis_auth.py @@ -3,6 +3,8 @@ from datetime import datetime, timedelta from sqlalchemy import select from app.db.database import SessionLocal from app.db.models import ApiSettings +from app.db.models import ApiSettings +from app.core.crypto import decrypt_str, encrypt_str class KisAuth: BASE_URL_REAL = "https://openapi.koreainvestment.com:9443" @@ -29,21 +31,26 @@ class KisAuth: if not settings_obj or not settings_obj.appKey or not settings_obj.appSecret: raise ValueError("KIS API Credentials not configured.") + # 2. Check Expiry (Buffer 10 mins) # 2. Check Expiry (Buffer 10 mins) if settings_obj.accessToken and settings_obj.tokenExpiry: - if settings_obj.tokenExpiry > datetime.now() + timedelta(minutes=10): - return settings_obj.accessToken + token_dec = decrypt_str(settings_obj.accessToken) + if token_dec and token_dec != "[Decryption Failed]": + if settings_obj.tokenExpiry > datetime.now() + timedelta(minutes=10): + return token_dec # 3. Issue New Token - token_data = await self._issue_token(settings_obj.appKey, settings_obj.appSecret) + app_key_dec = decrypt_str(settings_obj.appKey) + app_secret_dec = decrypt_str(settings_obj.appSecret) + token_data = await self._issue_token(app_key_dec, app_secret_dec) - # 4. Save to DB - settings_obj.accessToken = token_data['access_token'] + # 4. Save to DB (Encrypt Token) + settings_obj.accessToken = encrypt_str(token_data['access_token']) # expires_in is seconds (usually 86400) settings_obj.tokenExpiry = datetime.now() + timedelta(seconds=int(token_data['expires_in'])) await db_session.commit() - return settings_obj.accessToken + return token_data['access_token'] except Exception as e: await db_session.rollback() @@ -83,12 +90,16 @@ class KisAuth: raise ValueError("KIS API Credentials not configured.") if settings_obj.websocketApprovalKey: - return settings_obj.websocketApprovalKey + approval_key_dec = decrypt_str(settings_obj.websocketApprovalKey) + if approval_key_dec and approval_key_dec != "[Decryption Failed]": + return approval_key_dec # Issue New Key - approval_key = await self._issue_approval_key(settings_obj.appKey, settings_obj.appSecret) + app_key_dec = decrypt_str(settings_obj.appKey) + app_secret_dec = decrypt_str(settings_obj.appSecret) + approval_key = await self._issue_approval_key(app_key_dec, app_secret_dec) - settings_obj.websocketApprovalKey = approval_key + settings_obj.websocketApprovalKey = encrypt_str(approval_key) await db_session.commit() return approval_key diff --git a/backend/app/services/kis_client.py b/backend/app/services/kis_client.py index a0253a7..78e4f3b 100644 --- a/backend/app/services/kis_client.py +++ b/backend/app/services/kis_client.py @@ -5,6 +5,7 @@ from app.core.rate_limiter import global_rate_limiter from app.db.database import SessionLocal from app.db.models import ApiSettings from sqlalchemy import select +from app.core.crypto import decrypt_str class KisClient: """ @@ -51,8 +52,8 @@ class KisClient: headers = { "Content-Type": "application/json", "authorization": f"Bearer {token}", - "appkey": settings.appKey, - "appsecret": settings.appSecret, + "appkey": decrypt_str(settings.appKey), + "appsecret": decrypt_str(settings.appSecret), "tr_id": tr_id, "tr_cont": "", "custtype": "P" @@ -106,7 +107,7 @@ class KisClient: # ----------------------------- async def get_balance(self, market: str) -> Dict: settings = await self._get_settings() - acc_no = settings.accountNumber + acc_no = decrypt_str(settings.accountNumber) # acc_no is 8 digits. Split? "500xxx-01" -> 500xxx, 01 if '-' in acc_no: cano, prdt = acc_no.split('-') @@ -156,11 +157,13 @@ class KisClient: price: 0 for Market? KIS logic varies. """ settings = await self._get_settings() - if '-' in settings.accountNumber: - cano, prdt = settings.accountNumber.split('-') + acc_no_str = decrypt_str(settings.accountNumber) + + if '-' in acc_no_str: + cano, prdt = acc_no_str.split('-') else: - cano = settings.accountNumber[:8] - prdt = settings.accountNumber[8:] + cano = acc_no_str[:8] + prdt = acc_no_str[8:] if market == "Domestic": # TR_ID: TTT 0802U (Buy), 0801U (Sell) -> using sample 0012U/0011U diff --git a/backend/app/workers/__pycache__/scheduler.cpython-312.pyc b/backend/app/workers/__pycache__/scheduler.cpython-312.pyc new file mode 100644 index 0000000..8f0e95b Binary files /dev/null and b/backend/app/workers/__pycache__/scheduler.cpython-312.pyc differ diff --git a/backend/run.bat b/backend/run.bat new file mode 100644 index 0000000..19fd864 --- /dev/null +++ b/backend/run.bat @@ -0,0 +1 @@ +uvicorn app.main:app \ No newline at end of file