Files
Shadowbroker/backend/.env.example
T
Shadowbroker e36d1fc79c [security] Close tg12 audit issues #201–#214 seamlessly (#261)
External security audit by @tg12 (May 17, 2026) filed issues #201–#214
in addition to the #189–#200 batch already closed by PRs #227/#232/#260.
This PR closes all eight that are real security bugs (the other six in
the 201–214 range are either design discussions or upstream-abuse/TOS
concerns we're keeping intentional, see issue triage notes on each).

The user-facing principle for this PR: fix the security gap WITHOUT
introducing a single hostile error or behavior change for legitimate
users. Every fix follows the same template — fail forward, not loud.
When the secure path is harder than the insecure one, build a
fallback chain that ends in graceful degradation, not in a scary
modal or 422 response.

  #205 — OpenMHZ audio redirect SSRF (services/radio_intercept.py)

  Replaced requests.get(..., allow_redirects=True) with a manual
  redirect loop that re-validates each hop's host against
  _OPENMHZ_AUDIO_HOSTS. Same-host redirects (CDN edge selection)
  still work, so legitimate audio playback is unaffected. Cross-host
  redirects to disallowed hosts return a generic 502 which the
  browser audio element handles gracefully. Cap at 5 hops.

  #207 — infonet/status verify_signatures DoS (routers/mesh_public.py)

  Silently downgrade verify_signatures=true to False for
  unauthenticated callers. No error surfaced — the response shape is
  identical, just without the O(n_events) signature verification.
  Authenticated callers (scoped mesh.audit) still get the full path.
  The frontend never passes this param so legitimate UI is unaffected.

  #211 — thermal/verify expensive analysis (routers/sigint.py)

  Added Depends(require_local_operator). Frontend has no direct
  callers (verified by grep); Tauri/AI agents use scoped tokens that
  pass the auth check. Anonymous abusers blocked silently — the
  legitimate UI keeps working through the Next.js admin-key proxy.

  #213, #214 — OpenMHZ calls/audio upstream abuse (routers/radio.py)

  Added Depends(require_local_operator) to both. Browser users hit
  these through the Next.js proxy at src/app/api/[...path]/route.ts
  which injects X-Admin-Key, so the auth check passes transparently.
  Direct attackers can no longer rotate sys_names to hammer
  api.openmhz.com or relay arbitrary audio streams through the
  backend's bandwidth.

  #202 — overflights unbounded hours (routers/data.py)

  Silently clamp `hours` to OVERFLIGHTS_MAX_HOURS (default 72,
  configurable). NO 422 — clients asking for an absurd window get a
  shorter window back with `requested_hours` and `effective_hours`
  hint fields. Postel's law: liberal in what we accept, conservative
  in what we compute.

  #203 — Meshtastic callsign UA leak (services/fetchers/meshtastic_map.py)

  Added MESHTASTIC_SEND_CALLSIGN_HEADER opt-out env var. Default is
  TRUE — preserves existing operator behavior (callsign sent so
  meshtastic.org can rate-limit per-install). Privacy-conscious
  operators set it to false to suppress.

  #206 — KiwiSDR upstream is HTTP-only (services/kiwisdr_fetcher.py)

  Upstream rx.linkfanel.net doesn't speak HTTPS (verified — Apache
  2.4.10 only on port 80). We can't fix the transport. Instead added
  three layers:
    1. Content validation on fetched data — reject responses with
       <50 receivers or >5% malformed entries (likely MITM injection).
    2. Existing disk cache fallback (already present).
    3. NEW: bundled static directory at backend/data/kiwisdr_directory.json
       shipping 798 known-good receivers. Used as last resort so the
       KiwiSDR map layer always renders something useful.

  #208 — Merkle proof DoS via /api/mesh/infonet/sync (services/mesh/mesh_hashchain.py)

  The endpoint is part of the cross-node federation protocol — peers
  legitimately call it without local-operator auth, so we can't add
  Depends(). Instead made the underlying operation O(1) per proof
  via a cached Merkle level structure on the Infonet instance:
    - _merkle_levels_cache + _merkle_levels_for_event_count on each
      Infonet instance
    - _invalidate_merkle_cache() called from every chain mutation
      point (append, ingest_events, apply_fork, cleanup_expired)
    - _get_merkle_levels() does the lazy recompute on first read
      after invalidation, then serves from cache thereafter
  Effect: anonymous attackers hammering the proofs endpoint hit a
  cached structure; the rebuild happens at most once per real chain
  advance. Federation untouched.

  #201 — Tor bundle SHA-256 bypass (services/tor_hidden_service.py)

  Docker users were already covered — backend/Dockerfile installs
  Tor via apt-get at build time (signed by Debian's package system).
  No runtime download needed for the 80%-of-users case.

  For Tauri desktop, replaced the single .sha256sum check with a
  multi-source verification chain implemented in _verify_tor_bundle():
    1. Try upstream .sha256sum (current behavior — fast path)
    2. Try baked-in digest list at backend/data/tor_bundle_digests.json
       (pinned per-version, maintainer-updated)
    3. If neither source is REACHABLE: HTTPS-only fallback with a loud
       warning (avoids breaking first-run onboarding while the
       maintainer hasn't yet pinned a new Tor release)
  A mismatch from a source that DID respond is always fatal — only
  the "no source reachable" case falls back to HTTPS-only. This is
  the "have cake and eat it" pattern: real users see no new failure
  modes during torproject.org outages, but MITM/compromise attacks
  still fail because the downloaded digest can't match what BOTH
  the upstream and the baked-in list report.

  Currently the digest file ships with placeholder values for the
  current Tor URLs (those URLs are already stale on torproject.org
  too). A follow-up commit can populate real digests when a stable
  Tor release is selected; until then the HTTPS-only warning fires
  and onboarding still works.

Tests (82 total, all passing):
  test_openmhz_redirect_ssrf.py        (5 tests)  — #205
  test_infonet_status_verify_gate.py   (2 tests)  — #207
  test_overflights_clamp.py            (5 tests)  — #202
  test_meshtastic_callsign_optout.py   (3 tests)  — #203
  test_kiwisdr_fallback.py             (6 tests)  — #206
  test_merkle_cache.py                 (6 tests)  — #208
  test_tor_bundle_verification.py      (6 tests)  — #201
  test_control_surface_auth.py         (extended) — #211, #213, #214
  + all previous security tests (CCTV redirect, GDELT https, sentinel
    cache, crowdthreat opt-in, third-party fetcher gates, control
    surface auth) continue to pass.

Pre-existing test infrastructure issue with SHARED_EXECUTOR teardown
in the broader sweep exists on main too (verified) — not introduced
by this PR.

Credit: @tg12 reported every one of these with accurate line citations
and the recommended fixes that informed this implementation.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 19:57:06 -06:00

342 lines
18 KiB
Bash

# ShadowBroker Backend — Environment Variables
# Copy this file to .env and fill in your keys:
# cp .env.example .env
# ── Required Keys ──────────────────────────────────────────────
# Without these, the corresponding data layers will be empty.
OPENSKY_CLIENT_ID= # https://opensky-network.org/ — free account, OAuth2 client ID
OPENSKY_CLIENT_SECRET= # OAuth2 client secret from your OpenSky dashboard
AIS_API_KEY= # https://aisstream.io/ — free tier WebSocket key
# ── Optional ───────────────────────────────────────────────────
# Override allowed CORS origins (comma-separated). Defaults to localhost + LAN auto-detect.
# CORS_ORIGINS=http://192.168.1.50:3000,https://my-domain.com
# Admin key — protects sensitive endpoints (API key management, system update).
# If unset, loopback/localhost requests still work for local single-host dev.
# Remote/non-loopback admin access requires ADMIN_KEY, or ALLOW_INSECURE_ADMIN=true in debug-only setups.
# Set this in production and enter the same key in Settings → Admin Key.
# ADMIN_KEY=your-secret-admin-key-here
# Allow insecure admin access without ADMIN_KEY (local dev only, beyond loopback).
# Requires MESH_DEBUG_MODE=true; do not enable this for ordinary use.
# ALLOW_INSECURE_ADMIN=false
# Default outbound User-Agent for all third-party HTTP fetchers.
# Project-generic by default — does NOT include any personal contact info or
# operator-specific identifier. Override only if you run a public relay and
# want upstreams to be able to reach you (e.g. Nominatim/OSM usage policy).
# SHADOWBROKER_USER_AGENT=ShadowBroker-OSINT/0.9 (contact: ops@example.com)
# User-Agent for Nominatim geocoding requests (per OSM usage policy).
# NOMINATIM_USER_AGENT=ShadowBroker/1.0
# ── Third-party fetcher opt-ins ────────────────────────────────
# These data sources phone home to politically/commercially sensitive
# upstreams. Disabled by default; set to "true" only if the operator
# explicitly wants the node's IP to contact these services.
#
# CrowdThreat — backend.crowdthreat.world (paid threat-intel aggregator).
# CROWDTHREAT_ENABLED=false
#
# EUvsDisinfo FIMI — euvsdisinfo.eu (EU disinformation tracker).
# FIMI_ENABLED=false
#
# Polymarket + Kalshi — US political/election prediction markets.
# PREDICTION_MARKETS_ENABLED=false
#
# Finnhub fallback / yfinance — financial market data.
# Set FINNHUB_API_KEY to enable Finnhub, or set FINANCIAL_ENABLED=true to allow
# the unauthenticated yfinance fallback to call Yahoo Finance.
# FINANCIAL_ENABLED=false
#
# NUFORC UAP sightings — huggingface.co dataset download.
# NUFORC_ENABLED=false
#
# News RSS aggregator — defaults ON. Set to "false" to disable all
# configured news feeds (kill switch for the news layer).
# NEWS_ENABLED=true
# LTA Singapore traffic cameras — leave blank to skip this data source.
# LTA_ACCOUNT_KEY=
# NASA FIRMS country-scoped fire data — enriches global CSV with conflict-zone hotspots.
# Free MAP_KEY from https://firms.modaps.eosdis.nasa.gov/map/#d:24hrs;@0.0,0.0,3.0z
# FIRMS_MAP_KEY=
# Ukraine air raid alerts from alerts.in.ua — free token from https://alerts.in.ua/
# ALERTS_IN_UA_TOKEN=
# Optional NUFORC UAP sighting map enrichment via Mapbox Tilequery.
# Leave blank to skip this optional enrichment.
# NUFORC_MAPBOX_TOKEN=
# Google Earth Engine service account for VIIRS change detection (optional).
# Download JSON key from https://console.cloud.google.com/iam-admin/serviceaccounts
# pip install earthengine-api
# GEE_SERVICE_ACCOUNT_KEY=
# ── Meshtastic MQTT Bridge ─────────────────────────────────────
# Disabled by default to respect the public Meshtastic broker.
# When enabled, subscribes to US region only. Add more regions via MESH_MQTT_EXTRA_ROOTS.
# MESH_MQTT_ENABLED=false
# MESH_MQTT_EXTRA_ROOTS=EU_868,ANZ # comma-separated additional region roots
# MESH_MQTT_INCLUDE_DEFAULT_ROOTS=true
# MESH_MQTT_BROKER=mqtt.meshtastic.org
# MESH_MQTT_PORT=1883
# Leave user/pass blank for the public Meshtastic broker default.
# MESH_MQTT_USER=
# MESH_MQTT_PASS=
# Optional Meshtastic node ID (e.g. "!abcd1234"). When set, included in the
# User-Agent sent to meshtastic.liamcottle.net so the upstream service operator
# can identify per-install traffic instead of aggregated "ShadowBroker" hits.
# Leave blank to send a generic UA. If you set MESHTASTIC_OPERATOR_CALLSIGN,
# it is included in outbound headers to meshtastic.org by default so they
# can rate-limit per-operator. Set MESHTASTIC_SEND_CALLSIGN_HEADER=false to
# suppress the callsign while still using it locally (e.g. for APRS).
# MESHTASTIC_OPERATOR_CALLSIGN=
# MESHTASTIC_SEND_CALLSIGN_HEADER=true
# MESH_MQTT_PSK= # hex-encoded, empty = default LongFast key
# ── Mesh / Reticulum (RNS) ─────────────────────────────────────
# Full-node / participant-node posture for public Infonet sync.
# MESH_NODE_MODE=participant # participant | relay | perimeter
# Legacy compatibility sunset toggles. Default posture is to block these.
# Legacy 16-hex node-id binding no longer has a boolean escape hatch; use a
# dated migration override only when you intentionally need older peers during
# migration before the hard removal target in v0.10.0 / 2026-06-01.
# MESH_BLOCK_LEGACY_NODE_ID_COMPAT=true
# MESH_ALLOW_LEGACY_NODE_ID_COMPAT_UNTIL=2026-05-15
# MESH_BLOCK_LEGACY_AGENT_ID_LOOKUP=true
# Temporary DM invite migration escape hatch. Default posture blocks importing
# legacy/compat v1/v2 DM invites; use a dated override only while retiring
# older exports and ask senders to re-export a current signed invite.
# MESH_ALLOW_COMPAT_DM_INVITE_IMPORT_UNTIL=2026-05-15
# Temporary legacy GET DM poll/count escape hatch. Default posture requires the
# signed mailbox-claim POST APIs; only use this dated override while retiring
# older clients that still call GET poll/count directly.
# MESH_ALLOW_LEGACY_DM_GET_UNTIL=2026-05-15
# Temporary raw dm1 compose/decrypt escape hatch. Default posture expects MLS
# DM bootstrap on supported peers; only use this dated override while retiring
# older clients that still need the raw dm1 helper path.
# MESH_ALLOW_LEGACY_DM1_UNTIL=2026-05-15
# Temporary legacy dm_message signature escape hatch. Default posture requires
# the full modern signed payload; only enable this with a dated migration
# override while older senders are being retired.
# MESH_ALLOW_LEGACY_DM_SIGNATURE_COMPAT_UNTIL=2026-05-15
# Rotate voter-blinding salts so new reputation events stop reusing one
# forever-stable blinded ID. Keep grace >= rotation cadence so older votes
# remain matchable while they age out of the ledger.
# MESH_VOTER_BLIND_SALT_ROTATE_DAYS=30
# MESH_VOTER_BLIND_SALT_GRACE_DAYS=30
# Deprecated legacy env vars kept only for backward config compatibility.
# Ordinary shipped gate flows keep MLS decrypt local; service-side decrypt is
# reserved for explicit recovery reads.
# MESH_GATE_BACKEND_DECRYPT_COMPAT=false
# MESH_GATE_BACKEND_DECRYPT_COMPAT_ACKNOWLEDGE=false
# Deprecated legacy env vars kept only for backward config compatibility.
# Ordinary shipped gate flows keep plaintext compose/post local and only submit
# encrypted envelopes to the backend for sign/post.
# MESH_GATE_BACKEND_PLAINTEXT_COMPAT=false
# MESH_GATE_BACKEND_PLAINTEXT_COMPAT_ACKNOWLEDGE=false
# Legacy runtime switches for recovery envelopes. Per-gate envelope_policy is
# the source of truth; leave these at the default unless testing old behavior.
# MESH_GATE_RECOVERY_ENVELOPE_ENABLE=true
# MESH_GATE_RECOVERY_ENVELOPE_ENABLE_ACKNOWLEDGE=true
# Optional operator-only recovery tradeoff. Leave off for the default posture:
# ordinary gate reads keep plaintext local/in-memory unless you explicitly use
# the recovery-envelope path.
# MESH_GATE_PLAINTEXT_PERSIST=false
# MESH_GATE_PLAINTEXT_PERSIST_ACKNOWLEDGE=false
# Legacy Phase-1 gate envelope fallback is now explicit and time-bounded per
# gate. This only controls the default expiry window when you deliberately
# re-enable that migration path for older stored envelopes.
# MESH_GATE_LEGACY_ENVELOPE_FALLBACK_MAX_DAYS=30
# Feature-flagged multiplexed gate session stream. Stream-first room ownership
# is implemented; keep off until you want that rollout enabled in your env.
# MESH_GATE_SESSION_STREAM_ENABLED=false
# MESH_GATE_SESSION_STREAM_HEARTBEAT_S=20
# MESH_GATE_SESSION_STREAM_BATCH_MS=1500
# MESH_GATE_SESSION_STREAM_MAX_GATES=16
# MESH_BOOTSTRAP_DISABLED=false
# MESH_BOOTSTRAP_MANIFEST_PATH=data/bootstrap_peers.json
# MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY=
# Infonet/Wormhole fails closed to onion/RNS by default. Only enable clearnet
# sync for local relay development or an explicitly public testnet.
# MESH_INFONET_ALLOW_CLEARNET_SYNC=false
# MESH_BOOTSTRAP_SEED_PEERS=http://gqpbunqbgtkcqilvclm3xrkt3zowjyl3s62kkktvojgvxzizamvbrqid.onion:8000
# Add comma-separated http://*.onion peers as more private seed/relay nodes come online.
# MESH_DEFAULT_SYNC_PEERS= # legacy alias; prefer MESH_BOOTSTRAP_SEED_PEERS
# MESH_RELAY_PEERS= # comma-separated operator-trusted sync/push peers (empty by default)
# MESH_PEER_PUSH_SECRET= # REQUIRED when relay/RNS peers are configured (min 16 chars, generate with: python -c "import secrets; print(secrets.token_urlsafe(32))")
# MESH_SYNC_INTERVAL_S=300
# MESH_SYNC_FAILURE_BACKOFF_S=60
#
# Enable Reticulum bridge for Infonet event gossip.
# MESH_RNS_ENABLED=false
# MESH_RNS_APP_NAME=shadowbroker
# MESH_RNS_ASPECT=infonet
# MESH_RNS_IDENTITY_PATH=
# MESH_RNS_PEERS= # comma-separated destination hashes
# MESH_RNS_DANDELION_HOPS=2
# MESH_RNS_DANDELION_DELAY_MS=400
# MESH_RNS_CHURN_INTERVAL_S=300
# MESH_RNS_MAX_PEERS=32
# MESH_RNS_MAX_PAYLOAD=8192
# MESH_RNS_PEER_BUCKET_PREFIX=4
# MESH_RNS_MAX_PEERS_PER_BUCKET=4
# MESH_RNS_PEER_FAIL_THRESHOLD=3
# MESH_RNS_PEER_COOLDOWN_S=300
# MESH_RNS_SHARD_ENABLED=false
# MESH_RNS_SHARD_DATA_SHARDS=3
# MESH_RNS_SHARD_PARITY_SHARDS=1
# MESH_RNS_SHARD_TTL_S=30
# MESH_RNS_FEC_CODEC=xor
# MESH_RNS_BATCH_MS=200
# MESH_RNS_COVER_INTERVAL_S=0
# MESH_RNS_COVER_SIZE=64
# MESH_RNS_IBF_WINDOW=256
# MESH_RNS_IBF_TABLE_SIZE=64
# MESH_RNS_IBF_MINHASH_SIZE=16
# MESH_RNS_IBF_MINHASH_THRESHOLD=0.25
# MESH_RNS_IBF_WINDOW_JITTER=32
# MESH_RNS_IBF_INTERVAL_S=120
# MESH_RNS_IBF_SYNC_PEERS=3
# MESH_RNS_IBF_QUORUM_TIMEOUT_S=6
# MESH_RNS_IBF_MAX_REQUEST_IDS=64
# MESH_RNS_IBF_MAX_EVENTS=64
# MESH_RNS_SESSION_ROTATE_S=0
# MESH_RNS_IBF_FAIL_THRESHOLD=3
# MESH_RNS_IBF_COOLDOWN_S=120
# MESH_VERIFY_INTERVAL_S=600
# MESH_VERIFY_SIGNATURES=false
# ── Secure Storage (non-Windows) ───────────────────────────────
# Required on Linux/Docker to protect Wormhole key material at rest.
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
# Also supports Docker secrets via MESH_SECURE_STORAGE_SECRET_FILE.
# MESH_SECURE_STORAGE_SECRET=
#
# To rotate the storage secret, stop the backend and run:
# 1. Dry-run first (validates without writing):
# MESH_OLD_STORAGE_SECRET=<current> MESH_NEW_STORAGE_SECRET=<new> \
# python -m scripts.rotate_secure_storage_secret --dry-run
# 2. Rotate (creates .bak backups, then rewraps envelopes):
# MESH_OLD_STORAGE_SECRET=<current> MESH_NEW_STORAGE_SECRET=<new> \
# python -m scripts.rotate_secure_storage_secret
# 3. Update MESH_SECURE_STORAGE_SECRET to the new value and restart.
#
# If rotation is interrupted, .bak files preserve the old envelopes.
# To repair corrupted secure-json payloads (not key envelopes), use:
# python -m scripts.repair_wormhole_secure_storage
# ── Mesh DM Relay ──────────────────────────────────────────────
# MESH_DM_TOKEN_PEPPER=change-me
# Keep DM relay metadata retention explicit and bounded.
# MESH_DM_KEY_TTL_DAYS=30
# MESH_DM_PREKEY_LOOKUP_ALIAS_TTL_DAYS=14
# MESH_DM_WITNESS_TTL_DAYS=14
# MESH_DM_BINDING_TTL_DAYS=3
# Optional operational bridge for externally sourced root witnesses / transparency.
# Relative paths resolve from the backend directory.
# MESH_DM_ROOT_EXTERNAL_WITNESS_IMPORT_PATH=data/root_witness_import.json
# Local single-host dev example after bootstrapping an external witness locally:
# MESH_DM_ROOT_EXTERNAL_WITNESS_IMPORT_PATH=../ops/root_witness_receipt_import.json
# Optional URI bridge for externally retrieved root witness packages.
# MESH_DM_ROOT_EXTERNAL_WITNESS_IMPORT_URI=file:///absolute/path/root_witness_import.json
# Maximum acceptable age for external witness packages before strong DM trust fails closed.
# MESH_DM_ROOT_EXTERNAL_WITNESS_MAX_AGE_S=3600
# Warning threshold for external witness packages before fail-closed max age.
# MESH_DM_ROOT_EXTERNAL_WITNESS_WARN_AGE_S=2700
# MESH_DM_ROOT_TRANSPARENCY_LEDGER_EXPORT_PATH=data/root_transparency_ledger.json
# Local single-host dev example after publishing the transparency ledger locally:
# MESH_DM_ROOT_TRANSPARENCY_LEDGER_EXPORT_PATH=../ops/root_transparency_ledger.json
# Optional URI used to read back and verify a published transparency ledger.
# MESH_DM_ROOT_TRANSPARENCY_LEDGER_READBACK_URI=file:///absolute/path/root_transparency_ledger.json
# Local single-host dev readback example:
# MESH_DM_ROOT_TRANSPARENCY_LEDGER_READBACK_URI=../ops/root_transparency_ledger.json
# Maximum acceptable age for external transparency ledgers before strong DM trust fails closed.
# MESH_DM_ROOT_TRANSPARENCY_LEDGER_MAX_AGE_S=3600
# Warning threshold for external transparency ledgers before fail-closed max age.
# MESH_DM_ROOT_TRANSPARENCY_LEDGER_WARN_AGE_S=2700
# ── Self Update ────────────────────────────────────────────────
# MESH_UPDATE_SHA256=
# ── Wormhole (Local Agent) ─────────────────────────────────────
# WORMHOLE_HOST=127.0.0.1
# WORMHOLE_PORT=8787
# WORMHOLE_RELOAD=false
# WORMHOLE_TRANSPORT=direct
# WORMHOLE_SOCKS_PROXY=127.0.0.1:9050
# WORMHOLE_SOCKS_DNS=true
# Optional override for the loaded Rust privacy-core shared library. Leave
# unset for the default repo search order. When you override this, verify the
# authenticated wormhole status surfaces show the expected version, absolute
# library path, and SHA-256 for the loaded artifact before making stronger
# privacy claims about the deployment.
# PRIVACY_CORE_LIB=
# Minimum privacy-core version accepted when hidden/private carriers are
# enabled. Private-lane startup fails closed if the loaded artifact is
# missing, reports no parseable version, or falls below this minimum.
# PRIVACY_CORE_MIN_VERSION=0.1.0
# Comma-separated SHA-256 allowlist for the exact privacy-core artifact(s)
# your deployment is allowed to load. Required for Arti/RNS private-lane
# startup. Generate with:
# PowerShell: Get-FileHash .\privacy-core\target\release\privacy_core.dll -Algorithm SHA256
# macOS/Linux: sha256sum ./privacy-core/target/release/libprivacy_core.so
# PRIVACY_CORE_ALLOWED_SHA256=
# Optional structured release attestation artifact for the Sprint 8 release gate.
# Relative paths resolve from the backend directory. When set explicitly, a
# missing or unreadable file fails the DM relay security-suite criterion closed.
# CI/release tooling can generate this automatically via:
# uv run python scripts/release_helper.py write-attestation ...
# MESH_RELEASE_ATTESTATION_PATH=data/release_attestation.json
# Operator-only Sprint 8 release attestation. Set this only when the DM relay
# security suite has been run and passed for the current release candidate.
# File-based release attestation takes precedence when present.
# MESH_RELEASE_DM_RELAY_SECURITY_SUITE_GREEN=false
# ── OpenClaw Agent ─────────────────────────────────────────────
# HMAC shared secret for remote OpenClaw agent authentication.
# Auto-generated via the Connect OpenClaw modal — do not set manually.
# OPENCLAW_HMAC_SECRET=
# Access tier: "restricted" (read-only) or "full" (read+write+inject)
# OPENCLAW_ACCESS_TIER=restricted
# ── SAR (Synthetic Aperture Radar) Layer ───────────────────────
# Mode A — Free catalog metadata from Alaska Satellite Facility (ASF Search).
# No account, no downloads. Default-on. Set to false to disable entirely.
# MESH_SAR_CATALOG_ENABLED=true
#
# Mode B — Free pre-processed ground-change anomalies (deformation, flood,
# damage assessments) from NASA OPERA, Copernicus EGMS, GFM, EMS, UNOSAT.
# Two-step opt-in: BOTH of the following must be set together.
# 1. MESH_SAR_PRODUCTS_FETCH=allow
# 2. MESH_SAR_PRODUCTS_FETCH_ACKNOWLEDGE=true
# Either flag alone keeps Mode B disabled. You can also enable this from
# the Settings → SAR panel inside the app.
# MESH_SAR_PRODUCTS_FETCH=block
# MESH_SAR_PRODUCTS_FETCH_ACKNOWLEDGE=false
#
# NASA Earthdata Login (free, ~1 minute signup) — required for OPERA products.
# Sign up: https://urs.earthdata.nasa.gov/users/new
# Generate token: https://urs.earthdata.nasa.gov/profile → "Generate Token"
# MESH_SAR_EARTHDATA_USER=
# MESH_SAR_EARTHDATA_TOKEN=
#
# Copernicus Data Space (free, ~1 minute signup) — required for EGMS / EMS.
# Sign up: https://dataspace.copernicus.eu/
# MESH_SAR_COPERNICUS_USER=
# MESH_SAR_COPERNICUS_TOKEN=
#
# Allow OpenClaw agents to read and act on the SAR layer (default true).
# MESH_SAR_OPENCLAW_ENABLED=true
#
# Require private-tier transport (Tor / RNS) before signing and broadcasting
# SAR anomalies to the mesh. Default true — disable only for testnet/local use.
# MESH_SAR_REQUIRE_PRIVATE_TIER=true