04/24 Fixed keyring issues

This commit is contained in:
2026-04-24 17:33:20 -04:00
parent 5d6ca5a039
commit 71a53232a8
4 changed files with 56 additions and 156 deletions
+38 -142
View File
@@ -225,7 +225,12 @@ def load_config() -> dict:
if data.get("host") and data.get("database") and data.get("user"):
return data
# One-time migration: pull from config.ini if it still exists
# One-time migration: pull from config.ini if it still exists.
# IMPORTANT: decrypt_value() uses Windows DPAPI which is machine+user-scoped.
# A config.ini created on a different machine cannot be decrypted here —
# we catch RuntimeError (and any other error) per-field and skip gracefully,
# letting the user re-enter credentials via the Settings dialog instead of
# crashing the application.
try:
import configparser as _cp
_cfg_file = "config.ini"
@@ -235,13 +240,27 @@ def load_config() -> dict:
cfg.read(_cfg_file, encoding="utf-8")
if cfg.has_section("database"):
section = cfg["database"]
def _safe_decrypt(raw: str) -> str:
"""Decrypt a DPAPI value; return empty string on any failure."""
try:
return decrypt_value(raw)
except Exception as dec_err:
logger.warning(
f"config.ini migration: could not decrypt a value "
f"(DPAPI is machine-scoped — this is expected on a new "
f"machine). Skipping migration. Detail: {dec_err}"
)
return ""
migrated = {
"host": section.get("host", ""),
"port": section.getint("port", 3306),
"database": section.get("database", ""),
"user": decrypt_value(section.get("user", "")),
"password": decrypt_value(section.get("password", "")),
"user": _safe_decrypt(section.get("user", "")),
"password": _safe_decrypt(section.get("password", "")),
}
# Only migrate if we successfully decrypted a usable credential set
if migrated.get("host") and migrated.get("database") and migrated.get("user"):
_keyring_set(migrated)
logger.info(
@@ -249,6 +268,14 @@ def load_config() -> dict:
"config.ini [database] section is no longer needed."
)
return migrated
elif migrated.get("host"):
# File found but decryption failed (different machine) —
# log clearly and fall through to prompt the user
logger.warning(
"config.ini found but credentials could not be decrypted "
"(created on a different machine). "
"Please re-enter connection details in the Settings dialog."
)
except Exception as e:
logger.warning(f"config.ini migration attempt failed: {e}")
@@ -316,7 +343,14 @@ def reload_db_config():
# ─── Database Configuration ───────────────────────────────────────────────────
# Populated from the OS keychain at import time; falls back to placeholder
# strings so the module is importable even before first-run setup has completed.
_ini = load_config()
# The try/except ensures a corrupt or inaccessible keychain entry never crashes
# the import — the user will simply be shown the Settings dialog on first run.
try:
_ini = load_config()
except Exception as _load_err:
logger.warning(f"Could not load DB config at startup: {_load_err}")
_ini = {}
DB_CONFIG = {
"host": _ini.get("host", "your-mysql-host"),
"port": _ini.get("port", 3306),
@@ -819,144 +853,6 @@ logging.basicConfig(
)
logger = logging.getLogger("config")
# ─── Config File Load / Save ──────────────────────────────────────────────────
import configparser as _cp
import os as _os
CONFIG_FILE = "config.ini"
APP_TITLE = "Website Checker"
APP_VERSION = "1.0.0"
# Sensitive fields that are DPAPI-encrypted in config.ini
_DB_SENSITIVE = {"user", "password"}
def load_config() -> dict:
"""
Load DB settings from config.ini.
Returns a dict with keys: host, port, database, user, password.
Sensitive fields (user, password) are decrypted transparently via DPAPI.
Returns empty dict if the file does not exist or is incomplete.
"""
from utils.config_crypto import decrypt_value
cfg = _cp.ConfigParser()
if not _os.path.exists(CONFIG_FILE):
return {}
cfg.read(CONFIG_FILE, encoding="utf-8")
if "database" not in cfg:
return {}
section = cfg["database"]
try:
return {
"host": section.get("host", ""),
"port": section.getint("port", 3306),
"database": section.get("database", ""),
"user": decrypt_value(section.get("user", "")),
"password": decrypt_value(section.get("password", "")),
}
except RuntimeError as exc:
logger.error(f"Failed to decrypt DB credentials: {exc}")
raise
def save_config(host: str, port: int, database: str, user: str, password: str):
"""Persist DB connection settings to config.ini (sensitive fields DPAPI-encrypted)."""
from utils.config_crypto import encrypt_value
# Preserve any existing non-database sections (email, crypto, groq)
cfg = _cp.ConfigParser()
if _os.path.exists(CONFIG_FILE):
cfg.read(CONFIG_FILE, encoding="utf-8")
cfg["database"] = {
"host": host,
"port": str(port),
"database": database,
"user": encrypt_value(user),
"password": encrypt_value(password),
}
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
cfg.write(fh)
logger.info(f"Configuration saved to {CONFIG_FILE} (credentials encrypted).")
def migrate_plaintext_config():
"""
One-time migration: if config.ini contains plain-text DB credentials
(no 'dpapi:' prefix) encrypt them in-place using Windows DPAPI.
Safe to call on every startup — is a no-op when already encrypted.
"""
from utils.config_crypto import encrypt_value, is_encrypted
if not _os.path.exists(CONFIG_FILE):
return
cfg = _cp.ConfigParser()
cfg.read(CONFIG_FILE, encoding="utf-8")
changed = False
# DB section
for key in ("user", "password"):
if cfg.has_option("database", key):
raw = cfg.get("database", key)
if raw and not is_encrypted(raw):
cfg.set("database", key, encrypt_value(raw))
changed = True
# Email section
if cfg.has_option("email", "smtp_password"):
raw = cfg.get("email", "smtp_password")
if raw and not is_encrypted(raw):
cfg.set("email", "smtp_password", encrypt_value(raw))
changed = True
# Groq section
if cfg.has_option("groq", "api_key"):
raw = cfg.get("groq", "api_key")
if raw and not is_encrypted(raw):
cfg.set("groq", "api_key", encrypt_value(raw))
changed = True
if changed:
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
cfg.write(fh)
logger.info("config.ini: plain-text credentials encrypted with Windows DPAPI.")
def config_exists() -> bool:
"""Return True if config.ini contains all required connection fields."""
ini = load_config()
return bool(ini.get("host") and ini.get("database") and ini.get("user"))
def reload_db_config():
"""
Re-read config.ini and update DB_CONFIG in place.
Also resets the connection pool so the next get_connection() uses new creds.
"""
global _pool, DB_CONFIG
ini = load_config()
DB_CONFIG.update({
"host": ini.get("host", DB_CONFIG["host"]),
"port": ini.get("port", DB_CONFIG["port"]),
"database": ini.get("database", DB_CONFIG["database"]),
"user": ini.get("user", DB_CONFIG["user"]),
"password": ini.get("password", DB_CONFIG["password"]),
})
_pool = None # force pool recreation on next connection
logger.info("DB_CONFIG reloaded from config.ini.")
# ─── Database Configuration ───────────────────────────────────────────────────
# Populated from config.ini at runtime; falls back to placeholder strings so
# the module is importable even before first-run setup has completed.
_ini = load_config()
DB_CONFIG = {
"host": _ini.get("host", "your-mysql-host"),
"port": _ini.get("port", 3306),
"database": _ini.get("database", "website_checker"),
"user": _ini.get("user", "your-db-user"),
"password": _ini.get("password", "your-db-password"),
"connection_timeout": 10,
}
# ─── Connection Pool ──────────────────────────────────────────────────────────
_pool = None