mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-07-16 11:28:54 +08:00
187 lines
5.5 KiB
Python
187 lines
5.5 KiB
Python
"""On-disk OAuth token persistence — one machine-bound blob per provider.
|
|
|
|
Tokens live under ``folder_paths.get_system_user_directory("download_auth")``,
|
|
never in the SQLite DB. Each provider file is written ``0600`` and holds an
|
|
opaque blob, not readable JSON: the token JSON is XORed with an HMAC-SHA256
|
|
keystream whose key is derived from stable machine/install attributes plus a
|
|
per-install random salt.
|
|
|
|
This is obfuscation, not confidentiality. It stops a token from being read by a
|
|
human browsing files, grepped out of a backup, or lifted from a folder copied to
|
|
another machine (the blob won't decrypt off its origin machine). It does not
|
|
protect against code running inside this process (custom nodes) or an attacker
|
|
who reads this source and recomputes the key.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import getpass
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import platform
|
|
import secrets
|
|
import time
|
|
from dataclasses import asdict, dataclass
|
|
|
|
import folder_paths
|
|
|
|
_SALT_FILE = ".salt"
|
|
_SALT_LEN = 32
|
|
_NONCE_LEN = 16
|
|
_PBKDF2_ITERS = 200_000
|
|
|
|
|
|
@dataclass
|
|
class Token:
|
|
access_token: str
|
|
refresh_token: str | None = None
|
|
# Epoch seconds when the access token expires; 0 means "unknown / no expiry".
|
|
expires_at: int = 0
|
|
token_type: str = "Bearer"
|
|
scope: str | None = None
|
|
|
|
def is_expired(self, skew: int = 60) -> bool:
|
|
if not self.expires_at:
|
|
return False
|
|
return time.time() + skew >= self.expires_at
|
|
|
|
|
|
def _auth_dir() -> str:
|
|
path = folder_paths.get_system_user_directory("download_auth")
|
|
os.makedirs(path, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def _token_path(provider: str) -> str:
|
|
return os.path.join(_auth_dir(), f"{provider}.bin")
|
|
|
|
|
|
def _machine_id() -> bytes:
|
|
"""A stable per-machine identifier, best-effort across platforms."""
|
|
for path in ("/etc/machine-id", "/var/lib/dbus/machine-id"):
|
|
try:
|
|
with open(path, "rb") as f:
|
|
return f.read().strip()
|
|
except OSError:
|
|
pass
|
|
if os.name == "nt":
|
|
import winreg
|
|
|
|
try:
|
|
key = winreg.OpenKey(
|
|
winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Cryptography"
|
|
)
|
|
try:
|
|
guid, _ = winreg.QueryValueEx(key, "MachineGuid")
|
|
return str(guid).encode("utf-8")
|
|
finally:
|
|
winreg.CloseKey(key)
|
|
except OSError:
|
|
pass
|
|
return platform.node().encode("utf-8")
|
|
|
|
|
|
def _machine_material(auth_dir: str) -> bytes:
|
|
try:
|
|
user = getpass.getuser()
|
|
except Exception:
|
|
user = ""
|
|
parts = (_machine_id(), platform.node().encode("utf-8"), user.encode("utf-8"), auth_dir.encode("utf-8"))
|
|
return b"\x00".join(parts)
|
|
|
|
|
|
def _load_or_create_salt(auth_dir: str) -> bytes | None:
|
|
path = os.path.join(auth_dir, _SALT_FILE)
|
|
try:
|
|
with open(path, "rb") as f:
|
|
salt = f.read()
|
|
if len(salt) == _SALT_LEN:
|
|
return salt
|
|
except FileNotFoundError:
|
|
pass
|
|
except OSError:
|
|
return None
|
|
salt = secrets.token_bytes(_SALT_LEN)
|
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
with os.fdopen(fd, "wb") as f:
|
|
f.write(salt)
|
|
os.chmod(path, 0o600)
|
|
return salt
|
|
|
|
|
|
def _derive_key(salt: bytes, auth_dir: str) -> bytes:
|
|
return hashlib.pbkdf2_hmac(
|
|
"sha256", _machine_material(auth_dir), salt, _PBKDF2_ITERS, dklen=32
|
|
)
|
|
|
|
|
|
def _keystream(key: bytes, nonce: bytes, n: int) -> bytes:
|
|
out = bytearray()
|
|
counter = 0
|
|
while len(out) < n:
|
|
out.extend(hmac.new(key, nonce + counter.to_bytes(8, "big"), hashlib.sha256).digest())
|
|
counter += 1
|
|
return bytes(out[:n])
|
|
|
|
|
|
def _xor(data: bytes, stream: bytes) -> bytes:
|
|
return bytes(a ^ b for a, b in zip(data, stream))
|
|
|
|
|
|
def load(provider: str) -> Token | None:
|
|
auth_dir = _auth_dir()
|
|
try:
|
|
with open(_token_path(provider), "rb") as f:
|
|
blob = base64.b64decode(f.read())
|
|
except FileNotFoundError:
|
|
return None
|
|
except (ValueError, OSError):
|
|
return None
|
|
salt = _load_or_create_salt(auth_dir)
|
|
if salt is None or len(blob) <= _NONCE_LEN:
|
|
return None
|
|
nonce, ciphertext = blob[:_NONCE_LEN], blob[_NONCE_LEN:]
|
|
key = _derive_key(salt, auth_dir)
|
|
plaintext = _xor(ciphertext, _keystream(key, nonce, len(ciphertext)))
|
|
# A wrong machine / corrupt file decrypts to garbage; treat as logged out.
|
|
try:
|
|
data = json.loads(plaintext)
|
|
except ValueError:
|
|
return None
|
|
if not isinstance(data, dict) or "access_token" not in data:
|
|
return None
|
|
return Token(
|
|
access_token=data.get("access_token", ""),
|
|
refresh_token=data.get("refresh_token"),
|
|
expires_at=int(data.get("expires_at", 0) or 0),
|
|
token_type=data.get("token_type", "Bearer"),
|
|
scope=data.get("scope"),
|
|
)
|
|
|
|
|
|
def save(provider: str, token: Token) -> None:
|
|
auth_dir = _auth_dir()
|
|
salt = _load_or_create_salt(auth_dir)
|
|
if salt is None:
|
|
return
|
|
key = _derive_key(salt, auth_dir)
|
|
nonce = secrets.token_bytes(_NONCE_LEN)
|
|
plaintext = json.dumps(asdict(token)).encode("utf-8")
|
|
ciphertext = _xor(plaintext, _keystream(key, nonce, len(plaintext)))
|
|
blob = base64.b64encode(nonce + ciphertext)
|
|
path = _token_path(provider)
|
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
with os.fdopen(fd, "wb") as f:
|
|
f.write(blob)
|
|
os.chmod(path, 0o600)
|
|
|
|
|
|
def delete(provider: str) -> None:
|
|
try:
|
|
os.remove(_token_path(provider))
|
|
except FileNotFoundError:
|
|
pass
|