aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_bundle_kdf_parity.py
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/test_bundle_kdf_parity.py
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/test_bundle_kdf_parity.py')
-rw-r--r--packages/meshbay-hub/tests/test_bundle_kdf_parity.py102
1 files changed, 102 insertions, 0 deletions
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"