Restructure backend into microservices, add envelope encryption, DM files, and message editing

This commit is contained in:
2026-01-10 14:59:31 +03:00
Unverified
parent 1f706eaa34
commit fd4c00057c
74 changed files with 6647 additions and 820 deletions
+3
View File
@@ -0,0 +1,3 @@
# Backend package initializer
__all__ = []
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -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,
-13
View File
@@ -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")
-40
View File
@@ -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
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
# Services package initializer
__all__ = []
@@ -0,0 +1 @@
# File storage service module
+668
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
# Main service module
+24
View File
@@ -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")
+154
View File
@@ -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
+113 -23
View File
@@ -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"
)
+94
View File
@@ -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")
+410
View File
@@ -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:
+1
View File
@@ -0,0 +1 @@
# Messaging service module
+249
View File
@@ -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
+249
View File
@@ -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
}
+393
View File
@@ -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)
+208
View File
@@ -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,
}
+1
View File
@@ -0,0 +1 @@
# Shared code across microservices
+108
View File
@@ -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)