04/22 user dashboard view, no credentials popup automatically
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
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)
|
||||
+5
-3
@@ -38,6 +38,7 @@ _stop_event = threading.Event()
|
||||
# ─── Config helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def load_email_config() -> dict:
|
||||
from utils.config_crypto import decrypt_value
|
||||
cfg = configparser.ConfigParser()
|
||||
if not os.path.exists(CONFIG_FILE):
|
||||
return {}
|
||||
@@ -50,7 +51,7 @@ def load_email_config() -> dict:
|
||||
"smtp_host": s.get("smtp_host", ""),
|
||||
"smtp_port": s.getint("smtp_port", fallback=587),
|
||||
"smtp_user": s.get("smtp_user", ""),
|
||||
"smtp_password": s.get("smtp_password", ""),
|
||||
"smtp_password": decrypt_value(s.get("smtp_password", "")),
|
||||
"use_tls": s.getboolean("use_tls", fallback=True),
|
||||
"recipients": [r.strip() for r in s.get("recipients", "").split(",") if r.strip()],
|
||||
"send_time": s.get("send_time", "18:00"),
|
||||
@@ -60,6 +61,7 @@ def load_email_config() -> dict:
|
||||
def save_email_config(enabled: bool, smtp_host: str, smtp_port: int,
|
||||
smtp_user: str, smtp_password: str, use_tls: bool,
|
||||
recipients: str, send_time: str):
|
||||
from utils.config_crypto import encrypt_value
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
||||
cfg["email"] = {
|
||||
@@ -67,14 +69,14 @@ def save_email_config(enabled: bool, smtp_host: str, smtp_port: int,
|
||||
"smtp_host": smtp_host,
|
||||
"smtp_port": str(smtp_port),
|
||||
"smtp_user": smtp_user,
|
||||
"smtp_password": smtp_password,
|
||||
"smtp_password": encrypt_value(smtp_password),
|
||||
"use_tls": str(use_tls).lower(),
|
||||
"recipients": recipients,
|
||||
"send_time": send_time,
|
||||
}
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
|
||||
cfg.write(fh)
|
||||
logger.info("Email configuration saved.")
|
||||
logger.info("Email configuration saved (password encrypted).")
|
||||
|
||||
|
||||
def test_smtp_connection(smtp_host, smtp_port, smtp_user, smtp_password, use_tls) -> tuple:
|
||||
|
||||
Reference in New Issue
Block a user