summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py32
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js65
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css47
-rw-r--r--packages/meshbay-hub/tests/test_group_description.py103
-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
10 files changed, 317 insertions, 60 deletions
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(