142 lines
4.8 KiB
Python
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)
|