diff --git a/.env.example b/.env.example index 38cc898..80e5186 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,4 @@ +# Copy this file to .env for local use, or import it with: +# python setup_keys.py --from-env-file .env DATA_GO_KR_API_KEY= VWORLD_API_KEY= diff --git a/README.md b/README.md index 7567e60..964d5ae 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,32 @@ python server.py The server starts on `127.0.0.1:8765` and exposes the MCP endpoint at `/mcp`. +## API Key Setup + +CivilPlan supports two local-only key flows: + +### Option A: `.env` + +1. Copy `.env.example` to `.env` +2. Fill in your own keys +3. Start the server + +`.env` is ignored by git and loaded automatically at startup. + +### Option B: encrypted local storage + +```bash +python setup_keys.py +``` + +Or import an existing `.env` file into encrypted storage: + +```bash +python setup_keys.py --from-env-file .env +``` + +On Windows, this stores the keys with DPAPI under your local user profile so the same machine and user account are required to decrypt them. + ## Environment Variables ```env @@ -64,6 +90,8 @@ VWORLD_API_KEY= - `DATA_GO_KR_API_KEY`: public data portal API key used for benchmark probing and future official integrations - `VWORLD_API_KEY`: VWorld API key used for address-to-PNU lookup and cadastral queries +Live keys are intentionally not committed to the public repository. + ## Client Connection ### Claude Desktop @@ -120,6 +148,7 @@ If parsing fails, the server creates `.update_required_*` flag files and emits s - Land-price lookup requires manually downloaded source files under `civilplan_mcp/data/land_prices/`. - Nara benchmark validation currently probes API availability and falls back to local heuristics when the public endpoint is unavailable. - Updater fetchers are conservative and may request manual review when source pages change. +- Public repository builds do not include live API credentials. Users should set their own keys through `.env` or `setup_keys.py`. ## Verification diff --git a/civilplan_mcp/config.py b/civilplan_mcp/config.py index 5d0b3f8..b05fe8e 100644 --- a/civilplan_mcp/config.py +++ b/civilplan_mcp/config.py @@ -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 diff --git a/civilplan_mcp/secure_store.py b/civilplan_mcp/secure_store.py new file mode 100644 index 0000000..04be021 --- /dev/null +++ b/civilplan_mcp/secure_store.py @@ -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} diff --git a/civilplan_mcp/server.py b/civilplan_mcp/server.py index d051a18..53fa406 100644 --- a/civilplan_mcp/server.py +++ b/civilplan_mcp/server.py @@ -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: diff --git a/civilplan_mcp/setup_keys.py b/civilplan_mcp/setup_keys.py new file mode 100644 index 0000000..371c113 --- /dev/null +++ b/civilplan_mcp/setup_keys.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 96b4618..bad4eeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,9 +22,13 @@ dependencies = [ "aiosqlite>=0.20.0", "httpx>=0.27.0", "apscheduler>=3.10.0", + "python-dotenv>=1.0.1", "python-dateutil>=2.9.0", ] +[project.scripts] +civilplan-setup-keys = "civilplan_mcp.setup_keys:main" + [tool.setuptools.packages.find] include = ["civilplan_mcp*"] diff --git a/requirements.txt b/requirements.txt index 90dd2b5..12480d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,5 +7,6 @@ ezdxf>=1.3.0 aiosqlite>=0.20.0 httpx>=0.27.0 apscheduler>=3.10.0 +python-dotenv>=1.0.1 python-dateutil>=2.9.0 pytest>=8.0.0 diff --git a/setup_keys.py b/setup_keys.py new file mode 100644 index 0000000..f893be0 --- /dev/null +++ b/setup_keys.py @@ -0,0 +1,5 @@ +from civilplan_mcp.setup_keys import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_config_and_secure_store.py b/tests/test_config_and_secure_store.py new file mode 100644 index 0000000..fbaaf9b --- /dev/null +++ b/tests/test_config_and_secure_store.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from civilplan_mcp import config, secure_store + + +@pytest.fixture(autouse=True) +def clear_settings_cache() -> None: + config.get_settings.cache_clear() + yield + config.get_settings.cache_clear() + + +def test_secure_store_round_trip_with_stub_crypto(tmp_path: Path, monkeypatch) -> None: + target = tmp_path / "api-keys.dpapi.json" + + monkeypatch.setattr(secure_store, "_protect_bytes", lambda raw: raw[::-1]) + monkeypatch.setattr(secure_store, "_unprotect_bytes", lambda raw: raw[::-1]) + + secure_store.save_api_keys( + { + "DATA_GO_KR_API_KEY": "data-key", + "VWORLD_API_KEY": "vworld-key", + }, + path=target, + ) + + loaded = secure_store.load_api_keys(path=target) + + assert loaded == { + "DATA_GO_KR_API_KEY": "data-key", + "VWORLD_API_KEY": "vworld-key", + } + + +def test_get_settings_uses_secure_store_when_env_missing(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(config, "BASE_DIR", tmp_path) + monkeypatch.setattr(config, "load_local_env", lambda: None) + monkeypatch.setattr( + config, + "_load_secure_api_keys", + lambda path: { + "DATA_GO_KR_API_KEY": "secure-data-key", + "VWORLD_API_KEY": "secure-vworld-key", + }, + ) + monkeypatch.delenv("DATA_GO_KR_API_KEY", raising=False) + monkeypatch.delenv("VWORLD_API_KEY", raising=False) + + settings = config.get_settings() + + assert settings.data_go_kr_api_key == "secure-data-key" + assert settings.vworld_api_key == "secure-vworld-key" + + +def test_get_settings_prefers_env_values_over_secure_store(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(config, "BASE_DIR", tmp_path) + monkeypatch.setattr(config, "load_local_env", lambda: None) + monkeypatch.setattr( + config, + "_load_secure_api_keys", + lambda path: { + "DATA_GO_KR_API_KEY": "secure-data-key", + "VWORLD_API_KEY": "secure-vworld-key", + }, + ) + monkeypatch.setenv("DATA_GO_KR_API_KEY", "env-data-key") + monkeypatch.setenv("VWORLD_API_KEY", "env-vworld-key") + + settings = config.get_settings() + + assert settings.data_go_kr_api_key == "env-data-key" + assert settings.vworld_api_key == "env-vworld-key"