diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-14 19:35:37 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-14 19:35:37 +0200 |
| commit | c83a4f6ab0c8a83e8679e78427ae60dc29bb2c60 (patch) | |
| tree | dea71c8e115742beaac5952c8c65481bbc130b07 /packages/meshbay-node/tests/test_webrtc_transport.py | |
| parent | ee6573c57f721db8550e34e1c1c79c5922c62a4b (diff) | |
| parent | d324792d68503109ab99616af6c85ee37045e169 (diff) | |
| download | meshbay-c83a4f6ab0c8a83e8679e78427ae60dc29bb2c60.tar.gz | |
merge: Phase 11.5 security remediation, invite redesign, per-node identity
Brings in the security remediation branch. Three bodies of work, and what they
changed about what this project may claim.
Phase 11.5 closed the gap between the documents and the code: the unauthenticated
node HTTP API and the TCP transport deleted, one handshake shared by the
remaining two transports, mutual authentication, structured admin transcripts,
upload confinement, group isolation, revocation that reaches nodes. Six critical
and seven high findings closed, bounded, or deferred by decision.
The invite redesign closed H3 and M3 — the last open High. The hub was the key
directory: an inviter fetched the invitee's key from it and wrapped the group key
for whatever came back, so a hub answering with its own key was handed the group
key by an honest member following the protocol exactly. That lookup is gone. The
node holds the group key and wraps it itself, for a key its recipient proves
possession of, bound to an account by a one-time code the hub never sees. M3 fell
out of the same work: node authority comes from a local roster, never from the
hub.
Per-node identity cut what remains of C4 down to one operator. A single keypair
used to be copied to every node its owner joined; each node now gets its own, so
cracking the bundle on one machine yields a key that is a stranger everywhere
else — and on that machine, one that unlocks nothing its holder did not already
serve. The bundle KDF moved to Argon2id 128 MB, and the hub stopped storing or
publishing user keys at all.
What this project may now say: the hub cannot read your content unless it ships
you malicious client code. T3 remains, accepted (D1), and is what the native
client removes. C4 is reduced, not closed, until 13.3. Chat is still plaintext at
rest until Phase 15. Draft-v5 §2 states each claim against the adversary it holds
against, which is the convention this branch exists to keep.
Four defects were found by deploying it and using a browser, none by the test
suite: a node going deaf on its hub socket, a token that predated group
membership, a client reading values before they were assigned, and identity keys
a browser held but never re-read. The lessons are recorded in CLAUDE.md.
Tests: 343 across the three packages, plus QE/deploy/e2e.py — register, pair,
invite, join, download, stream, second browser, revoke — run against the live
deployment on a wiped hub and node.
Diffstat (limited to 'packages/meshbay-node/tests/test_webrtc_transport.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_webrtc_transport.py | 368 |
1 files changed, 263 insertions, 105 deletions
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py index 693a68b..93cd3fd 100644 --- a/packages/meshbay-node/tests/test_webrtc_transport.py +++ b/packages/meshbay-node/tests/test_webrtc_transport.py @@ -19,7 +19,9 @@ import jwt import msgpack import pytest from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, Ed25519PublicKey, +) from aiortc import RTCPeerConnection, RTCSessionDescription from meshbay_common import MNP_VERSION @@ -29,10 +31,24 @@ from meshbay_common.crypto import ( wrap_gek, wrap_gek_aes, unwrap_gek, + unwrap_gek_aes, ) from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes from meshbay_common.protocol import MNP +TEST_GROUP = "g" + +from meshbay_common.handshake import ( + NONCE_LEN, ROLE_CLIENT, ROLE_NODE, handshake_transcript, + make_proof, verify_proof, webrtc_binding, +) +from meshbay_common.adminop import ( + OP_FILE_DELETE, + OP_INVITE_CREATE, + admin_transcript, +) +from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR, join_transcript from meshbay_node.bundle_store import BundleStore +from meshbay_node.roster import Roster from meshbay_node.indexer import DirectoryIndexer from meshbay_node.transport.webrtc_server import WebRTCTransport @@ -72,6 +88,9 @@ def _hub_pk_pem(sk_hub): def _make_jwt(sk_hub, groups=None, pk_user="test"): + # group_id is mandatory now (M1), so the default token must be a member + # of the group the tests connect to. Tests that exercise refusal pass + # groups=[...] explicitly. sk_pem = sk_hub.private_bytes( serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, @@ -82,10 +101,25 @@ def _make_jwt(sk_hub, groups=None, pk_user="test"): "iss": "test-hub", "sub": "user-001", "pk_user": pk_user, "hub_id": "test-hub", "jti": "test-jti-webrtc", "iat": now, "exp": now + 3600, - "groups": groups or [], + "groups": groups if groups is not None else [TEST_GROUP], }, 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 @@ -103,36 +137,80 @@ def _extract_dtls_fp(sdp: str) -> bytes: return b"" -async def _handshake_with_gek_proof(channel, received, sk_hub, gek, groups=None, - browser_pc=None): - """Send handshake, handle GEK challenge, return handshake_ack.""" - token = _make_jwt(sk_hub, groups=groups) +async def _do_mnp_handshake(channel, received, token, gek, pc, group_id): + """ + Client half of the unified handshake (11.5.4): client nonce, length-prefixed + role-bound transcript, and verification of the node's own proof + signature. + """ + nonce_c = os.urandom(NONCE_LEN) channel.send(_pack({ - "type": MNP.HANDSHAKE, - "v": MNP_VERSION, - "token": token, + "type": MNP.HANDSHAKE, "v": MNP_VERSION, + "token": token, "group_id": group_id, + "nonce": base64.b64encode(nonce_c).decode(), })) msg = await asyncio.wait_for(received.get(), timeout=5.0) - if msg["type"] == MNP.HANDSHAKE_CHALLENGE: - nonce = base64.b64decode(msg["nonce"]) - offer_fp = b"" - answer_fp = b"" - if browser_pc: - offer_fp = _extract_dtls_fp(browser_pc.localDescription.sdp) - answer_fp = _extract_dtls_fp(browser_pc.remoteDescription.sdp) - proof = hmac.new(gek, nonce + offer_fp + answer_fp, hashlib.sha256).digest() - channel.send(_pack({ - "type": MNP.HANDSHAKE_RESPONSE, - "v": MNP_VERSION, - "proof": base64.b64encode(proof).decode(), - })) - msg = await asyncio.wait_for(received.get(), timeout=5.0) + if msg["type"] != MNP.HANDSHAKE_CHALLENGE: + return msg + + nonce_s = base64.b64decode(msg["nonce"]) + binding = webrtc_binding( + _extract_dtls_fp(pc.localDescription.sdp), + _extract_dtls_fp(pc.remoteDescription.sdp), + ) + proof = make_proof(gek, ROLE_CLIENT, group_id, nonce_c, nonce_s, binding) + channel.send(_pack({ + "type": MNP.HANDSHAKE_RESPONSE, "v": MNP_VERSION, + "proof": base64.b64encode(proof).decode(), + })) + ack = await asyncio.wait_for(received.get(), timeout=5.0) + + if ack.get("type") == MNP.HANDSHAKE_ACK: + # The client must authenticate the node too (C3). + assert verify_proof( + gek, base64.b64decode(ack["proof"]), ROLE_NODE, + group_id, nonce_c, nonce_s, binding), "node proof invalid" + transcript = handshake_transcript( + ROLE_NODE, group_id, nonce_c, nonce_s, binding) + Ed25519PublicKey.from_public_bytes( + base64.b64decode(ack["node_pk"]) + ).verify(base64.b64decode(ack["sig"]), transcript) + return ack + + +async def _handshake_with_gek_proof(channel, received, sk_hub, gek, groups=None, + browser_pc=None, group_id=TEST_GROUP): + """Send handshake, handle GEK challenge, return handshake_ack.""" + token = _make_jwt(sk_hub, groups=groups or [group_id]) + msg = await _do_mnp_handshake( + channel, received, token, gek, browser_pc, group_id) assert msg["type"] == MNP.HANDSHAKE_ACK return msg -async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_user=None): - """Create a peer connection, perform handshake with GEK proof, return (pc, channel, queue).""" +def _token(sk_hub, jwt_sub, peer_id, group_id, pk_user="test"): + """A hub-issued user token, as the browser would present it.""" + sk_h_pem = sk_hub.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + now = int(time.time()) + return jwt.encode({ + "iss": "test-hub", "sub": jwt_sub, + "pk_user": pk_user, "hub_id": "test-hub", + "jti": f"jti-{peer_id}", "iat": now, "exp": now + 3600, + "groups": [group_id], "scope": "user", + }, sk_h_pem, algorithm="EdDSA") + + +async def _open_channel(transport, peer_id): + """ + Signaling only: a live DataChannel with no MNP handshake performed. + + Separate from `_setup_peer` because someone joining a group for the first time + cannot complete the handshake — they have no GEK to prove — and the join has to + happen in that window. + """ pc = RTCPeerConnection() q = asyncio.Queue() buf = bytearray() @@ -161,6 +239,13 @@ async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_us answer_sdp, _ = await transport.handle_offer(pc.localDescription.sdp, peer_id) await pc.setRemoteDescription(RTCSessionDescription(sdp=answer_sdp, type="answer")) await asyncio.wait_for(ready.wait(), timeout=5.0) + return pc, ch, q + + +async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_user=None, + group_id=TEST_GROUP): + """Create a peer connection, perform handshake with GEK proof, return (pc, channel, queue).""" + pc, ch, q = await _open_channel(transport, peer_id) pk_user = "test" if sk_user: @@ -169,31 +254,9 @@ async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_us serialization.Encoding.Raw, serialization.PublicFormat.Raw) ).decode() - sk_h_pem = sk_hub.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.PKCS8, - serialization.NoEncryption(), - ) - now = int(time.time()) - token = jwt.encode({ - "iss": "test-hub", "sub": jwt_sub, - "pk_user": pk_user, "hub_id": "test-hub", - "jti": f"jti-{peer_id}", "iat": now, "exp": now + 3600, - "groups": [], - }, sk_h_pem, algorithm="EdDSA") + token = _token(sk_hub, jwt_sub, peer_id, group_id, pk_user) - ch.send(_pack({"type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token})) - msg = await asyncio.wait_for(q.get(), timeout=5.0) - if msg["type"] == MNP.HANDSHAKE_CHALLENGE: - nonce = base64.b64decode(msg["nonce"]) - offer_fp = _extract_dtls_fp(pc.localDescription.sdp) - answer_fp = _extract_dtls_fp(pc.remoteDescription.sdp) - proof = hmac.new(gek, nonce + offer_fp + answer_fp, hashlib.sha256).digest() - ch.send(_pack({ - "type": MNP.HANDSHAKE_RESPONSE, "v": MNP_VERSION, - "proof": base64.b64encode(proof).decode(), - })) - msg = await asyncio.wait_for(q.get(), timeout=5.0) + msg = await _do_mnp_handshake(ch, q, token, gek, pc, group_id) assert msg["type"] == MNP.HANDSHAKE_ACK return pc, ch, q @@ -676,6 +739,8 @@ async def test_webrtc_wrong_gek_proof_rejected(sk_node, sk_hub, gek, shared_dir) token = _make_jwt(sk_hub) channel.send(_pack({ "type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token, + "group_id": TEST_GROUP, + "nonce": base64.b64encode(os.urandom(NONCE_LEN)).decode(), })) challenge = await asyncio.wait_for(received.get(), timeout=5.0) @@ -734,6 +799,8 @@ async def test_webrtc_dtls_channel_binding_detects_mitm(sk_node, sk_hub, gek, sh token = _make_jwt(sk_hub) channel.send(_pack({ "type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token, + "group_id": TEST_GROUP, + "nonce": base64.b64encode(os.urandom(NONCE_LEN)).decode(), })) challenge = await asyncio.wait_for(received.get(), timeout=5.0) @@ -783,13 +850,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 +899,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 +980,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 +1045,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(), })) @@ -1014,54 +1079,122 @@ def x25519_keypair(): @pytest.mark.asyncio -async def test_gek_bundle_store_and_fetch(sk_node, sk_hub, gek, shared_dir, - tmp_path, x25519_keypair): - """GEK bundle stored on node via DataChannel, then fetched during handshake.""" +async def test_invite_then_join_delivers_the_gek(sk_node, sk_hub, gek, shared_dir, + tmp_path, x25519_keypair): + """ + The whole invite flow over a real DataChannel, end to end. + + The operator asks for a code; the invitee — who has never held the group key + and therefore cannot complete the GEK proof — redeems it in the pre-proof + window and the node wraps the key for the X25519 key they just proved they + hold. At no point is a public key fetched from the hub, which is the point: + that lookup was H3. + """ 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() - bundle_store = BundleStore(db_path=tmp_path / "bundles.db") - await bundle_store.open() + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() transport = WebRTCTransport( sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek, shared_root=shared_dir, index=indexer.index, stun_servers=[], ) - transport._ctx["bundle_store"] = bundle_store + transport._ctx["roster"] = roster + transport._ctx["has_admin_authority"] = True + transport._ctx["groups"] = { + TEST_GROUP: {"gek": gek, "shared_root": shared_dir, "index": indexer.index}, + } + + # A paired operator, as `meshbay-node operator pair` would have left it. + sk_admin = Ed25519PrivateKey.generate() + admin_pk_b64 = pk_to_b64(sk_admin.public_key()) + await roster.pin_identity("user-001", "grenet", admin_pk_b64, "AA==", "code") + await roster.set_member("", "user-001", ROLE_OPERATOR, "active", "local-cli") - # Connect as admin and store a GEK bundle for user-002 pc_admin, ch_admin, q_admin = await _setup_peer( transport, sk_hub, gek, "peer-admin") - sk_x_raw, pk_x_raw = x25519_keypair - bundle = wrap_gek(gek, pk_x_raw) + # 1. The operator asks the node for an invitation code. + ch_admin.send(_pack({ + "type": MNP.INVITE_CREATE, "v": MNP_VERSION, + "user_id": "user-002", "group_id": TEST_GROUP, "username": "bob", + })) + challenge_msg = await asyncio.wait_for(q_admin.get(), timeout=5.0) + assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE + assert challenge_msg["op"] == OP_INVITE_CREATE + assert challenge_msg["subject"] == "user-002" ch_admin.send(_pack({ - "type": MNP.GEK_BUNDLE_STORE, - "v": MNP_VERSION, - "user_id": "user-002", - "group_id": "g", - "pk_eph_b64": bundle["pk_eph_b64"], - "nonce_b64": bundle["nonce_b64"], - "wrapped_b64": bundle["wrapped_b64"], + "type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION, + "op_id": challenge_msg["op_id"], + "signature": base64.b64encode( + sk_admin.sign(_transcript_from(challenge_msg))).decode(), })) - ack = await asyncio.wait_for(q_admin.get(), timeout=5.0) - assert ack["type"] == "ack" - assert ack["detail"] == "gek_bundle_stored" + invite = await asyncio.wait_for(q_admin.get(), timeout=5.0) + assert invite["type"] == MNP.INVITE_RESULT + code = invite["code"] + assert code and len(code) == 9 # XXXX-XXXX - # Verify bundle was persisted - stored = await bundle_store.fetch("g", "user-002") - assert stored is not None - assert stored["pk_eph_b64"] == bundle["pk_eph_b64"] + # 2. Bob connects. He cannot prove GEK possession — he has never had it — so + # he redeems the code in the pre-proof window instead. + sk_x_raw, pk_x_raw = x25519_keypair + sk_bob_ed = Ed25519PrivateKey.generate() + pc_bob, ch_bob, q_bob = await _open_channel(transport, "peer-bob") - # Unwrap to verify it's correct - recovered = unwrap_gek(stored, sk_x_raw, pk_x_raw) - assert recovered == gek + nonce_c = os.urandom(NONCE_LEN) + ch_bob.send(_pack({ + "type": MNP.HANDSHAKE, "v": MNP_VERSION, + "token": _token(sk_hub, "user-002", "peer-bob", TEST_GROUP), + "group_id": TEST_GROUP, + "nonce": base64.b64encode(nonce_c).decode(), + })) + challenge = await asyncio.wait_for(q_bob.get(), timeout=5.0) + assert challenge["type"] == MNP.HANDSHAKE_CHALLENGE + nonce_s = base64.b64decode(challenge["nonce"]) - await bundle_store.close() + # Bob signs a transcript naming the node, and he cannot complete the handshake + # that would prove its key — he has no GEK yet. So he has to be able to learn + # it from the challenge; taking it from the test's own knowledge of sk_node + # would hide the fact that a real client cannot. + assert challenge["node_pk"] == pk_to_b64(sk_node.public_key()), ( + "the challenge must announce the node key to a first-time joiner") + node_pk_b64 = challenge["node_pk"] + + pk_ed_b64 = pk_to_b64(sk_bob_ed.public_key()) + pk_x_b64 = base64.b64encode(pk_x_raw).decode() + ts = int(time.time()) + transcript = join_transcript( + node_pk_b64=node_pk_b64, + group_id=TEST_GROUP, user_id="user-002", + pk_ed25519_b64=pk_ed_b64, pk_x25519_b64=pk_x_b64, + nonce_node=nonce_s, ts=ts, + ) + ch_bob.send(_pack({ + "type": MNP.JOIN_REQUEST, "v": MNP_VERSION, + "group_id": TEST_GROUP, + "pk_ed25519": pk_ed_b64, "pk_x25519": pk_x_b64, + "code": code, "ts": ts, + "sig": base64.b64encode(sk_bob_ed.sign(transcript)).decode(), + })) + + result = await asyncio.wait_for(q_bob.get(), timeout=5.0) + assert result["type"] == MNP.JOIN_RESULT + assert result["ok"] is True + assert result["gek"] is True + assert result["role"] == ROLE_MEMBER + + # 3. The key really is the group key, and only Bob's secret opens it. + assert unwrap_gek_aes(result, sk_x_raw, pk_x_raw) == gek + + # 4. The code is spent. + assert await roster.consume_invite(code, "user-002") is None + + await roster.close() await pc_admin.close() + await pc_bob.close() await transport.close_all() @@ -1124,8 +1257,10 @@ async def test_gek_bundle_fetch_during_handshake(sk_node, sk_hub, gek, shared_di # Step 1: Send handshake with group_id so _pending_group is set token = _make_jwt(sk_hub, groups=["g"]) + nonce_c = os.urandom(NONCE_LEN) channel.send(_pack({ "type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token, "group_id": "g", + "nonce": base64.b64encode(nonce_c).decode(), })) msg = await asyncio.wait_for(received.get(), timeout=5.0) assert msg["type"] == MNP.HANDSHAKE_CHALLENGE @@ -1140,11 +1275,12 @@ async def test_gek_bundle_fetch_during_handshake(sk_node, sk_hub, gek, shared_di recovered_gek = unwrap_gek(bundle_resp, sk_x_raw, pk_x_raw) assert recovered_gek == gek - nonce = base64.b64decode(msg["nonce"]) - offer_fp = _extract_dtls_fp(browser_pc.localDescription.sdp) - answer_fp = _extract_dtls_fp(browser_pc.remoteDescription.sdp) - proof = hmac.new(recovered_gek, nonce + offer_fp + answer_fp, - hashlib.sha256).digest() + nonce_s = base64.b64decode(msg["nonce"]) + binding = webrtc_binding( + _extract_dtls_fp(browser_pc.localDescription.sdp), + _extract_dtls_fp(browser_pc.remoteDescription.sdp), + ) + proof = make_proof(recovered_gek, ROLE_CLIENT, "g", nonce_c, nonce_s, binding) # Step 4: Complete handshake channel.send(_pack({ @@ -1229,8 +1365,10 @@ async def test_keypair_bundle_store_and_fetch(sk_node, sk_hub, gek, shared_dir, await asyncio.wait_for(ready.wait(), timeout=5.0) token = _make_jwt(sk_hub, groups=["g"]) + nonce_c = os.urandom(NONCE_LEN) channel.send(_pack({ "type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token, "group_id": "g", + "nonce": base64.b64encode(nonce_c).decode(), })) msg = await asyncio.wait_for(received.get(), timeout=5.0) assert msg["type"] == MNP.HANDSHAKE_CHALLENGE @@ -1296,8 +1434,10 @@ async def test_keypair_bundle_fetch_not_found(sk_node, sk_hub, gek, shared_dir, await asyncio.wait_for(ready.wait(), timeout=5.0) token = _make_jwt(sk_hub, groups=["g"]) + nonce_c = os.urandom(NONCE_LEN) channel.send(_pack({ "type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token, "group_id": "g", + "nonce": base64.b64encode(nonce_c).decode(), })) msg = await asyncio.wait_for(received.get(), timeout=5.0) assert msg["type"] == MNP.HANDSHAKE_CHALLENGE @@ -1313,9 +1453,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 +1473,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,14 +1486,18 @@ 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 and offers it to the node. The message that used to carry this no longer + # exists (the node wraps the GEK itself now), so it reaches no handler at all — + # a stronger outcome than the admin challenge this test used to assert. + node_bundle = wrap_gek_aes(attacker_gek, pk_x_raw) ch_admin.send(_pack({ - "type": MNP.GEK_BUNDLE_STORE, + "type": "gek_bundle_store", "v": MNP_VERSION, "user_id": "node-operator", "group_id": "g", @@ -1351,12 +1505,12 @@ 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" - await asyncio.sleep(0.2) + await asyncio.sleep(0.5) + assert q_admin.empty(), "the retired bundle message still gets a response" - 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() @@ -1398,6 +1552,8 @@ async def test_webrtc_no_gek_connection_refused(sk_node, sk_hub, shared_dir): token = _make_jwt(sk_hub) channel.send(_pack({ "type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token, + "group_id": TEST_GROUP, + "nonce": base64.b64encode(os.urandom(NONCE_LEN)).decode(), })) msg = await asyncio.wait_for(received.get(), timeout=5.0) @@ -1457,8 +1613,10 @@ async def test_gek_bundle_fetch_not_found(sk_node, sk_hub, gek, shared_dir, tmp_ await asyncio.wait_for(ready.wait(), timeout=5.0) token = _make_jwt(sk_hub, groups=["g"]) + nonce_c = os.urandom(NONCE_LEN) channel.send(_pack({ "type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token, "group_id": "g", + "nonce": base64.b64encode(nonce_c).decode(), })) msg = await asyncio.wait_for(received.get(), timeout=5.0) assert msg["type"] == MNP.HANDSHAKE_CHALLENGE |