From 0370d001a4e74d2af0809a3e1eb5ada699b1bca2 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 14 Sep 2026 03:01:58 +0200 Subject: fix(hub): the password verifier is Argon2id 64 MiB, and a hash's version names its parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pw_version` 4: Argon2id 64 MiB, t=3, lanes=4 — RFC 9106's second recommended setting. A v3 hash (256 MB) still verifies at its own parameters and is rewritten at the new ones on the next sign-in, through the rehash path that already existed. Why not more. The verifier matters against an offline attacker holding the database; online guessing is bounded by the sign-in lockout. That attacker pays the client's 600 000 PBKDF2-SHA512 iterations and the hub's Argon2id per guess, since `auth_key` is 256 bits and cannot be searched directly. Memory above 64 MiB multiplies that cost by a constant — at most 16 at 256 MB, less with PBKDF2 counted — while the hub pays the same memory at every sign-in, one derivation at a time. Measured on meshbay.org: 450 ms at 256 MB, 105 ms at 64 MiB, so a burst of sign-ins clears about four times faster. Changing the current version exposed a latent lockout. `hash_password` always used the current version's parameters, while the raw-password scheme recorded `pw_version = 2` — harmless while versions 2 and 3 shared their parameters, and with version 4 every legacy registration and v1→v2 rehash would have stored a 64 MiB hash labelled 256 MB, which nothing could then verify. Seventeen tests caught it. `hash_password` now takes the version it is hashing for. The OpenSSL deadlock between two concurrent `lanes=4` derivations is the same at 64 MiB, so Argon2 stays on its single worker. The loop-stall test measures against a v3 hash, because half of a 45 ms inline derivation is too close to scheduling noise to be a reliable bound. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LcF3QKWii7uQ2kSyXErzCt --- packages/meshbay-hub/tests/test_hub_api.py | 56 +++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) (limited to 'packages/meshbay-hub/tests/test_hub_api.py') diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py index 5e9e6c2..37e8e4f 100644 --- a/packages/meshbay-hub/tests/test_hub_api.py +++ b/packages/meshbay-hub/tests/test_hub_api.py @@ -621,6 +621,54 @@ async def test_password_rehash_on_login(client, app): assert r.status_code == 200 +@pytest.mark.asyncio +async def test_a_v3_auth_key_hash_is_rewritten_at_the_current_parameters(client, db_session): + """Every account registered before the 64 MiB change holds a v3 (256 MB) + hash. It must keep signing in, and be rewritten on the way through — + otherwise the old cost is paid at every sign-in for ever.""" + import os + + from cryptography.hazmat.primitives.kdf.argon2 import Argon2id + from meshbay_hub.auth import ( + _ARGON2_KEY_LEN, + _ARGON2_LANES, + _ARGON2_VERSIONS, + current_pw_version, + ) + from meshbay_hub.db.models import User + from sqlalchemy import select + + assert current_pw_version() > 3 + assert _ARGON2_VERSIONS[current_pw_version()]["memory_cost"] == 65536 + + key = "dGVzdGF1dGhrZXl0ZXN0YXV0aGtleXRlc3RhdXRo" + r = await client.post("/v1/users/register", json={ + "username": "v3holder", "email": "v3holder@test.com", "auth_key": key}) + assert r.status_code == 201, r.text + + salt = os.urandom(16) + v3 = _ARGON2_VERSIONS[3] + user = (await db_session.execute( + select(User).where(User.username == "v3holder"))).scalar_one() + user.pw_hash = Argon2id(salt=salt, length=_ARGON2_KEY_LEN, iterations=v3["iterations"], + lanes=_ARGON2_LANES, memory_cost=v3["memory_cost"]).derive(key.encode()) + user.pw_salt, user.pw_version = salt, 3 + await db_session.commit() + + r = await client.post("/v1/users/login", json={"username": "v3holder", "auth_key": key}) + assert r.status_code == 200, r.text + + db_session.expire_all() + user = (await db_session.execute( + select(User).where(User.username == "v3holder"))).scalar_one() + assert user.pw_version == current_pw_version() + assert user.pw_hash != Argon2id(salt=salt, length=_ARGON2_KEY_LEN, iterations=3, + lanes=_ARGON2_LANES, memory_cost=65536).derive(key.encode()) + + r = await client.post("/v1/users/login", json={"username": "v3holder", "auth_key": key}) + assert r.status_code == 200, r.text + + # ── WebRTC signaling (9.2) ────────────────────────────────────────────────── @pytest.mark.asyncio @@ -764,7 +812,8 @@ async def test_webapp_html_includes_scripts(client): @pytest.mark.asyncio async def test_register_with_auth_key(client, app): - """Registration with auth_key sets pw_version 3.""" + """Registration with auth_key sets the current auth_key pw_version.""" + from meshbay_hub.auth import current_pw_version from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import User from sqlalchemy import select @@ -783,7 +832,7 @@ async def test_register_with_auth_key(client, app): async for db in get_db(): result = await db.execute(select(User).where(User.username == "authuser")) user = result.scalar_one() - assert user.pw_version == 3 + assert user.pw_version == current_pw_version() break @@ -932,12 +981,11 @@ async def test_login_legacy_migration(client, app): }) assert r.status_code == 200 - # Verify pw_version is now 3 (migrated) + # Verify pw_version is now the current auth_key version (migrated) async for db in get_db(): result = await db.execute(select(User).where(User.username == "migrateuser")) user = result.scalar_one() assert user.pw_version == current_pw_version() - assert user.pw_version == 3 break # Login again with auth_key only → should succeed (migrated account) -- cgit v1.2.3