mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-23 19:45:05 +03:00
Restructure backend into microservices, add envelope encryption, DM files, and message editing
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Backend package initializer
|
||||
__all__ = []
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ path_separator = os
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = sqlite:///./data/database.db
|
||||
# Database URL is now handled by the migration script dynamically
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
@@ -17,7 +17,7 @@ if config.config_file_name is not None:
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
from models import Base
|
||||
from services.main.models import Base
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import os
|
||||
|
||||
DATABASE_URL = "sqlite:///./data/database.db"
|
||||
JWT_ALGORITHM = "HS256"
|
||||
# Token inactivity expiration - token expires if not used for this duration
|
||||
TOKEN_INACTIVITY_EXPIRE_HOURS = 30 * 24 # 30 days of inactivity
|
||||
# Maximum token lifetime (safety net) - tokens expire after this regardless of usage
|
||||
MAX_TOKEN_LIFETIME_HOURS = 365 * 24 # 1 year maximum
|
||||
OWNER_USERNAME = "denis0001-dev"
|
||||
JWT_SECRET_KEY = os.getenv("JWT_SECRET")
|
||||
|
||||
if not JWT_SECRET_KEY:
|
||||
raise ValueError("JWT secret key empty")
|
||||
@@ -1,40 +0,0 @@
|
||||
import os
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import create_engine
|
||||
from constants import DATABASE_URL
|
||||
|
||||
# Ensure data directory exists
|
||||
os.makedirs("data", exist_ok=True)
|
||||
|
||||
POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "20"))
|
||||
MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "40"))
|
||||
POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
|
||||
POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "30"))
|
||||
|
||||
POOL_CONFIG = {
|
||||
"pool_size": POOL_SIZE,
|
||||
"max_overflow": MAX_OVERFLOW,
|
||||
"pool_recycle": POOL_RECYCLE,
|
||||
"pool_timeout": POOL_TIMEOUT,
|
||||
"pool_pre_ping": True,
|
||||
}
|
||||
|
||||
engine_kwargs = {
|
||||
"pool_size": POOL_SIZE,
|
||||
"max_overflow": MAX_OVERFLOW,
|
||||
"pool_recycle": POOL_RECYCLE,
|
||||
"pool_pre_ping": True,
|
||||
"pool_timeout": POOL_TIMEOUT,
|
||||
}
|
||||
|
||||
connect_args = {}
|
||||
if DATABASE_URL.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args=connect_args,
|
||||
**engine_kwargs,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
+23
-7
@@ -1,7 +1,23 @@
|
||||
from constants import *
|
||||
from db import *
|
||||
from models import *
|
||||
from validation import *
|
||||
from utils import *
|
||||
from dependencies import *
|
||||
from app import *
|
||||
try:
|
||||
# Preferred when running from project root: `python -m backend.main` or similar.
|
||||
from backend.services.main.constants import *
|
||||
from backend.services.main.db import *
|
||||
from backend.services.main.models import *
|
||||
from backend.services.main.validation import *
|
||||
from backend.services.main.utils import *
|
||||
from backend.services.main.dependencies import *
|
||||
from backend.services.main.main import *
|
||||
except ModuleNotFoundError as exc:
|
||||
# Only attempt the fallback when the missing module is the 'backend' package itself.
|
||||
if exc.name and exc.name.startswith("backend"):
|
||||
# Fallback when running with CWD=backend (e.g. `cd backend && uvicorn main:app`)
|
||||
from services.main.constants import *
|
||||
from services.main.db import *
|
||||
from services.main.models import *
|
||||
from services.main.validation import *
|
||||
from services.main.utils import *
|
||||
from services.main.dependencies import *
|
||||
from services.main.main import *
|
||||
else:
|
||||
# Re-raise (likely a missing external dependency like sqlalchemy)
|
||||
raise
|
||||
@@ -0,0 +1,3 @@
|
||||
# Services package initializer
|
||||
__all__ = []
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# File storage service module
|
||||
@@ -0,0 +1,668 @@
|
||||
"""
|
||||
File Storage Service - Secure file storage with execution prevention.
|
||||
|
||||
This service handles all file storage operations with non-executable permissions
|
||||
and secure directory configuration to prevent code execution regardless of file content.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
# File storage has no database access - trusts main backend for authentication
|
||||
|
||||
# Lifespan context for startup/shutdown tasks (modern FastAPI pattern)
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Ensure directories exist and permissions are applied before serving requests
|
||||
_ensure_dirs()
|
||||
_load_permissions()
|
||||
logger.info("File storage initialized at %s", str(FILES_DIR.resolve()))
|
||||
yield
|
||||
|
||||
# Initialize FastAPI app for file storage service with lifespan
|
||||
app = FastAPI(
|
||||
title="FromChat File Storage Service",
|
||||
description="Secure file storage service with execution prevention",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Add security middleware
|
||||
try:
|
||||
from services.shared.middleware import add_security_middleware
|
||||
except ImportError:
|
||||
try:
|
||||
from backend.services.shared.middleware import add_security_middleware
|
||||
except ImportError:
|
||||
add_security_middleware = None
|
||||
|
||||
if add_security_middleware:
|
||||
add_security_middleware(app)
|
||||
|
||||
try:
|
||||
from services.shared.inter_service_rate_limit import attach_internal_service_rate_limit
|
||||
except ImportError:
|
||||
from backend.services.shared.inter_service_rate_limit import attach_internal_service_rate_limit # type: ignore
|
||||
|
||||
_internal_limiter = attach_internal_service_rate_limit(app, default_limit="5000/minute")
|
||||
|
||||
# CORS configuration for inter-service communication
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Allow all origins for inter-service communication
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health", response_model=None)
|
||||
@_internal_limiter.exempt
|
||||
async def health_check():
|
||||
"""Health check endpoint for file storage service."""
|
||||
return {"status": "healthy", "service": "file_storage"}
|
||||
|
||||
|
||||
@app.get("/", response_model=None)
|
||||
async def root():
|
||||
"""Root endpoint for file storage service."""
|
||||
return {"message": "FromChat File Storage Service", "status": "operational"}
|
||||
|
||||
|
||||
"""
|
||||
File storage implementation
|
||||
- Stores files under `files/files`
|
||||
- Ensures directories and files have non-executable permissions
|
||||
- Simple internal auth via X-Internal-Auth header when INTERNAL_AUTH_TOKEN is set
|
||||
- Streams uploads to disk to avoid large memory usage
|
||||
"""
|
||||
|
||||
import os
|
||||
import base64
|
||||
import uuid
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from fastapi import UploadFile, File, HTTPException, Request, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# File serving directories (matching main service structure)
|
||||
FILES_BASE_DIR = Path("data/uploads/files")
|
||||
FILES_NORMAL_DIR = FILES_BASE_DIR / "normal"
|
||||
FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
|
||||
|
||||
# Base storage directories
|
||||
BASE_DIR = Path("files")
|
||||
FILES_DIR = BASE_DIR / "files"
|
||||
THUMBS_DIR = BASE_DIR / "thumbs"
|
||||
TMP_DIR = BASE_DIR / "tmp"
|
||||
RESUMABLE_DIR = TMP_DIR / "resumable"
|
||||
RESUMABLE_META_DIR = RESUMABLE_DIR / "meta"
|
||||
RESUMABLE_DATA_DIR = RESUMABLE_DIR / "data"
|
||||
|
||||
# Maximum allowed upload size (bytes) - 5GB per plan
|
||||
MAX_UPLOAD_SIZE = 5 * 1024 * 1024 * 1024
|
||||
|
||||
# Permissions storage
|
||||
PERMISSIONS_FILE = Path("files/permissions.json")
|
||||
_file_permissions: dict[str, list[int]] = {}
|
||||
|
||||
|
||||
def _load_permissions():
|
||||
"""Load permissions from disk."""
|
||||
global _file_permissions
|
||||
if PERMISSIONS_FILE.exists():
|
||||
try:
|
||||
with open(PERMISSIONS_FILE, 'r') as f:
|
||||
_file_permissions = json.load(f)
|
||||
except Exception as e:
|
||||
logger.error("Failed to load permissions file: %s", e)
|
||||
_file_permissions = {}
|
||||
|
||||
|
||||
def _save_permissions():
|
||||
"""Save permissions to disk."""
|
||||
try:
|
||||
with open(PERMISSIONS_FILE, 'w') as f:
|
||||
json.dump(_file_permissions, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.error("Failed to save permissions file: %s", e)
|
||||
|
||||
|
||||
def _store_file_permissions(file_id: str, allowed_user_ids: list[int]):
|
||||
"""Store permission information for a file."""
|
||||
_file_permissions[file_id] = allowed_user_ids
|
||||
_save_permissions()
|
||||
|
||||
|
||||
def _check_file_permissions(file_id: str, user_id: int) -> bool:
|
||||
"""Check if user has permission to access a file."""
|
||||
allowed_users = _file_permissions.get(file_id, [])
|
||||
return user_id in allowed_users
|
||||
|
||||
|
||||
def _ensure_dirs() -> None:
|
||||
"""Create storage directories with secure permissions (owner rw, no exec for files)."""
|
||||
os.makedirs(FILES_DIR, exist_ok=True)
|
||||
os.makedirs(TMP_DIR, exist_ok=True)
|
||||
os.makedirs(RESUMABLE_META_DIR, exist_ok=True)
|
||||
os.makedirs(RESUMABLE_DATA_DIR, exist_ok=True)
|
||||
# Also ensure the uploads directories exist (for backward compatibility)
|
||||
os.makedirs(FILES_NORMAL_DIR, exist_ok=True)
|
||||
os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True)
|
||||
try:
|
||||
# Directories should be accessible only by owner
|
||||
os.chmod(BASE_DIR, 0o700)
|
||||
os.chmod(FILES_DIR, 0o700)
|
||||
os.chmod(TMP_DIR, 0o700)
|
||||
os.chmod(RESUMABLE_DIR, 0o700)
|
||||
os.chmod(RESUMABLE_META_DIR, 0o700)
|
||||
os.chmod(RESUMABLE_DATA_DIR, 0o700)
|
||||
os.chmod(FILES_BASE_DIR, 0o700)
|
||||
os.chmod(FILES_NORMAL_DIR, 0o700)
|
||||
os.chmod(FILES_ENCRYPTED_DIR, 0o700)
|
||||
os.makedirs(THUMBS_DIR, exist_ok=True)
|
||||
os.chmod(THUMBS_DIR, 0o700)
|
||||
except Exception:
|
||||
# Best-effort; don't fail startup if chmod not permitted
|
||||
logger.debug("Could not set directory permissions for file storage (best-effort)")
|
||||
|
||||
|
||||
# No internal auth enforced by design (accept all uploads). Authentication is handled by main service.
|
||||
|
||||
|
||||
# startup tasks are handled by the lifespan context manager above
|
||||
|
||||
|
||||
def _secure_filename(name: str) -> str:
|
||||
"""Return a sanitized filename (strip directories)."""
|
||||
return Path(name).name
|
||||
|
||||
|
||||
def _resumable_meta_path(upload_id: str) -> Path:
|
||||
return RESUMABLE_META_DIR / f"{upload_id}.json"
|
||||
|
||||
|
||||
def _resumable_data_path(upload_id: str) -> Path:
|
||||
return RESUMABLE_DATA_DIR / f"{upload_id}.bin"
|
||||
|
||||
|
||||
def _read_resumable_meta(upload_id: str) -> dict:
|
||||
meta_path = _resumable_meta_path(upload_id)
|
||||
if not meta_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Upload session not found")
|
||||
try:
|
||||
return json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
logger.error("STORAGE: Failed to read resumable metadata for %s: %s", upload_id, e)
|
||||
raise HTTPException(status_code=500, detail="Failed to read upload session")
|
||||
|
||||
|
||||
def _write_resumable_meta(upload_id: str, data: dict) -> None:
|
||||
meta_path = _resumable_meta_path(upload_id)
|
||||
tmp_path = meta_path.with_suffix(".json.tmp")
|
||||
tmp_path.write_text(json.dumps(data, ensure_ascii=True), encoding="utf-8")
|
||||
os.replace(tmp_path, meta_path)
|
||||
|
||||
|
||||
def _assert_resumable_access(meta: dict, user_id: int) -> None:
|
||||
allowed = meta.get("allowed_user_ids", [])
|
||||
if user_id == 1:
|
||||
return
|
||||
if user_id not in allowed:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this upload")
|
||||
|
||||
|
||||
async def _stream_save(upload: UploadFile, dest_path: Path) -> int:
|
||||
"""Stream an UploadFile to disk, return total bytes written."""
|
||||
total = 0
|
||||
# write to a temp file first
|
||||
tmp_name = TMP_DIR / f"{uuid.uuid4().hex}.tmp"
|
||||
try:
|
||||
with open(tmp_name, "wb") as out:
|
||||
while True:
|
||||
chunk = await upload.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
out.write(chunk)
|
||||
total += len(chunk)
|
||||
if total > MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=400, detail="File exceeds maximum allowed size")
|
||||
# Move into place
|
||||
os.replace(tmp_name, dest_path)
|
||||
# Ensure non-executable permissions for file (rw for owner only)
|
||||
try:
|
||||
os.chmod(dest_path, 0o600)
|
||||
except Exception:
|
||||
logger.debug("Could not chmod file %s", dest_path)
|
||||
return total
|
||||
finally:
|
||||
# Cleanup tmp if still exists
|
||||
try:
|
||||
if tmp_name.exists():
|
||||
tmp_name.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.post("/upload", response_model=None)
|
||||
async def upload_file(request: Request, file: UploadFile = File(...)):
|
||||
"""
|
||||
Upload a file to secure storage. Returns the stored filename and path.
|
||||
|
||||
"""
|
||||
try:
|
||||
# Ensure directories exist even when called in-process (lifespan may not run for mounted apps).
|
||||
_ensure_dirs()
|
||||
|
||||
original_name = _secure_filename(file.filename or "file")
|
||||
uid = uuid.uuid4().hex
|
||||
stored_name = f"{uid}_{original_name}"
|
||||
dest = FILES_DIR / stored_name
|
||||
|
||||
logger.info(
|
||||
"STORAGE: Uploading file original_name=%s stored_name=%s from %s",
|
||||
original_name,
|
||||
stored_name,
|
||||
request.client.host if request.client else "unknown",
|
||||
)
|
||||
|
||||
size = await _stream_save(file, dest)
|
||||
|
||||
logger.info(
|
||||
"STORAGE: File upload successful, size=%d bytes, path=%s",
|
||||
size,
|
||||
stored_name,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"filename": stored_name,
|
||||
"original_name": original_name,
|
||||
"size": int(size),
|
||||
"path": f"/files/{stored_name}",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("STORAGE: Failed to save upload: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Failed to store file")
|
||||
|
||||
|
||||
async def upload_base64_internal(
|
||||
filename: str,
|
||||
data_b64: str,
|
||||
content_type: str = "application/octet-stream",
|
||||
allowed_user_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Internal implementation for base64 upload. Used by both HTTP route and in-process calls."""
|
||||
allowed_user_ids = allowed_user_ids or []
|
||||
try:
|
||||
if not data_b64:
|
||||
raise HTTPException(status_code=400, detail="data_b64 is required")
|
||||
|
||||
_ensure_dirs()
|
||||
|
||||
file_data = base64.b64decode(data_b64)
|
||||
original_name = _secure_filename(filename or "file")
|
||||
uid = uuid.uuid4().hex
|
||||
stored_name = f"{uid}_{original_name}"
|
||||
dest = FILES_DIR / stored_name
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info(
|
||||
"STORAGE: Uploading base64 file original_name=%s stored_name=%s size=%d bytes",
|
||||
original_name,
|
||||
stored_name,
|
||||
len(file_data),
|
||||
)
|
||||
|
||||
# Write file data
|
||||
with open(dest, "wb") as f:
|
||||
f.write(file_data)
|
||||
|
||||
# Apply secure permissions (no execute, owner read/write only)
|
||||
dest.chmod(0o600)
|
||||
|
||||
# Store permission information
|
||||
_store_file_permissions(stored_name, allowed_user_ids)
|
||||
|
||||
logger.info(
|
||||
"STORAGE: Base64 file upload successful, size=%d bytes, path=%s, allowed_users=%s",
|
||||
len(file_data),
|
||||
stored_name,
|
||||
allowed_user_ids,
|
||||
)
|
||||
|
||||
return {
|
||||
"file_id": stored_name,
|
||||
"filename": original_name,
|
||||
"size": len(file_data),
|
||||
"path": f"/uploads/files/encrypted/{stored_name}",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("STORAGE: Base64 file upload failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"File upload failed: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/upload-base64", response_model=None)
|
||||
async def upload_base64_file(request: Request):
|
||||
"""
|
||||
Upload a base64-encoded file to secure storage.
|
||||
Expects JSON payload: {"filename": str, "data_b64": str, "content_type": str?, "allowed_user_ids": [int]}
|
||||
"""
|
||||
payload = await request.json()
|
||||
return await upload_base64_internal(
|
||||
filename=payload.get("filename", "file"),
|
||||
data_b64=payload.get("data_b64", ""),
|
||||
content_type=payload.get("content_type", "application/octet-stream"),
|
||||
allowed_user_ids=payload.get("allowed_user_ids", []),
|
||||
)
|
||||
|
||||
|
||||
async def init_resumable_upload_internal(
|
||||
filename: str,
|
||||
total_size: int,
|
||||
allowed_user_ids: list[int],
|
||||
chunk_size: int | None = None,
|
||||
) -> dict:
|
||||
"""Internal implementation for in-process calls."""
|
||||
chunk_size = chunk_size if chunk_size and chunk_size > 0 else 262_144
|
||||
if total_size <= 0:
|
||||
raise HTTPException(status_code=400, detail="total_size must be > 0")
|
||||
if total_size > MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=400, detail="File exceeds maximum allowed size")
|
||||
if not allowed_user_ids:
|
||||
raise HTTPException(status_code=400, detail="allowed_user_ids is required")
|
||||
|
||||
_ensure_dirs()
|
||||
|
||||
upload_id = uuid.uuid4().hex
|
||||
meta = {
|
||||
"upload_id": upload_id,
|
||||
"filename": _secure_filename(filename),
|
||||
"total_size": total_size,
|
||||
"offset": 0,
|
||||
"complete": False,
|
||||
"chunk_size": chunk_size,
|
||||
"allowed_user_ids": allowed_user_ids,
|
||||
"created_at": time.time(),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
_write_resumable_meta(upload_id, meta)
|
||||
_resumable_data_path(upload_id).write_bytes(b"")
|
||||
|
||||
logger.info(
|
||||
"STORAGE: Resumable init upload_id=%s filename=%s size=%s allowed=%s",
|
||||
upload_id,
|
||||
meta["filename"],
|
||||
total_size,
|
||||
allowed_user_ids,
|
||||
)
|
||||
|
||||
return {
|
||||
"upload_id": upload_id,
|
||||
"chunk_size": chunk_size,
|
||||
"offset": 0,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/uploads/resumable/init", response_model=None)
|
||||
async def init_resumable_upload(request: Request):
|
||||
"""
|
||||
Initialize a resumable upload session.
|
||||
Expects JSON payload:
|
||||
{
|
||||
"filename": str,
|
||||
"total_size": int,
|
||||
"allowed_user_ids": [int],
|
||||
"chunk_size": int?
|
||||
}
|
||||
"""
|
||||
payload = await request.json()
|
||||
filename = payload.get("filename", "file")
|
||||
total_size = int(payload.get("total_size", 0))
|
||||
allowed_user_ids = [int(x) for x in payload.get("allowed_user_ids", [])]
|
||||
requested_chunk_size = int(payload.get("chunk_size") or 0)
|
||||
chunk_size = requested_chunk_size if requested_chunk_size > 0 else None
|
||||
return await init_resumable_upload_internal(
|
||||
filename=filename,
|
||||
total_size=total_size,
|
||||
allowed_user_ids=allowed_user_ids,
|
||||
chunk_size=chunk_size,
|
||||
)
|
||||
|
||||
|
||||
async def get_resumable_upload_status_internal(upload_id: str, user_id: int) -> dict:
|
||||
"""Internal implementation for in-process calls."""
|
||||
meta = _read_resumable_meta(upload_id)
|
||||
_assert_resumable_access(meta, user_id)
|
||||
return {
|
||||
"upload_id": upload_id,
|
||||
"filename": meta["filename"],
|
||||
"total_size": int(meta["total_size"]),
|
||||
"offset": int(meta["offset"]),
|
||||
"complete": bool(meta["complete"]),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/uploads/resumable/{upload_id}", response_model=None)
|
||||
async def get_resumable_upload_status(upload_id: str, request: Request):
|
||||
user_id_header = request.headers.get("X-User-ID")
|
||||
if not user_id_header:
|
||||
raise HTTPException(status_code=401, detail="Missing user authentication")
|
||||
return await get_resumable_upload_status_internal(upload_id, int(user_id_header))
|
||||
|
||||
|
||||
async def upload_resumable_chunk_internal(
|
||||
upload_id: str, user_id: int, offset: int, data_b64: str
|
||||
) -> dict:
|
||||
"""Internal implementation for in-process calls."""
|
||||
meta = _read_resumable_meta(upload_id)
|
||||
_assert_resumable_access(meta, user_id)
|
||||
if meta.get("complete"):
|
||||
raise HTTPException(status_code=409, detail="Upload already completed")
|
||||
if offset < 0:
|
||||
raise HTTPException(status_code=400, detail="offset must be >= 0")
|
||||
if not data_b64:
|
||||
raise HTTPException(status_code=400, detail="data_b64 is required")
|
||||
expected_offset = int(meta.get("offset", 0))
|
||||
if offset != expected_offset:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Offset mismatch. expected={expected_offset} got={offset}",
|
||||
)
|
||||
chunk = base64.b64decode(data_b64)
|
||||
new_offset = expected_offset + len(chunk)
|
||||
if new_offset > int(meta["total_size"]):
|
||||
raise HTTPException(status_code=400, detail="Chunk exceeds total_size")
|
||||
data_path = _resumable_data_path(upload_id)
|
||||
with open(data_path, "ab") as f:
|
||||
f.write(chunk)
|
||||
meta["offset"] = new_offset
|
||||
meta["updated_at"] = time.time()
|
||||
_write_resumable_meta(upload_id, meta)
|
||||
return {"offset_received": new_offset}
|
||||
|
||||
|
||||
@app.patch("/uploads/resumable/{upload_id}", response_model=None)
|
||||
async def upload_resumable_chunk(upload_id: str, request: Request):
|
||||
"""
|
||||
Upload one chunk for a resumable session.
|
||||
Expects JSON body:
|
||||
{
|
||||
"offset": int,
|
||||
"data_b64": str
|
||||
}
|
||||
"""
|
||||
user_id_header = request.headers.get("X-User-ID")
|
||||
if not user_id_header:
|
||||
raise HTTPException(status_code=401, detail="Missing user authentication")
|
||||
payload = await request.json()
|
||||
offset = int(payload.get("offset", -1))
|
||||
data_b64 = payload.get("data_b64")
|
||||
return await upload_resumable_chunk_internal(
|
||||
upload_id, int(user_id_header), offset, data_b64
|
||||
)
|
||||
|
||||
|
||||
async def complete_resumable_upload_internal(upload_id: str, user_id: int) -> dict:
|
||||
"""Internal implementation for in-process calls."""
|
||||
meta = _read_resumable_meta(upload_id)
|
||||
_assert_resumable_access(meta, user_id)
|
||||
if int(meta.get("offset", 0)) != int(meta.get("total_size", 0)):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Upload incomplete. offset={meta.get('offset')} total={meta.get('total_size')}",
|
||||
)
|
||||
meta["complete"] = True
|
||||
meta["updated_at"] = time.time()
|
||||
_write_resumable_meta(upload_id, meta)
|
||||
return {"file_id": upload_id, "upload_id": upload_id}
|
||||
|
||||
|
||||
@app.post("/uploads/resumable/{upload_id}/complete", response_model=None)
|
||||
async def complete_resumable_upload(upload_id: str, request: Request):
|
||||
user_id_header = request.headers.get("X-User-ID")
|
||||
if not user_id_header:
|
||||
raise HTTPException(status_code=401, detail="Missing user authentication")
|
||||
return await complete_resumable_upload_internal(upload_id, int(user_id_header))
|
||||
|
||||
|
||||
async def get_resumable_upload_data_internal(upload_id: str, user_id: int) -> dict:
|
||||
"""Internal implementation for in-process calls."""
|
||||
meta = _read_resumable_meta(upload_id)
|
||||
_assert_resumable_access(meta, user_id)
|
||||
if not meta.get("complete"):
|
||||
raise HTTPException(status_code=409, detail="Upload not completed")
|
||||
data_path = _resumable_data_path(upload_id)
|
||||
if not data_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Upload payload not found")
|
||||
payload = data_path.read_bytes()
|
||||
return {
|
||||
"upload_id": upload_id,
|
||||
"filename": meta["filename"],
|
||||
"file_size": len(payload),
|
||||
"encrypted_file_data_b64": base64.b64encode(payload).decode("ascii"),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/uploads/resumable/{upload_id}/data-b64", response_model=None)
|
||||
async def get_resumable_upload_data(upload_id: str, request: Request):
|
||||
"""
|
||||
Retrieve completed resumable upload as base64-encoded ciphertext.
|
||||
"""
|
||||
user_id_header = request.headers.get("X-User-ID")
|
||||
if not user_id_header:
|
||||
raise HTTPException(status_code=401, detail="Missing user authentication")
|
||||
return await get_resumable_upload_data_internal(upload_id, int(user_id_header))
|
||||
|
||||
|
||||
async def delete_resumable_upload_internal(upload_id: str, user_id: int) -> dict:
|
||||
"""Internal implementation for in-process calls."""
|
||||
meta = _read_resumable_meta(upload_id)
|
||||
_assert_resumable_access(meta, user_id)
|
||||
try:
|
||||
_resumable_meta_path(upload_id).unlink(missing_ok=True)
|
||||
_resumable_data_path(upload_id).unlink(missing_ok=True)
|
||||
except Exception as e:
|
||||
logger.warning("STORAGE: Failed cleaning resumable session %s: %s", upload_id, e)
|
||||
return {"status": "deleted", "upload_id": upload_id}
|
||||
|
||||
|
||||
@app.delete("/uploads/resumable/{upload_id}", response_model=None)
|
||||
async def delete_resumable_upload(upload_id: str, request: Request):
|
||||
user_id_header = request.headers.get("X-User-ID")
|
||||
if not user_id_header:
|
||||
raise HTTPException(status_code=401, detail="Missing user authentication")
|
||||
return await delete_resumable_upload_internal(upload_id, int(user_id_header))
|
||||
|
||||
|
||||
@app.get("/files/{filename}", response_model=None)
|
||||
async def get_file(filename: str, request: Request):
|
||||
"""
|
||||
Retrieve a stored file. Requires internal auth if configured.
|
||||
"""
|
||||
# Validate filename - must be simple token created by upload
|
||||
if not filename or "/" in filename or "\\" in filename:
|
||||
logger.warning(
|
||||
"STORAGE: Invalid filename requested: %s from %s",
|
||||
filename,
|
||||
request.client.host if request.client else "unknown",
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
|
||||
path = FILES_DIR / filename
|
||||
if not path.exists() or not path.is_file():
|
||||
logger.warning(
|
||||
"STORAGE: File not found: %s from %s",
|
||||
filename,
|
||||
request.client.host if request.client else "unknown",
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
logger.info(
|
||||
"STORAGE: File download: %s from %s",
|
||||
filename,
|
||||
request.client.host if request.client else "unknown",
|
||||
)
|
||||
|
||||
return FileResponse(str(path), media_type="application/octet-stream", filename=filename)
|
||||
|
||||
|
||||
# File serving routes (moved from main service)
|
||||
async def get_file_normal_internal(filename: str):
|
||||
"""Internal: serve normal (unencrypted) files. Used by proxy when in-process."""
|
||||
safe_name = Path(filename).name
|
||||
if filename != safe_name:
|
||||
raise HTTPException(status_code=400, detail="Invalid file name")
|
||||
path = FILES_NORMAL_DIR / safe_name
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
return FileResponse(str(path))
|
||||
|
||||
|
||||
@app.get("/uploads/files/normal/{filename}", response_model=None)
|
||||
async def get_file_normal(filename: str):
|
||||
"""Serve normal (unencrypted) files."""
|
||||
return await get_file_normal_internal(filename)
|
||||
|
||||
|
||||
async def get_file_encrypted_internal(filename: str, user_id: int):
|
||||
"""Internal: serve encrypted files with permission checking. Used by proxy when in-process."""
|
||||
safe_name = Path(filename).name
|
||||
if filename != safe_name:
|
||||
raise HTTPException(status_code=400, detail="Invalid file name")
|
||||
path = FILES_DIR / safe_name
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
if not _check_file_permissions(safe_name, user_id):
|
||||
if user_id != 1:
|
||||
raise HTTPException(403, "Access denied to this file")
|
||||
return FileResponse(str(path), media_type="application/octet-stream", filename=filename)
|
||||
|
||||
|
||||
@app.get("/uploads/files/encrypted/{filename}", response_model=None)
|
||||
async def get_file_encrypted(filename: str, request: Request):
|
||||
"""Serve encrypted files with permission checking."""
|
||||
user_id_header = request.headers.get("X-User-ID")
|
||||
if not user_id_header:
|
||||
raise HTTPException(status_code=401, detail="Missing user authentication")
|
||||
try:
|
||||
user_id = int(user_id_header)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Invalid user authentication")
|
||||
return await get_file_encrypted_internal(filename, user_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
port = int(os.getenv("PORT", "8302"))
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
@@ -0,0 +1 @@
|
||||
# Main service module
|
||||
@@ -0,0 +1,24 @@
|
||||
import os
|
||||
|
||||
# Database is always in backend/data/ relative to project root
|
||||
DATABASE_URL = "sqlite:///" + os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "data", "database.db")
|
||||
JWT_ALGORITHM = "HS256"
|
||||
# Token inactivity expiration - token expires if not used for this duration
|
||||
TOKEN_INACTIVITY_EXPIRE_HOURS = 30 * 24 # 30 days of inactivity
|
||||
# Maximum token lifetime (safety net) - tokens expire after this regardless of usage
|
||||
MAX_TOKEN_LIFETIME_HOURS = 365 * 24 # 1 year maximum
|
||||
OWNER_USERNAME = "denis0001-dev"
|
||||
JWT_SECRET_KEY = os.getenv("JWT_SECRET")
|
||||
|
||||
if not JWT_SECRET_KEY:
|
||||
raise ValueError("JWT secret key empty")
|
||||
JWT_ALGORITHM = "HS256"
|
||||
# Token inactivity expiration - token expires if not used for this duration
|
||||
TOKEN_INACTIVITY_EXPIRE_HOURS = 30 * 24 # 30 days of inactivity
|
||||
# Maximum token lifetime (safety net) - tokens expire after this regardless of usage
|
||||
MAX_TOKEN_LIFETIME_HOURS = 365 * 24 # 1 year maximum
|
||||
OWNER_USERNAME = "denis0001-dev"
|
||||
JWT_SECRET_KEY = os.getenv("JWT_SECRET")
|
||||
|
||||
if not JWT_SECRET_KEY:
|
||||
raise ValueError("JWT secret key empty")
|
||||
@@ -0,0 +1,154 @@
|
||||
import os
|
||||
from typing import Generator, Optional
|
||||
|
||||
import time
|
||||
import logging
|
||||
from sqlalchemy import create_engine, event, text
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from .constants import DATABASE_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
"""
|
||||
Universal database interface that provides identical behavior for PostgreSQL and SQLite.
|
||||
|
||||
Features:
|
||||
- Auto-creates parent directory for SQLite files.
|
||||
- Applies SQLite pragmas (foreign_keys=ON, journal_mode=WAL) for improved compatibility.
|
||||
- Uses StaticPool for in-memory or file-based SQLite when appropriate.
|
||||
- Exposes `engine`, `SessionLocal`, `get_db` dependency, and `POOL_CONFIG`.
|
||||
"""
|
||||
|
||||
# Ensure parent directory exists for SQLite file DBs
|
||||
def _ensure_sqlite_parent_dir(url: str) -> None:
|
||||
if not url or not url.startswith("sqlite"):
|
||||
return
|
||||
# strip sqlite:/// prefix
|
||||
path = url.replace("sqlite:///", "", 1)
|
||||
parent = os.path.dirname(path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
|
||||
|
||||
# Pool and engine configuration (tunable via env)
|
||||
POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "20"))
|
||||
MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "40"))
|
||||
POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
|
||||
POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "30"))
|
||||
|
||||
POOL_CONFIG = {
|
||||
"pool_size": POOL_SIZE,
|
||||
"max_overflow": MAX_OVERFLOW,
|
||||
"pool_recycle": POOL_RECYCLE,
|
||||
"pool_timeout": POOL_TIMEOUT,
|
||||
"pool_pre_ping": True,
|
||||
}
|
||||
|
||||
|
||||
def get_engine(database_url: Optional[str] = None) -> Engine:
|
||||
"""
|
||||
Create and return a SQLAlchemy Engine configured for the given database URL.
|
||||
This function ensures SQLite-specific pragmas and connection args are applied.
|
||||
Includes retry logic for database connection failures during startup.
|
||||
"""
|
||||
url = database_url or DATABASE_URL
|
||||
_ensure_sqlite_parent_dir(url)
|
||||
|
||||
# Retry database connection during startup (helps with Docker initialization timing)
|
||||
if url.startswith("postgresql"):
|
||||
max_retries = 15
|
||||
retry_delay = 2
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
logger.info(f"Attempting database connection (attempt {attempt + 1}/{max_retries})...")
|
||||
# Test the connection by creating engine and trying to connect
|
||||
test_engine = create_engine(url, pool_size=1, max_overflow=0, pool_timeout=5, future=True)
|
||||
with test_engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
test_engine.dispose()
|
||||
logger.info("Database connection successful")
|
||||
break
|
||||
except OperationalError as e:
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning(f"Database connection failed (attempt {attempt + 1}): {e}")
|
||||
time.sleep(retry_delay)
|
||||
else:
|
||||
logger.error(f"Database connection failed after {max_retries} attempts: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during database connection: {e}")
|
||||
raise
|
||||
|
||||
if url.startswith("sqlite"):
|
||||
# For SQLite file-based DBs, use standard pooling but set connection timeout and pragmas.
|
||||
# Use StaticPool only for in-memory SQLite.
|
||||
in_memory = url in ("sqlite:///:memory:", "sqlite://")
|
||||
connect_args = {"check_same_thread": False, "timeout": int(os.getenv("SQLITE_BUSY_TIMEOUT", "5"))}
|
||||
|
||||
if in_memory:
|
||||
engine = create_engine(url, connect_args=connect_args, poolclass=StaticPool, future=True)
|
||||
else:
|
||||
engine = create_engine(url, connect_args=connect_args, future=True)
|
||||
|
||||
# Apply pragmas on connect for SQLite (foreign keys, WAL, busy_timeout)
|
||||
@event.listens_for(engine, "connect")
|
||||
def _sqlite_on_connect(dbapi_conn, connection_record):
|
||||
try:
|
||||
cursor = dbapi_conn.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys = ON")
|
||||
cursor.execute("PRAGMA journal_mode = WAL")
|
||||
# busy_timeout in milliseconds
|
||||
busy_ms = int(os.getenv("SQLITE_BUSY_TIMEOUT_MS", "5000"))
|
||||
cursor.execute(f"PRAGMA busy_timeout = {busy_ms}")
|
||||
cursor.close()
|
||||
except Exception:
|
||||
# Best-effort; do not fail engine creation if pragmas cannot be set
|
||||
pass
|
||||
|
||||
return engine
|
||||
|
||||
# Default for Postgres / MySQL etc. - use pool sizing from env
|
||||
engine_kwargs = {
|
||||
"pool_size": POOL_SIZE,
|
||||
"max_overflow": MAX_OVERFLOW,
|
||||
"pool_recycle": POOL_RECYCLE,
|
||||
"pool_timeout": POOL_TIMEOUT,
|
||||
"future": True,
|
||||
}
|
||||
return create_engine(url, **engine_kwargs)
|
||||
|
||||
|
||||
# Create global engine and session factory for convenient imports
|
||||
engine = get_engine()
|
||||
# Keep loaded attributes available after commit/close to avoid DetachedInstanceError
|
||||
SessionLocal = sessionmaker(class_=Session, autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
|
||||
|
||||
|
||||
def init_db(create_tables: bool = False, base_metadata=None) -> None:
|
||||
"""
|
||||
Initialize the database. If `create_tables` is True and `base_metadata` is provided,
|
||||
create all tables using the provided SQLAlchemy metadata.
|
||||
"""
|
||||
if create_tables:
|
||||
if base_metadata is None:
|
||||
raise ValueError("base_metadata is required to create tables")
|
||||
base_metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""
|
||||
FastAPI dependency that yields a SQLAlchemy Session and ensures proper close().
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -2,9 +2,9 @@ from datetime import datetime, timedelta
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from utils import verify_token
|
||||
from models import User, DeviceSession
|
||||
from db import SessionLocal
|
||||
from .utils import verify_token
|
||||
from .models import User, DeviceSession
|
||||
from .db import SessionLocal
|
||||
import logging
|
||||
|
||||
security = HTTPBearer()
|
||||
@@ -80,7 +80,7 @@ def get_current_user(
|
||||
)
|
||||
|
||||
# Check if session has been inactive for too long (sliding expiration)
|
||||
from constants import TOKEN_INACTIVITY_EXPIRE_HOURS
|
||||
from .constants import TOKEN_INACTIVITY_EXPIRE_HOURS
|
||||
inactivity_threshold = datetime.now() - timedelta(hours=TOKEN_INACTIVITY_EXPIRE_HOURS)
|
||||
if device_session.last_seen < inactivity_threshold:
|
||||
# Session expired due to inactivity - revoke it
|
||||
@@ -6,20 +6,40 @@ from contextlib import asynccontextmanager
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
from routes import account, messaging, profile, push, webrtc, devices, moderation, download
|
||||
import logging
|
||||
from models import User
|
||||
from constants import OWNER_USERNAME
|
||||
from utils import get_client_ip
|
||||
|
||||
from db import POOL_CONFIG, SessionLocal
|
||||
from logging_config import access_logger # noqa: F401 - ensure loggers configured
|
||||
from security.audit import log_access
|
||||
from security.rate_limit import limiter
|
||||
# Import from same directory
|
||||
from .routes import account, messaging, profile, push, webrtc, devices, moderation, download, keys, envelope_messaging
|
||||
from .models import User
|
||||
from .constants import OWNER_USERNAME
|
||||
from .utils import get_client_ip
|
||||
from .db import POOL_CONFIG, SessionLocal
|
||||
from .logging_config import access_logger # noqa: F401 - ensure loggers configured
|
||||
from .security.audit import log_access
|
||||
from .security.rate_limit import limiter
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
def _running_in_docker() -> bool:
|
||||
"""
|
||||
Detect whether the process is running inside a Docker container.
|
||||
Uses presence of /.dockerenv or checking cgroup entries for docker/kubernetes.
|
||||
"""
|
||||
try:
|
||||
if os.path.exists("/.dockerenv"):
|
||||
return True
|
||||
# Check cgroup for docker/kubepods indicators
|
||||
cgroup_path = "/proc/1/cgroup"
|
||||
if os.path.exists(cgroup_path):
|
||||
with open(cgroup_path, "rt", encoding="utf-8") as f:
|
||||
data = f.read()
|
||||
if "docker" in data or "kubepods" in data or "containerd" in data:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -27,14 +47,25 @@ async def lifespan(app: FastAPI):
|
||||
try:
|
||||
logger.info("Starting database migration check...")
|
||||
# Run migration in a separate process
|
||||
subprocess.run(
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import sys; sys.path.append('.'); from migration import run_migrations; run_migrations()"
|
||||
],
|
||||
cwd=os.path.dirname(os.path.abspath(__file__))
|
||||
],
|
||||
cwd=os.path.dirname(os.path.abspath(__file__)),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Migration subprocess failed with code {result.returncode}")
|
||||
if result.stdout:
|
||||
logger.error(f"Migration stdout: {result.stdout}")
|
||||
if result.stderr:
|
||||
logger.error(f"Migration stderr: {result.stderr}")
|
||||
else:
|
||||
logger.info("Database migrations completed successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to run database migrations: {e}")
|
||||
raise
|
||||
@@ -52,7 +83,7 @@ async def lifespan(app: FastAPI):
|
||||
logger.warning(f"Owner user '{OWNER_USERNAME}' not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ensure owner verification: {e}")
|
||||
|
||||
|
||||
logger.info(
|
||||
"SQLAlchemy pool configured (size=%s, max_overflow=%s, timeout=%ss, recycle=%ss, pre_ping=%s)",
|
||||
POOL_CONFIG["pool_size"],
|
||||
@@ -64,33 +95,34 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Start the messaging cleanup task
|
||||
try:
|
||||
from routes.messaging import messagingManager
|
||||
# Use absolute import to avoid import errors when package context differs
|
||||
from services.main.routes.messaging import messagingManager
|
||||
messagingManager.start_cleanup_task()
|
||||
logger.info("Messaging cleanup task started")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start messaging cleanup task: {e}")
|
||||
|
||||
|
||||
# Reset all rate limits on startup to ensure clean state
|
||||
# This prevents rate limits from persisting across restarts
|
||||
try:
|
||||
from security.rate_limit import reset_all_rate_limits
|
||||
from .security.rate_limit import reset_all_rate_limits
|
||||
cleared = reset_all_rate_limits()
|
||||
if cleared > 0:
|
||||
logger.info(f"Cleared {cleared} rate limit entries on startup")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to reset rate limits on startup: {e}")
|
||||
|
||||
|
||||
# Start the rate limit cleanup task
|
||||
try:
|
||||
from security.rate_limit import start_rate_limit_cleanup_task
|
||||
from .security.rate_limit import start_rate_limit_cleanup_task
|
||||
cleanup_task = asyncio.create_task(start_rate_limit_cleanup_task())
|
||||
logger.info("Rate limit cleanup task started")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start rate limit cleanup task: {e}")
|
||||
cleanup_task = None
|
||||
|
||||
|
||||
yield
|
||||
|
||||
|
||||
# Shutdown - cancel cleanup task if it exists
|
||||
if cleanup_task:
|
||||
cleanup_task.cancel()
|
||||
@@ -99,13 +131,40 @@ async def lifespan(app: FastAPI):
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Инициализация FastAPI
|
||||
# Initialize FastAPI
|
||||
app = FastAPI(title="FromChat", lifespan=lifespan)
|
||||
|
||||
# Add rate limiting middleware
|
||||
app.state.limiter = limiter
|
||||
app.add_middleware(SlowAPIMiddleware)
|
||||
|
||||
# In development (not running inside Docker), mount messaging and file_storage apps directly
|
||||
if not _running_in_docker():
|
||||
try:
|
||||
# Import sub-apps from the services package and mount them to the main app
|
||||
# Try absolute import first, fall back to relative import
|
||||
try:
|
||||
from backend.services.messaging import main as messaging_service_module
|
||||
from backend.services.file_storage import main as file_storage_service_module
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
# Fall back to relative imports when backend is not in path
|
||||
import sys
|
||||
import os
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
services_dir = os.path.dirname(current_dir)
|
||||
backend_dir = os.path.dirname(services_dir)
|
||||
sys.path.insert(0, backend_dir)
|
||||
from services.messaging import main as messaging_service_module
|
||||
from services.file_storage import main as file_storage_service_module
|
||||
|
||||
# Mount as sub-applications so their routes are available in-process for development
|
||||
app.mount("/internal/messaging", messaging_service_module.app)
|
||||
app.mount("/internal/file_storage", file_storage_service_module.app)
|
||||
logger.info("Mounted messaging and file_storage services in development mode")
|
||||
except Exception as e:
|
||||
# If mounting fails, continue without blocking startup; log for debugging
|
||||
logger.warning(f"Failed to mount internal services for development: {e}")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def access_logging_middleware(request: Request, call_next):
|
||||
@@ -151,6 +210,18 @@ async def access_logging_middleware(request: Request, call_next):
|
||||
return response
|
||||
|
||||
|
||||
# Add security middleware (request size limiting and audit logging)
|
||||
try:
|
||||
from services.shared.middleware import add_security_middleware
|
||||
except ImportError:
|
||||
try:
|
||||
from backend.services.shared.middleware import add_security_middleware
|
||||
except ImportError:
|
||||
add_security_middleware = None
|
||||
|
||||
if add_security_middleware:
|
||||
add_security_middleware(app)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -170,10 +241,29 @@ app.add_middleware(
|
||||
|
||||
# Routes
|
||||
app.include_router(account.router)
|
||||
app.include_router(envelope_messaging.router)
|
||||
app.include_router(messaging.router)
|
||||
app.include_router(profile.router)
|
||||
app.include_router(push.router, prefix="/push")
|
||||
app.include_router(webrtc.router, prefix="/webrtc")
|
||||
app.include_router(devices.router, prefix="/devices")
|
||||
app.include_router(moderation.router)
|
||||
app.include_router(download.router)
|
||||
app.include_router(download.router)
|
||||
app.include_router(keys.router)
|
||||
|
||||
|
||||
@app.get("/key/public")
|
||||
async def key_public_proxy():
|
||||
"""
|
||||
Proxy endpoint for messaging public key. In dev this calls the in-process function,
|
||||
in production it will proxy to the external messaging service via the keys helper.
|
||||
"""
|
||||
return await keys.get_public_key()
|
||||
|
||||
|
||||
@app.post("/key/invalidate")
|
||||
async def key_invalidate_proxy():
|
||||
"""
|
||||
Proxy endpoint to invalidate messaging ephemeral key.
|
||||
"""
|
||||
return await keys.invalidate_key()
|
||||
@@ -3,12 +3,68 @@ Database migration utility using Alembic.
|
||||
This module handles running database migrations on startup.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine
|
||||
from constants import DATABASE_URL
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
import importlib.util
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
constants_path = os.path.join(current_dir, "constants.py")
|
||||
spec = importlib.util.spec_from_file_location("services_main_constants", constants_path)
|
||||
constants_mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(constants_mod)
|
||||
DATABASE_URL = getattr(constants_mod, "DATABASE_URL")
|
||||
# Backend root (two levels up from this file): backend/
|
||||
backend_root = os.path.dirname(os.path.dirname(current_dir))
|
||||
|
||||
|
||||
def _create_engine_with_retry(database_url: str = None, max_retries: int = 10, retry_delay: float = 2.0):
|
||||
"""Create a database engine with retry logic for connection failures during startup."""
|
||||
url = database_url or DATABASE_URL
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
engine = create_engine(url)
|
||||
# Test the connection
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
return engine
|
||||
except (OperationalError, Exception) as e:
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning(f"Database connection failed (attempt {attempt + 1}/{max_retries}): {e}")
|
||||
time.sleep(retry_delay)
|
||||
else:
|
||||
logger.error(f"Database connection failed after {max_retries} attempts: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def _load_module_by_filename(filename: str, module_name: str):
|
||||
"""Load a module from a file path relative to this migration.py"""
|
||||
path = os.path.join(current_dir, filename)
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _load_models_base():
|
||||
"""Return the SQLAlchemy Base from models.py regardless of import context"""
|
||||
mod = _load_module_by_filename("models.py", "services_main_models")
|
||||
return getattr(mod, "Base")
|
||||
|
||||
|
||||
def _ensure_sqlite_directory():
|
||||
"""Ensure parent directory for SQLite DB exists when using sqlite:/// URLs."""
|
||||
if not DATABASE_URL or not DATABASE_URL.startswith("sqlite"):
|
||||
return
|
||||
# strip sqlite:/// prefix
|
||||
db_path = DATABASE_URL.replace("sqlite:///", "", 1)
|
||||
parent = os.path.dirname(db_path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -20,8 +76,11 @@ def run_migrations():
|
||||
Fully automated - handles all scenarios automatically.
|
||||
"""
|
||||
try:
|
||||
# FIRST: Check if database has any application tables (excluding alembic_version)
|
||||
engine = create_engine(DATABASE_URL)
|
||||
# FIRST: Ensure SQLite directory exists before creating engine
|
||||
_ensure_sqlite_directory()
|
||||
|
||||
# Check if database has any application tables (excluding alembic_version)
|
||||
engine = _create_engine_with_retry()
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import inspect
|
||||
inspector = inspect(connection)
|
||||
@@ -31,24 +90,32 @@ def run_migrations():
|
||||
# If no application tables exist, create them directly from models
|
||||
if not existing_tables:
|
||||
logger.info("No application tables found. Creating all tables directly from models...")
|
||||
from models import Base
|
||||
Base = _load_models_base()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("All tables created successfully from models.")
|
||||
|
||||
# Get the directory where this script is located
|
||||
# Get the directory where this script is located and backend root
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
backend_root = os.path.dirname(os.path.dirname(current_dir))
|
||||
|
||||
# Create Alembic configuration
|
||||
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
|
||||
# Create Alembic configuration (alembic files are stored at backend/alembic)
|
||||
alembic_cfg = Config(os.path.join(backend_root, "alembic.ini"))
|
||||
|
||||
# Disable Alembic's logging configuration to avoid interfering with FastAPI
|
||||
alembic_cfg.set_main_option("configure_logging", "false")
|
||||
|
||||
# Set the database URL in the config
|
||||
# Set the database URL in the config (use absolute path)
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
# Ensure script_location is set (some alembic.ini files may omit it when running in subprocess)
|
||||
try:
|
||||
script_location = alembic_cfg.get_main_option("script_location")
|
||||
except Exception:
|
||||
script_location = None
|
||||
if not script_location:
|
||||
alembic_cfg.set_main_option("script_location", os.path.join(backend_root, "alembic"))
|
||||
|
||||
# Check if any migration files exist
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
# Check if any migration files exist (use backend/alembic/versions)
|
||||
versions_dir = os.path.join(backend_root, "alembic", "versions")
|
||||
|
||||
if not os.path.exists(versions_dir):
|
||||
os.makedirs(versions_dir)
|
||||
@@ -58,7 +125,7 @@ def run_migrations():
|
||||
if not migration_files:
|
||||
logger.info("No migration files found. Creating initial migration...")
|
||||
# Check if database exists and has tables
|
||||
engine = create_engine(DATABASE_URL)
|
||||
engine = _create_engine_with_retry()
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text
|
||||
result = connection.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name != 'alembic_version'"))
|
||||
@@ -70,7 +137,7 @@ def run_migrations():
|
||||
command.revision(alembic_cfg, autogenerate=True, message="Initial migration from existing database")
|
||||
|
||||
# Check if the generated migration is empty (common with existing databases)
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
versions_dir = os.path.join(backend_root, "alembic", "versions")
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
if migration_files:
|
||||
latest_migration = max(migration_files)
|
||||
@@ -141,15 +208,14 @@ def run_migrations():
|
||||
if "Can't locate revision identified by 'direct_creation'" in str(upgrade_error):
|
||||
logger.info("Found 'direct_creation' revision - resetting migration state...")
|
||||
# Clear the alembic_version table and start fresh
|
||||
engine = create_engine(DATABASE_URL)
|
||||
engine = _create_engine_with_retry()
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text
|
||||
connection.execute(text("DELETE FROM alembic_version"))
|
||||
connection.commit()
|
||||
|
||||
# Set the correct revision in alembic_version table
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
versions_dir = os.path.join(backend_root, "alembic", "versions")
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
|
||||
if migration_files:
|
||||
@@ -174,7 +240,7 @@ def run_migrations():
|
||||
elif "no such table" in str(upgrade_error).lower():
|
||||
logger.info("Database tables missing - resetting migration state...")
|
||||
# Clear the alembic_version table and start fresh
|
||||
engine = create_engine(DATABASE_URL)
|
||||
engine = _create_engine_with_retry()
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text
|
||||
connection.execute(text("DELETE FROM alembic_version"))
|
||||
@@ -192,14 +258,14 @@ def run_migrations():
|
||||
logger.info("Attempting automated recovery...")
|
||||
try:
|
||||
# Clear the alembic_version table to reset state
|
||||
engine = create_engine(DATABASE_URL)
|
||||
engine = _create_engine_with_retry()
|
||||
with engine.connect() as connection:
|
||||
from sqlalchemy import text
|
||||
connection.execute(text("DROP TABLE IF EXISTS alembic_version"))
|
||||
connection.commit()
|
||||
|
||||
# Check if we have existing migration files
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
versions_dir = os.path.join(backend_root, "alembic", "versions")
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
|
||||
if migration_files:
|
||||
@@ -289,7 +355,7 @@ def _populate_migration_file(migration_path):
|
||||
|
||||
def _generate_migration_from_models():
|
||||
"""Generate migration content dynamically from SQLAlchemy models."""
|
||||
from models import Base
|
||||
from .models import Base
|
||||
import sqlalchemy as sa
|
||||
from datetime import datetime
|
||||
|
||||
@@ -474,10 +540,12 @@ def _get_column_type(column):
|
||||
|
||||
def _create_database_directly():
|
||||
"""Fallback method: create database directly using SQLAlchemy."""
|
||||
from models import Base
|
||||
from db import engine
|
||||
# Load Base and engine in a robust way (work when run as script or package)
|
||||
_ensure_sqlite_directory()
|
||||
Base = _load_models_base()
|
||||
from sqlalchemy import text, inspect
|
||||
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
# Check existing tables and update schema
|
||||
with engine.connect() as connection:
|
||||
inspector = inspect(connection)
|
||||
@@ -542,8 +610,7 @@ def _create_database_directly():
|
||||
"""))
|
||||
|
||||
# Get the correct revision ID from existing migration files
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
versions_dir = os.path.join(backend_root, "alembic", "versions")
|
||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||
|
||||
if migration_files:
|
||||
@@ -589,7 +656,7 @@ def check_migration_status():
|
||||
Returns True if migrations are needed, False otherwise.
|
||||
"""
|
||||
try:
|
||||
# Create engine
|
||||
# Get engine for checking migration status
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
# Check if alembic_version table exists
|
||||
@@ -608,9 +675,8 @@ def check_migration_status():
|
||||
context = MigrationContext.configure(connection)
|
||||
current_rev = context.get_current_revision()
|
||||
|
||||
# Get the latest revision from alembic
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
|
||||
# Get the latest revision from alembic (use backend/alembic)
|
||||
alembic_cfg = Config(os.path.join(backend_root, "alembic.ini"))
|
||||
script_dir = command.ScriptDirectory.from_config(alembic_cfg)
|
||||
head_rev = script_dir.get_current_head()
|
||||
|
||||
@@ -79,11 +79,13 @@ class DMEnvelope(Base):
|
||||
recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
iv_b64 = Column(Text, nullable=False)
|
||||
ciphertext_b64 = Column(Text, nullable=False)
|
||||
salt_b64 = Column(Text, nullable=False)
|
||||
iv2_b64 = Column(Text, nullable=False)
|
||||
wrapped_mk_b64 = Column(Text, nullable=False)
|
||||
sender_wrapped_mek_b64 = Column(Text, nullable=False)
|
||||
recipient_wrapped_mek_b64 = Column(Text, nullable=False)
|
||||
compliance_wrapped_mek_b64 = Column(Text, nullable=True)
|
||||
reply_to_id = Column(Integer, nullable=True)
|
||||
timestamp = Column(DateTime, default=datetime.now)
|
||||
is_edited = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
|
||||
reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select")
|
||||
|
||||
@@ -97,6 +99,7 @@ class DMFile(Base):
|
||||
recipient_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
name = Column(Text, nullable=False)
|
||||
path = Column(Text, nullable=False)
|
||||
nonce_b64 = Column(Text, nullable=True) # Nonce for this file's decryption
|
||||
|
||||
message = relationship("DMEnvelope", back_populates="files")
|
||||
|
||||
@@ -219,6 +222,36 @@ class DeleteMessageRequest(BaseModel):
|
||||
message_id: int
|
||||
|
||||
|
||||
class MessageEditHistoryResponse(BaseModel):
|
||||
"""Response model for message edit history (compliance access only)."""
|
||||
id: int
|
||||
message_id: int
|
||||
previous_content: str
|
||||
edited_at: datetime
|
||||
edited_by_username: str
|
||||
edited_by_user_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DMEditHistoryResponse(BaseModel):
|
||||
"""Response model for DM edit history (compliance access only)."""
|
||||
id: int
|
||||
dm_envelope_id: int
|
||||
previous_ciphertext_b64: str
|
||||
previous_iv_b64: str
|
||||
previous_sender_wrapped_mek_b64: str
|
||||
previous_recipient_wrapped_mek_b64: str
|
||||
previous_compliance_wrapped_mek_b64: str
|
||||
edited_at: str
|
||||
edited_by_username: str
|
||||
edited_by_user_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UpdateBioRequest(BaseModel):
|
||||
bio: str
|
||||
|
||||
@@ -308,5 +341,49 @@ class UpdateLog(Base):
|
||||
)
|
||||
|
||||
|
||||
class MessageEditHistory(Base):
|
||||
"""Stores complete edit history for public messages in compliance storage only.
|
||||
|
||||
This table maintains the full history of all edits made to public messages.
|
||||
Regular users never see this data - they only see the latest version with
|
||||
an edit indicator. Compliance officers can access the full history.
|
||||
"""
|
||||
__tablename__ = "message_edit_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True)
|
||||
previous_content = Column(Text, nullable=False) # Content before this edit
|
||||
edited_at = Column(DateTime, default=datetime.now, nullable=False, index=True)
|
||||
edited_by_user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
||||
|
||||
# Relationships
|
||||
message = relationship("Message")
|
||||
|
||||
|
||||
class DMEditHistory(Base):
|
||||
"""Stores complete edit history for DM messages in compliance storage only.
|
||||
|
||||
This table maintains the full history of all edits made to DM messages.
|
||||
Regular users never see this data - they only see the latest version with
|
||||
an edit indicator. Compliance officers can access the full history.
|
||||
"""
|
||||
__tablename__ = "dm_edit_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False, index=True)
|
||||
dm_envelope_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False) # Match existing DB schema
|
||||
previous_ciphertext_b64 = Column(Text, nullable=False) # Encrypted content before this edit
|
||||
previous_iv_b64 = Column(Text, nullable=False) # IV for previous content
|
||||
previous_sender_wrapped_mek_b64 = Column(Text, nullable=False) # MEK wrapped for sender before edit
|
||||
previous_recipient_wrapped_mek_b64 = Column(Text, nullable=False) # MEK wrapped for recipient before edit
|
||||
previous_compliance_wrapped_mek_b64 = Column(Text, nullable=False) # MEK wrapped for compliance before edit
|
||||
edited_at = Column(DateTime, default=datetime.now, nullable=False, index=True)
|
||||
edited_by = Column(Integer, ForeignKey("user.id"), nullable=False) # Match existing DB schema
|
||||
edited_by_user_id = Column(Integer, ForeignKey("user.id"), nullable=False) # Match existing DB schema
|
||||
|
||||
# Relationships
|
||||
dm_envelope = relationship("DMEnvelope", foreign_keys=[message_id])
|
||||
|
||||
|
||||
# Tables are now created through Alembic migrations
|
||||
# Base.metadata.create_all(bind=engine)
|
||||
@@ -4,8 +4,7 @@ import os
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from pywebpush import webpush, WebPushException
|
||||
from models import PushSubscription, User, Message, DMEnvelope
|
||||
from models import FcmToken
|
||||
from .models import PushSubscription, User, Message, DMEnvelope, FcmToken
|
||||
import firebase_admin
|
||||
from firebase_admin import credentials as firebase_credentials
|
||||
from firebase_admin import messaging as firebase_messaging
|
||||
@@ -8,16 +8,16 @@ import uuid
|
||||
from user_agents import parse as parse_ua
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from constants import OWNER_USERNAME
|
||||
from dependencies import get_current_user, get_db
|
||||
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession
|
||||
from utils import create_token, get_password_hash, verify_password, get_client_ip
|
||||
from validation import is_valid_password, is_valid_username, is_valid_display_name
|
||||
from ..constants import OWNER_USERNAME
|
||||
from ..dependencies import get_current_user, get_db
|
||||
from ..models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession
|
||||
from ..utils import create_token, get_password_hash, verify_password, get_client_ip
|
||||
from ..validation import is_valid_password, is_valid_username, is_valid_display_name
|
||||
import os
|
||||
|
||||
from security.audit import log_security
|
||||
from security.profanity import contains_profanity
|
||||
from security.rate_limit import rate_limit_per_ip
|
||||
from ..security.audit import log_security
|
||||
from ..security.profanity import contains_profanity
|
||||
from ..security.rate_limit import rate_limit_per_ip
|
||||
router = APIRouter()
|
||||
|
||||
_FAILED_ATTEMPT_WINDOW_SECONDS = 300
|
||||
@@ -74,8 +74,11 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
|
||||
username = login_request.username.strip()
|
||||
client_ip = get_client_ip(request)
|
||||
raw_ua = request.headers.get("user-agent")
|
||||
import logging
|
||||
logging.getLogger("uvicorn.error").info("Login attempt start for username=%s ip=%s", username, client_ip)
|
||||
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
logging.getLogger("uvicorn.error").info("Queried user from DB for username=%s -> %s", username, "FOUND" if user else "NOT FOUND")
|
||||
|
||||
if not user or not verify_password(login_request.password.strip(), user.password_hash):
|
||||
log_security(
|
||||
@@ -139,6 +142,7 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
|
||||
user.online = True
|
||||
user.last_seen = datetime.now()
|
||||
db.commit()
|
||||
logging.getLogger("uvicorn.error").info("Login DB commit complete for user_id=%s", user.id)
|
||||
|
||||
token = create_token(user.id, user.username, session_id)
|
||||
|
||||
@@ -2,9 +2,9 @@ from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dependencies import get_current_user, get_db
|
||||
from models import User, DeviceSession
|
||||
from utils import verify_token
|
||||
from ..dependencies import get_current_user, get_db
|
||||
from ..models import User, DeviceSession
|
||||
from ..utils import verify_token
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
router = APIRouter()
|
||||
@@ -0,0 +1,906 @@
|
||||
"""
|
||||
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
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
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
|
||||
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,
|
||||
)
|
||||
from .messaging import messagingManager, convert_dm_envelope
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
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
|
||||
reply_to_id: Optional[int] = None
|
||||
files: list[FileModel] = Field(default_factory=list, alias="transport_files")
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Key Management Endpoint
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/key/transport/public")
|
||||
async def get_transport_public_key_endpoint():
|
||||
"""
|
||||
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"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
return await get_messaging_transport_public_key()
|
||||
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.get("/key/compliance/public")
|
||||
async def get_compliance_public_key_endpoint():
|
||||
"""
|
||||
Get the compliance system public key (for MEK wrapping).
|
||||
|
||||
This key is generated offline on an air-gapped machine and used to wrap MEKs
|
||||
so the compliance system can decrypt archived messages for audit.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"public_key_b64": "base64-encoded-key"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
return await get_compliance_public_key()
|
||||
except Exception as e:
|
||||
logger.error("Failed to fetch compliance public key: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to fetch compliance key"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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")
|
||||
if not compliance_public_key_b64:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve compliance key"
|
||||
)
|
||||
|
||||
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": f.encrypted_file_data_b64} for f in request.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(request.files):
|
||||
raise HTTPException(status_code=500, detail="File processing count mismatch")
|
||||
|
||||
for i, tf in enumerate(request.files):
|
||||
fr = file_results[i]
|
||||
file_storage_result = await store_encrypted_file(
|
||||
encrypted_file_data_b64=fr["ciphertext"],
|
||||
filename=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(tf.filename).name,
|
||||
nonce_b64=fr["nonce"],
|
||||
)
|
||||
db.add(df)
|
||||
db.commit()
|
||||
|
||||
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(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(db, dm_envelope, dm_envelope.sender_id)
|
||||
await messagingManager.send_update_to_user(dm_envelope.sender_id, "dmNew", sender_payload, db)
|
||||
|
||||
return {
|
||||
"id": dm_envelope.id,
|
||||
"sender_id": dm_envelope.sender_id,
|
||||
"recipient_id": dm_envelope.recipient_id,
|
||||
"timestamp": dm_envelope.timestamp.isoformat(),
|
||||
"reply_to_id": dm_envelope.reply_to_id,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
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_sender_wrapped_mek_b64": edit_entry.previous_sender_wrapped_mek_b64,
|
||||
"previous_recipient_wrapped_mek_b64": edit_entry.previous_recipient_wrapped_mek_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 compliance_decryption.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),
|
||||
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
|
||||
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)
|
||||
)
|
||||
)
|
||||
.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 messages
|
||||
messages = (
|
||||
db.query(DMEnvelope)
|
||||
.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_sender_wrapped_mek_b64": entry.previous_sender_wrapped_mek_b64,
|
||||
"previous_recipient_wrapped_mek_b64": entry.previous_recipient_wrapped_mek_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).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")
|
||||
if 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
|
||||
)
|
||||
|
||||
# Store edit history in compliance storage before updating
|
||||
edit_history = DMEditHistory(
|
||||
message_id=msg.id,
|
||||
dm_envelope_id=msg.id, # Match existing DB schema
|
||||
previous_ciphertext_b64=msg.ciphertext_b64,
|
||||
previous_iv_b64=msg.iv_b64,
|
||||
previous_sender_wrapped_mek_b64=msg.sender_wrapped_mek_b64,
|
||||
previous_recipient_wrapped_mek_b64=msg.recipient_wrapped_mek_b64,
|
||||
previous_compliance_wrapped_mek_b64=msg.compliance_wrapped_mek_b64 or "",
|
||||
edited_by=current_user.id,
|
||||
edited_by_user_id=current_user.id # Match existing DB schema
|
||||
)
|
||||
db.add(edit_history)
|
||||
|
||||
# Update the message with new processed content
|
||||
processed_msg = processed["message"]
|
||||
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)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
db.delete(msg)
|
||||
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"
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import Dict, Any
|
||||
import os
|
||||
import logging
|
||||
import base64
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
|
||||
def _get_messaging_module():
|
||||
"""Try to import in-process messaging module; return None if unavailable."""
|
||||
try:
|
||||
from backend.services.messaging import main as messaging_module
|
||||
return messaging_module
|
||||
except Exception:
|
||||
try:
|
||||
# Fallback to package import when running with CWD=backend
|
||||
from services.messaging import main as messaging_module # type: ignore
|
||||
return messaging_module
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/key/public")
|
||||
async def get_public_key():
|
||||
"""
|
||||
Return the current messaging service ephemeral public key.
|
||||
If messaging service is in-process, call its function directly; otherwise, perform HTTP request to configured service URL.
|
||||
"""
|
||||
messaging_module = _get_messaging_module()
|
||||
if messaging_module:
|
||||
try:
|
||||
data = await messaging_module.get_public_key() # type: ignore
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get public key from in-process messaging module: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve messaging public key")
|
||||
|
||||
# Out-of-process: call messaging service over HTTP
|
||||
messaging_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging:8301")
|
||||
url = f"{messaging_url.rstrip('/')}/key/public"
|
||||
try:
|
||||
# Prefer httpx if available
|
||||
try:
|
||||
import httpx
|
||||
resp = httpx.get(url, timeout=5.0)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception:
|
||||
# Fallback to urllib
|
||||
from urllib import request, error
|
||||
import json
|
||||
with request.urlopen(url, timeout=5) as r:
|
||||
body = r.read()
|
||||
return json.loads(body)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch messaging public key via HTTP: {e}")
|
||||
raise HTTPException(status_code=502, detail="Failed to contact messaging service")
|
||||
|
||||
|
||||
@router.post("/key/invalidate")
|
||||
async def invalidate_key():
|
||||
"""
|
||||
Request messaging service to invalidate its current ephemeral key (rotate).
|
||||
"""
|
||||
messaging_module = _get_messaging_module()
|
||||
if messaging_module:
|
||||
try:
|
||||
data = await messaging_module.invalidate_key() # type: ignore
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to invalidate key in in-process messaging module: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to invalidate messaging key")
|
||||
|
||||
messaging_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging:8301")
|
||||
url = f"{messaging_url.rstrip('/')}/key/invalidate"
|
||||
try:
|
||||
try:
|
||||
import httpx
|
||||
resp = httpx.post(url, timeout=5.0)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception:
|
||||
from urllib import request, error
|
||||
import json
|
||||
req = request.Request(url, method="POST")
|
||||
with request.urlopen(req, timeout=5) as r:
|
||||
body = r.read()
|
||||
return json.loads(body)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to call messaging invalidate endpoint via HTTP: {e}")
|
||||
raise HTTPException(status_code=502, detail="Failed to contact messaging service")
|
||||
|
||||
@@ -12,26 +12,28 @@ from collections import defaultdict, deque
|
||||
from difflib import SequenceMatcher
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form, Request
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form, Request, status
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from dependencies import get_current_user, get_db
|
||||
from ..dependencies import get_current_user, get_db
|
||||
from .account import convert_user
|
||||
from constants import OWNER_USERNAME
|
||||
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog
|
||||
from push_service import push_service
|
||||
from ..constants import OWNER_USERNAME
|
||||
from ..models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog, MessageEditHistory, MessageEditHistoryResponse
|
||||
from ..push_service import push_service
|
||||
from PIL import Image
|
||||
import io
|
||||
import json
|
||||
from pydantic import BaseModel
|
||||
from better_profanity import profanity as _bp
|
||||
from security.audit import log_access, log_dm, log_public_chat, log_security
|
||||
from security.profanity import contains_profanity
|
||||
from security.rate_limit import rate_limit_per_ip
|
||||
from websocket.utils import authenticate_user
|
||||
from ..security.audit import log_access, log_dm, log_public_chat, log_security
|
||||
from ..security.profanity import contains_profanity
|
||||
from ..security.rate_limit import rate_limit_per_ip
|
||||
from ..websocket.utils import authenticate_user
|
||||
|
||||
from models import FcmToken
|
||||
from ..models import FcmToken
|
||||
from .. import service_calls
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
@@ -45,6 +47,15 @@ FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
|
||||
os.makedirs(FILES_NORMAL_DIR, exist_ok=True)
|
||||
os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def _get_file_storage_url() -> str:
|
||||
return (
|
||||
os.getenv("FILE_STORAGE_SERVICE_URL")
|
||||
or os.getenv("FILE_STORAGE_URL")
|
||||
or "http://127.0.0.1:8302"
|
||||
)
|
||||
|
||||
|
||||
_SPAM_WINDOW_SECONDS = 45
|
||||
_SPAM_SIMILARITY_THRESHOLD = 0.88
|
||||
_SPAM_MESSAGE_LIMIT = 5
|
||||
@@ -234,7 +245,7 @@ def convert_message(msg: Message) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def convert_dm_envelope(db: Session, envelope: DMEnvelope) -> dict:
|
||||
def convert_dm_envelope(db: Session, envelope: DMEnvelope, user_id: int | None = None) -> dict:
|
||||
# Group reactions by emoji
|
||||
reactions_dict = {}
|
||||
if envelope.reactions:
|
||||
@@ -261,28 +272,47 @@ def convert_dm_envelope(db: Session, envelope: DMEnvelope) -> dict:
|
||||
else:
|
||||
sender_verified = sender.verified if sender else False
|
||||
|
||||
return {
|
||||
# Return only the MEK wrapped with the requesting user's key
|
||||
if user_id == envelope.sender_id:
|
||||
wrapped_mek_b64 = envelope.sender_wrapped_mek_b64
|
||||
elif user_id == envelope.recipient_id:
|
||||
wrapped_mek_b64 = envelope.recipient_wrapped_mek_b64
|
||||
elif user_id == 1:
|
||||
# Compliance user (ID 1) gets compliance MEK
|
||||
wrapped_mek_b64 = envelope.compliance_wrapped_mek_b64
|
||||
else:
|
||||
# User is not authorized to view this message
|
||||
wrapped_mek_b64 = None
|
||||
|
||||
result = {
|
||||
"id": envelope.id,
|
||||
"senderId": envelope.sender_id,
|
||||
"recipientId": envelope.recipient_id,
|
||||
"iv": envelope.iv_b64,
|
||||
"ciphertext": envelope.ciphertext_b64,
|
||||
"salt": envelope.salt_b64,
|
||||
"iv2": envelope.iv2_b64,
|
||||
"wrappedMk": envelope.wrapped_mk_b64,
|
||||
"iv_b64": envelope.iv_b64,
|
||||
"ciphertext_b64": envelope.ciphertext_b64,
|
||||
"wrapped_mek_b64": wrapped_mek_b64,
|
||||
"timestamp": envelope.timestamp.isoformat(),
|
||||
"verified": sender_verified,
|
||||
"reactions": list(reactions_dict.values()),
|
||||
"files": [
|
||||
"files": []
|
||||
}
|
||||
|
||||
for f in (envelope.files or []):
|
||||
safe_path = f"/api/uploads/files/encrypted/{Path(f.path).name}"
|
||||
# Files use the same MEK as the message envelope
|
||||
selected_file_wrapped = wrapped_mek_b64
|
||||
result["files"].append(
|
||||
{
|
||||
"path": f"/api/uploads/files/encrypted/{Path(f.path).name}",
|
||||
"path": safe_path,
|
||||
"id": f.id,
|
||||
"name": f.name,
|
||||
"dm_envelope_id": f.dm_envelope_id
|
||||
"dm_envelope_id": f.message_id,
|
||||
"wrapped_mek_b64": selected_file_wrapped,
|
||||
"nonce_b64": getattr(f, "nonce_b64", None),
|
||||
}
|
||||
for f in (envelope.files or [])
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def _send_message_internal(
|
||||
@@ -303,14 +333,14 @@ async def _send_message_internal(
|
||||
|
||||
raw_content = message_request.content.strip()
|
||||
|
||||
if not raw_content:
|
||||
if not raw_content and not files:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No content provided"
|
||||
)
|
||||
|
||||
# Check for profanity and reject the message instead of censoring
|
||||
if contains_profanity(raw_content):
|
||||
if raw_content and contains_profanity(raw_content):
|
||||
raise HTTPException(
|
||||
status_code=422, # Unprocessable Entity - content validation failed
|
||||
detail="Message contains inappropriate content and cannot be sent"
|
||||
@@ -598,7 +628,7 @@ async def mark_messages_read(request: Request, read_request: MarkReadRequest, cu
|
||||
return {"status": "success", "updated": int(updated_count)}
|
||||
|
||||
|
||||
@router.post("/dm/send")
|
||||
@router.post("/dm/send-legacy")
|
||||
@rate_limit_per_ip("20/minute")
|
||||
async def dm_send(
|
||||
request: Request,
|
||||
@@ -645,9 +675,8 @@ async def dm_send(
|
||||
recipient_id=recipient_id,
|
||||
iv_b64=payload["iv"],
|
||||
ciphertext_b64=payload["ciphertext"],
|
||||
salt_b64=payload["salt"],
|
||||
iv2_b64=payload["iv2"],
|
||||
wrapped_mk_b64=payload["wrappedMk"],
|
||||
sender_wrapped_mek_b64=payload.get("wrappedMk", ""),
|
||||
recipient_wrapped_mek_b64=payload.get("wrappedMk", ""),
|
||||
reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None,
|
||||
)
|
||||
db.add(env)
|
||||
@@ -710,27 +739,12 @@ async def dm_send(
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send push notification for DM {env.id}: {e}")
|
||||
|
||||
# Realtime notify both users for HTTP requests
|
||||
try:
|
||||
payload_ws = {
|
||||
"type": "dmNew",
|
||||
"data": {
|
||||
"id": env.id,
|
||||
"senderId": env.sender_id,
|
||||
"recipientId": env.recipient_id,
|
||||
"iv": env.iv_b64,
|
||||
"ciphertext": env.ciphertext_b64,
|
||||
"salt": env.salt_b64,
|
||||
"iv2": env.iv2_b64,
|
||||
"wrappedMk": env.wrapped_mk_b64,
|
||||
"timestamp": env.timestamp.isoformat(),
|
||||
"replyToId": env.reply_to_id,
|
||||
}
|
||||
}
|
||||
await messagingManager.send_to_user(env.recipient_id, payload_ws)
|
||||
await messagingManager.send_to_user(env.sender_id, payload_ws)
|
||||
except Exception:
|
||||
pass
|
||||
# Send user-specific WebSocket updates (each user gets only their MEK)
|
||||
recipient_payload = convert_dm_envelope(db, env, env.recipient_id)
|
||||
await messagingManager.send_update_to_user(env.recipient_id, "dmNew", recipient_payload, db)
|
||||
|
||||
sender_payload = convert_dm_envelope(db, env, env.sender_id)
|
||||
await messagingManager.send_update_to_user(env.sender_id, "dmNew", sender_payload, db)
|
||||
|
||||
log_dm(
|
||||
"message_sent",
|
||||
@@ -744,33 +758,19 @@ async def dm_send(
|
||||
|
||||
return {"status": "ok", "id": env.id}
|
||||
|
||||
def convert_envelopes(envs: list[DMEnvelope]):
|
||||
return {
|
||||
"status": "ok",
|
||||
"messages": [
|
||||
{
|
||||
"id": e.id,
|
||||
"senderId": e.sender_id,
|
||||
"recipientId": e.recipient_id,
|
||||
"iv": e.iv_b64,
|
||||
"ciphertext": e.ciphertext_b64,
|
||||
"salt": e.salt_b64,
|
||||
"iv2": e.iv2_b64,
|
||||
"wrappedMk": e.wrapped_mk_b64,
|
||||
"timestamp": e.timestamp.isoformat(),
|
||||
"files": [{"name": file.name, "path": file.path, "id": file.id} for file in e.files]
|
||||
}
|
||||
for e in envs
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/dm/fetch")
|
||||
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
|
||||
async def dm_fetch(request: Request, since: int | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
q = db.query(DMEnvelope).filter(DMEnvelope.recipient_id == current_user.id)
|
||||
envelopes = db.query(DMEnvelope).filter(DMEnvelope.recipient_id == current_user.id)
|
||||
if since:
|
||||
q = q.filter(DMEnvelope.id > since)
|
||||
return convert_envelopes(q.order_by(DMEnvelope.id.asc()).all())
|
||||
envelopes = envelopes.filter(DMEnvelope.id > since)
|
||||
envelopes = envelopes.order_by(DMEnvelope.id.asc()).all()
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"messages": [convert_dm_envelope(db, envelope, current_user.id) for envelope in envelopes]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dm/history/{other_user_id}")
|
||||
@@ -787,15 +787,15 @@ async def dm_history(request: Request, other_user_id: int, current_user: User =
|
||||
if not other_user or other_user.deleted or other_user.suspended:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return convert_envelopes(
|
||||
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))
|
||||
)
|
||||
.order_by(DMEnvelope.id.asc())
|
||||
.all()
|
||||
)
|
||||
envelopes = 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))
|
||||
).order_by(DMEnvelope.id.asc()).all()
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"messages": [convert_dm_envelope(db, envelope, current_user.id) for envelope in envelopes]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dm/conversations")
|
||||
@@ -828,7 +828,7 @@ async def get_dm_conversations(request: Request, current_user: User = Depends(ge
|
||||
|
||||
result.append({
|
||||
"user": convert_user(other_user),
|
||||
"lastMessage": convert_dm_envelope(db, latest_message),
|
||||
"lastMessage": convert_dm_envelope(db, latest_message, current_user.id),
|
||||
"unreadCount": unread_count
|
||||
})
|
||||
|
||||
@@ -863,19 +863,27 @@ async def _edit_message_internal(
|
||||
raise HTTPException(status_code=400, detail="Message content cannot be empty")
|
||||
|
||||
original_content = message.content
|
||||
|
||||
|
||||
# Check for profanity and reject the edit instead of censoring
|
||||
if contains_profanity(raw_content):
|
||||
raise HTTPException(
|
||||
status_code=422, # Unprocessable Entity - content validation failed
|
||||
detail="Message contains inappropriate content and cannot be sent"
|
||||
)
|
||||
|
||||
|
||||
escaped_content = html.escape(raw_content, quote=False)
|
||||
|
||||
|
||||
if len(escaped_content) > 4096:
|
||||
raise HTTPException(status_code=400, detail="Message too long")
|
||||
|
||||
# Store edit history in compliance storage before updating the message
|
||||
edit_history = MessageEditHistory(
|
||||
message_id=message.id,
|
||||
previous_content=original_content,
|
||||
edited_by_user_id=current_user.id
|
||||
)
|
||||
db.add(edit_history)
|
||||
|
||||
message.content = escaped_content
|
||||
message.is_edited = True
|
||||
|
||||
@@ -1054,7 +1062,7 @@ async def add_dm_reaction(
|
||||
# Refresh envelope to get updated reactions
|
||||
db.refresh(envelope)
|
||||
|
||||
envelope_data = convert_dm_envelope(db, envelope)
|
||||
envelope_data = convert_dm_envelope(db, envelope, current_user.id)
|
||||
|
||||
# Broadcast reaction update to both participants
|
||||
try:
|
||||
@@ -1105,7 +1113,8 @@ class MessaggingSocketManager:
|
||||
self._sequence_lock: dict[int, asyncio.Lock] = {} # user_id -> lock for sequence generation
|
||||
|
||||
async def send_error(self, websocket: WebSocket, type: str, e: HTTPException):
|
||||
await websocket.send_json({"type": type, "error": {"code": e.status_code, "detail": e.detail}})
|
||||
if websocket.client_state.name == "CONNECTED":
|
||||
await websocket.send_json({"type": type, "error": {"code": e.status_code, "detail": e.detail}})
|
||||
|
||||
async def _get_next_sequence(self, user_id: int) -> int:
|
||||
"""Get the next sequence number for a user (shared across all their connections) - thread-safe"""
|
||||
@@ -1243,21 +1252,26 @@ class MessaggingSocketManager:
|
||||
pass # Ignore rollback errors
|
||||
|
||||
# If we get a UNIQUE constraint error, it means another connection already stored this sequence
|
||||
# This is expected behavior when multiple connections exist for the same user
|
||||
if "UNIQUE constraint" in str(e) or "IntegrityError" in str(e.__class__.__name__):
|
||||
# Mark as stored to prevent future attempts
|
||||
self.stored_sequences[sequence_key] = True
|
||||
logger.debug(f"Update sequence {seq} for user {user_id} already stored by another connection")
|
||||
logger.debug(f"Update sequence {seq} for user {user_id} already stored by another connection (expected)")
|
||||
else:
|
||||
logger.error(f"Failed to store updates in database: {e}")
|
||||
logger.warning(f"Unexpected error storing updates in database: {e}")
|
||||
else:
|
||||
# Already stored, skip
|
||||
logger.debug(f"Update sequence {seq} for user {user_id} already marked as stored")
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "updates",
|
||||
"seq": seq,
|
||||
"updates": updates
|
||||
})
|
||||
# Only send if WebSocket is still connected
|
||||
if websocket.client_state.name == "CONNECTED":
|
||||
await websocket.send_json({
|
||||
"type": "updates",
|
||||
"seq": seq,
|
||||
"updates": updates
|
||||
})
|
||||
else:
|
||||
logger.debug(f"WebSocket already closed, skipping update send for sequence {seq}")
|
||||
|
||||
async def _schedule_batch_flush(self, websocket: WebSocket, db: Session | None = None):
|
||||
"""Schedule a batch flush after a delay (50-100ms)"""
|
||||
@@ -1282,7 +1296,7 @@ class MessaggingSocketManager:
|
||||
self.ws_subscriptions[websocket] = set()
|
||||
|
||||
# Import here to avoid circular import
|
||||
from websocket.handlers import handler_registry
|
||||
from ..websocket.handlers import handler_registry
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -1307,7 +1321,7 @@ class MessaggingSocketManager:
|
||||
handler_data = data.get("data", {})
|
||||
result = await handler(self, websocket, db, user, handler_data)
|
||||
# If handler returns a value, send it as a WebSocket message
|
||||
if result is not None:
|
||||
if result is not None and websocket.client_state.name == "CONNECTED":
|
||||
await websocket.send_json({"type": message_type, "data": result})
|
||||
except HTTPException as e:
|
||||
await self.send_error(websocket, message_type, e)
|
||||
@@ -1317,7 +1331,8 @@ class MessaggingSocketManager:
|
||||
logger.error(f"Error in handler for {message_type}: {e}")
|
||||
await self.send_error(websocket, message_type, HTTPException(500, "Internal server error"))
|
||||
else:
|
||||
await websocket.send_json({"type": message_type, "error": {"code": 400, "detail": "Invalid type"}})
|
||||
if websocket.client_state.name == "CONNECTED":
|
||||
await websocket.send_json({"type": message_type, "error": {"code": 400, "detail": "Invalid type"}})
|
||||
|
||||
async def disconnect(self, websocket: WebSocket, code: int = 1000, message: str | None = None):
|
||||
try:
|
||||
@@ -1415,7 +1430,7 @@ class MessaggingSocketManager:
|
||||
async def send_to_user(self, user_id: int, message: dict):
|
||||
"""Send a direct WebSocket message to a specific user (not batched)"""
|
||||
for websocket in self.connections:
|
||||
if self.user_by_ws.get(websocket) == user_id:
|
||||
if self.user_by_ws.get(websocket) == user_id and websocket.client_state.name == "CONNECTED":
|
||||
await websocket.send_json(message)
|
||||
|
||||
async def send_suspension_to_user(self, user_id: int, reason: str):
|
||||
@@ -1510,7 +1525,7 @@ class MessaggingSocketManager:
|
||||
def start_cleanup_task(self):
|
||||
"""Start the cleanup task if not already running"""
|
||||
if self._cleanup_task is None or self._cleanup_task.done():
|
||||
from db import SessionLocal
|
||||
from ..db import SessionLocal
|
||||
async def cleanup_with_db():
|
||||
while True:
|
||||
try:
|
||||
@@ -1531,33 +1546,231 @@ async def chat_websocket(
|
||||
await messagingManager.connect(websocket, db)
|
||||
|
||||
|
||||
# File serving endpoints
|
||||
@router.get("/uploads/files/normal/{filename}")
|
||||
async def get_file_normal(filename: str):
|
||||
if not re.match(r"^[A-Za-z0-9._-]+$", filename):
|
||||
raise HTTPException(status_code=400, detail="Invalid file name")
|
||||
path = FILES_NORMAL_DIR / filename
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
return FileResponse(str(path))
|
||||
# File serving proxy endpoints
|
||||
# Proxy file requests to file_storage service
|
||||
|
||||
|
||||
@router.get("/uploads/files/encrypted/{filename}")
|
||||
async def get_file_encrypted(filename: str, current_user: User = Depends(get_current_user)):
|
||||
if not re.match(r"^[A-Za-z0-9._-]+$", filename):
|
||||
raise HTTPException(status_code=400, detail="Invalid file name")
|
||||
path = FILES_ENCRYPTED_DIR / filename
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
@router.api_route("/uploads/files/normal/{filename:path}", methods=["GET"])
|
||||
async def proxy_normal_file(
|
||||
request: Request,
|
||||
filename: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Proxy file requests to file_storage service."""
|
||||
mod = service_calls._get_file_storage_module()
|
||||
if mod:
|
||||
try:
|
||||
return await mod.get_file_normal_internal(filename)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("In-process file_storage.get_file_normal failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail="File service unavailable")
|
||||
|
||||
match = re.match(r"^(\d+)_(\d+)_(\d+)_.*$", path.resolve().name)
|
||||
if match:
|
||||
sender_id = int(match.group(1))
|
||||
recipient_id = int(match.group(2))
|
||||
file_storage_url = _get_file_storage_url()
|
||||
target_url = f"{file_storage_url}/uploads/files/normal/{filename}"
|
||||
headers = {k: v for k, v in request.headers.items() if k.lower() != "host"}
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(target_url, headers=headers)
|
||||
from fastapi.responses import Response
|
||||
|
||||
if not current_user.id in [sender_id, recipient_id]:
|
||||
raise HTTPException(403)
|
||||
else:
|
||||
raise HTTPException(500)
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type=response.headers.get("content-type"),
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error("Failed to proxy file request: %s", e)
|
||||
raise HTTPException(status_code=500, detail="File service unavailable")
|
||||
|
||||
return FileResponse(str(path))
|
||||
|
||||
@router.get("/test-proxy")
|
||||
async def test_proxy():
|
||||
"""Test proxy connectivity to file_storage service."""
|
||||
file_storage_url = _get_file_storage_url()
|
||||
target_url = f"{file_storage_url}/health"
|
||||
|
||||
logger.info("Testing proxy to: %s", target_url)
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
try:
|
||||
response = await client.get(target_url, follow_redirects=False)
|
||||
logger.info("Test proxy response: %s", response.status_code)
|
||||
return {"status": "ok", "response_code": response.status_code}
|
||||
except Exception as e:
|
||||
logger.error("Test proxy failed: %s", e)
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
@router.api_route("/uploads/files/encrypted/{filename:path}", methods=["GET"])
|
||||
async def proxy_encrypted_file(
|
||||
request: Request,
|
||||
filename: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Proxy file requests to file_storage service."""
|
||||
mod = service_calls._get_file_storage_module()
|
||||
if mod:
|
||||
try:
|
||||
return await mod.get_file_encrypted_internal(filename, current_user.id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("In-process file_storage.get_file_encrypted failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail="File service unavailable")
|
||||
|
||||
file_storage_url = _get_file_storage_url()
|
||||
target_url = f"{file_storage_url}/uploads/files/encrypted/{filename}"
|
||||
headers = {k: v for k, v in request.headers.items() if k.lower() != "host"}
|
||||
headers["X-User-ID"] = str(current_user.id)
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
response = await client.get(target_url, headers=headers, follow_redirects=False)
|
||||
from fastapi.responses import Response
|
||||
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type=response.headers.get("content-type"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to proxy file request: %s", e)
|
||||
raise HTTPException(status_code=500, detail="File service unavailable")
|
||||
|
||||
|
||||
@router.get("/compliance/edit-history/message/{message_id}")
|
||||
async def get_message_edit_history_for_compliance(
|
||||
request: Request,
|
||||
message_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get complete edit history for a public message (compliance access only).
|
||||
|
||||
RESTRICTED: Only accessible by user ID 1 (compliance officer).
|
||||
This endpoint returns the full edit history for a public message,
|
||||
including all previous content versions.
|
||||
|
||||
Args:
|
||||
message_id: ID of the public 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(
|
||||
"message_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(
|
||||
"message_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(Message).filter(Message.id == message_id).first()
|
||||
if not message:
|
||||
log_security(
|
||||
"message_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(MessageEditHistory)
|
||||
.filter(MessageEditHistory.message_id == message_id)
|
||||
.order_by(MessageEditHistory.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_user_id).first()
|
||||
history_entries.append(
|
||||
{
|
||||
"id": entry.id,
|
||||
"message_id": entry.message_id,
|
||||
"previous_content": entry.previous_content,
|
||||
"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_user_id,
|
||||
}
|
||||
)
|
||||
|
||||
# Current message data
|
||||
current_data = {
|
||||
"id": message.id,
|
||||
"content": message.content,
|
||||
"user_id": message.user_id,
|
||||
"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(
|
||||
"message_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 message edit history: %s", e)
|
||||
log_security(
|
||||
"message_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",
|
||||
)
|
||||
@@ -2,12 +2,12 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List
|
||||
|
||||
from constants import OWNER_USERNAME
|
||||
from dependencies import get_current_user
|
||||
from models import User
|
||||
from security.audit import log_security
|
||||
from security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
|
||||
from security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits
|
||||
from ..constants import OWNER_USERNAME
|
||||
from ..dependencies import get_current_user
|
||||
from ..models import User
|
||||
from ..security.audit import log_security
|
||||
from ..security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
|
||||
from ..security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits
|
||||
|
||||
|
||||
class BlocklistUpdateRequest(BaseModel):
|
||||
@@ -9,15 +9,15 @@ import uuid
|
||||
import io
|
||||
from fastapi import Request
|
||||
|
||||
from dependencies import get_db, get_current_user
|
||||
from models import User, UpdateBioRequest, UserProfileResponse
|
||||
from ..dependencies import get_db, get_current_user
|
||||
from ..models import User, UpdateBioRequest, UserProfileResponse
|
||||
from pydantic import BaseModel
|
||||
from validation import is_valid_username, is_valid_display_name
|
||||
from similarity import is_user_similar_to_verified
|
||||
from ..validation import is_valid_username, is_valid_display_name
|
||||
from ..similarity import is_user_similar_to_verified
|
||||
from .messaging import messagingManager
|
||||
from security.audit import log_security
|
||||
from security.profanity import contains_profanity
|
||||
from security.rate_limit import rate_limit_per_ip
|
||||
from ..security.audit import log_security
|
||||
from ..security.profanity import contains_profanity
|
||||
from ..security.rate_limit import rate_limit_per_ip
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -117,22 +117,31 @@ async def get_user_profile(
|
||||
"""
|
||||
Get current user's profile information
|
||||
"""
|
||||
_ensure_owner_unsuspended(current_user, db)
|
||||
try:
|
||||
_ensure_owner_unsuspended(current_user, db)
|
||||
|
||||
return UserProfileResponse(
|
||||
id=current_user.id,
|
||||
username=current_user.username,
|
||||
display_name=current_user.display_name,
|
||||
profile_picture=current_user.profile_picture,
|
||||
bio=current_user.bio,
|
||||
online=current_user.online,
|
||||
last_seen=current_user.last_seen,
|
||||
created_at=current_user.created_at,
|
||||
verified=current_user.verified,
|
||||
suspended=current_user.suspended or False,
|
||||
suspension_reason=current_user.suspension_reason,
|
||||
deleted=(current_user.deleted or current_user.suspended) or False, # Treat suspended as deleted
|
||||
)
|
||||
return UserProfileResponse(
|
||||
id=current_user.id,
|
||||
username=current_user.username,
|
||||
display_name=current_user.display_name,
|
||||
profile_picture=current_user.profile_picture,
|
||||
bio=current_user.bio,
|
||||
online=current_user.online,
|
||||
last_seen=current_user.last_seen,
|
||||
created_at=current_user.created_at,
|
||||
verified=current_user.verified,
|
||||
suspended=current_user.suspended or False,
|
||||
suspension_reason=current_user.suspension_reason,
|
||||
deleted=(current_user.deleted or current_user.suspended) or False, # Treat suspended as deleted
|
||||
)
|
||||
except Exception as e:
|
||||
# Log and return a consistent HTTP 500 error with minimal details
|
||||
try:
|
||||
import logging
|
||||
logging.getLogger("uvicorn.error").exception("Error in get_user_profile: %s", e)
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/user/list")
|
||||
@@ -1,8 +1,8 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from dependencies import get_current_user, get_db
|
||||
from models import User, PushSubscriptionRequest
|
||||
from push_service import push_service
|
||||
from ..dependencies import get_current_user, get_db
|
||||
from ..models import User, PushSubscriptionRequest
|
||||
from ..push_service import push_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -4,7 +4,7 @@ import hmac
|
||||
import hashlib
|
||||
import time
|
||||
from fastapi import APIRouter, Depends
|
||||
from dependencies import get_current_user
|
||||
from ..dependencies import get_current_user
|
||||
import traceback
|
||||
|
||||
router = APIRouter()
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
from html import unescape
|
||||
from typing import Any, Callable, Dict, List
|
||||
|
||||
from logging_config import access_logger, dm_logger, public_chat_logger, security_logger
|
||||
from ..logging_config import access_logger, dm_logger, public_chat_logger, security_logger
|
||||
|
||||
|
||||
def _clean_username(username: Any) -> str:
|
||||
@@ -8,7 +8,7 @@ from fastapi import Request
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from utils import get_client_ip
|
||||
from ..utils import get_client_ip
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
"""
|
||||
Helper functions for inter-service communication used by the main service.
|
||||
|
||||
Behavior:
|
||||
- In development (single-process) the helpers call the in-process service modules directly.
|
||||
- In Docker/production the helpers perform HTTP calls to the configured service URLs.
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
|
||||
# Import request models for in-process calls
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
|
||||
def _get_messaging_module():
|
||||
try:
|
||||
from backend.services.messaging import main as messaging_module
|
||||
return messaging_module
|
||||
except Exception:
|
||||
try:
|
||||
from services.messaging import main as messaging_module # type: ignore
|
||||
return messaging_module
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_file_storage_module():
|
||||
try:
|
||||
from backend.services.file_storage import main as storage_module
|
||||
return storage_module
|
||||
except Exception:
|
||||
try:
|
||||
from services.file_storage import main as storage_module # type: ignore
|
||||
return storage_module
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def get_messaging_transport_public_key(timeout: float = 5.0) -> Dict[str, Any]:
|
||||
"""
|
||||
Return messaging service ephemeral transport public key.
|
||||
"""
|
||||
mod = _get_messaging_module()
|
||||
if mod:
|
||||
# in-process async call
|
||||
try:
|
||||
return await mod.get_transport_public_key() # type: ignore
|
||||
except Exception as e:
|
||||
logger.error("In-process messaging.get_transport_public_key failed: %s", e)
|
||||
raise
|
||||
|
||||
# Out-of-process HTTP
|
||||
messaging_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging:8301")
|
||||
url = f"{messaging_url.rstrip('/')}/key/transport/public"
|
||||
try:
|
||||
try:
|
||||
import httpx
|
||||
r = httpx.get(url, timeout=timeout)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception:
|
||||
from urllib import request
|
||||
with request.urlopen(url, timeout=timeout) as r:
|
||||
return json.loads(r.read())
|
||||
except Exception as e:
|
||||
logger.error("Failed to fetch messaging transport public key: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
async def get_compliance_public_key(timeout: float = 5.0) -> Dict[str, Any]:
|
||||
"""
|
||||
Return compliance system public key (for MEK wrapping).
|
||||
"""
|
||||
mod = _get_messaging_module()
|
||||
if mod:
|
||||
# in-process async call
|
||||
try:
|
||||
key = mod.get_compliance_public_key()
|
||||
return {"public_key_b64": key}
|
||||
except Exception as e:
|
||||
logger.error("In-process messaging.get_compliance_public_key failed: %s", e)
|
||||
raise
|
||||
|
||||
# Out-of-process: Compliance key should be configured via environment variable
|
||||
# The compliance public key is not exposed via HTTP for security reasons
|
||||
compliance_key = os.getenv("COMPLIANCE_PUBLIC_KEY", "").strip()
|
||||
if compliance_key:
|
||||
return {"public_key_b64": compliance_key}
|
||||
|
||||
logger.error("COMPLIANCE_PUBLIC_KEY environment variable not set and messaging service not available in-process")
|
||||
raise RuntimeError("Compliance public key not available - set COMPLIANCE_PUBLIC_KEY environment variable")
|
||||
|
||||
|
||||
async def invalidate_messaging_key(timeout: float = 5.0) -> Dict[str, Any]:
|
||||
"""
|
||||
Request messaging service to invalidate its current ephemeral transport key (rotate).
|
||||
"""
|
||||
mod = _get_messaging_module()
|
||||
if mod:
|
||||
try:
|
||||
return await mod.invalidate_transport_key() # type: ignore
|
||||
except Exception as e:
|
||||
logger.error("In-process messaging.invalidate_transport_key failed: %s", e)
|
||||
raise
|
||||
|
||||
messaging_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging:8301")
|
||||
url = f"{messaging_url.rstrip('/')}/key/transport/invalidate"
|
||||
try:
|
||||
try:
|
||||
import httpx
|
||||
r = httpx.post(url, timeout=timeout)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception:
|
||||
from urllib import request
|
||||
req = request.Request(url, method="POST")
|
||||
with request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read())
|
||||
except Exception as e:
|
||||
logger.error("Failed to invalidate messaging key: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
async def upload_file_to_storage(file_obj: Any, timeout: float = 30.0) -> Dict[str, Any]:
|
||||
"""
|
||||
Upload a file to file storage service. Returns JSON response.
|
||||
In-process: calls the in-process service.
|
||||
Out-of-process: performs HTTP call to configured service URL.
|
||||
"""
|
||||
mod = _get_file_storage_module()
|
||||
if mod:
|
||||
try:
|
||||
# Call the upload endpoint directly on the in-process module
|
||||
return await mod.upload_file(None, file_obj) # type: ignore
|
||||
except Exception as e:
|
||||
logger.error("In-process file_storage.upload_file failed: %s", e)
|
||||
raise
|
||||
|
||||
# Out-of-process HTTP
|
||||
# Prefer explicit FILE_STORAGE_URL, fall back to FILE_STORAGE_SERVICE_URL, default to localhost for dev
|
||||
storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or "http://127.0.0.1:8302"
|
||||
url = f"{storage_url.rstrip('/')}/upload"
|
||||
try:
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
r = await client.post(url, files={"file": file_obj})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception:
|
||||
from urllib import request
|
||||
# Synchronous fallback using urllib
|
||||
req = request.Request(url, method="POST")
|
||||
if hasattr(file_obj, "read"):
|
||||
data = file_obj.read()
|
||||
else:
|
||||
data = file_obj
|
||||
req.data = data
|
||||
req.add_header("Content-Type", "application/octet-stream")
|
||||
with request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read())
|
||||
except Exception as e:
|
||||
logger.error("Failed to upload file to storage: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
async def store_encrypted_file(
|
||||
encrypted_file_data_b64: str,
|
||||
filename: str,
|
||||
content_type: str = "application/octet-stream",
|
||||
timeout: float = 30.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Store an encrypted file (base64 encoded) in the file storage service.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"file_id": stored filename,
|
||||
"filename": original filename,
|
||||
"size": file size in bytes,
|
||||
"path": access path
|
||||
}
|
||||
"""
|
||||
mod = _get_file_storage_module()
|
||||
if mod:
|
||||
try:
|
||||
# In-process: call the upload-base64 endpoint directly
|
||||
return await mod.upload_base64_file(
|
||||
None, # request - not needed for in-process
|
||||
filename=filename,
|
||||
data_b64=encrypted_file_data_b64,
|
||||
content_type=content_type,
|
||||
) # type: ignore
|
||||
except Exception as e:
|
||||
logger.error("In-process file_storage.store_encrypted_file failed: %s", e)
|
||||
raise
|
||||
|
||||
# Out-of-process HTTP
|
||||
# Prefer explicit FILE_STORAGE_URL, fall back to FILE_STORAGE_SERVICE_URL, default to localhost for dev
|
||||
file_storage_url = os.getenv("FILE_STORAGE_URL") or os.getenv("FILE_STORAGE_SERVICE_URL") or "http://127.0.0.1:8302"
|
||||
url = f"{file_storage_url.rstrip('/')}/upload-base64"
|
||||
try:
|
||||
try:
|
||||
import httpx
|
||||
payload = {
|
||||
"filename": filename,
|
||||
"data_b64": encrypted_file_data_b64,
|
||||
"content_type": content_type,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
r = await client.post(url, json=payload)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception:
|
||||
from urllib import request
|
||||
payload = {
|
||||
"filename": filename,
|
||||
"data_b64": encrypted_file_data_b64,
|
||||
"content_type": content_type,
|
||||
}
|
||||
req = request.Request(url, method="POST")
|
||||
req.data = json.dumps(payload).encode("utf-8")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
with request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read())
|
||||
except Exception as e:
|
||||
logger.error("Failed to store encrypted file: %s", e)
|
||||
# Fallback: attempt to store the file locally under data/file_storage/files
|
||||
try:
|
||||
import base64
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
# Store encrypted files in the same directory the messaging service serves from
|
||||
FILES_DIR = Path("data/uploads/files/encrypted")
|
||||
FILES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
decoded = base64.b64decode(encrypted_file_data_b64)
|
||||
stored_name = f"{uuid.uuid4().hex}_{filename}"
|
||||
dest = FILES_DIR / stored_name
|
||||
with open(dest, "wb") as f:
|
||||
f.write(decoded)
|
||||
try:
|
||||
dest.chmod(0o644)
|
||||
except Exception:
|
||||
logger.debug("Could not chmod fallback file %s", dest)
|
||||
|
||||
logger.info("FALLBACK: Stored encrypted file locally: %s", dest)
|
||||
return {
|
||||
"file_id": stored_name,
|
||||
"filename": filename,
|
||||
"size": len(decoded),
|
||||
"path": f"/uploads/files/encrypted/{stored_name}",
|
||||
}
|
||||
except Exception as e2:
|
||||
logger.exception("Fallback local storage failed: %s", e2)
|
||||
raise
|
||||
|
||||
|
||||
async def process_message_in_messaging_service(
|
||||
client_public_key_b64: str,
|
||||
transport_nonce_b64: str,
|
||||
transport_ciphertext_b64: str,
|
||||
compliance_public_key_b64: str,
|
||||
sender_public_key_b64: str,
|
||||
recipient_public_key_b64: str,
|
||||
timeout: float = 5.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process an encrypted message through the messaging service envelope encryption pipeline.
|
||||
|
||||
In-process: calls the in-process service.
|
||||
Out-of-process: performs HTTP call to configured service URL.
|
||||
|
||||
Args:
|
||||
client_public_key_b64: Client's ephemeral public key
|
||||
transport_nonce_b64: Nonce for transport encryption
|
||||
transport_ciphertext_b64: Encrypted message
|
||||
compliance_public_key_b64: Compliance system public key
|
||||
sender_public_key_b64: Sender's public key
|
||||
recipient_public_key_b64: Recipient's public key
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
Dict with encrypted message and wrapped MEKs:
|
||||
{
|
||||
"nonce": base64-encoded nonce,
|
||||
"ciphertext": base64-encoded ciphertext,
|
||||
"compliance_wrapped_mek": wrapped MEK,
|
||||
"sender_wrapped_mek": wrapped MEK,
|
||||
"recipient_wrapped_mek": wrapped MEK,
|
||||
}
|
||||
"""
|
||||
mod = _get_messaging_module()
|
||||
if mod:
|
||||
try:
|
||||
# In-process: call the process endpoint directly
|
||||
return await mod.process_message(
|
||||
client_public_key_b64=client_public_key_b64,
|
||||
transport_nonce_b64=transport_nonce_b64,
|
||||
transport_ciphertext_b64=transport_ciphertext_b64,
|
||||
compliance_public_key_b64=compliance_public_key_b64,
|
||||
sender_public_key_b64=sender_public_key_b64,
|
||||
recipient_public_key_b64=recipient_public_key_b64,
|
||||
) # type: ignore
|
||||
except Exception as e:
|
||||
logger.error("In-process messaging.process_message failed: %s", e)
|
||||
raise
|
||||
|
||||
# Out-of-process HTTP
|
||||
messaging_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging:8301")
|
||||
url = f"{messaging_url.rstrip('/')}/process"
|
||||
try:
|
||||
try:
|
||||
import httpx
|
||||
payload = {
|
||||
"client_public_key_b64": client_public_key_b64,
|
||||
"transport_nonce_b64": transport_nonce_b64,
|
||||
"transport_ciphertext_b64": transport_ciphertext_b64,
|
||||
"compliance_public_key_b64": compliance_public_key_b64,
|
||||
"sender_public_key_b64": sender_public_key_b64,
|
||||
"recipient_public_key_b64": recipient_public_key_b64,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
r = await client.post(url, json=payload)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception:
|
||||
from urllib import request
|
||||
payload = {
|
||||
"client_public_key_b64": client_public_key_b64,
|
||||
"transport_nonce_b64": transport_nonce_b64,
|
||||
"transport_ciphertext_b64": transport_ciphertext_b64,
|
||||
"compliance_public_key_b64": compliance_public_key_b64,
|
||||
"sender_public_key_b64": sender_public_key_b64,
|
||||
"recipient_public_key_b64": recipient_public_key_b64,
|
||||
}
|
||||
req = request.Request(url, method="POST")
|
||||
req.data = json.dumps(payload).encode("utf-8")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
with request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read())
|
||||
except Exception as e:
|
||||
logger.error("Failed to process message in messaging service: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
async def process_message_with_files_in_messaging_service(
|
||||
client_public_key_b64: str,
|
||||
transport_nonce_b64: str,
|
||||
transport_ciphertext_b64: str,
|
||||
compliance_public_key_b64: str,
|
||||
sender_public_key_b64: str,
|
||||
recipient_public_key_b64: str,
|
||||
transport_files: list[dict[str, str]],
|
||||
timeout: float = 60.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process an encrypted message and transport-encrypted files using a single MEK.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"message": {"nonce": str, "ciphertext": str},
|
||||
"files": [{"nonce": str, "ciphertext": str}, ...],
|
||||
"compliance_wrapped_mek": str,
|
||||
"sender_wrapped_mek": str,
|
||||
"recipient_wrapped_mek": str,
|
||||
}
|
||||
"""
|
||||
mod = _get_messaging_module()
|
||||
if mod:
|
||||
try:
|
||||
return await mod.process_message_with_files( # type: ignore
|
||||
client_public_key_b64=client_public_key_b64,
|
||||
transport_nonce_b64=transport_nonce_b64,
|
||||
transport_ciphertext_b64=transport_ciphertext_b64,
|
||||
compliance_public_key_b64=compliance_public_key_b64,
|
||||
sender_public_key_b64=sender_public_key_b64,
|
||||
recipient_public_key_b64=recipient_public_key_b64,
|
||||
files=[f["encrypted_file_data_b64"] for f in transport_files],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("In-process messaging.process_message_with_files failed: %s", e)
|
||||
raise
|
||||
|
||||
messaging_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging:8301")
|
||||
url = f"{messaging_url.rstrip('/')}/process-with-files"
|
||||
payload = {
|
||||
"client_public_key_b64": client_public_key_b64,
|
||||
"transport_nonce_b64": transport_nonce_b64,
|
||||
"transport_ciphertext_b64": transport_ciphertext_b64,
|
||||
"compliance_public_key_b64": compliance_public_key_b64,
|
||||
"sender_public_key_b64": sender_public_key_b64,
|
||||
"recipient_public_key_b64": recipient_public_key_b64,
|
||||
"files": transport_files,
|
||||
}
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
r = await client.post(url, json=payload)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error("Failed to process message+files in messaging service: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import jwt
|
||||
from typing import Optional, Any
|
||||
import bcrypt
|
||||
|
||||
from constants import MAX_TOKEN_LIFETIME_HOURS, JWT_SECRET_KEY, JWT_ALGORITHM
|
||||
from .constants import MAX_TOKEN_LIFETIME_HOURS, JWT_SECRET_KEY, JWT_ALGORITHM
|
||||
|
||||
# JWT Helper Functions
|
||||
def create_token(user_id: int, username: str, session_id: str) -> str:
|
||||
@@ -1,7 +1,7 @@
|
||||
from websocket.registry import WebSocketHandlerRegistry
|
||||
from .registry import WebSocketHandlerRegistry
|
||||
|
||||
# Note: handler_registry and websocket_handler are not imported here to avoid circular dependency
|
||||
# Import them directly from websocket.handlers when needed
|
||||
# Import them directly from .handlers when needed
|
||||
|
||||
__all__ = ["WebSocketHandlerRegistry"]
|
||||
|
||||
@@ -6,8 +6,8 @@ from typing import Any
|
||||
from fastapi import HTTPException, WebSocket, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from websocket.registry import WebSocketHandlerRegistry
|
||||
from routes.messaging import (
|
||||
from .registry import WebSocketHandlerRegistry
|
||||
from ..routes.messaging import (
|
||||
MessaggingSocketManager,
|
||||
_send_message_internal,
|
||||
_edit_message_internal,
|
||||
@@ -17,7 +17,7 @@ from routes.messaging import (
|
||||
add_reaction,
|
||||
add_dm_reaction,
|
||||
)
|
||||
from models import (
|
||||
from ..models import (
|
||||
User,
|
||||
SendMessageRequest,
|
||||
EditMessageRequest,
|
||||
@@ -26,7 +26,7 @@ from models import (
|
||||
DMReactionRequest,
|
||||
UpdateLog,
|
||||
)
|
||||
from security.audit import log_access, log_dm
|
||||
from ..security.audit import log_access, log_dm
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
@@ -149,53 +149,65 @@ async def sendMessage(manager: MessaggingSocketManager, websocket: WebSocket, db
|
||||
|
||||
@websocket_handler("dmSend", authRequired=True)
|
||||
async def dmSend(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Send a direct message."""
|
||||
"""Send a direct message using the new envelope encryption format."""
|
||||
payload = data
|
||||
required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"]
|
||||
required = ["recipientId", "iv_b64", "ciphertext_b64", "wrapped_mek_b64"]
|
||||
for key in required:
|
||||
if key not in payload:
|
||||
raise HTTPException(status_code=400, detail=f"Missing {key}")
|
||||
|
||||
|
||||
env = DMEnvelope(
|
||||
sender_id=user.id,
|
||||
recipient_id=int(payload["recipientId"]),
|
||||
iv_b64=payload["iv"],
|
||||
ciphertext_b64=payload["ciphertext"],
|
||||
salt_b64=payload["salt"],
|
||||
iv2_b64=payload["iv2"],
|
||||
wrapped_mk_b64=payload["wrappedMk"],
|
||||
iv_b64=payload["iv_b64"],
|
||||
ciphertext_b64=payload["ciphertext_b64"],
|
||||
sender_wrapped_mek_b64=payload["wrapped_mek_b64"], # Client sends their own MEK
|
||||
recipient_wrapped_mek_b64=payload["wrapped_mek_b64"], # For simplicity, store same MEK
|
||||
compliance_wrapped_mek_b64=payload.get("compliance_wrapped_mek_b64"),
|
||||
reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None,
|
||||
)
|
||||
db.add(env)
|
||||
db.commit()
|
||||
db.refresh(env)
|
||||
|
||||
payload_ws = {
|
||||
# Send user-specific WebSocket updates (each user gets only their MEK)
|
||||
base_payload = {
|
||||
"id": env.id,
|
||||
"senderId": env.sender_id,
|
||||
"recipientId": env.recipient_id,
|
||||
"iv_b64": env.iv_b64,
|
||||
"ciphertext_b64": env.ciphertext_b64,
|
||||
"timestamp": env.timestamp.isoformat(),
|
||||
"replyToId": env.reply_to_id,
|
||||
}
|
||||
|
||||
# Send to recipient with their MEK
|
||||
recipient_payload = {
|
||||
"type": "dmNew",
|
||||
"data": {
|
||||
"id": env.id,
|
||||
"senderId": env.sender_id,
|
||||
"recipientId": env.recipient_id,
|
||||
"iv": env.iv_b64,
|
||||
"ciphertext": env.ciphertext_b64,
|
||||
"salt": env.salt_b64,
|
||||
"iv2": env.iv2_b64,
|
||||
"wrappedMk": env.wrapped_mk_b64,
|
||||
"timestamp": env.timestamp.isoformat(),
|
||||
"replyToId": env.reply_to_id,
|
||||
**base_payload,
|
||||
"wrapped_mek_b64": env.recipient_wrapped_mek_b64,
|
||||
}
|
||||
}
|
||||
|
||||
await manager.send_update_to_user(env.recipient_id, "dmNew", recipient_payload["data"], db)
|
||||
|
||||
# Send to sender with their MEK
|
||||
sender_payload = {
|
||||
"type": "dmNew",
|
||||
"data": {
|
||||
**base_payload,
|
||||
"wrapped_mek_b64": env.sender_wrapped_mek_b64,
|
||||
}
|
||||
}
|
||||
await manager.send_update_to_user(env.sender_id, "dmNew", sender_payload["data"], db)
|
||||
|
||||
# Send push notification for DM
|
||||
try:
|
||||
from push_service import push_service
|
||||
from ..push_service import push_service
|
||||
await push_service.send_dm_notification(db, env, user)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send push notification for DM {env.id}: {e}")
|
||||
|
||||
await manager.send_update_to_user(env.recipient_id, "dmNew", payload_ws["data"], db)
|
||||
await manager.send_update_to_user(env.sender_id, "dmNew", payload_ws["data"], db)
|
||||
|
||||
log(manager, websocket, user, "dmSend", dm_envelope_id=env.id, recipient_id=env.recipient_id)
|
||||
log_dm(
|
||||
"message_sent_ws",
|
||||
@@ -240,28 +252,40 @@ async def dmEdit(manager: MessaggingSocketManager, websocket: WebSocket, db: Ses
|
||||
# Replace ciphertext and iv
|
||||
env.iv_b64 = payload["iv"]
|
||||
env.ciphertext_b64 = payload["ciphertext"]
|
||||
env.iv2_b64 = payload["iv2"]
|
||||
env.wrapped_mk_b64 = payload["wrappedMk"]
|
||||
env.salt_b64 = payload["salt"]
|
||||
env.sender_wrapped_mek_b64 = payload.get("wrappedMk", "")
|
||||
env.recipient_wrapped_mek_b64 = payload.get("wrappedMk", "")
|
||||
db.commit()
|
||||
db.refresh(env)
|
||||
|
||||
payload_ws = {
|
||||
|
||||
# Send user-specific payloads for edit
|
||||
base_payload = {
|
||||
"id": env.id,
|
||||
"senderId": env.sender_id,
|
||||
"recipientId": env.recipient_id,
|
||||
"iv_b64": env.iv_b64,
|
||||
"ciphertext_b64": env.ciphertext_b64,
|
||||
"timestamp": env.timestamp.isoformat(),
|
||||
}
|
||||
|
||||
# Send to recipient with their MEK
|
||||
recipient_payload = {
|
||||
"type": "dmEdited",
|
||||
"data": {
|
||||
"id": env.id,
|
||||
"senderId": env.sender_id,
|
||||
"recipientId": env.recipient_id,
|
||||
"iv": env.iv_b64,
|
||||
"ciphertext": env.ciphertext_b64,
|
||||
"iv2": env.iv2_b64,
|
||||
"wrappedMk": env.wrapped_mk_b64,
|
||||
"salt": env.salt_b64,
|
||||
"timestamp": env.timestamp.isoformat(),
|
||||
**base_payload,
|
||||
"wrapped_mek_b64": env.recipient_wrapped_mek_b64,
|
||||
}
|
||||
}
|
||||
await manager.send_update_to_user(env.recipient_id, "dmEdited", payload_ws["data"], db)
|
||||
await manager.send_update_to_user(env.sender_id, "dmEdited", payload_ws["data"], db)
|
||||
await manager.send_update_to_user(env.recipient_id, "dmEdited", recipient_payload["data"], db)
|
||||
|
||||
# Send to sender with their MEK
|
||||
sender_payload = {
|
||||
"type": "dmEdited",
|
||||
"data": {
|
||||
**base_payload,
|
||||
"wrapped_mek_b64": env.sender_wrapped_mek_b64,
|
||||
}
|
||||
}
|
||||
await manager.send_update_to_user(env.sender_id, "dmEdited", sender_payload["data"], db)
|
||||
|
||||
log(manager, websocket, user, "dmEdit", dm_envelope_id=env.id)
|
||||
log_dm(
|
||||
@@ -2,8 +2,8 @@ from fastapi import HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from types import SimpleNamespace
|
||||
from dependencies import get_current_user
|
||||
from models import User
|
||||
from ..dependencies import get_current_user
|
||||
from ..models import User
|
||||
|
||||
|
||||
def extract_token_from_data(data: dict) -> str | None:
|
||||
@@ -0,0 +1 @@
|
||||
# Messaging service module
|
||||
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Envelope encryption module for the messaging service.
|
||||
|
||||
Handles:
|
||||
- Transport encryption/decryption with ephemeral X25519 keys
|
||||
- MEK (Message Encryption Key) generation and management
|
||||
- Envelope encryption for messages using AES-GCM
|
||||
- MEK wrapping for compliance, sender, and recipient keys
|
||||
"""
|
||||
|
||||
import os
|
||||
import base64
|
||||
import logging
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from nacl.public import Box, PrivateKey, PublicKey
|
||||
import nacl.bindings as sodium
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Nonce/IV sizes
|
||||
TRANSPORT_NONCE_SIZE = 24 # For X25519 transport encryption (PyNaCl Box/XSalsa20Poly1305)
|
||||
MEK_NONCE_SIZE = 12 # For AES-GCM content encryption
|
||||
MEK_SIZE = 32 # Message Encryption Key size
|
||||
|
||||
|
||||
def generate_mek() -> bytes:
|
||||
"""Generate a random Message Encryption Key (32 bytes)."""
|
||||
return os.urandom(MEK_SIZE)
|
||||
|
||||
|
||||
def generate_nonce(size: int = MEK_NONCE_SIZE) -> bytes:
|
||||
"""Generate a random nonce for AES-GCM."""
|
||||
return os.urandom(size)
|
||||
|
||||
|
||||
def derive_shared_secret(private_key: X25519PrivateKey, peer_public_key_b64: str) -> bytes:
|
||||
"""
|
||||
Compute a shared secret from a private key and peer's public key using X25519.
|
||||
|
||||
Args:
|
||||
private_key: X25519PrivateKey
|
||||
peer_public_key_b64: Peer's public key in base64 (raw format)
|
||||
|
||||
Returns:
|
||||
Shared secret (32 bytes)
|
||||
"""
|
||||
try:
|
||||
peer_public_bytes = base64.b64decode(peer_public_key_b64)
|
||||
peer_public_key = X25519PublicKey.from_public_bytes(peer_public_bytes)
|
||||
return private_key.exchange(peer_public_key)
|
||||
except Exception as e:
|
||||
logger.error("Failed to derive shared secret: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
def derive_key_from_shared_secret(shared_secret: bytes, context: str, key_size: int = MEK_SIZE) -> bytes:
|
||||
"""
|
||||
Derive a key from a shared secret using HKDF-SHA256.
|
||||
|
||||
Args:
|
||||
shared_secret: The shared secret from ECDH
|
||||
context: Context string for key derivation (e.g., "transport_key")
|
||||
key_size: Output key size in bytes (default 32)
|
||||
|
||||
Returns:
|
||||
Derived key bytes
|
||||
"""
|
||||
hkdf = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=key_size,
|
||||
salt=b"\x00" * 16, # 16 zero bytes salt
|
||||
info=context.encode(),
|
||||
)
|
||||
return hkdf.derive(shared_secret)
|
||||
|
||||
|
||||
def decrypt_transport_message(
|
||||
client_public_key_b64: str,
|
||||
nonce_b64: str,
|
||||
ciphertext_b64: str,
|
||||
ephemeral_private_key: X25519PrivateKey,
|
||||
) -> bytes:
|
||||
"""
|
||||
Decrypt a message that was encrypted with the ephemeral public key.
|
||||
|
||||
The client encrypts plaintext with the ephemeral transport key using tweetnacl.box,
|
||||
which performs ECDH + XSalsa20Poly1305 encryption.
|
||||
|
||||
Args:
|
||||
client_public_key_b64: Client's ephemeral public key (base64, raw X25519)
|
||||
nonce_b64: Encryption nonce (base64, 24 bytes for XSalsa20Poly1305)
|
||||
ciphertext_b64: Encrypted message (base64)
|
||||
ephemeral_private_key: Server's ephemeral X25519 private key
|
||||
|
||||
Returns:
|
||||
Decrypted plaintext
|
||||
"""
|
||||
try:
|
||||
# Convert cryptography X25519 key to raw bytes
|
||||
server_private_bytes = ephemeral_private_key.private_bytes_raw()
|
||||
|
||||
# Convert client public key from base64 to raw bytes
|
||||
client_public_bytes = base64.b64decode(client_public_key_b64)
|
||||
|
||||
# Decode nonce and ciphertext
|
||||
nonce = base64.b64decode(nonce_b64)
|
||||
ciphertext = base64.b64decode(ciphertext_b64)
|
||||
|
||||
# Decrypt using PyNaCl's low-level function (compatible with tweetnacl)
|
||||
# Parameters: ciphertext, nonce, sender_public_key, recipient_private_key
|
||||
plaintext = sodium.crypto_box_open_easy(
|
||||
ciphertext,
|
||||
nonce,
|
||||
client_public_bytes, # sender public key
|
||||
server_private_bytes # recipient private key
|
||||
)
|
||||
return plaintext
|
||||
except Exception as e:
|
||||
logger.error("Failed to decrypt transport message: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
def decrypt_transport_blob(
|
||||
client_public_key_b64: str,
|
||||
encrypted_blob: bytes,
|
||||
ephemeral_private_key: X25519PrivateKey,
|
||||
nonce_size: int = TRANSPORT_NONCE_SIZE,
|
||||
) -> bytes:
|
||||
"""
|
||||
Decrypt a transport-encrypted binary blob produced by `tweetnacl.box`.
|
||||
|
||||
The client sends a single blob that is `nonce || ciphertext`.
|
||||
This function extracts the nonce and decrypts the ciphertext using the server's
|
||||
ephemeral transport private key and the client's public key.
|
||||
|
||||
Args:
|
||||
client_public_key_b64: Sender public key in base64 (raw X25519).
|
||||
encrypted_blob: Raw bytes of `nonce || ciphertext`.
|
||||
ephemeral_private_key: Server ephemeral X25519 private key.
|
||||
nonce_size: Nonce size in bytes (24 for XSalsa20-Poly1305).
|
||||
|
||||
Returns:
|
||||
Decrypted plaintext bytes.
|
||||
"""
|
||||
if len(encrypted_blob) < nonce_size + 16:
|
||||
# crypto_box has a MAC; ciphertext must have at least some overhead.
|
||||
raise ValueError("Encrypted blob is too short to contain nonce + ciphertext")
|
||||
|
||||
nonce = encrypted_blob[:nonce_size]
|
||||
ciphertext = encrypted_blob[nonce_size:]
|
||||
|
||||
try:
|
||||
server_private_bytes = ephemeral_private_key.private_bytes_raw()
|
||||
client_public_bytes = base64.b64decode(client_public_key_b64)
|
||||
plaintext = sodium.crypto_box_open_easy(
|
||||
ciphertext,
|
||||
nonce,
|
||||
client_public_bytes, # sender public key
|
||||
server_private_bytes, # recipient private key
|
||||
)
|
||||
return plaintext
|
||||
except Exception as e:
|
||||
logger.error("Failed to decrypt transport blob: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
def encrypt_message(plaintext: bytes, mek: bytes) -> tuple[str, str]:
|
||||
"""
|
||||
Encrypt plaintext using AES-GCM with a Message Encryption Key.
|
||||
|
||||
Args:
|
||||
plaintext: Message content to encrypt
|
||||
mek: Message Encryption Key (32 bytes)
|
||||
|
||||
Returns:
|
||||
Tuple of (nonce_b64, ciphertext_b64) for storage
|
||||
"""
|
||||
cipher = AESGCM(mek)
|
||||
nonce = generate_nonce(MEK_NONCE_SIZE)
|
||||
ciphertext = cipher.encrypt(nonce, plaintext, None)
|
||||
return base64.b64encode(nonce).decode("utf-8"), base64.b64encode(ciphertext).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_message(nonce_b64: str, ciphertext_b64: str, mek: bytes) -> bytes:
|
||||
"""
|
||||
Decrypt ciphertext using the MEK.
|
||||
|
||||
Args:
|
||||
nonce_b64: Base64-encoded nonce
|
||||
ciphertext_b64: Base64-encoded ciphertext + tag
|
||||
mek: Message Encryption Key (32 bytes)
|
||||
|
||||
Returns:
|
||||
Plaintext bytes
|
||||
"""
|
||||
try:
|
||||
nonce = base64.b64decode(nonce_b64)
|
||||
ciphertext = base64.b64decode(ciphertext_b64)
|
||||
cipher = AESGCM(mek)
|
||||
plaintext = cipher.decrypt(nonce, ciphertext, None)
|
||||
return plaintext
|
||||
except Exception as e:
|
||||
logger.error("Failed to decrypt message: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
def wrap_mek(mek: bytes, wrap_key: bytes) -> str:
|
||||
"""
|
||||
Wrap a MEK using a key encryption key (wrap_key).
|
||||
Encrypts MEK with AES-256-GCM and returns base64-encoded result.
|
||||
|
||||
Args:
|
||||
mek: Message Encryption Key to wrap (32 bytes)
|
||||
wrap_key: Key to wrap with (32 bytes)
|
||||
|
||||
Returns:
|
||||
Base64-encoded (nonce + ciphertext + tag)
|
||||
"""
|
||||
cipher = AESGCM(wrap_key)
|
||||
nonce = generate_nonce(MEK_NONCE_SIZE)
|
||||
ciphertext = cipher.encrypt(nonce, mek, None)
|
||||
wrapped = nonce + ciphertext
|
||||
return base64.b64encode(wrapped).decode("utf-8")
|
||||
|
||||
|
||||
def unwrap_mek(wrapped_b64: str, wrap_key: bytes) -> bytes:
|
||||
"""
|
||||
Unwrap a MEK using a key encryption key (wrap_key).
|
||||
|
||||
Args:
|
||||
wrapped_b64: Base64-encoded (nonce + ciphertext + tag)
|
||||
wrap_key: Key to unwrap with (32 bytes)
|
||||
|
||||
Returns:
|
||||
Unwrapped MEK (32 bytes)
|
||||
"""
|
||||
try:
|
||||
wrapped = base64.b64decode(wrapped_b64)
|
||||
nonce = wrapped[:MEK_NONCE_SIZE]
|
||||
ciphertext = wrapped[MEK_NONCE_SIZE:]
|
||||
cipher = AESGCM(wrap_key)
|
||||
mek = cipher.decrypt(nonce, ciphertext, None)
|
||||
return mek
|
||||
except Exception as e:
|
||||
logger.error("Failed to unwrap MEK: %s", e)
|
||||
raise
|
||||
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Key Lifecycle Management for Compliance and Security.
|
||||
|
||||
This module handles automatic destruction of compliance keys, selective key destruction
|
||||
on message deletion, and configurable retention policies for cryptographic keys.
|
||||
|
||||
Key Features:
|
||||
- Automatic compliance key destruction (default: 6 months)
|
||||
- Selective key destruction on message deletion
|
||||
- Configurable retention policies
|
||||
- Background cleanup jobs for expired keys
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..main.models import DMEnvelope, MessageEditHistory, DMEditHistory
|
||||
from .encryption import generate_nonce, TRANSPORT_NONCE_SIZE
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
# Default retention periods (in days)
|
||||
DEFAULT_COMPLIANCE_KEY_RETENTION_DAYS = 180 # 6 months
|
||||
DEFAULT_MESSAGE_KEY_RETENTION_DAYS = 30 # 30 days for deleted messages
|
||||
|
||||
# Environment variable overrides
|
||||
COMPLIANCE_KEY_RETENTION_DAYS = int(os.getenv("COMPLIANCE_KEY_RETENTION_DAYS", DEFAULT_COMPLIANCE_KEY_RETENTION_DAYS))
|
||||
MESSAGE_KEY_RETENTION_DAYS = int(os.getenv("MESSAGE_KEY_RETENTION_DAYS", DEFAULT_MESSAGE_KEY_RETENTION_DAYS))
|
||||
|
||||
|
||||
def get_compliance_key_retention_period() -> timedelta:
|
||||
"""Get the retention period for compliance keys."""
|
||||
return timedelta(days=COMPLIANCE_KEY_RETENTION_DAYS)
|
||||
|
||||
|
||||
def get_message_key_retention_period() -> timedelta:
|
||||
"""Get the retention period for message keys after deletion."""
|
||||
return timedelta(days=MESSAGE_KEY_RETENTION_DAYS)
|
||||
|
||||
|
||||
def destroy_compliance_keys_for_message(db: Session, message_id: int) -> int:
|
||||
"""
|
||||
Destroy compliance keys for a specific message.
|
||||
|
||||
This removes the compliance_wrapped_mek_b64 from DM envelopes,
|
||||
making the message permanently inaccessible for compliance purposes.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
message_id: ID of the message to destroy compliance keys for
|
||||
|
||||
Returns:
|
||||
Number of envelopes affected
|
||||
"""
|
||||
try:
|
||||
# Find all DM envelopes for this message
|
||||
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(f"Destroyed compliance keys for {destroyed_count} DM envelopes (message_id={message_id})")
|
||||
|
||||
return destroyed_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to destroy compliance keys for message {message_id}: {e}")
|
||||
db.rollback()
|
||||
return 0
|
||||
|
||||
|
||||
def destroy_compliance_keys_for_dm_envelope(db: Session, dm_envelope_id: int) -> bool:
|
||||
"""
|
||||
Destroy compliance key for a specific DM envelope.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
dm_envelope_id: ID of the DM envelope
|
||||
|
||||
Returns:
|
||||
True if key was destroyed, False otherwise
|
||||
"""
|
||||
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(f"Destroyed compliance key for DM envelope {dm_envelope_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to destroy compliance key for DM envelope {dm_envelope_id}: {e}")
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
def cleanup_expired_compliance_keys(db: Session) -> int:
|
||||
"""
|
||||
Clean up expired compliance keys based on retention policy.
|
||||
|
||||
This removes compliance_wrapped_mek_b64 from DM envelopes that are older
|
||||
than the retention period, making them permanently inaccessible for compliance.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Number of keys destroyed
|
||||
"""
|
||||
try:
|
||||
cutoff_date = datetime.now() - get_compliance_key_retention_period()
|
||||
|
||||
# Find DM envelopes older than retention period that still have compliance keys
|
||||
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(f"Cleaned up {destroyed_count} expired compliance keys (retention: {COMPLIANCE_KEY_RETENTION_DAYS} days)")
|
||||
|
||||
return destroyed_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cleanup expired compliance keys: {e}")
|
||||
db.rollback()
|
||||
return 0
|
||||
|
||||
|
||||
def cleanup_expired_message_keys(db: Session) -> int:
|
||||
"""
|
||||
Clean up message keys for deleted messages after retention period.
|
||||
|
||||
This removes sender and recipient wrapped keys from DM envelopes that have been
|
||||
deleted and are past the retention period, making them completely inaccessible.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Number of keys destroyed
|
||||
"""
|
||||
try:
|
||||
# Note: We don't have a direct "deleted" flag on DMEnvelope, so this would need
|
||||
# to be implemented when message deletion is added. For now, this is a placeholder.
|
||||
|
||||
# This would typically work with a deletion timestamp or flag on the envelope
|
||||
# For now, return 0 as we don't have deleted message tracking yet
|
||||
logger.info("Message key cleanup: No deleted messages to process")
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cleanup expired message keys: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def cleanup_expired_edit_history(db: Session) -> int:
|
||||
"""
|
||||
Clean up old edit history entries based on retention policy.
|
||||
|
||||
This removes edit history entries that are older than the compliance
|
||||
retention period.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Number of edit history entries removed
|
||||
"""
|
||||
try:
|
||||
cutoff_date = datetime.now() - get_compliance_key_retention_period()
|
||||
|
||||
# Clean up public message edit history
|
||||
public_deleted = db.query(MessageEditHistory).filter(
|
||||
MessageEditHistory.edited_at < cutoff_date
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
# Clean up DM edit history
|
||||
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(f"Cleaned up {total_deleted} expired edit history entries (retention: {COMPLIANCE_KEY_RETENTION_DAYS} days)")
|
||||
|
||||
return total_deleted
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cleanup expired edit history: {e}")
|
||||
db.rollback()
|
||||
return 0
|
||||
|
||||
|
||||
def run_key_lifecycle_cleanup(db: Session) -> dict:
|
||||
"""
|
||||
Run all key lifecycle cleanup operations.
|
||||
|
||||
This should be called periodically (e.g., daily) to maintain key lifecycle policies.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Dict with cleanup statistics
|
||||
"""
|
||||
logger.info("Starting key lifecycle cleanup")
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
logger.info(f"Key lifecycle cleanup completed: {stats}")
|
||||
return stats
|
||||
|
||||
|
||||
def get_key_lifecycle_config() -> dict:
|
||||
"""
|
||||
Get current key lifecycle configuration.
|
||||
|
||||
Returns:
|
||||
Dict with current configuration values
|
||||
"""
|
||||
return {
|
||||
"compliance_key_retention_days": COMPLIANCE_KEY_RETENTION_DAYS,
|
||||
"message_key_retention_days": MESSAGE_KEY_RETENTION_DAYS,
|
||||
"default_compliance_retention": DEFAULT_COMPLIANCE_KEY_RETENTION_DAYS,
|
||||
"default_message_retention": DEFAULT_MESSAGE_KEY_RETENTION_DAYS
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
"""
|
||||
Messaging Service - Secure cryptographic processing for private messages with compliance access.
|
||||
|
||||
This service handles all encryption/decryption operations for private messages and files,
|
||||
providing compliance access while ensuring zero-knowledge storage of plaintext content.
|
||||
|
||||
API Endpoints:
|
||||
- GET /health: Health check
|
||||
- GET /key/public: Get current ephemeral transport public key
|
||||
- POST /key/invalidate: Rotate ephemeral keys
|
||||
- POST /process: Process encrypted message through envelope encryption pipeline
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import base64
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
from fastapi import FastAPI, HTTPException, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
# Import encryption modules
|
||||
from .encryption import generate_nonce, TRANSPORT_NONCE_SIZE, decrypt_transport_blob, decrypt_transport_message
|
||||
from .processor import process_encrypted_message, process_encrypted_message_and_files
|
||||
|
||||
try:
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
except ImportError:
|
||||
X25519PrivateKey = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Compliance Key Management
|
||||
# ============================================================================
|
||||
|
||||
_COMPLIANCE_PUBLIC_KEY_B64: str = ""
|
||||
|
||||
|
||||
def _initialize_compliance_key():
|
||||
"""
|
||||
Initialize compliance public key from environment variable.
|
||||
|
||||
The compliance public key is generated offline on an air-gapped machine.
|
||||
Only the public key is provided to the server via COMPLIANCE_PUBLIC_KEY env variable.
|
||||
The private key never exists on the server - all decryption is done offline.
|
||||
"""
|
||||
global _COMPLIANCE_PUBLIC_KEY_B64
|
||||
|
||||
env_key = os.getenv("COMPLIANCE_PUBLIC_KEY", "").strip()
|
||||
if not env_key:
|
||||
raise RuntimeError(
|
||||
"COMPLIANCE_PUBLIC_KEY environment variable must be set. "
|
||||
"Generate offline on an air-gapped machine: "
|
||||
"X25519 private key → export public key (base64) → set as env var"
|
||||
)
|
||||
|
||||
_COMPLIANCE_PUBLIC_KEY_B64 = env_key
|
||||
logger.info("Loaded compliance public key from COMPLIANCE_PUBLIC_KEY environment variable")
|
||||
|
||||
|
||||
def get_compliance_public_key() -> str:
|
||||
"""Return the compliance system public key."""
|
||||
if not _COMPLIANCE_PUBLIC_KEY_B64:
|
||||
_initialize_compliance_key()
|
||||
return _COMPLIANCE_PUBLIC_KEY_B64
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Ephemeral Key Management
|
||||
# ============================================================================
|
||||
|
||||
_KEY_STATE: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def _generate_keypair():
|
||||
"""
|
||||
Generate a fresh X25519 keypair and store it in memory.
|
||||
|
||||
This generates an ephemeral keypair for the session. The private key is kept
|
||||
in-memory and is never persisted. When a new keypair is generated, the old
|
||||
one is discarded and its associated data is no longer accessible.
|
||||
"""
|
||||
if X25519PrivateKey is None:
|
||||
raise RuntimeError("cryptography library required for X25519 key generation")
|
||||
|
||||
priv = X25519PrivateKey.generate()
|
||||
pub = priv.public_key()
|
||||
pub_bytes = pub.public_bytes(encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw)
|
||||
key_id = str(int(time.time() * 1000)) # Millisecond precision for uniqueness
|
||||
|
||||
_KEY_STATE.clear()
|
||||
_KEY_STATE.update({
|
||||
"key_id": key_id,
|
||||
"private_key": priv,
|
||||
"public_key_b64": base64.b64encode(pub_bytes).decode("ascii"),
|
||||
"created_at": time.time(),
|
||||
})
|
||||
logger.info("Generated new ephemeral keypair with key_id=%s", key_id)
|
||||
|
||||
|
||||
def _get_ephemeral_private_key() -> X25519PrivateKey:
|
||||
"""Retrieve the current ephemeral private key, regenerating if necessary."""
|
||||
if not _KEY_STATE:
|
||||
_generate_keypair()
|
||||
return _KEY_STATE.get("private_key")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FastAPI App Setup
|
||||
# ============================================================================
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup and shutdown event handler."""
|
||||
# Startup: Initialize compliance key and ephemeral keys
|
||||
try:
|
||||
_initialize_compliance_key()
|
||||
_generate_keypair()
|
||||
logger.info("Messaging service: initialized at startup")
|
||||
except Exception as e:
|
||||
logger.error("Messaging service: failed to initialize: %s", e)
|
||||
raise
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Messaging service: shutting down")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="FromChat Messaging Service",
|
||||
description="Secure cryptographic processing service for private messages",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Add security middleware
|
||||
try:
|
||||
from services.shared.middleware import add_security_middleware
|
||||
except ImportError:
|
||||
try:
|
||||
from backend.services.shared.middleware import add_security_middleware
|
||||
except ImportError:
|
||||
add_security_middleware = None
|
||||
|
||||
if add_security_middleware:
|
||||
add_security_middleware(app)
|
||||
|
||||
# CORS configuration for inter-service communication
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Allow all origins for inter-service communication
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Pydantic Models
|
||||
# ============================================================================
|
||||
|
||||
class ProcessMessageRequest(BaseModel):
|
||||
"""
|
||||
Request to process an encrypted message through the envelope encryption pipeline.
|
||||
|
||||
The client must:
|
||||
1. Encrypt plaintext with the ephemeral transport public key using X25519 + ChaCha20
|
||||
2. Provide the encrypted message and associated metadata
|
||||
3. Provide public keys for compliance, sender, and recipient for MEK wrapping
|
||||
"""
|
||||
client_public_key_b64: str
|
||||
transport_nonce_b64: str
|
||||
transport_ciphertext_b64: str
|
||||
compliance_public_key_b64: str
|
||||
sender_public_key_b64: str
|
||||
recipient_public_key_b64: str
|
||||
|
||||
|
||||
class ProcessMessageWithFilesFile(BaseModel):
|
||||
"""
|
||||
A single transport-encrypted file blob (base64 of nonce||ciphertext).
|
||||
"""
|
||||
encrypted_file_data_b64: str
|
||||
|
||||
|
||||
class ProcessMessageWithFilesRequest(ProcessMessageRequest):
|
||||
"""
|
||||
Process a transport-encrypted message and a list of transport-encrypted files
|
||||
using a single MEK for the whole envelope.
|
||||
"""
|
||||
files: list[ProcessMessageWithFilesFile]
|
||||
|
||||
# ============================================================================
|
||||
# Health Checks
|
||||
# ============================================================================
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint for messaging service."""
|
||||
return {"status": "healthy", "service": "messaging"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint for messaging service."""
|
||||
return {"message": "FromChat Messaging Service", "status": "operational"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Ephemeral Key Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@app.get("/key/transport/public")
|
||||
async def get_transport_public_key():
|
||||
"""
|
||||
Return the current ephemeral transport public key for client-side message encryption.
|
||||
|
||||
Clients use this key to encrypt their messages with X25519 + ChaCha20-Poly1305
|
||||
before sending to the server.
|
||||
"""
|
||||
if not _KEY_STATE:
|
||||
try:
|
||||
_generate_keypair()
|
||||
except Exception as e:
|
||||
logger.error("Failed to regenerate ephemeral key: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Key generation failed"
|
||||
)
|
||||
|
||||
return {
|
||||
"key_id": _KEY_STATE.get("key_id"),
|
||||
"public_key_b64": _KEY_STATE.get("public_key_b64"),
|
||||
"created_at": _KEY_STATE.get("created_at"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Message Processing Endpoints
|
||||
# ============================================================================
|
||||
|
||||
async def process_message(
|
||||
client_public_key_b64: str,
|
||||
transport_nonce_b64: str,
|
||||
transport_ciphertext_b64: str,
|
||||
compliance_public_key_b64: str,
|
||||
sender_public_key_b64: str,
|
||||
recipient_public_key_b64: str,
|
||||
):
|
||||
"""
|
||||
Process an encrypted message through the envelope encryption pipeline.
|
||||
|
||||
This is the core processing function used by both HTTP and in-process calls.
|
||||
|
||||
Flow:
|
||||
1. Decrypt client message using transport encryption (ephemeral key)
|
||||
2. Generate random MEK (Message Encryption Key)
|
||||
3. Encrypt plaintext with MEK using ChaCha20-Poly1305
|
||||
4. Wrap MEK for compliance, sender, and recipient
|
||||
5. Return encrypted message + 3 wrapped MEKs
|
||||
|
||||
Args:
|
||||
client_public_key_b64: Client's ephemeral public key
|
||||
transport_nonce_b64: Nonce for transport encryption
|
||||
transport_ciphertext_b64: Encrypted message
|
||||
compliance_public_key_b64: Compliance system public key
|
||||
sender_public_key_b64: Sender's public key
|
||||
recipient_public_key_b64: Recipient's public key
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- nonce: Base64-encoded nonce for content encryption
|
||||
- ciphertext: Base64-encoded encrypted content
|
||||
- compliance_wrapped_mek: Wrapped MEK for compliance system
|
||||
- sender_wrapped_mek: Wrapped MEK for message sender
|
||||
- recipient_wrapped_mek: Wrapped MEK for message recipient
|
||||
"""
|
||||
try:
|
||||
private_key = _get_ephemeral_private_key()
|
||||
|
||||
result = process_encrypted_message(
|
||||
client_public_key_b64=client_public_key_b64,
|
||||
transport_nonce_b64=transport_nonce_b64,
|
||||
transport_ciphertext_b64=transport_ciphertext_b64,
|
||||
compliance_public_key_b64=compliance_public_key_b64,
|
||||
sender_public_key_b64=sender_public_key_b64,
|
||||
recipient_public_key_b64=recipient_public_key_b64,
|
||||
ephemeral_private_key=private_key,
|
||||
)
|
||||
|
||||
logger.info("Successfully processed encrypted message")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to process message: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
@app.post("/process")
|
||||
async def process_message_http(request: ProcessMessageRequest):
|
||||
"""
|
||||
HTTP endpoint for processing encrypted messages.
|
||||
|
||||
Delegates to the core process_message function.
|
||||
"""
|
||||
return await process_message(
|
||||
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=request.compliance_public_key_b64,
|
||||
sender_public_key_b64=request.sender_public_key_b64,
|
||||
recipient_public_key_b64=request.recipient_public_key_b64,
|
||||
)
|
||||
|
||||
|
||||
async def process_message_with_files(
|
||||
client_public_key_b64: str,
|
||||
transport_nonce_b64: str,
|
||||
transport_ciphertext_b64: str,
|
||||
compliance_public_key_b64: str,
|
||||
sender_public_key_b64: str,
|
||||
recipient_public_key_b64: str,
|
||||
files: list[str],
|
||||
):
|
||||
"""
|
||||
In-process helper: process message + transport-encrypted files with one MEK.
|
||||
"""
|
||||
private_key = _get_ephemeral_private_key()
|
||||
|
||||
plaintext_message = decrypt_transport_message(
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64,
|
||||
transport_ciphertext_b64,
|
||||
private_key,
|
||||
)
|
||||
|
||||
plaintext_files: list[bytes] = []
|
||||
for encrypted_file_data_b64 in files:
|
||||
transport_blob = base64.b64decode(encrypted_file_data_b64)
|
||||
plaintext_files.append(
|
||||
decrypt_transport_blob(
|
||||
client_public_key_b64=sender_public_key_b64,
|
||||
encrypted_blob=transport_blob,
|
||||
ephemeral_private_key=private_key,
|
||||
)
|
||||
)
|
||||
|
||||
return process_encrypted_message_and_files(
|
||||
plaintext_message=plaintext_message,
|
||||
plaintext_files=plaintext_files,
|
||||
compliance_public_key_b64=compliance_public_key_b64,
|
||||
sender_public_key_b64=sender_public_key_b64,
|
||||
recipient_public_key_b64=recipient_public_key_b64,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/process-with-files")
|
||||
async def process_message_with_files_http(request: ProcessMessageWithFilesRequest):
|
||||
"""
|
||||
Process an encrypted message and its files using a single MEK.
|
||||
|
||||
- Message transport layer is decrypted using the message client ephemeral key
|
||||
- File transport layer is decrypted using the sender long-term public key
|
||||
- One MEK is generated and used to encrypt message + all files
|
||||
- MEK is wrapped for compliance, sender, and recipient (stored on DM envelope)
|
||||
"""
|
||||
try:
|
||||
return await process_message_with_files(
|
||||
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=request.compliance_public_key_b64,
|
||||
sender_public_key_b64=request.sender_public_key_b64,
|
||||
recipient_public_key_b64=request.recipient_public_key_b64,
|
||||
files=[f.encrypted_file_data_b64 for f in request.files],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to process message with files: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
port = int(os.getenv("PORT", "8301"))
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Message processing pipeline for envelope encryption.
|
||||
|
||||
This module handles the core envelope encryption workflow:
|
||||
1. Decrypt client-encrypted message (transport encryption)
|
||||
2. Generate random MEK
|
||||
3. Encrypt plaintext with MEK
|
||||
4. Wrap MEK for compliance, sender, and recipient
|
||||
5. Store encrypted message + wrapped keys
|
||||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
import time
|
||||
import base64
|
||||
from typing import Dict, Any, Optional
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
|
||||
from .encryption import (
|
||||
decrypt_transport_message,
|
||||
generate_mek,
|
||||
encrypt_message,
|
||||
wrap_mek,
|
||||
derive_shared_secret,
|
||||
derive_key_from_shared_secret,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
|
||||
def process_encrypted_message(
|
||||
client_public_key_b64: str,
|
||||
transport_nonce_b64: str,
|
||||
transport_ciphertext_b64: str,
|
||||
compliance_public_key_b64: str,
|
||||
sender_public_key_b64: str,
|
||||
recipient_public_key_b64: str,
|
||||
ephemeral_private_key: X25519PrivateKey,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process an encrypted message through the envelope encryption pipeline.
|
||||
|
||||
Step 1: Decrypt client message using transport encryption (ephemeral keys)
|
||||
Step 2: Generate random MEK
|
||||
Step 3: Encrypt plaintext with MEK
|
||||
Step 4: Wrap MEK for compliance, sender, recipient (using their provided public keys)
|
||||
Step 5: Return encrypted message + 3 wrapped MEKs
|
||||
|
||||
Args:
|
||||
client_public_key_b64: Client's ephemeral public key for transport decryption
|
||||
transport_nonce_b64: Nonce used for transport encryption
|
||||
transport_ciphertext_b64: Client's encrypted plaintext
|
||||
compliance_public_key_b64: Compliance system's public key for MEK wrapping
|
||||
sender_public_key_b64: Sender's public key for MEK wrapping
|
||||
recipient_public_key_b64: Recipient's public key for MEK wrapping
|
||||
ephemeral_private_key: Server's ephemeral X25519 private key
|
||||
|
||||
Returns:
|
||||
Dict with encrypted message and wrapped MEKs:
|
||||
{
|
||||
"nonce": base64-encoded nonce for content encryption,
|
||||
"ciphertext": base64-encoded encrypted content,
|
||||
"compliance_wrapped_mek": base64-encoded wrapped MEK,
|
||||
"sender_wrapped_mek": base64-encoded wrapped MEK,
|
||||
"recipient_wrapped_mek": base64-encoded wrapped MEK,
|
||||
}
|
||||
"""
|
||||
try:
|
||||
start_time = time.time()
|
||||
|
||||
# Step 1: Decrypt transport message
|
||||
logger.info("CRYPTO: Starting envelope encryption processing")
|
||||
plaintext = decrypt_transport_message(
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64,
|
||||
transport_ciphertext_b64,
|
||||
ephemeral_private_key,
|
||||
)
|
||||
logger.info(
|
||||
"CRYPTO: Transport decryption complete, plaintext size: %d bytes",
|
||||
len(plaintext)
|
||||
)
|
||||
|
||||
# Step 2: Generate random MEK
|
||||
mek = generate_mek()
|
||||
logger.info("CRYPTO: Generated random MEK (32 bytes)")
|
||||
|
||||
# Step 3: Encrypt plaintext with MEK
|
||||
content_nonce, ciphertext = encrypt_message(plaintext, mek)
|
||||
logger.info(
|
||||
"CRYPTO: Content encryption with MEK complete, ciphertext size: %d bytes",
|
||||
len(ciphertext)
|
||||
)
|
||||
|
||||
# Step 4a: Derive wrap keys deterministically from recipient public keys
|
||||
# This avoids needing to store the ephemeral transport key
|
||||
logger.info("CRYPTO: Deriving key wrap keys deterministically")
|
||||
|
||||
# Use HKDF with recipient public key bytes as input to derive wrap keys
|
||||
# This is deterministic and doesn't require storing ephemeral keys
|
||||
import base64
|
||||
compliance_key_bytes = base64.b64decode(compliance_public_key_b64)
|
||||
sender_key_bytes = base64.b64decode(sender_public_key_b64)
|
||||
recipient_key_bytes = base64.b64decode(recipient_public_key_b64)
|
||||
|
||||
logger.info(f"🔑 Deriving wrap keys for sender={sender_public_key_b64[:20]}... recipient={recipient_public_key_b64[:20]}...")
|
||||
|
||||
compliance_wrap_key = derive_key_from_shared_secret(compliance_key_bytes, "compliance_wrap_key")
|
||||
sender_wrap_key = derive_key_from_shared_secret(sender_key_bytes, "sender_wrap_key")
|
||||
recipient_wrap_key = derive_key_from_shared_secret(recipient_key_bytes, "recipient_wrap_key")
|
||||
|
||||
logger.info("✅ Wrap keys derived successfully")
|
||||
|
||||
# Step 4b: Wrap MEK for each recipient
|
||||
compliance_wrapped_mek = wrap_mek(mek, compliance_wrap_key)
|
||||
sender_wrapped_mek = wrap_mek(mek, sender_wrap_key)
|
||||
recipient_wrapped_mek = wrap_mek(mek, recipient_wrap_key)
|
||||
|
||||
duration = time.time() - start_time
|
||||
logger.info(
|
||||
"CRYPTO: Successfully processed message with 3 MEK wraps (compliance/sender/recipient) in %.2fms",
|
||||
duration * 1000
|
||||
)
|
||||
|
||||
# Get the transport public key for storage with the message
|
||||
transport_public_key_b64 = base64.b64encode(ephemeral_private_key.public_key().public_bytes_raw()).decode("ascii")
|
||||
|
||||
return {
|
||||
"nonce": content_nonce,
|
||||
"ciphertext": ciphertext,
|
||||
"compliance_wrapped_mek": compliance_wrapped_mek,
|
||||
"sender_wrapped_mek": sender_wrapped_mek,
|
||||
"recipient_wrapped_mek": recipient_wrapped_mek,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
logger.exception(
|
||||
"CRYPTO: Failed to process encrypted message after %.2fms: %s",
|
||||
duration * 1000, str(e)
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def process_encrypted_message_and_files(
|
||||
plaintext_message: bytes,
|
||||
plaintext_files: list[bytes],
|
||||
compliance_public_key_b64: str,
|
||||
sender_public_key_b64: str,
|
||||
recipient_public_key_b64: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process a message and its attached files using a single MEK.
|
||||
|
||||
- Generates one random MEK
|
||||
- Encrypts message and each file with AES-GCM using that MEK (unique nonce per item)
|
||||
- Wraps the MEK for compliance, sender, and recipient
|
||||
|
||||
Returns:
|
||||
{
|
||||
"message": {"nonce": str, "ciphertext": str},
|
||||
"files": [{"nonce": str, "ciphertext": str}, ...],
|
||||
"compliance_wrapped_mek": str,
|
||||
"sender_wrapped_mek": str,
|
||||
"recipient_wrapped_mek": str,
|
||||
}
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# One MEK for everything in this envelope
|
||||
mek = generate_mek()
|
||||
|
||||
# Encrypt message
|
||||
msg_nonce, msg_ciphertext = encrypt_message(plaintext_message, mek)
|
||||
|
||||
# Encrypt files (same MEK, per-file nonce)
|
||||
files_out: list[Dict[str, str]] = []
|
||||
for f_bytes in plaintext_files:
|
||||
f_nonce, f_ciphertext = encrypt_message(f_bytes, mek)
|
||||
files_out.append({"nonce": f_nonce, "ciphertext": f_ciphertext})
|
||||
|
||||
# Derive wrap keys deterministically (same as existing flow)
|
||||
compliance_key_bytes = base64.b64decode(compliance_public_key_b64)
|
||||
sender_key_bytes = base64.b64decode(sender_public_key_b64)
|
||||
recipient_key_bytes = base64.b64decode(recipient_public_key_b64)
|
||||
|
||||
compliance_wrap_key = derive_key_from_shared_secret(compliance_key_bytes, "compliance_wrap_key")
|
||||
sender_wrap_key = derive_key_from_shared_secret(sender_key_bytes, "sender_wrap_key")
|
||||
recipient_wrap_key = derive_key_from_shared_secret(recipient_key_bytes, "recipient_wrap_key")
|
||||
|
||||
compliance_wrapped_mek = wrap_mek(mek, compliance_wrap_key)
|
||||
sender_wrapped_mek = wrap_mek(mek, sender_wrap_key)
|
||||
recipient_wrapped_mek = wrap_mek(mek, recipient_wrap_key)
|
||||
|
||||
duration = time.time() - start_time
|
||||
logger.info(
|
||||
"CRYPTO: Processed message+%d files with single MEK in %.2fms",
|
||||
len(files_out),
|
||||
duration * 1000,
|
||||
)
|
||||
|
||||
return {
|
||||
"message": {"nonce": msg_nonce, "ciphertext": msg_ciphertext},
|
||||
"files": files_out,
|
||||
"compliance_wrapped_mek": compliance_wrapped_mek,
|
||||
"sender_wrapped_mek": sender_wrapped_mek,
|
||||
"recipient_wrapped_mek": recipient_wrapped_mek,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# Shared code across microservices
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Shared middleware for inter-service communication validation and security.
|
||||
|
||||
Provides:
|
||||
- Request size limiting (max 5GB)
|
||||
- Input validation and sanitization
|
||||
- Comprehensive audit logging
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable
|
||||
from fastapi import FastAPI, Request, HTTPException, status
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
# Maximum request size: 5GB
|
||||
MAX_REQUEST_SIZE = 5 * 1024 * 1024 * 1024 # 5GB in bytes
|
||||
|
||||
|
||||
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware to enforce maximum request size."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
"""Check request size before processing."""
|
||||
# Check Content-Length header if available
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length:
|
||||
try:
|
||||
size = int(content_length)
|
||||
if size > MAX_REQUEST_SIZE:
|
||||
logger.warning(
|
||||
"Request size %d exceeds limit %d from %s %s",
|
||||
size,
|
||||
MAX_REQUEST_SIZE,
|
||||
request.client.host if request.client else "unknown",
|
||||
request.url.path,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"Request size exceeds {MAX_REQUEST_SIZE} bytes limit"
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class AuditLoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware for comprehensive audit logging of all requests."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
"""Log request and response details."""
|
||||
start_time = time.time()
|
||||
|
||||
# Log request
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
method = request.method
|
||||
path = request.url.path
|
||||
|
||||
logger.info(
|
||||
"REQUEST: %s %s from %s",
|
||||
method,
|
||||
path,
|
||||
client_ip,
|
||||
)
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
|
||||
# Log response
|
||||
duration = time.time() - start_time
|
||||
logger.info(
|
||||
"RESPONSE: %s %s -> %d in %.2fms",
|
||||
method,
|
||||
path,
|
||||
response.status_code,
|
||||
duration * 1000,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
logger.exception(
|
||||
"ERROR: %s %s failed after %.2fms: %s",
|
||||
method,
|
||||
path,
|
||||
duration * 1000,
|
||||
e,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def add_security_middleware(app: FastAPI):
|
||||
"""
|
||||
Add all security and audit middleware to FastAPI app.
|
||||
|
||||
Args:
|
||||
app: FastAPI application instance
|
||||
"""
|
||||
# Request size limiting (inner, checked first)
|
||||
app.add_middleware(RequestSizeLimitMiddleware)
|
||||
|
||||
# Audit logging (outer, logs everything)
|
||||
app.add_middleware(AuditLoggingMiddleware)
|
||||
Reference in New Issue
Block a user