summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_admin_ops_mnp.py
blob: 7fd1c2b0808651e1459f505d68b3d6518222b45e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
"""
`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
from pathlib import Path

import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
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

from conftest import one_root

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 = []
        self.deleted_keypairs = []

    async def store(self, *args):
        self.stored.append(args)

    async def delete_keypair(self, user_id):
        self.deleted_keypairs.append(user_id)
        return True


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
    # The stored keypair bundle goes too — left behind it blocks the re-join
    # the unpin exists to enable.
    assert "bob" in session.state["bundle_store"].deleted_keypairs


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"]