mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-30 23:47:27 +02:00
v0.9.6: InfoNet hashchain, Wormhole gate encryption, mesh reputation, 16 community contributors
Gate messages now propagate via the Infonet hashchain as encrypted blobs — every node syncs them through normal chain sync while only Gate members with MLS keys can decrypt. Added mesh reputation system, peer push workers, voluntary Wormhole opt-in for node participation, fork recovery, killwormhole scripts, obfuscated terminology, and hardened the self-updater to protect encryption keys and chain state during updates. New features: Shodan search, train tracking, Sentinel Hub imagery, 8 new intelligence layers, CCTV expansion to 11,000+ cameras across 6 countries, Mesh Terminal CLI, prediction markets, desktop-shell scaffold, and comprehensive mesh test suite (215 frontend + backend tests passing). Community contributors: @wa1id, @AlborzNazari, @adust09, @Xpirix, @imqdcr, @csysp, @suranyami, @chr0n1x, @johan-martensson, @singularfailure, @smithbh, @OrfeoTerkuci, @deuza, @tm-const, @Elhard1, @ttulttul
This commit is contained in:
@@ -5,12 +5,14 @@ from unittest.mock import patch, MagicMock
|
||||
@pytest.fixture(autouse=True)
|
||||
def _suppress_background_services():
|
||||
"""Prevent real scheduler/stream/tracker from starting during tests."""
|
||||
with patch("services.data_fetcher.start_scheduler"), \
|
||||
patch("services.data_fetcher.stop_scheduler"), \
|
||||
patch("services.ais_stream.start_ais_stream"), \
|
||||
patch("services.ais_stream.stop_ais_stream"), \
|
||||
patch("services.carrier_tracker.start_carrier_tracker"), \
|
||||
patch("services.carrier_tracker.stop_carrier_tracker"):
|
||||
with (
|
||||
patch("services.data_fetcher.start_scheduler"),
|
||||
patch("services.data_fetcher.stop_scheduler"),
|
||||
patch("services.ais_stream.start_ais_stream"),
|
||||
patch("services.ais_stream.stop_ais_stream"),
|
||||
patch("services.carrier_tracker.start_carrier_tracker"),
|
||||
patch("services.carrier_tracker.stop_carrier_tracker"),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
[
|
||||
{"id": "KDEN", "name": "Denver Intl", "iata": "DEN", "lat": 39.8561, "lng": -104.6737, "type": "airport"},
|
||||
{"id": "KJFK", "name": "John F Kennedy Intl", "iata": "JFK", "lat": 40.6413, "lng": -73.7781, "type": "airport"}
|
||||
]
|
||||
@@ -0,0 +1,437 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
|
||||
def _request(path: str, method: str = "POST") -> Request:
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [],
|
||||
"client": ("test", 12345),
|
||||
"method": method,
|
||||
"path": path,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_anonymous_mode_blocks_public_mesh_write_without_hidden_transport(monkeypatch):
|
||||
import main
|
||||
from services import wormhole_settings, wormhole_status
|
||||
|
||||
monkeypatch.setattr(
|
||||
wormhole_settings,
|
||||
"read_wormhole_settings",
|
||||
lambda: {
|
||||
"enabled": True,
|
||||
"privacy_profile": "default",
|
||||
"transport": "direct",
|
||||
"anonymous_mode": True,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
wormhole_status,
|
||||
"read_wormhole_status",
|
||||
lambda: {
|
||||
"running": True,
|
||||
"ready": True,
|
||||
"transport_active": "direct",
|
||||
},
|
||||
)
|
||||
|
||||
async def call_next(_request: Request) -> Response:
|
||||
return Response(status_code=200)
|
||||
|
||||
response = asyncio.run(main.enforce_high_privacy_mesh(_request("/api/mesh/send"), call_next))
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
|
||||
assert response.status_code == 428
|
||||
assert "hidden Wormhole transport" in payload["detail"]
|
||||
|
||||
|
||||
def test_anonymous_mode_allows_public_mesh_write_when_hidden_transport_ready(monkeypatch):
|
||||
import main
|
||||
from services import wormhole_settings, wormhole_status
|
||||
|
||||
monkeypatch.setattr(
|
||||
wormhole_settings,
|
||||
"read_wormhole_settings",
|
||||
lambda: {
|
||||
"enabled": True,
|
||||
"privacy_profile": "default",
|
||||
"transport": "tor",
|
||||
"anonymous_mode": True,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
wormhole_status,
|
||||
"read_wormhole_status",
|
||||
lambda: {
|
||||
"running": True,
|
||||
"ready": True,
|
||||
"transport_active": "tor",
|
||||
},
|
||||
)
|
||||
called = {"value": False}
|
||||
|
||||
async def call_next(_request: Request) -> Response:
|
||||
called["value"] = True
|
||||
return Response(status_code=200)
|
||||
|
||||
response = asyncio.run(main.enforce_high_privacy_mesh(_request("/api/mesh/send"), call_next))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert called["value"] is True
|
||||
|
||||
|
||||
def test_anonymous_mode_treats_tor_arti_as_hidden_transport(monkeypatch):
|
||||
import main
|
||||
from services import wormhole_settings, wormhole_status
|
||||
|
||||
monkeypatch.setattr(
|
||||
wormhole_settings,
|
||||
"read_wormhole_settings",
|
||||
lambda: {
|
||||
"enabled": True,
|
||||
"privacy_profile": "default",
|
||||
"transport": "tor_arti",
|
||||
"anonymous_mode": True,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
wormhole_status,
|
||||
"read_wormhole_status",
|
||||
lambda: {
|
||||
"running": True,
|
||||
"ready": True,
|
||||
"transport_active": "tor_arti",
|
||||
},
|
||||
)
|
||||
called = {"value": False}
|
||||
|
||||
async def call_next(_request: Request) -> Response:
|
||||
called["value"] = True
|
||||
return Response(status_code=200)
|
||||
|
||||
response = asyncio.run(main.enforce_high_privacy_mesh(_request("/api/mesh/send"), call_next))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert called["value"] is True
|
||||
|
||||
|
||||
def test_anonymous_mode_does_not_block_read_only_mesh_requests(monkeypatch):
|
||||
import main
|
||||
from services import wormhole_settings, wormhole_status
|
||||
|
||||
monkeypatch.setattr(
|
||||
wormhole_settings,
|
||||
"read_wormhole_settings",
|
||||
lambda: {
|
||||
"enabled": True,
|
||||
"privacy_profile": "default",
|
||||
"transport": "direct",
|
||||
"anonymous_mode": True,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
wormhole_status,
|
||||
"read_wormhole_status",
|
||||
lambda: {
|
||||
"running": False,
|
||||
"ready": False,
|
||||
"transport_active": "direct",
|
||||
},
|
||||
)
|
||||
called = {"value": False}
|
||||
|
||||
async def call_next(_request: Request) -> Response:
|
||||
called["value"] = True
|
||||
return Response(status_code=200)
|
||||
|
||||
response = asyncio.run(
|
||||
main.enforce_high_privacy_mesh(_request("/api/mesh/status", method="GET"), call_next)
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert called["value"] is True
|
||||
|
||||
|
||||
def test_anonymous_mode_blocks_private_dm_actions_without_hidden_transport(monkeypatch):
|
||||
import main
|
||||
from services import wormhole_settings, wormhole_status, wormhole_supervisor
|
||||
|
||||
monkeypatch.setattr(
|
||||
wormhole_settings,
|
||||
"read_wormhole_settings",
|
||||
lambda: {
|
||||
"enabled": True,
|
||||
"privacy_profile": "default",
|
||||
"transport": "direct",
|
||||
"anonymous_mode": True,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
wormhole_status,
|
||||
"read_wormhole_status",
|
||||
lambda: {
|
||||
"running": True,
|
||||
"ready": True,
|
||||
"transport_active": "direct",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
wormhole_supervisor,
|
||||
"get_wormhole_state",
|
||||
lambda: {
|
||||
"configured": True,
|
||||
"ready": True,
|
||||
"arti_ready": True,
|
||||
"rns_ready": True,
|
||||
},
|
||||
)
|
||||
|
||||
async def call_next(_request: Request) -> Response:
|
||||
return Response(status_code=200)
|
||||
|
||||
response = asyncio.run(main.enforce_high_privacy_mesh(_request("/api/mesh/dm/send"), call_next))
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
|
||||
assert response.status_code == 428
|
||||
assert "private DM activity" in payload["detail"]
|
||||
|
||||
|
||||
def test_anonymous_mode_allows_private_dm_actions_when_hidden_transport_ready(monkeypatch):
|
||||
import main
|
||||
from services import wormhole_settings, wormhole_status, wormhole_supervisor
|
||||
|
||||
monkeypatch.setattr(
|
||||
wormhole_settings,
|
||||
"read_wormhole_settings",
|
||||
lambda: {
|
||||
"enabled": True,
|
||||
"privacy_profile": "default",
|
||||
"transport": "tor",
|
||||
"anonymous_mode": True,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
wormhole_status,
|
||||
"read_wormhole_status",
|
||||
lambda: {
|
||||
"running": True,
|
||||
"ready": True,
|
||||
"transport_active": "tor",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
wormhole_supervisor,
|
||||
"get_wormhole_state",
|
||||
lambda: {
|
||||
"configured": True,
|
||||
"ready": True,
|
||||
"arti_ready": True,
|
||||
"rns_ready": True,
|
||||
},
|
||||
)
|
||||
called = {"value": False}
|
||||
|
||||
async def call_next(_request: Request) -> Response:
|
||||
called["value"] = True
|
||||
return Response(status_code=200)
|
||||
|
||||
response = asyncio.run(main.enforce_high_privacy_mesh(_request("/api/mesh/dm/poll"), call_next))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert called["value"] is True
|
||||
|
||||
|
||||
def test_anonymous_mode_blocks_dm_witness_and_block_without_hidden_transport(monkeypatch):
|
||||
import main
|
||||
from services import wormhole_settings, wormhole_status, wormhole_supervisor
|
||||
|
||||
monkeypatch.setattr(
|
||||
wormhole_settings,
|
||||
"read_wormhole_settings",
|
||||
lambda: {
|
||||
"enabled": True,
|
||||
"privacy_profile": "default",
|
||||
"transport": "direct",
|
||||
"anonymous_mode": True,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
wormhole_status,
|
||||
"read_wormhole_status",
|
||||
lambda: {
|
||||
"running": True,
|
||||
"ready": True,
|
||||
"transport_active": "direct",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
wormhole_supervisor,
|
||||
"get_wormhole_state",
|
||||
lambda: {
|
||||
"configured": True,
|
||||
"ready": True,
|
||||
"arti_ready": True,
|
||||
"rns_ready": True,
|
||||
},
|
||||
)
|
||||
|
||||
async def call_next(_request: Request) -> Response:
|
||||
return Response(status_code=200)
|
||||
|
||||
block_response = asyncio.run(
|
||||
main.enforce_high_privacy_mesh(_request("/api/mesh/dm/block"), call_next)
|
||||
)
|
||||
witness_response = asyncio.run(
|
||||
main.enforce_high_privacy_mesh(_request("/api/mesh/dm/witness"), call_next)
|
||||
)
|
||||
|
||||
assert block_response.status_code == 428
|
||||
assert witness_response.status_code == 428
|
||||
|
||||
|
||||
def test_anonymous_mode_blocks_public_vouch_without_hidden_transport(monkeypatch):
|
||||
import main
|
||||
from services import wormhole_settings, wormhole_status, wormhole_supervisor
|
||||
|
||||
monkeypatch.setattr(
|
||||
wormhole_settings,
|
||||
"read_wormhole_settings",
|
||||
lambda: {
|
||||
"enabled": True,
|
||||
"privacy_profile": "default",
|
||||
"transport": "direct",
|
||||
"anonymous_mode": True,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
wormhole_status,
|
||||
"read_wormhole_status",
|
||||
lambda: {
|
||||
"running": True,
|
||||
"ready": True,
|
||||
"transport_active": "direct",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
wormhole_supervisor,
|
||||
"get_wormhole_state",
|
||||
lambda: {
|
||||
"configured": True,
|
||||
"ready": True,
|
||||
"arti_ready": True,
|
||||
"rns_ready": False,
|
||||
},
|
||||
)
|
||||
|
||||
async def call_next(_request: Request) -> Response:
|
||||
return Response(status_code=200)
|
||||
|
||||
response = asyncio.run(
|
||||
main.enforce_high_privacy_mesh(_request("/api/mesh/trust/vouch"), call_next)
|
||||
)
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
|
||||
assert response.status_code == 428
|
||||
assert "hidden Wormhole transport" in payload["detail"]
|
||||
|
||||
|
||||
def test_private_infonet_gate_write_requires_wormhole_ready_but_not_rns(monkeypatch):
|
||||
import main
|
||||
from services import wormhole_supervisor
|
||||
|
||||
monkeypatch.setattr(
|
||||
wormhole_supervisor,
|
||||
"get_wormhole_state",
|
||||
lambda: {
|
||||
"configured": True,
|
||||
"ready": True,
|
||||
"arti_ready": True,
|
||||
"rns_ready": False,
|
||||
},
|
||||
)
|
||||
|
||||
async def call_next(_request: Request) -> Response:
|
||||
return Response(status_code=200)
|
||||
|
||||
response = asyncio.run(
|
||||
main.enforce_high_privacy_mesh(_request("/api/mesh/gate/test-gate/message"), call_next)
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_private_infonet_gate_write_blocks_when_wormhole_not_ready(monkeypatch):
|
||||
import main
|
||||
from services import wormhole_supervisor
|
||||
|
||||
monkeypatch.setattr(
|
||||
wormhole_supervisor,
|
||||
"get_wormhole_state",
|
||||
lambda: {
|
||||
"configured": True,
|
||||
"ready": False,
|
||||
"rns_ready": True,
|
||||
},
|
||||
)
|
||||
|
||||
async def call_next(_request: Request) -> Response:
|
||||
return Response(status_code=200)
|
||||
|
||||
response = asyncio.run(
|
||||
main.enforce_high_privacy_mesh(_request("/api/mesh/gate/test-gate/message"), call_next)
|
||||
)
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
|
||||
assert response.status_code == 428
|
||||
assert payload == {
|
||||
"ok": False,
|
||||
"detail": "transport tier insufficient",
|
||||
"required": "private_transitional",
|
||||
"current": "public_degraded",
|
||||
}
|
||||
|
||||
|
||||
def test_private_dm_send_blocks_at_transitional_tier(monkeypatch):
|
||||
import main
|
||||
from services import wormhole_settings, wormhole_supervisor
|
||||
|
||||
monkeypatch.setattr(
|
||||
wormhole_settings,
|
||||
"read_wormhole_settings",
|
||||
lambda: {
|
||||
"enabled": True,
|
||||
"privacy_profile": "default",
|
||||
"transport": "direct",
|
||||
"anonymous_mode": False,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
wormhole_supervisor,
|
||||
"get_wormhole_state",
|
||||
lambda: {
|
||||
"configured": True,
|
||||
"ready": True,
|
||||
"arti_ready": True,
|
||||
"rns_ready": False,
|
||||
},
|
||||
)
|
||||
|
||||
async def call_next(_request: Request) -> Response:
|
||||
return Response(status_code=200)
|
||||
|
||||
response = asyncio.run(main.enforce_high_privacy_mesh(_request("/api/mesh/dm/send"), call_next))
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
|
||||
assert response.status_code == 428
|
||||
assert payload == {
|
||||
"ok": False,
|
||||
"detail": "transport tier insufficient",
|
||||
"required": "private_strong",
|
||||
"current": "private_transitional",
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
from services.mesh.mesh_bootstrap_manifest import (
|
||||
BOOTSTRAP_MANIFEST_VERSION,
|
||||
BootstrapManifestError,
|
||||
bootstrap_signer_public_key_b64,
|
||||
build_bootstrap_manifest_payload,
|
||||
generate_bootstrap_signer,
|
||||
load_bootstrap_manifest,
|
||||
write_signed_bootstrap_manifest,
|
||||
)
|
||||
from services.mesh.mesh_crypto import canonical_json
|
||||
|
||||
|
||||
def _write_signed_manifest(
|
||||
path,
|
||||
*,
|
||||
private_key,
|
||||
peers,
|
||||
issued_at=1_700_000_000,
|
||||
valid_until=1_800_000_000,
|
||||
signer_id="bootstrap-test",
|
||||
):
|
||||
payload = {
|
||||
"version": BOOTSTRAP_MANIFEST_VERSION,
|
||||
"issued_at": issued_at,
|
||||
"valid_until": valid_until,
|
||||
"signer_id": signer_id,
|
||||
"peers": peers,
|
||||
}
|
||||
signature = base64.b64encode(private_key.sign(canonical_json(payload).encode("utf-8"))).decode("utf-8")
|
||||
manifest = dict(payload)
|
||||
manifest["signature"] = signature
|
||||
path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
return manifest
|
||||
|
||||
|
||||
def test_load_bootstrap_manifest_roundtrip(tmp_path):
|
||||
private_key = ed25519.Ed25519PrivateKey.generate()
|
||||
public_key_b64 = base64.b64encode(
|
||||
private_key.public_key().public_bytes(
|
||||
serialization.Encoding.Raw,
|
||||
serialization.PublicFormat.Raw,
|
||||
)
|
||||
).decode("utf-8")
|
||||
manifest_path = tmp_path / "bootstrap.json"
|
||||
_write_signed_manifest(
|
||||
manifest_path,
|
||||
private_key=private_key,
|
||||
peers=[
|
||||
{
|
||||
"peer_url": "https://seed.example",
|
||||
"transport": "clearnet",
|
||||
"role": "seed",
|
||||
"label": "Primary seed",
|
||||
},
|
||||
{
|
||||
"peer_url": "http://alphaexample.onion",
|
||||
"transport": "onion",
|
||||
"role": "relay",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
manifest = load_bootstrap_manifest(
|
||||
manifest_path,
|
||||
signer_public_key_b64=public_key_b64,
|
||||
now=1_750_000_000,
|
||||
)
|
||||
|
||||
assert manifest.signer_id == "bootstrap-test"
|
||||
assert [peer.peer_url for peer in manifest.peers] == [
|
||||
"https://seed.example",
|
||||
"http://alphaexample.onion",
|
||||
]
|
||||
assert [peer.transport for peer in manifest.peers] == ["clearnet", "onion"]
|
||||
|
||||
|
||||
def test_load_bootstrap_manifest_fails_on_tamper(tmp_path):
|
||||
private_key = ed25519.Ed25519PrivateKey.generate()
|
||||
public_key_b64 = base64.b64encode(
|
||||
private_key.public_key().public_bytes(
|
||||
serialization.Encoding.Raw,
|
||||
serialization.PublicFormat.Raw,
|
||||
)
|
||||
).decode("utf-8")
|
||||
manifest_path = tmp_path / "bootstrap.json"
|
||||
manifest = _write_signed_manifest(
|
||||
manifest_path,
|
||||
private_key=private_key,
|
||||
peers=[
|
||||
{
|
||||
"peer_url": "https://seed.example",
|
||||
"transport": "clearnet",
|
||||
"role": "seed",
|
||||
}
|
||||
],
|
||||
)
|
||||
manifest["peers"][0]["peer_url"] = "https://evil.example"
|
||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
|
||||
with pytest.raises(BootstrapManifestError, match="signature invalid"):
|
||||
load_bootstrap_manifest(
|
||||
manifest_path,
|
||||
signer_public_key_b64=public_key_b64,
|
||||
now=1_750_000_000,
|
||||
)
|
||||
|
||||
|
||||
def test_load_bootstrap_manifest_rejects_expired_manifest(tmp_path):
|
||||
private_key = ed25519.Ed25519PrivateKey.generate()
|
||||
public_key_b64 = base64.b64encode(
|
||||
private_key.public_key().public_bytes(
|
||||
serialization.Encoding.Raw,
|
||||
serialization.PublicFormat.Raw,
|
||||
)
|
||||
).decode("utf-8")
|
||||
manifest_path = tmp_path / "bootstrap.json"
|
||||
_write_signed_manifest(
|
||||
manifest_path,
|
||||
private_key=private_key,
|
||||
peers=[
|
||||
{
|
||||
"peer_url": "https://seed.example",
|
||||
"transport": "clearnet",
|
||||
"role": "seed",
|
||||
}
|
||||
],
|
||||
issued_at=100,
|
||||
valid_until=200,
|
||||
)
|
||||
|
||||
with pytest.raises(BootstrapManifestError, match="expired"):
|
||||
load_bootstrap_manifest(
|
||||
manifest_path,
|
||||
signer_public_key_b64=public_key_b64,
|
||||
now=500,
|
||||
)
|
||||
|
||||
|
||||
def test_generate_bootstrap_signer_roundtrip():
|
||||
signer = generate_bootstrap_signer()
|
||||
assert signer["private_key_b64"]
|
||||
assert signer["public_key_b64"]
|
||||
assert bootstrap_signer_public_key_b64(signer["private_key_b64"]) == signer["public_key_b64"]
|
||||
|
||||
|
||||
def test_write_signed_bootstrap_manifest_roundtrip(tmp_path):
|
||||
signer = generate_bootstrap_signer()
|
||||
manifest_path = tmp_path / "bootstrap.json"
|
||||
|
||||
manifest = write_signed_bootstrap_manifest(
|
||||
manifest_path,
|
||||
signer_id="seed-alpha",
|
||||
signer_private_key_b64=signer["private_key_b64"],
|
||||
peers=[
|
||||
{
|
||||
"peer_url": "https://seed.example",
|
||||
"transport": "clearnet",
|
||||
"role": "seed",
|
||||
"label": "Primary seed",
|
||||
}
|
||||
],
|
||||
valid_for_hours=24,
|
||||
)
|
||||
|
||||
loaded = load_bootstrap_manifest(
|
||||
manifest_path,
|
||||
signer_public_key_b64=signer["public_key_b64"],
|
||||
now=manifest.issued_at + 60,
|
||||
)
|
||||
|
||||
assert loaded.signer_id == "seed-alpha"
|
||||
assert [peer.peer_url for peer in loaded.peers] == ["https://seed.example"]
|
||||
|
||||
|
||||
def test_build_bootstrap_manifest_payload_rejects_invalid_peers():
|
||||
with pytest.raises(BootstrapManifestError, match="clearnet bootstrap peers must use https://"):
|
||||
build_bootstrap_manifest_payload(
|
||||
signer_id="seed-alpha",
|
||||
peers=[
|
||||
{
|
||||
"peer_url": "http://seed.example",
|
||||
"transport": "clearnet",
|
||||
"role": "seed",
|
||||
}
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from services.mesh.mesh_crypto import build_signature_payload
|
||||
|
||||
|
||||
def test_mesh_canonical_fixtures() -> None:
|
||||
root = Path(__file__).resolve().parents[3]
|
||||
fixtures_path = root / "docs" / "mesh" / "mesh-canonical-fixtures.json"
|
||||
fixtures = json.loads(fixtures_path.read_text(encoding="utf-8"))
|
||||
|
||||
for case in fixtures:
|
||||
result = build_signature_payload(
|
||||
event_type=case["event_type"],
|
||||
node_id=case["node_id"],
|
||||
sequence=case["sequence"],
|
||||
payload=case["payload"],
|
||||
)
|
||||
assert result == case["expected"], f"fixture mismatch: {case['name']}"
|
||||
@@ -0,0 +1,51 @@
|
||||
import base64
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, ed25519
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
|
||||
from services.mesh.mesh_crypto import build_signature_payload, verify_signature
|
||||
|
||||
|
||||
def test_ed25519_signature_roundtrip():
|
||||
key = ed25519.Ed25519PrivateKey.generate()
|
||||
pub_raw = key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
public_key_b64 = base64.b64encode(pub_raw).decode("utf-8")
|
||||
|
||||
payload = {"message": "hello", "destination": "broadcast", "channel": "LongFast"}
|
||||
sig_payload = build_signature_payload(
|
||||
event_type="message",
|
||||
node_id="!sb_test",
|
||||
sequence=1,
|
||||
payload=payload,
|
||||
)
|
||||
signature = key.sign(sig_payload.encode("utf-8")).hex()
|
||||
|
||||
assert verify_signature(
|
||||
public_key_b64=public_key_b64,
|
||||
public_key_algo="Ed25519",
|
||||
signature_hex=signature,
|
||||
payload=sig_payload,
|
||||
)
|
||||
|
||||
|
||||
def test_ecdsa_signature_roundtrip():
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
pub_raw = key.public_key().public_bytes(Encoding.X962, PublicFormat.UncompressedPoint)
|
||||
public_key_b64 = base64.b64encode(pub_raw).decode("utf-8")
|
||||
|
||||
payload = {"target_id": "!sb_abc12345", "vote": 1, "gate": ""}
|
||||
sig_payload = build_signature_payload(
|
||||
event_type="vote",
|
||||
node_id="!sb_test",
|
||||
sequence=5,
|
||||
payload=payload,
|
||||
)
|
||||
signature = key.sign(sig_payload.encode("utf-8"), ec.ECDSA(hashes.SHA256())).hex()
|
||||
|
||||
assert verify_signature(
|
||||
public_key_b64=public_key_b64,
|
||||
public_key_algo="ECDSA_P256",
|
||||
signature_hex=signature,
|
||||
payload=sig_payload,
|
||||
)
|
||||
@@ -0,0 +1,280 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
|
||||
def _json_request(path: str, body: dict) -> Request:
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
sent = {"value": False}
|
||||
|
||||
async def receive():
|
||||
if sent["value"]:
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
sent["value"] = True
|
||||
return {"type": "http.request", "body": payload, "more_body": False}
|
||||
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"client": ("test", 12345),
|
||||
"method": "POST",
|
||||
"path": path,
|
||||
},
|
||||
receive,
|
||||
)
|
||||
|
||||
|
||||
def test_dm_send_keeps_encrypted_payloads_off_ledger(monkeypatch):
|
||||
import main
|
||||
from services import wormhole_supervisor
|
||||
from services.mesh import mesh_hashchain, mesh_dm_relay
|
||||
|
||||
append_called = {"value": False}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_verify_signed_event",
|
||||
lambda **kwargs: (True, ""),
|
||||
)
|
||||
monkeypatch.setattr(main, "_secure_dm_enabled", lambda: False)
|
||||
monkeypatch.setattr(wormhole_supervisor, "get_transport_tier", lambda: "private_transitional")
|
||||
|
||||
def fake_append(**kwargs):
|
||||
append_called["value"] = True
|
||||
return {"event_id": "unexpected"}
|
||||
|
||||
monkeypatch.setattr(mesh_hashchain.infonet, "append", fake_append)
|
||||
monkeypatch.setattr(mesh_hashchain.infonet, "validate_and_set_sequence", lambda *_args, **_kwargs: (True, ""))
|
||||
monkeypatch.setattr(mesh_dm_relay.dm_relay, "consume_nonce", lambda *_args, **_kwargs: (True, "ok"))
|
||||
monkeypatch.setattr(
|
||||
mesh_dm_relay.dm_relay,
|
||||
"deposit",
|
||||
lambda **kwargs: {
|
||||
"ok": True,
|
||||
"msg_id": kwargs.get("msg_id", ""),
|
||||
"detail": "stored",
|
||||
},
|
||||
)
|
||||
|
||||
req = _json_request(
|
||||
"/api/mesh/dm/send",
|
||||
{
|
||||
"sender_id": "alice",
|
||||
"recipient_id": "bob",
|
||||
"delivery_class": "request",
|
||||
"recipient_token": "",
|
||||
"ciphertext": "x3dh1:opaque",
|
||||
"msg_id": "m1",
|
||||
"timestamp": int(time.time()),
|
||||
"public_key": "cHVi",
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 1,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
|
||||
response = asyncio.run(main.dm_send(req))
|
||||
|
||||
assert response["ok"] is True
|
||||
assert append_called["value"] is False
|
||||
|
||||
|
||||
def test_dm_key_registration_keeps_key_material_off_ledger(monkeypatch):
|
||||
import main
|
||||
from services.mesh import mesh_hashchain, mesh_dm_relay
|
||||
|
||||
append_called = {"value": False}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_verify_signed_event",
|
||||
lambda **kwargs: (True, ""),
|
||||
)
|
||||
|
||||
def fake_append(**kwargs):
|
||||
append_called["value"] = True
|
||||
return {"event_id": "unexpected"}
|
||||
|
||||
monkeypatch.setattr(mesh_hashchain.infonet, "append", fake_append)
|
||||
monkeypatch.setattr(
|
||||
mesh_dm_relay.dm_relay,
|
||||
"register_dh_key",
|
||||
lambda *args, **kwargs: (True, "ok", {"bundle_fingerprint": "bf", "accepted_sequence": 1}),
|
||||
)
|
||||
|
||||
req = _json_request(
|
||||
"/api/mesh/dm/register",
|
||||
{
|
||||
"agent_id": "alice",
|
||||
"dh_pub_key": "dhpub",
|
||||
"dh_algo": "X25519",
|
||||
"timestamp": int(time.time()),
|
||||
"public_key": "cHVi",
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 1,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
|
||||
response = asyncio.run(main.dm_register_key(req))
|
||||
|
||||
assert response["ok"] is True
|
||||
assert append_called["value"] is False
|
||||
|
||||
|
||||
def test_wormhole_dm_key_registration_keeps_key_material_off_ledger(tmp_path, monkeypatch):
|
||||
import main
|
||||
from services.mesh import (
|
||||
mesh_hashchain,
|
||||
mesh_secure_storage,
|
||||
mesh_wormhole_persona,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
monkeypatch.setattr(mesh_wormhole_persona, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_wormhole_persona, "PERSONA_FILE", tmp_path / "wormhole_persona.json")
|
||||
monkeypatch.setattr(
|
||||
mesh_wormhole_persona,
|
||||
"LEGACY_DM_IDENTITY_FILE",
|
||||
tmp_path / "wormhole_identity.json",
|
||||
)
|
||||
|
||||
append_called = {"value": False}
|
||||
|
||||
def fake_append(**kwargs):
|
||||
append_called["value"] = True
|
||||
return {"event_id": "unexpected"}
|
||||
|
||||
monkeypatch.setattr(mesh_hashchain.infonet, "append", fake_append)
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"register_wormhole_prekey_bundle",
|
||||
lambda *args, **kwargs: {"ok": True, "bundle": {}},
|
||||
)
|
||||
|
||||
response = asyncio.run(main.api_wormhole_dm_register_key(_json_request("/api/wormhole/dm/register-key", {})))
|
||||
|
||||
assert response["ok"] is True
|
||||
assert append_called["value"] is False
|
||||
|
||||
|
||||
def test_dead_drop_contact_consent_helpers_round_trip():
|
||||
from services.mesh.mesh_wormhole_dead_drop import (
|
||||
build_contact_accept,
|
||||
build_contact_deny,
|
||||
build_contact_offer,
|
||||
parse_contact_consent,
|
||||
)
|
||||
|
||||
offer = build_contact_offer(dh_pub_key="dhpub", dh_algo="X25519", geo_hint="40.12,-105.27")
|
||||
accept = build_contact_accept(shared_alias="dmx_pairwise")
|
||||
deny = build_contact_deny(reason="declined")
|
||||
|
||||
assert parse_contact_consent(offer) == {
|
||||
"kind": "contact_offer",
|
||||
"dh_pub_key": "dhpub",
|
||||
"dh_algo": "X25519",
|
||||
"geo_hint": "40.12,-105.27",
|
||||
}
|
||||
assert parse_contact_consent(accept) == {
|
||||
"kind": "contact_accept",
|
||||
"shared_alias": "dmx_pairwise",
|
||||
}
|
||||
assert parse_contact_consent(deny) == {
|
||||
"kind": "contact_deny",
|
||||
"reason": "declined",
|
||||
}
|
||||
|
||||
|
||||
def test_pairwise_alias_is_separate_from_gate_identities(tmp_path, monkeypatch):
|
||||
from services.mesh import (
|
||||
mesh_secure_storage,
|
||||
mesh_wormhole_contacts,
|
||||
mesh_wormhole_dead_drop,
|
||||
mesh_wormhole_persona,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
monkeypatch.setattr(mesh_wormhole_persona, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_wormhole_persona, "PERSONA_FILE", tmp_path / "wormhole_persona.json")
|
||||
monkeypatch.setattr(
|
||||
mesh_wormhole_persona,
|
||||
"LEGACY_DM_IDENTITY_FILE",
|
||||
tmp_path / "wormhole_identity.json",
|
||||
)
|
||||
monkeypatch.setattr(mesh_wormhole_contacts, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_wormhole_contacts, "CONTACTS_FILE", tmp_path / "wormhole_dm_contacts.json")
|
||||
|
||||
gate_session = mesh_wormhole_persona.enter_gate_anonymously("infonet", rotate=True)["identity"]
|
||||
gate_persona = mesh_wormhole_persona.create_gate_persona("infonet", label="watcher")["identity"]
|
||||
dm_identity = mesh_wormhole_persona.get_dm_identity()
|
||||
|
||||
issued = mesh_wormhole_dead_drop.issue_pairwise_dm_alias(
|
||||
peer_id="peer_alpha",
|
||||
peer_dh_pub="dhpub_alpha",
|
||||
)
|
||||
|
||||
assert issued["ok"] is True
|
||||
assert issued["identity_scope"] == "dm_alias"
|
||||
assert issued["shared_alias"].startswith("dmx_")
|
||||
assert issued["shared_alias"] != gate_session["node_id"]
|
||||
assert issued["shared_alias"] != gate_persona["node_id"]
|
||||
assert issued["shared_alias"] != dm_identity["node_id"]
|
||||
assert issued["dm_identity_id"] == dm_identity["node_id"]
|
||||
assert issued["contact"]["sharedAlias"] == issued["shared_alias"]
|
||||
assert issued["contact"]["dhPubKey"] == "dhpub_alpha"
|
||||
|
||||
|
||||
def test_pairwise_alias_rotation_promotes_after_grace(tmp_path, monkeypatch):
|
||||
from services.mesh import (
|
||||
mesh_secure_storage,
|
||||
mesh_wormhole_contacts,
|
||||
mesh_wormhole_dead_drop,
|
||||
mesh_wormhole_persona,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
monkeypatch.setattr(mesh_wormhole_persona, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_wormhole_persona, "PERSONA_FILE", tmp_path / "wormhole_persona.json")
|
||||
monkeypatch.setattr(
|
||||
mesh_wormhole_persona,
|
||||
"LEGACY_DM_IDENTITY_FILE",
|
||||
tmp_path / "wormhole_identity.json",
|
||||
)
|
||||
monkeypatch.setattr(mesh_wormhole_contacts, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_wormhole_contacts, "CONTACTS_FILE", tmp_path / "wormhole_dm_contacts.json")
|
||||
|
||||
initial = mesh_wormhole_dead_drop.issue_pairwise_dm_alias(
|
||||
peer_id="peer_beta",
|
||||
peer_dh_pub="dhpub_beta",
|
||||
)
|
||||
rotated = mesh_wormhole_dead_drop.rotate_pairwise_dm_alias(
|
||||
peer_id="peer_beta",
|
||||
peer_dh_pub="dhpub_beta",
|
||||
grace_ms=5_000,
|
||||
)
|
||||
|
||||
assert rotated["ok"] is True
|
||||
assert rotated["active_alias"] == initial["shared_alias"]
|
||||
assert rotated["pending_alias"].startswith("dmx_")
|
||||
assert rotated["pending_alias"] != initial["shared_alias"]
|
||||
assert rotated["contact"]["sharedAlias"] == initial["shared_alias"]
|
||||
assert rotated["contact"]["pendingSharedAlias"] == rotated["pending_alias"]
|
||||
assert rotated["contact"]["sharedAliasGraceUntil"] >= rotated["grace_until"]
|
||||
|
||||
future = rotated["grace_until"] / 1000.0 + 1
|
||||
monkeypatch.setattr(mesh_wormhole_contacts.time, "time", lambda: future)
|
||||
promoted = mesh_wormhole_contacts.list_wormhole_dm_contacts()["peer_beta"]
|
||||
|
||||
assert promoted["sharedAlias"] == rotated["pending_alias"]
|
||||
assert promoted["pendingSharedAlias"] == ""
|
||||
assert promoted["sharedAliasGraceUntil"] == 0
|
||||
assert initial["shared_alias"] in promoted["previousSharedAliases"]
|
||||
@@ -0,0 +1,318 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
REQUEST_CLAIMS = [{"type": "requests", "token": "request-claim-token"}]
|
||||
|
||||
|
||||
def _fresh_dm_mls_state(tmp_path, monkeypatch):
|
||||
from services import wormhole_supervisor
|
||||
from services.mesh import mesh_dm_mls, mesh_dm_relay, mesh_secure_storage, mesh_wormhole_persona
|
||||
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
monkeypatch.setattr(mesh_wormhole_persona, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_wormhole_persona, "PERSONA_FILE", tmp_path / "wormhole_persona.json")
|
||||
monkeypatch.setattr(
|
||||
mesh_wormhole_persona,
|
||||
"LEGACY_DM_IDENTITY_FILE",
|
||||
tmp_path / "wormhole_identity.json",
|
||||
)
|
||||
monkeypatch.setattr(mesh_dm_mls, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_dm_mls, "STATE_FILE", tmp_path / "wormhole_dm_mls.json")
|
||||
monkeypatch.setattr(mesh_dm_relay, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_dm_relay, "RELAY_FILE", tmp_path / "dm_relay.json")
|
||||
monkeypatch.setattr(
|
||||
mesh_dm_mls,
|
||||
"get_wormhole_state",
|
||||
lambda: {"configured": True, "ready": True, "arti_ready": True, "rns_ready": False},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
wormhole_supervisor,
|
||||
"get_wormhole_state",
|
||||
lambda: {"configured": True, "ready": True, "arti_ready": True, "rns_ready": False},
|
||||
)
|
||||
relay = mesh_dm_relay.DMRelay()
|
||||
monkeypatch.setattr(mesh_dm_relay, "dm_relay", relay)
|
||||
mesh_dm_mls.reset_dm_mls_state(clear_privacy_core=True, clear_persistence=True)
|
||||
return mesh_dm_mls, relay
|
||||
|
||||
|
||||
def test_dm_mls_initiate_accept_encrypt_decrypt_round_trip(tmp_path, monkeypatch):
|
||||
dm_mls, _relay = _fresh_dm_mls_state(tmp_path, monkeypatch)
|
||||
|
||||
bob_bundle = dm_mls.export_dm_key_package_for_alias("bob")
|
||||
assert bob_bundle["ok"] is True
|
||||
assert bob_bundle["welcome_dh_pub"]
|
||||
|
||||
initiated = dm_mls.initiate_dm_session("alice", "bob", bob_bundle)
|
||||
assert initiated["ok"] is True
|
||||
assert initiated["welcome"]
|
||||
|
||||
accepted = dm_mls.accept_dm_session("bob", "alice", initiated["welcome"])
|
||||
assert accepted["ok"] is True
|
||||
|
||||
encrypted = dm_mls.encrypt_dm("alice", "bob", "hello bob")
|
||||
assert encrypted["ok"] is True
|
||||
decrypted = dm_mls.decrypt_dm("bob", "alice", encrypted["ciphertext"], encrypted["nonce"])
|
||||
assert decrypted == {
|
||||
"ok": True,
|
||||
"plaintext": "hello bob",
|
||||
"session_id": accepted["session_id"],
|
||||
"nonce": encrypted["nonce"],
|
||||
}
|
||||
|
||||
encrypted_back = dm_mls.encrypt_dm("bob", "alice", "hello alice")
|
||||
assert encrypted_back["ok"] is True
|
||||
decrypted_back = dm_mls.decrypt_dm(
|
||||
"alice",
|
||||
"bob",
|
||||
encrypted_back["ciphertext"],
|
||||
encrypted_back["nonce"],
|
||||
)
|
||||
assert decrypted_back["ok"] is True
|
||||
assert decrypted_back["plaintext"] == "hello alice"
|
||||
|
||||
|
||||
def test_dm_mls_lock_rejects_legacy_dm1_decrypt(tmp_path, monkeypatch):
|
||||
import main
|
||||
|
||||
dm_mls, _relay = _fresh_dm_mls_state(tmp_path, monkeypatch)
|
||||
|
||||
bob_bundle = dm_mls.export_dm_key_package_for_alias("bob")
|
||||
initiated = dm_mls.initiate_dm_session("alice", "bob", bob_bundle)
|
||||
accepted = dm_mls.accept_dm_session("bob", "alice", initiated["welcome"])
|
||||
assert accepted["ok"] is True
|
||||
|
||||
encrypted = dm_mls.encrypt_dm("alice", "bob", "lock me in")
|
||||
assert encrypted["ok"] is True
|
||||
|
||||
first_decrypt = main.decrypt_wormhole_dm_envelope(
|
||||
peer_id="alice-agent",
|
||||
local_alias="bob",
|
||||
remote_alias="alice",
|
||||
ciphertext=encrypted["ciphertext"],
|
||||
payload_format="mls1",
|
||||
nonce=encrypted["nonce"],
|
||||
)
|
||||
assert first_decrypt["ok"] is True
|
||||
assert dm_mls.is_dm_locked_to_mls("bob", "alice") is True
|
||||
|
||||
locked = main.decrypt_wormhole_dm_envelope(
|
||||
peer_id="alice-agent",
|
||||
local_alias="bob",
|
||||
remote_alias="alice",
|
||||
ciphertext="legacy-ciphertext",
|
||||
payload_format="dm1",
|
||||
nonce="legacy-nonce",
|
||||
)
|
||||
assert locked == {
|
||||
"ok": False,
|
||||
"detail": "DM session is locked to MLS format",
|
||||
"required_format": "mls1",
|
||||
"current_format": "dm1",
|
||||
}
|
||||
|
||||
|
||||
def test_dm_mls_refuses_public_degraded_transport(tmp_path, monkeypatch):
|
||||
dm_mls, _relay = _fresh_dm_mls_state(tmp_path, monkeypatch)
|
||||
|
||||
monkeypatch.setattr(
|
||||
dm_mls,
|
||||
"get_wormhole_state",
|
||||
lambda: {"configured": False, "ready": False, "rns_ready": False},
|
||||
)
|
||||
|
||||
result = dm_mls.initiate_dm_session(
|
||||
"alice",
|
||||
"bob",
|
||||
{"mls_key_package": "ZmFrZQ=="},
|
||||
)
|
||||
|
||||
assert result == {"ok": False, "detail": "DM MLS requires PRIVATE transport tier"}
|
||||
|
||||
|
||||
def test_dm_mls_session_persistence_survives_same_process_restart(tmp_path, monkeypatch):
|
||||
dm_mls, _relay = _fresh_dm_mls_state(tmp_path, monkeypatch)
|
||||
|
||||
bob_bundle = dm_mls.export_dm_key_package_for_alias("bob")
|
||||
initiated = dm_mls.initiate_dm_session("alice", "bob", bob_bundle)
|
||||
accepted = dm_mls.accept_dm_session("bob", "alice", initiated["welcome"])
|
||||
|
||||
dm_mls.reset_dm_mls_state(clear_privacy_core=False, clear_persistence=False)
|
||||
|
||||
encrypted = dm_mls.encrypt_dm("alice", "bob", "persisted hello")
|
||||
assert encrypted["ok"] is True
|
||||
decrypted = dm_mls.decrypt_dm("bob", "alice", encrypted["ciphertext"], encrypted["nonce"])
|
||||
assert decrypted["ok"] is True
|
||||
assert decrypted["plaintext"] == "persisted hello"
|
||||
assert decrypted["session_id"] == accepted["session_id"]
|
||||
|
||||
|
||||
def test_dm_mls_encrypt_detects_stale_session_after_privacy_core_reset(tmp_path, monkeypatch):
|
||||
dm_mls, _relay = _fresh_dm_mls_state(tmp_path, monkeypatch)
|
||||
|
||||
bob_bundle = dm_mls.export_dm_key_package_for_alias("bob")
|
||||
initiated = dm_mls.initiate_dm_session("alice", "bob", bob_bundle)
|
||||
accepted = dm_mls.accept_dm_session("bob", "alice", initiated["welcome"])
|
||||
assert accepted["ok"] is True
|
||||
|
||||
dm_mls.reset_dm_mls_state(clear_privacy_core=True, clear_persistence=False)
|
||||
|
||||
expired = dm_mls.encrypt_dm("alice", "bob", "stale handle")
|
||||
assert expired == {
|
||||
"ok": False,
|
||||
"detail": "session_expired",
|
||||
"session_id": "alice::bob",
|
||||
}
|
||||
assert dm_mls.has_dm_session("alice", "bob") == {
|
||||
"ok": True,
|
||||
"exists": False,
|
||||
"session_id": "alice::bob",
|
||||
}
|
||||
|
||||
|
||||
def test_dm_mls_recreates_alias_identity_when_binding_proof_is_tampered(tmp_path, monkeypatch, caplog):
|
||||
import logging
|
||||
|
||||
from services.mesh.mesh_secure_storage import read_domain_json, write_domain_json
|
||||
|
||||
dm_mls, _relay = _fresh_dm_mls_state(tmp_path, monkeypatch)
|
||||
|
||||
first_bundle = dm_mls.export_dm_key_package_for_alias("alice")
|
||||
assert first_bundle["ok"] is True
|
||||
|
||||
stored = read_domain_json(dm_mls.STATE_DOMAIN, dm_mls.STATE_FILENAME, dm_mls._default_state)
|
||||
original_handle = int(stored["aliases"]["alice"]["handle"])
|
||||
stored["aliases"]["alice"]["binding_proof"] = "00" * 64
|
||||
write_domain_json(dm_mls.STATE_DOMAIN, dm_mls.STATE_FILENAME, stored)
|
||||
|
||||
dm_mls.reset_dm_mls_state(clear_privacy_core=False, clear_persistence=False)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
second_bundle = dm_mls.export_dm_key_package_for_alias("alice")
|
||||
|
||||
reloaded = read_domain_json(dm_mls.STATE_DOMAIN, dm_mls.STATE_FILENAME, dm_mls._default_state)
|
||||
assert second_bundle["ok"] is True
|
||||
assert "dm mls alias binding invalid for alice" in caplog.text.lower()
|
||||
assert int(reloaded["aliases"]["alice"]["handle"]) != original_handle
|
||||
|
||||
|
||||
def test_dm_mls_http_compose_store_poll_decrypt_round_trip(tmp_path, monkeypatch):
|
||||
import main
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from services.mesh import mesh_hashchain
|
||||
from services import wormhole_supervisor
|
||||
|
||||
dm_mls, relay = _fresh_dm_mls_state(tmp_path, monkeypatch)
|
||||
bob_bundle = dm_mls.export_dm_key_package_for_alias("bob")
|
||||
assert bob_bundle["ok"] is True
|
||||
|
||||
monkeypatch.setattr(main, "_current_admin_key", lambda: "test-admin")
|
||||
monkeypatch.setattr(main, "_verify_signed_event", lambda **_kwargs: (True, "ok"))
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_verify_dm_mailbox_request",
|
||||
lambda **_kwargs: (
|
||||
True,
|
||||
"ok",
|
||||
{"mailbox_claims": REQUEST_CLAIMS},
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(main, "_secure_dm_enabled", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
dm_mls,
|
||||
"get_wormhole_state",
|
||||
lambda: {"configured": True, "ready": True, "arti_ready": True, "rns_ready": True},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
wormhole_supervisor,
|
||||
"get_wormhole_state",
|
||||
lambda: {"configured": True, "ready": True, "arti_ready": True, "rns_ready": True},
|
||||
)
|
||||
monkeypatch.setattr(mesh_hashchain.infonet, "validate_and_set_sequence", lambda *_args, **_kwargs: (True, "ok"))
|
||||
|
||||
admin_headers = {"X-Admin-Key": main._current_admin_key()}
|
||||
|
||||
async def _run():
|
||||
async with AsyncClient(transport=ASGITransport(app=main.app), base_url="http://test") as ac:
|
||||
now = int(time.time())
|
||||
compose_response = await ac.post(
|
||||
"/api/wormhole/dm/compose",
|
||||
json={
|
||||
"peer_id": "bob-agent",
|
||||
"plaintext": "hello through http",
|
||||
"local_alias": "alice",
|
||||
"remote_alias": "bob",
|
||||
"remote_prekey_bundle": bob_bundle,
|
||||
},
|
||||
headers=admin_headers,
|
||||
)
|
||||
composed = compose_response.json()
|
||||
send_response = await ac.post(
|
||||
"/api/mesh/dm/send",
|
||||
json={
|
||||
"sender_id": "alice-agent",
|
||||
"recipient_id": "bob-agent",
|
||||
"delivery_class": "request",
|
||||
"ciphertext": composed["ciphertext"],
|
||||
"format": composed["format"],
|
||||
"session_welcome": composed["session_welcome"],
|
||||
"msg_id": "dm-mls-http-1",
|
||||
"timestamp": now,
|
||||
"nonce": "http-mls-nonce-1",
|
||||
"public_key": "cHVi",
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 11,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
poll_response = await ac.post(
|
||||
"/api/mesh/dm/poll",
|
||||
json={
|
||||
"agent_id": "bob-agent",
|
||||
"mailbox_claims": REQUEST_CLAIMS,
|
||||
"timestamp": now + 1,
|
||||
"nonce": "http-mls-nonce-2",
|
||||
"public_key": "cHVi",
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 12,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
polled = poll_response.json()
|
||||
decrypt_response = await ac.post(
|
||||
"/api/wormhole/dm/decrypt",
|
||||
json={
|
||||
"peer_id": "alice-agent",
|
||||
"local_alias": "bob",
|
||||
"remote_alias": "alice",
|
||||
"ciphertext": polled["messages"][0]["ciphertext"],
|
||||
"format": polled["messages"][0]["format"],
|
||||
"nonce": "",
|
||||
"session_welcome": polled["messages"][0]["session_welcome"],
|
||||
},
|
||||
headers=admin_headers,
|
||||
)
|
||||
return composed, send_response.json(), polled, decrypt_response.json()
|
||||
|
||||
composed, sent, polled, decrypted = asyncio.run(_run())
|
||||
|
||||
assert composed["ok"] is True
|
||||
assert composed["format"] == "mls1"
|
||||
assert sent["ok"] is True
|
||||
assert sent["msg_id"] == "dm-mls-http-1"
|
||||
assert polled["ok"] is True
|
||||
assert polled["count"] == 1
|
||||
assert polled["messages"][0]["format"] == "mls1"
|
||||
assert polled["messages"][0]["session_welcome"] == composed["session_welcome"]
|
||||
assert decrypted == {
|
||||
"ok": True,
|
||||
"peer_id": "alice-agent",
|
||||
"local_alias": "bob",
|
||||
"remote_alias": "alice",
|
||||
"plaintext": "hello through http",
|
||||
"format": "mls1",
|
||||
}
|
||||
assert relay.count_claims("bob-agent", REQUEST_CLAIMS) == 0
|
||||
@@ -0,0 +1,343 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
from services.config import get_settings
|
||||
from services.mesh import mesh_dm_relay, mesh_schema, mesh_secure_storage
|
||||
|
||||
REQUEST_CLAIM = [{"type": "requests", "token": "request-claim-token"}]
|
||||
|
||||
|
||||
def _fresh_relay(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(mesh_dm_relay, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_dm_relay, "RELAY_FILE", tmp_path / "dm_relay.json")
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
get_settings.cache_clear()
|
||||
return mesh_dm_relay.DMRelay()
|
||||
|
||||
|
||||
def test_dm_key_registration_is_monotonic(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
|
||||
ok, reason, meta = relay.register_dh_key(
|
||||
"alice",
|
||||
"pub1",
|
||||
"X25519",
|
||||
100,
|
||||
"sig1",
|
||||
"nodepub",
|
||||
"Ed25519",
|
||||
"infonet/2",
|
||||
1,
|
||||
)
|
||||
assert ok, reason
|
||||
assert meta["accepted_sequence"] == 1
|
||||
assert meta["bundle_fingerprint"]
|
||||
|
||||
ok, reason, _ = relay.register_dh_key(
|
||||
"alice",
|
||||
"pub1",
|
||||
"X25519",
|
||||
100,
|
||||
"sig1",
|
||||
"nodepub",
|
||||
"Ed25519",
|
||||
"infonet/2",
|
||||
1,
|
||||
)
|
||||
assert not ok
|
||||
assert "rollback" in reason.lower() or "replay" in reason.lower()
|
||||
|
||||
ok, reason, _ = relay.register_dh_key(
|
||||
"alice",
|
||||
"pub2",
|
||||
"X25519",
|
||||
99,
|
||||
"sig2",
|
||||
"nodepub",
|
||||
"Ed25519",
|
||||
"infonet/2",
|
||||
2,
|
||||
)
|
||||
assert not ok
|
||||
assert "older" in reason.lower()
|
||||
|
||||
ok, reason, meta = relay.register_dh_key(
|
||||
"alice",
|
||||
"pub3",
|
||||
"X25519",
|
||||
101,
|
||||
"sig3",
|
||||
"nodepub",
|
||||
"Ed25519",
|
||||
"infonet/2",
|
||||
2,
|
||||
)
|
||||
assert ok, reason
|
||||
assert meta["accepted_sequence"] == 2
|
||||
|
||||
|
||||
def test_secure_mailbox_claims_split_requests_and_shared(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
|
||||
request_result = relay.deposit(
|
||||
sender_id="alice",
|
||||
recipient_id="bob",
|
||||
ciphertext="cipher_req",
|
||||
msg_id="msg_req",
|
||||
delivery_class="request",
|
||||
)
|
||||
shared_result = relay.deposit(
|
||||
sender_id="carol",
|
||||
recipient_id="bob",
|
||||
ciphertext="cipher_shared",
|
||||
msg_id="msg_shared",
|
||||
delivery_class="shared",
|
||||
recipient_token="sharedtoken",
|
||||
)
|
||||
|
||||
assert request_result["ok"]
|
||||
assert shared_result["ok"]
|
||||
assert relay.count_legacy(agent_id="bob") == 0
|
||||
|
||||
request_claims = REQUEST_CLAIM
|
||||
shared_claims = [{"type": "shared", "token": "sharedtoken"}]
|
||||
|
||||
assert relay.count_claims("bob", request_claims) == 1
|
||||
assert relay.count_claims("bob", shared_claims) == 1
|
||||
|
||||
request_messages = relay.collect_claims("bob", request_claims)
|
||||
assert [msg["msg_id"] for msg in request_messages] == ["msg_req"]
|
||||
assert request_messages[0]["delivery_class"] == "request"
|
||||
assert relay.count_claims("bob", request_claims) == 0
|
||||
assert relay.count_claims("bob", [{"type": "requests"}]) == 0
|
||||
|
||||
shared_messages = relay.collect_claims("bob", shared_claims)
|
||||
assert [msg["msg_id"] for msg in shared_messages] == ["msg_shared"]
|
||||
assert shared_messages[0]["delivery_class"] == "shared"
|
||||
assert relay.count_claims("bob", shared_claims) == 0
|
||||
|
||||
|
||||
def test_legacy_collect_and_count_require_agent_token(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
|
||||
relay._mailboxes["legacy-token"].append(
|
||||
mesh_dm_relay.DMMessage(
|
||||
sender_id="alice",
|
||||
ciphertext="cipher",
|
||||
timestamp=time.time(),
|
||||
msg_id="legacy-1",
|
||||
delivery_class="request",
|
||||
)
|
||||
)
|
||||
|
||||
assert relay.collect_legacy(agent_id="bob") == []
|
||||
assert relay.count_legacy(agent_id="bob") == 0
|
||||
assert relay.count_legacy(agent_token="legacy-token") == 1
|
||||
|
||||
|
||||
def test_nonce_replay_and_memory_only_spool(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MESH_DM_PERSIST_SPOOL", "false")
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
|
||||
result = relay.deposit(
|
||||
sender_id="alice",
|
||||
recipient_id="bob",
|
||||
ciphertext="cipher",
|
||||
msg_id="msg1",
|
||||
delivery_class="request",
|
||||
)
|
||||
assert result["ok"]
|
||||
assert mesh_dm_relay.RELAY_FILE.exists()
|
||||
|
||||
payload = json.loads(mesh_dm_relay.RELAY_FILE.read_text(encoding="utf-8"))
|
||||
assert payload.get("kind") == "sb_secure_json"
|
||||
|
||||
restored = mesh_secure_storage.read_secure_json(mesh_dm_relay.RELAY_FILE, lambda: {})
|
||||
assert "mailboxes" not in restored
|
||||
|
||||
ok, reason = relay.consume_nonce("bob", "nonce-1", 100)
|
||||
assert ok, reason
|
||||
ok, reason = relay.consume_nonce("bob", "nonce-1", 100)
|
||||
assert not ok
|
||||
assert reason == "nonce replay detected"
|
||||
|
||||
|
||||
def test_request_mailbox_token_binding_requires_presented_token(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
|
||||
legacy_key = relay.mailbox_key_for_delivery(
|
||||
recipient_id="bob",
|
||||
delivery_class="request",
|
||||
)
|
||||
presented_token = "mailbox-token-bob"
|
||||
hashed = relay._hashed_mailbox_token(presented_token)
|
||||
|
||||
assert legacy_key != hashed
|
||||
claimed = relay.claim_mailbox_keys("bob", [{"type": "requests", "token": presented_token}])
|
||||
assert claimed[0] == hashed
|
||||
assert legacy_key in claimed
|
||||
assert relay.mailbox_key_for_delivery(recipient_id="bob", delivery_class="request") == hashed
|
||||
|
||||
|
||||
def test_shared_delivery_uses_hashed_mailbox_token(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
|
||||
result = relay.deposit(
|
||||
sender_id="alice",
|
||||
recipient_id="",
|
||||
ciphertext="cipher_shared",
|
||||
msg_id="msg_shared_hash",
|
||||
delivery_class="shared",
|
||||
recipient_token="shared-mailbox-token",
|
||||
sender_token_hash="abc123",
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
mailbox_key = relay._hashed_mailbox_token("shared-mailbox-token")
|
||||
assert list(relay._mailboxes.keys()) == [mailbox_key]
|
||||
assert relay._mailboxes[mailbox_key][0].sender_id == "sender_token:abc123"
|
||||
|
||||
|
||||
def test_request_and_shared_claims_freeze_current_sender_identity_contract(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
|
||||
request_result = relay.deposit(
|
||||
sender_id="alice",
|
||||
recipient_id="bob",
|
||||
ciphertext="cipher-req",
|
||||
msg_id="msg-req-1",
|
||||
delivery_class="request",
|
||||
)
|
||||
shared_result = relay.deposit(
|
||||
sender_id="alice",
|
||||
recipient_id="",
|
||||
ciphertext="cipher-shared",
|
||||
msg_id="msg-shared-1",
|
||||
delivery_class="shared",
|
||||
recipient_token="shared-mailbox-token",
|
||||
sender_token_hash="abc123",
|
||||
sender_seal="v3:sealed",
|
||||
)
|
||||
|
||||
assert request_result["ok"] is True
|
||||
assert shared_result["ok"] is True
|
||||
|
||||
request_messages = relay.collect_claims("bob", [{"type": "requests", "token": "request-claim-token"}])
|
||||
shared_messages = relay.collect_claims("bob", [{"type": "shared", "token": "shared-mailbox-token"}])
|
||||
|
||||
assert request_messages == [
|
||||
{
|
||||
"sender_id": "alice",
|
||||
"ciphertext": "cipher-req",
|
||||
"timestamp": request_messages[0]["timestamp"],
|
||||
"msg_id": "msg-req-1",
|
||||
"delivery_class": "request",
|
||||
"sender_seal": "",
|
||||
"format": "dm1",
|
||||
"session_welcome": "",
|
||||
}
|
||||
]
|
||||
assert shared_messages == [
|
||||
{
|
||||
"sender_id": "sender_token:abc123",
|
||||
"ciphertext": "cipher-shared",
|
||||
"timestamp": shared_messages[0]["timestamp"],
|
||||
"msg_id": "msg-shared-1",
|
||||
"delivery_class": "shared",
|
||||
"sender_seal": "v3:sealed",
|
||||
"format": "dm1",
|
||||
"session_welcome": "",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_block_purges_and_rejects_reduced_sender_handles(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
|
||||
first = relay.deposit(
|
||||
sender_id="sealed:first",
|
||||
raw_sender_id="alice",
|
||||
recipient_id="bob",
|
||||
ciphertext="cipher-req",
|
||||
msg_id="msg-sealed-1",
|
||||
delivery_class="request",
|
||||
sender_seal="v3:test-seal",
|
||||
)
|
||||
|
||||
assert first["ok"] is True
|
||||
relay.block("bob", "alice")
|
||||
assert relay.count_claims("bob", REQUEST_CLAIM) == 0
|
||||
|
||||
second = relay.deposit(
|
||||
sender_id="sealed:second",
|
||||
raw_sender_id="alice",
|
||||
recipient_id="bob",
|
||||
ciphertext="cipher-req-2",
|
||||
msg_id="msg-sealed-2",
|
||||
delivery_class="request",
|
||||
sender_seal="v3:test-seal",
|
||||
)
|
||||
|
||||
assert second == {"ok": False, "detail": "Recipient is not accepting your messages"}
|
||||
assert relay.count_claims("bob", REQUEST_CLAIM) == 0
|
||||
|
||||
|
||||
def test_nonce_cache_is_bounded_and_expires_entries(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MESH_DM_NONCE_CACHE_MAX", "2")
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
current = {"value": 1_000.0}
|
||||
monkeypatch.setattr(mesh_dm_relay.time, "time", lambda: current["value"])
|
||||
|
||||
assert relay.consume_nonce("bob", "nonce-1", 1_000)[0] is True
|
||||
assert relay.consume_nonce("bob", "nonce-2", 1_000)[0] is True
|
||||
assert len(relay._nonce_cache) == 2
|
||||
|
||||
ok, reason = relay.consume_nonce("bob", "nonce-3", 1_000)
|
||||
assert ok is False
|
||||
assert reason == "nonce cache at capacity"
|
||||
assert len(relay._nonce_cache) == 2
|
||||
assert "bob:nonce-1" in relay._nonce_cache
|
||||
assert "bob:nonce-2" in relay._nonce_cache
|
||||
|
||||
current["value"] = 1_000.0 + 301.0
|
||||
assert relay.consume_nonce("bob", "nonce-2", 1_000)[0] is True
|
||||
|
||||
|
||||
def test_dm_schema_requires_tokens_for_all_mailbox_claims():
|
||||
ok, reason = mesh_schema.validate_event_payload(
|
||||
"dm_poll",
|
||||
{
|
||||
"mailbox_claims": [{"type": "requests", "token": ""}],
|
||||
"timestamp": 123,
|
||||
"nonce": "abc",
|
||||
},
|
||||
)
|
||||
assert not ok
|
||||
assert "token" in reason.lower()
|
||||
|
||||
ok, reason = mesh_schema.validate_event_payload(
|
||||
"dm_count",
|
||||
{
|
||||
"mailbox_claims": [{"type": "shared", "token": ""}],
|
||||
"timestamp": 123,
|
||||
"nonce": "abc",
|
||||
},
|
||||
)
|
||||
assert not ok
|
||||
assert "token" in reason.lower()
|
||||
|
||||
ok, reason = mesh_schema.validate_event_payload(
|
||||
"dm_message",
|
||||
{
|
||||
"recipient_id": "bob",
|
||||
"delivery_class": "shared",
|
||||
"recipient_token": "",
|
||||
"ciphertext": "cipher",
|
||||
"format": "mls1",
|
||||
"msg_id": "m1",
|
||||
"timestamp": 123,
|
||||
},
|
||||
)
|
||||
assert not ok
|
||||
assert "recipient_token" in reason.lower()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Tests verifying the /api/mesh/dm/witness endpoint uses correct identifiers.
|
||||
|
||||
The bug: _preflight_signed_event_integrity was called with event_type="trust_vouch"
|
||||
and node_id=voucher_id (undefined NameError). Fixed to event_type="dm_key_witness"
|
||||
and node_id=witness_id.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
MAIN_PY = Path(__file__).resolve().parents[2] / "main.py"
|
||||
|
||||
|
||||
def _find_dm_key_witness_func(source: str) -> ast.AsyncFunctionDef | None:
|
||||
"""Parse main.py AST and find the dm_key_witness POST handler."""
|
||||
tree = ast.parse(source)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.AsyncFunctionDef) and node.name == "dm_key_witness":
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def test_witness_endpoint_uses_correct_event_type():
|
||||
"""The preflight call must use event_type='dm_key_witness', not 'trust_vouch'."""
|
||||
source = MAIN_PY.read_text(encoding="utf-8")
|
||||
func = _find_dm_key_witness_func(source)
|
||||
assert func is not None, "dm_key_witness function not found in main.py"
|
||||
|
||||
# Extract the function source and look for _preflight_signed_event_integrity call
|
||||
func_source = ast.get_source_segment(source, func)
|
||||
assert func_source is not None
|
||||
|
||||
assert 'event_type="dm_key_witness"' in func_source, (
|
||||
"preflight call should use event_type='dm_key_witness', "
|
||||
f"but the function contains: {func_source[:500]}"
|
||||
)
|
||||
assert 'event_type="trust_vouch"' not in func_source, (
|
||||
"preflight call still uses the wrong event_type='trust_vouch'"
|
||||
)
|
||||
|
||||
|
||||
def test_witness_endpoint_uses_witness_id_not_voucher_id():
|
||||
"""The preflight call must use node_id=witness_id, not voucher_id."""
|
||||
source = MAIN_PY.read_text(encoding="utf-8")
|
||||
func = _find_dm_key_witness_func(source)
|
||||
assert func is not None, "dm_key_witness function not found in main.py"
|
||||
|
||||
func_source = ast.get_source_segment(source, func)
|
||||
assert func_source is not None
|
||||
|
||||
# Find all _preflight_signed_event_integrity calls in the function
|
||||
lines = func_source.splitlines()
|
||||
in_preflight = False
|
||||
preflight_block = []
|
||||
for line in lines:
|
||||
if "_preflight_signed_event_integrity" in line:
|
||||
in_preflight = True
|
||||
if in_preflight:
|
||||
preflight_block.append(line)
|
||||
if ")" in line and line.strip().endswith(")"):
|
||||
break
|
||||
|
||||
preflight_text = "\n".join(preflight_block)
|
||||
assert "node_id=witness_id" in preflight_text, (
|
||||
f"preflight call should use node_id=witness_id, got:\n{preflight_text}"
|
||||
)
|
||||
assert "node_id=voucher_id" not in preflight_text, (
|
||||
"preflight call still references undefined voucher_id"
|
||||
)
|
||||
|
||||
|
||||
def test_verify_signed_event_also_uses_dm_key_witness():
|
||||
"""The _verify_signed_event call should also use event_type='dm_key_witness'."""
|
||||
source = MAIN_PY.read_text(encoding="utf-8")
|
||||
func = _find_dm_key_witness_func(source)
|
||||
assert func is not None
|
||||
|
||||
func_source = ast.get_source_segment(source, func)
|
||||
assert func_source is not None
|
||||
|
||||
# Count occurrences of the correct event_type
|
||||
assert func_source.count('event_type="dm_key_witness"') == 2, (
|
||||
"Both _verify_signed_event and _preflight_signed_event_integrity "
|
||||
"should use event_type='dm_key_witness'"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
"""Tests for security config guardrails in env_check._audit_security_config."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Reset pydantic settings cache before importing, so env overrides take effect
|
||||
os.environ.pop("MESH_DM_TOKEN_PEPPER", None)
|
||||
|
||||
from services.config import get_settings, Settings
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_settings_cache():
|
||||
"""Bust the lru_cache so each test gets fresh Settings."""
|
||||
get_settings.cache_clear()
|
||||
yield
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_pepper_env():
|
||||
"""Remove any auto-generated pepper between tests."""
|
||||
os.environ.pop("MESH_DM_TOKEN_PEPPER", None)
|
||||
yield
|
||||
os.environ.pop("MESH_DM_TOKEN_PEPPER", None)
|
||||
|
||||
|
||||
class TestInsecureAdminWarning:
|
||||
def test_allow_insecure_admin_without_key_logs_critical(self, caplog):
|
||||
with patch.dict(os.environ, {"ALLOW_INSECURE_ADMIN": "true", "ADMIN_KEY": ""}):
|
||||
get_settings.cache_clear()
|
||||
from services.env_check import _audit_security_config
|
||||
|
||||
with caplog.at_level(logging.CRITICAL):
|
||||
_audit_security_config(get_settings())
|
||||
|
||||
assert "ALLOW_INSECURE_ADMIN=true with no ADMIN_KEY" in caplog.text
|
||||
assert "completely unauthenticated" in caplog.text
|
||||
|
||||
def test_admin_key_present_no_warning(self, caplog):
|
||||
with patch.dict(
|
||||
os.environ, {"ALLOW_INSECURE_ADMIN": "true", "ADMIN_KEY": "secret123"}
|
||||
):
|
||||
get_settings.cache_clear()
|
||||
from services.env_check import _audit_security_config
|
||||
|
||||
with caplog.at_level(logging.CRITICAL):
|
||||
_audit_security_config(get_settings())
|
||||
|
||||
assert "ALLOW_INSECURE_ADMIN=true with no ADMIN_KEY" not in caplog.text
|
||||
|
||||
|
||||
class TestSignatureConfigWarnings:
|
||||
def test_non_strict_logs_warning(self, caplog):
|
||||
with patch.dict(os.environ, {"MESH_STRICT_SIGNATURES": "false"}):
|
||||
get_settings.cache_clear()
|
||||
from services.env_check import _audit_security_config
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_audit_security_config(get_settings())
|
||||
|
||||
assert "MESH_STRICT_SIGNATURES=false" in caplog.text
|
||||
|
||||
|
||||
class TestTokenPepperAutoGeneration:
|
||||
def test_empty_pepper_auto_generates(self, caplog):
|
||||
os.environ.pop("MESH_DM_TOKEN_PEPPER", None)
|
||||
get_settings.cache_clear()
|
||||
from services.env_check import _audit_security_config
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_audit_security_config(get_settings())
|
||||
|
||||
generated = os.environ.get("MESH_DM_TOKEN_PEPPER", "")
|
||||
assert len(generated) == 64 # 32 bytes hex
|
||||
assert "Auto-generated a random pepper" in caplog.text
|
||||
|
||||
def test_existing_pepper_preserved(self, caplog):
|
||||
os.environ["MESH_DM_TOKEN_PEPPER"] = "my-secret-pepper"
|
||||
get_settings.cache_clear()
|
||||
from services.env_check import _audit_security_config
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_audit_security_config(get_settings())
|
||||
|
||||
assert os.environ["MESH_DM_TOKEN_PEPPER"] == "my-secret-pepper"
|
||||
assert "Auto-generated" not in caplog.text
|
||||
|
||||
|
||||
class TestPeerSecretWarnings:
|
||||
def test_missing_peer_secret_only_warns_and_does_not_fail_validation(self, caplog):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"MESH_RELAY_PEERS": "https://peer.example",
|
||||
"MESH_PEER_PUSH_SECRET": "",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
get_settings.cache_clear()
|
||||
from services.env_check import validate_env
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = validate_env(strict=True)
|
||||
|
||||
assert result is True
|
||||
assert "MESH_PEER_PUSH_SECRET is invalid (empty)" in caplog.text
|
||||
|
||||
def test_security_posture_warnings_include_missing_peer_secret(self):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"MESH_RELAY_PEERS": "https://peer.example",
|
||||
"MESH_PEER_PUSH_SECRET": "",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
get_settings.cache_clear()
|
||||
from services.env_check import get_security_posture_warnings
|
||||
|
||||
warnings = get_security_posture_warnings(get_settings())
|
||||
|
||||
assert any("MESH_PEER_PUSH_SECRET is invalid (empty)" in item for item in warnings)
|
||||
|
||||
def test_placeholder_peer_secret_is_flagged(self, caplog):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"MESH_RELAY_PEERS": "https://peer.example",
|
||||
"MESH_PEER_PUSH_SECRET": "change-me",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
get_settings.cache_clear()
|
||||
from services.env_check import _audit_security_config
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_audit_security_config(get_settings())
|
||||
|
||||
assert "MESH_PEER_PUSH_SECRET is invalid (placeholder)" in caplog.text
|
||||
|
||||
|
||||
class TestCoverTrafficWarnings:
|
||||
def test_disabled_cover_traffic_logs_warning_when_rns_enabled(self, caplog):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"MESH_RNS_ENABLED": "true",
|
||||
"MESH_RNS_COVER_INTERVAL_S": "0",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
get_settings.cache_clear()
|
||||
from services.env_check import _audit_security_config
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_audit_security_config(get_settings())
|
||||
|
||||
assert "MESH_RNS_COVER_INTERVAL_S<=0 disables background RNS cover traffic" in caplog.text
|
||||
|
||||
def test_security_posture_warnings_include_disabled_cover_traffic(self):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"MESH_RNS_ENABLED": "true",
|
||||
"MESH_RNS_COVER_INTERVAL_S": "0",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
get_settings.cache_clear()
|
||||
from services.env_check import get_security_posture_warnings
|
||||
|
||||
warnings = get_security_posture_warnings(get_settings())
|
||||
|
||||
assert any("MESH_RNS_COVER_INTERVAL_S<=0" in item for item in warnings)
|
||||
@@ -0,0 +1,177 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from starlette.requests import Request
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
|
||||
def _json_request(path: str, body: dict) -> Request:
|
||||
raw = json.dumps(body).encode("utf-8")
|
||||
sent = {"value": False}
|
||||
|
||||
async def receive():
|
||||
if sent["value"]:
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
sent["value"] = True
|
||||
return {"type": "http.request", "body": raw, "more_body": False}
|
||||
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"client": ("test", 12345),
|
||||
"method": "POST",
|
||||
"path": path,
|
||||
},
|
||||
receive,
|
||||
)
|
||||
|
||||
|
||||
def test_fixed_launch_gate_catalog_contains_private_rooms():
|
||||
from services.mesh.mesh_reputation import gate_manager
|
||||
|
||||
gate_ids = [gate["gate_id"] for gate in gate_manager.list_gates()]
|
||||
|
||||
assert "infonet" in gate_ids
|
||||
assert "finance" in gate_ids
|
||||
assert "prediction-markets" in gate_ids
|
||||
assert "cryptography" in gate_ids
|
||||
assert "opsec-lab" in gate_ids
|
||||
assert "public-square" not in gate_ids
|
||||
|
||||
|
||||
def test_fixed_launch_gate_catalog_exposes_descriptions_and_welcome_text():
|
||||
from services.mesh.mesh_reputation import gate_manager
|
||||
|
||||
finance = next(g for g in gate_manager.list_gates() if g["gate_id"] == "finance")
|
||||
|
||||
assert finance["fixed"] is True
|
||||
assert "Macro" in finance["description"]
|
||||
assert "WELCOME TO FINANCE" in finance["welcome"]
|
||||
|
||||
|
||||
def test_gate_create_endpoint_is_disabled_for_fixed_launch_catalog():
|
||||
import main
|
||||
|
||||
response = asyncio.run(
|
||||
main.gate_create(
|
||||
_json_request(
|
||||
"/api/mesh/gate/create",
|
||||
{
|
||||
"creator_id": "!sb_test",
|
||||
"gate_id": "new-gate",
|
||||
"display_name": "New Gate",
|
||||
"rules": {"min_overall_rep": 0},
|
||||
},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert response["ok"] is False
|
||||
assert "fixed private launch catalog" in response["detail"]
|
||||
|
||||
|
||||
def test_infonet_messages_returns_seed_notice_for_empty_fixed_gate(monkeypatch):
|
||||
import main
|
||||
from services.mesh import mesh_hashchain
|
||||
|
||||
monkeypatch.setattr(mesh_hashchain.infonet, "get_messages", lambda **kwargs: [])
|
||||
monkeypatch.setattr(main, "_check_scoped_auth", lambda *_args, **_kwargs: (True, "ok"))
|
||||
|
||||
response = asyncio.run(
|
||||
main.infonet_messages(
|
||||
Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"x-admin-key", b"test-admin")],
|
||||
"client": ("test", 12345),
|
||||
"method": "GET",
|
||||
"path": "/api/mesh/infonet/messages",
|
||||
}
|
||||
),
|
||||
gate="finance",
|
||||
limit=20,
|
||||
offset=0,
|
||||
)
|
||||
)
|
||||
|
||||
assert response["count"] == 1
|
||||
assert response["messages"][0]["system_seed"] is True
|
||||
assert response["messages"][0]["gate"] == "finance"
|
||||
assert "WELCOME TO FINANCE" in response["messages"][0]["message"]
|
||||
|
||||
|
||||
def test_gate_scoped_vote_rejects_unknown_gate(monkeypatch):
|
||||
import main
|
||||
from services import wormhole_supervisor
|
||||
|
||||
monkeypatch.setattr(main, "_verify_signed_event", lambda **_kwargs: (True, "ok"))
|
||||
monkeypatch.setattr(
|
||||
wormhole_supervisor,
|
||||
"get_wormhole_state",
|
||||
lambda: {"configured": True, "ready": True, "arti_ready": True},
|
||||
)
|
||||
|
||||
async def _run():
|
||||
async with AsyncClient(transport=ASGITransport(app=main.app), base_url="http://test") as ac:
|
||||
response = await ac.post(
|
||||
"/api/mesh/vote",
|
||||
json={
|
||||
"voter_id": "!sb_voter",
|
||||
"target_id": "!sb_target",
|
||||
"vote": 1,
|
||||
"gate": "nonexistent-gate",
|
||||
"voter_pubkey": "pub",
|
||||
"public_key_algo": "Ed25519",
|
||||
"voter_sig": "sig",
|
||||
"sequence": 1,
|
||||
"protocol_version": "1",
|
||||
},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
result = asyncio.run(_run())
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "does not exist" in result["detail"]
|
||||
|
||||
|
||||
def test_gate_scoped_vote_requires_voter_gate_access(monkeypatch):
|
||||
import main
|
||||
from services import wormhole_supervisor
|
||||
from services.mesh.mesh_reputation import gate_manager
|
||||
|
||||
monkeypatch.setattr(main, "_verify_signed_event", lambda **_kwargs: (True, "ok"))
|
||||
monkeypatch.setattr(
|
||||
wormhole_supervisor,
|
||||
"get_wormhole_state",
|
||||
lambda: {"configured": True, "ready": True, "arti_ready": True},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gate_manager,
|
||||
"can_enter",
|
||||
lambda voter_id, gate_id: (False, "Need 10 overall rep (you have 0)"),
|
||||
)
|
||||
|
||||
async def _run():
|
||||
async with AsyncClient(transport=ASGITransport(app=main.app), base_url="http://test") as ac:
|
||||
response = await ac.post(
|
||||
"/api/mesh/vote",
|
||||
json={
|
||||
"voter_id": "!sb_voter",
|
||||
"target_id": "!sb_target",
|
||||
"vote": -1,
|
||||
"gate": "finance",
|
||||
"voter_pubkey": "pub",
|
||||
"public_key_algo": "Ed25519",
|
||||
"voter_sig": "sig",
|
||||
"sequence": 1,
|
||||
"protocol_version": "1",
|
||||
},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
result = asyncio.run(_run())
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "Gate vote denied" in result["detail"]
|
||||
@@ -0,0 +1,667 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
|
||||
|
||||
def _embedded_gate_event_wire_size(gate_mls_mod, persona_id: str, gate_id: str, plaintext: str) -> int:
|
||||
from services.mesh.mesh_hashchain import build_gate_wire_ref
|
||||
from services.mesh.mesh_rns import RNSMessage
|
||||
|
||||
binding = gate_mls_mod._sync_binding(gate_id)
|
||||
member = binding.members[persona_id]
|
||||
proof = {
|
||||
"proof_version": "embedded-proof-v1",
|
||||
"node_id": "!sb_embeddedproof",
|
||||
"public_key": "A" * 44,
|
||||
"public_key_algo": "Ed25519",
|
||||
"sequence": 7,
|
||||
"protocol_version": "infonet/2",
|
||||
"content_hash": "b" * 64,
|
||||
"transport_hash": "c" * 64,
|
||||
"signature": "d" * 128,
|
||||
}
|
||||
plaintext_with_proof = json.dumps(
|
||||
{
|
||||
"m": plaintext,
|
||||
"e": int(binding.epoch),
|
||||
"proof": proof,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
ciphertext = gate_mls_mod._privacy_client().encrypt_group_message(
|
||||
member.group_handle,
|
||||
plaintext_with_proof.encode("utf-8"),
|
||||
)
|
||||
padded = gate_mls_mod._pad_ciphertext_raw(ciphertext)
|
||||
event = {
|
||||
"gate_contract_version": "gate-v2-embedded-origin-v1",
|
||||
"event_type": "gate_message",
|
||||
"timestamp": 1710000000,
|
||||
"event_id": "e" * 64,
|
||||
"payload": {
|
||||
"ciphertext": gate_mls_mod._b64(padded),
|
||||
"format": gate_mls_mod.MLS_GATE_FORMAT,
|
||||
"nonce": "n" * 16,
|
||||
"sender_ref": "s" * 16,
|
||||
"epoch": int(binding.epoch),
|
||||
},
|
||||
}
|
||||
event["payload"]["gate_ref"] = build_gate_wire_ref(gate_id, event)
|
||||
return len(
|
||||
RNSMessage(
|
||||
msg_type="gate_event",
|
||||
body={"event": event},
|
||||
meta={"message_id": "mid", "dandelion": {"phase": "stem", "hops": 0, "max_hops": 3}},
|
||||
).encode()
|
||||
)
|
||||
|
||||
|
||||
def _fresh_gate_state(tmp_path, monkeypatch):
|
||||
from services import wormhole_supervisor
|
||||
from services.mesh import mesh_gate_mls, mesh_secure_storage, mesh_wormhole_persona
|
||||
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
monkeypatch.setattr(mesh_gate_mls, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_gate_mls, "STATE_FILE", tmp_path / "wormhole_gate_mls.json")
|
||||
monkeypatch.setattr(mesh_wormhole_persona, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_wormhole_persona, "PERSONA_FILE", tmp_path / "wormhole_persona.json")
|
||||
monkeypatch.setattr(
|
||||
mesh_wormhole_persona,
|
||||
"LEGACY_DM_IDENTITY_FILE",
|
||||
tmp_path / "wormhole_identity.json",
|
||||
)
|
||||
monkeypatch.setattr(wormhole_supervisor, "get_transport_tier", lambda: "private_transitional")
|
||||
monkeypatch.setattr(
|
||||
wormhole_supervisor,
|
||||
"get_wormhole_state",
|
||||
lambda: {"configured": True, "ready": True, "arti_ready": True, "rns_ready": False},
|
||||
)
|
||||
mesh_gate_mls.reset_gate_mls_state()
|
||||
return mesh_gate_mls, mesh_wormhole_persona
|
||||
|
||||
|
||||
def test_gate_message_schema_accepts_mls1_format():
|
||||
from services.mesh.mesh_protocol import normalize_payload
|
||||
from services.mesh.mesh_schema import validate_event_payload
|
||||
|
||||
payload = normalize_payload(
|
||||
"gate_message",
|
||||
{
|
||||
"gate": "infonet",
|
||||
"epoch": 1,
|
||||
"ciphertext": "ZmFrZQ==",
|
||||
"nonce": "bWxzMS1lbnZlbG9wZQ==",
|
||||
"sender_ref": "persona-1",
|
||||
"format": "mls1",
|
||||
},
|
||||
)
|
||||
|
||||
assert validate_event_payload("gate_message", payload) == (True, "ok")
|
||||
|
||||
|
||||
def test_compose_and_decrypt_gate_message_round_trip_via_mls(tmp_path, monkeypatch):
|
||||
gate_mls_mod, persona_mod = _fresh_gate_state(tmp_path, monkeypatch)
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
persona_mod.create_gate_persona("finance", label="scribe")
|
||||
|
||||
composed = gate_mls_mod.compose_encrypted_gate_message("finance", "hello mls gate")
|
||||
decrypted = gate_mls_mod.decrypt_gate_message_for_local_identity(
|
||||
gate_id="finance",
|
||||
epoch=int(composed["epoch"]),
|
||||
ciphertext=str(composed["ciphertext"]),
|
||||
nonce=str(composed["nonce"]),
|
||||
sender_ref=str(composed["sender_ref"]),
|
||||
)
|
||||
|
||||
assert composed["ok"] is True
|
||||
assert composed["format"] == "mls1"
|
||||
assert composed["ciphertext"] != "hello mls gate"
|
||||
assert decrypted == {
|
||||
"ok": True,
|
||||
"gate_id": "finance",
|
||||
"epoch": 1,
|
||||
"plaintext": "hello mls gate",
|
||||
"identity_scope": "persona",
|
||||
}
|
||||
|
||||
|
||||
def test_anonymous_gate_session_can_compose_and_decrypt_round_trip(tmp_path, monkeypatch):
|
||||
gate_mls_mod, persona_mod = _fresh_gate_state(tmp_path, monkeypatch)
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
persona_mod.enter_gate_anonymously("finance", rotate=True)
|
||||
|
||||
status = gate_mls_mod.get_local_gate_key_status("finance")
|
||||
composed = gate_mls_mod.compose_encrypted_gate_message("finance", "hello from anonymous gate")
|
||||
decrypted = gate_mls_mod.decrypt_gate_message_for_local_identity(
|
||||
gate_id="finance",
|
||||
epoch=int(composed["epoch"]),
|
||||
ciphertext=str(composed["ciphertext"]),
|
||||
nonce=str(composed["nonce"]),
|
||||
sender_ref=str(composed["sender_ref"]),
|
||||
)
|
||||
|
||||
assert status["ok"] is True
|
||||
assert status["identity_scope"] == "anonymous"
|
||||
assert status["has_local_access"] is True
|
||||
assert composed["ok"] is True
|
||||
assert composed["identity_scope"] == "anonymous"
|
||||
assert decrypted == {
|
||||
"ok": True,
|
||||
"gate_id": "finance",
|
||||
"epoch": 1,
|
||||
"plaintext": "hello from anonymous gate",
|
||||
"identity_scope": "anonymous",
|
||||
}
|
||||
|
||||
|
||||
def test_self_echo_decrypt_uses_local_plaintext_cache_fast_path(tmp_path, monkeypatch):
|
||||
gate_mls_mod, persona_mod = _fresh_gate_state(tmp_path, monkeypatch)
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
persona_mod.create_gate_persona("finance", label="scribe")
|
||||
composed = gate_mls_mod.compose_encrypted_gate_message("finance", "cache hit")
|
||||
|
||||
def fail_sync(_gate_id: str):
|
||||
raise AssertionError("self-echo cache should bypass MLS sync/decrypt")
|
||||
|
||||
monkeypatch.setattr(gate_mls_mod, "_sync_binding", fail_sync)
|
||||
|
||||
decrypted = gate_mls_mod.decrypt_gate_message_for_local_identity(
|
||||
gate_id="finance",
|
||||
epoch=int(composed["epoch"]),
|
||||
ciphertext=str(composed["ciphertext"]),
|
||||
nonce=str(composed["nonce"]),
|
||||
sender_ref=str(composed["sender_ref"]),
|
||||
)
|
||||
|
||||
assert decrypted == {
|
||||
"ok": True,
|
||||
"gate_id": "finance",
|
||||
"epoch": 1,
|
||||
"plaintext": "cache hit",
|
||||
"identity_scope": "persona",
|
||||
}
|
||||
|
||||
|
||||
def test_verifier_open_does_not_require_active_gate_persona(tmp_path, monkeypatch):
|
||||
gate_mls_mod, persona_mod = _fresh_gate_state(tmp_path, monkeypatch)
|
||||
gate_id = "finance"
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
first = persona_mod.create_gate_persona(gate_id, label="first")
|
||||
second = persona_mod.create_gate_persona(gate_id, label="second")
|
||||
|
||||
persona_mod.activate_gate_persona(gate_id, first["identity"]["persona_id"])
|
||||
composed = gate_mls_mod.compose_encrypted_gate_message(gate_id, "verifier open")
|
||||
assert composed["ok"] is True
|
||||
|
||||
persona_mod.enter_gate_anonymously(gate_id, rotate=True)
|
||||
|
||||
opened = gate_mls_mod.open_gate_ciphertext_for_verifier(
|
||||
gate_id=gate_id,
|
||||
epoch=int(composed["epoch"]),
|
||||
ciphertext=str(composed["ciphertext"]),
|
||||
format=str(composed["format"]),
|
||||
)
|
||||
|
||||
assert opened["ok"] is True
|
||||
assert opened["plaintext"] == "verifier open"
|
||||
assert opened["identity_scope"] == "verifier"
|
||||
assert opened["opened_by_persona_id"] in {
|
||||
first["identity"]["persona_id"],
|
||||
second["identity"]["persona_id"],
|
||||
}
|
||||
|
||||
|
||||
def test_verifier_open_does_not_use_self_echo_cache(tmp_path, monkeypatch):
|
||||
gate_mls_mod, persona_mod = _fresh_gate_state(tmp_path, monkeypatch)
|
||||
gate_id = "finance"
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
first = persona_mod.create_gate_persona(gate_id, label="first")
|
||||
second = persona_mod.create_gate_persona(gate_id, label="second")
|
||||
|
||||
persona_mod.activate_gate_persona(gate_id, first["identity"]["persona_id"])
|
||||
composed = gate_mls_mod.compose_encrypted_gate_message(gate_id, "no cache authority")
|
||||
assert composed["ok"] is True
|
||||
|
||||
monkeypatch.setattr(
|
||||
gate_mls_mod,
|
||||
"_peek_cached_plaintext",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("verifier must not peek cache")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gate_mls_mod,
|
||||
"_consume_cached_plaintext",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("verifier must not consume cache")),
|
||||
)
|
||||
monkeypatch.setattr(gate_mls_mod, "_active_gate_persona", lambda *_args, **_kwargs: None)
|
||||
|
||||
opened = gate_mls_mod.open_gate_ciphertext_for_verifier(
|
||||
gate_id=gate_id,
|
||||
epoch=int(composed["epoch"]),
|
||||
ciphertext=str(composed["ciphertext"]),
|
||||
format=str(composed["format"]),
|
||||
)
|
||||
|
||||
assert opened == {
|
||||
"ok": True,
|
||||
"gate_id": gate_id,
|
||||
"epoch": 1,
|
||||
"plaintext": "no cache authority",
|
||||
"opened_by_persona_id": second["identity"]["persona_id"],
|
||||
"identity_scope": "verifier",
|
||||
}
|
||||
|
||||
|
||||
def test_removed_member_cannot_decrypt_new_messages(tmp_path, monkeypatch):
|
||||
gate_mls_mod, persona_mod = _fresh_gate_state(tmp_path, monkeypatch)
|
||||
gate_id = "opsec-lab"
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
first = persona_mod.create_gate_persona(gate_id, label="first")
|
||||
second = persona_mod.create_gate_persona(gate_id, label="second")
|
||||
|
||||
persona_mod.activate_gate_persona(gate_id, first["identity"]["persona_id"])
|
||||
before_removal = gate_mls_mod.compose_encrypted_gate_message(gate_id, "before removal")
|
||||
|
||||
persona_mod.activate_gate_persona(gate_id, second["identity"]["persona_id"])
|
||||
readable_before = gate_mls_mod.decrypt_gate_message_for_local_identity(
|
||||
gate_id=gate_id,
|
||||
epoch=int(before_removal["epoch"]),
|
||||
ciphertext=str(before_removal["ciphertext"]),
|
||||
nonce=str(before_removal["nonce"]),
|
||||
sender_ref=str(before_removal["sender_ref"]),
|
||||
)
|
||||
|
||||
persona_mod.retire_gate_persona(gate_id, second["identity"]["persona_id"])
|
||||
persona_mod.activate_gate_persona(gate_id, first["identity"]["persona_id"])
|
||||
after_removal = gate_mls_mod.compose_encrypted_gate_message(gate_id, "after removal")
|
||||
|
||||
persona_mod.enter_gate_anonymously(gate_id, rotate=True)
|
||||
blocked_after = gate_mls_mod.decrypt_gate_message_for_local_identity(
|
||||
gate_id=gate_id,
|
||||
epoch=int(after_removal["epoch"]),
|
||||
ciphertext=str(after_removal["ciphertext"]),
|
||||
nonce=str(after_removal["nonce"]),
|
||||
sender_ref=str(after_removal["sender_ref"]),
|
||||
)
|
||||
|
||||
assert readable_before["ok"] is True
|
||||
assert readable_before["plaintext"] == "before removal"
|
||||
assert blocked_after == {
|
||||
"ok": True,
|
||||
"gate_id": gate_id,
|
||||
"epoch": int(after_removal["epoch"]),
|
||||
"plaintext": "after removal",
|
||||
"identity_scope": "anonymous",
|
||||
}
|
||||
|
||||
|
||||
def test_gate_mls_state_survives_simulated_restart(tmp_path, monkeypatch):
|
||||
gate_mls_mod, persona_mod = _fresh_gate_state(tmp_path, monkeypatch)
|
||||
gate_id = "infonet"
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
first = persona_mod.create_gate_persona(gate_id, label="first")
|
||||
second = persona_mod.create_gate_persona(gate_id, label="second")
|
||||
|
||||
persona_mod.activate_gate_persona(gate_id, first["identity"]["persona_id"])
|
||||
initial = gate_mls_mod.compose_encrypted_gate_message(gate_id, "before restart")
|
||||
|
||||
gate_mls_mod.reset_gate_mls_state()
|
||||
|
||||
persona_mod.activate_gate_persona(gate_id, first["identity"]["persona_id"])
|
||||
after_restart = gate_mls_mod.compose_encrypted_gate_message(gate_id, "after restart")
|
||||
|
||||
persona_mod.activate_gate_persona(gate_id, second["identity"]["persona_id"])
|
||||
decrypted = gate_mls_mod.decrypt_gate_message_for_local_identity(
|
||||
gate_id=gate_id,
|
||||
epoch=int(after_restart["epoch"]),
|
||||
ciphertext=str(after_restart["ciphertext"]),
|
||||
nonce=str(after_restart["nonce"]),
|
||||
sender_ref=str(after_restart["sender_ref"]),
|
||||
)
|
||||
|
||||
assert initial["ok"] is True
|
||||
assert after_restart["ok"] is True
|
||||
assert after_restart["epoch"] == initial["epoch"]
|
||||
assert decrypted["ok"] is True
|
||||
assert decrypted["plaintext"] == "after restart"
|
||||
|
||||
|
||||
def test_pre_restart_gate_message_fails_to_decrypt_after_reset(tmp_path, monkeypatch):
|
||||
gate_mls_mod, persona_mod = _fresh_gate_state(tmp_path, monkeypatch)
|
||||
gate_id = "restart-blackout"
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
first = persona_mod.create_gate_persona(gate_id, label="first")
|
||||
second = persona_mod.create_gate_persona(gate_id, label="second")
|
||||
|
||||
persona_mod.activate_gate_persona(gate_id, first["identity"]["persona_id"])
|
||||
before_reset = gate_mls_mod.compose_encrypted_gate_message(gate_id, "before reset")
|
||||
assert before_reset["ok"] is True
|
||||
|
||||
persona_mod.activate_gate_persona(gate_id, second["identity"]["persona_id"])
|
||||
readable_before = gate_mls_mod.decrypt_gate_message_for_local_identity(
|
||||
gate_id=gate_id,
|
||||
epoch=int(before_reset["epoch"]),
|
||||
ciphertext=str(before_reset["ciphertext"]),
|
||||
nonce=str(before_reset["nonce"]),
|
||||
sender_ref=str(before_reset["sender_ref"]),
|
||||
)
|
||||
assert readable_before["ok"] is True
|
||||
assert readable_before["plaintext"] == "before reset"
|
||||
|
||||
gate_mls_mod.reset_gate_mls_state()
|
||||
|
||||
persona_mod.activate_gate_persona(gate_id, second["identity"]["persona_id"])
|
||||
blocked_after = gate_mls_mod.decrypt_gate_message_for_local_identity(
|
||||
gate_id=gate_id,
|
||||
epoch=int(before_reset["epoch"]),
|
||||
ciphertext=str(before_reset["ciphertext"]),
|
||||
nonce=str(before_reset["nonce"]),
|
||||
sender_ref=str(before_reset["sender_ref"]),
|
||||
)
|
||||
|
||||
assert blocked_after == {
|
||||
"ok": False,
|
||||
"detail": "gate_mls_decrypt_failed",
|
||||
}
|
||||
|
||||
|
||||
def test_embedded_proof_budget_exceeds_rns_limit_before_6144_bucket_for_large_messages(tmp_path, monkeypatch):
|
||||
from services.config import get_settings
|
||||
|
||||
gate_mls_mod, persona_mod = _fresh_gate_state(tmp_path, monkeypatch)
|
||||
gate_id = "budget-gate"
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
first = persona_mod.create_gate_persona(gate_id, label="first")
|
||||
persona_id = first["identity"]["persona_id"]
|
||||
persona_mod.activate_gate_persona(gate_id, persona_id)
|
||||
|
||||
medium_wire = _embedded_gate_event_wire_size(gate_mls_mod, persona_id, gate_id, "x" * 1000)
|
||||
large_wire = _embedded_gate_event_wire_size(gate_mls_mod, persona_id, gate_id, "x" * 2000)
|
||||
|
||||
assert medium_wire < get_settings().MESH_RNS_MAX_PAYLOAD
|
||||
assert large_wire > get_settings().MESH_RNS_MAX_PAYLOAD
|
||||
|
||||
|
||||
def test_sync_binding_skips_persist_when_membership_is_unchanged(tmp_path, monkeypatch):
|
||||
gate_mls_mod, persona_mod = _fresh_gate_state(tmp_path, monkeypatch)
|
||||
gate_id = "quiet-room"
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
first = persona_mod.create_gate_persona(gate_id, label="first")
|
||||
second = persona_mod.create_gate_persona(gate_id, label="second")
|
||||
|
||||
persona_mod.activate_gate_persona(gate_id, first["identity"]["persona_id"])
|
||||
composed = gate_mls_mod.compose_encrypted_gate_message(gate_id, "steady state")
|
||||
|
||||
persist_calls = []
|
||||
original_persist = gate_mls_mod._persist_binding
|
||||
|
||||
def track_persist(binding):
|
||||
persist_calls.append(binding.gate_id)
|
||||
return original_persist(binding)
|
||||
|
||||
monkeypatch.setattr(gate_mls_mod, "_persist_binding", track_persist)
|
||||
persona_mod.activate_gate_persona(gate_id, second["identity"]["persona_id"])
|
||||
|
||||
decrypted = gate_mls_mod.decrypt_gate_message_for_local_identity(
|
||||
gate_id=gate_id,
|
||||
epoch=int(composed["epoch"]),
|
||||
ciphertext=str(composed["ciphertext"]),
|
||||
nonce=str(composed["nonce"]),
|
||||
sender_ref=str(composed["sender_ref"]),
|
||||
)
|
||||
|
||||
assert decrypted["ok"] is True
|
||||
assert decrypted["plaintext"] == "steady state"
|
||||
assert persist_calls == []
|
||||
|
||||
|
||||
def test_tampered_binding_is_rejected_on_sync(tmp_path, monkeypatch, caplog):
|
||||
from services.mesh.mesh_secure_storage import read_domain_json, write_domain_json
|
||||
import logging
|
||||
|
||||
gate_mls_mod, persona_mod = _fresh_gate_state(tmp_path, monkeypatch)
|
||||
gate_id = "cryptography"
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
persona = persona_mod.create_gate_persona(gate_id, label="scribe")
|
||||
composed = gate_mls_mod.compose_encrypted_gate_message(gate_id, "tamper target")
|
||||
assert composed["ok"] is True
|
||||
|
||||
stored = read_domain_json(
|
||||
gate_mls_mod.STATE_DOMAIN,
|
||||
gate_mls_mod.STATE_FILENAME,
|
||||
gate_mls_mod._default_binding_store,
|
||||
)
|
||||
persona_id = persona["identity"]["persona_id"]
|
||||
stored["gates"][gate_id]["members"][persona_id]["binding_signature"] = "00" * 64
|
||||
write_domain_json(gate_mls_mod.STATE_DOMAIN, gate_mls_mod.STATE_FILENAME, stored)
|
||||
|
||||
gate_mls_mod.reset_gate_mls_state()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
retry = gate_mls_mod.compose_encrypted_gate_message(gate_id, "should rebuild")
|
||||
|
||||
assert retry["ok"] is True
|
||||
assert "corrupted binding for gate#" in caplog.text.lower()
|
||||
assert "member persona#" in caplog.text.lower()
|
||||
|
||||
|
||||
def test_mls_compose_refuses_public_degraded_transport(tmp_path, monkeypatch):
|
||||
from services import wormhole_supervisor
|
||||
|
||||
gate_mls_mod, persona_mod = _fresh_gate_state(tmp_path, monkeypatch)
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
persona_mod.create_gate_persona("finance", label="scribe")
|
||||
monkeypatch.setattr(wormhole_supervisor, "get_transport_tier", lambda: "public_degraded")
|
||||
|
||||
result = gate_mls_mod.compose_encrypted_gate_message("finance", "should fail closed")
|
||||
|
||||
assert result == {
|
||||
"ok": False,
|
||||
"detail": "MLS gate compose requires PRIVATE transport tier",
|
||||
}
|
||||
|
||||
|
||||
def test_compose_endpoint_can_use_mls_without_changing_gate_post_envelope(tmp_path, monkeypatch):
|
||||
import main
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from services.mesh import mesh_hashchain, mesh_reputation
|
||||
from services import wormhole_supervisor
|
||||
|
||||
gate_mls_mod, persona_mod = _fresh_gate_state(tmp_path, monkeypatch)
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
persona_mod.create_gate_persona("infonet", label="scribe")
|
||||
monkeypatch.setattr(main, "_debug_mode_enabled", lambda: True)
|
||||
|
||||
class _Ledger:
|
||||
def __init__(self):
|
||||
self.registered = []
|
||||
|
||||
def register_node(self, *args):
|
||||
self.registered.append(args)
|
||||
|
||||
class _GateManager:
|
||||
def __init__(self):
|
||||
self.recorded = []
|
||||
self.enter_checks = []
|
||||
|
||||
def can_enter(self, sender_id, gate_id):
|
||||
self.enter_checks.append((sender_id, gate_id))
|
||||
return True, "ok"
|
||||
|
||||
def record_message(self, gate_id):
|
||||
self.recorded.append(gate_id)
|
||||
|
||||
fake_ledger = _Ledger()
|
||||
fake_gate_manager = _GateManager()
|
||||
append_calls = []
|
||||
|
||||
def fake_append(gate_id, event):
|
||||
append_calls.append({"gate_id": gate_id, "event": event})
|
||||
return event
|
||||
|
||||
admin_headers = {"X-Admin-Key": main._current_admin_key()}
|
||||
monkeypatch.setattr(main, "_preflight_signed_event_integrity", lambda **_: (True, "ok"))
|
||||
monkeypatch.setattr(
|
||||
wormhole_supervisor,
|
||||
"get_wormhole_state",
|
||||
lambda: {"configured": True, "ready": True, "arti_ready": True, "rns_ready": False},
|
||||
)
|
||||
monkeypatch.setattr(mesh_reputation, "reputation_ledger", fake_ledger, raising=False)
|
||||
monkeypatch.setattr(mesh_reputation, "gate_manager", fake_gate_manager, raising=False)
|
||||
monkeypatch.setattr(mesh_hashchain.gate_store, "append", fake_append)
|
||||
|
||||
async def _run():
|
||||
async with AsyncClient(transport=ASGITransport(app=main.app), base_url="http://test") as ac:
|
||||
compose_response = await ac.post(
|
||||
"/api/wormhole/gate/message/compose",
|
||||
json={"gate_id": "infonet", "plaintext": "field report"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
composed = compose_response.json()
|
||||
send_response = await ac.post(
|
||||
"/api/wormhole/gate/message/post",
|
||||
json={"gate_id": "infonet", "plaintext": "field report"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
decrypt_response = await ac.post(
|
||||
"/api/wormhole/gate/message/decrypt",
|
||||
json={
|
||||
"gate_id": "infonet",
|
||||
"epoch": composed["epoch"],
|
||||
"ciphertext": composed["ciphertext"],
|
||||
"nonce": composed["nonce"],
|
||||
"sender_ref": composed["sender_ref"],
|
||||
"format": composed["format"],
|
||||
},
|
||||
headers=admin_headers,
|
||||
)
|
||||
return composed, send_response.json(), decrypt_response.json()
|
||||
|
||||
try:
|
||||
composed, sent, decrypted = asyncio.run(_run())
|
||||
finally:
|
||||
gate_mls_mod.reset_gate_mls_state()
|
||||
|
||||
assert composed["ok"] is True
|
||||
assert composed["format"] == "mls1"
|
||||
assert len(base64.b64decode(composed["nonce"])) == 12
|
||||
assert sent["ok"] is True
|
||||
assert sent["detail"] == "Message posted to gate 'infonet'"
|
||||
assert sent["gate_id"] == "infonet"
|
||||
assert sent["event_id"] == append_calls[0]["event"]["event_id"]
|
||||
assert decrypted["ok"] is True
|
||||
assert decrypted["plaintext"] == "field report"
|
||||
assert fake_gate_manager.enter_checks == [(append_calls[0]["event"]["node_id"], "infonet")]
|
||||
assert fake_gate_manager.recorded == ["infonet"]
|
||||
assert fake_ledger.registered == [
|
||||
(
|
||||
append_calls[0]["event"]["node_id"],
|
||||
append_calls[0]["event"]["public_key"],
|
||||
append_calls[0]["event"]["public_key_algo"],
|
||||
)
|
||||
]
|
||||
assert append_calls[0]["gate_id"] == "infonet"
|
||||
assert append_calls[0]["event"]["payload"]["gate"] == "infonet"
|
||||
assert append_calls[0]["event"]["payload"]["format"] == "mls1"
|
||||
assert append_calls[0]["event"]["payload"]["ciphertext"]
|
||||
assert append_calls[0]["event"]["payload"]["nonce"]
|
||||
assert append_calls[0]["event"]["payload"]["sender_ref"]
|
||||
|
||||
|
||||
def test_receive_only_mls_decrypt_locks_gate_format(tmp_path, monkeypatch):
|
||||
from services.mesh.mesh_secure_storage import read_domain_json, write_domain_json
|
||||
|
||||
gate_mls_mod, persona_mod = _fresh_gate_state(tmp_path, monkeypatch)
|
||||
gate_id = "receive-only-lab"
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
first = persona_mod.create_gate_persona(gate_id, label="sender")
|
||||
second = persona_mod.create_gate_persona(gate_id, label="receiver")
|
||||
|
||||
persona_mod.activate_gate_persona(gate_id, first["identity"]["persona_id"])
|
||||
composed = gate_mls_mod.compose_encrypted_gate_message(gate_id, "receiver should lock gate")
|
||||
|
||||
stored = read_domain_json(
|
||||
gate_mls_mod.STATE_DOMAIN,
|
||||
gate_mls_mod.STATE_FILENAME,
|
||||
gate_mls_mod._default_binding_store,
|
||||
)
|
||||
stored.setdefault("gate_format_locks", {}).pop(gate_id, None)
|
||||
write_domain_json(gate_mls_mod.STATE_DOMAIN, gate_mls_mod.STATE_FILENAME, stored)
|
||||
|
||||
assert gate_mls_mod.is_gate_locked_to_mls(gate_id) is True
|
||||
|
||||
persona_mod.activate_gate_persona(gate_id, second["identity"]["persona_id"])
|
||||
decrypted = gate_mls_mod.decrypt_gate_message_for_local_identity(
|
||||
gate_id=gate_id,
|
||||
epoch=int(composed["epoch"]),
|
||||
ciphertext=str(composed["ciphertext"]),
|
||||
nonce=str(composed["nonce"]),
|
||||
sender_ref=str(composed["sender_ref"]),
|
||||
)
|
||||
|
||||
assert decrypted["ok"] is True
|
||||
assert decrypted["plaintext"] == "receiver should lock gate"
|
||||
assert gate_mls_mod.is_gate_locked_to_mls(gate_id) is True
|
||||
|
||||
|
||||
def test_mls_locked_gate_rejects_legacy_g1_decrypt(tmp_path, monkeypatch):
|
||||
import main
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from services import wormhole_supervisor
|
||||
|
||||
gate_mls_mod, persona_mod = _fresh_gate_state(tmp_path, monkeypatch)
|
||||
gate_id = "lockout-lab"
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
persona_mod.create_gate_persona(gate_id, label="scribe")
|
||||
monkeypatch.setattr(wormhole_supervisor, "get_transport_tier", lambda: "private_transitional")
|
||||
monkeypatch.setattr(
|
||||
wormhole_supervisor,
|
||||
"get_wormhole_state",
|
||||
lambda: {"configured": True, "ready": True, "arti_ready": True, "rns_ready": False},
|
||||
)
|
||||
|
||||
composed = gate_mls_mod.compose_encrypted_gate_message(gate_id, "mls only")
|
||||
|
||||
async def _run():
|
||||
async with AsyncClient(transport=ASGITransport(app=main.app), base_url="http://test") as ac:
|
||||
response = await ac.post(
|
||||
"/api/wormhole/gate/message/decrypt",
|
||||
json={
|
||||
"gate_id": gate_id,
|
||||
"epoch": composed["epoch"],
|
||||
"ciphertext": composed["ciphertext"],
|
||||
"nonce": composed["nonce"],
|
||||
"sender_ref": composed["sender_ref"],
|
||||
"format": "g1",
|
||||
},
|
||||
headers={"X-Admin-Key": main._current_admin_key()},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
try:
|
||||
result = asyncio.run(_run())
|
||||
finally:
|
||||
gate_mls_mod.reset_gate_mls_state()
|
||||
|
||||
assert composed["ok"] is True
|
||||
assert gate_mls_mod.is_gate_locked_to_mls(gate_id) is True
|
||||
assert result == {
|
||||
"ok": False,
|
||||
"detail": "gate is locked to MLS format",
|
||||
"gate_id": gate_id,
|
||||
"required_format": "mls1",
|
||||
"current_format": "g1",
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import pytest
|
||||
import base64
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
from services.mesh import mesh_crypto, mesh_hashchain, mesh_protocol
|
||||
|
||||
|
||||
def _signed_event_fields(event_type: str, payload: dict, sequence: int, private_key=None):
|
||||
priv = private_key or ed25519.Ed25519PrivateKey.generate()
|
||||
pub = priv.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
public_key = base64.b64encode(pub).decode("utf-8")
|
||||
node_id = mesh_crypto.derive_node_id(public_key)
|
||||
normalized = mesh_protocol.normalize_payload(event_type, payload)
|
||||
sig_payload = mesh_crypto.build_signature_payload(
|
||||
event_type=event_type,
|
||||
node_id=node_id,
|
||||
sequence=sequence,
|
||||
payload=normalized,
|
||||
)
|
||||
signature = priv.sign(sig_payload.encode("utf-8")).hex()
|
||||
return {
|
||||
"node_id": node_id,
|
||||
"payload": normalized,
|
||||
"signature": signature,
|
||||
"public_key": public_key,
|
||||
"public_key_algo": "Ed25519",
|
||||
"protocol_version": mesh_protocol.PROTOCOL_VERSION,
|
||||
"private_key": priv,
|
||||
}
|
||||
|
||||
|
||||
def test_infonet_sequence_enforced(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(mesh_hashchain, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_hashchain, "CHAIN_FILE", tmp_path / "infonet.json")
|
||||
|
||||
inf = mesh_hashchain.Infonet()
|
||||
evt1_fields = _signed_event_fields(
|
||||
"message",
|
||||
{"message": "hello", "destination": "broadcast"},
|
||||
1,
|
||||
)
|
||||
|
||||
evt = inf.append(
|
||||
event_type="message",
|
||||
node_id=evt1_fields["node_id"],
|
||||
payload=evt1_fields["payload"],
|
||||
signature=evt1_fields["signature"],
|
||||
sequence=1,
|
||||
public_key=evt1_fields["public_key"],
|
||||
public_key_algo=evt1_fields["public_key_algo"],
|
||||
protocol_version=evt1_fields["protocol_version"],
|
||||
)
|
||||
assert evt["payload"]["channel"] == "LongFast"
|
||||
assert evt["payload"]["priority"] == "normal"
|
||||
|
||||
replay_fields = _signed_event_fields(
|
||||
"message",
|
||||
{"message": "replay", "destination": "broadcast", "channel": "LongFast"},
|
||||
1,
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
inf.append(
|
||||
event_type="message",
|
||||
node_id=evt1_fields["node_id"],
|
||||
payload=replay_fields["payload"],
|
||||
signature=replay_fields["signature"],
|
||||
sequence=1,
|
||||
public_key=evt1_fields["public_key"],
|
||||
public_key_algo=evt1_fields["public_key_algo"],
|
||||
protocol_version=evt1_fields["protocol_version"],
|
||||
)
|
||||
|
||||
out_of_order_fields = _signed_event_fields(
|
||||
"message",
|
||||
{"message": "out-of-order", "destination": "broadcast", "channel": "LongFast"},
|
||||
1,
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
inf.append(
|
||||
event_type="message",
|
||||
node_id=evt1_fields["node_id"],
|
||||
payload=out_of_order_fields["payload"],
|
||||
signature=out_of_order_fields["signature"],
|
||||
sequence=1,
|
||||
public_key=evt1_fields["public_key"],
|
||||
public_key_algo=evt1_fields["public_key_algo"],
|
||||
protocol_version=evt1_fields["protocol_version"],
|
||||
)
|
||||
|
||||
evt2_fields = _signed_event_fields(
|
||||
"message",
|
||||
{"message": "next", "destination": "broadcast", "channel": "LongFast"},
|
||||
2,
|
||||
private_key=evt1_fields["private_key"],
|
||||
)
|
||||
inf.append(
|
||||
event_type="message",
|
||||
node_id=evt1_fields["node_id"],
|
||||
payload=evt2_fields["payload"],
|
||||
signature=evt2_fields["signature"],
|
||||
sequence=2,
|
||||
public_key=evt1_fields["public_key"],
|
||||
public_key_algo=evt1_fields["public_key_algo"],
|
||||
protocol_version=evt1_fields["protocol_version"],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
inf.append(
|
||||
event_type="not_valid",
|
||||
node_id=evt1_fields["node_id"],
|
||||
payload={"message": "nope", "destination": "broadcast"},
|
||||
signature=evt1_fields["signature"],
|
||||
sequence=1,
|
||||
public_key=evt1_fields["public_key"],
|
||||
public_key_algo=evt1_fields["public_key_algo"],
|
||||
protocol_version=evt1_fields["protocol_version"],
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
import hashlib
|
||||
|
||||
from services.mesh.mesh_ibf import IBLT, build_iblt
|
||||
|
||||
|
||||
def _key(seed: str) -> bytes:
|
||||
return hashlib.sha256(seed.encode("utf-8")).digest()
|
||||
|
||||
|
||||
def test_iblt_reconcile_diff() -> None:
|
||||
keys_a = [_key(f"a{i}") for i in range(20)]
|
||||
keys_b = [_key(f"a{i}") for i in range(12)] + [_key(f"b{i}") for i in range(6)]
|
||||
|
||||
iblt_a = build_iblt(keys_a, size=64)
|
||||
iblt_b = build_iblt(keys_b, size=64)
|
||||
|
||||
diff = iblt_a.subtract(iblt_b)
|
||||
ok, plus, minus = diff.decode()
|
||||
assert ok
|
||||
|
||||
plus_set = {p for p in plus}
|
||||
minus_set = {m for m in minus}
|
||||
|
||||
assert plus_set == set(keys_a) - set(keys_b)
|
||||
assert minus_set == set(keys_b) - set(keys_a)
|
||||
|
||||
|
||||
def test_iblt_compact_roundtrip() -> None:
|
||||
keys = [_key(f"x{i}") for i in range(15)]
|
||||
iblt = build_iblt(keys, size=64)
|
||||
packed = iblt.to_compact_dict()
|
||||
iblt2 = IBLT.from_compact_dict(packed)
|
||||
|
||||
diff = iblt.subtract(iblt2)
|
||||
ok, plus, minus = diff.decode()
|
||||
assert ok
|
||||
assert plus == []
|
||||
assert minus == []
|
||||
@@ -0,0 +1,263 @@
|
||||
import base64
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
from services.mesh import mesh_hashchain, mesh_crypto, mesh_protocol, mesh_schema
|
||||
|
||||
|
||||
def test_infonet_ingest_accepts_valid_event(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(mesh_hashchain, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_hashchain, "CHAIN_FILE", tmp_path / "infonet.json")
|
||||
|
||||
inf = mesh_hashchain.Infonet()
|
||||
|
||||
priv = ed25519.Ed25519PrivateKey.generate()
|
||||
pub = priv.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
pub_b64 = base64.b64encode(pub).decode("utf-8")
|
||||
node_id = mesh_crypto.derive_node_id(pub_b64)
|
||||
|
||||
payload = mesh_protocol.normalize_payload(
|
||||
"message",
|
||||
{"message": "hello", "destination": "broadcast", "channel": "LongFast", "priority": "normal", "ephemeral": False},
|
||||
)
|
||||
sig_payload = mesh_crypto.build_signature_payload(
|
||||
event_type="message", node_id=node_id, sequence=1, payload=payload
|
||||
)
|
||||
signature = priv.sign(sig_payload.encode("utf-8")).hex()
|
||||
|
||||
evt = mesh_hashchain.ChainEvent(
|
||||
prev_hash=mesh_hashchain.GENESIS_HASH,
|
||||
event_type="message",
|
||||
node_id=node_id,
|
||||
payload=payload,
|
||||
sequence=1,
|
||||
signature=signature,
|
||||
public_key=pub_b64,
|
||||
public_key_algo="Ed25519",
|
||||
protocol_version=mesh_protocol.PROTOCOL_VERSION,
|
||||
network_id=mesh_protocol.NETWORK_ID,
|
||||
)
|
||||
result = inf.ingest_events([evt.to_dict()])
|
||||
|
||||
assert result["accepted"] == 1
|
||||
assert inf.head_hash == evt.event_id
|
||||
|
||||
|
||||
def test_verify_node_binding_rejects_legacy_and_accepts_current_ids():
|
||||
priv = ed25519.Ed25519PrivateKey.generate()
|
||||
pub = priv.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
pub_b64 = base64.b64encode(pub).decode("utf-8")
|
||||
|
||||
current = mesh_crypto.derive_node_id(pub_b64)
|
||||
legacy = f"{mesh_crypto.NODE_ID_PREFIX}{current[len(mesh_crypto.NODE_ID_PREFIX):len(mesh_crypto.NODE_ID_PREFIX) + 8]}"
|
||||
|
||||
assert mesh_crypto.verify_node_binding(current, pub_b64)
|
||||
assert not mesh_crypto.verify_node_binding(legacy, pub_b64)
|
||||
|
||||
|
||||
def test_infonet_append_rejects_missing_signature_fields(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(mesh_hashchain, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_hashchain, "CHAIN_FILE", tmp_path / "infonet.json")
|
||||
|
||||
inf = mesh_hashchain.Infonet()
|
||||
payload = mesh_protocol.normalize_payload(
|
||||
"message",
|
||||
{"message": "hello", "destination": "broadcast", "channel": "LongFast", "priority": "normal", "ephemeral": False},
|
||||
)
|
||||
|
||||
try:
|
||||
inf.append(
|
||||
event_type="message",
|
||||
node_id="!sb_test",
|
||||
payload=payload,
|
||||
sequence=1,
|
||||
)
|
||||
assert False, "Expected missing signature fields to be rejected"
|
||||
except ValueError as exc:
|
||||
assert "signature" in str(exc).lower()
|
||||
|
||||
|
||||
def test_infonet_load_fails_closed_on_hash_mismatch(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(mesh_hashchain, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_hashchain, "CHAIN_FILE", tmp_path / "infonet.json")
|
||||
|
||||
priv = ed25519.Ed25519PrivateKey.generate()
|
||||
pub = priv.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
pub_b64 = base64.b64encode(pub).decode("utf-8")
|
||||
node_id = mesh_crypto.derive_node_id(pub_b64)
|
||||
payload = mesh_protocol.normalize_payload(
|
||||
"message",
|
||||
{"message": "hello", "destination": "broadcast", "channel": "LongFast", "priority": "normal", "ephemeral": False},
|
||||
)
|
||||
sig_payload = mesh_crypto.build_signature_payload(
|
||||
event_type="message", node_id=node_id, sequence=1, payload=payload
|
||||
)
|
||||
signature = priv.sign(sig_payload.encode("utf-8")).hex()
|
||||
evt = mesh_hashchain.ChainEvent(
|
||||
prev_hash=mesh_hashchain.GENESIS_HASH,
|
||||
event_type="message",
|
||||
node_id=node_id,
|
||||
payload=payload,
|
||||
sequence=1,
|
||||
signature=signature,
|
||||
public_key=pub_b64,
|
||||
public_key_algo="Ed25519",
|
||||
protocol_version=mesh_protocol.PROTOCOL_VERSION,
|
||||
network_id=mesh_protocol.NETWORK_ID,
|
||||
).to_dict()
|
||||
evt["event_id"] = "tampered"
|
||||
mesh_hashchain.CHAIN_FILE.write_text(
|
||||
json.dumps({"events": [evt], "head_hash": "tampered", "node_sequences": {node_id: 1}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Hash mismatch on event load"):
|
||||
mesh_hashchain.Infonet()
|
||||
|
||||
|
||||
def test_validate_gate_message_payload_rejects_plaintext_shape():
|
||||
payload = mesh_protocol.normalize_payload(
|
||||
"gate_message",
|
||||
{"gate": "infonet", "message": "plaintext should fail"},
|
||||
)
|
||||
|
||||
valid, reason = mesh_schema.validate_event_payload("gate_message", payload)
|
||||
|
||||
assert valid is False
|
||||
assert reason == "epoch must be a positive integer"
|
||||
|
||||
|
||||
def test_gate_store_accepts_encrypted_gate_payload(tmp_path, monkeypatch):
|
||||
priv = ed25519.Ed25519PrivateKey.generate()
|
||||
pub = priv.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
pub_b64 = base64.b64encode(pub).decode("utf-8")
|
||||
node_id = mesh_crypto.derive_node_id(pub_b64)
|
||||
|
||||
payload = mesh_protocol.normalize_payload(
|
||||
"gate_message",
|
||||
{
|
||||
"gate": "infonet",
|
||||
"epoch": 2,
|
||||
"ciphertext": "opaque-ciphertext",
|
||||
"nonce": "nonce-2",
|
||||
"sender_ref": "persona-ops-1",
|
||||
"format": "mls1",
|
||||
},
|
||||
)
|
||||
sig_payload = mesh_crypto.build_signature_payload(
|
||||
event_type="gate_message", node_id=node_id, sequence=1, payload=payload
|
||||
)
|
||||
signature = priv.sign(sig_payload.encode("utf-8")).hex()
|
||||
|
||||
store = mesh_hashchain.GateMessageStore(data_dir=str(tmp_path / "gate_messages"))
|
||||
evt = store.append(
|
||||
"infonet",
|
||||
{
|
||||
"event_id": "evt_gate_cipher",
|
||||
"event_type": "gate_message",
|
||||
"node_id": node_id,
|
||||
"payload": payload,
|
||||
"timestamp": 1_700_000_000.0,
|
||||
"sequence": 1,
|
||||
"signature": signature,
|
||||
"public_key": pub_b64,
|
||||
"public_key_algo": "Ed25519",
|
||||
"protocol_version": mesh_protocol.PROTOCOL_VERSION,
|
||||
},
|
||||
)
|
||||
|
||||
assert evt["payload"]["gate"] == "infonet"
|
||||
assert evt["payload"]["ciphertext"] == "opaque-ciphertext"
|
||||
assert evt["payload"]["epoch"] == 2
|
||||
assert evt["payload"]["nonce"] == "nonce-2"
|
||||
assert evt["payload"]["sender_ref"] == "persona-ops-1"
|
||||
|
||||
|
||||
def test_gate_store_rejects_replayed_ciphertext_across_append_and_peer_ingest(tmp_path):
|
||||
store = mesh_hashchain.GateMessageStore(data_dir=str(tmp_path / "gate_messages"))
|
||||
gate_id = "infonet"
|
||||
first = store.append(
|
||||
gate_id,
|
||||
{
|
||||
"event_id": "a" * 64,
|
||||
"event_type": "gate_message",
|
||||
"payload": {
|
||||
"gate": gate_id,
|
||||
"ciphertext": "opaque-ciphertext",
|
||||
"format": "mls1",
|
||||
},
|
||||
"timestamp": float(int(mesh_hashchain.time.time() / 60) * 60),
|
||||
},
|
||||
)
|
||||
|
||||
replayed = store.append(
|
||||
gate_id,
|
||||
{
|
||||
"event_id": "b" * 64,
|
||||
"event_type": "gate_message",
|
||||
"payload": {
|
||||
"gate": gate_id,
|
||||
"ciphertext": "opaque-ciphertext",
|
||||
"format": "mls1",
|
||||
},
|
||||
"timestamp": float(int(mesh_hashchain.time.time() / 60) * 60) + 60.0,
|
||||
},
|
||||
)
|
||||
peer_result = store.ingest_peer_events(
|
||||
gate_id,
|
||||
[
|
||||
{
|
||||
"event_type": "gate_message",
|
||||
"timestamp": mesh_hashchain.time.time(),
|
||||
"payload": {
|
||||
"gate": gate_id,
|
||||
"ciphertext": "opaque-ciphertext",
|
||||
"format": "mls1",
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert replayed is first
|
||||
assert peer_result == {"accepted": 0, "duplicates": 1, "rejected": 0}
|
||||
assert len(store.get_messages(gate_id, limit=10)) == 1
|
||||
|
||||
|
||||
def test_gate_store_prunes_stale_replay_fingerprints(tmp_path):
|
||||
store = mesh_hashchain.GateMessageStore(data_dir=str(tmp_path / "gate_messages"))
|
||||
old_ts = mesh_hashchain.time.time() - mesh_hashchain.GATE_REPLAY_WINDOW_S - 10
|
||||
|
||||
store.append(
|
||||
"infonet",
|
||||
{
|
||||
"event_id": "c" * 64,
|
||||
"event_type": "gate_message",
|
||||
"payload": {
|
||||
"gate": "infonet",
|
||||
"ciphertext": "old-cipher",
|
||||
"format": "mls1",
|
||||
},
|
||||
"timestamp": old_ts,
|
||||
},
|
||||
)
|
||||
|
||||
assert len(store._replay_index) == 1
|
||||
removed = store._prune_replay_index(now=mesh_hashchain.time.time())
|
||||
|
||||
assert removed == 1
|
||||
assert store._replay_index == {}
|
||||
@@ -0,0 +1,75 @@
|
||||
from services.mesh.mesh_infonet_sync_support import (
|
||||
SyncWorkerState,
|
||||
begin_sync,
|
||||
eligible_sync_peers,
|
||||
finish_sync,
|
||||
should_run_sync,
|
||||
)
|
||||
from services.mesh.mesh_peer_store import make_bootstrap_peer_record, make_sync_peer_record
|
||||
|
||||
|
||||
def test_eligible_sync_peers_filters_bucket_and_cooldown():
|
||||
records = [
|
||||
make_bootstrap_peer_record(
|
||||
peer_url="https://seed.example",
|
||||
transport="clearnet",
|
||||
role="seed",
|
||||
signer_id="bootstrap-a",
|
||||
now=100,
|
||||
),
|
||||
make_sync_peer_record(
|
||||
peer_url="https://active.example",
|
||||
transport="clearnet",
|
||||
now=100,
|
||||
),
|
||||
make_sync_peer_record(
|
||||
peer_url="https://cooldown.example",
|
||||
transport="clearnet",
|
||||
now=100,
|
||||
),
|
||||
]
|
||||
cooled = records[2]
|
||||
records[2] = type(cooled)(**{**cooled.to_dict(), "cooldown_until": 500})
|
||||
|
||||
candidates = eligible_sync_peers(records, now=200)
|
||||
|
||||
assert [record.peer_url for record in candidates] == ["https://active.example"]
|
||||
|
||||
|
||||
def test_finish_sync_success_updates_schedule():
|
||||
state = begin_sync(SyncWorkerState(), peer_url="https://seed.example", now=100)
|
||||
finished = finish_sync(
|
||||
state,
|
||||
ok=True,
|
||||
peer_url="https://seed.example",
|
||||
current_head="abc123",
|
||||
now=110,
|
||||
interval_s=300,
|
||||
)
|
||||
|
||||
assert finished.last_outcome == "ok"
|
||||
assert finished.last_sync_ok_at == 110
|
||||
assert finished.next_sync_due_at == 410
|
||||
assert finished.current_head == "abc123"
|
||||
assert not finished.fork_detected
|
||||
|
||||
|
||||
def test_finish_sync_failure_surfaces_fork_without_auto_merging():
|
||||
state = begin_sync(SyncWorkerState(), peer_url="https://seed.example", now=100)
|
||||
finished = finish_sync(
|
||||
state,
|
||||
ok=False,
|
||||
peer_url="https://seed.example",
|
||||
error="fork detected",
|
||||
fork_detected=True,
|
||||
now=120,
|
||||
failure_backoff_s=45,
|
||||
)
|
||||
|
||||
assert finished.last_outcome == "fork"
|
||||
assert finished.fork_detected is True
|
||||
assert finished.last_error == "fork detected"
|
||||
assert finished.consecutive_failures == 1
|
||||
assert finished.next_sync_due_at == 165
|
||||
assert should_run_sync(finished, now=150) is False
|
||||
assert should_run_sync(finished, now=165) is True
|
||||
@@ -0,0 +1,163 @@
|
||||
import base64
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
from services.mesh import mesh_crypto, mesh_hashchain, mesh_protocol
|
||||
|
||||
|
||||
def _signed_event_fields(
|
||||
event_type: str,
|
||||
payload: dict,
|
||||
sequence: int,
|
||||
*,
|
||||
private_key: ed25519.Ed25519PrivateKey | None = None,
|
||||
):
|
||||
priv = private_key or ed25519.Ed25519PrivateKey.generate()
|
||||
pub = priv.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
pub_b64 = base64.b64encode(pub).decode("utf-8")
|
||||
node_id = mesh_crypto.derive_node_id(pub_b64)
|
||||
normalized = mesh_protocol.normalize_payload(event_type, payload)
|
||||
sig_payload = mesh_crypto.build_signature_payload(
|
||||
event_type=event_type,
|
||||
node_id=node_id,
|
||||
sequence=sequence,
|
||||
payload=normalized,
|
||||
)
|
||||
signature = priv.sign(sig_payload.encode("utf-8")).hex()
|
||||
return {
|
||||
"node_id": node_id,
|
||||
"payload": normalized,
|
||||
"signature": signature,
|
||||
"public_key": pub_b64,
|
||||
"public_key_algo": "Ed25519",
|
||||
"protocol_version": mesh_protocol.PROTOCOL_VERSION,
|
||||
}
|
||||
|
||||
|
||||
def test_chain_linkage_and_head(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(mesh_hashchain, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_hashchain, "CHAIN_FILE", tmp_path / "infonet.json")
|
||||
|
||||
inf = mesh_hashchain.Infonet()
|
||||
evt1_fields = _signed_event_fields(
|
||||
"message",
|
||||
{"message": "one", "destination": "broadcast", "channel": "LongFast", "priority": "normal", "ephemeral": False},
|
||||
1,
|
||||
)
|
||||
evt1 = inf.append(
|
||||
event_type="message",
|
||||
node_id=evt1_fields["node_id"],
|
||||
payload=evt1_fields["payload"],
|
||||
signature=evt1_fields["signature"],
|
||||
sequence=1,
|
||||
public_key=evt1_fields["public_key"],
|
||||
public_key_algo=evt1_fields["public_key_algo"],
|
||||
protocol_version=evt1_fields["protocol_version"],
|
||||
)
|
||||
evt2_fields = _signed_event_fields(
|
||||
"message",
|
||||
{"message": "two", "destination": "broadcast", "channel": "LongFast", "priority": "normal", "ephemeral": False},
|
||||
2,
|
||||
)
|
||||
evt2 = inf.append(
|
||||
event_type="message",
|
||||
node_id=evt2_fields["node_id"],
|
||||
payload=evt2_fields["payload"],
|
||||
signature=evt2_fields["signature"],
|
||||
sequence=2,
|
||||
public_key=evt2_fields["public_key"],
|
||||
public_key_algo=evt2_fields["public_key_algo"],
|
||||
protocol_version=evt2_fields["protocol_version"],
|
||||
)
|
||||
|
||||
assert evt1["prev_hash"] == mesh_hashchain.GENESIS_HASH
|
||||
assert evt2["prev_hash"] == evt1["event_id"]
|
||||
assert inf.head_hash == evt2["event_id"]
|
||||
|
||||
|
||||
def test_ingest_rejects_non_normalized_payload(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(mesh_hashchain, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_hashchain, "CHAIN_FILE", tmp_path / "infonet.json")
|
||||
|
||||
inf = mesh_hashchain.Infonet()
|
||||
evt = mesh_hashchain.ChainEvent(
|
||||
prev_hash=mesh_hashchain.GENESIS_HASH,
|
||||
event_type="message",
|
||||
node_id="!sb_test",
|
||||
payload={"message": "hi", "destination": "broadcast"},
|
||||
sequence=1,
|
||||
signature="deadbeef",
|
||||
public_key="pub",
|
||||
public_key_algo="Ed25519",
|
||||
protocol_version=mesh_protocol.PROTOCOL_VERSION,
|
||||
network_id=mesh_protocol.NETWORK_ID,
|
||||
)
|
||||
result = inf.ingest_events([evt.to_dict()])
|
||||
|
||||
assert result["accepted"] == 0
|
||||
assert result["rejected"]
|
||||
assert "normalized" in result["rejected"][0]["reason"].lower()
|
||||
|
||||
|
||||
def test_revoked_key_rejects_future_events(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(mesh_hashchain, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_hashchain, "CHAIN_FILE", tmp_path / "infonet.json")
|
||||
|
||||
inf = mesh_hashchain.Infonet()
|
||||
now = int(mesh_hashchain.time.time())
|
||||
revoke_priv = ed25519.Ed25519PrivateKey.generate()
|
||||
revoked_payload = {
|
||||
"revoked_public_key": "",
|
||||
"revoked_public_key_algo": "Ed25519",
|
||||
"revoked_at": now - 10,
|
||||
"grace_until": now - 10,
|
||||
"reason": "compromised",
|
||||
}
|
||||
revoke_fields = _signed_event_fields(
|
||||
"key_revoke",
|
||||
revoked_payload,
|
||||
1,
|
||||
private_key=revoke_priv,
|
||||
)
|
||||
revoked_payload["revoked_public_key"] = revoke_fields["public_key"]
|
||||
revoke_fields = _signed_event_fields(
|
||||
"key_revoke",
|
||||
revoked_payload,
|
||||
1,
|
||||
private_key=revoke_priv,
|
||||
)
|
||||
inf.append(
|
||||
event_type="key_revoke",
|
||||
node_id=revoke_fields["node_id"],
|
||||
payload=revoked_payload,
|
||||
signature=revoke_fields["signature"],
|
||||
sequence=1,
|
||||
public_key=revoke_fields["public_key"],
|
||||
public_key_algo=revoke_fields["public_key_algo"],
|
||||
protocol_version=revoke_fields["protocol_version"],
|
||||
)
|
||||
|
||||
msg_fields = _signed_event_fields(
|
||||
"message",
|
||||
{"message": "blocked", "destination": "broadcast", "channel": "LongFast", "priority": "normal", "ephemeral": False},
|
||||
2,
|
||||
private_key=revoke_priv,
|
||||
)
|
||||
try:
|
||||
inf.append(
|
||||
event_type="message",
|
||||
node_id=revoke_fields["node_id"],
|
||||
payload=msg_fields["payload"],
|
||||
signature=msg_fields["signature"],
|
||||
sequence=2,
|
||||
public_key=revoke_fields["public_key"],
|
||||
public_key_algo=revoke_fields["public_key_algo"],
|
||||
protocol_version=revoke_fields["protocol_version"],
|
||||
)
|
||||
assert False, "Expected revocation to block new events"
|
||||
except ValueError as exc:
|
||||
assert "revoked" in str(exc).lower()
|
||||
@@ -0,0 +1,79 @@
|
||||
import base64
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
from services.mesh import mesh_crypto, mesh_hashchain, mesh_protocol
|
||||
|
||||
|
||||
def _signed_event_fields(event_type: str, payload: dict, sequence: int, private_key=None):
|
||||
priv = private_key or ed25519.Ed25519PrivateKey.generate()
|
||||
pub = priv.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
public_key = base64.b64encode(pub).decode("utf-8")
|
||||
node_id = mesh_crypto.derive_node_id(public_key)
|
||||
normalized = mesh_protocol.normalize_payload(event_type, payload)
|
||||
sig_payload = mesh_crypto.build_signature_payload(
|
||||
event_type=event_type,
|
||||
node_id=node_id,
|
||||
sequence=sequence,
|
||||
payload=normalized,
|
||||
)
|
||||
signature = priv.sign(sig_payload.encode("utf-8")).hex()
|
||||
return {
|
||||
"node_id": node_id,
|
||||
"payload": normalized,
|
||||
"signature": signature,
|
||||
"public_key": public_key,
|
||||
"public_key_algo": "Ed25519",
|
||||
"protocol_version": mesh_protocol.PROTOCOL_VERSION,
|
||||
"private_key": priv,
|
||||
}
|
||||
|
||||
|
||||
def test_locator_sync(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(mesh_hashchain, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_hashchain, "CHAIN_FILE", tmp_path / "infonet.json")
|
||||
|
||||
inf = mesh_hashchain.Infonet()
|
||||
evt1_fields = _signed_event_fields(
|
||||
"message",
|
||||
{"message": "hello", "destination": "broadcast"},
|
||||
1,
|
||||
)
|
||||
evt1 = inf.append(
|
||||
event_type="message",
|
||||
node_id=evt1_fields["node_id"],
|
||||
payload=evt1_fields["payload"],
|
||||
signature=evt1_fields["signature"],
|
||||
sequence=1,
|
||||
public_key=evt1_fields["public_key"],
|
||||
public_key_algo=evt1_fields["public_key_algo"],
|
||||
protocol_version=evt1_fields["protocol_version"],
|
||||
)
|
||||
evt2_fields = _signed_event_fields(
|
||||
"message",
|
||||
{"message": "world", "destination": "broadcast"},
|
||||
2,
|
||||
private_key=evt1_fields["private_key"],
|
||||
)
|
||||
evt2 = inf.append(
|
||||
event_type="message",
|
||||
node_id=evt1_fields["node_id"],
|
||||
payload=evt2_fields["payload"],
|
||||
signature=evt2_fields["signature"],
|
||||
sequence=2,
|
||||
public_key=evt1_fields["public_key"],
|
||||
public_key_algo=evt1_fields["public_key_algo"],
|
||||
protocol_version=evt1_fields["protocol_version"],
|
||||
)
|
||||
|
||||
locator = inf.get_locator()
|
||||
assert locator[0] == evt2["event_id"]
|
||||
|
||||
matched, _start, events = inf.get_events_after_locator([evt1["event_id"]], limit=10)
|
||||
assert matched == evt1["event_id"]
|
||||
assert len(events) == 1
|
||||
assert events[0]["event_id"] == evt2["event_id"]
|
||||
@@ -0,0 +1,18 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from services.mesh.mesh_merkle import merkle_root, verify_merkle_proof
|
||||
|
||||
|
||||
def test_merkle_fixtures() -> None:
|
||||
root_dir = Path(__file__).resolve().parents[3]
|
||||
fixture_path = root_dir / "docs" / "mesh" / "mesh-merkle-fixtures.json"
|
||||
fixtures = json.loads(fixture_path.read_text(encoding="utf-8"))
|
||||
|
||||
leaves = fixtures["leaves"]
|
||||
root = fixtures["root"]
|
||||
assert merkle_root(leaves) == root
|
||||
|
||||
for idx_str, proof in fixtures["proofs"].items():
|
||||
idx = int(idx_str)
|
||||
assert verify_merkle_proof(leaves[idx], idx, proof, root)
|
||||
@@ -0,0 +1,136 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
|
||||
def _write_signed_manifest(path, *, private_key):
|
||||
from services.mesh.mesh_bootstrap_manifest import BOOTSTRAP_MANIFEST_VERSION
|
||||
from services.mesh.mesh_crypto import canonical_json
|
||||
|
||||
payload = {
|
||||
"version": BOOTSTRAP_MANIFEST_VERSION,
|
||||
"issued_at": 1_700_000_000,
|
||||
"valid_until": 1_800_000_000,
|
||||
"signer_id": "bootstrap-a",
|
||||
"peers": [
|
||||
{
|
||||
"peer_url": "https://seed.example",
|
||||
"transport": "clearnet",
|
||||
"role": "seed",
|
||||
"label": "Seed A",
|
||||
}
|
||||
],
|
||||
}
|
||||
signature = base64.b64encode(private_key.sign(canonical_json(payload).encode("utf-8"))).decode("utf-8")
|
||||
path.write_text(json.dumps({**payload, "signature": signature}), encoding="utf-8")
|
||||
|
||||
|
||||
def test_refresh_node_peer_store_promotes_manifest_peers_to_sync_only(tmp_path, monkeypatch):
|
||||
import main
|
||||
from services.config import get_settings
|
||||
from services.mesh import mesh_bootstrap_manifest as manifest_mod
|
||||
from services.mesh import mesh_peer_store as peer_store_mod
|
||||
|
||||
manifest_key = ed25519.Ed25519PrivateKey.generate()
|
||||
manifest_pub = base64.b64encode(
|
||||
manifest_key.public_key().public_bytes(
|
||||
serialization.Encoding.Raw,
|
||||
serialization.PublicFormat.Raw,
|
||||
)
|
||||
).decode("utf-8")
|
||||
manifest_path = tmp_path / "bootstrap.json"
|
||||
peer_store_path = tmp_path / "peer_store.json"
|
||||
_write_signed_manifest(manifest_path, private_key=manifest_key)
|
||||
|
||||
monkeypatch.setattr(manifest_mod, "DEFAULT_BOOTSTRAP_MANIFEST_PATH", manifest_path)
|
||||
monkeypatch.setattr(peer_store_mod, "DEFAULT_PEER_STORE_PATH", peer_store_path)
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", manifest_pub)
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_MANIFEST_PATH", str(manifest_path))
|
||||
monkeypatch.setenv("MESH_RELAY_PEERS", "https://operator.example")
|
||||
get_settings.cache_clear()
|
||||
|
||||
try:
|
||||
snapshot = main._refresh_node_peer_store(now=1_750_000_000)
|
||||
store = peer_store_mod.PeerStore(peer_store_path)
|
||||
store.load()
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
assert snapshot["manifest_loaded"] is True
|
||||
assert snapshot["bootstrap_peer_count"] == 1
|
||||
assert snapshot["sync_peer_count"] == 2
|
||||
assert snapshot["push_peer_count"] == 1
|
||||
assert [record.peer_url for record in store.records_for_bucket("bootstrap")] == ["https://seed.example"]
|
||||
assert sorted(record.peer_url for record in store.records_for_bucket("sync")) == [
|
||||
"https://operator.example",
|
||||
"https://seed.example",
|
||||
]
|
||||
assert [record.peer_url for record in store.records_for_bucket("push")] == ["https://operator.example"]
|
||||
|
||||
|
||||
def test_verify_peer_push_hmac_requires_allowlisted_peer(monkeypatch):
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
import main
|
||||
from services.config import get_settings
|
||||
from services.mesh.mesh_crypto import _derive_peer_key
|
||||
|
||||
monkeypatch.setenv("MESH_PEER_PUSH_SECRET", "shared-secret")
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setattr(main, "authenticated_push_peer_urls", lambda *args, **kwargs: ["https://good.example"])
|
||||
|
||||
try:
|
||||
body = b'{"events":[]}'
|
||||
peer_url = "https://bad.example"
|
||||
peer_key = _derive_peer_key("shared-secret", peer_url)
|
||||
signature = hmac.new(peer_key, body, hashlib.sha256).hexdigest()
|
||||
request = SimpleNamespace(
|
||||
headers={"x-peer-url": peer_url, "x-peer-hmac": signature},
|
||||
url=SimpleNamespace(scheme="https", netloc="bad.example"),
|
||||
)
|
||||
assert main._verify_peer_push_hmac(request, body) is False
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_infonet_status_includes_node_runtime_snapshot(monkeypatch):
|
||||
import main
|
||||
from services import wormhole_supervisor
|
||||
|
||||
monkeypatch.setattr(main, "_check_scoped_auth", lambda *_args, **_kwargs: (True, "ok"))
|
||||
monkeypatch.setattr(
|
||||
wormhole_supervisor,
|
||||
"get_wormhole_state",
|
||||
lambda: {"configured": True, "ready": True, "arti_ready": True, "rns_ready": False},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_node_runtime_snapshot",
|
||||
lambda: {
|
||||
"node_mode": "participant",
|
||||
"node_enabled": True,
|
||||
"bootstrap": {"sync_peer_count": 2, "push_peer_count": 1},
|
||||
"sync_runtime": {"last_outcome": "ok"},
|
||||
"push_runtime": {"last_event_id": "evt-1"},
|
||||
},
|
||||
)
|
||||
|
||||
async def _run():
|
||||
async with AsyncClient(transport=ASGITransport(app=main.app), base_url="http://test") as ac:
|
||||
response = await ac.get("/api/mesh/infonet/status")
|
||||
return response.json()
|
||||
|
||||
result = asyncio.run(_run())
|
||||
|
||||
assert result["node_mode"] == "participant"
|
||||
assert result["node_enabled"] is True
|
||||
assert result["bootstrap"]["sync_peer_count"] == 2
|
||||
assert result["bootstrap"]["push_peer_count"] == 1
|
||||
assert result["sync_runtime"]["last_outcome"] == "ok"
|
||||
assert result["push_runtime"]["last_event_id"] == "evt-1"
|
||||
@@ -0,0 +1,98 @@
|
||||
from services.mesh.mesh_peer_store import (
|
||||
PeerStore,
|
||||
make_bootstrap_peer_record,
|
||||
make_push_peer_record,
|
||||
make_sync_peer_record,
|
||||
)
|
||||
|
||||
|
||||
def test_peer_store_preserves_provenance_across_buckets(tmp_path):
|
||||
store = PeerStore(tmp_path / "peer_store.json")
|
||||
bootstrap = make_bootstrap_peer_record(
|
||||
peer_url="https://seed.example",
|
||||
transport="clearnet",
|
||||
role="seed",
|
||||
signer_id="bootstrap-a",
|
||||
now=100,
|
||||
)
|
||||
sync_peer = make_sync_peer_record(
|
||||
peer_url="https://seed.example",
|
||||
transport="clearnet",
|
||||
role="seed",
|
||||
source="bootstrap_promoted",
|
||||
signer_id="bootstrap-a",
|
||||
now=101,
|
||||
)
|
||||
push_peer = make_push_peer_record(
|
||||
peer_url="https://seed.example",
|
||||
transport="clearnet",
|
||||
role="seed",
|
||||
now=102,
|
||||
)
|
||||
|
||||
store.upsert(bootstrap)
|
||||
store.upsert(sync_peer)
|
||||
store.upsert(push_peer)
|
||||
|
||||
assert [record.bucket for record in store.records()] == ["bootstrap", "push", "sync"]
|
||||
assert [record.source for record in store.records_for_bucket("sync")] == ["bootstrap_promoted"]
|
||||
assert [record.source for record in store.records_for_bucket("push")] == ["operator"]
|
||||
|
||||
|
||||
def test_peer_store_save_load_roundtrip(tmp_path):
|
||||
path = tmp_path / "peer_store.json"
|
||||
store = PeerStore(path)
|
||||
store.upsert(
|
||||
make_bootstrap_peer_record(
|
||||
peer_url="https://seed.example",
|
||||
transport="clearnet",
|
||||
role="seed",
|
||||
signer_id="bootstrap-a",
|
||||
now=100,
|
||||
)
|
||||
)
|
||||
store.upsert(
|
||||
make_sync_peer_record(
|
||||
peer_url="http://alphaexample.onion",
|
||||
transport="onion",
|
||||
role="relay",
|
||||
source="operator",
|
||||
now=101,
|
||||
)
|
||||
)
|
||||
store.save()
|
||||
|
||||
loaded = PeerStore(path)
|
||||
records = loaded.load()
|
||||
|
||||
assert len(records) == 2
|
||||
assert [record.bucket for record in records] == ["bootstrap", "sync"]
|
||||
assert records[0].signer_id == "bootstrap-a"
|
||||
assert records[1].peer_url == "http://alphaexample.onion"
|
||||
|
||||
|
||||
def test_peer_store_failure_and_success_lifecycle(tmp_path):
|
||||
store = PeerStore(tmp_path / "peer_store.json")
|
||||
store.upsert(
|
||||
make_sync_peer_record(
|
||||
peer_url="https://sync.example",
|
||||
transport="clearnet",
|
||||
now=100,
|
||||
)
|
||||
)
|
||||
failed = store.mark_failure(
|
||||
"https://sync.example",
|
||||
"sync",
|
||||
error="timeout",
|
||||
cooldown_s=30,
|
||||
now=200,
|
||||
)
|
||||
assert failed.failure_count == 1
|
||||
assert failed.cooldown_until == 230
|
||||
assert failed.last_error == "timeout"
|
||||
|
||||
recovered = store.mark_sync_success("https://sync.example", now=250)
|
||||
assert recovered.failure_count == 0
|
||||
assert recovered.cooldown_until == 0
|
||||
assert recovered.last_error == ""
|
||||
assert recovered.last_sync_ok_at == 250
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
import asyncio
|
||||
|
||||
|
||||
def test_x3dh_hkdf_uses_nonzero_ff_salt():
|
||||
from services.mesh.mesh_wormhole_prekey import _hkdf
|
||||
|
||||
derived = _hkdf(b"input-material", "SB-TEST")
|
||||
assert derived
|
||||
assert derived != _hkdf(b"input-material", "SB-TEST-ALT")
|
||||
|
||||
|
||||
def test_ratchet_padding_extends_large_payloads():
|
||||
from services.mesh.mesh_wormhole_ratchet import _build_padded_payload, PAD_MAGIC, PAD_STEP
|
||||
|
||||
plaintext = "x" * 5000
|
||||
padded = _build_padded_payload(plaintext)
|
||||
|
||||
assert padded[:4].decode("utf-8") == PAD_MAGIC
|
||||
assert len(padded) > len(plaintext.encode("utf-8"))
|
||||
assert len(padded) % PAD_STEP == 0
|
||||
|
||||
|
||||
def test_dead_drop_epoch_shortens_in_high_privacy(monkeypatch):
|
||||
from services.mesh import mesh_wormhole_dead_drop
|
||||
|
||||
monkeypatch.setattr(
|
||||
mesh_wormhole_dead_drop,
|
||||
"read_wormhole_settings",
|
||||
lambda: {"privacy_profile": "high"},
|
||||
)
|
||||
assert mesh_wormhole_dead_drop.mailbox_epoch_seconds() == 2 * 60 * 60
|
||||
|
||||
|
||||
def test_relay_jitter_only_applies_in_high_privacy(monkeypatch):
|
||||
import main
|
||||
|
||||
sleeps: list[float] = []
|
||||
|
||||
async def fake_sleep(delay: float):
|
||||
sleeps.append(delay)
|
||||
|
||||
monkeypatch.setattr(main, "_high_privacy_profile_enabled", lambda: True)
|
||||
monkeypatch.setattr(main.asyncio, "sleep", fake_sleep)
|
||||
|
||||
asyncio.run(main._maybe_apply_dm_relay_jitter())
|
||||
|
||||
assert len(sleeps) == 1
|
||||
assert 0.05 <= sleeps[0] <= 0.5
|
||||
|
||||
|
||||
def test_high_privacy_refuses_private_tier_clearnet_fallback(monkeypatch):
|
||||
from services.mesh.mesh_router import MeshEnvelope, MeshRouter, Priority, TransportResult
|
||||
|
||||
router = MeshRouter()
|
||||
internet_attempts: list[str] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_router._high_privacy_profile_blocks_clearnet_fallback",
|
||||
lambda: True,
|
||||
)
|
||||
monkeypatch.setattr(router.tor_arti, "can_reach", lambda _envelope: False)
|
||||
monkeypatch.setattr(
|
||||
router.internet,
|
||||
"send",
|
||||
lambda *_args, **_kwargs: (
|
||||
internet_attempts.append("internet"),
|
||||
TransportResult(True, "internet", "sent"),
|
||||
)[1],
|
||||
)
|
||||
|
||||
results = router.route(
|
||||
MeshEnvelope(
|
||||
sender_id="!sb_sender",
|
||||
destination="!sb_dest",
|
||||
payload="ciphertext",
|
||||
trust_tier="private_transitional",
|
||||
priority=Priority.NORMAL,
|
||||
),
|
||||
{},
|
||||
)
|
||||
|
||||
assert internet_attempts == []
|
||||
assert len(results) == 1
|
||||
assert results[0].transport == "policy"
|
||||
assert "clearnet fallback refused" in results[0].detail
|
||||
@@ -0,0 +1,296 @@
|
||||
import asyncio
|
||||
import time
|
||||
from collections import deque
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
class _DummyBreaker:
|
||||
def check_and_record(self, _priority):
|
||||
return True, "ok"
|
||||
|
||||
|
||||
class _FakeMeshtasticTransport:
|
||||
NAME = "meshtastic"
|
||||
|
||||
def __init__(self, can_reach: bool = True, send_ok: bool = True):
|
||||
self._can_reach = can_reach
|
||||
self._send_ok = send_ok
|
||||
self.sent = []
|
||||
|
||||
def can_reach(self, _envelope):
|
||||
return self._can_reach
|
||||
|
||||
def send(self, envelope, _credentials):
|
||||
from services.mesh.mesh_router import TransportResult
|
||||
|
||||
self.sent.append(envelope)
|
||||
return TransportResult(self._send_ok, self.NAME, "sent")
|
||||
|
||||
|
||||
class _FakeMeshRouter:
|
||||
def __init__(self, meshtastic):
|
||||
self.meshtastic = meshtastic
|
||||
self.breakers = {"meshtastic": _DummyBreaker()}
|
||||
self.route_called = False
|
||||
|
||||
def route(self, _envelope, _credentials):
|
||||
self.route_called = True
|
||||
return []
|
||||
|
||||
|
||||
def _valid_body(**overrides):
|
||||
body = {
|
||||
"destination": "!a0cc7a80",
|
||||
"message": "hello mesh",
|
||||
"sender_id": "!sb_sender",
|
||||
"node_id": "!sb_sender",
|
||||
"public_key": "pub",
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 1,
|
||||
"protocol_version": "1",
|
||||
"channel": "LongFast",
|
||||
"priority": "normal",
|
||||
"ephemeral": False,
|
||||
"transport_lock": "meshtastic",
|
||||
"credentials": {"mesh_region": "US"},
|
||||
}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def test_meshtastic_transport_lock_stays_on_public_direct_path(monkeypatch):
|
||||
import main
|
||||
from services.mesh import mesh_router as mesh_router_mod
|
||||
from services.sigint_bridge import sigint_grid
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
fake_meshtastic = _FakeMeshtasticTransport(can_reach=True, send_ok=True)
|
||||
fake_router = _FakeMeshRouter(fake_meshtastic)
|
||||
fake_bridge = SimpleNamespace(messages=deque(maxlen=10))
|
||||
|
||||
monkeypatch.setattr(main, "_verify_signed_event", lambda **_: (True, "ok"))
|
||||
monkeypatch.setattr(main, "_preflight_signed_event_integrity", lambda **_: (True, "ok"))
|
||||
monkeypatch.setattr(main, "_check_throttle", lambda *_: (True, "ok"))
|
||||
monkeypatch.setattr(mesh_router_mod, "mesh_router", fake_router)
|
||||
monkeypatch.setattr(sigint_grid, "mesh", fake_bridge)
|
||||
|
||||
async def _run():
|
||||
async with AsyncClient(transport=ASGITransport(app=main.app), base_url="http://test") as ac:
|
||||
response = await ac.post("/api/mesh/send", json=_valid_body())
|
||||
return response.json()
|
||||
|
||||
result = asyncio.run(_run())
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["routed_via"] == "meshtastic"
|
||||
assert "public node-targeted path" in result["route_reason"]
|
||||
assert fake_router.route_called is False
|
||||
assert len(fake_meshtastic.sent) == 1
|
||||
assert fake_meshtastic.sent[0].destination == "!a0cc7a80"
|
||||
assert fake_bridge.messages[0]["from"] == "!0779e8b8"
|
||||
|
||||
|
||||
def test_meshtastic_transport_lock_does_not_fallback_when_unreachable(monkeypatch):
|
||||
import main
|
||||
from services.mesh import mesh_router as mesh_router_mod
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
fake_meshtastic = _FakeMeshtasticTransport(can_reach=False, send_ok=False)
|
||||
fake_router = _FakeMeshRouter(fake_meshtastic)
|
||||
|
||||
monkeypatch.setattr(main, "_verify_signed_event", lambda **_: (True, "ok"))
|
||||
monkeypatch.setattr(main, "_preflight_signed_event_integrity", lambda **_: (True, "ok"))
|
||||
monkeypatch.setattr(main, "_check_throttle", lambda *_: (True, "ok"))
|
||||
monkeypatch.setattr(mesh_router_mod, "mesh_router", fake_router)
|
||||
|
||||
async def _run():
|
||||
async with AsyncClient(transport=ASGITransport(app=main.app), base_url="http://test") as ac:
|
||||
response = await ac.post("/api/mesh/send", json=_valid_body(message="x" * 10))
|
||||
return response.json()
|
||||
|
||||
result = asyncio.run(_run())
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["routed_via"] == ""
|
||||
assert fake_router.route_called is False
|
||||
assert fake_meshtastic.sent == []
|
||||
assert result["results"] == [
|
||||
{
|
||||
"ok": False,
|
||||
"transport": "meshtastic",
|
||||
"detail": "Message exceeds Meshtastic payload limit",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_meshtastic_transport_lock_allows_two_messages_per_minute(monkeypatch):
|
||||
import main
|
||||
|
||||
node_id = "!sb_meshrate"
|
||||
now = time.time()
|
||||
main._node_throttle[node_id] = {
|
||||
"last_send": now - 31,
|
||||
"daily_count": 0,
|
||||
"daily_reset": now,
|
||||
"first_seen": now,
|
||||
}
|
||||
|
||||
ok_first, _reason_first = main._check_throttle(node_id, "normal", "meshtastic")
|
||||
ok_second, reason_second = main._check_throttle(node_id, "normal", "meshtastic")
|
||||
|
||||
assert ok_first is True
|
||||
assert ok_second is False
|
||||
assert "1 message per 30s" in reason_second
|
||||
|
||||
|
||||
def test_private_trust_tier_skips_public_transports():
|
||||
from services.mesh.mesh_router import MeshEnvelope, MeshRouter, Priority, TransportResult
|
||||
|
||||
class _FakeTransport:
|
||||
def __init__(self, name):
|
||||
self.NAME = name
|
||||
self.sent = []
|
||||
|
||||
def can_reach(self, _envelope):
|
||||
return True
|
||||
|
||||
def send(self, envelope, _credentials):
|
||||
self.sent.append(envelope)
|
||||
return TransportResult(True, self.NAME, "sent")
|
||||
|
||||
router = MeshRouter()
|
||||
router.aprs = _FakeTransport("aprs")
|
||||
router.meshtastic = _FakeTransport("meshtastic")
|
||||
router.internet = _FakeTransport("internet")
|
||||
router.transports = [router.aprs, router.meshtastic, router.internet]
|
||||
|
||||
envelope = MeshEnvelope(
|
||||
sender_id="!sb_sender",
|
||||
destination="broadcast",
|
||||
priority=Priority.NORMAL,
|
||||
payload="private payload",
|
||||
trust_tier="private_strong",
|
||||
)
|
||||
|
||||
results = router.route(envelope, {})
|
||||
|
||||
assert [r.transport for r in results] == ["policy"]
|
||||
assert router.aprs.sent == []
|
||||
assert router.meshtastic.sent == []
|
||||
assert len(router.internet.sent) == 0
|
||||
|
||||
|
||||
def test_private_route_recognizes_tor_arti_and_falls_back_to_internet():
|
||||
from services.mesh.mesh_router import MeshEnvelope, MeshRouter, Priority, TransportResult
|
||||
|
||||
class _FakeTransport:
|
||||
def __init__(self, name, ok=True):
|
||||
self.NAME = name
|
||||
self.ok = ok
|
||||
self.sent = []
|
||||
|
||||
def can_reach(self, _envelope):
|
||||
return True
|
||||
|
||||
def send(self, envelope, _credentials):
|
||||
self.sent.append(envelope)
|
||||
return TransportResult(self.ok, self.NAME, "sent" if self.ok else "stub")
|
||||
|
||||
router = MeshRouter()
|
||||
router.aprs = _FakeTransport("aprs")
|
||||
router.meshtastic = _FakeTransport("meshtastic")
|
||||
router.tor_arti = _FakeTransport("tor_arti", ok=False)
|
||||
router.internet = _FakeTransport("internet", ok=True)
|
||||
router.transports = [router.aprs, router.meshtastic, router.tor_arti, router.internet]
|
||||
|
||||
envelope = MeshEnvelope(
|
||||
sender_id="!sb_sender",
|
||||
destination="broadcast",
|
||||
priority=Priority.NORMAL,
|
||||
payload="private payload",
|
||||
trust_tier="private_strong",
|
||||
)
|
||||
|
||||
results = router.route(envelope, {})
|
||||
|
||||
assert [r.transport for r in results] == ["tor_arti", "policy"]
|
||||
assert router.aprs.sent == []
|
||||
assert router.meshtastic.sent == []
|
||||
assert len(router.tor_arti.sent) == 1
|
||||
assert len(router.internet.sent) == 0
|
||||
|
||||
|
||||
def test_private_tier_blocks_meshtastic_transport_lock(monkeypatch):
|
||||
"""C-2 fix: transport_lock=meshtastic must be rejected when trust_tier is private."""
|
||||
import main
|
||||
from services.mesh import mesh_router as mesh_router_mod
|
||||
from services import wormhole_supervisor
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
fake_meshtastic = _FakeMeshtasticTransport(can_reach=True, send_ok=True)
|
||||
fake_router = _FakeMeshRouter(fake_meshtastic)
|
||||
|
||||
monkeypatch.setattr(main, "_verify_signed_event", lambda **_: (True, "ok"))
|
||||
monkeypatch.setattr(main, "_preflight_signed_event_integrity", lambda **_: (True, "ok"))
|
||||
monkeypatch.setattr(main, "_check_throttle", lambda *_: (True, "ok"))
|
||||
monkeypatch.setattr(mesh_router_mod, "mesh_router", fake_router)
|
||||
monkeypatch.setattr(wormhole_supervisor, "get_transport_tier", lambda: "private_transitional")
|
||||
|
||||
async def _run():
|
||||
async with AsyncClient(transport=ASGITransport(app=main.app), base_url="http://test") as ac:
|
||||
response = await ac.post("/api/mesh/send", json=_valid_body())
|
||||
return response.json()
|
||||
|
||||
result = asyncio.run(_run())
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "Private-tier content cannot be sent over Meshtastic" in result["results"][0]["detail"]
|
||||
assert fake_meshtastic.sent == []
|
||||
assert fake_router.route_called is False
|
||||
|
||||
|
||||
def test_envelope_trust_tier_set_from_wormhole_state(monkeypatch):
|
||||
"""C-1 fix: MeshEnvelope.trust_tier must reflect actual Wormhole transport tier."""
|
||||
import main
|
||||
from services.mesh import mesh_router as mesh_router_mod
|
||||
from services import wormhole_supervisor
|
||||
from services.sigint_bridge import sigint_grid
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
captured_envelopes = []
|
||||
|
||||
class _CapturingRouter:
|
||||
def __init__(self):
|
||||
self.meshtastic = _FakeMeshtasticTransport(can_reach=True, send_ok=True)
|
||||
self.breakers = {"meshtastic": _DummyBreaker()}
|
||||
|
||||
def route(self, envelope, _credentials):
|
||||
from services.mesh.mesh_router import TransportResult
|
||||
|
||||
captured_envelopes.append(envelope)
|
||||
return [TransportResult(True, "internet", "sent")]
|
||||
|
||||
fake_router = _CapturingRouter()
|
||||
fake_bridge = SimpleNamespace(messages=deque(maxlen=10))
|
||||
|
||||
monkeypatch.setattr(main, "_verify_signed_event", lambda **_: (True, "ok"))
|
||||
monkeypatch.setattr(main, "_preflight_signed_event_integrity", lambda **_: (True, "ok"))
|
||||
monkeypatch.setattr(main, "_check_throttle", lambda *_: (True, "ok"))
|
||||
monkeypatch.setattr(mesh_router_mod, "mesh_router", fake_router)
|
||||
monkeypatch.setattr(sigint_grid, "mesh", fake_bridge)
|
||||
monkeypatch.setattr(wormhole_supervisor, "get_transport_tier", lambda: "private_transitional")
|
||||
|
||||
body = _valid_body()
|
||||
del body["transport_lock"] # no lock — use normal routing
|
||||
|
||||
async def _run():
|
||||
async with AsyncClient(transport=ASGITransport(app=main.app), base_url="http://test") as ac:
|
||||
response = await ac.post("/api/mesh/send", json=body)
|
||||
return response.json()
|
||||
|
||||
result = asyncio.run(_run())
|
||||
|
||||
assert result["ok"] is True
|
||||
assert len(captured_envelopes) == 1
|
||||
assert captured_envelopes[0].trust_tier == "private_transitional"
|
||||
@@ -0,0 +1,158 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
from services.mesh import mesh_reputation, mesh_secure_storage
|
||||
|
||||
|
||||
def test_identity_link_merges_reputation(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(mesh_reputation, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_reputation, "LEDGER_FILE", tmp_path / "rep.json")
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
|
||||
ledger = mesh_reputation.ReputationLedger()
|
||||
|
||||
now = time.time()
|
||||
ledger.votes = [
|
||||
{
|
||||
"voter_id": "!sb_v1",
|
||||
"target_id": "!sb_old",
|
||||
"vote": 1,
|
||||
"gate": "",
|
||||
"timestamp": now,
|
||||
"weight": 1.0,
|
||||
"agent_verify": False,
|
||||
},
|
||||
{
|
||||
"voter_id": "!sb_v2",
|
||||
"target_id": "!sb_new",
|
||||
"vote": 1,
|
||||
"gate": "",
|
||||
"timestamp": now,
|
||||
"weight": 1.0,
|
||||
"agent_verify": False,
|
||||
},
|
||||
]
|
||||
ledger._scores_dirty = True
|
||||
|
||||
ok, _ = ledger.link_identities("!sb_old", "!sb_new")
|
||||
assert ok is True
|
||||
|
||||
rep = ledger.get_reputation("!sb_new")
|
||||
assert rep["overall"] == 2
|
||||
assert "linked_from" not in rep
|
||||
assert ledger.aliases["!sb_new"] == "!sb_old"
|
||||
|
||||
|
||||
def test_reputation_ledger_is_encrypted_at_rest(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(mesh_reputation, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_reputation, "LEDGER_FILE", tmp_path / "reputation_ledger.json")
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
|
||||
ledger = mesh_reputation.ReputationLedger()
|
||||
ledger.register_node("!sb_voter")
|
||||
ledger.register_node("!sb_target")
|
||||
|
||||
ok, _reason = ledger.cast_vote("!sb_voter", "!sb_target", 1)
|
||||
assert ok is True
|
||||
|
||||
ledger._flush()
|
||||
domain_path = tmp_path / mesh_reputation.LEDGER_DOMAIN / mesh_reputation.LEDGER_FILE.name
|
||||
raw = domain_path.read_text(encoding="utf-8")
|
||||
|
||||
assert '"kind": "sb_secure_json"' in raw
|
||||
assert domain_path.exists()
|
||||
assert not mesh_reputation.LEDGER_FILE.exists()
|
||||
assert "!sb_voter" not in raw
|
||||
assert "!sb_target" not in raw
|
||||
|
||||
|
||||
def test_reputation_votes_are_blinded_inside_encrypted_ledger(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(mesh_reputation, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_reputation, "LEDGER_FILE", tmp_path / "reputation_ledger.json")
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
|
||||
ledger = mesh_reputation.ReputationLedger()
|
||||
ledger.register_node("!sb_voter")
|
||||
ledger.register_node("!sb_target")
|
||||
|
||||
ok, _reason = ledger.cast_vote("!sb_voter", "!sb_target", 1)
|
||||
assert ok is True
|
||||
|
||||
ledger._flush()
|
||||
stored = mesh_secure_storage.read_domain_json(
|
||||
mesh_reputation.LEDGER_DOMAIN,
|
||||
mesh_reputation.LEDGER_FILE.name,
|
||||
lambda: {},
|
||||
)
|
||||
vote = stored["votes"][0]
|
||||
|
||||
assert "voter_id" not in vote
|
||||
assert vote["blinded_voter_id"]
|
||||
|
||||
|
||||
def test_reputation_duplicate_same_direction_vote_is_rejected(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(mesh_reputation, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_reputation, "LEDGER_FILE", tmp_path / "reputation_ledger.json")
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
|
||||
ledger = mesh_reputation.ReputationLedger()
|
||||
ledger.register_node("!sb_voter")
|
||||
ledger.register_node("!sb_target")
|
||||
|
||||
ok, reason = ledger.cast_vote("!sb_voter", "!sb_target", 1, "infonet")
|
||||
assert ok is True
|
||||
assert "Voted up" in reason
|
||||
assert len(ledger.votes) == 1
|
||||
|
||||
ok, reason = ledger.cast_vote("!sb_voter", "!sb_target", 1, "infonet")
|
||||
assert ok is False
|
||||
assert reason == "Vote already set to up on !sb_target in gate 'infonet'"
|
||||
assert len(ledger.votes) == 1
|
||||
|
||||
|
||||
def test_reputation_vote_direction_can_change_without_creating_duplicates(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(mesh_reputation, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_reputation, "LEDGER_FILE", tmp_path / "reputation_ledger.json")
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
|
||||
ledger = mesh_reputation.ReputationLedger()
|
||||
ledger.register_node("!sb_voter")
|
||||
ledger.register_node("!sb_target")
|
||||
|
||||
ok, _reason = ledger.cast_vote("!sb_voter", "!sb_target", 1, "infonet")
|
||||
assert ok is True
|
||||
assert len(ledger.votes) == 1
|
||||
|
||||
ok, reason = ledger.cast_vote("!sb_voter", "!sb_target", -1, "infonet")
|
||||
assert ok is True
|
||||
assert "Voted down" in reason
|
||||
assert len(ledger.votes) == 1
|
||||
assert ledger.votes[0]["vote"] == -1
|
||||
|
||||
|
||||
def test_gate_catalog_is_domain_encrypted_with_legacy_migration(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(mesh_reputation, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_reputation, "GATES_FILE", tmp_path / "gates.json")
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
|
||||
legacy = {"ops": {"display_name": "Ops", "fixed": False}}
|
||||
mesh_reputation.GATES_FILE.write_text(json.dumps(legacy), encoding="utf-8")
|
||||
|
||||
manager = mesh_reputation.GateManager(mesh_reputation.ReputationLedger())
|
||||
domain_path = tmp_path / mesh_reputation.GATES_DOMAIN / mesh_reputation.GATES_FILE.name
|
||||
stored = mesh_secure_storage.read_domain_json(
|
||||
mesh_reputation.GATES_DOMAIN,
|
||||
mesh_reputation.GATES_FILE.name,
|
||||
lambda: {},
|
||||
)
|
||||
|
||||
assert domain_path.exists()
|
||||
assert not mesh_reputation.GATES_FILE.exists()
|
||||
assert stored["ops"]["display_name"] == "Ops"
|
||||
assert manager.gates["ops"]["display_name"] == "Ops"
|
||||
@@ -0,0 +1,421 @@
|
||||
import base64
|
||||
import json
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services.mesh.mesh_hashchain import infonet
|
||||
from services.mesh.mesh_rns import RNSBridge
|
||||
|
||||
|
||||
def _xor_parity(data: list[bytes]) -> bytes:
|
||||
parity = data[0]
|
||||
for shard in data[1:]:
|
||||
parity = bytes(a ^ b for a, b in zip(parity, shard))
|
||||
return parity
|
||||
|
||||
|
||||
def _make_shard_body(
|
||||
shard_id: str,
|
||||
index: int,
|
||||
total: int,
|
||||
data_shards: int,
|
||||
parity_shards: int,
|
||||
size: int,
|
||||
length: int,
|
||||
parity: bool,
|
||||
fec: str,
|
||||
blob: bytes,
|
||||
) -> dict:
|
||||
return {
|
||||
"shard_id": shard_id,
|
||||
"index": index,
|
||||
"total": total,
|
||||
"data_shards": data_shards,
|
||||
"parity_shards": parity_shards,
|
||||
"size": size,
|
||||
"length": length,
|
||||
"parity": parity,
|
||||
"fec": fec,
|
||||
"data": base64.b64encode(blob).decode("ascii"),
|
||||
}
|
||||
|
||||
|
||||
def test_rns_quorum_thread_safety(monkeypatch) -> None:
|
||||
bridge = RNSBridge()
|
||||
sync_id = "sync-test"
|
||||
head_hash = infonet.head_hash or "head"
|
||||
with bridge._sync_lock:
|
||||
bridge._pending_sync[sync_id] = {
|
||||
"created": time.time(),
|
||||
"expected": set(),
|
||||
"quorum": 2,
|
||||
"responses": {},
|
||||
"responders": set(),
|
||||
}
|
||||
|
||||
ingested: list[list[dict]] = []
|
||||
|
||||
def _fake_ingest(events: list[dict]) -> None:
|
||||
ingested.append(events)
|
||||
|
||||
monkeypatch.setattr(bridge, "_ingest_ordered", _fake_ingest)
|
||||
|
||||
threads = []
|
||||
for idx in range(4):
|
||||
meta = {"sync_id": sync_id, "head_hash": head_hash, "reply_to": f"peer-{idx}"}
|
||||
t = threading.Thread(
|
||||
target=bridge._ingest_with_quorum, args=([{"event_id": f"e{idx}"}], meta)
|
||||
)
|
||||
threads.append(t)
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert sync_id not in bridge._pending_sync
|
||||
assert ingested
|
||||
|
||||
|
||||
def test_rns_shard_reassembly_thread_safety(monkeypatch) -> None:
|
||||
bridge = RNSBridge()
|
||||
payload = b"mesh-concurrency" * 256
|
||||
data_shards = 4
|
||||
data, length = bridge._split_payload(payload, data_shards)
|
||||
size = len(data[0])
|
||||
parity = _xor_parity(data)
|
||||
shard_id = uuid.uuid4().hex
|
||||
total = data_shards + 1
|
||||
|
||||
bodies = [
|
||||
_make_shard_body(
|
||||
shard_id,
|
||||
idx,
|
||||
total,
|
||||
data_shards,
|
||||
1,
|
||||
size,
|
||||
length,
|
||||
False,
|
||||
"xor",
|
||||
shard,
|
||||
)
|
||||
for idx, shard in enumerate(data)
|
||||
]
|
||||
bodies.append(
|
||||
_make_shard_body(
|
||||
shard_id,
|
||||
data_shards,
|
||||
total,
|
||||
data_shards,
|
||||
1,
|
||||
size,
|
||||
length,
|
||||
True,
|
||||
"xor",
|
||||
parity,
|
||||
)
|
||||
)
|
||||
random.shuffle(bodies)
|
||||
|
||||
assembled: list[bytes] = []
|
||||
|
||||
def _fake_on_packet(data: bytes, packet=None) -> None:
|
||||
assembled.append(data)
|
||||
|
||||
monkeypatch.setattr(bridge, "_on_packet", _fake_on_packet)
|
||||
|
||||
threads = [threading.Thread(target=bridge._handle_infonet_shard, args=(body,)) for body in bodies]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert assembled
|
||||
assert assembled[-1] == payload
|
||||
|
||||
|
||||
def test_rns_shard_reassembly_with_loss_and_delay(monkeypatch) -> None:
|
||||
bridge = RNSBridge()
|
||||
payload = b"mesh-loss-delay" * 256
|
||||
data_shards = 5
|
||||
data, length = bridge._split_payload(payload, data_shards)
|
||||
size = len(data[0])
|
||||
parity = _xor_parity(data)
|
||||
shard_id = uuid.uuid4().hex
|
||||
total = data_shards + 1
|
||||
|
||||
bodies = [
|
||||
_make_shard_body(
|
||||
shard_id,
|
||||
idx,
|
||||
total,
|
||||
data_shards,
|
||||
1,
|
||||
size,
|
||||
length,
|
||||
False,
|
||||
"xor",
|
||||
shard,
|
||||
)
|
||||
for idx, shard in enumerate(data)
|
||||
]
|
||||
bodies.append(
|
||||
_make_shard_body(
|
||||
shard_id,
|
||||
data_shards,
|
||||
total,
|
||||
data_shards,
|
||||
1,
|
||||
size,
|
||||
length,
|
||||
True,
|
||||
"xor",
|
||||
parity,
|
||||
)
|
||||
)
|
||||
|
||||
rng = random.Random(1337)
|
||||
drop_index = rng.randrange(data_shards)
|
||||
bodies = [b for b in bodies if not (not b["parity"] and b["index"] == drop_index)]
|
||||
rng.shuffle(bodies)
|
||||
|
||||
assembled: list[bytes] = []
|
||||
|
||||
def _fake_on_packet(data: bytes, packet=None) -> None:
|
||||
assembled.append(data)
|
||||
|
||||
monkeypatch.setattr(bridge, "_on_packet", _fake_on_packet)
|
||||
|
||||
def _deliver(body: dict) -> None:
|
||||
time.sleep(rng.uniform(0.0, 0.03))
|
||||
bridge._handle_infonet_shard(body)
|
||||
|
||||
threads = [threading.Thread(target=_deliver, args=(body,)) for body in bodies]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert assembled
|
||||
assert assembled[-1] == payload
|
||||
|
||||
|
||||
def test_rns_publish_gate_event_freezes_current_v1_signer_bundle(monkeypatch) -> None:
|
||||
from services import config as config_mod
|
||||
from services.mesh import mesh_rns as mesh_rns_mod
|
||||
|
||||
bridge = RNSBridge()
|
||||
sent: list[tuple[bytes, str | None]] = []
|
||||
settings = SimpleNamespace(
|
||||
MESH_PEER_PUSH_SECRET="peer-secret",
|
||||
MESH_RNS_MAX_PAYLOAD=8192,
|
||||
MESH_RNS_DANDELION_DELAY_MS=0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(config_mod, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(mesh_rns_mod, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(bridge, "enabled", lambda: True)
|
||||
monkeypatch.setattr(bridge, "_maybe_rotate_session", lambda: None)
|
||||
monkeypatch.setattr(bridge, "_seen", lambda _message_id: False)
|
||||
monkeypatch.setattr(bridge, "_make_message_id", lambda prefix: f"{prefix}-wire-id")
|
||||
monkeypatch.setattr(bridge, "_dandelion_hops", lambda: 3)
|
||||
monkeypatch.setattr(bridge, "_pick_stem_peer", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
bridge,
|
||||
"_send_diffuse",
|
||||
lambda payload, exclude=None: sent.append((payload, exclude)),
|
||||
)
|
||||
|
||||
bridge.publish_gate_event(
|
||||
"finance",
|
||||
{
|
||||
"event_type": "gate_message",
|
||||
"timestamp": 1710000000,
|
||||
"event_id": "gate-evt-1",
|
||||
"node_id": "!gate-persona-1",
|
||||
"sequence": 19,
|
||||
"signature": "deadbeef",
|
||||
"public_key": "pubkey-1",
|
||||
"public_key_algo": "Ed25519",
|
||||
"protocol_version": "infonet/2",
|
||||
"payload": {
|
||||
"gate": "finance",
|
||||
"ciphertext": "abc123",
|
||||
"format": "mls1",
|
||||
"nonce": "nonce-7",
|
||||
"sender_ref": "sender-ref-7",
|
||||
"epoch": 4,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert len(sent) == 1
|
||||
message, exclude = sent[0]
|
||||
decoded = json.loads(message.decode("utf-8"))
|
||||
event = decoded["body"]["event"]
|
||||
|
||||
assert exclude is None
|
||||
assert decoded["type"] == "gate_event"
|
||||
assert decoded["meta"] == {
|
||||
"message_id": "gate-wire-id",
|
||||
"dandelion": {"phase": "stem", "hops": 0, "max_hops": 3},
|
||||
}
|
||||
assert set(event.keys()) == {
|
||||
"event_type",
|
||||
"timestamp",
|
||||
"payload",
|
||||
"event_id",
|
||||
"node_id",
|
||||
"sequence",
|
||||
"signature",
|
||||
"public_key",
|
||||
"public_key_algo",
|
||||
"protocol_version",
|
||||
}
|
||||
assert event["event_id"] == "gate-evt-1"
|
||||
assert event["node_id"] == "!gate-persona-1"
|
||||
assert event["sequence"] == 19
|
||||
assert event["signature"] == "deadbeef"
|
||||
assert event["public_key"] == "pubkey-1"
|
||||
assert event["public_key_algo"] == "Ed25519"
|
||||
assert event["protocol_version"] == "infonet/2"
|
||||
assert set(event["payload"].keys()) == {"ciphertext", "format", "gate_ref", "nonce", "sender_ref", "epoch"}
|
||||
assert event["payload"]["ciphertext"] == "abc123"
|
||||
assert event["payload"]["format"] == "mls1"
|
||||
assert event["payload"]["nonce"] == "nonce-7"
|
||||
assert event["payload"]["sender_ref"] == "sender-ref-7"
|
||||
assert event["payload"]["epoch"] == 4
|
||||
assert event["payload"]["gate_ref"]
|
||||
assert "gate" not in event["payload"]
|
||||
|
||||
|
||||
def test_rns_inbound_gate_event_resolves_gate_ref_before_local_ingest(monkeypatch) -> None:
|
||||
from services import config as config_mod
|
||||
from services.mesh import mesh_hashchain as mesh_hashchain_mod, mesh_rns as mesh_rns_mod
|
||||
|
||||
bridge = RNSBridge()
|
||||
ingested: list[tuple[str, list[dict]]] = []
|
||||
settings = SimpleNamespace(MESH_RNS_DANDELION_HOPS=3)
|
||||
|
||||
monkeypatch.setattr(config_mod, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(mesh_rns_mod, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(bridge, "_seen", lambda _message_id: False)
|
||||
monkeypatch.setattr(mesh_hashchain_mod, "resolve_gate_wire_ref", lambda gate_ref, event: "finance")
|
||||
monkeypatch.setattr(
|
||||
mesh_hashchain_mod.gate_store,
|
||||
"ingest_peer_events",
|
||||
lambda gate_id, events: ingested.append((gate_id, events)) or {"accepted": 1, "duplicates": 0, "rejected": 0},
|
||||
)
|
||||
|
||||
packet = mesh_rns_mod.RNSMessage(
|
||||
msg_type="gate_event",
|
||||
body={
|
||||
"event": {
|
||||
"event_type": "gate_message",
|
||||
"timestamp": 1710000000,
|
||||
"event_id": "gate-evt-inbound",
|
||||
"node_id": "!gate-persona-1",
|
||||
"sequence": 9,
|
||||
"signature": "deadbeef",
|
||||
"public_key": "pubkey-1",
|
||||
"public_key_algo": "Ed25519",
|
||||
"protocol_version": "infonet/2",
|
||||
"payload": {
|
||||
"ciphertext": "abc123",
|
||||
"format": "mls1",
|
||||
"nonce": "nonce-7",
|
||||
"sender_ref": "sender-ref-7",
|
||||
"epoch": 4,
|
||||
"gate_ref": "opaque-ref-1",
|
||||
},
|
||||
}
|
||||
},
|
||||
meta={"message_id": "gate-inbound-1", "dandelion": {"phase": "diffuse"}},
|
||||
).encode()
|
||||
|
||||
bridge._on_packet(packet)
|
||||
|
||||
assert len(ingested) == 1
|
||||
gate_id, events = ingested[0]
|
||||
assert gate_id == "finance"
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert event["event_id"] == "gate-evt-inbound"
|
||||
assert event["node_id"] == "!gate-persona-1"
|
||||
assert event["sequence"] == 9
|
||||
assert event["signature"] == "deadbeef"
|
||||
assert event["public_key"] == "pubkey-1"
|
||||
assert event["public_key_algo"] == "Ed25519"
|
||||
assert event["protocol_version"] == "infonet/2"
|
||||
assert event["payload"]["gate"] == "finance"
|
||||
assert event["payload"]["gate_ref"] == "opaque-ref-1"
|
||||
assert event["payload"]["ciphertext"] == "abc123"
|
||||
assert event["payload"]["nonce"] == "nonce-7"
|
||||
assert event["payload"]["sender_ref"] == "sender-ref-7"
|
||||
assert event["payload"]["epoch"] == 4
|
||||
|
||||
|
||||
def test_rns_inbound_gate_event_blind_forwards_when_gate_cannot_be_resolved(monkeypatch) -> None:
|
||||
from services import config as config_mod
|
||||
from services.mesh import mesh_hashchain as mesh_hashchain_mod, mesh_rns as mesh_rns_mod
|
||||
|
||||
bridge = RNSBridge()
|
||||
forwarded: list[tuple[str, dict]] = []
|
||||
ingested: list[tuple[str, list[dict]]] = []
|
||||
settings = SimpleNamespace(MESH_RNS_DANDELION_HOPS=3)
|
||||
|
||||
monkeypatch.setattr(config_mod, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(mesh_rns_mod, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(bridge, "_seen", lambda _message_id: False)
|
||||
monkeypatch.setattr(bridge, "_pick_stem_peer", lambda: "peer-stem")
|
||||
monkeypatch.setattr(
|
||||
bridge,
|
||||
"_send_to_peer",
|
||||
lambda peer, payload: forwarded.append((peer, json.loads(payload.decode("utf-8")))),
|
||||
)
|
||||
monkeypatch.setattr(mesh_hashchain_mod, "resolve_gate_wire_ref", lambda gate_ref, event: "")
|
||||
monkeypatch.setattr(
|
||||
mesh_hashchain_mod.gate_store,
|
||||
"ingest_peer_events",
|
||||
lambda gate_id, events: ingested.append((gate_id, events)) or {"accepted": 1, "duplicates": 0, "rejected": 0},
|
||||
)
|
||||
|
||||
original_event = {
|
||||
"event_type": "gate_message",
|
||||
"timestamp": 1710000000,
|
||||
"event_id": "gate-evt-blind",
|
||||
"node_id": "!gate-persona-1",
|
||||
"sequence": 9,
|
||||
"signature": "deadbeef",
|
||||
"public_key": "pubkey-1",
|
||||
"public_key_algo": "Ed25519",
|
||||
"protocol_version": "infonet/2",
|
||||
"payload": {
|
||||
"ciphertext": "abc123",
|
||||
"format": "mls1",
|
||||
"nonce": "nonce-7",
|
||||
"sender_ref": "sender-ref-7",
|
||||
"epoch": 4,
|
||||
"gate_ref": "opaque-ref-1",
|
||||
},
|
||||
}
|
||||
packet = mesh_rns_mod.RNSMessage(
|
||||
msg_type="gate_event",
|
||||
body={"event": original_event},
|
||||
meta={"message_id": "gate-inbound-2", "dandelion": {"phase": "stem", "hops": 0, "max_hops": 2}},
|
||||
).encode()
|
||||
|
||||
bridge._on_packet(packet)
|
||||
|
||||
assert ingested == []
|
||||
assert len(forwarded) == 1
|
||||
peer, forwarded_msg = forwarded[0]
|
||||
assert peer == "peer-stem"
|
||||
assert forwarded_msg["type"] == "gate_event"
|
||||
assert forwarded_msg["meta"] == {
|
||||
"message_id": "gate-inbound-2",
|
||||
"dandelion": {"phase": "stem", "hops": 1, "max_hops": 2},
|
||||
}
|
||||
assert forwarded_msg["body"]["event"] == original_event
|
||||
@@ -0,0 +1,909 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
import main
|
||||
from services.config import get_settings
|
||||
from services.mesh.mesh_crypto import derive_node_id
|
||||
from services.mesh import mesh_dm_relay, mesh_hashchain, mesh_rns
|
||||
|
||||
|
||||
def _fresh_relay(tmp_path, monkeypatch):
|
||||
from services import wormhole_supervisor
|
||||
|
||||
monkeypatch.setattr(mesh_dm_relay, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_dm_relay, "RELAY_FILE", tmp_path / "dm_relay.json")
|
||||
monkeypatch.setattr(
|
||||
wormhole_supervisor,
|
||||
"get_wormhole_state",
|
||||
lambda: {"configured": True, "ready": True, "arti_ready": True, "rns_ready": True},
|
||||
)
|
||||
get_settings.cache_clear()
|
||||
relay = mesh_dm_relay.DMRelay()
|
||||
monkeypatch.setattr(mesh_dm_relay, "dm_relay", relay)
|
||||
return relay
|
||||
|
||||
|
||||
def _post(path: str, payload: dict):
|
||||
async def _run():
|
||||
async with AsyncClient(transport=ASGITransport(app=main.app), base_url="http://test") as ac:
|
||||
return await ac.post(path, json=payload)
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
class _FakeInfonet:
|
||||
def __init__(self):
|
||||
self.appended = []
|
||||
self.sequences = {}
|
||||
|
||||
def append(self, **kwargs):
|
||||
self.appended.append(kwargs)
|
||||
|
||||
def validate_and_set_sequence(self, node_id, sequence):
|
||||
last = self.sequences.get(node_id, 0)
|
||||
if sequence <= last:
|
||||
return False, f"Replay detected: sequence {sequence} <= last {last}"
|
||||
self.sequences[node_id] = sequence
|
||||
return True, ""
|
||||
|
||||
|
||||
class _DirectRNS:
|
||||
def __init__(self, send_result=True, direct_messages=None, direct_ids=None):
|
||||
self.send_result = send_result
|
||||
self.sent = []
|
||||
self.direct_messages = list(direct_messages or [])
|
||||
self.direct_ids_value = set(direct_ids or [])
|
||||
|
||||
def send_private_dm(self, *, mailbox_key, envelope):
|
||||
self.sent.append({"mailbox_key": mailbox_key, "envelope": envelope})
|
||||
return self.send_result
|
||||
|
||||
def collect_private_dm(self, mailbox_keys):
|
||||
return list(self.direct_messages)
|
||||
|
||||
def private_dm_ids(self, mailbox_keys):
|
||||
return set(self.direct_ids_value)
|
||||
|
||||
def count_private_dm(self, mailbox_keys):
|
||||
return len(self.direct_ids_value)
|
||||
|
||||
|
||||
TEST_PUBLIC_KEY = base64.b64encode(b"0" * 32).decode("ascii")
|
||||
TEST_SENDER_ID = derive_node_id(TEST_PUBLIC_KEY)
|
||||
REQUEST_CLAIMS = [{"type": "requests", "token": "request-claim-token"}]
|
||||
NOW_TS = lambda: int(time.time())
|
||||
|
||||
|
||||
def test_secure_dm_send_prefers_reticulum(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
infonet = _FakeInfonet()
|
||||
direct_rns = _DirectRNS(send_result=True)
|
||||
|
||||
monkeypatch.setattr(main, "_secure_dm_enabled", lambda: True)
|
||||
monkeypatch.setattr(main, "_rns_private_dm_ready", lambda: True)
|
||||
monkeypatch.setattr(main, "_verify_signed_event", lambda **_kwargs: (True, ""))
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", infonet)
|
||||
monkeypatch.setattr(mesh_rns, "rns_bridge", direct_rns)
|
||||
|
||||
response = _post(
|
||||
"/api/mesh/dm/send",
|
||||
{
|
||||
"sender_id": TEST_SENDER_ID,
|
||||
"recipient_id": "!sb_recipient1234",
|
||||
"delivery_class": "request",
|
||||
"ciphertext": "ciphertext",
|
||||
"msg_id": "msg-reticulum-1",
|
||||
"timestamp": NOW_TS(),
|
||||
"public_key": TEST_PUBLIC_KEY,
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 7,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
|
||||
body = response.json()
|
||||
assert response.status_code == 200
|
||||
assert body["ok"] is True
|
||||
assert body["transport"] == "reticulum"
|
||||
assert relay.count_claims("!sb_recipient1234", REQUEST_CLAIMS) == 0
|
||||
assert len(direct_rns.sent) == 1
|
||||
assert direct_rns.sent[0]["envelope"]["msg_id"] == "msg-reticulum-1"
|
||||
assert len(infonet.appended) == 0
|
||||
|
||||
|
||||
def test_secure_dm_send_falls_back_to_relay(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
infonet = _FakeInfonet()
|
||||
direct_rns = _DirectRNS(send_result=False)
|
||||
|
||||
monkeypatch.setattr(main, "_secure_dm_enabled", lambda: True)
|
||||
monkeypatch.setattr(main, "_rns_private_dm_ready", lambda: True)
|
||||
monkeypatch.setattr(main, "_verify_signed_event", lambda **_kwargs: (True, ""))
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", infonet)
|
||||
monkeypatch.setattr(mesh_rns, "rns_bridge", direct_rns)
|
||||
|
||||
response = _post(
|
||||
"/api/mesh/dm/send",
|
||||
{
|
||||
"sender_id": TEST_SENDER_ID,
|
||||
"recipient_id": "!sb_recipient1234",
|
||||
"delivery_class": "request",
|
||||
"ciphertext": "ciphertext",
|
||||
"msg_id": "msg-relay-1",
|
||||
"timestamp": NOW_TS(),
|
||||
"public_key": TEST_PUBLIC_KEY,
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 8,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
|
||||
body = response.json()
|
||||
assert response.status_code == 200
|
||||
assert body["ok"] is True
|
||||
assert body["transport"] == "relay"
|
||||
assert "relay fallback" in body["detail"].lower()
|
||||
assert relay.count_claims("!sb_recipient1234", REQUEST_CLAIMS) == 1
|
||||
assert len(infonet.appended) == 0
|
||||
|
||||
|
||||
def test_request_sender_seal_reduces_relay_sender_handle_on_fallback(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
infonet = _FakeInfonet()
|
||||
direct_rns = _DirectRNS(send_result=False)
|
||||
relay_salt = "0123456789abcdef0123456789abcdef"
|
||||
expected_sender = "sealed:" + hmac.new(
|
||||
bytes.fromhex(relay_salt), TEST_SENDER_ID.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()[:16]
|
||||
|
||||
monkeypatch.setattr(main, "_secure_dm_enabled", lambda: True)
|
||||
monkeypatch.setattr(main, "_rns_private_dm_ready", lambda: True)
|
||||
monkeypatch.setattr(main, "_verify_signed_event", lambda **_kwargs: (True, ""))
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", infonet)
|
||||
monkeypatch.setattr(mesh_rns, "rns_bridge", direct_rns)
|
||||
|
||||
response = _post(
|
||||
"/api/mesh/dm/send",
|
||||
{
|
||||
"sender_id": TEST_SENDER_ID,
|
||||
"recipient_id": "!sb_recipient1234",
|
||||
"delivery_class": "request",
|
||||
"ciphertext": "ciphertext",
|
||||
"sender_seal": "v3:test-seal",
|
||||
"relay_salt": relay_salt,
|
||||
"msg_id": "msg-relay-sealed-1",
|
||||
"timestamp": NOW_TS(),
|
||||
"public_key": TEST_PUBLIC_KEY,
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 18,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
|
||||
body = response.json()
|
||||
assert response.status_code == 200
|
||||
assert body["ok"] is True
|
||||
assert body["transport"] == "relay"
|
||||
messages = relay.collect_claims("!sb_recipient1234", REQUEST_CLAIMS)
|
||||
assert [msg["msg_id"] for msg in messages] == ["msg-relay-sealed-1"]
|
||||
assert messages[0]["sender_id"] == expected_sender
|
||||
assert messages[0]["sender_id"] != TEST_SENDER_ID
|
||||
assert messages[0]["sender_seal"] == "v3:test-seal"
|
||||
|
||||
|
||||
def test_request_sender_seal_reduces_direct_rns_sender_handle(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
infonet = _FakeInfonet()
|
||||
direct_rns = _DirectRNS(send_result=True)
|
||||
relay_salt = "fedcba9876543210fedcba9876543210"
|
||||
expected_sender = "sealed:" + hmac.new(
|
||||
bytes.fromhex(relay_salt), TEST_SENDER_ID.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()[:16]
|
||||
|
||||
monkeypatch.setattr(main, "_secure_dm_enabled", lambda: True)
|
||||
monkeypatch.setattr(main, "_rns_private_dm_ready", lambda: True)
|
||||
monkeypatch.setattr(main, "_verify_signed_event", lambda **_kwargs: (True, ""))
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", infonet)
|
||||
monkeypatch.setattr(mesh_rns, "rns_bridge", direct_rns)
|
||||
|
||||
response = _post(
|
||||
"/api/mesh/dm/send",
|
||||
{
|
||||
"sender_id": TEST_SENDER_ID,
|
||||
"recipient_id": "!sb_recipient1234",
|
||||
"delivery_class": "request",
|
||||
"ciphertext": "ciphertext",
|
||||
"sender_seal": "v3:test-seal",
|
||||
"relay_salt": relay_salt,
|
||||
"msg_id": "msg-direct-sealed-1",
|
||||
"timestamp": NOW_TS(),
|
||||
"public_key": TEST_PUBLIC_KEY,
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 19,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
|
||||
body = response.json()
|
||||
assert response.status_code == 200
|
||||
assert body["ok"] is True
|
||||
assert body["transport"] == "reticulum"
|
||||
assert len(direct_rns.sent) == 1
|
||||
assert direct_rns.sent[0]["envelope"]["sender_id"] == expected_sender
|
||||
assert direct_rns.sent[0]["envelope"]["sender_id"] != TEST_SENDER_ID
|
||||
assert direct_rns.sent[0]["envelope"]["sender_seal"] == "v3:test-seal"
|
||||
assert relay.count_claims("!sb_recipient1234", REQUEST_CLAIMS) == 0
|
||||
|
||||
|
||||
def test_request_sender_block_prevents_direct_rns_delivery(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
infonet = _FakeInfonet()
|
||||
direct_rns = _DirectRNS(send_result=True)
|
||||
relay.block("!sb_recipient1234", TEST_SENDER_ID)
|
||||
|
||||
monkeypatch.setattr(main, "_secure_dm_enabled", lambda: True)
|
||||
monkeypatch.setattr(main, "_rns_private_dm_ready", lambda: True)
|
||||
monkeypatch.setattr(main, "_verify_signed_event", lambda **_kwargs: (True, ""))
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", infonet)
|
||||
monkeypatch.setattr(mesh_rns, "rns_bridge", direct_rns)
|
||||
|
||||
response = _post(
|
||||
"/api/mesh/dm/send",
|
||||
{
|
||||
"sender_id": TEST_SENDER_ID,
|
||||
"recipient_id": "!sb_recipient1234",
|
||||
"delivery_class": "request",
|
||||
"ciphertext": "ciphertext",
|
||||
"sender_seal": "v3:test-seal",
|
||||
"relay_salt": "00112233445566778899aabbccddeeff",
|
||||
"msg_id": "msg-direct-blocked-1",
|
||||
"timestamp": NOW_TS(),
|
||||
"public_key": TEST_PUBLIC_KEY,
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 20,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": False, "detail": "Recipient is not accepting your messages"}
|
||||
assert len(direct_rns.sent) == 0
|
||||
assert relay.count_claims("!sb_recipient1234", REQUEST_CLAIMS) == 0
|
||||
|
||||
|
||||
def test_request_sender_seal_respects_raw_sender_block_on_relay_send_path(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
infonet = _FakeInfonet()
|
||||
relay.block("!sb_recipient1234", TEST_SENDER_ID)
|
||||
|
||||
monkeypatch.setattr(main, "_secure_dm_enabled", lambda: False)
|
||||
monkeypatch.setattr(main, "_verify_signed_event", lambda **_kwargs: (True, ""))
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", infonet)
|
||||
|
||||
response = _post(
|
||||
"/api/mesh/dm/send",
|
||||
{
|
||||
"sender_id": TEST_SENDER_ID,
|
||||
"recipient_id": "!sb_recipient1234",
|
||||
"delivery_class": "request",
|
||||
"ciphertext": "ciphertext",
|
||||
"sender_seal": "v3:test-seal",
|
||||
"relay_salt": "00112233445566778899aabbccddeeff",
|
||||
"msg_id": "msg-blocked-sealed-1",
|
||||
"timestamp": NOW_TS(),
|
||||
"public_key": TEST_PUBLIC_KEY,
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 20,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": False, "detail": "Recipient is not accepting your messages"}
|
||||
assert relay.count_claims("!sb_recipient1234", REQUEST_CLAIMS) == 0
|
||||
|
||||
|
||||
def test_secure_dm_send_rejects_replayed_msg_id_nonce(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
infonet = _FakeInfonet()
|
||||
|
||||
monkeypatch.setattr(main, "_secure_dm_enabled", lambda: False)
|
||||
monkeypatch.setattr(main, "_verify_signed_event", lambda **_kwargs: (True, ""))
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", infonet)
|
||||
|
||||
payload = {
|
||||
"sender_id": TEST_SENDER_ID,
|
||||
"recipient_id": "!sb_recipient1234",
|
||||
"delivery_class": "request",
|
||||
"ciphertext": "ciphertext",
|
||||
"msg_id": "msg-replay-1",
|
||||
"timestamp": NOW_TS(),
|
||||
"public_key": TEST_PUBLIC_KEY,
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 14,
|
||||
"protocol_version": "infonet/2",
|
||||
}
|
||||
|
||||
first = _post("/api/mesh/dm/send", payload)
|
||||
second = _post("/api/mesh/dm/send", payload)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert first.json()["ok"] is True
|
||||
assert second.status_code == 200
|
||||
assert second.json() == {"ok": False, "detail": "nonce replay detected"}
|
||||
assert relay.count_claims("!sb_recipient1234", REQUEST_CLAIMS) == 1
|
||||
|
||||
|
||||
def test_secure_dm_send_rejects_replayed_sequence_with_new_nonce(tmp_path, monkeypatch):
|
||||
_fresh_relay(tmp_path, monkeypatch)
|
||||
infonet = _FakeInfonet()
|
||||
|
||||
monkeypatch.setattr(main, "_secure_dm_enabled", lambda: False)
|
||||
monkeypatch.setattr(main, "_verify_signed_event", lambda **_kwargs: (True, ""))
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", infonet)
|
||||
|
||||
first = _post(
|
||||
"/api/mesh/dm/send",
|
||||
{
|
||||
"sender_id": TEST_SENDER_ID,
|
||||
"recipient_id": "!sb_recipient1234",
|
||||
"delivery_class": "request",
|
||||
"ciphertext": "ciphertext",
|
||||
"msg_id": "msg-seq-1",
|
||||
"nonce": "nonce-seq-1",
|
||||
"timestamp": NOW_TS(),
|
||||
"public_key": TEST_PUBLIC_KEY,
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 15,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
second = _post(
|
||||
"/api/mesh/dm/send",
|
||||
{
|
||||
"sender_id": TEST_SENDER_ID,
|
||||
"recipient_id": "!sb_recipient1234",
|
||||
"delivery_class": "request",
|
||||
"ciphertext": "ciphertext-again",
|
||||
"msg_id": "msg-seq-2",
|
||||
"nonce": "nonce-seq-2",
|
||||
"timestamp": NOW_TS(),
|
||||
"public_key": TEST_PUBLIC_KEY,
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 15,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert first.json()["ok"] is True
|
||||
assert second.status_code == 200
|
||||
assert second.json() == {"ok": False, "detail": "Replay detected: sequence 15 <= last 15"}
|
||||
|
||||
|
||||
def test_secure_dm_send_does_not_consume_nonce_before_signature_verification(tmp_path, monkeypatch):
|
||||
_fresh_relay(tmp_path, monkeypatch)
|
||||
infonet = _FakeInfonet()
|
||||
consumed = {"count": 0}
|
||||
|
||||
monkeypatch.setattr(main, "_secure_dm_enabled", lambda: False)
|
||||
monkeypatch.setattr(main, "_verify_signed_event", lambda **_kwargs: (False, "Invalid signature"))
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", infonet)
|
||||
monkeypatch.setattr(
|
||||
mesh_dm_relay.dm_relay,
|
||||
"consume_nonce",
|
||||
lambda *_args, **_kwargs: consumed.__setitem__("count", consumed["count"] + 1) or (True, "ok"),
|
||||
)
|
||||
|
||||
response = _post(
|
||||
"/api/mesh/dm/send",
|
||||
{
|
||||
"sender_id": TEST_SENDER_ID,
|
||||
"recipient_id": "!sb_recipient1234",
|
||||
"delivery_class": "request",
|
||||
"ciphertext": "ciphertext",
|
||||
"msg_id": "msg-invalid-sig",
|
||||
"timestamp": NOW_TS(),
|
||||
"public_key": TEST_PUBLIC_KEY,
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 16,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": False, "detail": "Invalid signature"}
|
||||
assert consumed["count"] == 0
|
||||
|
||||
|
||||
def test_anonymous_mode_dm_send_stays_off_reticulum(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
infonet = _FakeInfonet()
|
||||
direct_rns = _DirectRNS(send_result=True)
|
||||
|
||||
monkeypatch.setattr(main, "_secure_dm_enabled", lambda: True)
|
||||
monkeypatch.setattr(main, "_rns_private_dm_ready", lambda: True)
|
||||
monkeypatch.setattr(main, "_anonymous_dm_hidden_transport_enforced", lambda: True)
|
||||
monkeypatch.setattr(main, "_verify_signed_event", lambda **_kwargs: (True, ""))
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", infonet)
|
||||
monkeypatch.setattr(mesh_rns, "rns_bridge", direct_rns)
|
||||
|
||||
response = _post(
|
||||
"/api/mesh/dm/send",
|
||||
{
|
||||
"sender_id": TEST_SENDER_ID,
|
||||
"recipient_id": "!sb_recipient1234",
|
||||
"delivery_class": "request",
|
||||
"ciphertext": "ciphertext",
|
||||
"msg_id": "msg-anon-relay-1",
|
||||
"timestamp": NOW_TS(),
|
||||
"public_key": TEST_PUBLIC_KEY,
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 9,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
|
||||
body = response.json()
|
||||
assert response.status_code == 200
|
||||
assert body["ok"] is True
|
||||
assert body["transport"] == "relay"
|
||||
assert "off direct transport" in body["detail"].lower()
|
||||
assert relay.count_claims("!sb_recipient1234", REQUEST_CLAIMS) == 1
|
||||
assert len(direct_rns.sent) == 0
|
||||
assert len(infonet.appended) == 0
|
||||
|
||||
|
||||
def test_secure_dm_poll_and_count_merge_relay_and_reticulum(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
relay.deposit(
|
||||
sender_id="alice",
|
||||
recipient_id="bob",
|
||||
ciphertext="cipher-relay-dup",
|
||||
msg_id="dup",
|
||||
delivery_class="request",
|
||||
)
|
||||
relay.deposit(
|
||||
sender_id="alice",
|
||||
recipient_id="bob",
|
||||
ciphertext="cipher-relay-only",
|
||||
msg_id="relay-only",
|
||||
delivery_class="request",
|
||||
)
|
||||
|
||||
direct_rns = _DirectRNS(
|
||||
direct_messages=[
|
||||
{
|
||||
"sender_id": "sealed:1234",
|
||||
"ciphertext": "cipher-direct-dup",
|
||||
"timestamp": 100.0,
|
||||
"msg_id": "dup",
|
||||
"delivery_class": "request",
|
||||
"sender_seal": "",
|
||||
"transport": "reticulum",
|
||||
},
|
||||
{
|
||||
"sender_id": "sealed:1234",
|
||||
"ciphertext": "cipher-direct-only",
|
||||
"timestamp": 101.0,
|
||||
"msg_id": "direct-only",
|
||||
"delivery_class": "request",
|
||||
"sender_seal": "",
|
||||
"transport": "reticulum",
|
||||
},
|
||||
],
|
||||
direct_ids={"dup", "direct-only"},
|
||||
)
|
||||
infonet = _FakeInfonet()
|
||||
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_verify_dm_mailbox_request",
|
||||
lambda **_kwargs: (True, "", {"mailbox_claims": REQUEST_CLAIMS}),
|
||||
)
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", infonet)
|
||||
monkeypatch.setattr(mesh_rns, "rns_bridge", direct_rns)
|
||||
|
||||
poll_response = _post(
|
||||
"/api/mesh/dm/poll",
|
||||
{
|
||||
"agent_id": "bob",
|
||||
"mailbox_claims": REQUEST_CLAIMS,
|
||||
"timestamp": NOW_TS(),
|
||||
"nonce": "nonce-poll",
|
||||
"public_key": "pub",
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 10,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
poll_body = poll_response.json()
|
||||
assert poll_response.status_code == 200
|
||||
assert poll_body["ok"] is True
|
||||
assert poll_body["count"] == 3
|
||||
assert {msg["msg_id"] for msg in poll_body["messages"]} == {"dup", "relay-only", "direct-only"}
|
||||
dup_message = next(msg for msg in poll_body["messages"] if msg["msg_id"] == "dup")
|
||||
assert dup_message["sender_id"] == "alice"
|
||||
assert dup_message["ciphertext"] == "cipher-relay-dup"
|
||||
|
||||
count_response = _post(
|
||||
"/api/mesh/dm/count",
|
||||
{
|
||||
"agent_id": "bob",
|
||||
"mailbox_claims": REQUEST_CLAIMS,
|
||||
"timestamp": NOW_TS(),
|
||||
"nonce": "nonce-count",
|
||||
"public_key": "pub",
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 11,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
count_body = count_response.json()
|
||||
assert count_response.status_code == 200
|
||||
assert count_body["ok"] is True
|
||||
assert count_body["count"] == 2
|
||||
|
||||
|
||||
def test_secure_dm_poll_marks_reduced_v3_request_recovery_fields(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
relay.deposit(
|
||||
sender_id="sealed:relayv3",
|
||||
raw_sender_id="alice",
|
||||
recipient_id="bob",
|
||||
ciphertext="cipher-relay-v3",
|
||||
msg_id="relay-v3",
|
||||
delivery_class="request",
|
||||
sender_seal="v3:relay-seal",
|
||||
)
|
||||
relay.deposit(
|
||||
sender_id="alice",
|
||||
recipient_id="bob",
|
||||
ciphertext="cipher-legacy",
|
||||
msg_id="legacy-raw",
|
||||
delivery_class="request",
|
||||
)
|
||||
|
||||
direct_rns = _DirectRNS(
|
||||
direct_messages=[
|
||||
{
|
||||
"sender_id": "sealed:directv3",
|
||||
"ciphertext": "cipher-direct-v3",
|
||||
"timestamp": 101.0,
|
||||
"msg_id": "direct-v3",
|
||||
"delivery_class": "request",
|
||||
"sender_seal": "v3:direct-seal",
|
||||
"transport": "reticulum",
|
||||
}
|
||||
],
|
||||
direct_ids={"direct-v3"},
|
||||
)
|
||||
infonet = _FakeInfonet()
|
||||
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_verify_dm_mailbox_request",
|
||||
lambda **_kwargs: (True, "", {"mailbox_claims": REQUEST_CLAIMS}),
|
||||
)
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", infonet)
|
||||
monkeypatch.setattr(mesh_rns, "rns_bridge", direct_rns)
|
||||
|
||||
poll_response = _post(
|
||||
"/api/mesh/dm/poll",
|
||||
{
|
||||
"agent_id": "bob",
|
||||
"mailbox_claims": REQUEST_CLAIMS,
|
||||
"timestamp": NOW_TS(),
|
||||
"nonce": "nonce-poll-markers",
|
||||
"public_key": "pub",
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 12,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
poll_body = poll_response.json()
|
||||
|
||||
assert poll_response.status_code == 200
|
||||
assert poll_body["ok"] is True
|
||||
assert poll_body["count"] == 3
|
||||
|
||||
by_id = {msg["msg_id"]: msg for msg in poll_body["messages"]}
|
||||
|
||||
assert by_id["relay-v3"]["request_contract_version"] == "request-v2-reduced-v3"
|
||||
assert by_id["relay-v3"]["sender_recovery_required"] is True
|
||||
assert by_id["relay-v3"]["sender_recovery_state"] == "pending"
|
||||
|
||||
assert by_id["direct-v3"]["request_contract_version"] == "request-v2-reduced-v3"
|
||||
assert by_id["direct-v3"]["sender_recovery_required"] is True
|
||||
assert by_id["direct-v3"]["sender_recovery_state"] == "pending"
|
||||
|
||||
assert "request_contract_version" not in by_id["legacy-raw"]
|
||||
assert "sender_recovery_required" not in by_id["legacy-raw"]
|
||||
assert "sender_recovery_state" not in by_id["legacy-raw"]
|
||||
|
||||
|
||||
def test_secure_dm_poll_prefers_canonical_v2_duplicate_over_legacy_raw(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
relay.deposit(
|
||||
sender_id="alice",
|
||||
recipient_id="bob",
|
||||
ciphertext="cipher-relay-raw",
|
||||
msg_id="dup-v2-over-raw",
|
||||
delivery_class="request",
|
||||
)
|
||||
|
||||
direct_rns = _DirectRNS(
|
||||
direct_messages=[
|
||||
{
|
||||
"sender_id": "sealed:directv3",
|
||||
"ciphertext": "cipher-direct-v3",
|
||||
"timestamp": 101.0,
|
||||
"msg_id": "dup-v2-over-raw",
|
||||
"delivery_class": "request",
|
||||
"sender_seal": "v3:direct-seal",
|
||||
"transport": "reticulum",
|
||||
}
|
||||
],
|
||||
direct_ids={"dup-v2-over-raw"},
|
||||
)
|
||||
infonet = _FakeInfonet()
|
||||
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_verify_dm_mailbox_request",
|
||||
lambda **_kwargs: (True, "", {"mailbox_claims": REQUEST_CLAIMS}),
|
||||
)
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", infonet)
|
||||
monkeypatch.setattr(mesh_rns, "rns_bridge", direct_rns)
|
||||
|
||||
poll_response = _post(
|
||||
"/api/mesh/dm/poll",
|
||||
{
|
||||
"agent_id": "bob",
|
||||
"mailbox_claims": REQUEST_CLAIMS,
|
||||
"timestamp": NOW_TS(),
|
||||
"nonce": "nonce-poll-v2-over-raw",
|
||||
"public_key": "pub",
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 13,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
poll_body = poll_response.json()
|
||||
|
||||
assert poll_response.status_code == 200
|
||||
assert poll_body["ok"] is True
|
||||
assert poll_body["count"] == 1
|
||||
message = poll_body["messages"][0]
|
||||
assert message["msg_id"] == "dup-v2-over-raw"
|
||||
assert message["sender_id"] == "sealed:directv3"
|
||||
assert message["ciphertext"] == "cipher-direct-v3"
|
||||
assert message["transport"] == "reticulum"
|
||||
assert message["request_contract_version"] == "request-v2-reduced-v3"
|
||||
assert message["sender_recovery_required"] is True
|
||||
assert message["sender_recovery_state"] == "pending"
|
||||
|
||||
|
||||
def test_secure_dm_poll_prefers_legacy_raw_duplicate_over_legacy_sealed(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
relay.deposit(
|
||||
sender_id="sealed:relaylegacy",
|
||||
raw_sender_id="alice",
|
||||
recipient_id="bob",
|
||||
ciphertext="cipher-relay-sealed",
|
||||
msg_id="dup-raw-over-sealed",
|
||||
delivery_class="request",
|
||||
sender_seal="v2:legacy-seal",
|
||||
)
|
||||
|
||||
direct_rns = _DirectRNS(
|
||||
direct_messages=[
|
||||
{
|
||||
"sender_id": "alice",
|
||||
"ciphertext": "cipher-direct-raw",
|
||||
"timestamp": 101.0,
|
||||
"msg_id": "dup-raw-over-sealed",
|
||||
"delivery_class": "request",
|
||||
"sender_seal": "",
|
||||
"transport": "reticulum",
|
||||
}
|
||||
],
|
||||
direct_ids={"dup-raw-over-sealed"},
|
||||
)
|
||||
infonet = _FakeInfonet()
|
||||
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_verify_dm_mailbox_request",
|
||||
lambda **_kwargs: (True, "", {"mailbox_claims": REQUEST_CLAIMS}),
|
||||
)
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", infonet)
|
||||
monkeypatch.setattr(mesh_rns, "rns_bridge", direct_rns)
|
||||
|
||||
poll_response = _post(
|
||||
"/api/mesh/dm/poll",
|
||||
{
|
||||
"agent_id": "bob",
|
||||
"mailbox_claims": REQUEST_CLAIMS,
|
||||
"timestamp": NOW_TS(),
|
||||
"nonce": "nonce-poll-raw-over-sealed",
|
||||
"public_key": "pub",
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 14,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
poll_body = poll_response.json()
|
||||
|
||||
assert poll_response.status_code == 200
|
||||
assert poll_body["ok"] is True
|
||||
assert poll_body["count"] == 1
|
||||
message = poll_body["messages"][0]
|
||||
assert message["msg_id"] == "dup-raw-over-sealed"
|
||||
assert message["sender_id"] == "alice"
|
||||
assert message["ciphertext"] == "cipher-direct-raw"
|
||||
assert message["transport"] == "reticulum"
|
||||
assert "request_contract_version" not in message
|
||||
assert "sender_recovery_required" not in message
|
||||
assert "sender_recovery_state" not in message
|
||||
|
||||
|
||||
def test_secure_dm_poll_keeps_relay_copy_for_same_contract_v2_duplicate(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
relay.deposit(
|
||||
sender_id="sealed:sharedv3",
|
||||
raw_sender_id="alice",
|
||||
recipient_id="bob",
|
||||
ciphertext="cipher-relay-v3-dup",
|
||||
msg_id="dup-v2-tie",
|
||||
delivery_class="request",
|
||||
sender_seal="v3:relay-seal",
|
||||
)
|
||||
|
||||
direct_rns = _DirectRNS(
|
||||
direct_messages=[
|
||||
{
|
||||
"sender_id": "sealed:sharedv3",
|
||||
"ciphertext": "cipher-direct-v3-dup",
|
||||
"timestamp": 101.0,
|
||||
"msg_id": "dup-v2-tie",
|
||||
"delivery_class": "request",
|
||||
"sender_seal": "v3:relay-seal",
|
||||
"transport": "reticulum",
|
||||
}
|
||||
],
|
||||
direct_ids={"dup-v2-tie"},
|
||||
)
|
||||
infonet = _FakeInfonet()
|
||||
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_verify_dm_mailbox_request",
|
||||
lambda **_kwargs: (True, "", {"mailbox_claims": REQUEST_CLAIMS}),
|
||||
)
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", infonet)
|
||||
monkeypatch.setattr(mesh_rns, "rns_bridge", direct_rns)
|
||||
|
||||
poll_response = _post(
|
||||
"/api/mesh/dm/poll",
|
||||
{
|
||||
"agent_id": "bob",
|
||||
"mailbox_claims": REQUEST_CLAIMS,
|
||||
"timestamp": NOW_TS(),
|
||||
"nonce": "nonce-poll-v2-tie",
|
||||
"public_key": "pub",
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 15,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
poll_body = poll_response.json()
|
||||
|
||||
assert poll_response.status_code == 200
|
||||
assert poll_body["ok"] is True
|
||||
assert poll_body["count"] == 1
|
||||
message = poll_body["messages"][0]
|
||||
assert message["msg_id"] == "dup-v2-tie"
|
||||
assert message["sender_id"] == "sealed:sharedv3"
|
||||
assert message["ciphertext"] == "cipher-relay-v3-dup"
|
||||
assert "transport" not in message
|
||||
assert message["request_contract_version"] == "request-v2-reduced-v3"
|
||||
assert message["sender_recovery_required"] is True
|
||||
assert message["sender_recovery_state"] == "pending"
|
||||
|
||||
|
||||
def test_anonymous_mode_poll_and_count_ignore_reticulum(tmp_path, monkeypatch):
|
||||
relay = _fresh_relay(tmp_path, monkeypatch)
|
||||
relay.deposit(
|
||||
sender_id="alice",
|
||||
recipient_id="bob",
|
||||
ciphertext="cipher-relay-only",
|
||||
msg_id="relay-only",
|
||||
delivery_class="request",
|
||||
)
|
||||
|
||||
direct_rns = _DirectRNS(
|
||||
direct_messages=[
|
||||
{
|
||||
"sender_id": "sealed:1234",
|
||||
"ciphertext": "cipher-direct-only",
|
||||
"timestamp": 101.0,
|
||||
"msg_id": "direct-only",
|
||||
"delivery_class": "request",
|
||||
"sender_seal": "",
|
||||
"transport": "reticulum",
|
||||
},
|
||||
],
|
||||
direct_ids={"direct-only"},
|
||||
)
|
||||
infonet = _FakeInfonet()
|
||||
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_verify_dm_mailbox_request",
|
||||
lambda **_kwargs: (True, "", {"mailbox_claims": REQUEST_CLAIMS}),
|
||||
)
|
||||
monkeypatch.setattr(main, "_anonymous_dm_hidden_transport_enforced", lambda: True)
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", infonet)
|
||||
monkeypatch.setattr(mesh_rns, "rns_bridge", direct_rns)
|
||||
|
||||
poll_response = _post(
|
||||
"/api/mesh/dm/poll",
|
||||
{
|
||||
"agent_id": "bob",
|
||||
"mailbox_claims": REQUEST_CLAIMS,
|
||||
"timestamp": NOW_TS(),
|
||||
"nonce": "nonce-poll-anon",
|
||||
"public_key": "pub",
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 12,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
poll_body = poll_response.json()
|
||||
assert poll_response.status_code == 200
|
||||
assert poll_body["ok"] is True
|
||||
assert poll_body["count"] == 1
|
||||
assert {msg["msg_id"] for msg in poll_body["messages"]} == {"relay-only"}
|
||||
|
||||
count_response = _post(
|
||||
"/api/mesh/dm/count",
|
||||
{
|
||||
"agent_id": "bob",
|
||||
"mailbox_claims": REQUEST_CLAIMS,
|
||||
"timestamp": NOW_TS(),
|
||||
"nonce": "nonce-count-anon",
|
||||
"public_key": "pub",
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 13,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
count_body = count_response.json()
|
||||
assert count_response.status_code == 200
|
||||
assert count_body["ok"] is True
|
||||
assert count_body["count"] == 0
|
||||
@@ -0,0 +1,225 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
def _reset_secure_storage_state(mesh_secure_storage) -> None:
|
||||
mesh_secure_storage._MASTER_KEY_CACHE = None
|
||||
mesh_secure_storage._DOMAIN_KEY_CACHE.clear()
|
||||
|
||||
|
||||
def test_secure_storage_encrypts_and_reads_json(tmp_path, monkeypatch):
|
||||
from services.mesh import mesh_secure_storage
|
||||
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
_reset_secure_storage_state(mesh_secure_storage)
|
||||
|
||||
path = tmp_path / "secret.json"
|
||||
mesh_secure_storage.write_secure_json(path, {"alpha": 1, "bravo": "two"})
|
||||
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert raw["kind"] == "sb_secure_json"
|
||||
assert "alpha" not in path.read_text(encoding="utf-8")
|
||||
|
||||
data = mesh_secure_storage.read_secure_json(path, lambda: {})
|
||||
assert data == {"alpha": 1, "bravo": "two"}
|
||||
|
||||
|
||||
def test_secure_storage_migrates_plaintext_json(tmp_path, monkeypatch):
|
||||
from services.mesh import mesh_secure_storage
|
||||
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
_reset_secure_storage_state(mesh_secure_storage)
|
||||
|
||||
path = tmp_path / "legacy.json"
|
||||
path.write_text(json.dumps({"legacy": True}), encoding="utf-8")
|
||||
|
||||
data = mesh_secure_storage.read_secure_json(path, lambda: {})
|
||||
assert data == {"legacy": True}
|
||||
|
||||
migrated = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert migrated["kind"] == "sb_secure_json"
|
||||
|
||||
|
||||
def test_secure_storage_fails_closed_on_decrypt_error(tmp_path, monkeypatch):
|
||||
import pytest
|
||||
|
||||
from services.mesh import mesh_secure_storage
|
||||
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
_reset_secure_storage_state(mesh_secure_storage)
|
||||
|
||||
path = tmp_path / "corrupt.json"
|
||||
mesh_secure_storage.write_secure_json(path, {"secret": "value"})
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
payload["ciphertext"] = payload["ciphertext"][:-4] + "AAAA"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
with pytest.raises(mesh_secure_storage.SecureStorageError):
|
||||
mesh_secure_storage.read_secure_json(path, lambda: {})
|
||||
|
||||
|
||||
def test_secure_storage_round_trips_across_process_boundary(tmp_path, monkeypatch):
|
||||
if os.name != "nt":
|
||||
return
|
||||
|
||||
from services.mesh import mesh_secure_storage
|
||||
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
_reset_secure_storage_state(mesh_secure_storage)
|
||||
|
||||
path = tmp_path / "cross-process.json"
|
||||
mesh_secure_storage.write_secure_json(path, {"alpha": 7, "bravo": "cross-process"})
|
||||
backend_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
script = f"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from services.mesh import mesh_secure_storage
|
||||
mesh_secure_storage.DATA_DIR = Path(r"{tmp_path}")
|
||||
mesh_secure_storage.MASTER_KEY_FILE = Path(r"{tmp_path / 'wormhole_secure_store.key'}")
|
||||
print(json.dumps(mesh_secure_storage.read_secure_json(r"{path}", lambda: {{}})))
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=backend_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ.copy(), "PYTHONPATH": backend_root},
|
||||
check=True,
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout.strip()) == {"alpha": 7, "bravo": "cross-process"}
|
||||
|
||||
|
||||
def test_domain_storage_isolation_keeps_gate_and_dm_data_separate(tmp_path, monkeypatch):
|
||||
from services.mesh import mesh_secure_storage
|
||||
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
_reset_secure_storage_state(mesh_secure_storage)
|
||||
|
||||
mesh_secure_storage.write_domain_json("gate_persona", "gate.json", {"gate": "alpha"})
|
||||
mesh_secure_storage.write_domain_json("dm_alias", "dm.json", {"alias": "bravo"})
|
||||
|
||||
gate_data = mesh_secure_storage.read_domain_json("gate_persona", "gate.json", lambda: {})
|
||||
dm_data = mesh_secure_storage.read_domain_json("dm_alias", "dm.json", lambda: {})
|
||||
|
||||
assert gate_data == {"gate": "alpha"}
|
||||
assert dm_data == {"alias": "bravo"}
|
||||
assert (tmp_path / "gate_persona" / "gate.json").exists()
|
||||
assert (tmp_path / "dm_alias" / "dm.json").exists()
|
||||
|
||||
|
||||
def test_domain_storage_uses_independent_domain_key_files(tmp_path, monkeypatch):
|
||||
from services.mesh import mesh_secure_storage
|
||||
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
_reset_secure_storage_state(mesh_secure_storage)
|
||||
|
||||
mesh_secure_storage.write_domain_json("gate_persona", "gate.json", {"gate": "alpha"})
|
||||
mesh_secure_storage.write_domain_json("dm_alias", "dm.json", {"alias": "bravo"})
|
||||
|
||||
gate_key = tmp_path / "_domain_keys" / "gate_persona.key"
|
||||
dm_key = tmp_path / "_domain_keys" / "dm_alias.key"
|
||||
|
||||
assert gate_key.exists()
|
||||
assert dm_key.exists()
|
||||
assert gate_key.read_text(encoding="utf-8") != dm_key.read_text(encoding="utf-8")
|
||||
assert not mesh_secure_storage.MASTER_KEY_FILE.exists()
|
||||
|
||||
|
||||
def test_domain_storage_migrates_legacy_master_derived_ciphertext(tmp_path, monkeypatch):
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
from services.mesh import mesh_secure_storage
|
||||
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
_reset_secure_storage_state(mesh_secure_storage)
|
||||
|
||||
domain = "gate_persona"
|
||||
filename = "legacy.json"
|
||||
payload = {"legacy": True}
|
||||
file_path = tmp_path / domain / filename
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
nonce = os.urandom(12)
|
||||
ciphertext = AESGCM(mesh_secure_storage._derive_legacy_domain_key(domain)).encrypt(
|
||||
nonce,
|
||||
mesh_secure_storage._stable_json(payload),
|
||||
mesh_secure_storage._domain_aad(domain, filename),
|
||||
)
|
||||
envelope = mesh_secure_storage._secure_envelope(file_path, nonce, ciphertext)
|
||||
file_path.write_text(json.dumps(envelope), encoding="utf-8")
|
||||
_reset_secure_storage_state(mesh_secure_storage)
|
||||
|
||||
data = mesh_secure_storage.read_domain_json(domain, filename, lambda: {})
|
||||
|
||||
assert data == payload
|
||||
assert (tmp_path / "_domain_keys" / f"{domain}.key").exists()
|
||||
|
||||
migrated = json.loads(file_path.read_text(encoding="utf-8"))
|
||||
with pytest.raises(Exception):
|
||||
AESGCM(mesh_secure_storage._derive_legacy_domain_key(domain)).decrypt(
|
||||
mesh_secure_storage._unb64(migrated["nonce"]),
|
||||
mesh_secure_storage._unb64(migrated["ciphertext"]),
|
||||
mesh_secure_storage._domain_aad(domain, filename),
|
||||
)
|
||||
|
||||
|
||||
def test_domain_storage_rejects_path_traversal(tmp_path, monkeypatch):
|
||||
import pytest
|
||||
|
||||
from services.mesh import mesh_secure_storage
|
||||
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
_reset_secure_storage_state(mesh_secure_storage)
|
||||
|
||||
with pytest.raises(mesh_secure_storage.SecureStorageError):
|
||||
mesh_secure_storage._domain_file_path("../../etc", "passwd")
|
||||
|
||||
|
||||
def test_raw_fallback_requires_explicit_opt_in_not_debug(monkeypatch):
|
||||
from services import config as config_mod
|
||||
from services.mesh import mesh_secure_storage
|
||||
|
||||
monkeypatch.setattr(mesh_secure_storage, "_is_windows", lambda: False)
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
monkeypatch.setattr(
|
||||
config_mod,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(
|
||||
MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK=False,
|
||||
MESH_DEBUG_MODE=True,
|
||||
),
|
||||
)
|
||||
|
||||
assert mesh_secure_storage._raw_fallback_allowed() is False
|
||||
|
||||
|
||||
def test_raw_fallback_allows_explicit_opt_in(monkeypatch):
|
||||
from services import config as config_mod
|
||||
from services.mesh import mesh_secure_storage
|
||||
|
||||
monkeypatch.setattr(mesh_secure_storage, "_is_windows", lambda: False)
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
monkeypatch.setattr(
|
||||
config_mod,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(
|
||||
MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK=True,
|
||||
MESH_DEBUG_MODE=False,
|
||||
),
|
||||
)
|
||||
|
||||
assert mesh_secure_storage._raw_fallback_allowed() is True
|
||||
@@ -0,0 +1,73 @@
|
||||
import asyncio
|
||||
|
||||
|
||||
class TestSensitiveBackendNoStore:
|
||||
def test_mesh_status_sets_privacy_security_headers(self, client):
|
||||
r = client.get("/api/mesh/infonet/status")
|
||||
assert r.status_code == 200
|
||||
assert "default-src 'self'" in (r.headers.get("content-security-policy") or "")
|
||||
assert (r.headers.get("x-frame-options") or "").upper() == "DENY"
|
||||
assert (r.headers.get("x-content-type-options") or "").lower() == "nosniff"
|
||||
assert (r.headers.get("referrer-policy") or "").lower() == "no-referrer"
|
||||
|
||||
def test_wormhole_status_is_no_store(self, client):
|
||||
r = client.get("/api/wormhole/status")
|
||||
assert r.status_code == 200
|
||||
assert "no-store" in (r.headers.get("cache-control") or "").lower()
|
||||
|
||||
def test_settings_privacy_profile_is_no_store(self, client):
|
||||
r = client.get("/api/settings/privacy-profile")
|
||||
assert r.status_code == 200
|
||||
assert "no-store" in (r.headers.get("cache-control") or "").lower()
|
||||
|
||||
def test_settings_wormhole_is_no_store(self, client):
|
||||
r = client.get("/api/settings/wormhole")
|
||||
assert r.status_code == 200
|
||||
assert "no-store" in (r.headers.get("cache-control") or "").lower()
|
||||
|
||||
def test_settings_wormhole_status_is_no_store(self, client):
|
||||
r = client.get("/api/settings/wormhole-status")
|
||||
assert r.status_code == 200
|
||||
assert "no-store" in (r.headers.get("cache-control") or "").lower()
|
||||
|
||||
def test_dm_pubkey_is_no_store_even_on_failure(self, client):
|
||||
r = client.get("/api/mesh/dm/pubkey?agent_id=missing")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["ok"] is False
|
||||
assert "no-store" in (r.headers.get("cache-control") or "").lower()
|
||||
|
||||
def test_anonymous_mode_blocked_dm_send_is_no_store(self, client, monkeypatch):
|
||||
import main
|
||||
from services import wormhole_settings, wormhole_status
|
||||
|
||||
monkeypatch.setattr(
|
||||
wormhole_settings,
|
||||
"read_wormhole_settings",
|
||||
lambda: {
|
||||
"enabled": True,
|
||||
"privacy_profile": "default",
|
||||
"transport": "direct",
|
||||
"anonymous_mode": True,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
wormhole_status,
|
||||
"read_wormhole_status",
|
||||
lambda: {
|
||||
"running": True,
|
||||
"ready": True,
|
||||
"transport_active": "direct",
|
||||
},
|
||||
)
|
||||
|
||||
async def _post():
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
transport = ASGITransport(app=main.app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
return await ac.post("/api/mesh/dm/send", json={})
|
||||
|
||||
response = asyncio.run(_post())
|
||||
assert response.status_code == 428
|
||||
assert "no-store" in (response.headers.get("cache-control") or "").lower()
|
||||
@@ -0,0 +1,63 @@
|
||||
def test_wormhole_identity_allows_local_operator_without_admin_key(client, monkeypatch):
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main, "_current_admin_key", lambda: "test-key")
|
||||
monkeypatch.setattr(main, "_allow_insecure_admin", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"get_transport_identity",
|
||||
lambda: {
|
||||
"node_id": "transport-node",
|
||||
"public_key": "pub",
|
||||
"public_key_algo": "Ed25519",
|
||||
},
|
||||
)
|
||||
|
||||
allowed = client.get("/api/wormhole/identity")
|
||||
assert allowed.status_code == 200
|
||||
assert allowed.json()["node_id"] == "transport-node"
|
||||
|
||||
|
||||
def test_wormhole_gate_identity_allows_local_operator_without_admin_key(client, monkeypatch):
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main, "_current_admin_key", lambda: "test-key")
|
||||
monkeypatch.setattr(main, "_allow_insecure_admin", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"get_active_gate_identity",
|
||||
lambda gate_id: {
|
||||
"ok": True,
|
||||
"gate_id": gate_id,
|
||||
"identity": {"node_id": "gate-node", "scope": "gate_session"},
|
||||
},
|
||||
)
|
||||
|
||||
allowed = client.get("/api/wormhole/gate/journalists/identity")
|
||||
assert allowed.status_code == 200
|
||||
body = allowed.json()
|
||||
assert body["gate_id"] == "journalists"
|
||||
assert body["identity"]["node_id"] == "gate-node"
|
||||
|
||||
|
||||
def test_wormhole_gate_personas_allows_local_operator_without_admin_key(client, monkeypatch):
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main, "_current_admin_key", lambda: "test-key")
|
||||
monkeypatch.setattr(main, "_allow_insecure_admin", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"list_gate_personas",
|
||||
lambda gate_id: {
|
||||
"ok": True,
|
||||
"gate_id": gate_id,
|
||||
"active_persona_id": "",
|
||||
"personas": [{"node_id": "persona-node", "scope": "gate_persona"}],
|
||||
},
|
||||
)
|
||||
|
||||
allowed = client.get("/api/wormhole/gate/journalists/personas")
|
||||
assert allowed.status_code == 200
|
||||
body = allowed.json()
|
||||
assert body["gate_id"] == "journalists"
|
||||
assert body["personas"][0]["node_id"] == "persona-node"
|
||||
@@ -0,0 +1,480 @@
|
||||
import base64
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric import x25519
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat
|
||||
from starlette.requests import Request
|
||||
|
||||
|
||||
def _fresh_mesh_state(tmp_path, monkeypatch):
|
||||
from services.mesh import (
|
||||
mesh_dm_relay,
|
||||
mesh_secure_storage,
|
||||
mesh_wormhole_identity,
|
||||
mesh_wormhole_persona,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mesh_dm_relay, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_dm_relay, "RELAY_FILE", tmp_path / "dm_relay.json")
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
monkeypatch.setattr(mesh_wormhole_persona, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_wormhole_persona, "PERSONA_FILE", tmp_path / "wormhole_persona.json")
|
||||
monkeypatch.setattr(
|
||||
mesh_wormhole_persona,
|
||||
"LEGACY_DM_IDENTITY_FILE",
|
||||
tmp_path / "wormhole_identity.json",
|
||||
)
|
||||
relay = mesh_dm_relay.DMRelay()
|
||||
monkeypatch.setattr(mesh_dm_relay, "dm_relay", relay)
|
||||
return relay, mesh_wormhole_identity
|
||||
|
||||
|
||||
def _json_request(path: str, body: dict) -> Request:
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
sent = {"value": False}
|
||||
|
||||
async def receive():
|
||||
if sent["value"]:
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
sent["value"] = True
|
||||
return {"type": "http.request", "body": payload, "more_body": False}
|
||||
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"client": ("test", 12345),
|
||||
"method": "POST",
|
||||
"path": path,
|
||||
},
|
||||
receive,
|
||||
)
|
||||
|
||||
|
||||
def test_sender_token_can_resolve_recipient_without_clear_recipient_id(tmp_path, monkeypatch):
|
||||
_relay, identity_mod = _fresh_mesh_state(tmp_path, monkeypatch)
|
||||
identity_mod.bootstrap_wormhole_identity(force=True)
|
||||
|
||||
from services.mesh.mesh_wormhole_sender_token import (
|
||||
consume_wormhole_dm_sender_token,
|
||||
issue_wormhole_dm_sender_token,
|
||||
)
|
||||
|
||||
issued = issue_wormhole_dm_sender_token(
|
||||
recipient_id="peer123",
|
||||
delivery_class="shared",
|
||||
recipient_token="tok123",
|
||||
)
|
||||
assert issued["ok"]
|
||||
|
||||
consumed = consume_wormhole_dm_sender_token(
|
||||
sender_token=issued["sender_token"],
|
||||
recipient_id="",
|
||||
delivery_class="shared",
|
||||
recipient_token="tok123",
|
||||
)
|
||||
assert consumed["ok"]
|
||||
assert consumed["recipient_id"] == "peer123"
|
||||
assert consumed["sender_token_hash"]
|
||||
|
||||
|
||||
def test_signed_prekey_rotation_preserves_old_bootstrap_decrypt(tmp_path, monkeypatch):
|
||||
_relay, identity_mod = _fresh_mesh_state(tmp_path, monkeypatch)
|
||||
identity_mod.bootstrap_wormhole_identity(force=True)
|
||||
|
||||
from services.mesh.mesh_wormhole_prekey import (
|
||||
SIGNED_PREKEY_ROTATE_AFTER_S,
|
||||
bootstrap_decrypt_from_sender,
|
||||
bootstrap_encrypt_for_peer,
|
||||
register_wormhole_prekey_bundle,
|
||||
)
|
||||
|
||||
reg1 = register_wormhole_prekey_bundle(force_signed_prekey=True)
|
||||
assert reg1["ok"]
|
||||
agent_id = reg1["agent_id"]
|
||||
|
||||
old_envelope = bootstrap_encrypt_for_peer(agent_id, "ACCESS_REQUEST:X25519:testpub|geo=1,2")
|
||||
assert old_envelope["ok"]
|
||||
|
||||
data = identity_mod.read_wormhole_identity()
|
||||
data["signed_prekey_generated_at"] = int(time.time()) - SIGNED_PREKEY_ROTATE_AFTER_S - 10
|
||||
identity_mod._write_identity(data)
|
||||
|
||||
reg2 = register_wormhole_prekey_bundle()
|
||||
assert reg2["ok"]
|
||||
assert reg2["bundle"]["signed_prekey_id"] != reg1["bundle"]["signed_prekey_id"]
|
||||
|
||||
refreshed = identity_mod.read_wormhole_identity()
|
||||
history = list(refreshed.get("signed_prekey_history") or [])
|
||||
assert any(int(item.get("signed_prekey_id", 0) or 0) == reg1["bundle"]["signed_prekey_id"] for item in history)
|
||||
|
||||
dec = bootstrap_decrypt_from_sender(agent_id, old_envelope["result"])
|
||||
assert dec["ok"]
|
||||
assert dec["result"] == "ACCESS_REQUEST:X25519:testpub|geo=1,2"
|
||||
|
||||
|
||||
def test_prekey_bundle_fetch_rejects_stale_or_tampered_bundle(tmp_path, monkeypatch):
|
||||
relay, identity_mod = _fresh_mesh_state(tmp_path, monkeypatch)
|
||||
identity_mod.bootstrap_wormhole_identity(force=True)
|
||||
|
||||
from services.mesh import mesh_wormhole_prekey as prekey_mod
|
||||
|
||||
registered = prekey_mod.register_wormhole_prekey_bundle(force_signed_prekey=True)
|
||||
assert registered["ok"] is True
|
||||
agent_id = registered["agent_id"]
|
||||
|
||||
fresh = prekey_mod.fetch_dm_prekey_bundle(agent_id)
|
||||
assert fresh["ok"] is True
|
||||
assert int(fresh["signed_at"]) > 0
|
||||
assert fresh["bundle_signature"]
|
||||
|
||||
stored = relay.get_prekey_bundle(agent_id)
|
||||
stale_bundle = dict(stored.get("bundle") or {})
|
||||
stale_bundle["signed_at"] = int(time.time()) - prekey_mod._max_prekey_bundle_age_s() - 10
|
||||
stale_bundle = prekey_mod._attach_bundle_signature(stale_bundle, signed_at=stale_bundle["signed_at"])
|
||||
relay._prekey_bundles[agent_id]["bundle"] = stale_bundle
|
||||
|
||||
stale = prekey_mod.fetch_dm_prekey_bundle(agent_id)
|
||||
assert stale == {"ok": False, "detail": "Prekey bundle is stale"}
|
||||
|
||||
tampered_bundle = dict(stale_bundle)
|
||||
tampered_bundle["signed_at"] = int(time.time())
|
||||
tampered_bundle = prekey_mod._attach_bundle_signature(tampered_bundle, signed_at=tampered_bundle["signed_at"])
|
||||
tampered_bundle["bundle_signature"] = "00" * 64
|
||||
relay._prekey_bundles[agent_id]["bundle"] = tampered_bundle
|
||||
|
||||
tampered = prekey_mod.fetch_dm_prekey_bundle(agent_id)
|
||||
assert tampered == {"ok": False, "detail": "Prekey bundle signature invalid"}
|
||||
|
||||
|
||||
def test_prekey_bundle_fetch_rejects_future_dated_bundle(tmp_path, monkeypatch):
|
||||
relay, identity_mod = _fresh_mesh_state(tmp_path, monkeypatch)
|
||||
identity_mod.bootstrap_wormhole_identity(force=True)
|
||||
|
||||
from services.mesh import mesh_wormhole_prekey as prekey_mod
|
||||
|
||||
registered = prekey_mod.register_wormhole_prekey_bundle(force_signed_prekey=True)
|
||||
assert registered["ok"] is True
|
||||
agent_id = registered["agent_id"]
|
||||
|
||||
stored = relay.get_prekey_bundle(agent_id)
|
||||
future_bundle = dict(stored.get("bundle") or {})
|
||||
future_bundle["signed_at"] = int(time.time()) + 301
|
||||
future_bundle = prekey_mod._attach_bundle_signature(future_bundle, signed_at=future_bundle["signed_at"])
|
||||
relay._prekey_bundles[agent_id]["bundle"] = future_bundle
|
||||
|
||||
future = prekey_mod.fetch_dm_prekey_bundle(agent_id)
|
||||
assert future == {"ok": False, "detail": "Prekey bundle signed_at is in the future"}
|
||||
|
||||
|
||||
def test_remote_prekey_identity_is_pinned_and_detects_mismatch(tmp_path, monkeypatch):
|
||||
_relay, _identity_mod = _fresh_mesh_state(tmp_path, monkeypatch)
|
||||
from services.mesh import mesh_wormhole_contacts
|
||||
|
||||
monkeypatch.setattr(mesh_wormhole_contacts, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_wormhole_contacts, "CONTACTS_FILE", tmp_path / "wormhole_dm_contacts.json")
|
||||
|
||||
pinned = mesh_wormhole_contacts.observe_remote_prekey_identity(
|
||||
"peer-alpha",
|
||||
fingerprint="aa" * 32,
|
||||
sequence=3,
|
||||
signed_at=111,
|
||||
)
|
||||
same = mesh_wormhole_contacts.observe_remote_prekey_identity(
|
||||
"peer-alpha",
|
||||
fingerprint="aa" * 32,
|
||||
sequence=4,
|
||||
signed_at=222,
|
||||
)
|
||||
changed = mesh_wormhole_contacts.observe_remote_prekey_identity(
|
||||
"peer-alpha",
|
||||
fingerprint="bb" * 32,
|
||||
sequence=5,
|
||||
signed_at=333,
|
||||
)
|
||||
|
||||
assert pinned["trust_changed"] is False
|
||||
assert same["trust_changed"] is False
|
||||
assert changed["trust_changed"] is True
|
||||
stored = mesh_wormhole_contacts.list_wormhole_dm_contacts()["peer-alpha"]
|
||||
assert stored["remotePrekeyFingerprint"] == "aa" * 32
|
||||
assert stored["remotePrekeyObservedFingerprint"] == "bb" * 32
|
||||
assert stored["remotePrekeyMismatch"] is True
|
||||
|
||||
|
||||
def test_compose_wormhole_dm_rejects_remote_prekey_identity_change(tmp_path, monkeypatch):
|
||||
_relay, _identity_mod = _fresh_mesh_state(tmp_path, monkeypatch)
|
||||
import main
|
||||
from services.mesh import mesh_wormhole_contacts
|
||||
|
||||
monkeypatch.setattr(mesh_wormhole_contacts, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_wormhole_contacts, "CONTACTS_FILE", tmp_path / "wormhole_dm_contacts.json")
|
||||
monkeypatch.setattr(main, "has_mls_dm_session", lambda *_args, **_kwargs: {"ok": True, "exists": False})
|
||||
monkeypatch.setattr(main, "initiate_mls_dm_session", lambda *_args, **_kwargs: {"ok": True, "welcome": "welcome"})
|
||||
monkeypatch.setattr(main, "encrypt_mls_dm", lambda *_args, **_kwargs: {"ok": True, "ciphertext": "ct", "nonce": "n"})
|
||||
|
||||
initial = {
|
||||
"ok": True,
|
||||
"agent_id": "peer-alpha",
|
||||
"mls_key_package": "ZmFrZQ==",
|
||||
"identity_dh_pub_key": "peer-dh-pub",
|
||||
"public_key": "peer-signing-pub",
|
||||
"public_key_algo": "Ed25519",
|
||||
"protocol_version": "infonet/2",
|
||||
"sequence": 2,
|
||||
"signed_at": int(time.time()),
|
||||
"trust_fingerprint": "11" * 32,
|
||||
}
|
||||
changed = {
|
||||
**initial,
|
||||
"sequence": 3,
|
||||
"signed_at": int(time.time()) + 1,
|
||||
"trust_fingerprint": "22" * 32,
|
||||
}
|
||||
|
||||
first = main.compose_wormhole_dm(
|
||||
peer_id="peer-alpha",
|
||||
peer_dh_pub="peer-dh-pub",
|
||||
plaintext="hello",
|
||||
remote_prekey_bundle=initial,
|
||||
)
|
||||
second = main.compose_wormhole_dm(
|
||||
peer_id="peer-alpha",
|
||||
peer_dh_pub="peer-dh-pub",
|
||||
plaintext="hello again",
|
||||
remote_prekey_bundle=changed,
|
||||
)
|
||||
|
||||
assert first["ok"] is True
|
||||
assert second == {
|
||||
"ok": False,
|
||||
"peer_id": "peer-alpha",
|
||||
"detail": "remote prekey identity changed; verification required",
|
||||
"trust_changed": True,
|
||||
}
|
||||
|
||||
|
||||
def test_prekey_bundle_registration_rejects_invalid_bundle(tmp_path, monkeypatch):
|
||||
relay, identity_mod = _fresh_mesh_state(tmp_path, monkeypatch)
|
||||
identity = identity_mod.bootstrap_wormhole_identity(force=True)
|
||||
|
||||
from services.mesh import mesh_wormhole_prekey as prekey_mod
|
||||
|
||||
bundle = prekey_mod.ensure_wormhole_prekeys(force_signed_prekey=True)
|
||||
bundle = prekey_mod._attach_bundle_signature(bundle, signed_at=int(time.time()) + 301)
|
||||
|
||||
ok, reason, meta = relay.register_prekey_bundle(
|
||||
identity["node_id"],
|
||||
bundle,
|
||||
"sig",
|
||||
identity["public_key"],
|
||||
identity["public_key_algo"],
|
||||
"infonet/2",
|
||||
1,
|
||||
)
|
||||
|
||||
assert ok is False
|
||||
assert reason == "Prekey bundle signed_at is in the future"
|
||||
assert meta is None
|
||||
assert relay.get_prekey_bundle(identity["node_id"]) is None
|
||||
|
||||
|
||||
def test_dm_mailbox_token_derivation_and_shared_sender_token_routing(tmp_path, monkeypatch):
|
||||
relay, identity_mod = _fresh_mesh_state(tmp_path, monkeypatch)
|
||||
identity = identity_mod.bootstrap_wormhole_identity(force=True)
|
||||
|
||||
from services.mesh.mesh_wormhole_identity import derive_dm_mailbox_token
|
||||
from services.mesh.mesh_wormhole_sender_token import issue_wormhole_dm_sender_token
|
||||
import main
|
||||
from services import wormhole_supervisor
|
||||
from services.mesh import mesh_dm_relay, mesh_hashchain
|
||||
|
||||
mailbox_token = derive_dm_mailbox_token(identity["node_id"])
|
||||
assert mailbox_token
|
||||
|
||||
issued = issue_wormhole_dm_sender_token(
|
||||
recipient_id="peer123",
|
||||
delivery_class="shared",
|
||||
recipient_token=mailbox_token,
|
||||
)
|
||||
assert issued["ok"] is True
|
||||
|
||||
monkeypatch.setattr(main, "_verify_signed_event", lambda **_kwargs: (True, ""))
|
||||
monkeypatch.setattr(main, "_secure_dm_enabled", lambda: False)
|
||||
monkeypatch.setattr(wormhole_supervisor, "get_transport_tier", lambda: "private_strong")
|
||||
monkeypatch.setattr(mesh_hashchain.infonet, "validate_and_set_sequence", lambda *_args, **_kwargs: (True, ""))
|
||||
monkeypatch.setattr(mesh_dm_relay, "dm_relay", relay)
|
||||
|
||||
response = asyncio.run(
|
||||
main.dm_send(
|
||||
_json_request(
|
||||
"/api/mesh/dm/send",
|
||||
{
|
||||
"sender_token": issued["sender_token"],
|
||||
"recipient_id": "",
|
||||
"delivery_class": "shared",
|
||||
"recipient_token": mailbox_token,
|
||||
"ciphertext": "cipher-shared",
|
||||
"sender_seal": "v3:test-seal",
|
||||
"msg_id": "shared-msg-1",
|
||||
"timestamp": int(time.time()),
|
||||
"public_key": "",
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 1,
|
||||
"protocol_version": "infonet/2",
|
||||
},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert response["ok"] is True
|
||||
hashed_mailbox = relay._hashed_mailbox_token(mailbox_token)
|
||||
assert list(relay._mailboxes.keys()) == [hashed_mailbox]
|
||||
assert relay._mailboxes[hashed_mailbox][0].sender_id.startswith("sender_token:")
|
||||
assert relay._mailboxes[hashed_mailbox][0].sender_id != identity["node_id"]
|
||||
delivered = relay.collect_claims(identity["node_id"], [{"type": "shared", "token": mailbox_token}])
|
||||
assert [msg["msg_id"] for msg in delivered] == ["shared-msg-1"]
|
||||
|
||||
|
||||
def test_open_sender_seal_verifies_in_wormhole(tmp_path, monkeypatch):
|
||||
_relay, identity_mod = _fresh_mesh_state(tmp_path, monkeypatch)
|
||||
identity = identity_mod.bootstrap_wormhole_identity(force=True)
|
||||
|
||||
from services.mesh.mesh_wormhole_seal import build_sender_seal, open_sender_seal
|
||||
|
||||
msg_id = "dm_test_1"
|
||||
timestamp = 1234567890
|
||||
built = build_sender_seal(
|
||||
recipient_id=identity["node_id"],
|
||||
recipient_dh_pub=identity["dh_pub_key"],
|
||||
msg_id=msg_id,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
assert built["ok"]
|
||||
assert str(built["sender_seal"]).startswith("v3:")
|
||||
|
||||
opened = open_sender_seal(
|
||||
sender_seal=built["sender_seal"],
|
||||
candidate_dh_pub=identity["dh_pub_key"],
|
||||
recipient_id=identity["node_id"],
|
||||
expected_msg_id=msg_id,
|
||||
)
|
||||
assert opened["ok"]
|
||||
assert opened["sender_id"] == identity["node_id"]
|
||||
assert opened["seal_verified"] is True
|
||||
|
||||
|
||||
def test_open_sender_seal_still_accepts_legacy_format(tmp_path, monkeypatch):
|
||||
_relay, identity_mod = _fresh_mesh_state(tmp_path, monkeypatch)
|
||||
identity = identity_mod.bootstrap_wormhole_identity(force=True)
|
||||
|
||||
from services.mesh.mesh_wormhole_identity import sign_wormhole_message
|
||||
from services.mesh.mesh_wormhole_seal import open_sender_seal
|
||||
|
||||
sender_priv = x25519.X25519PrivateKey.generate()
|
||||
sender_pub = sender_priv.public_key()
|
||||
recipient_pub = x25519.X25519PublicKey.from_public_bytes(base64.b64decode(identity["dh_pub_key"]))
|
||||
shared = sender_priv.exchange(recipient_pub)
|
||||
|
||||
msg_id = "dm_test_legacy"
|
||||
timestamp = 1234567890
|
||||
signed = sign_wormhole_message(f"seal|{msg_id}|{timestamp}|{identity['node_id']}")
|
||||
seal_payload = {
|
||||
"sender_id": signed["node_id"],
|
||||
"public_key": signed["public_key"],
|
||||
"public_key_algo": signed["public_key_algo"],
|
||||
"msg_id": msg_id,
|
||||
"timestamp": timestamp,
|
||||
"signature": signed["signature"],
|
||||
}
|
||||
iv = b"\x00" * 12
|
||||
ciphertext = AESGCM(shared).encrypt(iv, json.dumps(seal_payload).encode("utf-8"), None)
|
||||
sender_seal = base64.b64encode(iv + ciphertext).decode("ascii")
|
||||
candidate_dh_pub = base64.b64encode(
|
||||
sender_pub.public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
).decode("ascii")
|
||||
|
||||
opened = open_sender_seal(
|
||||
sender_seal=sender_seal,
|
||||
candidate_dh_pub=candidate_dh_pub,
|
||||
recipient_id=identity["node_id"],
|
||||
expected_msg_id=msg_id,
|
||||
)
|
||||
assert opened["ok"]
|
||||
assert opened["sender_id"] == identity["node_id"]
|
||||
assert opened["seal_verified"] is True
|
||||
|
||||
|
||||
def test_legacy_sender_seal_rejected_in_hardened_mode(tmp_path, monkeypatch):
|
||||
_relay, identity_mod = _fresh_mesh_state(tmp_path, monkeypatch)
|
||||
identity = identity_mod.bootstrap_wormhole_identity(force=True)
|
||||
|
||||
from services.mesh.mesh_wormhole_identity import sign_wormhole_message
|
||||
from services.mesh import mesh_wormhole_seal
|
||||
|
||||
monkeypatch.setattr(
|
||||
mesh_wormhole_seal,
|
||||
"read_wormhole_settings",
|
||||
lambda: {"enabled": True, "anonymous_mode": True},
|
||||
)
|
||||
|
||||
sender_priv = x25519.X25519PrivateKey.generate()
|
||||
sender_pub = sender_priv.public_key()
|
||||
recipient_pub = x25519.X25519PublicKey.from_public_bytes(base64.b64decode(identity["dh_pub_key"]))
|
||||
shared = sender_priv.exchange(recipient_pub)
|
||||
|
||||
msg_id = "dm_test_legacy_hardened"
|
||||
timestamp = 1234567890
|
||||
signed = sign_wormhole_message(f"seal|{msg_id}|{timestamp}|{identity['node_id']}")
|
||||
seal_payload = {
|
||||
"sender_id": signed["node_id"],
|
||||
"public_key": signed["public_key"],
|
||||
"public_key_algo": signed["public_key_algo"],
|
||||
"msg_id": msg_id,
|
||||
"timestamp": timestamp,
|
||||
"signature": signed["signature"],
|
||||
}
|
||||
iv = b"\x00" * 12
|
||||
ciphertext = AESGCM(shared).encrypt(iv, json.dumps(seal_payload).encode("utf-8"), None)
|
||||
sender_seal = base64.b64encode(iv + ciphertext).decode("ascii")
|
||||
candidate_dh_pub = base64.b64encode(
|
||||
sender_pub.public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
).decode("ascii")
|
||||
|
||||
opened = mesh_wormhole_seal.open_sender_seal(
|
||||
sender_seal=sender_seal,
|
||||
candidate_dh_pub=candidate_dh_pub,
|
||||
recipient_id=identity["node_id"],
|
||||
expected_msg_id=msg_id,
|
||||
)
|
||||
assert opened["ok"] is False
|
||||
assert "Legacy sender seals" in opened["detail"]
|
||||
|
||||
|
||||
def test_require_admin_no_longer_trusts_loopback_without_override(monkeypatch):
|
||||
from fastapi import HTTPException
|
||||
from starlette.requests import Request
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main, "_current_admin_key", lambda: "")
|
||||
monkeypatch.setattr(main, "_allow_insecure_admin", lambda: False)
|
||||
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [],
|
||||
"client": ("127.0.0.1", 12345),
|
||||
"method": "GET",
|
||||
"path": "/api/wormhole/status",
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
main.require_admin(request)
|
||||
assert exc.value.status_code == 403
|
||||
@@ -0,0 +1,307 @@
|
||||
import asyncio
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
|
||||
def _fresh_persona_state(tmp_path, monkeypatch):
|
||||
from services.mesh import mesh_secure_storage, mesh_wormhole_identity, mesh_wormhole_persona
|
||||
|
||||
monkeypatch.setattr(mesh_secure_storage, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_secure_storage, "MASTER_KEY_FILE", tmp_path / "wormhole_secure_store.key")
|
||||
monkeypatch.setattr(mesh_wormhole_persona, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_wormhole_persona, "PERSONA_FILE", tmp_path / "wormhole_persona.json")
|
||||
monkeypatch.setattr(
|
||||
mesh_wormhole_persona,
|
||||
"LEGACY_DM_IDENTITY_FILE",
|
||||
tmp_path / "wormhole_identity.json",
|
||||
)
|
||||
return mesh_wormhole_persona, mesh_wormhole_identity
|
||||
|
||||
|
||||
def _request(path: str) -> Request:
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [],
|
||||
"client": ("test", 12345),
|
||||
"method": "POST",
|
||||
"path": path,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_transport_identity_is_separate_from_dm_identity(tmp_path, monkeypatch):
|
||||
persona_mod, identity_mod = _fresh_persona_state(tmp_path, monkeypatch)
|
||||
|
||||
dm_identity = identity_mod.bootstrap_wormhole_identity(force=True)
|
||||
persona_state = persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
transport_identity = persona_state["transport_identity"]
|
||||
|
||||
assert dm_identity["node_id"]
|
||||
assert transport_identity["node_id"]
|
||||
assert dm_identity["node_id"] != transport_identity["node_id"]
|
||||
assert dm_identity["public_key"] != transport_identity["public_key"]
|
||||
|
||||
|
||||
def test_gate_anonymous_session_differs_from_transport_identity(tmp_path, monkeypatch):
|
||||
persona_mod, _identity_mod = _fresh_persona_state(tmp_path, monkeypatch)
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
transport_identity = persona_mod.get_transport_identity()
|
||||
gate_identity = persona_mod.enter_gate_anonymously("journalists", rotate=True)
|
||||
|
||||
assert gate_identity["ok"] is True
|
||||
assert gate_identity["identity"]["scope"] == "gate_session"
|
||||
assert gate_identity["identity"]["gate_id"] == "journalists"
|
||||
assert gate_identity["identity"]["node_id"] != transport_identity["node_id"]
|
||||
|
||||
|
||||
def test_gate_identities_are_separate_from_root_identity(tmp_path, monkeypatch):
|
||||
persona_mod, _identity_mod = _fresh_persona_state(tmp_path, monkeypatch)
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
state = persona_mod.read_wormhole_persona_state()
|
||||
root_identity = state["root_identity"]
|
||||
gate_session = persona_mod.enter_gate_anonymously("journalists", rotate=True)["identity"]
|
||||
gate_persona = persona_mod.create_gate_persona("journalists", label="source-a")["identity"]
|
||||
|
||||
assert root_identity["node_id"]
|
||||
assert gate_session["node_id"] != root_identity["node_id"]
|
||||
assert gate_session["public_key"] != root_identity["public_key"]
|
||||
assert gate_persona["node_id"] != root_identity["node_id"]
|
||||
assert gate_persona["public_key"] != root_identity["public_key"]
|
||||
|
||||
|
||||
def test_gate_persona_activation_is_gate_local(tmp_path, monkeypatch):
|
||||
persona_mod, _identity_mod = _fresh_persona_state(tmp_path, monkeypatch)
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
first = persona_mod.create_gate_persona("sources", label="source-a")
|
||||
second = persona_mod.create_gate_persona("leaks", label="source-a")
|
||||
|
||||
assert first["ok"] is True
|
||||
assert second["ok"] is True
|
||||
assert first["identity"]["gate_id"] == "sources"
|
||||
assert second["identity"]["gate_id"] == "leaks"
|
||||
assert first["identity"]["node_id"] != second["identity"]["node_id"]
|
||||
|
||||
active_sources = persona_mod.get_active_gate_identity("sources")
|
||||
active_leaks = persona_mod.get_active_gate_identity("leaks")
|
||||
assert active_sources["identity"]["persona_id"] == first["identity"]["persona_id"]
|
||||
assert active_leaks["identity"]["persona_id"] == second["identity"]["persona_id"]
|
||||
|
||||
|
||||
def test_gate_persona_duplicate_labels_get_unique_suffixes(tmp_path, monkeypatch):
|
||||
persona_mod, _identity_mod = _fresh_persona_state(tmp_path, monkeypatch)
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
first = persona_mod.create_gate_persona("sources", label="source-a")
|
||||
second = persona_mod.create_gate_persona("sources", label="source-a")
|
||||
third = persona_mod.create_gate_persona("sources", label="Source-A")
|
||||
|
||||
assert first["ok"] is True
|
||||
assert second["ok"] is True
|
||||
assert third["ok"] is True
|
||||
assert first["identity"]["label"] == "source-a"
|
||||
assert second["identity"]["label"] == "source-a-2"
|
||||
assert third["identity"]["label"] == "Source-A-3"
|
||||
|
||||
|
||||
def test_sign_public_event_uses_transport_identity(tmp_path, monkeypatch):
|
||||
persona_mod, _identity_mod = _fresh_persona_state(tmp_path, monkeypatch)
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
transport_identity = persona_mod.get_transport_identity()
|
||||
signed = persona_mod.sign_public_wormhole_event(
|
||||
event_type="message",
|
||||
payload={
|
||||
"message": "hello",
|
||||
"destination": "broadcast",
|
||||
"channel": "LongFast",
|
||||
"priority": "normal",
|
||||
"ephemeral": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert signed["identity_scope"] == "transport"
|
||||
assert signed["node_id"] == transport_identity["node_id"]
|
||||
assert signed["public_key"] == transport_identity["public_key"]
|
||||
|
||||
|
||||
def test_sign_gate_event_uses_gate_session_identity(tmp_path, monkeypatch):
|
||||
persona_mod, _identity_mod = _fresh_persona_state(tmp_path, monkeypatch)
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
transport_identity = persona_mod.get_transport_identity()
|
||||
signed = persona_mod.sign_gate_wormhole_event(
|
||||
gate_id="journalists",
|
||||
event_type="gate_message",
|
||||
payload={
|
||||
"gate": "journalists",
|
||||
"epoch": 1,
|
||||
"ciphertext": "opaque-source-drop",
|
||||
"nonce": "nonce-j1",
|
||||
"sender_ref": "gate-session-j1",
|
||||
},
|
||||
)
|
||||
gate_identity = persona_mod.get_active_gate_identity("journalists")
|
||||
|
||||
assert signed["identity_scope"] == "gate_session"
|
||||
assert signed["gate_id"] == "journalists"
|
||||
assert signed["node_id"] == gate_identity["identity"]["node_id"]
|
||||
assert signed["node_id"] != transport_identity["node_id"]
|
||||
|
||||
|
||||
def test_leave_gate_forces_new_anonymous_session_on_reentry(tmp_path, monkeypatch):
|
||||
persona_mod, _identity_mod = _fresh_persona_state(tmp_path, monkeypatch)
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
first = persona_mod.enter_gate_anonymously("sources", rotate=True)
|
||||
persona_mod.leave_gate("sources")
|
||||
second = persona_mod.enter_gate_anonymously("sources", rotate=False)
|
||||
|
||||
assert first["identity"]["node_id"]
|
||||
assert second["identity"]["node_id"]
|
||||
assert first["identity"]["node_id"] != second["identity"]["node_id"]
|
||||
|
||||
|
||||
def test_gate_session_rotation_uses_jitter_window_before_auto_swap(tmp_path, monkeypatch):
|
||||
from services.config import get_settings
|
||||
|
||||
persona_mod, _identity_mod = _fresh_persona_state(tmp_path, monkeypatch)
|
||||
monkeypatch.setenv("MESH_GATE_SESSION_ROTATE_MSGS", "1")
|
||||
monkeypatch.setenv("MESH_GATE_SESSION_ROTATE_JITTER_S", "120")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
now = {"value": 1_000.0}
|
||||
monkeypatch.setattr(persona_mod.time, "time", lambda: now["value"])
|
||||
monkeypatch.setattr(persona_mod.random, "uniform", lambda *_args, **_kwargs: 45.0)
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
first = persona_mod.enter_gate_anonymously("sources", rotate=True)
|
||||
persona_mod.sign_gate_wormhole_event(
|
||||
gate_id="sources",
|
||||
event_type="gate_message",
|
||||
payload={
|
||||
"gate": "sources",
|
||||
"epoch": 1,
|
||||
"ciphertext": "opaque",
|
||||
"nonce": "nonce-1",
|
||||
"sender_ref": "sender-ref-1",
|
||||
"format": "mls1",
|
||||
},
|
||||
)
|
||||
|
||||
same = persona_mod.enter_gate_anonymously("sources", rotate=False)
|
||||
scheduled = persona_mod.read_wormhole_persona_state()["gate_sessions"]["sources"]["_rotate_after"]
|
||||
|
||||
assert same["identity"]["node_id"] == first["identity"]["node_id"]
|
||||
assert scheduled == 1_045.0
|
||||
|
||||
now["value"] = 1_046.0
|
||||
rotated = persona_mod.enter_gate_anonymously("sources", rotate=False)
|
||||
|
||||
assert rotated["identity"]["node_id"] != first["identity"]["node_id"]
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_gate_enter_leave_do_not_emit_public_breadcrumbs(tmp_path, monkeypatch):
|
||||
import main
|
||||
from services.mesh import mesh_hashchain
|
||||
|
||||
persona_mod, _identity_mod = _fresh_persona_state(tmp_path, monkeypatch)
|
||||
append_called = {"count": 0}
|
||||
|
||||
def fake_append(**kwargs):
|
||||
append_called["count"] += 1
|
||||
return {"event_id": "unexpected"}
|
||||
|
||||
monkeypatch.setattr(mesh_hashchain.infonet, "append", fake_append)
|
||||
|
||||
body = main.WormholeGateRequest(gate_id="sources", rotate=True)
|
||||
entered = asyncio.run(main.api_wormhole_gate_enter(_request("/api/wormhole/gate/enter"), body))
|
||||
left = asyncio.run(
|
||||
main.api_wormhole_gate_leave(
|
||||
_request("/api/wormhole/gate/leave"),
|
||||
main.WormholeGateRequest(gate_id="sources"),
|
||||
)
|
||||
)
|
||||
|
||||
assert entered["ok"] is True
|
||||
assert left["ok"] is True
|
||||
assert append_called["count"] == 0
|
||||
|
||||
|
||||
def test_clear_active_persona_reverts_gate_to_anonymous_session(tmp_path, monkeypatch):
|
||||
persona_mod, _identity_mod = _fresh_persona_state(tmp_path, monkeypatch)
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
persona_mod.enter_gate_anonymously("evidence", rotate=True)
|
||||
created = persona_mod.create_gate_persona("evidence", label="reporter")
|
||||
cleared = persona_mod.clear_active_gate_persona("evidence")
|
||||
active = persona_mod.get_active_gate_identity("evidence")
|
||||
|
||||
assert created["identity"]["scope"] == "gate_persona"
|
||||
assert cleared["ok"] is True
|
||||
assert cleared["identity"]["scope"] == "gate_session"
|
||||
assert active["source"] == "anonymous"
|
||||
|
||||
|
||||
def test_sign_gate_event_uses_active_persona_when_selected(tmp_path, monkeypatch):
|
||||
persona_mod, _identity_mod = _fresh_persona_state(tmp_path, monkeypatch)
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
persona_mod.enter_gate_anonymously("ops", rotate=True)
|
||||
created = persona_mod.create_gate_persona("ops", label="scribe")
|
||||
signed = persona_mod.sign_gate_wormhole_event(
|
||||
gate_id="ops",
|
||||
event_type="gate_message",
|
||||
payload={
|
||||
"gate": "ops",
|
||||
"epoch": 1,
|
||||
"ciphertext": "opaque-persona-post",
|
||||
"nonce": "nonce-o1",
|
||||
"sender_ref": "persona-ops-1",
|
||||
},
|
||||
)
|
||||
|
||||
assert created["identity"]["persona_id"]
|
||||
assert signed["identity_scope"] == "gate_persona"
|
||||
assert signed["node_id"] == created["identity"]["node_id"]
|
||||
|
||||
|
||||
def test_enter_gate_anonymously_clears_existing_active_persona(tmp_path, monkeypatch):
|
||||
persona_mod, _identity_mod = _fresh_persona_state(tmp_path, monkeypatch)
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
created = persona_mod.create_gate_persona("ops", label="scribe")
|
||||
entered = persona_mod.enter_gate_anonymously("ops", rotate=True)
|
||||
active = persona_mod.get_active_gate_identity("ops")
|
||||
|
||||
assert created["identity"]["scope"] == "gate_persona"
|
||||
assert entered["identity"]["scope"] == "gate_session"
|
||||
assert active["source"] == "anonymous"
|
||||
assert active["identity"]["node_id"] == entered["identity"]["node_id"]
|
||||
assert active["identity"]["node_id"] != created["identity"]["node_id"]
|
||||
|
||||
|
||||
def test_sign_gate_event_rejects_cross_gate_payload_mismatch(tmp_path, monkeypatch):
|
||||
persona_mod, _identity_mod = _fresh_persona_state(tmp_path, monkeypatch)
|
||||
|
||||
persona_mod.bootstrap_wormhole_persona_state(force=True)
|
||||
persona_mod.enter_gate_anonymously("ops", rotate=True)
|
||||
signed = persona_mod.sign_gate_wormhole_event(
|
||||
gate_id="ops",
|
||||
event_type="gate_message",
|
||||
payload={
|
||||
"gate": "finance",
|
||||
"epoch": 1,
|
||||
"ciphertext": "opaque-cross-gate-post",
|
||||
"nonce": "nonce-cross-1",
|
||||
"sender_ref": "persona-finance-1",
|
||||
},
|
||||
)
|
||||
|
||||
assert signed["ok"] is False
|
||||
assert signed["detail"] == "gate payload mismatch"
|
||||
@@ -0,0 +1,15 @@
|
||||
def test_node_settings_roundtrip(tmp_path, monkeypatch):
|
||||
from services import node_settings
|
||||
|
||||
settings_path = tmp_path / "node.json"
|
||||
monkeypatch.setattr(node_settings, "NODE_FILE", settings_path)
|
||||
monkeypatch.setattr(node_settings, "_cache", None)
|
||||
monkeypatch.setattr(node_settings, "_cache_ts", 0.0)
|
||||
|
||||
initial = node_settings.read_node_settings()
|
||||
updated = node_settings.write_node_settings(enabled=True)
|
||||
reread = node_settings.read_node_settings()
|
||||
|
||||
assert initial["enabled"] is False
|
||||
assert updated["enabled"] is True
|
||||
assert reread["enabled"] is True
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from services.privacy_core_client import (
|
||||
PrivacyCoreError,
|
||||
PrivacyCoreClient,
|
||||
PrivacyCoreUnavailable,
|
||||
candidate_library_paths,
|
||||
)
|
||||
|
||||
|
||||
def _built_library_path() -> Path:
|
||||
for candidate in candidate_library_paths():
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
raise PrivacyCoreUnavailable("privacy-core shared library not found")
|
||||
|
||||
|
||||
def _isolated_client(tmp_path: Path, name: str) -> PrivacyCoreClient:
|
||||
source = _built_library_path()
|
||||
target = tmp_path / f"{name}{source.suffix}"
|
||||
shutil.copy2(source, target)
|
||||
return PrivacyCoreClient.load(target)
|
||||
|
||||
|
||||
# NOTE: This test runs both clients in the same process. It validates key-package
|
||||
# serialization/deserialization correctness but does not prove cross-process isolation.
|
||||
# True cross-process testing deferred — see BUILD_TRACKER S3-F4 note.
|
||||
def test_cross_client_key_package_serialization_round_trip(tmp_path):
|
||||
try:
|
||||
client_a = _isolated_client(tmp_path, "privacy_core_node_a")
|
||||
client_b = _isolated_client(tmp_path, "privacy_core_node_b")
|
||||
except PrivacyCoreUnavailable:
|
||||
pytest.skip("privacy-core shared library not found")
|
||||
|
||||
assert client_a.reset_all_state() is True
|
||||
assert client_b.reset_all_state() is True
|
||||
|
||||
alice = client_a.create_identity()
|
||||
group = client_a.create_group(alice)
|
||||
throwaway = client_b.create_identity()
|
||||
bob = client_b.create_identity()
|
||||
|
||||
exported = client_b.export_key_package(bob)
|
||||
transported = base64.b64decode(base64.b64encode(exported))
|
||||
imported = client_a.import_key_package(transported)
|
||||
commit = client_a.add_member(group, imported)
|
||||
|
||||
assert client_a.commit_message_bytes(commit)
|
||||
assert client_a.commit_welcome_message_bytes(commit)
|
||||
|
||||
assert client_a.release_commit(commit) is True
|
||||
assert client_a.release_key_package(imported) is True
|
||||
assert client_a.release_group(group) is True
|
||||
assert client_a.release_identity(alice) is True
|
||||
assert client_b.release_identity(throwaway) is True
|
||||
assert client_b.release_identity(bob) is True
|
||||
|
||||
|
||||
def test_import_key_package_rejects_oversized_payload(tmp_path):
|
||||
try:
|
||||
client = _isolated_client(tmp_path, "privacy_core_oversized")
|
||||
except PrivacyCoreUnavailable:
|
||||
pytest.skip("privacy-core shared library not found")
|
||||
|
||||
assert client.reset_all_state() is True
|
||||
|
||||
with pytest.raises(PrivacyCoreError, match="maximum size"):
|
||||
client.import_key_package(b"x" * 65_537)
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Sprint 1 security-review invariant tests for privacy-core FFI.
|
||||
|
||||
These tests exercise the FFI boundary, handle lifecycle, buffer ownership,
|
||||
and MLS correctness of the Rust privacy-core as accessed through the Python
|
||||
ctypes bridge.
|
||||
|
||||
Test IDs map to the S1 Security Review findings:
|
||||
|
||||
S1-T1 Use-after-release: freed handle must produce error, no ciphertext
|
||||
S1-T2 Double-release: second release returns False, no crash
|
||||
S1-T3 Public-bundle key-material: exported JSON contains no private key
|
||||
S1-T4 MLS round-trip: encrypt → decrypt produces original plaintext
|
||||
S1-T5 Removed member cannot decrypt post-removal messages
|
||||
|
||||
Requires a compiled privacy-core shared library. If unavailable, tests are
|
||||
skipped rather than failed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the backend package is importable.
|
||||
_backend = Path(__file__).resolve().parents[2]
|
||||
if str(_backend) not in sys.path:
|
||||
sys.path.insert(0, str(_backend))
|
||||
|
||||
from services.privacy_core_client import (
|
||||
PrivacyCoreClient,
|
||||
PrivacyCoreError,
|
||||
PrivacyCoreUnavailable,
|
||||
)
|
||||
|
||||
_client: PrivacyCoreClient | None = None
|
||||
|
||||
|
||||
def _get_client() -> PrivacyCoreClient:
|
||||
"""Lazy-load the privacy-core library; skip if unavailable."""
|
||||
global _client # noqa: PLW0603
|
||||
if _client is not None:
|
||||
return _client
|
||||
try:
|
||||
_client = PrivacyCoreClient.load()
|
||||
except PrivacyCoreUnavailable:
|
||||
raise unittest.SkipTest(
|
||||
"privacy-core shared library not found — skipping FFI invariant tests"
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
class TestUseAfterRelease(unittest.TestCase):
|
||||
"""S1-T1: Operations on a released handle must fail cleanly."""
|
||||
|
||||
def test_encrypt_after_release_group(self) -> None:
|
||||
"""Releasing a group handle then encrypting with it must raise, not encrypt."""
|
||||
client = _get_client()
|
||||
|
||||
identity = client.create_identity()
|
||||
group = client.create_group(identity)
|
||||
|
||||
# Sanity: encryption works before release.
|
||||
ciphertext = client.encrypt_group_message(group, b"pre-release")
|
||||
self.assertIsInstance(ciphertext, bytes)
|
||||
self.assertGreater(len(ciphertext), 0)
|
||||
|
||||
# Release the group handle.
|
||||
released = client.release_group(group)
|
||||
self.assertTrue(released)
|
||||
|
||||
# Post-release: must raise, must NOT return ciphertext.
|
||||
with self.assertRaises(PrivacyCoreError) as ctx:
|
||||
client.encrypt_group_message(group, b"post-release-secret")
|
||||
self.assertIn("unknown group handle", str(ctx.exception).lower())
|
||||
|
||||
# Cleanup.
|
||||
client.release_identity(identity)
|
||||
|
||||
def test_decrypt_after_release_group(self) -> None:
|
||||
"""Decrypting with a freed group handle must fail."""
|
||||
client = _get_client()
|
||||
|
||||
identity = client.create_identity()
|
||||
group = client.create_group(identity)
|
||||
ciphertext = client.encrypt_group_message(group, b"test")
|
||||
client.release_group(group)
|
||||
|
||||
with self.assertRaises(PrivacyCoreError):
|
||||
client.decrypt_group_message(group, ciphertext)
|
||||
|
||||
client.release_identity(identity)
|
||||
|
||||
|
||||
class TestDoubleRelease(unittest.TestCase):
|
||||
"""S1-T2: Double-releasing a handle must not crash and must return False."""
|
||||
|
||||
def test_double_release_identity(self) -> None:
|
||||
client = _get_client()
|
||||
identity = client.create_identity()
|
||||
first = client.release_identity(identity)
|
||||
second = client.release_identity(identity)
|
||||
self.assertTrue(first)
|
||||
self.assertFalse(second)
|
||||
|
||||
def test_double_release_group(self) -> None:
|
||||
client = _get_client()
|
||||
identity = client.create_identity()
|
||||
group = client.create_group(identity)
|
||||
first = client.release_group(group)
|
||||
second = client.release_group(group)
|
||||
self.assertTrue(first)
|
||||
self.assertFalse(second)
|
||||
client.release_identity(identity)
|
||||
|
||||
def test_double_release_commit(self) -> None:
|
||||
client = _get_client()
|
||||
|
||||
alice = client.create_identity()
|
||||
bob = client.create_identity()
|
||||
group = client.create_group(alice)
|
||||
kp_bytes = client.export_key_package(bob)
|
||||
kp_handle = client.import_key_package(kp_bytes)
|
||||
commit = client.add_member(group, kp_handle)
|
||||
|
||||
first = client.release_commit(commit)
|
||||
second = client.release_commit(commit)
|
||||
self.assertTrue(first)
|
||||
self.assertFalse(second)
|
||||
|
||||
# Cleanup.
|
||||
client.release_group(group)
|
||||
client.release_key_package(kp_handle)
|
||||
client.release_identity(alice)
|
||||
client.release_identity(bob)
|
||||
|
||||
|
||||
class TestPublicBundleNoPrivateKey(unittest.TestCase):
|
||||
"""S1-T3: Exported public bundle must not contain private key material."""
|
||||
|
||||
def test_bundle_contains_only_public_fields(self) -> None:
|
||||
client = _get_client()
|
||||
identity = client.create_identity()
|
||||
|
||||
bundle_bytes = client.export_public_bundle(identity)
|
||||
bundle = json.loads(bundle_bytes)
|
||||
|
||||
# Expected fields only.
|
||||
allowed_keys = {"label", "cipher_suite", "signing_public_key", "credential"}
|
||||
self.assertEqual(set(bundle.keys()), allowed_keys)
|
||||
|
||||
# The signing_public_key field must be present and non-empty (it's the PUBLIC key).
|
||||
self.assertIsInstance(bundle["signing_public_key"], list)
|
||||
self.assertGreater(len(bundle["signing_public_key"]), 0)
|
||||
|
||||
# Verify no field name suggests private material.
|
||||
for key in bundle:
|
||||
self.assertNotIn("private", key.lower(), f"Field '{key}' suggests private material")
|
||||
self.assertNotIn("secret", key.lower(), f"Field '{key}' suggests secret material")
|
||||
|
||||
client.release_identity(identity)
|
||||
|
||||
def test_bundle_for_unknown_identity_fails(self) -> None:
|
||||
client = _get_client()
|
||||
with self.assertRaises(PrivacyCoreError):
|
||||
client.export_public_bundle(0xDEAD)
|
||||
|
||||
|
||||
class TestMLSRoundTrip(unittest.TestCase):
|
||||
"""S1-T4: Encrypt → decrypt must produce original plaintext."""
|
||||
|
||||
def test_two_member_encrypt_decrypt(self) -> None:
|
||||
client = _get_client()
|
||||
|
||||
alice_id = client.create_identity()
|
||||
bob_id = client.create_identity()
|
||||
|
||||
# Alice creates a group.
|
||||
alice_group = client.create_group(alice_id)
|
||||
|
||||
# Bob exports a key package; Alice imports it and adds Bob.
|
||||
kp_bytes = client.export_key_package(bob_id)
|
||||
kp_handle = client.import_key_package(kp_bytes)
|
||||
commit = client.add_member(alice_group, kp_handle)
|
||||
|
||||
# Get Bob's joined group handle.
|
||||
bob_group = client.commit_joined_group_handle(commit)
|
||||
|
||||
# Alice encrypts; Bob decrypts.
|
||||
plaintext = b"hello from alice"
|
||||
ciphertext = client.encrypt_group_message(alice_group, plaintext)
|
||||
self.assertNotEqual(ciphertext, plaintext)
|
||||
|
||||
decrypted = client.decrypt_group_message(bob_group, ciphertext)
|
||||
self.assertEqual(decrypted, plaintext)
|
||||
|
||||
# Bob encrypts; Alice decrypts.
|
||||
plaintext2 = b"hello from bob"
|
||||
ciphertext2 = client.encrypt_group_message(bob_group, plaintext2)
|
||||
decrypted2 = client.decrypt_group_message(alice_group, ciphertext2)
|
||||
self.assertEqual(decrypted2, plaintext2)
|
||||
|
||||
# Cleanup.
|
||||
client.release_commit(commit)
|
||||
client.release_key_package(kp_handle)
|
||||
client.release_group(alice_group)
|
||||
client.release_group(bob_group)
|
||||
client.release_identity(alice_id)
|
||||
client.release_identity(bob_id)
|
||||
|
||||
def test_old_epoch_ciphertext_fails_after_membership_change(self) -> None:
|
||||
client = _get_client()
|
||||
|
||||
alice_id = client.create_identity()
|
||||
bob_id = client.create_identity()
|
||||
charlie_id = client.create_identity()
|
||||
|
||||
alice_group = client.create_group(alice_id)
|
||||
bob_kp = client.export_key_package(bob_id)
|
||||
bob_kp_handle = client.import_key_package(bob_kp)
|
||||
commit1 = client.add_member(alice_group, bob_kp_handle)
|
||||
bob_group = client.commit_joined_group_handle(commit1)
|
||||
|
||||
old_epoch_ct = client.encrypt_group_message(alice_group, b"epoch one")
|
||||
self.assertEqual(client.decrypt_group_message(bob_group, old_epoch_ct), b"epoch one")
|
||||
|
||||
charlie_kp = client.export_key_package(charlie_id)
|
||||
charlie_kp_handle = client.import_key_package(charlie_kp)
|
||||
commit2 = client.add_member(alice_group, charlie_kp_handle)
|
||||
charlie_group = client.commit_joined_group_handle(commit2)
|
||||
|
||||
with self.assertRaises(PrivacyCoreError):
|
||||
client.decrypt_group_message(alice_group, old_epoch_ct)
|
||||
with self.assertRaises(PrivacyCoreError):
|
||||
client.decrypt_group_message(bob_group, old_epoch_ct)
|
||||
|
||||
new_epoch_ct = client.encrypt_group_message(alice_group, b"epoch two")
|
||||
self.assertEqual(client.decrypt_group_message(charlie_group, new_epoch_ct), b"epoch two")
|
||||
|
||||
for handle in (commit1, commit2):
|
||||
client.release_commit(handle)
|
||||
for handle in (bob_kp_handle, charlie_kp_handle):
|
||||
client.release_key_package(handle)
|
||||
for handle in (alice_group, bob_group, charlie_group):
|
||||
client.release_group(handle)
|
||||
for handle in (alice_id, bob_id, charlie_id):
|
||||
client.release_identity(handle)
|
||||
|
||||
|
||||
class TestRemovedMemberCannotDecrypt(unittest.TestCase):
|
||||
"""S1-T5: A removed member must fail to decrypt post-removal messages."""
|
||||
|
||||
def test_removed_member_decryption_fails(self) -> None:
|
||||
client = _get_client()
|
||||
|
||||
alice_id = client.create_identity()
|
||||
bob_id = client.create_identity()
|
||||
charlie_id = client.create_identity()
|
||||
|
||||
# Alice creates group, adds Bob.
|
||||
alice_group = client.create_group(alice_id)
|
||||
bob_kp = client.export_key_package(bob_id)
|
||||
bob_kp_h = client.import_key_package(bob_kp)
|
||||
commit1 = client.add_member(alice_group, bob_kp_h)
|
||||
bob_group = client.commit_joined_group_handle(commit1)
|
||||
|
||||
# Alice adds Charlie.
|
||||
charlie_kp = client.export_key_package(charlie_id)
|
||||
charlie_kp_h = client.import_key_package(charlie_kp)
|
||||
commit2 = client.add_member(alice_group, charlie_kp_h)
|
||||
charlie_group = client.commit_joined_group_handle(commit2)
|
||||
|
||||
# Verify all three can communicate.
|
||||
ct = client.encrypt_group_message(alice_group, b"all three")
|
||||
self.assertEqual(client.decrypt_group_message(bob_group, ct), b"all three")
|
||||
|
||||
# Alice removes Bob (member_ref=1, since Alice is 0, Bob is 1).
|
||||
# Note: member indices depend on insertion order in mls-rs.
|
||||
# We try member_ref=1 for Bob. If it fails we try 2.
|
||||
try:
|
||||
remove_commit = client.remove_member(alice_group, 1)
|
||||
except PrivacyCoreError:
|
||||
remove_commit = client.remove_member(alice_group, 2)
|
||||
|
||||
# Post-removal: Alice encrypts a new message.
|
||||
post_removal_ct = client.encrypt_group_message(alice_group, b"bob is gone")
|
||||
|
||||
# Charlie should still be able to decrypt.
|
||||
post_removal_plain = client.decrypt_group_message(charlie_group, post_removal_ct)
|
||||
self.assertEqual(post_removal_plain, b"bob is gone")
|
||||
|
||||
# Bob's group handle should have been removed by the remove_member
|
||||
# operation's family cleanup. Attempting to decrypt should fail.
|
||||
with self.assertRaises(PrivacyCoreError):
|
||||
client.decrypt_group_message(bob_group, post_removal_ct)
|
||||
|
||||
# Cleanup.
|
||||
for c in (commit1, commit2, remove_commit):
|
||||
client.release_commit(c)
|
||||
for kp in (bob_kp_h, charlie_kp_h):
|
||||
client.release_key_package(kp)
|
||||
for g in (alice_group, charlie_group):
|
||||
client.release_group(g)
|
||||
for i in (alice_id, bob_id, charlie_id):
|
||||
client.release_identity(i)
|
||||
|
||||
|
||||
class TestPrivacyCoreLimits(unittest.TestCase):
|
||||
"""S7-T5: privacy-core must enforce handle limits and report stats."""
|
||||
|
||||
def test_identity_limit_and_handle_stats(self) -> None:
|
||||
client = _get_client()
|
||||
client.reset_all_state()
|
||||
stats_before = client.handle_stats()
|
||||
self.assertEqual(stats_before["identities"], 0)
|
||||
self.assertEqual(stats_before["max_identities"], 1024)
|
||||
|
||||
handles = []
|
||||
for _ in range(stats_before["max_identities"]):
|
||||
handles.append(client.create_identity())
|
||||
|
||||
stats_full = client.handle_stats()
|
||||
self.assertEqual(stats_full["identities"], stats_full["max_identities"])
|
||||
|
||||
with self.assertRaises(PrivacyCoreError):
|
||||
client.create_identity()
|
||||
|
||||
for handle in handles:
|
||||
client.release_identity(handle)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,43 @@
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_wormhole_subprocess_env_whitelists_runtime_and_mesh_vars():
|
||||
from services import wormhole_supervisor
|
||||
|
||||
settings = {
|
||||
"transport": "tor",
|
||||
"socks_proxy": "127.0.0.1:9050",
|
||||
"socks_dns": True,
|
||||
}
|
||||
config_snapshot = SimpleNamespace(MESH_RNS_ENABLED=False)
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"PATH": "C:\\Python;C:\\Windows\\System32",
|
||||
"SYSTEMROOT": "C:\\Windows",
|
||||
"PYTHONPATH": "F:\\Codebase\\Oracle\\live-risk-dashboard\\backend",
|
||||
"ADMIN_KEY": "admin-secret",
|
||||
"MESH_PEER_PUSH_SECRET": "peer-secret-value",
|
||||
"UNRELATED_SECRET": "should-not-leak",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
env = wormhole_supervisor._wormhole_subprocess_env(
|
||||
settings,
|
||||
settings_obj=config_snapshot,
|
||||
)
|
||||
|
||||
assert env["PATH"] == "C:\\Python;C:\\Windows\\System32"
|
||||
assert env["SYSTEMROOT"] == "C:\\Windows"
|
||||
assert env["PYTHONPATH"] == "F:\\Codebase\\Oracle\\live-risk-dashboard\\backend"
|
||||
assert env["ADMIN_KEY"] == "admin-secret"
|
||||
assert env["MESH_PEER_PUSH_SECRET"] == "peer-secret-value"
|
||||
assert env["MESH_ONLY"] == "true"
|
||||
assert env["MESH_RNS_ENABLED"] == "false"
|
||||
assert env["WORMHOLE_TRANSPORT"] == "tor"
|
||||
assert env["WORMHOLE_SOCKS_PROXY"] == "127.0.0.1:9050"
|
||||
assert env["WORMHOLE_SOCKS_DNS"] == "true"
|
||||
assert "UNRELATED_SECRET" not in env
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Smoke tests for all API endpoints — verifies routes exist and return valid responses."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -51,6 +54,92 @@ class TestLiveDataEndpoints:
|
||||
for key in ("news", "stocks", "weather", "earthquakes"):
|
||||
assert key in data, f"Missing key: {key}"
|
||||
|
||||
def test_fast_returns_full_world_payload_and_filters_disabled_sigint_sources(self, client, monkeypatch):
|
||||
from services.fetchers import _store
|
||||
|
||||
ships = [{"lat": float(i % 80), "lng": float((i % 360) - 180), "id": i} for i in range(2000)]
|
||||
sigint = (
|
||||
[{"source": "aprs", "lat": 1.0, "lng": 1.0, "id": f"a-{i}"} for i in range(50)]
|
||||
+ [{"source": "meshtastic", "lat": 2.0, "lng": 2.0, "id": f"m-{i}"} for i in range(50)]
|
||||
+ [{"source": "meshtastic", "from_api": True, "lat": 3.0, "lng": 3.0, "id": f"mm-{i}"} for i in range(50)]
|
||||
+ [{"source": "js8call", "lat": 4.0, "lng": 4.0, "id": f"j-{i}"} for i in range(50)]
|
||||
)
|
||||
|
||||
monkeypatch.setitem(_store.latest_data, "ships", ships)
|
||||
monkeypatch.setitem(_store.latest_data, "sigint", sigint)
|
||||
monkeypatch.setitem(_store.active_layers, "sigint_aprs", False)
|
||||
monkeypatch.setitem(_store.active_layers, "sigint_meshtastic", True)
|
||||
|
||||
r = client.get("/api/live-data/fast")
|
||||
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data["ships"]) == len(ships)
|
||||
assert all(item["source"] != "aprs" for item in data["sigint"])
|
||||
assert data["sigint_totals"]["aprs"] == 0
|
||||
assert data["sigint_totals"]["meshtastic"] == 100
|
||||
assert data["sigint_totals"]["meshtastic_map"] == 50
|
||||
assert data["sigint_totals"]["js8call"] == 50
|
||||
|
||||
def test_slow_omits_disabled_power_plants_and_returns_full_world_datacenters(self, client, monkeypatch):
|
||||
from services.fetchers import _store
|
||||
|
||||
datacenters = [{"lat": float(i % 80), "lng": float((i % 360) - 180), "id": i} for i in range(2000)]
|
||||
power_plants = [{"lat": float(i % 80), "lng": float((i % 360) - 180), "id": i} for i in range(4000)]
|
||||
|
||||
monkeypatch.setitem(_store.latest_data, "datacenters", datacenters)
|
||||
monkeypatch.setitem(_store.latest_data, "power_plants", power_plants)
|
||||
monkeypatch.setitem(_store.active_layers, "datacenters", True)
|
||||
monkeypatch.setitem(_store.active_layers, "power_plants", False)
|
||||
|
||||
r = client.get("/api/live-data/slow")
|
||||
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data["datacenters"]) == len(datacenters)
|
||||
assert data["power_plants"] == []
|
||||
|
||||
def test_slow_handles_geojson_incidents_without_crashing(self, client, monkeypatch):
|
||||
from services.fetchers import _store
|
||||
|
||||
gdelt = [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {"name": "Incident A"},
|
||||
"geometry": {"type": "Point", "coordinates": [10.0, 20.0]},
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setitem(_store.latest_data, "gdelt", gdelt)
|
||||
monkeypatch.setitem(_store.active_layers, "global_incidents", True)
|
||||
|
||||
r = client.get("/api/live-data/slow")
|
||||
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["gdelt"] == gdelt
|
||||
|
||||
def test_enabling_viirs_layer_queues_immediate_refresh(self, monkeypatch):
|
||||
import main
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from services.fetchers import _store
|
||||
|
||||
queued = {"called": False}
|
||||
|
||||
monkeypatch.setitem(_store.active_layers, "viirs_nightlights", False)
|
||||
monkeypatch.setattr(main, "_queue_viirs_change_refresh", lambda: queued.__setitem__("called", True))
|
||||
|
||||
async def _exercise():
|
||||
transport = ASGITransport(app=main.app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
return await ac.post("/api/layers", json={"layers": {"viirs_nightlights": True}})
|
||||
|
||||
response = asyncio.run(_exercise())
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
assert queued["called"] is True
|
||||
|
||||
|
||||
class TestDebugEndpoint:
|
||||
def test_debug_latest_returns_list(self, client):
|
||||
@@ -74,6 +163,20 @@ class TestSettingsEndpoints:
|
||||
assert isinstance(data, list)
|
||||
|
||||
|
||||
class TestAdminProtection:
|
||||
def test_refresh_requires_admin_key(self, client, monkeypatch):
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main, "_ADMIN_KEY", "test-key")
|
||||
monkeypatch.setattr(main, "_ALLOW_INSECURE_ADMIN", False)
|
||||
|
||||
r = client.get("/api/refresh")
|
||||
assert r.status_code == 403
|
||||
|
||||
r_ok = client.get("/api/refresh", headers={"X-Admin-Key": "test-key"})
|
||||
assert r_ok.status_code in (200, 202)
|
||||
|
||||
|
||||
class TestRadioEndpoints:
|
||||
def test_radio_top_returns_200(self, client):
|
||||
r = client.get("/api/radio/top")
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from services.fetch_health import record_success, record_failure, get_health_snapshot
|
||||
|
||||
|
||||
def test_record_success_and_failure():
|
||||
record_success("unit_test_source", duration_s=0.1, count=3)
|
||||
record_failure("unit_test_source", error=Exception("boom"), duration_s=0.2)
|
||||
|
||||
snap = get_health_snapshot()
|
||||
assert "unit_test_source" in snap
|
||||
entry = snap["unit_test_source"]
|
||||
assert entry["ok_count"] >= 1
|
||||
assert entry["error_count"] >= 1
|
||||
assert entry["last_ok"] is not None
|
||||
assert entry["last_error"] is not None
|
||||
assert entry["last_duration_ms"] is not None
|
||||
@@ -0,0 +1,18 @@
|
||||
import json
|
||||
from services.fetchers import geo
|
||||
|
||||
|
||||
def test_find_nearest_airport_from_fixture():
|
||||
with open("tests/fixtures/airports.json", "r", encoding="utf-8") as f:
|
||||
airports = json.load(f)
|
||||
|
||||
geo.cached_airports = airports
|
||||
|
||||
# Near Denver
|
||||
result = geo.find_nearest_airport(39.74, -104.99, max_distance_nm=200)
|
||||
assert result is not None
|
||||
assert result["iata"] == "DEN"
|
||||
|
||||
# Far away (middle of the ocean)
|
||||
result_far = geo.find_nearest_airport(0.0, -140.0, max_distance_nm=50)
|
||||
assert result_far is None
|
||||
@@ -0,0 +1,100 @@
|
||||
import zipfile
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from services import geopolitics, updater
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_gdelt_caches():
|
||||
geopolitics._article_title_cache.clear()
|
||||
geopolitics._article_url_safety_cache.clear()
|
||||
yield
|
||||
geopolitics._article_title_cache.clear()
|
||||
geopolitics._article_url_safety_cache.clear()
|
||||
|
||||
|
||||
class TestGdeltArticleUrlSafety:
|
||||
def test_safe_public_article_url_allows_public_dns(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
geopolitics.socket,
|
||||
"getaddrinfo",
|
||||
lambda *args, **kwargs: [(0, 0, 0, "", ("93.184.216.34", 443))],
|
||||
)
|
||||
|
||||
allowed, reason = geopolitics._is_safe_public_article_url("https://example.com/story")
|
||||
|
||||
assert allowed is True
|
||||
assert reason == ""
|
||||
|
||||
def test_safe_public_article_url_blocks_private_dns(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
geopolitics.socket,
|
||||
"getaddrinfo",
|
||||
lambda *args, **kwargs: [(0, 0, 0, "", ("10.0.0.7", 443))],
|
||||
)
|
||||
|
||||
allowed, reason = geopolitics._is_safe_public_article_url("https://example.com/story")
|
||||
|
||||
assert allowed is False
|
||||
assert reason == "private_dns"
|
||||
|
||||
def test_fetch_article_title_refuses_private_ip_without_request(self):
|
||||
with patch("services.geopolitics.requests.get") as mock_get:
|
||||
title = geopolitics._fetch_article_title("http://127.0.0.1/story")
|
||||
|
||||
assert title is None
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
class TestUpdaterHardening:
|
||||
def test_validate_update_url_rejects_untrusted_host(self):
|
||||
with pytest.raises(RuntimeError, match="untrusted release host"):
|
||||
updater._validate_update_url("https://evil.example.com/update.zip")
|
||||
|
||||
def test_extract_and_copy_rejects_zip_path_traversal(self, tmp_path):
|
||||
zip_path = tmp_path / "bad.zip"
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("../escape.txt", "nope")
|
||||
|
||||
with pytest.raises(RuntimeError, match="path traversal entry"):
|
||||
updater._extract_and_copy(str(zip_path), str(tmp_path / "project"), str(tmp_path / "work"))
|
||||
|
||||
def test_perform_update_returns_manual_url_on_failure(self, monkeypatch, tmp_path):
|
||||
def _boom(_temp_dir):
|
||||
raise RuntimeError("update exploded")
|
||||
|
||||
monkeypatch.setattr(updater, "_download_release", _boom)
|
||||
|
||||
result = updater.perform_update(str(tmp_path))
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert result["manual_url"] == updater.GITHUB_RELEASES_PAGE_URL
|
||||
assert "update exploded" in result["message"]
|
||||
|
||||
def test_perform_update_surfaces_release_metadata(self, monkeypatch, tmp_path):
|
||||
release_url = "https://github.com/BigBodyCobain/Shadowbroker/releases/tag/v1.2.3"
|
||||
download_url = (
|
||||
"https://github.com/BigBodyCobain/Shadowbroker/releases/download/v1.2.3/update.zip"
|
||||
)
|
||||
backup_path = tmp_path / "backup.zip"
|
||||
|
||||
monkeypatch.setattr(
|
||||
updater,
|
||||
"_download_release",
|
||||
lambda _temp_dir: ("dummy.zip", "v1.2.3", download_url, release_url),
|
||||
)
|
||||
monkeypatch.setattr(updater, "_validate_zip_hash", lambda _zip_path: None)
|
||||
monkeypatch.setattr(updater, "_backup_current", lambda *_args: str(backup_path))
|
||||
monkeypatch.setattr(updater, "_extract_and_copy", lambda *_args: 42)
|
||||
|
||||
result = updater.perform_update(str(tmp_path))
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["version"] == "v1.2.3"
|
||||
assert result["files_updated"] == 42
|
||||
assert result["backup_path"] == str(backup_path)
|
||||
assert result["manual_url"] == release_url
|
||||
assert result["release_url"] == release_url
|
||||
assert result["download_url"] == download_url
|
||||
@@ -0,0 +1,20 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_geocode_search_proxy(client):
|
||||
with patch("main.search_geocode") as mock_search:
|
||||
mock_search.return_value = [{"label": "Denver, CO, USA", "lat": 39.7392, "lng": -104.9903}]
|
||||
r = client.get("/api/geocode/search?q=denver&limit=1")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["count"] == 1
|
||||
assert data["results"][0]["label"] == "Denver, CO, USA"
|
||||
|
||||
|
||||
def test_geocode_reverse_proxy(client):
|
||||
with patch("main.reverse_geocode") as mock_reverse:
|
||||
mock_reverse.return_value = {"label": "Boulder, CO, USA"}
|
||||
r = client.get("/api/geocode/reverse?lat=40.01499&lng=-105.27055")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["label"] == "Boulder, CO, USA"
|
||||
@@ -0,0 +1,37 @@
|
||||
from services.mesh.meshtastic_topics import (
|
||||
build_subscription_topics,
|
||||
normalize_root,
|
||||
parse_topic_metadata,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_root_accepts_custom_subroots():
|
||||
assert normalize_root("msh/US/rob/snd/#") == "US/rob/snd"
|
||||
assert normalize_root(" PL ") == "PL"
|
||||
|
||||
|
||||
def test_build_subscription_topics_keeps_defaults_and_extras():
|
||||
topics = build_subscription_topics(extra_roots="PL,US/rob/snd", extra_topics="msh/+/2/json/#")
|
||||
assert "msh/US/#" in topics
|
||||
assert "msh/PL/#" in topics
|
||||
assert "msh/US/rob/snd/#" in topics
|
||||
assert "msh/+/2/json/#" in topics
|
||||
|
||||
|
||||
def test_parse_topic_metadata_preserves_root_and_channel():
|
||||
meta = parse_topic_metadata("msh/US/rob/snd/2/e/LongFast/!abcd1234")
|
||||
assert meta == {
|
||||
"region": "US",
|
||||
"root": "US/rob/snd",
|
||||
"channel": "LongFast",
|
||||
"mode": "e",
|
||||
"version": "2",
|
||||
}
|
||||
|
||||
|
||||
def test_parse_topic_metadata_handles_json_topics():
|
||||
meta = parse_topic_metadata("msh/PL/2/json/PKI/!cafefeed")
|
||||
assert meta["region"] == "PL"
|
||||
assert meta["root"] == "PL"
|
||||
assert meta["channel"] == "PKI"
|
||||
assert meta["mode"] == "json"
|
||||
@@ -40,12 +40,34 @@ class TestMilitaryBasesData:
|
||||
for entry in raw:
|
||||
assert entry["branch"] in known_branches, f"{entry['name']} has unknown branch: {entry['branch']}"
|
||||
|
||||
def test_adversary_bases_present(self):
|
||||
def test_multi_country_bases_present(self):
|
||||
raw = json.loads(BASES_PATH.read_text(encoding="utf-8"))
|
||||
countries = {entry["country"] for entry in raw}
|
||||
for expected in ("China", "Russia", "North Korea", "Taiwan"):
|
||||
for expected in (
|
||||
"China", "Russia", "North Korea", "Taiwan", "Japan", "Guam",
|
||||
"Israel", "France", "Germany", "India", "Pakistan",
|
||||
"United States", "United Kingdom", "Iran", "Italy",
|
||||
"South Korea", "Australia", "Philippines", "Greece",
|
||||
"Netherlands", "Spain", "Poland",
|
||||
):
|
||||
assert expected in countries, f"Missing bases for {expected}"
|
||||
|
||||
def test_nuclear_sites_present(self):
|
||||
raw = json.loads(BASES_PATH.read_text(encoding="utf-8"))
|
||||
nuclear = [e for e in raw if e["branch"] == "nuclear"]
|
||||
countries_with_nuclear = {e["country"] for e in nuclear}
|
||||
for expected in ("China", "Russia", "North Korea", "Iran", "Israel",
|
||||
"India", "Pakistan", "United Kingdom", "France"):
|
||||
assert expected in countries_with_nuclear, f"Missing nuclear sites for {expected}"
|
||||
|
||||
def test_missile_sites_present(self):
|
||||
raw = json.loads(BASES_PATH.read_text(encoding="utf-8"))
|
||||
missiles = [e for e in raw if e["branch"] == "missile"]
|
||||
countries_with_missiles = {e["country"] for e in missiles}
|
||||
for expected in ("China", "Russia", "North Korea", "Iran", "Israel",
|
||||
"India", "Pakistan", "Taiwan", "South Korea", "Poland"):
|
||||
assert expected in countries_with_missiles, f"Missing missile sites for {expected}"
|
||||
|
||||
def test_no_duplicate_names(self):
|
||||
raw = json.loads(BASES_PATH.read_text(encoding="utf-8"))
|
||||
names = [entry["name"] for entry in raw]
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
"""Tests for network_utils — fetch_with_curl, circuit breaker, domain fail cache."""
|
||||
|
||||
import time
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from services.network_utils import fetch_with_curl, _circuit_breaker, _domain_fail_cache, _cb_lock, _DummyResponse
|
||||
from services.network_utils import (
|
||||
fetch_with_curl,
|
||||
_circuit_breaker,
|
||||
_domain_fail_cache,
|
||||
_cb_lock,
|
||||
_DummyResponse,
|
||||
)
|
||||
|
||||
|
||||
class TestDummyResponse:
|
||||
@@ -81,7 +88,7 @@ class TestCircuitBreaker:
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = '{"data": true}\n200'
|
||||
mock_result.stderr = ''
|
||||
mock_result.stderr = ""
|
||||
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
result = fetch_with_curl("https://skip-to-curl.com/api")
|
||||
@@ -138,8 +145,9 @@ class TestFetchWithCurl:
|
||||
|
||||
with patch("services.network_utils._session") as mock_session:
|
||||
mock_session.post.return_value = mock_resp
|
||||
result = fetch_with_curl("https://api.example.com/create",
|
||||
method="POST", json_data={"name": "test"})
|
||||
result = fetch_with_curl(
|
||||
"https://api.example.com/create", method="POST", json_data={"name": "test"}
|
||||
)
|
||||
assert result.status_code == 200
|
||||
mock_session.post.assert_called_once()
|
||||
|
||||
@@ -151,8 +159,9 @@ class TestFetchWithCurl:
|
||||
|
||||
with patch("services.network_utils._session") as mock_session:
|
||||
mock_session.get.return_value = mock_resp
|
||||
fetch_with_curl("https://api.example.com/data",
|
||||
headers={"Authorization": "Bearer token123"})
|
||||
fetch_with_curl(
|
||||
"https://api.example.com/data", headers={"Authorization": "Bearer token123"}
|
||||
)
|
||||
call_args = mock_session.get.call_args
|
||||
headers = call_args.kwargs.get("headers", {})
|
||||
assert "Authorization" in headers
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import json
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_HELPER_PATH = Path(__file__).resolve().parents[1] / "scripts" / "release_helper.py"
|
||||
_SPEC = importlib.util.spec_from_file_location("release_helper", _HELPER_PATH)
|
||||
assert _SPEC and _SPEC.loader
|
||||
release_helper = importlib.util.module_from_spec(_SPEC)
|
||||
_SPEC.loader.exec_module(release_helper)
|
||||
|
||||
|
||||
def test_normalize_version_accepts_plain_and_prefixed():
|
||||
assert release_helper._normalize_version("0.9.6") == "0.9.6"
|
||||
assert release_helper._normalize_version("v0.9.6") == "0.9.6"
|
||||
|
||||
|
||||
def test_normalize_version_rejects_non_semver_triplet():
|
||||
with pytest.raises(ValueError, match="X.Y.Z"):
|
||||
release_helper._normalize_version("0.9")
|
||||
|
||||
|
||||
def test_expected_release_names():
|
||||
assert release_helper.expected_tag("0.9.6") == "v0.9.6"
|
||||
assert release_helper.expected_asset("0.9.6") == "ShadowBroker_v0.9.6.zip"
|
||||
|
||||
|
||||
def test_set_version_updates_package_json(monkeypatch, tmp_path):
|
||||
package_json = tmp_path / "package.json"
|
||||
package_json.write_text(json.dumps({"name": "frontend", "version": "0.9.5"}) + "\n", encoding="utf-8")
|
||||
monkeypatch.setattr(release_helper, "PACKAGE_JSON", package_json)
|
||||
|
||||
version = release_helper.set_version("0.9.6")
|
||||
|
||||
assert version == "0.9.6"
|
||||
data = json.loads(package_json.read_text(encoding="utf-8"))
|
||||
assert data["version"] == "0.9.6"
|
||||
|
||||
|
||||
def test_sha256_file(tmp_path):
|
||||
payload = tmp_path / "payload.zip"
|
||||
payload.write_bytes(b"shadowbroker")
|
||||
|
||||
digest = release_helper.sha256_file(payload)
|
||||
|
||||
assert digest == "153f774fe47e71734bf608e20fd59d9ee0ad522811dc9a121bbfd3dbd79a4229"
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tests for Pydantic response schemas."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from services.schemas import HealthResponse, RefreshResponse, AisFeedResponse, RouteResponse
|
||||
@@ -11,7 +12,7 @@ class TestHealthResponse:
|
||||
"last_updated": "2024-01-01T00:00:00",
|
||||
"sources": {"flights": 150, "ships": 42},
|
||||
"freshness": {"flights": "2024-01-01T00:00:00", "ships": "2024-01-01T00:00:00"},
|
||||
"uptime_seconds": 3600
|
||||
"uptime_seconds": 3600,
|
||||
}
|
||||
resp = HealthResponse(**data)
|
||||
assert resp.status == "ok"
|
||||
@@ -19,12 +20,7 @@ class TestHealthResponse:
|
||||
assert resp.uptime_seconds == 3600
|
||||
|
||||
def test_health_response_optional_last_updated(self):
|
||||
data = {
|
||||
"status": "ok",
|
||||
"sources": {},
|
||||
"freshness": {},
|
||||
"uptime_seconds": 0
|
||||
}
|
||||
data = {"status": "ok", "sources": {}, "freshness": {}, "uptime_seconds": 0}
|
||||
resp = HealthResponse(**data)
|
||||
assert resp.last_updated is None
|
||||
|
||||
@@ -59,7 +55,7 @@ class TestRouteResponse:
|
||||
orig_loc=[40.6413, -73.7781],
|
||||
dest_loc=[51.4700, -0.4543],
|
||||
origin_name="JFK",
|
||||
dest_name="LHR"
|
||||
dest_name="LHR",
|
||||
)
|
||||
assert resp.origin_name == "JFK"
|
||||
assert len(resp.orig_loc) == 2
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
from types import SimpleNamespace
|
||||
import pytest
|
||||
|
||||
|
||||
def test_build_sigint_snapshot_merges_map_nodes_without_duplicate_meshtastic(monkeypatch):
|
||||
from services.fetchers import sigint as sigint_fetcher
|
||||
from services.fetchers._store import _data_lock, latest_data
|
||||
from services import sigint_bridge as sigint_bridge_mod
|
||||
|
||||
fake_grid = SimpleNamespace(
|
||||
get_all_signals=lambda: [
|
||||
{
|
||||
"callsign": "!live1",
|
||||
"source": "meshtastic",
|
||||
"timestamp": "2026-03-22T18:00:00+00:00",
|
||||
"region": "US",
|
||||
},
|
||||
{
|
||||
"callsign": "K1ABC",
|
||||
"source": "aprs",
|
||||
"timestamp": "2026-03-22T17:59:00+00:00",
|
||||
},
|
||||
],
|
||||
get_mesh_channel_stats=lambda api_nodes=None: {"total_api": len(api_nodes or [])},
|
||||
)
|
||||
monkeypatch.setattr(sigint_bridge_mod, "sigint_grid", fake_grid)
|
||||
with _data_lock:
|
||||
latest_data["meshtastic_map_nodes"] = [
|
||||
{
|
||||
"callsign": "!live1",
|
||||
"source": "meshtastic",
|
||||
"timestamp": "2026-03-22T17:40:00+00:00",
|
||||
"from_api": True,
|
||||
},
|
||||
{
|
||||
"callsign": "!map2",
|
||||
"source": "meshtastic",
|
||||
"timestamp": "2026-03-22T17:58:00+00:00",
|
||||
"from_api": True,
|
||||
},
|
||||
]
|
||||
|
||||
signals, channel_stats, totals = sigint_fetcher.build_sigint_snapshot()
|
||||
|
||||
assert [sig["callsign"] for sig in signals] == ["!live1", "K1ABC", "!map2"]
|
||||
assert channel_stats == {"total_api": 2}
|
||||
assert totals == {
|
||||
"total": 3,
|
||||
"meshtastic": 2,
|
||||
"meshtastic_live": 1,
|
||||
"meshtastic_map": 1,
|
||||
"aprs": 1,
|
||||
"js8call": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_rewrite_cctv_hls_playlist_proxies_relative_segments_and_keys():
|
||||
import main
|
||||
|
||||
playlist = """#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-KEY:METHOD=AES-128,URI="keys/key.bin"
|
||||
#EXTINF:5.0,
|
||||
segment-001.ts
|
||||
"""
|
||||
|
||||
rewritten = main._rewrite_cctv_hls_playlist(
|
||||
"https://navigator-c2c.dot.ga.gov/live/cam/index.m3u8",
|
||||
playlist,
|
||||
)
|
||||
|
||||
assert '/api/cctv/media?url=https%3A%2F%2Fnavigator-c2c.dot.ga.gov%2Flive%2Fcam%2Fkeys%2Fkey.bin' in rewritten
|
||||
assert '/api/cctv/media?url=https%3A%2F%2Fnavigator-c2c.dot.ga.gov%2Flive%2Fcam%2Fsegment-001.ts' in rewritten
|
||||
|
||||
|
||||
def test_cctv_proxy_allows_known_state_dot_media_hosts():
|
||||
import main
|
||||
|
||||
allowed_hosts = {
|
||||
"wzmedia.dot.ca.gov",
|
||||
"511ga.org",
|
||||
"cctv.travelmidwest.com",
|
||||
"micamerasimages.net",
|
||||
"tripcheck.com",
|
||||
}
|
||||
|
||||
for host in allowed_hosts:
|
||||
assert main._cctv_host_allowed(host)
|
||||
|
||||
|
||||
def test_fetch_satnogs_keeps_last_good_snapshot_on_error(monkeypatch):
|
||||
from services.fetchers import infrastructure
|
||||
from services.fetchers._store import _data_lock, latest_data
|
||||
from services.fetchers import _store as store_mod
|
||||
from services import satnogs_fetcher
|
||||
|
||||
with _data_lock:
|
||||
latest_data["satnogs_stations"] = [{"id": "station-1"}]
|
||||
latest_data["satnogs_observations"] = [{"id": "obs-1"}]
|
||||
|
||||
monkeypatch.setattr(store_mod, "is_any_active", lambda *args: True)
|
||||
monkeypatch.setattr(satnogs_fetcher, "fetch_satnogs_stations", lambda: (_ for _ in ()).throw(ValueError("boom")))
|
||||
monkeypatch.setattr(satnogs_fetcher, "fetch_satnogs_observations", lambda: [])
|
||||
|
||||
infrastructure.fetch_satnogs()
|
||||
|
||||
with _data_lock:
|
||||
assert latest_data["satnogs_stations"] == [{"id": "station-1"}]
|
||||
assert latest_data["satnogs_observations"] == [{"id": "obs-1"}]
|
||||
|
||||
|
||||
def test_fetch_tinygs_keeps_last_good_snapshot_on_error(monkeypatch):
|
||||
from services.fetchers import infrastructure
|
||||
from services.fetchers._store import _data_lock, latest_data
|
||||
from services.fetchers import _store as store_mod
|
||||
from services import tinygs_fetcher
|
||||
|
||||
with _data_lock:
|
||||
latest_data["tinygs_satellites"] = [{"norad_id": 12345}]
|
||||
|
||||
monkeypatch.setattr(store_mod, "is_any_active", lambda *args: True)
|
||||
monkeypatch.setattr(tinygs_fetcher, "fetch_tinygs_satellites", lambda: (_ for _ in ()).throw(ValueError("boom")))
|
||||
|
||||
infrastructure.fetch_tinygs()
|
||||
|
||||
with _data_lock:
|
||||
assert latest_data["tinygs_satellites"] == [{"norad_id": 12345}]
|
||||
|
||||
|
||||
def test_caltrans_ingestor_prefers_static_image_when_stream_url_is_not_browser_safe(monkeypatch):
|
||||
from services import cctv_pipeline
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
|
||||
def json(self):
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"cctv": {
|
||||
"location": {
|
||||
"latitude": "34.123",
|
||||
"longitude": "-118.456",
|
||||
"locationName": "I-5 @ Main",
|
||||
"route": "I-5",
|
||||
},
|
||||
"inService": "true",
|
||||
"imageData": {
|
||||
"streamingVideoURL": "viewer?id=123",
|
||||
"static": {"currentImageURL": "/images/cam123.jpg"},
|
||||
},
|
||||
"index": 123,
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
monkeypatch.setattr(cctv_pipeline, "fetch_with_curl", lambda *args, **kwargs: _Response())
|
||||
|
||||
cameras = cctv_pipeline.CaltransIngestor().fetch_data()
|
||||
|
||||
assert cameras[0]["media_url"] == "https://cwwp2.dot.ca.gov/images/cam123.jpg"
|
||||
assert cameras[0]["media_type"] == "image"
|
||||
|
||||
|
||||
def test_georgia_ingestor_uses_public_511ga_feed_and_paginates(monkeypatch):
|
||||
from services import cctv_pipeline
|
||||
|
||||
class _Response:
|
||||
def __init__(self, payload):
|
||||
self.status_code = 200
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
responses = [
|
||||
{
|
||||
"recordsTotal": 2,
|
||||
"data": [
|
||||
{
|
||||
"id": 14968,
|
||||
"location": "ALPH-0050: Rucker Rd at Charlotte Dr (Alpharetta)",
|
||||
"latLng": {
|
||||
"geography": {
|
||||
"wellKnownText": "POINT (-84.33039 34.076298)",
|
||||
}
|
||||
},
|
||||
"images": [
|
||||
{
|
||||
"id": 22378,
|
||||
"imageUrl": "/map/Cctv/22378",
|
||||
"blocked": False,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"recordsTotal": 2,
|
||||
"data": [
|
||||
{
|
||||
"id": 14969,
|
||||
"location": "BARR-0034: SR 211 at Pinot Nior Dr (Barrow)",
|
||||
"latLng": {
|
||||
"geography": {
|
||||
"wellKnownText": "POINT (-83.81524 34.10526)",
|
||||
}
|
||||
},
|
||||
"images": [
|
||||
{
|
||||
"id": 22379,
|
||||
"imageUrl": "/map/Cctv/22379",
|
||||
"blocked": False,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
calls = []
|
||||
|
||||
def _fake_fetch(url, method="GET", json_data=None, timeout=15, headers=None):
|
||||
calls.append(
|
||||
{
|
||||
"url": url,
|
||||
"method": method,
|
||||
"json_data": json_data,
|
||||
"headers": headers,
|
||||
}
|
||||
)
|
||||
return _Response(responses.pop(0))
|
||||
|
||||
monkeypatch.setattr(cctv_pipeline.GeorgiaDOTIngestor, "PAGE_SIZE", 1)
|
||||
monkeypatch.setattr(cctv_pipeline, "fetch_with_curl", _fake_fetch)
|
||||
|
||||
cameras = cctv_pipeline.GeorgiaDOTIngestor().fetch_data()
|
||||
|
||||
assert [cam["id"] for cam in cameras] == ["GDOT-14968", "GDOT-14969"]
|
||||
assert cameras[0]["media_url"] == "https://511ga.org/map/Cctv/22378"
|
||||
assert cameras[0]["media_type"] == "image"
|
||||
assert cameras[0]["lat"] == pytest.approx(34.076298)
|
||||
assert cameras[0]["lon"] == pytest.approx(-84.33039)
|
||||
assert len(calls) == 2
|
||||
assert all(call["url"] == "https://511ga.org/List/GetData/Cameras" for call in calls)
|
||||
assert all(call["method"] == "POST" for call in calls)
|
||||
assert calls[0]["json_data"] == {"draw": 1, "start": 0, "length": 1}
|
||||
assert calls[1]["json_data"] == {"draw": 2, "start": 1, "length": 1}
|
||||
assert calls[0]["headers"]["Referer"] == "https://511ga.org/cctv"
|
||||
|
||||
|
||||
def test_michigan_ingestor_absolutizes_relative_image_urls(monkeypatch):
|
||||
from services import cctv_pipeline
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
|
||||
def json(self):
|
||||
return [
|
||||
{
|
||||
"county": "id=42&lat=42.3314&lon=-83.0458",
|
||||
"image": '<img src="/MiDrive/camera/image/42.jpg">',
|
||||
"route": "I-94",
|
||||
"location": "Downtown",
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(cctv_pipeline, "fetch_with_curl", lambda *args, **kwargs: _Response())
|
||||
|
||||
cameras = cctv_pipeline.MichiganDOTIngestor().fetch_data()
|
||||
|
||||
assert cameras[0]["media_url"] == "https://mdotjboss.state.mi.us/MiDrive/camera/image/42.jpg"
|
||||
assert cameras[0]["media_type"] == "image"
|
||||
|
||||
|
||||
def test_austin_ingestor_prefers_source_screenshot_address_and_filters_disabled(monkeypatch):
|
||||
from services import cctv_pipeline
|
||||
|
||||
class _Response:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return [
|
||||
{
|
||||
"camera_id": "316",
|
||||
"camera_status": "TURNED_ON",
|
||||
"location_name": "Austin Camera 316",
|
||||
"screenshot_address": "https://cctv.austinmobility.io/image/316.jpg",
|
||||
"location": {"coordinates": [-97.74, 30.24]},
|
||||
},
|
||||
{
|
||||
"camera_id": "317",
|
||||
"camera_status": "TURNED_OFF",
|
||||
"location_name": "Austin Camera 317",
|
||||
"screenshot_address": "https://cctv.austinmobility.io/image/317.jpg",
|
||||
"location": {"coordinates": [-97.75, 30.25]},
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(cctv_pipeline, "fetch_with_curl", lambda *args, **kwargs: _Response())
|
||||
|
||||
cameras = cctv_pipeline.AustinTXIngestor().fetch_data()
|
||||
|
||||
assert len(cameras) == 1
|
||||
assert cameras[0]["id"] == "ATX-316"
|
||||
assert cameras[0]["media_url"] == "https://cctv.austinmobility.io/image/316.jpg"
|
||||
assert cameras[0]["media_type"] == "image"
|
||||
|
||||
|
||||
def test_cctv_proxy_profiles_are_source_specific():
|
||||
import main
|
||||
|
||||
tfl = main._cctv_proxy_profile_for_url("https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/00001.mp4")
|
||||
austin = main._cctv_proxy_profile_for_url("https://cctv.austinmobility.io/image/316.jpg")
|
||||
georgia = main._cctv_proxy_profile_for_url("https://511ga.org/map/Cctv/22378")
|
||||
spain = main._cctv_proxy_profile_for_url("https://infocar.dgt.es/etraffic/data/camaras/1050.jpg")
|
||||
|
||||
assert tfl.name == "tfl-jamcam"
|
||||
assert tfl.headers["Accept"].startswith("video/mp4")
|
||||
assert austin.name == "austin-mobility"
|
||||
assert austin.headers["Origin"] == "https://data.mobility.austin.gov"
|
||||
assert georgia.name == "gdot-511ga-image"
|
||||
assert georgia.timeout == (5.0, 12.0)
|
||||
assert georgia.headers["Referer"] == "https://511ga.org/cctv"
|
||||
assert spain.name == "dgt-spain"
|
||||
assert spain.headers["Referer"] == "https://infocar.dgt.es/"
|
||||
|
||||
|
||||
def test_cctv_proxy_preserves_upstream_http_status(monkeypatch):
|
||||
import main
|
||||
|
||||
class _Response:
|
||||
status_code = 404
|
||||
headers = {}
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("requests.get", lambda *args, **kwargs: _Response())
|
||||
|
||||
request = SimpleNamespace(headers={})
|
||||
profile = main._cctv_proxy_profile_for_url("https://infocar.dgt.es/etraffic/data/camaras/1050.jpg")
|
||||
|
||||
with pytest.raises(main.HTTPException) as exc:
|
||||
main._fetch_cctv_upstream_response(request, "https://infocar.dgt.es/etraffic/data/camaras/1050.jpg", profile)
|
||||
|
||||
assert exc.value.status_code == 404
|
||||
assert exc.value.detail == "Upstream returned 404"
|
||||
|
||||
|
||||
def test_colorado_ingestor_prefers_preview_image_with_hls_fallback(monkeypatch):
|
||||
from services import cctv_pipeline
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
|
||||
def json(self):
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"public": True,
|
||||
"active": True,
|
||||
"name": "I-70 EB",
|
||||
"location": {"latitude": 39.7, "longitude": -105.2, "routeId": "I-70"},
|
||||
"cameraOwner": {"name": "Colorado DOT"},
|
||||
"views": [
|
||||
{
|
||||
"url": "https://publicstreamer2.cotrip.org/rtplive/test/playlist.m3u8",
|
||||
"videoPreviewUrl": "https://cocam.carsprogram.org/Snapshots/test.flv.png",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"public": True,
|
||||
"active": True,
|
||||
"name": "US-285 NB",
|
||||
"location": {"latitude": 39.6, "longitude": -105.1, "routeId": "US-285"},
|
||||
"cameraOwner": {"name": "Colorado DOT"},
|
||||
"views": [
|
||||
{
|
||||
"url": "",
|
||||
"videoPreviewUrl": "https://cocam.carsprogram.org/Snapshots/test2.flv.png",
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(cctv_pipeline, "fetch_with_curl", lambda *args, **kwargs: _Response())
|
||||
|
||||
cameras = cctv_pipeline.ColoradoDOTIngestor().fetch_data()
|
||||
|
||||
assert cameras[0]["media_url"] == "https://cocam.carsprogram.org/Snapshots/test.flv.png"
|
||||
assert cameras[0]["media_type"] == "image"
|
||||
assert cameras[1]["media_url"] == "https://cocam.carsprogram.org/Snapshots/test2.flv.png"
|
||||
assert cameras[1]["media_type"] == "image"
|
||||
|
||||
|
||||
def test_caltrans_ingestor_prefers_static_image_over_flaky_hls(monkeypatch):
|
||||
from services import cctv_pipeline
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
|
||||
def json(self):
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"cctv": {
|
||||
"index": "1",
|
||||
"inService": "true",
|
||||
"location": {
|
||||
"latitude": "37.82539",
|
||||
"longitude": "-122.27291",
|
||||
"locationName": "TV102 -- I-580 : West of SR-24",
|
||||
},
|
||||
"imageData": {
|
||||
"streamingVideoURL": "https://wzmedia.dot.ca.gov/D4/W580_JWO_24_IC.stream/playlist.m3u8",
|
||||
"static": {
|
||||
"currentImageURL": "https://cwwp2.dot.ca.gov/data/d4/cctv/image/tv102i580westofsr24/tv102i580westofsr24.jpg"
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
monkeypatch.setattr(cctv_pipeline, "fetch_with_curl", lambda *args, **kwargs: _Response())
|
||||
monkeypatch.setattr(cctv_pipeline.CaltransIngestor, "DISTRICTS", [4])
|
||||
|
||||
cameras = cctv_pipeline.CaltransIngestor().fetch_data()
|
||||
|
||||
assert len(cameras) == 1
|
||||
assert cameras[0]["media_type"] == "image"
|
||||
assert cameras[0]["media_url"].endswith("tv102i580westofsr24.jpg")
|
||||
|
||||
|
||||
def test_dgt_ingestor_skips_dead_seed_urls(monkeypatch):
|
||||
from services import cctv_pipeline
|
||||
|
||||
monkeypatch.setattr(
|
||||
cctv_pipeline,
|
||||
"_media_url_reachable",
|
||||
lambda url, **kwargs: url.endswith("/1001.jpg"),
|
||||
)
|
||||
|
||||
cameras = cctv_pipeline.DGTNationalIngestor().fetch_data()
|
||||
|
||||
assert len(cameras) == 1
|
||||
assert cameras[0]["id"] == "DGT-1001"
|
||||
|
||||
|
||||
def test_base_ingestor_prunes_stale_rows_for_successful_source_refresh(tmp_path, monkeypatch):
|
||||
import sqlite3
|
||||
from services import cctv_pipeline
|
||||
|
||||
db_path = tmp_path / "cctv.db"
|
||||
monkeypatch.setattr(cctv_pipeline, "DB_PATH", db_path)
|
||||
cctv_pipeline.init_db()
|
||||
|
||||
rows = [
|
||||
{
|
||||
"id": "DGT-1001",
|
||||
"source_agency": "DGT Spain",
|
||||
"lat": 40.4,
|
||||
"lon": -3.7,
|
||||
"direction_facing": "A-6 Madrid",
|
||||
"media_url": "https://infocar.dgt.es/etraffic/data/camaras/1001.jpg",
|
||||
"media_type": "image",
|
||||
"refresh_rate_seconds": 300,
|
||||
},
|
||||
{
|
||||
"id": "DGT-1002",
|
||||
"source_agency": "DGT Spain",
|
||||
"lat": 40.45,
|
||||
"lon": -3.68,
|
||||
"direction_facing": "A-2 Madrid",
|
||||
"media_url": "https://infocar.dgt.es/etraffic/data/camaras/1002.jpg",
|
||||
"media_type": "image",
|
||||
"refresh_rate_seconds": 300,
|
||||
},
|
||||
]
|
||||
|
||||
class _Ingestor(cctv_pipeline.BaseCCTVIngestor):
|
||||
def fetch_data(self):
|
||||
return list(rows)
|
||||
|
||||
_Ingestor().ingest()
|
||||
rows.pop()
|
||||
_Ingestor().ingest()
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
stored_ids = [row[0] for row in conn.execute("select id from cameras order by id")]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert stored_ids == ["DGT-1001"]
|
||||
|
||||
|
||||
def test_osm_ingestor_keeps_only_direct_media_urls(monkeypatch):
|
||||
from services import cctv_pipeline
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
|
||||
def json(self):
|
||||
return {
|
||||
"elements": [
|
||||
{
|
||||
"id": 101,
|
||||
"lat": 39.7392,
|
||||
"lon": -104.9903,
|
||||
"tags": {
|
||||
"camera:type": "traffic_monitoring",
|
||||
"camera:url": "https://example.gov/cam101/playlist.m3u8",
|
||||
"camera:direction": "270",
|
||||
"operator": "Colorado DOT",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"lat": 39.7400,
|
||||
"lon": -104.9910,
|
||||
"tags": {
|
||||
"camera:type": "traffic_monitoring",
|
||||
"website": "https://example.gov/traffic/cameras",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
monkeypatch.setattr(cctv_pipeline, "fetch_with_curl", lambda *args, **kwargs: _Response())
|
||||
|
||||
cameras = cctv_pipeline.OSMTrafficCameraIngestor().fetch_data()
|
||||
|
||||
assert len(cameras) == 1
|
||||
assert cameras[0]["id"] == "OSM-101"
|
||||
assert cameras[0]["media_type"] == "hls"
|
||||
assert cameras[0]["direction_facing"] == "270"
|
||||
|
||||
|
||||
def test_cctv_proxy_allows_colorado_media_hosts():
|
||||
import main
|
||||
|
||||
assert main._cctv_host_allowed("publicstreamer2.cotrip.org")
|
||||
assert main._cctv_host_allowed("cocam.carsprogram.org")
|
||||
|
||||
|
||||
def test_data_fetcher_cctv_scheduler_includes_colorado_and_osm():
|
||||
from pathlib import Path
|
||||
|
||||
source = Path("backend/services/data_fetcher.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "ColoradoDOTIngestor()" in source
|
||||
assert "OSMTrafficCameraIngestor()" in source
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tests for the shared in-memory data store."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import pytest
|
||||
@@ -10,19 +11,46 @@ class TestLatestDataStructure:
|
||||
|
||||
def test_has_all_required_keys(self):
|
||||
expected_keys = {
|
||||
"last_updated", "news", "stocks", "oil", "flights", "ships",
|
||||
"military_flights", "tracked_flights", "cctv", "weather",
|
||||
"earthquakes", "uavs", "frontlines", "gdelt", "liveuamap",
|
||||
"kiwisdr", "space_weather", "internet_outages", "firms_fires",
|
||||
"datacenters"
|
||||
"last_updated",
|
||||
"news",
|
||||
"stocks",
|
||||
"oil",
|
||||
"flights",
|
||||
"ships",
|
||||
"military_flights",
|
||||
"tracked_flights",
|
||||
"cctv",
|
||||
"weather",
|
||||
"earthquakes",
|
||||
"uavs",
|
||||
"frontlines",
|
||||
"gdelt",
|
||||
"liveuamap",
|
||||
"kiwisdr",
|
||||
"space_weather",
|
||||
"internet_outages",
|
||||
"firms_fires",
|
||||
"datacenters",
|
||||
}
|
||||
assert expected_keys.issubset(set(latest_data.keys()))
|
||||
|
||||
def test_list_keys_default_to_empty_list(self):
|
||||
list_keys = ["news", "flights", "ships", "military_flights",
|
||||
"tracked_flights", "cctv", "earthquakes", "uavs",
|
||||
"gdelt", "liveuamap", "kiwisdr", "internet_outages",
|
||||
"firms_fires", "datacenters"]
|
||||
list_keys = [
|
||||
"news",
|
||||
"flights",
|
||||
"ships",
|
||||
"military_flights",
|
||||
"tracked_flights",
|
||||
"cctv",
|
||||
"earthquakes",
|
||||
"uavs",
|
||||
"gdelt",
|
||||
"liveuamap",
|
||||
"kiwisdr",
|
||||
"internet_outages",
|
||||
"firms_fires",
|
||||
"datacenters",
|
||||
]
|
||||
for key in list_keys:
|
||||
assert isinstance(latest_data[key], list), f"{key} should default to list"
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
from services.fetchers import trains as train_fetcher
|
||||
from services.fetchers._store import _data_lock, latest_data
|
||||
|
||||
|
||||
def setup_function():
|
||||
train_fetcher._TRAIN_TRACK_CACHE.clear()
|
||||
with _data_lock:
|
||||
latest_data["trains"] = []
|
||||
|
||||
|
||||
def test_merge_nonredundant_trains_prefers_higher_priority_source_and_backfills_fields():
|
||||
lower_fidelity = train_fetcher._normalize_train(
|
||||
source="amtrak",
|
||||
raw_id="AMTK-8",
|
||||
number="8",
|
||||
lat=40.0000,
|
||||
lng=-75.0000,
|
||||
route="New York -> Chicago",
|
||||
status="On Time",
|
||||
operator="Shared Rail",
|
||||
country="US",
|
||||
observed_at="2026-03-22T18:00:00Z",
|
||||
)
|
||||
higher_fidelity = train_fetcher._normalize_train(
|
||||
source="digitraffic",
|
||||
raw_id="FIN-8",
|
||||
number="8",
|
||||
lat=40.0080,
|
||||
lng=-75.0020,
|
||||
speed_kmh=128.4,
|
||||
status="Active",
|
||||
operator="Shared Rail",
|
||||
country="US",
|
||||
observed_at="2026-03-22T18:00:05Z",
|
||||
)
|
||||
|
||||
merged = train_fetcher._merge_nonredundant_trains([lower_fidelity], [higher_fidelity])
|
||||
|
||||
assert len(merged) == 1
|
||||
train = merged[0]
|
||||
assert train["source"] == "digitraffic"
|
||||
assert train["speed_kmh"] == 128.4
|
||||
assert train["route"] == "New York -> Chicago"
|
||||
assert train["source_label"] == "Digitraffic Finland"
|
||||
|
||||
|
||||
def test_motion_estimates_infer_speed_and_heading_from_previous_position():
|
||||
first = train_fetcher._normalize_train(
|
||||
source="amtrak",
|
||||
raw_id="AMTK-14",
|
||||
number="14",
|
||||
lat=40.0000,
|
||||
lng=-75.0000,
|
||||
observed_at=1_000,
|
||||
)
|
||||
assert first is not None
|
||||
second = train_fetcher._normalize_train(
|
||||
source="amtrak",
|
||||
raw_id="AMTK-14",
|
||||
number="14",
|
||||
lat=40.0000,
|
||||
lng=-74.9900,
|
||||
observed_at=1_060,
|
||||
)
|
||||
|
||||
assert second is not None
|
||||
assert second["speed_kmh"] is not None
|
||||
assert 40.0 <= second["speed_kmh"] <= 80.0
|
||||
assert second["heading"] is not None
|
||||
assert 80.0 <= second["heading"] <= 100.0
|
||||
|
||||
|
||||
def test_fetch_trains_merges_sources_into_store(monkeypatch):
|
||||
def _batch_one():
|
||||
return [
|
||||
train_fetcher._normalize_train(
|
||||
source="amtrak",
|
||||
raw_id="AMTK-22",
|
||||
number="22",
|
||||
lat=41.0000,
|
||||
lng=-87.0000,
|
||||
route="Chicago -> St. Louis",
|
||||
operator="Shared Rail",
|
||||
country="US",
|
||||
observed_at="2026-03-22T19:00:00Z",
|
||||
)
|
||||
]
|
||||
|
||||
def _batch_two():
|
||||
return [
|
||||
train_fetcher._normalize_train(
|
||||
source="digitraffic",
|
||||
raw_id="FIN-22",
|
||||
number="22",
|
||||
lat=41.0040,
|
||||
lng=-87.0010,
|
||||
speed_kmh=96.0,
|
||||
operator="Shared Rail",
|
||||
country="US",
|
||||
observed_at="2026-03-22T19:00:05Z",
|
||||
)
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
train_fetcher,
|
||||
"_TRAIN_FETCHERS",
|
||||
(("amtrak", _batch_one), ("digitraffic", _batch_two)),
|
||||
)
|
||||
|
||||
train_fetcher.fetch_trains()
|
||||
|
||||
with _data_lock:
|
||||
trains = list(latest_data["trains"])
|
||||
|
||||
assert len(trains) == 1
|
||||
assert trains[0]["source"] == "digitraffic"
|
||||
assert trains[0]["route"] == "Chicago -> St. Louis"
|
||||
assert trains[0]["speed_kmh"] == 96.0
|
||||
|
||||
|
||||
def test_fetch_trains_preserves_last_good_snapshot_when_refresh_fails(monkeypatch):
|
||||
with _data_lock:
|
||||
latest_data["trains"] = [
|
||||
{
|
||||
"id": "AMTK-1",
|
||||
"name": "Sunset Limited",
|
||||
"number": "1",
|
||||
"source": "amtrak",
|
||||
"lat": 32.33,
|
||||
"lng": -109.76,
|
||||
"speed_kmh": None,
|
||||
"heading": None,
|
||||
"status": "On Time",
|
||||
"route": "New Orleans -> Los Angeles",
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
train_fetcher,
|
||||
"_TRAIN_FETCHERS",
|
||||
(("amtrak", lambda: []), ("digitraffic", lambda: [])),
|
||||
)
|
||||
|
||||
train_fetcher.fetch_trains()
|
||||
|
||||
with _data_lock:
|
||||
trains = list(latest_data["trains"])
|
||||
|
||||
assert len(trains) == 1
|
||||
assert trains[0]["id"] == "AMTK-1"
|
||||
Reference in New Issue
Block a user