""" The browser half of MNP 2.0's sealed upload, measured rather than read. `test_upload_sealed.py` (node side) proves the node opens what the shared encoder produces and refuses everything else. This proves the *shipped browser code* produces it — and, the part that matters more, that a caller of `uploadFile` is still told the name the node stored the file under, which now arrives sealed and would otherwise be `undefined` with nothing on screen to say so: a chat attachment would point at a file that is not there. Driven through `harness/upload_seal_probe.mjs`, which runs the shipped `transport.js` over the shipped `crypto.js`. The node half in between is the real `_do_file_upload`, writing to a real directory. A source-reading test can see that `sealGroup` is called. Only this can see whether what comes out of it opens. """ import json import shutil import subprocess import tempfile from pathlib import Path import msgpack import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey 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 from meshbay_node.transport.webrtc_server import WebRTCPeerSession from spa_source import transport_argv STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" PROBE = Path(__file__).resolve().parent / "harness" / "upload_seal_probe.mjs" pytestmark = pytest.mark.skipif( shutil.which("node") is None or not PROBE.exists(), reason="node unavailable — the client half cannot be measured", ) GROUP = "g-upload-probe" CHUNK = 32 BODY = bytes(range(256)) * 3 # 768 bytes → 24 chunks of 32 def _run_probe(payload: dict) -> dict: with tempfile.TemporaryDirectory() as d: f = Path(d) / "input.json" f.write_text(json.dumps(payload)) proc = subprocess.run( ["node", str(PROBE), str(STATIC), str(f), transport_argv()], capture_output=True, text=True, timeout=60, ) if proc.returncode != 0 or not proc.stdout: pytest.fail(f"upload probe failed:\n{proc.stderr}") return json.loads(proc.stdout) def _node_session(tmp_path: Path, gek: bytes) -> WebRTCPeerSession: root = tmp_path / "library" root.mkdir() index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) session = WebRTCPeerSession.__new__(WebRTCPeerSession) session._ctx = { "roots": RootSet.build([{"path": str(root), "name": "library", "writable": True}]), "index": index, "sk_node": index.sk_node, "gek": gek, } session._group_id = GROUP session._user_id = "prober" session._pk_user = "" session._uploads = {} session.sent = [] session._send = session.sent.append session._audit = lambda *a, **k: None return session def _probe_input(gek: bytes, mode: str, **extra) -> dict: return { "mode": mode, "gek": gek.hex(), "group_id": GROUP, "chunk_size": CHUNK, "dir": "library", "root": "library", "file": {"name": "holiday.jpg", "data": BODY.hex()}, **extra, } @pytest.fixture(scope="module") def _gek(): return generate_gek() @pytest.fixture(scope="module") def _sent(_gek): """Every frame the shipped `uploadFile` puts on the wire, unanswered.""" return _run_probe(_probe_input(_gek, "send")) def test_the_browser_puts_no_filename_and_no_content_on_the_wire(_sent): """The whole point, measured on the bytes rather than read off the source.""" assert _sent["frames"], "the client sent nothing" for hexframe in _sent["frames"]: raw = bytes.fromhex(hexframe) assert b"holiday.jpg" not in raw, "the filename is on the wire in clear" msg = msgpack.unpackb(raw, raw=False) assert set(msg) == {"type", "v", "upload_id", "chunk_index", "total_chunks", "nonce", "ct"} assert msg["type"] == MNP.FILE_UPLOAD async def test_a_real_node_opens_what_the_real_browser_sealed(tmp_path, _gek, _sent): """ End to end: the shipped browser encoder into the shipped node handler, with the file that lands on disk as the assertion. A mismatch in the HKDF salt, the AAD encoding or the payload shape shows up here as "did not open" — and nowhere else until somebody tries to upload something. """ session = _node_session(tmp_path, _gek) for hexframe in _sent["frames"]: await session._do_file_upload(msgpack.unpackb(bytes.fromhex(hexframe), raw=False)) errors = [m for m in session.sent if m.get("type") == "error"] assert not errors, f"the node refused a frame the browser built: {errors[:1]}" root = session._ctx["roots"].roots[0].path assert (root / "holiday.jpg").read_bytes() == BODY assert not list(root.glob("*.part")), "a temp file was left behind" async def test_the_caller_is_told_the_name_the_node_chose(tmp_path, _gek, _sent): """ `stored_as` is sealed now, so reading it takes a decrypt that can fail silently. It must not: the node finds a free name rather than replacing anything, and a chat attachment that never learns which name points at nothing. The acks below are the ones the node really produced — only their `upload_id`, which is outside the seal, is retargeted to the second probe run's own upload. """ session = _node_session(tmp_path, _gek) # A file of that name is already there, so the node has to choose another. (session._ctx["roots"].roots[0].path / "holiday.jpg").write_bytes(b"someone else's") for hexframe in _sent["frames"]: await session._do_file_upload(msgpack.unpackb(bytes.fromhex(hexframe), raw=False)) acks = [msgpack.packb(m, use_bin_type=True).hex() for m in session.sent if m.get("type") == MNP.FILE_UPLOAD_ACK] assert len(acks) == len(_sent["frames"]) result = _run_probe(_probe_input(_gek, "receive", acks=acks)) assert result["state"] == "resolved", result.get("message") assert result["stored"]["stored_as"] == "holiday (2).jpg" assert result["stored"]["dir"] == "library" async def test_an_interrupted_upload_resumes_where_the_node_stopped(tmp_path, _gek): """ The browser asks, the node answers, and the second attempt sends only what is missing. Both halves are the shipped ones: the frames come from the real `uploadFile`, the answer comes from the real node handler. What is asserted is the thing that used to be impossible — an upload interrupted at chunk two of five that sends three chunks instead of five. """ body = bytes(range(256)) * ((CHUNK * 5) // 256 + 1) body = body[:CHUNK * 5] first = _run_probe(_probe_input(_gek, "send", file={"name": "film.mkv", "data": body.hex()})) frames = [msgpack.unpackb(bytes.fromhex(f), raw=False) for f in first["frames"]] assert [f["chunk_index"] for f in frames] == [-1, 0, 1, 2, 3, 4] # The link drops after two chunks. session = _node_session(tmp_path, _gek) for frame in frames[1:3]: await session._do_file_upload(frame) assert not [m for m in session.sent if m.get("type") == "error"] # It comes back and asks. session.sent.clear() await session._do_file_upload(frames[0]) probe_ack = msgpack.packb(session.sent[-1], use_bin_type=True).hex() second = _run_probe(_probe_input( _gek, "send", file={"name": "film.mkv", "data": body.hex()}, probe_ack=probe_ack)) resumed = [msgpack.unpackb(bytes.fromhex(f), raw=False)["chunk_index"] for f in second["frames"]] assert resumed == [-1, 2, 3, 4], ( f"sent {resumed} — the answer to the probe was not used")