aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/auth.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-09 04:39:34 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-09 04:39:34 +0200
commitfb91c4545c757711e1b5fd354ca4b311c89fd2c0 (patch)
treeeb1aee6cc0fb5fc020eed2763009dea5a32eb8ee /packages/meshbay-hub/src/meshbay_hub/auth.py
parent77d76421829161df6b1ef628b4e6e051a2c3c2ee (diff)
downloadmeshbay-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.py145
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()