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 | |
| 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>
| -rw-r--r-- | docs/MESHBAY_DESIGN.md | 18 | ||||
| -rw-r--r-- | docs/MESHBAY_NODE_PROTOCOL.md | 49 | ||||
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/__init__.py | 18 | ||||
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/handshake.py | 25 | ||||
| -rw-r--r-- | packages/meshbay-common/tests/test_handshake.py | 20 | ||||
| -rw-r--r-- | packages/meshbay-common/tests/test_js_python_parity.py | 30 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/crypto.js | 15 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 36 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_challenge_signature_client.py | 100 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/quic_client.py | 12 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/quic_server.py | 21 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 20 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_webrtc_transport.py | 60 |
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 |