aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_security_regressions.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-13 16:29:36 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-13 16:29:36 +0200
commit9da3cfc5d31bc4e1b9c4ea70f56e07b9aed8bb85 (patch)
tree3a3164422547658b753aece6a10b9ecb47668f6a /packages/meshbay-node/tests/test_security_regressions.py
parent6a3f927413d4fd44b708a306e5e053f6660ec357 (diff)
downloadmeshbay-9da3cfc5d31bc4e1b9c4ea70f56e07b9aed8bb85.tar.gz
fix(node): the node's own controls take no authority from a hub token
`_is_node_admin()` is `self._user_id == node_user_id`, and `_user_id` is the `sub` of a JWT the hub issued. Six node-wide controls were gated on that alone: `node_status` — which lists every group on the machine with each root's **absolute path** — plus `node_settings_set`, `roster_read`, `denylist_read`, `denylist_clear` and `node_reload`. So the answer to "are you the operator of this node" was "the hub says so", which NS4 and M3 rule out in as many words: operator authority comes from the node's roster and from nowhere else, and asking the hub is how the hub installs itself as node administrator. The reach is bounded — a completed handshake also needs the group key — but an active hub obtains one legitimately in an open-join group, which §3.5 concedes, and from there it could read the operator's directory layout or clear the denylist, which is the persisted revocation H4 exists to keep. `_operator_device()` requires both halves now: the account is the one the node belongs to, *and* the device on this connection has proved a key the roster holds as an operator. `device_hello` is signed over a transcript naming the node, the group and this connection's nonce, and `operator_pks()` is rebuilt from the roster on each call, so an unpinned browser and a revoked one are both refused at once. The hub holds no user keys and cannot countersign a device. Keeping the account check as well is deliberate: dropping it would widen these node-wide controls to any paired operator of any group on the machine, which is a separate decision. `_is_node_admin()` stays as what it is in the handshake ack — a hint telling a client whether to offer the Node page — and says so. Nothing changes for a paired operator: `device_hello` runs unconditionally after the ack, and anyone using the Node page's controls is already paired, since `root_add` and every other signed op has always verified against `operator_pks()`. A browser that never paired now reads nothing there, which is the state in which it could already write nothing. test_node_status.py's fixture set the account and not the device, which is how it went on passing; it now wires the device the way `device_hello` leaves it. The adversary itself is in test_security_regressions.py — a token naming the owner's account with no proved device, which the previous source answered with `node_status_ack`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
Diffstat (limited to 'packages/meshbay-node/tests/test_security_regressions.py')
-rw-r--r--packages/meshbay-node/tests/test_security_regressions.py121
1 files changed, 121 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py
index 988aa46..fa55ff6 100644
--- a/packages/meshbay-node/tests/test_security_regressions.py
+++ b/packages/meshbay-node/tests/test_security_regressions.py
@@ -837,3 +837,124 @@ def test_node_control_api_serves_no_html():
for gone in ("_render_page", "_render_audit_page", "_render_roster",
"_AUDIT_HTML"):
assert not hasattr(ui_app, gone), f"{gone} came back — HTML surface"
+
+
+# ── NS4 / M3: the node's own controls take no authority from the hub ─────────
+
+async def _owner_session(tmp_path, roster, *, device: str = "",
+ confirmed: bool = False):
+ """A session whose token says it is the account this node belongs to.
+
+ Which is all a hub can decide: `_user_id` is the `sub` of a JWT it issued,
+ so this is what an active hub forging a token arrives holding. Whether the
+ *device* on the connection is one the node pinned as an operator is the
+ other half, and no token can assert it.
+ """
+ from meshbay_node.indexer.group_index import GroupIndex
+
+ shared = tmp_path / "shared"
+ shared.mkdir(exist_ok=True)
+ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = {
+ "roots": one_root(shared), "index": index, "sk_node": index.sk_node,
+ "gek": generate_gek(), "roster": roster,
+ "node_user_id": "the-owner",
+ "daemon_state": {"groups_ctx": {}, "reload_fn": None},
+ }
+ session._group_id = "g" * 32
+ session._user_id = "the-owner"
+ session._username = "the-owner"
+ session._pinned_pk = device
+ session._device_confirmed = confirmed
+ session._pk_user = ""
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ return session
+
+
+@pytest.mark.asyncio
+async def test_a_token_naming_the_owner_is_not_node_authority(tmp_path):
+ """
+ The hub holds no user keys, so it cannot countersign a device — but it does
+ choose what a token says. Six node-wide controls used to be gated on the
+ account id alone, which is the hub's to decide: `node_status` (every group
+ on the machine, with the operator's absolute paths), `node_settings_set`,
+ `roster_read`, `denylist_read`, `denylist_clear` (the persisted revocation
+ H4 exists to keep) and `node_reload`.
+
+ An active hub reaches a completed handshake wherever it can also obtain the
+ group key, which §3.5 concedes it can in an open-join group. From there,
+ "the hub says you are the owner" was the whole of the check.
+ """
+ from meshbay_node.roster import Roster
+
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ forged = await _owner_session(tmp_path, roster)
+ await forged._do_node_status({})
+ assert forged.sent[-1]["type"] == "error"
+ assert forged.sent[-1]["code"] == "not_operator"
+
+ forged.sent.clear()
+ await forged._do_denylist_read({})
+ assert forged.sent[-1]["type"] == "error"
+
+ forged.sent.clear()
+ await forged._do_denylist_clear({"subject": ""})
+ assert forged.sent[-1]["type"] == "error", (
+ "clearing the denylist undoes a revocation every node enforces")
+ finally:
+ await roster.close()
+
+
+@pytest.mark.asyncio
+async def test_an_identified_device_that_is_not_an_operator_is_refused(tmp_path):
+ """Being a pinned member of the group is not being the node's operator.
+
+ The device proof is real here; what it proves is an ordinary member's key,
+ and `operator_pks()` is rebuilt from the roster on every call so a revoked
+ one stops working at once.
+ """
+ from meshbay_node.roster import Roster
+ from meshbay_common.crypto import pk_to_b64
+
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ sk = Ed25519PrivateKey.generate()
+ member_pk = pk_to_b64(sk.public_key())
+ await roster.pin_identity("the-owner", "the-owner", member_pk,
+ member_pk, "code")
+ session = await _owner_session(tmp_path, roster, device=member_pk,
+ confirmed=True)
+ await session._do_node_status({})
+ assert session.sent[-1]["type"] == "error"
+ finally:
+ await roster.close()
+
+
+@pytest.mark.asyncio
+async def test_a_paired_operator_device_is_what_opens_it(tmp_path):
+ """The positive case, so the test above is about authority and not about
+ everything being refused."""
+ from meshbay_node.roster import Roster
+ from meshbay_common.join import ROLE_OPERATOR
+ from meshbay_common.crypto import pk_to_b64
+
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ sk = Ed25519PrivateKey.generate()
+ pk = pk_to_b64(sk.public_key())
+ await roster.pin_identity("the-owner", "the-owner", pk, pk, "code")
+ await roster.set_member("", "the-owner", ROLE_OPERATOR, "active",
+ "local-cli")
+ session = await _owner_session(tmp_path, roster, device=pk,
+ confirmed=True)
+ await session._do_denylist_read({})
+ assert session.sent[-1]["type"] != "error", session.sent[-1]
+ finally:
+ await roster.close()