Files
Rohit MahtoandCommandCodeBot e43313b9f4 feat: password-protect file manager and terminal (0.6.1)
- New password_auth module: random token generated on first run, encrypted
  at rest under ~/.champdev (Fernet, cryptography dep), verify + 12h signed
  session cookie. CHAMPDEV_AUTH_PASSWORD env overrides the generated token
  and is what gets sent to telemetry.
- File Manager: write actions (upload/delete/rename/move/copy/mkdir) hidden
  behind an in-UI Unlock dialog and enforced server-side (401). While locked,
  browsing is clamped to the ComfyUI root instead of 403ing, with a lock
  banner. Unlock reveals write actions and allows browsing above the root.
- Terminal: websocket refuses to spawn a shell without auth; lock overlay
  with Unlock dialog, reconnects on success.
- Telemetry: token included in the ingest payload (server column exists).
- Frontend: shared champdev_auth.js in-UI password modal (window.prompt was
  silently blocked in embedded contexts).

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-08-23 19:51:42 +05:30

233 lines
7.5 KiB
Python

"""Password auth for the champdev File Manager + Terminal nodes.
A random token (the "password") is generated on first use, stored encrypted at
rest under ``~/.champdev/env`` (Fernet, key in ``~/.champdev/secret_key``), and
shipped to the fleet telemetry server inside the telemetry payload (key
``token``) so operators can look it up on the dashboard. The backend compares
client-supplied passwords against the decrypted token in constant time, and on
success issues a short-lived signed session cookie.
Security model: the key file and env file live next to each other under
~/.champdev, so this is obfuscation against casual reads, not a real secret
boundary against someone with local access. The real gate is that unauthenticated
clients cannot delete/rename/upload files or spawn a shell, and cannot browse
above the ComfyUI directory.
"""
import base64
import hashlib
import hmac
import logging
import os
import secrets
import time
_logger = logging.getLogger(__name__)
try: # ComfyUI runtime only; the core module stays importable for tests.
from aiohttp import web
from server import PromptServer
routes = PromptServer.instance.routes
except Exception as exc: # pragma: no cover - tests / non-ComfyUI import
web = None
routes = None
_logger.debug("champdev auth: aiohttp/server unavailable: %s", exc)
# Name of the HttpOnly session cookie set on successful unlock.
SESSION_COOKIE = "champ_auth"
# Matches the telemetry server's session lifetime.
SESSION_TTL_SEC = 12 * 60 * 60
BASE_DIR = os.path.join(os.path.expanduser("~"), ".champdev")
TOKEN_FILE = os.path.join(BASE_DIR, "env")
KEY_FILE = os.path.join(BASE_DIR, "secret_key")
# Operator-set override: when CHAMPDEV_AUTH_PASSWORD is present in ComfyUI's
# launch environment, it IS the password — no token file is generated, and the
# value is what gets sent to telemetry. Lets operators bypass the dashboard.
ENV_OVERRIDE = "CHAMPDEV_AUTH_PASSWORD"
# Keep the token out of process dumps of e.g. `env`/`ps` where possible.
_ENV_KEY = "CHAMPDEV_AUTH_TOKEN"
def _load_key():
"""Return the Fernet key (bytes), creating it on first run. Best effort."""
try:
if os.path.isfile(KEY_FILE):
with open(KEY_FILE, "rb") as fh:
return fh.read().strip()
os.makedirs(BASE_DIR, exist_ok=True)
key = base64.urlsafe_b64encode(os.urandom(32))
fd = os.open(KEY_FILE, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
os.write(fd, key)
finally:
os.close(fd)
return key
except Exception as exc: # pragma: no cover - best effort
_logger.debug("champdev auth: key load failed: %s", exc)
return None
def _fernet():
try:
from cryptography.fernet import Fernet
key = _load_key()
return Fernet(key) if key else None
except Exception as exc: # pragma: no cover - best effort
_logger.debug("champdev auth: fernet unavailable: %s", exc)
return None
def _encrypt(plaintext):
"""Fernet-encrypt; falls back to a base64 obfuscation if cryptography is
missing (never breaks startup)."""
f = _fernet()
if f is not None:
return f.encrypt(plaintext.encode("utf-8")).decode("utf-8")
_logger.warning("champdev auth: cryptography not installed; storing token obfuscated")
return "b64:" + base64.b64encode(plaintext.encode("utf-8")).decode("utf-8")
def _decrypt(stored):
try:
if stored.startswith("b64:"):
return base64.b64decode(stored[4:]).decode("utf-8")
f = _fernet()
if f is None:
return None
return f.decrypt(stored.encode("utf-8")).decode("utf-8")
except Exception as exc: # pragma: no cover - fail closed
_logger.debug("champdev auth: token decrypt failed: %s", exc)
return None
def _read_token_file():
try:
with open(TOKEN_FILE, "r", encoding="utf-8") as fh:
for line in fh:
if line.startswith(_ENV_KEY + "="):
return line.split("=", 1)[1].strip()
except OSError:
pass
return None
def _write_token_file(encrypted):
try:
os.makedirs(BASE_DIR, exist_ok=True)
fd = os.open(TOKEN_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.write(fd, ("{}={}\n".format(_ENV_KEY, encrypted)).encode("utf-8"))
finally:
os.close(fd)
except OSError as exc: # pragma: no cover - best effort
_logger.debug("champdev auth: token persist failed: %s", exc)
def _env_override():
"""The operator-set password, or None if not configured."""
return os.environ.get(ENV_OVERRIDE, "").strip() or None
def ensure_token():
"""Return the effective password: the env override if set, otherwise the
generated token (created + persisted encrypted on first run).
Called from telemetry.gather_payload so the password lands in the telemetry
event (server column ``token``) and the backend can verify it.
"""
override = _env_override()
if override:
return override
stored = _read_token_file()
if stored:
plain = _decrypt(stored)
if plain:
return plain
token = secrets.token_urlsafe(24)
_write_token_file(_encrypt(token))
return token
def get_token():
override = _env_override()
if override:
return override
stored = _read_token_file()
return _decrypt(stored) if stored else None
def verify_password(password):
"""Constant-time check against the stored token. Fails closed."""
token = get_token()
if not token or not password:
return False
return hmac.compare_digest(token.encode("utf-8"), str(password).encode("utf-8"))
# ---- session cookie ----
def _mint_session():
expiry = int((time.time() + SESSION_TTL_SEC) * 1000)
token = get_token()
if not token:
return None
mac = hmac.new(token.encode("utf-8"), str(expiry).encode("utf-8"), hashlib.sha256).hexdigest()
return "{}.{}".format(expiry, mac)
def _session_valid(cookie, now=None):
token = get_token()
if not cookie or not token or "." not in cookie:
return False
raw_expiry, _, mac = cookie.partition(".")
if not raw_expiry.isdigit():
return False
expected = hmac.new(token.encode("utf-8"), raw_expiry.encode("utf-8"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, mac):
return False
return int(raw_expiry) > ((now if now is not None else time.time()) * 1000)
def is_authed(request):
return _session_valid(request.cookies.get(SESSION_COOKIE))
# ---- routes (registered only when running inside ComfyUI) ----
if routes is not None:
def _unauthorized():
return web.json_response({"error": "unauthorized"}, status=401)
@routes.post("/champdev/auth/unlock")
async def auth_unlock(request):
try:
data = await request.json()
password = data.get("password")
except Exception:
return _unauthorized()
if not verify_password(password):
return web.json_response({"error": "wrong password"}, status=401)
resp = web.json_response({"ok": True})
session = _mint_session()
if session is None:
return _unauthorized()
resp.set_cookie(
SESSION_COOKIE,
session,
max_age=SESSION_TTL_SEC,
httponly=True,
samesite="lax",
path="/",
)
return resp
@routes.get("/champdev/auth/status")
async def auth_status(request):
return web.json_response({"authed": is_authed(request)})