""" 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"]