From 6d167392f6f8ede37e2794a68a3738f8ba03131d Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 30 Sep 2026 16:58:31 +0200 Subject: 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 --- .../src/meshbay_hub/static/group-page.js | 23 +++--- .../src/meshbay_hub/static/hub-client.js | 20 ++++- .../src/meshbay_hub/static/keyderive.js | 22 ++++- .../src/meshbay_hub/static/locales/de.js | 1 + .../src/meshbay_hub/static/locales/en.js | 1 + .../src/meshbay_hub/static/locales/es.js | 1 + .../src/meshbay_hub/static/locales/fr.js | 1 + .../src/meshbay_hub/static/locales/it.js | 1 + .../src/meshbay_hub/static/locales/ja.js | 1 + .../src/meshbay_hub/static/locales/nl.js | 1 + .../src/meshbay_hub/static/locales/pl.js | 1 + .../src/meshbay_hub/static/locales/pt-BR.js | 1 + .../src/meshbay_hub/static/locales/zh-CN.js | 1 + .../meshbay-hub/src/meshbay_hub/static/platform.js | 32 +++++++- .../src/meshbay_hub/static/playlists.js | 11 +++ .../src/meshbay_hub/static/profile-page.js | 51 +++++++----- .../src/meshbay_hub/static/transport-devices.js | 51 ++++++++++-- .../src/meshbay_hub/static/transport-rewrap.js | 16 ++++ .../src/meshbay_hub/static/transport.js | 93 ++++++++++++++++++---- 19 files changed, 270 insertions(+), 59 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index fcb905b..d2259b9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -249,7 +249,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // it is asked for with that token. Persisted so this browser is set up // from now on. const { pepper, version } = await window.MeshBayKeys.fetchBundlePepper(token); - session.bundleKey = await window.MeshBayKeys.deriveBundleSessionKey( + session.bundleKey = await window.MeshBayKeys.sessionBundleKey( pass, username, userId, pepper, version); await _storeBundleKey(session.bundleKey); setPassInput(''); @@ -509,18 +509,15 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, }; setOperatorPaired(transport.memberRole === 'operator'); - // A first join to this node generated an identity for it; leave it with - // the node so any other browser can become the same person here with the - // passphrase. It is this node's key and no other's. - if (transport.connected && transport.newNodeBundle) { - try { - await transport.storeKeypairBundle( - transport.newNodeBundle, transport.newNodeBundleRecovery); - transport.newNodeBundle = null; - transport.newNodeBundleRecovery = null; - } catch (e) { - console.warn('[MeshBay] could not leave our key with the node:', e.message); - } + // What this node should hold of our identity: the bundle of one just + // created, so another browser can become the same person here — or, in + // the desktop application, whatever the account's browser access says. + // Not awaited: nothing on this page depends on it, and the group opens + // without waiting for a round trip to the node about a backup. + if (transport.connected) { + transport.settleNodeBundle().catch((e) => { + console.warn('[MeshBay] could not settle our key with the node:', e.message); + }); } // Import GEK from transport (fetched from node during handshake) diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js index 3081ad8..18db1ba 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js @@ -158,10 +158,19 @@ async function _loadKey(slot) { // in a *later* session still get a recovery-wrapped identity copy, instead of // only groups joined in the unbroken session that generated it. Cleared with // everything else on sign-out. -const _storeBundleKey = (key) => _storeKey('bk', key); +// In the desktop application the key is not here at all: its main process +// keeps it (platform.keys), and this page holds a handle. Nothing is written to +// this database for it, and a handle is only returned while the application +// really holds a key for the signed-in account. +const _storeBundleKey = (key) => (key && key.native ? Promise.resolve() : _storeKey('bk', key)); // A key stored before the pepper (`{v2, v1, …}`) opens nothing any more: it is // no key at all, and the group page asks for the passphrase again. const _loadBundleKey = async () => { + if (await platform.nativeKeys()) { + const auth = loadAuth(); + return auth && await platform.keys.hasSession(auth.userId) + ? { native: true, userId: auth.userId, pepperVersion: 1 } : null; + } const k = await _loadKey('bk'); return k && k.v3 ? k : null; }; @@ -201,10 +210,19 @@ function saveAuth(auth) { if (auth) { localStorage.setItem(AUTH_KEY, JSON.stringify(auth)); } else { + // The account signing out, read before its record goes: the application + // drops that account's bundle key (the node identities stay — they are + // this device's, and dropping them would strand it on every node). + const leaving = loadAuth(); localStorage.removeItem(AUTH_KEY); session.bundleKey = null; session.recoveryKey = null; _clearKeyDB(); + if (leaving && platform.keys) { + platform.nativeKeys() + .then((native) => native && platform.keys.forgetSession(leaving.userId)) + .catch(() => { /* nothing held */ }); + } } } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js index 8f820ec..d847aab 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -164,6 +164,24 @@ async function deriveBundleSessionKey(password, username, userId, pepperB64, pep }; } +/** + * The session's bundle key wherever it is kept. In a browser, derived here. In + * the desktop application, derived and kept by its main process, and what + * comes back is a handle — `{ native, userId, pepperVersion }` — that opens + * nothing by itself: the transport asks the application for what it needs. + * `pending`: a passphrase change, held aside until the hub accepts it. + */ +async function sessionBundleKey(password, username, userId, pepperB64, pepperVersion, + { pending = false } = {}) { + const P = typeof window !== 'undefined' && window.MeshBayPlatform; + if (P && P.nativeKeys && await P.nativeKeys()) { + if (!userId || !pepperB64) throw new Error('the hub did not provide the bundle pepper'); + await P.keys.deriveSession({ password, username, userId, pepperB64, pepperVersion, pending }); + return { native: true, userId, pepperVersion: pepperVersion || 1, pending }; + } + return deriveBundleSessionKey(password, username, userId, pepperB64, pepperVersion); +} + /** * The key that seals this account's identity on ONE node. A leaked one opens * that node's bundle and no other. @@ -428,7 +446,7 @@ async function loginAndRecover(username, password) { refreshToken: data.refresh_token, // The pepper rides on the sign-in response, so this costs no extra call; // it is folded into the key here and not kept. - bundleKey: await deriveBundleSessionKey( + bundleKey: await sessionBundleKey( password, username, _subOf(data.access_token), data.bundle_pepper, data.bundle_pepper_version), }; @@ -457,7 +475,7 @@ window.MeshBayKeys = { deriveAuthKey, // The bundle key (docs/MESHBAY_DESIGN.md §3.1, §3.7): one session key per // sign-in, one derived key per node, one format. - deriveBundleSessionKey, nodeBundleKey, fetchBundlePepper, + deriveBundleSessionKey, sessionBundleKey, nodeBundleKey, fetchBundlePepper, encryptBundle, decryptBundle, bundleFormat, // Account recovery key (docs/MESHBAY_DESIGN.md §3.6). generateRecoveryKey, deriveRecoveryKey, diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 3190f9d..d34e9bf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -1247,5 +1247,6 @@ export default { 'native.attach_confirm': 'Die Gruppe „{name}" auf diesem Computer hosten und den Ordner {path} mit ihren Mitgliedern teilen?', 'native.folder_confirm': 'Den Ordner {path} mit den Mitgliedern einer auf diesem Computer gehosteten Gruppe teilen? Er wurde nicht in der Ordnerauswahl gewählt.', 'native.node_account_confirm': 'Der Node auf diesem Computer ist für {current} eingerichtet. Stattdessen für {next} einrichten? Er stellt dann die Gruppen, die er für {current} hostet, nicht mehr bereit.', + 'native.browser_access_confirm': 'Die Anmeldung über einen Browser erlauben? Die Anwendung legt Ihre Identität auf jedem genutzten Node ab, so versiegelt, dass nur Ihre Passphrase zusammen mit Ihrem Hub-Konto sie öffnen kann.', 'native.declined': 'Abgebrochen — nichts wurde geändert.', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index ea31e81..217a5d7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -1228,5 +1228,6 @@ export default { 'native.attach_confirm': 'Host the group "{name}" on this computer and share the folder {path} with its members?', 'native.folder_confirm': 'Share the folder {path} with the members of a group hosted on this computer? It was not chosen in the folder picker.', 'native.node_account_confirm': 'The node on this computer is set up for {current}. Set it up for {next} instead? It will stop serving the groups it hosts for {current}.', + 'native.browser_access_confirm': 'Allow signing in from a browser? The application will leave your identity on every node you use, sealed so that only your passphrase together with your hub account can open it.', 'native.declined': 'Cancelled — nothing was changed.', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 2621632..76c3884 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -1241,5 +1241,6 @@ export default { 'native.attach_confirm': '¿Alojar el grupo «{name}» en este ordenador y compartir la carpeta {path} con sus miembros?', 'native.folder_confirm': '¿Compartir la carpeta {path} con los miembros de un grupo alojado en este ordenador? No se eligió en el selector de carpetas.', 'native.node_account_confirm': 'El node de este ordenador está configurado para {current}. ¿Configurarlo para {next} en su lugar? Dejará de servir los grupos que aloja para {current}.', + 'native.browser_access_confirm': '¿Permitir el acceso desde un navegador? La aplicación dejará su identidad en cada node que use, sellada de modo que solo su frase de contraseña, junto con su cuenta del hub, pueda abrirla.', 'native.declined': 'Cancelado: no se ha cambiado nada.', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index bdb89bd..e142dc3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -1256,5 +1256,6 @@ export default { 'native.attach_confirm': 'Héberger le groupe « {name} » sur cet ordinateur et partager le dossier {path} avec ses membres ?', 'native.folder_confirm': 'Partager le dossier {path} avec les membres d\'un groupe hébergé sur cet ordinateur ? Il n\'a pas été choisi dans le sélecteur de dossier.', 'native.node_account_confirm': 'Le node de cet ordinateur est configuré pour {current}. Le configurer pour {next} à la place ? Il cessera de servir les groupes qu\'il héberge pour {current}.', + 'native.browser_access_confirm': 'Autoriser la connexion depuis un navigateur ? L\'application déposera votre identité sur chaque node que vous utilisez, scellée de sorte que seule votre phrase secrète, avec votre compte sur le hub, puisse l\'ouvrir.', 'native.declined': 'Annulé — rien n\'a été modifié.', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index f87df9a..62800db 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -1255,5 +1255,6 @@ export default { 'native.attach_confirm': 'Ospitare il gruppo «{name}» su questo computer e condividere la cartella {path} con i suoi membri?', 'native.folder_confirm': 'Condividere la cartella {path} con i membri di un gruppo ospitato su questo computer? Non è stata scelta nel selettore di cartelle.', 'native.node_account_confirm': 'Il node di questo computer è configurato per {current}. Configurarlo invece per {next}? Smetterà di servire i gruppi che ospita per {current}.', + 'native.browser_access_confirm': 'Consentire l\'accesso da un browser? L\'applicazione lascerà la tua identità su ogni node che usi, sigillata in modo che solo la tua passphrase, insieme al tuo account sull\'hub, possa aprirla.', 'native.declined': 'Annullato: non è stato modificato nulla.', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 3ca369f..ad25af6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -1239,5 +1239,6 @@ export default { 'native.attach_confirm': 'このコンピューターでグループ「{name}」をホストし、フォルダー {path} をメンバーと共有しますか?', 'native.folder_confirm': 'このコンピューターでホストしているグループのメンバーとフォルダー {path} を共有しますか?このフォルダーはフォルダー選択画面で選ばれたものではありません。', 'native.node_account_confirm': 'このコンピューターの node は {current} 用に設定されています。代わりに {next} 用に設定しますか?{current} のためにホストしているグループは提供されなくなります。', + 'native.browser_access_confirm': 'ブラウザーからのサインインを許可しますか?アプリは、利用中のすべての node にあなたの ID を残します。これは、パスフレーズと hub のアカウントの両方がそろった場合にのみ開けるよう封印されます。', 'native.declined': 'キャンセルしました。何も変更されていません。', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index a733fde..a6cfe02 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -1257,5 +1257,6 @@ export default { 'native.attach_confirm': 'De groep "{name}" op deze computer hosten en de map {path} met de leden delen?', 'native.folder_confirm': 'De map {path} delen met de leden van een groep die op deze computer wordt gehost? Hij is niet gekozen in de mapkiezer.', 'native.node_account_confirm': 'De node op deze computer is ingesteld voor {current}. In plaats daarvan instellen voor {next}? Hij stopt dan met het aanbieden van de groepen die hij voor {current} host.', + 'native.browser_access_confirm': 'Aanmelden vanuit een browser toestaan? De toepassing laat je identiteit achter op elke node die je gebruikt, zo verzegeld dat alleen je wachtzin samen met je hub-account haar kan openen.', 'native.declined': 'Geannuleerd — er is niets gewijzigd.', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 2537bc1..1d7a850 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -1283,5 +1283,6 @@ export default { 'native.attach_confirm': 'Hostować grupę „{name}" na tym komputerze i udostępnić jej członkom folder {path}?', 'native.folder_confirm': 'Udostępnić folder {path} członkom grupy hostowanej na tym komputerze? Nie został wybrany w oknie wyboru folderu.', 'native.node_account_confirm': 'Node na tym komputerze jest skonfigurowany dla {current}. Skonfigurować go zamiast tego dla {next}? Przestanie obsługiwać grupy, które hostuje dla {current}.', + 'native.browser_access_confirm': 'Zezwolić na logowanie z przeglądarki? Aplikacja pozostawi Twoją tożsamość na każdym używanym node, zapieczętowaną tak, by otworzyć ją mogło tylko Twoje hasło wraz z kontem na hubie.', 'native.declined': 'Anulowano — nic nie zostało zmienione.', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 73d3e21..be40102 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -1242,5 +1242,6 @@ export default { 'native.attach_confirm': 'Hospedar o grupo "{name}" neste computador e compartilhar a pasta {path} com os membros?', 'native.folder_confirm': 'Compartilhar a pasta {path} com os membros de um grupo hospedado neste computador? Ela não foi escolhida no seletor de pastas.', 'native.node_account_confirm': 'O node deste computador está configurado para {current}. Configurá-lo para {next} em vez disso? Ele deixará de servir os grupos que hospeda para {current}.', + 'native.browser_access_confirm': 'Permitir o acesso por um navegador? O aplicativo deixará sua identidade em cada node que você usa, selada de forma que apenas sua frase secreta, junto com sua conta no hub, possa abri-la.', 'native.declined': 'Cancelado — nada foi alterado.', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index f0440b8..9b5b5dd 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -1228,5 +1228,6 @@ export default { 'native.attach_confirm': '在这台电脑上托管群组“{name}”,并与其成员共享文件夹 {path}?', 'native.folder_confirm': '与这台电脑上托管的群组成员共享文件夹 {path}?该文件夹不是在文件夹选择器中选择的。', 'native.node_account_confirm': '这台电脑上的 node 已为 {current} 设置。改为为 {next} 设置吗?它将不再为 {current} 提供其托管的群组。', + 'native.browser_access_confirm': '允许从浏览器登录吗?应用会在您使用的每个 node 上留下您的身份,并加以封存,只有您的密码短语配合您的 hub 账户才能打开。', 'native.declined': '已取消,未做任何更改。', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index 8bf09b4..19f12a6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -508,9 +508,36 @@ export async function setUiLocale(code) { return bridge.setLocale(code); } +/** + * The account's keys, held by the desktop application's main process + * (`meshbay-client/src/keyring.js`): the bundle master key and the identity on + * every node. The page asks for public keys, signatures and agreements. + * + * Null in a browser. `available()` is false in the application too where the + * OS has no key storage — identities kept there and lost at the next start + * would strand the account on every node — and the page then keeps its keys as + * a browser does. + */ +let _keysAvailable = null; +export const keys = (bridge && bridge.keys) ? { + ...bridge.keys, + async available() { + if (_keysAvailable === null) { + try { _keysAvailable = Boolean(await bridge.keys.available()); } + catch { _keysAvailable = false; } + } + return _keysAvailable; + }, +} : null; + +/** Whether this page's keys are held by the application rather than here. */ +export async function nativeKeys() { + return Boolean(keys && await keys.available()); +} + export default { isNative, hubBase, capabilities, secrets, nativeSave, apiFetch, device, bridgeMessage, folder, rootPicker, node, - cast, minimizeToTray, setTrayLabels, setUiLocale }; + cast, minimizeToTray, setTrayLabels, setUiLocale, keys, nativeKeys }; // Also a global, because `transport.js` is loaded as a classic script — it // predates the module graph and exposes `MeshBayTransport` the same way. The @@ -520,5 +547,6 @@ if (typeof window !== 'undefined') { window.MeshBayPlatform = { isNative, hubBase, capabilities, secrets, nativeSave, apiFetch, device, bridgeMessage, folder, rootPicker, node, - cast, minimizeToTray, setTrayLabels, setUiLocale }; + cast, minimizeToTray, setTrayLabels, setUiLocale, + keys, nativeKeys }; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/playlists.js b/packages/meshbay-hub/src/meshbay_hub/static/playlists.js index eec94e8..afcb122 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/playlists.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/playlists.js @@ -4,6 +4,7 @@ import { toStored, fromStored, livePlaylists, repairTracks, indexTracks, } from './playlist-merge.js'; import { derivePlaylistKey, seal, open } from './playlist-crypto.js'; +import * as platform from './platform.js'; import { session, _loadBundleKey, openDB, IDB_PLAYLISTS, } from './hub-client.js'; @@ -108,6 +109,16 @@ async function playlistKey(userId) { bundleKey = await _loadBundleKey(); if (bundleKey) session.bundleKey = bundleKey; } + if (bundleKey && bundleKey.native) { + // The desktop application keeps the master key and hands this one key + // over: it opens nothing but the playlists this page shows anyway. + const raw = await platform.keys.playlistKey(bundleKey.userId); + _key = await crypto.subtle.importKey( + 'raw', Uint8Array.from(atob(raw), c => c.charCodeAt(0)), + { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); + _keyFor = userId; + return _key; + } if (!bundleKey || !bundleKey.v3) return null; _key = await derivePlaylistKey(bundleKey.v3); _keyFor = userId; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js index 7a309a9..cd00f03 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js @@ -4,6 +4,7 @@ import { import { t } from './i18n.js'; import { ask } from './ask.js'; import { Icon } from './icon.js'; +import * as platform from './platform.js'; import { hubFetch, HUB, session, setAuth, _storeBundleKey, _loadBundleKey, _storeRecoveryKey, @@ -143,33 +144,45 @@ export function ProfilePage({ user, onLogout }) { // run at all the account is left untouched. // Both keys from the passphrases, with the pepper this open session asks // for: the old one opens the bundles as they are, the new one seals them. + // In the desktop application both are kept by its main process, the new + // one set aside until the hub has accepted the change. const K = window.MeshBayKeys; const { pepper, version } = await K.fetchBundlePepper(user.token); - const oldKey = await K.deriveBundleSessionKey( + const oldKey = await K.sessionBundleKey( cpOld, user.username, user.userId, pepper, version); - const newKey = await K.deriveBundleSessionKey( - cpNew, user.username, user.userId, pepper, version); - const result = await window.MeshBayTransport.rewrapAllNodes({ - hubUrl: HUB, token: user.token, - username: user.username, userId: user.userId, - bundleKey: oldKey, newBundleKey: newKey, - onProgress: setCpProgress, - }); - - const oldAuthKey = await window.MeshBayKeys.deriveAuthKey(cpOld, user.username); - const newAuthKey = await window.MeshBayKeys.deriveAuthKey(cpNew, user.username); - const resp = await hubFetch('/v1/users/password', { - method: 'POST', token: user.token, - body: { old_auth_key: oldAuthKey, new_auth_key: newAuthKey }, - }); + const newKey = await K.sessionBundleKey( + cpNew, user.username, user.userId, pepper, version, { pending: true }); + let resp; + try { + const result = await window.MeshBayTransport.rewrapAllNodes({ + hubUrl: HUB, token: user.token, + username: user.username, userId: user.userId, + bundleKey: oldKey, newBundleKey: newKey, + onProgress: setCpProgress, + }); + const oldAuthKey = await window.MeshBayKeys.deriveAuthKey(cpOld, user.username); + const newAuthKey = await window.MeshBayKeys.deriveAuthKey(cpNew, user.username); + resp = await hubFetch('/v1/users/password', { + method: 'POST', token: user.token, + body: { old_auth_key: oldAuthKey, new_auth_key: newAuthKey }, + }); + setCpResult(result); + } catch (err) { + if (newKey.native) await platform.keys.dropPending(user.userId); + throw err; + } // Keep this tab signed in with the fresh pair, and move the session's // bundle key forward so the next node connection opens the new bundles. setAuth({ ...user, token: resp.access_token, refreshToken: resp.refresh_token }); - session.bundleKey = newKey; - _storeBundleKey(newKey); + if (newKey.native) { + await platform.keys.commitPending(user.userId); + session.bundleKey = { ...newKey, pending: false }; + } else { + session.bundleKey = newKey; + _storeBundleKey(newKey); + } - setCpResult(result); setCpPhase('done'); } catch (err) { const msg = err.message === 'account_locked' diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js index f17b1b6..1cb248a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js @@ -259,13 +259,11 @@ extendTransport(class { /** * Withdraw our key backup from this node. * - * The counterpart of storeKeypairBundle: turning the setting off has to remove - * what is already stored, not merely stop adding to it — otherwise the blob - * stays on every node the account has ever joined (C4). - * - * Nothing calls this yet, on purpose: it is reserved for `device_policy` - * (docs/MESHBAY_DESIGN.md §3.7, O3). Offered alone, it would strand the next - * browser that signs in to this node. + * The counterpart of storeKeypairBundle: turning browser access off has to + * remove what is already stored, not merely stop adding to it — otherwise + * the blob stays on every node the account has ever joined (C4). Called by + * `settleNodeBundle` only, for an account whose identities the desktop + * application holds. */ async deleteKeypairBundle() { const msg = await this._sendAndWait({ @@ -275,6 +273,45 @@ extendTransport(class { return msg; } + /** + * Leave on this node what should be there, once the connection is made. + * + * In a browser: the bundle of an identity just created, as always — it is + * how the next browser becomes the same person here. In the desktop + * application, which keeps the identity itself: nothing when the account + * has no browser access (and whatever was left before is withdrawn), or the + * identity sealed under the current key when it has — sealed again whenever + * the key changed since, which is what carries a passphrase change to nodes + * that were offline when it happened. + */ + async settleNodeBundle() { + const id = this._identity; + if (!id) return; + if (!id.native) { + if (this._newNodeBundle) { + await this.storeKeypairBundle(this._newNodeBundle, this._newNodeBundleRecovery); + this._newNodeBundle = null; + this._newNodeBundleRecovery = null; + } + return; + } + const P = window.MeshBayPlatform.keys; + const uid = this._userId; + if (!await P.browserAccess(uid)) { + if (this._nodeHasBundle) { + await this.deleteKeypairBundle(); + this._nodeHasBundle = false; + } + return; + } + if (this._nodeHasBundle && id.sealedWith && id.sealedWith === await P.fingerprint(uid)) return; + const sealed = await P.sealBundle(uid, this.nodePk); + await this.storeKeypairBundle(sealed.bundle, null); + await P.markSealed(uid, this.nodePk, sealed.fingerprint); + id.sealedWith = sealed.fingerprint; + this._nodeHasBundle = true; + } + async storeKeypairBundle(bundleEnc, recoveryEnc) { const msg = await this._sendAndWait({ type: 'keypair_bundle_store', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js index e0ae0fe..8bf884c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js @@ -101,6 +101,22 @@ async function rewrapAllNodes(o) { tp.connect(n.node_id, o.token, g.id, null, null, oldKey, o.username, o.userId, null, recoveryKey, undefined, n.pk_node), 30000, 'connect'); + if (tp.identity && tp.identity.native) { + // The desktop application holds this identity: it seals, and only + // for an account with browser access — without it, nothing of the + // identity is on the node to re-seal. + const P = window.MeshBayPlatform.keys; + if (await P.browserAccess(o.userId)) { + const sealed = await P.sealBundle(o.userId, tp.nodePk, + { pending: Boolean(o.newBundleKey && o.newBundleKey.pending) }); + const rec = o.recoveryKey + ? await P.sealRecovery(o.userId, tp.nodePk, o.recoveryKey, o.username) : null; + await tp.storeKeypairBundle(sealed.bundle, rec); + await P.markSealed(o.userId, tp.nodePk, sealed.fingerprint); + } + anyOk = true; + continue; + } if (tp.newNodeBundle) { // No identity existed on this node — connect just minted one under // the old key. Don't persist it: the next time this group is opened 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'); -- cgit v1.2.3