aboutsummaryrefslogtreecommitdiffstats
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.js250
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 {