summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js411
1 files changed, 365 insertions, 46 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index ca9c60e..0a8796e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -26,6 +26,31 @@ async function _pkFromSk(skPkcs8B64) {
return pad ? b64 + '='.repeat(4 - pad) : b64;
}
+async function _pkEdFromSk(skPkcs8B64) {
+ const raw = Uint8Array.from(atob(skPkcs8B64), c => c.charCodeAt(0));
+ const sk = await crypto.subtle.importKey('pkcs8', raw, { name: 'Ed25519' }, true, ['sign']);
+ const jwk = await crypto.subtle.exportKey('jwk', sk);
+ const b64 = jwk.x.replace(/-/g, '+').replace(/_/g, '/');
+ const pad = b64.length % 4;
+ return pad ? b64 + '='.repeat(4 - pad) : b64;
+}
+
+const JOIN_REFUSALS = {
+ code_required: 'This node does not know this browser yet. Ask the node operator '
+ + 'for a pairing code (meshbay-node operator pair).',
+ code_invalid: 'That pairing code is not valid — it may be mistyped, expired, '
+ + 'already used, or issued for a different account.',
+ key_changed: 'This account is already paired with a different key on this node. '
+ + 'If you reset your keys, the operator must unpin you before pairing again.',
+ not_authorized_for_group: 'The node does not list you as a member of this group. '
+ + 'Being a member on the hub is not enough — ask the operator for an invite.',
+ no_gek: 'This group has no key yet. The node operator must run '
+ + '`meshbay-node gek-init` for it.',
+ signature_invalid: 'The node rejected the signature over your keys.',
+ stale_request: 'Your clock is too far from the node\'s — check the system time.',
+ group_mismatch: 'The node refused a request naming a different group.',
+};
+
class MeshBayTransport {
constructor(hubUrl, accessToken) {
this._hubUrl = hubUrl;
@@ -53,11 +78,19 @@ class MeshBayTransport {
get sessionKeys() { return this._sessionKeys; }
- async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username) {
+ /** Set on a first join: the identity created for this node, still to be left with it. */
+ get newNodeBundle() { return this._newNodeBundle || null; }
+ set newNodeBundle(v) { this._newNodeBundle = v; }
+
+ async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username,
+ userId, joinCode) {
this._gekRaw = gekRaw || null;
this._sessionKeys = sessionKeys || null;
this._bundleKey = bundleKey || null;
this._username = username || null;
+ this._userId = userId || null;
+ this._newNodeBundle = null;
+ this._joinError = null;
this._pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
@@ -129,11 +162,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') {
@@ -141,7 +179,23 @@ class MeshBayTransport {
throw new Error('Node requires GEK proof but no crypto available');
}
- // Recover session keys from node if not available locally (P2P keypair bundle)
+ // Recorded the moment the challenge arrives, because everything below may
+ // need them — joining, in particular, happens before the proof and signs a
+ // transcript over both. Reading them further down, next to the proof that
+ // also uses them, meant join_request ran with neither.
+ //
+ // 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.
+ this._nonceNode = window.MeshBayCrypto.b64decode(reply.nonce);
+ this.nodePk = reply.node_pk || null;
+
+ // 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
+ // them — and an operator who cracks the copy on their own disk gets a key
+ // that opens nothing anywhere else.
+ let fresh = false;
if (!this._sessionKeys && this._bundleKey && window.MeshBayKeys) {
const kpResp = await this._sendAndWait({
type: 'keypair_bundle_fetch', v: '0.1',
@@ -151,11 +205,22 @@ class MeshBayTransport {
kpResp.bundle_enc, this._bundleKey);
const pkXB64 = await _pkFromSk(keys.skX);
this._sessionKeys = { skXB64: keys.skX, skEdB64: keys.skEd, pkXB64 };
+ } else {
+ // This node has never seen us. Generate the identity we will use here
+ // and nowhere else; it is stored on this node once the join succeeds,
+ // which is what lets another browser become the same person here.
+ const id = await window.MeshBayKeys.generateNodeIdentity(this._bundleKey);
+ this._sessionKeys = {
+ skEdB64: id.skEdB64, skXB64: id.skXB64, pkXB64: id.pkXB64,
+ };
+ this._newNodeBundle = id.bundleEnc;
+ fresh = true;
}
}
- // Fetch wrapped GEK bundle from node (P2P only — hub never touches crypto)
- if (!gekRaw && this._sessionKeys) {
+ // An identity this node already knows still needs its group key, which the
+ // node wraps on every connection.
+ if (!gekRaw && this._sessionKeys && !fresh) {
const bundleResp = await this._sendAndWait({
type: 'gek_bundle_fetch', v: '0.1',
});
@@ -166,52 +231,159 @@ class MeshBayTransport {
gekRaw = await window.MeshBayCrypto.unwrapGEK(bundleResp, skXRaw, myPkX);
this._gekRaw = gekRaw;
} catch (e) {
- console.warn('[MeshBay] GEK unwrap failed with local keys, trying node keypair bundle');
- if (this._bundleKey && window.MeshBayKeys) {
- const kpResp = await this._sendAndWait({
- type: 'keypair_bundle_fetch', v: '0.1',
- });
- if (kpResp.type === 'keypair_bundle_resp' && kpResp.found) {
- const keys = await window.MeshBayKeys.decryptBundleWithKey(
- kpResp.bundle_enc, this._bundleKey);
- const pkXB64 = await _pkFromSk(keys.skX);
- this._sessionKeys = { skXB64: keys.skX, skEdB64: keys.skEd, pkXB64 };
- const skXRaw2 = Uint8Array.from(atob(keys.skX), c => c.charCodeAt(0));
- const myPkX2 = Uint8Array.from(atob(pkXB64), c => c.charCodeAt(0));
- gekRaw = await window.MeshBayCrypto.unwrapGEK(bundleResp, skXRaw2, myPkX2);
- this._gekRaw = gekRaw;
- }
- }
+ console.warn('[MeshBay] stored GEK bundle did not open; joining instead');
}
}
}
- if (!gekRaw) {
- throw new Error('Node requires GEK proof but no GEK available');
+ // No stored bundle: ask the node to recognise us and wrap the key itself.
+ // This is the normal path for anyone who joined after the invite redesign —
+ // no bundle is pre-stored for members any more. A code is needed only the
+ // first time this node sees this account.
+ if (!gekRaw && this._sessionKeys && userId) {
+ try {
+ gekRaw = await this.joinGroup(userId, groupId, joinCode);
+ } catch (e) {
+ // The UI turns this into "ask the operator for an invite code".
+ this._joinError = e;
+ }
}
- 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);
+ if (!gekRaw && !this._sessionKeys) {
+ // No identity keys in this browser and none recoverable from the node:
+ // the keypair bundle is created where you register and only reaches a
+ // node after a first successful connection, so a brand-new member opening
+ // a second browser has nothing to sign or unwrap with. Say that, rather
+ // than blaming the GEK — a code prompt here would be useless, since a
+ // code proves who you are and we have no key to bind to.
+ const err = new Error(
+ 'This browser does not hold your keys. Open the group once from the '
+ + 'browser where you registered — after that this one can recover them.');
+ err.reason = 'no_keys';
+ throw err;
+ }
+
+ if (!gekRaw) {
+ throw this._joinError
+ || new Error('Node requires GEK proof but no GEK available');
}
+
+ 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 = this._nonceNode; // captured when the challenge arrived
+ 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)));
}
+
+ // 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');
+ }
+ // Trust On First Use (11.5.8). With C6 closed, a substituted node already
+ // fails the GEK proof — this covers the case where an attacker HAS the GEK
+ // (an ex-member, or a leaked key) and swaps the node underneath.
+ // Strict refusal: a warning users can click through is decorative.
+ // The key announced in the challenge must be the one that just proved
+ // itself. A peer that changed identity mid-handshake is not one to trust
+ // with anything, including a join we may already have signed for it.
+ if (this.nodePk && this.nodePk !== ack.node_pk) {
+ throw new Error('Node identity changed during the handshake — refusing');
+ }
+ _checkNodePin(nodeId, ack.node_pk);
+ this.nodePk = ack.node_pk;
+
return ack;
}
- if (reply.type !== 'handshake_ack') {
- throw new Error('MNP handshake rejected: ' + (reply.detail || JSON.stringify(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).
+ const rejected = new Error(
+ 'MNP handshake rejected: ' + (reply.detail || `unexpected ${reply.type}`));
+ // `not_a_member` usually means our token predates being added to the group;
+ // the caller refreshes it and tries again rather than showing that to someone
+ // who was invited thirty seconds ago.
+ rejected.reason = reply.code || '';
+ throw rejected;
+ }
+
+ /**
+ * Pair this browser with the node using a one-time code (M3, and the same
+ * substitution as H3).
+ *
+ * The node has no way to know which key belongs to its operator unless someone
+ * tells it locally — asking the hub would let the hub name itself node
+ * administrator. The code comes from `meshbay-node operator pair`, over SSH, and
+ * the hub never sees it.
+ */
+ async pairOperator(userId, code) {
+ if (!this._connected) throw new Error('Not connected to the node');
+ if (!userId) throw new Error('Missing user id');
+ if (!this._sessionKeys || !this._sessionKeys.skEdB64 || !this._sessionKeys.skXB64) {
+ throw new Error('Identity keys unavailable in this browser — sign in again');
}
+ if (!this._nonceNode || !this.nodePk) {
+ throw new Error('Handshake incomplete — reconnect and retry');
+ }
+
+ const C = window.MeshBayCrypto;
+ // Both public keys are derived from OUR OWN secret keys, never read back from
+ // the hub: signing a public key the directory handed us would reintroduce the
+ // substitution this whole mechanism exists to close.
+ const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64);
+ const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64);
+ const ts = Math.floor(Date.now() / 1000);
+
+ // group_id is empty: operator authority is node-wide, not per group.
+ const transcript = C.joinTranscript(
+ this.nodePk, '', userId, pkEdB64, pkXB64, this._nonceNode, ts);
+ const sig = await window.MeshBayKeys.signBytes(this._sessionKeys.skEdB64, transcript);
- return reply;
+ const resp = await this._sendAndWait({
+ type: 'join_request',
+ v: '0.1',
+ group_id: '',
+ pk_ed25519: pkEdB64,
+ pk_x25519: pkXB64,
+ code: code || '',
+ ts,
+ sig,
+ });
+
+ if (resp.type === 'error') throw new Error(resp.detail || 'Pairing refused');
+ if (resp.type !== 'join_result' || !resp.ok) {
+ const reason = resp.reason || 'unknown';
+ const err = new Error(JOIN_REFUSALS[reason] || `Pairing refused: ${reason}`);
+ err.reason = reason;
+ throw err;
+ }
+ return resp;
}
async fetchIndex() {
@@ -266,6 +438,39 @@ class MeshBayTransport {
return msg;
}
+ /**
+ * Authorize a privileged node operation with the user's Ed25519 identity key.
+ *
+ * The client rebuilds the signed transcript from the challenge fields and refuses
+ * to sign unless the operation and subject match what the user actually asked for.
+ * Previously the node sent 32 opaque random bytes and the client signed them
+ * blind, which let any peer obtain a signature over content of its choosing
+ * (finding H5).
+ */
+ async _authorizeAdminOp(challenge, expectedOp, expectedSubject, signFn) {
+ if (challenge.op !== expectedOp || challenge.subject !== expectedSubject) {
+ throw new Error(
+ `Refusing to sign: node asked to authorize "${challenge.op}" on ` +
+ `"${challenge.subject}", but the requested action was "${expectedOp}" ` +
+ `on "${expectedSubject}"`);
+ }
+ if (!signFn) throw new Error('Admin challenge received but no signing key available');
+
+ const transcript = window.MeshBayCrypto.adminTranscript(
+ challenge.op, challenge.node_pk, challenge.group_id,
+ challenge.subject, challenge.nonce, challenge.ts);
+
+ const signature = await signFn(transcript);
+ const ack = await this._sendAndWait({
+ type: 'admin_response',
+ v: '0.1',
+ op_id: challenge.op_id,
+ signature,
+ });
+ if (ack.type === 'error') throw new Error(ack.detail);
+ return ack;
+ }
+
async deleteFile(fileId, signFn) {
const msg = await this._sendAndWait({
type: 'file_delete',
@@ -274,16 +479,7 @@ class MeshBayTransport {
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
- if (!signFn) throw new Error('Admin challenge received but no signing key available');
- const signature = await signFn(msg.challenge);
- const ack = await this._sendAndWait({
- type: 'admin_response',
- v: '0.1',
- file_id: fileId,
- signature,
- });
- if (ack.type === 'error') throw new Error(ack.detail);
- return ack;
+ return this._authorizeAdminOp(msg, 'file_delete', fileId, signFn);
}
return msg;
}
@@ -304,15 +500,95 @@ class MeshBayTransport {
return msg;
}
- async storeGekBundle(userId, groupId, bundle) {
+ /**
+ * Ask the node for a one-time pairing code admitting `userId` to this group.
+ *
+ * This replaces wrapping the group key in the browser. We no longer fetch the
+ * invitee's public key from the hub, so the hub can no longer answer with its own
+ * and be handed the group key (H3). The node wraps the key later, itself, for a
+ * key the invitee proves possession of.
+ *
+ * Returns {code, expires_at} — the code is displayed once and passed to the
+ * invitee out of band.
+ */
+ async createInvite(userId, groupId, username, signFn) {
const msg = await this._sendAndWait({
- type: 'gek_bundle_store',
+ type: 'invite_create',
v: '0.1',
user_id: userId,
group_id: groupId,
- pk_eph_b64: bundle.pk_eph_b64,
- nonce_b64: bundle.nonce_b64,
- wrapped_b64: bundle.wrapped_b64,
+ username: username || '',
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'invite_create', userId, signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * Ask the node to recognise us and hand over the group key.
+ *
+ * Sent when we hold no GEK for a group. `code` is needed only the first time
+ * this node sees this account (and not at all in an open-join group).
+ */
+ async joinGroup(userId, groupId, code) {
+ if (!this._sessionKeys || !this._sessionKeys.skEdB64 || !this._sessionKeys.skXB64) {
+ throw new Error('Identity keys unavailable in this browser — sign in again');
+ }
+ if (!this._nonceNode || !this.nodePk) {
+ throw new Error('Handshake incomplete — reconnect and retry');
+ }
+
+ const C = window.MeshBayCrypto;
+ const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64);
+ const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64);
+ const ts = Math.floor(Date.now() / 1000);
+
+ const transcript = C.joinTranscript(
+ this.nodePk, groupId || '', userId, pkEdB64, pkXB64, this._nonceNode, ts);
+ const sig = await window.MeshBayKeys.signBytes(this._sessionKeys.skEdB64, transcript);
+
+ const resp = await this._sendAndWait({
+ type: 'join_request',
+ v: '0.1',
+ group_id: groupId || '',
+ pk_ed25519: pkEdB64,
+ pk_x25519: pkXB64,
+ code: code || '',
+ ts,
+ sig,
+ });
+
+ if (resp.type === 'error') throw new Error(resp.detail || 'Join refused');
+ if ((resp.type !== 'join_result' || !resp.ok) || !resp.gek) {
+ const reason = resp.reason || 'unknown';
+ const err = new Error(JOIN_REFUSALS[reason] || `Join refused: ${reason}`);
+ // The UI reacts to `code_required` by asking for one; everything else is
+ // shown as-is.
+ err.reason = reason;
+ throw err;
+ }
+
+ // Unwrap with our own secret key — the node wrapped for the public key we
+ // just proved we hold, so nobody else can open this.
+ const skXRaw = Uint8Array.from(atob(this._sessionKeys.skXB64), c => c.charCodeAt(0));
+ const myPkX = Uint8Array.from(atob(pkXB64), c => c.charCodeAt(0));
+ const gekRaw = await C.unwrapGEK(resp, skXRaw, myPkX);
+ this._gekRaw = gekRaw;
+ return gekRaw;
+ }
+
+ /**
+ * Withdraw our key backup from this node.
+ *
+ * The counterpart of storeKeypairBundle: turning the setting off has to remove
+ * what is already stored, not merely stop adding to it — otherwise the blob
+ * stays on every node the account has ever joined (C4).
+ */
+ async deleteKeypairBundle() {
+ const msg = await this._sendAndWait({
+ type: 'keypair_bundle_delete', v: '0.1',
});
if (msg.type === 'error') throw new Error(msg.detail);
return msg;
@@ -627,5 +903,48 @@ function _extractDtlsFingerprint(sdp) {
return bytes;
}
+// ── Node identity pinning (11.5.8) ───────────────────────────────────────────
+
+const NODE_PIN_PREFIX = 'mb_nodepin_';
+
+function _checkNodePin(nodeId, nodePk) {
+ if (!nodeId || !nodePk) return;
+ const key = NODE_PIN_PREFIX + nodeId;
+
+ let pinned = null;
+ try { pinned = localStorage.getItem(key); } catch { return; }
+
+ if (pinned === null) {
+ try { localStorage.setItem(key, nodePk); } catch {}
+ return;
+ }
+ if (pinned !== nodePk) {
+ throw new Error(
+ 'This node\'s identity key has changed. That is expected only if its ' +
+ 'operator reinstalled the node — otherwise someone may be impersonating ' +
+ 'it. Verify with the operator out of band, then clear the pin in ' +
+ 'Settings to accept the new key.');
+ }
+}
+
+/** Forget a pinned node identity — the deliberate escape hatch for a legitimate rotation. */
+function clearNodePin(nodeId) {
+ try {
+ if (nodeId) localStorage.removeItem(NODE_PIN_PREFIX + nodeId);
+ else {
+ for (const k of Object.keys(localStorage))
+ if (k.startsWith(NODE_PIN_PREFIX)) localStorage.removeItem(k);
+ }
+ } catch {}
+}
+
+function pinnedNodeCount() {
+ try {
+ return Object.keys(localStorage).filter(k => k.startsWith(NODE_PIN_PREFIX)).length;
+ } catch { return 0; }
+}
+
// Export
+MeshBayTransport.clearNodePin = clearNodePin;
+MeshBayTransport.pinnedNodeCount = pinnedNodeCount;
window.MeshBayTransport = MeshBayTransport;