diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/users.py | 4 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/auth.py | 31 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_argon2_off_loop.py | 20 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_hub_api.py | 56 |
4 files changed, 89 insertions, 22 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 991a4f7..7b2fc8c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -222,8 +222,8 @@ async def register( if not credential: raise HTTPException(status_code=400, detail="auth_key or password required") - pw_hash, pw_salt = await hash_password_off_loop(credential) pw_ver = current_pw_version() if body.auth_key else 2 + pw_hash, pw_salt = await hash_password_off_loop(credential, pw_ver) hub_id = _cfg.identity.id if _cfg else "meshbay.org" user = User( username=body.username, @@ -399,7 +399,7 @@ async def login( user.pw_version = current_pw_version() elif user.pw_version < 2: # Legacy rehash: upgrade Argon2 params within the password scheme (v1 -> v2) - new_hash, new_salt = await hash_password_off_loop(body.password) + new_hash, new_salt = await hash_password_off_loop(body.password, 2) user.pw_hash = new_hash user.pw_salt = new_salt user.pw_version = 2 diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py index 58e2310..7045197 100644 --- a/packages/meshbay-hub/src/meshbay_hub/auth.py +++ b/packages/meshbay-hub/src/meshbay_hub/auth.py @@ -31,10 +31,17 @@ _ARGON2_KEY_LEN = 32 _ARGON2_VERSIONS = { 1: {"iterations": 3, "memory_cost": 65536}, # 64 MB — initial - 2: {"iterations": 3, "memory_cost": 262144}, # 256 MB — production target + 2: {"iterations": 3, "memory_cost": 262144}, # 256 MB — raw password (legacy) 3: {"iterations": 3, "memory_cost": 262144}, # 256 MB — auth_key input (password split) + # 64 MiB, t=3, p=4 — RFC 9106's second recommended setting. What this hashes + # is already PBKDF2-SHA512 at 600 000 iterations of the passphrase, done by + # the client, and online guessing is bounded by the sign-in lockout, so the + # memory above this bought a constant factor against an offline attacker + # with the database, at four times the cost of every sign-in. Versions 3 and + # above are the auth_key scheme; a v3 hash is rewritten at its next sign-in. + 4: {"iterations": 3, "memory_cost": 65536}, } -_ARGON2_CURRENT_VERSION = 3 +_ARGON2_CURRENT_VERSION = 4 # Module-level hub keypair (loaded once at startup) _hub_sk_pem: bytes | None = None @@ -112,10 +119,16 @@ def hub_id() -> str: # ── Password ────────────────────────────────────────────────────────────────── -def hash_password(password: str) -> tuple[bytes, bytes]: - """Hash a password with Argon2id (current version). Returns (hash, salt).""" +def hash_password(password: str, version: int = _ARGON2_CURRENT_VERSION) -> tuple[bytes, bytes]: + """Hash with the parameters of `version`, which is what the caller stores. + + The version is an argument because the stored `pw_version` is what + verification reads the parameters from: hashing at one version's parameters + and recording another makes an account nobody can sign in to. That mistake + sat unseen while versions 2 and 3 shared their parameters. + """ salt = os.urandom(16) - params = _ARGON2_VERSIONS[_ARGON2_CURRENT_VERSION] + params = _ARGON2_VERSIONS[version] pw_hash = Argon2id( salt=salt, length=_ARGON2_KEY_LEN, @@ -143,7 +156,8 @@ def verify_password(password: str, pw_hash: bytes, salt: bytes, version: int = 2 # ── Argon2 off the event loop, one at a time ───────────────────────────────── # -# One derivation is 256 MB and a quarter to half a second of CPU. Called from an +# One derivation is 64 MiB and ~0.1 s on meshbay.org (0.45 s for an old 256 MB +# hash, until its account next signs in). Called from an # async handler it stops the whole hub for that long — every request, every node # socket, every offer relayed — once per sign-in, passphrase change, reset and # registration. @@ -164,9 +178,10 @@ def verify_password(password: str, pw_hash: bytes, salt: bytes, version: int = 2 _argon2_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="argon2") -async def hash_password_off_loop(password: str) -> tuple[bytes, bytes]: +async def hash_password_off_loop(password: str, + version: int = _ARGON2_CURRENT_VERSION) -> tuple[bytes, bytes]: loop = asyncio.get_running_loop() - return await loop.run_in_executor(_argon2_executor, hash_password, password) + return await loop.run_in_executor(_argon2_executor, hash_password, password, version) async def verify_password_off_loop(password: str, pw_hash: bytes, salt: bytes, diff --git a/packages/meshbay-hub/tests/test_argon2_off_loop.py b/packages/meshbay-hub/tests/test_argon2_off_loop.py index b156ebc..5c25a8e 100644 --- a/packages/meshbay-hub/tests/test_argon2_off_loop.py +++ b/packages/meshbay-hub/tests/test_argon2_off_loop.py @@ -1,10 +1,11 @@ """ Argon2 runs off the event loop, and never two at a time. -One derivation is 256 MB and a quarter to half a second of CPU. On the loop it -stopped the whole hub for that long at every sign-in. In a thread pool it would -have been worse: two concurrent `lanes=4` derivations deadlock inside OpenSSL -and never return (`auth._argon2_executor`). These pin both halves. +A derivation is 64 MiB and ~0.1 s at the current version, 256 MB and ~0.45 s +for a v3 hash not yet rewritten. On the loop it stopped the whole hub for that +long at every sign-in. In a thread pool it would have been worse: two concurrent +`lanes=4` derivations deadlock inside OpenSSL and never return, at 64 MiB as at +256 MB (`auth._argon2_executor`). These pin both halves. """ import asyncio @@ -51,9 +52,13 @@ async def test_concurrent_derivations_all_return(): @pytest.mark.asyncio async def test_the_loop_keeps_turning_while_argon2_runs(): - pw_hash, salt = auth.hash_password("k" * 44) + # A v3 (256 MB) hash, the heaviest the hub still verifies: at 64 MiB an + # inline derivation is ~45 ms and half of it is too close to scheduling + # noise to tell a stalled loop from a busy machine. + version = 3 + pw_hash, salt = auth.hash_password("k" * 44, version) started = time.perf_counter() - auth.verify_password("k" * 44, pw_hash, salt, auth.current_pw_version()) + auth.verify_password("k" * 44, pw_hash, salt, version) inline = time.perf_counter() - started gaps, done = [], asyncio.Event() @@ -68,8 +73,7 @@ async def test_the_loop_keeps_turning_while_argon2_runs(): task = asyncio.create_task(ticker()) await asyncio.sleep(0.02) - assert await auth.verify_password_off_loop( - "k" * 44, pw_hash, salt, auth.current_pw_version()) + assert await auth.verify_password_off_loop("k" * 44, pw_hash, salt, version) done.set() await task # Inline, the loop stalls for the whole derivation; off it, for scheduling noise. 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) |