summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
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
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')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js66
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js54
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() {