mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
289 lines
9.1 KiB
Python
289 lines
9.1 KiB
Python
"""
|
|
Key lifecycle: time-based removal of compliance MEK, soft-deleted DM keys, and edit history.
|
|
|
|
Uses MESSAGE_RETENTION_DAYS from the environment (see services.shared.message_retention).
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from sqlalchemy import or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .models import DMEnvelope, DMReaction, DMFile, MessageEditHistory, DMEditHistory
|
|
|
|
logger = logging.getLogger("uvicorn.error")
|
|
|
|
|
|
def _is_empty_wrapped_key(value: str | None) -> bool:
|
|
return value in (None, "")
|
|
|
|
|
|
def _is_decryptable_keyless(dm_envelope: DMEnvelope) -> bool:
|
|
return (
|
|
_is_empty_wrapped_key(dm_envelope.sender_wrapped_mek_b64)
|
|
and _is_empty_wrapped_key(dm_envelope.recipient_wrapped_mek_b64)
|
|
and _is_empty_wrapped_key(dm_envelope.compliance_wrapped_mek_b64)
|
|
)
|
|
|
|
|
|
def _delete_dm_envelopes_and_related(db: Session, envelope_ids: list[int]) -> int:
|
|
if not envelope_ids:
|
|
return 0
|
|
|
|
unique_ids = list(dict.fromkeys(envelope_ids))
|
|
deleted_reactions = db.query(DMReaction).filter(
|
|
DMReaction.dm_envelope_id.in_(unique_ids)
|
|
).delete(synchronize_session=False)
|
|
deleted_files = db.query(DMFile).filter(DMFile.message_id.in_(unique_ids)).delete(synchronize_session=False)
|
|
deleted_dm_edits = db.query(DMEditHistory).filter(
|
|
or_(
|
|
DMEditHistory.message_id.in_(unique_ids),
|
|
DMEditHistory.dm_envelope_id.in_(unique_ids),
|
|
)
|
|
).delete(synchronize_session=False)
|
|
deleted_messages = db.query(DMEnvelope).filter(
|
|
DMEnvelope.id.in_(unique_ids)
|
|
).delete(synchronize_session=False)
|
|
|
|
logger.info(
|
|
"Purging %s keyless DM envelopes. reactions=%s files=%s edit_history_rows=%s",
|
|
deleted_messages,
|
|
deleted_reactions,
|
|
deleted_files,
|
|
deleted_dm_edits,
|
|
)
|
|
|
|
return deleted_messages
|
|
|
|
|
|
def _retention_timedelta_or_skip():
|
|
try:
|
|
from services.shared.message_retention import get_message_retention
|
|
except ImportError:
|
|
from backend.services.shared.message_retention import get_message_retention # type: ignore
|
|
r = get_message_retention()
|
|
if not r.cleanup_enabled():
|
|
return None
|
|
return r.retention_timedelta()
|
|
|
|
|
|
def destroy_compliance_keys_for_message(db: Session, message_id: int) -> int:
|
|
try:
|
|
envelopes = db.query(DMEnvelope).filter(DMEnvelope.id == message_id).all()
|
|
|
|
destroyed_count = 0
|
|
for envelope in envelopes:
|
|
if envelope.compliance_wrapped_mek_b64:
|
|
envelope.compliance_wrapped_mek_b64 = None
|
|
destroyed_count += 1
|
|
|
|
if destroyed_count > 0:
|
|
db.commit()
|
|
logger.info(
|
|
"Destroyed compliance keys for %s DM envelopes (message_id=%s)",
|
|
destroyed_count,
|
|
message_id,
|
|
)
|
|
|
|
return destroyed_count
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to destroy compliance keys for message %s: %s", message_id, e)
|
|
db.rollback()
|
|
return 0
|
|
|
|
|
|
def destroy_compliance_keys_for_dm_envelope(db: Session, dm_envelope_id: int) -> bool:
|
|
try:
|
|
envelope = db.query(DMEnvelope).filter(DMEnvelope.id == dm_envelope_id).first()
|
|
if envelope and envelope.compliance_wrapped_mek_b64:
|
|
envelope.compliance_wrapped_mek_b64 = None
|
|
db.commit()
|
|
logger.info("Destroyed compliance key for DM envelope %s", dm_envelope_id)
|
|
return True
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to destroy compliance key for DM envelope %s: %s", dm_envelope_id, e)
|
|
db.rollback()
|
|
return False
|
|
|
|
|
|
def destroy_message_keys_for_user(db: Session, user_id: int, *, commit: bool = True) -> int:
|
|
try:
|
|
envelopes = db.query(DMEnvelope).filter(
|
|
(DMEnvelope.sender_id == user_id) | (DMEnvelope.recipient_id == user_id)
|
|
).all()
|
|
|
|
destroyed_count = 0
|
|
for envelope in envelopes:
|
|
if envelope.sender_id == user_id and envelope.sender_wrapped_mek_b64 not in (None, ""):
|
|
envelope.sender_wrapped_mek_b64 = ""
|
|
destroyed_count += 1
|
|
if envelope.recipient_id == user_id and envelope.recipient_wrapped_mek_b64 not in (None, ""):
|
|
envelope.recipient_wrapped_mek_b64 = ""
|
|
destroyed_count += 1
|
|
|
|
if destroyed_count > 0 and commit:
|
|
db.commit()
|
|
logger.info(
|
|
"Destroyed sender/recipient keys that belonged to user %s in %s DM envelopes (%s keys)",
|
|
len(envelopes),
|
|
destroyed_count,
|
|
user_id,
|
|
)
|
|
|
|
return destroyed_count
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to destroy sender/recipient keys for user %s: %s", user_id, e)
|
|
db.rollback()
|
|
return 0
|
|
|
|
|
|
def cleanup_expired_compliance_keys(db: Session) -> int:
|
|
delta = _retention_timedelta_or_skip()
|
|
if delta is None:
|
|
return 0
|
|
|
|
try:
|
|
cutoff_date = datetime.now() - delta
|
|
|
|
expired_envelopes = db.query(DMEnvelope).filter(
|
|
DMEnvelope.timestamp < cutoff_date,
|
|
DMEnvelope.compliance_wrapped_mek_b64.isnot(None),
|
|
).all()
|
|
|
|
destroyed_count = 0
|
|
for envelope in expired_envelopes:
|
|
envelope.compliance_wrapped_mek_b64 = None
|
|
destroyed_count += 1
|
|
|
|
if destroyed_count > 0:
|
|
db.commit()
|
|
logger.info("Cleaned up %s expired compliance MEK fields", destroyed_count)
|
|
|
|
return destroyed_count
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to cleanup expired compliance keys: %s", e)
|
|
db.rollback()
|
|
return 0
|
|
|
|
|
|
def cleanup_expired_message_keys(db: Session) -> int:
|
|
delta = _retention_timedelta_or_skip()
|
|
if delta is None:
|
|
return 0
|
|
|
|
try:
|
|
cutoff_date = datetime.now() - delta
|
|
|
|
expired_messages = db.query(DMEnvelope).filter(
|
|
DMEnvelope.deleted_at.is_not(None),
|
|
DMEnvelope.deleted_at < cutoff_date,
|
|
).all()
|
|
|
|
if not expired_messages:
|
|
return 0
|
|
|
|
keys_destroyed = 0
|
|
keyless_message_ids: list[int] = []
|
|
|
|
for message in expired_messages:
|
|
if not _is_empty_wrapped_key(message.sender_wrapped_mek_b64):
|
|
message.sender_wrapped_mek_b64 = ""
|
|
keys_destroyed += 1
|
|
if not _is_empty_wrapped_key(message.recipient_wrapped_mek_b64):
|
|
message.recipient_wrapped_mek_b64 = ""
|
|
keys_destroyed += 1
|
|
|
|
logger.debug(
|
|
"Destroyed keys for soft-deleted message id=%s (deleted %s)",
|
|
message.id,
|
|
message.deleted_at.isoformat(),
|
|
)
|
|
|
|
if _is_decryptable_keyless(message):
|
|
keyless_message_ids.append(message.id)
|
|
|
|
if keyless_message_ids:
|
|
deleted_messages = _delete_dm_envelopes_and_related(db, keyless_message_ids)
|
|
else:
|
|
deleted_messages = 0
|
|
|
|
db.commit()
|
|
logger.info(
|
|
"Message key cleanup: destroyed %s keys across %s messages; purged %s keyless messages",
|
|
keys_destroyed,
|
|
len(expired_messages),
|
|
deleted_messages,
|
|
)
|
|
|
|
return keys_destroyed
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to cleanup expired message keys: %s", e)
|
|
db.rollback()
|
|
return 0
|
|
|
|
|
|
def cleanup_expired_edit_history(db: Session) -> int:
|
|
delta = _retention_timedelta_or_skip()
|
|
if delta is None:
|
|
return 0
|
|
|
|
try:
|
|
cutoff_date = datetime.now() - delta
|
|
|
|
public_deleted = db.query(MessageEditHistory).filter(
|
|
MessageEditHistory.edited_at < cutoff_date
|
|
).delete(synchronize_session=False)
|
|
|
|
dm_deleted = db.query(DMEditHistory).filter(
|
|
DMEditHistory.edited_at < cutoff_date
|
|
).delete(synchronize_session=False)
|
|
|
|
total_deleted = public_deleted + dm_deleted
|
|
|
|
if total_deleted > 0:
|
|
db.commit()
|
|
logger.info("Cleaned up %s expired edit history entries", total_deleted)
|
|
|
|
return total_deleted
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to cleanup expired edit history: %s", e)
|
|
db.rollback()
|
|
return 0
|
|
|
|
|
|
def run_key_lifecycle_cleanup(db: Session) -> dict:
|
|
stats = {
|
|
"compliance_keys_destroyed": cleanup_expired_compliance_keys(db),
|
|
"message_keys_destroyed": cleanup_expired_message_keys(db),
|
|
"edit_history_entries_removed": cleanup_expired_edit_history(db),
|
|
"timestamp": datetime.now().isoformat(),
|
|
}
|
|
|
|
if (
|
|
stats["compliance_keys_destroyed"]
|
|
or stats["message_keys_destroyed"]
|
|
or stats["edit_history_entries_removed"]
|
|
):
|
|
logger.info("Key lifecycle cleanup completed: %s", stats)
|
|
return stats
|
|
|
|
|
|
def get_key_lifecycle_config() -> dict:
|
|
try:
|
|
from services.shared.message_retention import get_message_retention
|
|
except ImportError:
|
|
from backend.services.shared.message_retention import get_message_retention # type: ignore
|
|
r = get_message_retention()
|
|
return {
|
|
"message_retention_days": r.days,
|
|
"cleanup_enabled": r.cleanup_enabled(),
|
|
"never_store_compliance_mek": r.never_store_compliance_mek(),
|
|
}
|