summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-14 03:01:58 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-17 12:43:09 +0200
commit0370d001a4e74d2af0809a3e1eb5ada699b1bca2 (patch)
tree4eca51060cc94c04856c59765886523b70583b7c
parent9d1d4a7af5844f50f168ff00818382ffe57c3452 (diff)
downloadmeshbay-0370d001a4e74d2af0809a3e1eb5ada699b1bca2.tar.gz
fix(hub): the password verifier is Argon2id 64 MiB, and a hash's version names its parameters
`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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LcF3QKWii7uQ2kSyXErzCt
-rw-r--r--CLAUDE.md13
-rw-r--r--docs/MESHBAY_DESIGN.md19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py31
-rw-r--r--packages/meshbay-hub/tests/test_argon2_off_loop.py20
-rw-r--r--packages/meshbay-hub/tests/test_hub_api.py56
6 files changed, 116 insertions, 27 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 164c541..384e86a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -210,7 +210,8 @@ These are about working on the tree rather than about the design:
having a bad day. `mail.send_off_loop` is the door, and
`test_no_mail_is_sent_from_the_event_loop` reads the source for direct calls,
because there is nothing else to read. **Argon2 is the same, with a trap
- under it**: one derivation is 256 MB and ~0.25–0.5 s, so it goes through
+ under it**: one derivation is 64 MiB and ~0.1 s on meshbay.org (0.45 s at the
+ 256 MB of `pw_version` 3, still paid once per old account), so it goes through
`auth.hash_password_off_loop` / `verify_password_off_loop` — but **two
concurrent `lanes=4` derivations deadlock inside OpenSSL** and never return,
at no CPU (cryptography 50.0.x, OpenSSL 4.0.x, reproduced locally and on
@@ -723,9 +724,13 @@ older one wins by being read first.
## Known calibration TODOs
-- Argon2id `memory_cost`: ✅ DONE — bumped to 262144 (256 MB) in pw_version=2.
- Existing v1 users (64 MB) are transparently rehashed on next successful login.
- CLI `calibrate` command still TODO for per-hardware tuning.
+- Hub Argon2id verifier: **64 MiB, t=3, lanes=4 in `pw_version` 4** — why not
+ more is `docs/MESHBAY_DESIGN.md` §4.6. Older `auth_key` hashes (v3, 256 MB)
+ are rewritten on next sign-in; legacy raw-password hashes stay at v2.
+ **`hash_password` takes the version being stored** — hashing at one version's
+ parameters and recording another locks the account out, which went unseen
+ while v2 and v3 shared parameters. CLI `calibrate` still TODO for per-hardware
+ tuning.
## NAT traversal
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 05f6d73..92f56ef 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -736,9 +736,26 @@ the index.
| Parameter | Value |
|---|---|
| Node keystore KDF | Argon2id **256 MB**, t=3, lanes=4 — recorded per envelope, so raising it does not orphan existing keystores |
-| Hub password verifier | Argon2id **256 MB**, t=3, over the client-derived `auth_key` — `pw_version` migrates transparently on next login |
+| Hub password verifier | Argon2id **64 MiB**, t=3, lanes=4 (`pw_version` 4), over the client-derived `auth_key` — RFC 9106's second recommended setting. An older hash is verified at its own version's parameters and rewritten at the current ones on the next sign-in |
| Browser bundle key | Argon2id **128 MB**, t=3, p=1 |
| Browser `auth_key` | PBKDF2-SHA512, **600 000** iterations |
+
+**Why the hub verifier is 64 MiB and not more.** What it protects against is an
+offline attacker holding the database; online guessing is bounded by the sign-in
+lockout (§7.7), where Argon2's cost plays no part. That attacker pays, per guess,
+the client's 600 000 PBKDF2-SHA512 iterations *and* the hub's Argon2id, because
+`auth_key` is 256 bits and cannot be searched directly. Above 64 MiB the memory
+multiplies the attacker's cost by a constant factor — at most 16 at 256 MB, less
+once PBKDF2 is counted — which moves a weak passphrase from cracked-soon to
+cracked-later and a strong one from out of reach to out of reach. On the hub the
+same memory is paid at every sign-in, one derivation at a time (§13.5b, **AV9**):
+measured on meshbay.org, 450 ms at 256 MB against 105 ms at 64 MiB. The
+passphrase floor is what separates the two cases, not the verifier.
+
+**The stored `pw_version` always names the parameters the hash was made with.**
+Verification reads them from it, so `hash_password` takes the version it is
+hashing for; the legacy raw-password scheme stays at version 2 and the
+`auth_key` scheme is every version from 3 up.
| Chunk cipher | AEAD, 1 MB chunks, per-chunk key by HKDF |
| Chat nonce | 96 random bits per message, never a counter |
| Invite / pair codes | 40 bits, Crockford base32, single use, stored as `sha256` |
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)