diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/chat_send_probe.py | 94 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_chat_send.py | 57 |
2 files changed, 145 insertions, 6 deletions
diff --git a/packages/meshbay-hub/tests/harness/chat_send_probe.py b/packages/meshbay-hub/tests/harness/chat_send_probe.py index 98983f3f..5f99beb6 100644 --- a/packages/meshbay-hub/tests/harness/chat_send_probe.py +++ b/packages/meshbay-hub/tests/harness/chat_send_probe.py @@ -36,6 +36,21 @@ Both are run with an older request already pending — the condition that turns "guess by arrival order" from usually-right into wrong — and both must come back inside a second and a half. +Since MNP 2.0 a send also has to **seal and sign for real** before it goes +anywhere, so this drives `chatKeys()`, `openGroup`, `sealChat` and a genuine +Ed25519 signature rather than a model of any of them. The device key is +generated in the page — `signBytes` imports a pkcs8 key and WebCrypto will not +be fooled by a stand-in — and the `chat_keys_resp` the stub answers with is +sealed **by the shipped Python**, because a payload the page built itself would +prove only that the page agrees with the page. + +That path found two defects the moment it first ran, neither visible in any +source file: `chat_keys_resp` was routed by arrival order and handed to the +older pending request (this defect, in a message type that did not exist when +the probe was written), and `_asText` had been deleted along 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. + Prints JSON: `scenarios`, the state of the panel at each stage of each, and `log`, what the transport sent and how the deliberately-unanswered request ended up. @@ -55,7 +70,32 @@ PORT = 8755 RECORDS = [] socketserver.TCPServer.allow_reuse_address = True -PAGE = r"""<!doctype html><html><head><meta charset=utf-8> +GROUP_ID = "g" * 32 +GEK = bytes.fromhex("5a" * 32) +EPOCH_KEY = bytes.fromhex("7c" * 32) + + +def _page() -> str: + """ + The page, with a real sealed `chat_keys_resp` baked in. + + Sealed here, by the shipped Python, rather than assembled in the browser: + msgpack is private to transport.js and exported to nothing, and a payload + the page built itself would prove only that the page agrees with the page. + """ + import msgpack # noqa: F401 (imported for the failure it gives if absent) + + from meshbay_common.groupbox import PURPOSE_CHAT_KEYS, seal + + sealed = seal(GEK, PURPOSE_CHAT_KEYS, "chat_keys_resp", GROUP_ID, + {"epochs": [{"epoch": 1, "key": EPOCH_KEY}], "current": 1}) + return (PAGE_TEMPLATE + .replace("__GROUP_ID__", GROUP_ID) + .replace("__GEK_HEX__", GEK.hex()) + .replace("__KEYS_NONCE_HEX__", sealed["nonce"].hex()) + .replace("__KEYS_CT_HEX__", sealed["ct"].hex())) + +PAGE_TEMPLATE = r"""<!doctype html><html><head><meta charset=utf-8> <link rel="stylesheet" href="/style.css"></head> <body> <div class="layout"><div class="main"> @@ -63,6 +103,11 @@ PAGE = r"""<!doctype html><html><head><meta charset=utf-8> <div class="group-tabs"><button class="group-tab active">Chat</button></div> <div id="root"></div> </div></div> +<!-- The two the real page loads and the transport reaches for by global: + `sealChat`/`openGroup` live in crypto.js, `signBytes` in keyderive.js. + Without them a send fails with "cannot read properties of undefined". --> +<script src="/crypto.js"></script> +<script src="/keyderive.js"></script> <script src="/transport.js"></script> <script type="module"> import { html, render, useRef } from '/vendor/htm-preact.js'; @@ -70,6 +115,18 @@ import { ChatPanel } from '/chat-app.js'; const log = []; window.addEventListener('error', e => log.push('error: ' + e.message)); +window.addEventListener('unhandledrejection', + e => log.push('rejected: ' + (e.reason && e.reason.message || e.reason))); + +const hex = (s) => Uint8Array.from(s.match(/../g) || [], b => parseInt(b, 16)); +const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))); + +// One device key for both scenarios. Real, not stubbed: `signBytes` imports a +// pkcs8 key, so nothing else gets a signature past `verifyChatSignature`. +const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, + ['sign', 'verify']); +const SK_ED_B64 = b64(await crypto.subtle.exportKey('pkcs8', kp.privateKey)); +const DEVICE_PK_B64 = b64(await crypto.subtle.exportKey('raw', kp.publicKey)); const now = Date.now() / 1000; const history = []; @@ -83,7 +140,7 @@ const wait = ms => new Promise(r => setTimeout(r, ms)); // Stands in for the node, answering what webrtc_server.py answers — including // stamping the reply with the id of the request it is answering, which is what // `_send` does there. `chatReply` is the only difference between the two runs. -function makeTransport(chatReply) { +function makeTransport(name, chatReply) { // The real transport, with only the channel replaced: _send takes the plain // object _sendAndWait built, so the framing and msgpack are the only things // skipped — every pending entry, key and dispatch path below is the shipped @@ -91,13 +148,31 @@ function makeTransport(chatReply) { const tp = new window.MeshBayTransport('', 'token'); tp._connected = true; tp._channel = { readyState: 'open', send() {} }; + // What a completed MNP 2.0 handshake leaves behind: the group and its key + // from `connect`, the current epoch from the sealed ack, and the device this + // connection identified itself as with `device_hello`. + tp._groupId = '__GROUP_ID__'; + tp._gekRaw = hex('__GEK_HEX__'); + tp.chatEpoch = 1; + tp._sessionKeys = { skEdB64: SK_ED_B64 }; + tp.devicePk = DEVICE_PK_B64; tp._send = (obj) => { log.push('sent ' + obj.type); const answer = (reply) => setTimeout( () => tp._dispatch({ ...reply, req_id: obj.req_id }), 10); if (obj.type === 'chat_hist') { answer({ type: 'chat_hist_resp', v: '0.2', messages: history, has_more: false }); + } else if (obj.type === 'chat_keys_req') { + // Sealed under the group key, as `_do_chat_keys_req` sends it. + answer({ type: 'chat_keys_resp', v: '2.0', group_id: tp._groupId, + nonce: hex('__KEYS_NONCE_HEX__'), ct: hex('__KEYS_CT_HEX__') }); } else if (obj.type === 'chat_msg') { + // Recorded so the test can assert what actually left the browser, rather + // than trusting that a composer which accepted the text sealed it. + log.push(name + ': chat_msg format=' + obj.format + ' epoch=' + obj.epoch + + ' ct=' + (obj.ct ? obj.ct.length : 0) + + ' sig=' + (obj.sig ? obj.sig.length : 0) + + ' plaintextLeak=' + JSON.stringify(obj).includes('hello')); answer(chatReply); } // music_meta_req is answered by nothing at all, on purpose: the node holds @@ -105,6 +180,15 @@ function makeTransport(chatReply) { // was measured at over 100 seconds with that service failing. It is the // older pending request every scenario here needs. }; + // The panel swallows a send failure into `setInput(text)`, which is right for + // a person and useless for a probe: the symptom is a message not appearing, + // with no reason anywhere. Surfaced here so a failure names itself — the + // `error` scenario is *expected* to reach this. + const _sendChat = tp.sendChat.bind(tp); + tp.sendChat = (...a) => _sendChat(...a).catch((e) => { + log.push(name + ': sendChat failed: ' + (e && e.message || e)); + throw e; + }); return tp; } @@ -112,7 +196,7 @@ function Host({ tp }) { const transportRef = useRef(tp); const gekRef = useRef(null); return html`<${ChatPanel} transportRef=${transportRef} gekRef=${gekRef} - username="me" entries=${[]} status="connected" />`; + username="me" userId="user-me" entries=${[]} status="connected" />`; } function typeInto(root, text) { @@ -127,7 +211,7 @@ function typeInto(root, text) { async function runScenario(name, chatReply) { const root = document.createElement('div'); document.getElementById('root').appendChild(root); - const tp = makeTransport(chatReply); + const tp = makeTransport(name, chatReply); render(html`<${Host} tp=${tp} />`, root); const steps = []; @@ -192,7 +276,7 @@ class H(http.server.BaseHTTPRequestHandler): def do_GET(self): if self.path == "/": - body, ctype = PAGE.encode(), "text/html; charset=utf-8" + body, ctype = _page().encode(), "text/html; charset=utf-8" else: path = (STATIC / self.path.lstrip("/")).resolve() if not str(path).startswith(str(STATIC)) or not path.is_file(): diff --git a/packages/meshbay-hub/tests/test_chat_send.py b/packages/meshbay-hub/tests/test_chat_send.py index 8b098c2a..5db8f266 100644 --- a/packages/meshbay-hub/tests/test_chat_send.py +++ b/packages/meshbay-hub/tests/test_chat_send.py @@ -28,6 +28,7 @@ either source. import json import shutil import subprocess +import sys from pathlib import Path import pytest @@ -42,7 +43,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 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()) steps = {sc["name"]: {s["label"]: s for s in sc["steps"]} @@ -103,3 +109,52 @@ def test_the_reply_is_not_handed_to_another_request(probe, reply): f"the chat {reply} was routed to the pending music_meta_req ({stolen}) -- " "that request now believes it has an answer, and the chat send is " "waiting for a reply that already arrived") + + +@pytest.mark.parametrize("reply", ["ack", "error"]) +def test_the_message_goes_out_sealed_and_signed(probe, reply): + """ + What actually left the browser (MNP 2.0). + + Asserted for both scenarios because the composer is what decides to send: + a client that fell back to plaintext when something went wrong would be + refused by the node, but the refusal arrives after the fact and reads as + "the message did not send". There is no plaintext form on the wire. + """ + data, _ = probe + sent = [line for line in data["log"] + if line.startswith(f"{reply}: chat_msg ")] + assert sent, (f"no chat_msg reached the stand-in node in the {reply} " + f"scenario — the send did not 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 same defect this file exists for, in the message type that carries the + group's chat keys — which did not exist when it was written, and which a + send now depends on. It went astray on the probe's first encrypted run. + """ + data, _ = probe + stolen = [line for line in data["log"] if "music_meta resolved with" in line] + assert not stolen, ( + f"a reply was routed to the pending music_meta_req ({stolen}) — the " + "send then waits out its own 30s timeout with the composer disabled") + + +def test_history_still_renders(probe): + """ + Not about sending at all, and here because it broke without a sound: + `_asText` was deleted with an unrelated helper beside it, its only caller + sits inside a promise the panel catches, and every conversation rendered + empty with the node answering perfectly. + """ + _, steps = probe + assert steps["ack"]["older request pending"]["bubbles"] == 5, ( + "the five history messages did not render — the panel swallows a " + "failure in the transport's message reader, so this is silent") |