diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_playlist_key.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_playlist_key.py | 221 |
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 |