Encryption Service¶
File: lenzeye_encryption_service.py (21,000+ lines)
Responsibilities¶
This module is the single source of truth for all cryptographic operations in Lenzeye:
- Master key loading and validation
- Per-user AES-256 key generation, encryption, decryption, rotation
- File stream encryption and decryption (AES-256-CTR)
- Multipart chunk encryption at correct byte offset
- HMAC-SHA256 accumulation and verification
- Stateless upload session token creation and decryption
Master Key¶
python
def has_master_key() -> bool
def get_master_key() -> bytes # 32-byte key from MASTER_KEY env var (hex-encoded)
- Loaded from
MASTER_KEYenvironment variable - Must be 64 hex characters (32 bytes = 256 bits)
- Never stored in code or database
- Used only to wrap/unwrap per-user keys via AES-256-GCM
Per-User Key Management¶
python
def get_or_create_user_key(user) -> tuple[bytes, str]
- Queries
UserEncryptionKeyforis_active=Truerow for this user - If none exists, generates a new 32-byte key (
os.urandom(32)) - Encrypts the key with master key using AES-256-GCM
- Stores encrypted key in DB, returns
(raw_key_bytes, key_version) key_versionis SHA-256 hash of the raw key (stored in S3 metadata for future decryption routing)
python
def rotate_user_key(user) -> str
- Sets existing active key to
is_active=False - Generates a new key, stores it as active
- Does NOT re-encrypt existing files (old key is retained for decryption)
Session Token (Stateless Upload)¶
python
def create_upload_token(key: bytes, iv: bytes, part_size: int) -> str
def decrypt_upload_token(token: str) -> tuple[bytes, bytes, int]
Token layout (plaintext, before GCM seal):
32 bytes user_key
16 bytes iv
4 bytes part_size (big-endian uint32)
──────────────────
52 bytes plaintext → ~80 bytes after AES-256-GCM (nonce + tag + ciphertext)
- Sealed with
AESGCM(master_key)— authenticated encryption - Nonce is prepended to output
- Browser holds token opaquely; server decrypts with one AES-GCM operation per part
- Zero DB reads per upload part
Multipart Chunk Encryption¶
python
def encrypt_multipart_chunk(plaintext: bytes, key: bytes, iv: bytes, byte_offset: int) -> bytes
- Computes the correct AES-CTR counter value for
byte_offset - CTR counter =
byte_offset // 16(16 bytes per AES block) - Creates an
AES-256-CTRcipher starting at this counter - Encrypts the chunk independently — no dependency on previous chunks
- Returns ciphertext of same length as input
Why this matters: Part 3 can be encrypted before parts 1 and 2 are uploaded. Full parallelism.
Stream Encryption / Decryption¶
python
def encrypt_file_stream(plaintext_stream, key: bytes) -> generator
def decrypt_file_stream(ciphertext_stream, key: bytes, expected_hmac: bytes) -> generator
- Used for single-part (non-multipart) file operations
- Yields chunks for streaming — full file never in memory at once
decrypt_file_streamreads HMAC from first bytes, verifies after full stream, raisesInvalidSignatureon mismatch
HMAC Registry¶
python
_hmac_registry: dict # upload_id → {"h": HMAC object, "iv": bytes}
_hmac_registry_lock: threading.Lock
- Module-level dict shared across all threads within one Gunicorn worker
- Keyed by S3
upload_id - HMAC object accumulated per chunk during
upload-partphase - On
complete, HMAC is finalized and stored in S3 metadata - Entry is cleaned up after complete
Registry is in-process memory
The _hmac_registry only works within a single Gunicorn worker process. If workers are increased to >1, HMAC accumulation across requests breaks. This is the primary reason for the --workers=1 constraint.
TL;DR¶
What it does: All crypto in one file. Master key wraps per-user keys (AES-GCM). Per-user keys encrypt files (AES-256-CTR per chunk at offset). HMAC-SHA256 accumulated per chunk, verified on download.
Key techniques: AES-256-CTR (seekable, chunk-independent), AES-256-GCM (session token + key wrapping), HMAC-SHA256 (Encrypt-then-MAC), stateless session token (zero DB reads per part), in-process HMAC registry (requires single worker).