04/24 Migrated info which stored in .ini to database and keyring
This commit is contained in:
+110
-22
@@ -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:")
|
||||
Reference in New Issue
Block a user