Files
Shadowbroker/backend/tests/test_per_peer_secret_resolver.py
T
Shadowbroker 2e14e75a0e Fix #256: per-peer HMAC secrets defeat cross-peer impersonation (#281)
Before this change, every peer-push HMAC was derived from the single
fleet-shared MESH_PEER_PUSH_SECRET. The receiver could prove "this
request was signed by someone who knows the fleet secret" but it could
NOT prove which peer signed it. Any peer that knew the global secret
could compute the expected HMAC for any other peer URL and forge a
push pretending to be that peer.

Fix: introduce MESH_PEER_SECRETS, an optional comma-separated
url=secret map. When a peer URL appears in the map, only the listed
per-peer secret is accepted for it -- the global secret is ignored for
that specific URL. Peer A no longer knows peer B's secret, so peer A
cannot forge a push claiming to be peer B.

The new helper resolve_peer_key_for_url() in mesh_crypto.py wraps the
lookup and is called from every existing peer-push call site:

- backend/auth.py:_verify_peer_push_hmac (receiver)
- backend/main.py:_http_peer_push_loop (Infonet event push)
- backend/main.py:_http_gate_pull_loop (gate event pull)
- backend/main.py:_http_gate_push_loop (gate event push)
- backend/services/mesh/mesh_router.py (two transports, push)
- backend/services/mesh/mesh_hashchain.py (gate wire ref key)
- backend/services/mesh/mesh_wormhole_prekey.py (peer prekey lookup)

Zero hostility, by design:

- Single-peer installs leave MESH_PEER_SECRETS empty -> resolver falls
  back to MESH_PEER_PUSH_SECRET -> behavior is byte-for-byte unchanged.
- Multi-peer installs that haven't migrated yet behave exactly as
  before.
- Multi-peer installs that DO migrate set MESH_PEER_SECRETS on both
  ends of each peering and immediately close the impersonation surface
  for those URLs. Migration is incremental: unlisted peers keep using
  the global secret.

Tests in backend/tests/test_per_peer_secret_resolver.py:
- env parsing (default, override, whitespace, malformed entries, cache)
- precedence: per-peer beats global
- migration window: unlisted peer falls back to global
- IMPERSONATION REFUSAL: peer A with global-secret-only cannot forge
  HMAC for peer B that has a per-peer secret configured
- IMPERSONATION REFUSAL: peer A with its OWN per-peer secret cannot
  forge HMAC for peer B
- positive control: legitimate peer B request verifies
- zero-behavior-change: single-peer install produces the same key bytes
  as before the change

Credit: tg12 (external security audit, P1/High/High confidence)
2026-05-21 10:05:29 -06:00

367 lines
14 KiB
Python

"""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