summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
Diffstat (limited to 'packages/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
-rw-r--r--packages/meshbay-node/tests/test_daemon.py40
-rw-r--r--packages/meshbay-node/tests/test_security_regressions.py117
6 files changed, 270 insertions, 15 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
diff --git a/packages/meshbay-node/tests/test_daemon.py b/packages/meshbay-node/tests/test_daemon.py
index 9ebf945..0698104 100644
--- a/packages/meshbay-node/tests/test_daemon.py
+++ b/packages/meshbay-node/tests/test_daemon.py
@@ -228,10 +228,46 @@ async def test_daemon_index_change_pushes_to_peers(tmp_path, shared_dir, gek, hu
assert msg["group_id"] == "a" * 32
assert len(msg["entries"]) == indexer.index.count
+ # Finding H7: this group is private, so its content hashes must NOT be
+ # registered with the hub. The test previously asserted the opposite —
+ # publishing a fingerprint of every private file was treated as expected
+ # behaviour. Index push to members is unaffected (asserted above).
+ await asyncio.sleep(0.1)
+ daemon._hub.register_swarm.assert_not_called()
+
+@pytest.mark.asyncio
+async def test_daemon_index_change_registers_swarm_for_public_group(
+ tmp_path, shared_dir, gek, hub_pk_pem):
+ """Public groups still register content hashes with the hub swarm (H7)."""
+ config = Config(
+ hub=HubConfig(url="http://localhost:9999", username="testuser"),
+ node=NodeConfig(quic_port=29010, ui_port=28000),
+ groups=[GroupConfig(
+ id="a" * 32,
+ name="public-group",
+ shared_dir=str(shared_dir),
+ visibility="public",
+ quic_port=29010,
+ )],
+ keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
+ data_dir=tmp_path / "data",
+ )
+ daemon = NodeDaemon(config)
+ daemon._hub = AsyncMock()
+ daemon._hub.register_swarm = AsyncMock(return_value=2)
+ daemon._state["endpoint_hint"] = "node123"
+
+ indexer = DirectoryIndexer(
+ root=shared_dir, group_id="a" * 32,
+ sk_node=Ed25519PrivateKey.generate(), gek=gek)
+ await indexer.initial_scan()
+
+ await daemon._on_index_change(indexer)
+
await asyncio.sleep(0.1)
daemon._hub.register_swarm.assert_called_once()
- call_args = daemon._hub.register_swarm.call_args
- assert len(call_args[0][0]) == indexer.index.count
+ assert len(daemon._hub.register_swarm.call_args[0][0]) == indexer.index.count
+
@pytest.mark.asyncio
async def test_daemon_index_change_skips_other_group_peers(
diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py
index a480d21..9552848 100644
--- a/packages/meshbay-node/tests/test_security_regressions.py
+++ b/packages/meshbay-node/tests/test_security_regressions.py
@@ -348,6 +348,123 @@ def test_admin_challenge_expires(tmp_path):
for m in session.sent)
+def test_denylist_persists_and_honours_groups(tmp_path):
+ """
+ H4: revocations lived only in memory, so a node restart silently un-revoked
+ everyone, and 'group' targets were dropped entirely — the hub signed and
+ broadcast them, the node's handler understood only 'user' and 'jti'.
+ """
+ from meshbay_node.transport import Denylist
+
+ path = tmp_path / "denylist.json"
+ first = Denylist(path=path)
+ first.deny_group("g-revoked")
+ first.deny_user("u-revoked")
+ first.deny_jti("j-revoked")
+
+ # A fresh instance stands in for a daemon restart.
+ reloaded = Denylist(path=path)
+ assert reloaded.is_denied("", "", "g-revoked"), "group revocation not honoured"
+ assert reloaded.is_denied("u-revoked", "")
+ assert reloaded.is_denied("", "j-revoked")
+ assert not reloaded.is_denied("someone", "other", "g-allowed")
+
+
+def test_swarm_registration_skips_private_groups():
+ """
+ H7: the daemon registered content hashes for every group, private included,
+ handing the hub a fingerprint of every private file. The bug was masked by a
+ mis-mounted route, so fixing the route without this filter would have turned a
+ dormant leak into a live one.
+ """
+ source = (Path(__file__).parent.parent / "src" / "meshbay_node"
+ / "daemon.py").read_text()
+ assert 'visibility' in source and '_register_swarm' in source
+ # Both registration sites must gate on public visibility.
+ for marker in ['gctx.get("visibility") != "public"',
+ 'group_cfg.visibility == "public"']:
+ assert marker in source, f"swarm registration not gated: {marker}"
+
+
+def test_keystore_argon2_is_production_strength():
+ """M2: the keystore protects the node's private keys and sat at 64 MB."""
+ from meshbay_common.crypto import ARGON2_MEMORY_COST
+ assert ARGON2_MEMORY_COST >= 262144
+
+
+def test_keystore_records_argon2_params_for_migration(tmp_path):
+ """
+ M2: raising the parameters must not orphan existing keystores, so each
+ envelope records the parameters it was written with.
+ """
+ import json
+ from meshbay_node.keystore import create_keystore, load_keystore
+
+ path = tmp_path / "keystore.enc"
+ created = create_keystore(path=path, password="correct horse battery")
+ envelope = json.loads(path.read_text())
+ assert envelope["argon2"]["memory_cost"] >= 262144
+
+ reopened = load_keystore(path=path, password="correct horse battery")
+ assert reopened.pk_ed25519_b64 == created.pk_ed25519_b64
+
+
+def test_legacy_keystore_still_opens(tmp_path):
+ """M2: a keystore written under the 64 MB profile must still unlock."""
+ import base64 as _b64
+ import json
+ import msgpack
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+ from meshbay_common.crypto import (
+ LEGACY_ARGON2_ITERATIONS, LEGACY_ARGON2_LANES, LEGACY_ARGON2_MEMORY_COST,
+ derive_keystore_key, encrypt_keystore, pk_to_b64, sk_to_b64,
+ )
+ from meshbay_node.keystore import load_keystore
+
+ sk_ed, sk_x = Ed25519PrivateKey.generate(), X25519PrivateKey.generate()
+ payload = msgpack.packb({
+ "sk_ed25519_b64": sk_to_b64(sk_ed),
+ "sk_x25519_b64": sk_to_b64(sk_x),
+ }, use_bin_type=True)
+
+ salt = b"\x01" * 16
+ key = derive_keystore_key(
+ "legacy-pass", salt,
+ iterations=LEGACY_ARGON2_ITERATIONS,
+ memory_cost=LEGACY_ARGON2_MEMORY_COST,
+ lanes=LEGACY_ARGON2_LANES,
+ )
+ iv, ct, tag = encrypt_keystore(payload, key)
+
+ path = tmp_path / "legacy.enc"
+ # No "argon2" key — exactly how pre-M2 envelopes look.
+ path.write_text(json.dumps({
+ "version": 1,
+ "argon2_salt_b64": _b64.b64encode(salt).decode(),
+ "iv_b64": _b64.b64encode(iv).decode(),
+ "tag_b64": _b64.b64encode(tag).decode(),
+ "ciphertext_b64": _b64.b64encode(ct).decode(),
+ }))
+
+ keys = load_keystore(path=path, password="legacy-pass")
+ assert keys.pk_ed25519_b64 == pk_to_b64(sk_ed.public_key())
+ assert keys.pk_x25519_b64 == pk_to_b64(sk_x.public_key())
+
+
+def test_dead_gek_protocol_constants_removed():
+ """L1: the node never serves a GEK; the message types should not suggest it."""
+ from meshbay_common.protocol import MNP
+ assert not hasattr(MNP, "GEK_REQUEST")
+ assert not hasattr(MNP, "GEK_RESPONSE")
+
+
+def test_peer_errors_do_not_leak_internals():
+ """L3: exception text carries filesystem paths and internal state."""
+ source = (Path(__file__).parent.parent / "src" / "meshbay_node"
+ / "transport" / "webrtc_server.py").read_text()
+ assert '"detail": str(e)' not in source
+
+
def test_admin_ui_escapes_filenames(tmp_path):
"""
H2: filenames are chosen by any group member and were rendered into the