aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_chat_send.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-07 17:50:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-07 17:50:28 +0200
commit36cebf25d0e0f24cf63be4380ccb5d03da726a74 (patch)
tree8509ec4cf68a058f7383299e11bdea97ab06cadf /packages/meshbay-hub/tests/test_chat_send.py
parent8883d60d0afa2ed9dd1ef68bc21fe1b9a65a59ff (diff)
downloadmeshbay-36cebf25d0e0f24cf63be4380ccb5d03da726a74.tar.gz
feat(chat): encrypt group chat under per-device epoch keys (MNP 2.0)
Chat messages are sealed with AES-256-GCM under a key derived per group, per epoch, per *device*, and signed over the ciphertext with the device key the node pinned. The node relays and archives; it cannot read a message. There is no switch. MNP goes to 2.0 and MNP_MIN_SUPPORTED moves with it, so a 1.x peer is refused at the handshake with `version_too_old` rather than admitted and then unable to speak. An opt-in flag was designed and rejected: every node is a test node, so it would have bought nothing and left a plaintext branch reachable — C6's lesson one feature later. A test reads the source and refuses any code that consults a `chat_encrypted` setting. Not Sender Keys, and `senderkeys.py` is now documented as unused. With distribution under the group key and a node that serves history to devices which were not present, the node must retain each chain's earliest key, and a chain key at iteration i yields every message key from i on by pure HKDF — forward secrecy is zero either way. What the ratchet was left buying was stateful client code with silent failure modes, three of them reproduced: any member could sign as any other, a second device dropped the first's chain, and the skipped-key cache grew without bound. The reasoning is in docs/chat-sender-keys.md, which is the specification and the decision record. Epochs, not rotation: the epoch key is wrapped under the group key at delivery and never stored under it, so `gek_rotate` is a re-wrap. A group-key-derived archive key would have made every message ever sent unreadable on the first `member unpin`, which is the documented step after removing a member. A new epoch opens on member revoke/unpin, device revoke and `gek_rotate`; old epochs are kept and still delivered, so history stays readable to everyone who could already read it, and nothing anywhere deletes one. Three prerequisites this needed, each a live defect on its own: * The peer registry was keyed by user_id, so one account's second device evicted the first and the broadcast skipped recipients by account — a person's phone never saw what they typed on their laptop. * The handshake authenticated an account, never a device. `device_hello` (additive, signed, refused unless the key is a live device of this account in the node's own roster) is what lets the node refuse a member claiming somebody else's key. * `_admin_exec_file_delete` authorized against the exact uploading key, so device linking had already broken deleting your own file from your other device. It now authorizes against any non-revoked device of `uploader_id`. Found by driving the real panel over the real transport, not by reading source: `chat_keys_resp` was routed by arrival order and handed to an unanswered `media_meta_req` — the original frozen-tab defect in a message type that did not exist when that probe was written. And `_asText` had been deleted with an unrelated helper beside it; its only caller sits inside a promise the panel catches, so every conversation rendered empty with nothing in the console. Existing node data is migrated by QE/migration/migrate_chat_encryption.py (not versioned, per the QE rule), run with the node stopped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
Diffstat (limited to 'packages/meshbay-hub/tests/test_chat_send.py')
-rw-r--r--packages/meshbay-hub/tests/test_chat_send.py57
1 files changed, 55 insertions, 2 deletions
diff --git a/packages/meshbay-hub/tests/test_chat_send.py b/packages/meshbay-hub/tests/test_chat_send.py
index 4224fdb..250c9a3 100644
--- a/packages/meshbay-hub/tests/test_chat_send.py
+++ b/packages/meshbay-hub/tests/test_chat_send.py
@@ -1,5 +1,5 @@
"""
-Sending a chat message must come back.
+Sending a chat message must come back — and must go out encrypted.
The node answers a chat message with a bare `{"type": "ack"}` — no request id,
no type of its own — so `_dispatch` had nothing to match it on and left it to
@@ -18,10 +18,26 @@ Videos tab that asked about a file the index no longer has leaves a
None of that is visible in `chat-app.js`, where every line is correct, so this
drives the real panel over the real transport in a browser rather than reading
either source.
+
+Extended for MNP 2.0, where a send seals and signs before it goes anywhere.
+That turned out to matter twice on its first run:
+
+ * `chat_keys_resp` answers a `chat_keys_req` under a different type string,
+ so it fell through to the arrival-order guess and was handed to the very
+ `media_meta_req` this probe leaves outstanding — the original defect, one
+ feature later, in a message type that did not exist when it was written.
+ * `_asText` had been deleted along with an unrelated helper beside it. Its
+ only caller is inside `_openChatMessage`, whose rejection the panel
+ swallows, so the whole conversation rendered empty with nothing in the
+ console and the node answering perfectly.
+
+Neither is visible in any source file, and neither would have been caught by a
+test that reads one.
"""
import json
import shutil
import subprocess
+import sys
from pathlib import Path
import pytest
@@ -36,7 +52,12 @@ pytestmark = pytest.mark.skipif(
@pytest.fixture(scope="module")
def probe():
- run = subprocess.run(["python3", str(HARNESS)], capture_output=True, timeout=180)
+ # `sys.executable`, not a bare "python3": the harness now imports
+ # `meshbay_common` to seal the chat keys the way the node does, and the
+ # system interpreter has neither that nor msgpack. The other probes get
+ # away with "python3" because they import nothing from this project.
+ run = subprocess.run([sys.executable, str(HARNESS)],
+ capture_output=True, timeout=180)
assert run.returncode == 0, run.stderr.decode()[-2000:]
data = json.loads(run.stdout.decode())
return data, {s["label"]: s for s in data["steps"]}
@@ -71,3 +92,35 @@ def test_the_ack_is_not_handed_to_another_request(probe):
"the chat ack was routed to the pending media_meta_req -- that request "
"now believes it has an answer, and the chat send is waiting for a "
"reply that already arrived")
+
+
+def test_the_message_goes_out_sealed_and_signed(probe):
+ """
+ What actually left the browser. A composer that let a plaintext message
+ through would be refused by the node, but the refusal arrives after the
+ fact and reads as "the message did not send" — so assert the shape here,
+ where the reason is visible.
+ """
+ data, _ = probe
+ sent = [line for line in data["log"] if line.startswith("chat_msg ")]
+ assert sent, ("no chat_msg reached the stand-in node — the send did not "
+ f"complete. log: {data['log']}")
+ assert "format=1" in sent[0], "the message was not sealed"
+ assert "sig=64" in sent[0], "the message was not signed"
+ assert "ct=" in sent[0] and "ct=0" not in sent[0], "there was no ciphertext"
+ assert "plaintextLeak=false" in sent[0], (
+ "the text the person typed appears somewhere in the message that went "
+ "on the wire")
+
+
+def test_the_chat_keys_answer_is_not_handed_to_another_request(probe):
+ """
+ The original defect's shape, in the message type that carries the group's
+ chat keys. An unanswered request is the ordinary case, not a rare one, and
+ the one this probe leaves outstanding swallowed the keys on the first run.
+ """
+ data, _ = probe
+ assert not any("media_meta resolved with chat_keys_resp" in line
+ for line in data["log"]), (
+ "chat_keys_resp was routed by arrival order and handed to the stale "
+ "media_meta_req — the send then waits out its own 30s timeout")