summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-23 17:14:26 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-23 17:14:26 +0200
commit339cb427f886a0177014126bb684335837eff067 (patch)
tree5f79dc0df617be66287a06fc4f0c5dcc61ceb167 /packages/meshbay-node/tests
parentcd85808c13926c89a97987d320ac26391eae3267 (diff)
downloadmeshbay-339cb427f886a0177014126bb684335837eff067.tar.gz
feat: the node signs its handshake challenge (MNP 3.4)
node_pk in handshake_challenge is now signed over the channel binding and both nonces, so a client can check the node key before a join rather than only at the ack. Both transports; the browser and the QUIC client refuse a wrong signature and treat an absent one as an older node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests')
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py60
1 files changed, 60 insertions, 0 deletions
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