diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-13 11:46:12 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-13 11:46:12 +0200 |
| commit | e13659f8f3166b5a9a4155314941bc149fec2721 (patch) | |
| tree | 53e6851a9f0130c3427adec333aebfc4c8e9d43d /packages/meshbay-hub | |
| parent | b86be704df752f2fd3086fcca43b7f4de78389d1 (diff) | |
| download | meshbay-e13659f8f3166b5a9a4155314941bc149fec2721.tar.gz | |
feat(mnp): unified handshake with mutual authentication
Phase 11.5.4/5/7/8 — findings C6 (WebRTC half), C3, L4, M1, M9.
New meshbay_common/handshake.py is the single implementation of authorization
and proof: JWT verify, scope, denylist, mandatory group_id, membership, hosting.
The handshake previously existed three times over and only the newest copy
enforced the GEK proof.
C3 — mutual authentication. Authentication ran one way: the client proved
itself, the node proved nothing. handshake_ack.node_pk was never verified
against anything and per-chunk signatures had been dropped in Phase 9.15, so a
peer that had hijacked signaling (C2) or been substituted by the hub could
accept the client's proof, ignore it, and serve a forged index, forged chat
history and a forged is_node_admin flag. The client now sends a nonce; the node
answers with its own GEK proof over that nonce AND an Ed25519 signature over
the transcript; the browser verifies both and refuses otherwise. It also
refuses an unchallenged handshake_ack, which previously let a peer skip proving
anything at all.
L4 — the proof was nonce ‖ offer_fp ‖ answer_fp: bare concatenation, and a
missing fingerprint silently degraded it to nonce-only, dropping MitM detection
(NS5). Every field is now length-prefixed and domain-separated, the role is
bound so a client proof cannot be replayed as a node proof, and an absent
channel binding is refused rather than tolerated.
M1 — group_id was optional; omitting it skipped the membership check entirely
and fell back to the node's first group. Now mandatory.
M9 — node-scoped daemon tokens are refused on the client path.
NOT DONE: quic_server.py still runs its own JWT-only handshake, so C6 remains
open — a forged or stolen token reaches a node over QUIC and can inject chat
without holding the GEK. quic_binding() is written and unit-tested but unwired.
11.5.6 (whether the certificate-hash anchor works with aioquic, or an RFC 5705
exporter is reachable) is unproven. 11.5.8 TOFU pinning of pk_node is not done:
the client verifies the node's signature but does not yet remember which key it
saw last.
Adds packages/meshbay-common/tests/test_handshake.py (18 tests) covering the
properties every transport must inherit. WebRTC test helpers rewritten around
the shared module; _make_jwt now defaults to the test group, since group_id is
mandatory.
Tests: 24 webrtc, 176+ node+common.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/crypto.js | 66 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 54 |
2 files changed, 99 insertions, 21 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js index 2346fa2..d18eeae 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js @@ -268,22 +268,70 @@ function adminTranscript(op, nodePkB64, groupId, subject, nonceB64, ts) { // ── GEK proof (HMAC-SHA256 for handshake challenge) ───────────────────────── -async function hmacGEK(gekRaw, nonceB64, offerFp, answerFp) { - const nonce = b64decode(nonceB64); - const data = concatBuffers([ - nonce, - offerFp || new Uint8Array(0), - answerFp || new Uint8Array(0), +// Mirrors meshbay_common/handshake.py. Every field length-prefixed and the role +// 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'); + +function _lenPrefixed(parts) { + let total = 0; + for (const p of parts) total += 4 + p.length; + const out = new Uint8Array(total); + const view = new DataView(out.buffer); + let off = 0; + for (const p of parts) { + view.setUint32(off, p.length, false); + off += 4; + out.set(p, off); + off += p.length; + } + return out; +} + +function webrtcBinding(offerFp, answerFp) { + if (!offerFp || !offerFp.length || !answerFp || !answerFp.length) { + throw new Error('Channel binding unavailable — refusing to handshake'); + } + return _lenPrefixed([offerFp, answerFp]); +} + +function handshakeTranscript(role, groupId, nonceClient, nonceNode, binding) { + const enc = new TextEncoder(); + const body = _lenPrefixed([ + enc.encode(role), enc.encode(groupId), nonceClient, nonceNode, binding, ]); + const out = new Uint8Array(HANDSHAKE_PREFIX.length + body.length); + out.set(HANDSHAKE_PREFIX, 0); + out.set(body, HANDSHAKE_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( 'raw', gekRaw, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); - const sig = await crypto.subtle.sign('HMAC', key, data); - return b64encode(new Uint8Array(sig)); + const sig = await crypto.subtle.sign('HMAC', key, transcript); + return new Uint8Array(sig); +} + +function constantTimeEqual(a, b) { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff === 0; +} + +/** Verify the node's Ed25519 signature over the handshake transcript (C3). */ +async function verifyNodeSignature(nodePkB64, sigB64, transcript) { + const raw = b64decode(nodePkB64); + const key = await crypto.subtle.importKey('raw', raw, { name: 'Ed25519' }, false, ['verify']); + return crypto.subtle.verify('Ed25519', key, b64decode(sigB64), transcript); } // Export for use in app.js window.MeshBayCrypto = { importGEK, deriveChunkKey, decryptChunk, decryptChunkBin, decryptFile, generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode, - hmacGEK, adminTranscript, + adminTranscript, handshakeTranscript, handshakeProof, webrtcBinding, + verifyNodeSignature, constantTimeEqual, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index d636085..18faea1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -129,11 +129,16 @@ class MeshBayTransport { await channelReady; + // The client nonce is what makes the NODE's proof fresh (C3) — without it a + // recorded handshake_ack could be replayed by an impersonating peer. + this._nonceClient = crypto.getRandomValues(new Uint8Array(32)); + const reply = await this._sendAndWait({ type: 'handshake', v: '0.1', token: jwtToken, group_id: groupId || '', + nonce: window.MeshBayCrypto.b64encode(this._nonceClient), }); if (reply.type === 'handshake_challenge') { @@ -190,28 +195,53 @@ class MeshBayTransport { throw new Error('Node requires GEK proof but no GEK available'); } - let proof = ''; - if (gekRaw) { - const offerFp = _extractDtlsFingerprint(this._pc.localDescription.sdp); - const answerFp = _extractDtlsFingerprint(this._rawAnswerSdp); - proof = await window.MeshBayCrypto.hmacGEK(gekRaw, reply.nonce, offerFp, answerFp); - } + const C = window.MeshBayCrypto; + // Node's answer SDP carries ITS fingerprint; our offer carries ours. Throws + // if either is missing rather than proceeding with an unbound proof (L4). + const binding = C.webrtcBinding( + _extractDtlsFingerprint(this._pc.localDescription.sdp), + _extractDtlsFingerprint(this._rawAnswerSdp), + ); + const nonceNode = C.b64decode(reply.nonce); + const gid = groupId || ''; + + const proof = await C.handshakeProof( + gekRaw, 'client', gid, this._nonceClient, nonceNode, binding); + const ack = await this._sendAndWait({ type: 'handshake_response', v: '0.1', - proof, + proof: C.b64encode(proof), }); if (ack.type !== 'handshake_ack') { throw new Error('GEK proof rejected: ' + (ack.detail || JSON.stringify(ack))); } - return ack; - } - if (reply.type !== 'handshake_ack') { - throw new Error('MNP handshake rejected: ' + (reply.detail || JSON.stringify(reply))); + // Authenticate the NODE before trusting anything it says (C3). Until this + // ran, node_pk was decorative: a peer that had hijacked signaling could + // accept our proof, ignore it, and serve a forged index, chat history and + // is_node_admin flag. + const expected = await C.handshakeProof( + gekRaw, 'node', gid, this._nonceClient, nonceNode, binding); + if (!ack.proof || !C.constantTimeEqual(C.b64decode(ack.proof), expected)) { + throw new Error('Node failed to prove GEK possession — refusing connection'); + } + const transcript = C.handshakeTranscript( + 'node', gid, this._nonceClient, nonceNode, binding); + if (!ack.node_pk || !ack.sig + || !await C.verifyNodeSignature(ack.node_pk, ack.sig, transcript)) { + throw new Error('Node signature invalid — refusing connection'); + } + this.nodePk = ack.node_pk; + + return ack; } - return reply; + // A node that answers a handshake with anything other than a challenge is not + // running the mutual protocol. Accepting a bare handshake_ack here would let a + // peer skip proving GEK possession entirely (C3/C6). + throw new Error( + 'MNP handshake rejected: ' + (reply.detail || `unexpected ${reply.type}`)); } async fetchIndex() { |