diff options
Diffstat (limited to 'packages/meshbay-node/src')
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 |