aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_bundle_key.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_bundle_key.py')
-rw-r--r--packages/meshbay-hub/tests/test_bundle_key.py183
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"]