diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-30 16:58:31 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-30 16:58:31 +0200 |
| commit | 6d167392f6f8ede37e2794a68a3738f8ba03131d (patch) | |
| tree | 9caacef15dd034c6425f4bb623e0cd50a28ec52a /packages/meshbay-hub/src/meshbay_hub/static/transport.js | |
| parent | 8926f163dad9d32dc06c3a142658a4e11d9c12c1 (diff) | |
| download | meshbay-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.js | 93 |
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'); |