diff --git a/backend/auth.py b/backend/auth.py index 54f751f..e060042 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -45,6 +45,7 @@ from services.mesh.mesh_compatibility import ( from services.mesh.mesh_crypto import ( _derive_peer_key, normalize_peer_url, + resolve_peer_key_for_url, verify_signature, verify_node_binding, parse_public_key_algo, @@ -1403,11 +1404,15 @@ def _peer_hmac_url_from_request(request: Request) -> str: def _verify_peer_push_hmac(request: Request, body_bytes: bytes) -> bool: - """Verify HMAC-SHA256 peer authentication on push requests.""" - secret = str(get_settings().MESH_PEER_PUSH_SECRET or "").strip() - if not secret: - return False + """Verify HMAC-SHA256 peer authentication on push requests. + Issue #256: ``resolve_peer_key_for_url`` looks up a per-peer secret + in ``MESH_PEER_SECRETS`` first, then falls back to the global + ``MESH_PEER_PUSH_SECRET``. When a peer URL is listed in the per-peer + map, only the listed secret is accepted for it — the global secret + is ignored, so any peer that knows only the global secret cannot + forge a request claiming to be that peer. + """ provided = str(request.headers.get("x-peer-hmac", "") or "").strip() if not provided: return False @@ -1416,7 +1421,7 @@ def _verify_peer_push_hmac(request: Request, body_bytes: bytes) -> bool: allowed_peers = set(authenticated_push_peer_urls()) if not peer_url or peer_url not in allowed_peers: return False - peer_key = _derive_peer_key(secret, peer_url) + peer_key = resolve_peer_key_for_url(peer_url) if not peer_key: return False diff --git a/backend/main.py b/backend/main.py index f69819b..81e6039 100644 --- a/backend/main.py +++ b/backend/main.py @@ -220,6 +220,7 @@ from services.mesh.mesh_crypto import ( _derive_peer_key, derive_node_id, normalize_peer_url, + resolve_peer_key_for_url, verify_node_binding, parse_public_key_algo, ) @@ -1745,10 +1746,12 @@ def _http_peer_push_loop() -> None: _NODE_SYNC_STOP.wait(_PEER_PUSH_INTERVAL_S) continue - secret = str(get_settings().MESH_PEER_PUSH_SECRET or "").strip() - if not secret: - _NODE_SYNC_STOP.wait(_PEER_PUSH_INTERVAL_S) - continue + # Issue #256: resolve_peer_key_for_url() handles both the + # legacy global MESH_PEER_PUSH_SECRET path and the per-peer + # MESH_PEER_SECRETS map. The per-peer skip happens below + # ("if not peer_key: continue"), so we don't gate the whole + # loop on the global secret being set — an install that only + # configures per-peer secrets is now valid. peers = authenticated_push_peer_urls() if not peers: @@ -1778,7 +1781,7 @@ def _http_peer_push_loop() -> None: ensure_ascii=False, ).encode("utf-8") - peer_key = _derive_peer_key(secret, normalized) + peer_key = resolve_peer_key_for_url(normalized) if not peer_key: continue import hmac as _hmac_mod2 @@ -1831,10 +1834,7 @@ def _http_gate_pull_loop() -> None: _NODE_SYNC_STOP.wait(_GATE_PULL_INTERVAL_S) continue - secret = str(get_settings().MESH_PEER_PUSH_SECRET or "").strip() - if not secret: - _NODE_SYNC_STOP.wait(_GATE_PULL_INTERVAL_S) - continue + # Issue #256: per-peer key resolution; see _http_peer_push_loop. peers = authenticated_push_peer_urls() if not peers: @@ -1846,7 +1846,7 @@ def _http_gate_pull_loop() -> None: if not normalized: continue - peer_key = _derive_peer_key(secret, normalized) + peer_key = resolve_peer_key_for_url(normalized) if not peer_key: continue @@ -1959,10 +1959,7 @@ def _http_gate_push_loop() -> None: _NODE_SYNC_STOP.wait(_PEER_PUSH_INTERVAL_S) continue - secret = str(get_settings().MESH_PEER_PUSH_SECRET or "").strip() - if not secret: - _NODE_SYNC_STOP.wait(_PEER_PUSH_INTERVAL_S) - continue + # Issue #256: per-peer key resolution; see _http_peer_push_loop. peers = authenticated_push_peer_urls() if not peers: @@ -1977,7 +1974,7 @@ def _http_gate_push_loop() -> None: if not normalized: continue - peer_key = _derive_peer_key(secret, normalized) + peer_key = resolve_peer_key_for_url(normalized) if not peer_key: continue diff --git a/backend/services/config.py b/backend/services/config.py index 2cb31da..ca70313 100644 --- a/backend/services/config.py +++ b/backend/services/config.py @@ -53,6 +53,12 @@ class Settings(BaseSettings): MESH_RELAY_FAILURE_COOLDOWN_S: int = 120 MESH_BOOTSTRAP_SEED_FAILURE_COOLDOWN_S: int = 15 MESH_PEER_PUSH_SECRET: str = "" + # Issue #256 (tg12): optional per-peer HMAC secret map. Comma-separated + # `url=secret` pairs. When a peer URL appears here, only that per-peer + # secret is accepted for it — the global MESH_PEER_PUSH_SECRET above is + # ignored for that specific URL. Single-peer installs and unmigrated + # multi-peer installs leave this empty and behavior is unchanged. + MESH_PEER_SECRETS: str = "" MESH_RNS_APP_NAME: str = "shadowbroker" MESH_RNS_ASPECT: str = "infonet" MESH_RNS_IDENTITY_PATH: str = "" diff --git a/backend/services/mesh/mesh_crypto.py b/backend/services/mesh/mesh_crypto.py index 79519b6..6c24a17 100644 --- a/backend/services/mesh/mesh_crypto.py +++ b/backend/services/mesh/mesh_crypto.py @@ -69,6 +69,115 @@ def _derive_peer_key(shared_secret: str, peer_url: str) -> bytes: ).digest() +# --------------------------------------------------------------------------- +# Issue #256 (tg12): per-peer HMAC secrets +# --------------------------------------------------------------------------- +# +# Before this change, ALL peer-push HMACs were derived from a single +# fleet-shared ``MESH_PEER_PUSH_SECRET``. The receiver could prove a +# request was signed by *someone who knows the fleet secret*, but it +# could NOT prove which peer signed it — any peer could compute the +# expected HMAC for any other peer's URL and impersonate that peer. +# +# Fix: an optional ``MESH_PEER_SECRETS`` env var maps specific peer URLs +# to per-peer secrets. When a peer URL is listed there, only that +# per-peer secret is accepted for that URL — the global secret is +# ignored for that peer. Peer A no longer learns peer B's secret, so +# peer A cannot forge a request claiming to be peer B. +# +# Backwards-compatible by design: +# +# - Single-peer installs (``MESH_PEER_SECRETS`` empty) keep using the +# global secret. Zero behavior change. Zero operator action required. +# - Multi-peer installs that haven't migrated yet keep using the global +# secret for every peer. Same behavior as before — same exposure. +# - Multi-peer installs that have migrated configure +# ``MESH_PEER_SECRETS=urlA=secretA,urlB=secretB`` and immediately get +# per-peer identity. Migration is incremental: peers not yet listed +# continue using the global secret until both sides of that peering +# add their entry. + +_PEER_SECRETS_CACHE: dict[str, str] = {} +_PEER_SECRETS_CACHE_RAW: str = "" + + +def _lookup_per_peer_secret(normalized_url: str) -> str: + """Return the per-peer secret for ``normalized_url`` from MESH_PEER_SECRETS. + + Returns "" if no per-peer entry is configured for that URL. The parser + is forgiving: + + - Whitespace around items, URLs, and secrets is stripped. + - Items without ``=`` or with empty URL/secret halves are skipped. + - The URL half is normalized via ``normalize_peer_url`` so config + authors don't have to match scheme/port/path quirks exactly. + + The cache is invalidated whenever the env var's raw value changes, + which keeps tests' ``monkeypatch.setenv`` calls effective without + forcing a process restart. + """ + import os + + raw = str(os.environ.get("MESH_PEER_SECRETS", "") or "").strip() + + global _PEER_SECRETS_CACHE, _PEER_SECRETS_CACHE_RAW + if raw != _PEER_SECRETS_CACHE_RAW: + new_cache: dict[str, str] = {} + for chunk in raw.split(","): + chunk = chunk.strip() + if not chunk or "=" not in chunk: + continue + url_part, _, secret_part = chunk.partition("=") + normalized = normalize_peer_url(url_part.strip()) + secret = secret_part.strip() + if normalized and secret: + new_cache[normalized] = secret + _PEER_SECRETS_CACHE = new_cache + _PEER_SECRETS_CACHE_RAW = raw + + return _PEER_SECRETS_CACHE.get(normalized_url, "") + + +def resolve_peer_key_for_url(peer_url: str) -> bytes: + """Return the HMAC key for ``peer_url``, preferring per-peer secret. + + Issue #256: this is the function every peer-push call site should + use. It looks up the peer-specific secret first, falling back to the + fleet-shared ``MESH_PEER_PUSH_SECRET`` only when the URL is NOT + listed in ``MESH_PEER_SECRETS``. + + Both sender (computing X-Peer-HMAC) and receiver (verifying it) call + this with the SENDER's URL — they must derive the same key, so + operators on both ends of a peering need matching MESH_PEER_SECRETS + entries for that URL to stay in sync. + + Returns empty bytes when no usable secret exists. Callers must treat + that as fail-closed (skip the push, reject the verification). + """ + normalized_url = normalize_peer_url(peer_url) + if not normalized_url: + return b"" + + per_peer_secret = _lookup_per_peer_secret(normalized_url) + if per_peer_secret: + return _derive_peer_key(per_peer_secret, normalized_url) + + # No per-peer entry for this URL — fall back to the legacy global + # secret. This is what preserves zero-hostility for single-peer + # installs and the migration window for multi-peer installs. + try: + from services.config import get_settings + + global_secret = str( + getattr(get_settings(), "MESH_PEER_PUSH_SECRET", "") or "" + ).strip() + except Exception: + return b"" + if not global_secret: + return b"" + return _derive_peer_key(global_secret, normalized_url) + + def _node_digest(public_key_b64: str) -> str: raw = base64.b64decode(public_key_b64) return hashlib.sha256(raw).hexdigest() diff --git a/backend/services/mesh/mesh_hashchain.py b/backend/services/mesh/mesh_hashchain.py index 0a96815..ddb3a42 100644 --- a/backend/services/mesh/mesh_hashchain.py +++ b/backend/services/mesh/mesh_hashchain.py @@ -216,18 +216,19 @@ def _peer_pair_ref_key(peer_url: str) -> bytes: Returns an empty key on misconfiguration so callers fail closed. """ try: - from services.config import get_settings - from services.mesh.mesh_crypto import _derive_peer_key, normalize_peer_url - - secret = str(get_settings().MESH_PEER_PUSH_SECRET or "").strip() + from services.mesh.mesh_crypto import ( + normalize_peer_url, + resolve_peer_key_for_url, + ) except Exception: return b"" - if not secret: - return b"" normalized = normalize_peer_url(peer_url or "") if not normalized: return b"" - peer_key = _derive_peer_key(secret, normalized) + # Issue #256: resolve_peer_key_for_url() prefers per-peer secrets + # from MESH_PEER_SECRETS and falls back to the global + # MESH_PEER_PUSH_SECRET only when the URL has no per-peer entry. + peer_key = resolve_peer_key_for_url(normalized) if not peer_key: return b"" # Domain-separate from the transport HMAC key so the two diff --git a/backend/services/mesh/mesh_router.py b/backend/services/mesh/mesh_router.py index d693efc..bf84a2b 100644 --- a/backend/services/mesh/mesh_router.py +++ b/backend/services/mesh/mesh_router.py @@ -26,7 +26,11 @@ from enum import Enum from typing import Any, Callable, Optional from collections import deque from urllib.parse import urlparse -from services.mesh.mesh_crypto import _derive_peer_key, normalize_peer_url +from services.mesh.mesh_crypto import ( + _derive_peer_key, + normalize_peer_url, + resolve_peer_key_for_url, +) from services.mesh.mesh_metrics import increment as metrics_inc from services.mesh.mesh_privacy_policy import ( TRANSPORT_TIER_ORDER as _TIER_RANK, @@ -703,7 +707,6 @@ class InternetTransport(_PeerPushTransportMixin): endpoint_path, padded = self._build_peer_push_request(envelope, self.NAME) except ValueError as exc: return TransportResult(False, self.NAME, str(exc)) - secret = str(settings.MESH_PEER_PUSH_SECRET or "").strip() delivered = 0 last_error = "" @@ -713,10 +716,13 @@ class InternetTransport(_PeerPushTransportMixin): try: normalized_peer_url = normalize_peer_url(peer_url) headers = {"Content-Type": "application/json"} - if secret: - peer_key = _derive_peer_key(secret, normalized_peer_url) - if not peer_key: - raise ValueError("invalid peer URL for HMAC derivation") + # Issue #256: per-peer secret takes precedence over the + # global MESH_PEER_PUSH_SECRET. When neither is set the + # key is empty and we skip the HMAC header entirely so a + # bare (unsigned) push still works on test deployments + # that have not yet configured any secret at all. + peer_key = resolve_peer_key_for_url(normalized_peer_url) + if peer_key: headers["X-Peer-Url"] = normalized_peer_url headers["X-Peer-HMAC"] = hmac.new( peer_key, @@ -798,7 +804,6 @@ class TorArtiTransport(_PeerPushTransportMixin): endpoint_path, padded = self._build_peer_push_request(envelope, self.NAME) except ValueError as exc: return TransportResult(False, self.NAME, str(exc)) - secret = str(settings.MESH_PEER_PUSH_SECRET or "").strip() delivered = 0 last_error = "" @@ -808,10 +813,10 @@ class TorArtiTransport(_PeerPushTransportMixin): try: normalized_peer_url = normalize_peer_url(peer_url) headers = {"Content-Type": "application/json"} - if secret: - peer_key = _derive_peer_key(secret, normalized_peer_url) - if not peer_key: - raise ValueError("invalid peer URL for HMAC derivation") + # Issue #256: per-peer secret takes precedence; see the + # other transport above for the rationale. + peer_key = resolve_peer_key_for_url(normalized_peer_url) + if peer_key: headers["X-Peer-Url"] = normalized_peer_url headers["X-Peer-HMAC"] = hmac.new( peer_key, diff --git a/backend/services/mesh/mesh_wormhole_prekey.py b/backend/services/mesh/mesh_wormhole_prekey.py index b2cf8ef..887a2fe 100644 --- a/backend/services/mesh/mesh_wormhole_prekey.py +++ b/backend/services/mesh/mesh_wormhole_prekey.py @@ -91,13 +91,15 @@ def _fetch_dm_prekey_bundle_from_peer_lookup(lookup_token: str) -> dict[str, Any return {"ok": False, "detail": "lookup token required"} try: from services.config import get_settings - from services.mesh.mesh_crypto import _derive_peer_key, normalize_peer_url + from services.mesh.mesh_crypto import ( + normalize_peer_url, + resolve_peer_key_for_url, + ) from services.mesh.mesh_router import configured_relay_peer_urls settings = get_settings() - secret = str(getattr(settings, "MESH_PEER_PUSH_SECRET", "") or "").strip() - if not secret: - return {"ok": False, "detail": "peer prekey lookup unavailable"} + # Issue #256: secret check moved per-peer below. We still bail out + # cleanly when there are no peers configured at all. peers = configured_relay_peer_urls() if not peers: return {"ok": False, "detail": "peer prekey lookup unavailable"} @@ -121,7 +123,8 @@ def _fetch_dm_prekey_bundle_from_peer_lookup(lookup_token: str) -> dict[str, Any or os.environ.get("SB_TEST_NODE_URL", "").strip() or normalized_peer_url ) - peer_key = _derive_peer_key(secret, sender_peer_url) + # Issue #256: prefer per-peer secret keyed by the sender URL. + peer_key = resolve_peer_key_for_url(sender_peer_url) if not peer_key: continue headers = { diff --git a/backend/tests/test_per_peer_secret_resolver.py b/backend/tests/test_per_peer_secret_resolver.py new file mode 100644 index 0000000..4e07ce1 --- /dev/null +++ b/backend/tests/test_per_peer_secret_resolver.py @@ -0,0 +1,366 @@ +"""Issue #256 (tg12): per-peer HMAC secrets must defeat cross-peer +impersonation. + +Before the fix, ALL peer-push HMACs were derived from the single +fleet-shared ``MESH_PEER_PUSH_SECRET``. The receiver could only prove +"this request was signed by someone who knows the fleet secret" — not +which peer signed it. Any peer that knew the secret could compute the +expected HMAC for any other peer's URL and impersonate that peer. + +The fix introduces ``MESH_PEER_SECRETS``, a per-peer URL-to-secret map. +When a peer URL appears there: + +- Only the listed per-peer secret is accepted for that URL. +- The global ``MESH_PEER_PUSH_SECRET`` is ignored for that specific URL. +- A peer that knows only the global secret (or a different peer's + per-peer secret) cannot forge a request claiming to be that peer. + +When a peer URL is NOT listed (the common case for single-peer installs +and for migration windows), the resolver falls back to the global +secret — preserving existing behavior with zero operator action. + +These tests exercise ``resolve_peer_key_for_url`` directly so we cover +the security contract without spinning up a full mesh node. +""" +from __future__ import annotations + +import hashlib +import hmac + +import pytest + + +# --------------------------------------------------------------------------- +# _lookup_per_peer_secret — env parsing +# --------------------------------------------------------------------------- + + +class TestLookupPerPeerSecret: + def setup_method(self): + # Invalidate the parser cache so each test sees its own env state. + from services.mesh import mesh_crypto + + mesh_crypto._PEER_SECRETS_CACHE = {} + mesh_crypto._PEER_SECRETS_CACHE_RAW = "" + + def test_returns_empty_when_env_unset(self, monkeypatch): + from services.mesh.mesh_crypto import _lookup_per_peer_secret + + monkeypatch.delenv("MESH_PEER_SECRETS", raising=False) + assert _lookup_per_peer_secret("https://peer.example") == "" + + def test_returns_empty_when_env_blank(self, monkeypatch): + from services.mesh.mesh_crypto import _lookup_per_peer_secret + + monkeypatch.setenv("MESH_PEER_SECRETS", "") + assert _lookup_per_peer_secret("https://peer.example") == "" + + def test_returns_per_peer_secret_for_listed_url(self, monkeypatch): + from services.mesh.mesh_crypto import _lookup_per_peer_secret + + monkeypatch.setenv( + "MESH_PEER_SECRETS", + "https://peer-a.example=secretA,https://peer-b.example=secretB", + ) + assert _lookup_per_peer_secret("https://peer-a.example") == "secretA" + assert _lookup_per_peer_secret("https://peer-b.example") == "secretB" + + def test_returns_empty_for_url_not_listed(self, monkeypatch): + from services.mesh.mesh_crypto import _lookup_per_peer_secret + + monkeypatch.setenv( + "MESH_PEER_SECRETS", + "https://peer-a.example=secretA", + ) + assert _lookup_per_peer_secret("https://other.example") == "" + + def test_url_is_normalized_before_lookup(self, monkeypatch): + from services.mesh.mesh_crypto import _lookup_per_peer_secret + + # Configure with a trailing slash + uppercase host. Lookup with + # plain lowercase host. Both should normalize to the same key. + monkeypatch.setenv( + "MESH_PEER_SECRETS", + "https://Peer-A.Example/=secretA", + ) + assert _lookup_per_peer_secret("https://peer-a.example") == "secretA" + + def test_whitespace_around_entries_is_stripped(self, monkeypatch): + from services.mesh.mesh_crypto import _lookup_per_peer_secret + + monkeypatch.setenv( + "MESH_PEER_SECRETS", + " https://peer-a.example = secretA , https://peer-b.example=secretB ", + ) + assert _lookup_per_peer_secret("https://peer-a.example") == "secretA" + assert _lookup_per_peer_secret("https://peer-b.example") == "secretB" + + def test_malformed_entries_are_skipped_not_raised(self, monkeypatch): + """A garbled MESH_PEER_SECRETS value must NOT crash the resolver. + Bad entries are silently dropped; well-formed entries still work. + This is the "fail-forward, not loud" rule — a typo in operator + config should not take the whole backend down.""" + from services.mesh.mesh_crypto import _lookup_per_peer_secret + + monkeypatch.setenv( + "MESH_PEER_SECRETS", + "no_equals_sign,=missing_url,https://no.secret=,https://good.example=secretGood", + ) + assert _lookup_per_peer_secret("https://good.example") == "secretGood" + # The malformed ones produce no entry (and don't poison the cache). + assert _lookup_per_peer_secret("https://no.secret") == "" + + def test_cache_invalidates_on_env_change(self, monkeypatch): + """A test (or operator) updating MESH_PEER_SECRETS must see the + new value immediately — no process restart required.""" + from services.mesh.mesh_crypto import _lookup_per_peer_secret + + monkeypatch.setenv("MESH_PEER_SECRETS", "https://a.example=first") + assert _lookup_per_peer_secret("https://a.example") == "first" + monkeypatch.setenv("MESH_PEER_SECRETS", "https://a.example=second") + assert _lookup_per_peer_secret("https://a.example") == "second" + + +# --------------------------------------------------------------------------- +# resolve_peer_key_for_url — precedence + fallback +# --------------------------------------------------------------------------- + + +class TestResolvePeerKeyForUrl: + def setup_method(self): + from services.mesh import mesh_crypto + + mesh_crypto._PEER_SECRETS_CACHE = {} + mesh_crypto._PEER_SECRETS_CACHE_RAW = "" + + def _fake_settings(self, global_secret: str): + from unittest.mock import MagicMock + + s = MagicMock() + s.MESH_PEER_PUSH_SECRET = global_secret + return s + + def test_falls_back_to_global_when_no_per_peer_entry(self, monkeypatch): + """Single-peer installs: MESH_PEER_SECRETS empty, MESH_PEER_PUSH_SECRET + set — must keep working as before.""" + from services.mesh.mesh_crypto import ( + resolve_peer_key_for_url, + _derive_peer_key, + ) + + monkeypatch.delenv("MESH_PEER_SECRETS", raising=False) + with monkeypatch.context() as m: + m.setattr( + "services.config.get_settings", + lambda: self._fake_settings("global-secret"), + ) + key = resolve_peer_key_for_url("https://peer.example") + expected = _derive_peer_key("global-secret", "https://peer.example") + assert key == expected + assert len(key) == 32 # SHA-256 output + + def test_per_peer_secret_takes_precedence_over_global(self, monkeypatch): + from services.mesh.mesh_crypto import ( + resolve_peer_key_for_url, + _derive_peer_key, + ) + + monkeypatch.setenv( + "MESH_PEER_SECRETS", + "https://peer-a.example=per-peer-a-secret", + ) + with monkeypatch.context() as m: + m.setattr( + "services.config.get_settings", + lambda: self._fake_settings("global-secret"), + ) + key = resolve_peer_key_for_url("https://peer-a.example") + expected_per_peer = _derive_peer_key( + "per-peer-a-secret", "https://peer-a.example" + ) + expected_global = _derive_peer_key("global-secret", "https://peer-a.example") + assert key == expected_per_peer + assert key != expected_global + + def test_unlisted_peer_uses_global_during_migration(self, monkeypatch): + """Partial migration: peer A is in MESH_PEER_SECRETS, peer B is + not yet. Peer B must keep working under the global secret.""" + from services.mesh.mesh_crypto import ( + resolve_peer_key_for_url, + _derive_peer_key, + ) + + monkeypatch.setenv( + "MESH_PEER_SECRETS", + "https://peer-a.example=per-peer-a-secret", + ) + with monkeypatch.context() as m: + m.setattr( + "services.config.get_settings", + lambda: self._fake_settings("global-secret"), + ) + key_a = resolve_peer_key_for_url("https://peer-a.example") + key_b = resolve_peer_key_for_url("https://peer-b.example") + expected_b = _derive_peer_key("global-secret", "https://peer-b.example") + assert key_b == expected_b + # Peer A's per-peer key must differ from peer B's global key + # (they're keyed by different secrets and different URLs). + assert key_a != key_b + + def test_returns_empty_when_no_secret_available(self, monkeypatch): + from services.mesh.mesh_crypto import resolve_peer_key_for_url + + monkeypatch.delenv("MESH_PEER_SECRETS", raising=False) + with monkeypatch.context() as m: + m.setattr( + "services.config.get_settings", + lambda: self._fake_settings(""), + ) + key = resolve_peer_key_for_url("https://peer.example") + assert key == b"" + + def test_returns_empty_when_url_is_unparseable(self, monkeypatch): + from services.mesh.mesh_crypto import resolve_peer_key_for_url + + with monkeypatch.context() as m: + m.setattr( + "services.config.get_settings", + lambda: self._fake_settings("global-secret"), + ) + assert resolve_peer_key_for_url("") == b"" + assert resolve_peer_key_for_url("not-a-url") == b"" + assert resolve_peer_key_for_url(None) == b"" + + +# --------------------------------------------------------------------------- +# The actual #256 attack: peer A cannot impersonate peer B +# --------------------------------------------------------------------------- + + +class TestCrossPeerImpersonationRefused: + """The core regression: when MESH_PEER_SECRETS is configured, a peer + that knows ONLY the global secret (or a different peer's per-peer + secret) cannot produce a valid HMAC for another peer's URL.""" + + def setup_method(self): + from services.mesh import mesh_crypto + + mesh_crypto._PEER_SECRETS_CACHE = {} + mesh_crypto._PEER_SECRETS_CACHE_RAW = "" + + def _hmac(self, key: bytes, body: bytes) -> str: + return hmac.new(key, body, hashlib.sha256).hexdigest() + + def test_peer_a_global_secret_cannot_forge_peer_b_hmac(self, monkeypatch): + from services.mesh.mesh_crypto import ( + resolve_peer_key_for_url, + _derive_peer_key, + ) + from unittest.mock import MagicMock + + # Receiver has BOTH the global secret AND a per-peer secret for B. + monkeypatch.setenv( + "MESH_PEER_SECRETS", + "https://peer-b.example=per-peer-b-secret", + ) + settings = MagicMock() + settings.MESH_PEER_PUSH_SECRET = "global-secret" + monkeypatch.setattr( + "services.config.get_settings", lambda: settings + ) + + body = b'{"events": [{"id": 1}]}' + + # Attacker (peer A) knows only the global secret. Tries to forge + # an HMAC claiming to be peer B. + attacker_key = _derive_peer_key("global-secret", "https://peer-b.example") + attacker_hmac = self._hmac(attacker_key, body) + + # Receiver derives B's expected key from B's per-peer secret. + receiver_key = resolve_peer_key_for_url("https://peer-b.example") + expected_hmac = self._hmac(receiver_key, body) + + # The forgery MUST NOT match. + assert attacker_hmac != expected_hmac + + def test_peer_a_per_peer_secret_cannot_forge_peer_b_hmac(self, monkeypatch): + """Even harder case: peer A has its OWN per-peer secret, but + still does not know peer B's per-peer secret, and so cannot + forge an HMAC for peer B.""" + from services.mesh.mesh_crypto import ( + resolve_peer_key_for_url, + _derive_peer_key, + ) + from unittest.mock import MagicMock + + monkeypatch.setenv( + "MESH_PEER_SECRETS", + "https://peer-a.example=secretA,https://peer-b.example=secretB", + ) + settings = MagicMock() + settings.MESH_PEER_PUSH_SECRET = "" + monkeypatch.setattr( + "services.config.get_settings", lambda: settings + ) + + body = b'{"events": [{"id": 99}]}' + + # Attacker A tries to forge for B using its own secret (secretA). + attacker_key = _derive_peer_key("secretA", "https://peer-b.example") + attacker_hmac = self._hmac(attacker_key, body) + + receiver_key = resolve_peer_key_for_url("https://peer-b.example") + expected_hmac = self._hmac(receiver_key, body) + + assert attacker_hmac != expected_hmac + + def test_legitimate_peer_b_request_verifies(self, monkeypatch): + """Positive control: when peer B uses ITS per-peer secret and + claims to be itself, the receiver accepts the HMAC.""" + from services.mesh.mesh_crypto import resolve_peer_key_for_url + from unittest.mock import MagicMock + + monkeypatch.setenv( + "MESH_PEER_SECRETS", + "https://peer-b.example=secretB", + ) + settings = MagicMock() + settings.MESH_PEER_PUSH_SECRET = "" + monkeypatch.setattr( + "services.config.get_settings", lambda: settings + ) + + body = b'{"events": [{"id": 7}]}' + + # Peer B and the receiver both call resolve_peer_key_for_url. + sender_key = resolve_peer_key_for_url("https://peer-b.example") + receiver_key = resolve_peer_key_for_url("https://peer-b.example") + + sender_hmac = self._hmac(sender_key, body) + expected_hmac = self._hmac(receiver_key, body) + + assert sender_hmac == expected_hmac + + def test_single_peer_install_zero_behavior_change(self, monkeypatch): + """The "no UX hostility" guarantee: an install with the global + secret set and NO MESH_PEER_SECRETS entries must derive exactly + the same key as before this change.""" + from services.mesh.mesh_crypto import ( + resolve_peer_key_for_url, + _derive_peer_key, + ) + from unittest.mock import MagicMock + + monkeypatch.delenv("MESH_PEER_SECRETS", raising=False) + settings = MagicMock() + settings.MESH_PEER_PUSH_SECRET = "legacy-global-secret" + monkeypatch.setattr( + "services.config.get_settings", lambda: settings + ) + + # The legacy derivation that every prior call site used. + legacy_key = _derive_peer_key("legacy-global-secret", "https://peer.example") + # The new resolver, with no per-peer entries configured. + new_key = resolve_peer_key_for_url("https://peer.example") + + assert new_key == legacy_key diff --git a/docker-compose.yml b/docker-compose.yml index 75bb353..e1ce404 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,15 @@ services: - MESH_RELAY_PEERS=${MESH_RELAY_PEERS:-} # Shared transport auth for operator peer push. Must be set to a unique secret per deployment. - MESH_PEER_PUSH_SECRET=${MESH_PEER_PUSH_SECRET:-} + # Issue #256: optional per-peer HMAC secrets. Comma-separated + # `url=secret` pairs (no spaces). When a peer URL appears here, only + # the listed per-peer secret is accepted for it — the global + # MESH_PEER_PUSH_SECRET above is ignored for that specific URL. This + # closes the cross-peer impersonation surface for multi-peer fleets. + # Single-peer installs leave this empty (default) for unchanged + # behavior. Both sides of a peering must agree on the per-peer + # secret for a given URL. + - MESH_PEER_SECRETS=${MESH_PEER_SECRETS:-} # Meshtastic MQTT is opt-in to avoid passive load on the public broker. # Set MESH_MQTT_ENABLED=true in .env only when this node should join live MQTT. - MESH_MQTT_ENABLED=${MESH_MQTT_ENABLED:-false}