aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_device_on_connection.py
blob: 3da8a8c1a3204172d5570e43f4427a8c503b31b8 (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
"""
Which device is on this connection, and what depends on knowing.

The MNP handshake authenticates a *group membership* (the GEK-HMAC) and an
*account* (the hub's token). It has never authenticated a device. While one
person meant one key on a node those were the same statement; device linking
(2026-08-18) ended that, and two things were left resolving "the account's
oldest live device" and calling it the answer:

  * `_load_pinned_pk`, whose result is recorded as `entry.uploader_pk` on every
    upload — so a phone's uploads were attributed to a laptop;
  * `_admin_exec_file_delete`, which authorized deletion against **that exact
    key** — so a person could not delete their own file from their other device,
    and the only symptom was "Signature verification failed" on their own upload
    (`docs/desktop-client-v1.md` §4.8 A).

`device_hello` closes the first: additive, signed, refused unless the key is a
live device *of this account in the node's own roster*. The second is closed by
authorizing against the account rather than the key.
"""

import base64
import time

import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common.crypto import pk_to_b64
from meshbay_common.device import device_hello_transcript
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
NONCE = b"\x11" * 32


@pytest.fixture
async def roster(tmp_path):
    r = await open_roster(tmp_path)
    yield r
    await r.close()


def _keys():
    sk_ed = Ed25519PrivateKey.generate()
    sk_x = Ed25519PrivateKey.generate()          # stand-in; only its b64 is used
    return sk_ed, pk_to_b64(sk_ed.public_key()), pk_to_b64(sk_x.public_key())


def _session(tmp_path, roster, user_id="alice"):
    shared = tmp_path / "shared"
    shared.mkdir(exist_ok=True)
    index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
    session = WebRTCPeerSession.__new__(WebRTCPeerSession)
    session._ctx = {
        "roots": one_root(shared), "index": index, "sk_node": index.sk_node,
        "roster": roster,
        "groups": {GROUP: {"gek": b"\x01" * 32, "index": index,
                           "roots": one_root(shared)}},
    }
    session._group_id = GROUP
    session._user_id = user_id
    session._username = user_id
    session._pinned_pk = ""
    session._device_confirmed = False
    session._nonce_node = NONCE
    session._remote_ip = ""
    session.sent = []
    session._send = session.sent.append
    session._audit = lambda *a, **k: None
    return session


async def _hello(session, sk, pk_ed, *, ts=None, user_id=None):
    ts = int(time.time()) if ts is None else ts
    transcript = device_hello_transcript(
        node_pk_b64=session._node_pk_b64(), group_id=session._group_id,
        user_id=user_id or session._user_id, pk_ed25519_b64=pk_ed,
        nonce_node=NONCE, ts=ts)
    await session._do_device_hello({
        "pk_ed25519": pk_ed, "ts": ts,
        "sig": base64.b64encode(sk.sign(transcript)).decode(),
    })


def _last(session):
    return session.sent[-1] if session.sent else {}


# ── device_hello ─────────────────────────────────────────────────────────────

async def test_a_pinned_device_identifies_itself(tmp_path, roster):
    sk_a, pk_ed_a, pk_x_a = _keys()
    sk_b, pk_ed_b, pk_x_b = _keys()
    await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code")
    await roster.pin_identity("alice", "alice", pk_ed_b, pk_x_b, via="device")

    session = _session(tmp_path, roster)
    await _hello(session, sk_b, pk_ed_b)

    assert _last(session)["type"] == MNP.DEVICE_HELLO_ACK
    assert session._pinned_pk == pk_ed_b, (
        "the connection must be the device that signed, not the account's "
        "oldest key")
    assert session._device_confirmed is True


async def test_a_key_this_node_never_pinned_is_refused(tmp_path, roster):
    sk_a, pk_ed_a, pk_x_a = _keys()
    await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code")
    sk_x, pk_ed_x, _ = _keys()

    session = _session(tmp_path, roster)
    await _hello(session, sk_x, pk_ed_x)

    assert _last(session)["type"] == "error"
    assert session._device_confirmed is False


async def test_a_revoked_device_cannot_identify_itself(tmp_path, roster):
    sk_a, pk_ed_a, pk_x_a = _keys()
    sk_b, pk_ed_b, pk_x_b = _keys()
    await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code")
    await roster.pin_identity("alice", "alice", pk_ed_b, pk_x_b, via="device")
    await roster.revoke_device("alice", pk_ed_b)

    session = _session(tmp_path, roster)
    await _hello(session, sk_b, pk_ed_b)

    assert _last(session)["type"] == "error", (
        "a revoked key must stay refused — that is why revocation marks the "
        "row instead of deleting it")


async def test_another_accounts_device_cannot_identify_here(tmp_path, roster):
    """The roster lookup is scoped to *this* account, never to the key alone."""
    sk_a, pk_ed_a, pk_x_a = _keys()
    sk_m, pk_ed_m, pk_x_m = _keys()
    await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code")
    await roster.pin_identity("mallory", "mallory", pk_ed_m, pk_x_m, via="code")

    session = _session(tmp_path, roster, user_id="alice")
    await _hello(session, sk_m, pk_ed_m)

    assert _last(session)["type"] == "error"


async def test_a_signature_for_another_connection_does_not_transfer(
        tmp_path, roster):
    """`nonce_node` binds the statement to one connection (L4)."""
    sk_a, pk_ed_a, pk_x_a = _keys()
    await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code")

    session = _session(tmp_path, roster)
    ts = int(time.time())
    transcript = device_hello_transcript(
        node_pk_b64=session._node_pk_b64(), group_id=GROUP,
        user_id="alice", pk_ed25519_b64=pk_ed_a,
        nonce_node=b"\x99" * 32, ts=ts)          # another connection's nonce
    await session._do_device_hello({
        "pk_ed25519": pk_ed_a, "ts": ts,
        "sig": base64.b64encode(sk_a.sign(transcript)).decode(),
    })

    assert _last(session)["type"] == "error"


async def test_a_stale_hello_is_refused(tmp_path, roster):
    sk_a, pk_ed_a, pk_x_a = _keys()
    await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code")
    session = _session(tmp_path, roster)
    await _hello(session, sk_a, pk_ed_a, ts=int(time.time()) - 3600)
    assert _last(session)["type"] == "error"


async def test_a_connection_cannot_become_a_second_device(tmp_path, roster):
    """
    Both keys are legitimately this account's, and it is still refused: one
    connection's uploads must be attributable to one device.
    """
    sk_a, pk_ed_a, pk_x_a = _keys()
    sk_b, pk_ed_b, pk_x_b = _keys()
    await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code")
    await roster.pin_identity("alice", "alice", pk_ed_b, pk_x_b, via="device")

    session = _session(tmp_path, roster)
    await _hello(session, sk_a, pk_ed_a)
    assert _last(session)["type"] == MNP.DEVICE_HELLO_ACK
    await _hello(session, sk_b, pk_ed_b)
    assert _last(session)["type"] == "error"
    assert session._pinned_pk == pk_ed_a


async def test_the_late_roster_load_does_not_undo_a_confirmed_device(
        tmp_path, roster):
    """
    `_load_pinned_pk` is spawned at handshake and can finish *after* a fast
    client has identified itself. It must not put the account's oldest key back
    — a race that would have been intermittent and attributed to nothing.
    """
    sk_a, pk_ed_a, pk_x_a = _keys()
    sk_b, pk_ed_b, pk_x_b = _keys()
    await roster.pin_identity("alice", "alice", pk_ed_a, pk_x_a, via="code")
    await roster.pin_identity("alice", "alice", pk_ed_b, pk_x_b, via="device")

    session = _session(tmp_path, roster)
    await _hello(session, sk_b, pk_ed_b)
    await session._load_pinned_pk()               # arrives late

    assert session._pinned_pk == pk_ed_b


# ── deletion is authorized by account, not by the exact device ───────────────

class _Entry:
    def __init__(self, uploader_id="", uploader_pk=""):
        self.uploader_id = uploader_id
        self.uploader_pk = uploader_pk


async def test_a_second_device_can_delete_the_first_devices_upload(
        tmp_path, roster):
    """
    §4.8 A. Alice uploads from her phone and deletes from her desktop. Before
    the fix this failed with "Signature verification failed" on her own file.
    """
    sk_phone, pk_phone, pk_x_phone = _keys()
    sk_desk, pk_desk, pk_x_desk = _keys()
    await roster.pin_identity("alice", "alice", pk_phone, pk_x_phone, via="code")
    await roster.pin_identity("alice", "alice", pk_desk, pk_x_desk, via="device")

    session = _session(tmp_path, roster)
    entry = _Entry(uploader_id="alice", uploader_pk=pk_phone)
    transcript = b"delete-this-file"

    assert await session._verify_uploader_sig(
        entry, transcript, sk_desk.sign(transcript)) is True


async def test_a_stranger_still_cannot_delete_someone_elses_upload(
        tmp_path, roster):
    sk_alice, pk_alice, pk_x_alice = _keys()
    sk_mallory, pk_mallory, pk_x_mallory = _keys()
    await roster.pin_identity("alice", "alice", pk_alice, pk_x_alice, via="code")
    await roster.pin_identity("mallory", "mallory", pk_mallory, pk_x_mallory,
                              via="code")

    session = _session(tmp_path, roster)
    entry = _Entry(uploader_id="alice", uploader_pk=pk_alice)
    transcript = b"delete-this-file"

    assert await session._verify_uploader_sig(
        entry, transcript, sk_mallory.sign(transcript)) is False


async def test_a_revoked_device_can_no_longer_delete(tmp_path, roster):
    """Ownership survives revocation; the revoked *key* stops being able to act."""
    sk_old, pk_old, pk_x_old = _keys()
    sk_new, pk_new, pk_x_new = _keys()
    await roster.pin_identity("alice", "alice", pk_old, pk_x_old, via="code")
    await roster.pin_identity("alice", "alice", pk_new, pk_x_new, via="device")
    await roster.revoke_device("alice", pk_old)

    session = _session(tmp_path, roster)
    entry = _Entry(uploader_id="alice", uploader_pk=pk_old)
    transcript = b"delete-this-file"

    assert await session._verify_uploader_sig(
        entry, transcript, sk_old.sign(transcript)) is False
    assert await session._verify_uploader_sig(
        entry, transcript, sk_new.sign(transcript)) is True, (
        "the file is still Alice's — a retired laptop does not orphan its uploads")


async def test_an_entry_with_no_uploader_id_falls_back_to_the_recorded_key(
        tmp_path, roster):
    """An index written before `uploader_id` existed must not become undeletable."""
    sk_a, pk_a, pk_x_a = _keys()
    session = _session(tmp_path, roster)
    entry = _Entry(uploader_id="", uploader_pk=pk_a)
    transcript = b"delete-this-file"

    assert await session._verify_uploader_sig(
        entry, transcript, sk_a.sign(transcript)) is True