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:
Shadowbroker
2026-05-24 21:25:18 -06:00
committed by GitHub
co-authored by Claude Opus 4.7
parent fb97042c01
commit 1d7fa5185a
19 changed files with 2151 additions and 324 deletions
+7
View File
@@ -55,6 +55,12 @@ def _hydrate_gate_store_from_chain(events: list) -> int:
return count
def _hydrate_dm_relay_from_chain(events: list) -> int:
import main as _m
return int(_m._hydrate_dm_relay_from_chain(events))
@router.post("/api/mesh/infonet/peer-push")
@limiter.limit("30/minute")
async def infonet_peer_push(request: Request):
@@ -82,6 +88,7 @@ async def infonet_peer_push(request: Request):
return {"ok": True, "accepted": 0, "duplicates": 0, "rejected": []}
result = infonet.ingest_events(events)
_hydrate_gate_store_from_chain(events)
_hydrate_dm_relay_from_chain(events)
return {"ok": True, **result}
+33 -8
View File
@@ -65,6 +65,7 @@ from services.mesh.mesh_signed_events import (
logger = logging.getLogger(__name__)
router = APIRouter()
_INFONET_SYNC_RATE_LIMIT = "600/minute"
def _signed_body(request: Request) -> dict[str, Any]:
@@ -263,6 +264,19 @@ def _redact_public_event(event: dict) -> dict:
return _redact_vote_gate(_redact_key_rotate_payload(_redact_gate_metadata(event)))
def _infonet_private_transport_required() -> bool:
import main as _m
return bool(_m._infonet_private_transport_required())
def _infonet_sync_response_events(events: list[dict], request=None) -> list[dict]:
"""Build the sync event surface for the current transport policy."""
import main as _m
return _m._infonet_sync_response_events(events, request=request)
def _trusted_gate_reply_to(event: dict) -> str:
if not isinstance(event, dict):
return ""
@@ -574,6 +588,12 @@ def _hydrate_gate_store_from_chain(events: list[dict]) -> int:
pass
return count
def _hydrate_dm_relay_from_chain(events: list[dict]) -> int:
import main as _m
return int(_m._hydrate_dm_relay_from_chain(events))
# --- Safe type helpers ---
def _safe_int(val, default=0):
@@ -1531,7 +1551,7 @@ async def infonet_locator(request: Request, limit: int = Query(32, ge=4, le=128)
@router.post("/api/mesh/infonet/sync")
@limiter.limit("30/minute")
@limiter.limit(_INFONET_SYNC_RATE_LIMIT)
@mesh_write_exempt(MeshWriteExemption.PEER_GOSSIP)
async def infonet_sync_post(
request: Request,
@@ -1584,8 +1604,7 @@ async def infonet_sync_post(
elif matched_hash == GENESIS_HASH and len(locator) > 1:
forked = True
# Filter out legacy gate_message events — not part of the public sync surface.
events = [_redact_public_event(e) for e in events if e.get("event_type") != "gate_message"]
events = _infonet_sync_response_events(events, request=request)
response = {
"events": events,
@@ -1646,7 +1665,7 @@ async def mesh_rns_status(request: Request):
@router.get("/api/mesh/infonet/sync")
@limiter.limit("30/minute")
@limiter.limit(_INFONET_SYNC_RATE_LIMIT)
async def infonet_sync(
request: Request,
after_hash: str = "",
@@ -1684,8 +1703,7 @@ async def infonet_sync(
)
base = after_hash or GENESIS_HASH
events = infonet.get_events_after(base, limit=limit)
# Filter out legacy gate_message events — not part of the public sync surface.
events = [_redact_public_event(e) for e in events if e.get("event_type") != "gate_message"]
events = _infonet_sync_response_events(events, request=request)
return {
"events": events,
"after_hash": base,
@@ -1724,6 +1742,7 @@ async def infonet_ingest(request: Request):
result = infonet.ingest_events(events)
_hydrate_gate_store_from_chain(events)
_hydrate_dm_relay_from_chain(events)
return {"ok": True, **result}
@@ -2279,6 +2298,12 @@ async def infonet_event(request: Request, event_id: str):
)
return _strip_gate_for_access(evt, access)
return {"ok": False, "detail": "Event not found"}
if evt.get("event_type") == "dm_message":
return await _private_plane_refusal_response(
request,
status_code=403,
payload=_private_plane_access_denied_payload(),
)
if evt.get("event_type") == "gate_message":
gate_id = str(evt.get("payload", {}).get("gate", "") or evt.get("gate", "") or "").strip()
access = _verify_gate_access(request, gate_id) if gate_id else ""
@@ -2303,7 +2328,7 @@ async def infonet_node_events(
from services.mesh.mesh_hashchain import infonet
events = infonet.get_events_by_node(node_id, limit=limit)
events = [e for e in events if e.get("event_type") != "gate_message"]
events = [e for e in events if e.get("event_type") not in {"gate_message", "dm_message"}]
events = [_redact_public_event(e) for e in infonet.decorate_events(events)]
events = _redact_public_node_history(
events,
@@ -2328,7 +2353,7 @@ async def infonet_events_by_type(
else:
events = list(reversed(infonet.events))
events = events[offset : offset + limit]
events = [e for e in events if e.get("event_type") != "gate_message"]
events = [e for e in events if e.get("event_type") not in {"gate_message", "dm_message"}]
events = [_redact_public_event(e) for e in infonet.decorate_events(events)]
return {
"events": events,