Compare commits

..

1 Commits

Author SHA1 Message Date
BigBodyCobain 30aa30204f feat(infonet): private gate + DM hashchain spool with hardened propagation
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:09:08 -06:00
3 changed files with 4 additions and 69 deletions
+1 -9
View File
@@ -13,10 +13,6 @@
# 2. Reverse-mirrors main back to GitHub (only if commits land directly
# on GitLab) so the two sources stay in sync.
#
# Pipelines on this repo were instant-failing for free-tier accounts until
# identity verification was added — the May 2026 bump in this comment is
# the marker commit that confirms runner allocation after verification.
#
# Auth notes:
# - The image build/push uses $CI_JOB_TOKEN, which GitLab provides
# automatically. No credentials need to be configured.
@@ -52,11 +48,7 @@ variables:
- docker info
- docker login -u "$CI_REGISTRY_USER" -p "$CI_JOB_TOKEN" "$CI_REGISTRY"
- docker run --privileged --rm tonistiigi/binfmt --install all
# buildx --driver docker-container can't read TLS from the env vars
# the GitLab dind service exports. Wrap them in a docker context and
# bind buildx to it. See https://docs.gitlab.com/ee/ci/docker/using_docker_build.html#use-docker-buildx
- docker context create tls-env
- docker buildx create --use --name multiarch --driver docker-container tls-env
- docker buildx create --use --name multiarch --driver docker-container
# ── Backend image ────────────────────────────────────────────────────────
build-backend:
+3 -3
View File
@@ -43,8 +43,8 @@
"ShadowBroker_0.9.8_x64_en-US.msi": "fe22f9d51e4360d74c18a7250c2fbb9ed4fa4c7a884b3ac0d04a21115466386b"
},
"v0.9.81": {
"ShadowBroker_v0.9.81.zip": "f81f454bdc88e9a32c351df38212b8cfa624704d65764b971bb091eef62259c6",
"ShadowBroker_0.9.81_x64-setup.exe": "25e9a95d0d8ce959a7d08fe8e7406772ae24b596652793e81d1de5d02510a5a6",
"ShadowBroker_0.9.81_x64_en-US.msi": "34e655fc0c0f195ee4ac978f228a4b2b9d5565253b8771aca9ef4693409e9e70"
"ShadowBroker_v0.9.81.zip": "af8c87ccdece8fbb9aadc6be63cce10d3fcba74e6d87ef83289dda6d555fd270",
"ShadowBroker_0.9.81_x64-setup.exe": "4e866fa0423c0c2470ed32f4809167a7815dc23ee7762b69e95681c1f3a28250",
"ShadowBroker_0.9.81_x64_en-US.msi": "8977c9a1c54e1f0d030436be9c4e3d81d766cc0080699eb747649095f360c7ff"
}
}
@@ -1,57 +0,0 @@
import React from 'react';
import { act, cleanup, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/mesh/meshIdentity', () => ({
getNodeIdentity: vi.fn(() => ({ publicKey: 'test-public-key', nodeId: '!sb_test' })),
getWormholeIdentityDescriptor: vi.fn(() => ({ publicKey: 'wormhole-public-key' })),
}));
vi.mock('@/mesh/wormholeIdentityClient', () => ({
activateWormholeGatePersona: vi.fn(),
createWormholeGatePersona: vi.fn(),
enterWormholeGate: vi.fn(),
fetchWormholeIdentity: vi.fn(),
listWormholeGatePersonas: vi.fn(async () => ({ personas: [] })),
}));
vi.mock('@/components/InfonetTerminal/GateView', () => ({ default: () => <div>Gate view</div> }));
vi.mock('@/components/InfonetTerminal/MarketView', () => ({ default: () => <div>Market view</div> }));
vi.mock('@/components/InfonetTerminal/ProfileView', () => ({ default: () => <div>Profile view</div> }));
vi.mock('@/components/InfonetTerminal/MessagesView', () => ({ default: () => <div>Messages view</div> }));
vi.mock('@/components/InfonetTerminal/TerminalDashboard', () => ({ default: () => <div>Dashboard</div> }));
vi.mock('@/components/InfonetTerminal/WeatherWidget', () => ({ default: () => <div>Weather</div> }));
vi.mock('@/components/InfonetTerminal/TrendingPosts', () => ({ default: () => <div>Trending</div> }));
vi.mock('@/components/InfonetTerminal/HashchainEvents', () => ({ default: () => <div>Hashchain</div> }));
vi.mock('@/components/InfonetTerminal/NetworkStats', () => ({ default: () => <div>Network stats</div> }));
vi.mock('@/components/InfonetTerminal/AIQueryView', () => ({ default: () => <div>AI view</div> }));
vi.mock('@/components/InfonetTerminal/PetitionsView', () => ({ default: () => <div>Petitions</div> }));
vi.mock('@/components/InfonetTerminal/UpgradeView', () => ({ default: () => <div>Upgrades</div> }));
vi.mock('@/components/InfonetTerminal/ResolutionView', () => ({ default: () => <div>Resolution</div> }));
vi.mock('@/components/InfonetTerminal/GateShutdownView', () => ({ default: () => <div>Gate shutdown</div> }));
vi.mock('@/components/InfonetTerminal/BootstrapView', () => ({ default: () => <div>Bootstrap</div> }));
vi.mock('@/components/InfonetTerminal/FunctionKeyView', () => ({ default: () => <div>Function keys</div> }));
describe('InfonetShell gate directory', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
it('renders available gates under the landing logo section', async () => {
const { default: InfonetShell } = await import('@/components/InfonetTerminal/InfonetShell');
render(<InfonetShell isOpen onClose={() => {}} />);
await act(async () => {
vi.advanceTimersByTime(2500);
});
expect(screen.getByText('AVAILABLE OBFUSCATED GATES:')).toBeTruthy();
expect(screen.getByRole('button', { name: /\[>\]\s*infonet/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /\[>\]\s*gathered-intel/i })).toBeTruthy();
});
});