Files
web/backend/services/messaging/encryption.py
T

430 lines
14 KiB
Python

"""
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 pathlib import Path
from typing import BinaryIO
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
GCM_TAG_SIZE = 16 # AES-GCM authentication tag appended to file ciphertext
FILE_ENCRYPT_CHUNK_SIZE = 1024 * 1024
# Client streaming transport format (chunked AES-256-GCM): FCAE | version | frames…
FCAE_MAGIC = b"FCAE"
FCAE_VERSION = 1
FCAE_PREFIX_BYTES = len(FCAE_MAGIC) + 1
FCAE_FRAME_LENGTH_BYTES = 4
TRANSPORT_FILE_KEY_CONTEXT = "fromchat_transport_file_v1"
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 is_fcae_transport_blob(prefix: bytes) -> bool:
return len(prefix) >= len(FCAE_MAGIC) and prefix[: len(FCAE_MAGIC)] == FCAE_MAGIC
def derive_transport_file_aes_key(
client_public_key_b64: str,
ephemeral_private_key: X25519PrivateKey,
) -> bytes:
client_public_bytes = base64.b64decode(client_public_key_b64)
server_private_bytes = ephemeral_private_key.private_bytes_raw()
shared = sodium.crypto_box_beforenm(client_public_bytes, server_private_bytes)
return derive_key_from_shared_secret(shared, TRANSPORT_FILE_KEY_CONTEXT)
def _read_fcae_frame_payload(source: BinaryIO) -> tuple[bytes, bytes] | None:
length_bytes = source.read(FCAE_FRAME_LENGTH_BYTES)
if not length_bytes:
return None
if len(length_bytes) < FCAE_FRAME_LENGTH_BYTES:
raise ValueError("Truncated FCAE frame length")
frame_len = int.from_bytes(length_bytes, byteorder="big", signed=False)
if frame_len <= MEK_NONCE_SIZE:
raise ValueError("Invalid FCAE frame length")
frame = source.read(frame_len)
if len(frame) < frame_len:
raise ValueError("Truncated FCAE frame")
iv = frame[:MEK_NONCE_SIZE]
ciphertext = frame[MEK_NONCE_SIZE:]
return iv, ciphertext
def _decrypt_fcae_transport_stream_io(
client_public_key_b64: str,
source: BinaryIO,
ephemeral_private_key: X25519PrivateKey,
) -> bytes:
prefix = source.read(FCAE_PREFIX_BYTES)
if len(prefix) < FCAE_PREFIX_BYTES:
raise ValueError("FCAE blob is too short")
if not is_fcae_transport_blob(prefix):
raise ValueError("Not an FCAE transport blob")
if prefix[4] != FCAE_VERSION:
raise ValueError("Unsupported FCAE version")
aes_key = derive_transport_file_aes_key(client_public_key_b64, ephemeral_private_key)
cipher = AESGCM(aes_key)
parts: list[bytes] = []
while True:
frame = _read_fcae_frame_payload(source)
if frame is None:
break
iv, ciphertext = frame
parts.append(cipher.decrypt(iv, ciphertext, None))
return b"".join(parts)
def decrypt_fcae_transport_blob_to_file(
client_public_key_b64: str,
encrypted_path: Path,
ephemeral_private_key: X25519PrivateKey,
output_path: Path,
) -> int:
"""Stream-decrypt FCAE transport ciphertext from disk to a plaintext file."""
output_path.parent.mkdir(parents=True, exist_ok=True)
total_out = 0
with open(encrypted_path, "rb") as enc, open(output_path, "wb") as out:
prefix = enc.read(FCAE_PREFIX_BYTES)
if not is_fcae_transport_blob(prefix):
raise ValueError("Not an FCAE transport blob")
if prefix[4] != FCAE_VERSION:
raise ValueError("Unsupported FCAE version")
aes_key = derive_transport_file_aes_key(client_public_key_b64, ephemeral_private_key)
cipher = AESGCM(aes_key)
while True:
frame = _read_fcae_frame_payload(enc)
if frame is None:
break
iv, ciphertext = frame
plain = cipher.decrypt(iv, ciphertext, None)
out.write(plain)
total_out += len(plain)
return total_out
def encrypt_message_to_file(plaintext_path: Path, mek: bytes, output_path: Path) -> str:
"""
AES-GCM encrypt a file on disk; returns nonce_b64.
On-disk layout: ``ciphertext || tag`` (16-byte GCM tag at EOF).
Hazmat ``encryptor.finalize()`` does not emit the tag; it is taken from ``encryptor.tag``.
"""
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
nonce = generate_nonce(MEK_NONCE_SIZE)
output_path.parent.mkdir(parents=True, exist_ok=True)
encryptor = Cipher(algorithms.AES(mek), modes.GCM(nonce)).encryptor()
with open(plaintext_path, "rb") as src, open(output_path, "wb") as dst:
while True:
chunk = src.read(FILE_ENCRYPT_CHUNK_SIZE)
if not chunk:
break
dst.write(encryptor.update(chunk))
encryptor.finalize()
dst.write(encryptor.tag)
return base64.b64encode(nonce).decode("utf-8")
def decrypt_message_to_file(
nonce_b64: str,
mek: bytes,
encrypted_path: Path,
output_path: Path,
) -> int:
"""
Decrypt a file produced by [encrypt_message_to_file] (ciphertext || tag).
Returns plaintext byte count.
"""
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
nonce = base64.b64decode(nonce_b64)
enc_size = encrypted_path.stat().st_size
if enc_size < GCM_TAG_SIZE:
raise ValueError("Encrypted file is too short")
output_path.parent.mkdir(parents=True, exist_ok=True)
ciphertext_length = enc_size - GCM_TAG_SIZE
total_out = 0
with open(encrypted_path, "rb") as src:
src.seek(ciphertext_length)
tag = src.read(GCM_TAG_SIZE)
if len(tag) != GCM_TAG_SIZE:
raise ValueError("Encrypted file truncated (missing GCM tag)")
decryptor = Cipher(algorithms.AES(mek), modes.GCM(nonce, tag)).decryptor()
src.seek(0)
with open(output_path, "wb") as dst:
processed = 0
while processed < ciphertext_length:
to_read = min(FILE_ENCRYPT_CHUNK_SIZE, ciphertext_length - processed)
chunk = src.read(to_read)
if len(chunk) != to_read:
raise ValueError("Encrypted file truncated")
processed += len(chunk)
plain = decryptor.update(chunk)
if plain:
dst.write(plain)
total_out += len(plain)
final = decryptor.finalize()
if final:
dst.write(final)
total_out += len(final)
if total_out <= 0:
raise ValueError("Decrypted file is empty")
return total_out
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: Client ephemeral public key in base64 (raw X25519),
the same key as used for the transport-encrypted message body.
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 is_fcae_transport_blob(encrypted_blob):
import io
return _decrypt_fcae_transport_stream_io(
client_public_key_b64,
io.BytesIO(encrypted_blob),
ephemeral_private_key,
)
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