Skip to content

Commit 233842e

Browse files
committed
feat: add Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256 spec (Stage 1 Working Draft)
1 parent 6b6203e commit 233842e

1 file changed

Lines changed: 361 additions & 0 deletions

File tree

noise-pq/README.md

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
| Lifecycle Stage | Maturity | Status | Latest Revision |
2+
|-----------------|---------------|--------|-----------------|
3+
| 1A | Working Draft | Active | 2026-04-28 |
4+
5+
Authors: [@paschal533](https://github.com/paschal533)
6+
7+
Interest Group: to be formed -- post on the [libp2p forum](https://discuss.libp2p.io) to join
8+
9+
See the [lifecycle document](https://github.com/libp2p/specs/blob/master/00-framework-01-spec-lifecycle.md) for context about the maturity level and expected evolution of this spec.
10+
11+
---
12+
13+
# Noise PQ: Post-Quantum Hybrid Noise Handshake for libp2p
14+
15+
**Protocol ID:** `/noise-pq/1.0.0`
16+
**Pattern:** `Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256`
17+
**Based on:** [Noise HFS extension spec](https://github.com/noiseprotocol/noise_hfs_spec), [PQNoise (ePrint 2022/539)](https://eprint.iacr.org/2022/539), [draft-connolly-cfrg-xwing-kem](https://www.ietf.org/archive/id/draft-connolly-cfrg-xwing-kem-06.txt)
18+
19+
## Table of Contents
20+
21+
- [1. Overview](#1-overview)
22+
- [2. Algorithm Identifiers](#2-algorithm-identifiers)
23+
- [3. Handshake Pattern](#3-handshake-pattern)
24+
- [4. KEM Interface](#4-kem-interface)
25+
- [5. Wire Format](#5-wire-format)
26+
- [6. Token Ordering](#6-token-ordering)
27+
- [7. State Machine](#7-state-machine)
28+
- [8. Cipher State Split](#8-cipher-state-split)
29+
- [9. ML-KEM Implicit Rejection](#9-ml-kem-implicit-rejection)
30+
- [10. Security Properties](#10-security-properties)
31+
- [11. Test Vectors](#11-test-vectors)
32+
- [12. Usage](#12-usage)
33+
- [13. Interoperability Requirements](#13-interoperability-requirements)
34+
- [14. Performance Reference](#14-performance-reference)
35+
36+
---
37+
38+
## 1. Overview
39+
40+
This document specifies the `Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256` handshake as a post-quantum hybrid extension of the classical [Noise XX](https://github.com/libp2p/specs/tree/master/noise) protocol used in libp2p.
41+
42+
The handshake adds an ephemeral KEM step (the Noise HFS tokens `e1` and `ekem1`) alongside the existing X25519 ECDH operations. This provides **hybrid post-quantum forward secrecy**: the session is secure if **either** X25519 **or** ML-KEM-768 is unbroken, preserving full backward compatibility with classical security guarantees while adding protection against future quantum adversaries.
43+
44+
The protocol uses X-Wing as the KEM primitive. X-Wing is a hybrid KEM that combines ML-KEM-768 (NIST FIPS 203) and X25519 via a SHA3-256 combiner, producing a 32-byte shared secret compatible with standard Noise key schedules.
45+
46+
---
47+
48+
## 2. Algorithm Identifiers
49+
50+
| Role | Algorithm | Specification |
51+
|------|-----------|---------------|
52+
| KEM | X-Wing (ML-KEM-768 + X25519) | [draft-connolly-cfrg-xwing-kem-06](https://www.ietf.org/archive/id/draft-connolly-cfrg-xwing-kem-06.txt) |
53+
| DH | X25519 | RFC 7748 |
54+
| AEAD | ChaCha20-Poly1305 | RFC 8439 |
55+
| Hash / HKDF | SHA-256 | FIPS 180-4 / RFC 5869 |
56+
| KEM lattice | ML-KEM-768 | NIST FIPS 203 |
57+
58+
X-Wing outputs a 32-byte combined shared secret using the combiner defined in the IETF draft. This combiner binds to both the ML-KEM and X25519 ciphertexts and public keys, preventing mix-and-match attacks.
59+
60+
---
61+
62+
## 3. Handshake Pattern
63+
64+
The `XXhfs` pattern extends classical Noise XX by adding the `e1` and `ekem1` HFS tokens:
65+
66+
```
67+
Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256:
68+
<- s
69+
...
70+
-> e, e1
71+
<- e, ee, ekem1, s, es
72+
-> s, se
73+
```
74+
75+
- **`e`**: X25519 ephemeral key (classical, 32 bytes)
76+
- **`e1`**: X-Wing KEM ephemeral public key (1216 bytes: 1184-byte ML-KEM-768 encapsulation key + 32-byte X25519 pk)
77+
- **`ekem1`**: X-Wing ciphertext encrypted under the `ee`-derived key (1136 bytes: 1120-byte ct + 16-byte AEAD tag), followed by `MixKey(KEM shared secret)`
78+
79+
The HFS tokens follow the Noise HFS extension specification: `encryptAndHash(cipherText)` is applied **before** `mixKey(sharedSecret)`. This ordering is mandatory.
80+
81+
---
82+
83+
## 4. KEM Interface
84+
85+
Any KEM used with this protocol must implement the following interface:
86+
87+
```
88+
IKem:
89+
PUBKEY_LEN: integer // X-Wing: 1216 bytes
90+
CT_LEN: integer // X-Wing: 1120 bytes
91+
SS_LEN: integer // X-Wing: 32 bytes
92+
93+
generateKemKeyPair() -> (publicKey: bytes, secretKey: bytes)
94+
encapsulate(remotePublicKey: bytes) -> (cipherText: bytes, sharedSecret: bytes)
95+
decapsulate(cipherText: bytes, secretKey: bytes) -> sharedSecret: bytes
96+
```
97+
98+
The default implementation uses X-Wing as defined in [draft-connolly-cfrg-xwing-kem](https://www.ietf.org/archive/id/draft-connolly-cfrg-xwing-kem-06.txt). Implementations MAY substitute a different KEM conforming to this interface for testing or experimentation, but interoperability across implementations requires the X-Wing default.
99+
100+
---
101+
102+
## 5. Wire Format
103+
104+
Message sizes assume an empty libp2p `NoiseHandshakePayload`. Real handshakes include identity keys and signatures (approximately 108 bytes per side for Ed25519), adding roughly 308 bytes total to the figures below.
105+
106+
### 5.1 Message A (initiator to responder)
107+
108+
```
109+
+-------------------+-----------------------+---------+
110+
| e.publicKey | e1.publicKey | payload |
111+
| 32 bytes | 1216 bytes | 0 bytes |
112+
+-------------------+-----------------------+---------+
113+
Total: 1248 bytes
114+
```
115+
116+
`e.publicKey` is sent in plaintext (no cipher key exists yet). `e1.publicKey` is processed via `encryptAndHash()`, which at this stage is a plain `MixHash()` because there is no active cipher.
117+
118+
### 5.2 Message B (responder to initiator)
119+
120+
```
121+
+-------------------+-----------------------+--------------------+---------+
122+
| e.publicKey | enc(KEM ciphertext) | enc(s.publicKey) | payload |
123+
| 32 bytes | 1136 bytes | 48 bytes | 16 bytes|
124+
+-------------------+-----------------------+--------------------+---------+
125+
Total: 1232 bytes (with empty payload; 16-byte AEAD tag on payload)
126+
```
127+
128+
After `ee`: `MixKey(DH(e_R, e_I))` establishes the first cipher key. The 1120-byte X-Wing ciphertext is encrypted under this key (adding a 16-byte AEAD tag = 1136 bytes total). `MixKey(kemSharedSecret)` follows the ciphertext, strengthening subsequent operations.
129+
130+
### 5.3 Message C (initiator to responder)
131+
132+
```
133+
+--------------------+---------+
134+
| enc(s.publicKey) | payload |
135+
| 48 bytes | 16 bytes|
136+
+--------------------+---------+
137+
Total: 64 bytes (with empty payload)
138+
```
139+
140+
Identical structure to the classical Noise XX Message C.
141+
142+
### 5.4 Size comparison with classical XX
143+
144+
| Message | Classical XX | XXhfs (PQ) | Delta |
145+
|---------|------------:|----------:|------:|
146+
| Msg A | 32 bytes | 1,248 bytes | +1,216 bytes |
147+
| Msg B | 96 bytes | 1,232 bytes | +1,136 bytes |
148+
| Msg C | 64 bytes | 64 bytes | 0 bytes |
149+
| **Total** | **192 bytes** | **2,544 bytes** | **+2,352 bytes** |
150+
151+
---
152+
153+
## 6. Token Ordering
154+
155+
The `ekem1` token **must** follow this exact ordering on both initiator and responder sides:
156+
157+
**Responder (write ekem1):**
158+
159+
```
160+
1. (ct, ss) = encapsulate(re1) // encapsulate to initiator's e1 public key
161+
2. encryptAndHash(ct) // encrypt ciphertext under ee-derived key
162+
3. mixKey(ss) // mix KEM shared secret AFTER encrypting ct
163+
```
164+
165+
**Initiator (read ekem1):**
166+
167+
```
168+
1. ct = decryptAndHash(enc_ct) // decrypt the ciphertext (AEAD authenticated)
169+
2. ss = decapsulate(ct, e1.secretKey)
170+
3. mixKey(ss) // must match write ordering
171+
```
172+
173+
Swapping steps 2 and 3 (encrypt/decrypt after mixKey) produces divergent chaining keys and is **incorrect**. The AEAD protection on the ciphertext means tampering is caught at step 1 before decapsulation is attempted.
174+
175+
---
176+
177+
## 7. State Machine
178+
179+
```
180+
Initiator Responder
181+
--------- ---------
182+
generate e (X25519)
183+
generate e1 (X-Wing)
184+
writeMessageA(payload=empty)
185+
MixHash(e.publicKey)
186+
MixHash(e1.publicKey)
187+
-> e, e1
188+
readMessageA()
189+
MixHash(e.publicKey) // store as re
190+
MixHash(e1.publicKey) // store as re1
191+
192+
generate e (X25519)
193+
writeMessageB(payload)
194+
MixHash(e.publicKey)
195+
ee: MixKey(DH(e_R, e_I))
196+
(ct, ss) = encapsulate(re1)
197+
encryptAndHash(ct) // ekem1 write
198+
mixKey(ss)
199+
encryptAndHash(s.publicKey)
200+
es: MixKey(DH(s_R, e_I))
201+
encryptAndHash(payload)
202+
-> e, enc(ct), enc(s), enc(payload)
203+
204+
readMessageB()
205+
MixHash(re.publicKey)
206+
ee: MixKey(DH(e_I, re))
207+
ct = decryptAndHash(enc_ct) // ekem1 read
208+
ss = decapsulate(ct, e1.secretKey)
209+
mixKey(ss)
210+
resp_s = decryptAndHash(enc_s)
211+
es: MixKey(DH(e_I, resp_s))
212+
verify payload signature
213+
214+
writeMessageC(payload)
215+
encryptAndHash(s.publicKey)
216+
se: MixKey(DH(s_I, re))
217+
encryptAndHash(payload)
218+
-> enc(s), enc(payload)
219+
readMessageC()
220+
init_s = decryptAndHash(enc_s)
221+
se: MixKey(DH(e_R, init_s))
222+
verify payload signature
223+
224+
[cs1, cs2] = split() [cs1, cs2] = split()
225+
encrypt = cs1, decrypt = cs2 encrypt = cs2, decrypt = cs1
226+
```
227+
228+
---
229+
230+
## 8. Cipher State Split
231+
232+
After `split()`, two directional cipher states are derived from the final chaining key via HKDF-SHA256:
233+
234+
| Direction | Initiator | Responder |
235+
|-----------|-----------|-----------|
236+
| Initiator to responder | encrypt with `cs1` | decrypt with `cs1` |
237+
| Responder to initiator | decrypt with `cs2` | encrypt with `cs2` |
238+
239+
Each cipher state maintains an independent nonce counter starting at zero. The nonce is never transmitted; both sides increment in lockstep.
240+
241+
---
242+
243+
## 9. ML-KEM Implicit Rejection
244+
245+
ML-KEM-768 (FIPS 203 Section 6.4) implements implicit rejection: `Decaps()` never throws on an invalid ciphertext. Instead it returns a pseudorandom value derived from a secret implicit rejection key. This means:
246+
247+
- A tampered or wrong-key ciphertext produces a divergent KEM shared secret rather than an explicit error.
248+
- The divergence propagates through `mixKey()`, causing all subsequent AEAD operations to fail authentication.
249+
- The handshake still aborts cleanly via AEAD failure.
250+
251+
Because `encryptAndHash(ct)` precedes `mixKey(ss)`, an attacker who tampers with the ciphertext in transit will be caught by the AEAD tag before decapsulation runs.
252+
253+
---
254+
255+
## 10. Security Properties
256+
257+
| Property | Mechanism |
258+
|----------|-----------|
259+
| Forward secrecy (classical) | Ephemeral X25519 on both sides (DH ee) |
260+
| Forward secrecy (quantum-safe) | X-Wing KEM: ML-KEM-768 + X25519 hybrid |
261+
| Mutual authentication | DH(es) + DH(se) via libp2p identity signatures |
262+
| Identity hiding | Static keys transmitted after ephemeral exchange |
263+
| Hybrid robustness | Secure if either X25519 or ML-KEM-768 is unbroken |
264+
| Payload confidentiality | ChaCha20-Poly1305 under the post-split cipher states |
265+
266+
**Out of scope:** Quantum-safe *authentication*. Identity keys use Ed25519 (classical). Full post-quantum authentication requires ML-DSA (FIPS 204) identity keys and is tracked separately.
267+
268+
---
269+
270+
## 11. Test Vectors
271+
272+
Implementations should validate against the test vectors schema:
273+
274+
```json
275+
{
276+
"protocol": "Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256",
277+
"vectors": [
278+
{
279+
"vector_index": 1,
280+
"static_i_public": "<hex 32B>",
281+
"static_i_private": "<hex 32B>",
282+
"static_r_public": "<hex 32B>",
283+
"static_r_private": "<hex 32B>",
284+
"ephemeral_dh_i_public": "<hex 32B>",
285+
"ephemeral_dh_i_private": "<hex 32B>",
286+
"ephemeral_dh_r_public": "<hex 32B>",
287+
"ephemeral_dh_r_private": "<hex 32B>",
288+
"ephemeral_kem_i_public": "<hex 1216B>",
289+
"ephemeral_kem_i_secret": "<hex 32B seed>",
290+
"encap_seed_hex": "<hex 64B>",
291+
"msg_a": "<hex 1248B>",
292+
"msg_b": "<hex 1232B>",
293+
"msg_c": "<hex 64B>",
294+
"handshake_hash": "<hex 32B>",
295+
"cs1_k": "<hex 32B>",
296+
"cs2_k": "<hex 32B>"
297+
}
298+
]
299+
}
300+
```
301+
302+
Reference test vectors are published in the JavaScript implementation at [paschal533/js-libp2p-noise](https://github.com/paschal533/js-libp2p-noise) under `test/fixtures/pqc-test-vectors.json`.
303+
304+
---
305+
306+
## 12. Usage
307+
308+
### JavaScript (js-libp2p-noise)
309+
310+
```typescript
311+
import { createLibp2p } from 'libp2p'
312+
import { noiseHFS } from '@chainsafe/libp2p-noise'
313+
314+
const node = await createLibp2p({
315+
connectionEncrypters: [noiseHFS()],
316+
})
317+
```
318+
319+
### Python (py-libp2p)
320+
321+
```python
322+
from libp2p.security.noise.pq import TransportPQ, PROTOCOL_ID
323+
324+
host = await new_node(
325+
security_opt={PROTOCOL_ID: TransportPQ(libp2p_keypair, noise_privkey)}
326+
)
327+
```
328+
329+
Both implementations auto-select the fastest available KEM backend (native WASM or liboqs C library if available, falling back to pure-software implementations).
330+
331+
---
332+
333+
## 13. Interoperability Requirements
334+
335+
A conforming implementation MUST:
336+
337+
1. Use the exact protocol name: `Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256`
338+
2. Use X-Wing (ML-KEM-768 + X25519 with SHA3-256 combiner) as the KEM primitive
339+
3. Apply `encryptAndHash(cipherText)` BEFORE `mixKey(sharedSecret)` in the `ekem1` token
340+
4. Transmit `e1.publicKey` as 1216 bytes in Message A (no AEAD tag at this stage)
341+
5. Transmit `ekem1` as exactly 1136 bytes in Message B (1120-byte ciphertext + 16-byte AEAD tag)
342+
6. Pass the test vectors published by the reference implementation
343+
344+
---
345+
346+
## 14. Performance Reference
347+
348+
Measured on Node.js v22, Windows 11 x64 (pure-JS, no WASM):
349+
350+
| Operation | ops/s | ms/op |
351+
|-----------|------:|------:|
352+
| X-Wing keygen | 293 | 3.42 |
353+
| X-Wing encapsulate | 120 | 8.32 |
354+
| X-Wing decapsulate | 136 | 7.33 |
355+
| KEM round-trip | 47 | 21.43 |
356+
| Classical XX handshake | 114 | 8.75 |
357+
| XXhfs handshake | 23 | 44.18 |
358+
359+
The approximately 5x latency increase over classical XX is dominated by the X-Wing KEM (~21 ms per round-trip). WASM acceleration of the ML-KEM-768 lattice arithmetic yields a 3.2x speedup in isolated KEM throughput; full handshake improvement is approximately 4% because non-KEM operations (SHA-256, ChaCha20-Poly1305, HKDF, Ed25519, protobuf serialization, async scheduling) dominate in the JavaScript runtime.
360+
361+
Python measurements with the kyber-py pure-Python backend show significantly higher KEM latency (keygen 10.5 ms, encap 12.3 ms, decap 15.5 ms), with the KEM accounting for approximately 94% of total handshake time. The liboqs C backend reduces this to below 3.2 ms, below the classical Noise baseline.

0 commit comments

Comments
 (0)