""" credentials.py - fetch + decrypt Anycubic Kobra printer credentials. Talks to the printer's HTTP /info + /ctrl endpoints (port 18910) and decrypts the AES-256-CBC response to recover username/password/device_id/mode_id. Algorithm reverse-engineered in tools/fetch_credentials.py. Extracted from kobrax_moonraker_bridge.py; _kx_fetch_credentials is re-imported there for the "add printer" flow. ──────────────────────────────────────────────────────────────────────────── Copyright (C) 2026 viewit (KX-Bridge contributors) Licensed under GPLv3 — see LICENSE in the project root. Protocol reverse-engineered for interoperability (§69e UrhG). See NOTICE.md. """ import json import time import hashlib import aiohttp try: import base64 as _base64 from Crypto.Cipher import AES as _AES from Crypto.Util.Padding import unpad as _unpad _HAS_CRYPTO = True except ImportError: _HAS_CRYPTO = False def _kx_generate_signature(token: str, ts: int, nonce: str) -> str: first = hashlib.md5(token[:16].encode()).hexdigest() return hashlib.md5((first + str(ts) + nonce).encode()).hexdigest() def _kx_decrypt_info(encrypted_b64: str, key: str, iv: str) -> dict: cipher = _AES.new(key.encode(), _AES.MODE_CBC, iv.encode()) raw = _base64.b64decode(encrypted_b64) return json.loads(_unpad(cipher.decrypt(raw), _AES.block_size).decode()) async def _kx_fetch_credentials(ip: str, port: int = 18910) -> dict: """Fetches + decrypts printer credentials via HTTP /info + /ctrl. Raises an exception on network/decrypt errors. Algorithm from tools/fetch_credentials.py (AES-256-CBC, Key=token[16:32], IV=ctrl-token). """ if not _HAS_CRYPTO: raise RuntimeError("pycryptodome is not installed") import random, string nonce = "".join(random.choice(string.ascii_letters + string.digits) for _ in range(6)) timeout = aiohttp.ClientTimeout(total=10) async with aiohttp.ClientSession() as s: async with s.get(f"http://{ip}:{port}/info", timeout=timeout) as r: r.raise_for_status() info = await r.json() token = info["token"] ts = int(time.time() * 1000) sign = _kx_generate_signature(token, ts, nonce) params = {"ts": ts, "nonce": nonce, "sign": sign, "did": "random"} async with s.post(f"http://{ip}:{port}/ctrl", params=params, timeout=timeout) as r: r.raise_for_status() data = await r.json() result = _kx_decrypt_info(data["data"]["info"], token[16:32], data["data"]["token"]) if "error" in result: raise RuntimeError(result.get("error", "decrypt failed")) return { "printer_ip": result.get("ip", ip), "username": result.get("username", ""), "password": result.get("password", ""), "device_id": result.get("deviceId", ""), "mode_id": str(result.get("modeId", "20030")), "model": result.get("modelName", "Anycubic Kobra"), }