Encryption Implementation¶
Source: lenzeye_encryption_service.py
AES-256-CTR: How It Works¶
AES-CTR turns AES into a stream cipher by encrypting sequential counter blocks and XOR-ing with plaintext:
counterblock_0 = IV || 0x00000001
counterblock_1 = IV || 0x00000002
...
keystream_n = AES_encrypt(key, counterblock_n)
ciphertext_n = plaintext_n XOR keystream_n
For Lenzeye's multipart use case, any chunk can be encrypted at its correct byte offset by computing the correct counter start:
python
ctr_offset = byte_offset // 16 # 16 bytes per AES block
Chunk-by-Chunk Encryption¶
python
def encrypt_multipart_chunk(plaintext: bytes, key: bytes, iv: bytes, byte_offset: int) -> bytes:
ctr_start = byte_offset // 16
cipher = Cipher(
algorithms.AES(key),
modes.CTR(iv[:8] + ctr_start.to_bytes(8, 'big'))
)
encryptor = cipher.encryptor()
return encryptor.update(plaintext) + encryptor.finalize()
Key properties: - Each chunk is encrypted independently - No dependency between chunks — chunks can be sent to the server in any order - The output is the same size as input (no padding)
IV Handling¶
| Property | Value |
|---|---|
| IV size | 16 bytes |
| Generation | os.urandom(16) per file |
| Storage | Prepended to ciphertext in S3: [16B IV][ciphertext...] |
| On upload | IV is created at initiate step, included in session token |
| On download | First 16 bytes of S3 object are read as IV, then ciphertext follows |
| In HMAC | IV is included in HMAC computation — tampering detected |
Streaming Encryption (Single-Part)¶
For single-part encrypted files (not multipart):
python
def encrypt_file_stream(plaintext_stream, key: bytes):
iv = os.urandom(16)
yield iv # First: emit IV
cipher = Cipher(algorithms.AES(key), modes.CTR(iv))
encryptor = cipher.encryptor()
h = HMAC(key, hashes.SHA256())
h.update(iv)
for chunk in plaintext_stream.read(10 * 1024 * 1024): # 10MB chunks
encrypted = encryptor.update(chunk)
h.update(encrypted)
yield encrypted
yield encryptor.finalize()
yield h.finalize() # 32-byte HMAC at end of stream
The server never loads the full file into memory — it processes and yields 10 MB at a time.
Streaming Decryption¶
python
def decrypt_file_stream(ciphertext_stream, key: bytes, expected_hmac: bytes):
iv = ciphertext_stream.read(16) # Read IV from start
cipher = Cipher(algorithms.AES(key), modes.CTR(iv))
decryptor = cipher.decryptor()
h = HMAC(key, hashes.SHA256())
h.update(iv)
for chunk in stream_in_chunks(ciphertext_stream, 10 * 1024 * 1024):
h.update(chunk)
yield decryptor.update(chunk)
computed_hmac = h.finalize()
if not hmac_compare_digest(computed_hmac, expected_hmac):
raise InvalidSignature("HMAC mismatch — file tampered")
The HMAC is verified after the full stream, but the stream is only yielded to the client after the HMAC check passes. Flask streaming responses buffer the generator — no plaintext reaches the client if HMAC fails.
TL;DR¶
Algorithm: AES-256-CTR. IV: 16 bytes, random per file, prepended to ciphertext. Chunk encryption: Counter computed from byte offset — any chunk encrypted independently. Streaming: 10 MB chunks, never full file in memory. Integrity: HMAC computed during encryption, verified before first plaintext byte reaches client.