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 | 250 |
1 files changed, 171 insertions, 79 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 546d01b..9b86921 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -39,6 +39,68 @@ async function _pkEdFromSk(skPkcs8B64) { return pad ? b64 + '='.repeat(4 - pad) : b64; } +/** + * This account's identity on one node, as the rest of the transport sees it: + * two public keys, a signature and an X25519 agreement — never a private key. + * + * In a browser the keys are in this page, and this wraps them. In the desktop + * application they stay in the main process, which signs and agrees on the + * page's behalf (`platform.keys`), and the object has the same shape — so + * nothing below knows or cares where the keys are. `raw` exists only for keys + * held here, for the one thing that needs them: re-sealing a bundle during a + * passphrase change. + */ +async function _identityFromKeys(skEdB64, skXB64) { + const skXRaw = Uint8Array.from(atob(skXB64), c => c.charCodeAt(0)); + const own = { pkEdB64: await _pkEdFromSk(skEdB64), pkXB64: await _pkFromSk(skXB64) }; + return { + ...own, + // By kind and fields, never over bytes a caller chose (crypto.js, + // transcriptFor) — the same contract the desktop's main process keeps. + signAs: (kind, fields) => window.MeshBayKeys.signBytes( + skEdB64, window.MeshBayCrypto.transcriptFor(kind, fields, own)), + async shared(peerPkRaw) { + const sk = await crypto.subtle.importKey( + 'pkcs8', skXRaw, { name: 'X25519' }, false, ['deriveBits']); + const pk = await crypto.subtle.importKey('raw', peerPkRaw, { name: 'X25519' }, false, []); + return crypto.subtle.deriveBits({ name: 'X25519', public: pk }, sk, 256); + }, + raw: { skEdB64, skXB64 }, + }; +} + +/** + * The same identity when the desktop application holds the keys: its main + * process signs and agrees for this page, which is told public keys only. + */ +function _nativeIdentityHandle(keys, userId, nodePk, pub) { + const b64 = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))); + return { + pkEdB64: pub.pkEdB64, + pkXB64: pub.pkXB64, + sealedWith: pub.sealedWith || null, + native: true, + // The main process builds the bytes from the kind and the fields. + signAs: (kind, fields) => keys.sign(userId, nodePk, kind, fields), + async shared(peerPkRaw) { + const out = await keys.shared(userId, nodePk, b64(peerPkRaw)); + return Uint8Array.from(atob(out), c => c.charCodeAt(0)).buffer; + }, + }; +} + +function _retiredBundleError() { + // Sealed under the passphrase alone, before the pepper. Not opened, and not + // replaced by a new identity behind the member's back: that would leave the + // node pinning a key nobody holds. The operator unpins them (which drops + // this bundle) and sends a new code. + const err = new Error('This node holds your identity in a format this version ' + + 'no longer reads. Ask its operator to run "meshbay-node member unpin" ' + + 'for your account and send you a new invitation code.'); + err.reason = 'bundle_format_retired'; + return err; +} + // Segments of 256 KB: 24 in flight is 6 MB, enough to keep playback fed over a // slow link and small enough that nothing accumulates. // How long to collect ICE candidates before sending the offer anyway. Long @@ -305,8 +367,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,18 +562,13 @@ 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 // is being torn down. this._closed = false; // The arguments connect() was last given, minus the token (refreshed at - // reconnect time — see onNeedToken) and sessionKeys (kept live on `this`, + // reconnect time — see onNeedToken) and the identity (kept live on `this`, // since a reconnect must reuse the identity connect() settled on, not // whatever the very first caller passed in — see _reconnectLoop). this._connectArgs = null; @@ -571,18 +630,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; } @@ -623,7 +675,12 @@ class MeshBayTransport { // refresh the hub session token (see group-page.js's ensureFreshToken). set onNeedToken(fn) { this._onNeedToken = fn; } - get sessionKeys() { return this._sessionKeys; } + get identity() { return this._identity; } + /** Signs an admin operation with this node's identity, or null when there is none yet. */ + get signFn() { + const id = this._identity; + return id ? (fields) => id.signAs('admin', fields) : null; + } /** Set on a first join: the identity created for this node, still to be left with it. */ get newNodeBundle() { return this._newNodeBundle || null; } @@ -663,13 +720,16 @@ class MeshBayTransport { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this._accessToken}`, }, - body: JSON.stringify({ node_pk: this._nodePkTarget || '' }), + // The token names this connection's group and no other: it is handed to + // the node's operator, who has no business learning every group this + // account belongs to. + body: JSON.stringify({ node_pk: this._nodePkTarget || '', group_id: this._groupId }), }); 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, + async connect(nodeId, jwtToken, groupId, gekRaw, identity, bundleKey, username, 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() @@ -690,7 +750,7 @@ class MeshBayTransport { // never actually trying the fresh token connect() had just been handed. this._accessToken = jwtToken; this._gekRaw = gekRaw || null; - this._sessionKeys = sessionKeys || null; + this._identity = identity || null; this._bundleKey = bundleKey || null; this._recoveryKey = recoveryKey || null; this._username = username || null; @@ -941,12 +1001,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'); } @@ -959,16 +1016,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); @@ -977,16 +1032,26 @@ class MeshBayTransport { // 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) { + if (!this._identity && this._bundleKey && window.MeshBayKeys) { + const K = window.MeshBayKeys; + // A bundle is sealed for this account on this node — the key the node + // just proved above, and no other. + const sealedFor = { userId: this._userId, nodePk: this.nodePk }; const kpResp = await this._sendAndWait({ type: 'keypair_bundle_fetch', v: '0.1', }); let keys = null; let openErr = null; - if (kpResp.type === 'keypair_bundle_resp' && kpResp.found) { + this._nodeHasBundle = kpResp.type === 'keypair_bundle_resp' && !!kpResp.found; + if (this._bundleKey.native) { + // The desktop application holds the keys: it settles the identity. + fresh = await this._settleNativeIdentity(kpResp); + } else if (this._nodeHasBundle && K.bundleFormat(kpResp.bundle_enc) === 'retired') { + throw _retiredBundleError(); + } else if (this._nodeHasBundle) { try { - keys = await window.MeshBayKeys.decryptBundleWithKey( - kpResp.bundle_enc, this._bundleKey); + keys = await K.decryptBundle(kpResp.bundle_enc, + await K.nodeBundleKey(this._bundleKey, this.nodePk), sealedFor); } catch (e) { openErr = e; // The passphrase key did not open the bundle. If we hold a recovery @@ -995,15 +1060,15 @@ class MeshBayTransport { // passphrase, before re-wrapping it under the new one. if (this._recoveryKey && kpResp.bundle_enc_recovery) { try { - keys = await window.MeshBayKeys.decryptBundleWithKey( - kpResp.bundle_enc_recovery, this._recoveryKey); + keys = await K.decryptBundle( + kpResp.bundle_enc_recovery, this._recoveryKey, sealedFor); this._recoveredFromRecovery = true; } catch { /* recovery copy did not open either */ } } } } - if (!keys && this._rewrapOnly) { + if (!this._bundleKey.native && !keys && this._rewrapOnly) { // A passphrase-change / backfill run must recover the *existing* // identity or report the node — never mint a new one. These strings // are shown on the reset / backfill screens. @@ -1016,9 +1081,10 @@ class MeshBayTransport { : (openErr && openErr.message) || 'could not open the stored identity'); } - if (keys) { - const pkXB64 = await _pkFromSk(keys.skX); - this._sessionKeys = { skXB64: keys.skX, skEdB64: keys.skEd, pkXB64 }; + if (this._bundleKey.native) { + // The application settled it above; nothing of it is in this page. + } else if (keys) { + this._identity = await _identityFromKeys(keys.skEd, keys.skX); } else { // Either the node has never seen us, or it holds a stale bundle we // cannot open (wrapped under a passphrase we no longer use, with no @@ -1026,11 +1092,9 @@ class MeshBayTransport { // behind). Mint a fresh identity and let the join path take over; a // successful join overwrites whatever was stored. A recovery-wrapped // copy is left too when a recovery key is in hand (§4.3). - const id = await window.MeshBayKeys.generateNodeIdentity( - this._bundleKey, this._recoveryKey); - this._sessionKeys = { - skEdB64: id.skEdB64, skXB64: id.skXB64, pkXB64: id.pkXB64, - }; + const id = await K.generateNodeIdentity( + this._bundleKey, this._recoveryKey, sealedFor); + this._identity = await _identityFromKeys(id.skEdB64, id.skXB64); this._newNodeBundle = id.bundleEnc; this._newNodeBundleRecovery = id.bundleEncRecovery || null; fresh = true; @@ -1039,15 +1103,16 @@ class MeshBayTransport { // An identity this node already knows still needs its group key, which the // node wraps on every connection. - if (!gekRaw && this._sessionKeys && !fresh) { + if (!gekRaw && this._identity && !fresh) { const bundleResp = await this._sendAndWait({ type: 'gek_bundle_fetch', v: '0.1', }); if (bundleResp.type === 'gek_bundle_resp' && bundleResp.found) { - const skXRaw = Uint8Array.from(atob(this._sessionKeys.skXB64), c => c.charCodeAt(0)); - const myPkX = Uint8Array.from(atob(this._sessionKeys.pkXB64), c => c.charCodeAt(0)); + const id = this._identity; + const myPkX = Uint8Array.from(atob(id.pkXB64), c => c.charCodeAt(0)); try { - gekRaw = await window.MeshBayCrypto.unwrapGEK(bundleResp, skXRaw, myPkX); + gekRaw = await window.MeshBayCrypto.unwrapGEK( + bundleResp, (pk) => id.shared(pk), myPkX); this._gekRaw = gekRaw; } catch (e) { console.warn('[MeshBay] stored GEK bundle did not open; joining instead'); @@ -1064,11 +1129,10 @@ 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) { + } else if (!gekRaw && this._identity && userId) { try { gekRaw = await this.joinGroup(userId, groupId, joinCode); } catch (e) { @@ -1077,7 +1141,7 @@ class MeshBayTransport { } } - if (!gekRaw && !this._sessionKeys) { + if (!gekRaw && !this._identity) { // No key in this browser to sign or unwrap with — `bundleKey` was null. // The caller (group-page.js) shows a passphrase prompt on this reason // and retries; a code prompt would be useless, since a code proves who @@ -1165,7 +1229,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. @@ -1234,8 +1297,44 @@ class MeshBayTransport { * with it, which is unreadable from the outside — the shape of the "chat * hangs, the textbox is dead" report. Every exit below names itself. */ + /** + * This node's identity when the desktop application holds the keys. + * + * Kept by the application once it has it, so a bundle left on the node — + * even one in a format no longer read — does not matter: whether one should + * be there is `settleNodeBundle`'s question, after the connection is made. + * Otherwise the node's bundle is opened by the application, or a new + * identity is created there on a first join. Returns true for the latter. + */ + async _settleNativeIdentity(kpResp) { + const P = window.MeshBayPlatform.keys; + const uid = this._userId; + const pk = this.nodePk; + let pub = await P.identity(uid, pk); + if (!pub && this._nodeHasBundle) { + if (window.MeshBayKeys.bundleFormat(kpResp.bundle_enc) === 'retired') { + throw _retiredBundleError(); + } + try { + pub = await P.openBundle(uid, pk, { bundleEnc: kpResp.bundle_enc }); + } catch (e) { + // Sealed under a passphrase no longer in use: as in a browser, a + // passphrase change must report it, and a first join replaces it. + if (this._rewrapOnly) throw new Error('could not open the stored identity'); + } + } + let fresh = false; + if (!pub) { + if (this._rewrapOnly) throw new Error('no identity on this node'); + pub = await P.mint(uid, pk); + fresh = true; + } + this._identity = _nativeIdentityHandle(P, uid, pk, pub); + return fresh; + } + async _announceDevice() { - if (!this._sessionKeys || !this._sessionKeys.skEdB64) { + if (!this._identity) { return this._setDevicePk('', 'no identity key in this session'); } if (!this._nonceNode || !this.nodePk || !this._userId) { @@ -1246,13 +1345,12 @@ class MeshBayTransport { // Derived from our own secret key, never read back from anywhere — the same // rule as pairOperator: signing a public key someone handed us is the // substitution this mechanism exists to close. - const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64); + const pkEdB64 = this._identity.pkEdB64; const ts = Math.floor(Date.now() / 1000); - const transcript = C.deviceHelloTranscript( - this.nodePk, this._groupId || '', this._userId, pkEdB64, - this._nonceNode, ts); - const sig = await window.MeshBayKeys.signBytes( - this._sessionKeys.skEdB64, transcript); + const sig = await this._identity.signAs('device_hello', { + nodePk: this.nodePk, groupId: this._groupId || '', userId: this._userId, + nonceNode: C.b64encode(this._nonceNode), ts, + }); const resp = await this._sendAndWait({ type: 'device_hello', v: '2.0', pk_ed25519: pkEdB64, ts, sig, @@ -1323,7 +1421,7 @@ class MeshBayTransport { let ack; try { ack = await this.connect(args.nodeId, token, args.groupId, args.gekRaw, - this._sessionKeys, args.bundleKey, args.username, + this._identity, args.bundleKey, args.username, args.userId, args.joinCode, undefined, args.joinNodePk, args.nodePk); } finally { @@ -2198,35 +2296,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 { |