Protocol Specification
Technical specification of the Zentalk cryptographic protocol.
Overview
The Zentalk Protocol provides end-to-end encrypted messaging with forward secrecy and post-quantum resistance. All encryption and decryption occurs on the client device.
Zero-Knowledge Architecture
Zentalk implements a zero-knowledge design where the server cannot access message content, private keys, or meaningful metadata. The architecture ensures:
| Server Sees | Server Cannot See |
|---|---|
| Encrypted blobs | Message content |
| Public keys | Private keys |
| Wallet address | Contact graph |
| Encrypted timestamps | Sender ↔ Recipient correlation |
Metadata Protection:
- Traffic padding: Constant-rate dummy traffic masks real patterns
- Timestamp bucketing: Messages grouped into time windows
- 3-hop relay routing: Zentalk’s own relay system hides sender/recipient relationship (not Tor)
- No logging: Nodes don’t store message metadata
Security Properties
| Property | Mechanism |
|---|---|
| Confidentiality | AES-256-GCM encryption |
| Integrity | GCM authentication tag |
| Authenticity | Ed25519 signatures |
| Forward Secrecy | Double Ratchet key rotation |
| Post-Quantum Security | Kyber-768 hybrid mode |
| Deniability | No cryptographic proof of sender |
X3DH Key Exchange
Extended Triple Diffie-Hellman (X3DH) establishes a shared secret between two parties who have never communicated.
Why X3DH?
The Problem: Basic Diffie-Hellman requires both parties to be online simultaneously. But messaging needs to work when Bob is offline - Alice should be able to send a message that Bob decrypts later.
The Solution: Bob pre-publishes keys to a server. When Alice wants to contact Bob, she fetches these keys and computes the shared secret without Bob’s participation. Bob can decrypt later using his private keys.
Key Bundle
Each user publishes a key bundle:
| Key | Algorithm | Lifetime | Count |
|---|---|---|---|
| Identity Key (IK) | Ed25519 | Long-term | 1 |
| Signed Pre-Key (SPK) | X25519 | 7 days | 1 |
| One-Time Pre-Keys (OPK) | X25519 | Single use | 100 |
Key Lifecycle
Keys follow strict lifecycle management for forward secrecy:
- Identity Key: Generated once, never rotated (identifies user)
- Signed Pre-Key: Rotated every 7 days, signed by Identity Key
- One-Time Pre-Keys: Used exactly once, then deleted
- Message Keys: Derived per-message, immediately deleted after use
Prekey Lifecycle Management
Prekeys are critical to the X3DH protocol’s ability to establish secure sessions with offline recipients. This section details the complete lifecycle management of Signed Pre-Keys (SPK) and One-Time Pre-Keys (OPK).
Signed Pre-Key (SPK) Rotation
The Signed Pre-Key provides forward secrecy on a weekly basis and must be rotated regularly.
Rotation Schedule
| Parameter | Value |
|---|---|
| Rotation interval | 7 days (604,800 seconds) |
| Grace period for old SPK | 48 hours |
| Maximum SPK age | 9 days |
Rotation Trigger
SPK rotation is triggered client-side using a timer mechanism:
1. On app launch:
spk_age = current_time - spk_creation_timestamp
if spk_age >= 7 days:
trigger_spk_rotation()
else:
schedule_timer(7 days - spk_age)
2. Timer fires:
generate_new_spk()
sign_spk()
upload_to_server()
schedule_next_rotation(7 days)SPK Signature Format
The SPK signature proves the key was generated by the Identity Key holder:
┌─────────────────────────────────────────────────────────────┐
│ Signed Data Structure │
├─────────────────────────────────────────────────────────────┤
│ Protocol version (1 byte) │ 0x01 │
│ SPK public key (32 bytes) │ X25519 public key │
│ Timestamp (8 bytes) │ Unix epoch (seconds) │
│ SPK ID (4 bytes) │ Unique identifier │
└─────────────────────────────────────────────────────────────┘
Signature = Ed25519_Sign(IK_private, protocol_version || spk_public || timestamp || spk_id)
Signature length: 64 bytesOld SPK Retention
To handle in-flight messages encrypted with the previous SPK:
| State | Duration | Purpose |
|---|---|---|
| Active | 7 days | Current SPK for new sessions |
| Retained | 48 hours after rotation | Decrypt in-flight messages |
| Deleted | After retention period | Ensure forward secrecy |
SPK storage structure:
{
current_spk: { private, public, signature, created_at, id },
previous_spk: { private, public, signature, created_at, id, retained_until }
}Server-Side SPK Validation
| Check | Rule | Action on Failure |
|---|---|---|
| Signature valid | Ed25519_Verify(IK_public, signed_data, signature) | Reject upload |
| Timestamp fresh | timestamp > (current_time - 24 hours) | Reject upload |
| Timestamp not future | timestamp ≤ (current_time + 5 minutes) | Reject upload |
| SPK ID unique | No collision with existing SPK IDs | Reject upload |
One-Time Pre-Key (OPK) Management
One-Time Pre-Keys provide per-session forward secrecy and are consumed upon use.
Initial Generation
| Parameter | Value |
|---|---|
| Initial key count | 100 keys |
| Key algorithm | X25519 |
| Key size | 32 bytes (public), 32 bytes (private) |
| ID range | 32-bit unsigned integer |
OPK generation:
for i in 0..100:
keypair = X25519_keygen()
opk_id = secure_random_u32()
store_locally(opk_id, keypair.private)
upload_queue.add(opk_id, keypair.public)Exhaustion Detection
The server tracks OPK count per user:
Server-side count tracking:
{
user_address: "0x...",
opk_count: 47,
last_replenishment: 1704067200,
low_threshold_notified: false
}Replenishment Trigger
| Condition | Threshold | Action |
|---|---|---|
| Low OPK warning | count < 20 | Notify client |
| Critical OPK level | count < 5 | Priority notification |
| OPK exhausted | count = 0 | Fallback to SPK-only X3DH |
Replenishment flow:
1. Server detects opk_count < 20
2. Server sets pending_notification = true
3. On next client connection:
server sends: { type: "opk_low", current_count: 17 }
4. Client generates: 100 - current_count = 83 new OPKs
5. Client uploads new OPKs
6. Server updates count, clears notification flagServer Notification Mechanism
WebSocket notification:
{
"type": "key_status",
"opk_count": 17,
"opk_threshold": 20,
"action_required": "replenish_opks",
"timestamp": 1704067200
}
Push notification (if WebSocket unavailable):
{
"priority": "high",
"data": {
"type": "key_maintenance",
"action": "opk_replenish"
}
}SPK-Only Fallback (No OPKs Available)
When all OPKs are consumed, X3DH proceeds without DH4:
Standard X3DH (with OPK):
SK = HKDF(DH1 || DH2 || DH3 || DH4, "ZentalkX3DH")
Fallback X3DH (without OPK):
SK = HKDF(DH1 || DH2 || DH3, "ZentalkX3DHNoOPK")| Mode | Forward Secrecy | Security Level |
|---|---|---|
| With OPK | Per-session | Full |
| Without OPK | Per-SPK rotation (7 days) | Reduced |
Important: The fallback mode is secure but provides weaker forward secrecy. Multiple sessions initiated before SPK rotation share the same DH3 output.
Key Bundle Upload
Bundle Format
Key Bundle Structure:
┌─────────────────────────────────────────────────────────────┐
│ Header │
├─────────────────────────────────────────────────────────────┤
│ Protocol version (1 byte) │ 0x01 │
│ Bundle type (1 byte) │ 0x01 = full │
│ │ 0x02 = OPK only │
│ │ 0x03 = SPK rotation │
│ Timestamp (8 bytes) │ Unix epoch (seconds) │
├─────────────────────────────────────────────────────────────┤
│ Identity Key Section │
├─────────────────────────────────────────────────────────────┤
│ IK public (32 bytes) │ Ed25519 public key │
├─────────────────────────────────────────────────────────────┤
│ Signed Pre-Key Section │
├─────────────────────────────────────────────────────────────┤
│ SPK ID (4 bytes) │ Unique identifier │
│ SPK public (32 bytes) │ X25519 public key │
│ SPK signature (64 bytes) │ Ed25519 signature │
├─────────────────────────────────────────────────────────────┤
│ One-Time Pre-Keys Section │
├─────────────────────────────────────────────────────────────┤
│ OPK count (2 bytes) │ Number of OPKs │
│ For each OPK: │
│ OPK ID (4 bytes) │ Unique identifier │
│ OPK public (32 bytes) │ X25519 public key │
├─────────────────────────────────────────────────────────────┤
│ Kyber Section (if PQC enabled) │
├─────────────────────────────────────────────────────────────┤
│ Kyber public key (1,184 bytes) │ ML-KEM-768 │
└─────────────────────────────────────────────────────────────┘Bundle Size Calculation
| Component | Size | Notes |
|---|---|---|
| Header | 10 bytes | Fixed |
| Identity Key | 32 bytes | Fixed |
| Signed Pre-Key | 100 bytes | ID + public + signature |
| One-Time Pre-Keys (100) | 3,602 bytes | Count + 100 * (ID + public) |
| Kyber (optional) | 1,184 bytes | Only if PQC enabled |
| Total (without Kyber) | 3,744 bytes | |
| Total (with Kyber) | 4,928 bytes |
Upload Authentication
Upload request:
POST /v1/keys/bundle
Authorization: Bearer <JWT>
Content-Type: application/octet-stream
JWT claims:
{
"sub": "0x1234...abcd", // User wallet address
"iat": 1704067200, // Issued at
"exp": 1704067500, // Expires (5 min)
"action": "key_upload",
"bundle_hash": "sha256:abc123..." // Hash of bundle for integrity
}
Signature: Ed25519_Sign(IK_private, JWT_header || JWT_claims)Atomic Update Guarantees
| Guarantee | Implementation |
|---|---|
| All-or-nothing | Database transaction wraps entire update |
| No partial state | Bundle validation before commit |
| Rollback on error | Transaction abort on any failure |
| Concurrent safety | Row-level locking on user record |
Server-side transaction:
BEGIN TRANSACTION;
-- Validate entire bundle first
VALIDATE bundle_signature;
VALIDATE spk_signature;
VALIDATE all opk_ids unique;
-- Apply updates atomically
UPDATE identity_keys SET ...;
UPDATE signed_prekeys SET ...;
INSERT INTO one_time_prekeys ...;
-- Only commit if all validations pass
COMMIT;Protocol Versioning
| Version | Features | Bundle Type |
|---|---|---|
| 0x01 | X25519 + Ed25519 | Standard |
| 0x02 | + Kyber-768 | Hybrid PQC |
| 0x03 | Reserved | Future use |
Version negotiation:
1. Client sends bundle with version = 0x02
2. Server checks supported_versions
3. If server supports 0x02: accept
4. If server only supports 0x01: reject with error
{
"error": "unsupported_version",
"supported": [0x01],
"requested": 0x02
}Server-Side Validation
Complete Validation Pipeline
Validation order (fail-fast):
1. Parse bundle structure
2. Verify protocol version supported
3. Validate timestamp freshness
4. Verify IK matches registered identity
5. Verify SPK signature
6. Check SPK ID uniqueness
7. Check all OPK IDs unique
8. Verify no duplicate OPKs in request
9. Rate limit check
10. Commit to databaseSPK Signature Verification
Verification steps:
1. Extract signed_data = version || spk_public || timestamp || spk_id
2. Extract signature from bundle
3. Fetch IK_public from user record
4. result = Ed25519_Verify(IK_public, signed_data, signature)
5. If !result: reject("invalid_spk_signature")Timestamp Freshness Checks
| Check | Threshold | Error Code |
|---|---|---|
| Too old | > 24 hours in past | timestamp_expired |
| Too far future | > 5 minutes ahead | timestamp_future |
| Clock skew tolerance | +/- 5 minutes | Allowed |
Timestamp validation:
current = server_time()
bundle_time = bundle.timestamp
if bundle_time < current - 86400: // 24 hours
reject("timestamp_expired")
if bundle_time > current + 300: // 5 minutes
reject("timestamp_future")Rate Limiting
| Operation | Limit | Window | Cooldown |
|---|---|---|---|
| Full bundle upload | 1 | 24 hours | 24 hours |
| SPK rotation | 2 | 24 hours | 6 hours |
| OPK replenishment | 10 | 1 hour | None |
| Failed attempts | 5 | 15 minutes | 1 hour |
Rate limit response:
HTTP 429 Too Many Requests
{
"error": "rate_limited",
"operation": "spk_rotation",
"retry_after": 21600,
"limit": 2,
"window": 86400
}Duplicate Key Detection
| Check | Scope | Action |
|---|---|---|
| OPK ID collision (same user) | User’s existing OPKs | Reject specific OPK |
| OPK ID collision (cross-user) | Global | Reject (possible attack) |
| SPK ID reuse | User’s SPK history | Reject upload |
| Public key reuse | Global | Log and alert |
Duplicate detection query:
SELECT COUNT(*) FROM one_time_prekeys
WHERE opk_id IN (incoming_ids)
AND user_id != current_user_id;
if count > 0:
reject("opk_id_collision")
log_security_event("cross_user_opk_collision", details)Edge Cases
Clock Skew Handling
| Scenario | Detection | Resolution |
|---|---|---|
| Client ahead by < 5 min | Timestamp validation | Accept with warning |
| Client ahead by > 5 min | Timestamp rejected | Reject, require NTP sync |
| Client behind by < 24 hours | Timestamp validation | Accept |
| Client behind by > 24 hours | Timestamp expired | Reject, require NTP sync |
Client-side clock validation:
1. Before upload, fetch server time:
GET /v1/time -> { "server_time": 1704067200 }
2. Calculate drift:
drift = abs(local_time - server_time)
3. If drift > 300 seconds:
warn_user("Clock sync required")
offer_ntp_sync()
4. Adjust timestamps:
adjusted_timestamp = local_time - driftConcurrent Updates from Multiple Devices
| Scenario | Detection | Resolution |
|---|---|---|
| Same SPK ID from two devices | ID collision | Last-write-wins with version check |
| Different SPK from two devices | Version mismatch | Higher version wins |
| OPK uploads overlap | No conflict | Both sets merged |
| Simultaneous full bundle | Race condition | Optimistic locking with retry |
Optimistic locking:
1. Client fetches current bundle_version: 42
2. Client prepares update with expected_version: 42
3. Server checks:
if current_version != expected_version:
reject("version_conflict", current_version)
4. Client retries:
- Fetch latest bundle
- Merge changes
- Retry upload with new versionRecovery After Extended Offline Period
| Offline Duration | State on Return | Required Actions |
|---|---|---|
| < 7 days | SPK still valid | OPK replenishment only |
| 7-14 days | SPK expired | SPK rotation + OPK replenishment |
| 14-30 days | SPK very stale | Full bundle regeneration |
| > 30 days | Keys possibly compromised | Full key rotation recommended |
Recovery flow:
1. On app resume after offline:
offline_duration = current_time - last_active_time
2. If offline_duration > 7 days:
spk_must_rotate = true
3. If offline_duration > 30 days:
warn_user("Extended offline period detected")
recommend_full_key_rotation()
4. Check server for OPK count:
GET /v1/keys/status
-> { "opk_count": 3, "spk_valid": false }
5. Execute required maintenance:
if !spk_valid: rotate_spk()
if opk_count < 20: replenish_opks(100 - opk_count)Key Bundle Corruption Detection
| Corruption Type | Detection Method | Recovery |
|---|---|---|
| Local storage corruption | Checksum mismatch | Restore from backup |
| Incomplete bundle | Required fields missing | Re-upload full bundle |
| Invalid signature | Ed25519 verification fails | Regenerate affected key |
| Key mismatch | Public/private pair test | Regenerate key pair |
Integrity verification:
1. Local bundle checksum:
stored_hash = storage.get("bundle_checksum")
current_hash = SHA-256(serialize(bundle))
if stored_hash != current_hash:
corruption_detected = true
2. Key pair validation:
test_message = random_bytes(32)
signature = Ed25519_Sign(ik_private, test_message)
if !Ed25519_Verify(ik_public, test_message, signature):
key_mismatch = true
3. Recovery procedure:
if corruption_detected || key_mismatch:
if backup_available:
restore_from_encrypted_backup()
else:
initiate_full_key_regeneration()
notify_contacts_of_key_change()Protocol Steps
Alice initiates contact with Bob:
1. Alice fetches Bob's key bundle: IK_B, SPK_B, OPK_B
2. Alice generates ephemeral key pair: EK_A
3. Alice computes DH outputs:
DH1 = X25519(IK_A, SPK_B)
DH2 = X25519(EK_A, IK_B)
DH3 = X25519(EK_A, SPK_B)
DH4 = X25519(EK_A, OPK_B) // if OPK available
4. Alice derives shared secret:
SK = HKDF(DH1 || DH2 || DH3 || DH4, "ZentalkX3DH")
5. Alice sends initial message with:
- IK_A (her identity key)
- EK_A (ephemeral public key)
- OPK_B identifier (which one-time key was used)
- Encrypted message using SKWhy Four DH Computations?
Each DH operation provides different security guarantees:
| DH | Purpose | What it proves |
|---|---|---|
| DH1 | Alice’s IK + Bob’s SPK | Alice proves her identity |
| DH2 | Alice’s EK + Bob’s IK | Bob’s identity is verified |
| DH3 | Alice’s EK + Bob’s SPK | Fresh key material (weekly rotation) |
| DH4 | Alice’s EK + Bob’s OPK | Per-session forward secrecy |
Why all four matter:
- Without DH1: Alice could be impersonated
- Without DH2: Bob could be impersonated
- Without DH3/DH4: No forward secrecy (past messages revealed if IK compromised)
Combining all outputs means an attacker must break ALL four DH computations to recover the shared secret.
Key Derivation
Input: DH outputs (128 bytes)
Salt: 0x00 * 32
Info: "ZentalkX3DH"
Output: 32 bytes (root key for Double Ratchet)Double Ratchet
The Double Ratchet algorithm provides ongoing forward secrecy after X3DH establishes the initial shared secret.
Why Double Ratchet?
The Problem: X3DH creates ONE shared secret. If we used that secret for all messages:
- Compromise of that secret reveals ALL past and future messages
- No way to “heal” after a compromise
The Solution: Two ratcheting mechanisms work together:
| Ratchet | Purpose | Frequency |
|---|---|---|
| Symmetric (KDF Chain) | New key per message | Every message |
| Asymmetric (DH) | Completely fresh key material | Every reply |
Forward Secrecy: After each message, the old key is deleted. Even if an attacker captures the current key, they cannot decrypt past messages.
Post-Compromise Healing: When a new DH exchange occurs, completely fresh randomness enters the system. Even if an attacker had compromised a key, they lose access after the next DH ratchet.
Ratchet State
| Field | Type | Purpose |
|---|---|---|
| DHs | X25519 keypair | Current sending DH key |
| DHr | X25519 public | Current receiving DH key |
| RK | 32 bytes | Root key |
| CKs | 32 bytes | Sending chain key |
| CKr | 32 bytes | Receiving chain key |
| Ns | integer | Sending message number |
| Nr | integer | Receiving message number |
| PN | integer | Previous chain length |
Symmetric Ratchet
For each message sent:
1. Derive message key:
MK, CK_new = HKDF(CK, 0x01, "ZentalkChain")
2. Encrypt message:
ciphertext = AES-256-GCM(MK, nonce, plaintext, AD)
3. Update chain key:
CK = CK_new
4. Delete message key (forward secrecy)DH Ratchet
When receiving a new DH public key:
1. Compute DH output:
DH_out = X25519(DHs_private, DHr_new)
2. Derive new keys:
RK_new, CKr_new = HKDF(RK, DH_out, "ZentalkRatchet")
3. Generate new DH keypair:
DHs_new = X25519_keygen()
4. Compute second DH:
DH_out2 = X25519(DHs_new_private, DHr_new)
5. Derive sending chain:
RK_final, CKs_new = HKDF(RK_new, DH_out2, "ZentalkRatchet")Session State Machine
The session state machine governs the lifecycle of encrypted conversations between two parties. It defines the states a session can be in, the transitions between states, and how to handle edge cases like out-of-order messages and session recovery.
Session States
┌────────────┐ initiate() ┌─────────────┐
│ NO_SESSION │─────────────────→│ INITIATING │
└────────────┘ └─────────────┘
↑ │
│ reset() │ X3DH complete
│ ↓
┌────────────┐ timeout ┌─────────────┐
│ CLOSED │←─────────────────│ PENDING │
└────────────┘ └─────────────┘
↑ │
│ close() │ first DR message received
│ ↓
┌────────────┐ no activity ┌─────────────┐
│ STALE │←─────────────────│ ESTABLISHED │
└────────────┘ (30 days) └─────────────┘
│ ↑
└───────────────────────────────┘
any message| State | Description | Entry Condition | Timeout |
|---|---|---|---|
| NO_SESSION | No session exists with this peer | Initial state, or after reset | None |
| INITIATING | X3DH key exchange in progress | initiate() called, fetching key bundle | 30 seconds |
| PENDING | X3DH sent, awaiting first DR response | X3DH message sent to peer | 5 minutes |
| ESTABLISHED | Session active, DR fully operational | First DR message received/sent | None |
| STALE | No activity for extended period | 30 days without message exchange | 90 days |
| CLOSED | Session terminated | Explicit close or timeout from PENDING | None |
State Transitions
| From | To | Trigger | Action |
|---|---|---|---|
| NO_SESSION | INITIATING | User initiates contact | Fetch peer’s key bundle from server |
| INITIATING | PENDING | X3DH computed successfully | Send initial message with X3DH parameters |
| INITIATING | NO_SESSION | Key bundle fetch failed | Clear partial state, notify user |
| INITIATING | CLOSED | Timeout (30s) | Abort, clean up resources |
| PENDING | ESTABLISHED | First DR message received | Initialize DR receive chain |
| PENDING | CLOSED | Timeout (5 min) | Delete session, notify user |
| ESTABLISHED | ESTABLISHED | Message sent/received | Update DR state |
| ESTABLISHED | STALE | 30 days inactivity | Mark for potential refresh |
| STALE | ESTABLISHED | Any message exchange | Reset inactivity timer |
| STALE | CLOSED | 90 days total inactivity | Archive session |
| ESTABLISHED | CLOSED | User closes conversation | Delete ratchet state |
| CLOSED | NO_SESSION | User initiates new contact | Reset all state |
| Any | NO_SESSION | reset() called | Delete all session data |
Invalid Transitions:
| Invalid Transition | Handling |
|---|---|
| NO_SESSION → ESTABLISHED | Must go through INITIATING → PENDING first |
| PENDING → INITIATING | Cannot restart X3DH mid-handshake; reset first |
| CLOSED → ESTABLISHED | Must create new session via NO_SESSION |
When an invalid transition is attempted, the implementation MUST:
- Log the error with session ID and attempted transition
- Return an error to the caller
- NOT modify the current session state
- Optionally trigger a session reset if state is corrupted
X3DH to Double Ratchet Handoff
The transition from X3DH key exchange to Double Ratchet encryption is a critical handoff point. Understanding when each protocol is active prevents security vulnerabilities.
Timeline:
Alice (Initiator) Bob (Responder)
───────────────── ───────────────
1. Generate EK_A
2. Fetch Bob's key bundle
3. Compute SK = X3DH(...)
4. Initialize DR state:
- RK = SK
- DHs = EK_A (reused)
- DHr = SPK_B
- CKs = HKDF(RK, DH(DHs, DHr))
- CKr = null (not yet known)
- Ns = 0, Nr = 0, PN = 0
5. Send first message:
[IK_A, EK_A, OPK_id, DR_msg_0]
6. Receive message
─────── X3DH ENDS HERE ─────── 7. Verify IK_A, look up OPK
8. Compute SK = X3DH(...)
9. Initialize DR state:
- RK = SK
- DHs = generate new
- DHr = EK_A
- CKr = HKDF(RK, DH(DHr, DHs))
- CKs = null
- Ns = 0, Nr = 0, PN = 0
10. Decrypt DR_msg_0
11. Delete OPK_B (one-time use)
─────── DR BEGINS HERE ───────
12. Send reply with new DHs:
[DR_msg_1]
13. Receive DR_msg_1
14. DH ratchet with Bob's new DHs
15. Both sides now in full DR modeInitial DR State from X3DH Output:
| Field | Alice (Initiator) | Bob (Responder) |
|---|---|---|
| RK | SK from X3DH | SK from X3DH |
| DHs | EK_A keypair | Fresh keypair |
| DHr | SPK_B | EK_A |
| CKs | Derived from first DH | null (until reply) |
| CKr | null (until reply) | Derived from first DH |
| Ns, Nr, PN | 0, 0, 0 | 0, 0, 0 |
Who sends the first DR message?
The initiator (Alice) always sends the first Double Ratchet message. This message is bundled with the X3DH parameters (IK_A, EK_A, OPK_id). The responder (Bob) cannot send messages until receiving this initial message because Bob doesn’t know Alice’s ephemeral key until she sends it.
Out-of-Order Message Handling
Network conditions can cause messages to arrive out of order. The Double Ratchet handles this through skipped message key storage.
Skipped Message Keys Storage:
┌─────────────────────────────────────────────────────┐
│ Skipped Keys Store (max 1000 entries) │
├─────────────────────────────────────────────────────┤
│ Key: (DH_public, message_number) │
│ Value: message_key │
│ TTL: 7 days │
├─────────────────────────────────────────────────────┤
│ Example entries: │
│ (0xab12...cd34, 5) → 0x9f8e...7d6c │
│ (0xab12...cd34, 6) → 0x1a2b...3c4d │
│ (0xef56...gh78, 0) → 0x5e6f...7g8h │
└─────────────────────────────────────────────────────┘Message Counter Gap Handling:
When receiving a message with number N, and current Nr is M where N > M:
1. If (N - M) > MAX_SKIP (1000):
→ REJECT message (potential DoS attack)
→ Do NOT advance ratchet state
2. If (N - M) <= MAX_SKIP:
→ Derive and store keys for messages M through N-1
→ Each key stored as (current_DHr, index)
→ Process message N normally
→ Nr = N + 1Decision Matrix:
| Condition | Action |
|---|---|
| msg_num == Nr | Process immediately, increment Nr |
| msg_num < Nr | Check skipped keys store |
| msg_num > Nr (gap ≤ 1000) | Store skipped keys, then process |
| msg_num > Nr (gap > 1000) | REJECT (DoS protection) |
| msg_num in skipped keys | Decrypt, DELETE key from store |
| msg_num < Nr, not in store | REJECT (replay or already processed) |
Replay Attack Prevention:
On message receipt:
1. Extract (DH_pub, msg_num) from header
2. Compute message_id = SHA-256(DH_pub || msg_num || ciphertext)
3. Check replay cache:
if message_id in seen_messages:
→ REJECT (replay attack)
4. If msg_num < Nr and not in skipped_keys:
→ REJECT (already processed or replay)
5. After successful decryption:
→ Add message_id to seen_messages (TTL: 7 days)
→ If from skipped_keys: DELETE the keyStorage Limits:
| Parameter | Value | Rationale |
|---|---|---|
| MAX_SKIP | 1000 | Balance between usability and DoS protection |
| Skipped key TTL | 7 days | Match SPK rotation period |
| Replay cache TTL | 7 days | Match skipped key TTL |
| Replay cache max | 10,000 | Prevent memory exhaustion |
Session Recovery
When a session becomes corrupted or desynchronized, recovery mechanisms restore secure communication.
When to Reset a Session:
| Condition | Action |
|---|---|
| Decryption fails 3+ consecutive times | Trigger reset |
| Peer sends reset request | Accept and reset |
| MAC verification fails repeatedly | Trigger reset |
| State corruption detected | Immediate reset |
| User manually requests reset | Reset with confirmation |
Re-establishment Protocol:
Alice (detecting issue) Bob
─────────────────────── ───
1. Detect session problem
(3+ decrypt failures)
2. Send RESET_REQUEST:
{
type: "RESET_REQUEST",
reason: "decrypt_failure",
last_successful_msg: <hash>,
timestamp: <now>,
signature: Sign(IK_A, payload)
}
3. Verify signature
4. Validate reason
5. Clear session state
6. Generate fresh key bundle
7. Send RESET_ACK:
{
type: "RESET_ACK",
new_SPK: <public_key>,
new_OPKs: [<keys>],
signature: Sign(IK_B, payload)
}
8. Verify signature
9. Clear local session state
10. Initiate new X3DH with
fresh key bundle
11. Respond to X3DH
─── New session ESTABLISHED ───Preserving Message History During Reset:
Pre-Reset:
┌─────────────────────────────────────────────┐
│ Message Store (encrypted at rest) │
├─────────────────────────────────────────────┤
│ Messages are encrypted with: │
│ storage_key = HKDF(user_password, salt) │
│ │
│ NOT with session keys! │
│ │
│ This means: │
│ ✓ Messages survive session reset │
│ ✓ Messages survive device change │
│ ✓ Messages require user password to access │
└─────────────────────────────────────────────┘
Reset Process:
1. Export message history (already encrypted)
2. Delete ratchet state (RK, CKs, CKr, etc.)
3. Delete skipped message keys
4. Clear replay cache
5. Reset to NO_SESSION state
6. Message history remains intact
7. Re-establish session via X3DHMulti-Device Session Coordination
Each device maintains independent sessions with each peer. There is no session state synchronization between devices.
Architecture:
┌─────────────────┐
│ Bob's Phone │
│ Session: S_bp │
└────────┬────────┘
│
┌─────────────┐ │ ┌─────────────┐
│ Alice Phone │──────────────┼────────→│ Bob Tablet │
│ Session: │ │ │ Session: S_bt│
│ S_ap_bp │ │ └─────────────┘
│ S_ap_bt │ │
│ S_ap_bd │ │ ┌─────────────┐
└─────────────┘ └────────→│ Bob Desktop │
│ Session: S_bd│
└─────────────┘
Alice's Phone has 3 separate sessions:
- S_ap_bp: with Bob's Phone
- S_ap_bt: with Bob's Tablet
- S_ap_bd: with Bob's DesktopSession Independence:
| Aspect | Behavior |
|---|---|
| Ratchet state | Completely independent per device pair |
| Message keys | Different keys for each device session |
| Message delivery | Server fans out to all recipient devices |
| Read receipts | Per-device, not synchronized |
Handling Messages to Offline Devices:
1. Alice sends message to Bob
2. Server identifies Bob's devices: [Phone, Tablet, Desktop]
3. For each device:
- If device has session with Alice:
→ Queue encrypted message
- If no session exists:
→ Queue X3DH initiation request
4. Device comes online:
- Fetches queued messages
- Processes in order (by server timestamp)
- Handles X3DH initiations first
5. Message queue limits:
- Max 1000 messages per device
- Max 30 days retention
- Oldest messages dropped if limit reachedNew Device Setup:
1. User adds new device (Desktop)
2. Desktop generates:
- New Identity Key? NO - imported from backup
- New Signed Pre-Key: YES
- New One-Time Pre-Keys: YES (100)
3. Desktop publishes key bundle to server
4. Existing sessions: NONE
- Desktop has no sessions with anyone
- Must wait for incoming X3DH or initiate new
5. When Alice messages Bob:
- Server sees Bob has new device
- Alice's client fetches Desktop's key bundle
- Alice initiates X3DH with Desktop
- Desktop now has session with Alice
6. Message history:
- NOT automatically synced
- User can export/import encrypted backup
- Or: request history from another device (E2E encrypted)Device Revocation:
1. User marks device as lost/stolen
2. Server:
- Removes device's key bundle
- Stops routing messages to device
- Notifies peers: "device removed"
3. Peers:
- Delete sessions with revoked device
- Stop encrypting for that device
4. Revoked device:
- Cannot receive new messages
- Cannot establish new sessions
- Existing session keys eventually expireSession State Summary Table:
| Component | Synchronized | Stored Where |
|---|---|---|
| Identity Key | Yes (backup) | All devices |
| Signed Pre-Key | No | Per device |
| One-Time Pre-Keys | No | Per device |
| Ratchet State | No | Per device per peer |
| Message History | Optional | Local + encrypted backup |
| Contact List | Yes | Encrypted on server |
AES-256-GCM
Symmetric encryption for message payloads.
Why AES-GCM?
The Problem: Basic encryption modes (like AES-CBC) only provide confidentiality. An attacker can modify ciphertext bits, potentially changing the decrypted message without detection.
The Solution: GCM (Galois/Counter Mode) provides authenticated encryption - both confidentiality AND integrity in one operation:
| Property | What it means |
|---|---|
| Confidentiality | Attacker cannot read the message |
| Integrity | Any modification is detected |
| Authentication | Proves message wasn’t tampered with |
How GCM works:
- Counter Mode encrypts the message (confidentiality)
- GHASH computes an authentication tag over ciphertext + associated data (integrity)
- Tag is verified before decryption - if modified, decryption fails
Parameters
| Parameter | Value |
|---|---|
| Key size | 256 bits (32 bytes) |
| Nonce size | 96 bits (12 bytes) |
| Tag size | 128 bits (16 bytes) |
| Mode | Galois/Counter Mode |
Associated Data
The authentication tag covers:
AD = version || sender_id || recipient_id || timestampWhy these fields?
- version: Prevents downgrade attacks to weaker protocol versions
- sender_id: Proves message came from claimed sender, not forged
- recipient_id: Prevents message redirection to different recipient
- timestamp: Prevents replay of old messages
If an attacker modifies ANY of these fields, authentication fails and decryption is rejected.
Encryption
nonce = secure_random(12)
ciphertext, tag = AES-GCM-Encrypt(key, nonce, plaintext, AD)
output = nonce || ciphertext || tagKyber-768 (Post-Quantum)
Hybrid encryption combining X25519 with Kyber-768 for quantum resistance.
Why Post-Quantum Cryptography?
The Threat: Quantum computers running Shor’s algorithm can break:
- RSA (factoring problem)
- Diffie-Hellman / X25519 (discrete logarithm problem)
- ECDSA / Ed25519 (elliptic curve discrete log)
A future quantum computer could decrypt all messages encrypted today with these algorithms.
The Solution: Kyber is based on lattice problems (Module-LWE) that remain hard even for quantum computers. No efficient quantum algorithm is known to solve them.
Why Hybrid?
The hybrid approach ensures security if either X25519 or Kyber-768 remains unbroken:
| Scenario | Security |
|---|---|
| Quantum computer breaks X25519 | Kyber protects |
| Kyber has undiscovered flaw | X25519 protects |
| Both remain secure | Double protection |
This “belt and suspenders” approach means we’re safe even if one algorithm fails.
Parameters
| Parameter | Kyber-768 |
|---|---|
| Security level | NIST Level 3 |
| Public key | 1,184 bytes |
| Private key | 2,400 bytes |
| Ciphertext | 1,088 bytes |
| Shared secret | 32 bytes |
Hybrid X3DH
When PQC is enabled, X3DH includes Kyber:
1. Standard X3DH:
SK_classical = X3DH(IK, SPK, OPK, EK)
2. Kyber encapsulation:
ciphertext, SK_kyber = Kyber.Encapsulate(recipient_kyber_pk)
3. Combined secret:
SK_hybrid = HKDF(SK_classical || SK_kyber, "ZentalkHybrid")Key Bundle Extension
| Key | Algorithm | Size |
|---|---|---|
| Kyber Public Key | ML-KEM-768 | 1,184 bytes |
| Kyber Ciphertext | ML-KEM-768 | 1,088 bytes |
Message Format
Encrypted Message Structure
┌─────────────────────────────────────────┐
│ Version (1 byte) │
├─────────────────────────────────────────┤
│ Sender DH Public Key (32 bytes) │
├─────────────────────────────────────────┤
│ Message Number (4 bytes) │
├─────────────────────────────────────────┤
│ Previous Chain Length (4 bytes) │
├─────────────────────────────────────────┤
│ Nonce (12 bytes) │
├─────────────────────────────────────────┤
│ Ciphertext (variable) │
├─────────────────────────────────────────┤
│ Authentication Tag (16 bytes) │
└─────────────────────────────────────────┘With Kyber (Hybrid Mode)
┌─────────────────────────────────────────┐
│ Version (1 byte) = 0x02 │
├─────────────────────────────────────────┤
│ Sender DH Public Key (32 bytes) │
├─────────────────────────────────────────┤
│ Kyber Ciphertext (1,088 bytes) │
├─────────────────────────────────────────┤
│ Message Number (4 bytes) │
├─────────────────────────────────────────┤
│ Previous Chain Length (4 bytes) │
├─────────────────────────────────────────┤
│ Nonce (12 bytes) │
├─────────────────────────────────────────┤
│ Ciphertext (variable) │
├─────────────────────────────────────────┤
│ Authentication Tag (16 bytes) │
└─────────────────────────────────────────┘Key Backup
Encrypted backup of private keys for device recovery.
Encryption
1. User provides password
2. Derive key:
salt = secure_random(32)
key = PBKDF2-SHA256(password, salt, 600000)
3. Encrypt key bundle:
nonce = secure_random(12)
backup = AES-256-GCM(key, nonce, key_bundle)
4. Store on mesh:
mesh.store(address, salt || nonce || backup)Recovery
1. Fetch backup from mesh
2. Extract salt, nonce, ciphertext
3. Derive key from password:
key = PBKDF2-SHA256(password, salt, 600000)
4. Decrypt:
key_bundle = AES-256-GCM-Decrypt(key, nonce, ciphertext)Fingerprint Verification
Out-of-band identity verification using key fingerprints.
Generation
fingerprint = SHA-256(identity_public_key)
display = format_as_numeric(fingerprint)Display Format
37291 84756 19283 04857
29184 73829 18374 02948Users compare fingerprints in person or via trusted channel.
Session Management
Session States
| State | Description |
|---|---|
| NO_SESSION | No session exists |
| PENDING | X3DH sent, awaiting response |
| ESTABLISHED | Session active |
| STALE | No activity, consider refresh |
Session Persistence
Sessions are stored in IndexedDB with:
| Field | Encryption |
|---|---|
| Ratchet state | AES-256-GCM |
| Skipped message keys | AES-256-GCM |
| Peer identity | Plaintext (public) |
References
| Standard | Specification |
|---|---|
| X25519 | RFC 7748 |
| Ed25519 | RFC 8032 |
| AES-GCM | NIST SP 800-38D |
| HKDF | RFC 5869 |
| PBKDF2 | RFC 8018 |
| ML-KEM-768 | NIST FIPS 203 |
| X3DH | Signal Protocol |
| Double Ratchet | Signal Protocol |