summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_admin_ops_mnp.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/tests/test_admin_ops_mnp.py')
-rw-r--r--packages/meshbay-node/tests/test_admin_ops_mnp.py290
1 files changed, 290 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_admin_ops_mnp.py b/packages/meshbay-node/tests/test_admin_ops_mnp.py
new file mode 100644
index 0000000..1bf8365
--- /dev/null
+++ b/packages/meshbay-node/tests/test_admin_ops_mnp.py
@@ -0,0 +1,290 @@
+"""
+`gek_rotate` and `member_unpin` over MNP.
+
+Both are destructive and both are new, so the tests are negative assertions:
+nobody without the operator's pinned key can reach them, a signature over the
+wrong transcript does not count, and the operation cannot be triggered by the
+request message alone.
+
+The rule these live under is worth restating, because it is easy to read
+draft-v5 §5.1 as forbidding them: **"nothing arriving over MNP can activate a
+GEK" is about key material arriving from outside** (C5b — a member handing the
+node a key of their choosing). An operator-signed instruction where the node
+generates the key with its own CSPRNG is a different shape, and it is the only
+thing that finishes a revocation: the ex-member still holds the current key.
+"""
+
+import base64
+import time
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from conftest import one_root
+from meshbay_common.adminop import (
+ OP_GEK_ROTATE,
+ OP_MEMBER_UNPIN,
+ admin_transcript,
+)
+from meshbay_common.crypto import pk_to_b64
+from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR
+from meshbay_common.protocol import MNP
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.roster import open_roster
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+GROUP = "g" * 32
+
+
+@pytest.fixture
+async def roster(tmp_path):
+ r = await open_roster(tmp_path)
+ yield r
+ await r.close()
+
+
+def _keypair():
+ sk = Ed25519PrivateKey.generate()
+ return sk, pk_to_b64(sk.public_key())
+
+
+async def _session(tmp_path: Path, roster, *, operator: bool) -> WebRTCPeerSession:
+ """A peer session with an operator pinned, or deliberately without one."""
+ shared = tmp_path / "shared"
+ shared.mkdir(exist_ok=True)
+ index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
+ roots = one_root(shared)
+
+ sk_op, pk_op = _keypair()
+ if operator:
+ await roster.pin_identity("grenet", "grenet", pk_op, pk_op, "code")
+ await roster.set_member("", "grenet", ROLE_OPERATOR, "active", "local-cli")
+
+ group_ctx = {"gek": b"\x01" * 32, "roots": roots, "index": index,
+ "join_policy": "invite"}
+ state = {
+ "groups_ctx": {GROUP: group_ctx},
+ "roster": roster,
+ "indexes": {GROUP: index},
+ "bundle_store": _FakeBundleStore(),
+ "pk_x25519_raw": b"\x02" * 32,
+ "hub": _FakeHub(),
+ "node_user_id": "node-user",
+ }
+
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = {
+ "roots": roots, "index": index, "sk_node": index.sk_node,
+ "roster": roster, "groups": {GROUP: group_ctx},
+ "has_admin_authority": operator,
+ "daemon_state": state,
+ }
+ session._group_id = GROUP
+ session._user_id = "grenet" if operator else "mallory"
+ session._username = session._user_id
+ session._pk_user = ""
+ session._uploads = {}
+ session._admin_ops = {}
+ session._remote_ip = ""
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ session.state = state
+ session.sk_op = sk_op
+ session.spawned = []
+ session._spawn = session.spawned.append
+ return session
+
+
+class _FakeBundleStore:
+ def __init__(self):
+ self.stored = []
+
+ async def store(self, *args):
+ self.stored.append(args)
+
+
+class _FakeHub:
+ class _S:
+ user_id = "node-user"
+ _session = _S()
+
+
+def _last(session):
+ return session.sent[-1] if session.sent else {}
+
+
+async def _drain(session):
+ """Await whatever `_spawn` started. The real session holds its tasks; a
+ hand-built one collects them here so the assertion sees the result."""
+ for coro in session.spawned:
+ await coro
+ session.spawned.clear()
+
+
+async def _sign_and_exec(session, op: str, subject: str, sk, exec_fn):
+ challenge = _last(session)
+ assert challenge["type"] == "admin_challenge", challenge
+ transcript = admin_transcript(
+ op=op, node_pk_b64=session._node_pk_b64(), group_id=GROUP,
+ subject=subject, nonce=base64.b64decode(challenge["nonce"]),
+ ts=challenge["ts"])
+ pending = session._admin_ops.get(challenge["op_id"]) or {
+ "op": op, "subject": subject}
+ await exec_fn(pending, transcript, sk.sign(transcript))
+
+
+# ── gek_rotate ───────────────────────────────────────────────────────────────
+
+async def test_rotation_needs_an_operator(tmp_path, roster):
+ """Without a paired operator there is nobody who could sign, so the node
+ fails closed and says why rather than issuing a challenge nobody can meet."""
+ session = await _session(tmp_path, roster, operator=False)
+
+ session._do_gek_rotate({})
+
+ assert _last(session)["type"] == "error"
+ assert "authorized key" in _last(session)["detail"]
+ assert not session._admin_ops
+
+
+async def test_the_request_alone_rotates_nothing(tmp_path, roster):
+ """The message asks; only a signature acts. A node that rotated here would
+ let any member lock the group out."""
+ session = await _session(tmp_path, roster, operator=True)
+ before = session._ctx["groups"][GROUP]["gek"]
+
+ session._do_gek_rotate({})
+
+ assert _last(session)["type"] == "admin_challenge"
+ assert session._ctx["groups"][GROUP]["gek"] == before
+
+
+async def test_a_members_signature_does_not_rotate(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True)
+ sk_mallory, pk_mallory = _keypair()
+ await roster.pin_identity("mallory", "mallory", pk_mallory, pk_mallory, "code")
+ await roster.set_member(GROUP, "mallory", ROLE_MEMBER, "active", "grenet")
+ before = session._ctx["groups"][GROUP]["gek"]
+
+ session._do_gek_rotate({})
+ await _sign_and_exec(session, OP_GEK_ROTATE, GROUP, sk_mallory,
+ session._admin_exec_gek_rotate)
+
+ assert _last(session)["type"] == "error"
+ assert session._ctx["groups"][GROUP]["gek"] == before
+
+
+async def test_a_signature_over_another_operation_does_not_count(tmp_path, roster):
+ """
+ H5's rule: the node rebuilds the transcript from the operation it is holding
+ and verifies against *that*, so a signature collected for one act cannot be
+ presented as another.
+
+ Driven through `_do_admin_response`, deliberately. Handing a transcript
+ straight to `_admin_exec_*` would skip the reconstruction that is the
+ control, and the test would pass while proving nothing.
+ """
+ session = await _session(tmp_path, roster, operator=True)
+ before = session._ctx["groups"][GROUP]["gek"]
+
+ session._do_gek_rotate({})
+ challenge = _last(session)
+
+ # Signed over member_unpin, presented against the pending gek_rotate.
+ wrong = admin_transcript(
+ op=OP_MEMBER_UNPIN, node_pk_b64=session._node_pk_b64(), group_id=GROUP,
+ subject=GROUP, nonce=base64.b64decode(challenge["nonce"]),
+ ts=challenge["ts"])
+ session._do_admin_response({
+ "op_id": challenge["op_id"],
+ "signature": base64.b64encode(session.sk_op.sign(wrong)).decode(),
+ })
+ await _drain(session)
+
+ assert _last(session)["type"] == "error"
+ assert session.state["groups_ctx"][GROUP]["gek"] == before
+
+
+async def test_the_operator_rotates_and_the_node_makes_the_key(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True)
+ before = session._ctx["groups"][GROUP]["gek"]
+
+ session._do_gek_rotate({})
+ await _sign_and_exec(session, OP_GEK_ROTATE, GROUP, session.sk_op,
+ session._admin_exec_gek_rotate)
+
+ ack = _last(session)
+ assert ack["type"] == MNP.GEK_ROTATE_ACK, ack
+ after = session.state["groups_ctx"][GROUP]["gek"]
+ assert after != before, "the key did not change"
+ assert len(after) == 32
+ # Produced here, not received: no key material crossed the wire (C5b).
+ assert session.state["bundle_store"].stored, (
+ "the node's own copy was not stored — the daemon could not reload it")
+
+
+async def test_rotation_reaches_the_index(tmp_path, roster):
+ """The index is encrypted under the GEK. Leaving the old key on it would
+ serve members a listing they cannot open."""
+ session = await _session(tmp_path, roster, operator=True)
+
+ session._do_gek_rotate({})
+ await _sign_and_exec(session, OP_GEK_ROTATE, GROUP, session.sk_op,
+ session._admin_exec_gek_rotate)
+
+ assert session.state["indexes"][GROUP].gek == \
+ session.state["groups_ctx"][GROUP]["gek"]
+
+
+# ── member_unpin ─────────────────────────────────────────────────────────────
+
+async def test_unpinning_needs_an_operator(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=False)
+ session._do_member_unpin({"user_id": "bob"})
+ assert _last(session)["type"] == "error"
+
+
+async def test_unpinning_yourself_is_refused(tmp_path, roster):
+ """It would end the authority of the connection performing the operation,
+ halfway through it."""
+ session = await _session(tmp_path, roster, operator=True)
+ session._do_member_unpin({"user_id": "grenet"})
+ assert _last(session)["detail"] == "Cannot unpin yourself"
+
+
+async def test_a_members_signature_does_not_unpin(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True)
+ sk_bob, pk_bob = _keypair()
+ await roster.pin_identity("bob", "bob", pk_bob, pk_bob, "code")
+
+ session._do_member_unpin({"user_id": "bob"})
+ await _sign_and_exec(session, OP_MEMBER_UNPIN, "bob", sk_bob,
+ session._admin_exec_member_unpin)
+
+ assert _last(session)["type"] == "error"
+ assert await roster.get_identity("bob") is not None, (
+ "a member removed their own pin — only the operator may")
+
+
+async def test_the_operator_unpins(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True)
+ _, pk_bob = _keypair()
+ await roster.pin_identity("bob", "bob", pk_bob, pk_bob, "code")
+
+ session._do_member_unpin({"user_id": "bob"})
+ await _sign_and_exec(session, OP_MEMBER_UNPIN, "bob", session.sk_op,
+ session._admin_exec_member_unpin)
+
+ assert _last(session)["type"] == MNP.MEMBER_UNPIN_ACK
+ assert await roster.get_identity("bob") is None
+
+
+async def test_unpinning_someone_unknown_says_so(tmp_path, roster):
+ session = await _session(tmp_path, roster, operator=True)
+ session._do_member_unpin({"user_id": "nobody"})
+ await _sign_and_exec(session, OP_MEMBER_UNPIN, "nobody", session.sk_op,
+ session._admin_exec_member_unpin)
+ assert _last(session)["type"] == "error"
+ assert "No such pinned identity" in _last(session)["detail"]