diff options
Diffstat (limited to 'packages/meshbay-node/tests/test_webrtc_transport.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_webrtc_transport.py | 96 |
1 files changed, 73 insertions, 23 deletions
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py index 693a68b..d57f4f5 100644 --- a/packages/meshbay-node/tests/test_webrtc_transport.py +++ b/packages/meshbay-node/tests/test_webrtc_transport.py @@ -32,6 +32,11 @@ from meshbay_common.crypto import ( ) from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes from meshbay_common.protocol import MNP +from meshbay_common.adminop import ( + OP_FILE_DELETE, + OP_GEK_BUNDLE_STORE, + admin_transcript, +) from meshbay_node.bundle_store import BundleStore from meshbay_node.indexer import DirectoryIndexer from meshbay_node.transport.webrtc_server import WebRTCTransport @@ -86,6 +91,21 @@ def _make_jwt(sk_hub, groups=None, pk_user="test"): }, sk_pem, algorithm="EdDSA") +def _transcript_from(challenge_msg: dict) -> bytes: + """ + Rebuild the signed transcript from an admin_challenge, the way a real client + does — from the announced fields, never from opaque bytes on the wire (H5). + """ + return admin_transcript( + op=challenge_msg["op"], + node_pk_b64=challenge_msg["node_pk"], + group_id=challenge_msg["group_id"], + subject=challenge_msg["subject"], + nonce=base64.b64decode(challenge_msg["nonce"]), + ts=challenge_msg["ts"], + ) + + def _pack(obj: dict) -> bytes: data = msgpack.packb(obj, use_bin_type=True) return struct.pack(">I", len(data)) + data @@ -783,13 +803,13 @@ async def test_webrtc_admin_challenge_response(sk_node, sk_hub, gek, shared_dir) challenge_msg = await asyncio.wait_for(received.get(), timeout=5.0) assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE - assert challenge_msg["file_id"] == entry.id + assert challenge_msg["op"] == OP_FILE_DELETE + assert challenge_msg["subject"] == entry.id - challenge = base64.b64decode(challenge_msg["challenge"]) - signature = sk_admin.sign(challenge) + signature = sk_admin.sign(_transcript_from(challenge_msg)) channel.send(_pack({ "type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION, - "file_id": entry.id, + "op_id": challenge_msg["op_id"], "signature": base64.b64encode(signature).decode(), })) @@ -832,11 +852,10 @@ async def test_webrtc_admin_bad_signature_rejected(sk_node, sk_hub, gek, shared_ challenge_msg = await asyncio.wait_for(received.get(), timeout=5.0) assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE - challenge = base64.b64decode(challenge_msg["challenge"]) - bad_sig = sk_attacker.sign(challenge) + bad_sig = sk_attacker.sign(_transcript_from(challenge_msg)) channel.send(_pack({ "type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION, - "file_id": entry.id, + "op_id": challenge_msg["op_id"], "signature": base64.b64encode(bad_sig).decode(), })) @@ -914,14 +933,14 @@ async def test_webrtc_uploader_delete_requires_challenge(sk_node, sk_hub, gek, s challenge_msg = await asyncio.wait_for(received.get(), timeout=5.0) assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE - assert challenge_msg["file_id"] == entry.id + assert challenge_msg["op"] == OP_FILE_DELETE + assert challenge_msg["subject"] == entry.id # Sign with uploader's Ed25519 key - challenge = base64.b64decode(challenge_msg["challenge"]) - signature = sk_uploader.sign(challenge) + signature = sk_uploader.sign(_transcript_from(challenge_msg)) channel.send(_pack({ "type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION, - "file_id": entry.id, + "op_id": challenge_msg["op_id"], "signature": base64.b64encode(signature).decode(), })) @@ -979,11 +998,10 @@ async def test_webrtc_uploader_impersonation_blocked(sk_node, sk_hub, gek, share assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE # Sign with user B's key (wrong key) - challenge = base64.b64decode(challenge_msg["challenge"]) - bad_sig = sk_user_b.sign(challenge) + bad_sig = sk_user_b.sign(_transcript_from(challenge_msg)) channel.send(_pack({ "type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION, - "file_id": entry.id, + "op_id": challenge_msg["op_id"], "signature": base64.b64encode(bad_sig).decode(), })) @@ -1031,7 +1049,11 @@ async def test_gek_bundle_store_and_fetch(sk_node, sk_hub, gek, shared_dir, ) transport._ctx["bundle_store"] = bundle_store - # Connect as admin and store a GEK bundle for user-002 + # Storing a bundle is a node-operator operation (C5b): the node challenges and + # only the pinned admin key is accepted. + sk_admin = Ed25519PrivateKey.generate() + transport._ctx["admin_pk_ed25519"] = sk_admin.public_key() + pc_admin, ch_admin, q_admin = await _setup_peer( transport, sk_hub, gek, "peer-admin") @@ -1047,6 +1069,19 @@ async def test_gek_bundle_store_and_fetch(sk_node, sk_hub, gek, shared_dir, "nonce_b64": bundle["nonce_b64"], "wrapped_b64": bundle["wrapped_b64"], })) + + challenge_msg = await asyncio.wait_for(q_admin.get(), timeout=5.0) + assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE + assert challenge_msg["op"] == OP_GEK_BUNDLE_STORE + assert challenge_msg["subject"] == "user-002" + + signature = sk_admin.sign(_transcript_from(challenge_msg)) + ch_admin.send(_pack({ + "type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION, + "op_id": challenge_msg["op_id"], + "signature": base64.b64encode(signature).decode(), + })) + ack = await asyncio.wait_for(q_admin.get(), timeout=5.0) assert ack["type"] == "ack" assert ack["detail"] == "gek_bundle_stored" @@ -1313,9 +1348,18 @@ async def test_keypair_bundle_fetch_not_found(sk_node, sk_hub, gek, shared_dir, @pytest.mark.asyncio -async def test_gek_auto_activate_on_node_bundle_store(sk_node, sk_hub, gek, shared_dir, +async def test_gek_not_auto_activated_on_bundle_store(sk_node, sk_hub, gek, shared_dir, tmp_path, x25519_keypair): - """Storing the node operator's GEK bundle auto-activates GEK (AES variant).""" + """ + A GEK bundle arriving over MNP must NOT become the node's live key (C5b). + + This test previously asserted the opposite: storing a bundle addressed to the + node operator auto-activated it, with no signature required. Because the + operator's X25519 public key is public — the node publishes it in handshake_ack + — any group member could wrap a key of their own choosing for it and take over + the group, locking every legitimate member out. GEK activation now happens only + through the node's local admin UI or CLI. + """ hub_pk_pem = _hub_pk_pem(sk_hub) indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) await indexer.initial_scan() @@ -1324,7 +1368,8 @@ async def test_gek_auto_activate_on_node_bundle_store(sk_node, sk_hub, gek, shar bundle_store = BundleStore(db_path=tmp_path / "bundles.db") await bundle_store.open() - new_gek = generate_gek() + attacker_gek = generate_gek() + assert attacker_gek != gek transport = WebRTCTransport( sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek, @@ -1336,12 +1381,13 @@ async def test_gek_auto_activate_on_node_bundle_store(sk_node, sk_hub, gek, shar transport._ctx["sk_x25519_raw"] = sk_x_raw transport._ctx["pk_x25519_raw"] = pk_x_raw transport._ctx["pk_x25519_b64"] = base64.b64encode(pk_x_raw).decode() + transport._ctx["admin_pk_ed25519"] = Ed25519PrivateKey.generate().public_key() pc_admin, ch_admin, q_admin = await _setup_peer( transport, sk_hub, gek, "peer-setup-admin") - # Store GEK bundle wrapped with AES-GCM (browser-compatible) - node_bundle = wrap_gek_aes(new_gek, pk_x_raw) + # An ordinary member wraps a key of their choosing for the operator's public key. + node_bundle = wrap_gek_aes(attacker_gek, pk_x_raw) ch_admin.send(_pack({ "type": MNP.GEK_BUNDLE_STORE, "v": MNP_VERSION, @@ -1351,12 +1397,16 @@ async def test_gek_auto_activate_on_node_bundle_store(sk_node, sk_hub, gek, shar "nonce_b64": node_bundle["nonce_b64"], "wrapped_b64": node_bundle["wrapped_b64"], })) - ack = await asyncio.wait_for(q_admin.get(), timeout=5.0) - assert ack["type"] == "ack" + + # The node demands an operator signature instead of storing and adopting it. + reply = await asyncio.wait_for(q_admin.get(), timeout=5.0) + assert reply["type"] == MNP.ADMIN_CHALLENGE + assert reply["op"] == OP_GEK_BUNDLE_STORE await asyncio.sleep(0.2) - assert transport._ctx.get("gek") == new_gek + assert transport._ctx.get("gek") == gek, "group key was seized over MNP (C5b)" + assert await bundle_store.fetch("g", "node-operator") is None await bundle_store.close() await pc_admin.close() |