From 84b032c65e17267d41e04605e79eea82a6f5a59f Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 15 Aug 2026 02:34:19 +0200 Subject: feat(groups): editable description, and one source of operator authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A description could only be set the moment a group was created, so every group made before anyone thought of one stayed blank for good. The owner can now edit it from the group's page, and PATCH /v1/groups/{id} takes it. That endpoint takes the description and nothing else, deliberately. The name, the visibility and the join policy are the terms members joined on; a private group that can quietly become public is not the group they agreed to be in. Changing those needs a decision about who gets told, not a field on a form — there is a test saying so. Separately, the legacy operator key is gone. `admin_pk_ed25519` in node.toml named the operator before the roster existed and was kept so that an existing deployment would keep working; nothing uses it, and a second source of node authority is not something to carry around out of politeness. Authority is the roster, read fresh on every check. It is removed rather than ignored: a config that still names the key gets a warning at startup pointing at the file. Dropping it in silence would refuse invites and file deletion with a signature error that looks like a bug somewhere else — which is exactly how finding M3 presented. Two tests were verifying admin operations by naming a key in the context, which was the legacy path. They now pair an operator into a roster, the way an operator does. The authority test anchored on the deleted function and passed vacuously once it disappeared; it states the invariant against the verifier and the daemon instead. Also defined .btn-secondary, used in four places and styled in none. Co-Authored-By: Claude Opus 5 --- packages/meshbay-node/tests/test_roster_pairing.py | 24 +++++++++++---- .../meshbay-node/tests/test_webrtc_transport.py | 34 +++++++++++++++++++--- 2 files changed, 49 insertions(+), 9 deletions(-) (limited to 'packages/meshbay-node/tests') diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index 665c060..a2f7cd1 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -737,8 +737,22 @@ def test_admin_authority_is_never_fetched_from_the_hub(): The fix M3 invites: ask the hub which key belongs to the operator. That would hand a malicious hub the node — the same substitution as H3, one level deeper. """ - source = (Path(__file__).parent.parent - / "src" / "meshbay_node" / "daemon.py").read_text() - admin_region = source[source.find("_legacy_admin_pk"):] - assert "pubkeys" not in admin_region.split("def ")[1], ( - "node authority must never be resolved through a hub lookup") + src = Path(__file__).parent.parent / "src" / "meshbay_node" + + verifier = (src / "transport" / "webrtc_server.py").read_text() + body = verifier[verifier.index("async def _verify_admin_sig"):] + body = body[:body.index("\n def ", 1)] + assert "operator_pks" in body, "the roster is where authority comes from" + # Past the docstring: it names what was removed on purpose, so a reader knows + # not to put it back. What must not reappear is code. + code = body[body.index('"""', body.index('"""') + 3):] + for forbidden in ("hub", "pubkeys", "admin_pk_ed25519"): + assert forbidden not in code, ( + f"_verify_admin_sig mentions {forbidden!r} — authority must come from " + "the local roster and nothing else") + + daemon = (src / "daemon.py").read_text() + assert "has_operator()" in daemon, "the daemon reads authority from the roster" + assert "admin_pk_ed25519" not in daemon, ( + "the node.toml operator key is gone; it must not come back as a second " + "source of authority") diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py index 93cd3fd..cc0c6a2 100644 --- a/packages/meshbay-node/tests/test_webrtc_transport.py +++ b/packages/meshbay-node/tests/test_webrtc_transport.py @@ -823,8 +823,31 @@ async def test_webrtc_dtls_channel_binding_detects_mitm(sk_node, sk_hub, gek, sh await transport.close_all() +async def _paired_operator_roster(tmp_path, sk_admin): + """ + A roster holding one operator, which is the only thing that authorizes an + admin operation now. It used to be enough to name a key in node.toml; that + path is gone, so these tests build the authority the way an operator does — + by pairing. + """ + from meshbay_node.roster import Roster + + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + await roster.pin_identity( + user_id="user-001", username="operator", + pk_ed25519=base64.b64encode(sk_admin.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw)).decode(), + pk_x25519="", via="test") + await roster.set_member(group_id="", user_id="user-001", role="operator", + status="active", approved_by="test") + return roster + + @pytest.mark.asyncio -async def test_webrtc_admin_challenge_response(sk_node, sk_hub, gek, shared_dir): +async def test_webrtc_admin_challenge_response(sk_node, sk_hub, gek, shared_dir, + tmp_path): """WebRTC DataChannel: admin file delete requires Ed25519 challenge-response.""" hub_pk_pem = _hub_pk_pem(sk_hub) indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) @@ -837,7 +860,8 @@ async def test_webrtc_admin_challenge_response(sk_node, sk_hub, gek, shared_dir) shared_root=shared_dir, index=indexer.index, stun_servers=[], ) - transport._ctx["admin_pk_ed25519"] = sk_admin.public_key() + transport._ctx["roster"] = await _paired_operator_roster(tmp_path, sk_admin) + transport._ctx["has_admin_authority"] = True transport._ctx["node_user_id"] = "user-001" browser_pc, channel, received = await _setup_peer( @@ -871,7 +895,8 @@ async def test_webrtc_admin_challenge_response(sk_node, sk_hub, gek, shared_dir) @pytest.mark.asyncio -async def test_webrtc_admin_bad_signature_rejected(sk_node, sk_hub, gek, shared_dir): +async def test_webrtc_admin_bad_signature_rejected(sk_node, sk_hub, gek, shared_dir, + tmp_path): """WebRTC DataChannel: wrong Ed25519 signature is rejected — hub can't fake admin.""" hub_pk_pem = _hub_pk_pem(sk_hub) indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) @@ -885,7 +910,8 @@ async def test_webrtc_admin_bad_signature_rejected(sk_node, sk_hub, gek, shared_ shared_root=shared_dir, index=indexer.index, stun_servers=[], ) - transport._ctx["admin_pk_ed25519"] = sk_admin.public_key() + transport._ctx["roster"] = await _paired_operator_roster(tmp_path, sk_admin) + transport._ctx["has_admin_authority"] = True transport._ctx["node_user_id"] = "user-001" browser_pc, channel, received = await _setup_peer( -- cgit v1.2.3