fix: encrypt mail source credentials

This commit is contained in:
Christian Krakau-Louis
2026-05-23 00:27:08 +02:00
parent e219048b5c
commit d5693c6d1d
4 changed files with 216 additions and 10 deletions
+49
View File
@@ -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
+20
View File
@@ -287,6 +287,25 @@ def _migrate_imap_env_vars_to_db() -> None:
db.close() 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: def create_app() -> FastAPI:
"""Create and configure the FastAPI application""" """Create and configure the FastAPI application"""
application = FastAPI( application = FastAPI(
@@ -384,6 +403,7 @@ def create_app() -> FastAPI:
# create an initial MailSource from those settings so existing deployments # create an initial MailSource from those settings so existing deployments
# continue to work without manual reconfiguration. # continue to work without manual reconfiguration.
_migrate_imap_env_vars_to_db() _migrate_imap_env_vars_to_db()
_encrypt_legacy_mail_source_secrets()
# Start background polling task (iterates over DB-enabled mail sources) # Start background polling task (iterates over DB-enabled mail sources)
logger.info("Starting IMAP polling background task") logger.info("Starting IMAP polling background task")
+58 -10
View File
@@ -3,6 +3,7 @@ from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.core.credential_encryption import decrypt_secret, encrypt_secret, is_encrypted_secret
from app.core.database import Base from app.core.database import Base
@@ -33,21 +34,15 @@ class MailSource(Base):
server = Column(String, nullable=True) server = Column(String, nullable=True)
port = Column(Integer, nullable=True, default=993) port = Column(Integer, nullable=True, default=993)
username = Column(String, nullable=True) username = Column(String, nullable=True)
# NOTE: password is stored in plaintext. In a production environment this _password = Column("password", Text, nullable=True)
# field should be encrypted at the application layer before persisting.
password = Column(Text, nullable=True)
use_ssl = Column(Boolean, default=True) use_ssl = Column(Boolean, default=True)
folder = Column(String, default="INBOX") folder = Column(String, default="INBOX")
# Gmail API OAuth2 credentials (used by GMAIL_API method) # 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_id = Column(String, nullable=True)
gmail_client_secret = Column(Text, nullable=True) _gmail_client_secret = Column("gmail_client_secret", Text, nullable=True)
gmail_access_token = Column(Text, nullable=True) _gmail_access_token = Column("gmail_access_token", Text, nullable=True)
gmail_refresh_token = Column(Text, nullable=True) _gmail_refresh_token = Column("gmail_refresh_token", Text, nullable=True)
# Email address of the authorised Gmail account # Email address of the authorised Gmail account
gmail_email = Column(String, nullable=True) gmail_email = Column(String, nullable=True)
# JSON-encoded list of Gmail message IDs that have already been ingested # JSON-encoded list of Gmail message IDs that have already been ingested
@@ -72,3 +67,56 @@ class MailSource(Base):
def __repr__(self): def __repr__(self):
return f"<MailSource id={self.id} name={self.name!r} method={self.method!r}>" return f"<MailSource id={self.id} name={self.name!r} method={self.method!r}>"
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)
+89
View File
@@ -12,8 +12,10 @@ from urllib.parse import parse_qs, urlparse
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.orm import Session 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 MailSource
from app.models.mail_source_import import MailSourceImport from app.models.mail_source_import import MailSourceImport
from app.services.import_history import record_import_attempt from app.services.import_history import record_import_attempt
@@ -52,6 +54,93 @@ class TestMailSourceModel:
assert source.enabled is True assert source.enabled is True
assert source.last_checked is None 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): def test_default_values(self, db_session: Session):
source = MailSource(name="Minimal", method="IMAP") source = MailSource(name="Minimal", method="IMAP")
db_session.add(source) db_session.add(source)