mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-09-18 07:02:20 +02:00
Review follow-up: - Drop the Next.js route. CARTO_API_KEY is now a regular backend registry key (env, .env, or the API Keys panel) served by public GET /api/basemap-config. Every frontend mode already proxies /api/* to the backend (Next.js proxy in web mode, companion server in packaged desktop), so this covers web and desktop with one mechanism and leaves the static export untouched. Also removes the invalid non-handler export from the route module by removing the module. - useBasemapConfig: fail open to the unkeyed style after 3 s, abort the request at 15 s, apply a late key when it arrives, cache successes per page and retry failures on the next mount. - Declare OSM/CARTO attribution on the raster source (same markup as the viewer's existing AttributionControl so MapLibre de-duplicates it). - Tests: backend endpoint (unset / set+trimmed / persisted operator key / registry), hook behaviour (success, non-OK, network error, soft timeout then late key, hard abort, shared request and retry), attribution and gating source checks. - CARTO_API_KEY moves to the backend service in docker-compose.yml; docs updated accordingly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1162 lines
58 KiB
Python
1162 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)",
|
|
"AISHUB_USERNAME": "AISHub REST backup when AISStream is silent (optional; free at aishub.net/api)",
|
|
"GFW_API_TOKEN": "Global Fishing Watch fishing-vessel activity (fishing_activity layer)",
|
|
"LTA_ACCOUNT_KEY": "Singapore LTA traffic cameras (CCTV layer)",
|
|
"CARTO_API_KEY": "CARTO basemap tiles (DEFAULT map shows an API KEY REQUIRED watermark without it)",
|
|
"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
|