diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-14 12:42:51 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-14 12:42:51 +0200 |
| commit | 86563d5db0ae19fc58336b71a5c29a8712973590 (patch) | |
| tree | ef269f2f29e5d81fd6681f64632895520266bc84 /packages/meshbay-hub/tests/test_bundle_kdf_parity.py | |
| parent | 2caa93dbc06161b5d3f776a204ac8d921df92126 (diff) | |
| download | meshbay-86563d5db0ae19fc58336b71a5c29a8712973590.tar.gz | |
feat(client): Argon2id for the keypair bundle, and remove the backup toggle
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 <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.py | 131 |
1 files changed, 131 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 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 |