diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-15 02:34:19 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-15 02:34:19 +0200 |
| commit | 84b032c65e17267d41e04605e79eea82a6f5a59f (patch) | |
| tree | eb7708a72944bb15540e103b4319242199af6fbb | |
| parent | 5338894f7fec9e1a60affb0e2ff3b9797bcbc968 (diff) | |
| download | meshbay-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>
| -rw-r--r-- | CLAUDE.md | 6 | ||||
| -rw-r--r-- | docs/USERGUIDE.md | 20 | ||||
| -rw-r--r-- | docs/invite-pairing-v1.md | 11 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/groups.py | 32 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 65 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/i18n.js | 5 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/style.css | 47 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_group_description.py | 103 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/config.py | 19 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 33 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 15 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_roster_pairing.py | 24 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_webrtc_transport.py | 34 |
13 files changed, 348 insertions, 66 deletions
@@ -119,8 +119,10 @@ Scope: `hub`, `node`, `common`, or omitted for cross-cutting - **NS4** ~~`admin_pk_ed25519` auto-pinned from keystore~~ ❌ **that was finding M3.** The keystore key is not the key the browser signs with, so every admin operation failed closed. Authority now comes from the node's roster — `meshbay-node operator pair` - (2026-08-14). `admin_pk_ed25519` is still read as a legacy form; never auto-pin again, - and never resolve the operator's key through the hub + (2026-08-14). `admin_pk_ed25519` was kept as a legacy form and is now **removed** + (2026-08-15) — one source of authority, the roster. A config still naming it is + warned about at startup, never obeyed. Never auto-pin again, and never resolve the + operator's key through the hub - **NS5** DTLS channel binding in GEK-HMAC — `HMAC(GEK, nonce || offer_fp || answer_fp)` detects WebRTC signaling MitM ✅ DONE - **NS6** Chat `sender_id` enforced from authenticated session — prevents impersonation ✅ DONE - **NS7** Node Ed25519 auth — node daemon authenticates to hub via `POST /v1/nodes/auth` (Ed25519 signed timestamp), no auth_key/password on node. JWT `scope: "node"` blocks group management (create/add/delete/join). Operator manages groups from browser only. ✅ DONE diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index 214b6e9..8a1e405 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -221,6 +221,25 @@ Response: Save `group_id` — you will need it in your `node.toml` and when adding members. +### The description + +Set it at creation with `"description"`, or later from the group's page — the owner +sees an **Edit description** link under the name. Members see it on their home page +and, for public groups, in Explore. + +``` +PATCH /v1/groups/{group_id} +Authorization: Bearer <access_token> (the group's owner) +{"description": "host grenoble"} +→ 200 {"group_id": "...", "description": "host grenoble"} +``` + +An empty string clears it; anything past 512 characters is trimmed rather than +refused. The description is all this endpoint changes: the name, the visibility and +the join policy are the terms members joined on, and a private group that could +quietly become public is not the group they agreed to be in. Changing those needs a +decision about who gets told, so it is not a field on a form. + ### Public vs. private groups | | Public | Private | @@ -815,6 +834,7 @@ they speak MNP (§6), and their only HTTP surface is the operator's admin UI on | POST | `/v1/groups` | Access token | Create group. Body: `name`. Returns `group_id`. | | GET | `/v1/groups` | None / Access token | List/search groups. Private groups require membership. | | GET | `/v1/groups/{group_id}` | None / Access token | Group metadata. | +| PATCH | `/v1/groups/{group_id}` | Access token (owner) | Edit the description. Body: `description`. Nothing else is editable — see §3. | | DELETE | `/v1/groups/{group_id}` | Access token (admin) | Revoke and delete group. | **GEK distribution — removed.** The hub used to carry wrapped group keys between members. diff --git a/docs/invite-pairing-v1.md b/docs/invite-pairing-v1.md index 290c756..ad36a1a 100644 --- a/docs/invite-pairing-v1.md +++ b/docs/invite-pairing-v1.md @@ -96,8 +96,11 @@ node writes the pin to its roster DB, prints it in `status` The operator types 8 characters into their own browser. Nothing is pasted, nothing is copied out of a terminal, no browser is needed on the node host, and the hub is -not involved at any point. `admin_pk_ed25519` in `node.toml` becomes a legacy -fallback, still read, no longer required. +not involved at any point. `admin_pk_ed25519` in `node.toml` became a legacy +fallback, and was removed on 2026-08-15: the roster is the only source of operator +authority. A config that still names the key is warned about at startup, so a +deployment relying on it learns why its invites stopped rather than discovering a +signature error. ### 3.2 Invite (one click, operator or delegate) @@ -357,7 +360,7 @@ is a roster row and a CLI command — no protocol change, no migration. | Two people race one code | Single-use row, `used_at` set under a transaction; the loser gets a plain refusal | | Invite created while the node is offline | Not possible — invites are created on the node. The SPA must say "node offline, cannot invite" instead of failing obscurely | | Member connects while the group has no active GEK | `join_result {ok: false, reason: "no_gek"}`; the operator runs `gek-init` | -| Legacy deployment with `admin_pk_ed25519` set | Read at startup and inserted as an `identities` row with `pinned_via = 'legacy-config'`; no migration needed for the current demo | +| Legacy deployment with `admin_pk_ed25519` set | Ignored since 2026-08-15, with a warning at startup naming the config file. The operator pairs a browser; there is no second path | --- @@ -393,7 +396,7 @@ coherent with each other rather than individually demo-able. | `identities` / `members` / `invites`, codes, single-use redemption | `meshbay_node/roster.py` | | `join_transcript` — both public keys signed together | `meshbay_common/join.py` | | `join_request` / `join_result` handler, valid pre-proof and post-handshake | `transport/webrtc_server.py` | -| Admin authority read from the roster on every check, `admin_pk_ed25519` kept as legacy | `webrtc_server._verify_admin_sig` | +| Admin authority read from the roster on every check, and from nowhere else | `webrtc_server._verify_admin_sig` | | **Auto-pin of the keystore key deleted** (M3) | `daemon._legacy_admin_pk` | | `meshbay-node operator pair`, roster in `status` | `daemon.main`, `ui/app.py` | | Pairing form in the group's Members tab | `app.js`, `transport.js` | diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index 43d72c3..83feeb4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -276,6 +276,38 @@ async def create_group( return {"group_id": group.id, "name": group.name} +class GroupUpdateRequest(BaseModel): + description: str | None = None + + +@router.patch("/{group_id}") +async def update_group( + group_id: str, + body: GroupUpdateRequest, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """ + Change the group's description. Owner only. + + Only the description: name, visibility and join policy are what members + joined on the strength of, and a group that can quietly become public is a + different thing from the one they agreed to. Those need a decision about who + is told, not a PATCH. + """ + group = await db.get(Group, group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + if group.admin_id != current_user.id: + raise HTTPException(status_code=403, detail="Only the group owner can edit it") + + if body.description is not None: + desc = body.description.strip()[:512] + group.description = desc or None + await db.commit() + return {"group_id": group.id, "description": group.description or ""} + + @router.post("/{group_id}/members/{username}", status_code=201) async def add_group_member( group_id: str, diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index f9f6b55..fee21dc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -255,6 +255,8 @@ const ICON_PATHS = { door: ['M13.5 3.5H6a1 1 0 0 0-1 1v15a1 1 0 0 0 1 1h7.5', 'M10.5 12H21', 'M17.8 8.8L21 12l-3.2 3.2'], clip: ['M20.5 11.8l-8.4 8.4a5.4 5.4 0 0 1-7.6-7.6l8.8-8.8a3.6 3.6 0 0 1 5.1 5.1l-8.8 8.8a1.8 1.8 0 0 1-2.5-2.5l8.1-8.1'], + pencil: ['M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3', + 'M14.5 6.5l3 3'], check: ['M4.5 12.5l5 5 10-11'], chevron: ['M6 9.5l6 6 6-6'], close: ['M6 6l12 12M18 6L6 18'], @@ -871,11 +873,14 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk } function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, - onJoined }) { + onJoined, onGroupUpdated }) { const [status, setStatus] = useState('idle'); const [entries, setEntries] = useState([]); const [error, setError] = useState(''); + const [editingDesc, setEditingDesc] = useState(false); + const [descDraft, setDescDraft] = useState(''); + const [savingDesc, setSavingDesc] = useState(false); const [sortKey, setSortKey] = useState('name'); const [sortAsc, setSortAsc] = useState(true); const [filter, setFilter] = useState(''); @@ -1143,6 +1148,22 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, } }, [currentPath]); + const saveDescription = useCallback(async (e) => { + e.preventDefault(); + setSavingDesc(true); + try { + const r = await hubFetch(`/v1/groups/${groupId}`, { + method: 'PATCH', token, body: { description: descDraft }, + }); + if (onGroupUpdated) onGroupUpdated(groupId, { description: r.description }); + setEditingDesc(false); + } catch (err) { + setError(err.message); + } finally { + setSavingDesc(false); + } + }, [groupId, token, descDraft, onGroupUpdated]); + const deleteFile = useCallback(async (entry) => { const transport = transportRef.current; if (!transport || !transport.connected) return; @@ -1233,9 +1254,35 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, <h2 style="margin-bottom:${group && group.description ? '4px' : '0'}"> ${group ? group.name : t('group.default_name')} </h2> - ${group && group.description && html` - <p class="group-desc">${group.description}</p> - `} + ${editingDesc + ? html` + <form class="group-desc-edit" onSubmit=${saveDescription}> + <textarea rows="2" maxlength="512" autofocus + placeholder="${t('group.desc_placeholder')}" + value=${descDraft} + onInput=${e => setDescDraft(e.target.value)}></textarea> + <div> + <button class="admin-btn" type="submit" disabled=${savingDesc}> + ${savingDesc ? '...' : t('group.desc_save')} + </button> + <button class="btn-secondary" type="button" + onClick=${() => setEditingDesc(false)}>${t('group.desc_cancel')}</button> + </div> + </form> + ` + : html` + ${group && group.description && html` + <p class="group-desc">${group.description}</p> + `} + ${group && group.is_admin && html` + <button class="link-btn" title=${t('group.desc_edit')} + onClick=${() => { setDescDraft(group.description || ''); + setEditingDesc(true); }}> + <${Icon} name="pencil" />${' '} + ${group.description ? t('group.desc_edit') : t('group.desc_add')} + </button> + `} + `} </div> <span class="status-badge ${statusClass}"> ${(status === 'discovering' || status === 'connecting' || status === 'fetching') @@ -2903,6 +2950,13 @@ function App() { fetchNotifications(); }, [user]); + // The group list lives here, so an edit made three components down has to come + // back up rather than be re-fetched: a reload would drop the WebRTC connection + // the page is holding. + const updateGroup = useCallback((gid, patch) => { + setGroups(prev => prev.map(g => (g.id === gid ? { ...g, ...patch } : g))); + }, []); + const markRead = useCallback((id) => { if (!user) return; // Drop it here and now. Waiting for the round trip leaves it on screen while @@ -3011,7 +3065,8 @@ function App() { page = html`<${GroupPage} groupId=${groupId} group=${group} token=${user.token} username=${user.username} userId=${user.userId} - onRefreshAuth=${refreshAuth} onJoined=${dismissGroupNotifications} />`; + onRefreshAuth=${refreshAuth} onJoined=${dismissGroupNotifications} + onGroupUpdated=${updateGroup} />`; } else if (route === '/admin') { page = (user.role === 'moderator' || user.role === 'admin') ? html`<${AdminPage} token=${user.token} />` diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index cca595d..a6281e2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -278,6 +278,11 @@ const en = { + 'you normally talk. It works once, and it never passes through the hub.', 'members.invite_code_hint': 'They enter it the first time they open this group. ' + 'You do not need to be online then.', + 'group.desc_edit': 'Edit description', + 'group.desc_add': 'Add a description', + 'group.desc_save': 'Save', + 'group.desc_cancel': 'Cancel', + 'group.desc_placeholder': 'What this group is for — members see it on their home page.', 'group.join_code_title': 'This node needs to recognise you', 'group.join_code_hint': 'Ask whoever invited you for the one-time code, and enter ' + 'it here. After that this browser is recognised and you will not be asked again.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index fab4f55..f83b433 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -1157,6 +1157,53 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .admin-btn.danger:hover { border-color: var(--error); } .admin-btn:disabled { opacity: 0.4; cursor: not-allowed; } +/* Used in four places and never defined, so it rendered as a bare browser + button next to styled ones. Same shape as .admin-btn, quieter. */ +.btn-secondary { + padding: 4px 10px; + border: 1px solid transparent; + border-radius: 5px; + background: none; + color: var(--text-secondary); + font-size: 0.82em; + cursor: pointer; + white-space: nowrap; +} +.btn-secondary:hover { border-color: var(--border); color: var(--text); } +.btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; } + +/* Text that acts as a button: the "add a description" affordance should not + compete with the group's name for attention. */ +.link-btn { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 0; + border: none; + background: none; + color: var(--text-dim); + font-size: 0.8em; + cursor: pointer; +} +.link-btn:hover { color: var(--accent); } + +/* The header is a flex row, so a textarea in it collapses to its content + width unless told otherwise. */ +.group-desc-edit { margin: 4px 0 6px; width: min(460px, 60vw); min-width: 260px; } +.group-desc-edit textarea { + width: 100%; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--bg-surface); + color: var(--text); + font: inherit; + font-size: 0.85em; + resize: vertical; +} +.group-desc-edit textarea:focus { outline: none; border-color: var(--border-focus); } +.group-desc-edit div { display: flex; gap: 6px; margin-top: 4px; } + .admin-role-select { padding: 3px 6px; border: 1px solid var(--border); diff --git a/packages/meshbay-hub/tests/test_group_description.py b/packages/meshbay-hub/tests/test_group_description.py new file mode 100644 index 0000000..b18c90a --- /dev/null +++ b/packages/meshbay-hub/tests/test_group_description.py @@ -0,0 +1,103 @@ +""" +Editing a group's description. + +A description could only be set when the group was created, so every group made +before anyone thought to write one stayed blank for good. What is deliberately +*not* editable is as much the point: name, visibility and join policy are the +terms members joined on. +""" + +import base64 +import hashlib + +import pytest + + +def _auth_key(password: str, username: str) -> str: + salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest() + return base64.b64encode( + hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode() + + +async def _user(client, username, password="a-long-enough-passphrase"): + await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@example.com", + "auth_key": _auth_key(password, username)}) + r = await client.post("/v1/users/login", json={ + "username": username, "auth_key": _auth_key(password, username)}) + return {"Authorization": f"Bearer {r.json()['access_token']}"} + + +async def _group(client, headers, name="described", **kw): + r = await client.post("/v1/groups", json={"name": name, **kw}, headers=headers) + return r.json()["group_id"] + + +@pytest.mark.asyncio +async def test_the_owner_can_write_a_description(client): + owner = await _user(client, "writer") + gid = await _group(client, owner) + + r = await client.patch(f"/v1/groups/{gid}", + json={"description": "host grenoble"}, headers=owner) + assert r.status_code == 200, r.text + + mine = await client.get("/v1/groups/mine", headers=owner) + group = next(g for g in mine.json()["groups"] if g["id"] == gid) + assert group["description"] == "host grenoble" + + +@pytest.mark.asyncio +async def test_a_member_cannot(client): + owner = await _user(client, "owner2") + member = await _user(client, "member2") + gid = await _group(client, owner, name="not-yours") + await client.post(f"/v1/groups/{gid}/members/member2", json={}, headers=owner) + + r = await client.patch(f"/v1/groups/{gid}", + json={"description": "mine now"}, headers=member) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_an_empty_description_clears_it(client): + owner = await _user(client, "clearer") + gid = await _group(client, owner, name="clearme", description="temporary") + + r = await client.patch(f"/v1/groups/{gid}", json={"description": " "}, + headers=owner) + assert r.status_code == 200 + assert r.json()["description"] == "" + + +@pytest.mark.asyncio +async def test_the_terms_members_joined_on_are_not_editable(client): + """ + A private group that could quietly become public is not the group its + members agreed to be in. Changing that needs a decision about who gets told, + so the endpoint ignores it rather than half-implementing it. + """ + owner = await _user(client, "sneaky") + gid = await _group(client, owner, name="private-please", visibility="private") + + await client.patch(f"/v1/groups/{gid}", + json={"description": "hi", "visibility": "public", + "join_policy": "open", "name": "renamed"}, + headers=owner) + + mine = await client.get("/v1/groups/mine", headers=owner) + group = next(g for g in mine.json()["groups"] if g["id"] == gid) + assert group["visibility"] == "private" + assert group["join_policy"] == "invite" + assert group["name"] == "private-please" + + +@pytest.mark.asyncio +async def test_a_long_description_is_truncated_not_refused(client): + owner = await _user(client, "verbose") + gid = await _group(client, owner, name="long") + + r = await client.patch(f"/v1/groups/{gid}", json={"description": "x" * 900}, + headers=owner) + assert r.status_code == 200 + assert len(r.json()["description"]) == 512 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( |