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()
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")
+58 -10
View File
@@ -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"<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
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)