aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_upload_sealed.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/tests/test_upload_sealed.py')
-rw-r--r--packages/meshbay-node/tests/test_upload_sealed.py285
1 files changed, 285 insertions, 0 deletions
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)