Skip to main content

SimpleGo Protocol Analysis

SimpleX Protocol Analysis - Part 16: Session 19

Three Parsing Layers Discovered, Double Ratchet Header Decrypt Success

Document Version: v2 (rewritten for clarity, March 2026) Date: 2026-02-05 Status: COMPLETED -- Header decrypt working, MsgHeader fully parsed Previous: Part 15 - Session 18 Next: Part 17 - Session 20 Project: SimpleGo - ESP32 Native SimpleX Client License: AGPL-3.0


SESSION 19 SUMMARY

Session 19 discovered three additional parsing layers between E2E
decrypt output and the Double Ratchet: unPad (2-byte length prefix +
0x23 padding), ClientMessage (PrivHeader + AgentMsgEnvelope), and
EncRatchetMessage (v2 format with 1-byte length prefixes). The
PrivHeader mystery from Session 18 was solved: 0x3a was the unPad
length prefix (0x3aae = 15022), not a PrivHeader tag. The actual
PrivHeader is 'K' (PHConfirmation) at byte[2]. Double Ratchet header
decrypt succeeded using nhk (HKDF[32-63]) as header_key_recv.
MsgHeader parsed: msgMaxVersion=3, 68-byte X448 DH key, PN=0, Ns=0.
Bug #19 found: header_key_recv overwritten (workaround functional).

3 parsing layers discovered (unPad, ClientMessage, EncRatchetMessage)
PrivHeader mystery solved (0x3a was unPad length, not tag)
Double Ratchet header decrypt SUCCESS
MsgHeader fully parsed (version=3, PN=0, Ns=0)
Bug #19: header_key_recv overwritten (workaround: saved_nhk)

Three New Parsing Layers

Layer: unPad

The 15904 bytes from E2E Layer 2 are not directly a ClientMessage. A padding layer wraps the content:

[0-1]           originalLength (Word16 BE) = 15022 (0x3aae)
[2..15023] ClientMessage (actual content)
[15024..15903] Padding: 880 bytes of 0x23 ('#')

Layer: ClientMessage

smpEncode (ClientMessage h msg) = smpEncode h <> msg

Simple concatenation of PrivHeader + body, no length prefix.

PrivHeader encoding (Protocol.hs:1093-1098):

TagHexConstructorContent
'K'0x4BPHConfirmation1-byte length + Ed25519/X25519 SPKI key
'_'0x5FPHEmptynothing

Byte[2] after unPad = 0x4B ('K') = PHConfirmation.

Layer: EncRatchetMessage

For v < 3 (legacy): encodeLarge uses 1-byte length prefix.

FieldEncodingContent
emHeader Len1 byte123 (0x7B)
emHeader123 bytesEncMessageHeader
emAuthTag16 bytes rawAES-GCM auth tag
emBodyTail (rest)Encrypted payload

EncMessageHeader (inside emHeader):

FieldSizeContent
ehVersion2 bytes (Word16 BE)E2E ratchet version
ehIV16 bytes rawAES-256-GCM IV
ehAuthTag16 bytes rawHeader auth tag
ehBody Len1 byte (v<3)88 (0x58)
ehBody88 bytesEncrypted MsgHeader

SimpleX uses 16-byte IVs for AES-256-GCM (not the standard 12 bytes). The 16-byte IV is internally transformed by the cipher layer.


PrivHeader Mystery Solved

Session 18 asked: "What is PrivHeader ':' (0x3a)?"

0x3a was NOT a PrivHeader. It was the first byte of the unPad length prefix: 0x3a 0xae = 15022. The actual PrivHeader is at byte[2] = 0x4B ('K') = PHConfirmation.


X3DH to Header Key Assignment

HKDF #1 (X3DH):
Salt: 64 x 0x00
IKM: DH1 || DH2 || DH3 (168 bytes for X448)
Info: "SimpleXX3DH"
Output:
[0-31] hk = header_key_send (peer decrypts our headers)
[32-63] nhk = header_key_recv (WE decrypt peer headers)
[64-95] sk = root_key (input for Root KDF)

nhk (HKDF[32-63]) is the key that decrypts incoming headers. Verified: saved_nhk correctly decrypts the peer's header.


Decrypted MsgHeader

contentLen:     79
msgMaxVersion: 3 (peer supports PQ)
DH Key Len: 68 (X448 SPKI)
Peer DH Key: c3d0cb637a26c2c8... (56 bytes raw)
PN: 0 (first message)
Ns: 0 (message #0)
Padding: 0x23 ('#')

Verified Byte-Map

Level 1: E2E Plaintext (15904 bytes)
[0-1] 3a ae unPad originalLength: 15022
[2] 4B PrivHeader 'K' (PHConfirmation)
[3] 2C Auth Key Length: 44
[4-47] 30 2a 30.. Ed25519 SPKI Auth Key
[48-49] 00 07 agentVersion: 7
[50] 43 'C' = AgentConfirmation
[51] 30 e2eEncryption_ = Nothing

Level 2: EncRatchetMessage (from offset 52)
[52] 7B emHeader Length: 123
[53-175] emHeader (EncMessageHeader)
[176-191] emAuthTag (16 bytes)
[192-15023] emBody (14832 bytes)

Bug #19: header_key_recv Overwritten

header_key_recv after X3DH:   1c08e86e... (correct)
header_key_recv at receipt: cf0c74d2... (wrong)

Root cause not yet identified. Workaround: saved_nhk copied immediately after X3DH. Fix pending for Session 20.


Encoding Reference (from Haskell Source)

PrimitiveEncoding
Word162 bytes Big-Endian
Char1 byte
ByteString1-byte length + data
Large2-byte Word16 length + data
TailRest without length prefix
Maybe a'0'=Nothing, '1'+data=Just (NOT 0x00/0x01!)
AuthTag16 bytes raw (no prefix)
IV16 bytes raw
PublicKey1-byte length + X.509 DER SPKI
TupleSimple concatenation

Part 16 - Session 19: Three Layers + Header Decrypt SimpleGo Protocol Analysis Original date: February 5, 2026 Rewritten: March 4, 2026 (v2) unPad, ClientMessage, EncRatchetMessage layers, MsgHeader parsed