mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
393 lines
13 KiB
Python
393 lines
13 KiB
Python
"""
|
|
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)
|