summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/auth.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-10 03:57:55 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-10 03:57:55 +0200
commit1a53eb4cc404ec94658fde0ae04cfe2ccf1810dc (patch)
tree04a23f81d3eea49de7414bf973600d33913795f9 /packages/meshbay-hub/src/meshbay_hub/auth.py
parent4b3e8c3b8b9d10c8ac333dd8db614a7569052472 (diff)
downloadmeshbay-1a53eb4cc404ec94658fde0ae04cfe2ccf1810dc.tar.gz
feat(hub): Phase 8 — Hub v2 security hardening + production readiness
8.1 Config-based admin authz (require_admin on all admin endpoints) 8.2 Email encrypted at rest (AES-256-GCM, HKDF from hub Ed25519 key) 8.3 Refresh token rotation with family-based reuse detection 8.4 Federation persistence (HubPeer model replaces in-memory dict) 8.5 Federation token verification now async (DB-backed) 8.6 CSAM hash check wired into swarm registration flow 8.7 Rate limiting on auth endpoints (5/10/20 per minute) 8.8 Healthcheck endpoint (GET /v1/health, no auth) 8.9 IP log cleanup background task (365-day retention) 8.10 Argon2id params bumped to 256 MB (pw_version, rehash on login) Deployed to meshbay.org — schema migrated, existing emails encrypted. 117 tests pass (29 hub, 88 common+node). Resolves security review items S1, S2, S5. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/auth.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py71
1 files changed, 59 insertions, 12 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py
index 2b2c61e..11ad112 100644
--- a/packages/meshbay-hub/src/meshbay_hub/auth.py
+++ b/packages/meshbay-hub/src/meshbay_hub/auth.py
@@ -18,25 +18,33 @@ import blake3
import jwt
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
+from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+from cryptography.hazmat.primitives.hashes import SHA256
-# 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
+# Argon2id parameters — versioned for gradual migration
+_ARGON2_LANES = 4
+_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
+}
+_ARGON2_CURRENT_VERSION = 2
# 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"
+_email_key: bytes | None = None
# ── 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
+ global _hub_sk_pem, _hub_pk_pem, _hub_id, _email_key
_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(
@@ -45,6 +53,15 @@ def load_hub_keypair(private_key_path: Path, hub_id: str) -> None:
)
_hub_id = hub_id
+ sk_raw = sk.private_bytes(
+ serialization.Encoding.Raw,
+ serialization.PrivateFormat.Raw,
+ serialization.NoEncryption(),
+ )
+ _email_key = HKDF(
+ algorithm=SHA256(), length=32, salt=None, info=b"meshbay:email:v1",
+ ).derive(sk_raw)
+
def generate_hub_keypair(private_key_path: Path) -> None:
"""Generate a new hub Ed25519 keypair and save PEM files. Run once."""
@@ -73,32 +90,42 @@ def hub_public_key_pem() -> bytes:
# ── Password ──────────────────────────────────────────────────────────────────
def hash_password(password: str) -> tuple[bytes, bytes]:
- """Hash a password with Argon2id. Returns (hash, salt)."""
+ """Hash a password with Argon2id (current version). Returns (hash, salt)."""
salt = os.urandom(16)
+ params = _ARGON2_VERSIONS[_ARGON2_CURRENT_VERSION]
pw_hash = Argon2id(
salt=salt,
length=_ARGON2_KEY_LEN,
- iterations=_ARGON2_ITERATIONS,
+ iterations=params["iterations"],
lanes=_ARGON2_LANES,
- memory_cost=_ARGON2_MEMORY_COST,
+ memory_cost=params["memory_cost"],
).derive(password.encode())
return pw_hash, salt
-def verify_password(password: str, pw_hash: bytes, salt: bytes) -> bool:
+def verify_password(password: str, pw_hash: bytes, salt: bytes, version: int = 2) -> bool:
+ params = _ARGON2_VERSIONS.get(version, _ARGON2_VERSIONS[_ARGON2_CURRENT_VERSION])
try:
Argon2id(
salt=salt,
length=_ARGON2_KEY_LEN,
- iterations=_ARGON2_ITERATIONS,
+ iterations=params["iterations"],
lanes=_ARGON2_LANES,
- memory_cost=_ARGON2_MEMORY_COST,
+ memory_cost=params["memory_cost"],
).verify(password.encode(), pw_hash)
return True
except Exception:
return False
+def pw_needs_rehash(version: int) -> bool:
+ return version < _ARGON2_CURRENT_VERSION
+
+
+def current_pw_version() -> int:
+ return _ARGON2_CURRENT_VERSION
+
+
# ── JWT ───────────────────────────────────────────────────────────────────────
def issue_access_token(
@@ -135,6 +162,26 @@ def decode_access_token(token: str) -> dict:
return jwt.decode(token, _hub_pk_pem, algorithms=["EdDSA"])
+# ── Email encryption at rest ──────────────────────────────────────────────────
+
+def encrypt_email(plaintext: str) -> str:
+ """Encrypt an email address for storage. Returns base64(nonce + ciphertext)."""
+ if _email_key is None:
+ raise RuntimeError("Hub keypair not loaded")
+ nonce = os.urandom(12)
+ ct = AESGCM(_email_key).encrypt(nonce, plaintext.encode(), None)
+ return base64.b64encode(nonce + ct).decode()
+
+
+def decrypt_email(stored: str) -> str:
+ """Decrypt an email address from storage."""
+ if _email_key is None:
+ raise RuntimeError("Hub keypair not loaded")
+ raw = base64.b64decode(stored)
+ nonce, ct = raw[:12], raw[12:]
+ return AESGCM(_email_key).decrypt(nonce, ct, None).decode()
+
+
# ── Refresh tokens ────────────────────────────────────────────────────────────
def generate_refresh_token() -> tuple[str, str]: