보안 강화: DB 자격증명(AppKey, Secret) 및 세션토큰(Access Token) 암호화 저장 구현 (AES-GCM/CBC), .env 정리

This commit is contained in:
2026-02-03 00:08:15 +09:00
parent 4f0cc05f39
commit ed8fc0943b
15 changed files with 131 additions and 30 deletions

View File

@@ -4,11 +4,10 @@ HOST=0.0.0.0
# Security # Security
ALLOWED_HOSTS=["kis.tindevil.com", "localhost", "127.0.0.1"] 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
DATABASE_URL=sqlite+aiosqlite:///./kis_stock.db DATABASE_URL=sqlite+aiosqlite:///./kis_stock.db
# KIS API (Optional here, managed in DB mostly) # Security
# KIS_APP_KEY= SECRET_KEY=change_this_to_a_secure_random_string_min_32_chars
# KIS_APP_SECRET=

Binary file not shown.

Binary file not shown.

View File

@@ -19,6 +19,8 @@ class SettingsSchema(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
from app.core.crypto import encrypt_str, decrypt_str
@router.get("/", response_model=SettingsSchema) @router.get("/", response_model=SettingsSchema)
async def get_settings(db: AsyncSession = Depends(get_db)): async def get_settings(db: AsyncSession = Depends(get_db)):
stmt = select(ApiSettings).where(ApiSettings.id == 1) 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() settings = result.scalar_one_or_none()
if not settings: if not settings:
raise HTTPException(status_code=404, detail="Settings not initialized") 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) @router.put("/", response_model=SettingsSchema)
async def update_settings(payload: SettingsSchema, db: AsyncSession = Depends(get_db)): 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) settings = ApiSettings(id=1)
db.add(settings) db.add(settings)
# Update fields if provided # Update fields if provided - ENCRYPT SENSITIVE DATA
if payload.appKey is not None: settings.appKey = payload.appKey if payload.appKey is not None:
if payload.appSecret is not None: settings.appSecret = payload.appSecret settings.appKey = encrypt_str(payload.appKey)
if payload.accountNumber is not None: settings.accountNumber = payload.accountNumber 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 if payload.kisApiDelayMs is not None: settings.kisApiDelayMs = payload.kisApiDelayMs
await db.commit() await db.commit()
await db.refresh(settings) await db.refresh(settings)
# Trigger Token Refresh if Creds changed (Async Background task ideally) # Return masked object check
# await kis_auth.get_access_token(db) # 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

Binary file not shown.

View File

@@ -29,6 +29,9 @@ class Settings(BaseSettings):
# Timezone # Timezone
TIMEZONE: str = "Asia/Seoul" TIMEZONE: str = "Asia/Seoul"
# Encryption
SECRET_KEY: str = "dlrpwjdakfehlsmswl_skf!wkf!ahfmrpTDJ!@#unsafe_default_key_change_in_production_min_32_bytes"
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",

View File

@@ -1,7 +1,10 @@
from Crypto.Cipher import AES from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad from Crypto.Util.Padding import unpad, pad
from base64 import b64decode 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: def aes_cbc_base64_dec(key: str, iv: str, cipher_text: str) -> str:
""" """
Decrypts KIS WebSocket data using AES-256-CBC. 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) decrypted_bytes = unpad(cipher.decrypt(b64decode(cipher_text)), AES.block_size)
return bytes.decode(decrypted_bytes, 'utf-8') 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]"

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -3,6 +3,8 @@ from datetime import datetime, timedelta
from sqlalchemy import select from sqlalchemy import select
from app.db.database import SessionLocal from app.db.database import SessionLocal
from app.db.models import ApiSettings from app.db.models import ApiSettings
from app.db.models import ApiSettings
from app.core.crypto import decrypt_str, encrypt_str
class KisAuth: class KisAuth:
BASE_URL_REAL = "https://openapi.koreainvestment.com:9443" 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: if not settings_obj or not settings_obj.appKey or not settings_obj.appSecret:
raise ValueError("KIS API Credentials not configured.") raise ValueError("KIS API Credentials not configured.")
# 2. Check Expiry (Buffer 10 mins)
# 2. Check Expiry (Buffer 10 mins) # 2. Check Expiry (Buffer 10 mins)
if settings_obj.accessToken and settings_obj.tokenExpiry: if settings_obj.accessToken and settings_obj.tokenExpiry:
if settings_obj.tokenExpiry > datetime.now() + timedelta(minutes=10): token_dec = decrypt_str(settings_obj.accessToken)
return 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 # 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 # 4. Save to DB (Encrypt Token)
settings_obj.accessToken = token_data['access_token'] settings_obj.accessToken = encrypt_str(token_data['access_token'])
# expires_in is seconds (usually 86400) # expires_in is seconds (usually 86400)
settings_obj.tokenExpiry = datetime.now() + timedelta(seconds=int(token_data['expires_in'])) settings_obj.tokenExpiry = datetime.now() + timedelta(seconds=int(token_data['expires_in']))
await db_session.commit() await db_session.commit()
return settings_obj.accessToken return token_data['access_token']
except Exception as e: except Exception as e:
await db_session.rollback() await db_session.rollback()
@@ -83,12 +90,16 @@ class KisAuth:
raise ValueError("KIS API Credentials not configured.") raise ValueError("KIS API Credentials not configured.")
if settings_obj.websocketApprovalKey: 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 # 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() await db_session.commit()
return approval_key return approval_key

View File

@@ -5,6 +5,7 @@ from app.core.rate_limiter import global_rate_limiter
from app.db.database import SessionLocal from app.db.database import SessionLocal
from app.db.models import ApiSettings from app.db.models import ApiSettings
from sqlalchemy import select from sqlalchemy import select
from app.core.crypto import decrypt_str
class KisClient: class KisClient:
""" """
@@ -51,8 +52,8 @@ class KisClient:
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"authorization": f"Bearer {token}", "authorization": f"Bearer {token}",
"appkey": settings.appKey, "appkey": decrypt_str(settings.appKey),
"appsecret": settings.appSecret, "appsecret": decrypt_str(settings.appSecret),
"tr_id": tr_id, "tr_id": tr_id,
"tr_cont": "", "tr_cont": "",
"custtype": "P" "custtype": "P"
@@ -106,7 +107,7 @@ class KisClient:
# ----------------------------- # -----------------------------
async def get_balance(self, market: str) -> Dict: async def get_balance(self, market: str) -> Dict:
settings = await self._get_settings() 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 # acc_no is 8 digits. Split? "500xxx-01" -> 500xxx, 01
if '-' in acc_no: if '-' in acc_no:
cano, prdt = acc_no.split('-') cano, prdt = acc_no.split('-')
@@ -156,11 +157,13 @@ class KisClient:
price: 0 for Market? KIS logic varies. price: 0 for Market? KIS logic varies.
""" """
settings = await self._get_settings() settings = await self._get_settings()
if '-' in settings.accountNumber: acc_no_str = decrypt_str(settings.accountNumber)
cano, prdt = settings.accountNumber.split('-')
if '-' in acc_no_str:
cano, prdt = acc_no_str.split('-')
else: else:
cano = settings.accountNumber[:8] cano = acc_no_str[:8]
prdt = settings.accountNumber[8:] prdt = acc_no_str[8:]
if market == "Domestic": if market == "Domestic":
# TR_ID: TTT 0802U (Buy), 0801U (Sell) -> using sample 0012U/0011U # TR_ID: TTT 0802U (Buy), 0801U (Sell) -> using sample 0012U/0011U

1
backend/run.bat Normal file
View File

@@ -0,0 +1 @@
uvicorn app.main:app