summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
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-hub
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-hub')
-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
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()}