aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_webrtc_transport.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/tests/test_webrtc_transport.py')
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py178
1 files changed, 120 insertions, 58 deletions
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py
index 59b48ac..07bdbea 100644
--- a/packages/meshbay-node/tests/test_webrtc_transport.py
+++ b/packages/meshbay-node/tests/test_webrtc_transport.py
@@ -31,6 +31,7 @@ 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
@@ -42,10 +43,12 @@ from meshbay_common.handshake import (
)
from meshbay_common.adminop import (
OP_FILE_DELETE,
- OP_GEK_BUNDLE_STORE,
+ 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
@@ -184,9 +187,30 @@ async def _handshake_with_gek_proof(channel, received, sk_hub, gek, groups=None,
return msg
-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)."""
+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()
@@ -215,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:
@@ -223,18 +254,7 @@ 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": [group_id], "scope": "user",
- }, sk_h_pem, algorithm="EdDSA")
+ token = _token(sk_hub, jwt_sub, peer_id, group_id, pk_user)
msg = await _do_mnp_handshake(ch, q, token, gek, pc, group_id)
assert msg["type"] == MNP.HANDSHAKE_ACK
@@ -1059,71 +1079,114 @@ 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},
+ }
- # Storing a bundle is a node-operator operation (C5b): the node challenges and
- # only the pinned admin key is accepted.
+ # A paired operator, as `meshbay-node operator pair` would have left it.
sk_admin = Ed25519PrivateKey.generate()
- transport._ctx["admin_pk_ed25519"] = sk_admin.public_key()
+ 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")
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.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.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_GEK_BUNDLE_STORE
+ assert challenge_msg["op"] == OP_INVITE_CREATE
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(),
+ "signature": base64.b64encode(
+ sk_admin.sign(_transcript_from(challenge_msg))).decode(),
}))
+ 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
- ack = await asyncio.wait_for(q_admin.get(), timeout=5.0)
- assert ack["type"] == "ack"
- assert ack["detail"] == "gek_bundle_stored"
+ # 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")
- # 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"]
+ 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"])
- # Unwrap to verify it's correct
- recovered = unwrap_gek(stored, sk_x_raw, pk_x_raw)
- assert recovered == gek
+ 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=pk_to_b64(sk_node.public_key()),
+ 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(),
+ }))
- await bundle_store.close()
+ 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()
@@ -1420,10 +1483,13 @@ async def test_gek_not_auto_activated_on_bundle_store(sk_node, sk_hub, gek, shar
pc_admin, ch_admin, q_admin = await _setup_peer(
transport, sk_hub, gek, "peer-setup-admin")
- # An ordinary member wraps a key of their choosing for the operator's public key.
+ # 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",
@@ -1432,12 +1498,8 @@ async def test_gek_not_auto_activated_on_bundle_store(sk_node, sk_hub, gek, shar
"wrapped_b64": node_bundle["wrapped_b64"],
}))
- # 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)
+ await asyncio.sleep(0.5)
+ assert q_admin.empty(), "the retired bundle message still gets a response"
assert transport._ctx.get("gek") == gek, "group key was seized over MNP (C5b)"
assert await bundle_store.fetch("g", "node-operator") is None