75 lines
2.5 KiB
Python
75 lines
2.5 KiB
Python
|
|
"""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()
|