aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/daemon.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-13 11:00:39 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-13 11:00:39 +0200
commit9df71bd1e5244743fae8c1b2bda41143f0748d9d (patch)
treeb3cdf8ca3e87d35bdf54283d6cca34952d0bcf79 /packages/meshbay-node/src/meshbay_node/daemon.py
parentab4657789eaca1d88b54e5d5123a0bc71a95e6ce (diff)
downloadmeshbay-9df71bd1e5244743fae8c1b2bda41143f0748d9d.tar.gz
fix: swarm privacy, revocation persistence, keystore KDF, audit integrity
Phase 11.5 hardening batch — H7, H4, M2, M6, M7, L1, L3, L6. H7 — private content hashes leaked to the hub. The daemon registered blake3 hashes for every group it hosted, private ones included, giving the hub a content fingerprint of every private file and letting anyone confirm whether a known file exists in the network. The leak was dormant only because the routes were declared on the groups router with a full path and mounted at /v1/groups/v1/swarm/* — the node's calls 404'd into a swallowed exception. Fixing the path alone would have activated the leak, so both land together: registration is gated on group visibility, the routes moved to a real /v1/swarm router, and the lookup now requires authentication. H4 — revocation was advisory. Group revocations were signed and broadcast by the hub and then dropped by the node, whose handler understood only "user" and "jti", so "suspend a group" enforced nothing. The denylist was also in-memory only, so a restart silently un-revoked everyone. Now persisted to data_dir/denylist.json, group targets honoured on both transports, and live sessions for a revoked group are closed. M2 — the node keystore, which protects the node's Ed25519 and X25519 private keys, was still deriving at 64 MB long after the hub's password verifier moved to 256 MB; the docs recorded the bump as done, true for the hub only. Raising the constant alone would have made every existing keystore permanently undecryptable, so envelopes now record the parameters they were written with and pre-M2 files continue to open under the legacy profile. M6 — registration inserted its audit row with a NULL user_id and then ran UPDATE ip_logs SET user_id=<new> WHERE user_id IS NULL, claiming every unattributed row in the table: failed logins for other usernames, concurrent registrations. In logs retained a year for legal requests, that attributed other people's connections to the wrong account. M7 — X-Forwarded-For was trusted unconditionally at four call sites, so anyone could forge the IP written to the compliance log and evade per-IP rate limits. New netutil.client_ip honours the header only from a trusted proxy and takes the rightmost hop (the one our proxy appended); no direct header reads remain. L1 dead GEK_REQUEST/GEK_RESPONSE constants removed; L3 peer errors no longer echo exception text (paths, internal state); L6 email sanity-checked instead of accepting any string — deliberately not RFC 5322, to avoid a new dependency. test_daemon_index_change_pushes_to_peers asserted that a PRIVATE group's hashes are registered with the hub. Split: private asserts not-called (index push to members still asserted), and a new test proves public groups still register. That is the fourth pre-existing test found asserting a vulnerability as intended behaviour, after gek auto-activation, the transport-wide chat_store and the blind admin challenge. Tests: 116 node, 132 hub+common. Regression suite now 43. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py36
1 files changed, 32 insertions, 4 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index c75c721..7dedad8 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -107,7 +107,9 @@ class NodeDaemon:
}
self._quic_server = None
self._webrtc = None
- self._denylist = Denylist() if Denylist else None
+ # Persisted so a restart does not silently un-revoke everyone (H4)
+ self._denylist = (
+ Denylist(path=config.data_dir / "denylist.json") if Denylist else None)
self._chat_stores: dict[str, ChatStore] = {}
self._audit_store: AuditStore | None = None
self._bundle_store: BundleStore | None = None
@@ -205,6 +207,7 @@ class NodeDaemon:
"gek": gek,
"shared_root": shared_root,
"index": indexer.index,
+ "visibility": group_cfg.visibility,
}
if not groups_ctx:
@@ -310,8 +313,15 @@ class NodeDaemon:
tid = payload.get("target_id", "")
if target == "user":
denylist.deny_user(tid)
+ elif target == "group":
+ # H4: previously dropped on the floor, so "suspend a
+ # group" was a hub-only gesture that no node enforced.
+ denylist.deny_group(tid)
+ self._drop_group_sessions(tid)
elif target == "jti":
denylist.deny_jti(tid)
+ else:
+ log.warning("Unknown revocation target: %r", target)
except Exception as e:
log.warning("Invalid revocation token: %s", e)
@@ -343,9 +353,15 @@ class NodeDaemon:
"yes" if self._webrtc else "no",
"yes" if self._quic_server else "no")
- # 11. Initial swarm registration
+ # 11. Initial swarm registration — PUBLIC groups only.
+ # Finding H7: registering every group's hashes hands the hub a content
+ # fingerprint of every private file on the node, which is exactly the
+ # metadata the "hub stores no content metadata" claim rules out. It also
+ # lets anyone confirm whether a known file exists in the network.
endpoint = f"webrtc:{self._config.node.quic_port}"
for gctx in groups_ctx.values():
+ if gctx.get("visibility") != "public":
+ continue
hashes = [e.id for e in gctx["index"].entries]
if hashes:
asyncio.ensure_future(self._register_swarm(hashes, endpoint))
@@ -462,13 +478,25 @@ class NodeDaemon:
if pushed:
log.info("Index pushed to %d WebRTC peers", pushed)
- # 11.9 — Register file hashes with hub swarm table
- if self._hub and self._state.get("endpoint_hint"):
+ # 11.9 — Register file hashes with hub swarm table (public groups only, H7)
+ group_cfg = next(
+ (g for g in self._config.groups if g.id == group_id), None)
+ if (self._hub and self._state.get("endpoint_hint")
+ and group_cfg and group_cfg.visibility == "public"):
hashes = [e.id for e in idx.entries]
if hashes:
endpoint = f"webrtc:{self._config.node.quic_port}"
asyncio.ensure_future(self._register_swarm(hashes, endpoint))
+ def _drop_group_sessions(self, group_id: str) -> None:
+ """Close live sessions for a revoked group (H4)."""
+ if not self._webrtc or not group_id:
+ return
+ for session in list(self._webrtc._sessions.values()):
+ if session._group_id == group_id:
+ asyncio.ensure_future(session.close())
+ log.info("Dropped session for revoked group %s", group_id[:8])
+
async def _register_swarm(self, hashes: list[str], endpoint: str) -> None:
try:
n = await self._hub.register_swarm(hashes, endpoint)