Status
Beta Q2 2026, release Q3 2026
Overview
This is the hybrid post-quantum variant of the SSU2 transport protocol, as designed in Proposal 169. See that proposal for additional background.
PQ Hybrid SSU2 is only defined on the same address and port as standard SSU2. Operation on a different port, or without standard SSU2 support, is not permitted, and will not be for several years, when standard SSU2 is deprecated.
This specification documents only the changes required to standard SSU2 to support PQ Hybrid. See the SSU2 specification for the baseline implementation details.
Design
We support the NIST FIPS 203 and 204 standards FIPS 203 FIPS 204 which are based on, but NOT compatible with, CRYSTALS-Kyber and CRYSTALS-Dilithium (versions 3.1, 3, and older).
Key Exchange
PQ KEM provides ephemeral keys only, and does not directly support static-key handshakes such as Noise XK and IK. The encryption types are the same as used in PQ Hybrid Ratchet and are defined in the common structures document /docs/specs/common-structures/ , as in FIPS 203 , Hybrid types are only defined in combination with X25519.
The encryption types are:
| Type | Code | SSU2 Version |
|---|---|---|
| MLKEM512_X25519 | 5 | 3 |
| MLKEM768_X25519 | 6 | 4 |
Legal Combinations
The new encryption types are indicated in the RouterAddresses. The encryption type in the key certificate will continue to be type 4.
Specification
Handshake Patterns
Handshakes use Noise Protocol handshake patterns.
The following letter mapping is used:
- e = one-time ephemeral key
- s = static key
- p = message payload
- e1 = one-time ephemeral PQ key, sent from Alice to Bob
- ekem1 = the KEM ciphertext, sent from Bob to Alice
The following modifications to XK and IK for hybrid forward secrecy (hfs) are as specified in Noise HFS spec section 5:
XK: XKhfs:
<- s <- s
... ...
-> e, es, p -> e, es, e1, p
<- e, ee, p <- e, ee, ekem1, p
-> s, se -> s, se
<- p <- p
p -> p ->
e1 and ekem1 are encrypted. See pattern definitions below.
NOTE: e1 and ekem1 are different sizes (unlike X25519)
The e1 pattern is defined as follows, as specified in Noise HFS spec section 4:
For Alice:
(encap_key, decap_key) = PQ_KEYGEN()
// EncryptAndHash(encap_key)
ciphertext = ENCRYPT(k, n, encap_key, ad)
n++
MixHash(ciphertext)
For Bob:
// DecryptAndHash(ciphertext)
encap_key = DECRYPT(k, n, ciphertext, ad)
n++
MixHash(ciphertext)
The ekem1 pattern is defined as follows, as specified in Noise HFS spec section 4:
For Bob:
(kem_ciphertext, kem_shared_key) = ENCAPS(encap_key)
// EncryptAndHash(kem_ciphertext)
ciphertext = ENCRYPT(k, n, kem_ciphertext, ad)
MixHash(ciphertext)
// MixKey
MixKey(kem_shared_key)
For Alice:
// DecryptAndHash(ciphertext)
kem_ciphertext = DECRYPT(k, n, ciphertext, ad)
MixHash(ciphertext)
// MixKey
kem_shared_key = DECAPS(kem_ciphertext, decap_key)
MixKey(kem_shared_key)
Noise Handshake KDF
Overview
The hybrid handshake is defined in Noise HFS spec . The first message, from Alice to Bob, contains e1, the encapsulation key, before the message payload. This is treated as an additional static key; call EncryptAndHash() on it (as Alice) or DecryptAndHash() (as Bob). Then process the message payload as usual.
The second message, from Bob to Alice, contains ekem1, the ciphertext, before the message payload. This is treated as an additional static key; call EncryptAndHash() on it (as Bob) or DecryptAndHash() (as Alice). Then, calculate the kem_shared_key and call MixKey(kem_shared_key). Then process the message payload as usual.
Defined ML-KEM Operations
We define the following functions corresponding to the cryptographic building blocks used as defined in FIPS 203 .
(encap_key, decap_key) = PQ_KEYGEN() Alice creates the encapsulation and decapsulation keys The encapsulation key is sent in message 1. encap_key and decap_key sizes vary based on ML-KEM variant.
(ciphertext, kem_shared_key) = ENCAPS(encap_key) Bob calculates the ciphertext and shared key, using the ciphertext received in message 1. The ciphertext is sent in message 2. ciphertext size varies based on ML-KEM variant. The kem_shared_key is always 32 bytes.
kem_shared_key = DECAPS(ciphertext, decap_key) Alice calculates the shared key, using the ciphertext received in message 2. The kem_shared_key is always 32 bytes.
Note that both the encap_key and the ciphertext are encrypted inside ChaCha/Poly blocks in the Noise handshake messages 1 and 2. They will be decrypted as part of the handshake process.
The kem_shared_key is mixed into the chaining key with MixHash(). See below for details.
Alice KDF for Message 1
After the ’es’ message pattern and before the payload, add:
This is the "e1" message pattern:
(encap_key, decap_key) = PQ_KEYGEN()
// EncryptAndHash(encap_key)
// AEAD parameters
k = keydata[32:63]
n = 0
ad = h
ciphertext = ENCRYPT(k, n, encap_key, ad)
n++
// MixHash(ciphertext)
h = SHA256(h || ciphertext)
End of "e1" message pattern.
NOTE: For the next section (payload for XK or static key for IK),
the keydata and chain key remain the same,
and n now equals 1 (instead of 0 for non-hybrid).
Bob KDF for Message 1
After the ’es’ message pattern and before the payload, add:
This is the "e1" message pattern:
// DecryptAndHash(encap_key_section)
// AEAD parameters
k = keydata[32:63]
n = 0
ad = h
encap_key = DECRYPT(k, n, encap_key_section, ad)
n++
// MixHash(encap_key_section)
h = SHA256(h || encap_key_section)
End of "e1" message pattern.
NOTE: For the next section (payload for XK or static key for IK),
the keydata and chain key remain the same,
and n now equals 1 (instead of 0 for non-hybrid).
Bob KDF for Message 2
For XK: After the ’ee’ message pattern and before the payload, add:
This is the "ekem1" message pattern:
(kem_ciphertext, kem_shared_key) = ENCAPS(encap_key)
// EncryptAndHash(kem_ciphertext)
// AEAD parameters
k = keydata[32:63]
n = 0
ad = h
ciphertext = ENCRYPT(k, n, kem_ciphertext, ad)
// MixHash(ciphertext)
h = SHA256(h || ciphertext)
// MixKey(kem_shared_key)
keydata = HKDF(chainKey, kem_shared_key, "", 64)
chainKey = keydata[0:31]
End of "ekem1" message pattern.
Alice KDF for Message 2
After the ’ee’ message pattern, add:
This is the "ekem1" message pattern:
// DecryptAndHash(kem_ciphertext_section)
// AEAD parameters
k = keydata[32:63]
n = 0
ad = h
kem_ciphertext = DECRYPT(k, n, kem_ciphertext_section, ad)
// MixHash(kem_ciphertext_section)
h = SHA256(h || kem_ciphertext_section)
// MixKey(kem_shared_key)
kem_shared_key = DECAPS(kem_ciphertext, decap_key)
keydata = HKDF(chainKey, kem_shared_key, "", 64)
chainKey = keydata[0:31]
End of "ekem1" message pattern.
KDF for Message 3
unchanged
KDF for split()
unchanged
Handshake Details
Noise identifiers
- “Noise_XKhfschaobfse+hs1+hs2+hs3_25519+MLKEM512_ChaChaPoly_SHA256”
- “Noise_XKhfschaobfse+hs1+hs2+hs3_25519+MLKEM768_ChaChaPoly_SHA256”
Note that MLKEM-1024 is NOT supported for SSU2, as the keys are too large to fit within a standard 1500 byte datagram.
Long Header
The long header is 32 bytes. It is used before a session is created, for Token Request, SessionRequest, SessionCreated, and Retry. It is also used for out-of-session Peer Test and Hole Punch messages.
In the following messages, set the ver (version) field in the long header to 3 or 4, to indidate MLKEM-512 or MLKEM-768.
- (0) Session Request
- (1) Session Created
- (9) Retry
- (10) Token Request
- (11) Hole Punch
In the following messages, set the ver (version) field in the long header to 2, as usual, even if MLKEM-512 or MLKEM-768 is supported. Implementations may also set the value to 3 or 4, if the other end supports it, but this is not necessary. Implementations should accept any value 2-4.
- (7) Peer Test (out of session messages 5-7)
Discussion: Setting the version field to 3 or 4 may not be strictly necessary for all message types, but doing so aids earlier failure detection for unsupported post-quantum connections. Token Request and Retry (types 9 and 10) should have versions 3/4 for consistency. Hole Punch messages (type 11) may not require this treatment but we will follow the same pattern for uniformity. Peer Test messages (type 7) is out-of-session and does not indicate intent to initiate a session.
Before header encryption:
+----+----+----+----+----+----+----+----+
| Destination Connection ID |
+----+----+----+----+----+----+----+----+
| Packet Number |type| ver| id |flag|
+----+----+----+----+----+----+----+----+
| Source Connection ID |
+----+----+----+----+----+----+----+----+
| Token |
+----+----+----+----+----+----+----+----+
Destination Connection ID :: 8 bytes, unsigned big endian integer
Packet Number :: 4 bytes, unsigned big endian integer
type :: The message type = 0, 1, 7, 9, 10, or 11
ver :: The protocol version = 2, 3, or 4 for non-PQ, MLKEM512, MLKEM768
id :: 1 byte, the network ID (currently 2, except for test networks)
flag :: 1 byte, unused, set to 0 for future compatibility
Source Connection ID :: 8 bytes, unsigned big endian integer
Token :: 8 bytes, unsigned big endian integer
Short Header
unchanged
SessionRequest (Type 0)
Changes: Current SSU2 contains only the block data in the ChaCha section. With ML-KEM, the ChaCha section will also contain the encrypted PQ public key.
KDF Change for Spoof Protection: To address the issues raised in Proposal 165 [Prop165]_, but with a different solution, we modify the KDF for Session Request. This is only for PQ sessions. The KDF for non-PQ sessions remains unchanged.
// End of KDF for initial chain key (unchanged)
// Bob static key
// MixHash(bpk)
h = SHA256(h || bpk);
// Start of KDF for session request
// NEW for PQ only
// bhash = Bob router hash (32 bytes)
// MixHash(bhash)
h = SHA256(h || bhash);
// Rest of KDF for session request, unchanged, as in SSU2 spec
// MixHash(header)
h = SHA256(h || header)
...
Raw contents:
+----+----+----+----+----+----+----+----+
| Long Header bytes 0-15, ChaCha20 |
+ encrypted with Bob intro key +
| See Header Encryption KDF |
+----+----+----+----+----+----+----+----+
| Long Header bytes 16-31, ChaCha20 |
+ encrypted with Bob intro key n=0 +
| |
+----+----+----+----+----+----+----+----+
| |
+ X, ChaCha20 encrypted +
| with Bob intro key n=0 |
+ (32 bytes) +
| |
+ +
| |
+----+----+----+----+----+----+----+----+
| |
+ +
| ChaCha20 encrypted data (MLKEM) |
+ (length varies) +
| k defined in KDF for Session Request |
+ n = 0 +
| see KDF for associated data |
+----+----+----+----+----+----+----+----+
| |
+ +
| ChaCha20 encrypted data (payload) |
+ (length varies) +
| k defined in KDF for Session Request |
+ n = 0 +
| see KDF for associated data |
+----+----+----+----+----+----+----+----+
| |
+ Poly1305 MAC (16 bytes) +
| |
+----+----+----+----+----+----+----+----+
Unencrypted data (Poly1305 authentication tag not shown):
+----+----+----+----+----+----+----+----+
| Destination Connection ID |
+----+----+----+----+----+----+----+----+
| Packet Number |type| ver| id |flag|
+----+----+----+----+----+----+----+----+
| Source Connection ID |
+----+----+----+----+----+----+----+----+
| Token |
+----+----+----+----+----+----+----+----+
| |
+ +
| X |
+ (32 bytes) +
| |
+ +
| |
+----+----+----+----+----+----+----+----+
| ML-KEM encap_key |
+ (see table below for length) +
| |
+----+----+----+----+----+----+----+----+
| Noise payload (block data) |
+ (length varies) +
| see below for allowed blocks |
+----+----+----+----+----+----+----+----+
Sizes, not including IP overhead:
| Type | Type Code | X len | Msg 1 len | Msg 1 Enc len | Msg 1 Dec len | PQ key len | pl len |
|---|---|---|---|---|---|---|---|
| X25519 | 4 | 32 | 80+pl | 16+pl | pl | – | pl |
| MLKEM512_X25519 | 5 | 32 | 896+pl | 832+pl | 800+pl | 800 | pl |
| MLKEM768_X25519 | 6 | 32 | 1280+pl | 1216+pl | 1184+pl | 1184 | pl |
| MLKEM1024_X25519 | 7 | n/a | too big |
Note: Type codes are for internal use only. Routers will remain type 4, and support will be indicated in the router addresses.
Minimum MTU for MLKEM768_X25519: 1318 for IPv4 and 1338 for IPv6. See below.
SessionCreated (Type 1)
Changes: Current SSU2 contains only the block data in the ChaCha section. With ML-KEM, the ChaCha section will also contain the encrypted PQ public key.
Raw contents:
+----+----+----+----+----+----+----+----+
| Long Header bytes 0-15, ChaCha20 |
+ encrypted with Bob intro key and +
| derived key, see Header Encryption KDF|
+----+----+----+----+----+----+----+----+
| Long Header bytes 16-31, ChaCha20 |
+ encrypted with derived key n=0 +
| See Header Encryption KDF |
+----+----+----+----+----+----+----+----+
| |
+ Y, ChaCha20 encrypted +
| with derived key n=0 |
+ (32 bytes) +
| See Header Encryption KDF |
+ +
| |
+----+----+----+----+----+----+----+----+
| ChaCha20 data (MLKEM) |
+ Encrypted and authenticated data +
| length varies |
+ k defined in KDF for Session Created +
| n = 0; see KDF for associated data |
+ +
| |
+----+----+----+----+----+----+----+----+
| ChaCha20 data (payload) |
+ Encrypted and authenticated data +
| length varies |
+ k defined in KDF for Session Created +
| n = 0; see KDF for associated data |
+ +
| |
+----+----+----+----+----+----+----+----+
| |
+ Poly1305 MAC (16 bytes) +
| |
+----+----+----+----+----+----+----+----+
Unencrypted data (Poly1305 auth tag not shown):
+----+----+----+----+----+----+----+----+
| Destination Connection ID |
+----+----+----+----+----+----+----+----+
| Packet Number |type| ver| id |flag|
+----+----+----+----+----+----+----+----+
| Source Connection ID |
+----+----+----+----+----+----+----+----+
| Token |
+----+----+----+----+----+----+----+----+
| |
+ +
| Y |
+ (32 bytes) +
| |
+ +
| |
+----+----+----+----+----+----+----+----+
| ML-KEM Ciphertext |
+ (see table below for length) +
| |
+----+----+----+----+----+----+----+----+
| Noise payload (block data) |
+ (length varies) +
| see below for allowed blocks |
+----+----+----+----+----+----+----+----+
Sizes, not including IP overhead:
| Type | Type Code | Y len | Msg 2 len | Msg 2 Enc len | Msg 2 Dec len | PQ CT len | pl len |
|---|---|---|---|---|---|---|---|
| X25519 | 4 | 32 | 80+pl | 16+pl | pl | – | pl |
| MLKEM512_X25519 | 5 | 32 | 864+pl | 800+pl | 768+pl | 768 | pl |
| MLKEM768_X25519 | 6 | 32 | 1184+pl | 1118+pl | 1088+pl | 1088 | pl |
| MLKEM1024_X25519 | 7 | n/a | too big |
Note: Type codes are for internal use only. Routers will remain type 4, and support will be indicated in the router addresses.
Minimum MTU for MLKEM768_X25519: 1318 for IPv4 and 1338 for IPv6. See below.
SessionConfirmed (Type 2)
unchanged
KDF for data phase
unchanged
Relay and Peer Test
The following blocks contain version fields. They will remain version 2 (for compatibility with a non-PQ Bob), and will not change to version 3/4 for PQ.
- Relay Request
- Relay Response
- Relay Intro
- Peer Test
PQ Signatures: Relay blocks, Peer Test blocks, and Peer Test messages all contain signatures. Unfortunately, PQ signatures are larger than the MTU. There is no current mechanism to fragment Relay or Peer Test blocks or messages across multiple UDP packets. The protocol must be extended to support fragmentation. This will be done in a separate proposal TBD. Until that is completed, Relay and Peer Test will not be supported.
Published Addresses
In all cases, use the SSU2 transport name as usual. MLKEM-1024 is not supported.
Use the same address/port as non-PQ, non-firewalled. One or both PQ variants are supported. In the router address, publish v=2 (as usual) and the new parameter pq=[3|4|3,4|4,3] to indicate MLKEM 512/768/both. Routers with a MTU less than the minimum specified below must not publish a “pq” parameter containing “4”. Publish 4,3 to indicate a preference for MLKEM-768 or 3,4 to indicate a preference for MLKEM-512. The actual version is up to the initiator, and the preference may not be honored. Routers with a MTU less than the minimum specified below must not connect using MLKEM768. Older routers will ignore the pq parameter and connect non-pq as usual.
Different address/port as non-PQ, or PQ-only, non-firewalled is NOT supported. This will not be implemented until non-PQ SSU2 is disabled, several years from now. When non-PQ is disabled, one or both PQ variants are supported. In the router address, publish v=[3|4|3,4|4,3] to indicate MLKEM 512/768/both. Older routers will check the v parameter and skip this address as unsupported.
Firewalled addresses (no IP published): In the router address, publish v=2 (as usual). The pq parameter MUST be publised in firewalled addresses, to support relay.
Alice may connect to a PQ Bob using the PQ variant that Bob publishes, whether or not Alice advertises pq support in her router info, or whether she advertises the same variant.
MTU
Use caution not to exceed the MTU with MLKEM768. The Minimum MTU for MLKEM768_X25519 is 1318 for IPv4 and 1338 for IPv6 (assuming a min payload of 10 bytes with a DateTime and a Padding or RelayTagRequest block). The minimum MTU for SSU2 in general is 1280, so not all peers may use MLKEM768. Do not publish or use MLKEM768 if the actual MTU is less than the minimum, either locally or as advertised by the peer. Take care not to include padding size such that message 1 or 2 would exceed the local or remote MTU.
Overhead Analysis
Key Exchange
Size increase (bytes):
| Type | Pubkey (Msg 1) | Ciphertext (Msg 2) |
|---|---|---|
| MLKEM512_X25519 | +816 | +784 |
| MLKEM768_X25519 | +1200 | +1104 |
Security Analysis
NIST security categories are summarized in NIST presentation slide 10. Preliminary criteria: Our minimum NIST security category should be 2 for hybrid protocols and 3 for PQ-only.
| Category | As Secure As |
|---|---|
| 1 | AES128 |
| 2 | SHA256 |
| 3 | AES192 |
| 4 | SHA384 |
| 5 | AES256 |
Handshakes
These are all hybrid protocols. Implementations should prefer MLKEM768; MLKEM512 is not secure enough.
NIST security categories FIPS 203 :
| Algorithm | Security Category |
|---|---|
| MLKEM512 | 1 |
| MLKEM768 | 3 |
Implementation Notes
Library Support
Bouncycastle, BoringSSL, and WolfSSL libraries support MLKEM and MLDSA now. OpenSSL support is be in their 3.5 release April 8, 2025 OpenSSL .
Inbound Traffic Identification
We set the MSB of the ephemeral key (key[31] & 0x80) in the session request to indicate that this is a hybrid connection. This allows us to run both standard NTCP and hybrid NTCP on the same port. Only one hybrid variant is supported for inbound, and advertised in the router address. For example, pq=3 or pq=4.
Obfuscation
As Alice, for a PQ connection, before obfuscation, set X[31] |= 0x80. This makes X an invalid X25519 public key. After obfuscation, AES-CBC will randomize it. The MSB of X will be random after obfuscation.
As Bob, test if (X[31] & 0x80) != 0 after de-obfuscation. If so, it’s a PQ connection.
The minimum router version required for NTCP2-PQ is TBD.
Note: Type codes are for internal use only. Routers will remain type 4, and support will be indicated in the router addresses.
Router Compatibility
Transport Names
In all cases, use the NTCP2 transport name as usual. Older routers will ignore the pq parameter and connect with standard NTCP2 as usual.