diff options
Diffstat (limited to 'packages/meshbay-node/tests/test_admin_ops_mnp.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_admin_ops_mnp.py | 290 |
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"] |