diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-30 15:06:14 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-30 15:06:14 +0200 |
| commit | 91297944791a36f30302ef8c86dd69ebeb177671 (patch) | |
| tree | 568188114baf438059458f1bc87903f4894cec90 /packages/meshbay-hub/src/meshbay_hub/static/playlists.js | |
| parent | a55d40b74bda77dff6ec565abdd551607fc665d6 (diff) | |
| download | meshbay-91297944791a36f30302ef8c86dd69ebeb177671.tar.gz | |
feat: bundles sealed per node under the passphrase and the hub's pepper
The session key is M = HKDF(Argon2(passphrase) || pepper, account id); each
node's bundle key and the playlist key derive from it. Bundles are MBK3, bound
to account and node; MBK1/MBK2 are refused by name, never replaced silently.
Playlists move to key v2 and are re-sealed over unreadable node copies.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/playlists.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/playlists.js | 61 |
1 files changed, 43 insertions, 18 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/playlists.js b/packages/meshbay-hub/src/meshbay_hub/static/playlists.js index bac9b3d..eec94e8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/playlists.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/playlists.js @@ -92,14 +92,14 @@ let _key = null; let _keyFor = null; /** - * The playlist key, derived once per sign-in from the HKDF handle that rides - * alongside the bundle key (`keyderive.js`'s deriveBundleKeys). + * The playlist key, derived once per sign-in from the session's bundle master + * key (`keyderive.js`'s deriveBundleSessionKey). * - * `v2hkdf` is absent when this browser's session predates it — a stored bundle - * key from before the change, loaded out of IndexedDB. There is nothing to do - * about that here and nothing to fall back to: the passphrase is not in memory - * to re-derive from. Playlists stay local until the next sign-in, which is a - * degradation rather than a failure and is reported as one. + * Absent when this browser holds no such key — a session stored before the + * pepper, whose key `_loadBundleKey` no longer returns. There is nothing to + * fall back to: the passphrase is not in memory to re-derive from. Playlists + * stay local until the passphrase is entered again, which is a degradation + * rather than a failure and is reported as one. */ async function playlistKey(userId) { if (_key && _keyFor === userId) return _key; @@ -108,8 +108,8 @@ async function playlistKey(userId) { bundleKey = await _loadBundleKey(); if (bundleKey) session.bundleKey = bundleKey; } - if (!bundleKey || !bundleKey.v2hkdf) return null; - _key = await derivePlaylistKey(bundleKey.v2hkdf); + if (!bundleKey || !bundleKey.v3) return null; + _key = await derivePlaylistKey(bundleKey.v3); _keyFor = userId; return _key; } @@ -574,8 +574,8 @@ async function syncWith(transport, userId) { } const key = await playlistKey(userId); if (!key) { - // No HKDF handle: a session from before it existed. Nothing to fall back - // to, and silently doing nothing would be the worse answer. + // No bundle key in this browser (a session from before the pepper). Nothing + // to fall back to, and silently doing nothing would be the worse answer. result.reason = 'no_key'; return result; } @@ -597,6 +597,7 @@ async function syncWith(transport, userId) { } let theirs = null; + let unreadableManifest = false; if (have.has(MANIFEST_KIND)) { let row = null; try { @@ -620,6 +621,7 @@ async function syncWith(transport, userId) { console.warn('[MeshBay] playlist manifest on this node will not open:', err.message, '— overwriting it with the local copy'); result.unreadable = true; + unreadableManifest = true; theirs = null; } } @@ -647,8 +649,15 @@ async function syncWith(transport, userId) { const kind = bodyKind(p.id); const nodeRev = have.has(kind) ? (have.get(kind) || 0) : -1; const localRev = local.rev || 0; + // A body that will not open is not a newer copy of anything, and the local + // copy goes over it now — at a revision no lower than the node's, so every + // device still sees the node as current. Waiting for the next write left + // it unreadable indefinitely whenever the revisions happened to be equal, + // which after the playlist key changed is every body on every node. A + // manifest that would not open says the same of every body behind it. + let overwrite = unreadableManifest && have.has(kind); - if (nodeRev > localRev) { + if (nodeRev > localRev && !overwrite) { try { const row = await transport.fetchUserBlob(kind); if (row && row.blob_enc) { @@ -660,12 +669,17 @@ async function syncWith(transport, userId) { } } } catch { - // Same reasoning as the manifest above, and already the right shape: - // one body that will not open must not stop the rest, and the local - // copy is pushed over it on the next write to that playlist. + // Same reasoning as the manifest above: one body that will not open + // must not stop the rest. result.unreadable = true; + overwrite = true; } - } else if (localRev > nodeRev && localRev > 0) { + } + if ((overwrite || localRev > nodeRev) && localRev > 0) { + const pushRev = Math.max(localRev, nodeRev); + // The local copy takes the revision it is pushed under, or the next sync + // would see the node ahead and fetch it back every time. + if (pushRev > localRev) await _saveBody(st, { ...local, rev: pushRev }); // Was: one `catch {}` covering both of the cases below. A node that went // away mid-sweep and a playlist that can never be sent are not the same // event, and swallowing the second is the silent loss this whole design @@ -673,7 +687,7 @@ async function syncWith(transport, userId) { // stopped leaving the browser, and nothing anywhere says so. let sealed; try { - sealed = await seal(local, kind, userId, key); + sealed = await seal({ ...local, rev: pushRev }, kind, userId, key); } catch { result.failed.push(p.name); continue; @@ -683,7 +697,7 @@ async function syncWith(transport, userId) { continue; } try { - await transport.storeUserBlob(kind, localRev, sealed); + await transport.storeUserBlob(kind, pushRev, sealed); result.pushed += 1; } catch { // A node that went away mid-sweep: the next sync pushes this, because @@ -704,6 +718,17 @@ async function syncWith(transport, userId) { try { await transport.deleteUserBlob(kind); } catch { /* next time round */ } } + // Behind a manifest that would not open, a body this browser has no playlist + // for is unreadable and unknown: nothing can merge it, and it only fills the + // account's quota on this node. + if (unreadableManifest) { + const known = new Set(Object.keys(merged.playlists).map(bodyKind)); + for (const kind of have.keys()) { + if (kind === MANIFEST_KIND || known.has(kind)) continue; + try { await transport.deleteUserBlob(kind); } catch { /* next time round */ } + } + } + // The manifest goes last, so a node never advertises a body it has not been // given: a reader on a third device would fetch a watermark, ask for the // body behind it and be told there is none. |