diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-23 17:14:26 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-23 17:14:26 +0200 |
| commit | 339cb427f886a0177014126bb684335837eff067 (patch) | |
| tree | 5f79dc0df617be66287a06fc4f0c5dcc61ceb167 /packages/meshbay-hub | |
| parent | cd85808c13926c89a97987d320ac26391eae3267 (diff) | |
| download | meshbay-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-hub')
3 files changed, 148 insertions, 3 deletions
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()} |