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
+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,
}