aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/tests')
-rw-r--r--packages/meshbay-node/tests/test_roster_pairing.py504
-rw-r--r--packages/meshbay-node/tests/test_security_regressions.py58
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py178
3 files changed, 656 insertions, 84 deletions
diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py
new file mode 100644
index 0000000..e0492ae
--- /dev/null
+++ b/packages/meshbay-node/tests/test_roster_pairing.py
@@ -0,0 +1,504 @@
+"""
+Roster and operator pairing (M3, and the mechanism that will close H3).
+
+Negative assertions, per the posture set in Phase 11.5: each test states an attack
+or a mistake that must not work. The one to keep an eye on is
+`test_daemon_does_not_auto_pin_keystore_key` — the auto-pin is what made node
+sovereignty inert as shipped, and it fails closed, so nothing else in the suite
+notices if it comes back.
+
+See `docs/invite-pairing-v1.md`.
+"""
+
+import base64
+import time
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+
+from meshbay_common.crypto import generate_gek, pk_to_b64, unwrap_gek_aes
+from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR, join_transcript
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.roster import Roster, hash_code, normalize_code
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+
+# ── Fixtures ──────────────────────────────────────────────────────────────────
+
+@pytest.fixture
+async def roster(tmp_path):
+ r = Roster(db_path=tmp_path / "roster.db")
+ await r.open()
+ yield r
+ await r.close()
+
+
+def _keypair_full():
+ """(sk_ed, pk_ed_b64, pk_x_b64, sk_x) — the X25519 secret is needed to unwrap."""
+ sk_ed = Ed25519PrivateKey.generate()
+ sk_x = X25519PrivateKey.generate()
+ pk_ed_b64 = pk_to_b64(sk_ed.public_key())
+ pk_x_b64 = base64.b64encode(
+ sk_x.public_key().public_bytes(
+ encoding=serialization.Encoding.Raw,
+ format=serialization.PublicFormat.Raw,
+ )
+ ).decode()
+ return sk_ed, pk_ed_b64, pk_x_b64, sk_x
+
+
+def _keypair():
+ sk_ed, pk_ed_b64, pk_x_b64, _ = _keypair_full()
+ return sk_ed, pk_ed_b64, pk_x_b64
+
+
+def _session(tmp_path: Path, roster, user_id: str = "grenet",
+ group_id: str | None = None, gek: bytes | None = None,
+ join_policy: str = "invite") -> WebRTCPeerSession:
+ """A peer session with the join path wired and sending stubbed out."""
+ shared_root = tmp_path / "shared"
+ shared_root.mkdir(exist_ok=True)
+ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
+
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = {
+ "shared_root": shared_root,
+ "index": index,
+ "sk_node": index.sk_node,
+ "roster": roster,
+ }
+ if group_id:
+ session._ctx["groups"] = {
+ group_id: {
+ "gek": gek,
+ "shared_root": shared_root,
+ "index": index,
+ "join_policy": join_policy,
+ },
+ }
+ session._group_id = group_id
+ session._user_id = user_id
+ session._username = user_id
+ session._pk_user = ""
+ session._uploads = {}
+ session._join_attempts = 0
+ session._nonce_node = b"\x11" * 32
+ session._remote_ip = ""
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ return session
+
+
+def _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code="", user_id="grenet",
+ group_id="", nonce=None, ts=None):
+ ts = int(time.time()) if ts is None else ts
+ transcript = join_transcript(
+ node_pk_b64=session._node_pk_b64(),
+ group_id=group_id,
+ user_id=user_id,
+ pk_ed25519_b64=pk_ed_b64,
+ pk_x25519_b64=pk_x_b64,
+ nonce_node=nonce if nonce is not None else session._nonce_node,
+ ts=ts,
+ )
+ return {
+ "type": "join_request",
+ "group_id": group_id,
+ "pk_ed25519": pk_ed_b64,
+ "pk_x25519": pk_x_b64,
+ "code": code,
+ "ts": ts,
+ "sig": base64.b64encode(sk_ed.sign(transcript)).decode(),
+ }
+
+
+def _last(session):
+ return session.sent[-1] if session.sent else {}
+
+
+# ── Roster ────────────────────────────────────────────────────────────────────
+
+async def test_invite_is_single_use(roster):
+ code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+ assert await roster.consume_invite(code, "grenet") is not None
+ assert await roster.consume_invite(code, "grenet") is None, (
+ "a pairing code must not be redeemable twice")
+
+
+async def test_invite_is_bound_to_one_account(roster):
+ """A leaked code must be useless to whoever finds it."""
+ code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+ assert await roster.consume_invite(code, "eve") is None
+ assert await roster.consume_invite(code, "grenet") is not None
+
+
+async def test_expired_invite_is_refused(roster):
+ code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli", ttl=-1)
+ assert await roster.consume_invite(code, "grenet") is None
+
+
+async def test_reinvite_supersedes_the_previous_code(roster):
+ first = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+ second = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+ assert await roster.consume_invite(first, "grenet") is None
+ assert await roster.consume_invite(second, "grenet") is not None
+
+
+async def test_codes_are_not_stored_in_the_clear(roster, tmp_path):
+ code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+ rows = await roster.list_invites()
+ assert rows and rows[0]["code_hash"] != normalize_code(code)
+ assert rows[0]["code_hash"] == hash_code(code)
+
+
+def test_code_normalization_absorbs_human_error():
+ """Someone reading a code aloud must not be able to get it wrong."""
+ assert normalize_code("k7m2-qx4p") == normalize_code("K7M2QX4P")
+ assert normalize_code("O1IL") == "0111"
+ assert normalize_code(" k7m2 qx4p ") == "K7M2QX4P"
+
+
+async def test_operator_pks_reflect_unpinning(roster):
+ _, pk_ed_b64, pk_x_b64 = _keypair()
+ await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code")
+ await roster.set_member("", "grenet", ROLE_OPERATOR, "active", "local-cli")
+ assert await roster.operator_pks() == [pk_ed_b64]
+
+ await roster.unpin("grenet")
+ assert await roster.operator_pks() == [], (
+ "authority must disappear with the pin, without a daemon restart")
+
+
+# ── Join / pairing over MNP ───────────────────────────────────────────────────
+
+async def test_pairing_with_a_valid_code_pins_the_identity(tmp_path, roster):
+ session = _session(tmp_path, roster)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code))
+
+ assert _last(session).get("ok") is True
+ pinned = await roster.get_identity("grenet")
+ assert pinned["pk_ed25519"] == pk_ed_b64
+ assert await roster.operator_pks() == [pk_ed_b64]
+
+
+async def test_pairing_without_a_code_is_refused(tmp_path, roster):
+ """Fails closed: an unknown identity gets nothing until someone authorizes it."""
+ session = _session(tmp_path, roster)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+
+ await session._do_join_request(_join_msg(session, sk_ed, pk_ed_b64, pk_x_b64))
+
+ assert _last(session).get("ok") is False
+ assert _last(session).get("reason") == "code_required"
+ assert await roster.get_identity("grenet") is None
+
+
+async def test_wrong_code_pins_nothing(tmp_path, roster):
+ session = _session(tmp_path, roster)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code="ZZZZ-ZZZZ"))
+
+ assert _last(session).get("reason") == "code_invalid"
+ assert await roster.get_identity("grenet") is None
+
+
+async def test_signature_must_cover_the_presented_keys(tmp_path, roster):
+ """
+ The heart of it: the X25519 key is only trustworthy because the Ed25519
+ identity signed it. Swapping in another encryption key after signing must fail.
+ """
+ session = _session(tmp_path, roster)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+
+ msg = _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code)
+ _, _, attacker_pk_x = _keypair()
+ msg["pk_x25519"] = attacker_pk_x
+
+ await session._do_join_request(msg)
+
+ assert _last(session).get("reason") == "signature_invalid"
+ assert await roster.get_identity("grenet") is None
+
+
+async def test_join_cannot_be_replayed_onto_another_connection(tmp_path, roster):
+ session = _session(tmp_path, roster)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+
+ # Signed against a nonce this connection never issued.
+ msg = _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code,
+ nonce=b"\x99" * 32)
+ await session._do_join_request(msg)
+
+ assert _last(session).get("reason") == "signature_invalid"
+ assert await roster.get_identity("grenet") is None
+
+
+async def test_pinned_identity_presenting_a_new_key_is_refused(tmp_path, roster):
+ """
+ 11.5.8's rule, applied to people: a changed key is refused outright rather
+ than warned about, and clearing it is a deliberate operator action.
+ """
+ session = _session(tmp_path, roster)
+ _, old_pk_ed, old_pk_x = _keypair()
+ await roster.pin_identity("grenet", "grenet", old_pk_ed, old_pk_x, "code")
+
+ sk_ed2, new_pk_ed, new_pk_x = _keypair()
+ await session._do_join_request(
+ _join_msg(session, sk_ed2, new_pk_ed, new_pk_x, code="ANY-CODE"))
+
+ assert _last(session).get("reason") == "key_changed"
+ assert (await roster.get_identity("grenet"))["pk_ed25519"] == old_pk_ed
+
+
+async def test_attempts_are_bounded(tmp_path, roster):
+ session = _session(tmp_path, roster)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+
+ for _ in range(6):
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code="AAAA-AAAA"))
+
+ assert any(m.get("detail") == "Too many attempts" for m in session.sent), (
+ "a connection must not be able to sit there guessing codes")
+
+
+async def test_failures_are_counted_across_connections(tmp_path, roster):
+ """
+ The adversary who can mint a token for any account is the hub, and it can
+ reconnect at will — so a per-connection budget alone would bound nothing.
+ """
+ shared_ctx = None
+ for _ in range(6):
+ session = _session(tmp_path, roster)
+ if shared_ctx is None:
+ shared_ctx = session._ctx
+ else:
+ session._ctx = shared_ctx # same node, new connection
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ for _ in range(4):
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code="AAAA-AAAA"))
+
+ assert any(m.get("detail") == "Pairing temporarily locked"
+ for m in session.sent), (
+ "reconnecting must not reset the pairing budget")
+
+
+async def test_group_id_cannot_name_another_group(tmp_path, roster):
+ session = _session(tmp_path, roster)
+ session._group_id = "a" * 32
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, group_id="b" * 32))
+
+ assert _last(session).get("reason") == "group_mismatch"
+
+
+# ── H3: the node wraps the group key, and only for people it admitted ─────────
+
+GROUP = "g" * 32
+
+
+async def test_node_wraps_the_gek_for_the_key_the_member_proved(tmp_path, roster):
+ """
+ The H3 fix. Nobody fetches a public key from the hub: the node encrypts the
+ group key for the X25519 key the joiner signed with their pinned identity, so
+ a hub substituting a key of its own has nothing to substitute into.
+ """
+ gek = generate_gek()
+ session = _session(tmp_path, roster, user_id="bob", group_id=GROUP, gek=gek)
+ sk_ed, pk_ed_b64, pk_x_b64, sk_x = _keypair_full()
+
+ code = await roster.create_invite(GROUP, "bob", ROLE_MEMBER, "grenet")
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code,
+ user_id="bob", group_id=GROUP))
+
+ reply = _last(session)
+ assert reply["ok"] is True and reply["gek"] is True
+
+ pk_x_raw = base64.b64decode(pk_x_b64)
+ sk_x_raw = sk_x.private_bytes(
+ encoding=serialization.Encoding.Raw,
+ format=serialization.PrivateFormat.Raw,
+ encryption_algorithm=serialization.NoEncryption(),
+ )
+ assert unwrap_gek_aes(reply, sk_x_raw, pk_x_raw) == gek
+
+
+async def test_hub_membership_alone_yields_no_key(tmp_path, roster):
+ """
+ A hub can invent an account, add it to a group and mint it a token. What it
+ cannot do is put it on the node's roster — so the key never leaves.
+ """
+ gek = generate_gek()
+ session = _session(tmp_path, roster, user_id="eve", group_id=GROUP, gek=gek)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+
+ # Pinned on this node (say, for another group) but never admitted to this one.
+ await roster.pin_identity("eve", "eve", pk_ed_b64, pk_x_b64, "code")
+
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64,
+ user_id="eve", group_id=GROUP))
+
+ reply = _last(session)
+ assert reply.get("gek") is False
+ assert reply.get("reason") == "not_authorized_for_group"
+ assert "wrapped_b64" not in reply
+
+
+async def test_open_join_group_admits_without_a_code(tmp_path, roster):
+ """§3.4: where anyone may join, a code protects nothing and is not required."""
+ gek = generate_gek()
+ session = _session(tmp_path, roster, user_id="newcomer", group_id=GROUP,
+ gek=gek, join_policy="open")
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64,
+ user_id="newcomer", group_id=GROUP))
+
+ reply = _last(session)
+ assert reply["ok"] is True and reply["gek"] is True
+ pinned = await roster.get_identity("newcomer")
+ assert pinned["pinned_via"] == "tofu"
+
+
+async def test_invite_only_group_still_demands_a_code(tmp_path, roster):
+ """Being public (discoverable) is not being open (admitting anyone)."""
+ gek = generate_gek()
+ session = _session(tmp_path, roster, user_id="newcomer", group_id=GROUP,
+ gek=gek, join_policy="invite")
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64,
+ user_id="newcomer", group_id=GROUP))
+
+ assert _last(session).get("reason") == "code_required"
+ assert await roster.get_identity("newcomer") is None
+
+
+async def test_unknown_group_is_invite_only(tmp_path, roster):
+ """
+ Fail closed: a group whose policy the node cannot read is treated as
+ invite-only, never as open.
+ """
+ session = _session(tmp_path, roster, user_id="newcomer")
+ session._group_id = "unconfigured-group"
+ assert session._group_join_policy("unconfigured-group") == "invite"
+ assert session._group_join_policy("") == "invite"
+
+
+def test_join_policy_is_carried_from_node_config():
+ """
+ The policy reaches the transport from node.toml. If it ever came from the hub
+ instead, a hub could declare any group open and be handed its key.
+ """
+ daemon_src = (Path(__file__).parent.parent
+ / "src" / "meshbay_node" / "daemon.py").read_text()
+ assert '"join_policy": group_cfg.join_policy' in daemon_src
+
+ config_src = (Path(__file__).parent.parent
+ / "src" / "meshbay_node" / "config.py").read_text()
+ assert "join_policy" in config_src, "GroupConfig must carry the admission policy"
+
+
+async def test_revoked_member_stops_receiving_the_key(tmp_path, roster):
+ """
+ Wrapping on demand is what makes revocation work. A stored bundle survived
+ revocation; this does not. (Rotating the GEK is still required — the
+ ex-member has the old one.)
+ """
+ gek = generate_gek()
+ session = _session(tmp_path, roster, user_id="bob", group_id=GROUP, gek=gek)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ await roster.pin_identity("bob", "bob", pk_ed_b64, pk_x_b64, "code")
+ await roster.set_member(GROUP, "bob", ROLE_MEMBER, "active", "grenet")
+
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64,
+ user_id="bob", group_id=GROUP))
+ assert _last(session)["gek"] is True
+
+ await roster.set_status(GROUP, "bob", "revoked")
+ session.sent.clear()
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64,
+ user_id="bob", group_id=GROUP))
+ assert _last(session).get("gek") is False
+
+
+# ── M3: where node authority comes from ───────────────────────────────────────
+
+async def test_admin_signature_verified_against_the_paired_key(tmp_path, roster):
+ session = _session(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("", "grenet", ROLE_OPERATOR, "active", "local-cli")
+
+ transcript = b"meshbay:admin:v1 whatever"
+ assert await session._verify_admin_sig(transcript, sk_ed.sign(transcript))
+
+ stranger = Ed25519PrivateKey.generate()
+ assert not await session._verify_admin_sig(
+ transcript, stranger.sign(transcript))
+
+
+async def test_unpinned_operator_loses_authority_immediately(tmp_path, roster):
+ """No caching: revoking a paired browser must not need a daemon restart."""
+ session = _session(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("", "grenet", ROLE_OPERATOR, "active", "local-cli")
+
+ transcript = b"meshbay:admin:v1 whatever"
+ assert await session._verify_admin_sig(transcript, sk_ed.sign(transcript))
+
+ await roster.unpin("grenet")
+ assert not await session._verify_admin_sig(transcript, sk_ed.sign(transcript))
+
+
+def test_daemon_does_not_auto_pin_keystore_key():
+ """
+ M3: the daemon used to auto-pin its own keystore key as the admin key, while
+ the browser signs with the user's identity key. Different keys, so every
+ privileged operation failed closed with a signature error that looked like a
+ bug elsewhere — and the demo only worked because a deploy script overwrote it.
+
+ Authority now comes from the roster, or from an explicit node.toml value.
+ """
+ source = (Path(__file__).parent.parent
+ / "src" / "meshbay_node" / "daemon.py").read_text()
+ assert "Auto-pinning admin key" not in source
+ assert "_resolve_admin_pk" not in source, (
+ "the auto-pin resolver is back — node authority must be established "
+ "locally by pairing, never inferred from the node's own keystore (M3)")
+
+
+def test_admin_authority_is_never_fetched_from_the_hub():
+ """
+ The fix M3 invites: ask the hub which key belongs to the operator. That would
+ hand a malicious hub the node — the same substitution as H3, one level deeper.
+ """
+ source = (Path(__file__).parent.parent
+ / "src" / "meshbay_node" / "daemon.py").read_text()
+ admin_region = source[source.find("_legacy_admin_pk"):]
+ assert "pubkeys" not in admin_region.split("def ")[1], (
+ "node authority must never be resolved through a hub lookup")
diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py
index 9299bf4..6bb680c 100644
--- a/packages/meshbay-node/tests/test_security_regressions.py
+++ b/packages/meshbay-node/tests/test_security_regressions.py
@@ -216,16 +216,35 @@ def test_daemon_sets_no_global_chat_store(tmp_path):
# ── H2: node admin UI escaping ───────────────────────────────────────────────
-def test_gek_bundle_store_requires_admin_challenge(tmp_path):
+def test_no_member_can_hand_the_node_key_material(tmp_path):
"""
- C5b: gek_bundle_store used to write whatever any authenticated member sent.
- It must now answer with a challenge and store nothing until a valid
- node-operator signature arrives.
+ C5b, strengthened by the invite redesign (docs/invite-pairing-v1.md).
+
+ This test used to assert that `gek_bundle_store` answered with an admin
+ challenge and stored nothing without an operator signature. The message is now
+ gone entirely: the node holds the GEK and wraps it itself, so no member ever
+ submits key material, authorized or not. Deleting the path is a stronger
+ guarantee than gating it, which is why the assertion changed rather than the
+ behaviour regressing.
"""
+ from meshbay_common.protocol import MNP as _MNP
+
+ assert not hasattr(_MNP, "GEK_BUNDLE_STORE"), (
+ "the member-supplied bundle message is back — the node must never accept "
+ "key material over MNP (C5b)"
+ )
+
+ source = (Path(__file__).parent.parent
+ / "src" / "meshbay_node" / "transport" / "webrtc_server.py").read_text()
+ assert "_do_gek_bundle_store" not in source
+ assert "_admin_exec_bundle_store" not in source
+
+
+def test_unknown_message_stores_nothing(tmp_path):
+ """A peer sending the retired message must not reach any storage path."""
session = _session(tmp_path, "ordinary-member")
session._group_id = None
session._admin_ops = {}
- session._ctx["admin_pk_ed25519"] = Ed25519PrivateKey.generate().public_key()
stored = []
@@ -234,27 +253,13 @@ def test_gek_bundle_store_requires_admin_challenge(tmp_path):
stored.append(args)
session._ctx["bundle_store"] = _Store()
- session._do_gek_bundle_store({
+ session._handle_message({
+ "type": "gek_bundle_store",
"user_id": "victim", "group_id": "g" * 32,
"pk_eph_b64": "AA==", "nonce_b64": "AA==", "wrapped_b64": "AA==",
})
- assert stored == [], "bundle written without operator authorization (C5b)"
- assert any(m.get("type") == "admin_challenge" for m in session.sent)
-
-
-def test_gek_bundle_store_refused_without_pinned_admin_key(tmp_path):
- """C5b: deny by default — no pinned key means no privileged operation."""
- session = _session(tmp_path, "ordinary-member")
- session._group_id = None
- session._admin_ops = {}
- session._ctx["bundle_store"] = object()
-
- session._do_gek_bundle_store({
- "user_id": "victim", "group_id": "g" * 32,
- "pk_eph_b64": "AA==", "nonce_b64": "AA==", "wrapped_b64": "AA==",
- })
- assert any(m.get("type") == "error" for m in session.sent)
+ assert stored == [], "a retired message type still reached the bundle store"
def test_gek_auto_activation_is_gone():
@@ -287,7 +292,7 @@ def test_admin_transcript_is_domain_separated():
@pytest.mark.parametrize("field,value", [
- ("op", "gek_bundle_store"),
+ ("op", "invite_create"),
("subject", "file-2"),
("node_pk_b64", "OTHERNODE"),
("group_id", "h" * 32),
@@ -320,15 +325,15 @@ def test_admin_signature_does_not_transfer_between_operations(tmp_path):
H5: the concrete attack. A signature collected to delete a file must not
authorize storing a GEK bundle.
"""
- from meshbay_common.adminop import OP_FILE_DELETE, OP_GEK_BUNDLE_STORE
+ from meshbay_common.adminop import OP_FILE_DELETE, OP_INVITE_CREATE
sk_admin = Ed25519PrivateKey.generate()
delete_transcript = _transcript(op=OP_FILE_DELETE)
signature = sk_admin.sign(delete_transcript)
- store_transcript = _transcript(op=OP_GEK_BUNDLE_STORE)
+ invite_transcript = _transcript(op=OP_INVITE_CREATE)
with pytest.raises(Exception):
- sk_admin.public_key().verify(signature, store_transcript)
+ sk_admin.public_key().verify(signature, invite_transcript)
def test_admin_challenge_expires(tmp_path):
@@ -573,3 +578,4 @@ def test_admin_ui_escapes_filenames(tmp_path):
assert payload not in html, "filename rendered unescaped — stored XSS (H2)"
assert "<img" in html, "filename should appear escaped"
+
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py
index 59b48ac..07bdbea 100644
--- a/packages/meshbay-node/tests/test_webrtc_transport.py
+++ b/packages/meshbay-node/tests/test_webrtc_transport.py
@@ -31,6 +31,7 @@ from meshbay_common.crypto import (
wrap_gek,
wrap_gek_aes,
unwrap_gek,
+ unwrap_gek_aes,
)
from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes
from meshbay_common.protocol import MNP
@@ -42,10 +43,12 @@ from meshbay_common.handshake import (
)
from meshbay_common.adminop import (
OP_FILE_DELETE,
- OP_GEK_BUNDLE_STORE,
+ OP_INVITE_CREATE,
admin_transcript,
)
+from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR, join_transcript
from meshbay_node.bundle_store import BundleStore
+from meshbay_node.roster import Roster
from meshbay_node.indexer import DirectoryIndexer
from meshbay_node.transport.webrtc_server import WebRTCTransport
@@ -184,9 +187,30 @@ async def _handshake_with_gek_proof(channel, received, sk_hub, gek, groups=None,
return msg
-async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_user=None,
- group_id=TEST_GROUP):
- """Create a peer connection, perform handshake with GEK proof, return (pc, channel, queue)."""
+def _token(sk_hub, jwt_sub, peer_id, group_id, pk_user="test"):
+ """A hub-issued user token, as the browser would present it."""
+ sk_h_pem = sk_hub.private_bytes(
+ serialization.Encoding.PEM,
+ serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption(),
+ )
+ now = int(time.time())
+ return jwt.encode({
+ "iss": "test-hub", "sub": jwt_sub,
+ "pk_user": pk_user, "hub_id": "test-hub",
+ "jti": f"jti-{peer_id}", "iat": now, "exp": now + 3600,
+ "groups": [group_id], "scope": "user",
+ }, sk_h_pem, algorithm="EdDSA")
+
+
+async def _open_channel(transport, peer_id):
+ """
+ Signaling only: a live DataChannel with no MNP handshake performed.
+
+ Separate from `_setup_peer` because someone joining a group for the first time
+ cannot complete the handshake — they have no GEK to prove — and the join has to
+ happen in that window.
+ """
pc = RTCPeerConnection()
q = asyncio.Queue()
buf = bytearray()
@@ -215,6 +239,13 @@ async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_us
answer_sdp, _ = await transport.handle_offer(pc.localDescription.sdp, peer_id)
await pc.setRemoteDescription(RTCSessionDescription(sdp=answer_sdp, type="answer"))
await asyncio.wait_for(ready.wait(), timeout=5.0)
+ return pc, ch, q
+
+
+async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_user=None,
+ group_id=TEST_GROUP):
+ """Create a peer connection, perform handshake with GEK proof, return (pc, channel, queue)."""
+ pc, ch, q = await _open_channel(transport, peer_id)
pk_user = "test"
if sk_user:
@@ -223,18 +254,7 @@ async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_us
serialization.Encoding.Raw, serialization.PublicFormat.Raw)
).decode()
- sk_h_pem = sk_hub.private_bytes(
- serialization.Encoding.PEM,
- serialization.PrivateFormat.PKCS8,
- serialization.NoEncryption(),
- )
- now = int(time.time())
- token = jwt.encode({
- "iss": "test-hub", "sub": jwt_sub,
- "pk_user": pk_user, "hub_id": "test-hub",
- "jti": f"jti-{peer_id}", "iat": now, "exp": now + 3600,
- "groups": [group_id], "scope": "user",
- }, sk_h_pem, algorithm="EdDSA")
+ token = _token(sk_hub, jwt_sub, peer_id, group_id, pk_user)
msg = await _do_mnp_handshake(ch, q, token, gek, pc, group_id)
assert msg["type"] == MNP.HANDSHAKE_ACK
@@ -1059,71 +1079,114 @@ def x25519_keypair():
@pytest.mark.asyncio
-async def test_gek_bundle_store_and_fetch(sk_node, sk_hub, gek, shared_dir,
- tmp_path, x25519_keypair):
- """GEK bundle stored on node via DataChannel, then fetched during handshake."""
+async def test_invite_then_join_delivers_the_gek(sk_node, sk_hub, gek, shared_dir,
+ tmp_path, x25519_keypair):
+ """
+ The whole invite flow over a real DataChannel, end to end.
+
+ The operator asks for a code; the invitee — who has never held the group key
+ and therefore cannot complete the GEK proof — redeems it in the pre-proof
+ window and the node wraps the key for the X25519 key they just proved they
+ hold. At no point is a public key fetched from the hub, which is the point:
+ that lookup was H3.
+ """
hub_pk_pem = _hub_pk_pem(sk_hub)
indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek)
await indexer.initial_scan()
- bundle_store = BundleStore(db_path=tmp_path / "bundles.db")
- await bundle_store.open()
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
transport = WebRTCTransport(
sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek,
shared_root=shared_dir, index=indexer.index,
stun_servers=[],
)
- transport._ctx["bundle_store"] = bundle_store
+ transport._ctx["roster"] = roster
+ transport._ctx["has_admin_authority"] = True
+ transport._ctx["groups"] = {
+ TEST_GROUP: {"gek": gek, "shared_root": shared_dir, "index": indexer.index},
+ }
- # Storing a bundle is a node-operator operation (C5b): the node challenges and
- # only the pinned admin key is accepted.
+ # A paired operator, as `meshbay-node operator pair` would have left it.
sk_admin = Ed25519PrivateKey.generate()
- transport._ctx["admin_pk_ed25519"] = sk_admin.public_key()
+ admin_pk_b64 = pk_to_b64(sk_admin.public_key())
+ await roster.pin_identity("user-001", "grenet", admin_pk_b64, "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")
- sk_x_raw, pk_x_raw = x25519_keypair
- bundle = wrap_gek(gek, pk_x_raw)
-
+ # 1. The operator asks the node for an invitation code.
ch_admin.send(_pack({
- "type": MNP.GEK_BUNDLE_STORE,
- "v": MNP_VERSION,
- "user_id": "user-002",
- "group_id": "g",
- "pk_eph_b64": bundle["pk_eph_b64"],
- "nonce_b64": bundle["nonce_b64"],
- "wrapped_b64": bundle["wrapped_b64"],
+ "type": MNP.INVITE_CREATE, "v": MNP_VERSION,
+ "user_id": "user-002", "group_id": TEST_GROUP, "username": "bob",
}))
-
challenge_msg = await asyncio.wait_for(q_admin.get(), timeout=5.0)
assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE
- assert challenge_msg["op"] == OP_GEK_BUNDLE_STORE
+ assert challenge_msg["op"] == OP_INVITE_CREATE
assert challenge_msg["subject"] == "user-002"
- signature = sk_admin.sign(_transcript_from(challenge_msg))
ch_admin.send(_pack({
"type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION,
"op_id": challenge_msg["op_id"],
- "signature": base64.b64encode(signature).decode(),
+ "signature": base64.b64encode(
+ sk_admin.sign(_transcript_from(challenge_msg))).decode(),
}))
+ invite = await asyncio.wait_for(q_admin.get(), timeout=5.0)
+ assert invite["type"] == MNP.INVITE_RESULT
+ code = invite["code"]
+ assert code and len(code) == 9 # XXXX-XXXX
- ack = await asyncio.wait_for(q_admin.get(), timeout=5.0)
- assert ack["type"] == "ack"
- assert ack["detail"] == "gek_bundle_stored"
+ # 2. Bob connects. He cannot prove GEK possession — he has never had it — so
+ # he redeems the code in the pre-proof window instead.
+ sk_x_raw, pk_x_raw = x25519_keypair
+ sk_bob_ed = Ed25519PrivateKey.generate()
+ pc_bob, ch_bob, q_bob = await _open_channel(transport, "peer-bob")
- # Verify bundle was persisted
- stored = await bundle_store.fetch("g", "user-002")
- assert stored is not None
- assert stored["pk_eph_b64"] == bundle["pk_eph_b64"]
+ nonce_c = os.urandom(NONCE_LEN)
+ ch_bob.send(_pack({
+ "type": MNP.HANDSHAKE, "v": MNP_VERSION,
+ "token": _token(sk_hub, "user-002", "peer-bob", TEST_GROUP),
+ "group_id": TEST_GROUP,
+ "nonce": base64.b64encode(nonce_c).decode(),
+ }))
+ challenge = await asyncio.wait_for(q_bob.get(), timeout=5.0)
+ assert challenge["type"] == MNP.HANDSHAKE_CHALLENGE
+ nonce_s = base64.b64decode(challenge["nonce"])
- # Unwrap to verify it's correct
- recovered = unwrap_gek(stored, sk_x_raw, pk_x_raw)
- assert recovered == gek
+ pk_ed_b64 = pk_to_b64(sk_bob_ed.public_key())
+ pk_x_b64 = base64.b64encode(pk_x_raw).decode()
+ ts = int(time.time())
+ transcript = join_transcript(
+ node_pk_b64=pk_to_b64(sk_node.public_key()),
+ group_id=TEST_GROUP, user_id="user-002",
+ pk_ed25519_b64=pk_ed_b64, pk_x25519_b64=pk_x_b64,
+ nonce_node=nonce_s, ts=ts,
+ )
+ ch_bob.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_bob_ed.sign(transcript)).decode(),
+ }))
- await bundle_store.close()
+ result = await asyncio.wait_for(q_bob.get(), timeout=5.0)
+ assert result["type"] == MNP.JOIN_RESULT
+ assert result["ok"] is True
+ assert result["gek"] is True
+ assert result["role"] == ROLE_MEMBER
+
+ # 3. The key really is the group key, and only Bob's secret opens it.
+ assert unwrap_gek_aes(result, sk_x_raw, pk_x_raw) == gek
+
+ # 4. The code is spent.
+ assert await roster.consume_invite(code, "user-002") is None
+
+ await roster.close()
await pc_admin.close()
+ await pc_bob.close()
await transport.close_all()
@@ -1420,10 +1483,13 @@ async def test_gek_not_auto_activated_on_bundle_store(sk_node, sk_hub, gek, shar
pc_admin, ch_admin, q_admin = await _setup_peer(
transport, sk_hub, gek, "peer-setup-admin")
- # An ordinary member wraps a key of their choosing for the operator's public key.
+ # An ordinary member wraps a key of their choosing for the operator's public
+ # key and offers it to the node. The message that used to carry this no longer
+ # exists (the node wraps the GEK itself now), so it reaches no handler at all —
+ # a stronger outcome than the admin challenge this test used to assert.
node_bundle = wrap_gek_aes(attacker_gek, pk_x_raw)
ch_admin.send(_pack({
- "type": MNP.GEK_BUNDLE_STORE,
+ "type": "gek_bundle_store",
"v": MNP_VERSION,
"user_id": "node-operator",
"group_id": "g",
@@ -1432,12 +1498,8 @@ async def test_gek_not_auto_activated_on_bundle_store(sk_node, sk_hub, gek, shar
"wrapped_b64": node_bundle["wrapped_b64"],
}))
- # The node demands an operator signature instead of storing and adopting it.
- reply = await asyncio.wait_for(q_admin.get(), timeout=5.0)
- assert reply["type"] == MNP.ADMIN_CHALLENGE
- assert reply["op"] == OP_GEK_BUNDLE_STORE
-
- await asyncio.sleep(0.2)
+ await asyncio.sleep(0.5)
+ assert q_admin.empty(), "the retired bundle message still gets a response"
assert transport._ctx.get("gek") == gek, "group key was seized over MNP (C5b)"
assert await bundle_store.fetch("g", "node-operator") is None