diff options
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/test_link_invites.py | 270 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_webrtc_transport.py | 149 |
2 files changed, 419 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_link_invites.py b/packages/meshbay-node/tests/test_link_invites.py new file mode 100644 index 0000000..f5081d2 --- /dev/null +++ b/packages/meshbay-node/tests/test_link_invites.py @@ -0,0 +1,270 @@ +""" +Invitation links: a code bound to no account until somebody redeems it. + +A link is sent to someone who may not have an account yet, so its code cannot +name one. That makes it a bearer code at the node — the hub's ticket, bound to +a verified address, is what decides who can reach the node at all +(docs/invite-links.md §3). Everything here is a way a bearer code could be made +to mean more than "one new member of this group, once", or a way an unbound row +could leak into the code paths written for bound ones. The second family is the +one to watch: `user_id = ''` must never read as "anyone" (AV1). +""" + +import sqlite3 +from datetime import UTC, datetime, timedelta + +import pytest +from meshbay_common.crypto import generate_gek, unwrap_gek_aes +from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR +from meshbay_node.roster import ( + KIND_ACCOUNT, + KIND_LINK, + MAX_LINK_INVITES_PER_GROUP, + LinkInviteLimit, + Roster, + hash_code, +) +from test_roster_pairing import _join_msg, _keypair, _keypair_full, _last, _session, _x_raw + +GROUP_A = "a" * 32 +GROUP_B = "b" * 32 + + +@pytest.fixture +async def roster(tmp_path): + r = Roster(db_path=tmp_path / "roster.db") + await r.open() + yield r + await r.close() + + +async def _link(roster, group_id=GROUP_B): + code, invite_id, _expires = await roster.create_link_invite(group_id, "cbesson") + return code, invite_id + + +# ── The roster ─────────────────────────────────────────────────────────────── + +async def test_a_link_is_redeemed_once_and_then_names_its_redeemer(roster): + code, _ = await _link(roster) + invite = await roster.consume_invite(code, "alice", group_id=GROUP_B) + assert invite and invite["role"] == ROLE_MEMBER and invite["group_id"] == GROUP_B + assert invite["user_id"] == "alice" + + assert await roster.consume_invite(code, "mallory", group_id=GROUP_B) is None + used = [i for i in await roster.list_invites(include_used=True) + if i["code_hash"] == hash_code(code)] + assert used[0]["user_id"] == "alice" and used[0]["used_at"] + + +async def test_a_link_is_good_for_its_own_group_only(roster): + code, _ = await _link(roster, GROUP_B) + for other in (GROUP_A, ""): + assert await roster.consume_invite(code, "alice", group_id=other) is None + # Not spent by the refusals. + assert await roster.consume_invite(code, "alice", group_id=GROUP_B) + + +async def test_an_active_member_cannot_spend_somebody_elses_link(roster): + await roster.set_member(GROUP_B, "bob", ROLE_MEMBER, "active", "cbesson") + code, _ = await _link(roster) + assert await roster.consume_invite(code, "bob", group_id=GROUP_B) is None + assert await roster.consume_invite(code, "alice", group_id=GROUP_B) + + +async def test_a_link_never_carries_operator_authority(roster, tmp_path): + code, _ = await _link(roster) + rows = [i for i in await roster.list_invites() if i["kind"] == KIND_LINK] + assert rows and all(r["role"] == ROLE_MEMBER for r in rows) + + # Even a row edited on disk to say otherwise is refused, not honoured. + con = sqlite3.connect(tmp_path / "roster.db") + con.execute("UPDATE invites SET role = ? WHERE code_hash = ?", + (ROLE_OPERATOR, hash_code(code))) + con.commit() + con.close() + assert await roster.consume_invite(code, "alice", group_id=GROUP_B) is None + + +async def test_an_empty_account_on_a_bound_invite_is_nobody(roster, tmp_path): + """ + AV1's shape: an account invitation whose `user_id` is empty must match no + one — not whoever turns up. Only `kind = 'link'` makes a row a bearer code. + """ + code = await roster.create_invite(GROUP_B, "", ROLE_MEMBER, "cbesson") + for who in ("alice", ""): + assert await roster.consume_invite(code, who, group_id=GROUP_B) is None + + +async def test_bound_invitations_and_links_do_not_cancel_each_other(roster): + code, invite_id = await _link(roster) + # Re-inviting an account supersedes that account's earlier code — and must + # not take the group's unredeemed links with it. + await roster.create_invite(GROUP_B, "carol", ROLE_MEMBER, "cbesson") + await roster.create_invite(GROUP_B, "carol", ROLE_MEMBER, "cbesson") + # Cancelling "nobody's" invitations is not cancelling the links. + assert await roster.drop_invites(GROUP_B, "") == 0 + assert invite_id in {i["invite_id"] for i in await roster.list_invites()} + assert await roster.consume_invite(code, "alice", group_id=GROUP_B) + + +async def test_links_are_capped_per_group(roster): + codes = [(await _link(roster, GROUP_B))[0] for _ in range(MAX_LINK_INVITES_PER_GROUP)] + with pytest.raises(LinkInviteLimit): + await _link(roster, GROUP_B) + # Another group has its own allowance. + await _link(roster, GROUP_A) + # A redeemed link is no longer outstanding, so it frees a place. + assert await roster.consume_invite(codes[0], "alice", group_id=GROUP_B) + await _link(roster, GROUP_B) + + +async def test_a_link_needs_a_group(roster): + with pytest.raises(ValueError): + await roster.create_link_invite("", "cbesson") + + +async def test_cancel_takes_back_an_unredeemed_link_of_its_own_group(roster): + code, invite_id = await _link(roster, GROUP_B) + assert not await roster.cancel_invite(GROUP_A, invite_id) + assert await roster.cancel_invite(GROUP_B, invite_id) + assert await roster.consume_invite(code, "alice", group_id=GROUP_B) is None + + code, invite_id = await _link(roster, GROUP_B) + await roster.consume_invite(code, "alice", group_id=GROUP_B) + assert not await roster.cancel_invite(GROUP_B, invite_id), ( + "a redeemed link is the record of the join, not something to cancel") + + +async def test_an_expired_link_is_refused(roster): + code, _, _ = await roster.create_link_invite(GROUP_B, "cbesson", ttl=-1) + assert await roster.consume_invite(code, "alice", group_id=GROUP_B) is None + + +async def test_a_roster_from_before_links_opens_and_keeps_its_codes(tmp_path): + """The two columns arrive by ALTER TABLE; an existing code stays a bound one.""" + path = tmp_path / "old.db" + con = sqlite3.connect(path) + con.execute("""CREATE TABLE invites ( + code_hash TEXT PRIMARY KEY, group_id TEXT NOT NULL, user_id TEXT NOT NULL, + username TEXT NOT NULL DEFAULT '', role TEXT NOT NULL, created_by TEXT NOT NULL, + created_at TEXT NOT NULL, expires_at TEXT NOT NULL, used_at TEXT)""") + expires = (datetime.now(UTC) + timedelta(days=1)).isoformat(timespec="seconds") + con.execute("INSERT INTO invites VALUES (?, ?, ?, '', ?, 'op', ?, ?, NULL)", + (hash_code("K7P2-9WQX"), GROUP_B, "alice", ROLE_MEMBER, + datetime.now(UTC).isoformat(), expires)) + con.commit() + con.close() + + r = Roster(db_path=path) + await r.open() + try: + [row] = await r.list_invites() + assert row["kind"] == KIND_ACCOUNT + assert await r.consume_invite("K7P2-9WQX", "mallory", group_id=GROUP_B) is None + assert await r.consume_invite("K7P2-9WQX", "alice", group_id=GROUP_B) + finally: + await r.close() + + +# ── The join ───────────────────────────────────────────────────────────────── + +async def test_a_newcomer_joins_with_a_link_code(tmp_path, roster): + gek = generate_gek() + code, _ = await _link(roster) + sk_ed, pk_ed_b64, pk_x_b64, sk_x = _keypair_full() + session = _session(tmp_path, roster, user_id="alice", group_id=GROUP_B, gek=gek) + + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code, + user_id="alice", group_id=GROUP_B)) + + reply = _last(session) + assert reply["ok"] is True and reply["gek"] is True + assert unwrap_gek_aes(reply, *_x_raw(sk_x, pk_x_b64)) == gek + member = await roster.get_member(GROUP_B, "alice") + assert member and member["role"] == ROLE_MEMBER + + +async def test_a_link_code_opens_no_other_group(tmp_path, roster): + code, _ = await _link(roster, GROUP_A) + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + session = _session(tmp_path, roster, user_id="alice", group_id=GROUP_B, + gek=generate_gek()) + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code, + user_id="alice", group_id=GROUP_B)) + assert _last(session).get("reason") == "code_invalid" + assert await roster.get_member(GROUP_A, "alice") is None + + +async def test_someone_already_pinned_elsewhere_joins_with_a_link(tmp_path, roster): + """The common case: known to this node through another group.""" + gek_b = generate_gek() + sk_ed, pk_ed_b64, pk_x_b64, sk_x = _keypair_full() + await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code") + await roster.set_member(GROUP_A, "grenet", ROLE_MEMBER, "active", "cbesson") + code, _ = await _link(roster, GROUP_B) + + session = _session(tmp_path, roster, user_id="grenet", group_id=GROUP_B, gek=gek_b) + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code, + user_id="grenet", group_id=GROUP_B)) + + reply = _last(session) + assert reply["ok"] is True and reply["gek"] is True + assert unwrap_gek_aes(reply, *_x_raw(sk_x, pk_x_b64)) == gek_b + assert await roster.get_member(GROUP_B, "grenet") + + +async def test_a_member_opening_the_group_with_a_link_leaves_it_unspent(tmp_path, roster): + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code") + await roster.set_member(GROUP_B, "grenet", ROLE_MEMBER, "active", "cbesson") + code, _ = await _link(roster, GROUP_B) + + session = _session(tmp_path, roster, user_id="grenet", group_id=GROUP_B, + gek=generate_gek()) + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code, + user_id="grenet", group_id=GROUP_B)) + assert _last(session)["ok"] is True + assert await roster.consume_invite(code, "alice", group_id=GROUP_B) + + +async def test_a_known_device_with_a_wrong_code_is_told_so(tmp_path, roster): + """Not the flat `not_authorized_for_group`: a code was offered and refused, + and the refusal counts against the attempt budget like any other.""" + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + await roster.pin_identity("eve", "eve", pk_ed_b64, pk_x_b64, "code") + await roster.set_member(GROUP_A, "eve", ROLE_MEMBER, "active", "cbesson") + session = _session(tmp_path, roster, user_id="eve", group_id=GROUP_B, + gek=generate_gek()) + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code="AAAA-AAAA", + user_id="eve", group_id=GROUP_B)) + assert _last(session).get("reason") == "code_invalid" + assert session._join_attempts == 1 + + +async def test_someone_removed_can_come_back_with_a_link(tmp_path, roster): + """A revoked member still has a member row here. A link sent to bring them + back must work — it is what the operator asked for.""" + gek = generate_gek() + sk_ed, pk_ed_b64, pk_x_b64, sk_x = _keypair_full() + await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code") + await roster.set_member(GROUP_B, "grenet", ROLE_MEMBER, "revoked", "cbesson") + session = _session(tmp_path, roster, user_id="grenet", group_id=GROUP_B, gek=gek) + + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, user_id="grenet", group_id=GROUP_B)) + assert _last(session).get("reason") == "not_authorized_for_group" + + code, _ = await _link(roster, GROUP_B) + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code, + user_id="grenet", group_id=GROUP_B)) + reply = _last(session) + assert reply["ok"] is True and reply["gek"] is True + assert unwrap_gek_aes(reply, *_x_raw(sk_x, pk_x_b64)) == gek + assert (await roster.get_member(GROUP_B, "grenet"))["status"] == "active" 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): |