mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-09-26 18:51:49 +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:
@@ -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
|
||||
Reference in New Issue
Block a user