보안 강화: 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
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

Binary file not shown.

Binary file not shown.

View File

@@ -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 resp
return settings

Binary file not shown.

View File

@@ -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",

View File

@@ -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]"

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 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

View File

@@ -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

1
backend/run.bat Normal file
View File

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