fix: encrypt mail source credentials
This commit is contained in:
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user