Skip to Content
Media Encryption

Media Encryption

Technical specification for secure media handling in Zentalk, covering images, videos, audio, files, and voice messages.

Overview

Zentalk applies end-to-end encryption to all media attachments. Media files are chunked, encrypted independently, and distributed across the mesh network. Recipients reconstruct files by downloading and decrypting chunks in sequence.

Supported Media Types

Format Support

Media TypeSupported FormatsMax SizeUse Case
ImagesJPEG, PNG, GIF, WebP, HEIC50 MBPhotos, screenshots
VideosMP4, WebM, MOV500 MBRecordings, clips
AudioMP3, AAC, OGG, FLAC100 MBMusic, podcasts
Voice MessagesOpus (WebM container)15 MBVoice notes
DocumentsPDF, DOCX, XLSX, TXT100 MBFiles, documents
ArchivesZIP, 7z, RAR200 MBCompressed files
GenericAny file type500 MBArbitrary data

Size Limits by Context

ContextSingle File LimitDaily LimitRationale
Direct Messages500 MB5 GBPeer-to-peer flexibility
Group Chat (≤50 members)200 MB2 GBBandwidth consideration
Group Chat (>50 members)100 MB1 GBMesh load management
Voice Messages15 MB (~10 min)UnlimitedOptimized for voice

MIME Type Validation

CheckActionPurpose
Magic byte verificationValidate file headerPrevent type spoofing
Extension matchingWarn on mismatchUser awareness
Executable detectionBlock or warnSecurity protection
Nested archive scanLimit depth to 3Prevent zip bombs

Chunking Strategy

Why Chunking

Large files cannot be transmitted atomically in a decentralized mesh network. Chunking provides:

BenefitDescription
Resumable transfersContinue after network interruption
Parallel downloadsFetch chunks from multiple nodes
Streaming playbackBegin playback before full download
Memory efficiencyProcess chunks without loading entire file
Mesh distributionSpread storage across network

Chunk Parameters

ParameterValueRationale
Chunk size256 KB (262,144 bytes)Balance between overhead and granularity
Final chunkVariable (1 byte to 256 KB)Remainder of file
Maximum chunks2,048Supports files up to 512 MB
Chunk orderingSequential (0-indexed)Simple reassembly

Chunking Process

INPUT: media_file (raw bytes) OUTPUT: chunks[] (encrypted chunk array) 1. Calculate total chunks: total_chunks = ceil(file_size / 256KB) 2. For each chunk index i from 0 to total_chunks-1: start = i * 256KB end = min((i + 1) * 256KB, file_size) chunk_data = media_file[start:end] 3. Generate chunk metadata: chunk_id = SHA-256(media_key || chunk_index) 4. Store chunk with metadata: chunks[i] = { id: chunk_id, index: i, data: chunk_data, size: end - start }

Chunk Ordering and Reassembly

FieldSizePurpose
Chunk Index2 bytesPosition in sequence (0 to 2047)
Total Chunks2 bytesExpected chunk count
Chunk Hash32 bytesSHA-256 of encrypted chunk
Parent Hash32 bytesMedia file hash (integrity)
Reassembly Process: 1. Verify all chunks received: IF received_count ≠ total_chunks: request_missing_chunks() 2. Sort chunks by index: sorted_chunks = sort(chunks, by: index) 3. Verify chunk hashes: FOR EACH chunk IN sorted_chunks: IF SHA-256(chunk.data) ≠ chunk.hash: request_chunk_retry(chunk.index) 4. Concatenate decrypted chunks: media_data = concat(sorted_chunks.map(decrypt)) 5. Verify final hash: IF SHA-256(media_data) ≠ parent_hash: ABORT("File integrity check failed")

Per-Chunk Encryption

Encryption Architecture

Each chunk is encrypted independently using a key derived from the media key. This enables:

PropertyBenefit
Independent decryptionDecrypt any chunk without others
Parallel processingMulti-threaded encryption/decryption
Partial file accessSeek to specific positions
Chunk replacementRe-encrypt single chunks if needed

Media Key Generation

1. Generate media key (per file): media_key = secure_random(32 bytes) 2. Media key properties: - Unique per media file - Never reused across files - Distributed via message payload

Chunk Key Derivation

ParameterValue
KDFHKDF-SHA256
Input Key Materialmedia_key (32 bytes)
Saltchunk_index (big-endian, 2 bytes)
Info”zentalk-media-chunk-v1”
Output Length32 bytes (AES-256 key)
Chunk Key Derivation: FOR EACH chunk_index FROM 0 TO total_chunks-1: chunk_key = HKDF( ikm = media_key, salt = big_endian(chunk_index, 2), info = "zentalk-media-chunk-v1", length = 32 ) chunk_nonce = HKDF( ikm = media_key, salt = big_endian(chunk_index, 2), info = "zentalk-media-nonce-v1", length = 12 )

AES-256-GCM Per Chunk

ParameterValue
AlgorithmAES-256-GCM
Key Size256 bits
Nonce Size96 bits (12 bytes)
Tag Size128 bits (16 bytes)
Associated Datachunk_index || total_chunks || media_id
Chunk Encryption: encrypted_chunk = AES_GCM_Encrypt( key = chunk_key, nonce = chunk_nonce, plaintext = chunk_data, aad = chunk_index || total_chunks || media_id ) Output: encrypted_data || authentication_tag (16 bytes)

Chunk Authentication

CheckVerification
GCM TagValidated during decryption
Chunk HashSHA-256 of ciphertext verified
Index BindingAAD prevents chunk reordering
Count BindingAAD prevents chunk truncation
Chunk Decryption with Authentication: 1. Verify chunk hash: IF SHA-256(encrypted_chunk) ≠ expected_hash: REJECT("Chunk integrity failure") 2. Decrypt and authenticate: plaintext = AES_GCM_Decrypt( key = chunk_key, nonce = chunk_nonce, ciphertext = encrypted_chunk, aad = chunk_index || total_chunks || media_id ) IF authentication_fails: REJECT("Chunk authentication failure") 3. Return plaintext chunk

Media Key Distribution

Direct Message Media

For media in 1:1 conversations, the media key is encrypted within the message payload:

Message Structure with Media: ┌─────────────────────────────────────────┐ │ Double Ratchet Encrypted Payload │ ├─────────────────────────────────────────┤ │ ┌─────────────────────────────────────┐ │ │ │ Message Type (1 byte): MEDIA │ │ │ ├─────────────────────────────────────┤ │ │ │ Media Key (32 bytes) │ │ │ ├─────────────────────────────────────┤ │ │ │ Media ID (32 bytes) │ │ │ ├─────────────────────────────────────┤ │ │ │ File Hash (32 bytes) │ │ │ ├─────────────────────────────────────┤ │ │ │ Total Chunks (2 bytes) │ │ │ ├─────────────────────────────────────┤ │ │ │ File Size (8 bytes) │ │ │ ├─────────────────────────────────────┤ │ │ │ MIME Type (variable) │ │ │ ├─────────────────────────────────────┤ │ │ │ Filename (variable, optional) │ │ │ ├─────────────────────────────────────┤ │ │ │ Thumbnail Key (32 bytes, optional) │ │ │ ├─────────────────────────────────────┤ │ │ │ Caption (variable, optional) │ │ │ └─────────────────────────────────────┘ │ └─────────────────────────────────────────┘

Group Media Key Handling

In group chats, media keys are distributed differently:

ApproachDescriptionTrade-off
Sender Key EncryptionMedia key encrypted once with sender’s Sender KeyEfficient, all members decrypt
Per-Member EncryptionMedia key encrypted for each memberSecure but O(n) overhead

Zentalk uses Sender Key Encryption for groups:

Group Media Distribution: 1. Sender generates media_key 2. Encrypt media_key with own Sender Key: encrypted_media_key = AES_GCM(sender_key, media_key) 3. Broadcast to group: { sender_key_id: <id>, chain_iteration: <n>, encrypted_media_key: <ciphertext>, media_metadata: { ... } } 4. Recipients decrypt with sender's Sender Key: media_key = AES_GCM_Decrypt(sender_key, encrypted_media_key)

Key Distribution Security

PropertyMechanism
ConfidentialityMedia key encrypted in E2EE payload
Forward SecrecyNew media key per file
AuthenticationMessage signature covers media key
Non-repudiationSigned by sender’s identity

Thumbnail Security

Client-Side Thumbnail Generation

Thumbnails are generated exclusively on the sender’s device:

ParameterValue
Max dimensions480 x 480 pixels
FormatJPEG (quality 70) or WebP
Max size50 KB
GenerationClient-side only

Server never sees original media or generates thumbnails.

Thumbnail Generation: 1. Decode media (image/video first frame) 2. Resize maintaining aspect ratio: IF width > height: new_width = 480 new_height = height * (480 / width) ELSE: new_height = 480 new_width = width * (480 / height) 3. Encode as JPEG/WebP 4. Generate separate thumbnail_key 5. Encrypt thumbnail independently

Thumbnail Encryption

ParameterValue
KeySeparate thumbnail_key (32 bytes)
AlgorithmAES-256-GCM
DistributionIncluded in message payload
Thumbnail differs from main media: - NOT chunked (always ≤ 50 KB) - Separate encryption key - Can be decrypted without downloading full media - Enables preview without full transfer

Blur Hash for Previews

For instant preview before thumbnail loads:

ParameterValue
AlgorithmBlurHash
Components4 x 3 (12 components)
String length~28 characters
PurposePlaceholder while loading
BlurHash Process: 1. Generate BlurHash from thumbnail: blur_hash = BlurHash.encode(thumbnail, x=4, y=3) 2. Include in message metadata (unencrypted): { blur_hash: "LKO2?U%2Tw=w]~RBVZRi};RPxuwH", aspect_ratio: 1.33, ... } 3. Recipient renders blur immediately 4. Replace with thumbnail when decrypted 5. Replace with full media when downloaded

Visual Security Hierarchy

StageData SourceEncryptionLoad Time
1. PlaceholderBlurHash stringNone (non-sensitive)Instant
2. PreviewEncrypted thumbnailAES-256-GCMFast
3. Full MediaEncrypted chunksPer-chunk AES-256-GCMVariable

Upload and Download Flow

Upload Process

Upload Flow (Sender): 1. VALIDATE - Check file size against limits - Validate MIME type - Scan for malware indicators 2. GENERATE KEYS media_key = secure_random(32) media_id = secure_random(32) thumbnail_key = secure_random(32) // if applicable 3. GENERATE THUMBNAIL (for images/videos) thumbnail = generate_thumbnail(media) blur_hash = generate_blur_hash(thumbnail) encrypted_thumbnail = encrypt(thumbnail_key, thumbnail) 4. CHUNK AND ENCRYPT chunks = split_into_chunks(media, 256KB) FOR EACH chunk, index IN chunks: chunk_key = derive_chunk_key(media_key, index) encrypted_chunk = encrypt(chunk_key, chunk) chunk_hash = SHA-256(encrypted_chunk) chunk_refs[index] = { hash: chunk_hash, size: len(encrypted_chunk) } 5. UPLOAD TO MESH FOR EACH encrypted_chunk IN parallel: mesh.store(chunk_hash, encrypted_chunk, ttl=30_days) mesh.store(thumbnail_hash, encrypted_thumbnail, ttl=30_days) 6. CONSTRUCT MESSAGE media_message = { type: MEDIA, media_key: media_key, media_id: media_id, file_hash: SHA-256(original_media), total_chunks: len(chunks), chunk_refs: chunk_refs, thumbnail_key: thumbnail_key, thumbnail_ref: thumbnail_hash, blur_hash: blur_hash, mime_type: detected_mime, file_size: original_size, filename: original_filename // optional } 7. ENCRYPT AND SEND MESSAGE encrypted_message = double_ratchet_encrypt(media_message) send_via_onion_routing(encrypted_message)

Download Process

Download Flow (Recipient): 1. RECEIVE MESSAGE media_message = double_ratchet_decrypt(encrypted_message) 2. DISPLAY BLUR PREVIEW render_blur_hash(media_message.blur_hash) 3. DOWNLOAD THUMBNAIL (parallel) encrypted_thumbnail = mesh.fetch(media_message.thumbnail_ref) thumbnail = decrypt(media_message.thumbnail_key, encrypted_thumbnail) display_thumbnail(thumbnail) 4. USER INITIATES FULL DOWNLOAD (optional) // Full media not auto-downloaded to save bandwidth 5. DOWNLOAD CHUNKS (parallel, with resume) FOR EACH chunk_ref IN media_message.chunk_refs (parallel): encrypted_chunk = mesh.fetch(chunk_ref.hash) IF SHA-256(encrypted_chunk) ≠ chunk_ref.hash: retry_fetch(chunk_ref) store_locally(chunk_ref.index, encrypted_chunk) 6. DECRYPT AND REASSEMBLE FOR EACH index FROM 0 TO total_chunks-1: chunk_key = derive_chunk_key(media_message.media_key, index) decrypted_chunk = decrypt(chunk_key, local_chunks[index]) append_to_file(decrypted_chunk) 7. VERIFY INTEGRITY IF SHA-256(reassembled_file) ≠ media_message.file_hash: ABORT("File integrity verification failed") 8. DISPLAY MEDIA render_media(reassembled_file)

Resume Interrupted Transfers

StatePersisted DataResume Action
Upload interruptedEncrypted chunks locallyRe-upload missing chunks
Download interruptedPartial chunks + progressContinue from last chunk
Message lostChunk refs in messageRequest message resend
Resume Download: 1. Load transfer state: state = load_transfer_state(media_id) 2. Identify missing chunks: missing = [] FOR EACH index FROM 0 TO state.total_chunks-1: IF NOT exists_locally(media_id, index): missing.append(index) 3. Download missing chunks: FOR EACH index IN missing (parallel): fetch_and_store(state.chunk_refs[index]) 4. Continue with decryption and reassembly

Media Forwarding

Re-encryption for New Recipients

When forwarding media, the content is re-encrypted for the new recipient:

Forwarding Process: 1. DECRYPT ORIGINAL original_media = decrypt_all_chunks(original_media_key) 2. GENERATE NEW KEYS new_media_key = secure_random(32) new_media_id = secure_random(32) 3. RE-CHUNK AND RE-ENCRYPT // Same chunking process with new key new_chunks = chunk_and_encrypt(original_media, new_media_key) 4. UPLOAD NEW CHUNKS // Chunks stored under new hashes FOR EACH chunk IN new_chunks: mesh.store(chunk.hash, chunk.data) 5. SEND TO NEW RECIPIENT // New message with new keys forward_message = construct_media_message(new_media_key, new_chunks) send_to_recipient(forward_message)

Forward Security Properties

PropertyGuarantee
Original sender privacyNew recipient cannot identify original sender
Key isolationOriginal and forwarded use different keys
Chunk isolationDifferent chunk hashes (different keys)
No server correlationMesh cannot link original to forward

Forwarding vs. Sharing Reference

MethodDescriptionPrivacyEfficiency
Full Re-encryptionNew keys, new chunksMaximumLower (re-upload)
Reference SharingSame chunks, new key wrapperLowerHigher (no re-upload)

Zentalk uses Full Re-encryption by default for maximum privacy.


Storage and Retention

Mesh Storage Duration

Content TypeDefault TTLMaximum TTLExtension
Media chunks30 days90 daysOn access
Thumbnails30 days90 daysOn access
Voice messages14 days30 daysNone

TTL Extension Policy

TTL Extension Rules: 1. On download: IF chunk.remaining_ttl < 7 days: chunk.ttl = extend(chunk.ttl, 14 days) 2. Maximum extensions: total_age = current_time - chunk.created_at IF total_age > max_ttl: DENY extension 3. Storage node incentive: Nodes earn reputation for serving content

Automatic Cleanup

Cleanup TriggerAction
TTL expirationMesh nodes delete chunk
Sender deletionChunks orphaned, expire naturally
Recipient deletionLocal cache cleared
Group disbandmentAll group media expires

Local Cache Management

PlatformCache LocationDefault SizeCleanup Policy
WebIndexedDB500 MBLRU eviction
MobileApp sandbox1 GBLRU + manual
DesktopApp data dir2 GBLRU + manual
Local Cache Structure: zentalk_media_cache/ ├── thumbnails/ │ ├── {media_id}.thumb # Encrypted thumbnail │ └── ... ├── chunks/ │ ├── {chunk_hash} # Encrypted chunk │ └── ... ├── assembled/ │ ├── {media_id}.media # Decrypted full file │ └── ... └── metadata.db # Cache index, encrypted

Cache Encryption

DataEncryptionKey Source
Cached thumbnailsAES-256-GCMLocal storage key
Cached chunksAlready encryptedMedia key required
Assembled filesAES-256-GCMLocal storage key
Cache metadataAES-256-GCMLocal storage key

Security Considerations

Attack Mitigations

AttackMitigation
Chunk substitutionAAD binding + hash verification
Chunk reorderingIndex in AAD + sequence check
Chunk truncationTotal count in AAD + verification
Media key theftPer-message encryption, forward secrecy
Thumbnail analysisClient-side generation, encrypted storage
Traffic analysisPadded chunk sizes, 3-hop relay

Metadata Protection

MetadataProtection LevelNotes
File sizePartially hiddenChunk count visible
File typeEncryptedIn message payload
FilenameEncryptedOptional, in payload
ThumbnailEncryptedSeparate key
SenderHidden3-hop relay
TimestampBucketed15-minute windows

Size Padding

Chunk Padding (Optional): FOR EACH chunk: IF chunk.size < 256KB: padding_size = 256KB - chunk.size padding = secure_random(padding_size) padded_chunk = chunk.data || padding Benefit: All chunks appear same size Trade-off: 50% bandwidth overhead on average Default: Disabled (user option)

Performance Optimizations

Parallel Processing

OperationParallelismLimit
Chunk encryptionPer-chunkCPU cores
Chunk uploadPer-chunk4 concurrent
Chunk downloadPer-chunk8 concurrent
Chunk decryptionPer-chunkCPU cores

Streaming Support

Media TypeStreaming SupportMethod
VideoYesProgressive chunk decryption
AudioYesSequential chunk streaming
ImagesPartialProgressive JPEG
DocumentsNoFull download required
Video Streaming: 1. Download first N chunks (enough for playback start) 2. Begin decryption pipeline 3. Feed decrypted chunks to media player 4. Continue downloading remaining chunks 5. Buffer management: - Minimum buffer: 3 chunks (768 KB) - Target buffer: 10 chunks (2.5 MB)

Error Handling

ErrorRecovery
Chunk fetch failedRetry with exponential backoff (max 3)
Chunk hash mismatchRe-fetch from different node
Decryption failedVerify media key, request resend
Incomplete transferResume from last successful chunk
Storage quota exceededPrompt user to clear cache
TTL expiredNotify user, offer re-request

Last updated on