summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py70
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js38
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js57
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py6
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py318
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py79
-rw-r--r--packages/meshbay-node/tests/test_daemon.py9
-rw-r--r--packages/meshbay-node/tests/test_security_regressions.py372
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py96
11 files changed, 919 insertions, 155 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py
new file mode 100644
index 0000000..71446ca
--- /dev/null
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -0,0 +1,70 @@
+"""
+Admin operation challenge transcripts (MNP).
+
+Destructive and privileged node operations are authorized by an Ed25519 signature
+from the node operator, not by a JWT — the hub controls JWT issuance, so a JWT can
+never establish node-level authority (see draft-v4 §4.2.x).
+
+Finding H5: the node used to challenge the client with 32 raw random bytes and the
+client signed them blind. That is an unbound signing oracle — the signed message
+named no operation, no subject, no node and no time, so a signature obtained for one
+purpose was structurally valid for any other, and a malicious node could ask a user
+to sign bytes meaningful in a different protocol.
+
+The transcript below fixes that:
+
+ - a fixed domain-separation prefix, so these signatures can never collide with
+ node_auth, revocation tokens, chunk signatures or anything added later;
+ - the operation and its subject, so the client can display and verify what it is
+ authorizing before signing;
+ - the node's public key, so a signature for node A is not valid on node B;
+ - the group, so authority does not leak across groups on a multi-group node;
+ - a node-chosen nonce, so signatures cannot be replayed;
+ - a timestamp, so stale challenges can be rejected.
+
+Every field is length-prefixed. Plain concatenation would let a crafted subject
+impersonate a following field (finding L4 applies the same rule to the GEK proof).
+
+Both sides MUST build the transcript with this function — the client from the
+fields it received, the node from the state it stored. They are compared by
+producing the same bytes, never by trusting a value off the wire.
+"""
+
+ADMIN_TRANSCRIPT_PREFIX = b"meshbay:admin:v1"
+
+# Operations that require node-operator authority.
+OP_FILE_DELETE = "file_delete"
+OP_GEK_BUNDLE_STORE = "gek_bundle_store"
+
+# A challenge older than this is refused, so a signature captured from a stale
+# exchange cannot be replayed later.
+ADMIN_CHALLENGE_TTL = 120 # seconds
+
+
+def admin_transcript(
+ op: str,
+ node_pk_b64: str,
+ group_id: str,
+ subject: str,
+ nonce: bytes,
+ ts: int,
+) -> bytes:
+ """
+ Build the exact byte string signed for an admin operation.
+
+ `subject` identifies what is being acted on: a file_id for OP_FILE_DELETE, the
+ target user_id for OP_GEK_BUNDLE_STORE.
+ """
+ fields = [
+ op.encode(),
+ node_pk_b64.encode(),
+ group_id.encode(),
+ subject.encode(),
+ nonce,
+ str(ts).encode(),
+ ]
+ out = bytearray(ADMIN_TRANSCRIPT_PREFIX)
+ for field in fields:
+ out += len(field).to_bytes(4, "big")
+ out += field
+ return bytes(out)
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index ddff928..dee47ec 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -979,8 +979,10 @@ function GroupPage({ groupId, group, token, username, userId }) {
const transport = transportRef.current;
if (!transport || !transport.connected) return;
try {
+ // Signs an explicit transcript built by transport.js, not opaque bytes from
+ // the node — see MeshBayCrypto.adminTranscript and finding H5.
const signFn = (_sessionKeys && window.MeshBayKeys)
- ? (challenge) => window.MeshBayKeys.signChallenge(_sessionKeys.skEdB64, challenge)
+ ? (transcript) => window.MeshBayKeys.signBytes(_sessionKeys.skEdB64, transcript)
: null;
await transport.deleteFile(entry.id, signFn);
const indexMsg = await transport.fetchIndex();
@@ -1405,9 +1407,16 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) {
}
const gekBytes = transport.gekRaw;
- // Wrap GEK for invitee and store on node via P2P
+ // Wrap GEK for invitee and store on node via P2P.
+ // The node requires the operator's Ed25519 signature to accept the bundle
+ // (C5b), so inviting from a browser that is not the node operator's will be
+ // refused by the node — deliberately: only the operator decides what is
+ // stored on their machine.
+ const signFn = (_sessionKeys && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(_sessionKeys.skEdB64, transcript)
+ : null;
const bundle = await window.MeshBayCrypto.wrapGEK(gekBytes, pkXBytes);
- await transport.storeGekBundle(pubkeys.user_id, groupId, bundle);
+ await transport.storeGekBundle(pubkeys.user_id, groupId, bundle, signFn);
// Add member on hub (membership management only)
await hubFetch(`/v1/groups/${groupId}/members/${username}`, {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
index 5ebf624..2346fa2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
@@ -230,6 +230,42 @@ function b64encode(bytes) {
return btoa(String.fromCharCode(...bytes));
}
+// ── Admin operation transcript ───────────────────────────────────────────────
+// Mirrors meshbay_common/adminop.py::admin_transcript(). Both sides build these
+// bytes independently; they are never taken off the wire.
+//
+// Finding H5: the client used to sign 32 raw random bytes chosen by the node — a
+// blind signing oracle. It now reconstructs a domain-separated, length-prefixed
+// transcript naming the operation, subject, node and group, so the UI can show the
+// user what they are authorizing and a signature cannot be reused elsewhere.
+
+const ADMIN_TRANSCRIPT_PREFIX = new TextEncoder().encode('meshbay:admin:v1');
+
+function adminTranscript(op, nodePkB64, groupId, subject, nonceB64, ts) {
+ const enc = new TextEncoder();
+ const fields = [
+ enc.encode(op),
+ enc.encode(nodePkB64),
+ enc.encode(groupId),
+ enc.encode(subject),
+ b64decode(nonceB64),
+ enc.encode(String(ts)),
+ ];
+ let total = ADMIN_TRANSCRIPT_PREFIX.length;
+ for (const f of fields) total += 4 + f.length;
+
+ const out = new Uint8Array(total);
+ out.set(ADMIN_TRANSCRIPT_PREFIX, 0);
+ let off = ADMIN_TRANSCRIPT_PREFIX.length;
+ for (const f of fields) {
+ new DataView(out.buffer).setUint32(off, f.length, false);
+ off += 4;
+ out.set(f, off);
+ off += f.length;
+ }
+ return out;
+}
+
// ── GEK proof (HMAC-SHA256 for handshake challenge) ─────────────────────────
async function hmacGEK(gekRaw, nonceB64, offerFp, answerFp) {
@@ -249,5 +285,5 @@ async function hmacGEK(gekRaw, nonceB64, offerFp, answerFp) {
window.MeshBayCrypto = {
importGEK, deriveChunkKey, decryptChunk, decryptChunkBin, decryptFile,
generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode,
- hmacGEK,
+ hmacGEK, adminTranscript,
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index ff3da33..3d9c3c6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -246,16 +246,22 @@ async function regenerateKeys(token, username, password) {
};
}
-async function signChallenge(skEdPkcs8B64, challengeB64) {
+/**
+ * Sign an explicit byte string with the user's Ed25519 identity key.
+ *
+ * Takes bytes rather than a base64 blob from the wire: callers are expected to
+ * build the message themselves (see MeshBayCrypto.adminTranscript) so that the
+ * user's identity key is never applied to content the peer chose. Finding H5.
+ */
+async function signBytes(skEdPkcs8B64, message) {
const skRaw = Uint8Array.from(atob(skEdPkcs8B64), c => c.charCodeAt(0));
const sk = await crypto.subtle.importKey(
'pkcs8', skRaw, { name: 'Ed25519' }, false, ['sign']);
- const challenge = Uint8Array.from(atob(challengeB64), c => c.charCodeAt(0));
- const sig = await crypto.subtle.sign('Ed25519', sk, challenge);
+ const sig = await crypto.subtle.sign('Ed25519', sk, message);
return btoa(String.fromCharCode(...new Uint8Array(sig)));
}
window.MeshBayKeys = {
- registerUser, loginAndRecover, regenerateKeys, generateKeypairs, signChallenge,
+ registerUser, loginAndRecover, regenerateKeys, generateKeypairs, signBytes,
deriveAuthKey, decryptBundleWithKey,
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index ca9c60e..d636085 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -266,6 +266,39 @@ class MeshBayTransport {
return msg;
}
+ /**
+ * Authorize a privileged node operation with the user's Ed25519 identity key.
+ *
+ * The client rebuilds the signed transcript from the challenge fields and refuses
+ * to sign unless the operation and subject match what the user actually asked for.
+ * Previously the node sent 32 opaque random bytes and the client signed them
+ * blind, which let any peer obtain a signature over content of its choosing
+ * (finding H5).
+ */
+ async _authorizeAdminOp(challenge, expectedOp, expectedSubject, signFn) {
+ if (challenge.op !== expectedOp || challenge.subject !== expectedSubject) {
+ throw new Error(
+ `Refusing to sign: node asked to authorize "${challenge.op}" on ` +
+ `"${challenge.subject}", but the requested action was "${expectedOp}" ` +
+ `on "${expectedSubject}"`);
+ }
+ if (!signFn) throw new Error('Admin challenge received but no signing key available');
+
+ const transcript = window.MeshBayCrypto.adminTranscript(
+ challenge.op, challenge.node_pk, challenge.group_id,
+ challenge.subject, challenge.nonce, challenge.ts);
+
+ const signature = await signFn(transcript);
+ const ack = await this._sendAndWait({
+ type: 'admin_response',
+ v: '0.1',
+ op_id: challenge.op_id,
+ signature,
+ });
+ if (ack.type === 'error') throw new Error(ack.detail);
+ return ack;
+ }
+
async deleteFile(fileId, signFn) {
const msg = await this._sendAndWait({
type: 'file_delete',
@@ -274,16 +307,7 @@ class MeshBayTransport {
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
- if (!signFn) throw new Error('Admin challenge received but no signing key available');
- const signature = await signFn(msg.challenge);
- const ack = await this._sendAndWait({
- type: 'admin_response',
- v: '0.1',
- file_id: fileId,
- signature,
- });
- if (ack.type === 'error') throw new Error(ack.detail);
- return ack;
+ return this._authorizeAdminOp(msg, 'file_delete', fileId, signFn);
}
return msg;
}
@@ -304,7 +328,15 @@ class MeshBayTransport {
return msg;
}
- async storeGekBundle(userId, groupId, bundle) {
+ /**
+ * Store a wrapped GEK bundle on the node for a member.
+ *
+ * Node-operator operation: the node answers with an admin challenge and only the
+ * pinned operator key is accepted. Any member used to be able to write bundles —
+ * including one addressed to the operator, which the node then auto-adopted as the
+ * live group key (finding C5b).
+ */
+ async storeGekBundle(userId, groupId, bundle, signFn) {
const msg = await this._sendAndWait({
type: 'gek_bundle_store',
v: '0.1',
@@ -315,6 +347,9 @@ class MeshBayTransport {
wrapped_b64: bundle.wrapped_b64,
});
if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'gek_bundle_store', userId, signFn);
+ }
return msg;
}
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index c930e54..c75c721 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -241,7 +241,11 @@ class NodeDaemon:
groups=groups_ctx,
denylist=denylist,
)
- self._webrtc._ctx["chat_store"] = first.get("chat_store")
+ # No global chat_store here: each group's store lives in
+ # groups_ctx[gid]["chat_store"] and is resolved per session via
+ # _group_ctx(). Assigning the first group's store transport-wide
+ # sent every group's chat to one database and served it back to
+ # members of every other group (finding H1).
self._webrtc._ctx["hub_ws"] = _WsSender(hub)
self._webrtc._ctx["node_user_id"] = session.user_id
self._webrtc._ctx["audit_store"] = self._audit_store
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 13e90c8..9fb9ef2 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -28,7 +28,9 @@ import hashlib
import hmac
import logging
import os
+import re
import struct
+import time
from pathlib import Path
from typing import Any
@@ -41,6 +43,12 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
)
from meshbay_common import MNP_VERSION
+from meshbay_common.adminop import (
+ ADMIN_CHALLENGE_TTL,
+ OP_FILE_DELETE,
+ OP_GEK_BUNDLE_STORE,
+ admin_transcript,
+)
from meshbay_common.crypto import pk_to_b64
from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes
from meshbay_common.protocol import MNP
@@ -51,6 +59,16 @@ log = logging.getLogger(__name__)
CHUNK_SIZE = 1024 * 1024
MAX_MSG = 64 * 1024 * 1024
+# Upload limits (finding C5a). Uploads used to land directly in the shared root under
+# a name the client chose, overwriting whatever was already there — which both violated
+# node sovereignty and defeated the delete authorization (overwrite a file, become its
+# recorded uploader, then delete it legitimately).
+MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB per file
+UPLOAD_DIR_NAME = ".uploads"
+# Conservative allowlist: also what keeps markup out of filenames, which the node admin
+# UI used to render unescaped (finding H2).
+SAFE_UPLOAD_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._ -]{0,127}$")
+
def _extract_dtls_fingerprint(sdp: str) -> bytes:
"""Extract the DTLS SHA-256 fingerprint from SDP as raw 32 bytes."""
@@ -170,7 +188,8 @@ class WebRTCPeerSession:
self._username: str = ""
self._pk_user: str = ""
self._gek_challenge: bytes | None = None
- self._admin_challenges: dict[str, bytes] = {}
+ self._admin_ops: dict[str, dict] = {} # op_id → pending admin operation
+ self._uploads: dict[str, dict] = {} # filename → {next_index, bytes}
def _setup_channel(self, channel: RTCDataChannel) -> None:
self._channel = channel
@@ -214,7 +233,7 @@ class WebRTCPeerSession:
elif mtype == MNP.ADMIN_RESPONSE:
self._do_admin_response(msg)
elif mtype == MNP.GEK_BUNDLE_STORE:
- asyncio.ensure_future(self._do_gek_bundle_store(msg))
+ self._do_gek_bundle_store(msg)
elif mtype == MNP.KEYPAIR_BUNDLE_STORE:
asyncio.ensure_future(self._do_keypair_bundle_store(msg))
elif mtype == MNP.STREAM_REQUEST:
@@ -340,9 +359,7 @@ class WebRTCPeerSession:
self._username = self._pending_username
self._pk_user = self._pending_pk_user
- peers = self._ctx.get("_peers")
- if peers is not None:
- peers[self._user_id] = self
+ self._peer_registry()[self._user_id] = self
node_user_id = self._ctx.get("node_user_id")
log.info("WebRTC handshake OK — user=%s group=%s",
@@ -388,8 +405,21 @@ class WebRTCPeerSession:
else:
self._send({"type": MNP.GEK_BUNDLE_RESP, "v": MNP_VERSION, "found": False})
- async def _do_gek_bundle_store(self, msg: dict) -> None:
- """Store a wrapped GEK bundle for a target user (admin operation)."""
+ def _do_gek_bundle_store(self, msg: dict) -> None:
+ """
+ Request to store a wrapped GEK bundle for a target user.
+
+ Finding C5b: this used to write whatever any authenticated member sent, with
+ INSERT OR REPLACE semantics, and then auto-activate the bundle if it was
+ addressed to the node operator. Since the operator's X25519 public key is
+ public — the node even hands it out in handshake_ack — any member could wrap
+ a GEK of their own choosing for the operator and make the node adopt it,
+ locking every legitimate member out of the group and taking over the key.
+
+ Storing a bundle is now a node-operator operation gated by an Ed25519
+ challenge, and nothing arriving over MNP can activate a GEK: activation
+ happens only through the local admin UI or the CLI.
+ """
bundle_store = self._ctx.get("bundle_store")
if not bundle_store:
self._send({"type": "error", "detail": "Bundle store not available"})
@@ -405,49 +435,21 @@ class WebRTCPeerSession:
self._send({"type": "error", "detail": "Missing bundle fields"})
return
- await bundle_store.store(group_id, target_user_id, pk_eph, nonce, wrapped)
- log.info("GEK bundle stored: group=%s user=%s", group_id[:8], target_user_id[:8])
- self._audit("gek_bundle_store", f"target={target_user_id[:8]}")
+ if not self._ctx.get("admin_pk_ed25519"):
+ self._send({
+ "type": "error",
+ "detail": "No admin key pinned — bundle storage refused",
+ })
+ return
- self._send({
- "type": "ack", "v": MNP_VERSION,
- "detail": "gek_bundle_stored",
+ self._issue_admin_challenge(OP_GEK_BUNDLE_STORE, target_user_id, {
+ "group_id": group_id,
"user_id": target_user_id,
+ "pk_eph_b64": pk_eph,
+ "nonce_b64": nonce,
+ "wrapped_b64": wrapped,
})
- # Auto-activate GEK if the bundle is for the node operator
- node_user_id = self._ctx.get("node_user_id")
- if node_user_id and target_user_id == node_user_id and group_id:
- await self._try_activate_gek(group_id, target_user_id)
-
- async def _try_activate_gek(self, group_id: str, user_id: str) -> None:
- """Unwrap and activate GEK for the node when the operator's bundle arrives."""
- from meshbay_common.crypto import unwrap_gek_aes
-
- bundle_store = self._ctx.get("bundle_store")
- sk_x_raw = self._ctx.get("sk_x25519_raw")
- pk_x_raw = self._ctx.get("pk_x25519_raw")
- if not bundle_store or not sk_x_raw or not pk_x_raw:
- return
-
- bundle = await bundle_store.fetch(group_id, user_id)
- if not bundle:
- return
-
- try:
- gek = unwrap_gek_aes(bundle, sk_x_raw, pk_x_raw)
- except Exception as e:
- log.warning("Failed to unwrap GEK for auto-activation: %s", e)
- return
-
- groups = self._ctx.get("groups")
- if groups and group_id in groups:
- groups[group_id]["gek"] = gek
- log.info("GEK auto-activated for group %s", group_id[:8])
- elif "gek" in self._ctx:
- self._ctx["gek"] = gek
- log.info("GEK auto-activated (single-group mode)")
-
async def _do_keypair_bundle_fetch(self) -> None:
"""Serve the caller's encrypted keypair bundle during the handshake window."""
bundle_store = self._ctx.get("bundle_store")
@@ -508,6 +510,20 @@ class WebRTCPeerSession:
return self._ctx["groups"][self._group_id]
return self._ctx
+ def _peer_registry(self) -> dict:
+ """
+ Connected peers for THIS group only.
+
+ Finding H1: this used to live on the shared transport context, so a chat
+ message was broadcast to every peer on the node regardless of which group
+ they had authenticated to.
+ """
+ return self._group_ctx().setdefault("_peers", {})
+
+ def _user_names(self) -> dict:
+ """Display-name cache, per group — same leak as _peer_registry (H1)."""
+ return self._group_ctx().setdefault("_user_names", {})
+
def _do_index_sync(self) -> None:
ctx = self._group_ctx()
idx = ctx["index"]
@@ -599,11 +615,14 @@ class WebRTCPeerSession:
})
def _do_chat_message(self, msg: dict) -> None:
- chat_store = self._ctx.get("chat_store")
+ # Per-group store — see _peer_registry() and finding H1. Reading chat_store
+ # off the shared transport context sent every group's messages to the first
+ # group's database, and served them back to anyone on the node.
+ chat_store = self._group_ctx().get("chat_store")
payload = msg.get("payload", "")
sender_name = msg.get("sender_name", "")
if sender_name:
- self._ctx.setdefault("_user_names", {})[self._user_id] = sender_name
+ self._user_names()[self._user_id] = sender_name
if chat_store:
raw = payload.encode() if isinstance(payload, str) else payload
asyncio.ensure_future(chat_store.save_message(
@@ -614,7 +633,7 @@ class WebRTCPeerSession:
sender_name=sender_name,
))
- peers = self._ctx.get("_peers", {})
+ peers = self._peer_registry()
broadcast = {
"type": MNP.CHAT_MESSAGE,
"v": MNP_VERSION,
@@ -647,7 +666,7 @@ class WebRTCPeerSession:
self._audit("chat_message")
def _do_chat_history(self, msg: dict) -> None:
- chat_store = self._ctx.get("chat_store")
+ chat_store = self._group_ctx().get("chat_store")
if not chat_store:
self._send({
"type": MNP.CHAT_HISTORY_RESPONSE,
@@ -662,7 +681,7 @@ class WebRTCPeerSession:
async def _send_chat_history(self, chat_store, since: float, limit: int) -> None:
msgs = await chat_store.get_messages(since=since, limit=limit)
- names = self._ctx.get("_user_names", {})
+ names = self._user_names()
self._send({
"type": MNP.CHAT_HISTORY_RESPONSE,
"v": MNP_VERSION,
@@ -691,24 +710,55 @@ class WebRTCPeerSession:
self._send({"type": "error", "detail": "Missing filename or data"})
return
+ if not SAFE_UPLOAD_NAME.match(filename):
+ self._send({"type": "error", "detail": "Invalid filename"})
+ return
+
shared_root = ctx.get("shared_root")
if not shared_root:
self._send({"type": "error", "detail": "No shared directory"})
return
- upload_dir = shared_root / ".uploads"
- upload_dir.mkdir(exist_ok=True)
- safe_name = filename.replace("/", "_").replace("\\", "_").replace("..", "_")
- tmp_path = upload_dir / f"{safe_name}.part"
+ # Per-user quarantine: a member can only ever write inside their own directory,
+ # so they cannot overwrite the operator's files or another member's (C5a).
+ rel_dir = f"{UPLOAD_DIR_NAME}/{self._user_id}"
+ user_dir = shared_root / UPLOAD_DIR_NAME / self._user_id
+ user_dir.mkdir(parents=True, exist_ok=True)
+ tmp_path = user_dir / f"{filename}.part"
+ final_path = user_dir / filename
+
+ state = self._uploads.get(filename)
+ if chunk_index == 0:
+ if final_path.exists():
+ self._send({"type": "error", "detail": "File already exists"})
+ return
+ state = {"next_index": 0, "bytes": 0}
+ self._uploads[filename] = state
+ elif state is None:
+ self._send({"type": "error", "detail": "Upload not started"})
+ return
+
+ # Reject out-of-order or replayed chunks — otherwise chunk_index>0 appends
+ # blindly to whatever .part file is already on disk.
+ if chunk_index != state["next_index"]:
+ self._send({"type": "error", "detail": "Unexpected chunk index"})
+ return
if isinstance(data, str):
chunk_bytes = base64.b64decode(data)
else:
chunk_bytes = bytes(data)
- mode = "ab" if chunk_index > 0 else "wb"
- with open(tmp_path, mode) as f:
+ if state["bytes"] + len(chunk_bytes) > MAX_UPLOAD_BYTES:
+ self._uploads.pop(filename, None)
+ tmp_path.unlink(missing_ok=True)
+ self._send({"type": "error", "detail": "Upload exceeds size limit"})
+ return
+
+ with open(tmp_path, "wb" if chunk_index == 0 else "ab") as f:
f.write(chunk_bytes)
+ state["next_index"] = chunk_index + 1
+ state["bytes"] += len(chunk_bytes)
self._send({
"type": MNP.FILE_UPLOAD_ACK,
@@ -718,19 +768,20 @@ class WebRTCPeerSession:
})
if chunk_index + 1 >= total_chunks:
- final_path = shared_root / safe_name
+ self._uploads.pop(filename, None)
tmp_path.rename(final_path)
- log.info("Upload complete: %s (%d chunks)", safe_name, total_chunks)
- self._audit("file_upload", safe_name)
- self._register_uploader(ctx, safe_name)
+ log.info("Upload complete: %s (%d chunks, %d bytes)",
+ filename, total_chunks, state["bytes"])
+ self._audit("file_upload", f"{rel_dir}/{filename}")
+ self._register_uploader(ctx, rel_dir, filename)
- def _register_uploader(self, ctx: dict, filename: str) -> None:
- """Tag the index entry with the uploader's user_id after upload completes."""
+ def _register_uploader(self, ctx: dict, rel_dir: str, filename: str) -> None:
+ """Tag the index entry with the uploader's identity after upload completes."""
idx = ctx.get("index")
if not idx:
return
for entry in idx.entries:
- if entry.name == filename and entry.path == "":
+ if entry.name == filename and entry.path == rel_dir:
entry.uploader_id = self._user_id
entry.uploader_pk = self._pk_user
return
@@ -753,22 +804,64 @@ class WebRTCPeerSession:
self._send({"type": "error", "detail": "No authorized key for deletion"})
return
- challenge = os.urandom(32)
- self._admin_challenges[file_id] = challenge
+ self._issue_admin_challenge(OP_FILE_DELETE, file_id)
+
+ # ── Admin operation challenge/response (finding H5) ──────────────────────
+
+ def _node_pk_b64(self) -> str:
+ return pk_to_b64(self._ctx["sk_node"].public_key())
+
+ def _issue_admin_challenge(
+ self, op: str, subject: str, payload: dict | None = None,
+ ) -> None:
+ """
+ Ask the client to authorize `op` on `subject` with its Ed25519 identity key.
+
+ The client is sent the transcript *fields*, not opaque bytes, so it can
+ rebuild and inspect what it signs. The node keeps the authoritative copy and
+ rebuilds the transcript itself at verification time — nothing signed is ever
+ taken from the response message.
+ """
+ nonce = os.urandom(32)
+ ts = int(time.time())
+ op_id = base64.b64encode(os.urandom(16)).decode()
+ self._admin_ops[op_id] = {
+ "op": op, "subject": subject, "nonce": nonce, "ts": ts,
+ "payload": payload or {},
+ }
self._send({
"type": MNP.ADMIN_CHALLENGE,
"v": MNP_VERSION,
- "challenge": base64.b64encode(challenge).decode(),
- "file_id": file_id,
+ "op_id": op_id,
+ "op": op,
+ "subject": subject,
+ "nonce": base64.b64encode(nonce).decode(),
+ "ts": ts,
+ "node_pk": self._node_pk_b64(),
+ "group_id": self._group_id or "",
})
+ @staticmethod
+ def _verify_sig(pk: Ed25519PublicKey | None, transcript: bytes, sig: bytes) -> bool:
+ if pk is None:
+ return False
+ try:
+ pk.verify(sig, transcript)
+ return True
+ except Exception:
+ return False
+
def _do_admin_response(self, msg: dict) -> None:
- file_id = msg.get("file_id", "")
+ op_id = msg.get("op_id", "")
sig_b64 = msg.get("signature", "")
- challenge = self._admin_challenges.pop(file_id, None)
- if not challenge:
- self._send({"type": "error", "detail": "No pending admin challenge"})
+ pending = self._admin_ops.pop(op_id, None)
+ if not pending:
+ self._send({"type": "error", "detail": "No pending admin operation"})
+ return
+
+ if time.time() - pending["ts"] > ADMIN_CHALLENGE_TTL:
+ self._send({"type": "error", "detail": "Admin challenge expired"})
return
try:
@@ -777,40 +870,80 @@ class WebRTCPeerSession:
self._send({"type": "error", "detail": "Invalid signature encoding"})
return
+ transcript = admin_transcript(
+ op=pending["op"],
+ node_pk_b64=self._node_pk_b64(),
+ group_id=self._group_id or "",
+ subject=pending["subject"],
+ nonce=pending["nonce"],
+ ts=pending["ts"],
+ )
+
+ if pending["op"] == OP_FILE_DELETE:
+ self._admin_exec_file_delete(pending, transcript, sig_bytes)
+ elif pending["op"] == OP_GEK_BUNDLE_STORE:
+ asyncio.ensure_future(
+ self._admin_exec_bundle_store(pending, transcript, sig_bytes))
+ else:
+ self._send({"type": "error", "detail": "Unknown admin operation"})
+
+ def _admin_exec_file_delete(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ file_id = pending["subject"]
ctx = self._group_ctx()
entry = ctx["index"].get_entry(file_id)
if not entry:
self._send({"type": "error", "detail": "File not found"})
return
- verified = False
-
- # Try admin key (locally pinned)
- admin_pk = self._ctx.get("admin_pk_ed25519")
- if admin_pk:
- try:
- admin_pk.verify(sig_bytes, challenge)
- verified = True
- except Exception:
- pass
-
- # Try uploader key (stored at upload time)
- if not verified and entry.uploader_pk:
+ uploader_pk = None
+ if entry.uploader_pk:
try:
- uploader_key = Ed25519PublicKey.from_public_bytes(
+ uploader_pk = Ed25519PublicKey.from_public_bytes(
base64.b64decode(entry.uploader_pk))
- uploader_key.verify(sig_bytes, challenge)
- verified = True
except Exception:
- pass
+ uploader_pk = None
- if not verified:
+ # Node operator, or the user who uploaded this file — verified by the key
+ # recorded at upload time, never by a JWT claim (the hub controls those).
+ if not (self._verify_sig(self._ctx.get("admin_pk_ed25519"), transcript, sig)
+ or self._verify_sig(uploader_pk, transcript, sig)):
self._send({"type": "error", "detail": "Signature verification failed"})
self._audit("admin_auth_failed", f"file_delete:{file_id[:16]}")
return
self._exec_file_delete(ctx, file_id, entry)
+ async def _admin_exec_bundle_store(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ # Node operator only. A group admin who does not run the node has no
+ # authority over what this node stores (draft-v4 §4.2.x, deny by default).
+ if not self._verify_sig(self._ctx.get("admin_pk_ed25519"), transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"gek_bundle_store:{pending['subject'][:16]}")
+ return
+
+ payload = pending["payload"]
+ bundle_store = self._ctx.get("bundle_store")
+ if not bundle_store:
+ self._send({"type": "error", "detail": "Bundle store not available"})
+ return
+
+ await bundle_store.store(
+ payload["group_id"], payload["user_id"],
+ payload["pk_eph_b64"], payload["nonce_b64"], payload["wrapped_b64"],
+ )
+ log.info("GEK bundle stored: group=%s user=%s",
+ payload["group_id"][:8], payload["user_id"][:8])
+ self._audit("gek_bundle_store", f"target={payload['user_id'][:8]}")
+ self._send({
+ "type": "ack", "v": MNP_VERSION,
+ "detail": "gek_bundle_stored",
+ "user_id": payload["user_id"],
+ })
+
def _exec_file_delete(self, ctx: dict, file_id: str, entry) -> None:
file_path = ctx["shared_root"] / entry.path / entry.name
if file_path.exists():
@@ -914,9 +1047,8 @@ class WebRTCPeerSession:
async def close(self) -> None:
self._audit("disconnect")
- peers = self._ctx.get("_peers")
- if peers and self._user_id:
- peers.pop(self._user_id, None)
+ if self._user_id:
+ self._peer_registry().pop(self._user_id, None)
await self._pc.close()
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index 31deb66..d2c3429 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -16,6 +16,7 @@ import base64
import json
import logging
import time
+from html import escape
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
@@ -35,6 +36,33 @@ def create_ui_app(state: dict) -> FastAPI:
redoc_url=None,
)
+ @app.middleware("http")
+ async def _security_headers(request, call_next):
+ """
+ Defence in depth behind the escaping fixes for H2. This UI is unauthenticated
+ on loopback, so script execution here equals full control of the node admin API.
+
+ Note what this does and does not do: the page relies on inline <script>, so
+ script-src must allow 'unsafe-inline' and CSP therefore does NOT prevent an
+ injected script from running. Escaping is the actual fix. What CSP buys is
+ containment — connect-src/img-src/form-action 'self'|'none' stop an injected
+ script from exfiltrating the audit log or config to an external host.
+ """
+ response = await call_next(request)
+ response.headers["Content-Security-Policy"] = (
+ "default-src 'none'; "
+ "style-src 'unsafe-inline'; "
+ "script-src 'unsafe-inline'; "
+ "connect-src 'self'; "
+ "img-src 'self' data:; "
+ "form-action 'none'; "
+ "frame-ancestors 'none'; "
+ "base-uri 'none'"
+ )
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ response.headers["Referrer-Policy"] = "no-referrer"
+ return response
+
# ── JSON API ─────────────────────────────────────────────────────────────
@app.get("/api/status")
@@ -353,12 +381,16 @@ def _render_page(state: dict) -> str:
fcount = idx.count if idx else 0
total_size = sum(e.size for e in idx.entries) if idx else 0
+ # Everything interpolated below is attacker-controlled: filenames come from
+ # uploads by any group member. Rendering them raw was a stored XSS into the
+ # unauthenticated localhost admin UI, i.e. full control of the node admin API
+ # from the operator's browser (finding H2).
file_rows = ""
if idx:
for e in sorted(idx.entries, key=lambda x: x.name):
file_rows += (
- f"<tr><td>{e.name}</td><td>{e.type}</td>"
- f"<td>{_fmt_size(e.size)}</td><td>{e.path or '/'}</td></tr>"
+ f"<tr><td>{escape(e.name)}</td><td>{escape(e.type)}</td>"
+ f"<td>{_fmt_size(e.size)}</td><td>{escape(e.path or '/')}</td></tr>"
)
has_gek = bool(ctx.get("gek"))
@@ -382,14 +414,14 @@ def _render_page(state: dict) -> str:
groups_html += f"""
<div class="card">
- <h3>{name}
- <span class="badge" style="background:#6366f1">{vis}</span>
+ <h3>{escape(str(name))}
+ <span class="badge" style="background:#6366f1">{escape(str(vis))}</span>
{gek_badge}
</h3>
- <p><b>Directory:</b> <code>{shared}</code></p>
+ <p><b>Directory:</b> <code>{escape(str(shared))}</code></p>
<p><b>Files:</b> {fcount} &mdash; <b>Total:</b> {_fmt_size(total_size)}</p>
{gek_action}
- <p class="muted">ID: {gid}</p>
+ <p class="muted">ID: {escape(gid)}</p>
<details><summary>File list</summary>
<table>
<thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Path</th></tr></thead>
@@ -405,10 +437,10 @@ def _render_page(state: dict) -> str:
from meshbay_node.transport.webrtc_server import _get_remote_ip
ip = session._remote_ip or _get_remote_ip(session._pc)
peers_html += (
- f"<tr><td>{session._username or session._user_id or '—'}</td>"
- f"<td>{ip or '—'}</td>"
- f"<td>{session._group_id[:8] if session._group_id else '—'}</td>"
- f"<td>{session._pc.connectionState}</td></tr>"
+ f"<tr><td>{escape(session._username or session._user_id or '—')}</td>"
+ f"<td>{escape(ip or '—')}</td>"
+ f"<td>{escape(session._group_id[:8] if session._group_id else '—')}</td>"
+ f"<td>{escape(session._pc.connectionState)}</td></tr>"
)
if not peers_html:
peers_html = '<tr><td colspan="4" class="muted">No connected peers</td></tr>'
@@ -625,14 +657,25 @@ async function load() {
const data = await r.json();
const tbody = document.getElementById('tbody');
document.getElementById('count').textContent = data.entries.length + ' entries';
- tbody.innerHTML = data.entries.map(e => {
- const t = new Date(e.timestamp * 1000).toLocaleString();
- return '<tr><td>' + t + '</td><td>' + e.event + '</td><td>'
- + (e.username || e.user_id.slice(0,8)) + '</td><td>'
- + (e.ip || '—') + '</td><td>'
- + (e.group_id ? e.group_id.slice(0,8) : '—') + '</td><td>'
- + (e.detail || '') + '</td></tr>';
- }).join('');
+ // textContent, not innerHTML: e.detail carries filenames chosen by group members
+ // (finding H2). Building this row with string concatenation was a stored XSS.
+ tbody.replaceChildren(...data.entries.map(e => {
+ const tr = document.createElement('tr');
+ const cells = [
+ new Date(e.timestamp * 1000).toLocaleString(),
+ e.event,
+ e.username || (e.user_id || '').slice(0, 8),
+ e.ip || '—',
+ e.group_id ? e.group_id.slice(0, 8) : '—',
+ e.detail || '',
+ ];
+ for (const value of cells) {
+ const td = document.createElement('td');
+ td.textContent = value;
+ tr.appendChild(td);
+ }
+ return tr;
+ }));
}
document.getElementById('eventFilter').onchange = load;
document.getElementById('limitSelect').onchange = load;
diff --git a/packages/meshbay-node/tests/test_daemon.py b/packages/meshbay-node/tests/test_daemon.py
index e899639..9ebf945 100644
--- a/packages/meshbay-node/tests/test_daemon.py
+++ b/packages/meshbay-node/tests/test_daemon.py
@@ -121,7 +121,14 @@ async def test_daemon_creates_chat_store(tmp_path, node_config, gek, hub_pk_pem)
assert daemon._chat_stores[group_id]._db is not None
if daemon._webrtc:
- assert "chat_store" in daemon._webrtc._ctx
+ # Finding H1: chat_store must live in the per-group context, never on
+ # the shared transport context. Hoisting the first group's store
+ # transport-wide sent every group's chat to one database and served it
+ # back to members of every other group.
+ assert "chat_store" not in daemon._webrtc._ctx
+ groups_ctx = daemon._webrtc._ctx["groups"]
+ assert groups_ctx[group_id]["chat_store"] is daemon._chat_stores[group_id]
+
assert "hub_ws" in daemon._webrtc._ctx
assert "node_user_id" in daemon._webrtc._ctx
assert daemon._webrtc._ctx["node_user_id"] == "user123"
diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py
new file mode 100644
index 0000000..a480d21
--- /dev/null
+++ b/packages/meshbay-node/tests/test_security_regressions.py
@@ -0,0 +1,372 @@
+"""
+Phase 11.5 security regression tests.
+
+Each test here encodes a finding from `second-review.md`. They are negative tests:
+they assert that an attack does NOT work. The pre-11.5 code passed 209 feature
+tests while every one of these attacks succeeded — the suite only ever exercised
+happy paths, never an authorization boundary.
+
+If one of these starts failing, a fix has been reverted. Do not "fix" the test.
+"""
+
+import base64
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_common.protocol import IndexEntry
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+
+def _safe_name_re():
+ """
+ Imported lazily so that a missing allowlist fails the two tests that need it,
+ rather than aborting collection of the whole module and hiding every other
+ finding's result.
+ """
+ from meshbay_node.transport.webrtc_server import SAFE_UPLOAD_NAME
+ return SAFE_UPLOAD_NAME
+
+
+# ── C1: the unauthenticated HTTP file API must stay deleted ───────────────────
+
+def test_http_file_api_is_gone():
+ """
+ C1: transport/http_server.py served GET /index and GET /file/{id} on 0.0.0.0
+ with no authentication, for private groups too. It was deleted rather than
+ patched. Re-adding any module that serves file bytes outside the MNP handshake
+ reintroduces a full confidentiality bypass.
+ """
+ with pytest.raises(ImportError):
+ import meshbay_node.transport.http_server # noqa: F401
+
+ import meshbay_node.transport as transport
+ assert not hasattr(transport, "create_http_app")
+
+
+def test_tcp_transport_is_gone():
+ """C6: the TCP+TLS server accepted a bare JWT with no GEK proof."""
+ with pytest.raises(ImportError):
+ import meshbay_node.transport.server # noqa: F401
+
+ import meshbay_node.transport as transport
+ assert not hasattr(transport, "ChunkServer")
+
+
+def test_daemon_exposes_no_plaintext_listener():
+ """
+ C1: the daemon must not bind anything that serves content without a handshake.
+ NodeConfig no longer carries an HTTP port at all.
+ """
+ from meshbay_node.config import NodeConfig, GroupConfig
+
+ assert "http_port" not in NodeConfig.__dataclass_fields__
+ assert "http_port" not in GroupConfig.__dataclass_fields__
+ assert "port" not in NodeConfig.__dataclass_fields__
+
+
+# ── C5a: upload filename allowlist ───────────────────────────────────────────
+
+@pytest.mark.parametrize("name", [
+ "../../etc/passwd",
+ "..\\windows\\system32",
+ "/absolute/path",
+ "<img src=x onerror=alert(1)>", # the H2 stored-XSS vector
+ 'name";DROP TABLE x;--',
+ ".hidden",
+ "",
+ "a" * 200,
+ "file\x00.mp4",
+ "sub/dir/file.mp4",
+])
+def test_upload_rejects_unsafe_filenames(name):
+ """C5a/H2: only a conservative allowlist may reach the filesystem."""
+ assert not _safe_name_re().match(name), f"should be rejected: {name!r}"
+
+
+@pytest.mark.parametrize("name", [
+ "movie.mp4",
+ "My Holiday Video.mkv",
+ "report-2026.pdf",
+ "track_01.flac",
+])
+def test_upload_accepts_ordinary_filenames(name):
+ """The allowlist must not break normal use."""
+ assert _safe_name_re().match(name), f"should be accepted: {name!r}"
+
+
+def _session(tmp_path: Path, user_id: str) -> WebRTCPeerSession:
+ """A peer session wired to a real shared root, with sending stubbed out."""
+ shared_root = tmp_path / "shared"
+ shared_root.mkdir(exist_ok=True)
+ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
+ ctx = {"shared_root": shared_root, "index": index, "sk_node": index.sk_node}
+
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = ctx
+ session._group_id = None
+ session._user_id = user_id
+ session._pk_user = ""
+ session._uploads = {}
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ return session
+
+
+def test_upload_cannot_overwrite_another_members_file(tmp_path):
+ """
+ C5a: uploads used to land in the shared root under a client-chosen name and
+ overwrite whatever was there. That let any member destroy the operator's files,
+ and — by becoming the recorded uploader of the replaced file — delete them
+ through the uploader path, bypassing the Ed25519 admin challenge entirely.
+ """
+ victim = _session(tmp_path, "victim-user")
+ shared_root = victim._ctx["shared_root"]
+
+ original = shared_root / "important.mp4"
+ original.write_bytes(b"operator's original content")
+
+ attacker = _session(tmp_path, "attacker-user")
+ attacker._do_file_upload({
+ "filename": "important.mp4",
+ "chunk_index": 0,
+ "total_chunks": 1,
+ "data": base64.b64encode(b"attacker content").decode(),
+ })
+
+ assert original.read_bytes() == b"operator's original content"
+ uploaded = shared_root / ".uploads" / "attacker-user" / "important.mp4"
+ assert uploaded.exists(), "upload should be quarantined, not dropped"
+ assert uploaded.read_bytes() == b"attacker content"
+
+
+def test_upload_rejects_out_of_order_chunks(tmp_path):
+ """C5a: chunk_index > 0 used to append blindly to any .part file on disk."""
+ session = _session(tmp_path, "user-1")
+ session._do_file_upload({
+ "filename": "movie.mp4", "chunk_index": 3, "total_chunks": 5,
+ "data": base64.b64encode(b"spliced").decode(),
+ })
+ assert any(m.get("type") == "error" for m in session.sent)
+
+
+def test_upload_second_attempt_cannot_replace_own_completed_file(tmp_path):
+ """C5a: even the original uploader goes through a fresh name, not an overwrite."""
+ session = _session(tmp_path, "user-1")
+ payload = {"filename": "movie.mp4", "chunk_index": 0, "total_chunks": 1,
+ "data": base64.b64encode(b"first").decode()}
+ session._do_file_upload(dict(payload))
+ session.sent.clear()
+
+ session._do_file_upload(dict(payload))
+ assert any(m.get("type") == "error" for m in session.sent)
+ stored = session._ctx["shared_root"] / ".uploads" / "user-1" / "movie.mp4"
+ assert stored.read_bytes() == b"first"
+
+
+# ── H1: group isolation ──────────────────────────────────────────────────────
+
+def test_chat_store_and_peers_are_per_group(tmp_path):
+ """
+ H1: chat_store and the peer registry were read from the shared transport
+ context, so on a multi-group node every group's messages went to the first
+ group's database and were served back to members of every other group.
+ """
+ index_a = GroupIndex(group_id="a" * 32, sk_node=Ed25519PrivateKey.generate())
+ index_b = GroupIndex(group_id="b" * 32, sk_node=Ed25519PrivateKey.generate())
+ groups = {
+ "a" * 32: {"chat_store": "STORE_A", "index": index_a, "shared_root": tmp_path},
+ "b" * 32: {"chat_store": "STORE_B", "index": index_b, "shared_root": tmp_path},
+ }
+ ctx = {"groups": groups}
+
+ sess_a = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ sess_a._ctx, sess_a._group_id, sess_a._user_id = ctx, "a" * 32, "alice"
+
+ sess_b = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ sess_b._ctx, sess_b._group_id, sess_b._user_id = ctx, "b" * 32, "bob"
+
+ assert sess_a._group_ctx()["chat_store"] == "STORE_A"
+ assert sess_b._group_ctx()["chat_store"] == "STORE_B"
+
+ sess_a._peer_registry()["alice"] = sess_a
+ sess_b._peer_registry()["bob"] = sess_b
+
+ # Alice's broadcast target set must not contain Bob, who is in another group.
+ assert "bob" not in sess_a._peer_registry()
+ assert "alice" not in sess_b._peer_registry()
+
+ sess_a._user_names()["alice"] = "Alice"
+ assert "alice" not in sess_b._user_names()
+
+
+def test_daemon_sets_no_global_chat_store(tmp_path):
+ """H1: the daemon must not hoist one group's chat store onto the transport."""
+ source = (Path(__file__).parent.parent
+ / "src" / "meshbay_node" / "daemon.py").read_text()
+ assert '_ctx["chat_store"]' not in source, (
+ "daemon must not assign a transport-wide chat_store — it leaks chat "
+ "across groups (H1)"
+ )
+
+
+# ── H2: node admin UI escaping ───────────────────────────────────────────────
+
+def test_gek_bundle_store_requires_admin_challenge(tmp_path):
+ """
+ C5b: gek_bundle_store used to write whatever any authenticated member sent.
+ It must now answer with a challenge and store nothing until a valid
+ node-operator signature arrives.
+ """
+ session = _session(tmp_path, "ordinary-member")
+ session._group_id = None
+ session._admin_ops = {}
+ session._ctx["admin_pk_ed25519"] = Ed25519PrivateKey.generate().public_key()
+
+ stored = []
+
+ class _Store:
+ async def store(self, *args):
+ stored.append(args)
+
+ session._ctx["bundle_store"] = _Store()
+ session._do_gek_bundle_store({
+ "user_id": "victim", "group_id": "g" * 32,
+ "pk_eph_b64": "AA==", "nonce_b64": "AA==", "wrapped_b64": "AA==",
+ })
+
+ assert stored == [], "bundle written without operator authorization (C5b)"
+ assert any(m.get("type") == "admin_challenge" for m in session.sent)
+
+
+def test_gek_bundle_store_refused_without_pinned_admin_key(tmp_path):
+ """C5b: deny by default — no pinned key means no privileged operation."""
+ session = _session(tmp_path, "ordinary-member")
+ session._group_id = None
+ session._admin_ops = {}
+ session._ctx["bundle_store"] = object()
+
+ session._do_gek_bundle_store({
+ "user_id": "victim", "group_id": "g" * 32,
+ "pk_eph_b64": "AA==", "nonce_b64": "AA==", "wrapped_b64": "AA==",
+ })
+ assert any(m.get("type") == "error" for m in session.sent)
+
+
+def test_gek_auto_activation_is_gone():
+ """
+ C5b: the node used to unwrap and adopt any bundle addressed to the operator.
+ Since the operator's X25519 public key is public, any member could hand the
+ node a GEK of their choosing. Nothing arriving over MNP may set a live GEK.
+ """
+ source = (Path(__file__).parent.parent / "src" / "meshbay_node"
+ / "transport" / "webrtc_server.py").read_text()
+ assert "_try_activate_gek" not in source
+ assert 'unwrap_gek_aes' not in source, (
+ "the MNP path must not unwrap a GEK — activation is local-admin only"
+ )
+
+
+# ── H5: admin challenge is bound, not a blind signing oracle ─────────────────
+
+def _transcript(**kw):
+ from meshbay_common.adminop import admin_transcript
+ base = dict(op="file_delete", node_pk_b64="NODEPK", group_id="g" * 32,
+ subject="file-1", nonce=b"\x01" * 32, ts=1_700_000_000)
+ base.update(kw)
+ return admin_transcript(**base)
+
+
+def test_admin_transcript_is_domain_separated():
+ """H5: signatures here can never be valid in another MeshBay protocol."""
+ assert _transcript().startswith(b"meshbay:admin:v1")
+
+
+@pytest.mark.parametrize("field,value", [
+ ("op", "gek_bundle_store"),
+ ("subject", "file-2"),
+ ("node_pk_b64", "OTHERNODE"),
+ ("group_id", "h" * 32),
+ ("nonce", b"\x02" * 32),
+ ("ts", 1_700_000_001),
+])
+def test_admin_transcript_binds_every_field(field, value):
+ """
+ H5: a signature must not carry over to another operation, subject, node,
+ group, challenge or moment in time.
+ """
+ assert _transcript() != _transcript(**{field: value}), (
+ f"transcript ignores {field} — signature would be reusable"
+ )
+
+
+def test_admin_transcript_is_unambiguous():
+ """
+ H5/L4: fields are length-prefixed. With plain concatenation a crafted subject
+ could impersonate the following field and two different operations would
+ produce identical signed bytes.
+ """
+ a = _transcript(subject="file-1", group_id="g")
+ b = _transcript(subject="1", group_id="gfile-")
+ assert a != b, "concatenation is ambiguous — length prefixes missing"
+
+
+def test_admin_signature_does_not_transfer_between_operations(tmp_path):
+ """
+ H5: the concrete attack. A signature collected to delete a file must not
+ authorize storing a GEK bundle.
+ """
+ from meshbay_common.adminop import OP_FILE_DELETE, OP_GEK_BUNDLE_STORE
+
+ sk_admin = Ed25519PrivateKey.generate()
+ delete_transcript = _transcript(op=OP_FILE_DELETE)
+ signature = sk_admin.sign(delete_transcript)
+
+ store_transcript = _transcript(op=OP_GEK_BUNDLE_STORE)
+ with pytest.raises(Exception):
+ sk_admin.public_key().verify(signature, store_transcript)
+
+
+def test_admin_challenge_expires(tmp_path):
+ """H5: a stale challenge must not be usable."""
+ import time as _time
+ from meshbay_common.adminop import ADMIN_CHALLENGE_TTL, OP_FILE_DELETE
+
+ session = _session(tmp_path, "operator")
+ session._group_id = None
+ session._admin_ops = {
+ "op-1": {
+ "op": OP_FILE_DELETE, "subject": "file-1", "nonce": b"\x00" * 32,
+ "ts": int(_time.time()) - ADMIN_CHALLENGE_TTL - 5, "payload": {},
+ }
+ }
+ session._do_admin_response({"op_id": "op-1", "signature": ""})
+ assert any(m.get("type") == "error" and "expired" in m.get("detail", "").lower()
+ for m in session.sent)
+
+
+def test_admin_ui_escapes_filenames(tmp_path):
+ """
+ H2: filenames are chosen by any group member and were rendered into the
+ localhost admin UI unescaped, giving script execution against an
+ unauthenticated admin API.
+ """
+ from meshbay_node.ui.app import _render_page
+
+ payload = '<img src=x onerror="fetch(1)">'
+ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
+ index.add_entry(IndexEntry(
+ id="0" * 64, name=payload, path="", size=1, type="video", added_at=0,
+ ))
+
+ html = _render_page({
+ "status": "running",
+ "groups_ctx": {"g" * 32: {"index": index, "shared_root": tmp_path}},
+ "indexes": {"g" * 32: index},
+ })
+
+ assert payload not in html, "filename rendered unescaped — stored XSS (H2)"
+ assert "&lt;img" in html, "filename should appear escaped"
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py
index 693a68b..d57f4f5 100644
--- a/packages/meshbay-node/tests/test_webrtc_transport.py
+++ b/packages/meshbay-node/tests/test_webrtc_transport.py
@@ -32,6 +32,11 @@ from meshbay_common.crypto import (
)
from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes
from meshbay_common.protocol import MNP
+from meshbay_common.adminop import (
+ OP_FILE_DELETE,
+ OP_GEK_BUNDLE_STORE,
+ admin_transcript,
+)
from meshbay_node.bundle_store import BundleStore
from meshbay_node.indexer import DirectoryIndexer
from meshbay_node.transport.webrtc_server import WebRTCTransport
@@ -86,6 +91,21 @@ def _make_jwt(sk_hub, groups=None, pk_user="test"):
}, sk_pem, algorithm="EdDSA")
+def _transcript_from(challenge_msg: dict) -> bytes:
+ """
+ Rebuild the signed transcript from an admin_challenge, the way a real client
+ does — from the announced fields, never from opaque bytes on the wire (H5).
+ """
+ return admin_transcript(
+ op=challenge_msg["op"],
+ node_pk_b64=challenge_msg["node_pk"],
+ group_id=challenge_msg["group_id"],
+ subject=challenge_msg["subject"],
+ nonce=base64.b64decode(challenge_msg["nonce"]),
+ ts=challenge_msg["ts"],
+ )
+
+
def _pack(obj: dict) -> bytes:
data = msgpack.packb(obj, use_bin_type=True)
return struct.pack(">I", len(data)) + data
@@ -783,13 +803,13 @@ async def test_webrtc_admin_challenge_response(sk_node, sk_hub, gek, shared_dir)
challenge_msg = await asyncio.wait_for(received.get(), timeout=5.0)
assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE
- assert challenge_msg["file_id"] == entry.id
+ assert challenge_msg["op"] == OP_FILE_DELETE
+ assert challenge_msg["subject"] == entry.id
- challenge = base64.b64decode(challenge_msg["challenge"])
- signature = sk_admin.sign(challenge)
+ signature = sk_admin.sign(_transcript_from(challenge_msg))
channel.send(_pack({
"type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION,
- "file_id": entry.id,
+ "op_id": challenge_msg["op_id"],
"signature": base64.b64encode(signature).decode(),
}))
@@ -832,11 +852,10 @@ async def test_webrtc_admin_bad_signature_rejected(sk_node, sk_hub, gek, shared_
challenge_msg = await asyncio.wait_for(received.get(), timeout=5.0)
assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE
- challenge = base64.b64decode(challenge_msg["challenge"])
- bad_sig = sk_attacker.sign(challenge)
+ bad_sig = sk_attacker.sign(_transcript_from(challenge_msg))
channel.send(_pack({
"type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION,
- "file_id": entry.id,
+ "op_id": challenge_msg["op_id"],
"signature": base64.b64encode(bad_sig).decode(),
}))
@@ -914,14 +933,14 @@ async def test_webrtc_uploader_delete_requires_challenge(sk_node, sk_hub, gek, s
challenge_msg = await asyncio.wait_for(received.get(), timeout=5.0)
assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE
- assert challenge_msg["file_id"] == entry.id
+ assert challenge_msg["op"] == OP_FILE_DELETE
+ assert challenge_msg["subject"] == entry.id
# Sign with uploader's Ed25519 key
- challenge = base64.b64decode(challenge_msg["challenge"])
- signature = sk_uploader.sign(challenge)
+ signature = sk_uploader.sign(_transcript_from(challenge_msg))
channel.send(_pack({
"type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION,
- "file_id": entry.id,
+ "op_id": challenge_msg["op_id"],
"signature": base64.b64encode(signature).decode(),
}))
@@ -979,11 +998,10 @@ async def test_webrtc_uploader_impersonation_blocked(sk_node, sk_hub, gek, share
assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE
# Sign with user B's key (wrong key)
- challenge = base64.b64decode(challenge_msg["challenge"])
- bad_sig = sk_user_b.sign(challenge)
+ bad_sig = sk_user_b.sign(_transcript_from(challenge_msg))
channel.send(_pack({
"type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION,
- "file_id": entry.id,
+ "op_id": challenge_msg["op_id"],
"signature": base64.b64encode(bad_sig).decode(),
}))
@@ -1031,7 +1049,11 @@ async def test_gek_bundle_store_and_fetch(sk_node, sk_hub, gek, shared_dir,
)
transport._ctx["bundle_store"] = bundle_store
- # Connect as admin and store a GEK bundle for user-002
+ # Storing a bundle is a node-operator operation (C5b): the node challenges and
+ # only the pinned admin key is accepted.
+ sk_admin = Ed25519PrivateKey.generate()
+ transport._ctx["admin_pk_ed25519"] = sk_admin.public_key()
+
pc_admin, ch_admin, q_admin = await _setup_peer(
transport, sk_hub, gek, "peer-admin")
@@ -1047,6 +1069,19 @@ async def test_gek_bundle_store_and_fetch(sk_node, sk_hub, gek, shared_dir,
"nonce_b64": bundle["nonce_b64"],
"wrapped_b64": bundle["wrapped_b64"],
}))
+
+ challenge_msg = await asyncio.wait_for(q_admin.get(), timeout=5.0)
+ assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE
+ assert challenge_msg["op"] == OP_GEK_BUNDLE_STORE
+ assert challenge_msg["subject"] == "user-002"
+
+ signature = sk_admin.sign(_transcript_from(challenge_msg))
+ ch_admin.send(_pack({
+ "type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION,
+ "op_id": challenge_msg["op_id"],
+ "signature": base64.b64encode(signature).decode(),
+ }))
+
ack = await asyncio.wait_for(q_admin.get(), timeout=5.0)
assert ack["type"] == "ack"
assert ack["detail"] == "gek_bundle_stored"
@@ -1313,9 +1348,18 @@ async def test_keypair_bundle_fetch_not_found(sk_node, sk_hub, gek, shared_dir,
@pytest.mark.asyncio
-async def test_gek_auto_activate_on_node_bundle_store(sk_node, sk_hub, gek, shared_dir,
+async def test_gek_not_auto_activated_on_bundle_store(sk_node, sk_hub, gek, shared_dir,
tmp_path, x25519_keypair):
- """Storing the node operator's GEK bundle auto-activates GEK (AES variant)."""
+ """
+ A GEK bundle arriving over MNP must NOT become the node's live key (C5b).
+
+ This test previously asserted the opposite: storing a bundle addressed to the
+ node operator auto-activated it, with no signature required. Because the
+ operator's X25519 public key is public — the node publishes it in handshake_ack
+ — any group member could wrap a key of their own choosing for it and take over
+ the group, locking every legitimate member out. GEK activation now happens only
+ through the node's local admin UI or CLI.
+ """
hub_pk_pem = _hub_pk_pem(sk_hub)
indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek)
await indexer.initial_scan()
@@ -1324,7 +1368,8 @@ async def test_gek_auto_activate_on_node_bundle_store(sk_node, sk_hub, gek, shar
bundle_store = BundleStore(db_path=tmp_path / "bundles.db")
await bundle_store.open()
- new_gek = generate_gek()
+ attacker_gek = generate_gek()
+ assert attacker_gek != gek
transport = WebRTCTransport(
sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek,
@@ -1336,12 +1381,13 @@ async def test_gek_auto_activate_on_node_bundle_store(sk_node, sk_hub, gek, shar
transport._ctx["sk_x25519_raw"] = sk_x_raw
transport._ctx["pk_x25519_raw"] = pk_x_raw
transport._ctx["pk_x25519_b64"] = base64.b64encode(pk_x_raw).decode()
+ transport._ctx["admin_pk_ed25519"] = Ed25519PrivateKey.generate().public_key()
pc_admin, ch_admin, q_admin = await _setup_peer(
transport, sk_hub, gek, "peer-setup-admin")
- # Store GEK bundle wrapped with AES-GCM (browser-compatible)
- node_bundle = wrap_gek_aes(new_gek, pk_x_raw)
+ # An ordinary member wraps a key of their choosing for the operator's public key.
+ node_bundle = wrap_gek_aes(attacker_gek, pk_x_raw)
ch_admin.send(_pack({
"type": MNP.GEK_BUNDLE_STORE,
"v": MNP_VERSION,
@@ -1351,12 +1397,16 @@ async def test_gek_auto_activate_on_node_bundle_store(sk_node, sk_hub, gek, shar
"nonce_b64": node_bundle["nonce_b64"],
"wrapped_b64": node_bundle["wrapped_b64"],
}))
- ack = await asyncio.wait_for(q_admin.get(), timeout=5.0)
- assert ack["type"] == "ack"
+
+ # The node demands an operator signature instead of storing and adopting it.
+ reply = await asyncio.wait_for(q_admin.get(), timeout=5.0)
+ assert reply["type"] == MNP.ADMIN_CHALLENGE
+ assert reply["op"] == OP_GEK_BUNDLE_STORE
await asyncio.sleep(0.2)
- assert transport._ctx.get("gek") == new_gek
+ assert transport._ctx.get("gek") == gek, "group key was seized over MNP (C5b)"
+ assert await bundle_store.fetch("g", "node-operator") is None
await bundle_store.close()
await pc_admin.close()