"""User accounts: password hashing and bootstrap (0.24.0, P2). Passwords are hashed with PBKDF2-HMAC-SHA256 (stdlib) with a per-user salt; the stored string carries its parameters ("pbkdf2$$$") so the cost can be raised later without invalidating existing hashes. """ import base64 import hashlib import hmac import secrets from app import models _ITERATIONS = 260_000 def hash_password(password: str) -> str: salt = secrets.token_bytes(16) digest = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt, _ITERATIONS ) return "pbkdf2${}${}${}".format( _ITERATIONS, base64.b64encode(salt).decode("ascii"), base64.b64encode(digest).decode("ascii"), ) def verify_password(password: str, stored: str) -> bool: try: scheme, iterations, salt_b64, hash_b64 = stored.split("$") if scheme != "pbkdf2": return False salt = base64.b64decode(salt_b64) expected = base64.b64decode(hash_b64) digest = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt, int(iterations) ) return hmac.compare_digest(digest, expected) except Exception: return False def ensure_bootstrap_admin() -> dict | None: """Create the first admin from the env credentials. Runs when the users table is empty and KANBANTE_AUTH_USER / _PASSWORD are configured (decision D6): Bruno's existing login becomes the initial database admin, with no lockout. The env path keeps working as an emergency fallback either way (see app.main auth). """ from app.config import get_settings if models.count_users() > 0: return None settings = get_settings() if not settings.auth_enabled: return None user_id = models.create_user( settings.auth_user, hash_password(settings.auth_password), role="admin", display_name=settings.auth_user, ) return models.get_user(user_id)