"""Runtime secret management. Secrets are loaded from environment variables first. For local/offline installs, missing values are generated once into ``.runtime-secrets.json`` at the project root. That file must never be committed or served by the web application. """ import json import os import secrets import threading import base64 from pathlib import Path from monitor_runtime.paths import DATA_ROOT PROJECT_ROOT = DATA_ROOT RUNTIME_SECRETS_FILE = Path( os.environ.get("MONITOR_RUNTIME_SECRETS_FILE", PROJECT_ROOT / ".runtime-secrets.json") ).resolve() _ENV_NAMES = { "django_secret_key": "MONITOR_DJANGO_SECRET_KEY", "internal_api_secret": "MONITOR_INTERNAL_API_SECRET", "media_secret": "MONITOR_MEDIA_SECRET", "sip_server_password": "MONITOR_SIP_SERVER_PASSWORD", "sip_server_nonce": "MONITOR_SIP_SERVER_NONCE", "credential_encryption_key": "MONITOR_CREDENTIAL_ENCRYPTION_KEY", } _CACHE = None _LOCK = threading.RLock() def _new_secret_values(): return { "django_secret_key": secrets.token_urlsafe(64), "internal_api_secret": secrets.token_urlsafe(48), "media_secret": secrets.token_urlsafe(32), "sip_server_password": secrets.token_urlsafe(24), "sip_server_nonce": secrets.token_hex(16), "credential_encryption_key": base64.urlsafe_b64encode(os.urandom(32)).decode("ascii"), } def _read_file(): with open(RUNTIME_SECRETS_FILE, "r", encoding="utf-8") as f: data = json.load(f) if not isinstance(data, dict): raise RuntimeError("runtime secrets file must contain a JSON object") return data def _create_file_exclusive(data): RUNTIME_SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True) flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL fd = os.open(str(RUNTIME_SECRETS_FILE), flags, 0o600) try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) f.write("\n") except Exception: try: os.unlink(RUNTIME_SECRETS_FILE) except OSError: pass raise try: os.chmod(RUNTIME_SECRETS_FILE, 0o600) except OSError: pass def _write_file_atomic(data): RUNTIME_SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True) temp_path = RUNTIME_SECRETS_FILE.with_suffix(RUNTIME_SECRETS_FILE.suffix + ".tmp") flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC fd = os.open(str(temp_path), flags, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) f.write("\n") os.replace(temp_path, RUNTIME_SECRETS_FILE) try: os.chmod(RUNTIME_SECRETS_FILE, 0o600) except OSError: pass def _load_or_create(): global _CACHE with _LOCK: if _CACHE is not None: return dict(_CACHE) if RUNTIME_SECRETS_FILE.exists(): data = _read_file() else: data = _new_secret_values() try: _create_file_exclusive(data) except FileExistsError: data = _read_file() missing = [name for name in _ENV_NAMES if not data.get(name)] if missing: generated = _new_secret_values() for name in missing: data[name] = generated[name] _write_file_atomic(data) _CACHE = data return dict(_CACHE) def get_runtime_secret(name): if name not in _ENV_NAMES: raise KeyError("unknown runtime secret: %s" % name) env_value = os.environ.get(_ENV_NAMES[name], "").strip() if env_value: return env_value return str(_load_or_create()[name]) def rotate_runtime_secrets(): """Rotate service/session secrets while preserving the data-encryption key.""" global _CACHE with _LOCK: backup = None previous = {} if RUNTIME_SECRETS_FILE.exists(): import shutil from datetime import datetime backup = RUNTIME_SECRETS_FILE.with_name( RUNTIME_SECRETS_FILE.name + ".backup-" + datetime.now().strftime("%Y%m%d%H%M%S") ) shutil.copy2(RUNTIME_SECRETS_FILE, backup) previous = _read_file() data = _new_secret_values() # Rotating this value without decrypting and re-encrypting every database # row would make stored third-party credentials unrecoverable. if previous.get("credential_encryption_key"): data["credential_encryption_key"] = previous["credential_encryption_key"] _write_file_atomic(data) _CACHE = data return str(backup) if backup else ""