Add multi-service Docker setup, Postgres migrations, and messaging API cleanup

This commit is contained in:
2026-01-16 00:25:22 +03:00
Unverified
parent 0b683e3c83
commit 618f55e057
22 changed files with 473 additions and 154 deletions
+1 -2
View File
@@ -8,7 +8,6 @@ Perform a comprehensive security audit of the FromChat application codebase.
- React/TypeScript frontend - React/TypeScript frontend
- Python FastAPI backend - Python FastAPI backend
- End-to-end encryption for DMs and calls
- Caddy reverse proxy with security headers - Caddy reverse proxy with security headers
- WebSocket support for real-time features - WebSocket support for real-time features
- Electron support for desktop app - Electron support for desktop app
@@ -122,7 +121,7 @@ Provide a **clean, concise report** with:
- CORS configuration in backend/app.py - CORS configuration in backend/app.py
- Password validation in backend/validation.py - Password validation in backend/validation.py
- JWT token generation and validation - JWT token generation and validation
- E2E encryption implementation (NaCl, AES-GCM) - Encryption implementation (NaCl, AES-GCM)
- File upload sanitization - File upload sanitization
- Authorization checks on sensitive endpoints - Authorization checks on sensitive endpoints
- Rate limiting configuration - Rate limiting configuration
+1
View File
@@ -21,6 +21,7 @@ When working with this project, follow these rules:
## File Operations ## File Operations
- If possible, try to update files in a single edit when making multiple changes. - If possible, try to update files in a single edit when making multiple changes.
- Do NOT "cd" to the project directory. - Do NOT "cd" to the project directory.
- NEVER edit/delete/regenerate .env files without explicit permission.
## Testing & Validation ## Testing & Validation
- Do NOT "test the implementation" when you are done. The only exception is when you - Do NOT "test the implementation" when you are done. The only exception is when you
+2
View File
@@ -581,3 +581,5 @@ backend/alembic/**
tmp tmp
compliance_keypair.txt compliance_keypair.txt
backend/files backend/files
*.db-wal
*.db-shm
+2
View File
@@ -2,6 +2,7 @@ PyJWT>=2.8.0
fastapi[standard]>=0.116.1 fastapi[standard]>=0.116.1
pydantic>=2.11.7 pydantic>=2.11.7
sqlalchemy>=2.0.43 sqlalchemy>=2.0.43
psycopg2-binary>=2.9.9
bcrypt>=4.3.0 bcrypt>=4.3.0
websockets>=15.0.1 websockets>=15.0.1
Pillow>=10.0.0 Pillow>=10.0.0
@@ -15,3 +16,4 @@ httpx>=0.27.2
rich>=13.9.4 rich>=13.9.4
slowapi>=0.1.9 slowapi>=0.1.9
firebase_admin>=7.1.0 firebase_admin>=7.1.0
PyNaCl>=1.5.0
+2 -2
View File
@@ -1,7 +1,7 @@
import os import os
# Database is always in backend/data/ relative to project root # Database URL from environment (Docker) or fallback to SQLite (development)
DATABASE_URL = "sqlite:///" + os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "data", "database.db") DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///" + os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "data", "database.db"))
JWT_ALGORITHM = "HS256" JWT_ALGORITHM = "HS256"
# Token inactivity expiration - token expires if not used for this duration # Token inactivity expiration - token expires if not used for this duration
TOKEN_INACTIVITY_EXPIRE_HOURS = 30 * 24 # 30 days of inactivity TOKEN_INACTIVITY_EXPIRE_HOURS = 30 * 24 # 30 days of inactivity
+59 -17
View File
@@ -7,6 +7,7 @@ import subprocess
import sys import sys
import os import os
import logging import logging
from sqlalchemy.orm.exc import DetachedInstanceError
# Import from same directory # Import from same directory
from .routes import account, messaging, profile, push, webrtc, devices, moderation, download, keys, envelope_messaging from .routes import account, messaging, profile, push, webrtc, devices, moderation, download, keys, envelope_messaging
@@ -166,18 +167,55 @@ if not _running_in_docker():
logger.warning(f"Failed to mount internal services for development: {e}") logger.warning(f"Failed to mount internal services for development: {e}")
def _get_username_for_log(user) -> str | None:
"""
Safely extract username for access logs.
If the ORM instance is detached, we transparently open a short-lived session,
reload the user by ID and read the username from that fresh instance.
Logging must never break request handling.
"""
if user is None:
return None
# Fast path: instance is still bound to a session.
try:
return getattr(user, "username", None)
except DetachedInstanceError:
# Session is gone; try to reload user by primary key.
try:
user_id = getattr(user, "id", None)
except Exception:
user_id = None
if not user_id:
return None
try:
with SessionLocal() as db:
fresh = db.query(User).filter(User.id == user_id).first()
return getattr(fresh, "username", None) if fresh is not None else None
except Exception:
return None
except Exception:
# Fall back to no user information if anything else goes wrong.
return None
@app.middleware("http") @app.middleware("http")
async def access_logging_middleware(request: Request, call_next): async def access_logging_middleware(request: Request, call_next):
# Log incoming request and Authorization header presence for debugging auth issues # Log incoming request and Authorization header presence for debugging auth issues
try: # Skip logging for health check requests
auth_header = request.headers.get("authorization") if request.url.path != "/health":
if auth_header: try:
short = auth_header[:20] + "..." if len(auth_header) > 20 else auth_header auth_header = request.headers.get("authorization")
logger.info("Incoming request %s %s Authorization=%s", request.method, request.url.path, short) if auth_header:
else: short = auth_header[:20] + "..." if len(auth_header) > 20 else auth_header
logger.info("Incoming request %s %s Authorization=NONE", request.method, request.url.path) logger.info("Incoming request %s %s Authorization=%s", request.method, request.url.path, short)
except Exception: else:
pass logger.info("Incoming request %s %s Authorization=NONE", request.method, request.url.path)
except Exception:
pass
start = time.perf_counter() start = time.perf_counter()
try: try:
response = await call_next(request) response = await call_next(request)
@@ -189,7 +227,7 @@ async def access_logging_middleware(request: Request, call_next):
method=request.method, method=request.method,
path=request.url.path, path=request.url.path,
status="error", status="error",
user=getattr(user, "username", None), user=_get_username_for_log(user),
ip=get_client_ip(request), ip=get_client_ip(request),
duration=f"{duration:.3f}s", duration=f"{duration:.3f}s",
error=str(exc), error=str(exc),
@@ -203,7 +241,7 @@ async def access_logging_middleware(request: Request, call_next):
method=request.method, method=request.method,
path=request.url.path, path=request.url.path,
status=response.status_code, status=response.status_code,
user=getattr(user, "username", None), user=_get_username_for_log(user),
ip=get_client_ip(request), ip=get_client_ip(request),
duration=f"{duration:.3f}s", duration=f"{duration:.3f}s",
) )
@@ -252,6 +290,12 @@ app.include_router(download.router)
app.include_router(keys.router) app.include_router(keys.router)
@app.get("/health")
async def health_check():
"""Health check endpoint for Docker health checks."""
return {"status": "healthy", "service": "main"}
@app.get("/key/public") @app.get("/key/public")
async def key_public_proxy(): async def key_public_proxy():
""" """
@@ -261,9 +305,7 @@ async def key_public_proxy():
return await keys.get_public_key() return await keys.get_public_key()
@app.post("/key/invalidate") if __name__ == "__main__":
async def key_invalidate_proxy(): import uvicorn
""" port = int(os.getenv("PORT", "8300"))
Proxy endpoint to invalidate messaging ephemeral key. uvicorn.run(app, host="0.0.0.0", port=port)
"""
return await keys.invalidate_key()
+51 -12
View File
@@ -69,6 +69,15 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _ensure_all_model_tables():
"""Create any model tables that do not exist (e.g. dm_edit_history added after migrations)."""
engine = _create_engine_with_retry()
Base = _load_models_base()
Base.metadata.create_all(bind=engine)
logger.info("Ensured all model tables exist.")
def run_migrations(): def run_migrations():
""" """
Run database migrations using Alembic. Run database migrations using Alembic.
@@ -128,7 +137,14 @@ def run_migrations():
engine = _create_engine_with_retry() engine = _create_engine_with_retry()
with engine.connect() as connection: with engine.connect() as connection:
from sqlalchemy import text from sqlalchemy import text
result = connection.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name != 'alembic_version'")) # Check for PostgreSQL or SQLite
if 'postgresql' in DATABASE_URL.lower():
result = connection.execute(text("""
SELECT tablename as name FROM pg_tables
WHERE schemaname = 'public' AND tablename != 'alembic_version'
"""))
else:
result = connection.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name != 'alembic_version'"))
existing_tables = result.fetchall() existing_tables = result.fetchall()
if existing_tables: if existing_tables:
@@ -204,6 +220,7 @@ def run_migrations():
try: try:
command.upgrade(alembic_cfg, "head") command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully.") logger.info("Database migrations completed successfully.")
_ensure_all_model_tables()
except Exception as upgrade_error: except Exception as upgrade_error:
if "Can't locate revision identified by 'direct_creation'" in str(upgrade_error): if "Can't locate revision identified by 'direct_creation'" in str(upgrade_error):
logger.info("Found 'direct_creation' revision - resetting migration state...") logger.info("Found 'direct_creation' revision - resetting migration state...")
@@ -237,6 +254,7 @@ def run_migrations():
# Try upgrade again # Try upgrade again
command.upgrade(alembic_cfg, "head") command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully after reset.") logger.info("Database migrations completed successfully after reset.")
_ensure_all_model_tables()
elif "no such table" in str(upgrade_error).lower(): elif "no such table" in str(upgrade_error).lower():
logger.info("Database tables missing - resetting migration state...") logger.info("Database tables missing - resetting migration state...")
# Clear the alembic_version table and start fresh # Clear the alembic_version table and start fresh
@@ -249,6 +267,7 @@ def run_migrations():
# Try upgrade again # Try upgrade again
command.upgrade(alembic_cfg, "head") command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully after reset.") logger.info("Database migrations completed successfully after reset.")
_ensure_all_model_tables()
else: else:
raise upgrade_error raise upgrade_error
@@ -286,6 +305,7 @@ def run_migrations():
# Try upgrade again # Try upgrade again
command.upgrade(alembic_cfg, "head") command.upgrade(alembic_cfg, "head")
_ensure_all_model_tables()
logger.info("Automated recovery completed successfully.") logger.info("Automated recovery completed successfully.")
else: else:
# No migration files, create fresh ones # No migration files, create fresh ones
@@ -294,6 +314,7 @@ def run_migrations():
# Run the migration # Run the migration
command.upgrade(alembic_cfg, "head") command.upgrade(alembic_cfg, "head")
_ensure_all_model_tables()
logger.info("Automated recovery completed successfully.") logger.info("Automated recovery completed successfully.")
except Exception as recovery_error: except Exception as recovery_error:
@@ -600,6 +621,8 @@ def _create_database_directly():
else: else:
# Table doesn't exist, create it # Table doesn't exist, create it
logger.info(f"Creating table {table_name}") logger.info(f"Creating table {table_name}")
from sqlalchemy.schema import CreateTable
connection.execute(CreateTable(Base.metadata.tables[table_name]))
# Create alembic_version table manually # Create alembic_version table manually
connection.execute(text(""" connection.execute(text("""
@@ -623,29 +646,45 @@ def _create_database_directly():
revision_match = re.search(r"revision: str = '([^']+)'", content) revision_match = re.search(r"revision: str = '([^']+)'", content)
if revision_match: if revision_match:
revision_id = revision_match.group(1) revision_id = revision_match.group(1)
connection.execute(text(f"INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('{revision_id}')")) if 'postgresql' in DATABASE_URL.lower():
connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}') ON CONFLICT DO NOTHING"))
else:
connection.execute(text(f"INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('{revision_id}')"))
else: else:
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')")) if 'postgresql' in DATABASE_URL.lower():
connection.execute(text("INSERT INTO alembic_version (version_num) VALUES ('direct_creation') ON CONFLICT DO NOTHING"))
else:
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
else: else:
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')")) if 'postgresql' in DATABASE_URL.lower():
connection.execute(text("INSERT INTO alembic_version (version_num) VALUES ('direct_creation') ON CONFLICT DO NOTHING"))
else:
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
connection.commit() connection.commit()
def _get_sql_type(column): def _get_sql_type(column):
"""Get SQL type for direct SQL execution.""" """Get SQL type for direct SQL execution."""
type_name = column.type.__class__.__name__ from sqlalchemy import String, Integer, Text, Boolean, DateTime
import os
if type_name == 'String': # Check if we're using PostgreSQL
return f"VARCHAR({column.type.length})" is_postgres = 'postgresql' in os.getenv('DATABASE_URL', '').lower()
elif type_name == 'Integer':
if isinstance(column.type, String):
if column.type.length:
return f"VARCHAR({column.type.length})"
else:
return "TEXT"
elif isinstance(column.type, Integer):
return "INTEGER" return "INTEGER"
elif type_name == 'Text': elif isinstance(column.type, Text):
return "TEXT" return "TEXT"
elif type_name == 'Boolean': elif isinstance(column.type, Boolean):
return "BOOLEAN" return "BOOLEAN"
elif type_name == 'DateTime': elif isinstance(column.type, DateTime):
return "DATETIME" return "TIMESTAMP" if is_postgres else "DATETIME"
else: else:
return "TEXT" # fallback return "TEXT" # fallback
+1 -4
View File
@@ -86,6 +86,7 @@ class DMEnvelope(Base):
timestamp = Column(DateTime, default=datetime.now) timestamp = Column(DateTime, default=datetime.now)
is_edited = Column(Boolean, default=False) is_edited = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now)
deleted_at = Column(DateTime, nullable=True) # Soft delete timestamp
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select") files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select") reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select")
@@ -241,8 +242,6 @@ class DMEditHistoryResponse(BaseModel):
dm_envelope_id: int dm_envelope_id: int
previous_ciphertext_b64: str previous_ciphertext_b64: str
previous_iv_b64: str previous_iv_b64: str
previous_sender_wrapped_mek_b64: str
previous_recipient_wrapped_mek_b64: str
previous_compliance_wrapped_mek_b64: str previous_compliance_wrapped_mek_b64: str
edited_at: str edited_at: str
edited_by_username: str edited_by_username: str
@@ -374,8 +373,6 @@ class DMEditHistory(Base):
dm_envelope_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False) # Match existing DB schema 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_ciphertext_b64 = Column(Text, nullable=False) # Encrypted content before this edit
previous_iv_b64 = Column(Text, nullable=False) # IV for previous content 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 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_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 = Column(Integer, ForeignKey("user.id"), nullable=False) # Match existing DB schema
+16
View File
@@ -171,6 +171,8 @@ async def store_encrypted_file(
encrypted_file_data_b64: str, encrypted_file_data_b64: str,
filename: str, filename: str,
content_type: str = "application/octet-stream", content_type: str = "application/octet-stream",
sender_id: int = None,
recipient_id: int = None,
timeout: float = 30.0, timeout: float = 30.0,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
@@ -205,10 +207,17 @@ async def store_encrypted_file(
try: try:
try: try:
import httpx import httpx
allowed_user_ids = []
if sender_id is not None:
allowed_user_ids.append(sender_id)
if recipient_id is not None:
allowed_user_ids.append(recipient_id)
payload = { payload = {
"filename": filename, "filename": filename,
"data_b64": encrypted_file_data_b64, "data_b64": encrypted_file_data_b64,
"content_type": content_type, "content_type": content_type,
"allowed_user_ids": allowed_user_ids,
} }
async with httpx.AsyncClient(timeout=timeout) as client: async with httpx.AsyncClient(timeout=timeout) as client:
r = await client.post(url, json=payload) r = await client.post(url, json=payload)
@@ -216,10 +225,17 @@ async def store_encrypted_file(
return r.json() return r.json()
except Exception: except Exception:
from urllib import request from urllib import request
allowed_user_ids = []
if sender_id is not None:
allowed_user_ids.append(sender_id)
if recipient_id is not None:
allowed_user_ids.append(recipient_id)
payload = { payload = {
"filename": filename, "filename": filename,
"data_b64": encrypted_file_data_b64, "data_b64": encrypted_file_data_b64,
"content_type": content_type, "content_type": content_type,
"allowed_user_ids": allowed_user_ids,
} }
req = request.Request(url, method="POST") req = request.Request(url, method="POST")
req.data = json.dumps(payload).encode("utf-8") req.data = json.dumps(payload).encode("utf-8")
+39 -9
View File
@@ -6,7 +6,7 @@ on message deletion, and configurable retention policies for cryptographic keys.
Key Features: Key Features:
- Automatic compliance key destruction (default: 6 months) - Automatic compliance key destruction (default: 6 months)
- Selective key destruction on message deletion - Selective key destruction on message deletion (default: 6 months)
- Configurable retention policies - Configurable retention policies
- Background cleanup jobs for expired keys - Background cleanup jobs for expired keys
""" """
@@ -24,7 +24,7 @@ logger = logging.getLogger("uvicorn.error")
# Default retention periods (in days) # Default retention periods (in days)
DEFAULT_COMPLIANCE_KEY_RETENTION_DAYS = 180 # 6 months DEFAULT_COMPLIANCE_KEY_RETENTION_DAYS = 180 # 6 months
DEFAULT_MESSAGE_KEY_RETENTION_DAYS = 30 # 30 days for deleted messages DEFAULT_MESSAGE_KEY_RETENTION_DAYS = 180 # 6 months for deleted messages
# Environment variable overrides # Environment variable overrides
COMPLIANCE_KEY_RETENTION_DAYS = int(os.getenv("COMPLIANCE_KEY_RETENTION_DAYS", DEFAULT_COMPLIANCE_KEY_RETENTION_DAYS)) COMPLIANCE_KEY_RETENTION_DAYS = int(os.getenv("COMPLIANCE_KEY_RETENTION_DAYS", DEFAULT_COMPLIANCE_KEY_RETENTION_DAYS))
@@ -147,7 +147,8 @@ def cleanup_expired_message_keys(db: Session) -> int:
Clean up message keys for deleted messages after retention period. Clean up message keys for deleted messages after retention period.
This removes sender and recipient wrapped keys from DM envelopes that have been This removes sender and recipient wrapped keys from DM envelopes that have been
deleted and are past the retention period, making them completely inaccessible. soft-deleted and are past the retention period, making them completely inaccessible
except through compliance access (which preserves the compliance key).
Args: Args:
db: Database session db: Database session
@@ -156,16 +157,45 @@ def cleanup_expired_message_keys(db: Session) -> int:
Number of keys destroyed Number of keys destroyed
""" """
try: try:
# Note: We don't have a direct "deleted" flag on DMEnvelope, so this would need from datetime import datetime, timedelta
# to be implemented when message deletion is added. For now, this is a placeholder. from ..main.models import DMEnvelope
# This would typically work with a deletion timestamp or flag on the envelope # Calculate cutoff date for expired messages
# For now, return 0 as we don't have deleted message tracking yet cutoff_date = datetime.now() - get_message_key_retention_period()
logger.info("Message key cleanup: No deleted messages to process")
return 0 # Find soft-deleted messages past retention period
expired_messages = db.query(DMEnvelope).filter(
DMEnvelope.deleted_at.is_not(None),
DMEnvelope.deleted_at < cutoff_date
).all()
if not expired_messages:
logger.info("Message key cleanup: No expired deleted messages to process")
return 0
keys_destroyed = 0
for message in expired_messages:
# Destroy sender and recipient keys (compliance key remains for legal access)
message.sender_wrapped_mek_b64 = ""
message.recipient_wrapped_mek_b64 = ""
keys_destroyed += 2
logger.info(
"Destroyed keys for soft-deleted message id=%s (deleted %s)",
message.id,
message.deleted_at.isoformat()
)
db.commit()
logger.info("Message key cleanup: Destroyed %d keys across %d messages",
keys_destroyed, len(expired_messages))
return keys_destroyed
except Exception as e: except Exception as e:
logger.error(f"Failed to cleanup expired message keys: {e}") logger.error(f"Failed to cleanup expired message keys: {e}")
db.rollback()
return 0 return 0
-1
View File
@@ -386,7 +386,6 @@ async def process_message_with_files_http(request: ProcessMessageWithFilesReques
logger.exception("Failed to process message with files: %s", e) logger.exception("Failed to process message with files: %s", e)
raise raise
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
port = int(os.getenv("PORT", "8301")) port = int(os.getenv("PORT", "8301"))
+14 -47
View File
@@ -5,6 +5,7 @@ Provides:
- Request size limiting (max 5GB) - Request size limiting (max 5GB)
- Input validation and sanitization - Input validation and sanitization
- Comprehensive audit logging - Comprehensive audit logging
- Health check access log filtering
""" """
import logging import logging
@@ -15,6 +16,16 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response from starlette.responses import Response
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
access_logger = logging.getLogger("uvicorn.access")
class HealthCheckFilter(logging.Filter):
"""Filter to suppress access logs for health check requests."""
def filter(self, record: logging.LogRecord) -> bool:
"""Return False to suppress logs containing health check requests."""
message = record.getMessage()
return "GET /health HTTP/" not in message
# Maximum request size: 5GB # Maximum request size: 5GB
MAX_REQUEST_SIZE = 5 * 1024 * 1024 * 1024 # 5GB in bytes MAX_REQUEST_SIZE = 5 * 1024 * 1024 * 1024 # 5GB in bytes
@@ -48,50 +59,9 @@ class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
return await call_next(request) return await call_next(request)
class AuditLoggingMiddleware(BaseHTTPMiddleware): # Apply health check filter to access logger
"""Middleware for comprehensive audit logging of all requests.""" if not any(isinstance(f, HealthCheckFilter) for f in access_logger.filters):
access_logger.addFilter(HealthCheckFilter())
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): def add_security_middleware(app: FastAPI):
@@ -103,6 +73,3 @@ def add_security_middleware(app: FastAPI):
""" """
# Request size limiting (inner, checked first) # Request size limiting (inner, checked first)
app.add_middleware(RequestSizeLimitMiddleware) app.add_middleware(RequestSizeLimitMiddleware)
# Audit logging (outer, logs everything)
app.add_middleware(AuditLoggingMiddleware)
+111
View File
@@ -0,0 +1,111 @@
# ============================================================================
# COMPLIANCE ARCHITECTURE - Unified Dockerfile
# ============================================================================
# Base stage with common dependencies for all services
FROM python:3.12-slim AS base
# Create common directories
RUN mkdir -p /app && \
useradd -u 1000 -m app && \
useradd -u 1001 -m messaging && \
useradd -u 1002 -m -s /bin/false filestorage
# Set working directory
WORKDIR /app
# Copy health check script
COPY --chown=app:app deployment/healthcheck.py /usr/local/bin/healthcheck.py
RUN chmod +x /usr/local/bin/healthcheck.py
# Copy and install Python dependencies with pip cache
COPY --chown=app:app backend/requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
# ============================================================================
# MAIN SERVICE - User-facing operations
# ============================================================================
FROM base AS main
# Copy main service code
COPY --chown=app:app backend/services/main/ ./services/main/
COPY --chown=app:app backend/services/shared/ ./services/shared/
COPY --chown=app:app backend/alembic/ ./alembic/
COPY --chown=app:app backend/alembic.ini ./
# Create data directories for main service
RUN mkdir -p /app/data /app/logs /app/alembic/versions && \
chown -R app:app /app/data /app/logs /app/alembic
# Switch to non-root user
USER app
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD python3 /usr/local/bin/healthcheck.py
# Expose port
EXPOSE ${PORT:-8300}
# Run main service
CMD ["python", "-m", "services.main.main"]
# ============================================================================
# MESSAGING SERVICE - Secure cryptographic processing
# ============================================================================
FROM base AS messaging
# Copy messaging service code
COPY --chown=messaging:messaging backend/services/messaging/ ./services/messaging/
COPY --chown=messaging:messaging backend/services/shared/ ./services/shared/
# Create directories with restricted permissions
RUN mkdir -p /app/logs && \
chown -R messaging:messaging /app && \
chmod 700 /app
# Switch to non-root user
USER messaging
# Health check - only accessible internally
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD python3 /usr/local/bin/healthcheck.py
# Expose port (internal only)
EXPOSE ${PORT:-8301}
# Run messaging service
CMD ["python", "-m", "services.messaging.main"]
# ============================================================================
# FILE STORAGE SERVICE - Secure file storage with execution prevention
# ============================================================================
FROM base AS file_storage
# Copy file storage service code
COPY --chown=filestorage:filestorage backend/services/file_storage/ ./services/file_storage/
COPY --chown=filestorage:filestorage backend/services/shared/ ./services/shared/
COPY --chown=filestorage:filestorage backend/services/main/db.py ./services/main/
COPY --chown=filestorage:filestorage backend/services/main/dependencies.py ./services/main/
COPY --chown=filestorage:filestorage backend/services/main/models.py ./services/main/
COPY --chown=filestorage:filestorage backend/services/main/constants.py ./services/main/
COPY --chown=filestorage:filestorage backend/services/main/utils.py ./services/main/
# Create secure file storage directories
RUN mkdir -p /app/files /app/logs && \
chown -R filestorage:filestorage /app && \
chmod 700 /app
# Switch to non-root user
USER filestorage
# Health check - only accessible internally
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD python3 /usr/local/bin/healthcheck.py
# Expose port (internal only)
EXPOSE ${PORT:-8302}
# Run file storage service with permission fix
CMD ["sh", "-c", "chown -R filestorage:filestorage /app/files /app/logs 2>/dev/null || true && exec python -m services.file_storage.main"]
+98
View File
@@ -0,0 +1,98 @@
# FromChat Compliance Architecture - Docker Deployment
This directory contains the Docker configuration for the 3-service compliance architecture.
## Architecture Overview
```
┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Clients │────│ Main Service │────│ Messaging │
│ │ │ (Port 8300) │ │ Service │
│ Web/Apps │ │ │ │ (Port 8301) │
│ │ │ • User auth │ │ • Encryption │
└─────────────┘ │ • WebSocket │ │ • Compliance │
│ • API proxy │ │ • No ext access │
└─────────────────┘ └─────────────────┘
│ │
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ File Storage │ │ PostgreSQL │
│ Service │ │ Database │
│ (Port 8302) │ │ • Main schema │
│ • Secure files │ │ • Messaging │
│ • No ext access │ │ • File schema │
└─────────────────┘ └─────────────────┘
```
## Docker Build Optimization
- **Unified Dockerfile**: Single Dockerfile with multi-stage builds for all services
- **Shared Base**: Common Python dependencies cached in base stage
- **Zero System Dependencies**: No gcc, curl, or system packages - pure Python
- **Python Health Checks**: Built-in health monitoring using urllib
- **Aggressive Caching**: Pip cache and layer optimization
- **Security**: Non-root users, restricted permissions per service
## Security Features
- **Network Isolation**: Messaging and file storage services have NO external network access
- **Database Separation**: Each service has its own schema with minimal required permissions
- **Secure File Storage**: File storage uses restricted permissions and user isolation
- **Ephemeral Keys**: Messaging service generates temporary keys (never persisted)
## Environment Variables Required
Create a `.env` file in this directory with the following variables:
```bash
# Database
POSTGRES_PASSWORD=your_secure_postgres_password
MAIN_DB_PASSWORD=separate_password_for_main_service
MESSAGING_DB_PASSWORD=separate_password_for_messaging
FILE_STORAGE_DB_PASSWORD=separate_password_for_file_storage
# Security
JWT_SECRET=your_jwt_secret_key
VAPID_PUBLIC_KEY=generated_vapid_public_key
VAPID_PRIVATE_KEY=generated_vapid_private_key
FIREBASE_CERT='{"type":"service_account",...}'
# Compliance (public key only - private key stays offline)
COMPLIANCE_PUBLIC_KEY=base64_encoded_public_key
```
## Deployment Commands
```bash
# Start all services
docker compose up -d
# View logs
docker compose logs -f
# Stop services
docker compose down
# Rebuild and restart
docker compose up -d --build
```
## Development Mode
For local development, set `SERVICE_MODE=development` to run all services in a single Python process instead of containers.
## Network Architecture
- **public**: External client access (main service, frontend, reverse proxy)
- **services**: Internal service communication only (database, messaging, file storage)
- Messaging and file storage services have NO external network access
- All inter-service communication is HTTP-based with proper authentication
## Database Schema Separation
- `fromchat_main`: User data, authentication, profiles
- `fromchat_messaging`: Encrypted messages, keys, compliance data
- `fromchat_files`: File metadata, storage references
Each service has minimal required database permissions for security isolation.
-1
View File
@@ -196,7 +196,6 @@ services:
- db:/var/lib/postgresql/data - db:/var/lib/postgresql/data
networks: networks:
- services - services
- public
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"] test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s interval: 10s
+4 -1
View File
@@ -33,7 +33,10 @@ RUN npm run build
# 3. Put it all together # 3. Put it all together
FROM node:24-slim FROM node:24-slim
# 3.1. Non-root user # 3.1. Install curl for health checks
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# 3.2. Non-root user
RUN useradd -u 1001 app && \ RUN useradd -u 1001 app && \
mkdir -p /app && \ mkdir -p /app && \
chown -R app /app && \ chown -R app /app && \
+7
View File
@@ -5,6 +5,7 @@ import { resolve } from 'path';
const app = express(); const app = express();
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
const backendHost = process.env.BACKEND_HOST || "http://localhost:8300"; const backendHost = process.env.BACKEND_HOST || "http://localhost:8300";
const fileStorageHost = process.env.FILE_STORAGE_HOST || "http://localhost:8302";
const filePath = process.env.STATIC_FILE_PATH || "."; const filePath = process.env.STATIC_FILE_PATH || ".";
// API proxy middleware // API proxy middleware
@@ -15,6 +16,12 @@ app.use('/api', createProxyMiddleware({
ws: true ws: true
})); }));
// File serving proxy middleware
app.use('/uploads/files', createProxyMiddleware({
target: fileStorageHost,
changeOrigin: true
}));
// Serve static files // Serve static files
app.use(express.static(resolve(filePath))); app.use(express.static(resolve(filePath)));
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""
Simple health check script using built-in urllib
Replaces curl dependency in Docker health checks
"""
import sys
import urllib.request
import os
def main():
port = os.getenv('PORT', '8300')
url = f'http://localhost:{port}/health'
try:
with urllib.request.urlopen(url, timeout=10) as response:
if response.status == 200:
print("OK")
sys.exit(0)
else:
print(f"HTTP {response.status}")
sys.exit(1)
except Exception as e:
print(f"FAILED: {e}")
sys.exit(1)
if __name__ == '__main__':
main()
-16
View File
@@ -40,19 +40,9 @@ export async function decrypt(envelope: DmEnvelope, userId?: number): Promise<st
const wrappedMekB64 = envelope.wrapped_mek_b64; const wrappedMekB64 = envelope.wrapped_mek_b64;
if (!wrappedMekB64) throw new Error("No wrapped MEK available for decryption"); if (!wrappedMekB64) throw new Error("No wrapped MEK available for decryption");
console.log("🔐 Decrypting DM envelope:", {
id: envelope.id,
senderId: envelope.senderId,
recipientId: envelope.recipientId,
hasWrappedMek: !!wrappedMekB64,
wrappedMekLength: wrappedMekB64?.length
});
// Unwrap the MEK using shared logic // Unwrap the MEK using shared logic
const mek = await unwrapMek(wrappedMekB64, envelope, userId); const mek = await unwrapMek(wrappedMekB64, envelope, userId);
console.log("🔓 MEK unwrapped successfully, length:", mek.length);
// Decrypt the message using the unwrapped MEK // Decrypt the message using the unwrapped MEK
// Server encrypts with AES-GCM, so client decrypts with AES-GCM // Server encrypts with AES-GCM, so client decrypts with AES-GCM
// envelope.iv_b64 and envelope.ciphertext_b64 are base64-encoded separately // envelope.iv_b64 and envelope.ciphertext_b64 are base64-encoded separately
@@ -60,15 +50,9 @@ export async function decrypt(envelope: DmEnvelope, userId?: number): Promise<st
const messageNonce = ub64(envelope.iv_b64 || ""); const messageNonce = ub64(envelope.iv_b64 || "");
const messageCiphertext = ub64(envelope.ciphertext_b64); const messageCiphertext = ub64(envelope.ciphertext_b64);
console.log("💬 Message decryption with AES-GCM:", {
ivLength: messageNonce.length,
ciphertextLength: messageCiphertext.length
});
const plaintext = await aesGcmDecrypt(messageKey, messageNonce, messageCiphertext); const plaintext = await aesGcmDecrypt(messageKey, messageNonce, messageCiphertext);
const result = new TextDecoder().decode(plaintext); const result = new TextDecoder().decode(plaintext);
console.log("✅ Decryption successful:", result);
return result; return result;
} catch (error) { } catch (error) {
console.error("❌ Failed to decrypt DM envelope:", error); console.error("❌ Failed to decrypt DM envelope:", error);
+3 -5
View File
@@ -319,11 +319,9 @@ export interface DMEditPayload {
id: number; id: number;
senderId: number; senderId: number;
recipientId: number; recipientId: number;
iv: string; iv_b64: string;
ciphertext: string; ciphertext_b64: string;
iv2: string; wrapped_mek_b64: string;
wrappedMk: string;
salt: string;
timestamp: string; timestamp: string;
} }
@@ -55,12 +55,6 @@ export class DMPanel extends MessagePanel {
} }
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) { private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
console.log("🔔 DMPanel parsing message:", {
envelopeId: env.id,
currentUserId: this.currentUser.currentUser?.id,
envelopeRecipientId: env.recipientId,
envelopeSenderId: env.senderId
});
const plaintext = await api.chats.dm.decrypt(env, this.currentUser.currentUser?.id); const plaintext = await api.chats.dm.decrypt(env, this.currentUser.currentUser?.id);
const username = formatDMUsername( const username = formatDMUsername(
env.senderId, env.senderId,
@@ -258,34 +252,37 @@ export class DMPanel extends MessagePanel {
} }
} }
if (response.type === "dmEdited" && this.dmData) { if (response.type === "dmEdited" && this.dmData) {
const { id, iv, ciphertext, wrappedMk } = response.data; const { id, senderId, recipientId, iv_b64, ciphertext_b64, wrapped_mek_b64, timestamp } = response.data;
try { if (!wrapped_mek_b64) {
// Decrypt new content in-place
const plaintext = await api.chats.dm.decrypt(
{
id,
senderId: 0,
recipientId: 0,
iv_b64: iv,
ciphertext_b64: ciphertext,
wrapped_mek_b64: wrappedMk,
timestamp: new Date().toISOString()
},
this.currentUser.currentUser?.id
);
let content = plaintext;
let files: Message["files"] | undefined = undefined;
try {
const obj = JSON.parse(plaintext) as EncryptedMessageJson;
if (obj.type === "text" && obj.data) {
content = obj.data.content;
files = obj.data.files;
}
} catch {}
const updates: Partial<Message> = { content, is_edited: true, files };
this.updateMessage(id, updates);
} catch (e) {
this.updateMessage(id, { is_edited: true }); this.updateMessage(id, { is_edited: true });
} else {
try {
const plaintext = await api.chats.dm.decrypt(
{
id,
senderId: senderId ?? 0,
recipientId: recipientId ?? 0,
iv_b64: iv_b64 ?? "",
ciphertext_b64: ciphertext_b64 ?? "",
wrapped_mek_b64,
timestamp: timestamp ?? new Date().toISOString()
},
this.currentUser.currentUser?.id
);
let content = plaintext;
let files: Message["files"] | undefined = undefined;
try {
const obj = JSON.parse(plaintext) as EncryptedMessageJson;
if (obj.type === "text" && obj.data) {
content = obj.data.content;
files = obj.data.files;
}
} catch {}
const updates: Partial<Message> = { content, is_edited: true, files };
this.updateMessage(id, updates);
} catch (e) {
this.updateMessage(id, { is_edited: true });
}
} }
} }
if (response.type === "dmDeleted" && this.dmData) { if (response.type === "dmDeleted" && this.dmData) {
+1 -1
View File
@@ -2,7 +2,7 @@
echo > deployment/.env echo > deployment/.env
./.venv/bin/python3 backend/generate_vapid_keys.py >> deployment/.env ./.venv/bin/python3 backend/services/main/generate_vapid_keys.py >> deployment/.env
cat >> deployment/.env <<EOF cat >> deployment/.env <<EOF
JWT_SECRET="$(openssl rand -base64 32)" JWT_SECRET="$(openssl rand -base64 32)"