zehe 1 month ago
parent
commit
3757f1c418
2 changed files with 209 additions and 26 deletions
  1. 44 8
      config.py
  2. 165 18
      main.py

+ 44 - 8
config.py

@@ -2,6 +2,7 @@ import json
 import os
 import yaml
 import hashlib
+import hmac
 import secrets
 from typing import Dict, List, Any, Optional
 
@@ -33,19 +34,54 @@ class Config:
         self._keys = self._load_keys()
 
     @staticmethod
-    def generate_password_hash(password: str) -> str:
-        """Generate a password hash for use in configuration"""
+    def generate_password_hash(password: str, iterations: int = 100000) -> str:
+        """Generate a password hash for use in configuration.
+
+        Stored canonical format:
+          pbkdf2_sha256$<iterations>$<salt>$<hexhash>
+
+        Legacy format <salt>:<hex> remains supported for compatibility.
+        """
         salt = secrets.token_hex(16)
-        password_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
-        return f"{salt}:{password_hash.hex()}"
+        derived = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), iterations)
+        # Return canonical format including algorithm and iteration count
+        return f"pbkdf2_sha256${iterations}${salt}${derived.hex()}"
 
     @staticmethod
     def verify_password(password: str, password_hash: str) -> bool:
-        """Verify a password against its hash"""
+        """Verify a password against its hash using constant-time comparison.
+
+        Supported formats:
+          - New canonical format: pbkdf2_sha256$<iterations>$<salt>$<hexhash>
+          - Legacy format: <salt>:<hexhash>
+
+        Returns True only if the password matches the stored hash.
+        """
         try:
-            salt, stored_hash = password_hash.split(':')
-            password_hash_computed = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
-            return password_hash_computed.hex() == stored_hash
+            # New canonical format uses '$' as separator
+            if '$' in password_hash:
+                parts = password_hash.split('$')
+                # Expecting exactly 4 parts: algo, iterations, salt, hexhash
+                if len(parts) != 4 or not parts[0].startswith('pbkdf2_sha256'):
+                    return False
+                _, iterations_s, salt, stored_hex = parts
+                iterations = int(iterations_s)
+                derived = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), iterations)
+                try:
+                    stored_bytes = bytes.fromhex(stored_hex)
+                except ValueError:
+                    return False
+                # Use constant-time comparison
+                return hmac.compare_digest(derived, stored_bytes)
+            else:
+                # Legacy format: salt:hex
+                salt, stored_hex = password_hash.split(':')
+                derived = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
+                try:
+                    stored_bytes = bytes.fromhex(stored_hex)
+                except ValueError:
+                    return False
+                return hmac.compare_digest(derived, stored_bytes)
         except Exception:
             return False
 

+ 165 - 18
main.py

@@ -47,14 +47,30 @@ class NtfyLogHandler(logging.Handler):
                 # Don't fail the application if logging notification fails
                 pass
 
-# Configure logging
+# Configure logging with secure-file fallback
+log_path = '/var/log/emergency-access.log'
+try:
+    os.makedirs(os.path.dirname(log_path), exist_ok=True)
+    # Ensure the log file exists with restrictive permissions
+    if not os.path.exists(log_path):
+        open(log_path, 'a').close()
+        try:
+            os.chmod(log_path, 0o600)
+        except Exception:
+            # Best-effort; if this fails we continue with stream logging
+            pass
+    log_handlers = [
+        logging.FileHandler(log_path),
+        logging.StreamHandler()
+    ]
+except Exception:
+    # If any issue creating the file (permissions, etc.), fall back to stream-only logging
+    log_handlers = [logging.StreamHandler()]
+
 logging.basicConfig(
     level=logging.INFO,
     format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
-    handlers=[
-        logging.FileHandler('/var/log/emergency-access.log'),
-        logging.StreamHandler()
-    ]
+    handlers=log_handlers
 )
 logger = logging.getLogger(__name__)
 
@@ -66,7 +82,33 @@ config = None
 
 
 def require_auth(key_config=None, is_health=False):
-    """Decorator for HTTP Basic Authentication"""
+    """Decorator for HTTP Basic Authentication with simple in-memory rate limiting.
+
+    This implements a small, best-effort per-(IP,username) rate limiter and lockout
+    to mitigate brute-force attempts. It is intentionally simple and in-memory:
+    - MAX_ATTEMPTS: number of failed attempts within WINDOW before lockout
+    - WINDOW: window (seconds) to count attempts
+    - LOCKOUT: lockout duration (seconds) after exceeding MAX_ATTEMPTS
+
+    Notes:
+    - Because this is in-memory, it resets on process restart.
+    - For multi-process deployments, offload rate limiting to the reverse proxy (Caddy)
+      or a shared store (redis).
+    - On repeated lockouts a notification is sent (best-effort) to configured health backends
+      and, when applicable, to the key's backends.
+    """
+    # Initialize shared state on the require_auth function object (module-global alternative)
+    if not hasattr(require_auth, "_state"):
+        require_auth._state = {
+            'attempts': {},  # (ip, username) -> list[timestamps]
+            'blocked': {},   # (ip, username) -> blocked_until_timestamp
+            'lock': threading.Lock()
+        }
+
+    MAX_ATTEMPTS = 5
+    WINDOW = 300      # seconds
+    LOCKOUT = 900     # seconds
+
     def decorator(f):
         @wraps(f)
         def decorated_function(*args, **kwargs):
@@ -75,8 +117,54 @@ def require_auth(key_config=None, is_health=False):
                 return jsonify({'error': 'Server configuration error'}), 500
 
             auth = request.authorization
+            client_ip = request.remote_addr or 'unknown'
+
+            # determine username for rate limit key; use placeholder if missing
+            username_for_key = auth.username if auth and auth.username else "<no_auth>"
+
+            key = (client_ip, username_for_key)
+
+            now_ts = time.time()
+            state = require_auth._state
+
+            # Check block list
+            with state['lock']:
+                blocked_until = state['blocked'].get(key)
+                if blocked_until and now_ts < blocked_until:
+                    retry_after = int(blocked_until - now_ts)
+                    logger.warning(f"Blocked auth attempt from {client_ip} for user '{username_for_key}' (locked until {blocked_until})")
+                    # Return 429 Too Many Requests
+                    return jsonify({'error': 'Too many authentication attempts, temporarily locked'}), 429, {
+                        'Retry-After': str(retry_after)
+                    }
 
             if not auth:
+                # For missing auth, still account for attempts keyed by ip and '<no_auth>'
+                # Increment attempt count and possibly lock
+                with state['lock']:
+                    attempts = state['attempts'].setdefault(key, [])
+                    # prune old timestamps
+                    attempts = [t for t in attempts if now_ts - t <= WINDOW]
+                    attempts.append(now_ts)
+                    state['attempts'][key] = attempts
+                    if len(attempts) > MAX_ATTEMPTS:
+                        # Lock out
+                        state['blocked'][key] = now_ts + LOCKOUT
+                        logger.error(f"Locking out {client_ip} for missing/invalid auth (no credentials) after repeated attempts")
+                        # Try to notify about brute force (best-effort)
+                        try:
+                            if config and hasattr(config, 'ntfy_backends_health'):
+                                send_ntfy_notification(
+                                    config.ntfy_backends_health,
+                                    f"🚨 Brute-force lockout: {client_ip} (no credentials) on server",
+                                    "Emergency Access ALERT"
+                                )
+                        except Exception:
+                            pass
+                        return jsonify({'error': 'Too many authentication attempts, temporarily locked'}), 429, {
+                            'Retry-After': str(LOCKOUT)
+                        }
+
                 return jsonify({'error': 'Authentication required'}), 401, {
                     'WWW-Authenticate': 'Basic realm="Emergency Access"'
                 }
@@ -95,13 +183,59 @@ def require_auth(key_config=None, is_health=False):
                 auth_type = f"key '{key_config.key_id}'"
 
             # Verify credentials
-            if (auth.username != expected_username or
-                not Config.verify_password(auth.password, expected_password_hash)):
-                logger.warning(f"Authentication failed for {auth_type}: invalid credentials from {request.remote_addr}")
+            credential_ok = (auth.username == expected_username and
+                             Config.verify_password(auth.password, expected_password_hash))
+
+            if not credential_ok:
+                # record failed attempt and check for lockout
+                with state['lock']:
+                    attempts = state['attempts'].setdefault(key, [])
+                    attempts = [t for t in attempts if now_ts - t <= WINDOW]
+                    attempts.append(now_ts)
+                    state['attempts'][key] = attempts
+
+                    if len(attempts) > MAX_ATTEMPTS:
+                        state['blocked'][key] = now_ts + LOCKOUT
+                        logger.error(f"Locking out {client_ip} for user '{username_for_key}' after repeated failed attempts")
+                        # Notify about brute-force lockout (best-effort)
+                        try:
+                            notify_backends = []
+                            if config and hasattr(config, 'ntfy_backends_health'):
+                                notify_backends.extend(config.ntfy_backends_health)
+                            # Include key-specific backends when available
+                            if key_config and getattr(key_config, 'backends', None):
+                                notify_backends.extend(key_config.backends)
+                            if notify_backends:
+                                send_ntfy_notification(
+                                    list(dict.fromkeys(notify_backends)),  # deduplicate
+                                    f"🚨 Brute-force lockout: {client_ip} user='{username_for_key}' on {auth_type}",
+                                    "Emergency Access ALERT"
+                                )
+                        except Exception:
+                            pass
+
+                        return jsonify({'error': 'Too many authentication attempts, temporarily locked'}), 429, {
+                            'Retry-After': str(LOCKOUT)
+                        }
+
+                logger.warning(f"Authentication failed for {auth_type}: invalid credentials from {client_ip}")
                 return jsonify({'error': 'Invalid credentials'}), 401, {
                     'WWW-Authenticate': 'Basic realm="Emergency Access"'
                 }
 
+            # On success, clear recorded failed attempts for this key
+            with state['lock']:
+                if key in state['attempts']:
+                    try:
+                        del state['attempts'][key]
+                    except KeyError:
+                        pass
+                if key in state['blocked']:
+                    try:
+                        del state['blocked'][key]
+                    except KeyError:
+                        pass
+
             logger.info(f"Authentication successful for {auth_type}")
             return f(*args, **kwargs)
         return decorated_function
@@ -258,13 +392,21 @@ def create_key_handler(key_config):
                 }), 500
 
             logger.warning(f"EMERGENCY: Key part successfully retrieved and sent for key '{key_config.key_id}'")
-            return jsonify({
+            resp = jsonify({
                 'success': True,
                 'key_id': key_config.key_id,
                 'key_part': content,
                 'timestamp': time.time(),
                 'notified_backends': successful_backends
             })
+            # Ensure responses with secret material are never cached by clients or intermediaries
+            resp.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, private'
+            resp.headers['Pragma'] = 'no-cache'
+            resp.headers['Expires'] = '0'
+            # Additional defensive headers
+            resp.headers['X-Content-Type-Options'] = 'nosniff'
+            resp.headers['Content-Security-Policy'] = "default-src 'none'; frame-ancestors 'none'; sandbox"
+            return resp
 
         except Exception as e:
             logger.error(f"CRITICAL: Unexpected error in key access for '{key_config.key_id}': {str(e)}")
@@ -538,14 +680,19 @@ def main():
                 channel_timeout=120
             )
         except ImportError:
-            logger.warning("Waitress not available, falling back to Flask dev server")
-            app.run(
-                host=config.server_host,
-                port=config.server_port,
-                debug=False,
-                threaded=True,
-                request_handler=ProductionRequestHandler
-            )
+            logger.critical("Waitress WSGI server not installed. This application must run under a production WSGI server such as 'waitress' or 'gunicorn'. Aborting startup.")
+            # Attempt to notify configured health backends about startup failure (best-effort)
+            try:
+                if config and hasattr(config, 'ntfy_backends_health'):
+                    send_ntfy_notification(
+                        config.ntfy_backends_health,
+                        "🚨 Emergency Access server failed to start: waitress not installed",
+                        "Emergency Access CRITICAL"
+                    )
+            except Exception:
+                # Do not raise further - we are already aborting startup
+                pass
+            sys.exit(1)
 
     except KeyboardInterrupt:
         logger.info("Received keyboard interrupt, shutting down gracefully")