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/tests/harness | |
| 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/tests/harness')
| -rwxr-xr-x | packages/meshbay-hub/tests/harness/playlist_store_probe.py | 51 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/harness/playlist_ui_probe.py | 7 |
2 files changed, 45 insertions, 13 deletions
diff --git a/packages/meshbay-hub/tests/harness/playlist_store_probe.py b/packages/meshbay-hub/tests/harness/playlist_store_probe.py index 9a54a0d..857cf1e 100755 --- a/packages/meshbay-hub/tests/harness/playlist_store_probe.py +++ b/packages/meshbay-hub/tests/harness/playlist_store_probe.py @@ -80,15 +80,15 @@ function fakeNode() { (async () => { const fail = (why) => parent.postMessage({ error: why, logs: LOGS.slice(0, 10) }, '*'); try { - // A real HKDF handle over fixed bytes, as `deriveBundleKeys` would produce - // — the point is that playlists.js gets its key the way it really does. + // A real master key over fixed bytes, the shape `deriveBundleSessionKey` + // produces — the point is that playlists.js gets its key the way it + // really does. const raw = new Uint8Array(32).fill(5); session.bundleKey = { - v2: await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, - ['encrypt', 'decrypt']), - v2hkdf: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']), + v3: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey', 'deriveBits']), + pepperVersion: 1, }; - const key = await derivePlaylistKey(session.bundleKey.v2hkdf); + const key = await derivePlaylistKey(session.bundleKey.v3); // ── local editing ────────────────────────────────────────────────────── const eveningId = await P.createPlaylist(USER, 'Soirée'); @@ -364,6 +364,39 @@ function fakeNode() { names: readBack ? Object.values(readBack.playlists).map((p) => p.name).sort() : [], }); + // ── a node sealed under the previous playlist key ───────────────────── + // + // The key changed, not the playlists: every row on such a node carries the + // revision the local copy has, so "push only when the node is behind" + // would leave all of it unreadable for ever. Built from what a clean sync + // stores, then every row resealed under another key, plus a body for a + // playlist this browser has never heard of. + const clean = fakeNode(); + await P.syncWith(clean, USER); + const oldKeyNode = fakeNode(); + for (const [kind, r] of clean.rows) { + oldKeyNode.rows.set(kind, { rev: r.rev, blob: await seal( + kind === MANIFEST_KIND ? { v: 1, rev: r.rev, playlists: {} } + : { id: kind, rev: r.rev, tracks: [] }, + kind, USER, junkKey) }); + } + oldKeyNode.rows.set(bodyKind('never-seen-here'), { rev: 3, blob: await seal( + { id: 'never-seen-here', rev: 3, tracks: [] }, bodyKind('never-seen-here'), USER, junkKey) }); + const rekeyResult = await P.syncWith(oldKeyNode, USER); + const readable = []; + for (const [kind, r] of oldKeyNode.rows) { + try { await open(r.blob, kind, USER, key); readable.push(kind); } catch { /* not */ } + } + steps.push({ + step: 'a node sealed under the previous key', + result: rekeyResult, + kinds: [...clean.rows.keys()].sort(), + readable: readable.sort(), + remaining: [...oldKeyNode.rows.keys()].sort(), + revsNotLowered: [...clean.rows].every(([k, r]) => + (oldKeyNode.rows.get(k) || { rev: -1 }).rev >= r.rev), + }); + // ── what a playlist costs, sealed ───────────────────────────────────── // // The cap below is in bytes, but the only number a reader can act on is a @@ -449,12 +482,12 @@ function fakeNode() { // playlist is not a broken sync. stored: bigNode.stored.map((e) => ({ kind: e.kind, bytes: e.bytes })) }); - // ── a session with no HKDF handle degrades rather than failing ───────── + // ── a session with no bundle key degrades rather than failing ────────── P.setPlaylistTransport(null); P.forgetPlaylistKey(); - session.bundleKey = { v2: session.bundleKey.v2 }; // pre-change session + session.bundleKey = { v2: 'a key from before the pepper' }; // pre-change session const r3 = await P.syncWith(fakeNode(), USER); - steps.push({ step: 'a session from before the HKDF handle', result: r3 }); + steps.push({ step: 'a session from before the pepper', result: r3 }); parent.postMessage({ steps, logs: LOGS.slice(0, 8) }, '*'); } catch (err) { diff --git a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py index c705244..6e3be1e 100644 --- a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py +++ b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py @@ -173,12 +173,11 @@ const clickMenu = async (i) => { try { await initLocale(); - // A real HKDF handle, so the store derives its key the way it really does. + // A real master key, so the store derives its key the way it really does. const raw = new Uint8Array(32).fill(3); session.bundleKey = { - v2: await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, - ['encrypt', 'decrypt']), - v2hkdf: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']), + v3: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey', 'deriveBits']), + pepperVersion: 1, }; // Deleting a playlist asks, in the page (ask.js) — so the probe answers the // dialog the way a person would, by clicking its OK button. |