aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_upload_sealed.py
blob: 7f78ba7d5e95b1bfc9292ea79ee7f92af48e325e (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
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
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"


async 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)
    await 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}


async 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)
    await session._do_file_upload(sealed_upload(
        session, filename="a.txt", data=b"x", dir=_root(session).name,
        upload_id="upload-A"))
    await 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 ──────────────────────────────────────────────────────────

async 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)
    await 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)


async 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:]
    await session._do_file_upload(msg)

    assert _errors(session)[0]["code"] == "upload_not_sealed"
    assert not _wrote_anything(tmp_path)


async 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)
    await session._do_file_upload(msg)

    assert _errors(session)[0]["code"] == "upload_not_sealed"
    assert not _wrote_anything(tmp_path)


async 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)
    await 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)


async 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""
    await session._do_file_upload(msg)

    assert _errors(session)[0]["code"] == "no_group_key"
    assert not _wrote_anything(tmp_path)


# ── What must still work ─────────────────────────────────────────────────────

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


async 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()
        await 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)


async 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"
    await session._do_file_upload(msg)

    assert _errors(session)[0]["code"] == "bad_chunk_index"
    assert not _wrote_anything(tmp_path)