Files
leocrm/app/plugins/builtins/mail/crypto.py
T

75 lines
2.5 KiB
Python
Raw Normal View History

"""AES-256 password encryption for Mail accounts (Fernet-based).
Extracted from services.py as part of the God-object split (BUG-018 pilot).
Re-exported by ``app.plugins.builtins.mail.services`` for backwards
compatibility.
⚠️ The legacy salt and MAIL_ENCRYPTION_KEY env guard are load-bearing:
changing them makes existing encrypted passwords unreadable.
"""
from __future__ import annotations
import base64
import os
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
MAIL_ENCRYPTION_KEY = os.environ.get("MAIL_ENCRYPTION_KEY")
if not MAIL_ENCRYPTION_KEY:
raise RuntimeError(
"MAIL_ENCRYPTION_KEY environment variable is required. "
"Set it to a strong random value."
)
# Legacy salt for backward compatibility with existing encrypted passwords
_LEGACY_SALT = b"leocrm-mail-salt"
def _derive_key(password: str, salt: bytes) -> bytes:
"""Derive a 32-byte Fernet key from a password using PBKDF2 with the given salt."""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=480000,
)
return base64.urlsafe_b64encode(kdf.derive(password.encode()))
def generate_salt() -> str:
"""Generate a random 32-byte salt and return as base64 string."""
salt = os.urandom(32)
return base64.urlsafe_b64encode(salt).decode()
def _get_fernet(salt_b64: str | None = None) -> Fernet:
"""Get a Fernet instance. If salt_b64 is provided, use it; otherwise use legacy salt."""
if salt_b64:
salt = base64.urlsafe_b64decode(salt_b64.encode())
else:
salt = _LEGACY_SALT
return Fernet(_derive_key(MAIL_ENCRYPTION_KEY, salt))
def encrypt_password(plaintext: str, salt_b64: str | None = None) -> str:
"""Encrypt a password using AES-256 (Fernet). Returns base64 ciphertext.
If salt_b64 is provided, uses that salt for key derivation.
If not, uses the legacy hardcoded salt (for backward compatibility).
"""
fernet = _get_fernet(salt_b64)
return fernet.encrypt(plaintext.encode()).decode()
def decrypt_password(ciphertext: str, salt_b64: str | None = None) -> str:
"""Decrypt a password encrypted with encrypt_password.
If salt_b64 is provided, uses that salt for key derivation.
If not, uses the legacy hardcoded salt (for backward compatibility).
"""
fernet = _get_fernet(salt_b64)
return fernet.decrypt(ciphertext.encode()).decode()