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.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]: