diff --git a/backend/app/core/credential_encryption.py b/backend/app/core/credential_encryption.py new file mode 100644 index 0000000..0be37ad --- /dev/null +++ b/backend/app/core/credential_encryption.py @@ -0,0 +1,49 @@ +import base64 +import hashlib +from functools import lru_cache +from typing import Optional + +from cryptography.fernet import Fernet, InvalidToken + +from app.core.config import get_settings + +ENCRYPTED_SECRET_PREFIX = "enc:v1:" + + +@lru_cache(maxsize=1) +def _get_fernet() -> Fernet: + """Build a Fernet instance from the stable application secret key.""" + secret_key = get_settings().SECRET_KEY + digest = hashlib.sha256(secret_key.encode("utf-8")).digest() + return Fernet(base64.urlsafe_b64encode(digest)) + + +def is_encrypted_secret(value: Optional[str]) -> bool: + return bool(value and value.startswith(ENCRYPTED_SECRET_PREFIX)) + + +def encrypt_secret(value: Optional[str]) -> Optional[str]: + """Encrypt a secret for database storage, preserving empty and encrypted values.""" + if value is None or value == "": + return value + if is_encrypted_secret(value): + return value + + token = _get_fernet().encrypt(value.encode("utf-8")).decode("ascii") + return f"{ENCRYPTED_SECRET_PREFIX}{token}" + + +def decrypt_secret(value: Optional[str]) -> Optional[str]: + """Return plaintext for encrypted values and legacy plaintext unchanged.""" + if value is None or value == "": + return value + if not is_encrypted_secret(value): + return value + + token = value[len(ENCRYPTED_SECRET_PREFIX) :] + try: + return _get_fernet().decrypt(token.encode("ascii")).decode("utf-8") + except InvalidToken as exc: + raise ValueError( + "Stored credential could not be decrypted with the configured SECRET_KEY" + ) from exc diff --git a/backend/app/main.py b/backend/app/main.py index eb1c543..bfc004d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -287,6 +287,25 @@ def _migrate_imap_env_vars_to_db() -> None: db.close() +def _encrypt_legacy_mail_source_secrets() -> None: + """Encrypt plaintext mail-source secrets left by earlier versions.""" + db = SessionLocal() + try: + changed = 0 + for source in db.query(MailSource).all(): + if source.encrypt_legacy_secrets(): + changed += 1 + + if changed: + db.commit() + logger.info("Encrypted legacy mail-source credentials for %d source(s).", changed) + except Exception as e: # pylint: disable=broad-exception-caught + db.rollback() + logger.error("Failed to encrypt legacy mail-source credentials: %s", str(e)) + finally: + db.close() + + def create_app() -> FastAPI: """Create and configure the FastAPI application""" application = FastAPI( @@ -384,6 +403,7 @@ def create_app() -> FastAPI: # create an initial MailSource from those settings so existing deployments # continue to work without manual reconfiguration. _migrate_imap_env_vars_to_db() + _encrypt_legacy_mail_source_secrets() # Start background polling task (iterates over DB-enabled mail sources) logger.info("Starting IMAP polling background task") diff --git a/backend/app/models/mail_source.py b/backend/app/models/mail_source.py index 1e75907..84d27cb 100644 --- a/backend/app/models/mail_source.py +++ b/backend/app/models/mail_source.py @@ -3,6 +3,7 @@ from datetime import datetime from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text from sqlalchemy.orm import relationship +from app.core.credential_encryption import decrypt_secret, encrypt_secret, is_encrypted_secret from app.core.database import Base @@ -33,21 +34,15 @@ class MailSource(Base): server = Column(String, nullable=True) port = Column(Integer, nullable=True, default=993) username = Column(String, nullable=True) - # NOTE: password is stored in plaintext. In a production environment this - # field should be encrypted at the application layer before persisting. - password = Column(Text, nullable=True) + _password = Column("password", Text, nullable=True) use_ssl = Column(Boolean, default=True) folder = Column(String, default="INBOX") # Gmail API OAuth2 credentials (used by GMAIL_API method) - # NOTE: tokens and secrets are stored in plaintext – in a production - # environment these fields should be encrypted at the application layer - # (e.g. using Fernet/AES) before persisting, the same way IMAP passwords - # should be. Treat database access as equivalent to credential access. gmail_client_id = Column(String, nullable=True) - gmail_client_secret = Column(Text, nullable=True) - gmail_access_token = Column(Text, nullable=True) - gmail_refresh_token = Column(Text, nullable=True) + _gmail_client_secret = Column("gmail_client_secret", Text, nullable=True) + _gmail_access_token = Column("gmail_access_token", Text, nullable=True) + _gmail_refresh_token = Column("gmail_refresh_token", Text, nullable=True) # Email address of the authorised Gmail account gmail_email = Column(String, nullable=True) # JSON-encoded list of Gmail message IDs that have already been ingested @@ -72,3 +67,56 @@ class MailSource(Base): def __repr__(self): return f"" + + def encrypt_legacy_secrets(self) -> bool: + """Encrypt any legacy plaintext secrets already stored on this row.""" + changed = False + secret_fields = { + "password": self._password, + "gmail_client_secret": self._gmail_client_secret, + "gmail_access_token": self._gmail_access_token, + "gmail_refresh_token": self._gmail_refresh_token, + } + + for public_name, stored_value in secret_fields.items(): + if stored_value and not is_encrypted_secret(stored_value): + setattr(self, public_name, stored_value) + changed = True + + return changed + + @property + def password(self): + """Return the decrypted IMAP password, if present.""" + return decrypt_secret(self._password) + + @password.setter + def password(self, value): + self._password = encrypt_secret(value) + + @property + def gmail_client_secret(self): + """Return the decrypted Gmail OAuth client secret, if present.""" + return decrypt_secret(self._gmail_client_secret) + + @gmail_client_secret.setter + def gmail_client_secret(self, value): + self._gmail_client_secret = encrypt_secret(value) + + @property + def gmail_access_token(self): + """Return the decrypted Gmail OAuth access token, if present.""" + return decrypt_secret(self._gmail_access_token) + + @gmail_access_token.setter + def gmail_access_token(self, value): + self._gmail_access_token = encrypt_secret(value) + + @property + def gmail_refresh_token(self): + """Return the decrypted Gmail OAuth refresh token, if present.""" + return decrypt_secret(self._gmail_refresh_token) + + @gmail_refresh_token.setter + def gmail_refresh_token(self, value): + self._gmail_refresh_token = encrypt_secret(value) diff --git a/backend/app/tests/test_mail_sources.py b/backend/app/tests/test_mail_sources.py index b246d94..b325a67 100644 --- a/backend/app/tests/test_mail_sources.py +++ b/backend/app/tests/test_mail_sources.py @@ -12,8 +12,10 @@ from urllib.parse import parse_qs, urlparse import pytest from fastapi.testclient import TestClient +from sqlalchemy import text from sqlalchemy.orm import Session +from app.core.credential_encryption import is_encrypted_secret from app.models.mail_source import MailSource from app.models.mail_source_import import MailSourceImport from app.services.import_history import record_import_attempt @@ -52,6 +54,93 @@ class TestMailSourceModel: assert source.enabled is True assert source.last_checked is None + def test_imap_password_is_encrypted_at_rest(self, db_session: Session): + source = MailSource(name="Encrypted IMAP", method="IMAP", password="raw-secret") + db_session.add(source) + db_session.commit() + db_session.refresh(source) + + stored = db_session.execute( + text("SELECT password FROM mail_sources WHERE id = :id"), {"id": source.id} + ).scalar_one() + + assert source.password == "raw-secret" + assert stored != "raw-secret" + assert is_encrypted_secret(stored) + + def test_gmail_oauth_secrets_are_encrypted_at_rest(self, db_session: Session): + source = MailSource( + name="Encrypted Gmail", + method="GMAIL_API", + gmail_client_secret="client-secret", + gmail_access_token="access-token", + gmail_refresh_token="refresh-token", + ) + db_session.add(source) + db_session.commit() + db_session.refresh(source) + + stored = db_session.execute( + text( + "SELECT gmail_client_secret, gmail_access_token, gmail_refresh_token " + "FROM mail_sources WHERE id = :id" + ), + {"id": source.id}, + ).one() + + assert source.gmail_client_secret == "client-secret" + assert source.gmail_access_token == "access-token" + assert source.gmail_refresh_token == "refresh-token" + assert stored.gmail_client_secret != "client-secret" + assert stored.gmail_access_token != "access-token" + assert stored.gmail_refresh_token != "refresh-token" + assert is_encrypted_secret(stored.gmail_client_secret) + assert is_encrypted_secret(stored.gmail_access_token) + assert is_encrypted_secret(stored.gmail_refresh_token) + + def test_legacy_plaintext_mail_source_secret_remains_readable(self, db_session: Session): + db_session.execute( + text( + "INSERT INTO mail_sources (name, method, password) " + "VALUES (:name, :method, :password)" + ), + {"name": "Legacy IMAP", "method": "IMAP", "password": "legacy-secret"}, + ) + db_session.commit() + + source = db_session.query(MailSource).filter_by(name="Legacy IMAP").one() + + assert source.password == "legacy-secret" + + def test_encrypt_legacy_secrets_rewrites_plaintext_storage(self, db_session: Session): + db_session.execute( + text( + "INSERT INTO mail_sources (name, method, password, gmail_access_token) " + "VALUES (:name, :method, :password, :token)" + ), + { + "name": "Legacy Rewrite", + "method": "GMAIL_API", + "password": "legacy-secret", + "token": "legacy-token", + }, + ) + db_session.commit() + + source = db_session.query(MailSource).filter_by(name="Legacy Rewrite").one() + assert source.encrypt_legacy_secrets() is True + db_session.commit() + + stored = db_session.execute( + text("SELECT password, gmail_access_token FROM mail_sources WHERE id = :id"), + {"id": source.id}, + ).one() + + assert source.password == "legacy-secret" + assert source.gmail_access_token == "legacy-token" + assert is_encrypted_secret(stored.password) + assert is_encrypted_secret(stored.gmail_access_token) + def test_default_values(self, db_session: Session): source = MailSource(name="Minimal", method="IMAP") db_session.add(source)