""" utils/config_crypto.py — Transparent DPAPI encryption for config.ini values. Uses Windows Data Protection API (CryptProtectData / CryptUnprotectData) via pywin32. Encrypted values are stored with a "dpapi:" prefix in config.ini so the format is self-documenting. Key properties: - Tied to the current Windows USER account (not just the machine). The encrypted blob is completely unreadable on any other machine or under any other Windows account. - No key to manage, distribute, or store — Windows manages the key transparently via the user's password-derived master key. - Graceful fallback: if pywin32 is not available (e.g. running on a dev Linux box), values are stored/returned as plain text with a warning. This keeps the dev workflow intact. Usage ----- from utils.config_crypto import encrypt_value, decrypt_value stored = encrypt_value("my-secret") # "dpapi:AAAA..." secret = decrypt_value(stored) # "my-secret" # For values that may already be plain text (migration): secret = decrypt_value("plain-text") # "plain-text" (no-op) """ import base64 import logging logger = logging.getLogger("config_crypto") _DPAPI_PREFIX = "dpapi:" # --------------------------------------------------------------------------- # Low-level DPAPI wrappers # --------------------------------------------------------------------------- def _dpapi_protect(plaintext: str) -> bytes: """Encrypt a string with Windows DPAPI (current-user scope).""" import win32crypt # noqa: PLC0415 data = plaintext.encode("utf-8") encrypted = win32crypt.CryptProtectData( data, "WebChecker config", # optional descriptive label None, # optional entropy (None = no extra entropy) None, # reserved None, # no UI prompt 0, # flags: 0 = user-scope (default) ) return encrypted def _dpapi_unprotect(ciphertext_bytes: bytes) -> str: """Decrypt bytes produced by _dpapi_protect.""" import win32crypt # noqa: PLC0415 _desc, plaintext_bytes = win32crypt.CryptUnprotectData( ciphertext_bytes, None, # optional entropy None, # reserved None, # no UI prompt 0, # flags ) return plaintext_bytes.decode("utf-8") # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def encrypt_value(plaintext: str) -> str: """ Encrypt *plaintext* and return a "dpapi:" string suitable for storage in config.ini. If the value is already encrypted (starts with "dpapi:"), it is returned unchanged. If DPAPI is unavailable, the plain value is returned with a warning. """ if not plaintext: return plaintext if plaintext.startswith(_DPAPI_PREFIX): return plaintext # already encrypted try: ciphertext = _dpapi_protect(plaintext) return _DPAPI_PREFIX + base64.b64encode(ciphertext).decode("ascii") except ImportError: logger.warning( "pywin32 not available — config values stored as plain text. " "Install pywin32 for credential protection." ) return plaintext except Exception as exc: logger.error(f"DPAPI encryption failed: {exc}. Storing plain text.") return plaintext def decrypt_value(stored: str) -> str: """ Decrypt a value returned by encrypt_value(). - If *stored* starts with "dpapi:", it is decrypted and the plaintext is returned. - Otherwise the value is assumed to be plain text and returned as-is (handles legacy / non-Windows environments). Raises RuntimeError if the DPAPI decryption fails (e.g. wrong user account or corrupted data). """ if not stored: return stored if not stored.startswith(_DPAPI_PREFIX): return stored # plain text — pass through (legacy / no-DPAPI env) b64_part = stored[len(_DPAPI_PREFIX):] try: ciphertext = base64.b64decode(b64_part) return _dpapi_unprotect(ciphertext) except ImportError: logger.warning( "pywin32 not available — cannot decrypt DPAPI value. " "Returning raw stored value." ) return stored except Exception as exc: raise RuntimeError( f"Failed to decrypt a protected config value.\n\n" f"This usually means the config.ini was created by a different " f"Windows user account or on a different machine.\n\n" f"Please re-enter your settings in the Settings dialog.\n\n" f"Technical detail: {exc}" ) from exc def is_encrypted(value: str) -> bool: """Return True if *value* has already been DPAPI-encrypted.""" return value.startswith(_DPAPI_PREFIX)