diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:39:34 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:39:34 +0200 |
| commit | fb91c4545c757711e1b5fd354ca4b311c89fd2c0 (patch) | |
| tree | eb1aee6cc0fb5fc020eed2763009dea5a32eb8ee /packages/meshbay-hub/src/meshbay_hub/auth.py | |
| parent | 77d76421829161df6b1ef628b4e6e051a2c3c2ee (diff) | |
| download | meshbay-fb91c4545c757711e1b5fd354ca4b311c89fd2c0.tar.gz | |
feat(hub): add production hub — config, auth, API routers, tests
config.py: TOML + env var priority. auth.py: Argon2id passwords,
JWT EdDSA with jti, refresh token hashed (blake3). Routers:
hub (info/pubkey), users (register/login/refresh/pubkeys),
nodes (announce/get), groups (create/gek-bundle/gek-retrieve).
Rate limiting via slowapi. app.py factory with lifespan.
All 40 tests pass (SQLite in-memory, no PostgreSQL required).
Fix: remove tests/__init__.py to resolve namespace conflicts.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/auth.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/auth.py | 145 |
1 files changed, 145 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py new file mode 100644 index 0000000..a4c3bfb --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/auth.py @@ -0,0 +1,145 @@ +""" +MeshBay Hub — authentication helpers. + + - Password hashing/verification: Argon2id + - JWT issuance/verification: Ed25519 (EdDSA), includes jti + - Refresh token: random 32-byte, stored as blake3 hex hash + - Hub keypair: loaded from PEM file on startup +""" + +import base64 +import hashlib +import os +import time +import uuid +from pathlib import Path + +import blake3 +import jwt +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.kdf.argon2 import Argon2id + +# Argon2id parameters — see CLAUDE.md for production calibration guidance +_ARGON2_ITERATIONS = 3 +_ARGON2_MEMORY_COST = 65536 # 64 MB — increase to 262144 in production +_ARGON2_LANES = 4 +_ARGON2_KEY_LEN = 32 + +# Module-level hub keypair (loaded once at startup) +_hub_sk_pem: bytes | None = None +_hub_pk_pem: bytes | None = None +_hub_id: str = "meshbay.org" + + +# ── Hub keypair ─────────────────────────────────────────────────────────────── + +def load_hub_keypair(private_key_path: Path, hub_id: str) -> None: + """Load hub Ed25519 keypair from PEM file. Call once at startup.""" + global _hub_sk_pem, _hub_pk_pem, _hub_id + _hub_sk_pem = private_key_path.read_bytes() + sk = serialization.load_pem_private_key(_hub_sk_pem, password=None) + _hub_pk_pem = sk.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + _hub_id = hub_id + + +def generate_hub_keypair(private_key_path: Path) -> None: + """Generate a new hub Ed25519 keypair and save PEM files. Run once.""" + private_key_path.parent.mkdir(parents=True, exist_ok=True) + sk = Ed25519PrivateKey.generate() + private_key_path.write_bytes(sk.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + )) + private_key_path.chmod(0o600) + + pk_path = private_key_path.with_suffix(".pub.pem") + pk_path.write_bytes(sk.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + )) + + +def hub_public_key_pem() -> bytes: + if _hub_pk_pem is None: + raise RuntimeError("Hub keypair not loaded — call load_hub_keypair() first") + return _hub_pk_pem + + +# ── Password ────────────────────────────────────────────────────────────────── + +def hash_password(password: str) -> tuple[bytes, bytes]: + """Hash a password with Argon2id. Returns (hash, salt).""" + salt = os.urandom(16) + pw_hash = Argon2id( + salt=salt, + length=_ARGON2_KEY_LEN, + iterations=_ARGON2_ITERATIONS, + lanes=_ARGON2_LANES, + memory_cost=_ARGON2_MEMORY_COST, + ).derive(password.encode()) + return pw_hash, salt + + +def verify_password(password: str, pw_hash: bytes, salt: bytes) -> bool: + try: + Argon2id( + salt=salt, + length=_ARGON2_KEY_LEN, + iterations=_ARGON2_ITERATIONS, + lanes=_ARGON2_LANES, + memory_cost=_ARGON2_MEMORY_COST, + ).verify(password.encode(), pw_hash) + return True + except Exception: + return False + + +# ── JWT ─────────────────────────────────────────────────────────────────────── + +def issue_access_token( + user_id: str, + pk_user: str, + ttl: int = 3600, +) -> str: + """ + Issue a signed JWT access token. + Includes jti (UUID4) — required to prevent replay and enable revocation. + """ + if _hub_sk_pem is None: + raise RuntimeError("Hub keypair not loaded") + now = int(time.time()) + payload = { + "iss": _hub_id, + "sub": user_id, + "pk_user": pk_user, + "hub_id": _hub_id, + "jti": str(uuid.uuid4()), + "iat": now, + "exp": now + ttl, + } + return jwt.encode(payload, _hub_sk_pem, algorithm="EdDSA") + + +def decode_access_token(token: str) -> dict: + """Verify and decode an access token. Raises on failure.""" + if _hub_pk_pem is None: + raise RuntimeError("Hub keypair not loaded") + return jwt.decode(token, _hub_pk_pem, algorithms=["EdDSA"]) + + +# ── Refresh tokens ──────────────────────────────────────────────────────────── + +def generate_refresh_token() -> tuple[str, str]: + """Return (raw_token, token_hash). Store hash; give raw to client.""" + raw = base64.urlsafe_b64encode(os.urandom(32)).decode() + hashed = blake3.blake3(raw.encode()).hexdigest() + return raw, hashed + + +def hash_refresh_token(raw: str) -> str: + return blake3.blake3(raw.encode()).hexdigest() |