diff --git a/app/model_downloader/auth/token_store.py b/app/model_downloader/auth/token_store.py index 11e6f19af..333550017 100644 --- a/app/model_downloader/auth/token_store.py +++ b/app/model_downloader/auth/token_store.py @@ -1,19 +1,38 @@ -"""On-disk OAuth token persistence — one ``0600`` JSON file per provider. +"""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. The file is written with ``0600`` so only the owner can -read the refresh/access token at rest. +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: @@ -37,17 +56,103 @@ def _auth_dir() -> str: def _token_path(provider: str) -> str: - return os.path.join(_auth_dir(), f"{provider}.json") + 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), "r", encoding="utf-8") as f: - data = json.load(f) + 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"), @@ -58,10 +163,19 @@ def load(provider: str) -> Token | None: 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, "w", encoding="utf-8") as f: - json.dump(asdict(token), f) + with os.fdopen(fd, "wb") as f: + f.write(blob) os.chmod(path, 0o600)