summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-common/src/meshbay_common/crypto.py40
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py37
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/moderation.py8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/netutil.py34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py47
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py3
-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
14 files changed, 406 insertions, 66 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/crypto.py b/packages/meshbay-common/src/meshbay_common/crypto.py
index 682e1c0..b2ff3c0 100644
--- a/packages/meshbay-common/src/meshbay_common/crypto.py
+++ b/packages/meshbay-common/src/meshbay_common/crypto.py
@@ -168,21 +168,45 @@ def unwrap_gek_aes(bundle: dict, sk_recipient: bytes, pk_recipient: bytes) -> by
# ── Keystore (local key storage) ──────────────────────────────────────────────
-# Argon2id parameters — calibrate to ~500ms on target hardware before production.
-# POC measured 78ms with these; increase memory_cost to 262144 (256MB) for prod.
+# Argon2id parameters for the node keystore.
+#
+# Finding M2: these sat at 64 MB long after the hub's password verifier was raised
+# to 256 MB, and the docs recorded the bump as done — true for the hub, false here.
+# The keystore protects the node's Ed25519 and X25519 private keys, so it is the
+# more valuable target of the two.
+#
+# Parameters are recorded in each keystore envelope, so raising them does not
+# invalidate existing files: LEGACY_* is used when an envelope predates the field.
ARGON2_ITERATIONS = 3
-ARGON2_MEMORY_COST = 65536 # 64 MB — increase to 262144 for production
+ARGON2_MEMORY_COST = 262144 # 256 MB
ARGON2_LANES = 4
ARGON2_KEY_LENGTH = 32
-def derive_keystore_key(password: str, salt: bytes) -> bytes:
- """Derive AES-256 key from password using Argon2id."""
+LEGACY_ARGON2_ITERATIONS = 3
+LEGACY_ARGON2_MEMORY_COST = 65536 # 64 MB — keystores written before M2
+LEGACY_ARGON2_LANES = 4
+
+
+def derive_keystore_key(
+ password: str,
+ salt: bytes,
+ *,
+ iterations: int | None = None,
+ memory_cost: int | None = None,
+ lanes: int | None = None,
+) -> bytes:
+ """
+ Derive an AES-256 key from a password using Argon2id.
+
+ Parameters default to the current production values; callers pass the values
+ recorded in an existing envelope when opening an older keystore.
+ """
return Argon2id(
salt=salt,
length=ARGON2_KEY_LENGTH,
- iterations=ARGON2_ITERATIONS,
- lanes=ARGON2_LANES,
- memory_cost=ARGON2_MEMORY_COST,
+ iterations=ARGON2_ITERATIONS if iterations is None else iterations,
+ lanes=ARGON2_LANES if lanes is None else lanes,
+ memory_cost=ARGON2_MEMORY_COST if memory_cost is None else memory_cost,
).derive(password.encode())
def encrypt_keystore(plaintext: bytes, key: bytes) -> tuple[bytes, bytes, bytes]:
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index 55dcdde..d86b4ef 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -29,8 +29,10 @@ class MNP:
CHAT_ATTACHMENT = "chat_attach" # attachment metadata
CHAT_HISTORY = "chat_hist" # request message history
CHAT_HISTORY_RESPONSE = "chat_hist_resp" # history response with messages
- GEK_REQUEST = "gek_req" # browser requests group GEK
- GEK_RESPONSE = "gek_resp" # node delivers GEK over secure channel
+ # GEK_REQUEST / GEK_RESPONSE were removed (NS3, and finding L1): the node must
+ # never serve the GEK in plaintext. Members obtain it by unwrapping their own
+ # ECIES bundle. The constants lingered after the handlers were deleted, leaving
+ # the wire contract looking as though the endpoint still existed.
FILE_UPLOAD = "file_upload" # client pushes file chunk to node
FILE_UPLOAD_ACK = "file_upload_ack" # node acknowledges chunk receipt
FILE_DELETE = "file_delete" # client requests file deletion
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index 88af764..000f3f7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -6,6 +6,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.api.deps import get_current_user, require_user_scope
+from meshbay_hub.api.netutil import client_ip
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import (
FederatedGroup, Group, GroupMember,
@@ -14,6 +15,10 @@ from meshbay_hub.db.models import (
router = APIRouter(prefix="/v1/groups", tags=["groups"])
+# Swarm endpoints live at /v1/swarm/*. They were previously declared on the groups
+# router with a full path, which mounted them at /v1/groups/v1/swarm/* (H7).
+swarm_router = APIRouter(prefix="/v1/swarm", tags=["swarm"])
+
@router.get("/mine")
async def my_groups(
@@ -117,13 +122,21 @@ class SwarmRegisterRequest(BaseModel):
endpoint: str # "ip:port"
-@router.post("/v1/swarm/register", status_code=201)
+@swarm_router.post("/register", status_code=201)
async def swarm_register(
body: SwarmRegisterRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
- """Node registers itself as a source for a content hash (public swarm)."""
+ """
+ Node registers itself as a source for a PUBLIC content hash.
+
+ Finding H7: the node registered hashes for every group it hosted, private ones
+ included, and this route was mounted at /v1/groups/v1/swarm/register — so the
+ node's calls 404'd and the leak was masked by a routing bug rather than
+ prevented. Nodes now filter by group visibility before calling, and the path is
+ correct, so the filter has to be right.
+ """
from meshbay_hub.csam import check_content_hash
if check_content_hash(body.content_hash):
raise HTTPException(status_code=451, detail="Content blocked")
@@ -144,12 +157,18 @@ async def swarm_register(
return {"status": "registered", "hash": body.content_hash}
-@router.get("/v1/swarm/{content_hash}")
+@swarm_router.get("/{content_hash}")
async def swarm_sources(
content_hash: str,
+ current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
- """Return list of nodes that can serve a content hash."""
+ """
+ Return nodes that can serve a content hash.
+
+ Authenticated (H7): an open endpoint lets anyone probe whether a given file
+ exists anywhere in the network and which node holds it.
+ """
from datetime import datetime, timezone, timedelta
cutoff = datetime.now(timezone.utc) - timedelta(minutes=30)
result = await db.execute(
@@ -214,7 +233,7 @@ async def join_group(
db.add(GroupMember(group_id=group_id, user_id=current_user.id))
db.add(IPLog(user_id=current_user.id, event="group_join",
- ip_address=_ip(request), detail=group.name))
+ ip_address=client_ip(request), detail=group.name))
await db.commit()
return {"status": "joined", "group_id": group_id, "name": group.name}
@@ -246,7 +265,7 @@ async def create_group(
db.add(GroupMember(group_id=group.id, user_id=current_user.id))
db.add(IPLog(user_id=current_user.id, event="group_create",
- ip_address=_ip(request), detail=body.name))
+ ip_address=client_ip(request), detail=body.name))
await db.commit()
await db.refresh(group)
return {"group_id": group.id, "name": group.name}
@@ -304,13 +323,9 @@ async def delete_group(
from sqlalchemy import delete as sa_delete
await db.execute(sa_delete(GroupMember).where(GroupMember.group_id == group_id))
db.add(IPLog(user_id=current_user.id, event="group_delete",
- ip_address=_ip(request), detail=group.name))
+ ip_address=client_ip(request), detail=group.name))
await db.delete(group)
await db.commit()
return {"status": "deleted", "group_id": group_id}
-def _ip(request: Request) -> str:
- fwd = request.headers.get("X-Forwarded-For")
- return fwd.split(",")[0].strip() if fwd else (
- request.client.host if request.client else "unknown")
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
index 6bb007b..853f255 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
@@ -28,6 +28,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.api.deps import get_current_user, require_admin
+from meshbay_hub.api.netutil import client_ip
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import ContentBlocklist, ContentReport, User
@@ -64,7 +65,7 @@ async def report_content(
if len(body.content_hash) != 64 or not all(c in "0123456789abcdef" for c in body.content_hash):
raise HTTPException(status_code=422, detail="content_hash must be 64 hex chars (blake3)")
- ip = _ip(request)
+ ip = client_ip(request)
# Count existing reports for this hash
count_result = await db.execute(
@@ -193,8 +194,3 @@ async def admin_remove_blocklist(
await db.commit()
return {"status": "unblocked", "hash": content_hash}
-
-def _ip(request: Request) -> str:
- fwd = request.headers.get("X-Forwarded-For")
- return fwd.split(",")[0].strip() if fwd else (
- request.client.host if request.client else "unknown")
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/netutil.py b/packages/meshbay-hub/src/meshbay_hub/api/netutil.py
new file mode 100644
index 0000000..aa8344a
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/api/netutil.py
@@ -0,0 +1,34 @@
+"""
+Client address resolution for the audit log and rate limiting.
+
+Finding M7: every call site did
+
+ fwd = request.headers.get("X-Forwarded-For")
+ return fwd.split(",")[0].strip() if fwd else request.client.host
+
+which trusts a header the client controls. Anyone could forge the IP written into
+the compliance log — the log that exists specifically to answer legal requests —
+and sidestep per-IP rate limiting at the same time.
+
+X-Forwarded-For is only consulted when the immediate peer is a trusted proxy, and
+then the *rightmost* entry is used: that is the one our own proxy appended, whereas
+the leftmost is whatever the client sent.
+"""
+
+from fastapi import Request
+
+# Caddy terminates TLS on the same host and proxies to 127.0.0.1:8000.
+TRUSTED_PROXIES = frozenset({"127.0.0.1", "::1", "localhost"})
+
+
+def client_ip(request: Request) -> str:
+ peer = request.client.host if request.client else ""
+
+ if peer in TRUSTED_PROXIES:
+ forwarded = request.headers.get("X-Forwarded-For")
+ if forwarded:
+ hops = [h.strip() for h in forwarded.split(",") if h.strip()]
+ if hops:
+ return hops[-1]
+
+ return peer or "unknown"
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
index 321e43c..7738875 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.auth import issue_access_token
from meshbay_hub.api.deps import get_current_user
from meshbay_hub.api.middleware import limiter
+from meshbay_hub.api.netutil import client_ip
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import GroupMember, IPLog, Node, User
@@ -59,7 +60,7 @@ async def node_auth(
sig = base64.b64decode(body.signature)
pk.verify(sig, message)
except (InvalidSignature, Exception):
- db.add(IPLog(event="node_auth_fail", ip_address=_ip(request), detail=body.username))
+ db.add(IPLog(event="node_auth_fail", ip_address=client_ip(request), detail=body.username))
await db.commit()
raise HTTPException(status_code=401, detail="Invalid signature")
@@ -70,7 +71,7 @@ async def node_auth(
access_token = issue_access_token(
user.id, user.pk_node_ed25519, ttl=3600, groups=group_ids, scope="node")
- db.add(IPLog(user_id=user.id, event="node_auth", ip_address=_ip(request)))
+ db.add(IPLog(user_id=user.id, event="node_auth", ip_address=client_ip(request)))
await db.commit()
return {
@@ -101,7 +102,7 @@ async def announce_node(
db.add(IPLog(
user_id=current_user.id,
event="node_announce",
- ip_address=_ip(request),
+ ip_address=client_ip(request),
detail=body.endpoint_hint,
))
await db.commit()
@@ -128,8 +129,3 @@ async def get_node(
}
-def _ip(request: Request) -> str:
- fwd = request.headers.get("X-Forwarded-For")
- if fwd:
- return fwd.split(",")[0].strip()
- return request.client.host if request.client else "unknown"
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 53238de..5a0f9dc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -5,7 +5,7 @@ import uuid
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, status
-from pydantic import BaseModel, EmailStr, field_validator
+from pydantic import BaseModel, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -22,6 +22,7 @@ from meshbay_hub.auth import (
verify_password,
)
from meshbay_hub.api.middleware import limiter
+from meshbay_hub.api.netutil import client_ip
from meshbay_hub.config import HubConfig
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import GroupMember, IPLog, RefreshToken, User
@@ -62,6 +63,24 @@ class RegisterRequest(BaseModel):
raise ValueError("username: only letters, digits, -, _, .")
return v
+ @field_validator("email")
+ @classmethod
+ def email_valid(cls, v: str) -> str:
+ """
+ Sanity-check the address (L6): the field was plain `str`, so any junk was
+ accepted and stored encrypted forever. Deliberately not RFC 5322 — full
+ validation would pull in the email-validator dependency for little gain,
+ and the address is only ever used for recovery and legal contact.
+ """
+ v = v.strip()
+ local, sep, domain = v.partition("@")
+ if (not sep or not local or not domain
+ or "." not in domain
+ or len(v) > 254
+ or any(c.isspace() or ord(c) < 32 for c in v)):
+ raise ValueError("invalid email address")
+ return v
+
class LoginRequest(BaseModel):
username: str
@@ -106,21 +125,24 @@ async def register(
hub_id=hub_id,
)
db.add(user)
+ # flush assigns user.id so the log row can be attributed directly.
+ #
+ # Finding M6: this used to insert the row with a NULL user_id and then run
+ # UPDATE ip_logs SET user_id = <new user> WHERE user_id IS NULL
+ # which claimed *every* unattributed row in the table — failed logins for other
+ # usernames, other registrations racing this one — and stamped them with the
+ # account just created. For logs retained a year to answer legal requests, that
+ # attributed other people's connections to the wrong person.
+ await db.flush()
db.add(IPLog(
+ user_id=user.id,
event="account_create",
- ip_address=_client_ip(request),
+ ip_address=client_ip(request),
detail=body.username,
))
await db.commit()
await db.refresh(user)
- # Set user_id in IPLog after commit
- await db.execute(
- IPLog.__table__.update()
- .where(IPLog.user_id == None) # noqa: E711
- .values(user_id=user.id))
- await db.commit()
-
return {"user_id": user.id}
@@ -135,7 +157,7 @@ async def login(
select(User).where(User.username == body.username))
user = result.scalar_one_or_none()
- ip = _client_ip(request)
+ ip = client_ip(request)
if not body.auth_key and not body.password:
raise HTTPException(status_code=401, detail="No credentials provided")
@@ -358,8 +380,3 @@ async def get_user_pubkeys(
return resp
-def _client_ip(request: Request) -> str:
- forwarded = request.headers.get("X-Forwarded-For")
- if forwarded:
- return forwarded.split(",")[0].strip()
- return request.client.host if request.client else "unknown"
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index 7011bf0..668ae24 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -25,7 +25,7 @@ from meshbay_hub.api.hub import router as hub_router
from meshbay_hub.api.users import router as users_router, set_config as users_set_config
from meshbay_hub.api.deps import set_admin_usernames
from meshbay_hub.api.nodes import router as nodes_router
-from meshbay_hub.api.groups import router as groups_router
+from meshbay_hub.api.groups import router as groups_router, swarm_router
from meshbay_hub.api.revocation import router as revocation_router
from meshbay_hub.api.moderation import router as moderation_router
from meshbay_hub.api.federation import router as federation_router
@@ -110,6 +110,7 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
app.include_router(users_router)
app.include_router(nodes_router)
app.include_router(groups_router)
+ app.include_router(swarm_router)
app.include_router(revocation_router)
app.include_router(moderation_router)
app.include_router(federation_router)
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