diff options
Diffstat (limited to 'packages/meshbay-node/tests/test_webrtc_transport.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_webrtc_transport.py | 149 |
1 files changed, 149 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py index 0d2aa3a..c0dc6d1 100644 --- a/packages/meshbay-node/tests/test_webrtc_transport.py +++ b/packages/meshbay-node/tests/test_webrtc_transport.py @@ -29,7 +29,9 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import ( from meshbay_common import MNP_VERSION from meshbay_common.adminop import ( OP_FILE_DELETE, + OP_INVITE_CANCEL, OP_INVITE_CREATE, + OP_INVITE_LINK_CREATE, admin_transcript, ) from meshbay_common.crypto import ( @@ -1406,6 +1408,153 @@ async def test_invite_then_join_delivers_the_gek(sk_node, sk_hub, gek, shared_di await transport.close_all() +async def _bearer_join(transport, sk_hub, user_id, peer_id, code, x25519_keypair, + expect_node_pk): + """A newcomer's first connection with a link code, as the browser makes it: + the challenge must prove the node key the link named before the code goes.""" + sk_x_raw, pk_x_raw = x25519_keypair + sk_ed = Ed25519PrivateKey.generate() + pc, ch, q = await _open_channel(transport, peer_id) + nonce_c = os.urandom(NONCE_LEN) + ch.send(_pack({ + "type": MNP.HANDSHAKE, "v": MNP_VERSION, + "token": _token(sk_hub, user_id, peer_id, TEST_GROUP), + "group_id": TEST_GROUP, "nonce": base64.b64encode(nonce_c).decode(), + })) + challenge = await asyncio.wait_for(q.get(), timeout=5.0) + nonce_s = base64.b64decode(challenge["nonce"]) + assert challenge["node_pk"] == expect_node_pk + Ed25519PublicKey.from_public_bytes(base64.b64decode(expect_node_pk)).verify( + base64.b64decode(challenge["sig"]), + challenge_transcript(TEST_GROUP, nonce_c, nonce_s, webrtc_binding( + _extract_dtls_fp(pc.localDescription.sdp), + _extract_dtls_fp(pc.remoteDescription.sdp)))) + + pk_ed_b64 = pk_to_b64(sk_ed.public_key()) + pk_x_b64 = base64.b64encode(pk_x_raw).decode() + ts = int(time.time()) + ch.send(_pack({ + "type": MNP.JOIN_REQUEST, "v": MNP_VERSION, "group_id": TEST_GROUP, + "pk_ed25519": pk_ed_b64, "pk_x25519": pk_x_b64, "code": code, "ts": ts, + "sig": base64.b64encode(sk_ed.sign(join_transcript( + node_pk_b64=challenge["node_pk"], group_id=TEST_GROUP, user_id=user_id, + pk_ed25519_b64=pk_ed_b64, pk_x25519_b64=pk_x_b64, + nonce_node=nonce_s, ts=ts))).decode(), + })) + result = await asyncio.wait_for(q.get(), timeout=5.0) + return pc, result, sk_x_raw, pk_x_raw + + +@pytest.mark.asyncio +async def test_a_link_is_issued_signed_redeemed_once_and_cancellable( + sk_node, sk_hub, gek, shared_dir, tmp_path, x25519_keypair): + """ + Invitation links over a real DataChannel (docs/invite-links.md §3.4). + + The operator signs `invite_link_create` naming the outcome, `link:<group>`; + the first account to bring the code is admitted and handed the key; the + second is refused; a link not yet used can be taken back, by its handle, + and is then refused too. Nothing is registered on the hub: there is no + account to register until somebody redeems it. + """ + indexer = DirectoryIndexer(roots=one_root(shared_dir), group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=_hub_pk_pem(sk_hub), gek=gek, + roots=one_root(shared_dir), index=indexer.index, stun_servers=[], + ) + transport._ctx["roster"] = roster + transport._ctx["has_admin_authority"] = True + transport._ctx["groups"] = { + TEST_GROUP: {"gek": gek, "roots": one_root(shared_dir), "index": indexer.index}, + } + hub = _InviteHub() + transport._ctx["daemon_state"] = { + "roster": roster, "groups_ctx": transport._ctx["groups"], "hub": hub, + } + sk_admin = Ed25519PrivateKey.generate() + await roster.pin_identity("user-001", "grenet", pk_to_b64(sk_admin.public_key()), + "AA==", "code") + await roster.set_member("", "user-001", ROLE_OPERATOR, "active", "local-cli") + pc_admin, ch_admin, q_admin = await _setup_peer(transport, sk_hub, gek, "peer-admin") + node_pk = pk_to_b64(sk_node.public_key()) + peers = [pc_admin] + + async def signed(request: dict, op: str, subject: str) -> dict: + ch_admin.send(_pack({"v": MNP_VERSION, **request})) + challenge = await asyncio.wait_for(q_admin.get(), timeout=5.0) + assert challenge["type"] == MNP.ADMIN_CHALLENGE, challenge + assert (challenge["op"], challenge["subject"]) == (op, subject) + ch_admin.send(_pack({ + "type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION, "op_id": challenge["op_id"], + "signature": base64.b64encode( + sk_admin.sign(_transcript_from(challenge))).decode(), + })) + return await asyncio.wait_for(q_admin.get(), timeout=5.0) + + try: + link = await signed({"type": MNP.INVITE_LINK_CREATE, "group_id": TEST_GROUP}, + OP_INVITE_LINK_CREATE, f"link:{TEST_GROUP}") + assert link["type"] == MNP.INVITE_LINK_RESULT, link + assert len(link["code"]) == 9 and len(link["invite_id"]) == 32 + assert hub.added == [], "a link registers nobody on the hub" + + pc, result, sk_x_raw, pk_x_raw = await _bearer_join( + transport, sk_hub, "user-003", "peer-first", link["code"], x25519_keypair, + node_pk) + peers.append(pc) + assert result["ok"] is True and result["gek"] is True, result + assert unwrap_gek_aes(result, sk_x_raw, pk_x_raw) == gek + + pc, result, _, _ = await _bearer_join( + transport, sk_hub, "user-004", "peer-second", link["code"], x25519_keypair, + node_pk) + peers.append(pc) + assert result.get("reason") == "code_invalid" + + spare = await signed({"type": MNP.INVITE_LINK_CREATE, "group_id": TEST_GROUP}, + OP_INVITE_LINK_CREATE, f"link:{TEST_GROUP}") + done = await signed({"type": MNP.INVITE_CANCEL, "invite_id": spare["invite_id"]}, + OP_INVITE_CANCEL, spare["invite_id"]) + assert done.get("detail") == "invite_cancelled", done + pc, result, _, _ = await _bearer_join( + transport, sk_hub, "user-005", "peer-late", spare["code"], x25519_keypair, + node_pk) + peers.append(pc) + assert result.get("reason") == "code_invalid" + finally: + await roster.close() + for pc in peers: + await pc.close() + await transport.close_all() + + +@pytest.mark.asyncio +async def test_a_link_needs_the_operator(sk_node, sk_hub, gek, shared_dir, tmp_path): + """A member who is not the operator is refused before any challenge is issued.""" + indexer = DirectoryIndexer(roots=one_root(shared_dir), group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=_hub_pk_pem(sk_hub), gek=gek, + roots=one_root(shared_dir), index=indexer.index, stun_servers=[], + ) + transport._ctx["roster"] = roster + transport._ctx["has_admin_authority"] = False + pc, ch, q = await _setup_peer(transport, sk_hub, gek, "peer-member") + try: + ch.send(_pack({"type": MNP.INVITE_LINK_CREATE, "v": MNP_VERSION})) + reply = await asyncio.wait_for(q.get(), timeout=5.0) + assert reply["type"] == "error" and "operator" in reply["detail"] + assert await roster.list_invites() == [] + finally: + await roster.close() + await pc.close() + await transport.close_all() + @pytest.mark.asyncio async def test_gek_bundle_fetch_during_handshake(sk_node, sk_hub, gek, shared_dir, tmp_path, x25519_keypair): |