""" utils/crypto.py — Fernet symmetric encryption for website credentials. IMPORTANT: This module is intentionally byte-for-byte compatible with the desktop application's utils/crypto.py so that both apps can share the same MySQL database and read/write each other's encrypted credential values. Key derivation (must not change without re-encrypting all stored values): - APP_SECRET : b"WebsiteChecker-v1-CredentialKey" (fixed, same as desktop) - Salt : 32 random bytes, stored base64-encoded in app_settings under key "crypto.salt" (same as desktop) - KDF : PBKDF2-HMAC-SHA256, 100,000 iterations (same as desktop) - Ciphertext : prefixed with "enc:" so plaintext legacy values are distinguishable without attempting decryption (same as desktop) Salt storage format: - Stored as base64 (NOT hex) in app_settings.value for "crypto.salt". - The desktop generates 32 bytes; we do the same to stay consistent. Compatibility: - decrypt() returns the raw value unchanged for strings that are NOT prefixed with "enc:" — this handles legacy plaintext credentials written before encryption was introduced, identical to desktop behaviour. """ 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("utils.crypto") # ── Must match desktop exactly ──────────────────────────────────────────────── _APP_SECRET = b"WebsiteChecker-v1-CredentialKey" _ITERATIONS = 100_000 _SETTING_KEY = "crypto.salt" _fernet: "Fernet | None" = None # ── Key bootstrap ───────────────────────────────────────────────────────────── def _get_or_create_salt() -> bytes: """ Read the 32-byte salt from app_settings (base64-encoded), creating and persisting a fresh one if absent. The base64 format matches the desktop. """ from config import get_setting, set_setting raw = get_setting(_SETTING_KEY, "") if raw: try: return base64.b64decode(raw) except Exception as e: logger.warning(f"Crypto: could not decode stored salt: {e} — generating new salt.") # Generate a fresh 32-byte salt (same size as desktop) salt = os.urandom(32) b64_salt = base64.b64encode(salt).decode("ascii") try: 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: {e}") 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 in app_settings 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 callers can distinguish encrypted values from legacy plaintext ones. Returns the original string unchanged if plaintext is empty. Compatible with desktop encrypt(). """ 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 decryption failure. Compatible with desktop decrypt(). """ if not ciphertext: return ciphertext if not ciphertext.startswith("enc:"): # Legacy plaintext credential — return as-is, identical to desktop 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:")