summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/auth.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/auth.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py37
1 files changed, 37 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py
index c5ea34d..58e2310 100644
--- a/packages/meshbay-hub/src/meshbay_hub/auth.py
+++ b/packages/meshbay-hub/src/meshbay_hub/auth.py
@@ -7,11 +7,13 @@ MeshBay Hub — authentication helpers.
- Hub keypair: loaded from PEM file on startup
"""
+import asyncio
import base64
import hashlib
import os
import time
import uuid
+from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import blake3
@@ -139,6 +141,41 @@ def verify_password(password: str, pw_hash: bytes, salt: bytes, version: int = 2
return False
+# ── 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
+# 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.
+#
+# It cannot simply go to a thread pool: **two concurrent derivations with
+# `lanes=4` deadlock inside OpenSSL** and never return, at no CPU (measured
+# 2026-09-14 on cryptography 50.0.x / OpenSSL 4.0.x, here and on meshbay.org;
+# `lanes=1` does not, and `lanes` is part of every stored hash, so it is not
+# ours to change). Inline on the loop they could never overlap, which is the only
+# reason this never hung in production.
+#
+# So: a dedicated executor with exactly one worker. The loop is free while a
+# derivation runs, and derivations still never overlap — including when the
+# request that queued one is cancelled mid-way, which a semaphore around
+# `to_thread` would get wrong (the permit is released while the thread is still
+# deriving, and the next one starts beside it). It also bounds Argon2's memory
+# to one derivation, whatever the number of callers.
+_argon2_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="argon2")
+
+
+async def hash_password_off_loop(password: str) -> tuple[bytes, bytes]:
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(_argon2_executor, hash_password, password)
+
+
+async def verify_password_off_loop(password: str, pw_hash: bytes, salt: bytes,
+ version: int = 2) -> bool:
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(
+ _argon2_executor, verify_password, password, pw_hash, salt, version)
+
+
def pw_needs_rehash(version: int) -> bool:
return version < _ARGON2_CURRENT_VERSION