diff options
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/conftest.py | 31 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_root_writable_policy.py | 12 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_security_regressions.py | 89 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_upload_sealed.py | 285 |
4 files changed, 359 insertions, 58 deletions
diff --git a/packages/meshbay-node/tests/conftest.py b/packages/meshbay-node/tests/conftest.py index 3dc9cd9..ba86c13 100644 --- a/packages/meshbay-node/tests/conftest.py +++ b/packages/meshbay-node/tests/conftest.py @@ -39,3 +39,34 @@ def one_root(path: Path, *, name: str = "", kind: str = "generic", """ return RootSet.build([{"path": str(path), "name": name, "kind": kind, "writable": writable}]) + + +def sealed_upload(session, *, filename: str, data: bytes, + chunk_index: int = 0, total_chunks: int = 1, + dir: str = "", root: str = "", + upload_id: str = "up-test") -> dict: + """ + A `file_upload` message as the shipping client builds one (MNP 2.0). + + Built through `file_upload_wire`, not by hand: a test that assembles the + wire shape itself is a second encoder, and a second encoder is how + `file_chunk` and `index_sync` forked between the transports (finding C6) + with nobody noticing. The key and the AAD are taken off the session, so + these agree with the handler by construction rather than by copying. + """ + from meshbay_common.protocol import file_upload_wire + + ctx = session._group_ctx() + return file_upload_wire( + ctx["gek"], session._group_id or "", + upload_id=upload_id, chunk_index=chunk_index, total_chunks=total_chunks, + filename=filename, data=data, dir=dir, root=root, + ) + + +def opened_ack(session, msg: dict) -> dict: + """The payload of a `file_upload_ack` the node sent, opened as a client would.""" + from meshbay_common.protocol import file_upload_ack_payload + + ctx = session._group_ctx() + return file_upload_ack_payload(ctx["gek"], session._group_id or "", msg) diff --git a/packages/meshbay-node/tests/test_root_writable_policy.py b/packages/meshbay-node/tests/test_root_writable_policy.py index 7eb75fd..8345880 100644 --- a/packages/meshbay-node/tests/test_root_writable_policy.py +++ b/packages/meshbay-node/tests/test_root_writable_policy.py @@ -21,13 +21,14 @@ anything. A deprecated instruction that still works is not deprecated, and this one would reopen uploads group-wide. """ -import base64 from pathlib import Path import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from conftest import sealed_upload from meshbay_common.adminop import OP_ROOT_UPDATE, OP_ROOT_EJECT, OP_ROOT_PLUG +from meshbay_common.crypto import generate_gek from meshbay_common.protocol import MNP from meshbay_node.indexer.group_index import GroupIndex from meshbay_node.roots import RootSet @@ -47,6 +48,8 @@ def _session(tmp_path: Path, user_id: str, *, "index": index, "sk_node": index.sk_node, "node_user_id": operator, + # Uploads are sealed under the group key since MNP 2.0. + "gek": generate_gek(), } session = WebRTCPeerSession.__new__(WebRTCPeerSession) session._ctx = ctx @@ -61,11 +64,8 @@ def _session(tmp_path: Path, user_id: str, *, def _upload(session, filename="clip.mp4", body=b"bytes"): - session._do_file_upload({ - "filename": filename, "dir": "shared", - "chunk_index": 0, "total_chunks": 1, - "data": base64.b64encode(body).decode(), - }) + session._do_file_upload(sealed_upload( + session, filename=filename, data=body, dir="shared")) def _uploads_dir(session) -> Path: diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py index fa821d9..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") diff --git a/packages/meshbay-node/tests/test_upload_sealed.py b/packages/meshbay-node/tests/test_upload_sealed.py new file mode 100644 index 0000000..7c1be96 --- /dev/null +++ b/packages/meshbay-node/tests/test_upload_sealed.py @@ -0,0 +1,285 @@ +""" +The write path, sealed under the group key (MNP 2.0). + +Downloads have been encrypted under a GEK-derived key since the beginning: +`file_chunk` and `stream_data` both go through `chunk_ciphertext`. Uploads did +not. `file_upload` carried the filename and the raw bytes in plain msgpack and +`file_upload_ack` carried the name the node stored them under, so the same file +was ciphertext leaving a node and plaintext arriving at one — an asymmetry with +no threat model behind it. + +What sealing buys is what `groupbox.py` says and no more: nothing against a +network observer (DTLS covers that), nothing against the hub (never on this +channel), nothing against a member (they hold the GEK). It buys defence in +depth against our own next handshake bug, of a class already shipped twice — +C1, the unauthenticated node HTTP API, and C6, the transport that took a bare +JWT. Both were "a peer that had not finished the handshake was served data". +Sealed, the equivalent bug on this path leaks ciphertext instead of the +operator's filenames. +""" + +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 MNP, file_upload_wire +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +from conftest import one_root, opened_ack, sealed_upload + +GROUP = "g" * 32 + + +def _session(tmp_path: Path, *, gek: bytes | None = None) -> WebRTCPeerSession: + shared_root = tmp_path / "shared" + shared_root.mkdir(exist_ok=True) + index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = {"roots": one_root(shared_root), "index": index, + "sk_node": index.sk_node, "gek": gek or generate_gek()} + session._group_id = GROUP + session._user_id = "user-1" + session._pk_user = "" + session._uploads = {} + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +def _root(session): + return session._ctx["roots"].roots[0] + + +def _errors(session): + return [m for m in session.sent if m.get("type") == "error"] + + +def _wrote_anything(tmp_path) -> bool: + return any(p.is_file() for p in tmp_path.rglob("*")) + + +# ── The message itself ─────────────────────────────────────────────────────── + +def test_the_wire_message_carries_no_filename_and_no_plaintext(tmp_path): + """ + The point of the exercise. `upload_id` and `chunk_index` are outside the + seal because the node routes and orders on them before it can decrypt; + everything that names or is the operator's content is inside it. + """ + session = _session(tmp_path) + msg = sealed_upload(session, filename="holiday.jpg", data=b"JPEGDATA", + dir=f"{_root(session).name}") + + assert set(msg) == {"type", "v", "upload_id", "chunk_index", + "total_chunks", "nonce", "ct"} + blob = repr(msg).encode() + msg["ct"] + assert b"holiday.jpg" not in blob, "the filename is on the wire in clear" + assert b"JPEGDATA" not in blob, "the file content is on the wire in clear" + + +def test_the_ack_carries_no_stored_name(tmp_path): + """ + `stored_as` is the name the node settled on — it finds a free one rather + than replacing anything — and naming it in clear would hand back exactly + what the request took the trouble to hide. + """ + session = _session(tmp_path) + session._do_file_upload(sealed_upload( + session, filename="holiday.jpg", data=b"x", dir=_root(session).name)) + + ack = [m for m in session.sent if m.get("type") == MNP.FILE_UPLOAD_ACK][-1] + assert set(ack) == {"type", "v", "upload_id", "chunk_index", "nonce", "ct"} + assert b"holiday.jpg" not in repr(ack).encode() + ack["ct"] + assert opened_ack(session, ack) == { + "filename": "holiday.jpg", "stored_as": "holiday.jpg", + "dir": _root(session).name} + + +def test_the_ack_names_the_upload_so_one_refusal_fails_one_upload(tmp_path): + """ + `filename` used to be the correlation key on both sides. It cannot be one + any more, and `upload_id` replaces it — a client-chosen label, opaque to + the node, never an authorization input. Without it a client running several + uploads could only match replies by arrival order, which is how a refusal + for one file used to fail every upload in flight. + """ + session = _session(tmp_path) + session._do_file_upload(sealed_upload( + session, filename="a.txt", data=b"x", dir=_root(session).name, + upload_id="upload-A")) + session._do_file_upload(sealed_upload( + session, filename="../evil", data=b"x", dir=_root(session).name, + upload_id="upload-B")) + + ack = [m for m in session.sent if m.get("type") == MNP.FILE_UPLOAD_ACK][-1] + assert ack["upload_id"] == "upload-A" + assert _errors(session)[0]["upload_id"] == "upload-B" + + +# ── What is refused ────────────────────────────────────────────────────────── + +def test_a_plaintext_upload_is_refused(tmp_path): + """ + The MNP 1.x shape, which is what an un-updated client sends. Refused with a + code and a message saying which side is old — never accepted "just this + once", because a path that still takes plaintext is not a sealed path. + """ + session = _session(tmp_path) + session._do_file_upload({ + "filename": "note.txt", "dir": _root(session).name, + "chunk_index": 0, "total_chunks": 1, "data": b"x", + }) + + assert _errors(session)[0]["code"] == "upload_not_sealed" + assert not _wrote_anything(tmp_path) + + +def test_a_tampered_chunk_is_refused(tmp_path): + """ + AES-GCM's tag, asserted where it matters: a flipped bit in the ciphertext + must stop the upload, not produce a corrupt file with a plausible name. + """ + session = _session(tmp_path) + msg = sealed_upload(session, filename="note.txt", data=b"x" * 64, + dir=_root(session).name) + msg["ct"] = bytes([msg["ct"][0] ^ 0x01]) + msg["ct"][1:] + session._do_file_upload(msg) + + assert _errors(session)[0]["code"] == "upload_not_sealed" + assert not _wrote_anything(tmp_path) + + +def test_an_upload_sealed_for_another_group_is_refused(tmp_path): + """ + The group is the AAD, so a node hosting two groups cannot have a chunk + moved between them — and a member of one cannot write into the other by + reaching a session that is on it (finding H1's shape, on the write path). + """ + session = _session(tmp_path) + msg = file_upload_wire( + session._ctx["gek"], "some-other-group", + upload_id="u1", chunk_index=0, total_chunks=1, + filename="note.txt", data=b"x", dir=_root(session).name) + session._do_file_upload(msg) + + assert _errors(session)[0]["code"] == "upload_not_sealed" + assert not _wrote_anything(tmp_path) + + +def test_an_upload_under_another_key_is_refused(tmp_path): + """A peer past the handshake with the wrong GEK still writes nothing.""" + session = _session(tmp_path) + msg = file_upload_wire( + generate_gek(), GROUP, + upload_id="u1", chunk_index=0, total_chunks=1, + filename="note.txt", data=b"x", dir=_root(session).name) + session._do_file_upload(msg) + + assert _errors(session)[0]["code"] == "upload_not_sealed" + assert not _wrote_anything(tmp_path) + + +def test_an_ack_replayed_as_a_request_does_not_open(tmp_path): + """ + The message type is in the AAD, so the two halves of an upload cannot be + confused for each other. Cheap, and it closes a class that is tedious to + reason about after the fact. + """ + from cryptography.exceptions import InvalidTag + from meshbay_common.protocol import file_upload_ack_wire, file_upload_payload + + session = _session(tmp_path) + ack = file_upload_ack_wire( + session._ctx["gek"], GROUP, upload_id="u1", chunk_index=0, + filename="note.txt", stored_as="note.txt", dir="shared") + with pytest.raises(InvalidTag): + file_upload_payload(session._ctx["gek"], GROUP, ack) + + +def test_a_group_with_no_key_refuses_rather_than_falling_back(tmp_path): + """ + A node whose group has no GEK yet cannot open anything. It must say so, not + read the message as though it were the old plaintext shape. + """ + session = _session(tmp_path) + msg = sealed_upload(session, filename="note.txt", data=b"x", + dir=_root(session).name) + session._ctx["gek"] = b"" + session._do_file_upload(msg) + + assert _errors(session)[0]["code"] == "no_group_key" + assert not _wrote_anything(tmp_path) + + +# ── What must still work ───────────────────────────────────────────────────── + +def test_a_multi_chunk_upload_reassembles(tmp_path): + """ + Every chunk is sealed under its own nonce, and the node appends in order. + Nothing about the seal may change what lands on disk. + """ + session = _session(tmp_path) + body = bytes(range(256)) * 40 + parts = [body[i:i + 1024] for i in range(0, len(body), 1024)] + for i, part in enumerate(parts): + session._do_file_upload(sealed_upload( + session, filename="blob.bin", data=part, + chunk_index=i, total_chunks=len(parts), dir=_root(session).name)) + + assert (_root(session).path / "blob.bin").read_bytes() == body + assert not list(_root(session).path.glob("*.part")), "a temp file was left" + acks = [m for m in session.sent if m.get("type") == MNP.FILE_UPLOAD_ACK] + assert [m["chunk_index"] for m in acks] == list(range(len(parts))) + + +def test_two_identical_chunks_do_not_reuse_a_nonce(tmp_path): + """ + A file of repeated bytes is ordinary, so the nonce must come from the RNG + and never from the payload. Cheap to assert and expensive to discover. + """ + session = _session(tmp_path) + a = sealed_upload(session, filename="f", data=b"same", chunk_index=0) + b = sealed_upload(session, filename="f", data=b"same", chunk_index=0) + assert a["nonce"] != b["nonce"] + assert a["ct"] != b["ct"] + + +def test_a_sealed_payload_is_authenticated_not_validated(tmp_path): + """ + Opening a payload proves a member wrote it, not that they wrote something + sensible. A member can seal anything, so the fields still need their types + checked — `SAFE_UPLOAD_NAME.match(123)` raises where a refusal was meant, + and the dispatcher's catch-all would turn that into "Request failed". + """ + from meshbay_common.groupbox import PURPOSE_UPLOAD, seal + + session = _session(tmp_path) + for payload in ({"filename": 123, "data": b"x"}, + {"filename": "note.txt", "data": "not bytes"}, + {"filename": "note.txt"}): + session.sent.clear() + session._do_file_upload({ + "type": MNP.FILE_UPLOAD, "v": "2.0", "upload_id": "u1", + "chunk_index": 0, "total_chunks": 1, + **seal(session._ctx["gek"], PURPOSE_UPLOAD, MNP.FILE_UPLOAD, + GROUP, payload), + }) + errs = _errors(session) + assert errs, f"{payload!r} was accepted" + assert errs[0]["code"] in ("upload_incomplete", "bad_chunk_encoding") + assert not _wrote_anything(tmp_path) + + +def test_a_peer_controlled_chunk_index_cannot_crash_the_handler(tmp_path): + """`chunk_index` is outside the seal by necessity, so it is unchecked input.""" + session = _session(tmp_path) + msg = sealed_upload(session, filename="note.txt", data=b"x", + dir=_root(session).name) + msg["chunk_index"] = "zero" + session._do_file_upload(msg) + + assert _errors(session)[0]["code"] == "bad_chunk_index" + assert not _wrote_anything(tmp_path) |