summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common
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-common
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-common')
-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
4 files changed, 88 insertions, 5 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):
"""