diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_spa_ordering.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_spa_ordering.py | 103 |
1 files changed, 103 insertions, 0 deletions
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..0ef34fc --- /dev/null +++ b/packages/meshbay-hub/tests/test_spa_ordering.py @@ -0,0 +1,103 @@ +""" +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_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 + 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") |