import os from datetime import timedelta from dotenv import load_dotenv load_dotenv() # Shared set of known-insecure placeholder values that must never reach production. _INSECURE_SECRET_DEFAULTS = {'dev-secret-change-me', 'jwt-secret-change-me', '', 'change-me'} class BaseConfig: SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-change-me') JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-change-me') JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=15) JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=7) SQLALCHEMY_DATABASE_URI = ( 'mysql+pymysql://{user}:{password}@{host}:{port}/{db}?charset=utf8mb4'.format( user=os.environ.get('MYSQL_USER', 'passkeeper'), password=os.environ.get('MYSQL_PASSWORD', ''), host=os.environ.get('MYSQL_HOST', '127.0.0.1'), port=os.environ.get('MYSQL_PORT', '3306'), db=os.environ.get('MYSQL_DB', 'passkeeper'), ) ) SQLALCHEMY_TRACK_MODIFICATIONS = False # MySQL closes idle connections after wait_timeout (default 8 hours). # pool_recycle ensures SQLAlchemy replaces connections before that deadline. # pool_pre_ping sends a cheap SELECT 1 before each checkout so stale # connections are detected and recycled rather than causing "MySQL has gone # away" errors on the first request after a long idle period. SQLALCHEMY_ENGINE_OPTIONS = { 'pool_recycle': 3600, # recycle connections after 1 hour 'pool_pre_ping': True, # test each connection before use 'pool_timeout': 30, # raise after 30 s if no connection available 'pool_size': 10, # base pool size per worker 'max_overflow': 5, # allow up to 5 extra connections under load } WTF_CSRF_ENABLED = True WTF_CSRF_TIME_LIMIT = 3600 ARGON2_TIME_COST = int(os.environ.get('ARGON2_TIME_COST', 3)) ARGON2_MEMORY_COST = int(os.environ.get('ARGON2_MEMORY_COST', 65536)) ARGON2_PARALLELISM = int(os.environ.get('ARGON2_PARALLELISM', 4)) # Server-side AES-256-GCM key for encrypting TOTP secrets at rest. # Must be a 64-character hex string (32 bytes). # Generate: python -c "import secrets; print(secrets.token_hex(32))" TOTP_ENCRYPTION_KEY = os.environ.get('TOTP_ENCRYPTION_KEY', '') # Rate limiting — use Redis in production so all Gunicorn workers share one counter. # Falls back to memory (per-worker, dev only). RATELIMIT_STORAGE_URI = os.environ.get('RATELIMIT_STORAGE_URI', 'memory://') # CORS — restrict to the production origin in production config. CORS_ORIGINS = os.environ.get('CORS_ORIGINS', '*') # Cache-busting version string appended as ?v=... to all static assets. # Bump this value on every deploy to force browsers to reload CSS/JS. # Set via .env: STATIC_VERSION=20260418 STATIC_VERSION = os.environ.get('STATIC_VERSION', '1') # Session cookie defaults — applied in all environments. # SECURE is intentionally left out of BaseConfig so dev HTTP still works. # See ProductionConfig below for the full hardened set. SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = 'Lax' class DevelopmentConfig(BaseConfig): DEBUG = True RATELIMIT_ENABLED = False class ProductionConfig(BaseConfig): DEBUG = False RATELIMIT_ENABLED = True # Force HTTPS in production — marks cookie Secure so it is never sent over HTTP. SESSION_COOKIE_SECURE = True SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = 'Lax' # ── Critical security checks — fail loudly at startup, not silently at runtime ── # These checks run at class definition time (i.e. at import / app startup). # Any misconfiguration raises RuntimeError immediately so the process never # serves a single request with an insecure configuration. _secret_key = os.environ.get('SECRET_KEY', '') if not _secret_key or _secret_key in _INSECURE_SECRET_DEFAULTS: raise RuntimeError( '[PassKeeper] SECRET_KEY is not set or uses an insecure placeholder. ' 'Generate a strong key with: python -c "import secrets; print(secrets.token_hex(32))" ' 'and add SECRET_KEY= to your production .env file.' ) SECRET_KEY = _secret_key _jwt_secret = os.environ.get('JWT_SECRET_KEY', '') if not _jwt_secret or _jwt_secret in _INSECURE_SECRET_DEFAULTS: raise RuntimeError( '[PassKeeper] JWT_SECRET_KEY is not set or uses an insecure placeholder. ' 'Generate a strong key with: python -c "import secrets; print(secrets.token_hex(32))" ' 'and add JWT_SECRET_KEY= to your production .env file.' ) JWT_SECRET_KEY = _jwt_secret _cors = os.environ.get('CORS_ORIGINS', '') if not _cors or _cors.strip() == '*': raise RuntimeError( '[PassKeeper] CORS_ORIGINS must be set to a specific origin in production ' '(e.g. CORS_ORIGINS=https://pwkeeper.ngodanguyen.tech). ' 'A wildcard "*" is not permitted in production.' ) CORS_ORIGINS = _cors config = { 'development': DevelopmentConfig, 'production': ProductionConfig, }