aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node
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
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')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py36
-rw-r--r--packages/meshbay-node/src/meshbay_node/keystore.py25
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_server.py58
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py9
4 files changed, 115 insertions, 13 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)
diff --git a/packages/meshbay-node/src/meshbay_node/keystore.py b/packages/meshbay-node/src/meshbay_node/keystore.py
index 3777af0..59fc719 100644
--- a/packages/meshbay-node/src/meshbay_node/keystore.py
+++ b/packages/meshbay-node/src/meshbay_node/keystore.py
@@ -39,6 +39,12 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from meshbay_common.crypto import (
+ ARGON2_ITERATIONS,
+ ARGON2_LANES,
+ ARGON2_MEMORY_COST,
+ LEGACY_ARGON2_ITERATIONS,
+ LEGACY_ARGON2_LANES,
+ LEGACY_ARGON2_MEMORY_COST,
decrypt_keystore,
derive_keystore_key,
encrypt_keystore,
@@ -173,7 +179,18 @@ def load_keystore(
tag = base64.b64decode(envelope["tag_b64"])
ct = base64.b64decode(envelope["ciphertext_b64"])
- aes_key = derive_keystore_key(pwd, salt)
+ # Envelopes written before M2 carry no parameters and used the 64 MB profile.
+ params = envelope.get("argon2", {
+ "iterations": LEGACY_ARGON2_ITERATIONS,
+ "memory_cost": LEGACY_ARGON2_MEMORY_COST,
+ "lanes": LEGACY_ARGON2_LANES,
+ })
+ aes_key = derive_keystore_key(
+ pwd, salt,
+ iterations=params.get("iterations"),
+ memory_cost=params.get("memory_cost"),
+ lanes=params.get("lanes"),
+ )
try:
plaintext = decrypt_keystore(iv, ct, tag, aes_key)
except Exception:
@@ -205,6 +222,12 @@ def _write_keystore(path: Path, keys: NodeKeys, password: str) -> None:
envelope = {
"version": KEYSTORE_VERSION,
"argon2_salt_b64": base64.b64encode(salt).decode(),
+ # Recorded so parameters can be raised later without orphaning this file.
+ "argon2": {
+ "iterations": ARGON2_ITERATIONS,
+ "memory_cost": ARGON2_MEMORY_COST,
+ "lanes": ARGON2_LANES,
+ },
"iv_b64": base64.b64encode(iv).decode(),
"tag_b64": base64.b64encode(tag).decode(),
"ciphertext_b64": base64.b64encode(ct).decode(),
diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
index ede756e..7bb6ce5 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
@@ -50,22 +50,69 @@ ALPN = ["meshbay-mnp"]
class Denylist:
- """Shared denylist for revoked users and invalidated JWTs."""
+ """
+ Denylist for revoked users, groups and invalidated JWTs.
- def __init__(self):
+ Finding H4: revocations used to live only in memory, so a node restart silently
+ un-revoked everyone, and group revocations were dropped entirely — the hub
+ signed and broadcast them but the node's handler only understood "user" and
+ "jti". Now persisted to disk and group targets are honoured.
+ """
+
+ def __init__(self, path: Path | None = None):
self.user_ids: set[str] = set()
+ self.group_ids: set[str] = set()
self.jtis: set[str] = set()
+ self._path = path
+ self._load()
- def is_denied(self, user_id: str, jti: str) -> bool:
- return user_id in self.user_ids or jti in self.jtis
+ def is_denied(self, user_id: str, jti: str, group_id: str = "") -> bool:
+ return (user_id in self.user_ids
+ or jti in self.jtis
+ or (bool(group_id) and group_id in self.group_ids))
def deny_user(self, user_id: str) -> None:
self.user_ids.add(user_id)
log.info("Denied user: %s", user_id[:8])
+ self._save()
+
+ def deny_group(self, group_id: str) -> None:
+ self.group_ids.add(group_id)
+ log.info("Denied group: %s", group_id[:8])
+ self._save()
def deny_jti(self, jti: str) -> None:
self.jtis.add(jti)
log.info("Denied jti: %s", jti[:8])
+ self._save()
+
+ def _load(self) -> None:
+ if not self._path or not self._path.exists():
+ return
+ try:
+ import json
+ data = json.loads(self._path.read_text())
+ self.user_ids = set(data.get("users", []))
+ self.group_ids = set(data.get("groups", []))
+ self.jtis = set(data.get("jtis", []))
+ log.info("Denylist loaded: %d users, %d groups, %d jtis",
+ len(self.user_ids), len(self.group_ids), len(self.jtis))
+ except Exception as e:
+ log.warning("Could not load denylist from %s: %s", self._path, e)
+
+ def _save(self) -> None:
+ if not self._path:
+ return
+ try:
+ import json
+ self._path.parent.mkdir(parents=True, exist_ok=True)
+ self._path.write_text(json.dumps({
+ "users": sorted(self.user_ids),
+ "groups": sorted(self.group_ids),
+ "jtis": sorted(self.jtis),
+ }))
+ except Exception as e:
+ log.warning("Could not persist denylist to %s: %s", self._path, e)
# ── Wire helpers ──────────────────────────────────────────────────────────────
@@ -156,7 +203,8 @@ class _MNPServerProtocol(QuicConnectionProtocol):
return
denylist = self._ctx.get("denylist")
- if denylist and denylist.is_denied(decoded.get("sub", ""), decoded.get("jti", "")):
+ if denylist and denylist.is_denied(
+ decoded.get("sub", ""), decoded.get("jti", ""), group_id):
self._send(stream_id, {"type": "error", "detail": "Token revoked"})
self._quic.close()
return
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 9fb9ef2..43e6fdc 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -241,8 +241,10 @@ class WebRTCPeerSession:
else:
log.warning("Unknown MNP message type on DataChannel: %s", mtype)
except Exception as e:
- log.error("Error handling %s on DataChannel: %s", mtype, e)
- self._send({"type": "error", "detail": str(e)})
+ # Log the detail locally; send the peer a generic message. Exception
+ # text here carries filesystem paths and internal state (finding L3).
+ log.error("Error handling %s on DataChannel: %s", mtype, e, exc_info=True)
+ self._send({"type": "error", "detail": "Request failed"})
def _audit(self, event: str, detail: str = "") -> None:
audit = self._ctx.get("audit_store")
@@ -269,7 +271,8 @@ class WebRTCPeerSession:
return
denylist = self._ctx.get("denylist")
- if denylist and denylist.is_denied(decoded.get("sub", ""), decoded.get("jti", "")):
+ if denylist and denylist.is_denied(
+ decoded.get("sub", ""), decoded.get("jti", ""), group_id):
self._send({"type": "error", "detail": "Token revoked"})
return