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
| Parameter | Value |
|---|---|
| Algorithm | HKDF-SHA512 |
| Salt | 64 zero bytes |
| IKM | DH1 || DH2 || DH3 (168 bytes) |
| Info | "SimpleXX3DH" |
| Output Length | 96 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)
| Parameter | Value |
|---|---|
| Salt | "" (EMPTY — zero-length!) |
| IKM | chain_key |
| Info | "SimpleXChainRatchet" |
| Output Length | 96 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)