aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_client.py12
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_server.py21
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py20
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py60
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