Cryptography Fundamentals
Deep explanation of the cryptographic primitives used in Zentalk.
Why Cryptography Matters
Every message you send could be intercepted. Without cryptography:
- Network operators see everything
- Servers read your messages
- Attackers steal your data
Zentalk uses multiple layers of cryptography to ensure that only you and your recipient can read messages.
Diffie-Hellman Key Exchange
The Problem
Alice wants to talk to Bob securely. But how do they agree on a secret key if anyone can listen to their conversation?
The naive approach fails:
Alice: "Let's use password 'secret123'"
Eve (attacker): *intercepts* "Now I know the password too"The Solution: Diffie-Hellman
Diffie-Hellman (DH) lets two parties create a shared secret even if an attacker sees all communication.
How it works:
| Step | Alice | Bob | Eve sees |
|---|---|---|---|
| 1 | Picks secret a | Picks secret b | Nothing |
| 2 | Computes A = g^a mod p | Computes B = g^b mod p | - |
| 3 | Sends A to Bob | Sends B to Alice | A and B |
| 4 | Computes B^a = g^(ab) | Computes A^b = g^(ab) | Cannot compute g^(ab) |
Result: Alice and Bob both have g^(ab) but Eve only has A, B, g, and p.
Why Eve Cannot Compute the Secret
To find g^(ab) from A = g^a and B = g^b, Eve would need to solve:
Given: g^a and g^b
Find: g^(ab)This is the Computational Diffie-Hellman Problem (CDH). With proper parameters, this requires approximately 2^128 operations - more than all computers on Earth could perform in the age of the universe.
X25519: Modern Diffie-Hellman
Zentalk uses X25519, a modern elliptic curve variant:
| Property | Value |
|---|---|
| Curve | Curve25519 (Montgomery) |
| Field size | 2^255 - 19 |
| Security | 128-bit equivalent |
| Key size | 32 bytes |
Why X25519 over classical DH:
- Faster: 10-100x faster than RSA-based key exchange
- Smaller keys: 32 bytes vs 256+ bytes for RSA
- Constant-time: Resistant to timing attacks
- Simple: Fewer implementation pitfalls
Mathematical operation:
Alice's secret: a (256-bit random number)
Alice's public: A = a × G (point multiplication on Curve25519)
Bob's secret: b
Bob's public: B = b × G
Shared secret = a × B = b × A = ab × GBoth compute the same point on the curve, which becomes the shared secret.
X3DH: Extended Triple Diffie-Hellman
The Problem X3DH Solves
Basic Diffie-Hellman requires both parties to be online simultaneously. But messaging apps need asynchronous communication - Alice should be able to send a message even if Bob is offline.
Key Bundle
Bob publishes keys to a server before Alice contacts him:
| Key | Type | Purpose | Lifetime |
|---|---|---|---|
| Identity Key (IK) | Ed25519 | Identifies Bob | Permanent |
| Signed Pre-Key (SPK) | X25519 | Enables key exchange | 7 days |
| One-Time Pre-Keys (OPK) | X25519 | Extra forward secrecy | Single use |
Why Four DH Computations?
X3DH performs four separate DH operations:
DH1 = X25519(IK_Alice, SPK_Bob)
DH2 = X25519(EK_Alice, IK_Bob)
DH3 = X25519(EK_Alice, SPK_Bob)
DH4 = X25519(EK_Alice, OPK_Bob)Each DH provides different security properties:
| DH | Alice uses | Bob uses | Security Property |
|---|---|---|---|
| DH1 | Identity Key | Signed Pre-Key | Authenticates Alice to Bob |
| DH2 | Ephemeral Key | Identity Key | Authenticates Bob to Alice |
| DH3 | Ephemeral Key | Signed Pre-Key | Forward secrecy (weekly) |
| DH4 | Ephemeral Key | One-Time Key | Forward secrecy (per message) |
Why all four are needed:
- DH1 alone: No forward secrecy (if IK_Alice leaked, all past secrets revealed)
- DH2 alone: No forward secrecy (if IK_Bob leaked, all past secrets revealed)
- DH3 alone: No authentication (attacker could impersonate)
- DH4 alone: No authentication, only single-use
Combined: Authentication + Forward Secrecy + Single-use protection
Key Derivation
The four DH outputs are combined using HKDF:
SK = HKDF(DH1 || DH2 || DH3 || DH4, salt=0, info="ZentalkX3DH")Why concatenate all four?
- An attacker must break all four DH computations to recover SK
- Even if one key is compromised, the others protect the secret
- HKDF extracts entropy from all inputs uniformly
HKDF: Key Derivation Function
Why We Need Key Derivation
Raw DH outputs are not suitable as encryption keys:
- Non-uniform distribution (some bit patterns more likely)
- No domain separation (same secret used for different purposes)
- No key expansion (need multiple keys from one secret)
How HKDF Works
HKDF has two phases: Extract and Expand.
Extract Phase:
PRK = HMAC-SHA256(salt, input_key_material)- Takes non-uniform input (DH output)
- Produces uniformly random pseudorandom key (PRK)
- Salt adds additional randomness
Expand Phase:
OKM = HMAC-SHA256(PRK, info || 0x01) ||
HMAC-SHA256(PRK, T1 || info || 0x02) ||
...- Expands PRK to arbitrary length
infoparameter provides domain separation
Domain Separation
The info parameter ensures keys for different purposes are independent:
| Usage | Info String | Result |
|---|---|---|
| X3DH | ”ZentalkX3DH” | Initial shared secret |
| Chain key | ”ZentalkChain” | Message key derivation |
| Ratchet | ”ZentalkRatchet” | DH ratchet keys |
| Hybrid | ”ZentalkHybrid” | Post-quantum combination |
Why this matters:
- Even with the same input, different
infoproduces different keys - Compromise of one key doesn’t reveal others
- Prevents cross-protocol attacks
Double Ratchet Algorithm
The Problem
X3DH creates one shared secret. But we need:
- A new key for every message (forward secrecy)
- Recovery if a key is compromised (post-compromise security)
Two Ratchets
The Double Ratchet combines two mechanisms:
1. Symmetric Ratchet (KDF Chain)
For each message:
Message_Key, Chain_Key_new = HKDF(Chain_Key, "ZentalkChain")
Encrypt message with Message_Key
Delete Message_Key immediately
Chain_Key = Chain_Key_newWhy this provides forward secrecy:
- Each Message_Key is derived from Chain_Key
- After derivation, previous Chain_Key is deleted
- Attacker who captures Chain_Key at time T cannot derive keys from time T-1
2. DH Ratchet (Asymmetric)
When receiving new DH public key from peer:
DH_output = X25519(my_private, their_new_public)
Root_Key_new, Chain_Key_new = HKDF(Root_Key, DH_output)
Generate new DH keypair for myselfWhy this provides “healing”:
- Fresh DH exchange creates completely new key material
- Even if Chain_Key was compromised, new DH recovers security
- Attacker must maintain continuous access to break future messages
Visual: How Keys Flow
Root_Key ──HKDF──► Root_Key_new ──HKDF──► ...
│ │
└──► Chain_Key └──► Chain_Key
│ │
├──► MK1 ├──► MK4
├──► MK2 ├──► MK5
└──► MK3 └──► MK6Each message uses a unique Message Key (MK). Compromising MK3 reveals nothing about MK1, MK2, MK4, etc.
Forward Secrecy Timeline
| Event | Attacker learns | Can decrypt |
|---|---|---|
| Captures MK5 | MK5 only | Message 5 only |
| Captures Chain_Key at step 5 | MK5, MK6, MK7… | Messages 5+ until DH ratchet |
| Captures Root_Key | Future chains | Until peer sends new DH |
| Captures Identity Key | Nothing directly | Must also break session |
Key insight: The more frequently DH ratchets occur (more messages exchanged), the smaller the window of compromise.
Forward Secrecy Analysis
This section provides a rigorous analysis of forward secrecy guarantees, compromise windows, and practical limitations of the Double Ratchet and Sender Keys protocols.
What Forward Secrecy Actually Guarantees
Precise Definition: Forward secrecy ensures that compromise of long-term keys at time T does not reveal session keys (and thus messages) from time T-1 or earlier.
However, this definition requires careful qualification:
Forward Secrecy ≠ "Past messages are always safe"
Forward Secrecy = "Compromise of THIS key doesn't reveal THOSE messages"The crucial distinction: Forward secrecy protects against future compromise of current keys, not against current compromise of current keys. If an attacker obtains your Chain Key right now, they can derive all future Message Keys in that chain until a DH ratchet occurs.
Levels of Key Compromise
Different keys have different compromise impacts:
| Key Type | Derivation | Lifetime | Compromise Impact |
|---|---|---|---|
| Message Key | From Chain Key | Single message | Minimal - one message |
| Chain Key | From Root Key + DH | Until DH ratchet | Medium - messages in chain |
| Root Key | From previous Root + DH | Until DH ratchet | High - current + future chains |
| Identity Key | Generated once | Permanent | Critical - future sessions |
Compromise Window Analysis
Detailed breakdown of what each key compromise reveals:
| Key Compromised | Messages Exposed | Recovery Mechanism | Time to Recovery |
|---|---|---|---|
| Message Key (MK) | 1 message only | Automatic (MK deleted after use) | Immediate |
| Chain Key (CK) | All messages until next DH ratchet | Next reply from conversation peer | Variable (depends on peer activity) |
| Root Key (RK) | Current chain + all future chains | Next DH ratchet with new peer key | Variable (depends on peer activity) |
| Identity Key (IK) | Future sessions only (not past) | Manual key rotation | User action required |
Mathematical representation:
Let CK_n be the Chain Key at step n. An attacker with CK_n can compute:
CK_{n+1}, MK_n = HKDF(CK_n)
CK_{n+2}, MK_{n+1} = HKDF(CK_{n+1})
...and so onBut cannot compute:
CK_{n-1} (one-way function)
MK_{n-1} (deleted)DH Ratchet Frequency
When does a DH ratchet occur?
The DH ratchet advances when a party receives a message with a new DH public key from their peer. This happens when:
- Alice sends a message (includes her new DH public key)
- Bob receives it and performs DH ratchet
- Bob sends a reply (includes his new DH public key)
- Alice receives it and performs DH ratchet
Critical observation: The DH ratchet requires bidirectional communication.
One-Sided Conversation Problem
Scenario: Alice sends 1,000 messages to Bob without any reply.
Message 1: Alice → Bob (Alice's DH key: A₁)
Message 2: Alice → Bob (Alice's DH key: A₁) ← Same DH key!
Message 3: Alice → Bob (Alice's DH key: A₁)
...
Message 1000: Alice → Bob (Alice's DH key: A₁)
All 1000 messages use the same sending chain!Consequence: If Alice’s sending Chain Key is compromised at message 500:
- Messages 500-1000 can be decrypted
- Messages 1-499 are safe (MKs deleted, CK one-way)
Worst case exposure:
| Scenario | Messages at Risk |
|---|---|
| Normal conversation (alternating) | 1-2 messages per chain |
| Monologue (no replies) | Unlimited until reply |
| Group broadcast | All messages since last member change |
Quantitative Analysis
Average messages per ratchet step (typical conversation):
E[messages per chain] = 1 / P(peer replies)For a balanced conversation where each party has 50% chance of the next message:
E[messages per chain] ≈ 2For a support chat where user sends 80% of messages:
E[messages per chain for user] ≈ 1.25
E[messages per chain for support] ≈ 5Time window of exposure:
| Conversation Pattern | Avg Chain Length | Typical Time Window |
|---|---|---|
| Active chat (both typing) | 1-2 messages | Seconds |
| Async messaging | 3-5 messages | Minutes to hours |
| Announcements (one-way) | Unbounded | Days to weeks |
| Dormant conversation | 1 message | Until next exchange |
Comparison with Other Protocols
| Protocol | Forward Secrecy Granularity | Recovery Mechanism |
|---|---|---|
| Signal (Double Ratchet) | Per-chain (requires reply) | DH ratchet on reply |
| Matrix (Megolm) | Per-session | New session creation |
| WhatsApp (Signal) | Per-chain | DH ratchet on reply |
| TLS 1.3 | Per-session | New connection |
| OTR | Per-message (with ACK) | Every message exchange |
Signal/Zentalk vs OTR:
- OTR provides per-message forward secrecy but requires acknowledgments
- Signal optimizes for async by batching ratchets with replies
- Trade-off: Signal has larger compromise windows but works offline
Sender Keys Limitation (Group Chats)
Group messaging uses a fundamentally different model:
Alice's Sender Key → Encrypts ALL of Alice's messages to group
Bob's Sender Key → Encrypts ALL of Bob's messages to groupCritical limitation: There is no per-message forward secrecy in group chats.
| Individual Chat | Group Chat |
|---|---|
| Per-message keys | Per-sender key |
| DH ratchet on reply | No ratchet mechanism |
| Compromise exposes chain | Compromise exposes ALL future messages from sender |
| Automatic recovery | Recovery only on member change |
Group compromise scenario:
1. Alice joins group at time T₀
2. Alice's Sender Key is compromised at time T₁
3. Attacker can decrypt:
- All of Alice's messages from T₁ onwards
- NOT other members' messages
- NOT Alice's messages before T₁
4. Recovery: Alice must leave and rejoin (new Sender Key)
OR group membership must change (forces rekey)Mathematical representation:
For a sender key SK compromised at message n:
Exposed: Decrypt(SK, message_n), Decrypt(SK, message_{n+1}), ...
Safe: message_1 through message_{n-1} (forward secrecy preserved)Sender Key Rotation Policies
When Sender Keys rotate:
| Event | Rotation | Rationale |
|---|---|---|
| Member joins | All Sender Keys | New member shouldn’t read history |
| Member leaves | All Sender Keys | Leaving member shouldn’t read future |
| Periodic timer | Optional | Limit exposure window |
| Manual request | Single Sender Key | User-initiated recovery |
Recommended rotation frequency:
Rotation interval = min(
max_messages_per_sender,
max_time_since_rotation,
any_membership_change
)
Suggested values:
- max_messages_per_sender: 1000
- max_time_since_rotation: 7 daysAttack Scenarios and Mitigations
Scenario 1: Chain Key Theft
Attack: Malware extracts Chain Key from memory
Exposed: ~N messages (N = messages until peer replies)
Mitigation: Encourage frequent replies, periodic "ping" messagesScenario 2: Long-term Key Compromise
Attack: Identity Key stolen via device seizure
Exposed: All future sessions (past sessions safe)
Mitigation: Safety number verification, key rotation capabilityScenario 3: Group Sender Key Theft
Attack: Single group member's device compromised
Exposed: All future messages from that member
Mitigation: Periodic forced rotation, membership change triggersRecommendations for Maximum Security
For Individual Conversations:
-
Encourage back-and-forth messaging
- Each reply triggers a DH ratchet
- Smaller compromise windows
-
Send periodic “ping” messages
- For one-sided communications, receiver should acknowledge
- Even an empty acknowledgment advances the ratchet
-
Verify Safety Numbers
- Detects MITM attacks on key exchange
- Should be done out-of-band
For Group Chats:
-
Limit group size
- Fewer members = fewer potential compromise points
- Consider splitting large groups
-
Implement rotation policies
- Time-based: Rotate every 7 days
- Volume-based: Rotate every 1000 messages
- Event-based: Rotate on any membership change
-
Use ephemeral messages
- Auto-delete reduces value of key compromise
- Complements but doesn’t replace forward secrecy
-
Audit group membership regularly
- Remove inactive members
- Each removal triggers security recovery
Summary: What’s Protected vs What’s Not
| Protected (Forward Secrecy Works) | Not Protected (Limitations) |
|---|---|
| Past messages (MKs deleted) | Future messages in same chain |
| Messages before Chain Key compromise | Messages after CK compromise, before ratchet |
| Other parties’ Sender Keys | Your Sender Key (groups) |
| Past sessions with compromised IK | Future sessions with compromised IK |
Key takeaway: Forward secrecy is not absolute. Understanding its boundaries helps users make informed security decisions about when to force ratchets, rotate keys, and verify security.
AES-256-GCM: Authenticated Encryption
Why Encryption Alone Is Not Enough
Basic encryption (like AES-CBC) only provides confidentiality:
Attacker intercepts: E(key, "Transfer $100 to Alice")
Attacker modifies ciphertext bits
Result might decrypt to: "Transfer $900 to Alice"Without authentication, attackers can modify ciphertexts even without knowing the plaintext.
Galois/Counter Mode (GCM)
GCM provides both encryption AND authentication in one operation:
ciphertext, tag = AES-GCM-Encrypt(key, nonce, plaintext, associated_data)| Component | Purpose |
|---|---|
| Ciphertext | Encrypted message (confidentiality) |
| Tag | Authentication code (integrity) |
| Associated Data | Authenticated but not encrypted |
How GCM Works
Counter Mode Encryption:
Counter_0 = Nonce || 0x00000001
Counter_1 = Nonce || 0x00000002
...
Ciphertext = Plaintext XOR AES(Key, Counter_1) ||
Plaintext XOR AES(Key, Counter_2) || ...GHASH Authentication:
Tag = GHASH(H, Associated_Data || Ciphertext || lengths)
where H = AES(Key, 0)GHASH is a polynomial evaluation in GF(2^128) - very fast, especially with hardware support.
Nonce Requirements
Critical: The nonce must never repeat with the same key.
| Property | Value |
|---|---|
| Nonce size | 96 bits (12 bytes) |
| Generation | Random or counter |
| Collision probability | 2^-48 after 2^48 messages |
What happens if nonce repeats:
- XOR of two plaintexts is revealed
- Authentication key H can be recovered
- All past and future messages with this key become vulnerable
Zentalk’s approach: Random 96-bit nonce per message. With 2^48 messages before 50% collision probability, this is safe for any realistic message volume.
Associated Data in Zentalk
AD = version || sender_id || recipient_id || timestampWhy these fields:
- version: Prevents downgrade attacks
- sender_id: Ensures message from claimed sender
- recipient_id: Prevents message redirection
- timestamp: Prevents replay attacks
If an attacker modifies ANY of these fields, decryption fails.
Ed25519: Digital Signatures
What Signatures Provide
| Property | Meaning |
|---|---|
| Authentication | Message came from claimed sender |
| Integrity | Message was not modified |
| Non-repudiation | Sender cannot deny sending |
How Ed25519 Works
Ed25519 is based on the Edwards curve:
-x² + y² = 1 + d·x²·y² (mod p)
where p = 2^255 - 19, d = -121665/121666Signing:
1. r = H(private_key_prefix || message) // Deterministic!
2. R = r × G // Point multiplication
3. s = r + H(R || public_key || message) × private_key
4. Signature = (R, s)Verification:
Check: s × G == R + H(R || public_key || message) × public_keyWhy Ed25519
| Property | Benefit |
|---|---|
| Deterministic | No random number needed (no RNG failures) |
| Fast | ~70,000 signatures/second on modern CPU |
| Small | 64-byte signatures, 32-byte public keys |
| Secure | 128-bit security level |
| Simple | Fewer implementation pitfalls than ECDSA |
Signatures in Zentalk
| Location | What’s Signed | Purpose |
|---|---|---|
| Signed Pre-Key | SPK + timestamp | Proves SPK belongs to identity |
| Group messages | Ciphertext | Authenticates sender to group |
| Key bundles | Public keys | Prevents key substitution |
Kyber-768: Post-Quantum Cryptography
The Quantum Threat
Shor’s Algorithm can break:
- RSA (factoring)
- DH/ECDH (discrete logarithm)
- ECDSA/Ed25519 (elliptic curve discrete log)
A sufficiently powerful quantum computer could decrypt all messages encrypted with these algorithms.
Why Kyber Is Quantum-Safe
Kyber is based on the Module Learning With Errors (M-LWE) problem:
Given: A (random matrix), b = A·s + e (where e is small error)
Find: s (the secret)Why this is hard:
- No known quantum algorithm solves LWE efficiently
- Best known quantum attack is still exponential
- Even with error, recovering s is computationally infeasible
Encapsulation vs Encryption
Kyber is a Key Encapsulation Mechanism (KEM), not encryption:
Encryption:
ciphertext = Encrypt(public_key, message)
message = Decrypt(private_key, ciphertext)Encapsulation:
ciphertext, shared_secret = Encapsulate(public_key)
shared_secret = Decapsulate(private_key, ciphertext)The sender doesn’t choose the shared secret - they encapsulate a random value. This provides stronger security guarantees (IND-CCA2).
Hybrid Approach
Zentalk combines X25519 and Kyber:
SK_classical = X3DH(...) // X25519-based
SK_kyber = Kyber.Decapsulate(ciphertext)
SK_final = HKDF(SK_classical || SK_kyber, "ZentalkHybrid")Why hybrid:
- If X25519 is broken (quantum computer): Kyber protects
- If Kyber has undiscovered flaw: X25519 protects
- Security = stronger of the two
Kyber Parameters
| Parameter | Kyber-768 |
|---|---|
| Security level | NIST Level 3 (~192-bit classical) |
| Public key | 1,184 bytes |
| Private key | 2,400 bytes |
| Ciphertext | 1,088 bytes |
| Shared secret | 32 bytes |
PBKDF2: Password-Based Key Derivation
The Problem
Users have weak passwords. A password like “password123” has only ~40 bits of entropy.
An attacker can try billions of passwords per second with specialized hardware.
How PBKDF2 Slows Attacks
key = PBKDF2(password, salt, iterations=600000)How it works:
U1 = HMAC(password, salt || 0x00000001)
U2 = HMAC(password, U1)
U3 = HMAC(password, U2)
...
U600000 = HMAC(password, U599999)
Output = U1 XOR U2 XOR U3 XOR ... XOR U600000With 600,000 iterations:
| Attacker | Guesses/second | Time for 1 billion passwords |
|---|---|---|
| Without PBKDF2 | 10 billion | 0.1 seconds |
| With PBKDF2 | ~17,000 | ~17 hours |
Salt Prevents Precomputation
Without salt, attackers can build rainbow tables - precomputed password→key mappings.
With salt:
salt = random(32 bytes)
key = PBKDF2(password, salt, 600000)Each salt requires a completely new rainbow table. With 256-bit salts, precomputation is infeasible.
Usage in Zentalk
| Application | Iterations | Purpose |
|---|---|---|
| Key backup encryption | 600,000 | Protect exported keys |
| Local storage | Device-dependent | Protect IndexedDB |
Security Properties Summary
| Property | Mechanism | Protected Against |
|---|---|---|
| Confidentiality | AES-256-GCM | Eavesdroppers |
| Integrity | GCM tag | Message modification |
| Authentication | Ed25519 signatures | Impersonation |
| Forward Secrecy | Double Ratchet | Future key compromise |
| Post-Compromise | DH Ratchet | Past key compromise |
| Quantum Resistance | Kyber-768 | Future quantum computers |
| Anonymity | 3-hop relay | Traffic analysis |
Local Storage & Encryption
This section details how Zentalk protects data at rest on user devices. Local storage security is critical because even perfect transport encryption is meaningless if an attacker can simply read unencrypted data from disk.
Storage Architecture
Zentalk uses IndexedDB as the primary local storage mechanism across all web-based platforms. The database architecture is designed with security compartmentalization in mind.
Database Structure Overview:
| Database | Object Stores | Purpose | Encryption Layer |
|---|---|---|---|
zentalk_identity | identity_keys, prekeys, signed_prekeys | Long-term cryptographic identity | Storage Master Key |
zentalk_sessions | sessions, sender_keys | Active session state | Session Storage Key |
zentalk_messages | messages, attachments, drafts | Message history | Message Storage Key |
zentalk_contacts | contacts, groups, blocked | Contact metadata | Metadata Storage Key |
Object Store Schemas:
| Store | Key Path | Indexed Fields | Encrypted Fields |
|---|---|---|---|
identity_keys | key_id | created_at, key_type | private_key, public_key |
sessions | session_id | peer_id, updated_at | root_key, chain_keys, pending_prekey |
messages | message_id | conversation_id, timestamp, status | content, attachments, metadata |
contacts | contact_id | display_name, last_seen | identity_key, verified_status |
Storage Encryption Layers:
The encryption architecture uses a layered approach where each layer provides distinct protection:
User Password
│
▼
┌─────────────────────────────────┐
│ PBKDF2 Key Derivation │
│ (600,000 iterations) │
└─────────────────────────────────┘
│
▼
Storage Master Key (SMK)
│
├──► Identity Storage Key ──► Encrypts identity_keys store
├──► Session Storage Key ──► Encrypts sessions store
├──► Message Storage Key ──► Encrypts messages store
└──► Metadata Storage Key ──► Encrypts contacts storeLocal Encryption Key Derivation
The storage encryption key hierarchy begins with user authentication. A user-provided password (or biometric-derived secret) is transformed into cryptographic keys through a carefully designed derivation process.
PBKDF2 Parameters:
| Parameter | Value | Rationale |
|---|---|---|
| Algorithm | PBKDF2-HMAC-SHA256 | Widely audited, hardware-resistant |
| Iterations | 600,000 | OWASP 2024 recommendation |
| Salt length | 256 bits | Prevents rainbow table attacks |
| Output length | 256 bits | Matches AES-256 key size |
| Salt storage | Unencrypted in localStorage | Required for derivation |
Key Derivation Pseudocode:
FUNCTION derive_storage_master_key(password, salt):
IF salt IS NULL:
salt = secure_random(32 bytes)
store_salt(salt)
master_key = PBKDF2(
password = password,
salt = salt,
iterations = 600000,
key_length = 32,
hash = SHA256
)
RETURN master_keyKey Hierarchy Derivation:
From the Storage Master Key, individual per-database keys are derived using HKDF with distinct info strings:
| Derived Key | HKDF Info | Purpose |
|---|---|---|
| Identity Storage Key | "zentalk-identity-storage-v1" | Encrypt identity database |
| Session Storage Key | "zentalk-session-storage-v1" | Encrypt session database |
| Message Storage Key | "zentalk-message-storage-v1" | Encrypt message database |
| Metadata Storage Key | "zentalk-metadata-storage-v1" | Encrypt contact database |
FUNCTION derive_storage_keys(master_key):
identity_key = HKDF(
ikm = master_key,
salt = NULL,
info = "zentalk-identity-storage-v1",
length = 32
)
session_key = HKDF(
ikm = master_key,
salt = NULL,
info = "zentalk-session-storage-v1",
length = 32
)
// Similar for message_key and metadata_key
RETURN {identity_key, session_key, message_key, metadata_key}Why Separate Keys Per Database:
| Benefit | Description |
|---|---|
| Compromise isolation | Leaking one key doesn’t expose all data |
| Selective decryption | Can unlock only needed databases |
| Key rotation independence | Can rotate one key without affecting others |
| Audit granularity | Can track access per data category |
Message History Encryption
Messages are encrypted individually at rest, providing defense in depth beyond session encryption.
Per-Message Encryption:
Each message stored in IndexedDB is encrypted with a unique key derived from the Message Storage Key:
| Field | Encryption | Derivation |
|---|---|---|
content | AES-256-GCM | HKDF(message_storage_key, message_id) |
attachments | AES-256-GCM | HKDF(message_storage_key, attachment_id) |
metadata | AES-256-GCM | HKDF(message_storage_key, message_id + “meta”) |
FUNCTION encrypt_message_at_rest(message, message_storage_key):
message_encryption_key = HKDF(
ikm = message_storage_key,
salt = message.id,
info = "zentalk-message-encrypt",
length = 32
)
nonce = secure_random(12 bytes)
encrypted_content = AES_GCM_Encrypt(
key = message_encryption_key,
nonce = nonce,
plaintext = message.content,
aad = message.id || message.conversation_id
)
STORE {
id: message.id,
conversation_id: message.conversation_id,
timestamp: message.timestamp, // Plaintext for indexing
status: message.status, // Plaintext for queries
nonce: nonce,
encrypted_content: encrypted_content
}
SECURE_WIPE(message_encryption_key)Session Survival After Session Reset:
A critical design consideration is that message history must survive session resets. The storage encryption keys are completely independent from session keys:
| Key Type | Derived From | Lifetime | Purpose |
|---|---|---|---|
| Session Keys | X3DH + Double Ratchet | Per-conversation, rotating | Encrypt messages in transit |
| Storage Keys | User password | Until password change | Encrypt messages at rest |
Session Keys (Transport): Storage Keys (At Rest):
┌────────────────────────┐ ┌────────────────────────┐
│ Root Key │ │ Storage Master Key │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ Chain Key │ ──► │ Message Storage Key │
│ │ │ (no │ │ │
│ ▼ │ link) │ ▼ │
│ Message Key ──decrypt─►│ │ Per-Message Key │
│ │ │ │ │
│ (deleted after use) │ │ ▼ │
└────────────────────────┘ │ Stored ciphertext │
└────────────────────────┘Why This Separation Matters:
| Scenario | Session Keys | Storage Keys | Message History |
|---|---|---|---|
| Session reset by user | Regenerated | Unchanged | Preserved |
| Safety number change | Regenerated | Unchanged | Preserved |
| Password change | Unchanged | Regenerated | Re-encrypted |
| Device wipe | Destroyed | Destroyed | Lost |
Key Storage Security
Long-term cryptographic keys require special handling to protect against extraction and misuse.
Identity Key Protection:
| Protection Layer | Mechanism | Protects Against |
|---|---|---|
| Storage encryption | AES-256-GCM with Identity Storage Key | Disk access |
| Memory protection | Cleared after use, allocated in secure heap | Memory dumps |
| Access control | Requires user authentication | Unauthorized app access |
FUNCTION store_identity_key(identity_keypair, identity_storage_key):
nonce = secure_random(12 bytes)
serialized = serialize({
public_key: identity_keypair.public,
private_key: identity_keypair.private,
created_at: current_timestamp(),
key_version: 1
})
encrypted = AES_GCM_Encrypt(
key = identity_storage_key,
nonce = nonce,
plaintext = serialized,
aad = "identity-key-storage"
)
IndexedDB.put("identity_keys", {
key_id: hash(identity_keypair.public),
nonce: nonce,
ciphertext: encrypted,
created_at: current_timestamp()
})
SECURE_WIPE(serialized)
SECURE_WIPE(identity_keypair.private)Session Key Storage:
Active session state contains sensitive cryptographic material:
| Component | Sensitivity | Storage Treatment |
|---|---|---|
| Root Key | Critical | Encrypted, wiped from memory after derivation |
| Chain Keys (sending/receiving) | High | Encrypted, rotated frequently |
| Message Keys | High | Never stored (derived on demand, deleted after use) |
| Pending Prekey | Medium | Encrypted, deleted after X3DH completion |
| Ratchet Public Keys | Low | Encrypted (for consistency) |
Secure Key Deletion:
Expired and used keys must be securely deleted to maintain forward secrecy guarantees:
FUNCTION secure_delete_session(session_id, session_storage_key):
// Retrieve session for secure wiping
session = decrypt_session(session_id, session_storage_key)
// Overwrite sensitive fields with random data
SECURE_WIPE(session.root_key)
SECURE_WIPE(session.sending_chain_key)
SECURE_WIPE(session.receiving_chain_key)
FOR EACH skipped_key IN session.skipped_message_keys:
SECURE_WIPE(skipped_key)
// Delete from IndexedDB
IndexedDB.delete("sessions", session_id)
// Request garbage collection hint
schedule_gc_hint()Skipped Message Key Handling:
Out-of-order messages require temporary storage of skipped message keys:
| Policy | Value | Rationale |
|---|---|---|
| Maximum skipped keys | 1,000 per chain | Balance between flexibility and storage |
| Expiration time | 7 days | Limit exposure window |
| Storage | Encrypted with session key | Consistent protection |
Data at Rest Protection
Understanding what is encrypted versus what remains in plaintext is crucial for threat modeling.
Encryption Status by Data Type:
| Data Category | Encrypted | Plaintext | Rationale |
|---|---|---|---|
| Message content | Yes | - | Core privacy requirement |
| Message timestamps | - | Yes | Required for efficient queries |
| Message status | - | Yes | UI display without decryption |
| Contact names | Yes | - | Privacy protection |
| Contact IDs | - | Yes | Required for indexing |
| Identity private keys | Yes | - | Critical security |
| Identity public keys | Yes | - | Consistency (not secret) |
| Session root keys | Yes | - | Critical security |
| Group membership | - | Yes | Required for message routing |
| Settings/preferences | - | Yes | Non-sensitive configuration |
Attack Scenario Analysis:
| Attack Scenario | Data Exposed | Mitigation |
|---|---|---|
| Device theft (locked) | Nothing (screen lock protects access) | Full-disk encryption + screen lock |
| Device theft (unlocked, app closed) | IndexedDB ciphertexts, salts | Storage encryption requires password |
| Device theft (app open) | Decrypted keys in memory | Memory protection, auto-lock timer |
| Forensic analysis of disk | Encrypted blobs, metadata | No plaintext secrets on disk |
| Forensic analysis of memory dump | Potentially active keys | Secure memory allocation, key wiping |
| Malicious app on device | Limited by OS sandboxing | Storage encryption, secure enclaves |
| Cloud backup extraction | Depends on backup encryption | Optional encrypted backup, or exclude |
Memory Protection Considerations:
| Technique | Browser Support | Effectiveness |
|---|---|---|
| Zeroing after use | All | Medium (GC may copy) |
| Typed arrays (no GC) | All | High for key material |
| WebAssembly memory | Modern | High (isolated heap) |
| Secure enclave | Limited (see below) | Very high |
| Short key lifetimes | All | Reduces exposure window |
FUNCTION secure_wipe(buffer):
IF buffer IS TypedArray:
// Direct memory access - most secure in browser
crypto.getRandomValues(buffer) // Overwrite with random
buffer.fill(0) // Then zero
ELSE IF buffer IS String:
// Strings are immutable - cannot be securely wiped
// Minimize string usage for secrets
LOG_WARNING("Cannot securely wipe string - use TypedArray")
// Note: JavaScript GC may have already copied the data
// This is a fundamental browser limitationCross-Platform Considerations
Storage security varies significantly across platforms due to different OS capabilities and security APIs.
Browser Storage Limitations:
| Browser | Storage Mechanism | Security Limitations |
|---|---|---|
| Chrome | IndexedDB + LevelDB | No OS-level encryption by default |
| Firefox | IndexedDB + SQLite | Same origin policy only |
| Safari | IndexedDB + SQLite | Potentially more restrictive |
| All | localStorage | Never use for secrets (synchronous, no encryption) |
Browser-Specific Concerns:
| Concern | Description | Mitigation |
|---|---|---|
| No secure enclave access | Web Crypto API lacks hardware key storage | Software encryption + short lifetimes |
| GC unpredictability | Cannot guarantee memory wiping | Use TypedArrays, minimize copies |
| Extension access | Browser extensions may access storage | Content Security Policy, integrity checks |
| Dev tools | Sophisticated users can inspect | Defense in depth, not obscurity |
Mobile Secure Enclaves:
| Platform | Secure Storage API | Zentalk Usage |
|---|---|---|
| iOS | Keychain Services + Secure Enclave | Store Storage Master Key |
| Android | Android Keystore + StrongBox | Store Storage Master Key |
| Both | Biometric authentication | Gate key access to face/fingerprint |
MOBILE INTEGRATION (iOS/Android native wrapper):
FUNCTION initialize_secure_storage():
IF platform IS iOS:
// Check for Secure Enclave availability
IF SecureEnclave.isAvailable():
master_key_ref = SecureEnclave.generateKey(
algorithm = AES256,
access_control = biometric_or_passcode,
synchronizable = FALSE // Don't sync to iCloud
)
ELSE:
master_key_ref = Keychain.generateKey(...)
ELSE IF platform IS Android:
IF StrongBox.isAvailable():
master_key_ref = AndroidKeystore.generateKey(
algorithm = AES256,
user_authentication_required = TRUE,
invalidated_by_biometric_enrollment = TRUE
)
ELSE:
master_key_ref = AndroidKeystore.generateKey(
// Software-backed fallback
)
RETURN master_key_refDesktop Keychain Integration:
| Platform | Keychain System | Integration Method |
|---|---|---|
| macOS | Keychain Services | Store master key with ACL |
| Windows | Windows Credential Manager / DPAPI | Protect master key with user login |
| Linux | libsecret / GNOME Keyring / KWallet | Store master key per desktop environment |
Desktop Integration Pseudocode:
DESKTOP INTEGRATION:
FUNCTION store_master_key_in_keychain(master_key):
IF platform IS macOS:
Keychain.addItem(
service = "com.zentalk.storage",
account = user_id,
data = master_key,
access = when_unlocked_this_device_only
)
ELSE IF platform IS Windows:
// DPAPI ties encryption to user login
encrypted = DPAPI.Protect(
data = master_key,
scope = current_user
)
CredentialManager.write(
target = "Zentalk Storage Key",
credential = encrypted
)
ELSE IF platform IS Linux:
// Use secret service API (works with GNOME Keyring, KWallet)
SecretService.store(
schema = "com.zentalk.storage",
attributes = {user_id: user_id},
secret = master_key,
collection = default_collection
)Platform Comparison Matrix:
| Feature | Web Browser | iOS Native | Android Native | Desktop Electron |
|---|---|---|---|---|
| Secure enclave | No | Yes (A7+) | Yes (StrongBox) | No |
| Hardware-backed keys | No | Yes | Yes (TEE) | Limited (TPM) |
| Biometric auth for keys | No | Yes | Yes | OS-dependent |
| Memory protection | Limited | Good | Good | Limited |
| Secure key deletion | Best effort | Guaranteed | Guaranteed | Best effort |
| Backup exclusion | No control | entitlements | android:allowBackup | App-specific |
Cross-Platform Key Synchronization:
Zentalk does not automatically synchronize storage encryption keys across devices:
| Approach | Security | Usability | Zentalk Choice |
|---|---|---|---|
| No sync | Highest | Each device independent | Default |
| Encrypted backup to server | Medium | User controls recovery | Optional |
| Cloud keychain sync | Lower | Seamless multi-device | Not used |
MULTI-DEVICE STRATEGY:
Device A (phone):
Storage Master Key A ──► Encrypts local DB A
│
└──► User exports encrypted backup (optional)
│
▼
Cloud storage (E2E encrypted with backup password)
│
▼
Device B (tablet):
User imports backup
│
▼
Storage Master Key B ──► Decrypts imported data
│ Re-encrypts for local storage
└──► Independent key, independent local encryptionRelated Documentation
- Protocol Specification - How these primitives are combined
- Threat Model - Attack scenarios and defenses
- Architecture - System overview