aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/harness/chat_send_probe.py95
-rw-r--r--packages/meshbay-hub/tests/test_chat_send.py57
2 files changed, 144 insertions, 8 deletions
diff --git a/packages/meshbay-hub/tests/harness/chat_send_probe.py b/packages/meshbay-hub/tests/harness/chat_send_probe.py
index f1cc191..1f0557b 100644
--- a/packages/meshbay-hub/tests/harness/chat_send_probe.py
+++ b/packages/meshbay-hub/tests/harness/chat_send_probe.py
@@ -38,7 +38,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 test built itself would prove only that the page agrees with the page.
+ """
+ import msgpack
+
+ 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">
@@ -46,6 +71,13 @@ 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",
+ which is what this probe reported the first time it exercised the
+ encrypted path. -->
+<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';
@@ -53,6 +85,13 @@ 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 _warn = console.warn, _err = console.error;
+console.warn = (...a) => { log.push('warn: ' + a.join(' ')); _warn(...a); };
+console.error = (...a) => { log.push('console error: ' + a.join(' ')); _err(...a); };
+
+const hex = (s) => Uint8Array.from(s.match(/../g) || [], b => parseInt(b, 16));
// The real transport, with only the channel replaced: _send takes the plain
// object _sendAndWait built, so the framing and msgpack are the only things
@@ -61,6 +100,25 @@ const tp = new window.MeshBayTransport('', 'token');
tp._connected = true;
tp._channel = { readyState: 'open', send() {} };
+// Chat is encrypted (MNP 2.0), so a send that is going to come back has to
+// seal and sign for real. The device key is generated here rather than stubbed
+// — `signBytes` imports a pkcs8 key and WebCrypto will not be fooled — and the
+// group key and epoch keys come from Python, which sealed the `chat_keys_resp`
+// below exactly as the node does. So this exercises `chatKeys()`, `openGroup`,
+// `sealChat` and the real signature, not a model of any of them.
+tp._groupId = '__GROUP_ID__';
+tp._gekRaw = hex('__GEK_HEX__');
+tp.chatEpoch = 1;
+
+const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true,
+ ['sign', 'verify']);
+const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
+tp._sessionKeys = {
+ skEdB64: b64(await crypto.subtle.exportKey('pkcs8', kp.privateKey)),
+};
+// What `device_hello` sets on a live connection.
+tp.devicePk = b64(await crypto.subtle.exportKey('raw', kp.publicKey));
+
const now = Date.now() / 1000;
const history = [];
for (let i = 0; i < 5; i++) {
@@ -76,18 +134,42 @@ for (let i = 0; i < 5; i++) {
tp._send = (obj) => {
log.push('sent ' + obj.type);
if (obj.type === 'chat_hist') {
- setTimeout(() => tp._dispatch(
- { type: 'chat_hist_resp', v: '0.2', messages: history, has_more: false }), 10);
+ setTimeout(() => {
+ log.push('answering chat_hist');
+ tp._dispatch({ type: 'chat_hist_resp', v: '0.2', messages: history,
+ has_more: false });
+ }, 10);
+ } else if (obj.type === 'chat_keys_req') {
+ // Sealed under the group key, as `_do_chat_keys_req` sends it.
+ setTimeout(() => tp._dispatch({
+ type: 'chat_keys_resp', v: '2.0', group_id: tp._groupId,
+ nonce: hex('__KEYS_NONCE_HEX__'), ct: hex('__KEYS_CT_HEX__'),
+ }), 10);
} else if (obj.type === 'chat_msg') {
- setTimeout(() => tp._dispatch({ type: 'ack', v: '0.14' }), 10);
+ // Recorded so the test can assert the message really was sealed and
+ // signed rather than sent in clear past a composer that let it through.
+ log.push('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'));
+ setTimeout(() => tp._dispatch({ type: 'ack', v: '2.0' }), 10);
}
};
+// The panel swallows a send failure into `setInput(text)`, which is right for
+// a person and useless for a probe: the symptom is the message not appearing,
+// with no reason anywhere. Surfaced here so a failure names itself.
+const _sendChat = tp.sendChat.bind(tp);
+tp.sendChat = (...a) => _sendChat(...a).catch((e) => {
+ log.push('sendChat failed: ' + (e && e.message || e));
+ throw e;
+});
+
function Host() {
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" />`;
}
render(html`<${Host} />`, document.getElementById('root'));
@@ -99,6 +181,7 @@ function snap(label) {
out.steps.push({
label,
bubbles: document.querySelectorAll('.chat-bubble').length,
+ msgs: document.querySelectorAll('.chat-msg').length,
lastText: [...document.querySelectorAll('.chat-text')].pop()?.textContent ?? null,
// What a frozen tab actually is: the composer is disabled for as long as
// a send is in flight.
@@ -159,7 +242,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 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")