aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 16:58:31 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 16:58:31 +0200
commit6d167392f6f8ede37e2794a68a3738f8ba03131d (patch)
tree9caacef15dd034c6425f4bb623e0cd50a28ec52a /packages/meshbay-hub/src/meshbay_hub/static/transport.js
parent8926f163dad9d32dc06c3a142658a4e11d9c12c1 (diff)
downloadmeshbay-6d167392f6f8ede37e2794a68a3738f8ba03131d.tar.gz
feat(client): the desktop application keeps M and every node identity in its main process
keyring.js derives, opens, mints, seals, signs and agrees there; the page gets public keys and a handle. Argon2 comes from the page's own WebAssembly build (Electron's crypto has none). Without OS key storage the page keeps its keys as a browser does. A node's bundle is settled after connecting, re-sealed when the key changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js93
1 files changed, 78 insertions, 15 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 3586cb7..17a8e5d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -66,6 +66,37 @@ async function _identityFromKeys(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,
+ sign: (bytes) => keys.sign(userId, nodePk, b64(bytes)),
+ 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
@@ -1007,19 +1038,13 @@ class MeshBayTransport {
});
let keys = null;
let openErr = null;
- if (kpResp.type === 'keypair_bundle_resp' && kpResp.found
- && K.bundleFormat(kpResp.bundle_enc) === 'retired') {
- // 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';
- throw err;
- }
- 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 K.decryptBundle(kpResp.bundle_enc,
await K.nodeBundleKey(this._bundleKey, this.nodePk), sealedFor);
@@ -1039,7 +1064,7 @@ class MeshBayTransport {
}
}
- 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.
@@ -1052,7 +1077,9 @@ class MeshBayTransport {
: (openErr && openErr.message) || 'could not open the stored identity');
}
- if (keys) {
+ 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
@@ -1266,6 +1293,42 @@ 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._identity) {
return this._setDevicePk('', 'no identity key in this session');