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()
|
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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user