meshai/work/meshai/secrets_store.py
malice e9153943bf
chore(wzdx): remove two dead config fields (#150)
Both were fully plumbed and read by nothing.

api_key -- self-documented as dead at config.py ("Keyless: api_key is
retained but unused"), yet wired end-to-end: a GUI ManagedSecret field, a
SECRET_FIELDS entry, an EXPECTED_SECRETS entry, a secrets_store mapping and
label, and a line in .env.example. env/wzdx.py assigned self._api_key and
never read it again. So an operator could go get an API key, paste it into
the secure secrets manager, and have it do precisely nothing -- the ritual
looked complete end to end, which is what made it worth removing rather
than leaving.

endpoints -- default ["/get/event"], exposed as an editable list in the
dashboard, never read. Copy-paste from Roads511Config.endpoints (which IS
read, at env/roads511.py; Roads511 is untouched here). WZDx discovers feeds
via the FHWA registry_url/states instead.

WZDx's actual fetch behavior is unchanged; this removes dead config only.

Note for existing installs: anyone with WZDX_API_KEY set in
/data/secrets/.env will simply have an ignored env var. Harmless -- it was
already ignored.

Suite: 2337 passed, 6 failed (the pre-existing set: stale SCHEMA_VERSION x3,
expired TLE fixtures x2, one order-dependent), 72 skipped -- exact baseline
match, no new failures.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:07:46 -06:00

242 lines
8 KiB
Python

"""GUI-managed secrets store for MeshAI.
Secret VALUES live in a .env file (default /data/secrets/.env).
Config YAML holds only ${VAR} references — never raw values.
This module NEVER returns or logs secret values.
"""
from __future__ import annotations
import os
from pathlib import Path
from dotenv import set_key, unset_key, dotenv_values
def secret_env_path(config_dir: Path = Path("/data/config")) -> Path:
"""Resolve the secrets .env path the same way load_config does; ensure parent dir exists."""
p = Path(config_dir).parent / "secrets" / ".env"
p.parent.mkdir(parents=True, exist_ok=True)
return p
# config dotted-field -> env var name. llm.api_key is backend-dependent (see llm_env_var).
SECRET_FIELD_TO_ENV: dict[str, str] = {
"environmental.traffic.api_key": "TOMTOM_API_KEY",
"environmental.firms.map_key": "FIRMS_MAP_KEY",
"environmental.roads511.api_key": "ROADS511_API_KEY",
"notifications.toggles.*.smtp_password": "SMTP_PASSWORD",
"notifications.rules.*.smtp_password": "SMTP_PASSWORD",
"mesh_sources.*.api_token": "MESHMONITOR_API_TOKEN",
}
# llm.api_key backend -> env var (mirrors config_loader.py backend dict; else LLM_API_KEY)
LLM_BACKEND_TO_ENV: dict[str, str] = {
"openai": "OPENAI_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"google": "GOOGLE_API_KEY",
}
def llm_env_var(backend: str | None) -> str:
return LLM_BACKEND_TO_ENV.get((backend or "").lower(), "LLM_API_KEY")
# Human labels for every managed env var.
SECRET_LABELS: dict[str, str] = {
"GOOGLE_API_KEY": "Google (Gemini) API key",
"OPENAI_API_KEY": "OpenAI API key",
"ANTHROPIC_API_KEY": "Anthropic API key",
"LLM_API_KEY": "LLM API key (generic)",
"TOMTOM_API_KEY": "TomTom API key",
"FIRMS_MAP_KEY": "NASA FIRMS MAP_KEY",
"ROADS511_API_KEY": "511 Roads API key",
"SMTP_PASSWORD": "SMTP password",
"MESHMONITOR_API_TOKEN": "MeshMonitor API token",
"MQTT_PASSWORD": "MQTT password",
}
def _managed_vars() -> list[str]:
"""Return list of managed env var names (stable insertion order)."""
return list(SECRET_LABELS)
def get_status(config_dir: Path = Path("/data/config")) -> dict[str, bool]:
"""For each managed var, True if set (non-empty) in .env OR os.environ.
NEVER returns values — only booleans.
"""
path = secret_env_path(config_dir)
try:
env_file = dotenv_values(path) if path.exists() else {}
except Exception:
env_file = {}
result: dict[str, bool] = {}
for var in _managed_vars():
file_val = env_file.get(var, "")
env_val = os.environ.get(var, "")
result[var] = bool(file_val) or bool(env_val)
return result
def set_secret(var: str, value: str, config_dir: Path = Path("/data/config")) -> None:
"""Write var=value into the secrets .env file.
Raises ValueError if var is not a managed secret.
"""
if var not in SECRET_LABELS:
raise ValueError(f"Unknown secret var: {var!r}. Allowed: {list(SECRET_LABELS)}")
path = secret_env_path(config_dir)
set_key(str(path), var, value)
def delete_secret(var: str, config_dir: Path = Path("/data/config")) -> None:
"""Remove var from the secrets .env file (no-op if file/key missing).
Raises ValueError if var is not a managed secret.
"""
if var not in SECRET_LABELS:
raise ValueError(f"Unknown secret var: {var!r}. Allowed: {list(SECRET_LABELS)}")
path = secret_env_path(config_dir)
try:
unset_key(str(path), var)
except (KeyError, FileNotFoundError):
pass
def _env_to_fields() -> dict[str, list[str]]:
"""Reverse of SECRET_FIELD_TO_ENV: env var -> list of dotted config fields.
Also includes llm.api_key for all four LLM env vars.
"""
result: dict[str, list[str]] = {}
for field, var in SECRET_FIELD_TO_ENV.items():
result.setdefault(var, []).append(field)
# LLM keys all map to llm.api_key
for llm_var in ("GOOGLE_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "LLM_API_KEY"):
result.setdefault(llm_var, []).append("llm.api_key")
return result
def list_secrets(config_dir: Path = Path("/data/config")) -> list[dict]:
"""Build the API payload for GET /secrets.
Each item: {"env_var": str, "is_set": bool, "fields": list[str], "label": str}.
Values are NEVER included.
"""
status = get_status(config_dir)
field_map = _env_to_fields()
return [
{
"env_var": var,
"is_set": status[var],
"fields": field_map.get(var, []),
"label": SECRET_LABELS[var],
}
for var in _managed_vars()
]
# ---------------------------------------------------------------------------
# MeshCore room-server passwords (dynamic, per-room secrets)
# ---------------------------------------------------------------------------
#
# Unlike the fixed SECRET_LABELS allowlist above, room passwords are keyed by
# the room server's public key, so the env var name is DERIVED from the pubkey
# rather than drawn from a static vocabulary. Each password is stored in the
# same secrets .env file under a per-room var name:
#
# MESHCORE_ROOM_<PREFIX>_PWD
#
# where <PREFIX> is the first ROOM_PUBKEY_PREFIX_LEN hex chars of the room's
# public key, uppercased. The transport resolves the password by pubkey at
# send time (login-before-send); the GUI/API sets it via set_room_password().
#
# The routing cell NEVER holds the password — only ``room:<pubkey>``.
ROOM_PUBKEY_PREFIX_LEN = 12
_ROOM_PWD_PREFIX = "MESHCORE_ROOM_"
_ROOM_PWD_SUFFIX = "_PWD"
def room_pwd_env_var(pubkey: str) -> str:
"""Derive the .env var name for a room server's password from its pubkey.
``pubkey`` may be a full 32-byte hex key or a shorter prefix; the first
ROOM_PUBKEY_PREFIX_LEN hex chars are used (uppercased) so a cell holding a
prefix and a cell holding the full key resolve to the SAME secret.
Raises ValueError on an empty pubkey.
"""
pk = (pubkey or "").strip()
if not pk:
raise ValueError("room pubkey must be non-empty")
prefix = pk[:ROOM_PUBKEY_PREFIX_LEN].upper()
return f"{_ROOM_PWD_PREFIX}{prefix}{_ROOM_PWD_SUFFIX}"
def get_room_password(
pubkey: str, config_dir: Path = Path("/data/config")
) -> str | None:
"""Resolve a room server's password by pubkey, or None if none is set.
Resolution mirrors config_loader._interpolate_env_vars: os.environ takes
precedence over the .env file. Returns the raw value for internal use
(transport login-before-send) — do NOT expose this via a status API; the
GUI status path must use ``room_password_is_set`` (booleans only).
"""
try:
var = room_pwd_env_var(pubkey)
except ValueError:
return None
val = os.environ.get(var)
if val:
return val
path = secret_env_path(config_dir)
try:
env_file = dotenv_values(path) if path.exists() else {}
except Exception:
env_file = {}
val = env_file.get(var)
return val or None
def room_password_is_set(
pubkey: str, config_dir: Path = Path("/data/config")
) -> bool:
"""True if a password is configured for this room (never returns the value)."""
return get_room_password(pubkey, config_dir) is not None
def set_room_password(
pubkey: str, value: str, config_dir: Path = Path("/data/config")
) -> None:
"""Store a room server's password (GUI/API write path).
Keyed by pubkey via room_pwd_env_var(). Raises ValueError on empty pubkey.
An empty value deletes the entry (mirrors "clear the password").
"""
var = room_pwd_env_var(pubkey)
path = secret_env_path(config_dir)
if value:
set_key(str(path), var, value)
else:
try:
unset_key(str(path), var)
except (KeyError, FileNotFoundError):
pass
def delete_room_password(
pubkey: str, config_dir: Path = Path("/data/config")
) -> None:
"""Remove a room server's password from the secrets .env (no-op if absent)."""
var = room_pwd_env_var(pubkey)
path = secret_env_path(config_dir)
try:
unset_key(str(path), var)
except (KeyError, FileNotFoundError):
pass