summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_security_regressions.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-07 18:03:52 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-07 18:03:52 +0200
commite1383e1d545b994f4ad61694f868339defb0bdef (patch)
tree67a3b1933f2a9c5caf107d01d9ff91c44a975df6 /packages/meshbay-node/tests/test_security_regressions.py
parent36cebf25d0e0f24cf63be4380ccb5d03da726a74 (diff)
parent8980a8e42d94ab7c0bc9739283d39f938f8402b0 (diff)
downloadmeshbay-e1383e1d545b994f4ad61694f868339defb0bdef.tar.gz
Merge origin/main into the chat encryption work
Both sides landed a breaking MNP change and both called it 2.0, which is right: the sealed upload, the removal of `stream_seg` and mandatory chat encryption share one flag day. They are recorded as one version in `__init__.py` rather than as a race between two. The resolutions that were decisions rather than mechanics: * **`MNP_MIN_SUPPORTED` moves to "2.0".** The sealed upload alone was a *confined* break — a 1.x peer could still connect, browse, download, stream and chat, with only its uploads refused by `upload_not_sealed` — so the floor deliberately stayed at "1.0". Mandatory chat encryption ends that confinement: a 1.x peer can neither produce a sealed chat message nor read one, so it would connect, look fine, and be unable to say anything. Refusing it at the handshake is the honest form. The per-message `upload_not_sealed` path is untouched and still right if the floor is ever lowered. * **`sendChat` throws on an `error` reply**, from origin, applied to the sealed send. It matters more after this change, not less: the node now refuses a stale epoch, a malformed envelope and a device claim that is not the connection's own, so there are three new ways for a message to be rejected and none of them may look like a message that was sent. * **`req_id` supersedes the per-type routing** this branch added for `chat_keys_resp` and `device_hello_ack`. Both blocks are kept beside the existing `chat_hist_resp` one, for the same stated reason — a node too old to stamp — and their comments no longer claim to be the mechanism that closes the class. `req_id` is. * **`chat_send_probe.py` is rebuilt on origin's structure**, not beside it: two scenarios, a stub that stamps `req_id`, `music_meta_req` as the older pending request. The encrypted path is layered on — a real Ed25519 device key generated in the page, and a `chat_keys_resp` sealed by the shipped Python, because a payload the page built itself would prove only that the page agrees with the page. * **`test_reply_correlation.py` now sends a sealed message.** Its subject is which of the two messages leaving that handler carries the id; plaintext chat was only the fixture, and the node refuses one now. * `groupbox` keeps both new purposes (`upload`, `chat_keys`); `protocol.py` keeps origin's removal of `STREAM_SEGMENT` and this branch's correction of the "Double Ratchet message" comment on `CHAT_MESSAGE`, which was wrong when it was written and is wrong differently now. Full suite on the merged tree: 1993 passed, 11 failed — the same 11 that fail on a pristine checkout (2 Windows service tests, 1 apps-enabled policy, 7 transcode tests that pass in isolation, and the WebRTC invite test that hangs on its own). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
Diffstat (limited to 'packages/meshbay-node/tests/test_security_regressions.py')
-rw-r--r--packages/meshbay-node/tests/test_security_regressions.py137
1 files changed, 75 insertions, 62 deletions
diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py
index 1a318f7..988aa46 100644
--- a/packages/meshbay-node/tests/test_security_regressions.py
+++ b/packages/meshbay-node/tests/test_security_regressions.py
@@ -16,10 +16,11 @@ from pathlib import Path
import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_common.crypto import generate_gek
from meshbay_common.protocol import IndexEntry
from meshbay_node.indexer.group_index import GroupIndex
from meshbay_node.roots import RootSet
-from conftest import one_root
+from conftest import one_root, opened_ack, sealed_upload
from meshbay_node.transport.webrtc_server import WebRTCPeerSession
@@ -156,7 +157,10 @@ def _session(tmp_path: Path, user_id: str) -> WebRTCPeerSession:
shared_root = tmp_path / "shared"
shared_root.mkdir(exist_ok=True)
index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
- ctx = {"roots": one_root(shared_root), "index": index, "sk_node": index.sk_node}
+ # A group key, because uploads are sealed under it since MNP 2.0 — the
+ # handler opens the payload before it has a filename to refuse.
+ ctx = {"roots": one_root(shared_root), "index": index,
+ "sk_node": index.sk_node, "gek": generate_gek()}
session = WebRTCPeerSession.__new__(WebRTCPeerSession)
session._ctx = ctx
@@ -188,12 +192,8 @@ def test_upload_cannot_overwrite_another_members_file(tmp_path):
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(),
- })
+ attacker._do_file_upload(sealed_upload(
+ attacker, filename="important.mp4", data=b"attacker content"))
assert original.read_bytes() == b"operator's original content", (
"an upload replaced an existing file (C5a)")
@@ -203,12 +203,15 @@ def test_upload_cannot_overwrite_another_members_file(tmp_path):
def test_upload_second_attempt_cannot_replace_own_completed_file(tmp_path):
"""C5a: even the original uploader does not get to 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()
+ def _send_it():
+ # Sealed afresh each time: a nonce is drawn per message, so re-sending
+ # the same dict would be a replay rather than a second upload.
+ session._do_file_upload(sealed_upload(
+ session, filename="movie.mp4", data=b"first"))
- session._do_file_upload(dict(payload))
+ _send_it()
+ session.sent.clear()
+ _send_it()
uploads = _uploads_dir(session)
assert (uploads / "movie.mp4").read_bytes() == b"first", (
"the first upload was replaced")
@@ -252,11 +255,8 @@ def test_the_client_names_a_folder_and_never_a_filesystem_path(tmp_path):
for bad in ("../../etc", "/etc", "shared/../..", "shared/../../etc",
"nope", "shared/missing"):
session.sent.clear()
- session._do_file_upload({
- "filename": "note.txt", "dir": bad,
- "chunk_index": 0, "total_chunks": 1,
- "data": base64.b64encode(b"x").decode(),
- })
+ session._do_file_upload(sealed_upload(
+ session, filename="note.txt", data=b"x", dir=bad))
refusal = [m for m in session.sent if m.get("type") == "error"]
assert refusal, f"{bad!r} was accepted"
assert refusal[0].get("code") in ("no_such_root", "no_such_directory"), bad
@@ -274,11 +274,8 @@ def test_an_upload_lands_in_the_folder_it_names(tmp_path):
root = session._ctx["roots"].roots[0]
(root.path / "Albums").mkdir()
- session._do_file_upload({
- "filename": "note.txt", "dir": f"{root.name}/Albums",
- "chunk_index": 0, "total_chunks": 1,
- "data": base64.b64encode(b"x").decode(),
- })
+ session._do_file_upload(sealed_upload(
+ session, filename="note.txt", data=b"x", dir=f"{root.name}/Albums"))
assert (root.path / "Albums" / "note.txt").read_bytes() == b"x"
assert not (root.path / "Albums" / "uploads").exists(), (
@@ -304,11 +301,8 @@ def test_an_upload_goes_to_the_root_it_names(tmp_path):
{"path": str(incoming), "writable": True},
])
- session._do_file_upload({
- "filename": "note.txt", "dir": "Incoming",
- "chunk_index": 0, "total_chunks": 1,
- "data": base64.b64encode(b"x").decode(),
- })
+ session._do_file_upload(sealed_upload(
+ session, filename="note.txt", data=b"x", dir="Incoming"))
assert (incoming / "note.txt").read_bytes() == b"x"
assert not (media / "note.txt").exists(), "it went to the first root instead"
@@ -327,11 +321,8 @@ def test_a_read_only_root_refuses_an_upload(tmp_path):
session._ctx["roots"] = RootSet.build([{"path": str(published)}])
session._is_node_admin = lambda: True
- session._do_file_upload({
- "filename": "note.txt", "dir": "Published",
- "chunk_index": 0, "total_chunks": 1,
- "data": base64.b64encode(b"x").decode(),
- })
+ session._do_file_upload(sealed_upload(
+ session, filename="note.txt", data=b"x", dir="Published"))
refusal = [m for m in session.sent if m.get("type") == "error"]
assert refusal and refusal[0].get("code") == "root_read_only"
@@ -349,11 +340,8 @@ def test_a_fully_read_only_group_refuses_an_unaddressed_upload(tmp_path):
session = _session(tmp_path, "user-1")
session._ctx["roots"] = RootSet.build([{"path": str(published)}])
- session._do_file_upload({
- "filename": "note.txt",
- "chunk_index": 0, "total_chunks": 1,
- "data": base64.b64encode(b"x").decode(),
- })
+ session._do_file_upload(sealed_upload(
+ session, filename="note.txt", data=b"x"))
refusal = [m for m in session.sent if m.get("type") == "error"]
assert refusal and refusal[0].get("code") == "no_writable_root"
@@ -375,11 +363,8 @@ def test_an_ejected_root_refuses_an_upload(tmp_path):
roots.roots[0].available = False
session._ctx["roots"] = roots
- session._do_file_upload({
- "filename": "note.txt", "dir": "USB",
- "chunk_index": 0, "total_chunks": 1,
- "data": base64.b64encode(b"x").decode(),
- })
+ session._do_file_upload(sealed_upload(
+ session, filename="note.txt", data=b"x", dir="USB"))
refusal = [m for m in session.sent if m.get("type") == "error"]
assert refusal and refusal[0].get("code") == "root_unavailable"
@@ -392,22 +377,22 @@ def test_two_members_can_send_the_same_filename(tmp_path):
IMG_1234.jpg. The second gets a free name; neither replaces the other.
"""
first = _session(tmp_path, "user-1")
- first._do_file_upload({
- "filename": "IMG_1234.jpg", "chunk_index": 0, "total_chunks": 1,
- "data": base64.b64encode(b"first").decode(),
- })
+ first._do_file_upload(sealed_upload(
+ first, filename="IMG_1234.jpg", data=b"first"))
second = _session(tmp_path, "user-2")
- second._do_file_upload({
- "filename": "IMG_1234.jpg", "chunk_index": 0, "total_chunks": 1,
- "data": base64.b64encode(b"second").decode(),
- })
+ # Same group, so the same key: `_session` builds one per call, and two
+ # members of one group do not have two.
+ second._ctx["gek"] = first._ctx["gek"]
+ second._do_file_upload(sealed_upload(
+ second, filename="IMG_1234.jpg", data=b"second"))
uploads = _uploads_dir(first)
assert (uploads / "IMG_1234.jpg").read_bytes() == b"first"
assert (uploads / "IMG_1234 (2).jpg").read_bytes() == b"second"
ack = [m for m in second.sent if m.get("type") == "file_upload_ack"][-1]
- assert ack["stored_as"] == "IMG_1234 (2).jpg", (
+ assert "stored_as" not in ack, "the name the node chose must be sealed"
+ assert opened_ack(second, ack)["stored_as"] == "IMG_1234 (2).jpg", (
"the sender must be told the name that was used, or a chat attachment "
"points at someone else's file")
@@ -743,20 +728,48 @@ def test_pre_handshake_message_budget_is_small():
list(buf.messages())
-def test_stream_segment_is_not_synchronous():
+def test_no_transport_ships_media_outside_the_aead():
"""
- H6: _do_stream_segment ran subprocess.run(timeout=30) inside the event loop,
- stalling every peer on the node for up to thirty seconds per request.
+ `stream_seg` served an MPEG-TS segment as base64 with no encryption at all
+ — the one content-plane message that never went through a GEK-derived key,
+ on both transports, answering any authenticated member. Its browser caller
+ was defined and never invoked. Removed in MNP 2.0 rather than repaired:
+ `stream_data` already does the job under `chunk_ciphertext`.
- Asserts the property (the worker is a coroutine, ffmpeg is spawned through
- asyncio) rather than grepping for "subprocess.run" — which also matches the
- comment that documents the old behaviour.
+ Asserted as the property, not as "the function is gone": what matters is
+ that no transport has a field carrying media bytes past the AEAD. The old
+ H6 test lived here — it pinned `_do_stream_segment_async` to a coroutine so
+ ffmpeg could not block the event loop — and the handler outliving that
+ concern is exactly what this replaces.
"""
- import ast
- import inspect
- from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+ import re
+
+ from meshbay_common.protocol import MNP
+
+ assert not hasattr(MNP, "STREAM_SEGMENT"), (
+ "the constant outliving the handlers is how a deleted endpoint keeps "
+ "looking like part of the wire contract")
+
+ root = Path(__file__).parent.parent / "src" / "meshbay_node" / "transport"
+ for name in ("webrtc_server.py", "quic_server.py", "quic_client.py"):
+ source = (root / name).read_text(encoding="utf-8")
+ # Word boundaries: `_stream_segments` and `STREAM_SEGMENT_SIZE` belong
+ # to the live `stream_data` path, which is encrypted and stays.
+ assert not re.search(r"\bstream_seg\b", source), (
+ f"{name} still speaks stream_seg")
+ assert not re.search(r"\bSTREAM_SEGMENT\b", source), (
+ f"{name} still names the removed type")
+ assert "data_b64" not in source, (
+ f"{name} carries a base64 media field — media leaves this node "
+ "encrypted or not at all")
- assert inspect.iscoroutinefunction(WebRTCPeerSession._do_stream_segment_async)
+
+def test_ffmpeg_never_blocks_the_event_loop():
+ """
+ H6, the half that survives `stream_seg`: the live streaming path still
+ spawns ffmpeg, and a synchronous spawn stalls every peer on the node.
+ """
+ import ast
source = (Path(__file__).parent.parent / "src" / "meshbay_node"
/ "transport" / "webrtc_server.py").read_text(encoding="utf-8")