Files

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:")