Add local API key onboarding

This commit is contained in:
sinmb79
2026-04-03 09:13:05 +09:00
parent 544e4e0720
commit c8e1ec5d34
10 changed files with 328 additions and 0 deletions

View File

@@ -4,12 +4,28 @@ from functools import lru_cache
from pathlib import Path
import os
from dotenv import load_dotenv
from pydantic import BaseModel, Field
from civilplan_mcp.secure_store import default_key_store_path, load_api_keys
BASE_DIR = Path(__file__).resolve().parent.parent
def load_local_env() -> None:
env_path = BASE_DIR / ".env"
if env_path.exists():
load_dotenv(env_path, override=False)
def _load_secure_api_keys(path: Path) -> dict[str, str]:
try:
return load_api_keys(path=path)
except Exception:
return {}
class Settings(BaseModel):
app_name: str = "civilplan_mcp"
version: str = "1.0.0"
@@ -19,13 +35,22 @@ class Settings(BaseModel):
db_path: Path = Field(default_factory=lambda: BASE_DIR / "civilplan.db")
output_dir: Path = Field(default_factory=lambda: BASE_DIR / "output")
data_dir: Path = Field(default_factory=lambda: BASE_DIR / "civilplan_mcp" / "data")
key_store_path: Path = Field(default_factory=default_key_store_path)
data_go_kr_api_key: str = Field(default_factory=lambda: os.getenv("DATA_GO_KR_API_KEY", ""))
vworld_api_key: str = Field(default_factory=lambda: os.getenv("VWORLD_API_KEY", ""))
@lru_cache(maxsize=1)
def get_settings() -> Settings:
load_local_env()
settings = Settings()
secure_keys = _load_secure_api_keys(settings.key_store_path)
if not settings.data_go_kr_api_key:
settings.data_go_kr_api_key = secure_keys.get("DATA_GO_KR_API_KEY", "")
if not settings.vworld_api_key:
settings.vworld_api_key = secure_keys.get("VWORLD_API_KEY", "")
settings.output_dir.mkdir(parents=True, exist_ok=True)
return settings

View File

@@ -0,0 +1,128 @@
from __future__ import annotations
import base64
import ctypes
from ctypes import wintypes
import json
import os
from pathlib import Path
import sys
from typing import Any
DEFAULT_KEY_FILE_NAME = "api-keys.dpapi.json"
class DATA_BLOB(ctypes.Structure):
_fields_ = [
("cbData", wintypes.DWORD),
("pbData", ctypes.POINTER(ctypes.c_char)),
]
def default_key_store_path() -> Path:
key_root = Path(os.getenv("CIVILPLAN_KEY_DIR", Path.home() / "key"))
return key_root / "civilplan-mcp" / DEFAULT_KEY_FILE_NAME
def _require_windows() -> None:
if sys.platform != "win32":
raise RuntimeError("Windows DPAPI storage is only available on Windows.")
def _blob_from_bytes(data: bytes) -> tuple[DATA_BLOB, ctypes.Array[ctypes.c_char]]:
raw_buffer = ctypes.create_string_buffer(data, len(data))
blob = DATA_BLOB(
cbData=len(data),
pbData=ctypes.cast(raw_buffer, ctypes.POINTER(ctypes.c_char)),
)
return blob, raw_buffer
def _bytes_from_blob(blob: DATA_BLOB) -> bytes:
return ctypes.string_at(blob.pbData, blob.cbData)
def _protect_bytes(raw: bytes) -> bytes:
_require_windows()
crypt32 = ctypes.windll.crypt32
kernel32 = ctypes.windll.kernel32
input_blob, input_buffer = _blob_from_bytes(raw)
output_blob = DATA_BLOB()
success = crypt32.CryptProtectData(
ctypes.byref(input_blob),
None,
None,
None,
None,
0,
ctypes.byref(output_blob),
)
if not success:
raise ctypes.WinError()
try:
return _bytes_from_blob(output_blob)
finally:
if output_blob.pbData:
kernel32.LocalFree(output_blob.pbData)
del input_buffer
def _unprotect_bytes(raw: bytes) -> bytes:
_require_windows()
crypt32 = ctypes.windll.crypt32
kernel32 = ctypes.windll.kernel32
input_blob, input_buffer = _blob_from_bytes(raw)
output_blob = DATA_BLOB()
success = crypt32.CryptUnprotectData(
ctypes.byref(input_blob),
None,
None,
None,
None,
0,
ctypes.byref(output_blob),
)
if not success:
raise ctypes.WinError()
try:
return _bytes_from_blob(output_blob)
finally:
if output_blob.pbData:
kernel32.LocalFree(output_blob.pbData)
del input_buffer
def save_api_keys(keys: dict[str, str], path: Path | None = None) -> Path:
target = path or default_key_store_path()
payload = {
"version": 1,
"keys": {key: value for key, value in keys.items() if value},
}
protected = _protect_bytes(json.dumps(payload, ensure_ascii=False).encode("utf-8"))
envelope = {
"scheme": "windows-dpapi-base64",
"ciphertext": base64.b64encode(protected).decode("ascii"),
}
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(envelope, ensure_ascii=False, indent=2), encoding="utf-8")
return target
def load_api_keys(path: Path | None = None) -> dict[str, str]:
target = path or default_key_store_path()
if not target.exists():
return {}
envelope = json.loads(target.read_text(encoding="utf-8"))
ciphertext = base64.b64decode(envelope["ciphertext"])
payload = json.loads(_unprotect_bytes(ciphertext).decode("utf-8"))
keys: dict[str, Any] = payload.get("keys", {})
return {str(key): str(value) for key, value in keys.items() if value}

View File

@@ -64,6 +64,8 @@ async def civilplan_lifespan(_: FastMCP) -> AsyncIterator[dict[str, object]]:
missing_keys = check_api_keys()
for key in missing_keys:
logger.warning("API key missing: %s", key)
if missing_keys:
logger.warning("Provide keys in .env or run `python setup_keys.py` for encrypted local storage.")
scheduler = build_scheduler(start=True)
try:

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
import argparse
from getpass import getpass
from pathlib import Path
from civilplan_mcp.secure_store import default_key_store_path, save_api_keys
def _parse_env_file(path: Path) -> dict[str, str]:
parsed: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, value = stripped.split("=", 1)
parsed[key.strip()] = value.strip().strip("'").strip('"')
return parsed
def _prompt_value(name: str, current: str = "") -> str:
prompt = f"{name}"
if current:
prompt += " [press Enter to keep imported value]"
prompt += ": "
entered = getpass(prompt).strip()
return entered or current
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Store CivilPlan API keys in Windows DPAPI encrypted local storage.")
parser.add_argument(
"--from-env-file",
type=Path,
help="Import API keys from an existing .env file before prompting.",
)
args = parser.parse_args(argv)
imported: dict[str, str] = {}
if args.from_env_file:
imported = _parse_env_file(args.from_env_file)
data_go_kr_api_key = _prompt_value("DATA_GO_KR_API_KEY", imported.get("DATA_GO_KR_API_KEY", ""))
vworld_api_key = _prompt_value("VWORLD_API_KEY", imported.get("VWORLD_API_KEY", ""))
target = save_api_keys(
{
"DATA_GO_KR_API_KEY": data_go_kr_api_key,
"VWORLD_API_KEY": vworld_api_key,
}
)
print("CivilPlan API keys were saved to encrypted local storage.")
print(f"Path: {target}")
print("The file is protected with Windows DPAPI and can only be read by the same Windows user on this machine.")
return 0