summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_security_regressions.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 01:27:21 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 01:27:21 +0200
commitf15efd23f66c521ca9206789482bb38e7326eeb4 (patch)
treef069b741d3fe0114c3b5889c02dc0392c5201f68 /packages/meshbay-node/tests/test_security_regressions.py
parentaab4bc98a3361d9f23e048a52705baa2f4a4a078 (diff)
downloadmeshbay-f15efd23f66c521ca9206789482bb38e7326eeb4.tar.gz
feat(node)!: the node wraps the group key — closes H3 and M3
The invite flow fetched the invitee's pk_x25519 from the hub and wrapped the GEK for whatever came back (app.js:1466, and gek-init did the same server-side). The hub is the key directory, so a hub answering with its own key was handed the group key by an honest member following the protocol exactly. No forgery, no injection, nothing for the client to notice. That was H3. The fix is not safety numbers. Nobody reads the directory any more: - the node holds the GEK and wraps it itself, on every connection, for the X25519 key the joiner signed with their Ed25519 identity in one transcript (meshbay:join:v1), so the identity key vouches for the encryption key; - identities are bound to accounts by a one-time code the hub never sees — 40 bits, single use, one account, bounded per connection AND node-wide; - the node's own roster decides who may receive the key. Hub membership lets someone reach a node; it no longer gets them anything. A hub that invents an account and mints it a token is answered not_authorized_for_group. Safety numbers would have made substitution detectable by a human who checks, at the moment there is nothing to check against — first contact. Removing the lookup makes it impossible, and costs the user one code to pass along. M3 falls out of the same work. The daemon auto-pinned its own keystore key as admin_pk_ed25519 while the browser signs with the user identity key, so every privileged operation failed closed with a signature error that looked like a bug somewhere else; the demo only worked because a deploy script overwrote the value. Authority now comes from the roster, established locally by `operator pair`. Asking the hub for the operator's key — the obvious-looking fix — would have let the hub install itself as node administrator. BREAKING: gek_bundle_store is deleted, not gated. No member hands the node key material at all, so C5b becomes structural rather than an authorization to check. Existing stored bundles are still served, so current deployments keep working. Also: - join_policy (invite|open) is read from node.toml, never from the hub — a hub able to declare a group open would be handed its key. Unknown group ⇒ invite. - admin signatures are verified against the roster on every check, so unpinning takes effect without a restart. admin_pk_ed25519 stays readable as legacy. - two C5b tests were rewritten, deliberately: they asserted that gek_bundle_store demanded an operator signature, and the message is gone. They now assert the stronger property. The file says not to fix these tests, so this is the record of why they changed. - a slice-1 bug found while writing slice 2: connect() never passed skEdB64, so pairing would have failed at runtime with no test able to catch it. Tests: 152 node+common here, including an end-to-end DataChannel run where a member who has never held the group key redeems a code in the pre-proof window and receives the key wrapped for a key only they can open. Design: docs/invite-pairing-v1.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests/test_security_regressions.py')
-rw-r--r--packages/meshbay-node/tests/test_security_regressions.py58
1 files changed, 32 insertions, 26 deletions
diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py
index 9299bf4..6bb680c 100644
--- a/packages/meshbay-node/tests/test_security_regressions.py
+++ b/packages/meshbay-node/tests/test_security_regressions.py
@@ -216,16 +216,35 @@ def test_daemon_sets_no_global_chat_store(tmp_path):
# ── H2: node admin UI escaping ───────────────────────────────────────────────
-def test_gek_bundle_store_requires_admin_challenge(tmp_path):
+def test_no_member_can_hand_the_node_key_material(tmp_path):
"""
- C5b: gek_bundle_store used to write whatever any authenticated member sent.
- It must now answer with a challenge and store nothing until a valid
- node-operator signature arrives.
+ C5b, strengthened by the invite redesign (docs/invite-pairing-v1.md).
+
+ This test used to assert that `gek_bundle_store` answered with an admin
+ challenge and stored nothing without an operator signature. The message is now
+ gone entirely: the node holds the GEK and wraps it itself, so no member ever
+ submits key material, authorized or not. Deleting the path is a stronger
+ guarantee than gating it, which is why the assertion changed rather than the
+ behaviour regressing.
"""
+ from meshbay_common.protocol import MNP as _MNP
+
+ assert not hasattr(_MNP, "GEK_BUNDLE_STORE"), (
+ "the member-supplied bundle message is back — the node must never accept "
+ "key material over MNP (C5b)"
+ )
+
+ source = (Path(__file__).parent.parent
+ / "src" / "meshbay_node" / "transport" / "webrtc_server.py").read_text()
+ assert "_do_gek_bundle_store" not in source
+ assert "_admin_exec_bundle_store" not in source
+
+
+def test_unknown_message_stores_nothing(tmp_path):
+ """A peer sending the retired message must not reach any storage path."""
session = _session(tmp_path, "ordinary-member")
session._group_id = None
session._admin_ops = {}
- session._ctx["admin_pk_ed25519"] = Ed25519PrivateKey.generate().public_key()
stored = []
@@ -234,27 +253,13 @@ def test_gek_bundle_store_requires_admin_challenge(tmp_path):
stored.append(args)
session._ctx["bundle_store"] = _Store()
- session._do_gek_bundle_store({
+ session._handle_message({
+ "type": "gek_bundle_store",
"user_id": "victim", "group_id": "g" * 32,
"pk_eph_b64": "AA==", "nonce_b64": "AA==", "wrapped_b64": "AA==",
})
- assert stored == [], "bundle written without operator authorization (C5b)"
- assert any(m.get("type") == "admin_challenge" for m in session.sent)
-
-
-def test_gek_bundle_store_refused_without_pinned_admin_key(tmp_path):
- """C5b: deny by default — no pinned key means no privileged operation."""
- session = _session(tmp_path, "ordinary-member")
- session._group_id = None
- session._admin_ops = {}
- session._ctx["bundle_store"] = object()
-
- session._do_gek_bundle_store({
- "user_id": "victim", "group_id": "g" * 32,
- "pk_eph_b64": "AA==", "nonce_b64": "AA==", "wrapped_b64": "AA==",
- })
- assert any(m.get("type") == "error" for m in session.sent)
+ assert stored == [], "a retired message type still reached the bundle store"
def test_gek_auto_activation_is_gone():
@@ -287,7 +292,7 @@ def test_admin_transcript_is_domain_separated():
@pytest.mark.parametrize("field,value", [
- ("op", "gek_bundle_store"),
+ ("op", "invite_create"),
("subject", "file-2"),
("node_pk_b64", "OTHERNODE"),
("group_id", "h" * 32),
@@ -320,15 +325,15 @@ def test_admin_signature_does_not_transfer_between_operations(tmp_path):
H5: the concrete attack. A signature collected to delete a file must not
authorize storing a GEK bundle.
"""
- from meshbay_common.adminop import OP_FILE_DELETE, OP_GEK_BUNDLE_STORE
+ from meshbay_common.adminop import OP_FILE_DELETE, OP_INVITE_CREATE
sk_admin = Ed25519PrivateKey.generate()
delete_transcript = _transcript(op=OP_FILE_DELETE)
signature = sk_admin.sign(delete_transcript)
- store_transcript = _transcript(op=OP_GEK_BUNDLE_STORE)
+ invite_transcript = _transcript(op=OP_INVITE_CREATE)
with pytest.raises(Exception):
- sk_admin.public_key().verify(signature, store_transcript)
+ sk_admin.public_key().verify(signature, invite_transcript)
def test_admin_challenge_expires(tmp_path):
@@ -573,3 +578,4 @@ def test_admin_ui_escapes_filenames(tmp_path):
assert payload not in html, "filename rendered unescaped — stored XSS (H2)"
assert "&lt;img" in html, "filename should appear escaped"
+