diff options
Diffstat (limited to 'packages/meshbay-node')
4 files changed, 109 insertions, 4 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py index 273f225..8f3fb74 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py @@ -26,6 +26,7 @@ from meshbay_common.handshake import ( NONCE_LEN, ROLE_CLIENT, ROLE_NODE, + challenge_transcript, check_version, handshake_transcript, make_proof, @@ -211,6 +212,17 @@ class QuicChunkClient: "session — refusing to handshake without channel binding") binding = quic_binding(self._peer_cert_der) + # MNP 3.4: a signed challenge proves the node key before we send anything + # else. A wrong signature is refused; an absent one is an older node. + if reply.get("sig"): + try: + Ed25519PublicKey.from_public_bytes( + base64.b64decode(reply.get("node_pk", "")) + ).verify(base64.b64decode(reply["sig"]), challenge_transcript( + self._group_id, nonce_c, nonce_s, binding)) + except Exception as exc: + raise ConnectionError(f"Node challenge signature invalid: {exc}") from exc + self._proto._send(self._ctrl_stream, { "type": MNP.HANDSHAKE_RESPONSE, "v": MNP_VERSION, diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py index 036574b..96cd752 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py @@ -41,6 +41,7 @@ from meshbay_common.handshake import ( ROLE_NODE, HandshakeError, authorize_token, + challenge_transcript, check_version, handshake_transcript, make_proof, @@ -313,11 +314,23 @@ class _MNPServerProtocol(QuicConnectionProtocol): # Decoded but NOT authenticated: authentication is the GEK proof below. self._pending = peer self._gek_challenge = os.urandom(NONCE_LEN) + # The same announcement and signature as WebRTC's challenge (MNP 3.4), + # so the two transports stay one handshake. The binding is the node's + # certificate, which is known here as it is at the proof. + sig = {} + cert = self._ctx.get("server_cert_der") + if cert: + transcript = challenge_transcript( + peer.group_id, self._nonce_client, self._gek_challenge, + quic_binding(cert)) + sig = {"sig": base64.b64encode(self._ctx["sk_node"].sign(transcript)).decode()} self._send(stream_id, { - "type": MNP.HANDSHAKE_CHALLENGE, - "v": MNP_VERSION, - "v_min": MNP_MIN_SUPPORTED, - "nonce": base64.b64encode(self._gek_challenge).decode(), + "type": MNP.HANDSHAKE_CHALLENGE, + "v": MNP_VERSION, + "v_min": MNP_MIN_SUPPORTED, + "nonce": base64.b64encode(self._gek_challenge).decode(), + "node_pk": pk_to_b64(self._ctx["sk_node"].public_key()), + **sig, }) def _do_handshake_response_sync(self, stream_id: int, msg: dict) -> None: diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index a5e15df..809c5c5 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -99,6 +99,7 @@ from meshbay_common.handshake import ( ROLE_NODE, HandshakeError, authorize_token, + challenge_transcript, check_version, handshake_transcript, make_proof, @@ -962,8 +963,27 @@ class WebRTCPeerSession: # the two match, and a wrong value only makes our own verification # fail. It is never a substitute for the ack's proof and signature. "node_pk": self._node_pk_b64(), + # ...except that since 3.4 it is signed, so a client that already + # knows which key to expect can check it before it sends a code. + **self._challenge_sig(peer.group_id, self._channel_binding()), }) + def _challenge_sig(self, group_id: str, binding: bytes) -> dict: + """ + `{"sig": ...}` over the challenge transcript, or nothing (MNP 3.4). + + What makes `node_pk` above more than an announcement: a client about to + send an invitation code can check this node holds the key it was told + to expect, before the code leaves. No binding means no signature rather + than an unbound one — a signature that is not tied to the channel is one + somebody can relay, and the handshake proof refuses that case anyway. + """ + if not binding: + return {} + transcript = challenge_transcript( + group_id, self._nonce_client, self._gek_challenge, binding) + return {"sig": base64.b64encode(self._ctx["sk_node"].sign(transcript)).decode()} + def _do_handshake_response(self, msg: dict) -> None: if not self._gek_challenge or not hasattr(self, "_pending_sub"): self._send({"type": "error", "detail": "No pending handshake challenge"}) diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py index 91a6e1c..0d2aa3a 100644 --- a/packages/meshbay-node/tests/test_webrtc_transport.py +++ b/packages/meshbay-node/tests/test_webrtc_transport.py @@ -20,6 +20,7 @@ import jwt import msgpack import pytest from aiortc import RTCPeerConnection, RTCSessionDescription +from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import ( Ed25519PrivateKey, @@ -44,6 +45,7 @@ from meshbay_common.handshake import ( NONCE_LEN, ROLE_CLIENT, ROLE_NODE, + challenge_transcript, handshake_transcript, make_proof, verify_proof, @@ -166,6 +168,12 @@ async def _do_mnp_handshake(channel, received, token, gek, pc, group_id): _extract_dtls_fp(pc.localDescription.sdp), _extract_dtls_fp(pc.remoteDescription.sdp), ) + # MNP 3.4: every challenge in this suite must carry a signature by the key + # it announces, over this very connection. + Ed25519PublicKey.from_public_bytes( + base64.b64decode(msg["node_pk"]) + ).verify(base64.b64decode(msg["sig"]), + challenge_transcript(group_id, nonce_c, nonce_s, binding)) proof = make_proof(gek, ROLE_CLIENT, group_id, nonce_c, nonce_s, binding) channel.send(_pack({ "type": MNP.HANDSHAKE_RESPONSE, "v": MNP_VERSION, @@ -909,6 +917,58 @@ async def test_webrtc_dtls_channel_binding_detects_mitm(sk_node, sk_hub, gek, sh await transport.close_all() + +@pytest.mark.asyncio +async def test_the_challenge_signature_is_bound_to_this_connection( + sk_node, sk_hub, gek, shared_dir): + """ + MNP 3.4. The node signs its challenge so a client can check `node_pk` before + a join — which goes out before the ack that used to be the only proof. That + is only worth anything if the signature cannot be carried elsewhere: under a + substituted fingerprint (a relay in the middle), another client nonce (a + recording replayed) or another group, it must not verify. + """ + indexer = DirectoryIndexer(roots=one_root(shared_dir), group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=_hub_pk_pem(sk_hub), gek=gek, + roots=one_root(shared_dir), index=indexer.index, stun_servers=[], + ) + pc, ch, q = await _open_channel(transport, "peer-sig") + try: + nonce_c = os.urandom(NONCE_LEN) + ch.send(_pack({ + "type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": _make_jwt(sk_hub), + "group_id": TEST_GROUP, "nonce": base64.b64encode(nonce_c).decode(), + })) + challenge = await asyncio.wait_for(q.get(), timeout=5.0) + assert challenge["type"] == MNP.HANDSHAKE_CHALLENGE + assert challenge["node_pk"] == pk_to_b64(sk_node.public_key()) + + nonce_s = base64.b64decode(challenge["nonce"]) + offer_fp = _extract_dtls_fp(pc.localDescription.sdp) + answer_fp = _extract_dtls_fp(pc.remoteDescription.sdp) + pk = Ed25519PublicKey.from_public_bytes(base64.b64decode(challenge["node_pk"])) + sig = base64.b64decode(challenge["sig"]) + + pk.verify(sig, challenge_transcript( + TEST_GROUP, nonce_c, nonce_s, webrtc_binding(offer_fp, answer_fp))) + for label, transcript in ( + ("once relayed", challenge_transcript( + TEST_GROUP, nonce_c, nonce_s, webrtc_binding(offer_fp, os.urandom(32)))), + ("once replayed", challenge_transcript( + TEST_GROUP, os.urandom(NONCE_LEN), nonce_s, + webrtc_binding(offer_fp, answer_fp))), + ("for another group", challenge_transcript( + "other-group", nonce_c, nonce_s, webrtc_binding(offer_fp, answer_fp))), + ): + with pytest.raises(InvalidSignature): + pk.verify(sig, transcript) + pytest.fail(f"the challenge signature verified {label}") + finally: + await pc.close() + await transport.close_all() + async def _paired_operator_roster(tmp_path, sk_admin): """ A roster holding one operator, which is the only thing that authorizes an |