diff options
Diffstat (limited to 'packages/meshbay-common')
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): """ |