From a421a03d2be16670dc8d9076d26f4a7eac669986 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 28 Sep 2026 21:14:10 +0200 Subject: fix: bound pending admin challenges and sign every value an op acts on Any member could make a node hold unbounded challenge requests; a connection now keeps at most 8, 64 KiB each. root_add, group_attach, invite_create and tmdb_config signed less than they did; their subjects are now canonical JSON of every value (the TMDB token by SHA-256). MNP 5.0, floor kept at 4.0. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_node/transport/webrtc/admin.py | 27 ++++- .../src/meshbay_node/transport/webrtc/admission.py | 19 ++- .../transport/webrtc/apps/video_meta.py | 13 +- .../src/meshbay_node/transport/webrtc/node_ops.py | 31 +++-- packages/meshbay-node/tests/golden/dispatch.json | 128 +++++++++---------- .../tests/test_admin_challenge_bounds.py | 135 +++++++++++++++++++++ .../meshbay-node/tests/test_tmdb_config_policy.py | 13 +- .../meshbay-node/tests/test_webrtc_transport.py | 4 +- 8 files changed, 276 insertions(+), 94 deletions(-) create mode 100644 packages/meshbay-node/tests/test_admin_challenge_bounds.py (limited to 'packages/meshbay-node') diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py index f42d8e8..daaee62 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py @@ -5,6 +5,7 @@ import base64 import os import time +import msgpack from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from meshbay_common import MNP_VERSION from meshbay_common.adminop import ( @@ -42,6 +43,16 @@ from meshbay_common.adminop import ( from meshbay_common.crypto import pk_to_b64 from meshbay_common.protocol import MNP +# What one connection may have waiting for a signature. Anyone authenticated can +# ask for a challenge — the signature is what is checked, and it comes later — so +# without a bound a member who never answers makes the node keep every request, +# payload and all, for the life of the connection (§13.5b). A person signs one +# operation at a time; a handful covers a settings page saving several at once. +MAX_PENDING_ADMIN_OPS = 8 +# The subject and payload of one pending operation, packed. A path is at most a +# few KiB, and the largest field a legitimate request carries is a directory list. +MAX_ADMIN_OP_BYTES = 64 * 1024 + # Which executor runs each signed operation once its signature has been # checked. Every one runs as a task of the session. _ADMIN_EXECUTORS = { @@ -98,8 +109,22 @@ class AdminMixin: (e.g. root management from a NodePage connection). """ gid = group_id if group_id is not None else (self._group_id or "") + now = time.time() + for op_id, pending in list(self._admin_ops.items()): + if now - pending["ts"] > ADMIN_CHALLENGE_TTL: + del self._admin_ops[op_id] + if len(self._admin_ops) >= MAX_PENDING_ADMIN_OPS: + self._send({"type": "error", "detail": "Too many operations waiting for a " + "signature", "code": "too_many_pending"}) + self._audit("admin_pending_flood", op) + return + if len(msgpack.packb([subject, payload or {}], use_bin_type=True)) \ + > MAX_ADMIN_OP_BYTES: + self._send({"type": "error", "detail": "Request too large", + "code": "too_large"}) + return nonce = os.urandom(32) - ts = int(time.time()) + ts = int(now) op_id = base64.b64encode(os.urandom(16)).decode() self._admin_ops[op_id] = { "op": op, "subject": subject, "nonce": nonce, "ts": ts, diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py index b02e59a..e678a13 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py @@ -8,7 +8,12 @@ import time from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from meshbay_common import MNP_VERSION -from meshbay_common.adminop import OP_INVITE_CANCEL, OP_INVITE_CREATE, OP_INVITE_LINK_CREATE +from meshbay_common.adminop import ( + OP_INVITE_CANCEL, + OP_INVITE_CREATE, + OP_INVITE_LINK_CREATE, + invite_create_subject, +) from meshbay_common.crypto import wrap_gek_aes from meshbay_common.device import ( DEVICE_TTL, @@ -71,11 +76,13 @@ class AdmissionMixin: }) return - self._issue_admin_challenge(OP_INVITE_CREATE, invitee_id, { - "group_id": group_id, - "user_id": invitee_id, - "username": str(msg.get("username", ""))[:64], - }) + username = str(msg.get("username", ""))[:64] + self._issue_admin_challenge( + OP_INVITE_CREATE, invite_create_subject(invitee_id, username), { + "group_id": group_id, + "user_id": invitee_id, + "username": username, + }) def _do_invite_link_create(self, msg: dict) -> None: """ diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/video_meta.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/video_meta.py index 834bac4..a9b35dc 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/video_meta.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/video_meta.py @@ -11,6 +11,7 @@ from meshbay_common.adminop import ( OP_TMDB_ENABLED, OP_TMDB_OVERRIDE, OP_TMDB_REMATCH, + tmdb_config_subject, ) from meshbay_common.protocol import MNP @@ -60,12 +61,12 @@ class VideoMetaMixin: if not self._has_admin_authority(): self._send({"type": "error", "detail": "No authorized key for this"}) return - # The subject is the signed, audited, human-shown string — it must - # never contain the token itself (it would end up in the audit log - # in plaintext). The actual token travels only in `payload`, which - # is node-side context, never re-sent or re-verified from the wire. - # The language is not a secret, so it travels in the subject itself. - subject = f"custom_token={'yes' if token else 'no'},language={language or 'default'}" + # The subject is the signed, audited string, so it must never contain + # the token itself (it would end up in the audit log in plaintext); it + # carries the token's SHA-256 instead, which binds the signature to this + # token without writing it down. `None` (unchanged) and `""` (clear) + # stay distinct, for the token and the language alike. + subject = tmdb_config_subject(token, language) self._issue_admin_challenge( OP_TMDB_CONFIG, subject, payload={"token": token, "language": language}, diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/node_ops.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/node_ops.py index 9d58d8c..f7bbbfa 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/node_ops.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/node_ops.py @@ -14,6 +14,8 @@ from meshbay_common.adminop import ( OP_ROOT_UPDATE, OP_SET_SCAN_SETTINGS, OP_TRANSFER_LIMITS, + group_attach_subject, + root_add_subject, ) from meshbay_common.protocol import MNP @@ -246,10 +248,11 @@ class NodeOpsMixin: # is ignored rather than obeyed: on load it forces every other root # read-only, which is the model the RO/RW one replaced. A second # writable directory is `root_add` with `writable`. + writable = bool(msg.get("writable", True)) + # The directory being exposed is signed, not only the group's name. self._issue_admin_challenge( - OP_GROUP_ATTACH, name, - payload={"name": name, "shared_dir": shared_dir, - "writable": bool(msg.get("writable", True))}, + OP_GROUP_ATTACH, group_attach_subject(name, shared_dir, writable), + payload={"name": name, "shared_dir": shared_dir, "writable": writable}, group_id="") async def _admin_exec_group_attach( @@ -342,16 +345,20 @@ class NodeOpsMixin: if not self._has_admin_authority(): self._send({"type": "error", "detail": "No authorized key for this"}) return + payload = { + "group_id": target_group, "path": path, + "name": str(msg.get("name", ""))[:128], + "kind": str(msg.get("kind", "generic"))[:16], + "writable": bool(msg.get("writable", msg.get("upload", False))), + "removable": bool(msg.get("removable", False)), + } + # Everything the executor acts on is signed — `writable` decides whether + # every member may write there. The group is in the transcript itself. self._issue_admin_challenge( - OP_ROOT_ADD, path, - payload={ - "group_id": target_group, "path": path, - "name": str(msg.get("name", ""))[:128], - "kind": str(msg.get("kind", "generic"))[:16], - "writable": bool(msg.get("writable", msg.get("upload", False))), - "removable": bool(msg.get("removable", False)), - }, - group_id=target_group) + OP_ROOT_ADD, + root_add_subject(path, payload["name"], payload["kind"], + payload["writable"], payload["removable"]), + payload=payload, group_id=target_group) async def _admin_exec_root_add( self, pending: dict, transcript: bytes, sig: bytes, diff --git a/packages/meshbay-node/tests/golden/dispatch.json b/packages/meshbay-node/tests/golden/dispatch.json index 4767996..a7bb543 100644 --- a/packages/meshbay-node/tests/golden/dispatch.json +++ b/packages/meshbay-node/tests/golden/dispatch.json @@ -2068,7 +2068,7 @@ "subject": "x", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -2391,7 +2391,7 @@ "subject": "gggggggggggggggggggggggggggggggg", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -2410,7 +2410,7 @@ "subject": "['x']", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -2429,7 +2429,7 @@ "subject": "7", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -2448,7 +2448,7 @@ "subject": "x", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -2718,7 +2718,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -2732,7 +2732,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -2746,7 +2746,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -2760,7 +2760,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -2774,7 +2774,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -2788,7 +2788,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -2802,7 +2802,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -2816,7 +2816,7 @@ "messages": [], "req_id": 4242, "type": "chat_hist_resp", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -8535,7 +8535,7 @@ "subject": "gggggggggggggggggggggggggggggggg", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -8554,7 +8554,7 @@ "subject": "['x']", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -8573,7 +8573,7 @@ "subject": "7", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -8592,7 +8592,7 @@ "subject": "x", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -8924,10 +8924,10 @@ "op": "group_attach", "op_id": "", "req_id": 4242, - "subject": "['x']", + "subject": "{\"name\":\"['x']\",\"shared_dir\":\"['x']\",\"writable\":true}", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -8943,10 +8943,10 @@ "op": "group_attach", "op_id": "", "req_id": 4242, - "subject": "7", + "subject": "{\"name\":\"7\",\"shared_dir\":\"7\",\"writable\":true}", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -8962,10 +8962,10 @@ "op": "group_attach", "op_id": "", "req_id": 4242, - "subject": "x", + "subject": "{\"name\":\"x\",\"shared_dir\":\"x\",\"writable\":true}", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -9300,7 +9300,7 @@ "subject": "['x']", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -9319,7 +9319,7 @@ "subject": "7", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -9338,7 +9338,7 @@ "subject": "x", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -11641,7 +11641,7 @@ "subject": "link:gggggggggggggggggggggggggggggggg", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -13740,7 +13740,7 @@ "subject": "['x']", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -13759,7 +13759,7 @@ "subject": "7", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -13778,7 +13778,7 @@ "subject": "x", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -14113,7 +14113,7 @@ "subject": "['x']", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -14132,7 +14132,7 @@ "subject": "7", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -14151,7 +14151,7 @@ "subject": "x", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -16212,7 +16212,7 @@ "req_id": 4242, "token": null, "type": "pong", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -16227,7 +16227,7 @@ "x" ], "type": "pong", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -16240,7 +16240,7 @@ "req_id": 4242, "token": 7, "type": "pong", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -16253,7 +16253,7 @@ "req_id": 4242, "token": "x", "type": "pong", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -16266,7 +16266,7 @@ "req_id": 4242, "token": null, "type": "pong", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -16281,7 +16281,7 @@ "x" ], "type": "pong", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -16294,7 +16294,7 @@ "req_id": 4242, "token": 7, "type": "pong", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -16307,7 +16307,7 @@ "req_id": 4242, "token": "x", "type": "pong", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -16639,10 +16639,10 @@ "op": "root_add", "op_id": "", "req_id": 4242, - "subject": "['x']", + "subject": "{\"kind\":\"['x']\",\"name\":\"['x']\",\"path\":\"['x']\",\"removable\":true,\"writable\":true}", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -16658,10 +16658,10 @@ "op": "root_add", "op_id": "", "req_id": 4242, - "subject": "7", + "subject": "{\"kind\":\"7\",\"name\":\"7\",\"path\":\"7\",\"removable\":true,\"writable\":true}", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -16677,10 +16677,10 @@ "op": "root_add", "op_id": "", "req_id": 4242, - "subject": "x", + "subject": "{\"kind\":\"x\",\"name\":\"x\",\"path\":\"x\",\"removable\":true,\"writable\":true}", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -17015,7 +17015,7 @@ "subject": "['x']", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -17034,7 +17034,7 @@ "subject": "7", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -17053,7 +17053,7 @@ "subject": "x", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -17388,7 +17388,7 @@ "subject": "['x']", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -17407,7 +17407,7 @@ "subject": "7", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -17426,7 +17426,7 @@ "subject": "x", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -17761,7 +17761,7 @@ "subject": "['x']", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -17780,7 +17780,7 @@ "subject": "7", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -17799,7 +17799,7 @@ "subject": "x", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -18134,7 +18134,7 @@ "subject": "['x']:rw=on,rem=on", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -18153,7 +18153,7 @@ "subject": "7:rw=on,rem=on", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -18172,7 +18172,7 @@ "subject": "x:rw=on,rem=on", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -21116,10 +21116,10 @@ "op": "tmdb_config", "op_id": "", "req_id": 4242, - "subject": "custom_token=no,language=default", + "subject": "{\"language\":null,\"token\":null}", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -21159,10 +21159,10 @@ "op": "tmdb_config", "op_id": "", "req_id": 4242, - "subject": "custom_token=yes,language=x", + "subject": "{\"language\":\"x\",\"token\":\"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881\"}", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] @@ -23049,7 +23049,7 @@ "subject": "d=7,u=7", "ts": "", "type": "admin_challenge", - "v": "4.0" + "v": "5.0" } ], "spawned": [] diff --git a/packages/meshbay-node/tests/test_admin_challenge_bounds.py b/packages/meshbay-node/tests/test_admin_challenge_bounds.py new file mode 100644 index 0000000..fd3b2f1 --- /dev/null +++ b/packages/meshbay-node/tests/test_admin_challenge_bounds.py @@ -0,0 +1,135 @@ +""" +What a connection may leave waiting for a signature (docs/MESHBAY_DESIGN.md §13.5b). + +Anyone authenticated can ask for an admin challenge — the signature is checked +later — so a member who never answers must not make the node keep every request. +Measured before the bound: 200 `root_add` of 1 MiB each from a plain member held +200 pending operations and ~400 MiB for the life of the connection. +""" + +import struct +import time + +import msgpack +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_node.transport.webrtc.admin import MAX_ADMIN_OP_BYTES, MAX_PENDING_ADMIN_OPS +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +GROUP = "g" * 32 + + +class _Channel: + readyState = "open" + + def __init__(self): + self.sent = [] + + def send(self, data: bytes) -> None: + (n,) = struct.unpack(">I", data[:4]) + self.sent.append(msgpack.unpackb(data[4:4 + n], raw=False)) + + +class _PC: + connectionState = "connected" + iceConnectionState = "connected" + remoteDescription = None + localDescription = None + sctp = None + + +def _member_session(): + """An authenticated member — not the operator — on a node that has one.""" + ctx = {"sk_node": Ed25519PrivateKey.from_private_bytes(b"\x01" * 32), + "groups": {GROUP: {}}, "has_admin_authority": True} + s = WebRTCPeerSession(_PC(), ctx, peer_id="peer") + s._channel = _Channel() + s._audit = lambda *a, **k: None + s._user_id, s._group_id = "member-1", GROUP + return s + + +def _root_add(s, path: str) -> dict: + s._dispatch_message({"type": "root_add", "group_id": GROUP, "path": path}) + return s._channel.sent[-1] + + +def test_a_member_cannot_pile_up_challenges(): + s = _member_session() + for i in range(MAX_PENDING_ADMIN_OPS): + assert _root_add(s, f"/srv/{i}")["type"] == "admin_challenge" + refused = _root_add(s, "/srv/one-too-many") + assert refused["type"] == "error" and refused["code"] == "too_many_pending" + assert len(s._admin_ops) == MAX_PENDING_ADMIN_OPS + + +def test_an_oversized_request_is_not_kept(): + s = _member_session() + refused = _root_add(s, "x" * (MAX_ADMIN_OP_BYTES + 1)) + assert refused["type"] == "error" and refused["code"] == "too_large" + assert s._admin_ops == {} + + +def test_an_expired_challenge_frees_its_place(): + s = _member_session() + for i in range(MAX_PENDING_ADMIN_OPS): + _root_add(s, f"/srv/{i}") + for pending in s._admin_ops.values(): + pending["ts"] -= 10_000 + assert _root_add(s, "/srv/after-expiry")["type"] == "admin_challenge" + assert len(s._admin_ops) == 1 + + +def test_answering_a_challenge_frees_its_place(): + s = _member_session() + for i in range(MAX_PENDING_ADMIN_OPS): + _root_add(s, f"/srv/{i}") + op_id = next(iter(s._admin_ops)) + s._dispatch_message({"type": "admin_response", "op_id": op_id, "signature": "!!"}) + assert len(s._admin_ops) == MAX_PENDING_ADMIN_OPS - 1 + assert _root_add(s, "/srv/next")["type"] == "admin_challenge" + assert all(time.time() - p["ts"] < 5 for p in s._admin_ops.values()) + + +# ── What a challenge covers (docs/MESHBAY_DESIGN.md §5.4) ──────────────────── +# +# The signature covers the subject and nothing else of a request, so every value +# the executor acts on has to be in it. + +def test_root_add_signs_whether_members_may_write(): + from meshbay_common.adminop import root_add_subject + s = _member_session() + s._dispatch_message({"type": "root_add", "group_id": GROUP, "path": "/srv/drop", + "name": "Drop", "writable": True, "removable": False}) + challenge = s._channel.sent[-1] + assert challenge["subject"] == root_add_subject("/srv/drop", "Drop", "generic", + True, False) + assert challenge["subject"] != root_add_subject("/srv/drop", "Drop", "generic", + False, False) + + +def test_group_attach_signs_the_directory_it_exposes(): + from meshbay_common.adminop import group_attach_subject + s = _member_session() + s._dispatch_message({"type": "group_attach", "name": "photos", + "shared_dir": "/home/me/Photos"}) + assert s._channel.sent[-1]["subject"] == group_attach_subject( + "photos", "/home/me/Photos", True) + + +def test_invite_create_signs_the_name_it_records(): + from meshbay_common.adminop import invite_create_subject + s = _member_session() + s._ctx["roster"] = object() # only its presence is checked before the challenge + s._dispatch_message({"type": "invite_create", "group_id": GROUP, + "user_id": "u-1", "username": "alice"}) + assert s._channel.sent[-1]["subject"] == invite_create_subject("u-1", "alice") + + +def test_tmdb_config_signs_the_token_without_writing_it(): + from meshbay_common.adminop import tmdb_config_subject + s = _member_session() + s._dispatch_message({"type": "tmdb_config", "token": "secret-token", + "language": "fr-FR"}) + subject = s._channel.sent[-1]["subject"] + assert subject == tmdb_config_subject("secret-token", "fr-FR") + assert "secret-token" not in subject diff --git a/packages/meshbay-node/tests/test_tmdb_config_policy.py b/packages/meshbay-node/tests/test_tmdb_config_policy.py index 6a51eb0..c671ac8 100644 --- a/packages/meshbay-node/tests/test_tmdb_config_policy.py +++ b/packages/meshbay-node/tests/test_tmdb_config_policy.py @@ -22,7 +22,7 @@ from pathlib import Path import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey -from meshbay_common.adminop import OP_TMDB_CONFIG +from meshbay_common.adminop import OP_TMDB_CONFIG, tmdb_config_subject from meshbay_node.indexer.group_index import GroupIndex from meshbay_node.roster import Roster from meshbay_node.transport.webrtc_server import WebRTCPeerSession @@ -134,7 +134,9 @@ async def test_subject_reflects_whether_a_token_was_supplied(tmp_path): session._do_tmdb_config({"token": "x"}) _, subject, _, _ = issued[0] - assert subject == "custom_token=yes,language=default" + # The token is bound by its digest and never written into the subject. + assert subject == tmdb_config_subject("x", None) + assert "sha256:" in subject and tmdb_config_subject("y", None) != subject async def test_subject_says_no_custom_token_when_none_given(tmp_path): @@ -146,7 +148,10 @@ async def test_subject_says_no_custom_token_when_none_given(tmp_path): session._do_tmdb_config({}) _, subject, _, _ = issued[0] - assert subject == "custom_token=no,language=default" + # Nothing given means both unchanged — distinct from clearing either. + assert subject == tmdb_config_subject(None, None) + assert subject != tmdb_config_subject("", None) + assert subject != tmdb_config_subject(None, "") async def test_subject_reflects_a_configured_language(tmp_path): @@ -158,7 +163,7 @@ async def test_subject_reflects_a_configured_language(tmp_path): session._do_tmdb_config({"language": "fr-FR"}) _, subject, payload, _ = issued[0] - assert subject == "custom_token=no,language=fr-FR" + assert subject == tmdb_config_subject(None, "fr-FR") assert payload["language"] == "fr-FR" diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py index 4542817..7a6f517 100644 --- a/packages/meshbay-node/tests/test_webrtc_transport.py +++ b/packages/meshbay-node/tests/test_webrtc_transport.py @@ -34,6 +34,7 @@ from meshbay_common.adminop import ( OP_INVITE_CREATE, OP_INVITE_LINK_CREATE, admin_transcript, + invite_create_subject, ) from meshbay_common.crypto import ( generate_gek, @@ -1335,7 +1336,8 @@ async def test_invite_then_join_delivers_the_gek(sk_node, sk_hub, gek, shared_di challenge_msg = await asyncio.wait_for(q_admin.get(), timeout=5.0) assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE assert challenge_msg["op"] == OP_INVITE_CREATE - assert challenge_msg["subject"] == "user-002" + # The name the invitation records is signed with the account it is for. + assert challenge_msg["subject"] == invite_create_subject("user-002", "bob") ch_admin.send(_pack({ "type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION, -- cgit v1.2.3