Skip to main content

Encryption Layer 1: Double Ratchet

Draft

This section is under construction. This is the most complex chapter and will be expanded with complete byte layouts and test vectors.

Overview

SimpleX uses a Double Ratchet protocol based on the Signal specification with several important deviations:

  • Uses Curve448 (NOT Curve25519) for DH ratchet operations
  • Uses AES-256-GCM with 16-byte IVs (standard is 12 bytes)
  • Uses HKDF-SHA512 (not HKDF-SHA256)
  • Has SimpleX-specific KDF info strings

X3DH Key Agreement

Curve Selection

The X3DH key agreement uses X448 (Curve448), producing 56-byte public keys and 56-byte shared secrets.

Three DH Computations

DH1 = DH(initiator_identity_private, responder_signed_prekey_public)
DH2 = DH(initiator_ephemeral_private, responder_identity_public)
DH3 = DH(initiator_ephemeral_private, responder_signed_prekey_public)

HKDF Parameters

ParameterValue
AlgorithmHKDF-SHA512
Salt64 zero bytes
IKMDH1 || DH2 || DH3 (168 bytes)
Info"SimpleXX3DH"
Output Length96 bytes

Output Assignment

Bytes [0..31]:  hk  — Header key (send for initiator, recv for responder)
Bytes [32..63]: nhk — Next header key (recv for initiator, send for responder)
Bytes [64..95]: sk — Root key (symmetric ratchet seed)

EncRatchetMessage Wire Format

┌──────────────┬──────────┬──────────────┬──────────────────┬──────────────┬──────────┐
│ msgVersion │ ehIV │ ehAuthTag │ emHeader │ emAuthTag │ emBody │
│ (2B, BE16) │ (16B) │ (16B) │ (var, len-prefix)│ (16B) │ (var) │
└──────────────┴──────────┴──────────────┴──────────────────┴──────────────┴──────────┘

Associated Data (rcAD)

rcAD = sender_key1_raw[56] || receiver_key1_raw[56]
= 112 bytes total
warning

The rcAD uses raw X448 keys (56 bytes each), NOT ASN.1/SPKI-encoded keys. This is a critical implementation detail.

Header Decryption

  • Algorithm: AES-256-GCM
  • Key: header_key_recv (32 bytes)
  • IV: ehIV (16 bytes — NOT standard 12!)
  • AAD: rcAD (112 bytes)

DH Ratchet Step

Triggered when the peer's DH public key in the decrypted header differs from the stored key.

Step 1: Receiving Chain

dh_secret = DH(peer_new_pub, our_old_priv)
HKDF(salt=root_key, ikm=dh_secret, info="SimpleXRootRatchet", len=96)
→ [0..31]: new_root_key_1
→ [32..63]: recv_chain_key
→ [64..95]: new_nhk_recv

Step 2: Sending Chain

Generate new X448 keypair (our_NEW_priv, our_NEW_pub)
dh_secret = DH(peer_new_pub, our_NEW_priv)
HKDF(salt=new_root_key_1, ikm=dh_secret, info="SimpleXRootRatchet", len=96)
→ [0..31]: new_root_key_2
→ [32..63]: send_chain_key
→ [64..95]: new_nhk_send

Chain KDF (Message Key Derivation)

ParameterValue
Salt"" (EMPTY — zero-length!)
IKMchain_key
Info"SimpleXChainRatchet"
Output Length96 bytes

Output Assignment

Bytes [0..31]:  next_chain_key
Bytes [32..63]: message_key (AES-256 key)
Bytes [64..79]: iv_body (16 bytes)
Bytes [80..95]: iv_header (16 bytes, used for encryption only)

Body Decryption

  • Algorithm: AES-256-GCM
  • Key: message_key (32 bytes)
  • IV: iv_body (16 bytes)
  • AAD: rcAD[112] || emHeader[raw bytes]

unPad After Decrypt

First 2 bytes: BE16 original message length
Bytes [2..2+len-1]: actual plaintext
Remaining: padding (ignore)