"""관리자 연결 AI provider 자격증명 저장소. 관리자가 연결 UI에서 붙여넣은 provider 토큰(OAuth 액세스 토큰 또는 API 키)을 DB(``app.admin_provider_credential``)에 암호화 저장하고, 엔진 게이트웨이 프로세스에 shared-secret으로 보호되는 내부 엔드포인트로 push해 ``os.environ``에 주입한다. - 암호화는 표준 라이브러리만으로 구성한다(HMAC-SHA256 CTR 스트림 + encrypt-then-MAC). 마스터 키는 ``PROVIDER_CREDENTIAL_SECRET``, 비어 있으면 ``SESSION_SECRET``에서 파생한다. - 토큰 원문은 로그·응답에 절대 내보내지 않는다. 마지막 4글자 힌트만 남긴다. """ from __future__ import annotations import base64 import hashlib import hmac import json import os import time from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Literal import httpx from ..config import settings from ..db import get_pool from ..engine_client import EngineError, engine_client PROVIDER_CODES = ("claude", "codex", "agy", "openrouter") ProviderCode = Literal["claude", "codex", "agy", "openrouter"] _TOKEN_ENVELOPE_PREFIX = "v1." _KEY_SIZE = 32 _NONCE_SIZE = 16 _MAC_SIZE = 32 _HKDF_INFO_ENC = b"vignette/provider-credential/enc" _HKDF_INFO_MAC = b"vignette/provider-credential/mac" @dataclass(frozen=True, slots=True) class ProviderMeta: code: ProviderCode label: str description: str token_page_url: str token_page_label: str auth_kinds: tuple[Literal["api_key", "oauth_token"], ...] guide: str PROVIDER_CATALOG: tuple[ProviderMeta, ...] = ( ProviderMeta( code="claude", label="Claude (Anthropic)", description=( "claude_api 엔진과 Claude CLI에 쓰는 Anthropic 자격증명입니다. " "OAuth 액세스 토큰은 Authorization Bearer로, API 키는 x-api-key로 전송됩니다." ), token_page_url="https://console.anthropic.com/settings/keys", token_page_label="Anthropic 콘솔 · API 키", auth_kinds=("api_key", "oauth_token"), guide=( "콘솔에서 로그인 뒤 API 키를 발급해 붙여넣거나, " "Claude CLI OAuth 토큰(claude setup-token 출력값)을 붙여넣으세요." ), ), ProviderMeta( code="codex", label="Codex / OpenAI", description=( "codex_cli 엔진과 OpenAI 호환 엔진에 쓰는 API 토큰입니다. " "게이트웨이가 OPENAI_API_KEY로 주입합니다." ), token_page_url="https://platform.openai.com/api-keys", token_page_label="OpenAI 플랫폼 · API 키", auth_kinds=("api_key",), guide=( "플랫폼에 로그인해 API 키를 발급한 뒤 붙여넣으세요. " "ChatGPT OAuth 토큰은 Codex CLI 로그인으로도 대체할 수 있습니다." ), ), ProviderMeta( code="agy", label="Agy CLI", description=( "agy_cli 엔진이 사용하는 CLI 계정 토큰입니다. " "게이트웨이가 AGY_API_KEY로 자식 프로세스에 주입합니다." ), token_page_url="", token_page_label="Agy 계정 로그인", auth_kinds=("api_key", "oauth_token"), guide=( "Agy 계정에 로그인해 발급받은 접근 토큰을 붙여넣으세요. " "실제 가용 모델은 게이트웨이의 agy models 조회로 확인됩니다." ), ), ProviderMeta( code="openrouter", label="OpenRouter", description=( "OpenRouter를 통해 다중 provider 모델을 쓰는 엔진입니다. " "게이트웨이가 OPENROUTER_API_KEY로 주입합니다." ), token_page_url="https://openrouter.ai/keys", token_page_label="OpenRouter · API 키", auth_kinds=("api_key",), guide=( "OpenRouter에 로그인해 API 키를 발급한 뒤 붙여넣으세요. " "키에 연결된 계정 크레딧과 모델 목록이 자동 확인됩니다." ), ), ) def provider_meta(provider: str) -> ProviderMeta | None: for meta in PROVIDER_CATALOG: if meta.code == provider: return meta return None class ProviderCredentialError(RuntimeError): """자격증명 저장·검증 실패. 원문 토큰을 포함하지 않는다.""" def _master_secret() -> bytes: configured = settings.provider_credential_secret.get_secret_value().strip() if configured: if len(configured) < 16: raise ProviderCredentialError( "PROVIDER_CREDENTIAL_SECRET은 16자 이상이어야 합니다." ) return configured.encode("utf-8") session_secret = settings.session_secret if session_secret == "dev-insecure-change-me": # dev 기본 시크릿은 prod 기동 가드에서 이미 막힌다. dev 편의를 위해 허용. return ("dev::" + session_secret).encode("utf-8") if not session_secret or len(session_secret) < 16: raise ProviderCredentialError( "자격증명 암호화 키가 없습니다. PROVIDER_CREDENTIAL_SECRET 또는 SESSION_SECRET을 설정하세요." ) return session_secret.encode("utf-8") def _derived_key(info: bytes) -> bytes: return hmac.new(_master_secret(), info, hashlib.sha256).digest() def encrypt_token(plain: str) -> str: """HMAC-SHA256 CTR 스트림 + encrypt-then-MAC 봉투(v1).""" data = plain.encode("utf-8") nonce = os.urandom(_NONCE_SIZE) enc_key = _derived_key(_HKDF_INFO_ENC) mac_key = _derived_key(_HKDF_INFO_MAC) stream = bytearray() counter = 0 while len(stream) < len(data): stream.extend( hashlib.sha256( enc_key + nonce + counter.to_bytes(4, "big") ).digest() ) counter += 1 ciphertext = bytes(a ^ b for a, b in zip(data, stream)) tag = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()[:_MAC_SIZE] blob = nonce + ciphertext + tag return _TOKEN_ENVELOPE_PREFIX + base64.urlsafe_b64encode(blob).decode("ascii") def decrypt_token(envelope: str) -> str: raw = envelope.removeprefix(_TOKEN_ENVELOPE_PREFIX) try: blob = base64.urlsafe_b64decode(raw.encode("ascii")) except (ValueError, UnicodeEncodeError) as exc: raise ProviderCredentialError("자격증명 봉투가 손상되었습니다.") from exc if len(blob) < _NONCE_SIZE + _MAC_SIZE + 1: raise ProviderCredentialError("자격증명 봉투가 손상되었습니다.") nonce = blob[:_NONCE_SIZE] ciphertext = blob[_NONCE_SIZE:-_MAC_SIZE] tag = blob[-_MAC_SIZE:] mac_key = _derived_key(_HKDF_INFO_MAC) expected = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()[:_MAC_SIZE] if not hmac.compare_digest(tag, expected): raise ProviderCredentialError("자격증명 봉투 인증에 실패했습니다.") enc_key = _derived_key(_HKDF_INFO_ENC) stream = bytearray() counter = 0 while len(stream) < len(ciphertext): stream.extend( hashlib.sha256( enc_key + nonce + counter.to_bytes(4, "big") ).digest() ) counter += 1 return bytes(a ^ b for a, b in zip(ciphertext, stream)).decode("utf-8") def token_hint(plain: str) -> str: tail = plain[-4:] if len(plain) >= 8 else "" return f"…{tail}" if tail else "저장됨" def _row_ts(value: Any) -> float | None: if value is None: return None if isinstance(value, datetime): value = value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value return value.timestamp() try: return float(value) except (TypeError, ValueError): return None @dataclass(frozen=True, slots=True) class StoredCredential: provider: str auth_kind: str token_hint: str updated_by: str | None updated_at: float | None last_verified_at: float | None last_verify_ok: bool | None last_verify_error: str | None refresh_token: str | None = None extra: dict[str, Any] | None = None async def _credential_table_ready(conn) -> bool: return bool( await conn.fetchval( "SELECT to_regclass('app.admin_provider_credential') IS NOT NULL" ) ) async def ensure_table() -> None: """테이블이 없을 때만 DDL을 실행한다. 앱 접속 계정(vignette_app)에는 스키마 CREATE 권한이 없다. Postgres는 ``CREATE TABLE IF NOT EXISTS``가 no-op이어도 스키마 CREATE 권한을 먼저 검사하므로, 부트스트랩 SQL이 이미 만든 테이블이면 검사 없이 건너뛴다. """ pool = get_pool() async with pool.acquire() as conn: if await _credential_table_ready(conn): return await conn.execute( """ CREATE TABLE IF NOT EXISTS app.admin_provider_credential ( provider TEXT PRIMARY KEY, token_encrypted TEXT NOT NULL, token_hint TEXT NOT NULL DEFAULT '', auth_kind TEXT NOT NULL DEFAULT 'api_key' CHECK (auth_kind IN ('api_key','oauth_token')), updated_by TEXT, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_verified_at TIMESTAMPTZ, last_verify_ok BOOLEAN, last_verify_error TEXT, refresh_token_encrypted TEXT, extra JSONB NOT NULL DEFAULT '{}'::jsonb ) """ ) await conn.execute( """ ALTER TABLE app.admin_provider_credential ADD COLUMN IF NOT EXISTS refresh_token_encrypted TEXT, ADD COLUMN IF NOT EXISTS extra JSONB NOT NULL DEFAULT '{}'::jsonb """ ) async def get_credential_row(provider: str) -> StoredCredential | None: await ensure_table() pool = get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( """ SELECT provider, token_hint, auth_kind, updated_by, updated_at, last_verified_at, last_verify_ok, last_verify_error FROM app.admin_provider_credential WHERE provider = $1 """, provider, ) if row is None: return None return StoredCredential( provider=row["provider"], auth_kind=row["auth_kind"], token_hint=row["token_hint"] or "저장됨", updated_by=row["updated_by"], updated_at=_row_ts(row["updated_at"]), last_verified_at=_row_ts(row["last_verified_at"]), last_verify_ok=row["last_verify_ok"], last_verify_error=row["last_verify_error"], refresh_token=decrypt_token(row["refresh_token_encrypted"]) if row.get("refresh_token_encrypted") else None, extra=row.get("extra") if isinstance(row.get("extra"), dict) else None, ) async def get_plain_token(provider: str) -> tuple[str, str] | None: """저장된 토큰 원문과 auth_kind를 반환한다. 없으면 None.""" await ensure_table() pool = get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( "SELECT token_encrypted, auth_kind FROM app.admin_provider_credential WHERE provider = $1", provider, ) if row is None: return None return decrypt_token(row["token_encrypted"]), row["auth_kind"] async def save_credential( *, provider: str, token: str, auth_kind: str, updated_by: str, refresh_token: str | None = None, extra: dict[str, Any] | None = None, ) -> StoredCredential: if provider not in PROVIDER_CODES: raise ProviderCredentialError(f"지원하지 않는 provider입니다: {provider}") if auth_kind not in {"api_key", "oauth_token"}: raise ProviderCredentialError("auth_kind는 api_key 또는 oauth_token이어야 합니다.") plain = token.strip() if not plain: raise ProviderCredentialError("토큰이 비어 있습니다.") await ensure_table() pool = get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( """ INSERT INTO app.admin_provider_credential ( provider, token_encrypted, token_hint, auth_kind, updated_by, updated_at, refresh_token_encrypted, extra ) VALUES ($1, $2, $3, $4, $5, now(), $6, $7::jsonb) ON CONFLICT (provider) DO UPDATE SET token_encrypted = EXCLUDED.token_encrypted, token_hint = EXCLUDED.token_hint, auth_kind = EXCLUDED.auth_kind, updated_by = EXCLUDED.updated_by, updated_at = now(), refresh_token_encrypted = COALESCE(EXCLUDED.refresh_token_encrypted, refresh_token_encrypted), extra = CASE WHEN EXCLUDED.extra::text = '{}'::jsonb THEN extra ELSE EXCLUDED.extra END, last_verified_at = NULL, last_verify_ok = NULL, last_verify_error = NULL RETURNING provider, token_hint, auth_kind, updated_by, updated_at, last_verified_at, last_verify_ok, last_verify_error, refresh_token_encrypted, extra """, provider, encrypt_token(plain), token_hint(plain), auth_kind, updated_by, encrypt_token(refresh_token) if refresh_token and refresh_token.strip() else None, json.dumps(extra or {}), ) return StoredCredential( provider=row["provider"], auth_kind=row["auth_kind"], token_hint=row["token_hint"] or "저장됨", updated_by=row["updated_by"], updated_at=_row_ts(row["updated_at"]), last_verified_at=_row_ts(row["last_verified_at"]), last_verify_ok=row["last_verify_ok"], last_verify_error=row["last_verify_error"], refresh_token=decrypt_token(row["refresh_token_encrypted"]) if row.get("refresh_token_encrypted") else None, extra=row.get("extra") if isinstance(row.get("extra"), dict) else None, ) async def delete_credential(provider: str) -> bool: await ensure_table() pool = get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( "DELETE FROM app.admin_provider_credential WHERE provider = $1 RETURNING provider", provider, ) return row is not None async def mark_verified(provider: str, *, ok: bool, error: str | None) -> None: await ensure_table() pool = get_pool() async with pool.acquire() as conn: await conn.execute( """ UPDATE app.admin_provider_credential SET last_verified_at = now(), last_verify_ok = $2, last_verify_error = $3 WHERE provider = $1 """, provider, ok, (error or "")[:500] if error else None, ) # ── 게이트웨이 push 동기화 ──────────────────────────────────────────── _synced_boot_id: str | None = None _synced_detail: str | None = None def last_sync_state() -> tuple[str | None, str | None]: return _synced_boot_id, _synced_detail async def push_credentials_to_gateway() -> dict[str, Any]: """DB에 저장된 전체 자격증명을 게이트웨이 프로세스 환경으로 push한다. 실패해도 예외로 승격하지 않고 상태를 돌려준다 — 게이트웨이가 아직 안 떠 있어도 저장 자체는 성공해야 하고, 다음 동기화 기회(boot_id 불일치)에 다시 push된다. """ global _synced_boot_id, _synced_detail await ensure_table() pool = get_pool() async with pool.acquire() as conn: rows = await conn.fetch( "SELECT provider, token_encrypted, auth_kind, refresh_token_encrypted, extra " "FROM app.admin_provider_credential" ) providers = { row["provider"]: { "token": decrypt_token(row["token_encrypted"]), "auth_kind": row["auth_kind"], **( { "refresh_token": decrypt_token(row["refresh_token_encrypted"]), } if row["refresh_token_encrypted"] else {} ), "extra": row["extra"] if isinstance(row["extra"], dict) else {}, } for row in rows if row["provider"] in PROVIDER_CODES } if not providers: _synced_boot_id = None _synced_detail = "저장된 자격증명이 없습니다." return {"synced": False, "detail": _synced_detail} try: result = await engine_client.push_provider_credentials(providers) status = await engine_client.provider_credentials_status() _synced_boot_id = str(status.get("boot_id") or result.get("boot_id") or "") _synced_detail = f"pushed: {','.join(result.get('applied') or [])}" return {"synced": True, "boot_id": _synced_boot_id, "applied": result.get("applied") or []} except EngineError as exc: _synced_detail = str(exc) return {"synced": False, "detail": _synced_detail} async def ensure_gateway_synced() -> dict[str, Any]: """게이트웨이가 재시작(boot_id 변경)했으면 저장된 자격증명을 재-push한다.""" global _synced_boot_id, _synced_detail try: status = await engine_client.provider_credentials_status() except EngineError as exc: return {"synced": False, "detail": str(exc)} boot_id = str(status.get("boot_id") or "") if boot_id and boot_id == _synced_boot_id: return {"synced": True, "boot_id": boot_id} return await push_credentials_to_gateway() # ── 연결 검증 ──────────────────────────────────────────────────────── _ANTHROPIC_BASE = os.environ.get("ANTHROPIC_API_BASE", "https://api.anthropic.com").rstrip("/") _OPENAI_BASE = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/") _OPENROUTER_BASE = os.environ.get("OPENROUTER_API_BASE", "https://openrouter.ai/api/v1").rstrip("/") async def verify_provider(provider: str) -> dict[str, Any]: """저장된 토큰으로 provider에 실제 요청을 보내 연결을 확인한다.""" stored = await get_plain_token(provider) if stored is None: return {"ok": False, "detail": "저장된 자격증명이 없습니다."} token, auth_kind = stored ok = False detail = "" try: if provider == "claude": headers = ( {"Authorization": f"Bearer {token}", "anthropic-version": "2023-06-01"} if auth_kind == "oauth_token" else {"x-api-key": token, "anthropic-version": "2023-06-01"} ) async with httpx.AsyncClient(timeout=20) as client: response = await client.get(f"{_ANTHROPIC_BASE}/v1/models", params={"limit": 1}, headers=headers) ok = response.status_code == 200 detail = "" if ok else f"Anthropic 응답 {response.status_code}" elif provider == "codex": async with httpx.AsyncClient(timeout=20) as client: response = await client.get(f"{_OPENAI_BASE}/models", headers={"Authorization": f"Bearer {token}"}) ok = response.status_code == 200 detail = "" if ok else f"OpenAI 응답 {response.status_code}" elif provider == "openrouter": async with httpx.AsyncClient(timeout=20) as client: response = await client.get(f"{_OPENROUTER_BASE}/key", headers={"Authorization": f"Bearer {token}"}) ok = response.status_code == 200 detail = "" if ok else f"OpenRouter 응답 {response.status_code}" elif provider == "agy": # Agy CLI 계정 인증은 게이트웨이의 agy models 조회(ready)가 담당한다. # 여기서는 토큰 형식 보전 여부만 확인한다. ok = len(token) >= 8 detail = "" if ok else "토큰이 너무 짧습니다." else: detail = f"지원하지 않는 provider입니다: {provider}" except httpx.HTTPError as exc: detail = f"연결 확인 요청 실패: {exc}" await mark_verified(provider, ok=ok, error=None if ok else detail) return {"ok": ok, "detail": detail, "verified_at": time.time()}