aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-common/tests')
-rw-r--r--packages/meshbay-common/tests/test_js_python_parity.py209
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"]))