aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-15 02:34:19 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-15 02:34:19 +0200
commit84b032c65e17267d41e04605e79eea82a6f5a59f (patch)
treeeb7708a72944bb15540e103b4319242199af6fbb /packages/meshbay-node
parent5338894f7fec9e1a60affb0e2ff3b9797bcbc968 (diff)
downloadmeshbay-84b032c65e17267d41e04605e79eea82a6f5a59f.tar.gz
feat(groups): editable description, and one source of operator authority
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/config.py19
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py33
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py15
-rw-r--r--packages/meshbay-node/tests/test_roster_pairing.py24
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py34
5 files changed, 70 insertions, 55 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py
index f3752ea..e4444d3 100644
--- a/packages/meshbay-node/src/meshbay_node/config.py
+++ b/packages/meshbay-node/src/meshbay_node/config.py
@@ -6,6 +6,7 @@ All values have sensible defaults and can be overridden by env vars
prefixed with MESHBAY_ (e.g. MESHBAY_HUB_URL).
"""
+import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
@@ -15,6 +16,8 @@ try:
except ImportError:
import tomli as tomllib # type: ignore[no-redef]
+log = logging.getLogger(__name__)
+
DEFAULT_CONFIG_PATH = Path.home() / ".config" / "meshbay" / "node.toml"
EXAMPLE_CONFIG = """\
@@ -58,10 +61,9 @@ visibility = "public" # discoverable on the hub
# unlock_file = "~/.config/meshbay/unlock.key"
# or set MESHBAY_UNLOCK_KEY env var
-# Node sovereignty: pin the operator's Ed25519 public key (base64, 32 bytes raw).
-# Admin operations (file delete) require cryptographic proof of this key.
-# Auto-pinned on first startup from the node operator's keystore.
-# admin_pk_ed25519 = "base64-encoded-32-bytes"
+# Operator authority is not configured here. Run `meshbay-node operator pair` and
+# enter the code in your browser: the node pins that browser's key, and invites
+# and file deletion are signed with it.
"""
@@ -112,7 +114,6 @@ class Config:
groups: list[GroupConfig] = field(default_factory=list)
keystore: KeystoreConfig = field(default_factory=KeystoreConfig)
data_dir: Path = field(default_factory=lambda: Path.home() / ".local" / "share" / "meshbay")
- admin_pk_ed25519: str = "" # base64 raw Ed25519 public key pinned locally
# Back-compat: single-group access
@property
@@ -167,7 +168,13 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
cfg.data_dir = Path(raw["data_dir"]).expanduser().resolve()
if "admin_pk_ed25519" in raw:
- cfg.admin_pk_ed25519 = raw["admin_pk_ed25519"]
+ # Removed, not merely unused: a key named here granted operator
+ # authority, and dropping it silently would refuse invites and file
+ # deletion with a signature error that looks like something else.
+ log.warning(
+ "admin_pk_ed25519 in %s is ignored — operator authority now comes "
+ "from the roster. Run `meshbay-node operator pair` and delete the "
+ "line.", path)
ks = raw.get("keystore", {})
if "path" in ks:
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 58fa99a..1fe68b1 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -35,7 +35,6 @@ import sys
from pathlib import Path
import uvicorn
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from meshbay_common import MNP_VERSION
from meshbay_common.protocol import MNP
@@ -288,15 +287,10 @@ class NodeDaemon:
self._webrtc._ctx["roster"] = self._roster
self._webrtc._ctx["invite_ttl"] = (
self._config.node.invite_ttl_hours * 3600)
- admin_pk = self._legacy_admin_pk()
paired = await self._roster.has_operator() if self._roster else False
- if admin_pk:
- self._webrtc._ctx["admin_pk_ed25519"] = admin_pk
self._webrtc._ctx["has_admin_authority"] = paired
- if paired or admin_pk:
- sources = ([] if not paired else ["paired operator"]) + \
- ([] if not admin_pk else ["node.toml admin_pk"])
- log.info("Node authority: %s", " + ".join(sources))
+ if paired:
+ log.info("Node authority: paired operator")
else:
log.warning(
"No operator paired — invites and file deletion are "
@@ -482,26 +476,6 @@ class NodeDaemon:
log.warning("No unwrappable GEK bundle found for group %s", group_id[:8])
return None
- def _legacy_admin_pk(self) -> Ed25519PublicKey | None:
- """
- The pre-roster way of naming the operator: `admin_pk_ed25519` in node.toml.
-
- Still honoured so a deployment configured that way keeps working, but no
- longer the only path — and the auto-pin that used to stand in for it is
- gone. It pinned the node's *keystore* key while the browser signed with the
- user's *identity* key, so admin operations failed closed with a signature
- error that looked like a bug elsewhere (finding M3). An operator now pairs
- a browser with `meshbay-node operator pair`.
- """
- if not self._config.admin_pk_ed25519:
- return None
- try:
- raw = base64.b64decode(self._config.admin_pk_ed25519)
- return Ed25519PublicKey.from_public_bytes(raw)
- except Exception as e:
- log.error("Invalid admin_pk_ed25519 in config: %s", e)
- return None
-
async def _on_index_change(self, indexer: DirectoryIndexer) -> None:
"""Called when a DirectoryIndexer detects file changes."""
group_id = indexer.group_id
@@ -770,9 +744,6 @@ def main() -> None:
print(f"operator {op.get('username') or op['user_id'][:8]}"
f" key {(op.get('pk_ed25519') or '')[:16]}…"
f" paired {op.get('pinned_at', '?')}")
- elif cfg.admin_pk_ed25519:
- print("operator node.toml admin_pk_ed25519 (legacy)")
- print(" run `meshbay-node operator pair` to replace it")
else:
print("operator NONE PAIRED — file deletion and member invites are")
print(" refused. Run: meshbay-node operator pair")
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 572f6fd..64df7ac 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -1415,8 +1415,7 @@ class WebRTCPeerSession:
`_verify_admin_sig`. The flag is set at startup and refreshed in-process
when an operator pairs.
"""
- return bool(self._ctx.get("admin_pk_ed25519")
- or self._ctx.get("has_admin_authority"))
+ return bool(self._ctx.get("has_admin_authority"))
async def _verify_admin_sig(self, transcript: bytes, sig: bytes) -> bool:
"""
@@ -1424,14 +1423,12 @@ class WebRTCPeerSession:
Read from the roster on each call rather than cached: revoking a paired
browser must take effect immediately, and admin operations are rare enough
- that a SQLite read costs nothing. `admin_pk_ed25519` in node.toml is still
- honoured so an existing deployment keeps working until its operator pairs
- (M3) — it is the legacy form of the same statement.
- """
- legacy = self._ctx.get("admin_pk_ed25519")
- if self._verify_sig(legacy, transcript, sig):
- return True
+ that a SQLite read costs nothing.
+ There is one source of operator authority and this is it. `admin_pk_ed25519`
+ in node.toml used to be honoured alongside the roster; it is gone, and a
+ config that still names it is warned about at startup rather than obeyed.
+ """
roster = self._ctx.get("roster")
if roster is None:
return False
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(