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') # WebAuthn / Passkey # RP_ID must be the effective domain of the site (no scheme, no port). # In development this is "localhost"; in production use your actual domain. WEBAUTHN_RP_ID = os.environ.get('WEBAUTHN_RP_ID', 'localhost') WEBAUTHN_RP_NAME = os.environ.get('WEBAUTHN_RP_NAME', 'PassKeeper') # Allowed origins for WebAuthn ceremonies (comma-separated in env). # Must include the full origin (scheme + host + optional port). WEBAUTHN_ORIGINS = [ o.strip() for o in os.environ.get( 'WEBAUTHN_ORIGINS', 'http://localhost:5000,https://localhost', ).split(',') if o.strip() ] 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') # Background cleanup scheduler (token_blacklist / recovery_challenges / # totp_used_codes / expired shares). Disabled under test so the suite does # not spawn a daemon thread per app fixture. SCHEDULER_ENABLED = True # 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 TestingConfig(BaseConfig): """ In-memory SQLite, no rate limiting, no background threads. SQLite is viable here because the only MySQL-specific construct in the models is mysql.INTEGER(unsigned=True), which SQLAlchemy renders as a plain INTEGER on other dialects. That means these tests cover application logic and flow, NOT MySQL-specific behaviour (collation, ON UPDATE NOW(), unsigned range) — schema changes still need a real migration run against MySQL. """ TESTING = True DEBUG = False SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' SQLALCHEMY_ENGINE_OPTIONS = {} RATELIMIT_ENABLED = False SCHEDULER_ENABLED = False WTF_CSRF_ENABLED = False SECRET_KEY = 'test-secret-not-used-in-production' JWT_SECRET_KEY = 'test-jwt-secret-not-used-in-production' TOTP_ENCRYPTION_KEY = '00' * 32 CORS_ORIGINS = 'http://localhost' WEBAUTHN_RP_ID = 'localhost' WEBAUTHN_ORIGINS = ['http://localhost'] # Keep Argon2 cheap so the suite is not dominated by password hashing. ARGON2_TIME_COST = 1 ARGON2_MEMORY_COST = 8 ARGON2_PARALLELISM = 1 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' @classmethod def validate(cls): """ Fail loudly on insecure production configuration. Called from create_app() when this config is selected — NOT at class definition time. Running it in the class body meant merely *importing* app.config raised unless production secrets were present in the environment, which broke the test suite and any local tooling that imports the app (including `flask db upgrade` on a dev box). The fail-loud property is preserved: create_app('production') raises before the app is returned, so the process still never serves a request under 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.' ) 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.' ) 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.' ) config = { 'development': DevelopmentConfig, 'testing': TestingConfig, 'production': ProductionConfig, }