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

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

View File

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

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

View File

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

View File

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

5
setup_keys.py Normal file
View File

@@ -0,0 +1,5 @@
from civilplan_mcp.setup_keys import main
if __name__ == "__main__":
raise SystemExit(main())

View File

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