diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/auth.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/auth.py | 71 |
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]: |