diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_bundle_key.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_bundle_key.py | 183 |
1 files changed, 183 insertions, 0 deletions
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"] |