Files
Shadowbroker/backend/tests/mesh/test_private_gate_hashchain.py
Shadowbroker 1d7fa5185a 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>
2026-05-24 21:25:18 -06:00

270 lines
8.3 KiB
Python

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 == []