diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 79 |
1 files changed, 30 insertions, 49 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index f3683eb..98d010e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -305,8 +305,10 @@ window.addEventListener('hashchange', () => { // The `v: '0.1'` on every other message in this file is the historical value // and is read by nothing; it is left alone deliberately. The range is // negotiated once, at the start, not restated per message. -const MNP_V = '4.0'; -// Raised with it: 4.0 is a flag day. A member now presents a short-lived +const MNP_V = '5.0'; +// Not raised with 5.0 (see meshbay_common/__init__.py): the break is confined to +// four signed operations, which a peer on the other side of it refuses to sign. +// Set at 4.0, a flag day. A member now presents a short-lived // MNP-audience token in the handshake, not its hub session token — a node // older than 4.0 expected the session token, and one newer refuses it, so the // two cannot authenticate across the break. This is the C6 rule: no @@ -498,11 +500,6 @@ class MeshBayTransport { this._inFlightUploads = new Set(); // tr → Lease. A transfer's slot on the node, from the client's side. this._leases = new Map(); - // Set from the handshake ack: a node that answers with `transfer_limits` - // speaks transfer slots. Used instead of a timeout, because "no answer - // yet" and "this node will never answer" are indistinguishable in time and - // guessing wrong either stalls every download or defeats the cap. - this._transferLimits = null; // Set once close() runs — stops the automatic reconnect from firing on a // connection the caller tore down on purpose (leaving the group, page // unload), which would otherwise race back in right as everything else @@ -571,18 +568,11 @@ class MeshBayTransport { set onIndexDelta(fn) { this._onIndexDelta = fn; } set onRootsChanged(fn) { this._onRootsChanged = fn; } - /** The MNP version the connected node declared, or '' before a handshake. */ - get nodeVersion() { return this._nodeVersion || ''; } - - /** This member's own caps in this group, or null when the node said nothing. */ - get transferLimits() { return this._transferLimits; } - set onAppsEnabled(fn) { this._onAppsEnabled = fn; } set onAppDirectories(fn) { this._onAppDirectories = fn; } set onChatDirectory(fn) { this._onChatDirectory = fn; } set onChatLinkPreview(fn) { this._onChatLinkPreview = fn; } set onSearchListed(fn) { this._onSearchListed = fn; } - set onChatEpoch(fn) { this._onChatEpoch = fn; } set onTmdbConfig(fn) { this._onTmdbConfig = fn; } set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; } set onMusicbrainzEnabled(fn) { this._onMusicbrainzEnabled = fn; } @@ -663,21 +653,21 @@ class MeshBayTransport { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this._accessToken}`, }, - body: JSON.stringify({}), + body: JSON.stringify({ node_pk: this._nodePkTarget || '' }), }); if (!r.ok) throw new Error(`Could not obtain a node token: ${r.status}`); return (await r.json()).mnp_token; } async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username, - userId, joinCode, recoveryKey, joinNodePk) { + userId, joinCode, recoveryKey, joinNodePk, nodePk) { // Remembered for _reconnectLoop, which calls connect() again with these // same values (plus a freshly-fetched token and the identity connect() // itself settles on below) after the WebRTC connection is declared // "failed" — see the pc.onconnectionstatechange handler further down. this._connectArgs = { nodeId, groupId, gekRaw, bundleKey, username, userId, joinCode, recoveryKey, - joinNodePk, + joinNodePk, nodePk, }; this._lastToken = jwtToken; // The constructor sets this once from whatever token the caller had at @@ -695,6 +685,10 @@ class MeshBayTransport { this._recoveryKey = recoveryKey || null; this._username = username || null; this._userId = userId || null; + // The key of the node we mean to reach, from the hub's node list. The MNP + // token is bound to it (E10) so it cannot be replayed to another node. It is + // the *expected* key; `this.nodePk` below is the one the node then proves. + this._nodePkTarget = nodePk || ''; // The group this connection is for. Kept on the instance because the // handshake is not the only thing that needs it any more: device_hello and // the chat envelope both bind to it, and both run outside connect()'s scope. @@ -937,12 +931,9 @@ class MeshBayTransport { if (reply.type === 'handshake_challenge') { // The node's half of the range. Checked before anything else in this // block, because everything below — the join, the proof, the sealed ack - // — assumes both sides mean the same thing by each message. + // — assumes both sides mean the same thing by each message. Nothing else + // reads the node's version: a peer this admits speaks every message here. _checkNodeVersion(reply); - // Kept for diagnostics only. Nothing branches on it: the range check - // above is what decides whether these two can talk at all, and a peer it - // admits speaks every message in this file. - this._nodeVersion = String(reply.v || ''); if (!window.MeshBayCrypto) { throw new Error('Node requires GEK proof but no crypto available'); } @@ -955,16 +946,14 @@ class MeshBayTransport { // 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. From an - // older node it is unverified until the ack below checks it. + // The signature checked next is what proves it here, before the ack. this._nonceNode = window.MeshBayCrypto.b64decode(reply.nonce); this.nodePk = reply.node_pk || null; - // Since MNP 3.4 the node signs its challenge over this connection, so - // node_pk is proved here and not only at the ack — which comes after any - // join. A signature that does not verify is a peer lying about which node - // it is, and is refused. An absent one is an older node: `nodePkProved` - // stays false, and whatever needs the key proved before a code leaves - // (an invitation link names its node) reads that — never a version. - this.nodePkProved = await _challengeProvesNodeKey( + // The node signs its challenge over this connection, so node_pk is proved + // here and not only at the ack — which comes after any join. Every node + // this client can reach signs (the floor is 4.0, and signing is 3.4), so + // a missing signature is refused exactly like a wrong one. + await _challengeProvesNodeKey( reply, groupId || '', this._nonceClient, this._pc.localDescription.sdp, this._rawAnswerSdp); @@ -1060,8 +1049,7 @@ class MeshBayTransport { // (docs/MESHBAY_DESIGN.md §3.4). Otherwise nothing is sent at all — not // even a join without the code, which this node would answer by asking // for one. - const linkRefusal = _linkJoinRefusal(joinNodePk, joinCode, this.nodePk, - this.nodePkProved); + const linkRefusal = _linkJoinRefusal(joinNodePk, joinCode, this.nodePk); if (linkRefusal) { this._joinError = linkRefusal; } else if (!gekRaw && this._sessionKeys && userId) { @@ -1161,7 +1149,6 @@ class MeshBayTransport { delete ack.nonce; delete ack.ct; Object.assign(ack, config); - this._transferLimits = ack.transfer_limits || null; // From the *sealed* part of the ack: a forged epoch would have this // client sealing under a key the group has retired. @@ -1321,7 +1308,7 @@ class MeshBayTransport { ack = await this.connect(args.nodeId, token, args.groupId, args.gekRaw, this._sessionKeys, args.bundleKey, args.username, args.userId, args.joinCode, undefined, - args.joinNodePk); + args.joinNodePk, args.nodePk); } finally { this._inReconnectAttempt = false; } @@ -2194,35 +2181,29 @@ class MeshBayTransport { * Why a code from an invitation link must not go to this node, or null. * * `link_other_node` is the caller's cue to try the next node the hub listed, - * as for `not_hosted`: the link names one node, and this is not it. An older - * node that cannot prove its key early is refused rather than trusted — it - * cannot have issued a link code anyway. + * as for `not_hosted`: the link names one node, and this is not it. `nodePk` + * has already been proved by the challenge signature, which is required. */ -function _linkJoinRefusal(joinNodePk, joinCode, nodePk, nodePkProved) { +function _linkJoinRefusal(joinNodePk, joinCode, nodePk) { if (!joinNodePk || !joinCode) return null; if (nodePk !== joinNodePk) { const err = new Error('This invitation was issued by another machine hosting this group.'); err.reason = 'link_other_node'; return err; } - if (!nodePkProved) { - const err = new Error('This node is too old to accept invitation links.'); - err.reason = 'link_node_unproved'; - return err; - } return null; } /** - * Whether `handshake_challenge` proves the key it announces (MNP 3.4). + * Check that `handshake_challenge` proves the key it announces; throw if not. * - * True when it carries a signature that verifies over this connection, false - * when it carries none — an older node, which proves its key only at the ack. - * A signature that does not verify is a peer lying about which node it is, and - * throws: that is a refusal, not a node that merely cannot say. + * A node signs whenever it has a channel binding, and one without a binding + * could not complete the handshake anyway (its proof is refused), so a missing + * signature is refused like a wrong one: both are a peer that cannot show it is + * the node it names. There is no "older node" case — the floor is 4.0. */ async function _challengeProvesNodeKey(reply, groupId, nonceClient, offerSdp, answerSdp) { - if (!reply.sig) return false; + if (!reply.sig) throw new Error('Node challenge is not signed — refusing connection'); const C = window.MeshBayCrypto; let ok = false; try { |