04/30 Web Checker web app ver. 1.0
This commit is contained in:
+142
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
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:")
|
||||
Reference in New Issue
Block a user