""" 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