Files

142 lines
4.8 KiB
Python

"""
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:<base64>" 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)