diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-14 03:01:58 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-17 12:43:09 +0200 |
| commit | 0370d001a4e74d2af0809a3e1eb5ada699b1bca2 (patch) | |
| tree | 4eca51060cc94c04856c59765886523b70583b7c /packages/meshbay-hub/src/meshbay_hub | |
| parent | 9d1d4a7af5844f50f168ff00818382ffe57c3452 (diff) | |
| download | meshbay-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
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
| -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 |
2 files changed, 25 insertions, 10 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, |