From 9f0904247116005bba8e32b9428dc6a2b994e705 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 14 Aug 2026 03:51:55 +0200 Subject: fix(client): capture the challenge values before joining, not after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit join_request signs a transcript over the node key and the node nonce, and runs before the GEK proof — a first-time member has no key to prove with. Both values were read further down, beside the proof that also uses them, so by the time joinGroup() ran neither was set and every invited member got "Handshake incomplete — reconnect and retry". They are now recorded the moment the challenge arrives. Third bug of the same shape found in a browser, and the reason is worth writing down: QE/deploy/e2e.py cannot catch any of them. It is a second implementation of the client, written in the right order by construction, so it passes while the SPA fails. It proves the protocol; it proves nothing about app.js. So this adds ordering guards over transport.js — source-level, which is not how one would normally test behaviour, but it is what sees this class of mistake: - node_pk and nonce_node are captured before joinGroup() runs - the join happens before the GEK proof - the ack still verifies the key the challenge announced Verified the way the suite requires: each fails against the source as it was, on the ordering assertion rather than on a missing marker. e2e.py also waits for the node to re-register rather than reporting "no nodes" at whoever just restarted the hub. Tests: 337 across the three packages. Co-Authored-By: Claude Opus 5 --- packages/meshbay-hub/tests/test_spa_ordering.py | 87 +++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 packages/meshbay-hub/tests/test_spa_ordering.py (limited to 'packages/meshbay-hub/tests/test_spa_ordering.py') diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py new file mode 100644 index 0000000..839698a --- /dev/null +++ b/packages/meshbay-hub/tests/test_spa_ordering.py @@ -0,0 +1,87 @@ +""" +Ordering guards for the SPA's connect() flow. + +These are source-level checks, which is not how one would normally test +behaviour. They exist because a specific class of bug shipped to a live browser +twice and no other test could see it: `connect()` is a long sequence in which +later steps read values earlier steps set, and the Python end-to-end client in +QE/deploy/ cannot catch a mistake there — it is a different implementation, +written in the right order by construction, so it passes while the browser fails. + +Concretely: join_request signs a transcript over the node key and the node nonce, +and runs *before* the GEK proof, because a first-time member has no GEK to prove. +Both values were being read further down, next to the proof that also uses them, +so every invited member hit "Handshake incomplete — reconnect and retry". + +If you restructure connect(), these will fail. Check the invariant still holds — +that nothing reads a value assigned later — and then move the markers. +""" + +from pathlib import Path + +import pytest + +STATIC = (Path(__file__).resolve().parents[1] + / "src" / "meshbay_hub" / "static") +TRANSPORT = STATIC / "transport.js" + +pytestmark = pytest.mark.skipif( + not TRANSPORT.exists(), reason="SPA sources not present") + + +def _positions(*needles: str) -> list[int]: + source = TRANSPORT.read_text() + out = [] + for needle in needles: + idx = source.find(needle) + assert idx != -1, f"{needle!r} is gone from transport.js — update this test" + out.append(idx) + return out + + +def test_challenge_values_are_captured_before_joining(): + """ + joinGroup() signs over node_pk and nonce_node, so both must be recorded when + the challenge arrives — not later, beside the proof. + """ + # Deliberately loose markers: what matters is where the assignment happens, + # not how it is spelled, so a reordering fails on the ordering assertion + # below rather than on a missing string. + node_pk, nonce_node, join_call = _positions( + "this.nodePk = reply.node_pk", + "this._nonceNode = ", + "await this.joinGroup(", + ) + assert node_pk < join_call, ( + "node_pk is read from the challenge after joinGroup() runs — the join " + "would sign a transcript naming nothing") + assert nonce_node < join_call, ( + "nonce_node is captured after joinGroup() runs — the join would not be " + "bound to this connection") + + +def test_join_happens_before_the_gek_proof(): + """ + The whole point of joining in the pre-proof window: someone who has never + held the group key cannot produce a proof, so the key has to arrive first. + """ + join_call, proof = _positions( + "await this.joinGroup(", + "await C.handshakeProof(", + ) + assert join_call < proof, ( + "the join must happen before the GEK proof — a first-time member has no " + "key to prove with") + + +def test_the_ack_still_verifies_the_announced_node_key(): + """ + Taking node_pk from the challenge is only safe because the ack proves it and + the client compares the two. Losing that check would leave the announcement + trusted on its own. + """ + source = TRANSPORT.read_text() + assert "Node identity changed during the handshake" in source, ( + "the challenge's node_pk must be checked against the ack's") + assert "verifyNodeSignature" in source, ( + "the ack's signature over the handshake transcript must still be verified") -- cgit v1.2.3 From 6309894d019421f54cff311e630f3897f7eba93e Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 14 Aug 2026 11:15:27 +0200 Subject: test: prove a second browser works after pairing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mechanism was already there — the encrypted keypair bundle goes to the node after a first successful connection, and any client holding the password can recover it — but nothing exercised it. e2e.py never pushed a bundle, so the case that matters to an ordinary user was the one case never tested. It now does what app.js does: backs the member's keys up to the node, then opens a second client carrying nothing but a username and a password. Against the live deployment that client recovers its identity keys, is recognised as the same person with no second code, gets the same group key, and browses the group. Also guards the ordering this depends on: the keypair bundle must be fetched before joinGroup() runs, or a browser that did not register has no key to sign the join with — invisible on the browser that did register, broken on every other one. Co-Authored-By: Claude Opus 5 --- packages/meshbay-hub/tests/test_spa_ordering.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) (limited to 'packages/meshbay-hub/tests/test_spa_ordering.py') diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py index 839698a..0ef34fc 100644 --- a/packages/meshbay-hub/tests/test_spa_ordering.py +++ b/packages/meshbay-hub/tests/test_spa_ordering.py @@ -74,6 +74,22 @@ def test_join_happens_before_the_gek_proof(): "key to prove with") +def test_keys_are_recovered_before_the_join_is_attempted(): + """ + A second browser holds nothing but a password. It recovers its identity keys + from the node's encrypted keypair bundle, and only then can it sign a join — + so the recovery has to come first. Getting this order wrong is invisible on + the browser that registered, and breaks every other one. + """ + recover, join_call = _positions( + "type: 'keypair_bundle_fetch'", + "await this.joinGroup(", + ) + assert recover < join_call, ( + "the keypair bundle must be fetched before joinGroup() — otherwise a " + "browser that did not register has no key to sign the join with") + + def test_the_ack_still_verifies_the_announced_node_key(): """ Taking node_pk from the challenge is only safe because the ack proves it and -- cgit v1.2.3