Skip to main content

SimpleGo Protocol Analysis

SimpleX Protocol Analysis - Part 17: Session 20

Complete Double Ratchet Body Decrypt, Zstd Integration, Peer Profile on ESP32

Document Version: v2 (rewritten for clarity, March 2026) Date: 2026-02-06 Status: COMPLETED -- TLS to JSON, complete crypto chain working Previous: Part 16 - Session 19 Next: Part 18 - Session 21 Project: SimpleGo - ESP32 Native SimpleX Client License: AGPL-3.0


SESSION 20 SUMMARY

Session 20 completed the entire decryption chain from TLS 1.3 to
readable JSON on an ESP32. Bug #19 root cause found: a debug
self-decrypt test in smp_peer.c:347 triggered a spurious DH Ratchet
Step, corrupting header_key_recv. Fix: removed the debug test. DH
Ratchet Step implemented (two rootKdf calls: recv chain + send chain).
Body decrypt succeeded (14832 ciphertext to 8887 bytes plaintext).
ConnInfo tag 'I' identified as AgentConnInfo (peer profile). Zstd
decompression integrated (8881 compressed to 12268 bytes JSON).
Peer profile "cannatoshi" read on ESP32.

Bug #19 fixed (debug self-decrypt corrupted ratchet state)
DH Ratchet Step: 2x rootKdf (recv chain + send chain)
Body decrypt SUCCESS (14832 to 8887 bytes)
Zstd integrated (v1.5.5, ~117KB flash)
Peer profile JSON parsed: displayName "cannatoshi"

Bug #19: Debug Self-Decrypt Corrupted Ratchet State

Claude Code analysis found the root cause: smp_peer.c:347 called ratchet_decrypt() on our own encrypted message for debug verification. This function has side effects: when the DH key in the decrypted header differs from dh_peer, it triggers a DH Ratchet Step. Since the header contained dh_self.public_key (our own key), dh_changed = true triggered a spurious ratchet step, corrupting five fields:

FieldBeforeAfter
header_key_recv1c08e86e... (correct)cf0c74d2... (wrong)
root_keycorrect from X3DHcorrupted
chain_key_recvunsetcorrupted
dh_peerpeer's keyour own key
msg_num_recv0reset to 0

Call flow showing the corruption point:

send_agent_confirmation():
[309] ratchet_x3dh_sender() -> header_key_recv correct
[317] ratchet_init_sender() -> no change to recv keys
[335] ratchet_encrypt() -> no change to recv keys
[347] ratchet_decrypt() DEBUG -> header_key_recv CORRUPTED

Fix: removed the debug self-decrypt test. Rule: never use production decrypt functions for debug self-tests.


DH Ratchet Step + Body Decrypt

6-Step Process

Step 1: DH Ratchet Step Recv (rootKdf #1)

dh_secret_recv = X448_DH(peer_new_pub, our_old_priv)   // 56 bytes
HKDF_SHA512(salt=root_key, ikm=dh_secret_recv, info="SimpleXRootRatchet", len=96)
[0-31] new_root_key_1
[32-63] recv_chain_key
[64-95] new_nhk_recv

Step 2: DH Ratchet Step Send (rootKdf #2)

(our_new_priv, our_new_pub) = X448_GENERATE_KEYPAIR()
dh_secret_send = X448_DH(peer_new_pub, our_new_priv) // 56 bytes
HKDF_SHA512(salt=new_root_key_1, ikm=dh_secret_send, info="SimpleXRootRatchet", len=96)
[0-31] new_root_key_2
[32-63] send_chain_key
[64-95] new_nhk_send

Step 3: Chain KDF

HKDF_SHA512(salt="", ikm=recv_chain_key, info="SimpleXChainRatchet", len=96)
[0-31] next_recv_ck
[32-63] message_key
[64-79] iv_body (NOT header IV!)
[80-95] iv_header (ignored during decrypt)

Critical correction from earlier sessions: iv1 [64-79] is the body IV, not header IV. During decrypt, the header IV comes from ehIV in the wire format.

Step 4: AES-256-GCM Decrypt

AAD = rcAD[112] || emHeader[123 raw bytes] = 235 bytes
AES256_GCM_DECRYPT(key=message_key, iv=iv_body, aad=AAD, ct=emBody, tag=emAuthTag)

AAD uses the raw emHeader bytes as received, not re-serialized.

Step 5: unPad

msg_len = BE_uint16(decrypted[0..1])
plaintext = decrypted[2 .. 2+msg_len-1]

Step 6: State Update (activated in Session 21)


ConnInfo Tags and Zstd Compression

First byte after body decrypt + unPad: 0x49 = 'I' (AgentConnInfo), not 'D' (AgentConnInfoReply).

TagConstructorWho SendsContent
'I'AgentConnInfoInitiator (or joiner on Reply Queue)Profile only
'D'AgentConnInfoReplyJoiner (on Contact Queue)SMP Queues + Profile

ConnInfo is wrapped in a compressed batch format:

[0]     'I'   AgentConnInfo tag
[1] 'X' Compressed batch marker
[2] 01 NonEmpty count: 1
[3] '1' Zstd compressed (vs '0' for passthrough)
[4-5] 22 b1 Zstd data length: 8881
[6+] Zstd frame (magic: 28 b5 2f fd)

Zstd v1.5.5 integrated as ESP-IDF component (~117KB flash). Standard Level 3, no dictionary, max decompressed 65536 bytes. Result: 8881 bytes compressed to 12268 bytes JSON.


Peer Profile

{
"v": "1-16",
"event": "x.info",
"params": {
"profile": {
"displayName": "cannatoshi",
"fullName": "",
"image": "data:image/jpg;base64,...",
"preferences": { "calls": {"allow":"no"}, "files": {"allow":"always"}, ... }
}
}
}

First time a peer's SimpleX profile has been read on an ESP32 device.


Complete HKDF Chain (All Steps Verified)

HKDF #1: X3DH -> (hk, nhk, sk)
Salt: 64x0x00, IKM: DH1||DH2||DH3, Info: "SimpleXX3DH"
hk[0-31], nhk[32-63], sk[64-95]

HKDF #2: Root KDF Recv -> (rk1, ck_recv, nhk_recv)
Salt: sk, IKM: DH(peer_new, our_old), Info: "SimpleXRootRatchet"

HKDF #3: Root KDF Send -> (rk2, ck_send, nhk_send)
Salt: rk1, IKM: DH(peer_new, our_NEW), Info: "SimpleXRootRatchet"

HKDF #4: Chain KDF Recv -> (ck', mk, iv_body, iv_header)
Salt: "", IKM: ck_recv, Info: "SimpleXChainRatchet"

Part 17 - Session 20: Body Decrypt + Peer Profile SimpleGo Protocol Analysis Original date: February 6, 2026 Rewritten: March 4, 2026 (v2) Complete crypto chain TLS to JSON, displayName "cannatoshi" on ESP32