mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-08 02:16:41 +02:00
1159 lines
58 KiB
Python
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
|