aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_playlist_key.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_playlist_key.py')
-rw-r--r--packages/meshbay-hub/tests/test_playlist_key.py221
1 files changed, 221 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_playlist_key.py b/packages/meshbay-hub/tests/test_playlist_key.py
new file mode 100644
index 0000000..dbed8ae
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_playlist_key.py
@@ -0,0 +1,221 @@
+"""
+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()
+ script = tmp_path / "case.mjs"
+ script.write_text(f"{PRELUDE}\n{src}\n{body}\n")
+ out = subprocess.run(["node", str(script)],
+ capture_output=True, text=True, 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