From 738afda78abd9a2a47c70a94abd3de073791160b Mon Sep 17 00:00:00 2001 From: "DESKTOP-TKLFCPR\\ython" Date: Sun, 7 Jun 2026 21:13:38 +0900 Subject: [PATCH] manual-deploy 2026-06-07 21:13 --- .env.open | 6 +++++ core/crypto.py | 54 +++++++++++++++++++++++++++++++++++++++ routers/jira_sync.py | 7 +++-- routers/multicloud.py | 3 ++- routers/ncloud.py | 7 ++--- routers/public_api_hub.py | 3 ++- routers/snmp_discovery.py | 5 ++-- routers/sso_provider.py | 5 ++-- routers/upstage_ocr.py | 5 ++-- 9 files changed, 80 insertions(+), 15 deletions(-) create mode 100644 core/crypto.py diff --git a/.env.open b/.env.open index f778f23..8292541 100644 --- a/.env.open +++ b/.env.open @@ -4,6 +4,12 @@ # ── 네트워크 모드 ───────────────────────────────────────────────────────────── GUARDIA_NETWORK_MODE=open +# 나라장터(G2B) 공공데이터포털 서비스키 — 입찰 적합성 분석(g2b_opportunity)· +# 입찰 모니터(bid_watcher) 실시간 수집 기능에서 공유 사용. 비어 있으면 자동으로 +# 폐쇄망 샘플 데이터 모드로 전환되어 동작합니다(서비스 중단 없음). +# 발급: https://www.data.go.kr (조달청_나라장터 입찰공고정보서비스) +G2B_API_KEY= + # 허용 외부 출처 (쉼표 구분, HTTPS 도메인 또는 IP) # 예) https://itsm.zioinfo.co.kr,https://portal.myorg.go.kr GUARDIA_ALLOWED_ORIGINS=http://zioinfo.co.kr,https://zioinfo.co.kr diff --git a/core/crypto.py b/core/crypto.py new file mode 100644 index 0000000..cbc806d --- /dev/null +++ b/core/crypto.py @@ -0,0 +1,54 @@ +""" +GUARDiA ITSM — 자격증명 암호화 공통 유틸리티 + +외부 연동 자격증명(API 키·시크릿·토큰·SNMP 커뮤니티 문자열 등)을 DB에 평문으로 +저장하지 않기 위한 AES-256-GCM 암호화/복호화 모듈. 여러 라우터(jira_sync·multicloud· +ncloud·public_api_hub·snmp_discovery·sso_provider·upstage_ocr 등)가 공유한다. + +키 소스: GUARDIA_ENC_KEY 환경변수 (문자열을 UTF-8 인코딩 후 32바이트로 패딩/절단). + core/seed.py · routers/citizen_portal.py 와 동일한 컨벤션 — 운영 배포 시 + 반드시 32자 이상의 고유 값으로 설정할 것 (미설정 시 데모 기본값 사용). +저장 형식: base64(nonce[12] + ciphertext + GCM tag[16]) +""" +from __future__ import annotations + +import base64 +import os + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + +def _enc_key() -> bytes: + raw = os.environ.get("GUARDIA_ENC_KEY", "guardia-demo-key-32bytes-padding!").encode("utf-8") + return raw[:32].ljust(32, b"\x00") + + +def encrypt_secret(plain: str | None) -> str | None: + """평문 자격증명을 AES-256-GCM으로 암호화하여 base64 문자열로 반환한다.""" + if not plain: + return plain + nonce = os.urandom(12) + ct = AESGCM(_enc_key()).encrypt(nonce, plain.encode("utf-8"), None) + return base64.b64encode(nonce + ct).decode("ascii") + + +def decrypt_secret(enc_value: str | None) -> str | None: + """encrypt_secret()으로 암호화된 문자열을 복호화한다. + + 평문으로 저장된 레거시 값(이번 암호화 적용 이전 데이터)이나 손상된 값을 만나면 + 예외를 전파하지 않고 입력값을 그대로 반환한다 — Fail-Safe 원칙(CLAUDE.md)에 따라 + 기존 연동이 끊기지 않도록 한다. + """ + if not enc_value: + return enc_value + try: + raw = base64.b64decode(enc_value, validate=True) + nonce, ct = raw[:12], raw[12:] + return AESGCM(_enc_key()).decrypt(nonce, ct, None).decode("utf-8") + except Exception: + return enc_value + + +# kubernetes.py 등에서 참조하는 이름과 호환되는 별칭 +encrypt_password = encrypt_secret +decrypt_password = decrypt_secret diff --git a/routers/jira_sync.py b/routers/jira_sync.py index a2627c2..e0b9b81 100644 --- a/routers/jira_sync.py +++ b/routers/jira_sync.py @@ -37,6 +37,7 @@ from sqlalchemy import select, desc from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role +from core.crypto import encrypt_secret, decrypt_secret from database import get_db from models import ( User, SRRequest, SRStatus, @@ -101,8 +102,7 @@ async def _jira_request( payload: Optional[dict] = None ) -> Optional[dict]: """Jira REST API 호출 (오류 시 None 반환, 예외 미전파).""" - # 저장된 암호화 토큰 복호화 (실제 구현 시 core.crypto.decrypt 사용) - token = config.api_token_enc # 복호화된 토큰 (모델에서 property로 제공) + token = decrypt_secret(config.api_token_enc) auth = (config.email, token) url = f"{config.base_url.rstrip('/')}/rest/api/3{path}" try: @@ -160,8 +160,7 @@ async def save_jira_config( ) cfg = existing.scalar_one_or_none() - # API 토큰 암호화 (실제 구현: core.crypto.encrypt) - enc_token = req.api_token # TODO: AES-256-GCM 암호화 + enc_token = encrypt_secret(req.api_token) if cfg: cfg.base_url = req.base_url diff --git a/routers/multicloud.py b/routers/multicloud.py index 1ae735e..f5d591f 100644 --- a/routers/multicloud.py +++ b/routers/multicloud.py @@ -22,6 +22,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role +from core.crypto import encrypt_secret from database import get_db from models import User, MultiCloudConfig @@ -48,7 +49,7 @@ async def add_provider(req: ProviderCreate, db: AsyncSession = Depends(get_db), tenant_id=user.tenant_id, name=req.name, provider_type=req.provider_type, region=req.region, access_key=req.access_key, - secret_key_enc=req.secret_key, # TODO: AES-256-GCM + secret_key_enc=encrypt_secret(req.secret_key), extra_config=json.dumps(req.extra_config or {}), is_active=True, created_at=datetime.utcnow(), ) diff --git a/routers/ncloud.py b/routers/ncloud.py index b6f1944..4ad4f7d 100644 --- a/routers/ncloud.py +++ b/routers/ncloud.py @@ -29,6 +29,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role +from core.crypto import encrypt_secret, decrypt_secret from database import get_db from models import User, NCloudConfig # 신규 @@ -56,7 +57,7 @@ async def _ncloud_request(config: NCloudConfig, method: str, path: str, params: """NCloud API 호출.""" timestamp = str(int(datetime.utcnow().timestamp() * 1000)) url = f"{path}?{urlencode(params or {})}" if params else path - sig = _ncloud_signature(method, url, timestamp, config.access_key, config.secret_key_enc) + sig = _ncloud_signature(method, url, timestamp, config.access_key, decrypt_secret(config.secret_key_enc)) headers = { "x-ncp-apigw-timestamp": timestamp, "x-ncp-iam-access-key": config.access_key, @@ -83,13 +84,13 @@ async def save_ncloud_config( cfg = row.scalar_one_or_none() if cfg: cfg.access_key = req.access_key - cfg.secret_key_enc = req.secret_key # TODO: AES-256-GCM 암호화 + cfg.secret_key_enc = encrypt_secret(req.secret_key) cfg.region = req.region else: cfg = NCloudConfig( tenant_id=user.tenant_id, access_key=req.access_key, - secret_key_enc=req.secret_key, + secret_key_enc=encrypt_secret(req.secret_key), region=req.region, is_active=True, created_at=datetime.utcnow(), diff --git a/routers/public_api_hub.py b/routers/public_api_hub.py index 205b99f..6a95d02 100644 --- a/routers/public_api_hub.py +++ b/routers/public_api_hub.py @@ -25,6 +25,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role +from core.crypto import encrypt_secret from database import get_db from models import User, PublicApiConfig @@ -68,7 +69,7 @@ async def register_api(req: ApiRegisterRequest, db: AsyncSession = Depends(get_d cfg = PublicApiConfig( tenant_id=user.tenant_id, api_id=req.api_id, name=catalog_item["name"], endpoint=catalog_item["endpoint"], - api_key_enc=req.api_key, # TODO: AES-256-GCM + api_key_enc=encrypt_secret(req.api_key), is_active=True, created_at=datetime.utcnow(), ) db.add(cfg); await db.commit() diff --git a/routers/snmp_discovery.py b/routers/snmp_discovery.py index f419651..10fc635 100644 --- a/routers/snmp_discovery.py +++ b/routers/snmp_discovery.py @@ -24,6 +24,7 @@ from sqlalchemy import select, desc from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role +from core.crypto import encrypt_secret from database import get_db from models import User, SNMPConfig, SNMPDevice @@ -139,7 +140,7 @@ async def create_snmp_config( cfg = SNMPConfig( tenant_id=user.tenant_id, name=req.name, - community_enc=req.community, # TODO: AES-256-GCM 암호화 + community_enc=encrypt_secret(req.community), version=req.version, ip_ranges=req.ip_ranges, is_active=True, @@ -177,7 +178,7 @@ async def start_snmp_scan( # 기본 SNMP 설정 생성 cfg = SNMPConfig( tenant_id=user.tenant_id, name=f"scan_{datetime.utcnow().strftime('%Y%m%d%H%M')}", - community_enc=req.community, version=req.version, + community_enc=encrypt_secret(req.community), version=req.version, is_active=True, created_at=datetime.utcnow(), ) db.add(cfg) diff --git a/routers/sso_provider.py b/routers/sso_provider.py index 3e6cbf8..b9afad3 100644 --- a/routers/sso_provider.py +++ b/routers/sso_provider.py @@ -35,6 +35,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import create_access_token, get_current_user, require_admin_role +from core.crypto import encrypt_secret, decrypt_secret from database import get_db from models import User, UserRole, SSOConfig, SSOSession # 신규 모델 @@ -141,7 +142,7 @@ async def _exchange_code(config: SSOConfig, code: str, redirect_uri: str) -> Opt "code": code, "redirect_uri": redirect_uri, "client_id": config.client_id, - "client_secret": config.client_secret_enc, # 복호화 필요 + "client_secret": decrypt_secret(config.client_secret_enc), }, ) return r.json() if r.status_code == 200 else None @@ -237,7 +238,7 @@ async def create_sso_config( idp_sso_url=req.idp_sso_url, idp_cert=req.idp_cert, client_id=req.client_id, - client_secret_enc=req.client_secret, # TODO: AES-256-GCM 암호화 + client_secret_enc=encrypt_secret(req.client_secret), discovery_url=req.discovery_url, scopes=req.scopes, attribute_mapping=json.dumps(req.attribute_mapping), diff --git a/routers/upstage_ocr.py b/routers/upstage_ocr.py index d4660e6..fed5e0a 100644 --- a/routers/upstage_ocr.py +++ b/routers/upstage_ocr.py @@ -31,6 +31,7 @@ from sqlalchemy import select, func, desc from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role +from core.crypto import encrypt_secret, decrypt_secret from database import get_db from models import User, UpstageOCRConfig, OCRHistory @@ -170,13 +171,13 @@ async def save_ocr_config( ) cfg = row.scalar_one_or_none() if cfg: - cfg.api_key_enc = req.api_key # TODO: AES-256-GCM 암호화 + cfg.api_key_enc = encrypt_secret(req.api_key) cfg.model = req.model cfg.daily_limit = req.daily_limit else: cfg = UpstageOCRConfig( tenant_id=user.tenant_id, - api_key_enc=req.api_key, + api_key_enc=encrypt_secret(req.api_key), model=req.model, daily_limit=req.daily_limit, is_active=True,