04/24 Migrated info which stored in .ini to database and keyring

This commit is contained in:
2026-04-24 15:37:58 -04:00
parent f2a8d3f15b
commit 5d6ca5a039
9 changed files with 479 additions and 299 deletions
+110 -22
View File
@@ -2,14 +2,18 @@
utils/crypto.py — Fernet symmetric encryption for website credentials.
Key derivation:
- A 32-byte random salt is generated on first use and stored in config.ini
under [crypto] / salt.
- A 32-byte random salt is generated on first use and stored in the
app_settings table (key: 'crypto.salt') instead of config.ini.
- The Fernet key is derived from the salt + a fixed application secret
using PBKDF2-HMAC-SHA256 (100,000 iterations).
- This means credentials are tied to the specific config.ini file on the
operator's machine; moving config.ini to another machine retains access.
- Because the salt lives in the shared MySQL database, any machine that
connects to the same DB can decrypt credentials without needing a local
config.ini — making the app fully portable across machines.
Migration:
- On first start after this change, if config.ini still contains a
[crypto]/salt entry it is automatically migrated to app_settings and
removed from the file.
- _decrypt() tries Fernet first; if that fails it returns the raw value
unchanged so that plaintext legacy credentials are still readable.
- Callers should re-encrypt on next write (update_website handles this).
@@ -18,7 +22,6 @@ Migration:
import base64
import logging
import os
import configparser
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
@@ -27,30 +30,116 @@ from cryptography.hazmat.primitives import hashes
logger = logging.getLogger("crypto")
_APP_SECRET = b"WebsiteChecker-v1-CredentialKey"
_CONFIG_FILE = "config.ini"
_ITERATIONS = 100_000
_SETTING_KEY = "crypto.salt"
_fernet: "Fernet | None" = None
# ─── Key bootstrap ────────────────────────────────────────────────────────────
def _ensure_app_settings_table() -> bool:
"""
Guarantee the app_settings table exists before we try to read/write it.
Returns True if the table is available, False if it could not be created
(e.g. the DB pool itself is not yet ready).
This guard is necessary because crypto.py can be called during login —
before initialize_database() has had a chance to run on a fresh install
or an upgraded database that doesn't yet have the app_settings table.
"""
try:
from config import get_connection
conn = get_connection()
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS app_settings (
key_name VARCHAR(100) NOT NULL PRIMARY KEY,
value TEXT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
)
conn.commit()
cur.close()
conn.close()
return True
except Exception as e:
logger.warning(f"Crypto: could not ensure app_settings table: {e}")
return False
def _get_or_create_salt() -> bytes:
"""Read salt from config.ini [crypto] section; create and persist if absent."""
cfg = configparser.ConfigParser()
cfg.read(_CONFIG_FILE, encoding="utf-8")
"""
Read the Fernet salt from app_settings, falling back to config.ini for
legacy installs, and generating a fresh salt for brand-new installs.
if "crypto" in cfg and cfg["crypto"].get("salt"):
return base64.b64decode(cfg["crypto"]["salt"])
Order of precedence:
1. app_settings table (primary — shared across machines via the DB)
2. config.ini [crypto]/salt (legacy migration path)
3. Generate a new random salt and persist it to app_settings
# Generate a fresh 32-byte salt
salt = os.urandom(32)
if "crypto" not in cfg:
cfg["crypto"] = {}
cfg["crypto"]["salt"] = base64.b64encode(salt).decode("ascii")
The table is created here if it doesn't yet exist, so this function is
safe to call before initialize_database() has run.
"""
# ── Step 1: try config.ini first (fastest, no DB needed yet) ─────────────
# Reading config.ini for the salt is always safe — it doesn't require the
# app_settings table to exist. If found, we attempt to also persist it to
# the DB (best-effort), but we use the value regardless.
legacy_b64 = None
try:
import configparser, os as _os
_cfg_file = "config.ini"
if _os.path.exists(_cfg_file):
cfg = configparser.ConfigParser()
cfg.read(_cfg_file, encoding="utf-8")
if cfg.has_option("crypto", "salt"):
legacy_b64 = cfg.get("crypto", "salt")
except Exception as e:
logger.warning(f"Crypto: could not read config.ini for salt: {e}")
with open(_CONFIG_FILE, "w", encoding="utf-8") as fh:
cfg.write(fh)
logger.info("Crypto: generated and persisted new credential encryption salt.")
# ── Step 2: ensure app_settings table exists ──────────────────────────────
table_ok = _ensure_app_settings_table()
# ── Step 3: try to read salt from DB ──────────────────────────────────────
if table_ok:
try:
from config import get_setting
raw = get_setting(_SETTING_KEY, "")
if raw:
return base64.b64decode(raw)
except Exception as e:
logger.warning(f"Crypto: could not read salt from app_settings: {e}")
# ── Step 4: migrate legacy salt from config.ini → DB ─────────────────────
if legacy_b64:
if table_ok:
try:
from config import set_setting
set_setting(_SETTING_KEY, legacy_b64)
logger.info("Crypto: migrated salt from config.ini to app_settings.")
except Exception as e:
logger.warning(f"Crypto: could not persist migrated salt to DB: {e}")
else:
logger.info("Crypto: using salt from config.ini (app_settings not available yet).")
return base64.b64decode(legacy_b64)
# ── Step 5: generate a fresh salt ────────────────────────────────────────
salt = os.urandom(32)
b64_salt = base64.b64encode(salt).decode("ascii")
if table_ok:
try:
from config import set_setting
set_setting(_SETTING_KEY, b64_salt)
logger.info("Crypto: generated and persisted new salt to app_settings.")
except Exception as e:
logger.warning(f"Crypto: could not persist new salt to DB: {e}")
else:
logger.warning(
"Crypto: generated a new salt but app_settings is not available — "
"salt will NOT persist across restarts until the table is created."
)
return salt
@@ -74,7 +163,7 @@ def _get_fernet() -> Fernet:
def reset_fernet():
"""Force key reload — call after config.ini is replaced (e.g. settings save)."""
"""Force key reload — call if the salt is ever rotated."""
global _fernet
_fernet = None
@@ -107,7 +196,6 @@ def decrypt(ciphertext: str) -> str:
if not ciphertext:
return ciphertext
if not ciphertext.startswith("enc:"):
# Legacy plaintext — return unchanged; will be re-encrypted on next save
return ciphertext
try:
token = ciphertext[4:].encode("ascii")
@@ -122,4 +210,4 @@ def decrypt(ciphertext: str) -> str:
def is_encrypted(value: str) -> bool:
"""Return True if the value was produced by encrypt()."""
return isinstance(value, str) and value.startswith("enc:")
return isinstance(value, str) and value.startswith("enc:")