mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-14 06:30:29 +02:00
feat(infonet): private gate + DM hashchain spool with hardened propagation (#326)
Private gate messages and offline DMs now ride the Infonet hashchain as ciphertext-only events, replicated across nodes via private transports (Tor onion / RNS / loopback) and decrypted only by parties holding the gate or recipient keys. Hashchain core (mesh_hashchain.py) ---------------------------------- * New ``append_private_gate_message`` and ``append_private_dm_message`` append paths with full signature verification, public-key binding, revocation check, and replay protection in a dedicated sequence domain (so a gate post does not consume the author's public broadcast sequence, and a DM cannot replay-block a public message at sequence=1). * Fork validation and full-chain validation now accept the gate signature compatibility variants — older signatures that canonicalize with/without epoch or reply_to still verify, so a re-sync from an older peer doesn't reject still-valid history. * DM hashchain spool: capped at 2 active sealed offline DMs per recipient mailbox, plus a per-(sender, recipient) cap so one prolific sender can't consume both slots. 1-hour TTL on the cap counter. Spool intentionally small — it's an offline bootstrap channel, not a persistent mailbox. * Rebuild-state preserves the gate sequence domain across reloads so a chain reload doesn't accidentally let an old gate sequence replay-collide on next append. Schema enforcement (mesh_schema.py) ----------------------------------- * Private gate + DM payloads have closed allowlists of fields. Plaintext keys (``message``, ``plaintext``, ``_local_plaintext``, ``_local_reply_to``) are explicit rejection-bait — they raise before the event ever touches the chain. * DM ciphertext + nonce must look like base64-ish sealed bytes; obvious base64-encoded plaintext shapes are rejected. * ``transport_lock`` required: DM hashchain spool requires ``private_strong``; gate accepts ``private``/``private_strong``/ ``rns``/``onion``. Defense-in-depth at the network layer (main.py + mesh_public.py) ---------------------------------------------------------------- * ``_infonet_sync_response_events`` now silently redacts private events (gate_message + dm_message) unless the request looks like a loopback / onion / RNS / private transport caller. If an operator accidentally exposes :8000 to the public internet, an external puller gets public events only — never ciphertext. * ``_sync_from_peer`` raises ``PeerSyncRateLimited`` for 429 (handled as 4-tuple return with retry_after_s) and ``PeerSyncHTTPError`` for other non-200 statuses (handled by ``_run_public_sync_cycle`` to honor server cooldown hints even outside the 429 path). DM relay hydration (main.py) ----------------------------- * New ``_hydrate_dm_relay_from_chain``: when accepted dm_message chain events arrive on a node, they get deposited into the local DM relay store with a deterministic sender_token_hash so re-sync of the same event is idempotent. Recipients see the ciphertext as a normal DM on their next poll and decrypt with their existing recipient key. Other surfaces -------------- * meshnode.bat / meshnode.sh now set ``MESH_INFONET_ALLOW_CLEARNET_SYNC= false`` and the participant runtime flags by default so a freshly spun-up node defaults to private-only sync. * InfonetTerminal/InfonetShell.tsx adds a gate directory renderer for the new private-gate workflow. * docker-compose.relay.yml binds the relay backend to 127.0.0.1:8000 only; Tor's hidden service forwards onion traffic into 127.0.0.1. Public clearnet :8000 stays off the network edge. Tests ----- * 7 new tests in test_private_gate_hashchain.py + test_private_dm_ hashchain.py covering: gate fork accepts ciphertext propagation, gate fork rejects plaintext, append rejects plaintext before normalize, append requires private_strong, append rejects non-sealed ciphertext shape, DM spool 2-per-recipient + 1-per-pair cap, DM hydration delivers to poll/claim. * Updated test_mesh_node_bootstrap_runtime.py covers 429 backoff via PeerSyncRateLimited 4-tuple AND PeerSyncHTTPError exception. * Updated test_s14b_public_sync_gate_filter.py + test_s9b_gate_store_ hydration.py + test_gate_write_cutover.py cover the new private redaction on public sync responses. * test_private_gate_hashchain.py + test_private_dm_hashchain.py: 10 passed locally. * Combined mesh-relevant suite (the 5 modified existing tests + 2 new): 17 passed. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
fb97042c01
commit
1d7fa5185a
@@ -87,11 +87,28 @@ def _run_gate_release_once(monkeypatch, *, transport_tier="private_strong"):
|
||||
def _patch_for_successful_post(monkeypatch, module):
|
||||
"""Apply standard monkeypatches so a gate_message post succeeds."""
|
||||
import main
|
||||
from services.mesh import mesh_hashchain
|
||||
|
||||
_setup_gate_outbox(monkeypatch)
|
||||
monkeypatch.setattr(main, "_verify_gate_message_signed_write", lambda **kw: (True, "ok", kw.get("reply_to", "")))
|
||||
monkeypatch.setattr(main, "_resolve_envelope_policy", lambda _gate_id: "envelope_disabled")
|
||||
|
||||
def _fake_private_gate_append(**kwargs):
|
||||
return {
|
||||
"event_id": f"ledger-ev-{kwargs.get('sequence', 0)}",
|
||||
"event_type": "gate_message",
|
||||
"node_id": kwargs["node_id"],
|
||||
"payload": dict(kwargs["payload"]),
|
||||
"timestamp": kwargs.get("timestamp", 0) or 123.0,
|
||||
"sequence": kwargs["sequence"],
|
||||
"signature": kwargs["signature"],
|
||||
"public_key": kwargs["public_key"],
|
||||
"public_key_algo": kwargs["public_key_algo"],
|
||||
"protocol_version": kwargs.get("protocol_version", "infonet/2"),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(mesh_hashchain.infonet, "append_private_gate_message", _fake_private_gate_append)
|
||||
|
||||
from services.mesh.mesh_reputation import gate_manager, reputation_ledger
|
||||
|
||||
monkeypatch.setattr(gate_manager, "can_enter", lambda *a, **kw: (True, "ok"))
|
||||
@@ -255,19 +272,30 @@ def test_gate_post_preserves_gate_envelope_in_store(monkeypatch):
|
||||
|
||||
|
||||
def test_gate_post_advances_sequence(monkeypatch):
|
||||
"""validate_and_set_sequence must be called to advance the counter."""
|
||||
"""append_private_gate_message must receive the gate sequence."""
|
||||
import main
|
||||
from services.mesh import mesh_hashchain
|
||||
|
||||
_patch_for_successful_post(monkeypatch, main)
|
||||
|
||||
seq_calls = []
|
||||
append_calls = []
|
||||
|
||||
def track_seq(node_id, seq, *, domain=""):
|
||||
seq_calls.append((node_id, seq, domain))
|
||||
return (True, "ok")
|
||||
def track_private_append(**kwargs):
|
||||
append_calls.append(kwargs)
|
||||
return {
|
||||
"event_id": "ev-seq",
|
||||
"event_type": "gate_message",
|
||||
"node_id": kwargs["node_id"],
|
||||
"payload": dict(kwargs["payload"]),
|
||||
"timestamp": kwargs.get("timestamp", 0) or 123.0,
|
||||
"sequence": kwargs["sequence"],
|
||||
"signature": kwargs["signature"],
|
||||
"public_key": kwargs["public_key"],
|
||||
"public_key_algo": kwargs["public_key_algo"],
|
||||
"protocol_version": kwargs.get("protocol_version", "infonet/2"),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(mesh_hashchain.infonet, "validate_and_set_sequence", track_seq)
|
||||
monkeypatch.setattr(mesh_hashchain.infonet, "append_private_gate_message", track_private_append)
|
||||
monkeypatch.setattr(
|
||||
mesh_hashchain.gate_store,
|
||||
"append",
|
||||
@@ -280,8 +308,9 @@ def test_gate_post_advances_sequence(monkeypatch):
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["queued"] is True
|
||||
assert len(seq_calls) == 1
|
||||
assert seq_calls[0] == ("!sb_test1234567890", 42, "gate_message")
|
||||
assert len(append_calls) == 1
|
||||
assert append_calls[0]["node_id"] == "!sb_test1234567890"
|
||||
assert append_calls[0]["sequence"] == 42
|
||||
|
||||
|
||||
def test_gate_post_rejects_replay_via_sequence(monkeypatch):
|
||||
@@ -290,11 +319,11 @@ def test_gate_post_rejects_replay_via_sequence(monkeypatch):
|
||||
from services.mesh import mesh_hashchain
|
||||
|
||||
_patch_for_successful_post(monkeypatch, main)
|
||||
monkeypatch.setattr(
|
||||
mesh_hashchain.infonet,
|
||||
"validate_and_set_sequence",
|
||||
lambda node_id, seq: (False, "Replay detected: sequence 1 <= last 1"),
|
||||
)
|
||||
|
||||
def reject_private_append(**_kwargs):
|
||||
raise ValueError("Replay detected: sequence 1 <= last 1")
|
||||
|
||||
monkeypatch.setattr(mesh_hashchain.infonet, "append_private_gate_message", reject_private_append)
|
||||
|
||||
gate_id = "infonet"
|
||||
body = _build_gate_message_body(gate_id, sequence=1)
|
||||
|
||||
@@ -117,3 +117,11 @@ def test_finish_solo_sync_marks_first_node_ready_without_peer_failure():
|
||||
assert finished.next_sync_due_at == 500
|
||||
assert should_run_sync(finished, now=499) is False
|
||||
assert should_run_sync(finished, now=500) is True
|
||||
|
||||
|
||||
def test_should_run_sync_recovers_stale_running_state():
|
||||
fresh = SyncWorkerState(last_sync_started_at=100, last_outcome="running")
|
||||
stale = SyncWorkerState(last_sync_started_at=100, last_outcome="running")
|
||||
|
||||
assert should_run_sync(fresh, now=399) is False
|
||||
assert should_run_sync(stale, now=400) is True
|
||||
|
||||
@@ -8,6 +8,53 @@ from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
|
||||
def test_onion_peer_requests_use_arti_socks_proxy(monkeypatch):
|
||||
import main
|
||||
from services import wormhole_supervisor
|
||||
|
||||
monkeypatch.setattr(main, "_infonet_private_transport_required", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(MESH_ARTI_ENABLED=True, MESH_ARTI_SOCKS_PORT=19050),
|
||||
)
|
||||
monkeypatch.setattr(wormhole_supervisor, "_check_arti_ready", lambda: True)
|
||||
|
||||
proxies = main._infonet_peer_requests_proxies("http://exampleabcd.onion:8000")
|
||||
|
||||
assert proxies == {
|
||||
"http": "socks5h://127.0.0.1:19050",
|
||||
"https": "socks5h://127.0.0.1:19050",
|
||||
}
|
||||
|
||||
|
||||
def test_private_peer_requests_reject_clearnet(monkeypatch):
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main, "_infonet_private_transport_required", lambda: True)
|
||||
|
||||
try:
|
||||
main._infonet_peer_requests_proxies("https://seed.example")
|
||||
except RuntimeError as exc:
|
||||
assert "private Infonet requires onion/RNS transport" in str(exc)
|
||||
else:
|
||||
raise AssertionError("clearnet peer was allowed while private transport is required")
|
||||
|
||||
|
||||
def test_local_peer_url_prefers_configured_public_peer_url(monkeypatch):
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(
|
||||
MESH_PUBLIC_PEER_URL="HTTP://LOCALPEEREXAMPLE.onion:8000/",
|
||||
),
|
||||
)
|
||||
|
||||
assert main._local_infonet_peer_url() == "http://localpeerexample.onion:8000"
|
||||
|
||||
|
||||
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
|
||||
@@ -142,6 +189,134 @@ def test_refresh_node_peer_store_suppresses_clearnet_seed_by_default(tmp_path, m
|
||||
assert store.records_for_bucket("sync") == []
|
||||
|
||||
|
||||
def test_refresh_node_peer_store_prunes_persisted_clearnet_records_in_private_mode(tmp_path, monkeypatch):
|
||||
import main
|
||||
from services.config import get_settings
|
||||
from services.mesh import mesh_peer_store as peer_store_mod
|
||||
|
||||
peer_store_path = tmp_path / "peer_store.json"
|
||||
monkeypatch.setattr(peer_store_mod, "DEFAULT_PEER_STORE_PATH", peer_store_path)
|
||||
store = peer_store_mod.PeerStore(peer_store_path)
|
||||
store.upsert(
|
||||
peer_store_mod.make_bootstrap_peer_record(
|
||||
peer_url="https://node.shadowbroker.info",
|
||||
transport="clearnet",
|
||||
role="seed",
|
||||
signer_id="shadowbroker-default",
|
||||
now=1_749_999_900,
|
||||
)
|
||||
)
|
||||
store.upsert(
|
||||
peer_store_mod.make_sync_peer_record(
|
||||
peer_url="https://node.shadowbroker.info",
|
||||
transport="clearnet",
|
||||
role="seed",
|
||||
source="bundle",
|
||||
now=1_749_999_900,
|
||||
)
|
||||
)
|
||||
store.upsert(
|
||||
peer_store_mod.make_push_peer_record(
|
||||
peer_url="https://node.shadowbroker.info",
|
||||
transport="clearnet",
|
||||
role="relay",
|
||||
now=1_749_999_900,
|
||||
)
|
||||
)
|
||||
store.save()
|
||||
|
||||
onion_seed = "http://gqpbunqbgtkcqilvclm3xrkt3zowjyl3s62kkktvojgvxzizamvbrqid.onion:8000"
|
||||
monkeypatch.setenv("MESH_RELAY_PEERS", "")
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SEED_PEERS", onion_seed)
|
||||
monkeypatch.setenv("MESH_DEFAULT_SYNC_PEERS", "")
|
||||
monkeypatch.delenv("MESH_INFONET_ALLOW_CLEARNET_SYNC", raising=False)
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", "")
|
||||
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["private_transport_required"] is True
|
||||
assert snapshot["pruned_clearnet_peer_count"] == 3
|
||||
assert [record.peer_url for record in store.records()] == [onion_seed, onion_seed]
|
||||
assert {record.bucket for record in store.records()} == {"bootstrap", "sync"}
|
||||
assert all(record.transport == "onion" for record in store.records())
|
||||
|
||||
|
||||
def test_infonet_peer_url_filter_excludes_clearnet_in_private_mode(monkeypatch):
|
||||
import main
|
||||
from services.config import get_settings
|
||||
|
||||
monkeypatch.delenv("MESH_INFONET_ALLOW_CLEARNET_SYNC", raising=False)
|
||||
get_settings.cache_clear()
|
||||
|
||||
try:
|
||||
assert main._filter_infonet_peer_urls(
|
||||
[
|
||||
"https://node.shadowbroker.info",
|
||||
"http://gqpbunqbgtkcqilvclm3xrkt3zowjyl3s62kkktvojgvxzizamvbrqid.onion:8000",
|
||||
]
|
||||
) == ["http://gqpbunqbgtkcqilvclm3xrkt3zowjyl3s62kkktvojgvxzizamvbrqid.onion:8000"]
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_public_sync_cycle_backs_off_on_429_retry_after(tmp_path, monkeypatch):
|
||||
import time
|
||||
|
||||
import main
|
||||
from services.config import get_settings
|
||||
from services.mesh import mesh_peer_store as peer_store_mod
|
||||
|
||||
peer_store_path = tmp_path / "peer_store.json"
|
||||
monkeypatch.setattr(peer_store_mod, "DEFAULT_PEER_STORE_PATH", peer_store_path)
|
||||
onion_seed = "http://gqpbunqbgtkcqilvclm3xrkt3zowjyl3s62kkktvojgvxzizamvbrqid.onion:8000"
|
||||
store = peer_store_mod.PeerStore(peer_store_path)
|
||||
store.upsert(
|
||||
peer_store_mod.make_sync_peer_record(
|
||||
peer_url=onion_seed,
|
||||
transport="onion",
|
||||
role="seed",
|
||||
source="bundle",
|
||||
now=1_750_000_000,
|
||||
)
|
||||
)
|
||||
store.save()
|
||||
|
||||
monkeypatch.delenv("MESH_INFONET_ALLOW_CLEARNET_SYNC", raising=False)
|
||||
monkeypatch.setenv("MESH_SYNC_FAILURE_BACKOFF_S", "60")
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SEED_FAILURE_COOLDOWN_S", "15")
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setattr(main, "_participant_node_enabled", lambda: True)
|
||||
monkeypatch.setattr(main, "_ensure_infonet_private_transport_ready", lambda reason="": True)
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_sync_from_peer",
|
||||
lambda peer_url: (_ for _ in ()).throw(
|
||||
main.PeerSyncHTTPError(429, "rate limited", retry_after_s=180)
|
||||
),
|
||||
)
|
||||
main.set_sync_state(main.SyncWorkerState())
|
||||
|
||||
try:
|
||||
before = int(time.time())
|
||||
state = main._run_public_sync_cycle()
|
||||
store = peer_store_mod.PeerStore(peer_store_path)
|
||||
store.load()
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
main.set_sync_state(main.SyncWorkerState())
|
||||
|
||||
record = store.records_for_bucket("sync")[0]
|
||||
assert state.last_error == "HTTP 429: rate limited"
|
||||
assert state.next_sync_due_at >= before + 180
|
||||
assert record.cooldown_until >= before + 180
|
||||
|
||||
|
||||
def test_verify_peer_push_hmac_requires_allowlisted_peer(monkeypatch):
|
||||
import hashlib
|
||||
import hmac
|
||||
@@ -225,3 +400,29 @@ def test_public_sync_cycle_allows_first_node_without_peers(tmp_path, monkeypatch
|
||||
assert result.last_error == ""
|
||||
assert result.last_peer_url == ""
|
||||
assert result.consecutive_failures == 0
|
||||
|
||||
|
||||
def test_headless_mesh_node_runtime_is_explicit(monkeypatch):
|
||||
import main
|
||||
|
||||
monkeypatch.setattr(main, "_MESH_ONLY", True)
|
||||
monkeypatch.setattr(main, "_HEADLESS_MESH_NODE_RUNTIME", False)
|
||||
assert main._infonet_node_runtime_requested() is False
|
||||
|
||||
monkeypatch.setattr(main, "_HEADLESS_MESH_NODE_RUNTIME", True)
|
||||
assert main._infonet_node_runtime_requested() is True
|
||||
|
||||
|
||||
def test_meshnode_scripts_enable_private_hashchain_runtime():
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[3]
|
||||
bat = (root / "meshnode.bat").read_text(encoding="utf-8")
|
||||
sh = (root / "meshnode.sh").read_text(encoding="utf-8")
|
||||
|
||||
for script in (bat, sh):
|
||||
assert "SHADOWBROKER_MESH_NODE_RUNTIME=true" in script
|
||||
assert "MESH_INFONET_ALLOW_CLEARNET_SYNC=false" in script
|
||||
assert "MESH_ARTI_ENABLED=true" in script
|
||||
assert "MESH_DM_HASHCHAIN_SPOOL_LIMIT=2" in script
|
||||
assert "gqpbunqbgtkcqilvclm3xrkt3zowjyl3s62kkktvojgvxzizamvbrqid.onion:8000" in script
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import base64
|
||||
import time
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
from services.config import get_settings
|
||||
from services.mesh import mesh_crypto, mesh_dm_relay, mesh_hashchain, mesh_protocol, mesh_secure_storage
|
||||
|
||||
|
||||
def _keypair():
|
||||
private_key = ed25519.Ed25519PrivateKey.generate()
|
||||
public_raw = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
public_key = base64.b64encode(public_raw).decode("utf-8")
|
||||
node_id = mesh_crypto.derive_node_id(public_key)
|
||||
return private_key, public_key, node_id
|
||||
|
||||
|
||||
def _payload(recipient_id: str = "recipient-a", msg_id: str = "dm-1") -> dict:
|
||||
return mesh_protocol.normalize_payload(
|
||||
"dm_message",
|
||||
{
|
||||
"recipient_id": recipient_id,
|
||||
"delivery_class": "request",
|
||||
"recipient_token": "",
|
||||
"ciphertext": base64.b64encode(f"cipher-{msg_id}".encode("utf-8")).decode("ascii"),
|
||||
"msg_id": msg_id,
|
||||
"timestamp": int(time.time()),
|
||||
"format": "mls1",
|
||||
"transport_lock": "private_strong",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _signature(private_key, node_id: str, sequence: int, payload: dict) -> str:
|
||||
signature_payload = mesh_crypto.build_signature_payload(
|
||||
event_type="dm_message",
|
||||
node_id=node_id,
|
||||
sequence=sequence,
|
||||
payload=payload,
|
||||
)
|
||||
return private_key.sign(signature_payload.encode("utf-8")).hex()
|
||||
|
||||
|
||||
def _fresh_infonet(tmp_path, monkeypatch) -> mesh_hashchain.Infonet:
|
||||
monkeypatch.setattr(mesh_hashchain, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_hashchain, "CHAIN_FILE", tmp_path / "infonet.json")
|
||||
monkeypatch.setattr(mesh_hashchain, "WAL_FILE", tmp_path / "infonet.wal")
|
||||
return mesh_hashchain.Infonet()
|
||||
|
||||
|
||||
def _fresh_relay(tmp_path, monkeypatch) -> mesh_dm_relay.DMRelay:
|
||||
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_private_dm_hashchain_spools_two_ciphertexts_per_recipient_from_distinct_senders(tmp_path, monkeypatch):
|
||||
inf = _fresh_infonet(tmp_path, monkeypatch)
|
||||
senders = [_keypair(), _keypair()]
|
||||
|
||||
for idx, (private_key, public_key, node_id) in enumerate(senders, start=1):
|
||||
payload = _payload(msg_id=f"dm-{idx}")
|
||||
event = inf.append_private_dm_message(
|
||||
node_id=node_id,
|
||||
payload=payload,
|
||||
signature=_signature(private_key, node_id, 1, payload),
|
||||
sequence=1,
|
||||
public_key=public_key,
|
||||
public_key_algo="Ed25519",
|
||||
protocol_version=mesh_protocol.PROTOCOL_VERSION,
|
||||
timestamp=float(payload["timestamp"]),
|
||||
)
|
||||
assert event["event_type"] == "dm_message"
|
||||
|
||||
private_key, public_key, node_id = _keypair()
|
||||
third = _payload(msg_id="dm-3")
|
||||
try:
|
||||
inf.append_private_dm_message(
|
||||
node_id=node_id,
|
||||
payload=third,
|
||||
signature=_signature(private_key, node_id, 1, third),
|
||||
sequence=1,
|
||||
public_key=public_key,
|
||||
public_key_algo="Ed25519",
|
||||
protocol_version=mesh_protocol.PROTOCOL_VERSION,
|
||||
timestamp=float(third["timestamp"]),
|
||||
)
|
||||
except ValueError as exc:
|
||||
assert "spool full" in str(exc)
|
||||
else:
|
||||
raise AssertionError("third DM spool event was accepted")
|
||||
|
||||
for _private_key, _public_key, sender_node_id in senders:
|
||||
assert inf.sequence_domains[f"{sender_node_id}|dm_message"] == 1
|
||||
assert inf.validate_chain(verify_signatures=True)[0] is True
|
||||
|
||||
|
||||
def test_private_dm_hashchain_limits_one_active_spool_per_sender_recipient_pair(tmp_path, monkeypatch):
|
||||
inf = _fresh_infonet(tmp_path, monkeypatch)
|
||||
private_key, public_key, node_id = _keypair()
|
||||
|
||||
first = _payload(msg_id="dm-1")
|
||||
inf.append_private_dm_message(
|
||||
node_id=node_id,
|
||||
payload=first,
|
||||
signature=_signature(private_key, node_id, 1, first),
|
||||
sequence=1,
|
||||
public_key=public_key,
|
||||
public_key_algo="Ed25519",
|
||||
protocol_version=mesh_protocol.PROTOCOL_VERSION,
|
||||
timestamp=float(first["timestamp"]),
|
||||
)
|
||||
|
||||
second = _payload(msg_id="dm-2")
|
||||
try:
|
||||
inf.append_private_dm_message(
|
||||
node_id=node_id,
|
||||
payload=second,
|
||||
signature=_signature(private_key, node_id, 2, second),
|
||||
sequence=2,
|
||||
public_key=public_key,
|
||||
public_key_algo="Ed25519",
|
||||
protocol_version=mesh_protocol.PROTOCOL_VERSION,
|
||||
timestamp=float(second["timestamp"]),
|
||||
)
|
||||
except ValueError as exc:
|
||||
assert "sender spool full" in str(exc)
|
||||
else:
|
||||
raise AssertionError("second DM from same sender to same recipient was accepted")
|
||||
|
||||
|
||||
def test_private_dm_hashchain_rejects_plaintext(tmp_path, monkeypatch):
|
||||
inf = _fresh_infonet(tmp_path, monkeypatch)
|
||||
private_key, public_key, node_id = _keypair()
|
||||
payload = _payload()
|
||||
payload["message"] = "plaintext"
|
||||
|
||||
try:
|
||||
inf.append_private_dm_message(
|
||||
node_id=node_id,
|
||||
payload=payload,
|
||||
signature=_signature(private_key, node_id, 1, _payload()),
|
||||
sequence=1,
|
||||
public_key=public_key,
|
||||
public_key_algo="Ed25519",
|
||||
protocol_version=mesh_protocol.PROTOCOL_VERSION,
|
||||
)
|
||||
except ValueError as exc:
|
||||
assert "plaintext" in str(exc)
|
||||
else:
|
||||
raise AssertionError("private DM append accepted plaintext")
|
||||
|
||||
|
||||
def test_private_dm_hashchain_rejects_non_sealed_ciphertext_shape(tmp_path, monkeypatch):
|
||||
inf = _fresh_infonet(tmp_path, monkeypatch)
|
||||
private_key, public_key, node_id = _keypair()
|
||||
payload = _payload()
|
||||
payload["ciphertext"] = "not sealed plaintext"
|
||||
|
||||
try:
|
||||
inf.append_private_dm_message(
|
||||
node_id=node_id,
|
||||
payload=payload,
|
||||
signature=_signature(private_key, node_id, 1, payload),
|
||||
sequence=1,
|
||||
public_key=public_key,
|
||||
public_key_algo="Ed25519",
|
||||
protocol_version=mesh_protocol.PROTOCOL_VERSION,
|
||||
)
|
||||
except ValueError as exc:
|
||||
assert "sealed bytes" in str(exc)
|
||||
else:
|
||||
raise AssertionError("private DM append accepted non-base64 ciphertext")
|
||||
|
||||
|
||||
def test_hydrate_dm_relay_from_chain_delivers_to_poll_claim(tmp_path, monkeypatch):
|
||||
inf = _fresh_infonet(tmp_path / "chain", monkeypatch)
|
||||
relay = _fresh_relay(tmp_path / "relay", monkeypatch)
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", inf)
|
||||
monkeypatch.setattr(mesh_dm_relay, "dm_relay", relay)
|
||||
|
||||
private_key, public_key, node_id = _keypair()
|
||||
payload = _payload(recipient_id="recipient-a", msg_id="dm-chain-1")
|
||||
event = inf.append_private_dm_message(
|
||||
node_id=node_id,
|
||||
payload=payload,
|
||||
signature=_signature(private_key, node_id, 1, payload),
|
||||
sequence=1,
|
||||
public_key=public_key,
|
||||
public_key_algo="Ed25519",
|
||||
protocol_version=mesh_protocol.PROTOCOL_VERSION,
|
||||
timestamp=float(payload["timestamp"]),
|
||||
)
|
||||
|
||||
from main import _hydrate_dm_relay_from_chain
|
||||
|
||||
assert _hydrate_dm_relay_from_chain([event]) == 1
|
||||
messages, more = relay.collect_claims(
|
||||
"recipient-a",
|
||||
[{"type": "requests", "token": "recipient-request-token"}],
|
||||
limit=8,
|
||||
)
|
||||
|
||||
assert more is False
|
||||
assert [message["msg_id"] for message in messages] == ["dm-chain-1"]
|
||||
assert messages[0]["ciphertext"] == payload["ciphertext"]
|
||||
@@ -0,0 +1,269 @@
|
||||
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 _keypair():
|
||||
private_key = ed25519.Ed25519PrivateKey.generate()
|
||||
public_raw = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
public_key = base64.b64encode(public_raw).decode("utf-8")
|
||||
node_id = mesh_crypto.derive_node_id(public_key)
|
||||
return private_key, public_key, node_id
|
||||
|
||||
|
||||
def _sign(private_key, *, event_type: str, node_id: str, sequence: int, payload: dict) -> str:
|
||||
signature_payload = mesh_crypto.build_signature_payload(
|
||||
event_type=event_type,
|
||||
node_id=node_id,
|
||||
sequence=sequence,
|
||||
payload=payload,
|
||||
)
|
||||
return private_key.sign(signature_payload.encode("utf-8")).hex()
|
||||
|
||||
|
||||
def _message_payload(text: str) -> dict:
|
||||
return mesh_protocol.normalize_payload(
|
||||
"message",
|
||||
{
|
||||
"message": text,
|
||||
"destination": "broadcast",
|
||||
"channel": "LongFast",
|
||||
"priority": "normal",
|
||||
"ephemeral": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _gate_payload(gate_id: str = "ops-gate", *, epoch: int = 2, plaintext: bool = False) -> dict:
|
||||
payload = {
|
||||
"gate": gate_id,
|
||||
"ciphertext": base64.b64encode(b"encrypted-gate-ciphertext").decode("ascii"),
|
||||
"nonce": base64.b64encode(b"nonce-value-1234").decode("ascii"),
|
||||
"sender_ref": "sender-ref-1",
|
||||
"format": "mls1",
|
||||
"transport_lock": "private_strong",
|
||||
}
|
||||
if epoch > 0:
|
||||
payload["epoch"] = epoch
|
||||
if plaintext:
|
||||
payload["message"] = "this must never land on the chain"
|
||||
return mesh_protocol.normalize_payload("gate_message", payload) if not plaintext else payload
|
||||
|
||||
|
||||
def _gate_event(
|
||||
private_key,
|
||||
public_key: str,
|
||||
node_id: str,
|
||||
*,
|
||||
sequence: int,
|
||||
prev_hash: str,
|
||||
payload: dict,
|
||||
signature_payload: dict | None = None,
|
||||
) -> dict:
|
||||
signature = _sign(
|
||||
private_key,
|
||||
event_type="gate_message",
|
||||
node_id=node_id,
|
||||
sequence=sequence,
|
||||
payload=signature_payload or payload,
|
||||
)
|
||||
return mesh_hashchain.ChainEvent(
|
||||
prev_hash=prev_hash,
|
||||
event_type="gate_message",
|
||||
node_id=node_id,
|
||||
payload=payload,
|
||||
timestamp=1234.0 + sequence,
|
||||
sequence=sequence,
|
||||
signature=signature,
|
||||
public_key=public_key,
|
||||
public_key_algo="Ed25519",
|
||||
protocol_version=mesh_protocol.PROTOCOL_VERSION,
|
||||
network_id=mesh_protocol.NETWORK_ID,
|
||||
).to_dict()
|
||||
|
||||
|
||||
def _fresh_infonet(tmp_path, monkeypatch) -> mesh_hashchain.Infonet:
|
||||
monkeypatch.setattr(mesh_hashchain, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_hashchain, "CHAIN_FILE", tmp_path / "infonet.json")
|
||||
monkeypatch.setattr(mesh_hashchain, "WAL_FILE", tmp_path / "infonet.wal")
|
||||
return mesh_hashchain.Infonet()
|
||||
|
||||
|
||||
def test_private_gate_fork_uses_gate_sequence_domain_and_signature_variants(tmp_path, monkeypatch):
|
||||
inf = _fresh_infonet(tmp_path, monkeypatch)
|
||||
private_key, public_key, node_id = _keypair()
|
||||
|
||||
public_payload = _message_payload("public prefix")
|
||||
public_event = inf.append(
|
||||
event_type="message",
|
||||
node_id=node_id,
|
||||
payload=public_payload,
|
||||
sequence=1,
|
||||
signature=_sign(
|
||||
private_key,
|
||||
event_type="message",
|
||||
node_id=node_id,
|
||||
sequence=1,
|
||||
payload=public_payload,
|
||||
),
|
||||
public_key=public_key,
|
||||
public_key_algo="Ed25519",
|
||||
protocol_version=mesh_protocol.PROTOCOL_VERSION,
|
||||
)
|
||||
|
||||
gate_payload = _gate_payload(epoch=3)
|
||||
signature_payload = dict(gate_payload)
|
||||
signature_payload.pop("epoch", None)
|
||||
gate_event = _gate_event(
|
||||
private_key,
|
||||
public_key,
|
||||
node_id,
|
||||
sequence=1,
|
||||
prev_hash=public_event["event_id"],
|
||||
payload=gate_payload,
|
||||
signature_payload=signature_payload,
|
||||
)
|
||||
|
||||
ok, reason = inf.apply_fork([gate_event], gate_event["event_id"], proof_count=2, quorum=2)
|
||||
|
||||
assert ok is True, reason
|
||||
assert inf.events[-1]["event_type"] == "gate_message"
|
||||
assert inf.node_sequences[node_id] == 1
|
||||
assert inf.sequence_domains[f"{node_id}|gate_message"] == 1
|
||||
assert inf.validate_chain(verify_signatures=True)[0] is True
|
||||
|
||||
|
||||
def test_private_gate_fork_rejects_plaintext_payload(tmp_path, monkeypatch):
|
||||
inf = _fresh_infonet(tmp_path, monkeypatch)
|
||||
private_key, public_key, node_id = _keypair()
|
||||
|
||||
public_payload = _message_payload("public prefix")
|
||||
public_event = inf.append(
|
||||
event_type="message",
|
||||
node_id=node_id,
|
||||
payload=public_payload,
|
||||
sequence=1,
|
||||
signature=_sign(
|
||||
private_key,
|
||||
event_type="message",
|
||||
node_id=node_id,
|
||||
sequence=1,
|
||||
payload=public_payload,
|
||||
),
|
||||
public_key=public_key,
|
||||
public_key_algo="Ed25519",
|
||||
protocol_version=mesh_protocol.PROTOCOL_VERSION,
|
||||
)
|
||||
|
||||
plaintext_payload = _gate_payload(plaintext=True)
|
||||
gate_event = _gate_event(
|
||||
private_key,
|
||||
public_key,
|
||||
node_id,
|
||||
sequence=1,
|
||||
prev_hash=public_event["event_id"],
|
||||
payload=plaintext_payload,
|
||||
)
|
||||
|
||||
ok, reason = inf.apply_fork([gate_event], gate_event["event_id"], proof_count=2, quorum=2)
|
||||
|
||||
assert ok is False
|
||||
assert "normalized" in reason or "plaintext" in reason
|
||||
assert len(inf.events) == 1
|
||||
assert "gate_message" not in inf.get_info()["event_types"]
|
||||
|
||||
|
||||
def test_append_private_gate_message_rejects_plaintext_before_normalizing(tmp_path, monkeypatch):
|
||||
inf = _fresh_infonet(tmp_path, monkeypatch)
|
||||
private_key, public_key, node_id = _keypair()
|
||||
payload = _gate_payload()
|
||||
payload["message"] = "plaintext should not be silently dropped"
|
||||
|
||||
try:
|
||||
inf.append_private_gate_message(
|
||||
node_id=node_id,
|
||||
payload=payload,
|
||||
sequence=1,
|
||||
signature=_sign(
|
||||
private_key,
|
||||
event_type="gate_message",
|
||||
node_id=node_id,
|
||||
sequence=1,
|
||||
payload=_gate_payload(),
|
||||
),
|
||||
public_key=public_key,
|
||||
public_key_algo="Ed25519",
|
||||
protocol_version=mesh_protocol.PROTOCOL_VERSION,
|
||||
)
|
||||
except ValueError as exc:
|
||||
assert "plaintext" in str(exc)
|
||||
else:
|
||||
raise AssertionError("private gate append accepted plaintext")
|
||||
|
||||
assert inf.events == []
|
||||
|
||||
|
||||
def test_append_private_gate_message_requires_private_strong_transport_lock(tmp_path, monkeypatch):
|
||||
inf = _fresh_infonet(tmp_path, monkeypatch)
|
||||
private_key, public_key, node_id = _keypair()
|
||||
payload = _gate_payload()
|
||||
payload.pop("transport_lock", None)
|
||||
|
||||
try:
|
||||
inf.append_private_gate_message(
|
||||
node_id=node_id,
|
||||
payload=payload,
|
||||
sequence=1,
|
||||
signature=_sign(
|
||||
private_key,
|
||||
event_type="gate_message",
|
||||
node_id=node_id,
|
||||
sequence=1,
|
||||
payload=_gate_payload(),
|
||||
),
|
||||
public_key=public_key,
|
||||
public_key_algo="Ed25519",
|
||||
protocol_version=mesh_protocol.PROTOCOL_VERSION,
|
||||
)
|
||||
except ValueError as exc:
|
||||
assert "private_strong" in str(exc)
|
||||
else:
|
||||
raise AssertionError("private gate append accepted missing transport_lock")
|
||||
|
||||
assert inf.events == []
|
||||
|
||||
|
||||
def test_append_private_gate_message_rejects_non_sealed_ciphertext_shape(tmp_path, monkeypatch):
|
||||
inf = _fresh_infonet(tmp_path, monkeypatch)
|
||||
private_key, public_key, node_id = _keypair()
|
||||
payload = _gate_payload()
|
||||
payload["ciphertext"] = "not sealed plaintext"
|
||||
|
||||
try:
|
||||
inf.append_private_gate_message(
|
||||
node_id=node_id,
|
||||
payload=payload,
|
||||
sequence=1,
|
||||
signature=_sign(
|
||||
private_key,
|
||||
event_type="gate_message",
|
||||
node_id=node_id,
|
||||
sequence=1,
|
||||
payload=payload,
|
||||
),
|
||||
public_key=public_key,
|
||||
public_key_algo="Ed25519",
|
||||
protocol_version=mesh_protocol.PROTOCOL_VERSION,
|
||||
)
|
||||
except ValueError as exc:
|
||||
assert "sealed bytes" in str(exc)
|
||||
else:
|
||||
raise AssertionError("private gate append accepted non-base64 ciphertext")
|
||||
|
||||
assert inf.events == []
|
||||
@@ -1,14 +1,12 @@
|
||||
"""S14B Public Sync Gate Event Filter.
|
||||
"""S14B private sync gate event policy.
|
||||
|
||||
Tests:
|
||||
- GET /api/mesh/infonet/sync excludes gate_message when local infonet contains legacy gate_message plus public events
|
||||
- POST /api/mesh/infonet/sync excludes gate_message under the same condition
|
||||
- Both main app and router-served paths are covered
|
||||
- Non-gate public redactions still hold (vote gate label stripped, key_rotate identity stripped)
|
||||
- Do not overclaim that gate_message is removed from historical infonet storage or ingest
|
||||
Private Infonet sync carries encrypted gate_message ledger events. If a node
|
||||
is configured to allow clearnet-compatible sync, those gate events are filtered
|
||||
out of the sync response.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
|
||||
from starlette.requests import Request
|
||||
@@ -17,9 +15,6 @@ import main
|
||||
from services.mesh import mesh_hashchain
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _message_event() -> dict:
|
||||
return {
|
||||
"event_id": "msg-1",
|
||||
@@ -83,6 +78,7 @@ def _gate_message_event() -> dict:
|
||||
"nonce": "nonce-1",
|
||||
"sender_ref": "sender-ref-1",
|
||||
"format": "mls1",
|
||||
"transport_lock": "private_strong",
|
||||
},
|
||||
"timestamp": 103.0,
|
||||
"sequence": 4,
|
||||
@@ -93,9 +89,31 @@ def _gate_message_event() -> dict:
|
||||
}
|
||||
|
||||
|
||||
class _FakeInfonet:
|
||||
"""Minimal fake infonet with a gate_message among public events."""
|
||||
def _dm_message_event() -> dict:
|
||||
return {
|
||||
"event_id": "dm-1",
|
||||
"event_type": "dm_message",
|
||||
"node_id": "!node-5",
|
||||
"payload": {
|
||||
"recipient_id": "recipient-a",
|
||||
"delivery_class": "request",
|
||||
"recipient_token": "",
|
||||
"ciphertext": base64.b64encode(b"sealed-dm-ciphertext").decode("ascii"),
|
||||
"msg_id": "dm-1",
|
||||
"timestamp": 104,
|
||||
"format": "mls1",
|
||||
"transport_lock": "private_strong",
|
||||
},
|
||||
"timestamp": 104.0,
|
||||
"sequence": 5,
|
||||
"signature": "sig",
|
||||
"public_key": "pub",
|
||||
"public_key_algo": "Ed25519",
|
||||
"protocol_version": "infonet/2",
|
||||
}
|
||||
|
||||
|
||||
class _FakeInfonet:
|
||||
def __init__(self):
|
||||
self.head_hash = "head-1"
|
||||
self.events = [
|
||||
@@ -113,12 +131,10 @@ class _FakeInfonet:
|
||||
return int(getattr(limit, "default", 100) or 100)
|
||||
|
||||
def get_events_after(self, after_hash: str, limit=100):
|
||||
resolved = self._limit_value(limit)
|
||||
return [dict(e) for e in self.events[:resolved]]
|
||||
return [dict(e) for e in self.events[: self._limit_value(limit)]]
|
||||
|
||||
def get_events_after_locator(self, locator: list[str], limit=100):
|
||||
resolved = self._limit_value(limit)
|
||||
return self.head_hash, 0, [dict(e) for e in self.events[:resolved]]
|
||||
return self.head_hash, 0, [dict(e) for e in self.events[: self._limit_value(limit)]]
|
||||
|
||||
def get_merkle_proofs(self, start_index: int, count: int):
|
||||
return {"root": "merkle-root", "total": len(self.events), "start": start_index, "proofs": []}
|
||||
@@ -127,7 +143,7 @@ class _FakeInfonet:
|
||||
return "merkle-root"
|
||||
|
||||
|
||||
def _json_request(path: str, body: dict) -> Request:
|
||||
def _json_request(path: str, body: dict, *, client_host: str = "127.0.0.1", headers: dict[str, str] | None = None) -> Request:
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
sent = {"value": False}
|
||||
|
||||
@@ -137,11 +153,14 @@ def _json_request(path: str, body: dict) -> Request:
|
||||
sent["value"] = True
|
||||
return {"type": "http.request", "body": payload, "more_body": False}
|
||||
|
||||
raw_headers = [(b"content-type", b"application/json")]
|
||||
for key, value in dict(headers or {}).items():
|
||||
raw_headers.append((key.lower().encode("ascii"), str(value).encode("ascii")))
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"client": ("test", 12345),
|
||||
"headers": raw_headers,
|
||||
"client": (client_host, 12345),
|
||||
"method": "POST",
|
||||
"path": path,
|
||||
},
|
||||
@@ -149,20 +168,15 @@ def _json_request(path: str, body: dict) -> Request:
|
||||
)
|
||||
|
||||
|
||||
def _get_request(path: str) -> Request:
|
||||
sent = {"value": False}
|
||||
|
||||
def _get_request(path: str, *, client_host: str = "127.0.0.1", headers: dict[str, str] | None = None) -> Request:
|
||||
async def receive():
|
||||
if sent["value"]:
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
sent["value"] = True
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [],
|
||||
"client": ("test", 12345),
|
||||
"headers": [(key.lower().encode("ascii"), str(value).encode("ascii")) for key, value in dict(headers or {}).items()],
|
||||
"client": (client_host, 12345),
|
||||
"method": "GET",
|
||||
"path": path,
|
||||
},
|
||||
@@ -170,120 +184,166 @@ def _get_request(path: str) -> Request:
|
||||
)
|
||||
|
||||
|
||||
# ── GET sync excludes gate_message (main app) ──────────────────────────
|
||||
def _force_private_sync(monkeypatch):
|
||||
monkeypatch.setattr(main, "_infonet_private_transport_required", lambda: True)
|
||||
monkeypatch.setattr(main, "_request_appears_private_infonet_transport", lambda request: True)
|
||||
|
||||
|
||||
def test_get_sync_excludes_gate_message(client, monkeypatch):
|
||||
"""GET /api/mesh/infonet/sync must not return gate_message events."""
|
||||
def _force_private_policy_only(monkeypatch):
|
||||
monkeypatch.setattr(main, "_infonet_private_transport_required", lambda: True)
|
||||
|
||||
|
||||
def _force_clearnet_sync(monkeypatch):
|
||||
monkeypatch.setattr(main, "_infonet_private_transport_required", lambda: False)
|
||||
|
||||
|
||||
def _event_types(events: list[dict]) -> list[str]:
|
||||
return [str(e.get("event_type", "")) for e in events]
|
||||
|
||||
|
||||
def test_private_sync_redacts_private_events_from_exposed_clearnet_request(monkeypatch):
|
||||
_force_private_policy_only(monkeypatch)
|
||||
request = _get_request("/api/mesh/infonet/sync", client_host="203.0.113.10")
|
||||
|
||||
events = main._infonet_sync_response_events(
|
||||
[_message_event(), _gate_message_event(), _dm_message_event()],
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert _event_types(events) == ["message"]
|
||||
|
||||
|
||||
def test_private_sync_includes_private_events_for_loopback_request(monkeypatch):
|
||||
_force_private_policy_only(monkeypatch)
|
||||
request = _get_request("/api/mesh/infonet/sync", client_host="127.0.0.1")
|
||||
|
||||
events = main._infonet_sync_response_events(
|
||||
[_message_event(), _gate_message_event(), _dm_message_event()],
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert _event_types(events) == ["message", "gate_message", "dm_message"]
|
||||
|
||||
|
||||
def test_private_sync_redacts_private_events_when_forwarded_for_is_clearnet(monkeypatch):
|
||||
_force_private_policy_only(monkeypatch)
|
||||
request = _get_request(
|
||||
"/api/mesh/infonet/sync",
|
||||
client_host="127.0.0.1",
|
||||
headers={"x-forwarded-for": "198.51.100.44"},
|
||||
)
|
||||
|
||||
events = main._infonet_sync_response_events(
|
||||
[_message_event(), _gate_message_event(), _dm_message_event()],
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert _event_types(events) == ["message"]
|
||||
|
||||
|
||||
def test_get_sync_includes_gate_message_on_private_transport(client, monkeypatch):
|
||||
_force_private_sync(monkeypatch)
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", _FakeInfonet(), raising=False)
|
||||
resp = client.get("/api/mesh/infonet/sync")
|
||||
data = resp.json()
|
||||
event_types = [e["event_type"] for e in data["events"]]
|
||||
assert "gate_message" not in event_types
|
||||
assert "message" in event_types
|
||||
assert "vote" in event_types
|
||||
assert "key_rotate" in event_types
|
||||
|
||||
data = client.get("/api/mesh/infonet/sync").json()
|
||||
|
||||
assert "gate_message" in _event_types(data["events"])
|
||||
assert data["count"] == 4
|
||||
|
||||
|
||||
def test_get_sync_count_excludes_gate_message(client, monkeypatch):
|
||||
"""GET sync count field must reflect filtered events (gate_message excluded)."""
|
||||
def test_post_sync_includes_gate_message_on_private_transport(monkeypatch):
|
||||
_force_private_sync(monkeypatch)
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", _FakeInfonet(), raising=False)
|
||||
resp = client.get("/api/mesh/infonet/sync")
|
||||
data = resp.json()
|
||||
assert data["count"] == 3 # message, vote, key_rotate — not gate_message
|
||||
|
||||
|
||||
# ── POST sync excludes gate_message (main app) ─────────────────────────
|
||||
|
||||
|
||||
def test_post_sync_excludes_gate_message(monkeypatch):
|
||||
"""POST /api/mesh/infonet/sync must not return gate_message events."""
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", _FakeInfonet(), raising=False)
|
||||
result = asyncio.run(
|
||||
main.infonet_sync_post(
|
||||
_json_request("/api/mesh/infonet/sync", {"locator": ["head-1"]})
|
||||
)
|
||||
)
|
||||
event_types = [e["event_type"] for e in result["events"]]
|
||||
assert "gate_message" not in event_types
|
||||
assert "message" in event_types
|
||||
assert "vote" in event_types
|
||||
assert "key_rotate" in event_types
|
||||
|
||||
assert "gate_message" in _event_types(result["events"])
|
||||
assert result["count"] == 4
|
||||
|
||||
|
||||
def test_post_sync_count_excludes_gate_message(monkeypatch):
|
||||
"""POST sync count field must reflect filtered events."""
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", _FakeInfonet(), raising=False)
|
||||
result = asyncio.run(
|
||||
main.infonet_sync_post(
|
||||
_json_request("/api/mesh/infonet/sync", {"locator": ["head-1"]})
|
||||
)
|
||||
)
|
||||
assert result["count"] == 3
|
||||
|
||||
|
||||
# ── Router-served paths ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_router_get_sync_excludes_gate_message(monkeypatch):
|
||||
"""Router GET /api/mesh/infonet/sync must not return gate_message."""
|
||||
def test_router_get_sync_includes_gate_message_on_private_transport(monkeypatch):
|
||||
from routers.mesh_public import infonet_sync
|
||||
|
||||
_force_private_sync(monkeypatch)
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", _FakeInfonet(), raising=False)
|
||||
|
||||
result = asyncio.run(infonet_sync(_get_request("/api/mesh/infonet/sync")))
|
||||
event_types = [e["event_type"] for e in result["events"]]
|
||||
assert "gate_message" not in event_types
|
||||
assert "message" in event_types
|
||||
assert data_count_matches(result)
|
||||
|
||||
assert "gate_message" in _event_types(result["events"])
|
||||
assert result["count"] == len(result["events"])
|
||||
|
||||
|
||||
def test_router_post_sync_excludes_gate_message(monkeypatch):
|
||||
"""Router POST /api/mesh/infonet/sync must not return gate_message."""
|
||||
def test_router_post_sync_includes_gate_message_on_private_transport(monkeypatch):
|
||||
from routers.mesh_public import infonet_sync_post
|
||||
|
||||
_force_private_sync(monkeypatch)
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", _FakeInfonet(), raising=False)
|
||||
|
||||
result = asyncio.run(
|
||||
infonet_sync_post(
|
||||
_json_request("/api/mesh/infonet/sync", {"locator": ["head-1"]})
|
||||
)
|
||||
)
|
||||
event_types = [e["event_type"] for e in result["events"]]
|
||||
assert "gate_message" not in event_types
|
||||
assert "message" in event_types
|
||||
assert data_count_matches(result)
|
||||
|
||||
assert "gate_message" in _event_types(result["events"])
|
||||
assert result["count"] == len(result["events"])
|
||||
|
||||
|
||||
def data_count_matches(result: dict) -> bool:
|
||||
return result["count"] == len(result["events"])
|
||||
def test_get_sync_excludes_gate_message_when_clearnet_sync_allowed(client, monkeypatch):
|
||||
_force_clearnet_sync(monkeypatch)
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", _FakeInfonet(), raising=False)
|
||||
|
||||
data = client.get("/api/mesh/infonet/sync").json()
|
||||
|
||||
assert "gate_message" not in _event_types(data["events"])
|
||||
assert data["count"] == 3
|
||||
|
||||
|
||||
# ── Non-gate redactions still hold ─────────────────────────────────────
|
||||
def test_post_sync_excludes_gate_message_when_clearnet_sync_allowed(monkeypatch):
|
||||
_force_clearnet_sync(monkeypatch)
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", _FakeInfonet(), raising=False)
|
||||
|
||||
result = asyncio.run(
|
||||
main.infonet_sync_post(
|
||||
_json_request("/api/mesh/infonet/sync", {"locator": ["head-1"]})
|
||||
)
|
||||
)
|
||||
|
||||
assert "gate_message" not in _event_types(result["events"])
|
||||
assert result["count"] == 3
|
||||
|
||||
|
||||
def test_get_sync_still_redacts_vote_gate_label(client, monkeypatch):
|
||||
"""Public sync must still strip gate label from vote payload."""
|
||||
_force_private_sync(monkeypatch)
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", _FakeInfonet(), raising=False)
|
||||
resp = client.get("/api/mesh/infonet/sync")
|
||||
events = resp.json()["events"]
|
||||
|
||||
events = client.get("/api/mesh/infonet/sync").json()["events"]
|
||||
vote = next(e for e in events if e["event_type"] == "vote")
|
||||
|
||||
assert "gate" not in vote.get("payload", {})
|
||||
|
||||
|
||||
def test_get_sync_still_redacts_key_rotate_identity(client, monkeypatch):
|
||||
"""Public sync must still strip old identity fields from key_rotate payload."""
|
||||
_force_private_sync(monkeypatch)
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", _FakeInfonet(), raising=False)
|
||||
resp = client.get("/api/mesh/infonet/sync")
|
||||
events = resp.json()["events"]
|
||||
|
||||
events = client.get("/api/mesh/infonet/sync").json()["events"]
|
||||
rotate = next(e for e in events if e["event_type"] == "key_rotate")
|
||||
payload = rotate.get("payload", {})
|
||||
|
||||
assert "old_node_id" not in payload
|
||||
assert "old_public_key" not in payload
|
||||
assert "old_signature" not in payload
|
||||
|
||||
|
||||
def test_post_sync_still_redacts_vote_and_rotate(monkeypatch):
|
||||
"""POST sync must still apply standard public redactions to non-gate events."""
|
||||
_force_private_sync(monkeypatch)
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", _FakeInfonet(), raising=False)
|
||||
|
||||
result = asyncio.run(
|
||||
main.infonet_sync_post(
|
||||
_json_request("/api/mesh/infonet/sync", {"locator": ["head-1"]})
|
||||
@@ -291,24 +351,17 @@ def test_post_sync_still_redacts_vote_and_rotate(monkeypatch):
|
||||
)
|
||||
vote = next(e for e in result["events"] if e["event_type"] == "vote")
|
||||
rotate = next(e for e in result["events"] if e["event_type"] == "key_rotate")
|
||||
|
||||
assert "gate" not in vote.get("payload", {})
|
||||
assert "old_node_id" not in rotate.get("payload", {})
|
||||
|
||||
|
||||
# ── No overclaim ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_gate_message_still_in_fake_infonet_storage():
|
||||
"""The filter does NOT remove gate_message from underlying storage.
|
||||
This test documents that the infonet still holds gate_message events;
|
||||
only the public sync response surface filters them out."""
|
||||
fake = _FakeInfonet()
|
||||
all_types = [e["event_type"] for e in fake.events]
|
||||
assert "gate_message" in all_types
|
||||
assert "gate_message" in _event_types(fake.events)
|
||||
|
||||
|
||||
def test_sync_with_only_gate_messages_returns_empty(client, monkeypatch):
|
||||
"""If infonet contains only gate_message events, sync returns empty list."""
|
||||
def test_private_sync_with_only_gate_messages_returns_gate_events(client, monkeypatch):
|
||||
class _GateOnlyInfonet:
|
||||
head_hash = "head-1"
|
||||
events = [_gate_message_event()]
|
||||
@@ -325,8 +378,10 @@ def test_sync_with_only_gate_messages_returns_empty(client, monkeypatch):
|
||||
def get_merkle_root(self):
|
||||
return "r"
|
||||
|
||||
_force_private_sync(monkeypatch)
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", _GateOnlyInfonet(), raising=False)
|
||||
resp = client.get("/api/mesh/infonet/sync")
|
||||
data = resp.json()
|
||||
assert data["events"] == []
|
||||
assert data["count"] == 0
|
||||
|
||||
data = client.get("/api/mesh/infonet/sync").json()
|
||||
|
||||
assert _event_types(data["events"]) == ["gate_message"]
|
||||
assert data["count"] == 1
|
||||
|
||||
@@ -66,6 +66,20 @@ def _make_gate_message_event(priv, pub_b64, node_id, sequence, prev_hash, gate_i
|
||||
return evt.to_dict()
|
||||
|
||||
|
||||
def _make_gate_payload(gate_id="test-gate") -> dict:
|
||||
return mesh_protocol.normalize_payload(
|
||||
"gate_message",
|
||||
{
|
||||
"gate": gate_id,
|
||||
"ciphertext": base64.b64encode(b"encrypted-data").decode(),
|
||||
"nonce": base64.b64encode(b"nonce-value-1234").decode(),
|
||||
"sender_ref": "sender-abc",
|
||||
"format": "mls1",
|
||||
"transport_lock": "private_strong",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fresh_env(tmp_path, monkeypatch):
|
||||
"""Set up isolated infonet + gate_store, return (infonet, gate_store)."""
|
||||
@@ -89,6 +103,74 @@ def fresh_env(tmp_path, monkeypatch):
|
||||
# ── Rejected gate_message must NOT hydrate gate_store ─────────────────────
|
||||
|
||||
|
||||
def test_append_private_gate_message_uses_hashchain_gate_sequence(fresh_env):
|
||||
"""Local gate posts become private hashchain events in a gate sequence domain."""
|
||||
inf, _gs = fresh_env
|
||||
priv, pub_b64, node_id = _make_keypair()
|
||||
sequence = 1
|
||||
payload = _make_gate_payload("test-gate")
|
||||
sig_payload = mesh_crypto.build_signature_payload(
|
||||
event_type="gate_message",
|
||||
node_id=node_id,
|
||||
sequence=sequence,
|
||||
payload=payload,
|
||||
)
|
||||
signature = priv.sign(sig_payload.encode("utf-8")).hex()
|
||||
|
||||
event = inf.append_private_gate_message(
|
||||
node_id=node_id,
|
||||
payload=payload,
|
||||
signature=signature,
|
||||
sequence=sequence,
|
||||
public_key=pub_b64,
|
||||
public_key_algo="Ed25519",
|
||||
protocol_version=mesh_protocol.PROTOCOL_VERSION,
|
||||
timestamp=123.0,
|
||||
)
|
||||
|
||||
assert event["event_type"] == "gate_message"
|
||||
assert inf.head_hash == event["event_id"]
|
||||
assert inf.sequence_domains[f"{node_id}|gate_message"] == sequence
|
||||
assert inf.node_sequences.get(node_id, 0) == 0
|
||||
assert event["payload"]["transport_lock"] == "private_strong"
|
||||
|
||||
|
||||
def test_ingest_accepts_new_suffix_after_duplicate_prefix(fresh_env):
|
||||
"""Peer-push batches may include events the receiver already has."""
|
||||
inf, _gs = fresh_env
|
||||
priv, pub_b64, node_id = _make_keypair()
|
||||
evt1 = _make_gate_message_event(
|
||||
priv,
|
||||
pub_b64,
|
||||
node_id,
|
||||
sequence=1,
|
||||
prev_hash=mesh_hashchain.GENESIS_HASH,
|
||||
)
|
||||
assert inf.ingest_events([evt1])["accepted"] == 1
|
||||
evt2 = _make_gate_message_event(
|
||||
priv,
|
||||
pub_b64,
|
||||
node_id,
|
||||
sequence=2,
|
||||
prev_hash=evt1["event_id"],
|
||||
)
|
||||
assert inf.ingest_events([evt2])["accepted"] == 1
|
||||
evt3 = _make_gate_message_event(
|
||||
priv,
|
||||
pub_b64,
|
||||
node_id,
|
||||
sequence=3,
|
||||
prev_hash=evt2["event_id"],
|
||||
)
|
||||
|
||||
result = inf.ingest_events([evt1, evt2, evt3])
|
||||
|
||||
assert result["duplicates"] == 2
|
||||
assert result["accepted"] == 1
|
||||
assert result["rejected"] == []
|
||||
assert inf.head_hash == evt3["event_id"]
|
||||
|
||||
|
||||
def test_rejected_event_does_not_hydrate_gate_store(fresh_env):
|
||||
"""A gate_message rejected by ingest must not appear in gate_store."""
|
||||
inf, gs = fresh_env
|
||||
|
||||
Reference in New Issue
Block a user