213 lines
8.2 KiB
Python
213 lines
8.2 KiB
Python
"""
|
|
utils/crypto.py — Fernet symmetric encryption for website credentials.
|
|
|
|
Key derivation:
|
|
- 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).
|
|
- 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).
|
|
"""
|
|
|
|
import base64
|
|
import logging
|
|
import os
|
|
|
|
from cryptography.fernet import Fernet, InvalidToken
|
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
from cryptography.hazmat.primitives import hashes
|
|
|
|
logger = logging.getLogger("crypto")
|
|
|
|
_APP_SECRET = b"WebsiteChecker-v1-CredentialKey"
|
|
_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 the Fernet salt from app_settings, falling back to config.ini for
|
|
legacy installs, and generating a fresh salt for brand-new installs.
|
|
|
|
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
|
|
|
|
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}")
|
|
|
|
# ── 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
|
|
|
|
|
|
def _build_fernet() -> Fernet:
|
|
salt = _get_or_create_salt()
|
|
kdf = PBKDF2HMAC(
|
|
algorithm=hashes.SHA256(),
|
|
length=32,
|
|
salt=salt,
|
|
iterations=_ITERATIONS,
|
|
)
|
|
key = base64.urlsafe_b64encode(kdf.derive(_APP_SECRET))
|
|
return Fernet(key)
|
|
|
|
|
|
def _get_fernet() -> Fernet:
|
|
global _fernet
|
|
if _fernet is None:
|
|
_fernet = _build_fernet()
|
|
return _fernet
|
|
|
|
|
|
def reset_fernet():
|
|
"""Force key reload — call if the salt is ever rotated."""
|
|
global _fernet
|
|
_fernet = None
|
|
|
|
|
|
# ─── Public API ───────────────────────────────────────────────────────────────
|
|
|
|
def encrypt(plaintext: str) -> str:
|
|
"""
|
|
Encrypt a plaintext string. Returns a UTF-8-safe ciphertext string
|
|
prefixed with 'enc:' so we can detect encrypted values reliably.
|
|
Returns the original string unchanged if plaintext is empty.
|
|
"""
|
|
if not plaintext:
|
|
return plaintext
|
|
try:
|
|
token = _get_fernet().encrypt(plaintext.encode("utf-8"))
|
|
return "enc:" + token.decode("ascii")
|
|
except Exception as e:
|
|
logger.error(f"Credential encryption failed: {e}")
|
|
return plaintext # safe fallback — don't lose data
|
|
|
|
|
|
def decrypt(ciphertext: str) -> str:
|
|
"""
|
|
Decrypt a ciphertext string produced by encrypt().
|
|
- If ciphertext starts with 'enc:', decrypts with Fernet.
|
|
- Otherwise returns the value as-is (plaintext legacy credential).
|
|
Returns empty string on failure.
|
|
"""
|
|
if not ciphertext:
|
|
return ciphertext
|
|
if not ciphertext.startswith("enc:"):
|
|
return ciphertext
|
|
try:
|
|
token = ciphertext[4:].encode("ascii")
|
|
return _get_fernet().decrypt(token).decode("utf-8")
|
|
except InvalidToken:
|
|
logger.error("Credential decryption failed - wrong key or corrupted data.")
|
|
return ""
|
|
except Exception as e:
|
|
logger.error(f"Credential decryption error: {e}")
|
|
return ""
|
|
|
|
|
|
def is_encrypted(value: str) -> bool:
|
|
"""Return True if the value was produced by encrypt()."""
|
|
return isinstance(value, str) and value.startswith("enc:") |