mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Restructure backend into microservices, add envelope encryption, DM files, and message editing
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Messaging service module
|
||||
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Envelope encryption module for the messaging service.
|
||||
|
||||
Handles:
|
||||
- Transport encryption/decryption with ephemeral X25519 keys
|
||||
- MEK (Message Encryption Key) generation and management
|
||||
- Envelope encryption for messages using AES-GCM
|
||||
- MEK wrapping for compliance, sender, and recipient keys
|
||||
"""
|
||||
|
||||
import os
|
||||
import base64
|
||||
import logging
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from nacl.public import Box, PrivateKey, PublicKey
|
||||
import nacl.bindings as sodium
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Nonce/IV sizes
|
||||
TRANSPORT_NONCE_SIZE = 24 # For X25519 transport encryption (PyNaCl Box/XSalsa20Poly1305)
|
||||
MEK_NONCE_SIZE = 12 # For AES-GCM content encryption
|
||||
MEK_SIZE = 32 # Message Encryption Key size
|
||||
|
||||
|
||||
def generate_mek() -> bytes:
|
||||
"""Generate a random Message Encryption Key (32 bytes)."""
|
||||
return os.urandom(MEK_SIZE)
|
||||
|
||||
|
||||
def generate_nonce(size: int = MEK_NONCE_SIZE) -> bytes:
|
||||
"""Generate a random nonce for AES-GCM."""
|
||||
return os.urandom(size)
|
||||
|
||||
|
||||
def derive_shared_secret(private_key: X25519PrivateKey, peer_public_key_b64: str) -> bytes:
|
||||
"""
|
||||
Compute a shared secret from a private key and peer's public key using X25519.
|
||||
|
||||
Args:
|
||||
private_key: X25519PrivateKey
|
||||
peer_public_key_b64: Peer's public key in base64 (raw format)
|
||||
|
||||
Returns:
|
||||
Shared secret (32 bytes)
|
||||
"""
|
||||
try:
|
||||
peer_public_bytes = base64.b64decode(peer_public_key_b64)
|
||||
peer_public_key = X25519PublicKey.from_public_bytes(peer_public_bytes)
|
||||
return private_key.exchange(peer_public_key)
|
||||
except Exception as e:
|
||||
logger.error("Failed to derive shared secret: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
def derive_key_from_shared_secret(shared_secret: bytes, context: str, key_size: int = MEK_SIZE) -> bytes:
|
||||
"""
|
||||
Derive a key from a shared secret using HKDF-SHA256.
|
||||
|
||||
Args:
|
||||
shared_secret: The shared secret from ECDH
|
||||
context: Context string for key derivation (e.g., "transport_key")
|
||||
key_size: Output key size in bytes (default 32)
|
||||
|
||||
Returns:
|
||||
Derived key bytes
|
||||
"""
|
||||
hkdf = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=key_size,
|
||||
salt=b"\x00" * 16, # 16 zero bytes salt
|
||||
info=context.encode(),
|
||||
)
|
||||
return hkdf.derive(shared_secret)
|
||||
|
||||
|
||||
def decrypt_transport_message(
|
||||
client_public_key_b64: str,
|
||||
nonce_b64: str,
|
||||
ciphertext_b64: str,
|
||||
ephemeral_private_key: X25519PrivateKey,
|
||||
) -> bytes:
|
||||
"""
|
||||
Decrypt a message that was encrypted with the ephemeral public key.
|
||||
|
||||
The client encrypts plaintext with the ephemeral transport key using tweetnacl.box,
|
||||
which performs ECDH + XSalsa20Poly1305 encryption.
|
||||
|
||||
Args:
|
||||
client_public_key_b64: Client's ephemeral public key (base64, raw X25519)
|
||||
nonce_b64: Encryption nonce (base64, 24 bytes for XSalsa20Poly1305)
|
||||
ciphertext_b64: Encrypted message (base64)
|
||||
ephemeral_private_key: Server's ephemeral X25519 private key
|
||||
|
||||
Returns:
|
||||
Decrypted plaintext
|
||||
"""
|
||||
try:
|
||||
# Convert cryptography X25519 key to raw bytes
|
||||
server_private_bytes = ephemeral_private_key.private_bytes_raw()
|
||||
|
||||
# Convert client public key from base64 to raw bytes
|
||||
client_public_bytes = base64.b64decode(client_public_key_b64)
|
||||
|
||||
# Decode nonce and ciphertext
|
||||
nonce = base64.b64decode(nonce_b64)
|
||||
ciphertext = base64.b64decode(ciphertext_b64)
|
||||
|
||||
# Decrypt using PyNaCl's low-level function (compatible with tweetnacl)
|
||||
# Parameters: ciphertext, nonce, sender_public_key, recipient_private_key
|
||||
plaintext = sodium.crypto_box_open_easy(
|
||||
ciphertext,
|
||||
nonce,
|
||||
client_public_bytes, # sender public key
|
||||
server_private_bytes # recipient private key
|
||||
)
|
||||
return plaintext
|
||||
except Exception as e:
|
||||
logger.error("Failed to decrypt transport message: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
def decrypt_transport_blob(
|
||||
client_public_key_b64: str,
|
||||
encrypted_blob: bytes,
|
||||
ephemeral_private_key: X25519PrivateKey,
|
||||
nonce_size: int = TRANSPORT_NONCE_SIZE,
|
||||
) -> bytes:
|
||||
"""
|
||||
Decrypt a transport-encrypted binary blob produced by `tweetnacl.box`.
|
||||
|
||||
The client sends a single blob that is `nonce || ciphertext`.
|
||||
This function extracts the nonce and decrypts the ciphertext using the server's
|
||||
ephemeral transport private key and the client's public key.
|
||||
|
||||
Args:
|
||||
client_public_key_b64: Sender public key in base64 (raw X25519).
|
||||
encrypted_blob: Raw bytes of `nonce || ciphertext`.
|
||||
ephemeral_private_key: Server ephemeral X25519 private key.
|
||||
nonce_size: Nonce size in bytes (24 for XSalsa20-Poly1305).
|
||||
|
||||
Returns:
|
||||
Decrypted plaintext bytes.
|
||||
"""
|
||||
if len(encrypted_blob) < nonce_size + 16:
|
||||
# crypto_box has a MAC; ciphertext must have at least some overhead.
|
||||
raise ValueError("Encrypted blob is too short to contain nonce + ciphertext")
|
||||
|
||||
nonce = encrypted_blob[:nonce_size]
|
||||
ciphertext = encrypted_blob[nonce_size:]
|
||||
|
||||
try:
|
||||
server_private_bytes = ephemeral_private_key.private_bytes_raw()
|
||||
client_public_bytes = base64.b64decode(client_public_key_b64)
|
||||
plaintext = sodium.crypto_box_open_easy(
|
||||
ciphertext,
|
||||
nonce,
|
||||
client_public_bytes, # sender public key
|
||||
server_private_bytes, # recipient private key
|
||||
)
|
||||
return plaintext
|
||||
except Exception as e:
|
||||
logger.error("Failed to decrypt transport blob: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
def encrypt_message(plaintext: bytes, mek: bytes) -> tuple[str, str]:
|
||||
"""
|
||||
Encrypt plaintext using AES-GCM with a Message Encryption Key.
|
||||
|
||||
Args:
|
||||
plaintext: Message content to encrypt
|
||||
mek: Message Encryption Key (32 bytes)
|
||||
|
||||
Returns:
|
||||
Tuple of (nonce_b64, ciphertext_b64) for storage
|
||||
"""
|
||||
cipher = AESGCM(mek)
|
||||
nonce = generate_nonce(MEK_NONCE_SIZE)
|
||||
ciphertext = cipher.encrypt(nonce, plaintext, None)
|
||||
return base64.b64encode(nonce).decode("utf-8"), base64.b64encode(ciphertext).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_message(nonce_b64: str, ciphertext_b64: str, mek: bytes) -> bytes:
|
||||
"""
|
||||
Decrypt ciphertext using the MEK.
|
||||
|
||||
Args:
|
||||
nonce_b64: Base64-encoded nonce
|
||||
ciphertext_b64: Base64-encoded ciphertext + tag
|
||||
mek: Message Encryption Key (32 bytes)
|
||||
|
||||
Returns:
|
||||
Plaintext bytes
|
||||
"""
|
||||
try:
|
||||
nonce = base64.b64decode(nonce_b64)
|
||||
ciphertext = base64.b64decode(ciphertext_b64)
|
||||
cipher = AESGCM(mek)
|
||||
plaintext = cipher.decrypt(nonce, ciphertext, None)
|
||||
return plaintext
|
||||
except Exception as e:
|
||||
logger.error("Failed to decrypt message: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
def wrap_mek(mek: bytes, wrap_key: bytes) -> str:
|
||||
"""
|
||||
Wrap a MEK using a key encryption key (wrap_key).
|
||||
Encrypts MEK with AES-256-GCM and returns base64-encoded result.
|
||||
|
||||
Args:
|
||||
mek: Message Encryption Key to wrap (32 bytes)
|
||||
wrap_key: Key to wrap with (32 bytes)
|
||||
|
||||
Returns:
|
||||
Base64-encoded (nonce + ciphertext + tag)
|
||||
"""
|
||||
cipher = AESGCM(wrap_key)
|
||||
nonce = generate_nonce(MEK_NONCE_SIZE)
|
||||
ciphertext = cipher.encrypt(nonce, mek, None)
|
||||
wrapped = nonce + ciphertext
|
||||
return base64.b64encode(wrapped).decode("utf-8")
|
||||
|
||||
|
||||
def unwrap_mek(wrapped_b64: str, wrap_key: bytes) -> bytes:
|
||||
"""
|
||||
Unwrap a MEK using a key encryption key (wrap_key).
|
||||
|
||||
Args:
|
||||
wrapped_b64: Base64-encoded (nonce + ciphertext + tag)
|
||||
wrap_key: Key to unwrap with (32 bytes)
|
||||
|
||||
Returns:
|
||||
Unwrapped MEK (32 bytes)
|
||||
"""
|
||||
try:
|
||||
wrapped = base64.b64decode(wrapped_b64)
|
||||
nonce = wrapped[:MEK_NONCE_SIZE]
|
||||
ciphertext = wrapped[MEK_NONCE_SIZE:]
|
||||
cipher = AESGCM(wrap_key)
|
||||
mek = cipher.decrypt(nonce, ciphertext, None)
|
||||
return mek
|
||||
except Exception as e:
|
||||
logger.error("Failed to unwrap MEK: %s", e)
|
||||
raise
|
||||
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Key Lifecycle Management for Compliance and Security.
|
||||
|
||||
This module handles automatic destruction of compliance keys, selective key destruction
|
||||
on message deletion, and configurable retention policies for cryptographic keys.
|
||||
|
||||
Key Features:
|
||||
- Automatic compliance key destruction (default: 6 months)
|
||||
- Selective key destruction on message deletion
|
||||
- Configurable retention policies
|
||||
- Background cleanup jobs for expired keys
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..main.models import DMEnvelope, MessageEditHistory, DMEditHistory
|
||||
from .encryption import generate_nonce, TRANSPORT_NONCE_SIZE
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
# Default retention periods (in days)
|
||||
DEFAULT_COMPLIANCE_KEY_RETENTION_DAYS = 180 # 6 months
|
||||
DEFAULT_MESSAGE_KEY_RETENTION_DAYS = 30 # 30 days for deleted messages
|
||||
|
||||
# Environment variable overrides
|
||||
COMPLIANCE_KEY_RETENTION_DAYS = int(os.getenv("COMPLIANCE_KEY_RETENTION_DAYS", DEFAULT_COMPLIANCE_KEY_RETENTION_DAYS))
|
||||
MESSAGE_KEY_RETENTION_DAYS = int(os.getenv("MESSAGE_KEY_RETENTION_DAYS", DEFAULT_MESSAGE_KEY_RETENTION_DAYS))
|
||||
|
||||
|
||||
def get_compliance_key_retention_period() -> timedelta:
|
||||
"""Get the retention period for compliance keys."""
|
||||
return timedelta(days=COMPLIANCE_KEY_RETENTION_DAYS)
|
||||
|
||||
|
||||
def get_message_key_retention_period() -> timedelta:
|
||||
"""Get the retention period for message keys after deletion."""
|
||||
return timedelta(days=MESSAGE_KEY_RETENTION_DAYS)
|
||||
|
||||
|
||||
def destroy_compliance_keys_for_message(db: Session, message_id: int) -> int:
|
||||
"""
|
||||
Destroy compliance keys for a specific message.
|
||||
|
||||
This removes the compliance_wrapped_mek_b64 from DM envelopes,
|
||||
making the message permanently inaccessible for compliance purposes.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
message_id: ID of the message to destroy compliance keys for
|
||||
|
||||
Returns:
|
||||
Number of envelopes affected
|
||||
"""
|
||||
try:
|
||||
# Find all DM envelopes for this message
|
||||
envelopes = db.query(DMEnvelope).filter(DMEnvelope.id == message_id).all()
|
||||
|
||||
destroyed_count = 0
|
||||
for envelope in envelopes:
|
||||
if envelope.compliance_wrapped_mek_b64:
|
||||
envelope.compliance_wrapped_mek_b64 = None
|
||||
destroyed_count += 1
|
||||
|
||||
if destroyed_count > 0:
|
||||
db.commit()
|
||||
logger.info(f"Destroyed compliance keys for {destroyed_count} DM envelopes (message_id={message_id})")
|
||||
|
||||
return destroyed_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to destroy compliance keys for message {message_id}: {e}")
|
||||
db.rollback()
|
||||
return 0
|
||||
|
||||
|
||||
def destroy_compliance_keys_for_dm_envelope(db: Session, dm_envelope_id: int) -> bool:
|
||||
"""
|
||||
Destroy compliance key for a specific DM envelope.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
dm_envelope_id: ID of the DM envelope
|
||||
|
||||
Returns:
|
||||
True if key was destroyed, False otherwise
|
||||
"""
|
||||
try:
|
||||
envelope = db.query(DMEnvelope).filter(DMEnvelope.id == dm_envelope_id).first()
|
||||
if envelope and envelope.compliance_wrapped_mek_b64:
|
||||
envelope.compliance_wrapped_mek_b64 = None
|
||||
db.commit()
|
||||
logger.info(f"Destroyed compliance key for DM envelope {dm_envelope_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to destroy compliance key for DM envelope {dm_envelope_id}: {e}")
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
def cleanup_expired_compliance_keys(db: Session) -> int:
|
||||
"""
|
||||
Clean up expired compliance keys based on retention policy.
|
||||
|
||||
This removes compliance_wrapped_mek_b64 from DM envelopes that are older
|
||||
than the retention period, making them permanently inaccessible for compliance.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Number of keys destroyed
|
||||
"""
|
||||
try:
|
||||
cutoff_date = datetime.now() - get_compliance_key_retention_period()
|
||||
|
||||
# Find DM envelopes older than retention period that still have compliance keys
|
||||
expired_envelopes = db.query(DMEnvelope).filter(
|
||||
DMEnvelope.timestamp < cutoff_date,
|
||||
DMEnvelope.compliance_wrapped_mek_b64.isnot(None)
|
||||
).all()
|
||||
|
||||
destroyed_count = 0
|
||||
for envelope in expired_envelopes:
|
||||
envelope.compliance_wrapped_mek_b64 = None
|
||||
destroyed_count += 1
|
||||
|
||||
if destroyed_count > 0:
|
||||
db.commit()
|
||||
logger.info(f"Cleaned up {destroyed_count} expired compliance keys (retention: {COMPLIANCE_KEY_RETENTION_DAYS} days)")
|
||||
|
||||
return destroyed_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cleanup expired compliance keys: {e}")
|
||||
db.rollback()
|
||||
return 0
|
||||
|
||||
|
||||
def cleanup_expired_message_keys(db: Session) -> int:
|
||||
"""
|
||||
Clean up message keys for deleted messages after retention period.
|
||||
|
||||
This removes sender and recipient wrapped keys from DM envelopes that have been
|
||||
deleted and are past the retention period, making them completely inaccessible.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Number of keys destroyed
|
||||
"""
|
||||
try:
|
||||
# Note: We don't have a direct "deleted" flag on DMEnvelope, so this would need
|
||||
# to be implemented when message deletion is added. For now, this is a placeholder.
|
||||
|
||||
# This would typically work with a deletion timestamp or flag on the envelope
|
||||
# For now, return 0 as we don't have deleted message tracking yet
|
||||
logger.info("Message key cleanup: No deleted messages to process")
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cleanup expired message keys: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def cleanup_expired_edit_history(db: Session) -> int:
|
||||
"""
|
||||
Clean up old edit history entries based on retention policy.
|
||||
|
||||
This removes edit history entries that are older than the compliance
|
||||
retention period.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Number of edit history entries removed
|
||||
"""
|
||||
try:
|
||||
cutoff_date = datetime.now() - get_compliance_key_retention_period()
|
||||
|
||||
# Clean up public message edit history
|
||||
public_deleted = db.query(MessageEditHistory).filter(
|
||||
MessageEditHistory.edited_at < cutoff_date
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
# Clean up DM edit history
|
||||
dm_deleted = db.query(DMEditHistory).filter(
|
||||
DMEditHistory.edited_at < cutoff_date
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
total_deleted = public_deleted + dm_deleted
|
||||
|
||||
if total_deleted > 0:
|
||||
db.commit()
|
||||
logger.info(f"Cleaned up {total_deleted} expired edit history entries (retention: {COMPLIANCE_KEY_RETENTION_DAYS} days)")
|
||||
|
||||
return total_deleted
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cleanup expired edit history: {e}")
|
||||
db.rollback()
|
||||
return 0
|
||||
|
||||
|
||||
def run_key_lifecycle_cleanup(db: Session) -> dict:
|
||||
"""
|
||||
Run all key lifecycle cleanup operations.
|
||||
|
||||
This should be called periodically (e.g., daily) to maintain key lifecycle policies.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Dict with cleanup statistics
|
||||
"""
|
||||
logger.info("Starting key lifecycle cleanup")
|
||||
|
||||
stats = {
|
||||
"compliance_keys_destroyed": cleanup_expired_compliance_keys(db),
|
||||
"message_keys_destroyed": cleanup_expired_message_keys(db),
|
||||
"edit_history_entries_removed": cleanup_expired_edit_history(db),
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
logger.info(f"Key lifecycle cleanup completed: {stats}")
|
||||
return stats
|
||||
|
||||
|
||||
def get_key_lifecycle_config() -> dict:
|
||||
"""
|
||||
Get current key lifecycle configuration.
|
||||
|
||||
Returns:
|
||||
Dict with current configuration values
|
||||
"""
|
||||
return {
|
||||
"compliance_key_retention_days": COMPLIANCE_KEY_RETENTION_DAYS,
|
||||
"message_key_retention_days": MESSAGE_KEY_RETENTION_DAYS,
|
||||
"default_compliance_retention": DEFAULT_COMPLIANCE_KEY_RETENTION_DAYS,
|
||||
"default_message_retention": DEFAULT_MESSAGE_KEY_RETENTION_DAYS
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
"""
|
||||
Messaging Service - Secure cryptographic processing for private messages with compliance access.
|
||||
|
||||
This service handles all encryption/decryption operations for private messages and files,
|
||||
providing compliance access while ensuring zero-knowledge storage of plaintext content.
|
||||
|
||||
API Endpoints:
|
||||
- GET /health: Health check
|
||||
- GET /key/public: Get current ephemeral transport public key
|
||||
- POST /key/invalidate: Rotate ephemeral keys
|
||||
- POST /process: Process encrypted message through envelope encryption pipeline
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import base64
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
from fastapi import FastAPI, HTTPException, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
# Import encryption modules
|
||||
from .encryption import generate_nonce, TRANSPORT_NONCE_SIZE, decrypt_transport_blob, decrypt_transport_message
|
||||
from .processor import process_encrypted_message, process_encrypted_message_and_files
|
||||
|
||||
try:
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
except ImportError:
|
||||
X25519PrivateKey = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Compliance Key Management
|
||||
# ============================================================================
|
||||
|
||||
_COMPLIANCE_PUBLIC_KEY_B64: str = ""
|
||||
|
||||
|
||||
def _initialize_compliance_key():
|
||||
"""
|
||||
Initialize compliance public key from environment variable.
|
||||
|
||||
The compliance public key is generated offline on an air-gapped machine.
|
||||
Only the public key is provided to the server via COMPLIANCE_PUBLIC_KEY env variable.
|
||||
The private key never exists on the server - all decryption is done offline.
|
||||
"""
|
||||
global _COMPLIANCE_PUBLIC_KEY_B64
|
||||
|
||||
env_key = os.getenv("COMPLIANCE_PUBLIC_KEY", "").strip()
|
||||
if not env_key:
|
||||
raise RuntimeError(
|
||||
"COMPLIANCE_PUBLIC_KEY environment variable must be set. "
|
||||
"Generate offline on an air-gapped machine: "
|
||||
"X25519 private key → export public key (base64) → set as env var"
|
||||
)
|
||||
|
||||
_COMPLIANCE_PUBLIC_KEY_B64 = env_key
|
||||
logger.info("Loaded compliance public key from COMPLIANCE_PUBLIC_KEY environment variable")
|
||||
|
||||
|
||||
def get_compliance_public_key() -> str:
|
||||
"""Return the compliance system public key."""
|
||||
if not _COMPLIANCE_PUBLIC_KEY_B64:
|
||||
_initialize_compliance_key()
|
||||
return _COMPLIANCE_PUBLIC_KEY_B64
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Ephemeral Key Management
|
||||
# ============================================================================
|
||||
|
||||
_KEY_STATE: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def _generate_keypair():
|
||||
"""
|
||||
Generate a fresh X25519 keypair and store it in memory.
|
||||
|
||||
This generates an ephemeral keypair for the session. The private key is kept
|
||||
in-memory and is never persisted. When a new keypair is generated, the old
|
||||
one is discarded and its associated data is no longer accessible.
|
||||
"""
|
||||
if X25519PrivateKey is None:
|
||||
raise RuntimeError("cryptography library required for X25519 key generation")
|
||||
|
||||
priv = X25519PrivateKey.generate()
|
||||
pub = priv.public_key()
|
||||
pub_bytes = pub.public_bytes(encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw)
|
||||
key_id = str(int(time.time() * 1000)) # Millisecond precision for uniqueness
|
||||
|
||||
_KEY_STATE.clear()
|
||||
_KEY_STATE.update({
|
||||
"key_id": key_id,
|
||||
"private_key": priv,
|
||||
"public_key_b64": base64.b64encode(pub_bytes).decode("ascii"),
|
||||
"created_at": time.time(),
|
||||
})
|
||||
logger.info("Generated new ephemeral keypair with key_id=%s", key_id)
|
||||
|
||||
|
||||
def _get_ephemeral_private_key() -> X25519PrivateKey:
|
||||
"""Retrieve the current ephemeral private key, regenerating if necessary."""
|
||||
if not _KEY_STATE:
|
||||
_generate_keypair()
|
||||
return _KEY_STATE.get("private_key")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FastAPI App Setup
|
||||
# ============================================================================
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup and shutdown event handler."""
|
||||
# Startup: Initialize compliance key and ephemeral keys
|
||||
try:
|
||||
_initialize_compliance_key()
|
||||
_generate_keypair()
|
||||
logger.info("Messaging service: initialized at startup")
|
||||
except Exception as e:
|
||||
logger.error("Messaging service: failed to initialize: %s", e)
|
||||
raise
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Messaging service: shutting down")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="FromChat Messaging Service",
|
||||
description="Secure cryptographic processing service for private messages",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Add security middleware
|
||||
try:
|
||||
from services.shared.middleware import add_security_middleware
|
||||
except ImportError:
|
||||
try:
|
||||
from backend.services.shared.middleware import add_security_middleware
|
||||
except ImportError:
|
||||
add_security_middleware = None
|
||||
|
||||
if add_security_middleware:
|
||||
add_security_middleware(app)
|
||||
|
||||
# CORS configuration for inter-service communication
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Allow all origins for inter-service communication
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Pydantic Models
|
||||
# ============================================================================
|
||||
|
||||
class ProcessMessageRequest(BaseModel):
|
||||
"""
|
||||
Request to process an encrypted message through the envelope encryption pipeline.
|
||||
|
||||
The client must:
|
||||
1. Encrypt plaintext with the ephemeral transport public key using X25519 + ChaCha20
|
||||
2. Provide the encrypted message and associated metadata
|
||||
3. Provide public keys for compliance, sender, and recipient for MEK wrapping
|
||||
"""
|
||||
client_public_key_b64: str
|
||||
transport_nonce_b64: str
|
||||
transport_ciphertext_b64: str
|
||||
compliance_public_key_b64: str
|
||||
sender_public_key_b64: str
|
||||
recipient_public_key_b64: str
|
||||
|
||||
|
||||
class ProcessMessageWithFilesFile(BaseModel):
|
||||
"""
|
||||
A single transport-encrypted file blob (base64 of nonce||ciphertext).
|
||||
"""
|
||||
encrypted_file_data_b64: str
|
||||
|
||||
|
||||
class ProcessMessageWithFilesRequest(ProcessMessageRequest):
|
||||
"""
|
||||
Process a transport-encrypted message and a list of transport-encrypted files
|
||||
using a single MEK for the whole envelope.
|
||||
"""
|
||||
files: list[ProcessMessageWithFilesFile]
|
||||
|
||||
# ============================================================================
|
||||
# Health Checks
|
||||
# ============================================================================
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint for messaging service."""
|
||||
return {"status": "healthy", "service": "messaging"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint for messaging service."""
|
||||
return {"message": "FromChat Messaging Service", "status": "operational"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Ephemeral Key Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@app.get("/key/transport/public")
|
||||
async def get_transport_public_key():
|
||||
"""
|
||||
Return the current ephemeral transport public key for client-side message encryption.
|
||||
|
||||
Clients use this key to encrypt their messages with X25519 + ChaCha20-Poly1305
|
||||
before sending to the server.
|
||||
"""
|
||||
if not _KEY_STATE:
|
||||
try:
|
||||
_generate_keypair()
|
||||
except Exception as e:
|
||||
logger.error("Failed to regenerate ephemeral key: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Key generation failed"
|
||||
)
|
||||
|
||||
return {
|
||||
"key_id": _KEY_STATE.get("key_id"),
|
||||
"public_key_b64": _KEY_STATE.get("public_key_b64"),
|
||||
"created_at": _KEY_STATE.get("created_at"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Message Processing Endpoints
|
||||
# ============================================================================
|
||||
|
||||
async def process_message(
|
||||
client_public_key_b64: str,
|
||||
transport_nonce_b64: str,
|
||||
transport_ciphertext_b64: str,
|
||||
compliance_public_key_b64: str,
|
||||
sender_public_key_b64: str,
|
||||
recipient_public_key_b64: str,
|
||||
):
|
||||
"""
|
||||
Process an encrypted message through the envelope encryption pipeline.
|
||||
|
||||
This is the core processing function used by both HTTP and in-process calls.
|
||||
|
||||
Flow:
|
||||
1. Decrypt client message using transport encryption (ephemeral key)
|
||||
2. Generate random MEK (Message Encryption Key)
|
||||
3. Encrypt plaintext with MEK using ChaCha20-Poly1305
|
||||
4. Wrap MEK for compliance, sender, and recipient
|
||||
5. Return encrypted message + 3 wrapped MEKs
|
||||
|
||||
Args:
|
||||
client_public_key_b64: Client's ephemeral public key
|
||||
transport_nonce_b64: Nonce for transport encryption
|
||||
transport_ciphertext_b64: Encrypted message
|
||||
compliance_public_key_b64: Compliance system public key
|
||||
sender_public_key_b64: Sender's public key
|
||||
recipient_public_key_b64: Recipient's public key
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- nonce: Base64-encoded nonce for content encryption
|
||||
- ciphertext: Base64-encoded encrypted content
|
||||
- compliance_wrapped_mek: Wrapped MEK for compliance system
|
||||
- sender_wrapped_mek: Wrapped MEK for message sender
|
||||
- recipient_wrapped_mek: Wrapped MEK for message recipient
|
||||
"""
|
||||
try:
|
||||
private_key = _get_ephemeral_private_key()
|
||||
|
||||
result = process_encrypted_message(
|
||||
client_public_key_b64=client_public_key_b64,
|
||||
transport_nonce_b64=transport_nonce_b64,
|
||||
transport_ciphertext_b64=transport_ciphertext_b64,
|
||||
compliance_public_key_b64=compliance_public_key_b64,
|
||||
sender_public_key_b64=sender_public_key_b64,
|
||||
recipient_public_key_b64=recipient_public_key_b64,
|
||||
ephemeral_private_key=private_key,
|
||||
)
|
||||
|
||||
logger.info("Successfully processed encrypted message")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to process message: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
@app.post("/process")
|
||||
async def process_message_http(request: ProcessMessageRequest):
|
||||
"""
|
||||
HTTP endpoint for processing encrypted messages.
|
||||
|
||||
Delegates to the core process_message function.
|
||||
"""
|
||||
return await process_message(
|
||||
client_public_key_b64=request.client_public_key_b64,
|
||||
transport_nonce_b64=request.transport_nonce_b64,
|
||||
transport_ciphertext_b64=request.transport_ciphertext_b64,
|
||||
compliance_public_key_b64=request.compliance_public_key_b64,
|
||||
sender_public_key_b64=request.sender_public_key_b64,
|
||||
recipient_public_key_b64=request.recipient_public_key_b64,
|
||||
)
|
||||
|
||||
|
||||
async def process_message_with_files(
|
||||
client_public_key_b64: str,
|
||||
transport_nonce_b64: str,
|
||||
transport_ciphertext_b64: str,
|
||||
compliance_public_key_b64: str,
|
||||
sender_public_key_b64: str,
|
||||
recipient_public_key_b64: str,
|
||||
files: list[str],
|
||||
):
|
||||
"""
|
||||
In-process helper: process message + transport-encrypted files with one MEK.
|
||||
"""
|
||||
private_key = _get_ephemeral_private_key()
|
||||
|
||||
plaintext_message = decrypt_transport_message(
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64,
|
||||
transport_ciphertext_b64,
|
||||
private_key,
|
||||
)
|
||||
|
||||
plaintext_files: list[bytes] = []
|
||||
for encrypted_file_data_b64 in files:
|
||||
transport_blob = base64.b64decode(encrypted_file_data_b64)
|
||||
plaintext_files.append(
|
||||
decrypt_transport_blob(
|
||||
client_public_key_b64=sender_public_key_b64,
|
||||
encrypted_blob=transport_blob,
|
||||
ephemeral_private_key=private_key,
|
||||
)
|
||||
)
|
||||
|
||||
return process_encrypted_message_and_files(
|
||||
plaintext_message=plaintext_message,
|
||||
plaintext_files=plaintext_files,
|
||||
compliance_public_key_b64=compliance_public_key_b64,
|
||||
sender_public_key_b64=sender_public_key_b64,
|
||||
recipient_public_key_b64=recipient_public_key_b64,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/process-with-files")
|
||||
async def process_message_with_files_http(request: ProcessMessageWithFilesRequest):
|
||||
"""
|
||||
Process an encrypted message and its files using a single MEK.
|
||||
|
||||
- Message transport layer is decrypted using the message client ephemeral key
|
||||
- File transport layer is decrypted using the sender long-term public key
|
||||
- One MEK is generated and used to encrypt message + all files
|
||||
- MEK is wrapped for compliance, sender, and recipient (stored on DM envelope)
|
||||
"""
|
||||
try:
|
||||
return await process_message_with_files(
|
||||
client_public_key_b64=request.client_public_key_b64,
|
||||
transport_nonce_b64=request.transport_nonce_b64,
|
||||
transport_ciphertext_b64=request.transport_ciphertext_b64,
|
||||
compliance_public_key_b64=request.compliance_public_key_b64,
|
||||
sender_public_key_b64=request.sender_public_key_b64,
|
||||
recipient_public_key_b64=request.recipient_public_key_b64,
|
||||
files=[f.encrypted_file_data_b64 for f in request.files],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to process message with files: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
port = int(os.getenv("PORT", "8301"))
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Message processing pipeline for envelope encryption.
|
||||
|
||||
This module handles the core envelope encryption workflow:
|
||||
1. Decrypt client-encrypted message (transport encryption)
|
||||
2. Generate random MEK
|
||||
3. Encrypt plaintext with MEK
|
||||
4. Wrap MEK for compliance, sender, and recipient
|
||||
5. Store encrypted message + wrapped keys
|
||||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
import time
|
||||
import base64
|
||||
from typing import Dict, Any, Optional
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
|
||||
from .encryption import (
|
||||
decrypt_transport_message,
|
||||
generate_mek,
|
||||
encrypt_message,
|
||||
wrap_mek,
|
||||
derive_shared_secret,
|
||||
derive_key_from_shared_secret,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
|
||||
def process_encrypted_message(
|
||||
client_public_key_b64: str,
|
||||
transport_nonce_b64: str,
|
||||
transport_ciphertext_b64: str,
|
||||
compliance_public_key_b64: str,
|
||||
sender_public_key_b64: str,
|
||||
recipient_public_key_b64: str,
|
||||
ephemeral_private_key: X25519PrivateKey,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process an encrypted message through the envelope encryption pipeline.
|
||||
|
||||
Step 1: Decrypt client message using transport encryption (ephemeral keys)
|
||||
Step 2: Generate random MEK
|
||||
Step 3: Encrypt plaintext with MEK
|
||||
Step 4: Wrap MEK for compliance, sender, recipient (using their provided public keys)
|
||||
Step 5: Return encrypted message + 3 wrapped MEKs
|
||||
|
||||
Args:
|
||||
client_public_key_b64: Client's ephemeral public key for transport decryption
|
||||
transport_nonce_b64: Nonce used for transport encryption
|
||||
transport_ciphertext_b64: Client's encrypted plaintext
|
||||
compliance_public_key_b64: Compliance system's public key for MEK wrapping
|
||||
sender_public_key_b64: Sender's public key for MEK wrapping
|
||||
recipient_public_key_b64: Recipient's public key for MEK wrapping
|
||||
ephemeral_private_key: Server's ephemeral X25519 private key
|
||||
|
||||
Returns:
|
||||
Dict with encrypted message and wrapped MEKs:
|
||||
{
|
||||
"nonce": base64-encoded nonce for content encryption,
|
||||
"ciphertext": base64-encoded encrypted content,
|
||||
"compliance_wrapped_mek": base64-encoded wrapped MEK,
|
||||
"sender_wrapped_mek": base64-encoded wrapped MEK,
|
||||
"recipient_wrapped_mek": base64-encoded wrapped MEK,
|
||||
}
|
||||
"""
|
||||
try:
|
||||
start_time = time.time()
|
||||
|
||||
# Step 1: Decrypt transport message
|
||||
logger.info("CRYPTO: Starting envelope encryption processing")
|
||||
plaintext = decrypt_transport_message(
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64,
|
||||
transport_ciphertext_b64,
|
||||
ephemeral_private_key,
|
||||
)
|
||||
logger.info(
|
||||
"CRYPTO: Transport decryption complete, plaintext size: %d bytes",
|
||||
len(plaintext)
|
||||
)
|
||||
|
||||
# Step 2: Generate random MEK
|
||||
mek = generate_mek()
|
||||
logger.info("CRYPTO: Generated random MEK (32 bytes)")
|
||||
|
||||
# Step 3: Encrypt plaintext with MEK
|
||||
content_nonce, ciphertext = encrypt_message(plaintext, mek)
|
||||
logger.info(
|
||||
"CRYPTO: Content encryption with MEK complete, ciphertext size: %d bytes",
|
||||
len(ciphertext)
|
||||
)
|
||||
|
||||
# Step 4a: Derive wrap keys deterministically from recipient public keys
|
||||
# This avoids needing to store the ephemeral transport key
|
||||
logger.info("CRYPTO: Deriving key wrap keys deterministically")
|
||||
|
||||
# Use HKDF with recipient public key bytes as input to derive wrap keys
|
||||
# This is deterministic and doesn't require storing ephemeral keys
|
||||
import base64
|
||||
compliance_key_bytes = base64.b64decode(compliance_public_key_b64)
|
||||
sender_key_bytes = base64.b64decode(sender_public_key_b64)
|
||||
recipient_key_bytes = base64.b64decode(recipient_public_key_b64)
|
||||
|
||||
logger.info(f"🔑 Deriving wrap keys for sender={sender_public_key_b64[:20]}... recipient={recipient_public_key_b64[:20]}...")
|
||||
|
||||
compliance_wrap_key = derive_key_from_shared_secret(compliance_key_bytes, "compliance_wrap_key")
|
||||
sender_wrap_key = derive_key_from_shared_secret(sender_key_bytes, "sender_wrap_key")
|
||||
recipient_wrap_key = derive_key_from_shared_secret(recipient_key_bytes, "recipient_wrap_key")
|
||||
|
||||
logger.info("✅ Wrap keys derived successfully")
|
||||
|
||||
# Step 4b: Wrap MEK for each recipient
|
||||
compliance_wrapped_mek = wrap_mek(mek, compliance_wrap_key)
|
||||
sender_wrapped_mek = wrap_mek(mek, sender_wrap_key)
|
||||
recipient_wrapped_mek = wrap_mek(mek, recipient_wrap_key)
|
||||
|
||||
duration = time.time() - start_time
|
||||
logger.info(
|
||||
"CRYPTO: Successfully processed message with 3 MEK wraps (compliance/sender/recipient) in %.2fms",
|
||||
duration * 1000
|
||||
)
|
||||
|
||||
# Get the transport public key for storage with the message
|
||||
transport_public_key_b64 = base64.b64encode(ephemeral_private_key.public_key().public_bytes_raw()).decode("ascii")
|
||||
|
||||
return {
|
||||
"nonce": content_nonce,
|
||||
"ciphertext": ciphertext,
|
||||
"compliance_wrapped_mek": compliance_wrapped_mek,
|
||||
"sender_wrapped_mek": sender_wrapped_mek,
|
||||
"recipient_wrapped_mek": recipient_wrapped_mek,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
logger.exception(
|
||||
"CRYPTO: Failed to process encrypted message after %.2fms: %s",
|
||||
duration * 1000, str(e)
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def process_encrypted_message_and_files(
|
||||
plaintext_message: bytes,
|
||||
plaintext_files: list[bytes],
|
||||
compliance_public_key_b64: str,
|
||||
sender_public_key_b64: str,
|
||||
recipient_public_key_b64: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process a message and its attached files using a single MEK.
|
||||
|
||||
- Generates one random MEK
|
||||
- Encrypts message and each file with AES-GCM using that MEK (unique nonce per item)
|
||||
- Wraps the MEK for compliance, sender, and recipient
|
||||
|
||||
Returns:
|
||||
{
|
||||
"message": {"nonce": str, "ciphertext": str},
|
||||
"files": [{"nonce": str, "ciphertext": str}, ...],
|
||||
"compliance_wrapped_mek": str,
|
||||
"sender_wrapped_mek": str,
|
||||
"recipient_wrapped_mek": str,
|
||||
}
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# One MEK for everything in this envelope
|
||||
mek = generate_mek()
|
||||
|
||||
# Encrypt message
|
||||
msg_nonce, msg_ciphertext = encrypt_message(plaintext_message, mek)
|
||||
|
||||
# Encrypt files (same MEK, per-file nonce)
|
||||
files_out: list[Dict[str, str]] = []
|
||||
for f_bytes in plaintext_files:
|
||||
f_nonce, f_ciphertext = encrypt_message(f_bytes, mek)
|
||||
files_out.append({"nonce": f_nonce, "ciphertext": f_ciphertext})
|
||||
|
||||
# Derive wrap keys deterministically (same as existing flow)
|
||||
compliance_key_bytes = base64.b64decode(compliance_public_key_b64)
|
||||
sender_key_bytes = base64.b64decode(sender_public_key_b64)
|
||||
recipient_key_bytes = base64.b64decode(recipient_public_key_b64)
|
||||
|
||||
compliance_wrap_key = derive_key_from_shared_secret(compliance_key_bytes, "compliance_wrap_key")
|
||||
sender_wrap_key = derive_key_from_shared_secret(sender_key_bytes, "sender_wrap_key")
|
||||
recipient_wrap_key = derive_key_from_shared_secret(recipient_key_bytes, "recipient_wrap_key")
|
||||
|
||||
compliance_wrapped_mek = wrap_mek(mek, compliance_wrap_key)
|
||||
sender_wrapped_mek = wrap_mek(mek, sender_wrap_key)
|
||||
recipient_wrapped_mek = wrap_mek(mek, recipient_wrap_key)
|
||||
|
||||
duration = time.time() - start_time
|
||||
logger.info(
|
||||
"CRYPTO: Processed message+%d files with single MEK in %.2fms",
|
||||
len(files_out),
|
||||
duration * 1000,
|
||||
)
|
||||
|
||||
return {
|
||||
"message": {"nonce": msg_nonce, "ciphertext": msg_ciphertext},
|
||||
"files": files_out,
|
||||
"compliance_wrapped_mek": compliance_wrapped_mek,
|
||||
"sender_wrapped_mek": sender_wrapped_mek,
|
||||
"recipient_wrapped_mek": recipient_wrapped_mek,
|
||||
}
|
||||
Reference in New Issue
Block a user