diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-07 17:50:28 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-07 17:50:28 +0200 |
| commit | 36cebf25d0e0f24cf63be4380ccb5d03da726a74 (patch) | |
| tree | 8509ec4cf68a058f7383299e11bdea97ab06cadf /packages/meshbay-common/tests | |
| parent | 8883d60d0afa2ed9dd1ef68bc21fe1b9a65a59ff (diff) | |
| download | meshbay-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-common/tests')
| -rw-r--r-- | packages/meshbay-common/tests/test_js_python_parity.py | 209 |
1 files changed, 209 insertions, 0 deletions
diff --git a/packages/meshbay-common/tests/test_js_python_parity.py b/packages/meshbay-common/tests/test_js_python_parity.py index 6f7437f..dffee36 100644 --- a/packages/meshbay-common/tests/test_js_python_parity.py +++ b/packages/meshbay-common/tests/test_js_python_parity.py @@ -13,6 +13,7 @@ tests drive the real `crypto.js` under node and compare against the real Python. Skipped when node is unavailable; that is a coverage gap, not a pass. """ +import base64 import json import shutil import subprocess @@ -391,3 +392,211 @@ def test_the_browser_refuses_a_payload_sealed_for_another_message(groupbox_js): sealed["nonce"].hex(), sealed["ct"].hex()], capture_output=True, text=True, timeout=60) assert proc.stdout.strip() == "REFUSED", proc.stdout + proc.stderr + + +# ── chatbox: a chat message, sealed and signed, both directions ────────────── +# +# Same class of invisible disagreement as groupbox above, with one more moving +# part: the per-device subkey is derived from a *string* that carries the group +# id and the device's base64 key, so a mismatch in how either is encoded means +# messages that encrypt fine and never decrypt — and AES-GCM reports that +# exactly the way it reports a wrong key. +# +# The signature is checked in both directions too. It is the half that +# establishes who spoke, and unlike the ciphertext it is verified by clients +# that may never hold the epoch key at all. + +# (group_id, epoch) +CHAT_VECTORS = [ + ("g" * 32, 1), + # Epoch is inside the AAD and the transcript as a decimal string; 0 and a + # large value must not collide with each other or with the empty field. + ("g" * 32, 0), + ("g" * 32, 4294967296), + # Empty group id, the operator-pairing shape. + ("", 1), + # Non-ASCII: TextEncoder and Python's .encode() must agree. + ("groupe-café-日本", 2), + # A '|' inside the group id — the separator used by both the AAD and the + # HKDF info string. + ("a|b", 3), +] + +CHAT_EPOCH_KEY = bytes.fromhex("7c" * 32) + +_CHATBOX_HARNESS = r""" +const fs = require('fs'); + +globalThis.window = {}; +const src = fs.readFileSync(process.argv[2], 'utf8'); +const M = new Function(src + + '\nreturn { sealChat, openChat, chatSigningTranscript, verifyChatSignature };')(); + +const hex = (s) => { + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < s.length; i += 2) out[i / 2] = parseInt(s.substr(i, 2), 16); + return out; +}; +const toHex = (u8) => + Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join(''); + +(async () => { + const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); + const epochKey = hex(input.epoch_key); + const out = { opened: [], sealed: [], transcripts: [], verified: [] }; + + for (const v of input.vectors) { + // Python sealed it; open it here. + out.opened.push(toHex(await M.openChat( + epochKey, v.group_id, v.epoch, v.device_b64, hex(v.nonce), hex(v.ct)))); + // Seal the same plaintext here, for Python to open. + const sealed = await M.sealChat( + epochKey, v.group_id, v.epoch, v.device_b64, hex(v.plaintext)); + out.sealed.push({ nonce: toHex(sealed.nonce), ct: toHex(sealed.ct) }); + // The signed bytes, and whether Python's signature verifies here. + out.transcripts.push(toHex(M.chatSigningTranscript( + v.group_id, v.epoch, hex(v.device_raw), hex(v.nonce), hex(v.ct)))); + out.verified.push(await M.verifyChatSignature( + hex(v.device_raw), v.group_id, v.epoch, hex(v.nonce), hex(v.ct), + hex(v.sig))); + } + + process.stdout.write(JSON.stringify(out)); +})().catch((e) => { console.error(e); process.exit(1); }); +""" + + +def _chat_payload(idx: int) -> dict: + """A distinct message per vector, so a crossed result cannot pass.""" + return {"text": f"message {idx} — café", "thread_id": None, + "sender_name": f"member-{idx}", "sent_at": 1_700_000_000 + idx} + + +@pytest.fixture(scope="module") +def chatbox_js(tmp_path_factory): + import msgpack + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + ) + + from meshbay_common.chatbox import seal + + d = tmp_path_factory.mktemp("chatbox-parity") + harness = d / "harness.js" + harness.write_text(_CHATBOX_HARNESS) + + vectors = [] + for i, (group_id, epoch) in enumerate(CHAT_VECTORS): + # A distinct device per vector: the subkey is derived from the device's + # own base64 key, so reusing one would hide a derivation that ignored it. + sk = Ed25519PrivateKey.from_private_bytes(bytes([i + 1]) * 32) + device_raw = sk.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + device_b64 = base64.b64encode(device_raw).decode() + payload = _chat_payload(i) + sealed = seal(CHAT_EPOCH_KEY, group_id, epoch, device_b64, device_raw, + sk, payload) + vectors.append({ + "group_id": group_id, "epoch": epoch, + "device_b64": device_b64, "device_raw": device_raw.hex(), + "nonce": sealed["nonce"].hex(), "ct": sealed["ct"].hex(), + "sig": sealed["sig"].hex(), + "plaintext": msgpack.packb(payload, use_bin_type=True).hex(), + }) + + payload_file = d / "vectors.json" + payload_file.write_text(json.dumps({"epoch_key": CHAT_EPOCH_KEY.hex(), + "vectors": vectors})) + + proc = subprocess.run( + ["node", str(harness), str(CRYPTO_JS), str(payload_file)], + capture_output=True, text=True, timeout=60, + ) + if proc.returncode != 0: + pytest.fail(f"node chatbox harness failed:\n{proc.stderr}") + return json.loads(proc.stdout), vectors + + +@pytest.mark.parametrize("idx,vector", list(enumerate(CHAT_VECTORS))) +def test_browser_opens_a_chat_message_python_sealed(idx, vector, chatbox_js): + import msgpack + + js, _ = chatbox_js + assert msgpack.unpackb(bytes.fromhex(js["opened"][idx]), raw=False) == \ + _chat_payload(idx) + + +@pytest.mark.parametrize("idx,vector", list(enumerate(CHAT_VECTORS))) +def test_python_opens_a_chat_message_the_browser_sealed(idx, vector, chatbox_js): + import msgpack + + from meshbay_common.chatbox import open_message + + js, vectors = chatbox_js + group_id, epoch = vector + sealed = js["sealed"][idx] + opened = open_message( + CHAT_EPOCH_KEY, group_id, epoch, vectors[idx]["device_b64"], + bytes.fromhex(sealed["nonce"]), bytes.fromhex(sealed["ct"])) + assert opened == _chat_payload(idx) + # And the msgpack the browser produced is what Python produces, so the two + # are not merely each self-consistent. + assert msgpack.packb(opened, use_bin_type=True) == \ + bytes.fromhex(vectors[idx]["plaintext"]) + + +@pytest.mark.parametrize("idx,vector", list(enumerate(CHAT_VECTORS))) +def test_the_signing_transcript_is_byte_identical(idx, vector, chatbox_js): + from meshbay_common.chatbox import signing_transcript + + js, vectors = chatbox_js + group_id, epoch = vector + v = vectors[idx] + expected = signing_transcript( + group_id, epoch, bytes.fromhex(v["device_raw"]), + bytes.fromhex(v["nonce"]), bytes.fromhex(v["ct"])) + assert js["transcripts"][idx] == expected.hex() + + +@pytest.mark.parametrize("idx,vector", list(enumerate(CHAT_VECTORS))) +def test_the_browser_verifies_a_signature_python_made(idx, vector, chatbox_js): + js, _ = chatbox_js + assert js["verified"][idx] is True + + +def test_a_message_does_not_open_under_another_epoch(chatbox_js): + """ + The epoch is in the AAD, so a message cannot be re-presented as belonging + to a later key. Without it, a member who kept an old epoch key could make + an old message look current — and an AEAD that ignored `additionalData` + would round-trip against itself and pass every other test here. + """ + import pytest as _pytest + + from meshbay_common.chatbox import open_message + + _js, vectors = chatbox_js + v = vectors[0] + group_id, epoch = CHAT_VECTORS[0] + with _pytest.raises(Exception): + open_message(CHAT_EPOCH_KEY, group_id, epoch + 1, v["device_b64"], + bytes.fromhex(v["nonce"]), bytes.fromhex(v["ct"])) + + +def test_a_message_does_not_open_under_another_devices_key(chatbox_js): + """ + Each device has its own subkey, derived from its own public key. That is + what makes nonce reuse across two devices of one person impossible without + any coordination — the property per-device ratchet chains were wanted for. + """ + import pytest as _pytest + + from meshbay_common.chatbox import open_message + + _js, vectors = chatbox_js + group_id, epoch = CHAT_VECTORS[0] + with _pytest.raises(Exception): + open_message(CHAT_EPOCH_KEY, group_id, epoch, vectors[1]["device_b64"], + bytes.fromhex(vectors[0]["nonce"]), + bytes.fromhex(vectors[0]["ct"])) |