Session Handshake (X3DH + PQXDH)
This chapter specifies the cryptographic handshake that establishes a new Konstruct session between two identities. The classical part (X3DH) follows the Signal Protocol X3DH whitepaper with concrete parameters listed in §4.1. The post-quantum extension (PQXDH) layers an ML-KEM-768 KEM on top of X3DH such that the resulting session is secure if either the classical or the post-quantum component holds.
Keywords MUST, MUST NOT, SHOULD, MAY are per RFC 2119.
4.1 Notation and constants
| Symbol | Definition |
|---|---|
| INITIATOR (Alice) | The party that begins the handshake by fetching the responder's bundle. |
| RESPONDER (Bob) | The party whose published bundle Alice consumes; Bob first learns the session exists when he decrypts the first message. |
| IK_X | Identity key of party X (X25519). Subscript pub / priv for the halves. |
| SK_X | Signing key of party X (Ed25519). |
| SPK_X | Signed prekey of party X (X25519). |
| OPK_X | A one-time prekey of party X (X25519). |
| EK_A | Ephemeral key generated by Alice, used exactly once per handshake (X25519). |
| KEM_X | ML-KEM-768 encapsulation key of party X (Kyber-SPK or Kyber-OPK). |
| DH(a, B) | X25519 scalar multiplication of private scalar a against public point B. Output: 32 bytes. |
| KDF(salt, IKM, info, L) | HKDF-SHA-256 extract-then-expand. Output: L bytes. The reference uses the hkdf crate. |
| F | Constant salt: [0xFF, 0xFF, ..., 0xFF] (32 bytes). Required by the Signal X3DH spec §2.2. |
|| | Byte concatenation. |
The following protocol-level byte strings are fixed:
| Constant | Value | Source |
|---|---|---|
| Prologue | b"KonstruktX3DH-v1" (17 bytes) | construct-core/src/crypto/keys.rs:17 |
| Salt F | [0xFF; 32] | construct-core/src/crypto/handshake/x3dh.rs:410 |
| HKDF info (root key derivation) | b"Construct-X3DH-RootKey-v1" (25 bytes) | construct-core/src/crypto/handshake/x3dh.rs:414 |
| Suite 1 identifier | 0x01 | construct-core/src/config.rs:125 |
| Suite 2 identifier | 0x02 | reserved for PQXDH |
An interoperable implementation MUST use these exact byte values. Changing any of them produces incompatible sessions (the AEAD on the first message will fail).
4.2 Bundle publication (Bob)
Before any handshake can occur, Bob MUST have published a registration bundle to the directory. The bundle wire format is defined in §3.9.
Bob MUST:
- Generate a fresh SPK and Kyber-SPK at registration; record the
issue timestamp and
spk_rotation_epoch = 0. - Sign the SPK as
sig_SPK = Ed25519_Sign(SK_priv, SPK_pub || epoch_be). - Sign the Kyber-SPK as
sig_KSPK = Ed25519_Sign(SK_priv, KEM_pub || epoch_be). - Upload (IK_pub, SK_pub, SPK_pub, sig_SPK, epoch, OPKs, Kyber-SPK, sig_KSPK, Kyber-OPKs) to the directory.
- Re-upload (rotate) the SPK and Kyber-SPK at most every 10 days
(
SPK_MAX_AGE_SECS, §3.5), monotonically incrementing the epoch.
4.3 X3DH (Suite 1) — initiator path
Alice MUST perform the following steps to initiate a session with Bob.
Step 1: Fetch and validate the bundle
Alice fetches Bob's bundle from the directory. She MUST then:
- Verify
sig_SPKoverSPK_pub || epoch_beusingSK_pub. If verification fails, abort. - Verify that
SPK_age = now − SPK_issued_at ≤ SPK_MAX_AGE_SECS(10 days). A stale SPK MUST cause abort (replay protection perSEC-001). - If the bundle contains one or more
OPKs, select exactly one and record itsopk_id(for Bob to consume). - (Suite 2 only) Verify
sig_KSPKand Kyber-SPK freshness identically. If Suite 2 is requested and the Kyber-SPK is missing or stale, abort (SEC-002). The protocol MUST NOT silently downgrade from Suite 2 to Suite 1.
Step 2: Generate ephemerals
Alice generates a fresh X25519 ephemeral keypair EK_A.
Step 3: Compute DH outputs
The four (or three, if no OPK) DH outputs are:
DH1 = DH(IK_A_priv, SPK_B_pub)
DH2 = DH(EK_A_priv, IK_B_pub)
DH3 = DH(EK_A_priv, SPK_B_pub)
DH4 = DH(EK_A_priv, OPK_B_pub) — omitted if no OPK
The order matters; an implementation that concatenates them in a different order produces a different root key and is non-interoperable.
DH_combined = DH1 || DH2 || DH3 || DH4
In Suite 1 the length of DH_combined is either 128 bytes (4-DH) or
96 bytes (3-DH fallback). The 3-DH fallback path is functional but
has reduced forward secrecy and is tracked as the open issue BS-3;
implementations SHOULD warn when it is taken.
Step 4: Derive the root key
SK_root = KDF(salt = F, IKM = DH_combined,
info = b"Construct-X3DH-RootKey-v1", L = 32)
SK_root is the initial Double Ratchet root key (RK₀). Implementations
MUST zeroise DH_combined and the individual DH_n slices after this
step.
Step 5: Initialise the Double Ratchet sending state
The initiator immediately performs the first DH ratchet step
(see §5.6) using SK_root
and Bob's SPK_pub as the initial remote DH key. The result is RK₁
plus a new sending chain key CK_s₀.
Step 6: Encrypt and send the first message
Alice encrypts her first plaintext under MK_0 = KDF_CK(CK_s₀, "msg")
(§5.4). The wire-format envelope MUST contain:
FirstMessageEnvelope (Suite 1) ::=
suite_id : u16 = 0x0001
ek_pub : [u8; 32] -- Alice's X25519 ephemeral pub
opk_id : Option<u32> -- which of Bob's OPKs was used
message_number : u32 = 0
dh_pub : [u8; 32] -- Alice's first ratchet DH pub
prev_chain_length : u32 = 0
nonce : [u8; 12]
ciphertext : Bytes
aead_tag : [u8; 16]
Field ordering and sizes are normative for interoperability. The
nonce is generated by the AEAD implementation (chacha20poly1305 0.10).
4.4 X3DH (Suite 1) — responder path
Bob first learns about the session by receiving Alice's first message. He MUST:
-
Look up his own
IK_priv,SPK_priv(at the epoch indicated by the bundle Alice consumed), andOPK_priv[opk_id]from his key store. -
Compute the same four DH outputs:
DH1 = DH(SPK_B_priv, IK_A_pub) DH2 = DH(IK_B_priv, EK_A_pub) DH3 = DH(SPK_B_priv, EK_A_pub) DH4 = DH(OPK_B_priv, EK_A_pub) — when opk_id is presentNote: subscripts swap to maintain
DH(IK_A_priv, SPK_B_pub) = DH(SPK_B_priv, IK_A_pub)(X25519 is symmetric). -
Derive
SK_rootwith the same KDF call as the initiator. -
Initialise the Double Ratchet receiving state, then run the first ratchet step using
EK_A_pubfrom the envelope as the initial remote DH key. -
AEAD-decrypt the ciphertext (with AD as defined in §5.4). If decrypt fails, abort and surface a clear error (the reference returns
Crypto::AeadVerifyFailedrather than silently dropping). -
MUST delete the consumed
OPK_priv[opk_id]so it cannot be reused (this is the source of forward secrecy contributed by the OPK).
If both Alice and Bob initiate concurrently (each fetched the other's bundle simultaneously), the tie-break rule is:
The party whose
device_idis lexicographically greater wins the INITIATOR role and proceeds; the other party MUST discard its half-built state and accept the winner's first message as RESPONDER.
The reference implements this in orchestration/session_lifecycle.rs.
4.5 PQXDH (Suite 2) — post-quantum extension
Suite 2 augments Suite 1 with an ML-KEM-768 shared secret. The
addition is deferred: the ML-KEM shared secret is mixed in after
the first DH ratchet step, not into the initial root-key derivation.
This design lets the same KEM_priv be safely paired with multiple
incoming sessions and matches the construct-core implementation in
src/crypto/pq_x3dh.rs.
Step P1: Initiator KEM encapsulation
After §4.3 Step 1 (bundle validation), Alice additionally selects Bob's Kyber-SPK (or, if available, a Kyber-OPK) and calls:
(KEM_pub_ct, kem_ss) = MlKem768::Encapsulate(KEM_pub_B)
KEM_pub_ct is the 1088-byte ciphertext. kem_ss is the 32-byte
post-quantum shared secret. The classical X3DH proceeds in parallel
and produces SK_root exactly as in §4.3.
Step P2: First message includes the KEM ciphertext
The Suite 2 envelope additionally carries:
+ kem_ct : [u8; 1088] -- ML-KEM-768 ciphertext
+ kyber_opk_id : Option<u32> -- which Kyber-OPK was consumed
+ kem_len : u16 = 1088 -- defensive length tag
The classical part of the envelope is unchanged.
Step P3: Initiator caches kem_ss until the first ratchet completes
Alice MUST persist kem_ss to durable storage before sending the
first message. The reference uses RustPQContributions, an
in-memory + Keychain-backed store, to survive an app crash between
"first message sent" and "PQ contribution applied" (open issue BS-6:
the persistence layer is currently in-memory only, which means a crash
in this window degrades the session to classical-only silently).
Step P4: Responder KEM decapsulation
Bob, after performing classical X3DH (§4.4) and the first DH ratchet step, calls:
kem_ss = MlKem768::Decapsulate(KEM_priv_B, kem_ct)
He MUST then immediately persist kem_ss (same BS-6 caveat).
Step P5: Apply the PQ contribution at RK₁
After the first Double Ratchet DH step has produced RK₁ (the post- first-ratchet root key), both sides MUST update:
RK₁' = KDF(salt = RK₁, IKM = kem_ss,
info = b"Construct-X3DH-RootKey-v1", L = 32)
and re-derive any sending chain key that was already computed from RK₁. From this point onward the session is secured under both the classical X25519 contribution and the PQ ML-KEM-768 contribution.
The deferred-mix design ensures the responder applies the PQ strengthening at the same logical state as the initiator (after the first DH step), which is required for the chains to stay in sync.
Step P6: Zeroise kem_ss
Once RK₁' is computed and persisted, kem_ss MUST be zeroised. The
PQ contribution store removes the entry from the Keychain at this
point.
4.6 Failure modes and recovery
| Failure | Required behaviour |
|---|---|
| Bundle signature verification fails | Abort handshake. Do not retry against the same bundle. |
| SPK or Kyber-SPK stale (> 10 days) | Abort. Trigger directory refetch. |
| Suite 2 requested but Kyber-SPK missing | Abort. MUST NOT downgrade to Suite 1 silently. |
| AEAD decrypt of first message fails | Abort. RESPONDER MUST NOT mark OPK consumed if it has not yet decrypted successfully. |
| Both parties initiate concurrently | Apply tie-break rule (§4.4); loser discards state. |
| App crash between sending first message and PQ contribution apply (initiator) | Recover from RustPQContributions Keychain entry on next launch. If no entry survives, the session is classically secure only. |
4.7 Reference
- Classical X3DH:
construct-core/src/crypto/handshake/x3dh.rs - PQXDH KEM:
construct-core/src/crypto/pq_x3dh.rs - Session initiation orchestration:
construct-core/src/orchestration/session_lifecycle.rs - PQ contribution store:
construct-core/src/orchestration/pq_contribution.rs
The cryptographic test vectors that an interoperable implementation
should reproduce are in construct-core/tests/handshake_vectors.rs
(to be expanded in v0.2 of this specification with a published vector
set).