aboutsummaryrefslogtreecommitdiffstats
path: root/packages
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
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')
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py18
-rw-r--r--packages/meshbay-common/src/meshbay_common/handshake.py25
-rw-r--r--packages/meshbay-common/tests/test_handshake.py20
-rw-r--r--packages/meshbay-common/tests/test_js_python_parity.py30
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js36
-rw-r--r--packages/meshbay-hub/tests/test_challenge_signature_client.py100
-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
11 files changed, 345 insertions, 12 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py
index f10302f..1271c4e 100644
--- a/packages/meshbay-common/src/meshbay_common/__init__.py
+++ b/packages/meshbay-common/src/meshbay_common/__init__.py
@@ -220,5 +220,21 @@ __version__ = "0.15.0"
# the node's own `stream_init` and from no version number, so the request is
# never sent to a peer that could not answer it. `MNP_MIN_SUPPORTED` does not
# move.
-MNP_VERSION = "3.3"
+#
+# **3.4 (2026-09-23): the node signs its challenge.**
+#
+# `handshake_challenge` carries `sig`, Ed25519 by the node key over
+# `meshbay:mnp:challenge:v1` ‖ group_id ‖ nonce_c ‖ nonce_s ‖ binding
+# (`handshake.challenge_transcript`). Until now `node_pk` in the challenge was a
+# claim: the ack proves it, but a join is sent *before* the ack, so an
+# invitation code went to whichever peer answered signaling. With the
+# signature, a client that knows which node it means to reach — an invitation
+# link names it — can refuse to send the code anywhere else.
+#
+# **Additive, and MINOR because nothing is required of an older peer.** A client
+# that ignores `sig` behaves exactly as before; a client that reads it refuses a
+# *wrong* one outright and treats an absent one as "this node cannot prove
+# itself early", which it discovers from the node's answer and never from the
+# version number. `MNP_MIN_SUPPORTED` does not move.
+MNP_VERSION = "3.4"
MHP_VERSION = "0.1"
diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py
index 4d2cac2..73e0b2e 100644
--- a/packages/meshbay-common/src/meshbay_common/handshake.py
+++ b/packages/meshbay-common/src/meshbay_common/handshake.py
@@ -12,7 +12,7 @@ The sequence:
client → node handshake {token, group_id, nonce_c, v, v_min}
node check_version() supported range, both ways
node authorize_token() JWT, scope, denylist, membership, hosting
- node → client handshake_challenge {nonce_s, v, v_min}
+ node → client handshake_challenge {nonce_s, v, v_min, node_pk, sig}
client → node handshake_response {proof}
node verify client proof HMAC(GEK, client transcript)
node → client handshake_ack {proof, sig, node_pk, nonce, ct}
@@ -65,6 +65,7 @@ import jwt
from meshbay_common import MNP_VERSION
HANDSHAKE_PREFIX = b"meshbay:mnp:handshake:v1"
+CHALLENGE_PREFIX = b"meshbay:mnp:challenge:v1"
# The oldest peer this build will talk to.
#
@@ -157,6 +158,28 @@ def handshake_transcript(
return bytes(out)
+def challenge_transcript(
+ group_id: str,
+ nonce_client: bytes,
+ nonce_node: bytes,
+ binding: bytes,
+) -> bytes:
+ """
+ Bytes the node signs in `handshake_challenge` (MNP 3.4).
+
+ The ack proves the node key, but a join is sent before the ack — so without
+ this, `node_pk` in the challenge was an announcement anyone answering
+ signaling could make. `nonce_client` makes the signature fresh and `binding`
+ pins it to this connection, so one cannot be recorded and relayed. No role
+ field: it has its own prefix, and nothing else is signed under it.
+ """
+ out = bytearray(CHALLENGE_PREFIX)
+ for field in (group_id.encode(), nonce_client, nonce_node, binding):
+ out += len(field).to_bytes(4, "big")
+ out += field
+ return bytes(out)
+
+
def make_proof(
gek: bytes,
role: str,
diff --git a/packages/meshbay-common/tests/test_handshake.py b/packages/meshbay-common/tests/test_handshake.py
index 11313aa..ad615a9 100644
--- a/packages/meshbay-common/tests/test_handshake.py
+++ b/packages/meshbay-common/tests/test_handshake.py
@@ -13,6 +13,7 @@ import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common.handshake import (
+ CHALLENGE_PREFIX,
HANDSHAKE_PREFIX,
NONCE_LEN,
ROLE_CLIENT,
@@ -20,6 +21,7 @@ from meshbay_common.handshake import (
AuthorizedPeer,
HandshakeError,
authorize_token,
+ challenge_transcript,
handshake_transcript,
make_proof,
quic_binding,
@@ -131,6 +133,24 @@ def test_transcript_is_domain_separated():
).startswith(HANDSHAKE_PREFIX)
+def test_the_challenge_transcript_is_its_own_domain():
+ """
+ MNP 3.4: the node signs the challenge with the same key that signs the ack.
+ The two must never be interchangeable — a challenge signature passed off as
+ an ack signature would authenticate a node that never proved the GEK.
+ """
+ challenge = challenge_transcript(GROUP, NONCE_C, NONCE_S, BINDING)
+ assert challenge.startswith(CHALLENGE_PREFIX)
+ assert challenge != handshake_transcript(ROLE_NODE, GROUP, NONCE_C, NONCE_S, BINDING)
+ for other in (
+ challenge_transcript("other", NONCE_C, NONCE_S, BINDING),
+ challenge_transcript(GROUP, b"x" * 32, NONCE_S, BINDING),
+ challenge_transcript(GROUP, NONCE_C, b"y" * 32, BINDING),
+ challenge_transcript(GROUP, NONCE_C, NONCE_S, BINDING + b"z"),
+ ):
+ assert other != challenge
+
+
def test_client_proof_is_not_a_node_proof():
"""
C3: the node proves itself with the same key over the same connection. Without
diff --git a/packages/meshbay-common/tests/test_js_python_parity.py b/packages/meshbay-common/tests/test_js_python_parity.py
index 3887414..5bf34aa 100644
--- a/packages/meshbay-common/tests/test_js_python_parity.py
+++ b/packages/meshbay-common/tests/test_js_python_parity.py
@@ -22,7 +22,11 @@ from pathlib import Path
import pytest
from meshbay_common.adminop import admin_transcript
-from meshbay_common.handshake import handshake_transcript, webrtc_binding
+from meshbay_common.handshake import (
+ challenge_transcript,
+ handshake_transcript,
+ webrtc_binding,
+)
from meshbay_common.join import join_transcript
CRYPTO_JS = (Path(__file__).resolve().parents[2]
@@ -76,7 +80,7 @@ globalThis.crypto = globalThis.crypto || {};
const src = fs.readFileSync(process.argv[2], 'utf8');
const load = new Function(
- src + '\nreturn { handshakeTranscript, adminTranscript, joinTranscript, '
+ src + '\nreturn { handshakeTranscript, challengeTranscript, adminTranscript, joinTranscript, '
+ 'webrtcBinding, b64encode };');
const M = load();
@@ -89,7 +93,7 @@ const toHex = (u8) =>
Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join('');
const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
-const out = { handshake: [], admin: [], join: [] };
+const out = { handshake: [], challenge: [], admin: [], join: [] };
for (const v of input.handshake) {
const binding = M.webrtcBinding(hex(v.offer_fp), hex(v.answer_fp));
@@ -97,6 +101,12 @@ for (const v of input.handshake) {
v.role, v.group_id, hex(v.nonce_c), hex(v.nonce_s), binding)));
}
+for (const v of input.handshake) {
+ const binding = M.webrtcBinding(hex(v.offer_fp), hex(v.answer_fp));
+ out.challenge.push(toHex(M.challengeTranscript(
+ v.group_id, hex(v.nonce_c), hex(v.nonce_s), binding)));
+}
+
for (const v of input.admin) {
out.admin.push(toHex(M.adminTranscript(
v.op, v.node_pk, v.group_id, v.subject, M.b64encode(hex(v.nonce)), v.ts)));
@@ -167,6 +177,20 @@ def test_handshake_transcript_parity(idx, vector, js_output):
)
+@pytest.mark.parametrize("idx,vector", list(enumerate(HANDSHAKE_VECTORS)))
+def test_challenge_transcript_parity(idx, vector, js_output):
+ """
+ MNP 3.4. A mismatch means every browser refuses every node that signs its
+ challenge — the signature is checked, and a wrong one is a refusal.
+ """
+ _, group_id, nonce_c, nonce_s, offer_fp, answer_fp = vector
+ expected = challenge_transcript(
+ group_id, bytes.fromhex(nonce_c), bytes.fromhex(nonce_s),
+ webrtc_binding(bytes.fromhex(offer_fp), bytes.fromhex(answer_fp)))
+ assert js_output["challenge"][idx] == expected.hex(), (
+ f"crypto.js and meshbay_common.handshake disagree for group={group_id!r}")
+
+
@pytest.mark.parametrize("idx,vector", list(enumerate(ADMIN_VECTORS)))
def test_admin_transcript_parity(idx, vector, js_output):
"""
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
index fd24404..a3680ce 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
@@ -398,6 +398,7 @@ function adminTranscript(op, nodePkB64, groupId, subject, nonceB64, ts) {
// bound in, so a client proof can never be replayed as a node proof and a missing
// fingerprint cannot silently degrade the proof to nonce-only (L4).
const HANDSHAKE_PREFIX = new TextEncoder().encode('meshbay:mnp:handshake:v1');
+const CHALLENGE_PREFIX = new TextEncoder().encode('meshbay:mnp:challenge:v1');
function _lenPrefixed(parts) {
let total = 0;
@@ -432,6 +433,18 @@ function handshakeTranscript(role, groupId, nonceClient, nonceNode, binding) {
return out;
}
+// Mirrors meshbay_common/handshake.py challenge_transcript (MNP 3.4): what the
+// node signs in handshake_challenge, so its key can be checked before a join.
+function challengeTranscript(groupId, nonceClient, nonceNode, binding) {
+ const body = _lenPrefixed([
+ new TextEncoder().encode(groupId), nonceClient, nonceNode, binding,
+ ]);
+ const out = new Uint8Array(CHALLENGE_PREFIX.length + body.length);
+ out.set(CHALLENGE_PREFIX, 0);
+ out.set(body, CHALLENGE_PREFIX.length);
+ return out;
+}
+
async function handshakeProof(gekRaw, role, groupId, nonceClient, nonceNode, binding) {
const transcript = handshakeTranscript(role, groupId, nonceClient, nonceNode, binding);
const key = await crypto.subtle.importKey(
@@ -572,7 +585,7 @@ window.MeshBayCrypto = {
openGroup, sealGroup,
generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode,
adminTranscript, handshakeTranscript, handshakeProof, webrtcBinding,
- joinTranscript, verifyNodeSignature, constantTimeEqual,
+ challengeTranscript, joinTranscript, verifyNodeSignature, constantTimeEqual,
deviceRequestTranscript, deviceAddTranscript, deviceHelloTranscript,
deviceCodeHash,
sealChat, openChat, chatSigningTranscript, verifyChatSignature,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 39e0ccf..c43422f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -883,10 +883,19 @@ class MeshBayTransport {
//
// nonce_node ties a join to this connection, so one cannot be lifted onto
// another. node_pk is announced here because a first-time member has no
- // GEK and so cannot complete the handshake that would prove it; it is
- // unverified at this point and checked against the ack below.
+ // GEK and so cannot complete the handshake that would prove it. From an
+ // older node it is unverified until the ack below checks it.
this._nonceNode = window.MeshBayCrypto.b64decode(reply.nonce);
this.nodePk = reply.node_pk || null;
+ // Since MNP 3.4 the node signs its challenge over this connection, so
+ // node_pk is proved here and not only at the ack — which comes after any
+ // join. A signature that does not verify is a peer lying about which node
+ // it is, and is refused. An absent one is an older node: `nodePkProved`
+ // stays false, and whatever needs the key proved before a code leaves
+ // (an invitation link names its node) reads that — never a version.
+ this.nodePkProved = await _challengeProvesNodeKey(
+ reply, groupId || '', this._nonceClient,
+ this._pc.localDescription.sdp, this._rawAnswerSdp);
// Our identity for THIS node: fetched from it, or created if this is a
// first join. Keys are per node, so there is nothing to carry between
@@ -3972,6 +3981,29 @@ function _hex(bytes) {
return [...bytes].map(b => b.toString(16).padStart(2, '0')).join('');
}
+/**
+ * Whether `handshake_challenge` proves the key it announces (MNP 3.4).
+ *
+ * True when it carries a signature that verifies over this connection, false
+ * when it carries none — an older node, which proves its key only at the ack.
+ * A signature that does not verify is a peer lying about which node it is, and
+ * throws: that is a refusal, not a node that merely cannot say.
+ */
+async function _challengeProvesNodeKey(reply, groupId, nonceClient, offerSdp, answerSdp) {
+ if (!reply.sig) return false;
+ const C = window.MeshBayCrypto;
+ let ok = false;
+ try {
+ ok = Boolean(reply.node_pk) && await C.verifyNodeSignature(
+ reply.node_pk, reply.sig,
+ C.challengeTranscript(groupId, nonceClient, C.b64decode(reply.nonce),
+ C.webrtcBinding(_extractDtlsFingerprint(offerSdp),
+ _extractDtlsFingerprint(answerSdp))));
+ } catch { ok = false; }
+ if (!ok) throw new Error('Node challenge signature invalid — refusing connection');
+ return true;
+}
+
function _extractDtlsFingerprint(sdp) {
const match = sdp.match(/a=fingerprint:sha-256 ([0-9A-Fa-f:]+)/);
if (!match) return new Uint8Array(0);
diff --git a/packages/meshbay-hub/tests/test_challenge_signature_client.py b/packages/meshbay-hub/tests/test_challenge_signature_client.py
new file mode 100644
index 0000000..90aa5f2
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_challenge_signature_client.py
@@ -0,0 +1,100 @@
+"""
+The browser checks the node's challenge signature with the code it ships (MNP 3.4).
+
+The node signs `handshake_challenge` so a client can hold it to the key it
+announces *before* a join, which goes out ahead of the ack. The Python side is
+tested against a real connection in the node suite; this runs the other half —
+the real `_challengeProvesNodeKey` out of `transport.js` over the real
+`crypto.js` — against a challenge Python signed, because the two agreeing on
+paper (the parity test) is not the browser accepting what the node sends.
+"""
+
+import base64
+import json
+import os
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_common.crypto import pk_to_b64
+from meshbay_common.handshake import challenge_transcript, webrtc_binding
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(shutil.which("node") is None,
+ reason="node is not available")
+
+GROUP = "g" * 32
+
+_HARNESS = r"""
+const fs = require('fs');
+globalThis.window = globalThis;
+globalThis.addEventListener = () => {};
+globalThis.removeEventListener = () => {};
+globalThis.location = { hash: '' };
+globalThis.document = {
+ addEventListener() {}, removeEventListener() {}, visibilityState: 'visible',
+};
+const STATIC = process.argv[2];
+new Function(fs.readFileSync(`${STATIC}/crypto.js`, 'utf8'))();
+const src = fs.readFileSync(`${STATIC}/transport.js`, 'utf8');
+const { _challengeProvesNodeKey } =
+ new Function(src + '\nreturn { _challengeProvesNodeKey };')();
+
+const cases = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
+(async () => {
+ const out = [];
+ for (const c of cases) {
+ const nonceC = Uint8Array.from(Buffer.from(c.nonce_c, 'base64'));
+ try {
+ out.push(String(await _challengeProvesNodeKey(
+ c.reply, c.group_id, nonceC, c.offer_sdp, c.answer_sdp)));
+ } catch (e) {
+ out.push('refused');
+ }
+ }
+ process.stdout.write(JSON.stringify(out));
+})();
+"""
+
+
+def _sdp(fp: bytes) -> str:
+ return "v=0\r\na=fingerprint:sha-256 " + ":".join(f"{b:02X}" for b in fp) + "\r\n"
+
+
+def test_the_browser_holds_the_node_to_its_challenge(tmp_path):
+ sk_node, sk_other = Ed25519PrivateKey.generate(), Ed25519PrivateKey.generate()
+ nonce_c, nonce_s = os.urandom(32), os.urandom(32)
+ offer_fp, answer_fp = os.urandom(32), os.urandom(32)
+ sig = sk_node.sign(challenge_transcript(
+ GROUP, nonce_c, nonce_s, webrtc_binding(offer_fp, answer_fp)))
+
+ def case(*, sig=sig, pk=sk_node, group=GROUP, answer=answer_fp, nonce=nonce_c):
+ reply = {"type": "handshake_challenge", "nonce": base64.b64encode(nonce_s).decode(),
+ "node_pk": pk_to_b64(pk.public_key())}
+ if sig is not None:
+ reply["sig"] = base64.b64encode(sig).decode()
+ return {"reply": reply, "group_id": group,
+ "nonce_c": base64.b64encode(nonce).decode(),
+ "offer_sdp": _sdp(offer_fp), "answer_sdp": _sdp(answer)}
+
+ cases = {
+ "signed over this connection": (case(), "true"),
+ "an older node, no signature": (case(sig=None), "false"),
+ "another key announced": (case(pk=sk_other), "refused"),
+ "a relay's fingerprint": (case(answer=os.urandom(32)), "refused"),
+ "a replay under another nonce": (case(nonce=os.urandom(32)), "refused"),
+ "another group": (case(group="other"), "refused"),
+ "garbage for a signature": (case(sig=b"\x00" * 64), "refused"),
+ }
+ harness = tmp_path / "harness.js"
+ harness.write_text(_HARNESS)
+ payload = tmp_path / "cases.json"
+ payload.write_text(json.dumps([c for c, _ in cases.values()]))
+ proc = subprocess.run(["node", str(harness), str(STATIC), str(payload)],
+ capture_output=True, text=True, timeout=60)
+ assert proc.returncode == 0, proc.stderr
+ got = dict(zip(cases, json.loads(proc.stdout)))
+ assert got == {name: want for name, (_, want) in cases.items()}
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