mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
1031 lines
37 KiB
Python
1031 lines
37 KiB
Python
"""
|
|
Envelope encryption API endpoints for private messaging.
|
|
|
|
Handles:
|
|
- Sending encrypted private messages (proxies to messaging service)
|
|
- Retrieving encrypted conversations
|
|
- Decrypting messages with proper MEK unwrapping
|
|
- Managing transport public key distribution
|
|
"""
|
|
|
|
import logging
|
|
import json
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import httpx
|
|
from nacl.exceptions import CryptoError
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
|
from sqlalchemy.orm import Session
|
|
from pydantic import BaseModel, Field
|
|
|
|
from ..db import get_db
|
|
from ..models import User, DMEnvelope, DMFile, DMEditHistory, EditMessageRequest
|
|
from ..dependencies import get_current_user, get_current_user_allow_suspended
|
|
from ..security.audit import log_security
|
|
from ..service_calls import (
|
|
get_messaging_transport_public_key,
|
|
get_compliance_public_key,
|
|
process_message_with_files_in_messaging_service,
|
|
store_encrypted_file,
|
|
init_resumable_upload_in_storage,
|
|
get_resumable_upload_status_in_storage,
|
|
upload_resumable_chunk_in_storage,
|
|
complete_resumable_upload_in_storage,
|
|
get_resumable_upload_data_in_storage,
|
|
delete_resumable_upload_in_storage,
|
|
)
|
|
from .messaging import messagingManager, convert_dm_envelope, convert_dm_envelope_for_user
|
|
from ..push_service import push_service
|
|
|
|
logger = logging.getLogger("uvicorn.error")
|
|
|
|
|
|
def _compliance_public_key_required() -> bool:
|
|
try:
|
|
from services.shared.message_retention import get_message_retention
|
|
except ImportError:
|
|
from backend.services.shared.message_retention import get_message_retention # type: ignore
|
|
return not get_message_retention().never_store_compliance_mek()
|
|
|
|
|
|
router = APIRouter(prefix="/dm", tags=["Direct Messages"])
|
|
|
|
|
|
# ============================================================================
|
|
# Pydantic Models
|
|
# ============================================================================
|
|
|
|
class FileModel(BaseModel):
|
|
encrypted_file_data_b64: str
|
|
filename: str
|
|
file_size: int
|
|
|
|
|
|
class SendEncryptedMessageRequest(BaseModel):
|
|
"""Request to send an encrypted message."""
|
|
recipient_id: int
|
|
client_public_key_b64: str
|
|
transport_nonce_b64: str
|
|
transport_ciphertext_b64: str
|
|
sender_public_key_b64: str
|
|
recipient_public_key_b64: str
|
|
client_message_id: Optional[str] = None
|
|
reply_to_id: Optional[int] = None
|
|
files: list[FileModel] = Field(default_factory=list, alias="transport_files")
|
|
uploaded_file_ids: list[str] = Field(default_factory=list, alias="uploaded_file_ids")
|
|
|
|
class Config:
|
|
allow_population_by_field_name = True
|
|
|
|
|
|
class EditEncryptedMessageRequest(BaseModel):
|
|
"""Request to edit an encrypted message."""
|
|
client_public_key_b64: str
|
|
transport_nonce_b64: str
|
|
transport_ciphertext_b64: str
|
|
sender_public_key_b64: str
|
|
recipient_public_key_b64: str
|
|
|
|
|
|
class InitResumableUploadRequest(BaseModel):
|
|
filename: str
|
|
total_size: int
|
|
recipient_id: int
|
|
chunk_size: Optional[int] = None
|
|
|
|
|
|
class UploadChunkRequest(BaseModel):
|
|
offset: int
|
|
data_b64: str
|
|
|
|
|
|
# ============================================================================
|
|
# Key Management Endpoint
|
|
# ============================================================================
|
|
|
|
@router.get("/key/transport/public")
|
|
async def get_transport_public_key_endpoint(request: Request):
|
|
"""
|
|
Get the current messaging service ephemeral transport public key.
|
|
|
|
Clients use this key to encrypt their messages with X25519 + ChaCha20-Poly1305.
|
|
|
|
Returns:
|
|
{
|
|
"key_id": "key-identifier",
|
|
"public_key_b64": "base64-encoded-key",
|
|
"created_at": "unix-timestamp"
|
|
}
|
|
"""
|
|
client_ip = getattr(request.client, 'host', 'unknown') if request.client else 'unknown'
|
|
|
|
try:
|
|
result = await get_messaging_transport_public_key()
|
|
return result
|
|
except Exception as e:
|
|
logger.error("Failed to fetch transport public key: %s", e)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to fetch encryption key"
|
|
)
|
|
|
|
|
|
@router.post("/upload/init")
|
|
async def init_resumable_upload(
|
|
request: InitResumableUploadRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
if request.total_size <= 0:
|
|
raise HTTPException(status_code=400, detail="total_size must be > 0")
|
|
|
|
if current_user.id == request.recipient_id:
|
|
raise HTTPException(status_code=400, detail="Cannot send files to yourself")
|
|
|
|
payload = await init_resumable_upload_in_storage(
|
|
filename=request.filename,
|
|
total_size=request.total_size,
|
|
allowed_user_ids=[current_user.id, request.recipient_id],
|
|
chunk_size=request.chunk_size,
|
|
)
|
|
return payload
|
|
|
|
|
|
@router.get("/upload/{upload_id}")
|
|
async def get_resumable_upload_status(
|
|
upload_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
return await get_resumable_upload_status_in_storage(upload_id, current_user.id)
|
|
|
|
|
|
@router.patch("/upload/{upload_id}")
|
|
async def upload_resumable_chunk(
|
|
upload_id: str,
|
|
request: UploadChunkRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
return await upload_resumable_chunk_in_storage(
|
|
upload_id=upload_id,
|
|
user_id=current_user.id,
|
|
offset=request.offset,
|
|
data_b64=request.data_b64,
|
|
)
|
|
|
|
|
|
@router.post("/upload/{upload_id}/complete")
|
|
async def complete_resumable_upload(
|
|
upload_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
return await complete_resumable_upload_in_storage(upload_id, current_user.id)
|
|
|
|
|
|
@router.delete("/upload/{upload_id}")
|
|
async def delete_resumable_upload(
|
|
upload_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
return await delete_resumable_upload_in_storage(upload_id, current_user.id)
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
# Message Sending Endpoint
|
|
# ============================================================================
|
|
|
|
@router.post("/send")
|
|
async def send_encrypted_message(
|
|
request: SendEncryptedMessageRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Send an encrypted private message using envelope encryption.
|
|
|
|
Flow:
|
|
1. Client encrypts plaintext with transport public key (X25519 + ChaCha20)
|
|
2. Sends encrypted message to this endpoint with public keys
|
|
3. Main backend forwards to messaging service for envelope encryption processing
|
|
4. Messaging service returns encrypted message + 3 wrapped MEKs
|
|
5. Main backend stores in database
|
|
|
|
Args:
|
|
request: SendEncryptedMessageRequest
|
|
current_user: Current authenticated user
|
|
db: Database session
|
|
|
|
Returns:
|
|
{
|
|
"id": message-id,
|
|
"sender_id": sender-user-id,
|
|
"recipient_id": recipient-user-id,
|
|
"timestamp": iso-timestamp,
|
|
"reply_to_id": optional-reply-id
|
|
}
|
|
"""
|
|
try:
|
|
# Verify recipient exists
|
|
recipient = db.query(User).filter(User.id == request.recipient_id).first()
|
|
if not recipient:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Recipient not found"
|
|
)
|
|
|
|
# Verify not sending to self
|
|
if current_user.id == request.recipient_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot send messages to yourself"
|
|
)
|
|
|
|
# Fetch compliance public key and process through messaging service
|
|
compliance_key_response = await get_compliance_public_key()
|
|
compliance_public_key_b64 = compliance_key_response.get("public_key_b64") or ""
|
|
if _compliance_public_key_required() and not compliance_public_key_b64:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to retrieve compliance key"
|
|
)
|
|
|
|
all_transport_files: list[dict[str, object]] = [
|
|
{
|
|
"encrypted_file_data_b64": f.encrypted_file_data_b64,
|
|
"filename": f.filename,
|
|
"file_size": f.file_size,
|
|
}
|
|
for f in request.files
|
|
]
|
|
|
|
for upload_id in request.uploaded_file_ids:
|
|
uploaded_payload = await get_resumable_upload_data_in_storage(upload_id, current_user.id)
|
|
all_transport_files.append(
|
|
{
|
|
"encrypted_file_data_b64": uploaded_payload["encrypted_file_data_b64"],
|
|
"filename": uploaded_payload["filename"],
|
|
"file_size": uploaded_payload["file_size"],
|
|
"upload_id": upload_id,
|
|
}
|
|
)
|
|
|
|
processed = await process_message_with_files_in_messaging_service(
|
|
client_public_key_b64=request.client_public_key_b64,
|
|
transport_nonce_b64=request.transport_nonce_b64,
|
|
transport_ciphertext_b64=request.transport_ciphertext_b64,
|
|
compliance_public_key_b64=compliance_public_key_b64,
|
|
sender_public_key_b64=request.sender_public_key_b64,
|
|
recipient_public_key_b64=request.recipient_public_key_b64,
|
|
transport_files=[
|
|
{
|
|
"encrypted_file_data_b64": str(f["encrypted_file_data_b64"]),
|
|
"filename": str(f.get("filename", "file")),
|
|
}
|
|
for f in all_transport_files
|
|
],
|
|
)
|
|
|
|
logger.info(
|
|
"Processed encrypted message, storing in database sender_id=%s recipient_id=%s",
|
|
current_user.id,
|
|
request.recipient_id,
|
|
)
|
|
|
|
msg = processed["message"]
|
|
dm_envelope = DMEnvelope(
|
|
sender_id=current_user.id,
|
|
recipient_id=request.recipient_id,
|
|
iv_b64=msg["nonce"],
|
|
ciphertext_b64=msg["ciphertext"],
|
|
sender_wrapped_mek_b64=processed["sender_wrapped_mek"],
|
|
recipient_wrapped_mek_b64=processed["recipient_wrapped_mek"],
|
|
compliance_wrapped_mek_b64=processed["compliance_wrapped_mek"],
|
|
reply_to_id=request.reply_to_id,
|
|
)
|
|
|
|
db.add(dm_envelope)
|
|
db.commit()
|
|
db.refresh(dm_envelope)
|
|
|
|
# Store files encrypted with the SAME MEK as the message.
|
|
# We persist per-file nonce (for AES-GCM) but do not persist per-file wrapped MEKs.
|
|
try:
|
|
file_results: list[dict] = processed.get("files", []) or []
|
|
if len(file_results) != len(all_transport_files):
|
|
raise HTTPException(status_code=500, detail="File processing count mismatch")
|
|
|
|
for i, tf in enumerate(all_transport_files):
|
|
fr = file_results[i]
|
|
file_storage_result = await store_encrypted_file(
|
|
encrypted_file_data_b64=fr["ciphertext"],
|
|
filename=str(tf["filename"]),
|
|
content_type="application/octet-stream",
|
|
sender_id=current_user.id,
|
|
recipient_id=request.recipient_id,
|
|
)
|
|
|
|
df = DMFile(
|
|
message_id=dm_envelope.id,
|
|
sender_id=current_user.id,
|
|
recipient_id=dm_envelope.recipient_id,
|
|
path=file_storage_result.get("path") or f"/uploads/files/encrypted/{file_storage_result['file_id']}",
|
|
name=Path(str(tf["filename"])).name,
|
|
nonce_b64=fr["nonce"],
|
|
)
|
|
db.add(df)
|
|
db.commit()
|
|
|
|
for upload_id in request.uploaded_file_ids:
|
|
try:
|
|
await delete_resumable_upload_in_storage(upload_id, current_user.id)
|
|
except Exception as cleanup_error:
|
|
logger.warning("Failed to cleanup resumable upload %s: %s", upload_id, cleanup_error)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception:
|
|
try:
|
|
db.rollback()
|
|
except Exception:
|
|
pass
|
|
raise
|
|
logger.info(
|
|
"Stored encrypted message msg_id=%s from user_id=%s to user_id=%s",
|
|
dm_envelope.id,
|
|
current_user.id,
|
|
request.recipient_id,
|
|
)
|
|
|
|
# Send user-specific WebSocket updates (each user gets only their MEK and files metadata)
|
|
recipient_payload = convert_dm_envelope_for_user(
|
|
db, dm_envelope, dm_envelope.recipient_id,
|
|
)
|
|
await messagingManager.send_update_to_user(dm_envelope.recipient_id, "dmNew", recipient_payload, db)
|
|
|
|
sender_payload = convert_dm_envelope_for_user(
|
|
db,
|
|
dm_envelope,
|
|
dm_envelope.sender_id,
|
|
sender_client_message_id=request.client_message_id,
|
|
)
|
|
await messagingManager.send_update_to_user(dm_envelope.sender_id, "dmNew", sender_payload, db)
|
|
|
|
try:
|
|
await push_service.send_dm_notification(db, dm_envelope, current_user)
|
|
except Exception as e:
|
|
logger.error("Failed to send push notification for DM %s: %s", dm_envelope.id, e)
|
|
|
|
return {
|
|
"id": dm_envelope.id,
|
|
"sender_id": dm_envelope.sender_id,
|
|
"recipient_id": dm_envelope.recipient_id,
|
|
"timestamp": dm_envelope.timestamp.isoformat(),
|
|
"client_message_id": request.client_message_id,
|
|
"reply_to_id": dm_envelope.reply_to_id,
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except CryptoError as e:
|
|
logger.warning(
|
|
"DM send: transport NaCl decrypt failed (message/file key mismatch or corrupt ciphertext): %s",
|
|
e,
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"Transport decryption failed: the encrypted message and each attachment must be "
|
|
"encrypted with the same client ephemeral keypair. For resumable uploads, the "
|
|
"ciphertext bytes on the server must match the transport fields in this request—"
|
|
"re-encrypt on the client or abort the upload session and start over."
|
|
),
|
|
) from e
|
|
except Exception as e:
|
|
logger.exception("Error sending encrypted message: %s", e)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to send message"
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Compliance Endpoint (User ID 1 Only)
|
|
# ============================================================================
|
|
|
|
@router.get("/compliance/extract/{message_id}")
|
|
async def extract_message_for_compliance(
|
|
message_id: int,
|
|
request: Request,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Extract message data for compliance review.
|
|
|
|
RESTRICTED: Only accessible by user ID 1 (compliance officer).
|
|
This endpoint extracts encrypted message data that can be transferred
|
|
to an air-gapped machine for decryption using the compliance private key.
|
|
"""
|
|
# Log compliance access attempt
|
|
client_ip = getattr(request.client, 'host', 'unknown') if request.client else 'unknown'
|
|
log_security("compliance_access_attempt", "warning",
|
|
username=current_user.username, user_id=current_user.id,
|
|
message_id=message_id, ip=client_ip)
|
|
|
|
# Security check: only user ID 1 can access this
|
|
if current_user.id != 1:
|
|
log_security("compliance_access_denied", "error",
|
|
username=current_user.username, user_id=current_user.id,
|
|
message_id=message_id, ip=client_ip,
|
|
reason="Unauthorized user (compliance officer access required)")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied. This endpoint is restricted to compliance officers."
|
|
)
|
|
|
|
# Find the message
|
|
envelope = db.query(DMEnvelope).filter(DMEnvelope.id == message_id).first()
|
|
if not envelope:
|
|
log_security("compliance_access_failed", "warning",
|
|
username=current_user.username, user_id=current_user.id,
|
|
message_id=message_id, ip=client_ip,
|
|
reason="Message not found")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Message not found"
|
|
)
|
|
|
|
# Get sender and recipient usernames for logging
|
|
sender = db.query(User).filter(User.id == envelope.sender_id).first()
|
|
recipient = db.query(User).filter(User.id == envelope.recipient_id).first()
|
|
sender_username = sender.username if sender else f"user_{envelope.sender_id}"
|
|
recipient_username = recipient.username if recipient else f"user_{envelope.recipient_id}"
|
|
|
|
# Extract compliance-relevant data (excluding sensitive server-only fields)
|
|
files = []
|
|
try:
|
|
for f in (envelope.files or []):
|
|
wrapped = envelope.compliance_wrapped_mek_b64
|
|
|
|
files.append(
|
|
{
|
|
"id": f.id,
|
|
"name": f.name,
|
|
"path": f.path,
|
|
"wrapped_mek_b64": wrapped,
|
|
"nonce_b64": getattr(f, "nonce_b64", None),
|
|
}
|
|
)
|
|
except Exception:
|
|
files = []
|
|
|
|
# Get complete edit history for compliance
|
|
edit_history = db.query(DMEditHistory).filter(
|
|
DMEditHistory.message_id == message_id
|
|
).order_by(DMEditHistory.edited_at).all()
|
|
|
|
edit_history_data = []
|
|
for edit_entry in edit_history:
|
|
edited_by_user = db.query(User).filter(User.id == edit_entry.edited_by).first()
|
|
edit_history_data.append({
|
|
"edit_id": edit_entry.id,
|
|
"edited_at": edit_entry.edited_at.isoformat(),
|
|
"edited_by_user_id": edit_entry.edited_by,
|
|
"edited_by_username": edited_by_user.username if edited_by_user else "unknown",
|
|
"previous_ciphertext_b64": edit_entry.previous_ciphertext_b64,
|
|
"previous_iv_b64": edit_entry.previous_iv_b64,
|
|
"previous_compliance_wrapped_mek_b64": edit_entry.previous_compliance_wrapped_mek_b64,
|
|
})
|
|
|
|
compliance_data = {
|
|
"message_id": envelope.id,
|
|
"sender_id": envelope.sender_id,
|
|
"recipient_id": envelope.recipient_id,
|
|
"timestamp": envelope.timestamp.isoformat(),
|
|
"iv_b64": envelope.iv_b64,
|
|
"ciphertext_b64": envelope.ciphertext_b64,
|
|
"compliance_wrapped_mek_b64": envelope.compliance_wrapped_mek_b64,
|
|
"files": files,
|
|
"edit_history": edit_history_data,
|
|
"total_edits": len(edit_history_data),
|
|
"extraction_timestamp": datetime.now().isoformat(),
|
|
"extracted_by_user_id": current_user.id,
|
|
"compliance_system_ready": envelope.compliance_wrapped_mek_b64 is not None
|
|
}
|
|
|
|
log_security("compliance_extraction_success", "info",
|
|
username=current_user.username, user_id=current_user.id,
|
|
message_id=message_id, sender_id=envelope.sender_id,
|
|
recipient_id=envelope.recipient_id, sender_username=sender_username,
|
|
recipient_username=recipient_username, ip=client_ip)
|
|
|
|
return {
|
|
"status": "success",
|
|
"message": "Message data extracted for compliance review",
|
|
"data": compliance_data,
|
|
"instructions": [
|
|
"Transfer this data to an air-gapped machine",
|
|
"Use scripts/compliance/decryption/main.py decrypt --input-file <json_file>",
|
|
"Keep the compliance private key offline at all times"
|
|
]
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Conversation Retrieval Endpoint
|
|
# ============================================================================
|
|
|
|
@router.get("/conversation/{other_user_id}")
|
|
async def get_encrypted_conversation(
|
|
other_user_id: int,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
current_user: User = Depends(get_current_user_allow_suspended),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Retrieve encrypted conversation with another user.
|
|
|
|
Returns messages with the wrapped MEK that the current user can unwrap.
|
|
Each user receives only their own wrapped MEK version.
|
|
|
|
Args:
|
|
other_user_id: ID of the other user in conversation
|
|
limit: Max messages to return (default 50)
|
|
offset: Pagination offset (default 0)
|
|
current_user: Current authenticated user
|
|
db: Database session
|
|
|
|
Returns:
|
|
List of encrypted messages with metadata:
|
|
[
|
|
{
|
|
"id": message-id,
|
|
"sender_id": sender-id,
|
|
"recipient_id": recipient-id,
|
|
"nonce": base64-encoded-nonce,
|
|
"ciphertext": base64-encoded-ciphertext,
|
|
"wrapped_mek": wrapped-mek-for-current-user,
|
|
"timestamp": iso-timestamp,
|
|
"reply_to_id": optional-id,
|
|
"is_edited": boolean
|
|
},
|
|
...
|
|
]
|
|
"""
|
|
try:
|
|
# Verify other user exists
|
|
other_user = db.query(User).filter(User.id == other_user_id).first()
|
|
if not other_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found"
|
|
)
|
|
|
|
# Fetch messages in both directions, sorted by timestamp (exclude deleted)
|
|
messages = (
|
|
db.query(DMEnvelope)
|
|
.filter(
|
|
(
|
|
(DMEnvelope.sender_id == current_user.id)
|
|
& (DMEnvelope.recipient_id == other_user_id)
|
|
)
|
|
| (
|
|
(DMEnvelope.sender_id == other_user_id)
|
|
& (DMEnvelope.recipient_id == current_user.id)
|
|
),
|
|
DMEnvelope.deleted_at.is_(None) # Exclude soft-deleted messages
|
|
)
|
|
.order_by(DMEnvelope.timestamp.desc())
|
|
.limit(limit)
|
|
.offset(offset)
|
|
.all()
|
|
)
|
|
|
|
result = []
|
|
for msg in reversed(messages):
|
|
# Select wrapped MEK appropriate for current user
|
|
if msg.sender_id == current_user.id:
|
|
wrapped_mek = msg.sender_wrapped_mek_b64
|
|
else:
|
|
wrapped_mek = msg.recipient_wrapped_mek_b64
|
|
|
|
result.append(
|
|
{
|
|
"id": msg.id,
|
|
"sender_id": msg.sender_id,
|
|
"recipient_id": msg.recipient_id,
|
|
"nonce": msg.iv_b64,
|
|
"ciphertext": msg.ciphertext_b64,
|
|
"wrapped_mek": wrapped_mek,
|
|
"timestamp": msg.timestamp.isoformat(),
|
|
"reply_to_id": msg.reply_to_id,
|
|
"is_edited": msg.is_edited,
|
|
}
|
|
)
|
|
|
|
logger.info(
|
|
"Retrieved %d messages for conversation between user_id=%s and user_id=%s",
|
|
len(result),
|
|
current_user.id,
|
|
other_user_id,
|
|
)
|
|
|
|
return result
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.exception("Error fetching conversation: %s", e)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to fetch conversation"
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Message Deletion Endpoint
|
|
# ============================================================================
|
|
|
|
@router.get("/owner/compliance-view")
|
|
async def get_owner_compliance_view(
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get all encrypted messages accessible to the owner (user_id 1) for compliance.
|
|
|
|
This endpoint returns all DM envelopes with their compliance-wrapped MEKs.
|
|
Only accessible to the system owner for audit/compliance purposes.
|
|
|
|
Returns:
|
|
List of all encrypted messages with compliance_wrapped_mek:
|
|
[
|
|
{
|
|
"id": message-id,
|
|
"sender_id": sender-id,
|
|
"recipient_id": recipient-id,
|
|
"nonce": base64-encoded-nonce,
|
|
"ciphertext": base64-encoded-ciphertext,
|
|
"compliance_wrapped_mek": wrapped-mek-for-compliance,
|
|
"timestamp": iso-timestamp,
|
|
},
|
|
...
|
|
]
|
|
"""
|
|
if current_user.id != 1:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only owner (user_id 1) can access compliance view"
|
|
)
|
|
|
|
try:
|
|
# Fetch all non-deleted messages
|
|
messages = (
|
|
db.query(DMEnvelope)
|
|
.filter(DMEnvelope.deleted_at.is_(None)) # Exclude soft-deleted messages
|
|
.order_by(DMEnvelope.timestamp.desc())
|
|
.all()
|
|
)
|
|
|
|
result = []
|
|
for msg in messages:
|
|
result.append(
|
|
{
|
|
"id": msg.id,
|
|
"sender_id": msg.sender_id,
|
|
"recipient_id": msg.recipient_id,
|
|
"nonce": msg.iv_b64,
|
|
"ciphertext": msg.ciphertext_b64,
|
|
"compliance_wrapped_mek": msg.compliance_wrapped_mek_b64,
|
|
"timestamp": msg.timestamp.isoformat(),
|
|
}
|
|
)
|
|
|
|
logger.info(
|
|
"Owner retrieved %d messages for compliance view",
|
|
len(result),
|
|
)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.exception("Error retrieving compliance view: %s", e)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to retrieve compliance view"
|
|
)
|
|
|
|
|
|
@router.get("/compliance/edit-history/dm/{message_id}")
|
|
async def get_dm_edit_history_for_compliance(
|
|
message_id: int,
|
|
request: Request,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get complete edit history for a DM message (compliance access only).
|
|
|
|
RESTRICTED: Only accessible by user ID 1 (compliance officer).
|
|
This endpoint returns the full edit history for a DM message,
|
|
including all previous encrypted versions.
|
|
|
|
Args:
|
|
message_id: ID of the DM message
|
|
current_user: Current authenticated user (must be user_id 1)
|
|
db: Database session
|
|
|
|
Returns:
|
|
Complete edit history for the message
|
|
"""
|
|
client_ip = getattr(request.client, 'host', 'unknown') if request.client else 'unknown'
|
|
|
|
# Log compliance access attempt
|
|
log_security("dm_edit_history_access_attempt", "warning",
|
|
user_id=current_user.id,
|
|
username=current_user.username,
|
|
ip=client_ip,
|
|
message_id=message_id)
|
|
|
|
# Only user_id 1 (compliance officer) can access
|
|
if current_user.id != 1:
|
|
log_security("dm_edit_history_access_denied", "error",
|
|
user_id=current_user.id,
|
|
username=current_user.username,
|
|
ip=client_ip,
|
|
reason="Unauthorized user (compliance officer access required)")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied. This endpoint is restricted to compliance officers."
|
|
)
|
|
|
|
try:
|
|
# Get the original message
|
|
message = db.query(DMEnvelope).filter(DMEnvelope.id == message_id).first()
|
|
if not message:
|
|
log_security("dm_edit_history_access_failed", "warning",
|
|
user_id=current_user.id,
|
|
ip=client_ip,
|
|
message_id=message_id,
|
|
reason="Message not found")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Message not found"
|
|
)
|
|
|
|
# Get edit history
|
|
edit_history = db.query(DMEditHistory).filter(
|
|
DMEditHistory.message_id == message_id
|
|
).order_by(DMEditHistory.edited_at).all()
|
|
|
|
# Convert to response format
|
|
history_entries = []
|
|
for entry in edit_history:
|
|
edited_by_user = db.query(User).filter(User.id == entry.edited_by).first()
|
|
history_entries.append({
|
|
"id": entry.id,
|
|
"dm_envelope_id": entry.message_id,
|
|
"previous_ciphertext_b64": entry.previous_ciphertext_b64,
|
|
"previous_iv_b64": entry.previous_iv_b64,
|
|
"previous_compliance_wrapped_mek_b64": entry.previous_compliance_wrapped_mek_b64,
|
|
"edited_at": entry.edited_at.isoformat(),
|
|
"edited_by_username": edited_by_user.username if edited_by_user else "unknown",
|
|
"edited_by_user_id": entry.edited_by
|
|
})
|
|
|
|
# Current message data
|
|
current_data = {
|
|
"id": message.id,
|
|
"sender_id": message.sender_id,
|
|
"recipient_id": message.recipient_id,
|
|
"ciphertext_b64": message.ciphertext_b64,
|
|
"iv_b64": message.iv_b64,
|
|
"sender_wrapped_mek_b64": message.sender_wrapped_mek_b64,
|
|
"recipient_wrapped_mek_b64": message.recipient_wrapped_mek_b64,
|
|
"compliance_wrapped_mek_b64": message.compliance_wrapped_mek_b64,
|
|
"timestamp": message.timestamp.isoformat(),
|
|
"is_edited": message.is_edited
|
|
}
|
|
|
|
result = {
|
|
"message_id": message_id,
|
|
"current_version": current_data,
|
|
"edit_history": history_entries,
|
|
"total_edits": len(history_entries)
|
|
}
|
|
|
|
log_security("dm_edit_history_access_success", "info",
|
|
user_id=current_user.id,
|
|
username=current_user.username,
|
|
ip=client_ip,
|
|
message_id=message_id,
|
|
edit_count=len(history_entries))
|
|
|
|
return result
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.exception("Error retrieving DM edit history: %s", e)
|
|
log_security("dm_edit_history_access_error", "error",
|
|
user_id=current_user.id,
|
|
ip=client_ip,
|
|
message_id=message_id,
|
|
error=str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to retrieve edit history"
|
|
)
|
|
|
|
|
|
@router.put("/edit/{message_id}")
|
|
async def edit_encrypted_message(
|
|
message_id: int,
|
|
request: EditEncryptedMessageRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Edit an encrypted private message.
|
|
|
|
This endpoint allows users to edit their own DM messages. The edit history
|
|
is stored in compliance storage, but users only see the latest version.
|
|
The message goes through the same envelope encryption process as sending.
|
|
|
|
Args:
|
|
message_id: ID of the message to edit
|
|
request: Edit request with transport-encrypted content
|
|
current_user: Current authenticated user
|
|
db: Database session
|
|
|
|
Returns:
|
|
Updated message info
|
|
"""
|
|
try:
|
|
# Find the message
|
|
msg = db.query(DMEnvelope).filter(
|
|
DMEnvelope.id == message_id,
|
|
DMEnvelope.deleted_at.is_(None) # Can't edit deleted messages
|
|
).first()
|
|
if not msg:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Message not found"
|
|
)
|
|
|
|
# Verify ownership
|
|
if msg.sender_id != current_user.id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Cannot edit others' messages"
|
|
)
|
|
|
|
# Fetch compliance public key and process through messaging service
|
|
compliance_key_response = await get_compliance_public_key()
|
|
compliance_public_key_b64 = compliance_key_response.get("public_key_b64") or ""
|
|
if _compliance_public_key_required() and not compliance_public_key_b64:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to retrieve compliance key"
|
|
)
|
|
|
|
# Process the transport-encrypted message through envelope encryption
|
|
processed = await process_message_with_files_in_messaging_service(
|
|
client_public_key_b64=request.client_public_key_b64,
|
|
transport_nonce_b64=request.transport_nonce_b64,
|
|
transport_ciphertext_b64=request.transport_ciphertext_b64,
|
|
compliance_public_key_b64=compliance_public_key_b64,
|
|
sender_public_key_b64=request.sender_public_key_b64,
|
|
recipient_public_key_b64=request.recipient_public_key_b64,
|
|
transport_files=[], # No file support for edits currently
|
|
)
|
|
|
|
# Update the message with new processed content (commit first so edit always succeeds)
|
|
processed_msg = processed["message"]
|
|
prev_ciphertext = msg.ciphertext_b64
|
|
prev_iv = msg.iv_b64
|
|
prev_wrapped_mek = msg.compliance_wrapped_mek_b64 or ""
|
|
msg.ciphertext_b64 = processed_msg["ciphertext"]
|
|
msg.iv_b64 = processed_msg["nonce"]
|
|
msg.sender_wrapped_mek_b64 = processed["sender_wrapped_mek"]
|
|
msg.recipient_wrapped_mek_b64 = processed["recipient_wrapped_mek"]
|
|
msg.compliance_wrapped_mek_b64 = processed["compliance_wrapped_mek"]
|
|
msg.is_edited = True
|
|
|
|
db.commit()
|
|
db.refresh(msg)
|
|
|
|
# Best-effort: store edit history for compliance (table may not exist yet)
|
|
try:
|
|
edit_history = DMEditHistory(
|
|
message_id=msg.id,
|
|
dm_envelope_id=msg.id,
|
|
previous_ciphertext_b64=prev_ciphertext,
|
|
previous_iv_b64=prev_iv,
|
|
previous_compliance_wrapped_mek_b64=prev_wrapped_mek,
|
|
edited_by=current_user.id,
|
|
edited_by_user_id=current_user.id,
|
|
)
|
|
db.add(edit_history)
|
|
db.commit()
|
|
except Exception as history_err:
|
|
db.rollback()
|
|
logger.warning(
|
|
"Could not store DM edit history (table dm_edit_history may not exist): %s",
|
|
history_err,
|
|
)
|
|
|
|
logger.info(
|
|
"Edited encrypted message msg_id=%s by user_id=%s",
|
|
message_id,
|
|
current_user.id
|
|
)
|
|
|
|
# Send WebSocket updates to both sender and recipient
|
|
recipient_payload = convert_dm_envelope(db, msg, msg.recipient_id)
|
|
await messagingManager.send_update_to_user(msg.recipient_id, "dmEdited", recipient_payload, db)
|
|
|
|
sender_payload = convert_dm_envelope(db, msg, msg.sender_id)
|
|
await messagingManager.send_update_to_user(msg.sender_id, "dmEdited", sender_payload, db)
|
|
|
|
return {
|
|
"id": msg.id,
|
|
"sender_id": msg.sender_id,
|
|
"recipient_id": msg.recipient_id,
|
|
"timestamp": msg.timestamp.isoformat(),
|
|
"is_edited": msg.is_edited
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.exception("Error editing encrypted message: %s", e)
|
|
db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to edit message"
|
|
)
|
|
|
|
|
|
@router.delete("/{message_id}")
|
|
async def delete_encrypted_message(
|
|
message_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Delete an encrypted message (soft delete).
|
|
|
|
Only the sender can delete their own messages.
|
|
In the compliance system, keys are automatically destroyed after deletion.
|
|
|
|
Args:
|
|
message_id: ID of message to delete
|
|
current_user: Current authenticated user
|
|
db: Database session
|
|
|
|
Returns:
|
|
{"status": "deleted", "message_id": message-id}
|
|
"""
|
|
try:
|
|
msg = db.query(DMEnvelope).filter(DMEnvelope.id == message_id).first()
|
|
if not msg:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Message not found"
|
|
)
|
|
|
|
# Only sender can delete
|
|
if msg.sender_id != current_user.id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Cannot delete others' messages"
|
|
)
|
|
|
|
# Soft delete: set deleted_at timestamp instead of hard delete
|
|
from datetime import datetime
|
|
msg.deleted_at = datetime.now()
|
|
db.commit()
|
|
|
|
logger.info(
|
|
"Deleted encrypted message msg_id=%s by user_id=%s",
|
|
message_id,
|
|
current_user.id
|
|
)
|
|
|
|
return {"status": "deleted", "message_id": message_id}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.exception("Error deleting message: %s", e)
|
|
db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to delete message"
|
|
)
|