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 Type | Supported Formats | Max Size | Use Case |
|---|---|---|---|
| Images | JPEG, PNG, GIF, WebP, HEIC | 50 MB | Photos, screenshots |
| Videos | MP4, WebM, MOV | 500 MB | Recordings, clips |
| Audio | MP3, AAC, OGG, FLAC | 100 MB | Music, podcasts |
| Voice Messages | Opus (WebM container) | 15 MB | Voice notes |
| Documents | PDF, DOCX, XLSX, TXT | 100 MB | Files, documents |
| Archives | ZIP, 7z, RAR | 200 MB | Compressed files |
| Generic | Any file type | 500 MB | Arbitrary data |
Size Limits by Context
| Context | Single File Limit | Daily Limit | Rationale |
|---|---|---|---|
| Direct Messages | 500 MB | 5 GB | Peer-to-peer flexibility |
| Group Chat (≤50 members) | 200 MB | 2 GB | Bandwidth consideration |
| Group Chat (>50 members) | 100 MB | 1 GB | Mesh load management |
| Voice Messages | 15 MB (~10 min) | Unlimited | Optimized for voice |
MIME Type Validation
| Check | Action | Purpose |
|---|---|---|
| Magic byte verification | Validate file header | Prevent type spoofing |
| Extension matching | Warn on mismatch | User awareness |
| Executable detection | Block or warn | Security protection |
| Nested archive scan | Limit depth to 3 | Prevent zip bombs |
Chunking Strategy
Why Chunking
Large files cannot be transmitted atomically in a decentralized mesh network. Chunking provides:
| Benefit | Description |
|---|---|
| Resumable transfers | Continue after network interruption |
| Parallel downloads | Fetch chunks from multiple nodes |
| Streaming playback | Begin playback before full download |
| Memory efficiency | Process chunks without loading entire file |
| Mesh distribution | Spread storage across network |
Chunk Parameters
| Parameter | Value | Rationale |
|---|---|---|
| Chunk size | 256 KB (262,144 bytes) | Balance between overhead and granularity |
| Final chunk | Variable (1 byte to 256 KB) | Remainder of file |
| Maximum chunks | 2,048 | Supports files up to 512 MB |
| Chunk ordering | Sequential (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
| Field | Size | Purpose |
|---|---|---|
| Chunk Index | 2 bytes | Position in sequence (0 to 2047) |
| Total Chunks | 2 bytes | Expected chunk count |
| Chunk Hash | 32 bytes | SHA-256 of encrypted chunk |
| Parent Hash | 32 bytes | Media 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:
| Property | Benefit |
|---|---|
| Independent decryption | Decrypt any chunk without others |
| Parallel processing | Multi-threaded encryption/decryption |
| Partial file access | Seek to specific positions |
| Chunk replacement | Re-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 payloadChunk Key Derivation
| Parameter | Value |
|---|---|
| KDF | HKDF-SHA256 |
| Input Key Material | media_key (32 bytes) |
| Salt | chunk_index (big-endian, 2 bytes) |
| Info | ”zentalk-media-chunk-v1” |
| Output Length | 32 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
| Parameter | Value |
|---|---|
| Algorithm | AES-256-GCM |
| Key Size | 256 bits |
| Nonce Size | 96 bits (12 bytes) |
| Tag Size | 128 bits (16 bytes) |
| Associated Data | chunk_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
| Check | Verification |
|---|---|
| GCM Tag | Validated during decryption |
| Chunk Hash | SHA-256 of ciphertext verified |
| Index Binding | AAD prevents chunk reordering |
| Count Binding | AAD 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 chunkMedia 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:
| Approach | Description | Trade-off |
|---|---|---|
| Sender Key Encryption | Media key encrypted once with sender’s Sender Key | Efficient, all members decrypt |
| Per-Member Encryption | Media key encrypted for each member | Secure 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
| Property | Mechanism |
|---|---|
| Confidentiality | Media key encrypted in E2EE payload |
| Forward Secrecy | New media key per file |
| Authentication | Message signature covers media key |
| Non-repudiation | Signed by sender’s identity |
Thumbnail Security
Client-Side Thumbnail Generation
Thumbnails are generated exclusively on the sender’s device:
| Parameter | Value |
|---|---|
| Max dimensions | 480 x 480 pixels |
| Format | JPEG (quality 70) or WebP |
| Max size | 50 KB |
| Generation | Client-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 independentlyThumbnail Encryption
| Parameter | Value |
|---|---|
| Key | Separate thumbnail_key (32 bytes) |
| Algorithm | AES-256-GCM |
| Distribution | Included 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 transferBlur Hash for Previews
For instant preview before thumbnail loads:
| Parameter | Value |
|---|---|
| Algorithm | BlurHash |
| Components | 4 x 3 (12 components) |
| String length | ~28 characters |
| Purpose | Placeholder 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 downloadedVisual Security Hierarchy
| Stage | Data Source | Encryption | Load Time |
|---|---|---|---|
| 1. Placeholder | BlurHash string | None (non-sensitive) | Instant |
| 2. Preview | Encrypted thumbnail | AES-256-GCM | Fast |
| 3. Full Media | Encrypted chunks | Per-chunk AES-256-GCM | Variable |
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
| State | Persisted Data | Resume Action |
|---|---|---|
| Upload interrupted | Encrypted chunks locally | Re-upload missing chunks |
| Download interrupted | Partial chunks + progress | Continue from last chunk |
| Message lost | Chunk refs in message | Request 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 reassemblyMedia 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
| Property | Guarantee |
|---|---|
| Original sender privacy | New recipient cannot identify original sender |
| Key isolation | Original and forwarded use different keys |
| Chunk isolation | Different chunk hashes (different keys) |
| No server correlation | Mesh cannot link original to forward |
Forwarding vs. Sharing Reference
| Method | Description | Privacy | Efficiency |
|---|---|---|---|
| Full Re-encryption | New keys, new chunks | Maximum | Lower (re-upload) |
| Reference Sharing | Same chunks, new key wrapper | Lower | Higher (no re-upload) |
Zentalk uses Full Re-encryption by default for maximum privacy.
Storage and Retention
Mesh Storage Duration
| Content Type | Default TTL | Maximum TTL | Extension |
|---|---|---|---|
| Media chunks | 30 days | 90 days | On access |
| Thumbnails | 30 days | 90 days | On access |
| Voice messages | 14 days | 30 days | None |
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 contentAutomatic Cleanup
| Cleanup Trigger | Action |
|---|---|
| TTL expiration | Mesh nodes delete chunk |
| Sender deletion | Chunks orphaned, expire naturally |
| Recipient deletion | Local cache cleared |
| Group disbandment | All group media expires |
Local Cache Management
| Platform | Cache Location | Default Size | Cleanup Policy |
|---|---|---|---|
| Web | IndexedDB | 500 MB | LRU eviction |
| Mobile | App sandbox | 1 GB | LRU + manual |
| Desktop | App data dir | 2 GB | LRU + 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, encryptedCache Encryption
| Data | Encryption | Key Source |
|---|---|---|
| Cached thumbnails | AES-256-GCM | Local storage key |
| Cached chunks | Already encrypted | Media key required |
| Assembled files | AES-256-GCM | Local storage key |
| Cache metadata | AES-256-GCM | Local storage key |
Security Considerations
Attack Mitigations
| Attack | Mitigation |
|---|---|
| Chunk substitution | AAD binding + hash verification |
| Chunk reordering | Index in AAD + sequence check |
| Chunk truncation | Total count in AAD + verification |
| Media key theft | Per-message encryption, forward secrecy |
| Thumbnail analysis | Client-side generation, encrypted storage |
| Traffic analysis | Padded chunk sizes, 3-hop relay |
Metadata Protection
| Metadata | Protection Level | Notes |
|---|---|---|
| File size | Partially hidden | Chunk count visible |
| File type | Encrypted | In message payload |
| Filename | Encrypted | Optional, in payload |
| Thumbnail | Encrypted | Separate key |
| Sender | Hidden | 3-hop relay |
| Timestamp | Bucketed | 15-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
| Operation | Parallelism | Limit |
|---|---|---|
| Chunk encryption | Per-chunk | CPU cores |
| Chunk upload | Per-chunk | 4 concurrent |
| Chunk download | Per-chunk | 8 concurrent |
| Chunk decryption | Per-chunk | CPU cores |
Streaming Support
| Media Type | Streaming Support | Method |
|---|---|---|
| Video | Yes | Progressive chunk decryption |
| Audio | Yes | Sequential chunk streaming |
| Images | Partial | Progressive JPEG |
| Documents | No | Full 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
| Error | Recovery |
|---|---|
| Chunk fetch failed | Retry with exponential backoff (max 3) |
| Chunk hash mismatch | Re-fetch from different node |
| Decryption failed | Verify media key, request resend |
| Incomplete transfer | Resume from last successful chunk |
| Storage quota exceeded | Prompt user to clear cache |
| TTL expired | Notify user, offer re-request |
Related Documentation
- Protocol Specification - Message encryption details
- Group Chat Protocol - Sender Keys for groups
- Architecture - Mesh network overview
- Wire Protocol - Binary message formats