From 5330896c0e8023053d4cd15961b2ae0482686ca6 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 28 Sep 2026 15:48:04 +0200 Subject: 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 --- .../src/meshbay_hub/static/transport.js | 41 +++++++++------------- .../tests/test_challenge_signature_client.py | 2 +- .../meshbay-hub/tests/test_invite_link_client.py | 11 +++--- 3 files changed, 22 insertions(+), 32 deletions(-) (limited to 'packages/meshbay-hub') 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 ───────────────────────────────────────────────────── -- cgit v1.2.3