mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
362 lines
12 KiB
Python
362 lines
12 KiB
Python
"""
|
|
S25: Server-side Secret Store
|
|
|
|
Secure persistence for API keys and sensitive configuration values.
|
|
Designed for localhost-only, single-user scenarios where UI secret entry is more convenient than ENV vars.
|
|
|
|
Security principles:
|
|
- Never log secret values
|
|
- Best-effort file permissions (0600 on POSIX)
|
|
- Clear source tracking (env vs server_store)
|
|
- S57: At-rest encryption via Fernet AEAD (secrets.enc.json)
|
|
- Automatic migration from legacy plaintext secrets.json
|
|
|
|
Storage location: {STATE_DIR}/secrets.enc.json
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import stat
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
try:
|
|
from .tenant_context import (
|
|
DEFAULT_TENANT_ID,
|
|
get_current_tenant_id,
|
|
is_multi_tenant_enabled,
|
|
normalize_tenant_id,
|
|
)
|
|
except ImportError:
|
|
try:
|
|
from services.tenant_context import ( # type: ignore
|
|
DEFAULT_TENANT_ID,
|
|
get_current_tenant_id,
|
|
is_multi_tenant_enabled,
|
|
normalize_tenant_id,
|
|
)
|
|
except ImportError:
|
|
DEFAULT_TENANT_ID = "default"
|
|
|
|
def get_current_tenant_id(): # type: ignore
|
|
return DEFAULT_TENANT_ID
|
|
|
|
def is_multi_tenant_enabled(): # type: ignore
|
|
return False
|
|
|
|
def normalize_tenant_id(value): # type: ignore
|
|
return str(value or DEFAULT_TENANT_ID).strip().lower() or DEFAULT_TENANT_ID
|
|
|
|
|
|
try:
|
|
from .state_dir import get_state_dir
|
|
except ImportError:
|
|
from state_dir import get_state_dir
|
|
|
|
logger = logging.getLogger("ComfyUI-OpenClaw.services.secret_store")
|
|
|
|
# S57: Encrypted store filename (replaces legacy secrets.json)
|
|
SECRET_STORE_FILE = "secrets.enc.json"
|
|
LEGACY_STORE_FILE = "secrets.json"
|
|
|
|
|
|
def _get_encryption_module():
|
|
"""Lazy-load secrets_encryption to avoid circular imports."""
|
|
try:
|
|
from . import secrets_encryption
|
|
|
|
return secrets_encryption
|
|
except ImportError:
|
|
import secrets_encryption # type: ignore
|
|
|
|
return secrets_encryption
|
|
|
|
|
|
class SecretStore:
|
|
"""
|
|
Server-side secret storage with encrypted file-based persistence.
|
|
|
|
Thread-safe for read/write operations.
|
|
S57: All secrets are encrypted at rest using Fernet AEAD.
|
|
"""
|
|
|
|
def __init__(self, state_dir: Optional[str] = None):
|
|
"""
|
|
Initialize secret store.
|
|
|
|
Args:
|
|
state_dir: Override state directory (for testing)
|
|
"""
|
|
self._state_dir = Path(state_dir) if state_dir else Path(get_state_dir())
|
|
self._store_path = self._state_dir / SECRET_STORE_FILE
|
|
self._legacy_path = self._state_dir / LEGACY_STORE_FILE
|
|
self._secrets: Dict[str, str] = {}
|
|
self._lock = threading.RLock()
|
|
self._encryption_key: Optional[bytes] = None
|
|
self._load()
|
|
|
|
def _get_key(self) -> bytes:
|
|
"""Get or load the encryption key."""
|
|
if self._encryption_key is None:
|
|
enc = _get_encryption_module()
|
|
self._encryption_key = enc._load_or_create_key(self._state_dir)
|
|
return self._encryption_key
|
|
|
|
def _resolve_tenant_id(self, tenant_id: Optional[str]) -> str:
|
|
if tenant_id is not None:
|
|
return normalize_tenant_id(tenant_id)
|
|
if is_multi_tenant_enabled():
|
|
try:
|
|
return normalize_tenant_id(get_current_tenant_id())
|
|
except Exception:
|
|
return DEFAULT_TENANT_ID
|
|
return DEFAULT_TENANT_ID
|
|
|
|
def _tenant_key(self, provider_id: str, tenant_id: Optional[str] = None) -> str:
|
|
tenant = self._resolve_tenant_id(tenant_id)
|
|
if tenant == DEFAULT_TENANT_ID:
|
|
return provider_id
|
|
return f"tenant::{tenant}::{provider_id}"
|
|
|
|
def _allow_legacy_fallback(self) -> bool:
|
|
value = (
|
|
os.environ.get("OPENCLAW_MULTI_TENANT_ALLOW_LEGACY_SECRET_FALLBACK")
|
|
or os.environ.get("MOLTBOT_MULTI_TENANT_ALLOW_LEGACY_SECRET_FALLBACK")
|
|
or "0"
|
|
)
|
|
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
|
|
|
def _migrate_legacy(self) -> bool:
|
|
"""Migrate legacy plaintext secrets.json to encrypted format."""
|
|
if not self._legacy_path.exists():
|
|
return False
|
|
if self._store_path.exists():
|
|
# Both exist — encrypted store takes precedence
|
|
return False
|
|
|
|
try:
|
|
with open(self._legacy_path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
if not isinstance(data, dict):
|
|
return False
|
|
|
|
self._secrets = data
|
|
self._save()
|
|
|
|
# Rename legacy file to prevent re-migration
|
|
backup_path = self._legacy_path.with_suffix(".json.bak")
|
|
self._legacy_path.rename(backup_path)
|
|
logger.info(
|
|
f"S57: Migrated {len(data)} secrets from plaintext to encrypted store "
|
|
f"(legacy file renamed to {backup_path.name})"
|
|
)
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"S57: Failed to migrate legacy secrets: {e}")
|
|
return False
|
|
|
|
def _load(self) -> None:
|
|
"""Load secrets from disk (encrypted)."""
|
|
with self._lock:
|
|
# Try encrypted store first
|
|
if self._store_path.exists():
|
|
try:
|
|
if self._store_path.stat().st_size == 0:
|
|
logger.warning(
|
|
"S57: Encrypted store file empty (likely crashed write)"
|
|
)
|
|
return
|
|
|
|
with open(self._store_path, "r", encoding="utf-8") as f:
|
|
envelope_data = json.load(f)
|
|
|
|
# Decrypt the envelope
|
|
enc = _get_encryption_module()
|
|
envelope = enc.EncryptedEnvelope.from_dict(envelope_data)
|
|
key = self._get_key()
|
|
self._secrets = enc.decrypt_secrets(envelope, key)
|
|
logger.info(
|
|
f"S57: Loaded {len(self._secrets)} secrets from encrypted store"
|
|
)
|
|
return
|
|
except Exception as e:
|
|
logger.error(f"S57: Failed to load encrypted store: {e}")
|
|
return
|
|
|
|
# Try legacy migration
|
|
if self._migrate_legacy():
|
|
return
|
|
|
|
# No store exists
|
|
logger.debug(
|
|
"S25: Secret store file not found (will be created on first write)"
|
|
)
|
|
|
|
def _save(self) -> None:
|
|
"""Save secrets to disk with encryption."""
|
|
with self._lock:
|
|
try:
|
|
# Ensure state dir exists
|
|
self._state_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Encrypt secrets
|
|
enc = _get_encryption_module()
|
|
key = self._get_key()
|
|
envelope = enc.encrypt_secrets(self._secrets, key)
|
|
envelope_dict = envelope.to_dict()
|
|
|
|
# Write atomically (temp file + rename)
|
|
temp_path = self._store_path.with_suffix(".tmp")
|
|
with open(temp_path, "w", encoding="utf-8") as f:
|
|
json.dump(envelope_dict, f, indent=2)
|
|
|
|
# Best-effort file permissions (POSIX only)
|
|
try:
|
|
if hasattr(os, "chmod"):
|
|
os.chmod(temp_path, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"S25: Failed to set file permissions (non-fatal): {e}"
|
|
)
|
|
|
|
# Atomic rename
|
|
temp_path.replace(self._store_path)
|
|
|
|
# Never log secret values
|
|
logger.info(f"S57: Saved {len(self._secrets)} secrets (encrypted)")
|
|
except Exception as e:
|
|
logger.error(f"S57: Failed to save encrypted store: {e}")
|
|
raise
|
|
|
|
def get_secret(
|
|
self, provider_id: str, tenant_id: Optional[str] = None
|
|
) -> Optional[str]:
|
|
"""
|
|
Get secret for provider.
|
|
|
|
Args:
|
|
provider_id: Provider identifier (e.g., "openai", "anthropic", "generic")
|
|
|
|
Returns:
|
|
Secret value or None if not found
|
|
"""
|
|
tenant = self._resolve_tenant_id(tenant_id)
|
|
scoped_key = self._tenant_key(provider_id, tenant)
|
|
with self._lock:
|
|
value = self._secrets.get(scoped_key)
|
|
if value:
|
|
return value
|
|
# S49: Optional compatibility fallback (explicit only) when tenant data
|
|
# has not been migrated yet.
|
|
if tenant != DEFAULT_TENANT_ID and self._allow_legacy_fallback():
|
|
return self._secrets.get(provider_id)
|
|
return None
|
|
|
|
def set_secret(
|
|
self, provider_id: str, secret: str, tenant_id: Optional[str] = None
|
|
) -> None:
|
|
"""
|
|
Set secret for provider.
|
|
|
|
Args:
|
|
provider_id: Provider identifier
|
|
secret: Secret value (API key, token, etc.)
|
|
"""
|
|
if not isinstance(secret, str) or not secret.strip():
|
|
raise ValueError("Secret must be non-empty string")
|
|
|
|
tenant = self._resolve_tenant_id(tenant_id)
|
|
scoped_key = self._tenant_key(provider_id, tenant)
|
|
with self._lock:
|
|
self._secrets[scoped_key] = secret.strip()
|
|
self._save()
|
|
|
|
# Never log secret value
|
|
logger.info(
|
|
"S25/S49: Set secret for provider '%s' (tenant=%s)", provider_id, tenant
|
|
)
|
|
|
|
def clear_secret(self, provider_id: str, tenant_id: Optional[str] = None) -> bool:
|
|
"""
|
|
Clear secret for provider.
|
|
|
|
Args:
|
|
provider_id: Provider identifier
|
|
|
|
Returns:
|
|
True if secret was removed, False if not found
|
|
"""
|
|
tenant = self._resolve_tenant_id(tenant_id)
|
|
scoped_key = self._tenant_key(provider_id, tenant)
|
|
with self._lock:
|
|
if scoped_key in self._secrets:
|
|
del self._secrets[scoped_key]
|
|
self._save()
|
|
logger.info(
|
|
"S25/S49: Cleared secret for provider '%s' (tenant=%s)",
|
|
provider_id,
|
|
tenant,
|
|
)
|
|
return True
|
|
return False
|
|
|
|
def clear_all(self) -> int:
|
|
"""
|
|
Clear all secrets.
|
|
|
|
Returns:
|
|
Number of secrets cleared
|
|
"""
|
|
with self._lock:
|
|
count = len(self._secrets)
|
|
self._secrets.clear()
|
|
self._save()
|
|
logger.info(f"S25: Cleared all secrets ({count} total)")
|
|
return count
|
|
|
|
def get_status(self, tenant_id: Optional[str] = None) -> Dict[str, Dict[str, Any]]:
|
|
"""
|
|
Get secret status (NO SECRET VALUES).
|
|
|
|
Returns:
|
|
Dict of {provider_id: {configured: bool, source: "server_store"}}
|
|
"""
|
|
tenant = self._resolve_tenant_id(tenant_id)
|
|
prefix = f"tenant::{tenant}::"
|
|
with self._lock:
|
|
status = {}
|
|
for key in self._secrets.keys():
|
|
provider_id = None
|
|
if tenant == DEFAULT_TENANT_ID:
|
|
if key.startswith("tenant::"):
|
|
continue
|
|
provider_id = key
|
|
elif key.startswith(prefix):
|
|
provider_id = key[len(prefix) :]
|
|
if not provider_id:
|
|
continue
|
|
status[provider_id] = {"configured": True, "source": "server_store"}
|
|
return status
|
|
|
|
|
|
# Singleton instance
|
|
_store_instance: Optional[SecretStore] = None
|
|
|
|
|
|
def get_secret_store(state_dir: Optional[str] = None) -> SecretStore:
|
|
"""
|
|
Get singleton secret store instance.
|
|
|
|
Args:
|
|
state_dir: Override state directory (for testing)
|
|
|
|
Returns:
|
|
SecretStore instance
|
|
"""
|
|
global _store_instance
|
|
if _store_instance is None or state_dir is not None:
|
|
_store_instance = SecretStore(state_dir=state_dir)
|
|
return _store_instance
|