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/test_rewrap_fanout.py | |
| 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/test_rewrap_fanout.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_rewrap_fanout.py | 61 |
1 files changed, 40 insertions, 21 deletions
diff --git a/packages/meshbay-hub/tests/test_rewrap_fanout.py b/packages/meshbay-hub/tests/test_rewrap_fanout.py index b278d0c..79a5bf5 100644 --- a/packages/meshbay-hub/tests/test_rewrap_fanout.py +++ b/packages/meshbay-hub/tests/test_rewrap_fanout.py @@ -46,12 +46,13 @@ eval(process.argv[2].split(require('path').delimiter) .map((p) => fs.readFileSync(p, 'utf8')).join('\n')); const T = window.MeshBayTransport; -let deriveEncCalls = 0; +// Keys are opaque tags here; what is checked is which key sealed what, for +// which account on which node, under which pepper version. window.MeshBayKeys = { - deriveEncryptionKey: async (p) => { deriveEncCalls++; return { kind: 'enc', p }; }, - deriveEncryptionKeyV1: async (p) => ({ kind: 'encv1', p }), - deriveRecoveryKey: async (r) => ({ kind: 'rec', r }), - encryptBundleWithKey: async (_skEd, _skX, key) => 'wrapped:' + key.kind, + deriveRecoveryKey: async (r) => ({ kind: 'rec', r }), + nodeBundleKey: async (key, nodePk) => ({ kind: `${key.kind}@${nodePk}` }), + encryptBundle: async (_skEd, _skX, key, m) => + `wrapped:${key.kind}|${m.userId}|${m.nodePk}|v${m.pepperVersion}`, }; const b64 = (s) => Buffer.from(s).toString('base64'); @@ -65,8 +66,11 @@ const NODES = { const stored = []; const rewrapOnlySeen = []; -T.prototype.connect = async function (nodeId) { +const openedWith = []; +T.prototype.connect = async function (nodeId, _t, _g, _gek, _sk, bundleKey) { this._nodeId = nodeId; + this.nodePk = 'pk-' + nodeId; // what the handshake proves + openedWith.push(bundleKey && bundleKey.kind); rewrapOnlySeen.push(this._rewrapOnly === true); const s = NODES[nodeId] || {}; if (s.throws) throw new Error(s.throws); @@ -110,38 +114,47 @@ global.fetch = async (url) => { const names = (a) => a.map((x) => x.name).sort(); (async () => { + // Flow A — passphrase change: opened with the old key, sealed with the new. const A = await T.rewrapAllNodes({ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid', - oldPassphrase: 'old', newPassphrase: 'new', + bundleKey: { kind: 'old', pepperVersion: 1 }, + newBundleKey: { kind: 'new', pepperVersion: 1 }, }); const storeA = stored.splice(0); + const openedA = openedWith.splice(0); + // Flow B — reset: the session key of the new passphrase opens nothing, the + // recovery copy does, and both copies are sealed again. const B = await T.rewrapAllNodes({ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid', - newPassphrase: 'new', recoveryKey: 'A RECOVERY MNEMONIC', + bundleKey: { kind: 'cur', pepperVersion: 1 }, recoveryKey: 'A RECOVERY MNEMONIC', }); const storeB = stored.splice(0); - // Flow C — Profile backfill: keep the live passphrase key, just add the - // recovery copy. No passphrase strings, so deriveEncryptionKey is not called. - deriveEncCalls = 0; + // Flow C — Profile backfill: keep the live key, just add the recovery copy. const C = await T.rewrapAllNodes({ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid', - bundleKey: { v2: { kind: 'bk' }, v1: { kind: 'bkv1' } }, + bundleKey: { kind: 'bk', pepperVersion: 1 }, recoveryKey: 'A RECOVERY MNEMONIC', }); const storeC = stored.splice(0); + let refusedWithoutKey = false; + try { + await T.rewrapAllNodes({ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid' }); + } catch { refusedWithoutKey = true; } + process.stdout.write(JSON.stringify({ a_updated: names(A.updated), a_unreachable: names(A.unreachable), a_failed: names(A.failed), a_stored_nodes: storeA.map((s) => s.nodeId).sort(), a_recovery_always_null: storeA.every((s) => s.rec === null), - a_new_bundle_key_kind: A.newBundleKey && A.newBundleKey.v2 && A.newBundleKey.v2.kind, + a_stored: storeA.map((s) => s.enc), + a_opened_with: [...new Set(openedA)], b_stored: storeB.map((s) => ({ node: s.nodeId, enc: s.enc, rec: s.rec })), c_stored: storeC.map((s) => ({ node: s.nodeId, enc: s.enc, rec: s.rec })), - c_derive_enc_calls: deriveEncCalls, + refused_without_key: refusedWithoutKey, // Every transport the fan-out builds is flagged rewrap-only, so a stored // bundle it cannot open is reported, not silently replaced with a new one. all_rewrap_only: rewrapOnlySeen.length > 0 && rewrapOnlySeen.every(Boolean), @@ -189,24 +202,30 @@ def test_a_node_that_returns_no_identity_is_a_failure(result): assert "f@ann" in result["a_failed"] -def test_flow_a_writes_only_the_passphrase_copy(result): +def test_flow_a_opens_with_the_old_key_and_seals_with_the_new_for_that_node(result): assert result["a_recovery_always_null"] is True - assert result["a_new_bundle_key_kind"] == "enc" + assert result["a_opened_with"] == ["old"] + assert result["a_stored"] == ["wrapped:new@pk-n-ok|uid|pk-n-ok|v1"] def test_flow_b_writes_both_the_passphrase_and_the_recovery_copy(result): + # The recovery copy owes nothing to the pepper: version 0. assert result["b_stored"] == [ - {"node": "n-ok", "enc": "wrapped:enc", "rec": "wrapped:rec"}, + {"node": "n-ok", "enc": "wrapped:cur@pk-n-ok|uid|pk-n-ok|v1", + "rec": "wrapped:rec|uid|pk-n-ok|v0"}, ] def test_profile_backfill_keeps_the_live_key_and_adds_the_recovery_copy(result): - # bundleKey mode: the passphrase copy is re-wrapped with the same live key - # (kind "bk"), the recovery copy is added, and no passphrase is derived. assert result["c_stored"] == [ - {"node": "n-ok", "enc": "wrapped:bk", "rec": "wrapped:rec"}, + {"node": "n-ok", "enc": "wrapped:bk@pk-n-ok|uid|pk-n-ok|v1", + "rec": "wrapped:rec|uid|pk-n-ok|v0"}, ] - assert result["c_derive_enc_calls"] == 0 + + +def test_there_is_no_passphrase_path_left(result): + """Keys only: a caller that has not derived one with the pepper is refused.""" + assert result["refused_without_key"] is True def test_every_fanout_transport_is_rewrap_only(result): |