04/22 user dashboard view, no credentials popup automatically

This commit is contained in:
2026-04-22 18:38:57 -04:00
parent b7e2a164c7
commit eab4207e1f
10 changed files with 479 additions and 62 deletions
+141
View File
@@ -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
View File
@@ -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: