aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 15:06:14 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 15:06:14 +0200
commit91297944791a36f30302ef8c86dd69ebeb177671 (patch)
tree568188114baf438059458f1bc87903f4894cec90 /packages/meshbay-hub/tests
parenta55d40b74bda77dff6ec565abdd551607fc665d6 (diff)
downloadmeshbay-91297944791a36f30302ef8c86dd69ebeb177671.tar.gz
feat: bundles sealed per node under the passphrase and the hub's pepper
The session key is M = HKDF(Argon2(passphrase) || pepper, account id); each node's bundle key and the playlist key derive from it. Bundles are MBK3, bound to account and node; MBK1/MBK2 are refused by name, never replaced silently. Playlists move to key v2 and are re-sealed over unreadable node copies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests')
-rwxr-xr-xpackages/meshbay-hub/tests/harness/playlist_store_probe.py51
-rw-r--r--packages/meshbay-hub/tests/harness/playlist_ui_probe.py7
-rw-r--r--packages/meshbay-hub/tests/test_bundle_kdf_parity.py102
-rw-r--r--packages/meshbay-hub/tests/test_bundle_key.py183
-rw-r--r--packages/meshbay-hub/tests/test_bundle_pepper.py12
-rw-r--r--packages/meshbay-hub/tests/test_playlist_key.py221
-rw-r--r--packages/meshbay-hub/tests/test_playlist_store.py21
-rw-r--r--packages/meshbay-hub/tests/test_recovery_key.py7
-rw-r--r--packages/meshbay-hub/tests/test_rewrap_fanout.py61
-rw-r--r--packages/meshbay-hub/tests/test_spa_ordering.py17
10 files changed, 406 insertions, 276 deletions
diff --git a/packages/meshbay-hub/tests/harness/playlist_store_probe.py b/packages/meshbay-hub/tests/harness/playlist_store_probe.py
index 9a54a0d..857cf1e 100755
--- a/packages/meshbay-hub/tests/harness/playlist_store_probe.py
+++ b/packages/meshbay-hub/tests/harness/playlist_store_probe.py
@@ -80,15 +80,15 @@ function fakeNode() {
(async () => {
const fail = (why) => parent.postMessage({ error: why, logs: LOGS.slice(0, 10) }, '*');
try {
- // A real HKDF handle over fixed bytes, as `deriveBundleKeys` would produce
- // — the point is that playlists.js gets its key the way it really does.
+ // A real master key over fixed bytes, the shape `deriveBundleSessionKey`
+ // produces — the point is that playlists.js gets its key the way it
+ // really does.
const raw = new Uint8Array(32).fill(5);
session.bundleKey = {
- v2: await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false,
- ['encrypt', 'decrypt']),
- v2hkdf: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']),
+ v3: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey', 'deriveBits']),
+ pepperVersion: 1,
};
- const key = await derivePlaylistKey(session.bundleKey.v2hkdf);
+ const key = await derivePlaylistKey(session.bundleKey.v3);
// ── local editing ──────────────────────────────────────────────────────
const eveningId = await P.createPlaylist(USER, 'Soirée');
@@ -364,6 +364,39 @@ function fakeNode() {
names: readBack ? Object.values(readBack.playlists).map((p) => p.name).sort() : [],
});
+ // ── a node sealed under the previous playlist key ─────────────────────
+ //
+ // The key changed, not the playlists: every row on such a node carries the
+ // revision the local copy has, so "push only when the node is behind"
+ // would leave all of it unreadable for ever. Built from what a clean sync
+ // stores, then every row resealed under another key, plus a body for a
+ // playlist this browser has never heard of.
+ const clean = fakeNode();
+ await P.syncWith(clean, USER);
+ const oldKeyNode = fakeNode();
+ for (const [kind, r] of clean.rows) {
+ oldKeyNode.rows.set(kind, { rev: r.rev, blob: await seal(
+ kind === MANIFEST_KIND ? { v: 1, rev: r.rev, playlists: {} }
+ : { id: kind, rev: r.rev, tracks: [] },
+ kind, USER, junkKey) });
+ }
+ oldKeyNode.rows.set(bodyKind('never-seen-here'), { rev: 3, blob: await seal(
+ { id: 'never-seen-here', rev: 3, tracks: [] }, bodyKind('never-seen-here'), USER, junkKey) });
+ const rekeyResult = await P.syncWith(oldKeyNode, USER);
+ const readable = [];
+ for (const [kind, r] of oldKeyNode.rows) {
+ try { await open(r.blob, kind, USER, key); readable.push(kind); } catch { /* not */ }
+ }
+ steps.push({
+ step: 'a node sealed under the previous key',
+ result: rekeyResult,
+ kinds: [...clean.rows.keys()].sort(),
+ readable: readable.sort(),
+ remaining: [...oldKeyNode.rows.keys()].sort(),
+ revsNotLowered: [...clean.rows].every(([k, r]) =>
+ (oldKeyNode.rows.get(k) || { rev: -1 }).rev >= r.rev),
+ });
+
// ── what a playlist costs, sealed ─────────────────────────────────────
//
// The cap below is in bytes, but the only number a reader can act on is a
@@ -449,12 +482,12 @@ function fakeNode() {
// playlist is not a broken sync.
stored: bigNode.stored.map((e) => ({ kind: e.kind, bytes: e.bytes })) });
- // ── a session with no HKDF handle degrades rather than failing ─────────
+ // ── a session with no bundle key degrades rather than failing ──────────
P.setPlaylistTransport(null);
P.forgetPlaylistKey();
- session.bundleKey = { v2: session.bundleKey.v2 }; // pre-change session
+ session.bundleKey = { v2: 'a key from before the pepper' }; // pre-change session
const r3 = await P.syncWith(fakeNode(), USER);
- steps.push({ step: 'a session from before the HKDF handle', result: r3 });
+ steps.push({ step: 'a session from before the pepper', result: r3 });
parent.postMessage({ steps, logs: LOGS.slice(0, 8) }, '*');
} catch (err) {
diff --git a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py
index c705244..6e3be1e 100644
--- a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py
+++ b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py
@@ -173,12 +173,11 @@ const clickMenu = async (i) => {
try {
await initLocale();
- // A real HKDF handle, so the store derives its key the way it really does.
+ // A real master key, so the store derives its key the way it really does.
const raw = new Uint8Array(32).fill(3);
session.bundleKey = {
- v2: await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false,
- ['encrypt', 'decrypt']),
- v2hkdf: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']),
+ v3: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey', 'deriveBits']),
+ pepperVersion: 1,
};
// Deleting a playlist asks, in the page (ask.js) — so the probe answers the
// dialog the way a person would, by clicking its OK button.
diff --git a/packages/meshbay-hub/tests/test_bundle_kdf_parity.py b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py
index c62aace..a4bee43 100644
--- a/packages/meshbay-hub/tests/test_bundle_kdf_parity.py
+++ b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py
@@ -129,3 +129,105 @@ def test_parameters_still_match_the_client():
assert f"ARGON2_TIME = {TIME_COST}" in source
assert f"ARGON2_LANES = {LANES}" in source
assert "meshbay:bundle:v2:" in source
+
+
+# ── The whole chain: A, the pepper, M, the node key, the playlist key ─────────
+#
+# The real keyderive.js and playlist-crypto.js, over the real WebAssembly
+# Argon2, against a reference written from the specification with nothing
+# shared: argon2-cffi, `cryptography`'s HKDF and AES-GCM. Down to opening an
+# MBK3 bundle, so the format and its associated data agree too.
+
+_CHAIN_HARNESS = r"""
+const fs = require('fs'), path = require('path'), url = require('url');
+const webcrypto = require('crypto').webcrypto;
+global.self = global; global.window = global; global.crypto = webcrypto;
+global.Module = { wasmBinary: fs.readFileSync(process.argv[2]) };
+global.argon2 = require(process.argv[3]);
+eval(fs.readFileSync(process.argv[4], 'utf8'));
+const K = window.MeshBayKeys;
+const fp = async (key) => Buffer.from(await webcrypto.subtle.encrypt(
+ { name: 'AES-GCM', iv: new Uint8Array(12) }, key, new Uint8Array(16))).toString('hex');
+(async () => {
+ const { derivePlaylistKey } = await import(url.pathToFileURL(process.argv[5]).href);
+ const out = [];
+ for (const v of JSON.parse(fs.readFileSync(process.argv[6], 'utf8'))) {
+ const sk = await K.deriveBundleSessionKey(v.password, v.username, v.user_id, v.pepper, 1);
+ const kNode = await K.nodeBundleKey(sk, v.node_pk);
+ const bundle = await K.encryptBundle(
+ Buffer.from('ed-private'), Buffer.from('x-private'), kNode,
+ { userId: v.user_id, nodePk: v.node_pk, pepperVersion: 1 });
+ out.push({ node: await fp(kNode), playlists: await fp(await derivePlaylistKey(sk.v3)),
+ bundle });
+ }
+ process.stdout.write(JSON.stringify(out));
+})().catch((e) => { console.error(e); process.exit(1); });
+"""
+
+CHAIN = [
+ {"username": "alice", "password": "correct horse battery staple",
+ "user_id": "0b4f6f0e-5d7e-4e8a-9d2b-6a1c1b9e2f11", "node_pk": "Tm9kZUtleUE="},
+ {"username": "utilisateur-é", "password": "üñïçø∂é ✓ 🔐",
+ "user_id": "7d1e0c2a-3b4c-4d5e-8f60-718293a4b5c6", "node_pk": "Tm9kZUtleUI="},
+]
+
+
+def _hkdf(ikm: bytes, info: str) -> bytes:
+ from cryptography.hazmat.primitives.hashes import SHA256
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+ return HKDF(algorithm=SHA256(), length=32, salt=None, info=info.encode()).derive(ikm)
+
+
+def _fp(key: bytes) -> str:
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+ return AESGCM(key).encrypt(bytes(12), bytes(16), None).hex()
+
+
+@pytest.fixture(scope="module")
+def chain(tmp_path_factory):
+ import base64
+ d = tmp_path_factory.mktemp("chain")
+ vectors = [{**v, "pepper": base64.b64encode(bytes([i + 1]) * 32).decode()}
+ for i, v in enumerate(CHAIN)]
+ (d / "harness.cjs").write_text(_CHAIN_HARNESS, encoding="utf-8")
+ (d / "vectors.json").write_text(json.dumps(vectors), encoding="utf-8")
+ proc = subprocess.run(
+ ["node", str(d / "harness.cjs"), str(VENDOR / "argon2.wasm"),
+ str(VENDOR / "argon2.min.js"), str(STATIC / "keyderive.js"),
+ str(STATIC / "playlist-crypto.js"), str(d / "vectors.json")],
+ capture_output=True, text=True, encoding="utf-8", timeout=300)
+ if proc.returncode != 0:
+ pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}")
+ return vectors, json.loads(proc.stdout)
+
+
+def _reference(v: dict) -> tuple[bytes, bytes]:
+ import base64
+ a = bytes.fromhex(_python_hash(v["username"], v["password"]))
+ m = _hkdf(a + base64.b64decode(v["pepper"]), f"meshbay:bundle-master:v3|{v['user_id']}")
+ return (_hkdf(m, f"meshbay:bundle:v3|node|{v['node_pk']}"),
+ _hkdf(m, "meshbay:playlists:v2"))
+
+
+def test_the_node_and_playlist_keys_match_across_languages(chain):
+ vectors, js = chain
+ for v, got in zip(vectors, js):
+ k_node, k_pl = _reference(v)
+ assert got["node"] == _fp(k_node), f"node key disagrees for {v['username']!r}"
+ assert got["playlists"] == _fp(k_pl), f"playlist key disagrees for {v['username']!r}"
+
+
+def test_an_mbk3_bundle_opens_from_the_specification(chain):
+ """Magic, pepper version, nonce, AES-GCM with the account and node as
+ associated data — read back by code that shares nothing with the writer."""
+ import base64
+
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+ vectors, js = chain
+ for v, got in zip(vectors, js):
+ raw = base64.b64decode(got["bundle"])
+ assert raw[:4] == b"MBK3" and raw[4] == 1
+ aad = f"meshbay:bundle:v3|{v['user_id']}|{v['node_pk']}".encode()
+ plain = json.loads(AESGCM(_reference(v)[0]).decrypt(raw[5:17], raw[17:], aad))
+ assert base64.b64decode(plain["skEd"]) == b"ed-private"
+ assert base64.b64decode(plain["skX"]) == b"x-private"
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"]
diff --git a/packages/meshbay-hub/tests/test_bundle_pepper.py b/packages/meshbay-hub/tests/test_bundle_pepper.py
index e7b126f..e99799f 100644
--- a/packages/meshbay-hub/tests/test_bundle_pepper.py
+++ b/packages/meshbay-hub/tests/test_bundle_pepper.py
@@ -105,18 +105,6 @@ async def test_an_open_session_may_ask_and_a_node_may_not(client):
@pytest.mark.asyncio
-async def test_a_passphrase_change_carries_it(client):
- """The new passphrase makes a new bundle key, and the client does not keep
- the pepper to re-seal under it."""
- await _register(client, "pepper_chg")
- login = await _login(client, "pepper_chg")
- r = await client.post("/v1/users/password", headers=_bearer(login["access_token"]),
- json={"old_auth_key": KEY, "new_auth_key": "n" * 44})
- assert r.status_code == 200, r.text
- assert r.json()["bundle_pepper"] == login["bundle_pepper"]
-
-
-@pytest.mark.asyncio
async def test_it_is_sealed_at_rest_and_bound_to_its_account(client, db_session):
await _register(client, "pepper_rest")
login = await _login(client, "pepper_rest")
diff --git a/packages/meshbay-hub/tests/test_playlist_key.py b/packages/meshbay-hub/tests/test_playlist_key.py
deleted file mode 100644
index 261cc32..0000000
--- a/packages/meshbay-hub/tests/test_playlist_key.py
+++ /dev/null
@@ -1,221 +0,0 @@
-"""
-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(encoding="utf-8")
- script = tmp_path / "case.mjs"
- script.write_text(f"{PRELUDE}\n{src}\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):
- """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
diff --git a/packages/meshbay-hub/tests/test_playlist_store.py b/packages/meshbay-hub/tests/test_playlist_store.py
index 701de41..3c1d109 100644
--- a/packages/meshbay-hub/tests/test_playlist_store.py
+++ b/packages/meshbay-hub/tests/test_playlist_store.py
@@ -272,16 +272,25 @@ def test_a_node_holding_something_unreadable_does_not_wedge_the_sync(steps):
assert "Depuis le menu" in s["names"]
-def test_a_session_from_before_the_hkdf_handle_degrades_rather_than_failing(steps):
- """A bundle key loaded out of IndexedDB from before `deriveBundleKeys`
- existed has no HKDF handle, and the passphrase is not in memory to
- re-derive from. Playlists stay local until the next sign-in — reported,
- rather than silently doing nothing."""
- r = steps["a session from before the HKDF handle"]["result"]
+def test_a_session_from_before_the_pepper_degrades_rather_than_failing(steps):
+ """A bundle key from before the pepper opens nothing, and the passphrase
+ is not in memory to re-derive from. Playlists stay local until it is
+ entered again — reported, rather than silently doing nothing."""
+ r = steps["a session from before the pepper"]["result"]
assert r["ok"] is False and r["reason"] == "no_key"
assert r["pushed"] == 0
+def test_a_node_sealed_under_the_previous_key_is_sealed_again(steps):
+ """After the playlist key changed, every row on a node has the revision the
+ local copy has. All of it is sealed again under the current key, no
+ revision goes down, and a body nothing here can name is dropped."""
+ s = steps["a node sealed under the previous key"]
+ assert s["readable"] == s["kinds"], "a row is still sealed under the old key"
+ assert s["remaining"] == s["kinds"], "the unknown body was not dropped"
+ assert s["revsNotLowered"] is True
+
+
def test_what_a_playlist_costs_sealed_is_measured_not_quoted(steps):
"""The ceiling the UI promises comes from here, not from the design doc.
diff --git a/packages/meshbay-hub/tests/test_recovery_key.py b/packages/meshbay-hub/tests/test_recovery_key.py
index e43e6de..c574597 100644
--- a/packages/meshbay-hub/tests/test_recovery_key.py
+++ b/packages/meshbay-hub/tests/test_recovery_key.py
@@ -84,10 +84,11 @@ const fp = async (key) => hex(await webcrypto.subtle.encrypt(
const kB = await K.deriveRecoveryKey(raw, 'acc-B');
const skEd = new Uint8Array([1, 2, 3]);
const skX = new Uint8Array([4, 5, 6]);
- const blob = await K.encryptBundleWithKey(skEd, skX, kA);
+ const sealedFor = { userId: 'acc-A', nodePk: 'node-key' };
+ const blob = await K.encryptBundle(skEd, skX, kA, { ...sealedFor, pepperVersion: 0 });
let wrongRejected = false;
- try { await K.decryptBundleWithKey(blob, kB); } catch { wrongRejected = true; }
- const opened = await K.decryptBundleWithKey(blob, kA);
+ try { await K.decryptBundle(blob, kB, sealedFor); } catch { wrongRejected = true; }
+ const opened = await K.decryptBundle(blob, kA, sealedFor);
out.recovery_wrap_isolates = wrongRejected
&& opened.skEd === btoa(String.fromCharCode(1, 2, 3))
&& opened.skX === btoa(String.fromCharCode(4, 5, 6));
diff --git a/packages/meshbay-hub/tests/test_rewrap_fanout.py b/packages/meshbay-hub/tests/test_rewrap_fanout.py
index b278d0c..79a5bf5 100644
--- a/packages/meshbay-hub/tests/test_rewrap_fanout.py
+++ b/packages/meshbay-hub/tests/test_rewrap_fanout.py
@@ -46,12 +46,13 @@ eval(process.argv[2].split(require('path').delimiter)
.map((p) => fs.readFileSync(p, 'utf8')).join('\n'));
const T = window.MeshBayTransport;
-let deriveEncCalls = 0;
+// Keys are opaque tags here; what is checked is which key sealed what, for
+// which account on which node, under which pepper version.
window.MeshBayKeys = {
- deriveEncryptionKey: async (p) => { deriveEncCalls++; return { kind: 'enc', p }; },
- deriveEncryptionKeyV1: async (p) => ({ kind: 'encv1', p }),
- deriveRecoveryKey: async (r) => ({ kind: 'rec', r }),
- encryptBundleWithKey: async (_skEd, _skX, key) => 'wrapped:' + key.kind,
+ deriveRecoveryKey: async (r) => ({ kind: 'rec', r }),
+ nodeBundleKey: async (key, nodePk) => ({ kind: `${key.kind}@${nodePk}` }),
+ encryptBundle: async (_skEd, _skX, key, m) =>
+ `wrapped:${key.kind}|${m.userId}|${m.nodePk}|v${m.pepperVersion}`,
};
const b64 = (s) => Buffer.from(s).toString('base64');
@@ -65,8 +66,11 @@ const NODES = {
const stored = [];
const rewrapOnlySeen = [];
-T.prototype.connect = async function (nodeId) {
+const openedWith = [];
+T.prototype.connect = async function (nodeId, _t, _g, _gek, _sk, bundleKey) {
this._nodeId = nodeId;
+ this.nodePk = 'pk-' + nodeId; // what the handshake proves
+ openedWith.push(bundleKey && bundleKey.kind);
rewrapOnlySeen.push(this._rewrapOnly === true);
const s = NODES[nodeId] || {};
if (s.throws) throw new Error(s.throws);
@@ -110,38 +114,47 @@ global.fetch = async (url) => {
const names = (a) => a.map((x) => x.name).sort();
(async () => {
+ // Flow A — passphrase change: opened with the old key, sealed with the new.
const A = await T.rewrapAllNodes({
hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid',
- oldPassphrase: 'old', newPassphrase: 'new',
+ bundleKey: { kind: 'old', pepperVersion: 1 },
+ newBundleKey: { kind: 'new', pepperVersion: 1 },
});
const storeA = stored.splice(0);
+ const openedA = openedWith.splice(0);
+ // Flow B — reset: the session key of the new passphrase opens nothing, the
+ // recovery copy does, and both copies are sealed again.
const B = await T.rewrapAllNodes({
hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid',
- newPassphrase: 'new', recoveryKey: 'A RECOVERY MNEMONIC',
+ bundleKey: { kind: 'cur', pepperVersion: 1 }, recoveryKey: 'A RECOVERY MNEMONIC',
});
const storeB = stored.splice(0);
- // Flow C — Profile backfill: keep the live passphrase key, just add the
- // recovery copy. No passphrase strings, so deriveEncryptionKey is not called.
- deriveEncCalls = 0;
+ // Flow C — Profile backfill: keep the live key, just add the recovery copy.
const C = await T.rewrapAllNodes({
hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid',
- bundleKey: { v2: { kind: 'bk' }, v1: { kind: 'bkv1' } },
+ bundleKey: { kind: 'bk', pepperVersion: 1 },
recoveryKey: 'A RECOVERY MNEMONIC',
});
const storeC = stored.splice(0);
+ let refusedWithoutKey = false;
+ try {
+ await T.rewrapAllNodes({ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid' });
+ } catch { refusedWithoutKey = true; }
+
process.stdout.write(JSON.stringify({
a_updated: names(A.updated),
a_unreachable: names(A.unreachable),
a_failed: names(A.failed),
a_stored_nodes: storeA.map((s) => s.nodeId).sort(),
a_recovery_always_null: storeA.every((s) => s.rec === null),
- a_new_bundle_key_kind: A.newBundleKey && A.newBundleKey.v2 && A.newBundleKey.v2.kind,
+ a_stored: storeA.map((s) => s.enc),
+ a_opened_with: [...new Set(openedA)],
b_stored: storeB.map((s) => ({ node: s.nodeId, enc: s.enc, rec: s.rec })),
c_stored: storeC.map((s) => ({ node: s.nodeId, enc: s.enc, rec: s.rec })),
- c_derive_enc_calls: deriveEncCalls,
+ refused_without_key: refusedWithoutKey,
// Every transport the fan-out builds is flagged rewrap-only, so a stored
// bundle it cannot open is reported, not silently replaced with a new one.
all_rewrap_only: rewrapOnlySeen.length > 0 && rewrapOnlySeen.every(Boolean),
@@ -189,24 +202,30 @@ def test_a_node_that_returns_no_identity_is_a_failure(result):
assert "f@ann" in result["a_failed"]
-def test_flow_a_writes_only_the_passphrase_copy(result):
+def test_flow_a_opens_with_the_old_key_and_seals_with_the_new_for_that_node(result):
assert result["a_recovery_always_null"] is True
- assert result["a_new_bundle_key_kind"] == "enc"
+ assert result["a_opened_with"] == ["old"]
+ assert result["a_stored"] == ["wrapped:new@pk-n-ok|uid|pk-n-ok|v1"]
def test_flow_b_writes_both_the_passphrase_and_the_recovery_copy(result):
+ # The recovery copy owes nothing to the pepper: version 0.
assert result["b_stored"] == [
- {"node": "n-ok", "enc": "wrapped:enc", "rec": "wrapped:rec"},
+ {"node": "n-ok", "enc": "wrapped:cur@pk-n-ok|uid|pk-n-ok|v1",
+ "rec": "wrapped:rec|uid|pk-n-ok|v0"},
]
def test_profile_backfill_keeps_the_live_key_and_adds_the_recovery_copy(result):
- # bundleKey mode: the passphrase copy is re-wrapped with the same live key
- # (kind "bk"), the recovery copy is added, and no passphrase is derived.
assert result["c_stored"] == [
- {"node": "n-ok", "enc": "wrapped:bk", "rec": "wrapped:rec"},
+ {"node": "n-ok", "enc": "wrapped:bk@pk-n-ok|uid|pk-n-ok|v1",
+ "rec": "wrapped:rec|uid|pk-n-ok|v0"},
]
- assert result["c_derive_enc_calls"] == 0
+
+
+def test_there_is_no_passphrase_path_left(result):
+ """Keys only: a caller that has not derived one with the pepper is refused."""
+ assert result["refused_without_key"] is True
def test_every_fanout_transport_is_rewrap_only(result):
diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py
index fdedaf8..6f28aaa 100644
--- a/packages/meshbay-hub/tests/test_spa_ordering.py
+++ b/packages/meshbay-hub/tests/test_spa_ordering.py
@@ -91,6 +91,23 @@ def test_keys_are_recovered_before_the_join_is_attempted():
"browser that did not register has no key to sign the join with")
+def test_a_bundle_is_opened_with_the_key_the_node_has_already_proved():
+ """
+ A bundle is sealed for one account on one node: its key derives from the
+ node's public key and its associated data names both. That key has to be
+ the one the challenge signature proved — recorded before the bundle is
+ fetched — or a bundle would be opened, or a new one sealed, for nothing.
+ """
+ proved, user, sealed_for, fetch = _positions(
+ "this.nodePk = reply.node_pk",
+ "this._userId = userId",
+ "const sealedFor = { userId: this._userId, nodePk: this.nodePk }",
+ "type: 'keypair_bundle_fetch'",
+ )
+ assert proved < sealed_for < fetch
+ assert user < sealed_for
+
+
def test_the_ack_still_verifies_the_announced_node_key():
"""
Taking node_pk from the challenge is only safe because the ack proves it and