mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
326 lines
11 KiB
Python
326 lines
11 KiB
Python
"""
|
|
F10 — Sidecar Bridge Authentication.
|
|
Device-token authentication for bridge endpoints.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
import logging
|
|
import os
|
|
from typing import Dict, Optional, Set, Tuple
|
|
|
|
try:
|
|
from aiohttp import web # type: ignore
|
|
except ModuleNotFoundError: # pragma: no cover (optional for unit tests)
|
|
web = None # type: ignore
|
|
|
|
from .bridge_contract import BridgeScope
|
|
|
|
try:
|
|
from ..redaction import stable_redaction_tag
|
|
except ImportError: # pragma: no cover
|
|
from services.redaction import stable_redaction_tag # type: ignore
|
|
|
|
logger = logging.getLogger("ComfyUI-OpenClaw.sidecar.auth")
|
|
|
|
# Environment configuration
|
|
ENV_BRIDGE_ENABLED = "OPENCLAW_BRIDGE_ENABLED"
|
|
LEGACY_ENV_BRIDGE_ENABLED = "MOLTBOT_BRIDGE_ENABLED"
|
|
ENV_BRIDGE_DEVICE_TOKEN = "OPENCLAW_BRIDGE_DEVICE_TOKEN"
|
|
LEGACY_ENV_BRIDGE_DEVICE_TOKEN = "MOLTBOT_BRIDGE_DEVICE_TOKEN"
|
|
ENV_BRIDGE_ALLOWED_DEVICE_IDS = "OPENCLAW_BRIDGE_ALLOWED_DEVICE_IDS"
|
|
LEGACY_ENV_BRIDGE_ALLOWED_DEVICE_IDS = "MOLTBOT_BRIDGE_ALLOWED_DEVICE_IDS"
|
|
# R104: mTLS Contract
|
|
ENV_BRIDGE_MTLS_ENABLED = "OPENCLAW_BRIDGE_MTLS_ENABLED"
|
|
ENV_BRIDGE_DEVICE_CERT_MAP = (
|
|
"OPENCLAW_BRIDGE_DEVICE_CERT_MAP" # device_id:fingerprint,...
|
|
)
|
|
|
|
# Headers
|
|
HEADER_DEVICE_ID = "X-OpenClaw-Device-Id"
|
|
LEGACY_HEADER_DEVICE_ID = "X-Moltbot-Device-Id"
|
|
HEADER_DEVICE_TOKEN = "X-OpenClaw-Device-Token"
|
|
LEGACY_HEADER_DEVICE_TOKEN = "X-Moltbot-Device-Token"
|
|
HEADER_SCOPES = "X-OpenClaw-Scopes"
|
|
LEGACY_HEADER_SCOPES = "X-Moltbot-Scopes"
|
|
# R104: mTLS Headers
|
|
HEADER_CLIENT_CERT_HASH = "X-Client-Cert-Hash" # SHA256 fingerprint from proxy
|
|
|
|
|
|
def _device_tag(device_id: Optional[str]) -> str:
|
|
return stable_redaction_tag(device_id, label="device")
|
|
|
|
|
|
def _cert_tag(cert_hash: Optional[str]) -> str:
|
|
return stable_redaction_tag(cert_hash, label="cert")
|
|
|
|
|
|
def _env_get(primary: str, legacy: str, default: str = "") -> str:
|
|
"""Get env var value (prefers new names, falls back to legacy). Respects empty-string overrides."""
|
|
if primary in os.environ:
|
|
return os.environ.get(primary, default)
|
|
if legacy in os.environ:
|
|
return os.environ.get(legacy, default)
|
|
return default
|
|
|
|
|
|
def is_bridge_enabled() -> bool:
|
|
"""Check if bridge endpoints are enabled."""
|
|
return _env_get(ENV_BRIDGE_ENABLED, LEGACY_ENV_BRIDGE_ENABLED, "").lower() in (
|
|
"1",
|
|
"true",
|
|
"yes",
|
|
)
|
|
|
|
|
|
def get_bridge_device_token() -> str:
|
|
"""Get the configured bridge device token."""
|
|
return _env_get(ENV_BRIDGE_DEVICE_TOKEN, LEGACY_ENV_BRIDGE_DEVICE_TOKEN, "")
|
|
|
|
|
|
def get_allowed_device_ids() -> Optional[Set[str]]:
|
|
"""
|
|
Get allowlisted device IDs.
|
|
Returns None if no allowlist configured (all IDs allowed).
|
|
"""
|
|
ids_str = _env_get(
|
|
ENV_BRIDGE_ALLOWED_DEVICE_IDS, LEGACY_ENV_BRIDGE_ALLOWED_DEVICE_IDS, ""
|
|
)
|
|
if not ids_str:
|
|
return None
|
|
return set(id.strip() for id in ids_str.split(",") if id.strip())
|
|
|
|
|
|
def is_mtls_enabled() -> bool:
|
|
"""Check if mTLS enforcement is enabled."""
|
|
return os.environ.get(ENV_BRIDGE_MTLS_ENABLED, "false").lower() in (
|
|
"true",
|
|
"1",
|
|
"yes",
|
|
"on",
|
|
)
|
|
|
|
|
|
def get_device_cert_map() -> Dict[str, str]:
|
|
"""
|
|
Get map of device_id -> certificate fingerprint.
|
|
Format: device_id:fingerprint,device_id2:fingerprint2
|
|
"""
|
|
mapping_str = os.environ.get(ENV_BRIDGE_DEVICE_CERT_MAP, "")
|
|
if not mapping_str:
|
|
return {}
|
|
|
|
result = {}
|
|
for entry in mapping_str.split(","):
|
|
if ":" in entry:
|
|
parts = entry.split(":", 1)
|
|
result[parts[0].strip()] = parts[1].strip()
|
|
return result
|
|
|
|
|
|
def validate_mtls_binding(request: web.Request, device_id: str) -> Tuple[bool, str]:
|
|
"""
|
|
R104: Validate mTLS certificate binding for the device.
|
|
"""
|
|
if not is_mtls_enabled():
|
|
return True, ""
|
|
|
|
cert_hash = request.headers.get(HEADER_CLIENT_CERT_HASH, "")
|
|
if not cert_hash:
|
|
# Strict mode: mTLS enabled but no cert header -> fail
|
|
return False, "Missing client certificate header (mTLS required)"
|
|
|
|
cert_map = get_device_cert_map()
|
|
expected_hash = cert_map.get(device_id)
|
|
|
|
if not expected_hash:
|
|
# Strict mode: mTLS enabled implies explicit device binding
|
|
return False, "Device not bound to a certificate"
|
|
|
|
# Constant-time comparison not strictly required for public fingerprints but good practice
|
|
if not hmac.compare_digest(cert_hash, expected_hash):
|
|
# IMPORTANT: keep bound-device diagnostics redacted; raw fingerprints are sensitive.
|
|
logger.warning(
|
|
"mTLS violation for %s presented=%s expected=%s",
|
|
_device_tag(device_id),
|
|
_cert_tag(cert_hash),
|
|
_cert_tag(expected_hash),
|
|
)
|
|
return False, "Certificate fingerprint mismatch"
|
|
|
|
return True, ""
|
|
|
|
|
|
def validate_device_token(
|
|
request: web.Request, required_scope: Optional[BridgeScope] = None
|
|
) -> Tuple[bool, str, Optional[str]]:
|
|
"""
|
|
Validate device authentication for bridge endpoints.
|
|
|
|
Args:
|
|
request: aiohttp request
|
|
required_scope: Optional required scope (currently ignored; all scopes granted)
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message, device_id)
|
|
"""
|
|
# Check if bridge is enabled
|
|
if not is_bridge_enabled():
|
|
return False, "Bridge not enabled", None
|
|
|
|
# Extract headers
|
|
device_id = request.headers.get(HEADER_DEVICE_ID)
|
|
if not device_id and request.headers.get(LEGACY_HEADER_DEVICE_ID):
|
|
device_id = request.headers.get(LEGACY_HEADER_DEVICE_ID)
|
|
try:
|
|
from ..metrics import metrics
|
|
|
|
if metrics:
|
|
metrics.inc("legacy_api_hits")
|
|
except ImportError:
|
|
pass
|
|
logger.warning(
|
|
f"DEPRECATION WARNING: Legacy header {LEGACY_HEADER_DEVICE_ID} used. Please migrate to {HEADER_DEVICE_ID}."
|
|
)
|
|
|
|
device_token = request.headers.get(HEADER_DEVICE_TOKEN)
|
|
if not device_token and request.headers.get(LEGACY_HEADER_DEVICE_TOKEN):
|
|
device_token = request.headers.get(LEGACY_HEADER_DEVICE_TOKEN)
|
|
try:
|
|
from ..metrics import metrics
|
|
|
|
if metrics:
|
|
metrics.inc("legacy_api_hits")
|
|
except ImportError:
|
|
pass
|
|
logger.warning(
|
|
f"DEPRECATION WARNING: Legacy header {LEGACY_HEADER_DEVICE_TOKEN} used. Please migrate to {HEADER_DEVICE_TOKEN}."
|
|
)
|
|
|
|
if not device_id:
|
|
return False, "Missing device ID", None
|
|
|
|
if not device_token:
|
|
return False, "Missing device token", None
|
|
|
|
# S58: lifecycle-aware validation path.
|
|
# CRITICAL: keep this check before legacy static-token fallback so revoked/
|
|
# expired lifecycle tokens are deterministically denied on every bridge route.
|
|
try:
|
|
try:
|
|
from ..bridge_token_lifecycle import get_token_store
|
|
except ImportError:
|
|
from services.bridge_token_lifecycle import get_token_store # type: ignore
|
|
|
|
required_scope_value = (
|
|
required_scope.value
|
|
if isinstance(required_scope, BridgeScope)
|
|
else required_scope
|
|
)
|
|
lifecycle_result = get_token_store().validate_token(
|
|
device_token, required_scope=required_scope_value
|
|
)
|
|
except Exception:
|
|
logger.warning("S58 lifecycle validation unavailable, falling back.")
|
|
lifecycle_result = None
|
|
|
|
if lifecycle_result and lifecycle_result.ok and lifecycle_result.token:
|
|
token_device_id = lifecycle_result.token.device_id
|
|
if device_id and device_id != token_device_id:
|
|
return False, "Device ID mismatch", None
|
|
device_id = token_device_id
|
|
elif lifecycle_result and lifecycle_result.reject_reason != "unknown_token":
|
|
reason_map = {
|
|
"token_revoked": "Token revoked",
|
|
"token_expired": "Token expired",
|
|
"overlap_window_expired": "Token overlap window expired",
|
|
"insufficient_scope": "Missing required scope",
|
|
"token_not_found": "Token not found",
|
|
}
|
|
return (
|
|
False,
|
|
reason_map.get(lifecycle_result.reject_reason, "Invalid device token"),
|
|
None,
|
|
)
|
|
else:
|
|
# Legacy static-token fallback for backward compatibility.
|
|
expected_token = get_bridge_device_token()
|
|
if not expected_token:
|
|
logger.error(
|
|
"Bridge enabled but OPENCLAW_BRIDGE_DEVICE_TOKEN (or legacy MOLTBOT_BRIDGE_DEVICE_TOKEN) not set"
|
|
)
|
|
return False, "Bridge misconfigured", None
|
|
|
|
if not device_id:
|
|
return False, "Missing device ID", None
|
|
|
|
# Constant-time token comparison
|
|
if not hmac.compare_digest(device_token, expected_token):
|
|
logger.warning("Invalid device token from %s", _device_tag(device_id))
|
|
return False, "Invalid device token", None
|
|
|
|
# Scope validation (legacy header contract)
|
|
if required_scope:
|
|
scopes_header = request.headers.get(
|
|
HEADER_SCOPES, ""
|
|
) or request.headers.get(LEGACY_HEADER_SCOPES, "")
|
|
if not scopes_header:
|
|
logger.warning(
|
|
"Bridge device %s missing required scopes header.",
|
|
_device_tag(device_id),
|
|
)
|
|
return False, "Missing X-OpenClaw-Scopes header", None
|
|
|
|
granted_scopes = set(
|
|
s.strip() for s in scopes_header.split(",") if s.strip()
|
|
)
|
|
if required_scope not in granted_scopes:
|
|
logger.warning(
|
|
"Bridge device %s missing scope %s (granted_count=%d).",
|
|
_device_tag(device_id),
|
|
required_scope,
|
|
len(granted_scopes),
|
|
)
|
|
return False, f"Missing required scope: {required_scope}", None
|
|
|
|
# Check allowlist if configured
|
|
allowed_ids = get_allowed_device_ids()
|
|
if allowed_ids is not None and device_id not in allowed_ids:
|
|
logger.warning("Device ID not in allowlist: %s", _device_tag(device_id))
|
|
return False, "Device not authorized", None
|
|
|
|
# R104: mTLS Binding Check
|
|
is_mtls_valid, mtls_error = validate_mtls_binding(request, device_id)
|
|
if not is_mtls_valid:
|
|
logger.warning(
|
|
"mTLS validation failed for %s: %s", _device_tag(device_id), mtls_error
|
|
)
|
|
return False, mtls_error, None
|
|
|
|
return True, "", device_id
|
|
|
|
|
|
def require_bridge_auth(
|
|
request: web.Request, required_scope: Optional[BridgeScope] = None
|
|
) -> Tuple[bool, Optional[web.Response], Optional[str]]:
|
|
"""
|
|
Middleware-style auth check for bridge endpoints.
|
|
|
|
Args:
|
|
request: aiohttp request
|
|
required_scope: Optional required scope
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_response_or_none, device_id)
|
|
"""
|
|
is_valid, error_msg, device_id = validate_device_token(request, required_scope)
|
|
|
|
if not is_valid:
|
|
status = (
|
|
403 if "not enabled" in error_msg or "misconfigured" in error_msg else 401
|
|
)
|
|
return False, web.json_response({"error": error_msg}, status=status), None
|
|
|
|
return True, None, device_id
|