""" 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 == [["legacy", "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"] def test_an_mbk2_bundle_is_opened_once_and_sealed_again_as_mbk3(tmp_path): """ TRANSITIONAL. Nodes still hold bundles sealed under the passphrase's Argon2 key alone. The same Argon2 run that makes `M` makes that key, so the session keeps it — decrypt only — and the identity is moved to MBK3 on the account's next visit instead of the member being re-invited. """ out = _run(tmp_path, """ argonCalls = 0; const sk = await K().deriveBundleSessionKey('p', 'someone', 'uid-1', PEPPER, 1); const calls = argonCalls; // An MBK2 bundle as 0.16 wrote it: "MBK2" ‖ nonce ‖ AES-GCM(A), no AAD. const a = await crypto.subtle.importKey('raw', await _bundleKeyBytes('p', 'someone'), { name: 'AES-GCM' }, false, ['encrypt']); const nonce = new Uint8Array(12).fill(3); const plain = new TextEncoder().encode(JSON.stringify({ skEd: btoa('ED'), skX: btoa('XX') })); const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, a, plain)); const raw = new Uint8Array(4 + 12 + ct.length); raw.set(new TextEncoder().encode('MBK2')); raw.set(nonce, 4); raw.set(ct, 16); const mbk2 = btoa(String.fromCharCode(...raw)); const keys = await K().decryptLegacyBundle(mbk2, sk.legacy); const resealed = await K().resealLegacyIdentity(keys, sk, null, { userId: 'uid-1', nodePk: 'NODE' }); const back = await K().decryptBundle(resealed.bundleEnc, await K().nodeBundleKey(sk, 'NODE'), { userId: 'uid-1', nodePk: 'NODE' }); let otherPassphrase = 'opened'; const sk2 = await K().deriveBundleSessionKey('another', 'someone', 'uid-1', PEPPER, 1); try { await K().decryptLegacyBundle(mbk2, sk2.legacy); } catch { otherPassphrase = 'refused'; } let sealsUnderLegacy = 'yes'; try { await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, sk.legacy, plain); } catch { sealsUnderLegacy = 'no'; } console.log(JSON.stringify({ calls, format: K().bundleFormat(mbk2), keys, newFormat: K().bundleFormat(resealed.bundleEnc), back, otherPassphrase, sealsUnderLegacy, extractable: sk.legacy.extractable, })); """) assert out["calls"] == 1, "keeping the legacy key must not cost a second Argon2 run" assert out["format"] == "legacy" assert out["keys"] == {"skEd": "RUQ=", "skX": "WFg="} assert out["newFormat"] == "current" assert out["back"] == out["keys"] assert out["otherPassphrase"] == "refused" assert out["sealsUnderLegacy"] == "no", "the legacy key opens; it never seals" assert out["extractable"] is False