aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_chat_encryption.py
blob: 0401d27d462dae0974cd9d8c06dc8aa68a1ccdae (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
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
"""
Chat encryption: what the node stores, what it refuses, and what survives.

Design A of `docs/chat-sender-keys.md`. Every test here is written as "this
does not work" or "this still works after X" — the regressions the plan's
register names, in the order they would bite.

The load-bearing ones are the last three. Rotation is the failure the design
exists to avoid: a chat key derived from the group key would have made every
message ever sent unreadable on the first `member unpin`, for everybody,
including the operator, and that is the *documented* procedure after removing
someone. Key storage is the failure that would make the whole feature a
decoration. Downgrade is C6's lesson, one feature later.
"""

import base64
import time
from pathlib import Path

import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common.chatbox import open_message, seal
from meshbay_common.crypto import generate_gek
from meshbay_common.groupbox import PURPOSE_CHAT_KEYS, unseal
from meshbay_common.protocol import MNP
from meshbay_node import ops
from meshbay_node.bundle_store import BundleStore
from meshbay_node.chat import FORMAT_SEALED_V1, ChatStore
from meshbay_node.indexer.group_index import GroupIndex
from meshbay_node.roster import open_roster
from meshbay_node.transport.webrtc_server import WebRTCPeerSession

from conftest import one_root

GROUP = "g" * 32


def _device():
    sk = Ed25519PrivateKey.generate()
    raw = sk.public_key().public_bytes(
        serialization.Encoding.Raw, serialization.PublicFormat.Raw)
    return sk, raw, base64.b64encode(raw).decode()


@pytest.fixture
async def node(tmp_path):
    """A daemon state with the pieces the chat path actually touches."""
    roster = await open_roster(tmp_path)
    bundles = BundleStore(tmp_path / "bundles.db")
    await bundles.open()
    chat = ChatStore(tmp_path / "chat.db")
    await chat.open()

    sk_x = Ed25519PrivateKey.generate()   # stand-in shape; X25519 below
    from cryptography.hazmat.primitives.asymmetric.x25519 import (
        X25519PrivateKey,
    )
    sk_x = X25519PrivateKey.generate()
    sk_x_raw = sk_x.private_bytes(
        serialization.Encoding.Raw, serialization.PrivateFormat.Raw,
        serialization.NoEncryption())
    pk_x_raw = sk_x.public_key().public_bytes(
        serialization.Encoding.Raw, serialization.PublicFormat.Raw)

    shared = tmp_path / "shared"
    shared.mkdir(exist_ok=True)
    index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
    gek = generate_gek()

    group_ctx = {
        "gek": gek, "index": index, "roots": one_root(shared),
        "chat_store": chat, "chat_epoch": 0,
        "_peers": {},
    }
    state = {
        "roster": roster, "bundle_store": bundles,
        "sk_x25519_raw": sk_x_raw, "pk_x25519_raw": pk_x_raw,
        "groups_ctx": {GROUP: group_ctx}, "node_user_id": "operator",
    }
    yield {"state": state, "group_ctx": group_ctx, "gek": gek,
           "chat": chat, "roster": roster, "bundles": bundles,
           "index": index, "tmp_path": tmp_path}
    await chat.close()
    await bundles.close()
    await roster.close()


def _session(node, user_id="alice", device_b64=""):
    session = WebRTCPeerSession.__new__(WebRTCPeerSession)
    session._ctx = {"groups": {GROUP: node["group_ctx"]},
                    "daemon_state": node["state"]}
    session._group_id = GROUP
    session._user_id = user_id
    session._username = user_id
    session._pinned_pk = device_b64
    session._device_confirmed = bool(device_b64)
    session._registry_key = f"conn-{user_id}-{len(node['group_ctx']['_peers'])}"
    session.sent = []
    session._send = session.sent.append
    session._audit = lambda *a, **k: None
    return session


async def _drain(session, coro_holder):
    """`_spawn` stubbed to await inline, so a test sees the store written."""
    pass


def _spawn_inline(session):
    import asyncio

    pending = []
    session._spawn = lambda coro: pending.append(
        asyncio.get_event_loop().create_task(coro))
    return pending


async def _send_sealed(node, session, sk, device_raw, device_b64, text,
                       epoch=None):
    keys = await ops.chat_epoch_keys(node["state"], GROUP)
    epoch = epoch or keys[-1]["epoch"]
    key = next(k["key"] for k in keys if k["epoch"] == epoch)
    env = seal(key, GROUP, epoch, device_b64, device_raw, sk,
               {"text": text, "sender_name": session._user_id})
    pending = _spawn_inline(session)
    session._do_chat_message({
        "format": FORMAT_SEALED_V1, "epoch": epoch, "device": device_raw,
        "ct": env["ct"], "nonce": env["nonce"], "sig": env["sig"],
    })
    for task in pending:
        await task
    return env


# ── the archive survives what would destroy it ───────────────────────────────

async def test_history_survives_a_group_key_rotation(node):
    """
    R1, and the reason Design A exists.

    A chat key derived from the group key would be gone the moment the operator
    rotates — which is the documented step after removing a member. Every
    message ever sent would become unreadable, for everybody. The epoch key is
    wrapped under the group key *at delivery* and never stored under it, so a
    rotation is a re-wrap and costs nothing.
    """
    await ops.ensure_chat_epoch(node["state"], GROUP)
    sk, raw, b64 = _device()
    session = _session(node, device_b64=b64)
    await _send_sealed(node, session, sk, raw, b64, "before the rotation")

    # Rotate the group key, exactly as the operator does after a removal.
    node["group_ctx"]["gek"] = generate_gek()

    keys = await ops.chat_epoch_keys(node["state"], GROUP)
    stored = (await node["chat"].get_recent(10))[0]
    opened = open_message(
        next(k["key"] for k in keys if k["epoch"] == stored.epoch),
        GROUP, stored.epoch, b64, stored.nonce, stored.payload)
    assert opened["text"] == "before the rotation", (
        "rotating the group key must not make the chat archive unreadable — "
        "F4, and the whole reason the epoch key is not derived from it")


async def test_a_new_epoch_does_not_orphan_the_old_ones(node):
    """
    R2. Opening an epoch stops a removed member reading what comes *next*; it
    must leave what they could already read readable to everybody else.
    """
    await ops.ensure_chat_epoch(node["state"], GROUP)
    sk, raw, b64 = _device()
    session = _session(node, device_b64=b64)
    await _send_sealed(node, session, sk, raw, b64, "epoch one")

    await ops.open_chat_epoch(node["state"], GROUP)
    await _send_sealed(node, session, sk, raw, b64, "epoch two")

    keys = {k["epoch"]: k["key"]
            for k in await ops.chat_epoch_keys(node["state"], GROUP)}
    assert len(keys) == 2
    texts = []
    for m in await node["chat"].get_recent(10):
        texts.append(open_message(keys[m.epoch], GROUP, m.epoch, b64,
                                  m.nonce, m.payload)["text"])
    assert texts == ["epoch one", "epoch two"]


async def test_an_epoch_key_is_never_written_in_the_clear(node):
    """
    R15. The claim chat encryption makes is against someone who takes the
    node's storage *without the keystore password*. An epoch key sitting in a
    plaintext SQLite beside chat.db would collapse that to nothing, silently,
    and it is the obvious thing to write.
    """
    await ops.ensure_chat_epoch(node["state"], GROUP)
    keys = await ops.chat_epoch_keys(node["state"], GROUP)
    assert keys

    live = keys[-1]["key"]
    for path in sorted(node["tmp_path"].rglob("*")):
        if not path.is_file():
            continue
        assert live not in path.read_bytes(), (
            f"the live chat epoch key appears verbatim in {path.name} — "
            "it must be wrapped to the node's own key, as the GEK is")


async def test_the_stored_message_contains_neither_text_nor_display_name(node):
    """
    What "encrypted at rest" has to mean. The display name is inside the
    envelope too: on the wire it is a field any peer can set to anything, and
    the node caches it to render history, so leaving it outside would both
    leak it and leave spoofing free.
    """
    await ops.ensure_chat_epoch(node["state"], GROUP)
    sk, raw, b64 = _device()
    session = _session(node, device_b64=b64)
    await _send_sealed(node, session, sk, raw, b64, "a secret message")

    blob = (node["tmp_path"] / "chat.db").read_bytes()
    assert b"a secret message" not in blob
    stored = (await node["chat"].get_recent(10))[0]
    assert stored.format == FORMAT_SEALED_V1
    assert b"a secret message" not in stored.payload


# ── refusals ─────────────────────────────────────────────────────────────────

async def test_plaintext_is_refused_always(node):
    """
    R5 / C6's lesson one feature later, and now unconditional: there is no
    switch to leave in the wrong position. A member who can post in clear into
    a group whose members believe their chat is encrypted is a downgrade anyone
    could ask for.
    """
    await ops.ensure_chat_epoch(node["state"], GROUP)
    session = _session(node)
    _spawn_inline(session)
    session._do_chat_message({"payload": "in the clear", "sender_name": "alice"})

    assert session.sent[-1]["type"] == "error"
    assert await node["chat"].message_count() == 0


async def test_there_is_no_setting_that_re_enables_plaintext(node):
    """
    The switch is gone, not defaulted. A `chat_encrypted` in the group context
    — left by an older node's roster row, or invented by anything reading one —
    must not be consulted, or the bypass is back with a name.
    """
    node["group_ctx"]["chat_encrypted"] = False
    await ops.ensure_chat_epoch(node["state"], GROUP)
    session = _session(node)
    _spawn_inline(session)
    session._do_chat_message({"payload": "in the clear", "sender_name": "alice"})

    assert session.sent[-1]["type"] == "error"
    assert await node["chat"].message_count() == 0

    source = (Path(__file__).parent.parent / "src" / "meshbay_node"
              / "transport" / "webrtc_server.py").read_text(encoding="utf-8")
    assert 'get("chat_encrypted"' not in source, (
        "nothing may read a chat_encrypted setting — there is no switch")


async def test_a_member_cannot_send_as_another_members_device(node):
    """
    The hole that would have made encrypted chat *worse* than plaintext chat.

    Receivers verify a signature against the `device` field, so a member free
    to name somebody else's key could be that member to everyone — and the
    signature would check out. The connection has proved which device it is,
    and the claim must match it.
    """
    await ops.ensure_chat_epoch(node["state"], GROUP)
    _sk_alice, raw_alice, b64_alice = _device()
    sk_mallory, raw_mallory, b64_mallory = _device()

    session = _session(node, user_id="mallory", device_b64=b64_mallory)
    keys = await ops.chat_epoch_keys(node["state"], GROUP)
    epoch, key = keys[-1]["epoch"], keys[-1]["key"]
    # Correctly sealed and correctly signed — by Mallory, claiming to be Alice.
    env = seal(key, GROUP, epoch, b64_alice, raw_alice, sk_mallory,
               {"text": "not from alice"})
    _spawn_inline(session)
    session._do_chat_message({
        "format": FORMAT_SEALED_V1, "epoch": epoch, "device": raw_alice,
        "ct": env["ct"], "nonce": env["nonce"], "sig": env["sig"],
    })

    assert session.sent[-1]["type"] == "error"
    assert await node["chat"].message_count() == 0


async def test_a_signed_message_cannot_be_replayed(node):
    """
    A replay is a *validly signed* copy of a real message, so nothing about
    the signature refuses it. The unique (device, nonce) does — and the nonce
    is already required to be unique for AES-GCM to be safe, so it costs
    nothing to make it a key.
    """
    await ops.ensure_chat_epoch(node["state"], GROUP)
    sk, raw, b64 = _device()
    session = _session(node, device_b64=b64)
    env = await _send_sealed(node, session, sk, raw, b64, "said once")
    assert await node["chat"].message_count() == 1

    keys = await ops.chat_epoch_keys(node["state"], GROUP)
    pending = _spawn_inline(session)
    session._do_chat_message({
        "format": FORMAT_SEALED_V1, "epoch": keys[-1]["epoch"], "device": raw,
        "ct": env["ct"], "nonce": env["nonce"], "sig": env["sig"],
    })
    for task in pending:
        await task
    assert await node["chat"].message_count() == 1, (
        "a replayed message must not be stored twice")


async def test_an_unidentified_connection_cannot_send_a_signed_message(node):
    """
    `device_hello` is what makes "that is not the device on this connection"
    checkable at all. Without it the node knows the account and not the key,
    and a `device` field would be an assertion nobody verified.
    """
    await ops.ensure_chat_epoch(node["state"], GROUP)
    sk, raw, b64 = _device()
    session = _session(node)              # no device_hello
    keys = await ops.chat_epoch_keys(node["state"], GROUP)
    epoch, key = keys[-1]["epoch"], keys[-1]["key"]
    env = seal(key, GROUP, epoch, b64, raw, sk, {"text": "x"})
    _spawn_inline(session)
    session._do_chat_message({
        "format": FORMAT_SEALED_V1, "epoch": epoch, "device": raw,
        "ct": env["ct"], "nonce": env["nonce"], "sig": env["sig"],
    })
    assert session.sent[-1]["type"] == "error"


# ── key delivery ─────────────────────────────────────────────────────────────

async def test_the_keys_are_delivered_sealed_under_the_group_key(node):
    """
    Sealed for the same reason the index and the ack are, one step stronger:
    the payload *is* key material. A peer that has completed the handshake
    holds the group key and can open it; anything short of that gets a
    ciphertext.
    """
    await ops.ensure_chat_epoch(node["state"], GROUP)
    await ops.open_chat_epoch(node["state"], GROUP)
    session = _session(node)
    await session._do_chat_keys_req({})

    resp = session.sent[-1]
    assert resp["type"] == MNP.CHAT_KEYS_RESP
    assert "epochs" not in resp, "the keys must not travel in clear"
    payload = unseal(node["gek"], PURPOSE_CHAT_KEYS, MNP.CHAT_KEYS_RESP,
                     GROUP, resp)
    assert [e["epoch"] for e in payload["epochs"]] == [1, 2]
    assert payload["current"] == 2
    for e in payload["epochs"]:
        assert len(e["key"]) == 32


async def test_every_epoch_is_delivered_not_just_the_current_one(node):
    """
    R2 again, from the delivery side: this is what lets a device linked this
    morning read a conversation from last year.
    """
    await ops.ensure_chat_epoch(node["state"], GROUP)
    for _ in range(3):
        await ops.open_chat_epoch(node["state"], GROUP)
    session = _session(node)
    await session._do_chat_keys_req({})
    payload = unseal(node["gek"], PURPOSE_CHAT_KEYS, MNP.CHAT_KEYS_RESP,
                     GROUP, session.sent[-1])
    assert [e["epoch"] for e in payload["epochs"]] == [1, 2, 3, 4]


# ── epochs move when access shrinks ─────────────────────────────────────────

async def test_revoking_a_device_opens_a_new_epoch(node):
    """
    A revoked device holds every chat key it ever received. Revocation stops
    the node handing over the *next* one; nothing else takes the current one
    away — the exact counterpart of "still rotate the GEK".
    """
    await ops.ensure_chat_epoch(node["state"], GROUP)
    before = await node["bundles"].latest_chat_epoch(GROUP)
    session = _session(node)
    await session._new_chat_epoch(GROUP, "device_revoke")
    assert await node["bundles"].latest_chat_epoch(GROUP) == before + 1


async def test_a_group_always_gets_an_epoch(node):
    """
    Chat is always encrypted, so a group with no epoch key is a group nobody
    can speak in. `ensure_chat_epoch` is what the daemon calls at group load —
    at start-up, where a failure lands in the log the operator is already
    reading rather than on somebody's first message.
    """
    assert await node["bundles"].latest_chat_epoch(GROUP) == 0
    epoch = await ops.ensure_chat_epoch(node["state"], GROUP)
    assert epoch == 1
    # Idempotent: called at every group load, and a second epoch per restart
    # would be a key nobody needed and the node keeps for ever.
    assert await ops.ensure_chat_epoch(node["state"], GROUP) == 1


async def test_an_epoch_key_is_never_deleted(node):
    """
    Nothing in the system removes an epoch key, and nothing may: the messages
    sealed under it become unreadable the moment it goes, for everybody. The
    only operation that touches the table adds a row.
    """
    await ops.ensure_chat_epoch(node["state"], GROUP)
    sk, raw, b64 = _device()
    session = _session(node, device_b64=b64)
    await _send_sealed(node, session, sk, raw, b64, "still readable")
    await ops.open_chat_epoch(node["state"], GROUP)
    await ops.prune_chat(node["state"], GROUP, 3650)

    keys = await ops.chat_epoch_keys(node["state"], GROUP)
    assert [k["epoch"] for k in keys] == [1, 2]

    source = (Path(__file__).parent.parent / "src" / "meshbay_node"
              / "bundle_store.py").read_text(encoding="utf-8")
    assert "DELETE FROM chat_epochs" not in source
    assert "INSERT OR REPLACE INTO chat_epochs" not in source, (
        "an epoch key is written once — REPLACE would destroy the history "
        "sealed under it, with no error anywhere")


# ── the explicit history migration, and retention ───────────────────────────

async def test_encrypt_history_converts_the_old_plaintext(node):
    """
    The migration for a node that ran before MNP 2.0.

    The plaintext row is written straight into the store, because that is the
    only way one can exist now: `_do_chat_message` refuses plaintext outright.
    Such rows are the ones still readable off a stolen disk, and the node can
    convert them only because it holds them in the clear — it is the last
    moment at which anyone can.
    """
    node["state"]["sk_node"] = Ed25519PrivateKey.generate()
    await node["chat"].save_message(
        sender_id="alice", iteration=0, payload=b"written in the clear",
        sender_name="alice")
    await ops.ensure_chat_epoch(node["state"], GROUP)

    result = await ops.encrypt_chat_history(node["state"], GROUP)

    assert result["converted"] == 1
    stored = (await node["chat"].get_recent(10))[0]
    assert stored.format == FORMAT_SEALED_V1
    assert b"written in the clear" not in stored.payload
    assert stored.sender_name == "", (
        "the display name moves inside the envelope — leaving it would keep in "
        "the clear the one field the sealing was for")

    keys = {k["epoch"]: k["key"]
            for k in await ops.chat_epoch_keys(node["state"], GROUP)}
    device_b64 = base64.b64encode(stored.device).decode()
    opened = open_message(keys[stored.epoch], GROUP, stored.epoch, device_b64,
                          stored.nonce, stored.payload)
    assert opened["text"] == "written in the clear"
    assert opened["sender_name"] == "alice"
    assert opened["migrated"] is True, (
        "a migrated message carries the node's word for who wrote it, which is "
        "all it ever carried — that has to be visible, not inferred")


async def test_encrypt_history_backs_the_database_up_first(node):
    node["state"]["sk_node"] = Ed25519PrivateKey.generate()
    await node["chat"].save_message(
        sender_id="alice", iteration=0, payload=b"one", sender_name="alice")
    await ops.ensure_chat_epoch(node["state"], GROUP)

    result = await ops.encrypt_chat_history(node["state"], GROUP)

    from pathlib import Path
    backup = Path(result["backup"])
    assert backup.exists() and backup.stat().st_size > 0
    assert b"one" in backup.read_bytes(), (
        "the backup is taken before the rewrite, or it is not a backup")


async def test_retention_deletes_messages_and_never_epoch_keys(node):
    """
    R16. An epoch whose messages have all aged out costs 32 bytes; deleting it
    would make anything still stored under it unreadable.
    """
    await ops.ensure_chat_epoch(node["state"], GROUP)
    sk, raw, b64 = _device()
    session = _session(node, device_b64=b64)
    await _send_sealed(node, session, sk, raw, b64, "old news")

    # Age it past the cutoff.
    await node["chat"]._db.execute(
        "UPDATE messages SET timestamp = ?", (time.time() - 40 * 86400,))
    await node["chat"].commit()

    result = await ops.prune_chat(node["state"], GROUP, 30)

    assert result["removed"] == 1
    assert await node["chat"].message_count() == 0
    assert await ops.chat_epoch_keys(node["state"], GROUP), (
        "retention deletes messages, never keys")


async def test_retention_refuses_a_zero_day_window(node):
    """`prune 0` would delete the whole conversation and read as a typo."""
    with pytest.raises(ops.OpError):
        await ops.prune_chat(node["state"], GROUP, 0)