aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-28 15:48:04 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-28 15:48:04 +0200
commit5330896c0e8023053d4cd15961b2ae0482686ca6 (patch)
treee20b35069d4a7a87b1271e9063adb6a5c8e1b157
parent0584daa4b77048712c42fa113e77efdd31fc0336 (diff)
downloadmeshbay-5330896c0e8023053d4cd15961b2ae0482686ca6.tar.gz
fix: refuse an unsigned handshake challenge
Every node the 4.0 floor admits signs its challenge, and one without a channel binding could not complete the proof anyway, so a missing signature is refused like a wrong one (browser and QUIC client). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--docs/MESHBAY_DESIGN.md6
-rw-r--r--docs/MESHBAY_NODE_PROTOCOL.md13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js41
-rw-r--r--packages/meshbay-hub/tests/test_challenge_signature_client.py2
-rw-r--r--packages/meshbay-hub/tests/test_invite_link_client.py11
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_client.py23
6 files changed, 44 insertions, 52 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 170081f..74c1050 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -959,9 +959,9 @@ buys, per the convention at the top: a client that knows which node it means to
reach can refuse to send a code anywhere else — against a hijacked signaling path
and against a second host of the same group. It proves *a* key, not the *right*
one: it helps only a client that already knows which key to expect. A wrong
-signature is refused; an absent one leaves the key unproved until the ack, and a
-client holding a link code then does not send it. With the floor at 4.0 every
-reachable node signs, so that branch is one only a lowered floor could reach again.
+signature is refused, and so is an absent one: every node the floor admits signs
+whenever it has a channel binding, and one without a binding could not complete the
+proof anyway.
**Channel binding is mandatory and an absent one is refused** — never degraded to
nonce-only, which would silently drop MitM detection:
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md
index 34e2464..642e966 100644
--- a/docs/MESHBAY_NODE_PROTOCOL.md
+++ b/docs/MESHBAY_NODE_PROTOCOL.md
@@ -549,13 +549,12 @@ this is the key the client wanted. A client with no expectation learns nothing m
than before, and the ack remains the proof of GEK possession.
Client rule, from the node's answer and never from its version (§13): a `sig` that
-does not verify is a refusal (`Node challenge signature invalid`); no `sig` leaves the
-key unproved until the ack, and a client holding an invitation-link code then refuses
-to send it. A node signs whenever it has a binding, and a node with none sends no
-signature rather than an unbound one — the proof would be refused on that connection
-anyway. With the floor at 4.0 every peer a client can reach signs, so an absent `sig`
-no longer means an older node: the client's handling of it is a branch only a lowered
-floor could make reachable again.
+does not verify is a refusal (`Node challenge signature invalid`), and so is a
+challenge with no `sig` at all. A node signs whenever it has a binding, and a node
+with none sends no signature rather than an unbound one — its proof would be refused
+on that connection anyway, so refusing the unsigned challenge only says so earlier.
+With the floor at 4.0 every peer a client can reach signs, and there is no "older
+node" case to tolerate.
### 6.6 `handshake_ack` fields
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 546d01b..97b2293 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -959,16 +959,14 @@ 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. From an
- // older node it is unverified until the ack below checks it.
+ // The signature checked next is what proves it here, before the ack.
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(
+ // 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. Every node
+ // this client can reach signs (the floor is 4.0, and signing is 3.4), so
+ // a missing signature is refused exactly like a wrong one.
+ await _challengeProvesNodeKey(
reply, groupId || '', this._nonceClient,
this._pc.localDescription.sdp, this._rawAnswerSdp);
@@ -1064,8 +1062,7 @@ class MeshBayTransport {
// (docs/MESHBAY_DESIGN.md §3.4). Otherwise nothing is sent at all — not
// even a join without the code, which this node would answer by asking
// for one.
- const linkRefusal = _linkJoinRefusal(joinNodePk, joinCode, this.nodePk,
- this.nodePkProved);
+ const linkRefusal = _linkJoinRefusal(joinNodePk, joinCode, this.nodePk);
if (linkRefusal) {
this._joinError = linkRefusal;
} else if (!gekRaw && this._sessionKeys && userId) {
@@ -2198,35 +2195,29 @@ class MeshBayTransport {
* Why a code from an invitation link must not go to this node, or null.
*
* `link_other_node` is the caller's cue to try the next node the hub listed,
- * as for `not_hosted`: the link names one node, and this is not it. An older
- * node that cannot prove its key early is refused rather than trusted — it
- * cannot have issued a link code anyway.
+ * as for `not_hosted`: the link names one node, and this is not it. `nodePk`
+ * has already been proved by the challenge signature, which is required.
*/
-function _linkJoinRefusal(joinNodePk, joinCode, nodePk, nodePkProved) {
+function _linkJoinRefusal(joinNodePk, joinCode, nodePk) {
if (!joinNodePk || !joinCode) return null;
if (nodePk !== joinNodePk) {
const err = new Error('This invitation was issued by another machine hosting this group.');
err.reason = 'link_other_node';
return err;
}
- if (!nodePkProved) {
- const err = new Error('This node is too old to accept invitation links.');
- err.reason = 'link_node_unproved';
- return err;
- }
return null;
}
/**
- * Whether `handshake_challenge` proves the key it announces (MNP 3.4).
+ * Check that `handshake_challenge` proves the key it announces; throw if not.
*
- * 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.
+ * A node signs whenever it has a channel binding, and one without a binding
+ * could not complete the handshake anyway (its proof is refused), so a missing
+ * signature is refused like a wrong one: both are a peer that cannot show it is
+ * the node it names. There is no "older node" case — the floor is 4.0.
*/
async function _challengeProvesNodeKey(reply, groupId, nonceClient, offerSdp, answerSdp) {
- if (!reply.sig) return false;
+ if (!reply.sig) throw new Error('Node challenge is not signed — refusing connection');
const C = window.MeshBayCrypto;
let ok = false;
try {
diff --git a/packages/meshbay-hub/tests/test_challenge_signature_client.py b/packages/meshbay-hub/tests/test_challenge_signature_client.py
index b904698..c13c55d 100644
--- a/packages/meshbay-hub/tests/test_challenge_signature_client.py
+++ b/packages/meshbay-hub/tests/test_challenge_signature_client.py
@@ -85,7 +85,7 @@ def test_the_browser_holds_the_node_to_its_challenge(tmp_path):
cases = {
"signed over this connection": (case(), "true"),
- "an older node, no signature": (case(sig=None), "false"),
+ "no signature": (case(sig=None), "refused"),
"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"),
diff --git a/packages/meshbay-hub/tests/test_invite_link_client.py b/packages/meshbay-hub/tests/test_invite_link_client.py
index aa8c194..3fb8922 100644
--- a/packages/meshbay-hub/tests/test_invite_link_client.py
+++ b/packages/meshbay-hub/tests/test_invite_link_client.py
@@ -151,14 +151,13 @@ def test_a_link_code_goes_to_the_node_the_link_names_and_no_other(tmp_path):
got = _run(tmp_path, fn.group(0) + """
const r = (...a) => { const e = _linkJoinRefusal(...a); return e ? e.reason : null; };
process.stdout.write(JSON.stringify([
- r('KEY', 'K7P2-9WQX', 'KEY', true),
- r('KEY', 'K7P2-9WQX', 'OTHER', true),
- r('KEY', 'K7P2-9WQX', 'KEY', false),
- r(undefined, 'K7P2-9WQX', 'OTHER', false),
- r('KEY', null, 'OTHER', false),
+ r('KEY', 'K7P2-9WQX', 'KEY'),
+ r('KEY', 'K7P2-9WQX', 'OTHER'),
+ r(undefined, 'K7P2-9WQX', 'OTHER'),
+ r('KEY', null, 'OTHER'),
]));
""")
- assert got == [None, "link_other_node", "link_node_unproved", None, None]
+ assert got == [None, "link_other_node", None, None]
# ── Read from the source ─────────────────────────────────────────────────────
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 8f3fb74..d548103 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py
@@ -212,16 +212,19 @@ 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
+ # A signed challenge proves the node key before we send anything else.
+ # Every node we can reach signs (the floor is 4.0, signing is 3.4), and
+ # one without a certificate to bind could not complete the proof anyway,
+ # so a missing signature is refused like a wrong one.
+ if not reply.get("sig"):
+ raise ConnectionError("Node challenge is not signed")
+ 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,