|
| 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