Skip to Content
Protocol Specification

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 SeesServer Cannot See
Encrypted blobsMessage content
Public keysPrivate keys
Wallet addressContact graph
Encrypted timestampsSender ↔ 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

PropertyMechanism
ConfidentialityAES-256-GCM encryption
IntegrityGCM authentication tag
AuthenticityEd25519 signatures
Forward SecrecyDouble Ratchet key rotation
Post-Quantum SecurityKyber-768 hybrid mode
DeniabilityNo 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:

KeyAlgorithmLifetimeCount
Identity Key (IK)Ed25519Long-term1
Signed Pre-Key (SPK)X255197 days1
One-Time Pre-Keys (OPK)X25519Single use100

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

ParameterValue
Rotation interval7 days (604,800 seconds)
Grace period for old SPK48 hours
Maximum SPK age9 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 bytes

Old SPK Retention

To handle in-flight messages encrypted with the previous SPK:

StateDurationPurpose
Active7 daysCurrent SPK for new sessions
Retained48 hours after rotationDecrypt in-flight messages
DeletedAfter retention periodEnsure 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

CheckRuleAction on Failure
Signature validEd25519_Verify(IK_public, signed_data, signature)Reject upload
Timestamp freshtimestamp > (current_time - 24 hours)Reject upload
Timestamp not futuretimestamp ≤ (current_time + 5 minutes)Reject upload
SPK ID uniqueNo collision with existing SPK IDsReject upload

One-Time Pre-Key (OPK) Management

One-Time Pre-Keys provide per-session forward secrecy and are consumed upon use.

Initial Generation

ParameterValue
Initial key count100 keys
Key algorithmX25519
Key size32 bytes (public), 32 bytes (private)
ID range32-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

ConditionThresholdAction
Low OPK warningcount < 20Notify client
Critical OPK levelcount < 5Priority notification
OPK exhaustedcount = 0Fallback 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 flag

Server 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")
ModeForward SecrecySecurity Level
With OPKPer-sessionFull
Without OPKPer-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

ComponentSizeNotes
Header10 bytesFixed
Identity Key32 bytesFixed
Signed Pre-Key100 bytesID + public + signature
One-Time Pre-Keys (100)3,602 bytesCount + 100 * (ID + public)
Kyber (optional)1,184 bytesOnly 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

GuaranteeImplementation
All-or-nothingDatabase transaction wraps entire update
No partial stateBundle validation before commit
Rollback on errorTransaction abort on any failure
Concurrent safetyRow-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

VersionFeaturesBundle Type
0x01X25519 + Ed25519Standard
0x02+ Kyber-768Hybrid PQC
0x03ReservedFuture 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 database

SPK 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

CheckThresholdError Code
Too old> 24 hours in pasttimestamp_expired
Too far future> 5 minutes aheadtimestamp_future
Clock skew tolerance+/- 5 minutesAllowed
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

OperationLimitWindowCooldown
Full bundle upload124 hours24 hours
SPK rotation224 hours6 hours
OPK replenishment101 hourNone
Failed attempts515 minutes1 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

CheckScopeAction
OPK ID collision (same user)User’s existing OPKsReject specific OPK
OPK ID collision (cross-user)GlobalReject (possible attack)
SPK ID reuseUser’s SPK historyReject upload
Public key reuseGlobalLog 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

ScenarioDetectionResolution
Client ahead by < 5 minTimestamp validationAccept with warning
Client ahead by > 5 minTimestamp rejectedReject, require NTP sync
Client behind by < 24 hoursTimestamp validationAccept
Client behind by > 24 hoursTimestamp expiredReject, 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 - drift

Concurrent Updates from Multiple Devices

ScenarioDetectionResolution
Same SPK ID from two devicesID collisionLast-write-wins with version check
Different SPK from two devicesVersion mismatchHigher version wins
OPK uploads overlapNo conflictBoth sets merged
Simultaneous full bundleRace conditionOptimistic 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 version

Recovery After Extended Offline Period

Offline DurationState on ReturnRequired Actions
< 7 daysSPK still validOPK replenishment only
7-14 daysSPK expiredSPK rotation + OPK replenishment
14-30 daysSPK very staleFull bundle regeneration
> 30 daysKeys possibly compromisedFull 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 TypeDetection MethodRecovery
Local storage corruptionChecksum mismatchRestore from backup
Incomplete bundleRequired fields missingRe-upload full bundle
Invalid signatureEd25519 verification failsRegenerate affected key
Key mismatchPublic/private pair testRegenerate 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 SK

Why Four DH Computations?

Each DH operation provides different security guarantees:

DHPurposeWhat it proves
DH1Alice’s IK + Bob’s SPKAlice proves her identity
DH2Alice’s EK + Bob’s IKBob’s identity is verified
DH3Alice’s EK + Bob’s SPKFresh key material (weekly rotation)
DH4Alice’s EK + Bob’s OPKPer-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:

RatchetPurposeFrequency
Symmetric (KDF Chain)New key per messageEvery message
Asymmetric (DH)Completely fresh key materialEvery 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

FieldTypePurpose
DHsX25519 keypairCurrent sending DH key
DHrX25519 publicCurrent receiving DH key
RK32 bytesRoot key
CKs32 bytesSending chain key
CKr32 bytesReceiving chain key
NsintegerSending message number
NrintegerReceiving message number
PNintegerPrevious 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
StateDescriptionEntry ConditionTimeout
NO_SESSIONNo session exists with this peerInitial state, or after resetNone
INITIATINGX3DH key exchange in progressinitiate() called, fetching key bundle30 seconds
PENDINGX3DH sent, awaiting first DR responseX3DH message sent to peer5 minutes
ESTABLISHEDSession active, DR fully operationalFirst DR message received/sentNone
STALENo activity for extended period30 days without message exchange90 days
CLOSEDSession terminatedExplicit close or timeout from PENDINGNone

State Transitions

FromToTriggerAction
NO_SESSIONINITIATINGUser initiates contactFetch peer’s key bundle from server
INITIATINGPENDINGX3DH computed successfullySend initial message with X3DH parameters
INITIATINGNO_SESSIONKey bundle fetch failedClear partial state, notify user
INITIATINGCLOSEDTimeout (30s)Abort, clean up resources
PENDINGESTABLISHEDFirst DR message receivedInitialize DR receive chain
PENDINGCLOSEDTimeout (5 min)Delete session, notify user
ESTABLISHEDESTABLISHEDMessage sent/receivedUpdate DR state
ESTABLISHEDSTALE30 days inactivityMark for potential refresh
STALEESTABLISHEDAny message exchangeReset inactivity timer
STALECLOSED90 days total inactivityArchive session
ESTABLISHEDCLOSEDUser closes conversationDelete ratchet state
CLOSEDNO_SESSIONUser initiates new contactReset all state
AnyNO_SESSIONreset() calledDelete all session data

Invalid Transitions:

Invalid TransitionHandling
NO_SESSION → ESTABLISHEDMust go through INITIATING → PENDING first
PENDING → INITIATINGCannot restart X3DH mid-handshake; reset first
CLOSED → ESTABLISHEDMust create new session via NO_SESSION

When an invalid transition is attempted, the implementation MUST:

  1. Log the error with session ID and attempted transition
  2. Return an error to the caller
  3. NOT modify the current session state
  4. 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 mode

Initial DR State from X3DH Output:

FieldAlice (Initiator)Bob (Responder)
RKSK from X3DHSK from X3DH
DHsEK_A keypairFresh keypair
DHrSPK_BEK_A
CKsDerived from first DHnull (until reply)
CKrnull (until reply)Derived from first DH
Ns, Nr, PN0, 0, 00, 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 + 1

Decision Matrix:

ConditionAction
msg_num == NrProcess immediately, increment Nr
msg_num < NrCheck 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 keysDecrypt, DELETE key from store
msg_num < Nr, not in storeREJECT (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 key

Storage Limits:

ParameterValueRationale
MAX_SKIP1000Balance between usability and DoS protection
Skipped key TTL7 daysMatch SPK rotation period
Replay cache TTL7 daysMatch skipped key TTL
Replay cache max10,000Prevent memory exhaustion

Session Recovery

When a session becomes corrupted or desynchronized, recovery mechanisms restore secure communication.

When to Reset a Session:

ConditionAction
Decryption fails 3+ consecutive timesTrigger reset
Peer sends reset requestAccept and reset
MAC verification fails repeatedlyTrigger reset
State corruption detectedImmediate reset
User manually requests resetReset 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 X3DH

Multi-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 Desktop

Session Independence:

AspectBehavior
Ratchet stateCompletely independent per device pair
Message keysDifferent keys for each device session
Message deliveryServer fans out to all recipient devices
Read receiptsPer-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 reached

New 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 expire

Session State Summary Table:

ComponentSynchronizedStored Where
Identity KeyYes (backup)All devices
Signed Pre-KeyNoPer device
One-Time Pre-KeysNoPer device
Ratchet StateNoPer device per peer
Message HistoryOptionalLocal + encrypted backup
Contact ListYesEncrypted 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:

PropertyWhat it means
ConfidentialityAttacker cannot read the message
IntegrityAny modification is detected
AuthenticationProves message wasn’t tampered with

How GCM works:

  1. Counter Mode encrypts the message (confidentiality)
  2. GHASH computes an authentication tag over ciphertext + associated data (integrity)
  3. Tag is verified before decryption - if modified, decryption fails

Parameters

ParameterValue
Key size256 bits (32 bytes)
Nonce size96 bits (12 bytes)
Tag size128 bits (16 bytes)
ModeGalois/Counter Mode

Associated Data

The authentication tag covers:

AD = version || sender_id || recipient_id || timestamp

Why 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 || tag

Kyber-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:

ScenarioSecurity
Quantum computer breaks X25519Kyber protects
Kyber has undiscovered flawX25519 protects
Both remain secureDouble protection

This “belt and suspenders” approach means we’re safe even if one algorithm fails.

Parameters

ParameterKyber-768
Security levelNIST Level 3
Public key1,184 bytes
Private key2,400 bytes
Ciphertext1,088 bytes
Shared secret32 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

KeyAlgorithmSize
Kyber Public KeyML-KEM-7681,184 bytes
Kyber CiphertextML-KEM-7681,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 02948

Users compare fingerprints in person or via trusted channel.

Session Management

Session States

StateDescription
NO_SESSIONNo session exists
PENDINGX3DH sent, awaiting response
ESTABLISHEDSession active
STALENo activity, consider refresh

Session Persistence

Sessions are stored in IndexedDB with:

FieldEncryption
Ratchet stateAES-256-GCM
Skipped message keysAES-256-GCM
Peer identityPlaintext (public)

References

StandardSpecification
X25519RFC 7748
Ed25519RFC 8032
AES-GCMNIST SP 800-38D
HKDFRFC 5869
PBKDF2RFC 8018
ML-KEM-768NIST FIPS 203
X3DHSignal Protocol
Double RatchetSignal Protocol
Last updated on