diff options
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/test_device_linking.py | 414 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_roster_pairing.py | 36 |
2 files changed, 446 insertions, 4 deletions
diff --git a/packages/meshbay-node/tests/test_device_linking.py b/packages/meshbay-node/tests/test_device_linking.py new file mode 100644 index 0000000..3580ff8 --- /dev/null +++ b/packages/meshbay-node/tests/test_device_linking.py @@ -0,0 +1,414 @@ +""" +One person, several devices on one node. + +Identity keys are per node, so a browser and a desktop client are two keys on +one account. Admitting the second must not need an operator — that friction is +what would make "the native client must not prevent web use" fail — and must not +be something the hub or the node itself can do. + +The controls, and the tests that hold them: + + * **A key the node already pinned countersigns.** The hub has stored no user + keys since 2026-08-14, so it cannot produce that signature. + * **The code is hashed together with the requesting keys**, so the node cannot + answer an approver with a substituted key: the approver recomputes the hash + and finds nothing. + * **Nothing rests on a human comparing digits.** Phase 12.1 dropped that + ritual as "correct, unusable as the default"; it must not come back here. + +Everything below is written as "this does not work". +""" + +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.crypto import pk_to_b64 +from meshbay_common.device import ( + device_add_transcript, + device_code_hash, + device_request_transcript, +) +from meshbay_common.join import ROLE_MEMBER +from meshbay_common.protocol import MNP +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roster import generate_code, normalize_code, open_roster +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +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()) + + +async def _session(tmp_path: Path, roster, user_id: str = "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, "device_request_ttl": 3600, + "groups": {GROUP: {"gek": b"\x01" * 32, "index": index, + "roots": one_root(shared), "join_policy": "invite"}}, + } + session._group_id = GROUP + session._user_id = user_id + session._username = user_id + session._pk_user = "" + session._pinned_pk = "" + session._uploads = {} + session._nonce_node = NONCE + session._remote_ip = "" + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +def _last(session): + return session.sent[-1] if session.sent else {} + + +async def _file_request(session, sk_new, pk_ed, pk_x, code): + """A new device asks to be added, signing over its own keys.""" + code_hash = device_code_hash(normalize_code(code), pk_ed, pk_x) + ts = int(time.time()) + transcript = device_request_transcript( + node_pk_b64=session._node_pk_b64(), user_id=session._user_id, + pk_ed25519_b64=pk_ed, pk_x25519_b64=pk_x, code_hash=code_hash, + nonce_node=NONCE, ts=ts) + await session._do_device_request({ + "pk_ed25519": pk_ed, "pk_x25519": pk_x, "code_hash": code_hash, + "ts": ts, "sig": base64.b64encode(sk_new.sign(transcript)).decode(), + }) + return code_hash + + +async def _approve(session, sk_signer, pk_ed, pk_x, code_hash=""): + ts = int(time.time()) + transcript = device_add_transcript( + node_pk_b64=session._node_pk_b64(), user_id=session._user_id, + pk_ed25519_b64=pk_ed, pk_x25519_b64=pk_x, nonce_node=NONCE, ts=ts) + await session._do_device_add({ + "pk_ed25519": pk_ed, "pk_x25519": pk_x, "ts": ts, + "code_hash": code_hash, + "sig": base64.b64encode(sk_signer.sign(transcript)).decode(), + }) + + +async def _match_by_code(session, code): + """ + What an approving client does: list what is pending and recompute. + + The code never reaches the node. The client hashes it against each + candidate's keys and keeps the row that matches — so a node offering + fabricated keys produces no match, having no way to compute a hash over a + code it does not know. + """ + await session._do_device_lookup({}) + listed = _last(session) + if listed.get("type") != MNP.DEVICE_LOOKUP_RESULT: + return None + for req in listed.get("requests", []): + expect = device_code_hash(normalize_code(code), req["pk_ed25519"], + req["pk_x25519"]) + if expect == req["code_hash"]: + return req + return None + + +# ── The happy path, so the refusals mean something ─────────────────────────── + +async def test_an_existing_device_admits_a_new_one(tmp_path, roster): + sk_old, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + sk_new, pk_new_ed, pk_new_x = _keys() + + session = await _session(tmp_path, roster) + code = generate_code() + await _file_request(session, sk_new, pk_new_ed, pk_new_x, code) + assert _last(session)["type"] == MNP.DEVICE_REQUEST_ACK + + match = await _match_by_code(session, code) + assert match is not None, "the approver could not find the pending request" + assert match["pk_ed25519"] == pk_new_ed + + await _approve(session, sk_old, pk_new_ed, pk_new_x, + code_hash=match["code_hash"]) + + assert _last(session)["type"] == MNP.DEVICE_ADD_ACK + devices = await roster.list_devices("alice") + assert {d["pk_ed25519"] for d in devices} == {pk_old_ed, pk_new_ed} + added = next(d for d in devices if d["pk_ed25519"] == pk_new_ed) + assert added["added_by_pk"] == pk_old_ed, "provenance is not recorded" + + +async def test_both_devices_then_open_the_group(tmp_path, roster): + """The point of the whole exercise: web and native at the same time.""" + sk_old, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + await roster.set_member(GROUP, "alice", ROLE_MEMBER, "active", "grenet") + _, pk_new_ed, pk_new_x = _keys() + await roster.pin_identity("alice", "alice", pk_new_ed, pk_new_x, "device", + added_by_pk=pk_old_ed) + + for pk in (pk_old_ed, pk_new_ed): + assert await roster.find_device("alice", pk) is not None + assert await roster.is_authorized(GROUP, "alice") + + +# ── What must not work ─────────────────────────────────────────────────────── + +async def test_the_request_alone_admits_nothing(tmp_path, roster): + """Filing is inert. A node that pinned here would let anyone with a hub + token join any account that has ever used it.""" + _, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + sk_new, pk_new_ed, pk_new_x = _keys() + + session = await _session(tmp_path, roster) + await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code()) + + assert await roster.find_device("alice", pk_new_ed) is None + assert [d["pk_ed25519"] for d in await roster.list_devices("alice")] == \ + [pk_old_ed] + + +async def test_the_new_device_cannot_approve_itself(tmp_path, roster): + """ + Otherwise anyone the hub can mint a token for walks in: the request is + self-signed by construction, so self-approval would be no control at all. + """ + _, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + sk_new, pk_new_ed, pk_new_x = _keys() + + session = await _session(tmp_path, roster) + await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code()) + await _approve(session, sk_new, pk_new_ed, pk_new_x) + + assert _last(session)["type"] == "error" + assert await roster.find_device("alice", pk_new_ed) is None + + +async def test_a_stranger_cannot_approve(tmp_path, roster): + """A key belonging to somebody else, or to nobody, is not this account's.""" + _, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + sk_bob, pk_bob_ed, pk_bob_x = _keys() + await roster.pin_identity("bob", "bob", pk_bob_ed, pk_bob_x, "code") + _, pk_new_ed, pk_new_x = _keys() + + session = await _session(tmp_path, roster) + await _approve(session, sk_bob, pk_new_ed, pk_new_x) + + assert _last(session)["type"] == "error" + assert await roster.find_device("alice", pk_new_ed) is None + + +async def test_a_revoked_device_cannot_admit_its_replacement(tmp_path, roster): + """ + The lost laptop. Marking rather than deleting is what makes this hold: a + deleted row is a key the node would pin again on the next device-add. + """ + sk_lost, pk_lost_ed, pk_lost_x = _keys() + _, pk_keep_ed, pk_keep_x = _keys() + await roster.pin_identity("alice", "alice", pk_lost_ed, pk_lost_x, "code") + await roster.pin_identity("alice", "alice", pk_keep_ed, pk_keep_x, "device") + await roster.revoke_device("alice", pk_lost_ed) + + _, pk_new_ed, pk_new_x = _keys() + session = await _session(tmp_path, roster) + await _approve(session, sk_lost, pk_new_ed, pk_new_x) + + assert _last(session)["type"] == "error" + assert await roster.find_device("alice", pk_new_ed) is None + + +async def test_an_account_with_no_device_here_cannot_file(tmp_path, roster): + """The first device is admitted by an operator's invitation code. Letting + this path serve that purpose would bypass the roster entirely.""" + sk_new, pk_new_ed, pk_new_x = _keys() + session = await _session(tmp_path, roster, user_id="nobody") + + await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code()) + + assert _last(session)["type"] == "error" + assert "invitation code" in _last(session)["detail"] + + +async def test_a_signature_by_the_wrong_key_is_not_a_request(tmp_path, roster): + """Proof of possession: the request must be signed by the keys it presents.""" + _, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + sk_other, _, _ = _keys() + _, pk_new_ed, pk_new_x = _keys() + + session = await _session(tmp_path, roster) + await _file_request(session, sk_other, pk_new_ed, pk_new_x, generate_code()) + + assert _last(session)["type"] == "error" + assert "signature" in _last(session)["detail"].lower() + + +# ── The code binds the keys ────────────────────────────────────────────────── + +async def test_the_node_cannot_substitute_the_keys(tmp_path, roster): + """ + The load-bearing property. The hash covers the code **and** the requesting + keys, so an approver looking a request up with different keys finds nothing + — and never signs. This is what replaces "compare these digits". + """ + _, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + sk_new, pk_new_ed, pk_new_x = _keys() + _, pk_evil_ed, pk_evil_x = _keys() + + session = await _session(tmp_path, roster) + code = generate_code() + await _file_request(session, sk_new, pk_new_ed, pk_new_x, code) + + # A node that answered with keys of its own choosing would have to produce a + # hash matching sha256(code ‖ those keys) — over a code it never receives. + forged = device_code_hash(normalize_code(code), pk_evil_ed, pk_evil_x) + real = (await _match_by_code(session, code))["code_hash"] + + assert forged != real, "substituted keys produced a matching hash" + # And the client's own matching would reject the substitution outright. + await session._do_device_lookup({}) + offered = _last(session)["requests"] + assert all(r["pk_ed25519"] != pk_evil_ed for r in offered) + + +async def test_a_wrong_code_finds_nothing(tmp_path, roster): + _, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + sk_new, pk_new_ed, pk_new_x = _keys() + + session = await _session(tmp_path, roster) + await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code()) + + assert await _match_by_code(session, generate_code()) is None + + +async def test_a_code_is_spent_once(tmp_path, roster): + _, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + sk_new, pk_new_ed, pk_new_x = _keys() + + sk_old_signer, pk_old_ed2, pk_old_x2 = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed2, pk_old_x2, "device") + session = await _session(tmp_path, roster) + code = generate_code() + await _file_request(session, sk_new, pk_new_ed, pk_new_x, code) + + match = await _match_by_code(session, code) + await _approve(session, sk_old_signer, pk_new_ed, pk_new_x, + code_hash=match["code_hash"]) + assert _last(session)["type"] == MNP.DEVICE_ADD_ACK + + # Spent: the same approval cannot be replayed. + await _approve(session, sk_old_signer, pk_new_ed, pk_new_x, + code_hash=match["code_hash"]) + assert _last(session)["type"] == "error" + + +async def test_another_account_cannot_redeem_your_code(tmp_path, roster): + """Scoped to the account as well as to the keys.""" + _, pk_a_ed, pk_a_x = _keys() + await roster.pin_identity("alice", "alice", pk_a_ed, pk_a_x, "code") + _, pk_b_ed, pk_b_x = _keys() + await roster.pin_identity("bob", "bob", pk_b_ed, pk_b_x, "code") + sk_new, pk_new_ed, pk_new_x = _keys() + + alice = await _session(tmp_path, roster, user_id="alice") + code = generate_code() + await _file_request(alice, sk_new, pk_new_ed, pk_new_x, code) + + bob = await _session(tmp_path, roster, user_id="bob") + + # Scoped to the account: Bob is not offered Alice's pending request at all, + # so the code buys him nothing even if he has it. + assert await _match_by_code(bob, code) is None + + +async def test_guessing_is_bounded_on_a_connection(tmp_path, roster): + _, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + session = await _session(tmp_path, roster) + + sk_new, pk_new_ed, pk_new_x = _keys() + for _ in range(8): + await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code()) + + assert "Too many device attempts" in _last(session)["detail"] + + +# ── Limits and revocation ──────────────────────────────────────────────────── + +async def test_the_device_ceiling_holds(tmp_path, roster): + """ + A chain of devices inherits the weakness of its weakest ancestor, so the + answer to "how many" is a ceiling and visibility, not cryptography. + """ + sk_first, pk_first_ed, pk_first_x = _keys() + await roster.pin_identity("alice", "alice", pk_first_ed, pk_first_x, "code") + for _ in range(roster.MAX_DEVICES_PER_USER - 1): + _, pk_ed, pk_x = _keys() + await roster.pin_identity("alice", "alice", pk_ed, pk_x, "device") + + session = await _session(tmp_path, roster) + sk_new, pk_new_ed, pk_new_x = _keys() + await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code()) + + assert _last(session)["type"] == "error" + assert "limit" in _last(session)["detail"] + + +async def test_your_last_device_cannot_be_revoked(tmp_path, roster): + """Removing it would need an operator's code to come back, and doing that + to yourself by accident is not a mistake worth allowing.""" + sk_only, pk_only_ed, pk_only_x = _keys() + await roster.pin_identity("alice", "alice", pk_only_ed, pk_only_x, "code") + session = await _session(tmp_path, roster) + + ts = int(time.time()) + transcript = device_add_transcript( + node_pk_b64=session._node_pk_b64(), user_id="alice", + pk_ed25519_b64=pk_only_ed, pk_x25519_b64=pk_only_x, + nonce_node=NONCE, ts=ts) + await session._do_device_revoke({ + "pk_ed25519": pk_only_ed, "ts": ts, + "sig": base64.b64encode(sk_only.sign(transcript)).decode()}) + + assert _last(session)["type"] == "error" + assert await roster.find_device("alice", pk_only_ed) is not None + + +async def test_unpinning_an_account_takes_every_device(tmp_path, roster): + """`member unpin` is what an operator runs when someone must start over. + Leaving one device would let them walk back in with a forgotten key.""" + for _ in range(3): + _, pk_ed, pk_x = _keys() + await roster.pin_identity("alice", "alice", pk_ed, pk_x, "device") + + assert len(await roster.list_devices("alice")) == 3 + await roster.unpin("alice") + assert await roster.list_devices("alice") == [] diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index 9e45dbc..61d53ac 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -247,10 +247,16 @@ async def test_join_cannot_be_replayed_onto_another_connection(tmp_path, roster) assert await roster.get_identity("grenet") is None -async def test_pinned_identity_presenting_a_new_key_is_refused(tmp_path, roster): +async def test_a_key_this_node_never_pinned_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. + 11.5.8's rule, applied to people: an unrecognised key does not get in, and + a code cannot talk its way past that. + + What changed with device linking (2026-08-18) is the way back, not the + refusal. This used to be `key_changed` and needed an operator to unpin; now + it is `unknown_device` and the person approves the new key from a device + already paired here. Nothing is pinned either way, which is the part that + matters. """ session = _session(tmp_path, roster) _, old_pk_ed, old_pk_x = _keypair() @@ -260,8 +266,30 @@ async def test_pinned_identity_presenting_a_new_key_is_refused(tmp_path, roster) await session._do_join_request( _join_msg(session, sk_ed2, new_pk_ed, new_pk_x, code="ANY-CODE")) + assert _last(session).get("reason") == "unknown_device" + assert await roster.find_device("grenet", new_pk_ed) is None + assert [d["pk_ed25519"] for d in await roster.list_devices("grenet")] == \ + [old_pk_ed] + + +async def test_a_pinned_key_arriving_with_a_different_x25519_is_refused( + tmp_path, roster): + """ + The join transcript signs both keys together, so a pinned Ed25519 key + presenting a different encryption key is either a client that regenerated + half its identity or two messages spliced. Either way the pair is not the + one admitted, and the group key must not be wrapped for it. + """ + session = _session(tmp_path, roster) + sk_ed, pk_ed, pk_x = _keypair() + await roster.pin_identity("grenet", "grenet", pk_ed, pk_x, "code") + + _, _, other_pk_x = _keypair() + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed, other_pk_x, code="ANY-CODE")) + assert _last(session).get("reason") == "key_changed" - assert (await roster.get_identity("grenet"))["pk_ed25519"] == old_pk_ed + assert (await roster.find_device("grenet", pk_ed))["pk_x25519"] == pk_x async def test_attempts_are_bounded(tmp_path, roster): |