Files
pvemanager/backend/app/crypto.py
Dilshod Rasulov 50f2ee1966
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 46s
fix(security): authenticate VNC/terminal WebSockets and harden SSH/config
Critical security and robustness fixes from a code audit:

- WS terminal/VNC now require a JWT (?token=), validate the session,
  vms.console permission and VM ownership before bridging to Proxmox
  (previously the LXC terminal gave unauthenticated root shell access).
  Frontend ConsolePage passes the token in both WS URLs.
- Fix SSH fallback for `pvecm delnode`: SSHClient now supports password
  auth (it previously crashed with TypeError); ProxmoxClient stores the
  password for SSH fallbacks and the host port suffix is stripped.
- Add production startup warnings for default ADMIN_PASSWORD and wildcard
  CORS_ORIGINS.
- dashboard: use tz-aware utcnow() instead of naive datetime.utcnow().
- crypto: narrow decrypt_value except to InvalidToken and log failures.
- ssh_client: guard the shared connection cache with a lock; make
  unknown-host-key rejection opt-in via SSH_REJECT_UNKNOWN_HOSTS.
- logging: send loguru console output to stderr instead of stdout.
- Sync version fallback to 1.5.1; remove one-off refactor scripts.
- Add CODE_REVIEW.md audit report.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 06:31:26 +05:00

92 lines
2.8 KiB
Python

"""
Field-level encryption for sensitive database columns.
Uses Fernet symmetric encryption (AES-128-CBC + HMAC-SHA256).
Configure via FERNET_KEY environment variable.
Key generation:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
"""
import logging
from cryptography.fernet import Fernet, InvalidToken
from sqlalchemy import TypeDecorator, String
logger = logging.getLogger(__name__)
# Warn once if FERNET_KEY is not configured
_warned_no_key = False
def _get_fernet():
global _warned_no_key
from .config import settings
key = settings.FERNET_KEY
if not key:
if not _warned_no_key:
logger.warning(
"FERNET_KEY is not set — sensitive fields (passwords) are stored "
"as plaintext. Set FERNET_KEY in your environment for production."
)
_warned_no_key = True
return None
return Fernet(key.encode() if isinstance(key, str) else key)
def encrypt_value(value: str) -> str:
"""Encrypt a string value. Returns plaintext if FERNET_KEY is not configured."""
if not value:
return value
f = _get_fernet()
if f is None:
return value
return f.encrypt(value.encode()).decode()
def decrypt_value(value: str) -> str:
"""Decrypt a Fernet-encrypted string.
Falls back to returning the value as-is if decryption fails
(handles legacy plaintext rows during migration).
"""
if not value:
return value
f = _get_fernet()
if f is None:
return value
try:
return f.decrypt(value.encode()).decode()
except InvalidToken:
# Value is likely plaintext (pre-encryption legacy row) or was
# encrypted with a different FERNET_KEY. Return as-is for backward
# compatibility, but warn so key-rotation mistakes are visible.
logger.warning(
"Fernet decryption failed (InvalidToken). Value returned as-is — "
"this is expected for legacy plaintext rows, but check FERNET_KEY "
"if previously-encrypted data is now unreadable."
)
return value
class EncryptedString(TypeDecorator):
"""SQLAlchemy column type that transparently encrypts/decrypts values via Fernet.
Usage::
from .crypto import EncryptedString
password = Column(EncryptedString(255), nullable=True)
The underlying storage type is String, so no schema change is required.
"""
impl = String
cache_ok = True
def process_bind_param(self, value, dialect):
"""Called when writing to the database."""
return encrypt_value(value) if value else value
def process_result_value(self, value, dialect):
"""Called when reading from the database."""
return decrypt_value(value) if value else value