aboutsummaryrefslogtreecommitdiffstats
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
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>
-rw-r--r--docs/MESHBAY_DESIGN.md18
-rw-r--r--docs/MESHBAY_NODE_PROTOCOL.md49
-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
-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
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_client.py12
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_server.py21
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py20
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py60
13 files changed, 399 insertions, 25 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index f42fa69..4b99fb4 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -16,7 +16,7 @@
> them — it names the invariant that holds today, not the incident that produced
> it. §13 is the register of those labels.
>
-> Wire versions at the time of writing: **MNP 3.3** (oldest peer accepted 3.0),
+> Wire versions at the time of writing: **MNP 3.4** (oldest peer accepted 3.0),
> **MHP 0.1**, packages **0.15.0**. The normative source for the wire format is
> `MESHBAY_NODE_PROTOCOL.md`; this document states the design the protocol
> serves, not its byte layout.
@@ -858,7 +858,7 @@ implementations of one security check is **C6** waiting to happen.
```
client → node handshake {token, group_id, nonce_c, v, v_min}
node authorize_token() JWT · scope · denylist · group_id · membership · hosting
-node → client handshake_challenge {nonce_s, node_pk}
+node → client handshake_challenge {nonce_s, node_pk, sig} sig: Ed25519 over the challenge (3.4)
── pre-proof window: bundle fetch, join ──
client → node handshake_response {proof}
node verify HMAC(GEK, client transcript)
@@ -876,6 +876,16 @@ client verify HMAC(GEK, node transcript) + Ed25519(node_p
Every field is length-prefixed so the concatenation is unambiguous, and the role
is bound in so a client proof can never be replayed as a node proof (**L4**).
+**Since 3.4 the node signs its challenge**, with the key it announces, over
+`"meshbay:mnp:challenge:v1" ‖ len‖group_id ‖ len‖nonce_c ‖ len‖nonce_s ‖ len‖binding`.
+A join is sent in the pre-proof window, before the ack that proves the node, so
+without it an invitation code went to whichever peer answered signaling. What it
+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 is an older node, discovered from its answer.
+
**Channel binding is mandatory and an absent one is refused** — never degraded to
nonce-only, which would silently drop MitM detection:
@@ -1159,8 +1169,8 @@ it**.
> the branches come back.**
**The floor is not the current version, and MINOR additions are why.** It is
-`MNP_MIN_SUPPORTED` in `handshake.py`, it equals the last MAJOR, and 3.1, 3.2 and
-3.3 have all been added above it without moving it. So a peer can be reachable and
+`MNP_MIN_SUPPORTED` in `handshake.py`, it equals the last MAJOR, and 3.1, 3.2,
+3.3 and 3.4 have all been added above it without moving it. So a peer can be reachable and
still not do something the current version can, and the client has to cope with
that — **by reading the peer's own answer, never by comparing version numbers**.
3.2's audio tracks are the worked example: the node lists them in `stream_init`,
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md
index b2508c1..ed06edd 100644
--- a/docs/MESHBAY_NODE_PROTOCOL.md
+++ b/docs/MESHBAY_NODE_PROTOCOL.md
@@ -1,6 +1,6 @@
# MeshBay Node Protocol (MNP)
-**Wire version:** `3.3` — `meshbay_common/__init__.py` (`MNP_VERSION`)
+**Wire version:** `3.4` — `meshbay_common/__init__.py` (`MNP_VERSION`)
**Oldest peer accepted:** `3.0` — `handshake.py` (`MNP_MIN_SUPPORTED`)
**Normative implementation:** `meshbay-common` (`protocol.py`, `handshake.py`,
`groupbox.py`, `chatbox.py`, `adminop.py`, `join.py`, `device.py`, `crypto.py`,
@@ -385,7 +385,8 @@ fails if a transport skips a step.
| 4. GEK exists for group |
| |
| 5. handshake_challenge |
- | {v, v_min, nonce: b64(nonce_s), node_pk} |
+ | {v, v_min, nonce: b64(nonce_s), node_pk, |
+ | sig: b64(Ed25519(C))} (3.4, section 6.5) |
|<--------------------------------------------------------------|
| |
| 5b. client checks the node's range the same way |
@@ -508,10 +509,37 @@ This is deliberate and safe:
* a first-time joiner needs it *before* the ack — `join_request` signs a transcript
naming this node (§8.2), and someone who has never held the GEK cannot complete the
handshake that would prove the key;
-* it is **unverified at that point**. The ack proves possession and signs the
- transcript; the client refuses if `ack.node_pk` differs from the announced value;
+* the ack proves possession and signs the transcript; the client refuses if
+ `ack.node_pk` differs from the announced value;
* a wrong value only makes the node's own verification fail.
+**Since 3.4 it is also signed, in the challenge itself:**
+
+```
+C = "meshbay:mnp:challenge:v1" || LP(group_id) || LP(nonce_c) || LP(nonce_s) || LP(binding)
+sig = Ed25519(sk_node, C)
+```
+
+`binding` is the transport's channel binding (§6.4), already known when the challenge
+is sent; `nonce_c` makes the signature fresh. So it cannot be recorded and replayed,
+nor relayed through a peer with different fingerprints. The prefix is its own, so it
+is never interchangeable with the ack's signature over `T("node")`.
+
+Why it exists: a join (§8) is sent **in the pre-proof window**, before the ack — so
+before 3.4 an invitation code went to whichever peer answered signaling, and in a
+group with two hosts, to whichever host answered first. With the signature, a client
+that knows which node key to expect can refuse to send a code anywhere else.
+
+What it does not do: it proves that the peer holds *the key it announces*, not that
+this is the key the client wanted. A client with no expectation learns nothing more
+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` is an
+older node, whose key is proved only at the ack. 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.
+
### 6.6 `handshake_ack` fields
Three fields are in clear, and the rest travel **sealed under a group-key-derived
@@ -1899,7 +1927,7 @@ it back (§3.5).
| Type | Dir | Stage | Purpose |
|---|---|---|---|
| `handshake` | C→N | — | open the session; version range, token, group, client nonce |
-| `handshake_challenge` | N→C | — | node nonce, node's version range, announced `node_pk` |
+| `handshake_challenge` | N→C | — | node nonce, node's version range, announced `node_pk`, and since 3.4 `sig` over the challenge transcript |
| `handshake_response` | C→N | — | client's `HMAC(GEK, T("client"))` |
| `handshake_ack` | N→C | — | node proof, node signature, session parameters **sealed** (§11.1a) |
| `keypair_bundle_fetch` / `_resp` | C→N / N→C | pre | the caller's encrypted identity bundle |
@@ -2012,9 +2040,9 @@ message:
## 13. Versioning and compatibility
-MNP versions independently of the package version. Current: **`3.3`**; oldest peer
-accepted: **`3.0`** — 3.1, 3.2 and 3.3 are all additive, so the floor does not move with
-them.
+MNP versions independently of the package version. Current: **`3.4`**; oldest peer
+accepted: **`3.0`** — 3.1, 3.2, 3.3 and 3.4 are all additive, so the floor does not move
+with them. 3.4 adds the challenge signature (section 6.5).
The two numbers are separate on purpose. `MNP_VERSION` says what this build speaks;
`MNP_MIN_SUPPORTED` says what it will talk to, and moving the second is a decision about
@@ -2163,6 +2191,9 @@ walks through the gate meant to stop it.
handshake "meshbay:mnp:handshake:v1" LP(role) LP(group_id) LP(nonce_c) LP(nonce_s) LP(binding)
-> HMAC-SHA256 under the GEK; role in {"client","node"}
+challenge "meshbay:mnp:challenge:v1" LP(group_id) LP(nonce_c) LP(nonce_s) LP(binding)
+ -> Ed25519 by the node key, in handshake_challenge (3.4)
+
admin op "meshbay:admin:v1" LP(op) LP(node_pk) LP(group_id) LP(subject) LP(nonce) LP(ts)
-> Ed25519 by an operator key from the roster
@@ -2212,7 +2243,7 @@ LP(x) = uint32be(len(x)) || x every field, no exceptions
| Constant | Value | Source |
|---|---|---|
-| `MNP_VERSION` | `3.2` | `meshbay_common/__init__.py` |
+| `MNP_VERSION` | `3.4` | `meshbay_common/__init__.py` |
| `MNP_MIN_SUPPORTED` | `3.0` | `handshake.py` |
| `NONCE_LEN` | 32 bytes (both handshake nonces) | `handshake.py` |
| `ADMIN_CHALLENGE_TTL` | 120 s | `adminop.py` |
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):
"""
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()}
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 273f225..8f3fb74 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py
@@ -26,6 +26,7 @@ from meshbay_common.handshake import (
NONCE_LEN,
ROLE_CLIENT,
ROLE_NODE,
+ challenge_transcript,
check_version,
handshake_transcript,
make_proof,
@@ -211,6 +212,17 @@ 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
+
self._proto._send(self._ctrl_stream, {
"type": MNP.HANDSHAKE_RESPONSE,
"v": MNP_VERSION,
diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
index 036574b..96cd752 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
@@ -41,6 +41,7 @@ from meshbay_common.handshake import (
ROLE_NODE,
HandshakeError,
authorize_token,
+ challenge_transcript,
check_version,
handshake_transcript,
make_proof,
@@ -313,11 +314,23 @@ class _MNPServerProtocol(QuicConnectionProtocol):
# Decoded but NOT authenticated: authentication is the GEK proof below.
self._pending = peer
self._gek_challenge = os.urandom(NONCE_LEN)
+ # The same announcement and signature as WebRTC's challenge (MNP 3.4),
+ # so the two transports stay one handshake. The binding is the node's
+ # certificate, which is known here as it is at the proof.
+ sig = {}
+ cert = self._ctx.get("server_cert_der")
+ if cert:
+ transcript = challenge_transcript(
+ peer.group_id, self._nonce_client, self._gek_challenge,
+ quic_binding(cert))
+ sig = {"sig": base64.b64encode(self._ctx["sk_node"].sign(transcript)).decode()}
self._send(stream_id, {
- "type": MNP.HANDSHAKE_CHALLENGE,
- "v": MNP_VERSION,
- "v_min": MNP_MIN_SUPPORTED,
- "nonce": base64.b64encode(self._gek_challenge).decode(),
+ "type": MNP.HANDSHAKE_CHALLENGE,
+ "v": MNP_VERSION,
+ "v_min": MNP_MIN_SUPPORTED,
+ "nonce": base64.b64encode(self._gek_challenge).decode(),
+ "node_pk": pk_to_b64(self._ctx["sk_node"].public_key()),
+ **sig,
})
def _do_handshake_response_sync(self, stream_id: int, msg: dict) -> None:
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index a5e15df..809c5c5 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -99,6 +99,7 @@ from meshbay_common.handshake import (
ROLE_NODE,
HandshakeError,
authorize_token,
+ challenge_transcript,
check_version,
handshake_transcript,
make_proof,
@@ -962,8 +963,27 @@ class WebRTCPeerSession:
# the two match, and a wrong value only makes our own verification
# fail. It is never a substitute for the ack's proof and signature.
"node_pk": self._node_pk_b64(),
+ # ...except that since 3.4 it is signed, so a client that already
+ # knows which key to expect can check it before it sends a code.
+ **self._challenge_sig(peer.group_id, self._channel_binding()),
})
+ def _challenge_sig(self, group_id: str, binding: bytes) -> dict:
+ """
+ `{"sig": ...}` over the challenge transcript, or nothing (MNP 3.4).
+
+ What makes `node_pk` above more than an announcement: a client about to
+ send an invitation code can check this node holds the key it was told
+ to expect, before the code leaves. No binding means no signature rather
+ than an unbound one — a signature that is not tied to the channel is one
+ somebody can relay, and the handshake proof refuses that case anyway.
+ """
+ if not binding:
+ return {}
+ transcript = challenge_transcript(
+ group_id, self._nonce_client, self._gek_challenge, binding)
+ return {"sig": base64.b64encode(self._ctx["sk_node"].sign(transcript)).decode()}
+
def _do_handshake_response(self, msg: dict) -> None:
if not self._gek_challenge or not hasattr(self, "_pending_sub"):
self._send({"type": "error", "detail": "No pending handshake challenge"})
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py
index 91a6e1c..0d2aa3a 100644
--- a/packages/meshbay-node/tests/test_webrtc_transport.py
+++ b/packages/meshbay-node/tests/test_webrtc_transport.py
@@ -20,6 +20,7 @@ import jwt
import msgpack
import pytest
from aiortc import RTCPeerConnection, RTCSessionDescription
+from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
@@ -44,6 +45,7 @@ from meshbay_common.handshake import (
NONCE_LEN,
ROLE_CLIENT,
ROLE_NODE,
+ challenge_transcript,
handshake_transcript,
make_proof,
verify_proof,
@@ -166,6 +168,12 @@ async def _do_mnp_handshake(channel, received, token, gek, pc, group_id):
_extract_dtls_fp(pc.localDescription.sdp),
_extract_dtls_fp(pc.remoteDescription.sdp),
)
+ # MNP 3.4: every challenge in this suite must carry a signature by the key
+ # it announces, over this very connection.
+ Ed25519PublicKey.from_public_bytes(
+ base64.b64decode(msg["node_pk"])
+ ).verify(base64.b64decode(msg["sig"]),
+ challenge_transcript(group_id, nonce_c, nonce_s, binding))
proof = make_proof(gek, ROLE_CLIENT, group_id, nonce_c, nonce_s, binding)
channel.send(_pack({
"type": MNP.HANDSHAKE_RESPONSE, "v": MNP_VERSION,
@@ -909,6 +917,58 @@ async def test_webrtc_dtls_channel_binding_detects_mitm(sk_node, sk_hub, gek, sh
await transport.close_all()
+
+@pytest.mark.asyncio
+async def test_the_challenge_signature_is_bound_to_this_connection(
+ sk_node, sk_hub, gek, shared_dir):
+ """
+ MNP 3.4. The node signs its challenge so a client can check `node_pk` before
+ a join — which goes out before the ack that used to be the only proof. That
+ is only worth anything if the signature cannot be carried elsewhere: under a
+ substituted fingerprint (a relay in the middle), another client nonce (a
+ recording replayed) or another group, it must not verify.
+ """
+ indexer = DirectoryIndexer(roots=one_root(shared_dir), group_id="g", sk_node=sk_node, gek=gek)
+ await indexer.initial_scan()
+ transport = WebRTCTransport(
+ sk_node=sk_node, hub_pk_pem=_hub_pk_pem(sk_hub), gek=gek,
+ roots=one_root(shared_dir), index=indexer.index, stun_servers=[],
+ )
+ pc, ch, q = await _open_channel(transport, "peer-sig")
+ try:
+ nonce_c = os.urandom(NONCE_LEN)
+ ch.send(_pack({
+ "type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": _make_jwt(sk_hub),
+ "group_id": TEST_GROUP, "nonce": base64.b64encode(nonce_c).decode(),
+ }))
+ challenge = await asyncio.wait_for(q.get(), timeout=5.0)
+ assert challenge["type"] == MNP.HANDSHAKE_CHALLENGE
+ assert challenge["node_pk"] == pk_to_b64(sk_node.public_key())
+
+ nonce_s = base64.b64decode(challenge["nonce"])
+ offer_fp = _extract_dtls_fp(pc.localDescription.sdp)
+ answer_fp = _extract_dtls_fp(pc.remoteDescription.sdp)
+ pk = Ed25519PublicKey.from_public_bytes(base64.b64decode(challenge["node_pk"]))
+ sig = base64.b64decode(challenge["sig"])
+
+ pk.verify(sig, challenge_transcript(
+ TEST_GROUP, nonce_c, nonce_s, webrtc_binding(offer_fp, answer_fp)))
+ for label, transcript in (
+ ("once relayed", challenge_transcript(
+ TEST_GROUP, nonce_c, nonce_s, webrtc_binding(offer_fp, os.urandom(32)))),
+ ("once replayed", challenge_transcript(
+ TEST_GROUP, os.urandom(NONCE_LEN), nonce_s,
+ webrtc_binding(offer_fp, answer_fp))),
+ ("for another group", challenge_transcript(
+ "other-group", nonce_c, nonce_s, webrtc_binding(offer_fp, answer_fp))),
+ ):
+ with pytest.raises(InvalidSignature):
+ pk.verify(sig, transcript)
+ pytest.fail(f"the challenge signature verified {label}")
+ finally:
+ await pc.close()
+ await transport.close_all()
+
async def _paired_operator_roster(tmp_path, sk_admin):
"""
A roster holding one operator, which is the only thing that authorizes an