aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_upload_seal_client.py
blob: d233ccd6130970b64f3cf33461ed9544be8f57d2 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
"""
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

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)],
            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


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"]:
        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"


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"]:
        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"


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]:
        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()
    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")