aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-13 11:46:12 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-13 11:46:12 +0200
commite13659f8f3166b5a9a4155314941bc149fec2721 (patch)
tree53e6851a9f0130c3427adec333aebfc4c8e9d43d /packages/meshbay-hub/src/meshbay_hub/static/transport.js
parentb86be704df752f2fd3086fcca43b7f4de78389d1 (diff)
downloadmeshbay-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/src/meshbay_hub/static/transport.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js54
1 files changed, 42 insertions, 12 deletions
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() {