diff options
Diffstat (limited to 'packages/meshbay-common/tests/test_js_python_parity.py')
| -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"])) |