From 86563d5db0ae19fc58336b71a5c29a8712973590 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 14 Aug 2026 12:42:51 +0200 Subject: feat(client): Argon2id for the keypair bundle, and remove the backup toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to yesterday's judgement, in the order they matter. **The toggle is gone.** Asked to make the remote key backup optional, I shipped a setting whose "off" position meant: no second browser, ever, and clearing your storage destroys the account. I wrote the warning that says so without drawing the conclusion. A control whose only effect is to break the ordinary case is not a control, and removing an exposure by removing the feature is not a fix. Every browser backs its keys up again, unconditionally. **The exposure is fixed where it actually lives: the KDF.** The keypair bundle rests on every node whose group its owner joins, protected by the passphrase alone (finding C4). It used PBKDF2-SHA512 at 600k — compute-only, which is exactly what a GPU eats. Measured on this machine: PBKDF2 600k costs 241 ms and Argon2id 64 MB/t=3 costs 322 ms, near enough the same honest work, except only one of them forces an attacker to find 64 MB per guess. So the bundle key is now Argon2id 64 MB / t=3 / p=1, via a vendored WebAssembly build (no external host — the CSP forbids one, and 12.2 will tighten it further). Parameters chosen by measurement through that build: 19 MB is OWASP's floor at 118 ms, 256 MB is 1.3 s and too slow for a phone, 64 MB sits where a login should. What this buys, stated honestly: cracking a bundle yields the owner's identity keys, and with them content on OTHER nodes and the ability to sign as them — not the content on the operator's own node, which they host in the clear by design. Argon2id raises that price steeply; it does not remove it, and a weak passphrase still loses. Hence the floor raised to 12 characters and ~60 bits in the same breath, which can only be enforced client-side: with the password split (T1) the hub never sees a passphrase. Migration is automatic and invisible. Bundles carry an "MBK2" marker; the old form is still readable, and is re-encrypted the first time a browser backs it up. Both keys are derived at sign-in, because which one a bundle needs is only known once it is read and the passphrase is deliberately not kept around. Two implementations of the KDF now exist — the browser's WASM and argon2-cffi in QE — so a parity test holds them byte-identical. A disagreement would not look like an error; it would look like an account nobody can open. keypair_bundle_delete stays, without a UI. It is the mechanism behind withdrawing your data from a node, exercised end to end, and it will belong to a deliberate "forget me on this node" action rather than a setting that quietly disables multi-device. Verified against the live deployment: the full workflow passes, including recovering keys on a second client from the passphrase alone. Tests: 341. Co-Authored-By: Claude Opus 5 --- .../meshbay-hub/tests/test_bundle_kdf_parity.py | 131 +++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 packages/meshbay-hub/tests/test_bundle_kdf_parity.py (limited to 'packages/meshbay-hub/tests/test_bundle_kdf_parity.py') diff --git a/packages/meshbay-hub/tests/test_bundle_kdf_parity.py b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py new file mode 100644 index 0000000..c3c8ff9 --- /dev/null +++ b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py @@ -0,0 +1,131 @@ +""" +Cross-language parity for the keypair bundle KDF. + +The bundle is the one thing a user carries between browsers, and the passphrase +is all that stands between it and whoever holds the disk of a node they joined +(finding C4). It moved from PBKDF2-SHA512 to Argon2id for that reason — PBKDF2 is +compute-only, which is what makes it cheap on a GPU. + +Two implementations now have to agree byte for byte: the vendored WebAssembly the +browser runs, and `argon2-cffi` used by the QE harness. A disagreement would not +show up as an error — it would show up as a bundle nobody can open, which is +somebody's account gone. + +Skipped when node or argon2-cffi is missing; that is a coverage gap, not a pass. +""" + +import hashlib +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +VENDOR = STATIC / "vendor" + +try: + from argon2.low_level import Type, hash_secret_raw + HAVE_ARGON2 = True +except ImportError: + HAVE_ARGON2 = False + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None + or not (VENDOR / "argon2.min.js").exists() + or not HAVE_ARGON2, + reason="node, the vendored argon2, or argon2-cffi is unavailable", +) + +# Parameters must match keyderive.js. If someone tunes them there and not here, +# this test fails — which is the point: changing them silently orphans every +# bundle already written. +MEM_KIB, TIME_COST, LANES = 65536, 3, 1 + +CASES = ["alice", "grenet", "utilisateur-é", ""] +PASSWORDS = ["correct horse battery staple", "p", "üñïçø∂é ✓ 🔐"] + +_HARNESS = r""" +const fs = require('fs'), webcrypto = require('crypto').webcrypto; +global.self = global; global.crypto = webcrypto; +// The browser uses the copy inlined in the bundle; under node the emscripten +// loader looks for a file, so hand it the same bytes explicitly. +global.Module = { wasmBinary: fs.readFileSync(process.argv[2]) }; +const argon2 = require(process.argv[3]); + +(async () => { + const input = JSON.parse(fs.readFileSync(process.argv[4], 'utf8')); + const out = []; + for (const v of input) { + const salt = new Uint8Array(await webcrypto.subtle.digest( + 'SHA-256', new TextEncoder().encode(`meshbay:bundle:v2:${v.username}`) + )).slice(0, 16); + const r = await argon2.hash({ + pass: v.password, salt, + time: v.time, mem: v.mem, parallelism: v.lanes, + hashLen: 32, type: argon2.ArgonType.Argon2id, + }); + out.push(Buffer.from(r.hash).toString('hex')); + } + process.stdout.write(JSON.stringify(out)); +})(); +""" + + +@pytest.fixture(scope="module") +def js_hashes(tmp_path_factory): + d = tmp_path_factory.mktemp("kdf") + harness = d / "harness.cjs" + harness.write_text(_HARNESS) + vectors = [ + {"username": u, "password": p, + "mem": MEM_KIB, "time": TIME_COST, "lanes": LANES} + for u in CASES for p in PASSWORDS + ] + payload = d / "vectors.json" + payload.write_text(json.dumps(vectors)) + + proc = subprocess.run( + ["node", str(harness), str(VENDOR / "argon2.wasm"), + str(VENDOR / "argon2.min.js"), str(payload)], + capture_output=True, text=True, timeout=300, + ) + if proc.returncode != 0: + pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}") + return vectors, json.loads(proc.stdout) + + +def _python_hash(username: str, password: str) -> str: + salt = hashlib.sha256(f"meshbay:bundle:v2:{username}".encode()).digest()[:16] + return hash_secret_raw( + password.encode(), salt, time_cost=TIME_COST, memory_cost=MEM_KIB, + parallelism=LANES, hash_len=32, type=Type.ID, + ).hex() + + +def test_bundle_key_matches_across_languages(js_hashes): + vectors, js = js_hashes + for i, v in enumerate(vectors): + assert js[i] == _python_hash(v["username"], v["password"]), ( + f"argon2id disagrees for username={v['username']!r} — a bundle " + f"written by one implementation would be unreadable by the other" + ) + + +def test_the_salt_separates_users(js_hashes): + """Two accounts with the same passphrase must not share a bundle key.""" + assert _python_hash("alice", "same passphrase") != \ + _python_hash("bob", "same passphrase") + + +def test_parameters_still_match_the_client(): + """ + The numbers live in keyderive.js; this test is the second copy. Tuning one + without the other orphans every bundle already written, so make it fail. + """ + source = (STATIC / "keyderive.js").read_text() + assert f"ARGON2_MEM_KIB = {MEM_KIB}" in source + assert f"ARGON2_TIME = {TIME_COST}" in source + assert f"ARGON2_LANES = {LANES}" in source + assert "meshbay:bundle:v2:" in source -- cgit v1.2.3 From 1c96aceb54d66cae1b48aa0eb8887f68e53f9e24 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 14 Aug 2026 14:25:44 +0200 Subject: perf(client): bundle KDF to 128 MB, and derive it once per sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Argon2id memory 64 → 128 MB. Memory is the lever, not time: it caps how many guesses a card can hold at once, so the ceiling on one high-end GPU moves from roughly 4k to roughly 2k guesses/s and its 24 GB fits ~187 lanes instead of ~375. Measured through the vendored build: 640 ms, against 322 ms at 64 MB. While measuring the real cost of a sign-in, found the SPA deriving the bundle key twice — once for the key pair kept for the session, then again inside decryptBundle() for the local bundle. At these parameters that is 0.6 s of pure waste. Measured now, end to end: auth_key (PBKDF2 600k) 239 ms bundle v1 (PBKDF2 600k) 240 ms legacy, until every bundle is upgraded bundle v2 (Argon2id 128MB) 650 ms ----------------------------------- sign-in 1 129 ms (889 ms once no v1 bundles remain) Once per sign-in, and only then: reopening a group, downloading, streaming and reloading the page all reuse the key, which lives in IndexedDB from login. Also bounds two waits in the node's hub WebSocket, found because the node went silent again mid-deploy. It had reconnected after the hub restart, sent its auth frame, and waited for a reply that never came — `ws.recv()` had no timeout, so a hub that accepts a socket and then says nothing for a few seconds while starting up parks the task forever: node running, logging nothing, invisible to everyone. The auth exchange now times out at 15 s, connect at 15 s, and a refused auth retries with a fresh token instead of ending the task for good. QE harness signs in once per account and reuses the token — several clients there stand for several browsers of one person, and what tells them apart is which keys they hold, not which token, while the hub quite rightly rate-limits repeated logins from one address. Tests: 341, plus the live workflow. Co-Authored-By: Claude Opus 5 --- docs/meshbay-draft-v5.md | 15 ++++++++++++--- .../meshbay-hub/src/meshbay_hub/static/keyderive.js | 15 +++++++++------ .../src/meshbay_hub/static/vendor/PROVENANCE.md | 2 +- packages/meshbay-hub/tests/test_bundle_kdf_parity.py | 2 +- packages/meshbay-node/src/meshbay_node/hub_client.py | 17 ++++++++++++++--- 5 files changed, 37 insertions(+), 14 deletions(-) (limited to 'packages/meshbay-hub/tests/test_bundle_kdf_parity.py') diff --git a/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md index 6fdbbc6..e8cc3a4 100644 --- a/docs/meshbay-draft-v5.md +++ b/docs/meshbay-draft-v5.md @@ -353,11 +353,20 @@ whose group the user joins, and GEK and keypair bundle fetches are served in the window because the client needs its bundle to compute the proof. The window is bounded (4 fetches) and audited. -The bundle's own protection moved from PBKDF2-SHA512 to **Argon2id, 64 MB, t=3, p=1** +The bundle's own protection moved from PBKDF2-SHA512 to **Argon2id, 128 MB, t=3, p=1** (`static/vendor/argon2.min.js`, WebAssembly, no external host). PBKDF2 is compute-only, so 600k iterations cost an attacker with a GPU far less than the wall clock suggested: -measured on the dev machine, both take ~0.3 s honestly, but only one of them makes a -graphics card allocate 64 MB per guess. The two implementations — the browser's WASM and +measured on the dev machine, PBKDF2 costs 241 ms and Argon2id 88 ms natively, but only +one of them makes a graphics card find 128 MB per guess. The honest size of that gain: +on a single card the ceiling moves from roughly 8k guesses/s to roughly 2k — a factor of +four, not a thousand. What it really buys is the cost of scale, since 128 MB per lane caps +a 24 GB card at about 187 concurrent guesses and makes custom hardware unattractive. + +**The passphrase, not the KDF, is what decides this.** At ~2k guesses/s a dictionary-plus- +rules run of 10⁹ candidates — which covers a large share of human-chosen passwords — +takes about six days on one card. Four random words (~52 bits) takes longer than the age +of the universe. No parameter choice saves a weak passphrase; it only moves it from hours +to days. The two implementations — the browser's WASM and `argon2-cffi` in QE — are held byte-identical by a parity test, because a disagreement would present as an account nobody can open. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js index afa5d27..af119c7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -75,11 +75,12 @@ async function generateKeypairs() { // is exactly what a GPU is good at, so 600k iterations bought far less than the // wall-clock time suggested. // -// 64 MB / t=3 / p=1 measured at ~320 ms through this WASM build on a desktop, so -// roughly a second on a modest phone — the most that belongs in a login. Memory -// is what matters here: at 64 MB per guess, a 24 GB card holds a few hundred in -// parallel instead of the effectively unbounded number PBKDF2 allows. -const ARGON2_MEM_KIB = 65536; // 64 MB +// 128 MB / t=3 / p=1 measured at ~640 ms through this WASM build on a desktop. +// Memory is the lever, not time: each guess must hold 128 MB, so a 24 GB card +// fits ~187 in parallel and its bandwidth caps it near 2k guesses/s, against no +// ceiling at all for PBKDF2. 256 MB would double that again at ~1.3 s, which is +// too much to ask of a phone for something paid at every sign-in. +const ARGON2_MEM_KIB = 131072; // 128 MB const ARGON2_TIME = 3; const ARGON2_LANES = 1; @@ -273,7 +274,9 @@ async function loginAndRecover(username, password) { && localStorage.getItem(`meshbay_kp_${username}`)) || null; if (bundleEnc) { - const keys = await decryptBundle(bundleEnc, password, username); + // Reuse the keys just derived — decryptBundle() would run the KDF again, + // and at these parameters that is another 0.6 s for nothing. + const keys = await decryptBundleWithKey(bundleEnc, result.bundleKey); result.skEdB64 = keys.skEd; result.skXB64 = keys.skX; result.keypairBundleEnc = bundleEnc; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/vendor/PROVENANCE.md b/packages/meshbay-hub/src/meshbay_hub/static/vendor/PROVENANCE.md index 35d742b..6935e91 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/vendor/PROVENANCE.md +++ b/packages/meshbay-hub/src/meshbay_hub/static/vendor/PROVENANCE.md @@ -22,7 +22,7 @@ request and nothing to locate at runtime. keypair bundle is protected by the passphrase alone and rests on every node whose group its owner joins (finding C4), so PBKDF2 — compute-only, and therefore cheap on a GPU — was the wrong tool for it. Measured through this build on the dev -machine: Argon2id 64 MB / t=3 / p=1 takes ~320 ms, against ~240 ms for +machine: Argon2id 128 MB / t=3 / p=1 takes ~640 ms, against ~240 ms for PBKDF2-SHA512 at 600k, for a memory cost a GPU cannot ignore. ### argon2.wasm diff --git a/packages/meshbay-hub/tests/test_bundle_kdf_parity.py b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py index c3c8ff9..27e10d4 100644 --- a/packages/meshbay-hub/tests/test_bundle_kdf_parity.py +++ b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py @@ -41,7 +41,7 @@ pytestmark = pytest.mark.skipif( # Parameters must match keyderive.js. If someone tunes them there and not here, # this test fails — which is the point: changing them silently orphans every # bundle already written. -MEM_KIB, TIME_COST, LANES = 65536, 3, 1 +MEM_KIB, TIME_COST, LANES = 131072, 3, 1 CASES = ["alice", "grenet", "utilisateur-é", ""] PASSWORDS = ["correct horse battery staple", "p", "üñïçø∂é ✓ 🔐"] diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index 691ac7c..334107d 100644 --- a/packages/meshbay-node/src/meshbay_node/hub_client.py +++ b/packages/meshbay-node/src/meshbay_node/hub_client.py @@ -241,6 +241,7 @@ class HubClient: # working one until someone notices the node has vanished. async with websockets.connect( ws_url, ping_interval=20, ping_timeout=20, close_timeout=5, + open_timeout=15, ) as ws: auth_msg = { "type": "auth", @@ -250,10 +251,20 @@ class HubClient: if group_ids: auth_msg["group_ids"] = group_ids await ws.send(json.dumps(auth_msg)) - auth_resp = json.loads(await ws.recv()) + # Bounded: a hub that accepts the socket and then says nothing + # — which is what it does for a few seconds while restarting — + # would otherwise park this task here forever, with the node + # running, silent, and invisible to everyone. + auth_resp = json.loads( + await asyncio.wait_for(ws.recv(), timeout=15)) if auth_resp.get("type") != "auth_ok": - log.error("WS auth failed: %s", auth_resp) - return + # Not fatal: the token may simply have expired while we + # were disconnected. Refresh on the next pass rather than + # ending the task, which used to strand the node for good. + log.warning("WS auth refused: %s — retrying in 5s", auth_resp) + await asyncio.sleep(5) + await self.ensure_fresh_token() + continue self._ws = ws log.info("Hub WS connected") -- cgit v1.2.3