04/21 Fisrt commit
This commit is contained in:
+125
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
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.
|
||||
- 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.
|
||||
|
||||
Migration:
|
||||
- _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
|
||||
import configparser
|
||||
|
||||
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"
|
||||
_CONFIG_FILE = "config.ini"
|
||||
_ITERATIONS = 100_000
|
||||
_fernet: "Fernet | None" = None
|
||||
|
||||
|
||||
# ─── Key bootstrap ────────────────────────────────────────────────────────────
|
||||
|
||||
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")
|
||||
|
||||
if "crypto" in cfg and cfg["crypto"].get("salt"):
|
||||
return base64.b64decode(cfg["crypto"]["salt"])
|
||||
|
||||
# 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")
|
||||
|
||||
with open(_CONFIG_FILE, "w", encoding="utf-8") as fh:
|
||||
cfg.write(fh)
|
||||
logger.info("Crypto: generated and persisted new credential encryption salt.")
|
||||
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 after config.ini is replaced (e.g. settings save)."""
|
||||
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:"):
|
||||
# Legacy plaintext — return unchanged; will be re-encrypted on next save
|
||||
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:")
|
||||
Reference in New Issue
Block a user