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 | |
| 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')
| -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 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_bundle_kdf_parity.py | 102 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_bundle_key.py | 183 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_bundle_pepper.py | 12 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_playlist_key.py | 221 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_playlist_store.py | 21 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_recovery_key.py | 7 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_rewrap_fanout.py | 61 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_spa_ordering.py | 17 |
10 files changed, 406 insertions, 276 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. diff --git a/packages/meshbay-hub/tests/test_bundle_kdf_parity.py b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py index c62aace..a4bee43 100644 --- a/packages/meshbay-hub/tests/test_bundle_kdf_parity.py +++ b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py @@ -129,3 +129,105 @@ def test_parameters_still_match_the_client(): assert f"ARGON2_TIME = {TIME_COST}" in source assert f"ARGON2_LANES = {LANES}" in source assert "meshbay:bundle:v2:" in source + + +# ── The whole chain: A, the pepper, M, the node key, the playlist key ───────── +# +# The real keyderive.js and playlist-crypto.js, over the real WebAssembly +# Argon2, against a reference written from the specification with nothing +# shared: argon2-cffi, `cryptography`'s HKDF and AES-GCM. Down to opening an +# MBK3 bundle, so the format and its associated data agree too. + +_CHAIN_HARNESS = r""" +const fs = require('fs'), path = require('path'), url = require('url'); +const webcrypto = require('crypto').webcrypto; +global.self = global; global.window = global; global.crypto = webcrypto; +global.Module = { wasmBinary: fs.readFileSync(process.argv[2]) }; +global.argon2 = require(process.argv[3]); +eval(fs.readFileSync(process.argv[4], 'utf8')); +const K = window.MeshBayKeys; +const fp = async (key) => Buffer.from(await webcrypto.subtle.encrypt( + { name: 'AES-GCM', iv: new Uint8Array(12) }, key, new Uint8Array(16))).toString('hex'); +(async () => { + const { derivePlaylistKey } = await import(url.pathToFileURL(process.argv[5]).href); + const out = []; + for (const v of JSON.parse(fs.readFileSync(process.argv[6], 'utf8'))) { + const sk = await K.deriveBundleSessionKey(v.password, v.username, v.user_id, v.pepper, 1); + const kNode = await K.nodeBundleKey(sk, v.node_pk); + const bundle = await K.encryptBundle( + Buffer.from('ed-private'), Buffer.from('x-private'), kNode, + { userId: v.user_id, nodePk: v.node_pk, pepperVersion: 1 }); + out.push({ node: await fp(kNode), playlists: await fp(await derivePlaylistKey(sk.v3)), + bundle }); + } + process.stdout.write(JSON.stringify(out)); +})().catch((e) => { console.error(e); process.exit(1); }); +""" + +CHAIN = [ + {"username": "alice", "password": "correct horse battery staple", + "user_id": "0b4f6f0e-5d7e-4e8a-9d2b-6a1c1b9e2f11", "node_pk": "Tm9kZUtleUE="}, + {"username": "utilisateur-é", "password": "üñïçø∂é ✓ 🔐", + "user_id": "7d1e0c2a-3b4c-4d5e-8f60-718293a4b5c6", "node_pk": "Tm9kZUtleUI="}, +] + + +def _hkdf(ikm: bytes, info: str) -> bytes: + from cryptography.hazmat.primitives.hashes import SHA256 + from cryptography.hazmat.primitives.kdf.hkdf import HKDF + return HKDF(algorithm=SHA256(), length=32, salt=None, info=info.encode()).derive(ikm) + + +def _fp(key: bytes) -> str: + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + return AESGCM(key).encrypt(bytes(12), bytes(16), None).hex() + + +@pytest.fixture(scope="module") +def chain(tmp_path_factory): + import base64 + d = tmp_path_factory.mktemp("chain") + vectors = [{**v, "pepper": base64.b64encode(bytes([i + 1]) * 32).decode()} + for i, v in enumerate(CHAIN)] + (d / "harness.cjs").write_text(_CHAIN_HARNESS, encoding="utf-8") + (d / "vectors.json").write_text(json.dumps(vectors), encoding="utf-8") + proc = subprocess.run( + ["node", str(d / "harness.cjs"), str(VENDOR / "argon2.wasm"), + str(VENDOR / "argon2.min.js"), str(STATIC / "keyderive.js"), + str(STATIC / "playlist-crypto.js"), str(d / "vectors.json")], + capture_output=True, text=True, encoding="utf-8", timeout=300) + if proc.returncode != 0: + pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}") + return vectors, json.loads(proc.stdout) + + +def _reference(v: dict) -> tuple[bytes, bytes]: + import base64 + a = bytes.fromhex(_python_hash(v["username"], v["password"])) + m = _hkdf(a + base64.b64decode(v["pepper"]), f"meshbay:bundle-master:v3|{v['user_id']}") + return (_hkdf(m, f"meshbay:bundle:v3|node|{v['node_pk']}"), + _hkdf(m, "meshbay:playlists:v2")) + + +def test_the_node_and_playlist_keys_match_across_languages(chain): + vectors, js = chain + for v, got in zip(vectors, js): + k_node, k_pl = _reference(v) + assert got["node"] == _fp(k_node), f"node key disagrees for {v['username']!r}" + assert got["playlists"] == _fp(k_pl), f"playlist key disagrees for {v['username']!r}" + + +def test_an_mbk3_bundle_opens_from_the_specification(chain): + """Magic, pepper version, nonce, AES-GCM with the account and node as + associated data — read back by code that shares nothing with the writer.""" + import base64 + + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + vectors, js = chain + for v, got in zip(vectors, js): + raw = base64.b64decode(got["bundle"]) + assert raw[:4] == b"MBK3" and raw[4] == 1 + aad = f"meshbay:bundle:v3|{v['user_id']}|{v['node_pk']}".encode() + plain = json.loads(AESGCM(_reference(v)[0]).decrypt(raw[5:17], raw[17:], aad)) + assert base64.b64decode(plain["skEd"]) == b"ed-private" + assert base64.b64decode(plain["skX"]) == b"x-private" diff --git a/packages/meshbay-hub/tests/test_bundle_key.py b/packages/meshbay-hub/tests/test_bundle_key.py new file mode 100644 index 0000000..333f8f8 --- /dev/null +++ b/packages/meshbay-hub/tests/test_bundle_key.py @@ -0,0 +1,183 @@ +""" +The bundle key: one Argon2 run, the hub's pepper, one key per node. + +What a node stores — an identity bundle, a playlist blob — is sealed under keys +derived from `M = HKDF(A ‖ pepper, account id)`, where `A` is the passphrase's +Argon2id and the pepper is held by the hub (docs/MESHBAY_DESIGN.md §3.7). Each +of these is quiet when wrong: + + - **One Argon2id run per sign-in.** The budget is the ~650 ms already on that + path; a second run doubles it and nothing on screen says so. + - **The pepper and the account are in the key.** Without the pepper, the + operator holding a bundle can test passphrase guesses again. + - **One key per node, and a bundle bound to its node and account.** A leaked + node key, or a bundle copied elsewhere, opens nothing else. + - **The playlist key is the same on every device of one account**, and is not + any node's key. + - **An earlier format is refused, by name** — never opened, never guessed at. + +Node's WebCrypto is the real implementation here; only Argon2 is stubbed, and +stubbed precisely so the calls can be counted. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +KEYDERIVE = STATIC / "keyderive.js" +PLAYLIST_CRYPTO = STATIC / "playlist-crypto.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not KEYDERIVE.exists(), + reason="node or the SPA sources are not available") + +# keyderive.js assigns `window.MeshBayKeys` and reads `window.argon2`; node has +# neither, and a counted stub is the whole point. +PRELUDE = """ +globalThis.window = globalThis; +let argonCalls = 0; +globalThis.argon2 = { + ArgonType: { Argon2id: 2 }, + async hash(opts) { + argonCalls++; + // Deterministic, and a function of what was actually passed, so a changed + // salt domain or cost parameter shows up as different bytes rather than + // silently agreeing. + const seed = new TextEncoder().encode( + opts.pass + ':' + Array.from(opts.salt).join(',') + ':' + opts.time); + const digest = new Uint8Array( + await crypto.subtle.digest('SHA-256', seed)); + return { hash: digest }; + }, +}; +""" + + +def _run(tmp_path, body): + src = KEYDERIVE.read_text(encoding="utf-8") + script = tmp_path / "case.mjs" + helpers = ( + "const K = () => window.MeshBayKeys;\n" + "const PEPPER = btoa(String.fromCharCode(...new Uint8Array(32).fill(7)));\n" + "const OTHER_PEPPER = btoa(String.fromCharCode(...new Uint8Array(32).fill(9)));\n" + "// Same key <=> same bytes out of a fixed encryption.\n" + "const fp = async (key) => btoa(String.fromCharCode(...new Uint8Array(\n" + " await crypto.subtle.encrypt({ name: 'AES-GCM', iv: new Uint8Array(12) }, key,\n" + " new Uint8Array(16)))));\n" + f"const {{ derivePlaylistKey }} = await import('{PLAYLIST_CRYPTO.as_uri()}');\n" + ) + script.write_text(f"{PRELUDE}\n{src}\n{helpers}\n{body}\n", encoding="utf-8") + out = subprocess.run(["node", str(script)], + capture_output=True, text=True, encoding="utf-8", timeout=60) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +def test_a_sign_in_runs_argon2_exactly_once(tmp_path): + out = _run(tmp_path, """ + argonCalls = 0; + const key = await K().deriveBundleSessionKey('passphrase', 'someone', 'uid-1', PEPPER, 1); + console.log(JSON.stringify({ + calls: argonCalls, alg: key.v3.algorithm.name, + extractable: key.v3.extractable, version: key.pepperVersion, + })); + """) + assert out["calls"] == 1, "a second Argon2id run doubles the sign-in cost" + assert out["alg"] == "HKDF" and out["extractable"] is False + assert out["version"] == 1 + + +def test_without_the_pepper_there_is_no_key(tmp_path): + out = _run(tmp_path, """ + let refused = false; + try { await K().deriveBundleSessionKey('p', 'someone', 'uid-1', null, 1); } + catch { refused = true; } + console.log(JSON.stringify({ refused })); + """) + assert out["refused"], "a key derived from the passphrase alone is what a node could attack" + + +def test_the_pepper_and_the_account_are_part_of_the_key(tmp_path): + out = _run(tmp_path, """ + const node = async (pepper, uid) => fp(await K().nodeBundleKey( + await K().deriveBundleSessionKey('same passphrase', 'someone', uid, pepper, 1), 'NODE')); + const base = await node(PEPPER, 'uid-1'); + console.log(JSON.stringify({ + again: base === await node(PEPPER, 'uid-1'), + other_pepper: base !== await node(OTHER_PEPPER, 'uid-1'), + other_account: base !== await node(PEPPER, 'uid-2'), + })); + """) + assert out == {"again": True, "other_pepper": True, "other_account": True} + + +def test_a_bundle_opens_on_its_node_for_its_account_only(tmp_path): + out = _run(tmp_path, """ + const sk = await K().deriveBundleSessionKey('p', 'someone', 'uid-1', PEPPER, 1); + const kA = await K().nodeBundleKey(sk, 'NODE-A'); + const kB = await K().nodeBundleKey(sk, 'NODE-B'); + const sealed = await K().encryptBundle(new Uint8Array([1]), new Uint8Array([2]), kA, + { userId: 'uid-1', nodePk: 'NODE-A', pepperVersion: 1 }); + const opens = async (key, meta) => { + try { await K().decryptBundle(sealed, key, meta); return true; } catch { return false; } + }; + console.log(JSON.stringify({ + format: K().bundleFormat(sealed), + magic: atob(sealed).slice(0, 4), version: atob(sealed).charCodeAt(4), + right: await opens(kA, { userId: 'uid-1', nodePk: 'NODE-A' }), + other_node_key: await opens(kB, { userId: 'uid-1', nodePk: 'NODE-B' }), + moved_to_other_node: await opens(kA, { userId: 'uid-1', nodePk: 'NODE-B' }), + served_for_other_account: await opens(kA, { userId: 'uid-2', nodePk: 'NODE-A' }), + })); + """) + assert out["format"] == "current" and out["magic"] == "MBK3" and out["version"] == 1 + assert out["right"] is True + assert out["other_node_key"] is False + assert out["moved_to_other_node"] is False + assert out["served_for_other_account"] is False + + +def test_an_earlier_format_is_refused_by_name(tmp_path): + """Sealed under the passphrase alone. Never opened, and the refusal says why + — the caller must not take it for an absent bundle and mint a new one.""" + out = _run(tmp_path, """ + const sk = await K().deriveBundleSessionKey('p', 'someone', 'uid-1', PEPPER, 1); + const k = await K().nodeBundleKey(sk, 'NODE'); + const results = []; + for (const old of [btoa('MBK2' + 'x'.repeat(40)), btoa('y'.repeat(44))]) { + let code = null; + try { await K().decryptBundle(old, k, { userId: 'uid-1', nodePk: 'NODE' }); } + catch (e) { code = e.code || null; } + results.push([K().bundleFormat(old), code]); + } + console.log(JSON.stringify(results)); + """) + assert out == [["retired", "bundle_format_retired"], ["retired", "bundle_format_retired"]] + + +def test_two_devices_of_one_account_derive_the_same_playlist_key(tmp_path): + """The one key an account must hold everywhere: node keys differ per node, + and a playlist is read from any of them.""" + out = _run(tmp_path, """ + const pl = async (uid) => fp(await derivePlaylistKey((await K().deriveBundleSessionKey( + 'same passphrase', 'someone', uid, PEPPER, 1)).v3)); + const a = await pl('uid-1'); + console.log(JSON.stringify({ same: a === await pl('uid-1'), + other_account: a !== await pl('uid-2') })); + """) + assert out == {"same": True, "other_account": True} + + +def test_the_playlist_key_is_no_node_key(tmp_path): + out = _run(tmp_path, """ + const sk = await K().deriveBundleSessionKey('p', 'someone', 'uid-1', PEPPER, 1); + console.log(JSON.stringify({ + distinct: await fp(await derivePlaylistKey(sk.v3)) + !== await fp(await K().nodeBundleKey(sk, 'NODE')), + })); + """) + assert out["distinct"] diff --git a/packages/meshbay-hub/tests/test_bundle_pepper.py b/packages/meshbay-hub/tests/test_bundle_pepper.py index e7b126f..e99799f 100644 --- a/packages/meshbay-hub/tests/test_bundle_pepper.py +++ b/packages/meshbay-hub/tests/test_bundle_pepper.py @@ -105,18 +105,6 @@ async def test_an_open_session_may_ask_and_a_node_may_not(client): @pytest.mark.asyncio -async def test_a_passphrase_change_carries_it(client): - """The new passphrase makes a new bundle key, and the client does not keep - the pepper to re-seal under it.""" - await _register(client, "pepper_chg") - login = await _login(client, "pepper_chg") - r = await client.post("/v1/users/password", headers=_bearer(login["access_token"]), - json={"old_auth_key": KEY, "new_auth_key": "n" * 44}) - assert r.status_code == 200, r.text - assert r.json()["bundle_pepper"] == login["bundle_pepper"] - - -@pytest.mark.asyncio async def test_it_is_sealed_at_rest_and_bound_to_its_account(client, db_session): await _register(client, "pepper_rest") login = await _login(client, "pepper_rest") diff --git a/packages/meshbay-hub/tests/test_playlist_key.py b/packages/meshbay-hub/tests/test_playlist_key.py deleted file mode 100644 index 261cc32..0000000 --- a/packages/meshbay-hub/tests/test_playlist_key.py +++ /dev/null @@ -1,221 +0,0 @@ -""" -The playlist key: one Argon2 run, two handles, one subkey. - -Identity keys are per node, so a blob encrypted under one is unreadable from -every other node — the precise opposite of what a playlist needs. The only -secret an account holds *everywhere* is the bundle key, so the playlist key is -derived from it with HKDF (docs/playlists.md §3.4). - -Three things have to hold, and getting any of them wrong is quiet: - - - **One Argon2id run per sign-in.** The budget is the ~650 ms already on that - path. A second call is mathematically pointless and doubles it, and nothing - on screen would say so. - - **The HKDF handle is a second import of the same bytes**, not a derivation - from the AES one — that is imported non-extractably with - `['encrypt','decrypt']`, from which nothing can be derived at all. - - **A purpose-separated subkey**, not the bundle key with a different AAD. - `groupbox.py` writes that rule down for chunk keys; it is the same rule. - -Node's WebCrypto is the real implementation here; only Argon2 is stubbed, and -stubbed precisely so the calls can be counted. -""" - -import json -import shutil -import subprocess -from pathlib import Path - -import pytest - -STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" -KEYDERIVE = STATIC / "keyderive.js" - -pytestmark = pytest.mark.skipif( - shutil.which("node") is None or not KEYDERIVE.exists(), - reason="node or the SPA sources are not available") - -# keyderive.js assigns `window.MeshBayKeys` and reads `window.argon2`; node has -# neither, and a counted stub is the whole point. -PRELUDE = """ -globalThis.window = globalThis; -let argonCalls = 0; -globalThis.argon2 = { - ArgonType: { Argon2id: 2 }, - async hash(opts) { - argonCalls++; - // Deterministic, and a function of what was actually passed, so a changed - // salt domain or cost parameter shows up as different bytes rather than - // silently agreeing. - const seed = new TextEncoder().encode( - opts.pass + ':' + Array.from(opts.salt).join(',') + ':' + opts.time); - const digest = new Uint8Array( - await crypto.subtle.digest('SHA-256', seed)); - return { hash: digest }; - }, -}; -""" - - -def _run(tmp_path, body): - src = KEYDERIVE.read_text(encoding="utf-8") - script = tmp_path / "case.mjs" - script.write_text(f"{PRELUDE}\n{src}\n{body}\n", encoding="utf-8") - out = subprocess.run(["node", str(script)], - capture_output=True, text=True, encoding="utf-8", timeout=60) - assert out.returncode == 0, out.stderr - return json.loads(out.stdout) - - -def test_a_sign_in_runs_argon2_exactly_once(tmp_path): - """The budget is the 650 ms already on the sign-in path. Two handles over - one run is the whole point of `deriveBundleKeys`.""" - out = _run(tmp_path, """ - argonCalls = 0; - const keys = await deriveBundleKeys('passphrase', 'someone'); - console.log(JSON.stringify({ - calls: argonCalls, - aes: keys.aes.algorithm.name, - hkdf: keys.hkdf.algorithm.name, - })); - """) - assert out["calls"] == 1, "a second Argon2id run doubles the sign-in cost" - assert out["aes"] == "AES-GCM" - assert out["hkdf"] == "HKDF" - - -def test_the_bundle_key_fields_a_session_carries_are_built_in_one_run(tmp_path): - """Both places that build a `session.bundleKey` go through this, so - `v2hkdf` cannot be the field one sign-in path forgot.""" - out = _run(tmp_path, """ - argonCalls = 0; - const fields = await window.MeshBayKeys.bundleKeyPairFields('p', 'someone'); - console.log(JSON.stringify({ - calls: argonCalls, - keys: Object.keys(fields).sort(), - v2: fields.v2.algorithm.name, - v2hkdf: fields.v2hkdf.algorithm.name, - })); - """) - assert out["calls"] == 1 - assert out["keys"] == ["v2", "v2hkdf"] - assert out["v2"] == "AES-GCM" and out["v2hkdf"] == "HKDF" - - -def test_the_aes_handle_is_unchanged_by_the_hkdf_one(tmp_path): - """`deriveEncryptionKey` still returns exactly what it always did — every - identity bundle already written is opened with it.""" - out = _run(tmp_path, """ - const legacy = await deriveEncryptionKey('p', 'someone'); - const paired = (await deriveBundleKeys('p', 'someone')).aes; - const data = new TextEncoder().encode('a keypair bundle'); - const iv = new Uint8Array(12); - const ct = await crypto.subtle.encrypt({name:'AES-GCM', iv}, legacy, data); - const back = await crypto.subtle.decrypt({name:'AES-GCM', iv}, paired, ct); - console.log(JSON.stringify({ - same: new TextDecoder().decode(back) === 'a keypair bundle', - extractable: legacy.extractable, - })); - """) - assert out["same"], "the paired AES handle is not the same key as before" - assert out["extractable"] is False - - -def test_the_playlist_key_is_a_subkey_and_not_the_bundle_key(tmp_path): - """Derived under its own `info`, so what opens a playlist opens nothing - else — and cannot be produced from the AES handle at all.""" - out = _run(tmp_path, """ - const { aes, hkdf } = await deriveBundleKeys('p', 'someone'); - const playlistKey = await crypto.subtle.deriveKey( - { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), - info: new TextEncoder().encode('meshbay:playlists:v1') }, - hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); - - const iv = new Uint8Array(12); - const ct = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, playlistKey, new TextEncoder().encode('tracks')); - - // The bundle key must not open what the playlist key sealed. - let bundleOpens = true; - try { await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aes, ct); } - catch { bundleOpens = false; } - - // And nothing can be derived from the AES handle, which is why the HKDF - // one has to be a second import rather than a derivation. - let derivable = true; - try { - await crypto.subtle.deriveKey( - { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), - info: new Uint8Array(0) }, - aes, { name: 'AES-GCM', length: 256 }, false, ['encrypt']); - } catch { derivable = false; } - - console.log(JSON.stringify({ bundleOpens, derivable })); - """) - assert out["bundleOpens"] is False, ( - "the playlist key is the bundle key — purpose separation is gone") - assert out["derivable"] is False, ( - "if the AES handle were derivable the second import would be needless; " - "it is not, which is exactly why deriveBundleKeys imports twice") - - -def test_a_different_info_gives_a_different_key(tmp_path): - """What makes it a *purpose*-separated subkey rather than a rename.""" - out = _run(tmp_path, """ - const { hkdf } = await deriveBundleKeys('p', 'someone'); - const mk = (info) => crypto.subtle.deriveKey( - { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), - info: new TextEncoder().encode(info) }, - hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); - const a = await mk('meshbay:playlists:v1'); - const b = await mk('meshbay:something-else:v1'); - const iv = new Uint8Array(12); - const ct = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, a, new TextEncoder().encode('x')); - let opens = true; - try { await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, b, ct); } - catch { opens = false; } - console.log(JSON.stringify({ opens })); - """) - assert out["opens"] is False - - -def test_two_devices_of_one_account_derive_the_same_playlist_key(tmp_path): - """The whole point, and the reason the nonce must be random rather than a - counter: two devices derive the *same* key, so a counter would repeat.""" - out = _run(tmp_path, """ - const mk = async () => { - const { hkdf } = await deriveBundleKeys('same passphrase', 'someone'); - return crypto.subtle.deriveKey( - { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), - info: new TextEncoder().encode('meshbay:playlists:v1') }, - hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); - }; - const iv = crypto.getRandomValues(new Uint8Array(12)); - const ct = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, await mk(), new TextEncoder().encode('Evening')); - const back = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, await mk(), ct); - console.log(JSON.stringify({ text: new TextDecoder().decode(back) })); - """) - assert out["text"] == "Evening" - - -def test_a_different_account_derives_a_different_key(tmp_path): - """The salt is domain-separated per user; this is what that buys.""" - out = _run(tmp_path, """ - const mk = async (user) => { - const { hkdf } = await deriveBundleKeys('p', user); - return crypto.subtle.deriveKey( - { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), - info: new TextEncoder().encode('meshbay:playlists:v1') }, - hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); - }; - const iv = new Uint8Array(12); - const ct = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, await mk('alice'), new TextEncoder().encode('x')); - let opens = true; - try { await crypto.subtle.decrypt({ name:'AES-GCM', iv }, await mk('bob'), ct); } - catch { opens = false; } - console.log(JSON.stringify({ opens })); - """) - assert out["opens"] is False diff --git a/packages/meshbay-hub/tests/test_playlist_store.py b/packages/meshbay-hub/tests/test_playlist_store.py index 701de41..3c1d109 100644 --- a/packages/meshbay-hub/tests/test_playlist_store.py +++ b/packages/meshbay-hub/tests/test_playlist_store.py @@ -272,16 +272,25 @@ def test_a_node_holding_something_unreadable_does_not_wedge_the_sync(steps): assert "Depuis le menu" in s["names"] -def test_a_session_from_before_the_hkdf_handle_degrades_rather_than_failing(steps): - """A bundle key loaded out of IndexedDB from before `deriveBundleKeys` - existed has no HKDF handle, and the passphrase is not in memory to - re-derive from. Playlists stay local until the next sign-in — reported, - rather than silently doing nothing.""" - r = steps["a session from before the HKDF handle"]["result"] +def test_a_session_from_before_the_pepper_degrades_rather_than_failing(steps): + """A bundle key from before the pepper opens nothing, and the passphrase + is not in memory to re-derive from. Playlists stay local until it is + entered again — reported, rather than silently doing nothing.""" + r = steps["a session from before the pepper"]["result"] assert r["ok"] is False and r["reason"] == "no_key" assert r["pushed"] == 0 +def test_a_node_sealed_under_the_previous_key_is_sealed_again(steps): + """After the playlist key changed, every row on a node has the revision the + local copy has. All of it is sealed again under the current key, no + revision goes down, and a body nothing here can name is dropped.""" + s = steps["a node sealed under the previous key"] + assert s["readable"] == s["kinds"], "a row is still sealed under the old key" + assert s["remaining"] == s["kinds"], "the unknown body was not dropped" + assert s["revsNotLowered"] is True + + def test_what_a_playlist_costs_sealed_is_measured_not_quoted(steps): """The ceiling the UI promises comes from here, not from the design doc. diff --git a/packages/meshbay-hub/tests/test_recovery_key.py b/packages/meshbay-hub/tests/test_recovery_key.py index e43e6de..c574597 100644 --- a/packages/meshbay-hub/tests/test_recovery_key.py +++ b/packages/meshbay-hub/tests/test_recovery_key.py @@ -84,10 +84,11 @@ const fp = async (key) => hex(await webcrypto.subtle.encrypt( const kB = await K.deriveRecoveryKey(raw, 'acc-B'); const skEd = new Uint8Array([1, 2, 3]); const skX = new Uint8Array([4, 5, 6]); - const blob = await K.encryptBundleWithKey(skEd, skX, kA); + const sealedFor = { userId: 'acc-A', nodePk: 'node-key' }; + const blob = await K.encryptBundle(skEd, skX, kA, { ...sealedFor, pepperVersion: 0 }); let wrongRejected = false; - try { await K.decryptBundleWithKey(blob, kB); } catch { wrongRejected = true; } - const opened = await K.decryptBundleWithKey(blob, kA); + try { await K.decryptBundle(blob, kB, sealedFor); } catch { wrongRejected = true; } + const opened = await K.decryptBundle(blob, kA, sealedFor); out.recovery_wrap_isolates = wrongRejected && opened.skEd === btoa(String.fromCharCode(1, 2, 3)) && opened.skX === btoa(String.fromCharCode(4, 5, 6)); 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): diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py index fdedaf8..6f28aaa 100644 --- a/packages/meshbay-hub/tests/test_spa_ordering.py +++ b/packages/meshbay-hub/tests/test_spa_ordering.py @@ -91,6 +91,23 @@ def test_keys_are_recovered_before_the_join_is_attempted(): "browser that did not register has no key to sign the join with") +def test_a_bundle_is_opened_with_the_key_the_node_has_already_proved(): + """ + A bundle is sealed for one account on one node: its key derives from the + node's public key and its associated data names both. That key has to be + the one the challenge signature proved — recorded before the bundle is + fetched — or a bundle would be opened, or a new one sealed, for nothing. + """ + proved, user, sealed_for, fetch = _positions( + "this.nodePk = reply.node_pk", + "this._userId = userId", + "const sealedFor = { userId: this._userId, nodePk: this.nodePk }", + "type: 'keypair_bundle_fetch'", + ) + assert proved < sealed_for < fetch + assert user < sealed_for + + def test_the_ack_still_verifies_the_announced_node_key(): """ Taking node_pk from the challenge is only safe because the ack proves it and |