Files
Shadowbroker/backend/services/env_check.py
T
2026-05-01 22:56:50 -06:00

1159 lines
58 KiB
Python

"""Startup environment validation — called once in the FastAPI lifespan hook.
Ensures required env vars are present before the scheduler starts.
Logs warnings for optional keys that degrade functionality when missing.
Audits security-critical config for dangerous combinations.
"""
import os
import secrets
import sys
import time
import logging
import json
from pathlib import Path
from services.config import (
backend_gate_decrypt_compat_effective,
backend_gate_plaintext_compat_effective,
gate_plaintext_persist_effective,
gate_recovery_envelope_effective,
get_settings,
private_clearnet_fallback_effective,
private_clearnet_fallback_requested,
)
from services.mesh.mesh_compatibility import (
compat_dm_invite_import_override_active,
legacy_dm1_override_active,
legacy_dm_get_override_active,
legacy_dm_signature_compat_override_active,
)
from services.release_profiles import profile_readiness_snapshot
logger = logging.getLogger(__name__)
_BACKEND_DIR = Path(__file__).resolve().parents[1]
_DEFAULT_RELEASE_ATTESTATION_PATH = _BACKEND_DIR / "data" / "release_attestation.json"
# Keys grouped by criticality
_REQUIRED = {
# Empty for now — add keys here only if the app literally cannot function without them
}
_CRITICAL_WARN = {
"ADMIN_KEY": "Authentication for /api/settings and /api/system/update — endpoints are UNPROTECTED without it!",
"OPENSKY_CLIENT_ID": "OpenSky Network OAuth2 — REQUIRED for airplane telemetry. Without it the flights layer falls back to ADS-B-only with major gaps in Africa/Asia/LatAm. Free registration at opensky-network.org.",
"OPENSKY_CLIENT_SECRET": "OpenSky Network OAuth2 — REQUIRED for airplane telemetry (paired with OPENSKY_CLIENT_ID).",
}
_OPTIONAL = {
"AIS_API_KEY": "AIS vessel streaming (ships layer will be empty without it)",
"LTA_ACCOUNT_KEY": "Singapore LTA traffic cameras (CCTV layer)",
"PUBLIC_API_KEY": "Optional client auth for public endpoints (recommended for exposed deployments)",
}
_DEFAULT_MQTT_BROKER = "mqtt.meshtastic.org"
_DEFAULT_MQTT_USER = "meshdev"
_DEFAULT_MQTT_PASS = "large4cats"
def _release_attestation_status(snapshot) -> dict[str, str | bool]:
explicit_raw = str(
getattr(snapshot, "MESH_RELEASE_ATTESTATION_PATH", "") or ""
).strip()
manual_flag = bool(
getattr(snapshot, "MESH_RELEASE_DM_RELAY_SECURITY_SUITE_GREEN", False)
)
candidate = Path(explicit_raw) if explicit_raw else _DEFAULT_RELEASE_ATTESTATION_PATH
if not candidate.is_absolute():
candidate = _BACKEND_DIR / candidate
if candidate.exists():
try:
payload = json.loads(candidate.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError("release attestation payload must be an object")
return {
"state": "file_ok",
"path": str(candidate),
"detail": "file-based release attestation present",
"manual_env_active": manual_flag,
}
except Exception as exc:
return {
"state": "file_error",
"path": str(candidate),
"detail": str(exc) or type(exc).__name__,
"manual_env_active": manual_flag,
}
if explicit_raw:
return {
"state": "file_missing",
"path": str(candidate),
"detail": "configured release attestation file is missing",
"manual_env_active": manual_flag,
}
if manual_flag:
return {
"state": "env_only",
"path": str(candidate),
"detail": "manual operator attestation is active without a file-based artifact",
"manual_env_active": manual_flag,
}
return {
"state": "missing",
"path": str(candidate),
"detail": "no release attestation evidence is staged",
"manual_env_active": manual_flag,
}
def _release_attestation_warning(snapshot) -> str:
status = _release_attestation_status(snapshot)
state = str(status.get("state", "") or "").strip()
path = str(status.get("path", "") or "").strip()
if state == "file_error":
return (
"MESH_RELEASE_ATTESTATION_PATH points to an unreadable release attestation "
f"({path}) — authenticated release_gate evidence is broken until CI/release "
"stages a valid JSON artifact."
)
if state == "file_missing":
return (
"MESH_RELEASE_ATTESTATION_PATH is set but the release attestation file is missing "
f"({path}) — authenticated release_gate evidence is blocked until the artifact is restored."
)
if state == "env_only":
return (
"MESH_RELEASE_DM_RELAY_SECURITY_SUITE_GREEN=true without a file-based release attestation "
f"({path}) — authenticated release_gate is relying on a manual operator flag instead of CI/release evidence."
)
if state == "missing":
return (
"No file-based Sprint 8 release attestation is staged "
f"({path}) — authenticated release_gate will stay blocked until CI/release evidence is present."
)
return ""
def validate_mesh_mqtt_psk(value: str) -> str | None:
"""Validate MESH_MQTT_PSK. Returns an error string, or None if valid."""
raw = str(value or "").strip()
if not raw:
return None # empty means use default LongFast key
try:
decoded = bytes.fromhex(raw)
except ValueError:
return "not valid hex"
if len(decoded) not in (16, 32):
return f"decoded length is {len(decoded)} bytes, must be 16 or 32"
return None
def _mqtt_startup_warnings(settings) -> list[str]:
"""Return warnings for risky MQTT broker/credential combinations."""
warnings: list[str] = []
broker = str(getattr(settings, "MESH_MQTT_BROKER", _DEFAULT_MQTT_BROKER) or _DEFAULT_MQTT_BROKER).strip()
user = str(getattr(settings, "MESH_MQTT_USER", _DEFAULT_MQTT_USER) or _DEFAULT_MQTT_USER).strip()
password = str(getattr(settings, "MESH_MQTT_PASS", _DEFAULT_MQTT_PASS) or _DEFAULT_MQTT_PASS).strip()
psk_raw = str(getattr(settings, "MESH_MQTT_PSK", "") or "").strip()
is_custom_broker = broker.lower() != _DEFAULT_MQTT_BROKER.lower()
is_default_creds = (user == _DEFAULT_MQTT_USER and password == _DEFAULT_MQTT_PASS)
is_default_psk = not psk_raw # empty means default LongFast key
if is_custom_broker and is_default_psk:
warnings.append(
f"MESH_MQTT_BROKER={broker} with default public LongFast PSK — "
"traffic on this broker is decryptable by anyone with the firmware default key."
)
if is_custom_broker and is_default_creds:
warnings.append(
f"MESH_MQTT_BROKER={broker} with default public credentials (meshdev/large4cats) — "
"consider using private credentials for a private broker."
)
return warnings
def _invalid_dm_token_pepper_reason(value: str) -> str:
raw = str(value or "").strip()
lowered = raw.lower()
if not raw:
return "empty"
if lowered in {"change-me", "changeme"}:
return "placeholder"
if len(raw) < 16:
return "too short"
return ""
def _invalid_peer_push_secret_reason(value: str) -> str:
raw = str(value or "").strip()
lowered = raw.lower()
if not raw:
return "empty"
if lowered in {"change-me", "changeme"}:
return "placeholder"
if len(raw) < 16:
return "too short"
return ""
_PEPPER_FILE = Path(__file__).resolve().parents[1] / "data" / "dm_token_pepper.key"
def _raw_secure_storage_fallback_requested(snapshot) -> bool:
return os.name != "nt" and bool(
getattr(snapshot, "MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK", False)
)
def _raw_secure_storage_fallback_acknowledged(snapshot) -> bool:
return bool(getattr(snapshot, "MESH_ACK_RAW_FALLBACK_AT_OWN_RISK", False))
def _raw_secure_storage_fallback_missing_ack(snapshot) -> bool:
return _raw_secure_storage_fallback_requested(snapshot) and not _raw_secure_storage_fallback_acknowledged(
snapshot
)
def _ensure_dm_token_pepper(settings) -> str:
token_pepper = str(getattr(settings, "MESH_DM_TOKEN_PEPPER", "") or "").strip()
pepper_reason = _invalid_dm_token_pepper_reason(token_pepper)
if not pepper_reason:
return token_pepper
# Try loading a previously persisted pepper before generating a new one.
try:
from services.mesh.mesh_secure_storage import read_secure_json
stored = read_secure_json(_PEPPER_FILE, lambda: {})
stored_pepper = str(stored.get("pepper", "") or "").strip()
if stored_pepper and not _invalid_dm_token_pepper_reason(stored_pepper):
os.environ["MESH_DM_TOKEN_PEPPER"] = stored_pepper
get_settings.cache_clear()
logger.info("Loaded persisted DM token pepper from %s", _PEPPER_FILE.name)
return stored_pepper
except Exception:
pass
generated = secrets.token_hex(32)
os.environ["MESH_DM_TOKEN_PEPPER"] = generated
get_settings.cache_clear()
log_fn = logger.warning if bool(getattr(settings, "MESH_DEBUG_MODE", False)) else logger.critical
log_fn(
"⚠️ SECURITY: MESH_DM_TOKEN_PEPPER is invalid (%s) — mailbox tokens "
"would be predictably derivable. Auto-generated a random pepper for "
"this session.",
pepper_reason,
)
# Persist so the same pepper survives restarts.
try:
from services.mesh.mesh_secure_storage import write_secure_json
_PEPPER_FILE.parent.mkdir(parents=True, exist_ok=True)
write_secure_json(_PEPPER_FILE, {"pepper": generated, "generated_at": int(time.time())})
logger.info("Persisted auto-generated DM token pepper to %s", _PEPPER_FILE.name)
except Exception:
logger.warning("Could not persist auto-generated DM token pepper to disk — will regenerate on next restart")
return generated
def _peer_push_secret_required(settings) -> bool:
relay_peers = str(getattr(settings, "MESH_RELAY_PEERS", "") or "").strip()
rns_peers = str(getattr(settings, "MESH_RNS_PEERS", "") or "").strip()
return bool(getattr(settings, "MESH_RNS_ENABLED", False) or relay_peers or rns_peers)
def _deprecated_get_security_posture_warnings(settings=None) -> list[str]:
snapshot = settings or get_settings()
warnings: list[str] = []
admin_key = str(getattr(snapshot, "ADMIN_KEY", "") or "").strip()
allow_insecure = bool(getattr(snapshot, "ALLOW_INSECURE_ADMIN", False))
if allow_insecure and not admin_key:
warnings.append(
"ALLOW_INSECURE_ADMIN=true with no ADMIN_KEY leaves admin and Wormhole endpoints unauthenticated."
)
if not bool(getattr(snapshot, "MESH_STRICT_SIGNATURES", True)):
warnings.append(
"MESH_STRICT_SIGNATURES=false is deprecated and ignored; signature enforcement remains mandatory."
)
peer_secret = str(getattr(snapshot, "MESH_PEER_PUSH_SECRET", "") or "").strip()
peer_secret_reason = _invalid_peer_push_secret_reason(peer_secret)
if _peer_push_secret_required(snapshot) and peer_secret_reason:
warnings.append(
"MESH_PEER_PUSH_SECRET is invalid "
f"({peer_secret_reason}) while relay or RNS peers are enabled; private peer authentication, opaque gate forwarding, and voter blinding are not secure-by-default."
)
if _raw_secure_storage_fallback_missing_ack(snapshot):
warnings.append(
"MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK=true without MESH_ACK_RAW_FALLBACK_AT_OWN_RISK=true "
"stores Wormhole keys in raw local files on this platform and should not be used outside development/CI."
)
elif _raw_secure_storage_fallback_requested(snapshot):
warnings.append(
"MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK=true with MESH_ACK_RAW_FALLBACK_AT_OWN_RISK=true "
"stores Wormhole keys in raw local files on this platform."
)
if bool(getattr(snapshot, "MESH_RNS_ENABLED", False)) and int(getattr(snapshot, "MESH_RNS_COVER_INTERVAL_S", 0) or 0) <= 0:
warnings.append(
"MESH_RNS_COVER_INTERVAL_S<=0 disables RNS cover traffic outside high-privacy mode, making quiet-node traffic analysis easier."
)
fallback_requested = private_clearnet_fallback_requested(snapshot)
fallback_effective = private_clearnet_fallback_effective(snapshot)
fallback_ack = bool(getattr(snapshot, "MESH_PRIVATE_CLEARNET_FALLBACK_ACKNOWLEDGE", False))
if fallback_requested == "allow" and not fallback_ack:
warnings.append(
"MESH_PRIVATE_CLEARNET_FALLBACK=allow without MESH_PRIVATE_CLEARNET_FALLBACK_ACKNOWLEDGE=true — "
"private-tier clearnet fallback remains blocked until you explicitly acknowledge the transport downgrade."
)
elif fallback_effective == "allow":
warnings.append(
"MESH_PRIVATE_CLEARNET_FALLBACK=allow with MESH_PRIVATE_CLEARNET_FALLBACK_ACKNOWLEDGE=true — "
"private-tier messages may fall back to clearnet relay when Tor/RNS is unavailable."
)
metadata_persist = bool(getattr(snapshot, "MESH_DM_METADATA_PERSIST", False))
metadata_persist_ack = bool(getattr(snapshot, "MESH_DM_METADATA_PERSIST_ACKNOWLEDGE", False))
binding_ttl = int(getattr(snapshot, "MESH_DM_BINDING_TTL_DAYS", 3) or 3)
if metadata_persist and not metadata_persist_ack:
warnings.append(
"MESH_DM_METADATA_PERSIST=true without MESH_DM_METADATA_PERSIST_ACKNOWLEDGE=true — "
"mailbox binding metadata will remain memory-only until you explicitly acknowledge the at-rest privacy tradeoff."
)
if metadata_persist and metadata_persist_ack and binding_ttl > 7:
warnings.append(
f"MESH_DM_BINDING_TTL_DAYS={binding_ttl} with MESH_DM_METADATA_PERSIST=true — long-lived mailbox binding metadata persists communication graph structure on disk."
)
if bool(getattr(snapshot, "MESH_ALLOW_COMPAT_DM_INVITE_IMPORT", False)):
warnings.append(
"MESH_ALLOW_COMPAT_DM_INVITE_IMPORT=true — legacy/compat v1/v2 DM invites can still import. "
"Prefer re-exporting current attested v3 invites and disable this migration escape hatch after cleanup."
)
if legacy_dm_get_override_active():
warnings.append(
"MESH_ALLOW_LEGACY_DM_GET_UNTIL is active — GET /api/mesh/dm/poll and GET /api/mesh/dm/count remain enabled for migration. "
"Disable it after older clients move to the signed mailbox-claim POST APIs."
)
if legacy_dm_get_override_active():
warnings.append(
"MESH_ALLOW_LEGACY_DM_GET_UNTIL is active — GET /api/mesh/dm/poll and GET /api/mesh/dm/count remain enabled for migration. "
"Disable it after older clients move to the signed mailbox-claim POST APIs."
)
if legacy_dm_get_override_active():
warnings.append(
"MESH_ALLOW_LEGACY_DM_GET_UNTIL is active — GET /api/mesh/dm/poll and GET /api/mesh/dm/count remain enabled for migration. "
"Disable it after older clients move to the signed mailbox-claim POST APIs."
)
if legacy_dm_signature_compat_override_active():
warnings.append(
"MESH_ALLOW_LEGACY_DM_SIGNATURE_COMPAT_UNTIL is active — dm_message still accepts the legacy signature payload. "
"Disable it after migration so modern DM fields stay fully signed."
)
gate_decrypt_requested = bool(getattr(snapshot, "MESH_GATE_BACKEND_DECRYPT_COMPAT", False))
gate_decrypt_ack = bool(getattr(snapshot, "MESH_GATE_BACKEND_DECRYPT_COMPAT_ACKNOWLEDGE", False))
if gate_decrypt_requested or gate_decrypt_ack:
warnings.append(
"MESH_GATE_BACKEND_DECRYPT_COMPAT / MESH_GATE_BACKEND_DECRYPT_COMPAT_ACKNOWLEDGE are deprecated and ignored — ordinary backend MLS gate decrypt stays retired; service-side decrypt is reserved for explicit recovery reads."
)
gate_plaintext_requested = bool(getattr(snapshot, "MESH_GATE_BACKEND_PLAINTEXT_COMPAT", False))
gate_plaintext_ack = bool(getattr(snapshot, "MESH_GATE_BACKEND_PLAINTEXT_COMPAT_ACKNOWLEDGE", False))
if gate_plaintext_requested or gate_plaintext_ack:
warnings.append(
"MESH_GATE_BACKEND_PLAINTEXT_COMPAT / MESH_GATE_BACKEND_PLAINTEXT_COMPAT_ACKNOWLEDGE are deprecated and ignored — ordinary backend gate compose/post stays retired; shipped gate clients keep plaintext local."
)
if bool(getattr(snapshot, "MESH_ALLOW_COMPAT_DM_INVITE_IMPORT", False)):
warnings.append(
"MESH_ALLOW_COMPAT_DM_INVITE_IMPORT=true — legacy/compat v1/v2 DM invites can still import. "
"Prefer re-exporting current attested v3 invites and disable this migration escape hatch after cleanup."
)
if legacy_dm_get_override_active():
warnings.append(
"MESH_ALLOW_LEGACY_DM_GET_UNTIL is active — GET /api/mesh/dm/poll and GET /api/mesh/dm/count remain enabled for migration. "
"Disable it after older clients move to the signed mailbox-claim POST APIs."
)
if legacy_dm_signature_compat_override_active():
warnings.append(
"MESH_ALLOW_LEGACY_DM_SIGNATURE_COMPAT_UNTIL is active — dm_message still accepts the legacy signature payload. "
"Disable it after migration so modern DM fields stay fully signed."
)
if legacy_dm_get_override_active():
warnings.append(
"MESH_ALLOW_LEGACY_DM_GET_UNTIL is active — GET /api/mesh/dm/poll and GET /api/mesh/dm/count remain enabled for migration. "
"Disable it after older clients move to the signed mailbox-claim POST APIs."
)
if legacy_dm_get_override_active():
warnings.append(
"MESH_ALLOW_LEGACY_DM_GET_UNTIL is active — GET /api/mesh/dm/poll and GET /api/mesh/dm/count remain enabled for migration. "
"Disable it after older clients move to the signed mailbox-claim POST APIs."
)
gate_recovery_envelope_requested = bool(getattr(snapshot, "MESH_GATE_RECOVERY_ENVELOPE_ENABLE", False))
gate_recovery_envelope_ack = bool(
getattr(snapshot, "MESH_GATE_RECOVERY_ENVELOPE_ENABLE_ACKNOWLEDGE", False)
)
if gate_recovery_envelope_requested and not gate_recovery_envelope_ack:
warnings.append(
"MESH_GATE_RECOVERY_ENVELOPE_ENABLE=true without MESH_GATE_RECOVERY_ENVELOPE_ENABLE_ACKNOWLEDGE=true — envelope_recovery and envelope_always gates remain disabled until you explicitly acknowledge the recovery-material privacy tradeoff."
)
elif gate_recovery_envelope_effective(snapshot):
warnings.append(
"MESH_GATE_RECOVERY_ENVELOPE_ENABLE=true with MESH_GATE_RECOVERY_ENVELOPE_ENABLE_ACKNOWLEDGE=true — gates configured for envelope_recovery or envelope_always may retain recovery envelopes."
)
gate_recovery_envelope_requested = bool(getattr(snapshot, "MESH_GATE_RECOVERY_ENVELOPE_ENABLE", False))
gate_recovery_envelope_ack = bool(
getattr(snapshot, "MESH_GATE_RECOVERY_ENVELOPE_ENABLE_ACKNOWLEDGE", False)
)
if gate_recovery_envelope_requested and not gate_recovery_envelope_ack:
warnings.append(
"MESH_GATE_RECOVERY_ENVELOPE_ENABLE=true without MESH_GATE_RECOVERY_ENVELOPE_ENABLE_ACKNOWLEDGE=true — envelope_recovery and envelope_always gates remain disabled until you explicitly acknowledge the recovery-material privacy tradeoff."
)
elif gate_recovery_envelope_effective(snapshot):
warnings.append(
"MESH_GATE_RECOVERY_ENVELOPE_ENABLE=true with MESH_GATE_RECOVERY_ENVELOPE_ENABLE_ACKNOWLEDGE=true — gates configured for envelope_recovery or envelope_always may retain recovery envelopes."
)
gate_recovery_envelope_requested = bool(
getattr(snapshot, "MESH_GATE_RECOVERY_ENVELOPE_ENABLE", False)
)
gate_recovery_envelope_ack = bool(
getattr(snapshot, "MESH_GATE_RECOVERY_ENVELOPE_ENABLE_ACKNOWLEDGE", False)
)
if gate_recovery_envelope_requested and not gate_recovery_envelope_ack:
warnings.append(
"MESH_GATE_RECOVERY_ENVELOPE_ENABLE=true without MESH_GATE_RECOVERY_ENVELOPE_ENABLE_ACKNOWLEDGE=true — envelope_recovery and envelope_always gates remain disabled until you explicitly acknowledge the recovery-material privacy tradeoff."
)
elif gate_recovery_envelope_effective(snapshot):
warnings.append(
"MESH_GATE_RECOVERY_ENVELOPE_ENABLE=true with MESH_GATE_RECOVERY_ENVELOPE_ENABLE_ACKNOWLEDGE=true — gates configured for envelope_recovery or envelope_always may retain recovery envelopes."
)
gate_plaintext_persist_requested = bool(getattr(snapshot, "MESH_GATE_PLAINTEXT_PERSIST", False))
gate_plaintext_persist_ack = bool(
getattr(snapshot, "MESH_GATE_PLAINTEXT_PERSIST_ACKNOWLEDGE", False)
)
if gate_plaintext_persist_requested and not gate_plaintext_persist_ack:
warnings.append(
"MESH_GATE_PLAINTEXT_PERSIST=true without MESH_GATE_PLAINTEXT_PERSIST_ACKNOWLEDGE=true — ordinary gate reads keep plaintext local/in-memory until you explicitly acknowledge durable at-rest retention."
)
elif gate_plaintext_persist_effective(snapshot):
warnings.append(
"MESH_GATE_PLAINTEXT_PERSIST=true with MESH_GATE_PLAINTEXT_PERSIST_ACKNOWLEDGE=true — decrypted gate plaintext is retained on disk outside explicit recovery mode."
)
gate_plaintext_persist_requested = bool(getattr(snapshot, "MESH_GATE_PLAINTEXT_PERSIST", False))
gate_plaintext_persist_ack = bool(
getattr(snapshot, "MESH_GATE_PLAINTEXT_PERSIST_ACKNOWLEDGE", False)
)
if gate_plaintext_persist_requested and not gate_plaintext_persist_ack:
warnings.append(
"MESH_GATE_PLAINTEXT_PERSIST=true without MESH_GATE_PLAINTEXT_PERSIST_ACKNOWLEDGE=true — ordinary gate reads keep plaintext local/in-memory until you explicitly acknowledge durable at-rest retention."
)
elif gate_plaintext_persist_effective(snapshot):
warnings.append(
"MESH_GATE_PLAINTEXT_PERSIST=true with MESH_GATE_PLAINTEXT_PERSIST_ACKNOWLEDGE=true — decrypted gate plaintext is retained on disk outside explicit recovery mode."
)
gate_plaintext_persist_requested = bool(getattr(snapshot, "MESH_GATE_PLAINTEXT_PERSIST", False))
gate_plaintext_persist_ack = bool(
getattr(snapshot, "MESH_GATE_PLAINTEXT_PERSIST_ACKNOWLEDGE", False)
)
if gate_plaintext_persist_requested and not gate_plaintext_persist_ack:
warnings.append(
"MESH_GATE_PLAINTEXT_PERSIST=true without MESH_GATE_PLAINTEXT_PERSIST_ACKNOWLEDGE=true — ordinary gate reads keep plaintext local/in-memory until you explicitly acknowledge durable at-rest retention."
)
elif gate_plaintext_persist_effective(snapshot):
warnings.append(
"MESH_GATE_PLAINTEXT_PERSIST=true with MESH_GATE_PLAINTEXT_PERSIST_ACKNOWLEDGE=true — decrypted gate plaintext is retained on disk outside explicit recovery mode."
)
gate_recovery_envelope_requested = bool(
getattr(snapshot, "MESH_GATE_RECOVERY_ENVELOPE_ENABLE", False)
)
gate_recovery_envelope_ack = bool(
getattr(snapshot, "MESH_GATE_RECOVERY_ENVELOPE_ENABLE_ACKNOWLEDGE", False)
)
if gate_recovery_envelope_requested and not gate_recovery_envelope_ack:
warnings.append(
"MESH_GATE_RECOVERY_ENVELOPE_ENABLE=true without MESH_GATE_RECOVERY_ENVELOPE_ENABLE_ACKNOWLEDGE=true — envelope_recovery and envelope_always gates remain disabled until you explicitly acknowledge the recovery-material privacy tradeoff."
)
elif gate_recovery_envelope_effective(snapshot):
warnings.append(
"MESH_GATE_RECOVERY_ENVELOPE_ENABLE=true with MESH_GATE_RECOVERY_ENVELOPE_ENABLE_ACKNOWLEDGE=true — gates configured for envelope_recovery or envelope_always may retain recovery envelopes."
)
release_attestation_warning = _release_attestation_warning(snapshot)
if release_attestation_warning:
warnings.append(release_attestation_warning)
warnings.extend(_mqtt_startup_warnings(snapshot))
return warnings
def _deprecated_audit_security_config(settings) -> None:
"""Audit security-critical config combinations and log loud warnings.
This does not block startup (dev ergonomics), but makes dangerous
settings impossible to miss in the logs.
"""
# ── 1. ALLOW_INSECURE_ADMIN without ADMIN_KEY ─────────────────────
admin_key = (getattr(settings, "ADMIN_KEY", "") or "").strip()
allow_insecure = bool(getattr(settings, "ALLOW_INSECURE_ADMIN", False))
if allow_insecure and not admin_key:
logger.critical(
"🚨 SECURITY: ALLOW_INSECURE_ADMIN=true with no ADMIN_KEY — "
"ALL admin/wormhole endpoints are completely unauthenticated. "
"This is acceptable ONLY for local development. "
"Set ADMIN_KEY for any networked or production deployment."
)
# ── 2. Signature enforcement ──────────────────────────────────────
mesh_strict = bool(getattr(settings, "MESH_STRICT_SIGNATURES", True))
if not mesh_strict:
logger.warning(
"⚠️ CONFIG: MESH_STRICT_SIGNATURES=false is deprecated and ignored — "
"runtime signature enforcement remains mandatory."
)
# ── 3. Empty DM token pepper ──────────────────────────────────────
_ensure_dm_token_pepper(settings)
# ── 4. Peer push secret / private-plane integrity ─────────────────
peer_secret = str(getattr(settings, "MESH_PEER_PUSH_SECRET", "") or "").strip()
peer_secret_reason = _invalid_peer_push_secret_reason(peer_secret)
if _peer_push_secret_required(settings) and peer_secret_reason:
log_fn = logger.warning if bool(getattr(settings, "MESH_DEBUG_MODE", False)) else logger.critical
log_fn(
"⚠️ SECURITY: MESH_PEER_PUSH_SECRET is invalid (%s) while relay or RNS peers are enabled — "
"private peer authentication, opaque gate forwarding, and voter blinding are not secure-by-default until it is set to a non-placeholder secret.",
peer_secret_reason,
)
# ── 5. Raw secure-storage fallback on non-Windows ────────────────
if _raw_secure_storage_fallback_requested(settings):
log_fn = logger.warning if bool(getattr(settings, "MESH_DEBUG_MODE", False)) else logger.critical
if _raw_secure_storage_fallback_missing_ack(settings):
log_fn(
"⚠️ SECURITY: MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK=true without "
"MESH_ACK_RAW_FALLBACK_AT_OWN_RISK=true leaves Wormhole keys in raw local files. "
"Startup should fail closed outside tests until the operator explicitly acknowledges this risk."
)
else:
log_fn(
"⚠️ SECURITY: MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK=true with "
"MESH_ACK_RAW_FALLBACK_AT_OWN_RISK=true leaves Wormhole keys in raw local files. "
"Use this only for development/CI until a stronger local custody provider is configured."
)
# ── 6. Disabled cover traffic outside forced high-privacy mode ─────────
if bool(getattr(settings, "MESH_RNS_ENABLED", False)) and int(getattr(settings, "MESH_RNS_COVER_INTERVAL_S", 0) or 0) <= 0:
logger.warning(
"⚠️ PRIVACY: MESH_RNS_COVER_INTERVAL_S<=0 disables background RNS cover traffic outside high-privacy mode. "
"Quiet nodes become easier to fingerprint by silence and burst timing."
)
# ── 7. Clearnet fallback policy ──────────────────────────────────
fallback_requested = private_clearnet_fallback_requested(settings)
fallback_effective = private_clearnet_fallback_effective(settings)
fallback_ack = bool(getattr(settings, "MESH_PRIVATE_CLEARNET_FALLBACK_ACKNOWLEDGE", False))
if fallback_requested == "allow" and not fallback_ack:
logger.warning(
"⚠️ PRIVACY: MESH_PRIVATE_CLEARNET_FALLBACK=allow without "
"MESH_PRIVATE_CLEARNET_FALLBACK_ACKNOWLEDGE=true — private-tier clearnet fallback remains blocked "
"until you explicitly acknowledge the transport downgrade."
)
elif fallback_effective == "allow":
logger.warning(
"⚠️ PRIVACY: MESH_PRIVATE_CLEARNET_FALLBACK=allow with "
"MESH_PRIVATE_CLEARNET_FALLBACK_ACKNOWLEDGE=true — private-tier messages will fall "
"back to clearnet relay when Tor/RNS is unavailable. Set to 'block' for safer defaults."
)
# ── 8. MQTT broker / credential / PSK mismatch warnings ──────────
if bool(getattr(settings, "MESH_ALLOW_COMPAT_DM_INVITE_IMPORT", False)):
logger.warning(
"⚠️ TRUST: MESH_ALLOW_COMPAT_DM_INVITE_IMPORT=true allows importing weaker legacy/compat v1/v2 DM invites. "
"Re-export attested v3 invites and disable this migration escape hatch after cleanup."
)
if legacy_dm_signature_compat_override_active():
logger.warning(
"⚠️ TRUST: MESH_ALLOW_LEGACY_DM_SIGNATURE_COMPAT_UNTIL is active and keeps dm_message legacy signature compatibility enabled. "
"Disable it after migration so modern DM fields stay fully signed."
)
gate_decrypt_requested = bool(getattr(settings, "MESH_GATE_BACKEND_DECRYPT_COMPAT", False))
gate_decrypt_ack = bool(getattr(settings, "MESH_GATE_BACKEND_DECRYPT_COMPAT_ACKNOWLEDGE", False))
if gate_decrypt_requested or gate_decrypt_ack:
logger.warning(
"⚠️ PRIVACY: MESH_GATE_BACKEND_DECRYPT_COMPAT* is deprecated and ignored — ordinary backend MLS "
"gate decrypt stays retired; service-side decrypt is reserved for explicit recovery reads."
)
gate_decrypt_requested = False
gate_decrypt_ack = False
gate_decrypt_effective = backend_gate_decrypt_compat_effective(settings)
if gate_decrypt_requested and not gate_decrypt_ack:
logger.warning(
"⚠️ PRIVACY: MESH_GATE_BACKEND_DECRYPT_COMPAT=true without "
"MESH_GATE_BACKEND_DECRYPT_COMPAT_ACKNOWLEDGE=true — ordinary backend MLS gate decrypt remains blocked "
"until you explicitly acknowledge the operator-visible compatibility path."
)
elif gate_decrypt_effective:
logger.warning(
"⚠️ PRIVACY: MESH_GATE_BACKEND_DECRYPT_COMPAT=true — non-native runtimes may request service-side "
"MLS gate decrypt, which weakens operator-resistance on that lane."
)
gate_plaintext_requested = bool(getattr(settings, "MESH_GATE_BACKEND_PLAINTEXT_COMPAT", False))
gate_plaintext_ack = bool(getattr(settings, "MESH_GATE_BACKEND_PLAINTEXT_COMPAT_ACKNOWLEDGE", False))
if gate_plaintext_requested or gate_plaintext_ack:
logger.warning(
"⚠️ PRIVACY: MESH_GATE_BACKEND_PLAINTEXT_COMPAT* is deprecated and ignored — ordinary backend gate "
"compose/post stays retired; shipped gate clients keep plaintext local."
)
gate_plaintext_requested = False
gate_plaintext_ack = False
gate_plaintext_effective = backend_gate_plaintext_compat_effective(settings)
if gate_plaintext_requested and not gate_plaintext_ack:
logger.warning(
"⚠️ PRIVACY: MESH_GATE_BACKEND_PLAINTEXT_COMPAT=true without "
"MESH_GATE_BACKEND_PLAINTEXT_COMPAT_ACKNOWLEDGE=true — ordinary backend gate compose/post remains blocked "
"until you explicitly acknowledge the plaintext compatibility path."
)
elif gate_plaintext_effective:
logger.warning(
"⚠️ PRIVACY: MESH_GATE_BACKEND_PLAINTEXT_COMPAT=true — non-native runtimes may submit gate plaintext "
"to the backend for compose/post, which weakens operator-resistance on that lane."
)
for w in _mqtt_startup_warnings(settings):
logger.warning("⚠️ MQTT: %s", w)
def _get_security_posture_warnings_legacy(settings=None) -> list[str]:
"""Return user-facing security posture warnings for current config."""
snapshot = settings or get_settings()
warnings: list[str] = []
admin_key = str(getattr(snapshot, "ADMIN_KEY", "") or "").strip()
allow_insecure = bool(getattr(snapshot, "ALLOW_INSECURE_ADMIN", False))
if allow_insecure and not admin_key:
warnings.append(
"ALLOW_INSECURE_ADMIN=true with no ADMIN_KEY leaves admin and Wormhole endpoints unauthenticated."
)
if not bool(getattr(snapshot, "MESH_STRICT_SIGNATURES", True)):
warnings.append(
"MESH_STRICT_SIGNATURES=false is deprecated and ignored; signature enforcement remains mandatory."
)
peer_secret = str(getattr(snapshot, "MESH_PEER_PUSH_SECRET", "") or "").strip()
peer_secret_reason = _invalid_peer_push_secret_reason(peer_secret)
if _peer_push_secret_required(snapshot) and peer_secret_reason:
warnings.append(
"MESH_PEER_PUSH_SECRET is invalid "
f"({peer_secret_reason}) while relay or RNS peers are enabled; private peer authentication, opaque gate forwarding, and voter blinding are not secure-by-default."
)
if _raw_secure_storage_fallback_missing_ack(snapshot):
warnings.append(
"MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK=true without MESH_ACK_RAW_FALLBACK_AT_OWN_RISK=true "
"stores Wormhole keys in raw local files on this platform and should not be used outside development/CI."
)
elif _raw_secure_storage_fallback_requested(snapshot):
warnings.append(
"MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK=true with MESH_ACK_RAW_FALLBACK_AT_OWN_RISK=true "
"stores Wormhole keys in raw local files on this platform."
)
if os.name != "nt" and not str(getattr(snapshot, "MESH_SECURE_STORAGE_SECRET", "") or "").strip():
if not bool(getattr(snapshot, "MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK", False)):
warnings.append(
"MESH_SECURE_STORAGE_SECRET is not set on non-Windows — Wormhole secure storage will fail closed. "
"Set MESH_SECURE_STORAGE_SECRET (or MESH_SECURE_STORAGE_SECRET_FILE for Docker secrets) to enable at-rest key protection."
)
if bool(getattr(snapshot, "MESH_RNS_ENABLED", False)) and int(getattr(snapshot, "MESH_RNS_COVER_INTERVAL_S", 0) or 0) <= 0:
warnings.append(
"MESH_RNS_COVER_INTERVAL_S<=0 disables RNS cover traffic outside high-privacy mode, making quiet-node traffic analysis easier."
)
fallback_requested = private_clearnet_fallback_requested(snapshot)
fallback_effective = private_clearnet_fallback_effective(snapshot)
fallback_ack = bool(getattr(snapshot, "MESH_PRIVATE_CLEARNET_FALLBACK_ACKNOWLEDGE", False))
if fallback_requested == "allow" and not fallback_ack:
warnings.append(
"MESH_PRIVATE_CLEARNET_FALLBACK=allow without MESH_PRIVATE_CLEARNET_FALLBACK_ACKNOWLEDGE=true — "
"private-tier clearnet fallback remains blocked until you explicitly acknowledge the transport downgrade."
)
elif fallback_effective == "allow":
warnings.append(
"MESH_PRIVATE_CLEARNET_FALLBACK=allow with MESH_PRIVATE_CLEARNET_FALLBACK_ACKNOWLEDGE=true — "
"private-tier messages may fall back to clearnet relay when Tor/RNS is unavailable."
)
metadata_persist = bool(getattr(snapshot, "MESH_DM_METADATA_PERSIST", False))
metadata_persist_ack = bool(getattr(snapshot, "MESH_DM_METADATA_PERSIST_ACKNOWLEDGE", False))
binding_ttl = int(getattr(snapshot, "MESH_DM_BINDING_TTL_DAYS", 3) or 3)
if metadata_persist and not metadata_persist_ack:
warnings.append(
"MESH_DM_METADATA_PERSIST=true without MESH_DM_METADATA_PERSIST_ACKNOWLEDGE=true — mailbox binding metadata will remain memory-only until you explicitly acknowledge the at-rest privacy tradeoff."
)
if metadata_persist and metadata_persist_ack:
warnings.append(
"MESH_DM_METADATA_PERSIST=true — DM request/self mailbox binding metadata will be written to disk for restart continuity."
)
if metadata_persist and metadata_persist_ack and binding_ttl > 7:
warnings.append(
f"MESH_DM_BINDING_TTL_DAYS={binding_ttl} with MESH_DM_METADATA_PERSIST=true — long-lived mailbox binding metadata persists communication graph structure on disk."
)
if bool(getattr(snapshot, "MESH_ALLOW_COMPAT_DM_INVITE_IMPORT", False)):
warnings.append(
"MESH_ALLOW_COMPAT_DM_INVITE_IMPORT=true — legacy/compat v1/v2 DM invites can still import. "
"Prefer re-exporting current attested v3 invites and disable this migration escape hatch after cleanup."
)
if legacy_dm_signature_compat_override_active():
warnings.append(
"MESH_ALLOW_LEGACY_DM_SIGNATURE_COMPAT_UNTIL is active — dm_message still accepts the legacy signature payload. "
"Disable it after migration so modern DM fields stay fully signed."
)
gate_plaintext_persist_requested = bool(getattr(snapshot, "MESH_GATE_PLAINTEXT_PERSIST", False))
gate_plaintext_persist_ack = bool(
getattr(snapshot, "MESH_GATE_PLAINTEXT_PERSIST_ACKNOWLEDGE", False)
)
if gate_plaintext_persist_requested and not gate_plaintext_persist_ack:
warnings.append(
"MESH_GATE_PLAINTEXT_PERSIST=true without MESH_GATE_PLAINTEXT_PERSIST_ACKNOWLEDGE=true — ordinary gate reads keep plaintext local/in-memory until you explicitly acknowledge durable at-rest retention."
)
elif gate_plaintext_persist_effective(snapshot):
warnings.append(
"MESH_GATE_PLAINTEXT_PERSIST=true with MESH_GATE_PLAINTEXT_PERSIST_ACKNOWLEDGE=true — decrypted gate plaintext is retained on disk outside explicit recovery mode."
)
release_attestation_warning = _release_attestation_warning(snapshot)
if release_attestation_warning:
warnings.append(release_attestation_warning)
warnings.extend(_mqtt_startup_warnings(snapshot))
return warnings
def get_security_posture_warnings(settings=None) -> list[str]:
"""Return user-facing security posture warnings for current config."""
snapshot = settings or get_settings()
warnings: list[str] = []
release_profile = profile_readiness_snapshot(snapshot)
profile_name = str(release_profile.get("profile", "dev") or "dev")
for blocker in list(release_profile.get("blockers") or []):
warnings.append(
f"MESH_RELEASE_PROFILE={profile_name} blocks private/release claims: {blocker}."
)
admin_key = str(getattr(snapshot, "ADMIN_KEY", "") or "").strip()
allow_insecure = bool(getattr(snapshot, "ALLOW_INSECURE_ADMIN", False))
if allow_insecure and not admin_key:
warnings.append(
"ALLOW_INSECURE_ADMIN=true with no ADMIN_KEY leaves admin and Wormhole endpoints unauthenticated."
)
if not bool(getattr(snapshot, "MESH_STRICT_SIGNATURES", True)):
warnings.append(
"MESH_STRICT_SIGNATURES=false is deprecated and ignored; signature enforcement remains mandatory."
)
peer_secret = str(getattr(snapshot, "MESH_PEER_PUSH_SECRET", "") or "").strip()
peer_secret_reason = _invalid_peer_push_secret_reason(peer_secret)
if _peer_push_secret_required(snapshot) and peer_secret_reason:
warnings.append(
"MESH_PEER_PUSH_SECRET is invalid "
f"({peer_secret_reason}) while relay or RNS peers are enabled; private peer authentication, opaque gate forwarding, and voter blinding are not secure-by-default."
)
if _raw_secure_storage_fallback_missing_ack(snapshot):
warnings.append(
"MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK=true without MESH_ACK_RAW_FALLBACK_AT_OWN_RISK=true "
"stores Wormhole keys in raw local files on this platform and should not be used outside development/CI."
)
elif _raw_secure_storage_fallback_requested(snapshot):
warnings.append(
"MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK=true with MESH_ACK_RAW_FALLBACK_AT_OWN_RISK=true "
"stores Wormhole keys in raw local files on this platform."
)
if os.name != "nt" and not str(getattr(snapshot, "MESH_SECURE_STORAGE_SECRET", "") or "").strip():
if not bool(getattr(snapshot, "MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK", False)):
warnings.append(
"MESH_SECURE_STORAGE_SECRET is not set on non-Windows — Wormhole secure storage will fail closed. "
"Set MESH_SECURE_STORAGE_SECRET (or MESH_SECURE_STORAGE_SECRET_FILE for Docker secrets) to enable at-rest key protection."
)
if bool(getattr(snapshot, "MESH_RNS_ENABLED", False)) and int(getattr(snapshot, "MESH_RNS_COVER_INTERVAL_S", 0) or 0) <= 0:
warnings.append(
"MESH_RNS_COVER_INTERVAL_S<=0 disables RNS cover traffic outside high-privacy mode, making quiet-node traffic analysis easier."
)
fallback_requested = private_clearnet_fallback_requested(snapshot)
fallback_effective = private_clearnet_fallback_effective(snapshot)
fallback_ack = bool(getattr(snapshot, "MESH_PRIVATE_CLEARNET_FALLBACK_ACKNOWLEDGE", False))
if fallback_requested == "allow" and not fallback_ack:
warnings.append(
"MESH_PRIVATE_CLEARNET_FALLBACK=allow without MESH_PRIVATE_CLEARNET_FALLBACK_ACKNOWLEDGE=true — "
"private-tier clearnet fallback remains blocked until you explicitly acknowledge the transport downgrade."
)
elif fallback_effective == "allow":
warnings.append(
"MESH_PRIVATE_CLEARNET_FALLBACK=allow with MESH_PRIVATE_CLEARNET_FALLBACK_ACKNOWLEDGE=true — "
"private-tier messages may fall back to clearnet relay when Tor/RNS is unavailable."
)
metadata_persist = bool(getattr(snapshot, "MESH_DM_METADATA_PERSIST", False))
metadata_persist_ack = bool(getattr(snapshot, "MESH_DM_METADATA_PERSIST_ACKNOWLEDGE", False))
binding_ttl = int(getattr(snapshot, "MESH_DM_BINDING_TTL_DAYS", 3) or 3)
if metadata_persist and not metadata_persist_ack:
warnings.append(
"MESH_DM_METADATA_PERSIST=true without MESH_DM_METADATA_PERSIST_ACKNOWLEDGE=true — mailbox binding metadata will remain memory-only until you explicitly acknowledge the at-rest privacy tradeoff."
)
if metadata_persist and metadata_persist_ack:
warnings.append(
"MESH_DM_METADATA_PERSIST=true — DM request/self mailbox binding metadata will be written to disk for restart continuity."
)
if metadata_persist and metadata_persist_ack and binding_ttl > 7:
warnings.append(
f"MESH_DM_BINDING_TTL_DAYS={binding_ttl} with MESH_DM_METADATA_PERSIST=true — long-lived mailbox binding metadata persists communication graph structure on disk."
)
if compat_dm_invite_import_override_active():
warnings.append(
"MESH_ALLOW_COMPAT_DM_INVITE_IMPORT_UNTIL is active — legacy/compat v1/v2 DM invites can still import. "
"Prefer re-exporting current attested v3 invites and disable this migration escape hatch after cleanup."
)
if legacy_dm_get_override_active():
warnings.append(
"MESH_ALLOW_LEGACY_DM_GET_UNTIL is active — GET /api/mesh/dm/poll and GET /api/mesh/dm/count remain enabled for migration. "
"Disable it after clients leave the legacy pull path."
)
if legacy_dm_signature_compat_override_active():
warnings.append(
"MESH_ALLOW_LEGACY_DM_SIGNATURE_COMPAT_UNTIL is active — dm_message still accepts the legacy signature payload. "
"Disable it after migration so modern DM fields stay fully signed."
)
if legacy_dm1_override_active():
warnings.append(
"MESH_ALLOW_LEGACY_DM1_UNTIL is active — raw dm1 compose/decrypt remains enabled for migration. "
"Disable it after peers move to MLS."
)
gate_recovery_envelope_requested = bool(
getattr(snapshot, "MESH_GATE_RECOVERY_ENVELOPE_ENABLE", False)
)
gate_recovery_envelope_ack = bool(
getattr(snapshot, "MESH_GATE_RECOVERY_ENVELOPE_ENABLE_ACKNOWLEDGE", False)
)
if gate_recovery_envelope_requested and not gate_recovery_envelope_ack:
warnings.append(
"MESH_GATE_RECOVERY_ENVELOPE_ENABLE=true without MESH_GATE_RECOVERY_ENVELOPE_ENABLE_ACKNOWLEDGE=true — envelope_recovery and envelope_always gates remain disabled until you explicitly acknowledge the recovery-material privacy tradeoff."
)
elif gate_recovery_envelope_effective(snapshot):
warnings.append(
"MESH_GATE_RECOVERY_ENVELOPE_ENABLE=true with MESH_GATE_RECOVERY_ENVELOPE_ENABLE_ACKNOWLEDGE=true — gates configured for envelope_recovery or envelope_always may retain recovery envelopes."
)
gate_plaintext_persist_requested = bool(getattr(snapshot, "MESH_GATE_PLAINTEXT_PERSIST", False))
gate_plaintext_persist_ack = bool(
getattr(snapshot, "MESH_GATE_PLAINTEXT_PERSIST_ACKNOWLEDGE", False)
)
if gate_plaintext_persist_requested and not gate_plaintext_persist_ack:
warnings.append(
"MESH_GATE_PLAINTEXT_PERSIST=true without MESH_GATE_PLAINTEXT_PERSIST_ACKNOWLEDGE=true — ordinary gate reads keep plaintext local/in-memory until you explicitly acknowledge durable at-rest retention."
)
elif gate_plaintext_persist_effective(snapshot):
warnings.append(
"MESH_GATE_PLAINTEXT_PERSIST=true with MESH_GATE_PLAINTEXT_PERSIST_ACKNOWLEDGE=true — decrypted gate plaintext is retained on disk outside explicit recovery mode."
)
release_attestation_warning = _release_attestation_warning(snapshot)
if release_attestation_warning:
warnings.append(release_attestation_warning)
warnings.extend(_mqtt_startup_warnings(snapshot))
return warnings
def _audit_security_config(settings) -> None:
"""Audit security-critical config combinations and log loud warnings."""
release_profile = profile_readiness_snapshot(settings)
profile_name = str(release_profile.get("profile", "dev") or "dev")
for blocker in list(release_profile.get("blockers") or []):
logger.critical(
"RELEASE PROFILE: MESH_RELEASE_PROFILE=%s is blocked by unsafe default: %s",
profile_name,
blocker,
)
admin_key = (getattr(settings, "ADMIN_KEY", "") or "").strip()
allow_insecure = bool(getattr(settings, "ALLOW_INSECURE_ADMIN", False))
if allow_insecure and not admin_key:
logger.critical(
"🚨 SECURITY: ALLOW_INSECURE_ADMIN=true with no ADMIN_KEY — "
"ALL admin/wormhole endpoints are completely unauthenticated. "
"This is acceptable ONLY for local development. "
"Set ADMIN_KEY for any networked or production deployment."
)
mesh_strict = bool(getattr(settings, "MESH_STRICT_SIGNATURES", True))
if not mesh_strict:
logger.warning(
"⚠️ CONFIG: MESH_STRICT_SIGNATURES=false is deprecated and ignored — "
"runtime signature enforcement remains mandatory."
)
_ensure_dm_token_pepper(settings)
peer_secret = str(getattr(settings, "MESH_PEER_PUSH_SECRET", "") or "").strip()
peer_secret_reason = _invalid_peer_push_secret_reason(peer_secret)
if _peer_push_secret_required(settings) and peer_secret_reason:
log_fn = logger.warning if bool(getattr(settings, "MESH_DEBUG_MODE", False)) else logger.critical
log_fn(
"⚠️ SECURITY: MESH_PEER_PUSH_SECRET is invalid (%s) while relay or RNS peers are enabled — "
"private peer authentication, opaque gate forwarding, and voter blinding are not secure-by-default until it is set to a non-placeholder secret.",
peer_secret_reason,
)
if _raw_secure_storage_fallback_requested(settings):
log_fn = logger.warning if bool(getattr(settings, "MESH_DEBUG_MODE", False)) else logger.critical
if _raw_secure_storage_fallback_missing_ack(settings):
log_fn(
"⚠️ SECURITY: MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK=true without "
"MESH_ACK_RAW_FALLBACK_AT_OWN_RISK=true leaves Wormhole keys in raw local files. "
"Startup should fail closed outside tests until the operator explicitly acknowledges this risk."
)
else:
log_fn(
"⚠️ SECURITY: MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK=true with "
"MESH_ACK_RAW_FALLBACK_AT_OWN_RISK=true leaves Wormhole keys in raw local files. "
"Use this only for development/CI. Set MESH_SECURE_STORAGE_SECRET for production."
)
if os.name != "nt" and not str(getattr(settings, "MESH_SECURE_STORAGE_SECRET", "") or "").strip():
if not bool(getattr(settings, "MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK", False)):
log_fn = logger.warning if bool(getattr(settings, "MESH_DEBUG_MODE", False)) else logger.critical
log_fn(
"⚠️ SECURITY: MESH_SECURE_STORAGE_SECRET is not set on non-Windows — "
"Wormhole secure storage will fail closed. Set MESH_SECURE_STORAGE_SECRET "
"(or MESH_SECURE_STORAGE_SECRET_FILE for Docker secrets) to enable at-rest key protection."
)
if bool(getattr(settings, "MESH_RNS_ENABLED", False)) and int(getattr(settings, "MESH_RNS_COVER_INTERVAL_S", 0) or 0) <= 0:
logger.warning(
"⚠️ PRIVACY: MESH_RNS_COVER_INTERVAL_S<=0 disables background RNS cover traffic outside high-privacy mode. "
"Quiet nodes become easier to fingerprint by silence and burst timing."
)
fallback_requested = private_clearnet_fallback_requested(settings)
fallback_effective = private_clearnet_fallback_effective(settings)
fallback_ack = bool(getattr(settings, "MESH_PRIVATE_CLEARNET_FALLBACK_ACKNOWLEDGE", False))
if fallback_requested == "allow" and not fallback_ack:
logger.warning(
"⚠️ PRIVACY: MESH_PRIVATE_CLEARNET_FALLBACK=allow without "
"MESH_PRIVATE_CLEARNET_FALLBACK_ACKNOWLEDGE=true — private-tier clearnet fallback remains blocked "
"until you explicitly acknowledge the transport downgrade."
)
elif fallback_effective == "allow":
logger.warning(
"⚠️ PRIVACY: MESH_PRIVATE_CLEARNET_FALLBACK=allow with "
"MESH_PRIVATE_CLEARNET_FALLBACK_ACKNOWLEDGE=true — private-tier messages will fall "
"back to clearnet relay when Tor/RNS is unavailable. Set to 'block' for safer defaults."
)
metadata_persist = bool(getattr(settings, "MESH_DM_METADATA_PERSIST", False))
metadata_persist_ack = bool(getattr(settings, "MESH_DM_METADATA_PERSIST_ACKNOWLEDGE", False))
binding_ttl = int(getattr(settings, "MESH_DM_BINDING_TTL_DAYS", 3) or 3)
if metadata_persist and not metadata_persist_ack:
logger.warning(
"⚠️ PRIVACY: MESH_DM_METADATA_PERSIST=true without MESH_DM_METADATA_PERSIST_ACKNOWLEDGE=true — "
"mailbox binding metadata will remain memory-only until you explicitly acknowledge the at-rest privacy tradeoff."
)
if metadata_persist and metadata_persist_ack:
logger.warning(
"⚠️ PRIVACY: MESH_DM_METADATA_PERSIST=true — DM request/self mailbox binding metadata "
"will be written to disk for restart continuity. Leave this off unless you explicitly need it."
)
if metadata_persist and metadata_persist_ack and binding_ttl > 7:
logger.warning(
"⚠️ PRIVACY: MESH_DM_BINDING_TTL_DAYS=%s with MESH_DM_METADATA_PERSIST=true — long-lived "
"mailbox binding metadata persists communication graph structure on disk.",
binding_ttl,
)
for w in _mqtt_startup_warnings(settings):
logger.warning("⚠️ MQTT: %s", w)
if bool(getattr(settings, "MESH_ALLOW_COMPAT_DM_INVITE_IMPORT", False)):
logger.warning(
"⚠️ TRUST: MESH_ALLOW_COMPAT_DM_INVITE_IMPORT=true allows importing weaker legacy/compat v1/v2 DM invites. "
"Re-export attested v3 invites and disable this migration escape hatch after cleanup."
)
if legacy_dm_signature_compat_override_active():
logger.warning(
"⚠️ TRUST: MESH_ALLOW_LEGACY_DM_SIGNATURE_COMPAT_UNTIL is active and keeps dm_message legacy signature compatibility enabled. "
"Disable it after migration so modern DM fields stay fully signed."
)
release_attestation_warning = _release_attestation_warning(settings)
if release_attestation_warning:
logger.warning("⚠️ RELEASE: %s", release_attestation_warning)
def validate_env(*, strict: bool = True) -> bool:
"""Validate environment variables at startup.
Args:
strict: If True, exit the process on missing required keys.
If False, only log errors (useful for tests).
Returns:
True if all required keys are present, False otherwise.
"""
all_ok = True
settings = get_settings()
# Required keys — must be set
for key, desc in _REQUIRED.items():
value = getattr(settings, key, "")
if isinstance(value, str):
value = value.strip()
if not value:
logger.error(
"❌ REQUIRED env var %s is not set. %s\n"
" Set it in .env or via Docker secrets (%s_FILE).",
key,
desc,
key,
)
all_ok = False
if not all_ok and strict:
logger.critical("Startup aborted — required environment variables are missing.")
sys.exit(1)
# Critical-warn keys — app works but security/functionality is degraded
for key, desc in _CRITICAL_WARN.items():
value = getattr(settings, key, "")
if isinstance(value, str):
value = value.strip()
if not value:
allow_insecure = bool(getattr(settings, "ALLOW_INSECURE_ADMIN", False))
if key == "ADMIN_KEY" and allow_insecure:
logger.critical(
"🔓 CRITICAL: %s is not set and ALLOW_INSECURE_ADMIN=True — "
"admin endpoints are open without authentication. %s",
key,
desc,
)
else:
logger.warning(
"⚠️ %s is not set — %s",
key,
desc,
)
# Optional keys — warn if missing
for key, desc in _OPTIONAL.items():
value = getattr(settings, key, "")
if isinstance(value, str):
value = value.strip()
if not value:
logger.warning("⚠️ Optional env var %s is not set — %s", key, desc)
# ── MESH_MQTT_PSK validation (fatal) ────────────────────────────
psk_error = validate_mesh_mqtt_psk(str(getattr(settings, "MESH_MQTT_PSK", "") or ""))
if psk_error:
logger.error(
"❌ MESH_MQTT_PSK is invalid: %s. "
"Must be a hex string that decodes to exactly 16 or 32 bytes, or empty for the default LongFast key.",
psk_error,
)
all_ok = False
if strict:
logger.critical("Startup aborted — MESH_MQTT_PSK validation failed.")
sys.exit(1)
# ── MESH_PEER_PUSH_SECRET with peers configured (fatal in strict) ──
if _peer_push_secret_required(settings):
peer_reason = _invalid_peer_push_secret_reason(
str(getattr(settings, "MESH_PEER_PUSH_SECRET", "") or "")
)
if peer_reason:
logger.error(
"❌ MESH_PEER_PUSH_SECRET is invalid (%s) while relay or RNS "
"peers are configured. Private peer authentication requires "
"a valid secret (at least 16 non-placeholder characters).",
peer_reason,
)
all_ok = False
if strict:
logger.critical(
"Startup aborted — MESH_PEER_PUSH_SECRET is required "
"when MESH_RELAY_PEERS or MESH_RNS_PEERS are configured."
)
sys.exit(1)
# ── Security posture audit ────────────────────────────────────────
if _raw_secure_storage_fallback_missing_ack(settings):
logger.error(
"? MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK=true without "
"MESH_ACK_RAW_FALLBACK_AT_OWN_RISK=true leaves Wormhole keys in raw local files "
"on this platform. Add the explicit acknowledgement only for development/CI, or "
"configure MESH_SECURE_STORAGE_SECRET for protected local custody."
)
all_ok = False
if strict:
logger.critical(
"Startup aborted ? raw secure-storage fallback requires "
"MESH_ACK_RAW_FALLBACK_AT_OWN_RISK=true on non-Windows platforms."
)
sys.exit(1)
release_profile = profile_readiness_snapshot(settings)
profile_blockers = list(release_profile.get("blockers") or [])
if profile_blockers:
logger.error(
"MESH_RELEASE_PROFILE=%s is blocked by unsafe defaults: %s",
release_profile.get("profile", "dev"),
", ".join(str(item) for item in profile_blockers),
)
all_ok = False
if strict and str(release_profile.get("profile", "dev")) == "release-candidate":
logger.critical(
"Startup aborted - release-candidate profile cannot boot with unsafe defaults."
)
sys.exit(1)
_audit_security_config(settings)
if all_ok:
logger.info("✅ Environment validation passed.")
return all_ok