aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/chat_send_probe.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/harness/chat_send_probe.py')
-rw-r--r--packages/meshbay-hub/tests/harness/chat_send_probe.py94
1 files changed, 89 insertions, 5 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():