Skip to content

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.