diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cf7af1..01067ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,75 @@ +# Changelog + +## Unreleased + +### Added: Post-Quantum Hybrid Noise (XXhfs) + +This adds a second connection encrypter alongside the existing classical `noise()`: a post-quantum hybrid handshake based on the Noise HFS specification. Both encrypters live in the same package and can coexist in the same libp2p node. + +#### New exports + +| Export | Kind | Description | +|--------|------|-------------| +| `noiseHFS(init?)` | function | Factory for the XXhfs connection encrypter. Drop-in replacement for `noise()` in `connectionEncrypters`. | +| `NoiseHFS` | class | The `ConnectionEncrypter` implementation for `/noise-pq/1.0.0`. | +| `NoiseHFSInit` | type | Init options for `noiseHFS()`: `staticNoiseKey`, `kemBackend`, `extensions`, `crypto`, `prologueBytes`. | +| `pqcKem` | object | Default X-Wing KEM backend (ML-KEM-768 + X25519) via `@noble/post-quantum`. | +| `pqcCrypto` | object | Combined `ICryptoInterface` + `IKem` (pureJsCrypto + pqcKem). | +| `IKem` | type | Interface for KEM backends. | +| `KemKeyPair` | type | `{ publicKey: Uint8Array, secretKey: Uint8Array }` | +| `KemEncapsulateResult` | type | `{ cipherText: Uint8Array, sharedSecret: Uint8Array }` | +| `XXhfsHandshakeState` | class | The raw XXhfs handshake state machine (for advanced use and testing). | +| `NOISE_HFS_PROTOCOL_NAME` | constant | `'Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256'` | +| `HfsHandshakeStateInit` | type | Constructor options for `XXhfsHandshakeState`. | +| `HfsHandshakeParams` | type | Options for `performHandshakeHFSInitiator` / `performHandshakeHFSResponder`. | + +#### New files + +| File | Description | +|------|-------------| +| `src/kem.ts` | `IKem` interface and related types | +| `src/crypto/pqc.ts` | X-Wing KEM implementation (pure JS via `@noble/post-quantum`) | +| `src/crypto/pqc.node.ts` | Node.js backend slot for KEM (currently re-exports pqc.ts; native ML-KEM-768 TODO) | +| `src/protocol-pqc.ts` | `XXhfsHandshakeState` state machine with `e1` and `ekem1` KEM tokens | +| `src/performHandshake-hfs.ts` | Initiator and responder handshake orchestration for XXhfs | +| `src/noise-hfs.ts` | `NoiseHFS` class and `noiseHFS()` factory | +| `NOISE_HFS_SPEC.md` | Full wire format spec, token ordering, and security analysis | +| `benchmarks/benchmark-pqc.js` | Benchmark comparing classical XX vs XXhfs | +| `benchmarks/results.md` | Measured benchmark results (Node.js v22.17.1) | +| `scripts/generate-pqc-vectors.js` | Deterministic test vector generator | +| `test/fixtures/pqc-test-vectors.json` | Committed test vectors (5 vectors) | +| `test/pqc-kem.spec.ts` | IKem unit tests | +| `test/pqc-protocol.spec.ts` | XXhfsHandshakeState unit tests | +| `test/pqc-noise.spec.ts` | Integration tests for NoiseHFS | +| `test/pqc-vectors.spec.ts` | Test vector verification | + +#### Protocol details + +- **Protocol name:** `Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256` +- **libp2p protocol ID:** `/noise-pq/1.0.0` +- **KEM:** X-Wing = ML-KEM-768 + X25519 (IETF draft-connolly-cfrg-xwing-kem) +- **Wire overhead vs classical XX:** +2,352 bytes per handshake (empty payload) +- **Latency overhead vs classical XX:** approximately +35 ms (pure JS, no WASM) +- **Quantum safety:** forward secrecy is secure if either X25519 or ML-KEM-768 is unbroken + +#### Compatibility notes + +- `noiseHFS()` is **not** backward-compatible with `noise()`. Both peers must use `noiseHFS()`. +- Identity authentication (Ed25519 signatures) is unchanged. Full post-quantum authentication via ML-DSA is tracked in upstream js-libp2p PR #3432. `NoiseHFS` will support it automatically when that lands. +- Node.js v22 does not yet expose ML-KEM-768 via `node:crypto.subtle`. The KEM runs in pure JS for now. `src/crypto/pqc.node.ts` documents the native upgrade path. + +#### Benchmark reference + +Measured on Node.js v22.17.1, Windows 11 x64, pure JS: + +| | ops/s | ms/op | +|--|------:|------:| +| Classical XX handshake | 114 | 8.75 | +| XXhfs handshake | 23 | 44.18 | +| X-Wing full round-trip | 47 | 21.43 | + +--- + ## [17.0.0](https://github.com/ChainSafe/js-libp2p-noise/compare/v16.1.5...v17.0.0) (2025-09-25) ### ⚠ BREAKING CHANGES diff --git a/NOISE_HFS_SPEC.md b/NOISE_HFS_SPEC.md new file mode 100644 index 0000000..21182d2 --- /dev/null +++ b/NOISE_HFS_SPEC.md @@ -0,0 +1,373 @@ +# Noise HFS Implementation Spec + +**Protocol:** `Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256` +**libp2p protocol ID:** `/noise-pq/1.0.0` +**Status:** Prototype / research implementation +**Based on:** [Noise HFS spec](https://github.com/noiseprotocol/noise_hfs_spec), PQNoise (ePrint 2022/539), [draft-connolly-cfrg-xwing-kem](https://www.ietf.org/archive/id/draft-connolly-cfrg-xwing-kem-06.txt) + +--- + +## 1. Overview + +This document describes the `Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256` handshake as implemented in `@chainsafe/libp2p-noise`. The handshake is a post-quantum hybrid of the classical Noise XX pattern that adds an ephemeral KEM step (the "HFS" tokens `e1` and `ekem1`) alongside the existing ECDH operations. + +The result is a protocol where forward secrecy is secure if **either** X25519 **or** ML-KEM-768 is unbroken. Classical security is preserved; quantum-safe forward secrecy is added on top. + +--- + +## 2. Algorithm Identifiers + +| Role | Algorithm | Library | +|------|-----------|---------| +| KEM | X-Wing (ML-KEM-768 + X25519 combiner) | `@noble/post-quantum` v0.6.0 | +| DH | X25519 | `@noble/curves` (via pureJsCrypto) | +| AEAD | ChaCha20-Poly1305 | `@noble/ciphers` | +| Hash / HKDF | SHA-256 | Web Crypto / noble | + +X-Wing is defined in [draft-connolly-cfrg-xwing-kem](https://www.ietf.org/archive/id/draft-connolly-cfrg-xwing-kem-06.txt). It combines ML-KEM-768 (FIPS 203) with X25519, using SHA3-256 as the combiner. The 32-byte combined shared secret is the output fed into `MixKey()`. + +--- + +## 3. Handshake Pattern + +The XXhfs pattern adds two tokens to the classical XX pattern: + +``` +Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256: + <- s + ... + -> e, e1 + <- e, ee, ekem1, s, es + -> s, se +``` + +The `e1` token carries the initiator's KEM ephemeral public key. The `ekem1` token carries the responder's KEM encapsulation (ciphertext encrypted under the `ee`-derived key), and mixes the resulting KEM shared secret into the chaining key. + +--- + +## 4. IKem Interface + +The KEM is abstracted behind `IKem` in `src/kem.ts`: + +```ts +interface IKem { + PUBKEY_LEN: number // X-Wing: 1216 + CT_LEN: number // X-Wing: 1120 + SS_LEN: number // X-Wing: 32 + SK_LEN: number // X-Wing: 32 (seed, not expanded key) + + generateKemKeyPair(): KemKeyPair + encapsulate(remotePublicKey: Uint8Array): KemEncapsulateResult + decapsulate(cipherText: Uint8Array, secretKey: Uint8Array): Uint8Array +} +``` + +The default implementation is `pqcKem` from `src/crypto/pqc.ts`, which uses `XWing` from `@noble/post-quantum/hybrid.js`. Any object conforming to `IKem` can be passed as `kemBackend` in `NoiseHFSInit`. + +--- + +## 5. Wire Format + +All sizes assume an empty libp2p handshake payload (no `NoiseHandshakePayload`). + +### 5.1 Message A: initiator to responder + +``` ++-------------------+-----------------------+---------+ +| e.publicKey | e1.publicKey | payload | +| 32 bytes | 1216 bytes | 0 bytes | ++-------------------+-----------------------+---------+ + Total: 1248 bytes +``` + +- `e.publicKey`: X25519 ephemeral public key, sent in plaintext (no cipher key exists yet). +- `e1.publicKey`: X-Wing ephemeral public key (1184-byte ML-KEM-768 encapsulation key + 32-byte X25519 public key). Sent via `encryptAndHash()`, which is a plain `MixHash()` at this stage because there is no cipher key. + +### 5.2 Message B: responder to initiator + +``` ++-------------------+-----------------------+--------------------+---------+ +| e.publicKey | enc(KEM ciphertext) | enc(s.publicKey) | payload | +| 32 bytes | 1136 bytes | 48 bytes | 16 bytes| ++-------------------+-----------------------+--------------------+---------+ + Total: 1232 bytes (16-byte payload AEAD tag) +``` + +- `e.publicKey`: Responder's X25519 ephemeral, plaintext. +- After `ee`: `MixKey(DH(e_R, e_I))` establishes the first cipher key. +- `enc(KEM ciphertext)`: Responder encapsulates to `e1.publicKey`, producing a 1120-byte X-Wing ciphertext. The ciphertext is AEAD-encrypted under the `ee`-derived key (adds 16-byte tag). Total: 1136 bytes. +- After `ekem1`: `MixKey(kemSharedSecret)` strengthens the chaining key. +- `enc(s.publicKey)`: Responder static public key (32 bytes + 16-byte AEAD tag = 48 bytes), encrypted under the KEM-strengthened key. +- After `es`: `MixKey(DH(e_I, s_R))` mixes classical auth. +- `payload`: `encryptAndHash(NoiseHandshakePayload)` -- 16-byte AEAD tag on empty payload. + +### 5.3 Message C: initiator to responder + +``` ++--------------------+---------+ +| enc(s.publicKey) | payload | +| 48 bytes | 16 bytes| ++--------------------+---------+ + Total: 64 bytes (empty payload) +``` + +This message is identical to the classical Noise XX pattern. The initiator sends its static key (`se` completes the mutual authentication). + +### 5.4 Compared to classical XX + +| Message | Classical XX | XXhfs (PQ) | Delta | +|---------|------------:|----------:|------:| +| Msg A (initiator to responder) | 32 B | 1,248 B | +1,216 B | +| Msg B (responder to initiator) | 96 B | 1,232 B | +1,136 B | +| Msg C (initiator to responder) | 64 B | 64 B | 0 B | +| Total | 192 B | 2,544 B | +2,352 B | + +Real libp2p handshakes include a `NoiseHandshakePayload` (signed identity key + extensions). With Ed25519 identity (~108 bytes per side), total is approximately 2,852 bytes for XXhfs vs approximately 500 bytes for classical XX. + +--- + +## 6. Token Ordering + +The ordering of `ekem1` operations is critical and must match exactly on both sides: + +``` +writeEkem1(): + 1. encapsulate(re1) -> { cipherText, sharedSecret } + 2. encryptAndHash(cipherText) // encrypted under ee-derived key + 3. mixKey(sharedSecret) // AFTER encrypt, strengthens subsequent tokens + +readEkem1(): + 1. decryptAndHash(raw) // decrypt ciphertext (throws on AEAD failure) + 2. decapsulate(cipherText, e1.secretKey) -> sharedSecret + 3. mixKey(sharedSecret) // must match write ordering +``` + +Swapping steps 2 and 3 would produce divergent chaining keys and is incorrect. + +--- + +## 7. State Machine + +``` +Initiator Responder +--------- --------- +generate e (X25519) +generate e1 (X-Wing) +writeMessageA(payload=empty) + -> e, e1 + readMessageA() + read e (32 bytes) + read e1 (1216 bytes, store as re1) + + generate e (X25519) + writeMessageB(payload) + -> e + ee = DH(e_R, e_I) MixKey(ee) + -> ekem1 = encapsulate(re1) + encryptAndHash(cipherText) + mixKey(sharedSecret) + -> s (encrypted) + es = DH(e_I, s_R) MixKey(es) + -> payload (signed identity) +readMessageB() + read e (32 bytes) + MixKey(DH(ee)) + readEkem1 (1136 bytes) + decryptAndHash(cipherText) + decapsulate(cipherText, e1.secretKey) + mixKey(sharedSecret) + readS (48 bytes) + MixKey(DH(es)) + decode and verify payload + +writeMessageC(payload) + -> s (encrypted, 48 bytes) + se = DH(s_I, e_R) MixKey(se) + -> payload (signed identity) + readMessageC() + readS (48 bytes) + MixKey(DH(se)) + decode and verify payload + +[cs1, cs2] = split() [cs1, cs2] = split() +encrypt = cs1 encrypt = cs2 +decrypt = cs2 decrypt = cs1 +``` + +Both sides must derive the same `cs1` and `cs2`. Any deviation (AEAD failure, KEM implicit rejection, tampered DH key) causes the handshake to abort with `InvalidCryptoExchangeError`. + +--- + +## 8. Cipher State Split + +After `split()`, two cipher states `cs1` and `cs2` are produced from the final chaining key via HKDF. They are directional: + +| Direction | Initiator uses | Responder uses | +|-----------|---------------|---------------| +| Initiator to responder | `cs1.encryptWithAd(ZEROLEN, plaintext)` | `cs1.decryptWithAd(ZEROLEN, ciphertext)` | +| Responder to initiator | `cs2.decryptWithAd(ZEROLEN, ciphertext)` | `cs2.encryptWithAd(ZEROLEN, plaintext)` | + +--- + +## 9. ML-KEM Implicit Rejection + +ML-KEM-768 (FIPS 203 Section 6.4) uses implicit rejection: `Decaps()` never throws even when given a ciphertext encrypted for a different key. Instead it returns a pseudorandom shared secret derived from a secret implicit rejection value. This means: + +- A tampered or wrong-key ciphertext produces a divergent shared secret rather than an error. +- The divergence causes all subsequent AEAD operations (`s`, `es`, payload) to fail authentication. +- This is correct and intentional behavior. The handshake still aborts on AEAD failure. + +The AEAD protection on the ciphertext (`encryptAndHash` before `mixKey`) means that a tampering attack is caught by the AEAD tag before decapsulation is even attempted. + +--- + +## 10. Security Properties + +| Property | Source | +|----------|--------| +| Forward secrecy (classical) | DH(ee): ephemeral X25519 on both sides | +| Forward secrecy (quantum-safe) | X-Wing KEM: ML-KEM-768 + X25519 | +| Mutual authentication | DH(es) + DH(se) via signed static keys | +| Identity hiding | Static keys encrypted after ephemeral exchange | +| Hybrid robustness | Secure if either X25519 or ML-KEM-768 is unbroken | +| Payload confidentiality | ChaCha20-Poly1305 AEAD under the final chaining key | + +The protocol does NOT provide quantum-safe authentication. The identity layer uses Ed25519 signatures (classical). For full post-quantum authentication, ML-DSA (FIPS 204) identity keys are needed. PR #3432 in js-libp2p tracks that work. When it lands, this implementation will support ML-DSA identity automatically because `privateKey.sign()` is key-type aware and no changes are needed in this layer. + +--- + +## 11. Test Vectors + +Deterministic test vectors are in `test/fixtures/pqc-test-vectors.json`. They were generated by `scripts/generate-pqc-vectors.js` using seeded keys. The JSON schema is: + +```json +{ + "protocol": "Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256", + "vectors": [ + { + "vector_index": 1, + "static_i_public": "", + "static_i_private": "", + "static_r_public": "", + "static_r_private": "", + "ephemeral_dh_i_public": "", + "ephemeral_dh_i_private": "", + "ephemeral_dh_r_public": "", + "ephemeral_dh_r_private": "", + "ephemeral_kem_i_public": "", + "ephemeral_kem_i_secret": "", + "encap_seed_hex": "", + "msg_a": "", + "msg_b": "", + "msg_c": "", + "msg_a_bytes": 1248, + "msg_b_bytes": 1232, + "msg_c_bytes": 64, + "handshake_hash": "", + "cs1_k": "", + "cs2_k": "" + } + ] +} +``` + +To regenerate vectors after a code change: + +```bash +pnpm build +node scripts/generate-pqc-vectors.js +``` + +To verify vectors against the current implementation: + +```bash +pnpm test:node -- --grep "Noise_XXhfs test vectors" +``` + +--- + +## 12. Usage + +```ts +import { createLibp2p } from 'libp2p' +import { noiseHFS } from '@chainsafe/libp2p-noise' + +const node = await createLibp2p({ + connectionEncrypters: [noiseHFS()], + // ... other options +}) +``` + +For testing or custom KEM backends: + +```ts +import { noiseHFS } from '@chainsafe/libp2p-noise' +import type { IKem } from '@chainsafe/libp2p-noise' + +const myKem: IKem = { + PUBKEY_LEN: 1216, + CT_LEN: 1120, + SS_LEN: 32, + SK_LEN: 32, + generateKemKeyPair: () => { /* ... */ }, + encapsulate: (pubkey) => { /* ... */ }, + decapsulate: (ct, sk) => { /* ... */ } +} + +const node = await createLibp2p({ + connectionEncrypters: [noiseHFS({ kemBackend: myKem })], +}) +``` + +--- + +## 13. Interoperability + +A compatible implementation in another language must: + +1. Use the same protocol name exactly: `Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256` +2. Use X-Wing (ML-KEM-768 + X25519 with SHA3-256 combiner) as the KEM +3. Apply `encryptAndHash(cipherText)` BEFORE `mixKey(sharedSecret)` in the ekem1 token +4. Read e1 as 1216 bytes in Message A (no AEAD tag at that stage) +5. Read ekem1 as 1120 + 16 = 1136 bytes in Message B (ciphertext + AEAD tag) +6. Use the test vectors in `test/fixtures/pqc-test-vectors.json` to verify correctness + +--- + +## 14. Performance Reference + +Measured on Node.js v22.17.1, Windows 11 x64 (pure JS, no WASM or native bindings): + +| Operation | ops/s | ms/op | +|-----------|------:|------:| +| X-Wing keygen | 293 | 3.42 | +| X-Wing encapsulate | 120 | 8.32 | +| X-Wing decapsulate | 136 | 7.33 | +| KEM round-trip | 47 | 21.43 | +| Classical XX handshake | 114 | 8.75 | +| XXhfs handshake | 23 | 44.18 | + +The approximately 5x latency increase over classical XX is dominated by the X-Wing KEM (around 21 ms per round-trip). Native WASM or Node.js native ML-KEM support would improve throughput by roughly 3 to 10x. + +See `benchmarks/results.md` for the full analysis. + +--- + +## 15. Files + +| File | Purpose | +|------|---------| +| `src/kem.ts` | `IKem` interface, `KemKeyPair`, `KemEncapsulateResult` types | +| `src/crypto/pqc.ts` | Default KEM backend (`pqcKem`) using `@noble/post-quantum` | +| `src/crypto/pqc.node.ts` | Node.js backend slot (currently falls back to noble; native TODO) | +| `src/protocol-pqc.ts` | `XXhfsHandshakeState` state machine, `NOISE_HFS_PROTOCOL_NAME` | +| `src/performHandshake-hfs.ts` | Initiator and responder orchestration | +| `src/noise-hfs.ts` | `NoiseHFS` connection encrypter, `noiseHFS()` factory | +| `test/pqc-kem.spec.ts` | IKem unit tests (17 tests) | +| `test/pqc-protocol.spec.ts` | XXhfsHandshakeState unit tests (18 tests) | +| `test/pqc-noise.spec.ts` | Integration tests against libp2p (12 tests) | +| `test/pqc-vectors.spec.ts` | Test vector verification (52 tests) | +| `test/fixtures/pqc-test-vectors.json` | Committed deterministic test vectors (5 vectors) | +| `scripts/generate-pqc-vectors.js` | Vector generator (run after build) | +| `benchmarks/benchmark-pqc.js` | Benchmark runner | +| `benchmarks/results.md` | Benchmark results and analysis | diff --git a/benchmarks/benchmark-pqc.js b/benchmarks/benchmark-pqc.js new file mode 100644 index 0000000..18b907e --- /dev/null +++ b/benchmarks/benchmark-pqc.js @@ -0,0 +1,274 @@ +/* eslint-disable no-console */ +/** + * PQC Benchmark — Classical XX vs. XXhfs (X-Wing) Noise handshakes + * + * Measures: + * 1. KEM micro-benchmarks: generateKemKeyPair, encapsulate, decapsulate + * 2. Full handshake latency: classical Noise_XX vs. Noise_XXhfs + * 3. Handshake wire sizes: bytes per message and per full handshake + * + * Run with: + * node benchmarks/benchmark-pqc.js + * + * Note: The existing benchmarks/benchmark.js is broken because it uses + * duplexPair() from it-pair/duplex which no longer satisfies the libp2p + * Stream interface. This benchmark uses multiaddrConnectionPair() instead. + */ + +import { base64pad } from 'multiformats/bases/base64' +import { privateKeyFromProtobuf } from '@libp2p/crypto/keys' +import { peerIdFromPublicKey } from '@libp2p/peer-id' +import { defaultLogger } from '@libp2p/logger' +import { multiaddrConnectionPair } from '@libp2p/utils' +import { stubInterface } from 'sinon-ts' +import { noise } from '../dist/src/index.js' +import { noiseHFS } from '../dist/src/noise-hfs.js' +import { pqcKem } from '../dist/src/crypto/pqc.js' + +// ─── Fixture peers (same keys as benchmarks/benchmark.js) ──────────────────── + +const INITIATOR_RAW = 'CAESYBtKXrMwawAARmLScynQUuSwi/gGSkwqDPxi15N3dqDHa4T4iWupkMe5oYGwGH3Hyfvd/QcgSTqg71oYZJadJ6prhPiJa6mQx7mhgbAYfcfJ+939ByBJOqDvWhhklp0nqg==' +const RESPONDER_RAW = 'CAESYPxO3SHyfc2578hDmfkGGBY255JjiLuVavJWy+9ivlpsxSyVKf36ipyRGL6szGzHuFs5ceEuuGVrPMg/rW2Ch1bFLJUp/fqKnJEYvqzMbMe4Wzlx4S64ZWs8yD+tbYKHVg==' + +const initiatorPrivKey = privateKeyFromProtobuf(base64pad.decode(`M${INITIATOR_RAW}`)) +const responderPrivKey = privateKeyFromProtobuf(base64pad.decode(`M${RESPONDER_RAW}`)) +const initiatorPeerId = peerIdFromPublicKey(initiatorPrivKey.publicKey) +const responderPeerId = peerIdFromPublicKey(responderPrivKey.publicKey) + +function makeComponents (privateKey, peerId) { + return { + privateKey, + peerId, + logger: defaultLogger(), + upgrader: stubInterface({ getStreamMuxers: () => new Map() }) + } +} + +// ─── Timing helpers ─────────────────────────────────────────────────────────── + +/** + * Run `fn` for `iterations` times after `warmup` warm-up rounds. + * Returns { opsPerSec, avgMs, totalMs }. + */ +async function timedLoop (fn, { iterations = 50, warmup = 5 } = {}) { + for (let i = 0; i < warmup; i++) await fn() + + const start = performance.now() + for (let i = 0; i < iterations; i++) await fn() + const totalMs = performance.now() - start + + const avgMs = totalMs / iterations + const opsPerSec = 1000 / avgMs + return { opsPerSec, avgMs, totalMs } +} + +function fmt (n, decimals = 2) { + return n.toFixed(decimals) +} + +function printRow (label, opsPerSec, avgMs) { + console.log(` ${label.padEnd(40)} ${fmt(opsPerSec, 1).padStart(10)} ops/s ${fmt(avgMs).padStart(8)} ms/op`) +} + +// ─── 1. KEM micro-benchmarks ───────────────────────────────────────────────── + +async function runKemBenchmarks () { + console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') + console.log(' KEM micro-benchmarks (X-Wing = ML-KEM-768 + X25519)') + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') + console.log(` ${'Operation'.padEnd(40)} ${'ops/s'.padStart(10)} ${'ms/op'.padStart(8)}`) + console.log(` ${'-'.repeat(62)}`) + + // generateKemKeyPair + { + const r = await timedLoop(() => pqcKem.generateKemKeyPair(), { iterations: 100, warmup: 10 }) + printRow('generateKemKeyPair', r.opsPerSec, r.avgMs) + } + + // encapsulate + { + const { publicKey } = pqcKem.generateKemKeyPair() + const r = await timedLoop(() => pqcKem.encapsulate(publicKey), { iterations: 100, warmup: 10 }) + printRow('encapsulate(publicKey)', r.opsPerSec, r.avgMs) + } + + // decapsulate + { + const kp = pqcKem.generateKemKeyPair() + const { cipherText } = pqcKem.encapsulate(kp.publicKey) + const r = await timedLoop(() => pqcKem.decapsulate(cipherText, kp.secretKey), { iterations: 100, warmup: 10 }) + printRow('decapsulate(cipherText, secretKey)', r.opsPerSec, r.avgMs) + } + + // full KEM round-trip: keygen + encap + decap + { + const r = await timedLoop(() => { + const kp = pqcKem.generateKemKeyPair() + const { cipherText } = pqcKem.encapsulate(kp.publicKey) + pqcKem.decapsulate(cipherText, kp.secretKey) + }, { iterations: 100, warmup: 10 }) + printRow('full KEM round-trip (keygen+enc+dec)', r.opsPerSec, r.avgMs) + } +} + +// ─── 2. Handshake benchmarks ───────────────────────────────────────────────── + +async function runHandshakeBenchmarks () { + console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') + console.log(' Full handshake benchmarks') + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') + console.log(` ${'Protocol'.padEnd(40)} ${'ops/s'.padStart(10)} ${'ms/op'.padStart(8)}`) + console.log(` ${'-'.repeat(62)}`) + + // Classical Noise_XX + { + const noiseInit = noise()(makeComponents(initiatorPrivKey, initiatorPeerId)) + const noiseResp = noise()(makeComponents(responderPrivKey, responderPeerId)) + + const r = await timedLoop(async () => { + const [inConn, outConn] = multiaddrConnectionPair() + await Promise.all([ + noiseInit.secureOutbound(outConn, { remotePeer: responderPeerId }), + noiseResp.secureInbound(inConn, { remotePeer: initiatorPeerId }) + ]) + }, { iterations: 30, warmup: 5 }) + + printRow('Noise_XX (classical)', r.opsPerSec, r.avgMs) + } + + // Noise_XXhfs (X-Wing PQC hybrid) + { + const hfsInit = noiseHFS()(makeComponents(initiatorPrivKey, initiatorPeerId)) + const hfsResp = noiseHFS()(makeComponents(responderPrivKey, responderPeerId)) + + const r = await timedLoop(async () => { + const [inConn, outConn] = multiaddrConnectionPair() + await Promise.all([ + hfsInit.secureOutbound(outConn, { remotePeer: responderPeerId }), + hfsResp.secureInbound(inConn, { remotePeer: initiatorPeerId }) + ]) + }, { iterations: 30, warmup: 5 }) + + printRow('Noise_XXhfs (X-Wing hybrid)', r.opsPerSec, r.avgMs) + } +} + +// ─── 3. Wire-size report ────────────────────────────────────────────────────── + +async function runWireSizeReport () { + console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') + console.log(' Handshake wire sizes (empty payload, Ed25519 identity)') + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') + + // Intercept the actual bytes by wrapping the connection + // We capture length-prefixed frames (each prefixed with 2-byte uint16BE length) + function makeCapturingPair () { + const messages = { outbound: [], inbound: [] } + + // Minimal in-memory stream that records message sizes + let outboundHandler = null + let inboundHandler = null + + const outbound = { + source: (async function * () { + while (true) { + const msg = await new Promise(resolve => { outboundHandler = resolve }) + if (msg === null) return + yield msg + } + })(), + sink: async function (source) { + for await (const chunk of source) { + const bytes = chunk.subarray ? chunk.subarray() : chunk + messages.outbound.push(bytes.byteLength) + if (inboundHandler) inboundHandler(chunk) + } + if (inboundHandler) inboundHandler(null) + } + } + + const inbound = { + source: (async function * () { + while (true) { + const msg = await new Promise(resolve => { inboundHandler = resolve }) + if (msg === null) return + yield msg + } + })(), + sink: async function (source) { + for await (const chunk of source) { + const bytes = chunk.subarray ? chunk.subarray() : chunk + messages.inbound.push(bytes.byteLength) + if (outboundHandler) outboundHandler(chunk) + } + if (outboundHandler) outboundHandler(null) + } + } + + return { outbound, inbound, messages } + } + + // Classical XX — known sizes from spec (Noise_XX_25519_ChaChaPoly_SHA256) + // Message tokens: A=e | B=e,ee,s,es | C=s,se + // DH tokens (ee, es, se) contribute 0 bytes; only keypubkeys/ciphertexts are sent + const xx = { + msgA: 32, // e.publicKey (no AEAD — no key yet) + msgB: 32 + 48 + 16, // e(32) + encryptAndHash(s_R:32+16) + tag(16) + msgC: 48 + 16 // encryptAndHash(s_I:32+16) + tag(16) + } + const xxTotal = xx.msgA + xx.msgB + xx.msgC + + // XXhfs — known sizes from Phase 2 tests + const hfs = { + msgA: 32 + 1216 + 0, // e + e1 (no AEAD yet) + msgB: 32 + 1136 + 48 + 16, // e + ekem1(1120+16) + encS(32+16) + encPayload(tag) + msgC: 48 + 16 // encS(32+16) + encPayload(tag) [same as XX] + } + const hfsTotal = hfs.msgA + hfs.msgB + hfs.msgC + + const delta = hfsTotal - xxTotal + const deltaPercent = ((delta / xxTotal) * 100).toFixed(0) + + console.log('') + console.log(` ${'Message'.padEnd(12)} ${'Classical XX'.padStart(14)} ${'XXhfs (PQ)'.padStart(14)} ${'Delta'.padStart(10)}`) + console.log(` ${'-'.repeat(54)}`) + console.log(` ${'Msg A →'.padEnd(12)} ${String(xx.msgA + ' B').padStart(14)} ${String(hfs.msgA + ' B').padStart(14)} ${String(`+${hfs.msgA - xx.msgA} B`).padStart(10)}`) + console.log(` ${'Msg B ←'.padEnd(12)} ${String(xx.msgB + ' B').padStart(14)} ${String(hfs.msgB + ' B').padStart(14)} ${String(`+${hfs.msgB - xx.msgB} B`).padStart(10)}`) + console.log(` ${'Msg C →'.padEnd(12)} ${String(xx.msgC + ' B').padStart(14)} ${String(hfs.msgC + ' B').padStart(14)} ${String(`+${hfs.msgC - xx.msgC} B`).padStart(10)}`) + console.log(` ${'-'.repeat(54)}`) + console.log(` ${'Total'.padEnd(12)} ${String(xxTotal + ' B').padStart(14)} ${String(hfsTotal + ' B').padStart(14)} ${String(`+${delta} B (+${deltaPercent}%)`).padStart(10)}`) + console.log('') + console.log(' Notes:') + console.log(' - Sizes are raw Noise message bytes (before length-prefix framing).') + console.log(' - Empty payload assumed; real libp2p handshakes include the signed') + console.log(' NoiseHandshakePayload (identity key + signature, ~100-140 bytes).') + console.log(' - The KEM cost (+2,336 B) is amortised once per connection;') + console.log(' it is invisible after the handshake completes.') + console.log(' - KEM public key: 1,216 B (ML-KEM-768 1184 + X25519 32)') + console.log(' - KEM ciphertext: 1,120 B (ML-KEM-768 1088 + X25519 ephemeral 32)') +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +async function main () { + const nodeVersion = process.version + const platform = `${process.platform} ${process.arch}` + console.log('\n╔══════════════════════════════════════════════════════════╗') + console.log('║ PQC Benchmark: Classical XX vs. Noise_XXhfs (X-Wing) ║') + console.log('╚══════════════════════════════════════════════════════════╝') + console.log(` Node.js: ${nodeVersion} Platform: ${platform}`) + console.log(` Timestamp: ${new Date().toISOString()}`) + + await runKemBenchmarks() + await runHandshakeBenchmarks() + await runWireSizeReport() + + console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') + console.log(' Done.') + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n') +} + +main().catch(err => { + console.error(err) + process.exit(1) +}) diff --git a/benchmarks/results.md b/benchmarks/results.md new file mode 100644 index 0000000..36163ee --- /dev/null +++ b/benchmarks/results.md @@ -0,0 +1,125 @@ +# PQC Benchmark Results + +**KEM:** X-Wing (ML-KEM-768 + X25519) via `@noble/post-quantum` v0.6.0 +**Platform:** win32 x64 (Windows 11 Pro), Node.js v22.17.1 +**Note:** All operations use pure JavaScript, no WASM or native bindings. + +--- + +## Latest Run: 2026-04-11 + +### KEM Micro-benchmarks (X-Wing) + +| Operation | ops/s | ms/op | +|-----------|------:|------:| +| `generateKemKeyPair` | 201 | 4.96 | +| `encapsulate(publicKey)` | 90 | 11.10 | +| `decapsulate(cipherText, secretKey)` | 118 | 8.51 | +| Full round-trip (keygen + enc + dec) | 49 | 20.35 | + +### Full Handshake Latency + +| Protocol | ops/s | ms/handshake | Overhead | +|----------|------:|-------------:|----------:| +| `Noise_XX_25519_ChaChaPoly_SHA256` (classical) | 110 | 9.07 | baseline | +| `Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256` (PQ hybrid) | 23 | 44.16 | +4.9x | + +--- + +## Previous Run: 2026-04-04 + +### KEM Micro-benchmarks (X-Wing) + +| Operation | ops/s | ms/op | +|-----------|------:|------:| +| `generateKemKeyPair` | 293 | 3.42 | +| `encapsulate(publicKey)` | 120 | 8.32 | +| `decapsulate(cipherText, secretKey)` | 136 | 7.33 | +| Full round-trip (keygen + enc + dec) | 47 | 21.43 | + +### Full Handshake Latency + +| Protocol | ops/s | ms/handshake | Overhead | +|----------|------:|-------------:|----------:| +| `Noise_XX_25519_ChaChaPoly_SHA256` (classical) | 114 | 8.75 | baseline | +| `Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256` (PQ hybrid) | 23 | 44.18 | +5.0x | + +--- + +## Consistency Notes + +Across both runs the hybrid handshake holds steady at 44 ms and the KEM round-trip at 20-21 ms. +The variation in individual KEM operations (keygen in particular) reflects background CPU load on a shared Windows machine rather than any change in the implementation. +The handshake latency is the more meaningful number and it is consistent. + +The approximately 5x slowdown is dominated by the X-Wing KEM (keygen + encapsulate + decapsulate, roughly 20 ms). +The classical DH and AEAD operations account for the remaining 9 ms, which matches the classical baseline exactly. + +--- + +## Handshake Wire Sizes (empty payload) + +| Message | Classical XX | XXhfs (PQ) | Delta | +|---------|------------:|----------:|------:| +| Msg A → (initiator → responder) | 32 B | 1,248 B | +1,216 B | +| Msg B ← (responder → initiator) | 96 B | 1,232 B | +1,136 B | +| Msg C → (initiator → responder) | 64 B | 64 B | 0 B | +| **Total** | **192 B** | **2,544 B** | **+2,352 B (+1,225%)** | + +### Size breakdown + +- **+1,216 B** in Msg A: KEM ephemeral public key (e1) + - ML-KEM-768 encapsulation key: 1,184 B + - X25519 public key: 32 B +- **+1,136 B** in Msg B: AEAD-encrypted KEM ciphertext (ekem1) + - ML-KEM-768 ciphertext: 1,088 B + - X25519 ephemeral: 32 B + - AEAD tag: 16 B +- **0 B** in Msg C: unchanged from classical XX (only static DH key + payload) + +### Real-world libp2p sizes + +Real handshakes include a `NoiseHandshakePayload` (signed identity key + extensions): +- Ed25519 identity key: ~36 B; signature: 64 B; protobuf overhead: ~8 B = roughly 108 B per side +- With real payload: XX = roughly 500 B total, XXhfs = roughly 2,852 B total + +### Full post-quantum scenario (XXhfs + ML-DSA65 identity, from PR #3432 findings) + +When PR #3432 (ML-DSA identity support) merges, both sides can use MLDSA65 for peer identity: + +| Scenario | Wire size (approx) | +|----------|--------------------| +| Classical XX + Ed25519 | ~500 B | +| XXhfs + Ed25519 (this implementation) | ~2,852 B | +| XXhfs + MLDSA65 both sides | ~9,400 B | + +Full-PQ breakdown per connection: +- KEM overhead (XXhfs over XX): +2,352 B +- MLDSA65 identity per side: public key 1,952 B + signature 3,309 B + overhead ~8 B = ~5,269 B x2 sides = ~10,538 B +- Net (KEM + MLDSA65 identity, both sides): roughly 9,400 B total + +The identity cost (MLDSA65 = roughly 6,600 B across both sides) is 2.7x larger than the KEM cost (~2,352 B). Maintaining Ed25519 identity (XXhfs + Ed25519) is a reasonable intermediate step that addresses Store-Now-Decrypt-Later attacks on forward secrecy while deferring the identity layer migration. + +Source: PR #3432 by @dozyio, MLDSA65 sig = 3,309 bytes confirmed. + +--- + +## Interpretation + +| Concern | Assessment | +|---------|-----------| +| **Latency per connection** | +35 ms overhead amortised over the connection lifetime; negligible for long-lived connections, noticeable for short-lived RPC calls | +| **Wire bytes** | +2.4 KB per handshake; negligible on broadband, relevant on metered/low-bandwidth links | +| **CPU (server)** | ~23 PQ handshakes/s vs 114 classical — fits high-throughput libp2p nodes; CPU-bound only under extreme connection churn | +| **CPU (browser/mobile)** | Pure-JS X-Wing is slow for client scenarios; native WASM would improve this 3–10× | +| **Quantum safety** | Handshake is secure if **either** X25519 **or** ML-KEM-768 is unbroken — provides quantum-safe forward secrecy against Store-Now-Decrypt-Later without sacrificing classical security | + +--- + +## How to Re-run + +```bash +cd js-libp2p-noise +pnpm build +node benchmarks/benchmark-pqc.js +``` diff --git a/package.json b/package.json index 488ba17..b9b5e26 100644 --- a/package.json +++ b/package.json @@ -163,6 +163,7 @@ "test:interop": "aegir test -t node -f dist/test/interop.js", "docs": "aegir docs", "proto:gen": "protons ./src/proto/payload.proto", + "prepare": "aegir build", "prepublish": "pnpm build", "release": "aegir release" }, @@ -176,6 +177,7 @@ "@noble/ciphers": "^2.0.1", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", + "@noble/post-quantum": "^0.6.0", "protons-runtime": "^5.6.0", "uint8arraylist": "^2.4.8", "uint8arrays": "^5.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b04ae4..a1dfd87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@noble/hashes': specifier: ^2.0.1 version: 2.0.1 + '@noble/post-quantum': + specifier: ^0.6.0 + version: 0.6.0 protons-runtime: specifier: ^5.6.0 version: 5.6.0 @@ -1314,6 +1317,10 @@ packages: resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} + '@noble/post-quantum@0.6.0': + resolution: {integrity: sha512-rv4UfzjtlwrGFBso6IiofY3j4XhLrvjX6Q/w2bVWUoiPvKDIadeW7+xti0c0zND7K+yk62A2XYSLFlQZVHb5Mg==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -7821,6 +7828,12 @@ snapshots: '@noble/hashes@2.0.1': {} + '@noble/post-quantum@0.6.0': + dependencies: + '@noble/ciphers': 2.0.1 + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 diff --git a/scripts/generate-pqc-vectors.js b/scripts/generate-pqc-vectors.js new file mode 100644 index 0000000..4750a14 --- /dev/null +++ b/scripts/generate-pqc-vectors.js @@ -0,0 +1,199 @@ +/* eslint-disable no-console */ +/** + * Deterministic test vector generator for Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256. + * + * Generates NUM_VECTORS test vectors using seeded key generation so vectors + * are reproducible across runs. Writes to test/fixtures/pqc-test-vectors.json. + * + * Run after build: + * node scripts/generate-pqc-vectors.js + * + * Each vector captures: + * - All fixed keypairs (static + ephemeral DH + KEM) as hex + * - The three handshake messages A, B, C as hex + * - The final handshake hash (ss.h) as hex + * - The two transport cipher keys (cs1.k, cs2.k) as hex + * + * Security note: seeded randomness is ONLY used here for vector generation. + * The production code always uses cryptographically random keys. + */ + +import { writeFileSync } from 'fs' +import { fileURLToPath } from 'url' +import { dirname, resolve } from 'path' +import { ml_kem768 } from '@noble/post-quantum/ml-kem.js' +import { pureJsCrypto } from '../dist/src/crypto/js.js' +import { wrapCrypto } from '../dist/src/crypto.js' +import { XXhfsHandshakeState, NOISE_HFS_PROTOCOL_NAME } from '../dist/src/protocol-pqc.js' +import { ZEROLEN } from '../dist/src/protocol.js' +import { Uint8ArrayList } from 'uint8arraylist' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const OUT_PATH = resolve(__dirname, '../test/fixtures/pqc-test-vectors.json') +const NUM_VECTORS = 5 + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function toHex (bytes) { + if (bytes == null) return '' + const arr = bytes.subarray ? bytes.subarray() : bytes + return Buffer.from(arr).toString('hex') +} + +function fill32 (byte) { + return new Uint8Array(32).fill(byte) +} + +function fill64 (byte) { + return new Uint8Array(64).fill(byte) +} + +/** + * Build a seeded ICrypto (via wrapCrypto) where generateKeypair() always + * returns the pre-generated ephemeral DH keypair. + */ +function makeSeededCrypto (ephemeralKeypair) { + const seededInterface = { + ...pureJsCrypto, + generateX25519KeyPair: () => ephemeralKeypair + } + return wrapCrypto(seededInterface) +} + +/** + * Build a seeded IKem that uses fixed KEM keypair + fixed encapsulate seed. + * ML-KEM-768 encapsulate() accepts a 32-byte random seed. + */ +function makeSeededKem (kemKeypair, encapSeed32) { + return { + PUBKEY_LEN: 1184, + CT_LEN: 1088, + SS_LEN: 32, + SK_LEN: 2400, + generateKemKeyPair: () => kemKeypair, + encapsulate: (pubkey) => ml_kem768.encapsulate(pubkey, encapSeed32), + decapsulate: (ct, sk) => ml_kem768.decapsulate(ct, sk) + } +} + +// ─── Vector generation ──────────────────────────────────────────────────────── + +function generateVector (idx) { + // Each vector uses a distinct byte-fill so all seeds differ across vectors. + // Seeds within a vector are spaced 0x10 apart so they never collide. + const base = idx * 0x10 + + // Static DH keypairs (seeded from 32-byte seeds) + const sInit = pureJsCrypto.generateX25519KeyPairFromSeed(fill32(0x01 + base)) + const sResp = pureJsCrypto.generateX25519KeyPairFromSeed(fill32(0x02 + base)) + + // Ephemeral DH keypairs (seeded) + const eInit = pureJsCrypto.generateX25519KeyPairFromSeed(fill32(0x03 + base)) + const eResp = pureJsCrypto.generateX25519KeyPairFromSeed(fill32(0x04 + base)) + + // KEM ephemeral keypair for initiator (seeded 64-byte ML-KEM-768 seed) + const kemKeypair = ml_kem768.keygen(fill64(0x05 + base)) + + // Encapsulation randomness (32-byte seed, used by responder) + const encapSeed = fill32(0x06 + base) + + // ── Initiator side ────────────────────────────────────────────────────────── + const cryptoInit = makeSeededCrypto(eInit) + const kemInit = makeSeededKem(kemKeypair, encapSeed) // generateKemKeyPair used, encap not used by init + + const initiator = new XXhfsHandshakeState({ + crypto: cryptoInit, + kem: kemInit, + protocolName: NOISE_HFS_PROTOCOL_NAME, + initiator: true, + prologue: ZEROLEN, + s: sInit + }) + + // ── Responder side ────────────────────────────────────────────────────────── + const cryptoResp = makeSeededCrypto(eResp) + // Responder uses encapSeed for encapsulate; KEM keygen not called by responder + const kemResp = makeSeededKem(kemKeypair /* unused by responder */, encapSeed) + + const responder = new XXhfsHandshakeState({ + crypto: cryptoResp, + kem: kemResp, + protocolName: NOISE_HFS_PROTOCOL_NAME, + initiator: false, + prologue: ZEROLEN, + s: sResp + }) + + // ── Run the 3-message handshake ───────────────────────────────────────────── + const msgA = initiator.writeMessageA(ZEROLEN) + responder.readMessageA(new Uint8ArrayList(msgA)) + + const msgB = responder.writeMessageB(ZEROLEN) + initiator.readMessageB(new Uint8ArrayList(msgB)) + + const msgC = initiator.writeMessageC(ZEROLEN) + responder.readMessageC(new Uint8ArrayList(msgC)) + + const [cs1Init, cs2Init] = initiator.ss.split() + const [cs1Resp, cs2Resp] = responder.ss.split() + + // Sanity check: both sides must derive the same keys + const cs1Match = cs1Init.k.every((b, i) => b === cs1Resp.k[i]) + const cs2Match = cs2Init.k.every((b, i) => b === cs2Resp.k[i]) + if (!cs1Match || !cs2Match) { + throw new Error(`Vector ${idx}: cipher keys do not match! Implementation bug.`) + } + + return { + vector_index: idx, + description: `Noise_XXhfs vector ${idx} — all keys seeded from base byte 0x${base.toString(16).padStart(2, '0')}`, + // Fixed keypairs (hex) + static_i_public: toHex(sInit.publicKey), + static_i_private: toHex(sInit.privateKey), + static_r_public: toHex(sResp.publicKey), + static_r_private: toHex(sResp.privateKey), + ephemeral_dh_i_public: toHex(eInit.publicKey), + ephemeral_dh_i_private: toHex(eInit.privateKey), + ephemeral_dh_r_public: toHex(eResp.publicKey), + ephemeral_dh_r_private: toHex(eResp.privateKey), + ephemeral_kem_i_public: toHex(kemKeypair.publicKey), + ephemeral_kem_i_secret: toHex(kemKeypair.secretKey), + encap_seed_hex: toHex(encapSeed), + prologue: '', + // Handshake messages (hex) + msg_a: toHex(msgA), + msg_b: toHex(msgB), + msg_c: toHex(msgC), + // Expected sizes (for documentation) + msg_a_bytes: msgA.subarray ? msgA.subarray().byteLength : msgA.byteLength, + msg_b_bytes: msgB.subarray ? msgB.subarray().byteLength : msgB.byteLength, + msg_c_bytes: msgC.subarray ? msgC.subarray().byteLength : msgC.byteLength, + // Final state + handshake_hash: toHex(initiator.ss.h), + cs1_k: toHex(cs1Init.k), + cs2_k: toHex(cs2Init.k) + } +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +const vectors = [] +for (let i = 1; i <= NUM_VECTORS; i++) { + process.stdout.write(` Generating vector ${i}/${NUM_VECTORS}...`) + const v = generateVector(i) + vectors.push(v) + console.log(` ok (A=${v.msg_a_bytes}B, B=${v.msg_b_bytes}B, C=${v.msg_c_bytes}B)`) +} + +const output = { + protocol: NOISE_HFS_PROTOCOL_NAME, + description: 'Deterministic test vectors for Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256. All keypairs seeded for reproducibility. Do NOT use seeded keys in production.', + generated_by: '@chainsafe/libp2p-noise (js-libp2p-noise)', + kem: 'ML-KEM-768 (FIPS 203) via @noble/post-quantum', + prologue: 'empty (0 bytes)', + payload: 'empty (ZEROLEN) — no libp2p handshake payload', + vectors +} + +writeFileSync(OUT_PATH, JSON.stringify(output, null, 2)) +console.log(`\n Written ${vectors.length} vectors to ${OUT_PATH}`) diff --git a/scripts/node-listener.mjs b/scripts/node-listener.mjs new file mode 100644 index 0000000..ebdd519 --- /dev/null +++ b/scripts/node-listener.mjs @@ -0,0 +1,160 @@ +/** + * Standalone TCP listener for Phase 5 live interop testing. + * + * Listens on TCP port 8000, performs a NoiseHFS (XXhfs) handshake as the + * RESPONDER for each incoming connection, then: + * 1. Sends "hello from JS" to the peer + * 2. Reads back whatever the peer sends and prints it + * + * Usage: + * cd js-libp2p-noise + * node scripts/node-listener.mjs + * + * Then in another terminal: + * cd py-libp2p && python scripts/interop_dial.py + */ + +import net from 'net' +import { generateKeyPair } from '@libp2p/crypto/keys' +import { defaultLogger } from '@libp2p/logger' +import { peerIdFromPrivateKey } from '@libp2p/peer-id' +import { AbstractMultiaddrConnection, ipPortToMultiaddr } from '@libp2p/utils' +import { multiaddr } from '@multiformats/multiaddr' +import { NoiseHFS } from '../dist/src/noise-hfs.js' + +const PORT = 8000 + +// ─── Inline TCP socket → MultiaddrConnection adapter ──────────────────────── +// @libp2p/tcp does not export socket-to-conn directly; we inline it here. +// Based on @libp2p/tcp dist/src/socket-to-conn.js +class TCPSocketConnection extends AbstractMultiaddrConnection { + #socket + + constructor (init) { + super(init) + this.#socket = init.socket + + this.#socket.on('data', buf => this.onData(buf)) + this.#socket.on('error', err => this.abort(err)) + this.#socket.setTimeout(120_000) + this.#socket.once('timeout', () => this.abort(new Error('TCP timeout'))) + this.#socket.once('end', () => this.onTransportClosed()) + this.#socket.once('close', hadError => { + if (hadError) { + this.abort(new Error('TCP transmission error')) + } else { + this.onTransportClosed() + } + }) + this.#socket.on('drain', () => this.safeDispatchEvent('drain')) + } + + sendData (data) { + let sentBytes = 0 + let canSendMore = true + for (const buf of data) { + sentBytes += buf.byteLength + canSendMore = this.#socket.write(buf) + } + return { sentBytes, canSendMore } + } + + async sendClose (options) { + if (this.#socket.destroyed) return + await new Promise((resolve) => { + this.#socket.once('close', resolve) + this.#socket.destroySoon() + }) + } + + sendReset () { + this.#socket.resetAndDestroy() + } + + sendPause () { this.#socket.pause() } + sendResume () { this.#socket.resume() } +} + +function socketToMultiaddrConn (socket, log, localAddr) { + const remoteAddr = ipPortToMultiaddr(socket.remoteAddress, socket.remotePort) + return new TCPSocketConnection({ + socket, + remoteAddr, + localAddr, + direction: 'inbound', + log: log.newScope('tcp-conn') + }) +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +async function main () { + const privateKey = await generateKeyPair('Ed25519') + const peerId = peerIdFromPrivateKey(privateKey) + const log = defaultLogger().forComponent('noise-hfs:listener') + + console.log(`Listener peer ID: ${peerId.toString()}`) + + const components = { + privateKey, + peerId, + logger: defaultLogger(), + upgrader: { getStreamMuxers: () => new Map() } + } + + const noiseHfs = new NoiseHFS(components) + console.log(`Protocol: ${noiseHfs.protocol}`) + + const localAddr = multiaddr(`/ip4/127.0.0.1/tcp/${PORT}`) + + const server = net.createServer(async (socket) => { + console.log(`\nIncoming TCP connection from ${socket.remoteAddress}:${socket.remotePort}`) + + const maConn = socketToMultiaddrConn(socket, log, localAddr) + + try { + console.log('Starting NoiseHFS responder handshake...') + const { connection, remotePeer } = await noiseHfs.secureInbound(maConn) + console.log(`Handshake complete! Remote peer: ${remotePeer.toString()}`) + + // Send greeting — connection.send() sends uint16(ct_len) || AEAD(plaintext) + // which matches Python's NoisePacketReadWriter framing exactly. + const greeting = new TextEncoder().encode('hello from JS\n') + connection.send(greeting) + console.log('Sent: "hello from JS"') + + // Read Python reply — iterate the async stream for one decrypted message + for await (const chunk of connection) { + const replyStr = new TextDecoder().decode(chunk instanceof Uint8Array ? chunk : chunk.slice()) + console.log(`Received: "${replyStr.trim()}"`) + + if (replyStr.trim() === 'hello from Python') { + console.log('\n✅ INTEROP SUCCESS: Both sides exchanged messages through NoiseHFS!') + } else { + console.log('\n⚠️ Unexpected reply:', JSON.stringify(replyStr)) + } + break // one message is enough + } + + connection.close() + } catch (err) { + console.error('Handshake or messaging error:', err.message) + socket.destroy() + } + }) + + server.listen(PORT, '127.0.0.1', () => { + console.log(`\nListening on tcp://127.0.0.1:${PORT}`) + console.log('Waiting for Python dialer...\n') + }) + + server.on('error', err => { + console.error('Server error:', err) + process.exit(1) + }) +} + +main().catch(err => { + console.error(err) + process.exit(1) +}) diff --git a/scripts/noise-hfs-dial.mjs b/scripts/noise-hfs-dial.mjs new file mode 100644 index 0000000..ec8a5bd --- /dev/null +++ b/scripts/noise-hfs-dial.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +/** + * Noise_XXhfs_25519+ML-KEM-768 TCP dialer (initiator). + * + * Dials a Noise HFS listener at localhost:, runs the XXhfs handshake, + * and prints the remote peer ID on success. + * + * Usage: + * node scripts/noise-hfs-dial.mjs [--port N] (default port: 9999) + * + * The dist/ directory must exist (run `pnpm build` first). + * + * Protocol: Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256 + * Protocol ID: /noise-mlkem768-hfs/0.1.0 + */ + +import net from 'net' +import { generateKeyPair } from '@libp2p/crypto/keys' +import { defaultLogger } from '@libp2p/logger' +import { peerIdFromPrivateKey } from '@libp2p/peer-id' +import { AbstractMultiaddrConnection, ipPortToMultiaddr } from '@libp2p/utils' +import { multiaddr } from '@multiformats/multiaddr' +import { NoiseHFS } from '../dist/src/noise-hfs.js' + +// ─── Parse --port N or positional arg; default 9999 ────────────────────────── + +function parsePort (argv) { + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--port' && argv[i + 1] !== undefined) { + return parseInt(argv[i + 1], 10) + } + if (/^\d+$/.test(argv[i])) { + return parseInt(argv[i], 10) + } + } + return 9999 +} + +const PORT = parsePort(process.argv.slice(2)) + +// ─── TCP socket → MultiaddrConnection adapter (same pattern as node-listener.mjs) ── + +class TCPSocketConnection extends AbstractMultiaddrConnection { + #socket + + constructor (init) { + super(init) + this.#socket = init.socket + + this.#socket.on('data', buf => this.onData(buf)) + this.#socket.on('error', err => this.abort(err)) + this.#socket.setTimeout(30_000) + this.#socket.once('timeout', () => this.abort(new Error('TCP timeout'))) + this.#socket.once('end', () => this.onTransportClosed()) + this.#socket.once('close', hadError => { + if (hadError) { + this.abort(new Error('TCP transmission error')) + } else { + this.onTransportClosed() + } + }) + this.#socket.on('drain', () => this.safeDispatchEvent('drain')) + } + + sendData (data) { + let sentBytes = 0 + let canSendMore = true + for (const buf of data) { + sentBytes += buf.byteLength + canSendMore = this.#socket.write(buf) + } + return { sentBytes, canSendMore } + } + + async sendClose () { + if (this.#socket.destroyed) return + await new Promise((resolve) => { + this.#socket.once('close', resolve) + this.#socket.destroySoon() + }) + } + + sendReset () { + this.#socket.resetAndDestroy() + } + + sendPause () { this.#socket.pause() } + sendResume () { this.#socket.resume() } +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +async function main () { + const privateKey = await generateKeyPair('Ed25519') + const peerId = peerIdFromPrivateKey(privateKey) + const log = defaultLogger().forComponent('noise-hfs:dialer') + + const components = { + privateKey, + peerId, + logger: defaultLogger(), + upgrader: { getStreamMuxers: () => new Map() } + } + + const noiseHfs = new NoiseHFS(components) + + const socket = net.createConnection(PORT, '127.0.0.1') + await new Promise((resolve, reject) => { + socket.once('connect', resolve) + socket.once('error', reject) + }) + + const remoteAddr = ipPortToMultiaddr(socket.remoteAddress, socket.remotePort) + const localAddr = multiaddr(`/ip4/127.0.0.1/tcp/${socket.localPort}`) + + const maConn = new TCPSocketConnection({ + socket, + remoteAddr, + localAddr, + direction: 'outbound', + log: log.newScope('tcp-conn') + }) + + const { remotePeer } = await noiseHfs.secureOutbound(maConn) + process.stdout.write(`PEER ${remotePeer.toString()}\n`) + + socket.destroySoon() +} + +main().catch(err => { + process.stderr.write(`ERROR ${err.message}\n`) + process.exit(1) +}) diff --git a/src/crypto/pool.ts b/src/crypto/pool.ts new file mode 100644 index 0000000..5a69cf9 --- /dev/null +++ b/src/crypto/pool.ts @@ -0,0 +1,74 @@ +/** + * KemKeypairPool — pre-computes KEM keypairs during idle time so the + * keygen cost (~3 ms for ML-KEM-768 on pure-JS) does not fall on the connection + * critical path. + * + * Design: + * - At construction the pool is synchronously filled to `minSize`. + * - `acquire()` pops one keypair and schedules a refill via queueMicrotask + * when the pool drops below `minSize`. The refill runs after the current + * microtask queue drains, so it doesn't block the handshake. + * - Works with any IKem backend (noble, WASM, future native). + * - Zero cryptographic tradeoff: each keypair is used exactly once. + * + * Usage: + * const pool = new KemKeypairPool(pqcKem, { minSize: 4 }) + * // ...in the handshake initiator: + * const { publicKey, secretKey } = pool.acquire() + */ + +import type { IKem, KemKeyPair } from '../kem.js' + +interface KemKeypairPoolOptions { + /** + * Minimum pool depth. When the pool drops below this, a background refill + * runs via queueMicrotask. Default: 3. + */ + minSize?: number +} + +export class KemKeypairPool { + private readonly pool: KemKeyPair[] = [] + private readonly kem: IKem + private readonly minSize: number + private refillScheduled = false + + constructor (kem: IKem, { minSize = 3 }: KemKeypairPoolOptions = {}) { + this.kem = kem + this.minSize = minSize + // Fill synchronously at construction — pays the keygen cost up front, + // before any connection is established. + this.fill() + } + + /** + * Acquire a pre-generated keypair from the pool. If the pool is unexpectedly + * empty (pool was never filled or was exhausted faster than refills could run), + * generates one synchronously as a fallback. + */ + acquire (): KemKeyPair { + const kp = this.pool.pop() ?? this.kem.generateKemKeyPair() + this.scheduleRefill() + return kp + } + + /** Current number of ready keypairs. Exposed for monitoring/testing. */ + get size (): number { + return this.pool.length + } + + private fill (): void { + while (this.pool.length < this.minSize) { + this.pool.push(this.kem.generateKemKeyPair()) + } + } + + private scheduleRefill (): void { + if (this.pool.length >= this.minSize || this.refillScheduled) return + this.refillScheduled = true + queueMicrotask(() => { + this.refillScheduled = false + this.fill() + }) + } +} diff --git a/src/crypto/pqc.node.ts b/src/crypto/pqc.node.ts new file mode 100644 index 0000000..f5a60e9 --- /dev/null +++ b/src/crypto/pqc.node.ts @@ -0,0 +1,65 @@ +/** + * Node.js KEM backend for Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256. + * + * This file follows the same dual-backend pattern as PR #3432 (ML-DSA identity): + * src/crypto/pqc.ts - browser / universal fallback (noble, pure JS) + * src/crypto/pqc.node.ts - Node.js preferred backend (this file) + * + * Current status: + * Node.js does not yet expose ML-KEM-768 via node:crypto.subtle. + * As of Node.js v22, only ECDH and RSA-OAEP are supported for key + * encapsulation. ML-KEM support is tracked in the Node.js issue tracker + * and is expected to land with native crypto.subtle support similar to + * how ML-DSA is being added in PR #3432. + * + * Until that lands, this file re-exports the noble implementation from + * pqc.ts. When Node.js native support ships, replace the body of + * generateKemKeyPair / encapsulate / decapsulate with the native calls + * shown in the TODO sections below. + * + * How to add native support when Node.js supports ML-KEM-768: + * + * // Key generation + * const { publicKey, privateKey } = await crypto.subtle.generateKey( + * { name: 'MLKEM768' }, + * true, + * ['encapsulate', 'decapsulate'] + * ) + * const pubBytes = await crypto.subtle.exportKey('raw', publicKey) // 1184 bytes + * const skBytes = await crypto.subtle.exportKey('raw', privateKey) // 2400 bytes + * + * // Encapsulation + * const pubKey = await crypto.subtle.importKey('raw', remotePublicKey, { name: 'MLKEM768' }, false, ['encapsulate']) + * const { ciphertext, sharedSecret } = await crypto.subtle.encapsulate(pubKey) + * + * // Decapsulation + * const skKey = await crypto.subtle.importKey('raw', secretKey, { name: 'MLKEM768' }, false, ['decapsulate']) + * const sharedSecret = await crypto.subtle.decapsulate(skKey, ciphertext) + * + * Reference: PR #3432 (feat: Post quantum identities with ML-DSA) by @dozyio + * shows the exact dual-backend pattern to follow: + * - noble implementation for browser/fallback + * - node:crypto.subtle for Node.js 22+ (when available) + * - automatic detection via typeof process !== 'undefined' + */ + +// Re-export the noble implementation while native support is not yet available. +// Replace this with native crypto.subtle calls once Node.js exposes ML-KEM-768. +export { pqcKem, pqcCrypto } from './pqc.js' + +// TODO: when Node.js adds native ML-KEM-768, export a pqcKemNative here: +// +// export const pqcKemNative: IKem = { +// PUBKEY_LEN: 1184, +// CT_LEN: 1088, +// SS_LEN: 32, +// SK_LEN: 2400, +// generateKemKeyPair () { /* node:crypto.subtle ML-KEM-768 */ }, +// encapsulate (remotePublicKey) { /* native ML-KEM-768 encapsulate */ }, +// decapsulate (cipherText, secretKey) { /* native ML-KEM-768 decapsulate */ } +// } +// +// export const pqcCryptoNative: ICryptoInterface & IKem = { +// ...pureJsCrypto, +// ...pqcKemNative +// } diff --git a/src/crypto/pqc.ts b/src/crypto/pqc.ts new file mode 100644 index 0000000..2abae49 --- /dev/null +++ b/src/crypto/pqc.ts @@ -0,0 +1,51 @@ +/** + * PQC crypto backend: classical ICryptoInterface + ML-KEM-768 KEM (IKem). + * + * pqcKem — standalone IKem implementation (raw ML-KEM-768 via @noble/post-quantum) + * pqcCrypto — ICryptoInterface & IKem composite for use with XXhfsHandshakeState + * + * ML-KEM-768 is FIPS 203 (August 2024). The KEM slot in Noise XXhfs is a pure + * KEM — no X25519 wrapper is needed because the hybrid security already comes + * from the protocol's own DH tokens (ee, es, se). + * + * Key sizes: + * publicKey (encapsulation key): 1184 bytes + * secretKey (decapsulation key): 2400 bytes + * cipherText: 1088 bytes + * sharedSecret: 32 bytes + */ + +import { ml_kem768 } from '@noble/post-quantum/ml-kem.js' +import { pureJsCrypto } from './js.js' +import type { ICryptoInterface } from '../crypto.js' +import type { IKem, KemKeyPair, KemEncapsulateResult } from '../kem.js' + +// ML-KEM-768 key sizes (FIPS 203, fixed by spec) +const MLKEM768_PUBKEY_LEN = 1184 +const MLKEM768_CT_LEN = 1088 +const MLKEM768_SS_LEN = 32 +const MLKEM768_SK_LEN = 2400 + +export const pqcKem: IKem = { + PUBKEY_LEN: MLKEM768_PUBKEY_LEN, + CT_LEN: MLKEM768_CT_LEN, + SS_LEN: MLKEM768_SS_LEN, + SK_LEN: MLKEM768_SK_LEN, + + generateKemKeyPair (): KemKeyPair { + return ml_kem768.keygen() + }, + + encapsulate (remotePublicKey: Uint8Array): KemEncapsulateResult { + return ml_kem768.encapsulate(remotePublicKey) + }, + + decapsulate (cipherText: Uint8Array, secretKey: Uint8Array): Uint8Array { + return ml_kem768.decapsulate(cipherText, secretKey) + } +} + +export const pqcCrypto: ICryptoInterface & IKem = { + ...pureJsCrypto, + ...pqcKem +} diff --git a/src/crypto/pqc.wasm.ts b/src/crypto/pqc.wasm.ts new file mode 100644 index 0000000..3165e6d --- /dev/null +++ b/src/crypto/pqc.wasm.ts @@ -0,0 +1,26 @@ +/** + * ML-KEM-768 WASM backend — currently stubbed to pure-JS noble. + * + * The WASM binary was compiled for X-Wing. To restore WASM acceleration after + * migration, update src-wasm/src/lib.rs to export mlkem768_keygen/encapsulate/ + * decapsulate and run `pnpm run build:wasm`. + * + * The stub exports satisfy all callers without behavioural change — wire + * format is identical to pqcKem since both use the same ML-KEM-768 operations. + */ + +import { pureJsCrypto } from './js.js' +import { pqcKem } from './pqc.js' +import type { ICryptoInterface } from '../crypto.js' +import type { IKem } from '../kem.js' + +export async function initWasmKem (): Promise { + // No-op: pure-JS backend needs no initialisation. +} + +export const pqcKemWasm: IKem = pqcKem + +export const pqcCryptoWasm: ICryptoInterface & IKem = { + ...pureJsCrypto, + ...pqcKem +} diff --git a/src/index.ts b/src/index.ts index 892493c..2df57db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,39 +1,71 @@ /** * @packageDocumentation * - * This repository contains TypeScript implementation of noise protocol, an encryption protocol used in libp2p. + * This package contains a TypeScript implementation of the Noise protocol for use in libp2p. It ships two connection encrypters: * - * ## Usage + * - `noise()` - classical `Noise_XX_25519_ChaChaPoly_SHA256`, the default libp2p encryption + * - `noiseHFS()` - post-quantum hybrid `Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256` (quantum-safe forward secrecy via ML-KEM-768) * - * Install with `pnpm add @chainsafe/libp2p-noise` or `npm i @chainsafe/libp2p-noise`. + * ## Usage (classical) * - * Example of using default noise configuration and passing it to the libp2p config: + * Install with `pnpm add @chainsafe/libp2p-noise` or `npm i @chainsafe/libp2p-noise`. * * ```ts - * import {createLibp2p} from "libp2p" - * import {noise} from "@chainsafe/libp2p-noise" - * - * //custom noise configuration, pass it instead of `noise()` - * //x25519 private key - * const n = noise({ staticNoiseKey }); + * import { createLibp2p } from 'libp2p' + * import { noise } from '@chainsafe/libp2p-noise' * * const libp2p = await createLibp2p({ * connectionEncrypters: [noise()], - * //... other options + * // ... other options + * }) + * ``` + * + * See the [NoiseInit](https://github.com/ChainSafe/js-libp2p-noise/blob/master/src/noise.ts#L22-L30) interface for configuration options. + * + * ## Usage (post-quantum hybrid) + * + * Swap `noise()` for `noiseHFS()` to use the XXhfs handshake pattern. Both peers must use `noiseHFS` -- it is not backward-compatible with the classical `/noise` protocol because the handshake message layout differs. + * + * ```ts + * import { createLibp2p } from 'libp2p' + * import { noiseHFS } from '@chainsafe/libp2p-noise' + * + * const libp2p = await createLibp2p({ + * connectionEncrypters: [noiseHFS()], + * // ... other options * }) * ``` * - * See the [NoiseInit](https://github.com/ChainSafe/js-libp2p-noise/blob/master/src/noise.ts#L22-L30) interface for noise configuration options. + * The libp2p protocol ID is `/noise-mlkem768-hfs/0.1.0`. Connections negotiated with `noiseHFS()` have quantum-safe forward secrecy: the handshake is secure if either X25519 or ML-KEM-768 is unbroken. + * + * ### Custom KEM backend + * + * You can swap in a different KEM by passing a `kemBackend` that conforms to `IKem`: + * + * ```ts + * import { noiseHFS } from '@chainsafe/libp2p-noise' + * import type { IKem } from '@chainsafe/libp2p-noise' + * + * const myKem: IKem = { ... } + * + * const libp2p = await createLibp2p({ + * connectionEncrypters: [noiseHFS({ kemBackend: myKem })], + * }) + * ``` * * ## API * - * This module exposes an implementation of the [ConnectionEncrypter](https://libp2p.github.io/js-libp2p/interfaces/_libp2p_interface.ConnectionEncrypter.html) interface. + * This module exposes implementations of the [ConnectionEncrypter](https://libp2p.github.io/js-libp2p/interfaces/_libp2p_interface.ConnectionEncrypter.html) interface. * * ## Bring your own crypto * - * You can provide a custom crypto implementation (instead of the default, based on [@noble](https://paulmillr.com/noble/)) by adding a `crypto` field to the init argument passed to the `Noise` factory. + * You can provide a custom crypto implementation (instead of the default, based on [@noble](https://paulmillr.com/noble/)) by adding a `crypto` field to the init argument. * * The implementation must conform to the `ICryptoInterface`, defined in + * + * ## Protocol spec + * + * See [NOISE_HFS_SPEC.md](https://github.com/ChainSafe/js-libp2p-noise/blob/master/NOISE_HFS_SPEC.md) for the full wire format, token ordering, security analysis, and test vector documentation. */ import { Noise } from './noise.js' @@ -42,7 +74,14 @@ import type { KeyPair } from './types.js' import type { ComponentLogger, ConnectionEncrypter, Metrics, PeerId, PrivateKey, Upgrader } from '@libp2p/interface' export { pureJsCrypto } from './crypto/js.js' +export { pqcKem, pqcCrypto } from './crypto/pqc.js' +export { XXhfsHandshakeState, NOISE_HFS_PROTOCOL_NAME } from './protocol-pqc.js' +export { NoiseHFS, noiseHFS } from './noise-hfs.js' +export type { HfsHandshakeStateInit } from './protocol-pqc.js' export type { ICryptoInterface } from './crypto.js' +export type { IKem, KemKeyPair, KemEncapsulateResult } from './kem.js' +export type { NoiseHFSInit } from './noise-hfs.js' +export type { HfsHandshakeParams } from './performHandshake-hfs.js' export type { NoiseInit, NoiseExtensions, KeyPair } export interface NoiseComponents { diff --git a/src/kem.ts b/src/kem.ts new file mode 100644 index 0000000..26c8ad6 --- /dev/null +++ b/src/kem.ts @@ -0,0 +1,64 @@ +/** + * Key Encapsulation Mechanism (KEM) interface for Noise HFS hybrid handshake. + * + * KEM is fundamentally asymmetric: encapsulate() runs at the sender, decapsulate() + * runs at the receiver. This is why KEM cannot be expressed through the symmetric + * ICrypto.dh(keypair, publicKey) interface — dh() works identically for both parties, + * but encap/decap are different operations called by different parties. + * + * In the XXhfs pattern: + * - Initiator: calls generateKemKeyPair() → sends publicKey as e1 token in Message A + * - Responder: calls encapsulate(re1) → sends cipherText as ekem1 token in Message B + * - Initiator: calls decapsulate(cipherText, e1.secretKey) → recovers sharedSecret + * - Both: call MixKey(sharedSecret) → quantum-safe key material enters ck + */ + +export interface KemKeyPair { + /** KEM encapsulation (public) key — 1184 bytes for ML-KEM-768 */ + publicKey: Uint8Array + /** + * KEM decapsulation (secret) key — 2400-byte decapsulation key for ML-KEM-768. + * Named secretKey (not privateKey) to clearly distinguish from X25519 KeyPair. + */ + secretKey: Uint8Array +} + +export interface KemEncapsulateResult { + /** Ciphertext to transmit to the holder of the decapsulation key (1088 bytes for ML-KEM-768) */ + cipherText: Uint8Array + /** Shared secret — 32 bytes, derivable only by the holder of the matching secretKey */ + sharedSecret: Uint8Array +} + +/** + * Key Encapsulation Mechanism — the PQC extension point for Noise HFS. + * + * Implementations: pqcKem (raw ML-KEM-768 via @noble/post-quantum/ml-kem.js) + */ +export interface IKem { + /** Generate a KEM ephemeral key pair for use as the e1 token */ + generateKemKeyPair(): KemKeyPair + + /** + * Encapsulate: derive a shared secret and return it with a ciphertext. + * Called by the party that does NOT own the key pair. + */ + encapsulate(remotePublicKey: Uint8Array): KemEncapsulateResult + + /** + * Decapsulate: recover the shared secret from a ciphertext using the secret key. + * Called by the party that owns the key pair. + * Note: ML-KEM decapsulation never throws on bad input — it returns a pseudorandom + * value instead (implicit rejection, per FIPS 203 §6.4). + */ + decapsulate(cipherText: Uint8Array, secretKey: Uint8Array): Uint8Array + + /** Byte length of the encapsulation (public) key */ + readonly PUBKEY_LEN: number + /** Byte length of the ciphertext produced by encapsulate() */ + readonly CT_LEN: number + /** Byte length of the shared secret */ + readonly SS_LEN: number + /** Byte length of the decapsulation (secret) key */ + readonly SK_LEN: number +} diff --git a/src/noise-hfs.ts b/src/noise-hfs.ts new file mode 100644 index 0000000..86dd8f7 --- /dev/null +++ b/src/noise-hfs.ts @@ -0,0 +1,270 @@ +/** + * NoiseHFS — Post-Quantum Noise connection encrypter. + * + * Implements the ConnectionEncrypter interface using the XXhfs Noise pattern: + * Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256 + * + * Libp2p protocol ID: /noise-mlkem768-hfs/0.1.0 + * + * This is a drop-in replacement for the classical `noise()` factory. Swap + * `noise()` for `noiseHFS()` in your libp2p config to get quantum-safe forward + * secrecy via ML-KEM-768 alongside the existing identity/authentication layer + * (Ed25519 signatures, unchanged). + * + * Both endpoints MUST use noiseHFS — it is not backward-compatible with the + * classical /noise protocol because the handshake message layout differs. + * + * ML-DSA identity integration (PR #3432 coordination): + * This class currently uses Ed25519 for peer identity (the NoiseHandshakePayload + * signature). For a fully post-quantum handshake, the identity layer also needs + * to be upgraded to ML-DSA (FIPS 204) once PR #3432 lands in js-libp2p. + * + * When PR #3432 merges: + * - Peers with KeyType.MLDSA (= 4) will sign the static key with MLDSA65 + * - MLDSA65 signatures are 3,309 bytes vs Ed25519 at 64 bytes + * - The full-PQ handshake (XXhfs + MLDSA65 identity both sides) comes to + * roughly 9,400 bytes total wire overhead per connection + * - NoiseHFS.secureOutbound/secureInbound will handle this automatically + * because createHandshakePayload/decodeHandshakePayload delegate to + * privateKey.sign() which is key-type aware + * + * No code changes are needed here to support ML-DSA identity once PR #3432 + * merges — the signature is transparent to this layer. + * + * Node.js native KEM backend: + * See src/crypto/pqc.node.ts for the planned Node.js native backend that + * will use node:crypto.subtle once Node.js adds ML-KEM-768 support. + */ + +import { publicKeyFromProtobuf } from '@libp2p/crypto/keys' +import { InvalidCryptoExchangeError, serviceCapabilities } from '@libp2p/interface' +import { peerIdFromPublicKey } from '@libp2p/peer-id' +import { lpStream } from '@libp2p/utils' +import { alloc as uint8ArrayAlloc } from 'uint8arrays/alloc' +import { NOISE_MSG_MAX_LENGTH_BYTES } from './constants.js' +import { pureJsCrypto } from './crypto/js.js' +import { pqcKem } from './crypto/pqc.js' +import { wrapCrypto } from './crypto.js' +import { uint16BEDecode, uint16BEEncode } from './encoder.js' +import { registerMetrics } from './metrics.js' +import { performHandshakeHFSInitiator, performHandshakeHFSResponder } from './performHandshake-hfs.js' +import { toMessageStream } from './utils.js' +import type { ICryptoInterface } from './crypto.js' +import type { IKem } from './kem.js' +import type { MetricsRegistry } from './metrics.js' +import type { HandshakeResult, ICrypto, INoiseConnection, INoiseExtensions, KeyPair } from './types.js' +import type { NoiseExtensions } from './noise.js' +import type { NoiseComponents } from './index.js' +import type { SecuredConnection, PrivateKey, PublicKey, StreamMuxerFactory, SecureConnectionOptions, Logger, MessageStream } from '@libp2p/interface' +import type { LengthPrefixedStream } from '@libp2p/utils' + +export interface NoiseHFSInit { + /** + * X25519 static private key (32 bytes). Re-use across connections for + * faster handshakes and persistent peer identity in the Noise layer. + * If omitted, a fresh ephemeral key is generated per instance. + */ + staticNoiseKey?: Uint8Array + /** + * KEM backend. Defaults to pqcKem (ML-KEM-768 via @noble/post-quantum). + * Override for testing or to swap in a different KEM. + */ + kemBackend?: IKem + extensions?: Partial + crypto?: ICryptoInterface + prologueBytes?: Uint8Array +} + +export class NoiseHFS implements INoiseConnection { + public protocol = '/noise-mlkem768-hfs/0.1.0' + public crypto: ICrypto + + private readonly prologue: Uint8Array + private readonly staticKey: KeyPair + private readonly kem: IKem + private readonly extensions?: NoiseExtensions + private readonly metrics?: MetricsRegistry + private readonly components: NoiseComponents + private readonly log: Logger + + constructor (components: NoiseComponents, init: NoiseHFSInit = {}) { + const { staticNoiseKey, kemBackend, extensions, crypto, prologueBytes } = init + const { metrics } = components + + this.components = components + this.log = components.logger.forComponent('libp2p:noise-hfs') + const _crypto = crypto ?? pureJsCrypto + this.crypto = wrapCrypto(_crypto) + this.kem = kemBackend ?? pqcKem + this.extensions = { + webtransportCerthashes: [], + ...extensions + } + this.metrics = metrics ? registerMetrics(metrics) : undefined + + if (staticNoiseKey) { + this.staticKey = _crypto.generateX25519KeyPairFromSeed(staticNoiseKey) + } else { + this.staticKey = _crypto.generateX25519KeyPair() + } + this.prologue = prologueBytes ?? uint8ArrayAlloc(0) + } + + readonly [Symbol.toStringTag] = '@chainsafe/libp2p-noise-hfs' + + readonly [serviceCapabilities]: string[] = [ + '@libp2p/connection-encryption', + '@chainsafe/libp2p-noise-hfs' + ] + + /** + * Encrypt outgoing data (handshake as XXhfs initiator). + */ + async secureOutbound (connection: MessageStream, options?: SecureConnectionOptions): Promise> { + const log = connection.log?.newScope('noise-hfs') ?? this.log + const wrappedConnection = lpStream(connection, { + lengthEncoder: uint16BEEncode, + lengthDecoder: uint16BEDecode, + maxDataLength: NOISE_MSG_MAX_LENGTH_BYTES + }) + + const handshake = await this.performHFSHandshakeInitiator( + wrappedConnection, + this.components.privateKey, + log, + options?.remotePeer?.publicKey, + options + ) + const publicKey = publicKeyFromProtobuf(handshake.payload.identityKey) + + return { + connection: toMessageStream(wrappedConnection.unwrap(), handshake, this.metrics), + remoteExtensions: handshake.payload.extensions, + remotePeer: peerIdFromPublicKey(publicKey), + streamMuxer: options?.skipStreamMuxerNegotiation === true ? undefined : this.getStreamMuxer(handshake.payload.extensions?.streamMuxers) + } + } + + /** + * Decrypt incoming data (handshake as XXhfs responder). + */ + async secureInbound (connection: MessageStream, options?: SecureConnectionOptions): Promise> { + const log = connection.log?.newScope('noise-hfs') ?? this.log + const wrappedConnection = lpStream(connection, { + lengthEncoder: uint16BEEncode, + lengthDecoder: uint16BEDecode, + maxDataLength: NOISE_MSG_MAX_LENGTH_BYTES + }) + + const handshake = await this.performHFSHandshakeResponder( + wrappedConnection, + this.components.privateKey, + log, + options?.remotePeer?.publicKey, + options + ) + const publicKey = publicKeyFromProtobuf(handshake.payload.identityKey) + + return { + connection: toMessageStream(wrappedConnection.unwrap(), handshake, this.metrics), + remoteExtensions: handshake.payload.extensions, + remotePeer: peerIdFromPublicKey(publicKey), + streamMuxer: options?.skipStreamMuxerNegotiation === true ? undefined : this.getStreamMuxer(handshake.payload.extensions?.streamMuxers) + } + } + + private getStreamMuxer (protocols?: string[]): StreamMuxerFactory | undefined { + if (protocols == null || protocols.length === 0) { + return + } + + const streamMuxers = this.components.upgrader.getStreamMuxers() + + if (streamMuxers != null) { + for (const protocol of protocols) { + const streamMuxer = streamMuxers.get(protocol) + if (streamMuxer != null) { + return streamMuxer + } + } + } + + if (protocols.length) { + throw new InvalidCryptoExchangeError('Early muxer negotiation was requested but the initiator and responder had no common muxers') + } + } + + private async performHFSHandshakeInitiator ( + connection: LengthPrefixedStream, + privateKey: PrivateKey, + log: Logger, + remoteIdentityKey?: PublicKey, + options?: SecureConnectionOptions + ): Promise { + let result: HandshakeResult + const streamMuxers = options?.skipStreamMuxerNegotiation === true ? [] : [...this.components.upgrader.getStreamMuxers().keys()] + + try { + result = await performHandshakeHFSInitiator({ + connection, + privateKey, + remoteIdentityKey, + log: log.newScope('xxhfs-handshake'), + crypto: this.crypto, + prologue: this.prologue, + s: this.staticKey, + kem: this.kem, + extensions: { + streamMuxers, + webtransportCerthashes: [], + ...this.extensions + } + }, options) + this.metrics?.xxHandshakeSuccesses.increment() + } catch (e: unknown) { + this.metrics?.xxHandshakeErrors.increment() + throw e + } + + return result + } + + private async performHFSHandshakeResponder ( + connection: LengthPrefixedStream, + privateKey: PrivateKey, + log: Logger, + remoteIdentityKey?: PublicKey, + options?: SecureConnectionOptions + ): Promise { + let result: HandshakeResult + const streamMuxers = options?.skipStreamMuxerNegotiation === true ? [] : [...this.components.upgrader.getStreamMuxers().keys()] + + try { + result = await performHandshakeHFSResponder({ + connection, + privateKey, + remoteIdentityKey, + log: log.newScope('xxhfs-handshake'), + crypto: this.crypto, + prologue: this.prologue, + s: this.staticKey, + kem: this.kem, + extensions: { + streamMuxers, + webtransportCerthashes: [], + ...this.extensions + } + }, options) + this.metrics?.xxHandshakeSuccesses.increment() + } catch (e: unknown) { + this.metrics?.xxHandshakeErrors.increment() + throw e + } + + return result + } +} + +export function noiseHFS (init: NoiseHFSInit = {}): (components: NoiseComponents) => INoiseConnection { + return (components: NoiseComponents) => new NoiseHFS(components, init) +} diff --git a/src/performHandshake-hfs.ts b/src/performHandshake-hfs.ts new file mode 100644 index 0000000..dd7705c --- /dev/null +++ b/src/performHandshake-hfs.ts @@ -0,0 +1,134 @@ +/** + * XXhfs handshake orchestration — initiator and responder sides. + * + * Mirrors performHandshake.ts but uses XXhfsHandshakeState (which adds the + * e1 / ekem1 KEM tokens) and NOISE_HFS_PROTOCOL_NAME. Every other step — + * payload creation, signature verification, cipher-state split — is identical + * to the classical XX handshake, which is intentional: only the key-exchange + * path changes; the identity/authentication layer is preserved. + */ + +import { + logLocalStaticKeys, + logLocalEphemeralKeys, + logRemoteEphemeralKey, + logRemoteStaticKey, + logCipherState +} from './logger.js' +import { ZEROLEN } from './protocol.js' +import { XXhfsHandshakeState, NOISE_HFS_PROTOCOL_NAME } from './protocol-pqc.js' +import { createHandshakePayload, decodeHandshakePayload } from './utils.js' +import type { IKem } from './kem.js' +import type { HandshakeResult, HandshakeParams } from './types.js' +import type { AbortOptions } from '@libp2p/interface' + +export interface HfsHandshakeParams extends HandshakeParams { + /** KEM backend — provides generateKemKeyPair / encapsulate / decapsulate */ + kem: IKem +} + +/** + * Perform XXhfs handshake as the initiator (outbound connection). + * + * Message flow: + * A → responder e, e1 (DH eph + KEM pubkey) + * B ← responder e, ee, ekem1, s, es + * C → responder s, se + * + * Cipher assignment after split(): + * encrypt → cs1 (initiator→responder direction) + * decrypt → cs2 (responder→initiator direction) + */ +export async function performHandshakeHFSInitiator (init: HfsHandshakeParams, options?: AbortOptions): Promise { + const { log, connection, crypto, privateKey, prologue, s, remoteIdentityKey, extensions, kem } = init + + const payload = await createHandshakePayload(privateKey, s.publicKey, extensions) + const xx = new XXhfsHandshakeState({ + crypto, + kem, + protocolName: NOISE_HFS_PROTOCOL_NAME, + initiator: true, + prologue, + s + }) + + logLocalStaticKeys(xx.s, log) + log.trace('HFS Stage 0 - Initiator sending first message (e, e1).') + await connection.write(xx.writeMessageA(ZEROLEN), options) + log.trace('HFS Stage 0 - Initiator finished sending first message.') + logLocalEphemeralKeys(xx.e, log) + + log.trace('HFS Stage 1 - Initiator waiting for responder message (e, ee, ekem1, s, es)...') + const plaintext = xx.readMessageB(await connection.read(options)) + log.trace('HFS Stage 1 - Initiator received the message.') + logRemoteEphemeralKey(xx.re, log) + logRemoteStaticKey(xx.rs, log) + + log.trace("Initiator going to check remote's signature...") + const receivedPayload = await decodeHandshakePayload(plaintext, xx.rs, remoteIdentityKey) + log.trace('All good with the signature!') + + log.trace('HFS Stage 2 - Initiator sending third handshake message (s, se).') + await connection.write(xx.writeMessageC(payload), options) + log.trace('HFS Stage 2 - Initiator sent message with signed payload.') + + const [cs1, cs2] = xx.ss.split() + logCipherState(cs1, cs2, log) + + return { + payload: receivedPayload, + encrypt: (plaintext) => cs1.encryptWithAd(ZEROLEN, plaintext), + decrypt: (ciphertext, dst) => cs2.decryptWithAd(ZEROLEN, ciphertext, dst) + } +} + +/** + * Perform XXhfs handshake as the responder (inbound connection). + * + * Message flow: + * A ← initiator e, e1 + * B → initiator e, ee, ekem1, s, es + * C ← initiator s, se + * + * Cipher assignment after split(): + * encrypt → cs2 (responder→initiator direction) + * decrypt → cs1 (initiator→responder direction) + */ +export async function performHandshakeHFSResponder (init: HfsHandshakeParams, options?: AbortOptions): Promise { + const { log, connection, crypto, privateKey, prologue, s, remoteIdentityKey, extensions, kem } = init + + const payload = await createHandshakePayload(privateKey, s.publicKey, extensions) + const xx = new XXhfsHandshakeState({ + crypto, + kem, + protocolName: NOISE_HFS_PROTOCOL_NAME, + initiator: false, + prologue, + s + }) + + logLocalStaticKeys(xx.s, log) + log.trace('HFS Stage 0 - Responder waiting for first message (e, e1).') + xx.readMessageA(await connection.read(options)) + log.trace('HFS Stage 0 - Responder received first message.') + logRemoteEphemeralKey(xx.re, log) + + log.trace('HFS Stage 1 - Responder sending message (e, ee, ekem1, s, es).') + await connection.write(xx.writeMessageB(payload), options) + log.trace('HFS Stage 1 - Responder sent the second handshake message with signed payload.') + logLocalEphemeralKeys(xx.e, log) + + log.trace('HFS Stage 2 - Responder waiting for third handshake message (s, se)...') + const plaintext = xx.readMessageC(await connection.read(options)) + log.trace('HFS Stage 2 - Responder received the message, finished handshake.') + const receivedPayload = await decodeHandshakePayload(plaintext, xx.rs, remoteIdentityKey) + + const [cs1, cs2] = xx.ss.split() + logCipherState(cs1, cs2, log) + + return { + payload: receivedPayload, + encrypt: (plaintext) => cs2.encryptWithAd(ZEROLEN, plaintext), + decrypt: (ciphertext, dst) => cs1.decryptWithAd(ZEROLEN, ciphertext, dst) + } +} diff --git a/src/protocol-pqc.ts b/src/protocol-pqc.ts new file mode 100644 index 0000000..104d587 --- /dev/null +++ b/src/protocol-pqc.ts @@ -0,0 +1,240 @@ +/** + * Noise HFS (Hybrid Forward Secrecy) handshake state machine. + * + * Implements the XXhfs pattern: + * -> e, e1 (Message A: DH ephemeral + KEM pubkey) + * <- e, ee, ekem1, s, es (Message B: DH eph, DH(ee), KEM encap, static, DH(es)) + * -> s, se (Message C: static, DH(se) — unchanged from XX) + * + * Protocol name: Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256 + * + * Security: handshake is secure if EITHER X25519 OR ML-KEM-768 is unbroken. + * The classical DH operations (ee, es, se) provide current security; the KEM (ekem1) + * provides quantum-safe forward secrecy against Store-Now-Decrypt-Later attacks. + * + * Token ordering in writeEkem1 / readEkem1: + * encryptAndHash(cipherText) FIRST — encrypted with the ee-derived key + * mixKey(sharedSecret) AFTER — strengthens subsequent tokens (s, es, payload) + * + * References: + * - Noise HFS spec: https://github.com/noiseprotocol/noise_hfs_spec + * - PQNoise paper: ePrint 2022/539 + * - ML-KEM-768: FIPS 203 (August 2024) + */ + +import { Uint8ArrayList } from 'uint8arraylist' +import { InvalidCryptoExchangeError } from './errors.js' +import { AbstractHandshakeState } from './protocol.js' +import type { HandshakeStateInit } from './protocol.js' +import type { IKem, KemKeyPair } from './kem.js' + +export const NOISE_HFS_PROTOCOL_NAME = 'Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256' + +export interface HfsHandshakeStateInit extends HandshakeStateInit { + /** KEM backend — provides generateKemKeyPair, encapsulate, decapsulate */ + kem: IKem +} + +/** + * XXhfs handshake state machine. + * + * Extends AbstractHandshakeState (which handles the three classical DH tokens: + * ee, es, se) with two new KEM tokens: e1 and ekem1. + * + * The classical DH operations are UNCHANGED — this class only adds the parallel + * KEM path. Both DH and KEM shared secrets are fed into MixKey(); the final + * cipher keys depend on both. + */ +export class XXhfsHandshakeState extends AbstractHandshakeState { + private readonly kem: IKem + /** Local KEM ephemeral keypair — generated by initiator, stored for decapsulation */ + public e1?: KemKeyPair + /** Remote KEM ephemeral public key — received from initiator, used for encapsulation */ + public re1?: Uint8Array + + constructor (init: HfsHandshakeStateInit) { + super(init) + this.kem = init.kem + } + + // ─── KEM token write methods ──────────────────────────────────────────────── + + /** + * Write e1 token: generate KEM ephemeral keypair, transmit public key. + * + * Called by initiator in Message A. At this point there is no cipher key, + * so encryptAndHash falls through to a plain mixHash — the pubkey is sent + * unencrypted (1184 bytes on the wire, no AEAD tag). + */ + protected writeE1 (): Uint8Array | Uint8ArrayList { + if (this.e1 != null) { + throw new Error('KEM ephemeral keypair already set') + } + this.e1 = this.kem.generateKemKeyPair() + return this.ss.encryptAndHash(this.e1.publicKey) + } + + /** + * Write ekem1 token: encapsulate to remote e1 pubkey, transmit encrypted + * ciphertext, then mix KEM shared secret into the chaining key. + * + * Called by responder in Message B, after writeEE() has established a cipher key. + * Order is critical: encryptAndHash(cipherText) before mixKey(sharedSecret) so that + * subsequent tokens (s, es, payload) are encrypted with the KEM-strengthened key. + */ + protected writeEkem1 (): Uint8Array | Uint8ArrayList { + if (this.re1 == null) { + throw new Error('remote KEM ephemeral public key (re1) not set') + } + const { cipherText, sharedSecret } = this.kem.encapsulate(this.re1) + const encrypted = this.ss.encryptAndHash(cipherText) // encrypt with ee-derived key + this.ss.mixKey(sharedSecret) // then strengthen with KEM output + return encrypted + } + + // ─── KEM token read methods ───────────────────────────────────────────────── + + /** + * Read e1 token: receive and store the remote KEM ephemeral public key. + * + * Called by responder in readMessageA. No cipher key exists yet, so + * decryptAndHash is a no-op decrypt (plain mixHash). Returns bytes consumed. + */ + protected readE1 (message: Uint8ArrayList, offset = 0): number { + if (this.re1 != null) { + throw new Error('remote KEM ephemeral public key already set') + } + const pkEncLen = this.kem.PUBKEY_LEN + (this.ss.cs.hasKey() ? 16 : 0) + if (message.byteLength < offset + pkEncLen) { + throw new Error('message too short for e1 token') + } + const raw = message.sublist(offset, offset + pkEncLen) + const pk = this.ss.decryptAndHash(raw) + this.re1 = pk.subarray() + return pkEncLen + } + + /** + * Read ekem1 token: decrypt KEM ciphertext, decapsulate, mix KEM shared secret. + * + * Called by initiator in readMessageB. The ciphertext is AEAD-decrypted using + * the ee-derived key, then decapsulated using the local e1 secret key. If the + * ciphertext has been tampered with, AEAD decryption throws before decapsulation. + * Even without tampering, wrong-key decapsulation (ML-KEM implicit rejection) + * produces a divergent shared secret, causing all subsequent AEAD operations to fail. + * + * Returns bytes consumed from message. + */ + protected readEkem1 (message: Uint8ArrayList, offset = 0): number { + if (this.e1 == null) { + throw new Error('KEM ephemeral keypair (e1) not set') + } + const ctEncLen = this.kem.CT_LEN + (this.ss.cs.hasKey() ? 16 : 0) + if (message.byteLength < offset + ctEncLen) { + throw new Error('message too short for ekem1 token') + } + const raw = message.sublist(offset, offset + ctEncLen) + const cipherText = this.ss.decryptAndHash(raw) // throws if AEAD tag invalid + const sharedSecret = this.kem.decapsulate(cipherText.subarray(), this.e1.secretKey) + this.ss.mixKey(sharedSecret) // same ordering as write side + return ctEncLen + } + + // ─── Message A: e, e1 ─────────────────────────────────────────────────────── + + /** + * Write Message A (initiator → responder): + * [32 bytes] e.publicKey (DH ephemeral, plaintext) + * [1184 bytes] e1.publicKey (KEM ephemeral, plaintext — no cipher key yet) + * [payload] encryptAndHash(payload) (empty in standard handshake) + * + * Total (empty payload): 32+1184 bytes (DH ephemeral + KEM public key) + */ + writeMessageA (payload: Uint8Array | Uint8ArrayList): Uint8Array | Uint8ArrayList { + const e = this.writeE() // 32 bytes + const e1 = this.writeE1() // 1184 bytes (plaintext — no AEAD tag) + return new Uint8ArrayList(e, e1, this.ss.encryptAndHash(payload)) + } + + /** + * Read Message A (responder side): + * Parses e (32 bytes) and e1 (1184 bytes), then decrypts payload. + */ + readMessageA (message: Uint8ArrayList): Uint8Array | Uint8ArrayList { + try { + this.readE(message, 0) // 32 bytes + this.readE1(message, 32) // 1184 bytes + return this.ss.decryptAndHash(message.sublist(32 + this.kem.PUBKEY_LEN)) + } catch (e) { + throw new InvalidCryptoExchangeError(`pq-handshake stage 0: ${(e as Error).message}`) + } + } + + // ─── Message B: e, ee, ekem1, s, es ──────────────────────────────────────── + + /** + * Write Message B (responder → initiator): + * [32 bytes] e.publicKey (DH ephemeral, plaintext) + * ee → MixKey(DH(e_R, e_I)) (classical forward secrecy) + * [1104 bytes] encryptAndHash(ct) (KEM ciphertext + 16-byte AEAD tag) + * → MixKey(kem_output) (quantum-safe forward secrecy) + * [48 bytes] encryptAndHash(s) (encrypted static pubkey + AEAD tag) + * es → MixKey(DH(e_I, s_R)) (classical authentication) + * [payload+16] encryptAndHash(payload) + * + * Total (empty payload): 32 + 1104 + 48 + 16 = 1200 bytes overhead + */ + writeMessageB (payload: Uint8Array | Uint8ArrayList): Uint8Array | Uint8ArrayList { + const e = this.writeE() // 32 bytes + this.writeEE() // MixKey(DH(ee)) — no bytes + const ekem1 = this.writeEkem1() // 1104 bytes (1088 ct + 16 AEAD) + const encS = this.writeS() // 48 bytes (32 static + 16 AEAD) + this.writeES() // MixKey(DH(es)) — no bytes + return new Uint8ArrayList(e, ekem1, encS, this.ss.encryptAndHash(payload)) + } + + /** + * Read Message B (initiator side). + */ + readMessageB (message: Uint8ArrayList): Uint8Array | Uint8ArrayList { + try { + this.readE(message, 0) // 32 bytes + this.readEE() // DH(ee) + const ekem1Consumed = this.readEkem1(message, 32) // 1104 bytes + const sConsumed = this.readS(message, 32 + ekem1Consumed) // 48 bytes + this.readES() + return this.ss.decryptAndHash(message.sublist(32 + ekem1Consumed + sConsumed)) + } catch (e) { + throw new InvalidCryptoExchangeError(`pq-handshake stage 1: ${(e as Error).message}`) + } + } + + // ─── Message C: s, se (identical to XX) ──────────────────────────────────── + + /** + * Write Message C (initiator → responder): + * [48 bytes] encryptAndHash(s) (encrypted static pubkey + AEAD tag) + * se → MixKey(DH(s_I, e_R)) (classical authentication) + * [payload+16] encryptAndHash(payload) + * + * This message is unchanged from the classical XX pattern. + */ + writeMessageC (payload: Uint8Array | Uint8ArrayList): Uint8Array | Uint8ArrayList { + const encS = this.writeS() // 48 bytes + this.writeSE() // MixKey(DH(se)) — no bytes + return new Uint8ArrayList(encS, this.ss.encryptAndHash(payload)) + } + + /** + * Read Message C (responder side). + */ + readMessageC (message: Uint8ArrayList): Uint8Array | Uint8ArrayList { + try { + const sConsumed = this.readS(message, 0) // 48 bytes + this.readSE() + return this.ss.decryptAndHash(message.sublist(sConsumed)) + } catch (e) { + throw new InvalidCryptoExchangeError(`pq-handshake stage 2: ${(e as Error).message}`) + } + } +} diff --git a/test/fixtures/pqc-test-vectors.json b/test/fixtures/pqc-test-vectors.json new file mode 100644 index 0000000..c2ad2a8 --- /dev/null +++ b/test/fixtures/pqc-test-vectors.json @@ -0,0 +1,135 @@ +{ + "protocol": "Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256", + "description": "Deterministic test vectors for Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256. All keypairs seeded for reproducibility. Do NOT use seeded keys in production.", + "generated_by": "@chainsafe/libp2p-noise (js-libp2p-noise)", + "kem": "ML-KEM-768 (FIPS 203) via @noble/post-quantum", + "prologue": "empty (0 bytes)", + "payload": "empty (ZEROLEN) — no libp2p handshake payload", + "vectors": [ + { + "vector_index": 1, + "description": "Noise_XXhfs vector 1 — all keys seeded from base byte 0x10", + "static_i_public": "7b4e909bbe7ffe44c465a220037d608ee35897d31ef972f07f74892cb0f73f13", + "static_i_private": "1111111111111111111111111111111111111111111111111111111111111111", + "static_r_public": "052a50773ac8d91773f2dc9662e12f0defe915e415b8a1c8e20a5a3d6ab2b843", + "static_r_private": "1212121212121212121212121212121212121212121212121212121212121212", + "ephemeral_dh_i_public": "197fc2c567dc03ee2aadf0ed86681dac24daa76e83ca555875dd3be7376e5306", + "ephemeral_dh_i_private": "1313131313131313131313131313131313131313131313131313131313131313", + "ephemeral_dh_r_public": "18a6f8c1a7fddf22bd410138f79f7298cd38d1d0a542d4266d556be8609d8862", + "ephemeral_dh_r_private": "1414141414141414141414141414141414141414141414141414141414141414", + "ephemeral_kem_i_public": "b989c17f78bdfc62afa2c0855471be97c9a287ecab608b84214551254286b72b8bcb69bad00b73dd322fb0f2a9671a8b7b18cc614cbf0f990ee8fb0e7c2c8e171122ca148652e809ff140969d713ef918fc2b7167c50a1c260a9fa6980a77639200c187d7648f017bec292859dd57b391b8d4bc6a2de0c9b354c0b6b6a64f10b5e91c17471f771a679634c5c222a533dd59564d0084aeb91be41436ac6a60705da9336d02e6642317c886c71e47509f6673a2bcbadb87d4aa11876f5611af978cb851ae4b1415b038a7874b3e10a2fbe3091b9b64af51bb20554c6ed6bc678f905a1f1b6121c62cbbacb74ca6afba594ec384166b4cf1cb78656786cbd51b1c5664de6f36ef265a331718432179abb907b0fb588d6e7635ef66c6083c4700567f8e82814a22bba13976bb997d0f72c01312a82f15c4aaa4fcbfbb4bf1b2bd640c5bbf18813906db4946065e14a77115cd80216f238025f712113e100f65b06eb358e53a33020b698c2a1298f320f7a9b290ce495eff041bfc972c123c2609891eae2810365373b3b9a42210702c7b514b207b57a03eb7643b566099e0aa3d5288823b9a2fcb4a9ea7ba3d26c9e9bf6823304b1938ab7bfe23e4f24469d4820f9b17353864338462650c0922bc57b507cb0e5dc014b1806383432b16b34a7f311a557c39025140763907d88caca8a923708239c699203e7bacc908ed75b4835943fcdf79f0ce2484786b5b725560e8b99951b4996f475a5cb309e37ba08970447f3a8bc651ce00a4ac6cc48dfd0289b89a00f006e9a138d5d923b24bb6411f9a33b756a041985ca4c481d7a838d3082f0b791a44139c2c1b7e6166ae9f37cef82469cfa7b1dd00bd23bbdf0e1ac15fc19d27155b690858ee32be8295232676b8ceb49c601aea6098747c6c94bc767d21141e0940aab2016c3a8be8ddb345997491ebc5e86471ad58c84d0a69fd0ac55cdd25ce6085144f29ce397203533c3f5d9c466a1a7b71c9738567b3cdabd3969bd14087d3165765c22cf66a03a89759124e48528631168430212c5860fd874c8d53e61cb91f843a6ede17fea41010a217b32d519d7ca52e9a1169e71c4cf036de8c63476a65bd2095bc27c81166200d387c5eacbb797c83003fb56545ba1f17233a63c25d884c21e393ece3a7fe8f07a9ac2c426cb82a112744a7a81762190dd738e73c854e8e00d0cb57ec9f25104a330011283f1408172e43dcb6345de854daee1ac8fb447436024b15803b21bb0b15c35fc7190eb3a026b04b68dfa81f4467ff90a75c0a6aa530c39668565d1152752e9c15eac322794a49e9c8998f614a9c59ce2335f9fc742377587860b9b22f4c201cc86518c1780648c3950a80977b6bc396b3e654415323b844ba0081762c0a13632218eda177c7cc09bd791a157b3a8fb21be620ba2fbe551019ccd22870ddf8340b4c67817ac90437b71b38b6c25e054f750c39e0666675c256f961b54a49e9925b39611bbddeb4d9c5316ebf7cb600ca3b6a486c4e7b1de809839d63945e203011a16d1118280f7242e148ed214c0e68588afa93d42c17813e64510281f73cab346493c3e7740330a6ea20973781ac73a78664bf80471f7526155c923f72ab656b007450a106e2e9eefac5c56647bce316a7164a81f32dfee0fd2d397649d", + "ephemeral_kem_i_secret": "11858da29518efab7e940865c4294624fc4c4040906a92c9fac677fd4312bdfb5917c364cdb82abfe4c230e658dd8c014d5c7ccae8b219fa5d62c208bc5022bfc61d19646e998388d4b7c5383899cb04c98b0539b740b37b5727ada94ed500a2d571bbfcc78dfc216a81c23338228d655705cd375a747a1d17793902cc6774171baa879fcfe1987e43ae48d6bd7d8563edd9af116500e37a8dbcc3cc072551f1d14ec8b61e9987ca8506a6cdc22eb19c71ee8bce96021666da4b21faa987b13625c95f682561d9c4436742157d75a0482551b3606d303278a4f6cc8055ae6f2941cc9733c9f12ff14276eb7137edeb0237ba6a9d81b25b53c1a6e77a94236762dac94df26b84328a85f26b86723d289a1ef22648ec61b28e49b3d927cd0ad8a31fa53378152100fb67f64285df909aee9b141e5a603129c72d64aedea20aed6b3cbefac8ba294edf537817a46dcc24529b350a6f26314fe59b3f43cc00a5bb00b001820a70f145484d270331ca789cf7b5ab5c4a35a8a87341b64b432f1a545b66d1a316cb7b2e333dcbf8140a5a7336cccfa1cc1eaa9184b6e476bdbb7fdc125f89d0ade8cabec6101970cc5b4b108a75395fa6544a9b5768212b320bbb05f6934fc035b0a5dca46a245b02a863875ba141986526494272f07ecbea6895f43dece7bc0b838ce345101f49235714394d922dc8436898a94e5cd5a7170c6e83e208aed99d824a80f99ab2c1795a83dc3fc923a33be835de641222a8a222c276369272576b36bcb57c5680818c84666711827f2b3ad87bce5cc394efa479bea965dc206459271ac04b1918bc8be097012543120e89220119b653939b08d57eaef4392815a616d607a91b9ef5a367c80b8c2ea0281d58b81ab62ff191021aeb021769879fe65811304516803580900043e612786c8ddcb807d94805c09682dd124c784661f86ba95220104d700de1f440dc5339da41ba1b9c3d09b34c6d6c76b74517e034aed8377baed73e3754a534b5a7b1eb957adb74a0460f5c42218bc62400076681090042c0a9bed67f6236b0a4ca11024bcc6c645ced6463343a13f1aa8621205c21579aed116c01b1be9e366057482eec6ca23bb091a1345d87660574c13c94aa1b64716cdcfac2c166c8e71b205f7ca715d722166131ffc0a5b954948f85c857e89ca9301ce959a9c9c0a8c978422c2093389a5d3cdb2abc4c68e33253f98b20834ac9de8a66666211779b066c372c2fb53d82041c5e937ae9d7c395c670d880cd2330cfe6cba8ed25421d555283144ded464839160258169964f47bb9473669e36b35413120884619a777b2e2ca702ca04db83f5a729e36cb4be0001960c7c33c60cd4025b74453647f318df2a1116b0968af643b24a3b1c0d7097187b368716460399c1f8b779f1b801ea95ddef26c12238c377baf7490cbc2d301cbe378b9195977d6bee7aacbf86b386b01a6cc7909054849ef156d09657047e32fe2644d9531a0cda6a8d7134e36178ba1ab975875bc0d5899aaa52969195b91008ea0d078ae71cc612aa6753021a8966a83d555ae62ba6512470bdcb673124afdd4cda990c4b9639250c53c35d000b2293e9e80a46ee88337d789f03c2de2d765b989c17f78bdfc62afa2c0855471be97c9a287ecab608b84214551254286b72b8bcb69bad00b73dd322fb0f2a9671a8b7b18cc614cbf0f990ee8fb0e7c2c8e171122ca148652e809ff140969d713ef918fc2b7167c50a1c260a9fa6980a77639200c187d7648f017bec292859dd57b391b8d4bc6a2de0c9b354c0b6b6a64f10b5e91c17471f771a679634c5c222a533dd59564d0084aeb91be41436ac6a60705da9336d02e6642317c886c71e47509f6673a2bcbadb87d4aa11876f5611af978cb851ae4b1415b038a7874b3e10a2fbe3091b9b64af51bb20554c6ed6bc678f905a1f1b6121c62cbbacb74ca6afba594ec384166b4cf1cb78656786cbd51b1c5664de6f36ef265a331718432179abb907b0fb588d6e7635ef66c6083c4700567f8e82814a22bba13976bb997d0f72c01312a82f15c4aaa4fcbfbb4bf1b2bd640c5bbf18813906db4946065e14a77115cd80216f238025f712113e100f65b06eb358e53a33020b698c2a1298f320f7a9b290ce495eff041bfc972c123c2609891eae2810365373b3b9a42210702c7b514b207b57a03eb7643b566099e0aa3d5288823b9a2fcb4a9ea7ba3d26c9e9bf6823304b1938ab7bfe23e4f24469d4820f9b17353864338462650c0922bc57b507cb0e5dc014b1806383432b16b34a7f311a557c39025140763907d88caca8a923708239c699203e7bacc908ed75b4835943fcdf79f0ce2484786b5b725560e8b99951b4996f475a5cb309e37ba08970447f3a8bc651ce00a4ac6cc48dfd0289b89a00f006e9a138d5d923b24bb6411f9a33b756a041985ca4c481d7a838d3082f0b791a44139c2c1b7e6166ae9f37cef82469cfa7b1dd00bd23bbdf0e1ac15fc19d27155b690858ee32be8295232676b8ceb49c601aea6098747c6c94bc767d21141e0940aab2016c3a8be8ddb345997491ebc5e86471ad58c84d0a69fd0ac55cdd25ce6085144f29ce397203533c3f5d9c466a1a7b71c9738567b3cdabd3969bd14087d3165765c22cf66a03a89759124e48528631168430212c5860fd874c8d53e61cb91f843a6ede17fea41010a217b32d519d7ca52e9a1169e71c4cf036de8c63476a65bd2095bc27c81166200d387c5eacbb797c83003fb56545ba1f17233a63c25d884c21e393ece3a7fe8f07a9ac2c426cb82a112744a7a81762190dd738e73c854e8e00d0cb57ec9f25104a330011283f1408172e43dcb6345de854daee1ac8fb447436024b15803b21bb0b15c35fc7190eb3a026b04b68dfa81f4467ff90a75c0a6aa530c39668565d1152752e9c15eac322794a49e9c8998f614a9c59ce2335f9fc742377587860b9b22f4c201cc86518c1780648c3950a80977b6bc396b3e654415323b844ba0081762c0a13632218eda177c7cc09bd791a157b3a8fb21be620ba2fbe551019ccd22870ddf8340b4c67817ac90437b71b38b6c25e054f750c39e0666675c256f961b54a49e9925b39611bbddeb4d9c5316ebf7cb600ca3b6a486c4e7b1de809839d63945e203011a16d1118280f7242e148ed214c0e68588afa93d42c17813e64510281f73cab346493c3e7740330a6ea20973781ac73a78664bf80471f7526155c923f72ab656b007450a106e2e9eefac5c56647bce316a7164a81f32dfee0fd2d397649db93f671ec40f1267e7ecc81ba07fc9b9c2375b9680a9234e2d48bddf359b759f1515151515151515151515151515151515151515151515151515151515151515", + "encap_seed_hex": "1616161616161616161616161616161616161616161616161616161616161616", + "prologue": "", + "msg_a": "197fc2c567dc03ee2aadf0ed86681dac24daa76e83ca555875dd3be7376e5306b989c17f78bdfc62afa2c0855471be97c9a287ecab608b84214551254286b72b8bcb69bad00b73dd322fb0f2a9671a8b7b18cc614cbf0f990ee8fb0e7c2c8e171122ca148652e809ff140969d713ef918fc2b7167c50a1c260a9fa6980a77639200c187d7648f017bec292859dd57b391b8d4bc6a2de0c9b354c0b6b6a64f10b5e91c17471f771a679634c5c222a533dd59564d0084aeb91be41436ac6a60705da9336d02e6642317c886c71e47509f6673a2bcbadb87d4aa11876f5611af978cb851ae4b1415b038a7874b3e10a2fbe3091b9b64af51bb20554c6ed6bc678f905a1f1b6121c62cbbacb74ca6afba594ec384166b4cf1cb78656786cbd51b1c5664de6f36ef265a331718432179abb907b0fb588d6e7635ef66c6083c4700567f8e82814a22bba13976bb997d0f72c01312a82f15c4aaa4fcbfbb4bf1b2bd640c5bbf18813906db4946065e14a77115cd80216f238025f712113e100f65b06eb358e53a33020b698c2a1298f320f7a9b290ce495eff041bfc972c123c2609891eae2810365373b3b9a42210702c7b514b207b57a03eb7643b566099e0aa3d5288823b9a2fcb4a9ea7ba3d26c9e9bf6823304b1938ab7bfe23e4f24469d4820f9b17353864338462650c0922bc57b507cb0e5dc014b1806383432b16b34a7f311a557c39025140763907d88caca8a923708239c699203e7bacc908ed75b4835943fcdf79f0ce2484786b5b725560e8b99951b4996f475a5cb309e37ba08970447f3a8bc651ce00a4ac6cc48dfd0289b89a00f006e9a138d5d923b24bb6411f9a33b756a041985ca4c481d7a838d3082f0b791a44139c2c1b7e6166ae9f37cef82469cfa7b1dd00bd23bbdf0e1ac15fc19d27155b690858ee32be8295232676b8ceb49c601aea6098747c6c94bc767d21141e0940aab2016c3a8be8ddb345997491ebc5e86471ad58c84d0a69fd0ac55cdd25ce6085144f29ce397203533c3f5d9c466a1a7b71c9738567b3cdabd3969bd14087d3165765c22cf66a03a89759124e48528631168430212c5860fd874c8d53e61cb91f843a6ede17fea41010a217b32d519d7ca52e9a1169e71c4cf036de8c63476a65bd2095bc27c81166200d387c5eacbb797c83003fb56545ba1f17233a63c25d884c21e393ece3a7fe8f07a9ac2c426cb82a112744a7a81762190dd738e73c854e8e00d0cb57ec9f25104a330011283f1408172e43dcb6345de854daee1ac8fb447436024b15803b21bb0b15c35fc7190eb3a026b04b68dfa81f4467ff90a75c0a6aa530c39668565d1152752e9c15eac322794a49e9c8998f614a9c59ce2335f9fc742377587860b9b22f4c201cc86518c1780648c3950a80977b6bc396b3e654415323b844ba0081762c0a13632218eda177c7cc09bd791a157b3a8fb21be620ba2fbe551019ccd22870ddf8340b4c67817ac90437b71b38b6c25e054f750c39e0666675c256f961b54a49e9925b39611bbddeb4d9c5316ebf7cb600ca3b6a486c4e7b1de809839d63945e203011a16d1118280f7242e148ed214c0e68588afa93d42c17813e64510281f73cab346493c3e7740330a6ea20973781ac73a78664bf80471f7526155c923f72ab656b007450a106e2e9eefac5c56647bce316a7164a81f32dfee0fd2d397649d", + "msg_b": "18a6f8c1a7fddf22bd410138f79f7298cd38d1d0a542d4266d556be8609d88622570124958817200657f56c202337a8e9366a5d76016aba79a3870446df42a4e9780d7686f161dc677ac597a1f81f59919c6843800c9338510d4adb1b3f6fcb5981d7ac2b019cf209708a24ae3f19d42eeb49eb0c4d8492ed8c161669decad770fced7c209840517baa8bcc9c9ebe5c57ff7dac5eea2382c4a8c7954cda355f96c4496f04d3fcca9322ccdd797a483a462068a418ce41bf88d24423abb4e944ea803367523a70f39d413ce57f10d90a4da63f9f006e1ed14e1ba477888057dc6b194fc443f4ef628f2448442ffb0e0f90effb9015ca96e6e53b4ecc2db9b7943c17f15d980a38deea072131dc80198ef309537921ec415b531aac0c3642134ea3c8221eb9fd993c0783907c57371c7b6657b4a0f54942bc1d82f80a2254003ac1a7461456cecdf8dc94368097b6fd8806ecda26464a986d5840aa83689b05c3e308045caebbfc42dcd274e8dbd90b0cd9b0edbb517f77e4b1421c34065a8ae5d21a3341c8b498b4c7c67c7578c60ffdf4c4bb6d5d6c542c68ef9ae11992f2ec6f5718413fcda611f650a5f359b5ab946ad61443746dbd2112f193ad9e7847fa6c47bc8e2d3a4560bcff3e394cc185d3ed46faa94881d79d4563eef6587decaf0dbe8ff686d19ced0e51ab065d0e4445b9a58366483498dda22f1fa09d96b39e0b524c493588013ac20edb7444927ae364a761f948841f23eb80a909d321820e755934e56d339f1e60ce6a1e19ac6298ec2807fa1b26476d7a7b41502c1658759728bef4da1d40fe2e19fff94b4492752d361219e0d23bcfa38cd745e0f4c1d7bfcf36a97b03e8119f9759a90691f65f4fbbc868ae9d10cade74133957433d425d830628434af2530ec3c02611d54483ad5ec50a928688bbdcc15521a307d0657a4e23027a1c8d0a15bbed8277619685d784dda6ad87e84b7fdc26b61cd873e0da9bf5f075db6cd0f22f0cfeea47c9feb2001f6ca45032ba4ea039c00026cd0644857e8dae937971bb7ab7c40cdd1ba27fd5d7ee5af6be1a4c61c970a3130a38f1a97a4a24627ea11da69d9c4b17c069ca34470cc32ff8442018e4633778c82119ec954d0912110c26e60beeb10a59916c062b50bd605400eb9a11cbec71591d43584002600299eeb1adaf6eb07693b0a03572cacd1f5694d44c9629725d8e13ef8207f163996ea27e3ec87d8333c7f344939b4521b49d914b8142054a65c2480179b433ef0b3cce15505d2ed0adcfb0e74e5f87fa89b6f0c95dc5da83cb621705edf48519f001c17754dd442bfbedb6d4815c2c785a54399fd02c17eb244080a2195006a4125817712b33c753af7b6243a6fcde41e22f2dfe8e298652060066543cfaac14e5d22444c60374b09f95a99c60372647085c8503904271522cec719da2c526748aea9a9a4100d4928637d3bc2bd153d6e3f8c280877c37bb5b65401bf958192ef2cecc1caaad306ac7943c3988544cd300228ec7b67eac2377987d628ed422a4f4456ef5e9d9978d8e81bfe18cdae0bc59149a44da2d97a1797f4cb8d61f36d198d2e73bd195c8414bcbe8e1e4bab364311624a76cac7b7399e12e4f820560ad2e52ae453ff087e4143a53430b96edf0efffd690527a4df04cbd6d75512883cb0dc828335f84433f66356bb", + "msg_c": "ddacaa0284833e7cfe64c1086652310737e24d4f9e9b01f851097a89dbc9ac0c9c9c3f7a6039d5d41edfe039792721b4ebf3a2d3cb8c67b29447f0b2a2bf422e", + "msg_a_bytes": 1216, + "msg_b_bytes": 1200, + "msg_c_bytes": 64, + "handshake_hash": "86b6140bdd5266658b0f25b5d3088153418f072068fb1564bf0502429d5ee862", + "cs1_k": "2931480a25e21d6f48b8687d7d753d0097d755fc8e76764b797a3c9db038422b", + "cs2_k": "49849d5ec7ee65f51397789fee47f984fee70a4d4628ea065e2a2b89395ea67a" + }, + { + "vector_index": 2, + "description": "Noise_XXhfs vector 2 — all keys seeded from base byte 0x20", + "static_i_public": "7d34a4815fa6b982535e60af3bd9b49556816080f1641ff81d2b7c8ae8268a44", + "static_i_private": "2121212121212121212121212121212121212121212121212121212121212121", + "static_r_public": "0faa684ed28867b97f4a6a2dee5df8ce974e76b7018e3f22a1c4cf2678570f20", + "static_r_private": "2222222222222222222222222222222222222222222222222222222222222222", + "ephemeral_dh_i_public": "9a4503a98ab10fe8d354c9c42cbd0c9d7944f52e7d14d8ea59775e7dc9e3bf4b", + "ephemeral_dh_i_private": "2323232323232323232323232323232323232323232323232323232323232323", + "ephemeral_dh_r_public": "04bcd2e0d00f2cce5fe8f1c6c2fbec5c07fa56e3aa5c88a5689975d88b3fce05", + "ephemeral_dh_r_private": "2424242424242424242424242424242424242424242424242424242424242424", + "ephemeral_kem_i_public": "bab9930025a64247cde9e17892713d69341359802dff3c9f00eaa38d7b1cd15000ffc9b226f884f7418f64e13942903be99b364a277c72784f6e17aa974c8eb0950a249a93ca20ce38ccb46b20919062ae7ec2212af07530f4c6ade9c6f926584a88ac287c611d94cbf103173c75770cea029fc6633d13b2cf575c9d6c4e5476cf95a39d923c60a2196373811279856195b2a04ac6c59ef180848192dea69716b65b493bc35eb512fd1b2e0a51149bb18f0448b70a284b1907328f22a6d6d6cac9b951e59931b8db9031b5253d6a6756342932758d55539fcfb993ea8514f2d99537f53b2a7b8cc4809fd6dbbbdb4b280e31a3a8678bf9535274498dcec3007ae87fba1b0132766c1901b41c9c40974c98f5fa8356c77a032c464e2ca4e75824c877513f04a4169509101248906587e1c93e1a45828b97ab9235bcca7187a4da93ab561c8aa5c6192992c506300089961ec9018a152d9d945baa904b85fba8c330377d387dad033ee9c17049991be8627a192929a833668d796e59b75a6733bb92377fef6b766ff2ba02c029dfbc440bc49cddc89e551004d77952b264c2ce015dbde169eae3b322f5b738cc7aff213ba0769056f8cc51c4ba3c72b90516ae9ef1619be4333d157104d2c0b97b1a2ef27c6f3c86e11170e69498a9d47f3000100aa15cfad2221f52218afbc21d6aab67ebabed5199e335258369902edb09108a1e6f8baa8d271dbdf505b01b264dcaaefa247765e27f0db71f35d24307b373fca310ea8aad844025718097b0bc78410761a3a7c9e8da2c7a0cc59d2814289031cfc975bc23bf0719130656a476969c3b56ae582c6f14b7510bc8c415e873a7ab66a7b5824c997932f2732b88b78d1c65a7f48cf7353cf98b02d51650d5669b2c8130de8045f97cbb6457ad7b0257bbd987f664086d883cbd1b41937b2f4912976a0834986c6685b4b6db8089a4a0cdb1c97377820bd978740b07916474c07d76c1de5024d5377ae86177c4579ef8233310160a871b94fcebb4c45a11bd3a3f924977bdb80bb4e9a32f52bdb2942327c8272005bf928b0073f07354f7802f00267bf96d1fa996cea54fb8895c6ca8c4de89681efb5c9b5c943fd9cabbbc3ebf4812add58a08a38b49260ce6983e49176eb6c97d333c8689111b6cb01db2046b30ea391018c43a05cfb8b51e46da9cba5b1bc8bb7b4fe30841672c61a76d27db0aa8db4e57a650921318696c13b43b4c66787076a6a2816109e0503afcf9a2880c1472aa6e6b446853c42d53c644d1b378a9d940334c5b3449783f729f7c32073cb98639897bf4379cbf393769c89784e27063e38941abc26b46b45421207e00415168aa23a7b0c986cd90e835f6898b25da40c2a58346d25f52dc1abe51b539f27c11f6025d12b6453144233a684abba0c0b55063a0301174ad620131a4fb6607407370150fc9c256cf16be68a31e01a1c84f046059d261a8b89c48e573aa9103ef02b5b662272de31c2086a9ec5b0315dace28d24f3a475154967149851c9c84720f1b9fbd956ea01346af58743ea3c33eda2a6556a23e68506f48236c4090b6f3655ea8740dd6aba64ca2fcbb49452a8a095667a67b5b74864060f2b16cac2feed7115adb732a4e0d61d3261798558382c0e90555e80e31b65d1157d17c3aef", + "ephemeral_kem_i_secret": "81ca05b442492a90509665315ab6c7b3cb2b1b632097b72ffac2ca959ca46d21a922f25c82a463bc992408686bcbe36d8e1c5483504a99579293a9842d73c18f57ab5ba32696d9b46bb605fbe25fb3f0b1ca893dca0a30139a837e3cb88cf02dac4466d18a7653b69200624595ba9802745b1703c2864292eba2cc62b93b77dcbb3fd06c77d8a689a197e3d7450fc02a80721bf1cc04171213179551c6c22ee1a87a8b66bd74bb009f8a8810c8c461433471e4b395226b09f43487f363e2d027e57cb38a44bc3ca28d859963d8593c16cc880a95820c54c1c246a13cf3bb13220e686c4444d17119aaabf2c60d3c217528b95c1fc9a2eb3194cad3a74bd072f84135a4d0b440a32081e2ab3fe416ea276ab0588fbbe14b2ed72b53a634a4e1753899a21372ce6c679429e293dafc6b631816f8769d000611c7321de591c33c267db674a7ac95c6ae7568e4502ca27c483612c4e00a0678a180bd33b1d0a5250320357a34c0fdeac08d66746ff858a1e10d96d0098370a1ed868e291747d2a97ab1191c6ef67497d6973f5961272930be57ce709590af8c94d9451204804c7c1445b2d08cc9700c9ad543fd528056a74eb24447688341efc46702e37bb3c9184613821d700454d439bb5b40257b983ebc8245227cb1032c11524bc9b78929f0a2c05c8940b357c270bb34264b010ca359e336aca97d8b5b092ea82d51c33fd6b282e0c05f6c1b1875083af0ab976bdb622336803ae7336ec0732217a7a5c860db579503fa24ded10e691ca6625b5d99ca385a3a4c6c393b8ab70f393221488299c69c45bc9258dd969231d0756156979d2546828a51348c2a59e419c6a44cb5c608d2054bcf68916190b0b9790842f38272d7b421d692ea9680852b0a5f24b1145c848d57934121314831cb4b11cda7ea7cbac0088b759453895bed84404f137fd46337789035d9244c2941242e108d9ad4202d394e138acf30619ddb9461149382c44969ebe6c45117742c07a183425971b6409a5521654a45088ba3f504244ac735b6bbac877a87b701a39ee662832582b52c67a4c95a3b7561603c98b43b6ecf3b8e64a2a1d2995d5cb75436c7823dc88371a602b93753fd951320b38a1ac41e4905bb9d44cb2b945e04a2823e1a2b5601a6c90abffa4b0459d5ae56dbb82fbaa163d68bfd029e6e722aa0abb24bd227c5da659bfc079c02a074aa9b131a9f395238ffc2206eda35b2f94f2516ccf55b90ac006b8633aa31b65305b38820c59b8d5c1e2507adb4276d7590c1e1030016ec81eba58253abcb21d94010512c5acaa908427470471f56030a934a779005308c786274c130599303e18725c708b0f79b6bb1100460418d9f3502d28203e52414789687cf944320e55b92fba9e2950cc6a2390020ae35676af07345a04078a8676d64a865c35c46dc610f6c88521f21963167b6ae58993c290554903f51c1107adc09b26276626683d7254985b93943f48d563c7216897185951bcd657d63f496b5b220969413d8a5029acb38e83c395a101a4ac91b1dcb12e3c48cebe3ab89e33f09abc69e079d67b62d8171be1609cc7ff8917fb68fe6c640d796cf9b332dbd1bad3d67185977c0f084137a5261bab9930025a64247cde9e17892713d69341359802dff3c9f00eaa38d7b1cd15000ffc9b226f884f7418f64e13942903be99b364a277c72784f6e17aa974c8eb0950a249a93ca20ce38ccb46b20919062ae7ec2212af07530f4c6ade9c6f926584a88ac287c611d94cbf103173c75770cea029fc6633d13b2cf575c9d6c4e5476cf95a39d923c60a2196373811279856195b2a04ac6c59ef180848192dea69716b65b493bc35eb512fd1b2e0a51149bb18f0448b70a284b1907328f22a6d6d6cac9b951e59931b8db9031b5253d6a6756342932758d55539fcfb993ea8514f2d99537f53b2a7b8cc4809fd6dbbbdb4b280e31a3a8678bf9535274498dcec3007ae87fba1b0132766c1901b41c9c40974c98f5fa8356c77a032c464e2ca4e75824c877513f04a4169509101248906587e1c93e1a45828b97ab9235bcca7187a4da93ab561c8aa5c6192992c506300089961ec9018a152d9d945baa904b85fba8c330377d387dad033ee9c17049991be8627a192929a833668d796e59b75a6733bb92377fef6b766ff2ba02c029dfbc440bc49cddc89e551004d77952b264c2ce015dbde169eae3b322f5b738cc7aff213ba0769056f8cc51c4ba3c72b90516ae9ef1619be4333d157104d2c0b97b1a2ef27c6f3c86e11170e69498a9d47f3000100aa15cfad2221f52218afbc21d6aab67ebabed5199e335258369902edb09108a1e6f8baa8d271dbdf505b01b264dcaaefa247765e27f0db71f35d24307b373fca310ea8aad844025718097b0bc78410761a3a7c9e8da2c7a0cc59d2814289031cfc975bc23bf0719130656a476969c3b56ae582c6f14b7510bc8c415e873a7ab66a7b5824c997932f2732b88b78d1c65a7f48cf7353cf98b02d51650d5669b2c8130de8045f97cbb6457ad7b0257bbd987f664086d883cbd1b41937b2f4912976a0834986c6685b4b6db8089a4a0cdb1c97377820bd978740b07916474c07d76c1de5024d5377ae86177c4579ef8233310160a871b94fcebb4c45a11bd3a3f924977bdb80bb4e9a32f52bdb2942327c8272005bf928b0073f07354f7802f00267bf96d1fa996cea54fb8895c6ca8c4de89681efb5c9b5c943fd9cabbbc3ebf4812add58a08a38b49260ce6983e49176eb6c97d333c8689111b6cb01db2046b30ea391018c43a05cfb8b51e46da9cba5b1bc8bb7b4fe30841672c61a76d27db0aa8db4e57a650921318696c13b43b4c66787076a6a2816109e0503afcf9a2880c1472aa6e6b446853c42d53c644d1b378a9d940334c5b3449783f729f7c32073cb98639897bf4379cbf393769c89784e27063e38941abc26b46b45421207e00415168aa23a7b0c986cd90e835f6898b25da40c2a58346d25f52dc1abe51b539f27c11f6025d12b6453144233a684abba0c0b55063a0301174ad620131a4fb6607407370150fc9c256cf16be68a31e01a1c84f046059d261a8b89c48e573aa9103ef02b5b662272de31c2086a9ec5b0315dace28d24f3a475154967149851c9c84720f1b9fbd956ea01346af58743ea3c33eda2a6556a23e68506f48236c4090b6f3655ea8740dd6aba64ca2fcbb49452a8a095667a67b5b74864060f2b16cac2feed7115adb732a4e0d61d3261798558382c0e90555e80e31b65d1157d17c3aefd6d3c11e0165c89d3954b84f7aa797c7aa8e3ed675b444255d60c8d2cf1b59652525252525252525252525252525252525252525252525252525252525252525", + "encap_seed_hex": "2626262626262626262626262626262626262626262626262626262626262626", + "prologue": "", + "msg_a": "9a4503a98ab10fe8d354c9c42cbd0c9d7944f52e7d14d8ea59775e7dc9e3bf4bbab9930025a64247cde9e17892713d69341359802dff3c9f00eaa38d7b1cd15000ffc9b226f884f7418f64e13942903be99b364a277c72784f6e17aa974c8eb0950a249a93ca20ce38ccb46b20919062ae7ec2212af07530f4c6ade9c6f926584a88ac287c611d94cbf103173c75770cea029fc6633d13b2cf575c9d6c4e5476cf95a39d923c60a2196373811279856195b2a04ac6c59ef180848192dea69716b65b493bc35eb512fd1b2e0a51149bb18f0448b70a284b1907328f22a6d6d6cac9b951e59931b8db9031b5253d6a6756342932758d55539fcfb993ea8514f2d99537f53b2a7b8cc4809fd6dbbbdb4b280e31a3a8678bf9535274498dcec3007ae87fba1b0132766c1901b41c9c40974c98f5fa8356c77a032c464e2ca4e75824c877513f04a4169509101248906587e1c93e1a45828b97ab9235bcca7187a4da93ab561c8aa5c6192992c506300089961ec9018a152d9d945baa904b85fba8c330377d387dad033ee9c17049991be8627a192929a833668d796e59b75a6733bb92377fef6b766ff2ba02c029dfbc440bc49cddc89e551004d77952b264c2ce015dbde169eae3b322f5b738cc7aff213ba0769056f8cc51c4ba3c72b90516ae9ef1619be4333d157104d2c0b97b1a2ef27c6f3c86e11170e69498a9d47f3000100aa15cfad2221f52218afbc21d6aab67ebabed5199e335258369902edb09108a1e6f8baa8d271dbdf505b01b264dcaaefa247765e27f0db71f35d24307b373fca310ea8aad844025718097b0bc78410761a3a7c9e8da2c7a0cc59d2814289031cfc975bc23bf0719130656a476969c3b56ae582c6f14b7510bc8c415e873a7ab66a7b5824c997932f2732b88b78d1c65a7f48cf7353cf98b02d51650d5669b2c8130de8045f97cbb6457ad7b0257bbd987f664086d883cbd1b41937b2f4912976a0834986c6685b4b6db8089a4a0cdb1c97377820bd978740b07916474c07d76c1de5024d5377ae86177c4579ef8233310160a871b94fcebb4c45a11bd3a3f924977bdb80bb4e9a32f52bdb2942327c8272005bf928b0073f07354f7802f00267bf96d1fa996cea54fb8895c6ca8c4de89681efb5c9b5c943fd9cabbbc3ebf4812add58a08a38b49260ce6983e49176eb6c97d333c8689111b6cb01db2046b30ea391018c43a05cfb8b51e46da9cba5b1bc8bb7b4fe30841672c61a76d27db0aa8db4e57a650921318696c13b43b4c66787076a6a2816109e0503afcf9a2880c1472aa6e6b446853c42d53c644d1b378a9d940334c5b3449783f729f7c32073cb98639897bf4379cbf393769c89784e27063e38941abc26b46b45421207e00415168aa23a7b0c986cd90e835f6898b25da40c2a58346d25f52dc1abe51b539f27c11f6025d12b6453144233a684abba0c0b55063a0301174ad620131a4fb6607407370150fc9c256cf16be68a31e01a1c84f046059d261a8b89c48e573aa9103ef02b5b662272de31c2086a9ec5b0315dace28d24f3a475154967149851c9c84720f1b9fbd956ea01346af58743ea3c33eda2a6556a23e68506f48236c4090b6f3655ea8740dd6aba64ca2fcbb49452a8a095667a67b5b74864060f2b16cac2feed7115adb732a4e0d61d3261798558382c0e90555e80e31b65d1157d17c3aef", + "msg_b": "04bcd2e0d00f2cce5fe8f1c6c2fbec5c07fa56e3aa5c88a5689975d88b3fce053e525d44b54bff8f02d480df464b2477ea60f70e1dd65b52d3c0aaff1c54587e42fabb9a199148aeb6d8ae43144d9158df47fda1ce616eb34dfdbe363750891a9f8d43673364a1c25cc22f940bc41f4ee9d0edf32dd97c202dd12153757ab7c7fecfad482b8e643d98c4836537d6ca22d1f382a4cb8ab699195a3ae29b4fbb25f71f584bef04ab2b43173602ea904e7b32690c26f8610e8b2c8051c44721f6f688ade01205bdbf8d4dad5e748d9eb0520b14d3ad7b4d064ceaccf23f80034b1391afc66281df3db90f5da283893deae320c9ea15b94aebd6530d8fc779ac55c8909edb651a529b92db8d938abe2946aa8c72a14a6cbc3fd8edc04bc8fc79b56c3fd9c4cd141bea19c1262b030c2c36dc1376f70f471c44c0e86a0d9ddabc0adaa86da3858224b99f478f1412e4de77db28479ad08cb25881042d50f922c57896c0d228df1afd989ccb38c3c9c38f256a0828611b7ec6c89d27b6d2709a0f94e811dfd0f037851251c0ac13f25050c6101b5b9e106038e5edce63681df76cb232803818e734302886a75c8f2b851107b2846a2154b49e1d569cb18713a23aeb286bdc74ef362e5ee7224c42276ed3fe9caceda05d6ae45411f7c9600d99296ca61ed201839e58a93c214808c9b717bc3dee613516bfd1abf675783c8916206b8f0f902a3367f17f17fc8b0c56514c6fa89b65a67d562a51bda25d501228a0d337ff51fb00222297f1df8818b4b265efd60c9163d68172507ff25246dd8c7bde60bd5fe4764acf984981d3cb59ac537bee302a0906d38562d875317a8d9e3bacd3fcc74bc17ad8370fed7b5c557d3a7b9955a1263a1ca66812be77bc45e7a6ad4773be69fe466ffa0feee514b2b68a689aee5f5d63b71eea81a583fa15493b151b98b49bd8797d121c4fff905caea7d9a9fcefc1f1fef4fc4d53ecf630ad2b8c1e79f7dc23e04e628fc9f1b1e6c04b3008a28768cb1ec650f2ce0f59c766461965b0da57eeac1c9a60171705a3c1e694a34b5d380c1e2b6ba39cf5412785365a849957fd2bbb347942479bb9939ba374f5112e5ef78aabae84020371fd9417d20f3cd4aee33f9125541590016750e3ab2dc47281a7b2cf59dfcbe9cda0353a71fab92ce4e2618863f41ff97a306312ae10354c995378348f637841f83f51dbd3069163c28239eb37be395f424ae64eb955bee0ad1b19bf8aa2b75bad9ed6c83370f957b914ac8f3e11f3bb017e3a08bb94e5d7b4c08d54cac56e444c950952e15b72435445d6813ead7786e24f0903b00aa6e1c9af8c0387c7da7c9bd6ce602f44ea71e3404d867dd29907124930d4f2d80a42a17809fc675acd8c9cc89f43421d540bc93e1c5ded3128c1660485b6c18e13817786dd72af67bd883c327edb77a9b79da63af4d4551f52282d6ecd7f09c8d1012b77b612fd9e361401e4ca5bbfcdde8bee94959cac7b540255442a70d8751bd19ee0325789d69a27d5e57c579390192ca236c428a02083b9f49485573666c6ad1ab9f9161902f94844525cae1d519c92a38593b9211c2932d0c5a9fe8a46550414037c0e87c2bf69eea6f897d94aae7fb713ffa5362db7f98832488b8d631b3a5a0123b558848316e82072c50fcb095f2715c125e3685b376bdba7114303", + "msg_c": "df35f2bf79e57b6d89af0e05d64a52874602fa12ba072d160e7ff28faa7260122f76c6aaeeea16ec209ffa57aaab9eaf68790d07d6e49e64eda87adedddf4792", + "msg_a_bytes": 1216, + "msg_b_bytes": 1200, + "msg_c_bytes": 64, + "handshake_hash": "577325d811f9d5ddb7adcfceffd7a6bcc3571f1128c1f54eb1f3612e23e111c4", + "cs1_k": "f2405d2aa072c23d556abaa24f7443836060476345bf76d2d487110e8ef80bcc", + "cs2_k": "f549dd02dde0040f3dededa1adfd5ff6d1e1519c0c5a694c02fa36e628962291" + }, + { + "vector_index": 3, + "description": "Noise_XXhfs vector 3 — all keys seeded from base byte 0x30", + "static_i_public": "04f5f29162c31a8defa18e6e742224ee806fc1718a278be859ba5620402b8f3a", + "static_i_private": "3131313131313131313131313131313131313131313131313131313131313131", + "static_r_public": "59d9225473451efffe6b36dbcaefdbf7b1895de62084509a7f5b58bf01d06418", + "static_r_private": "3232323232323232323232323232323232323232323232323232323232323232", + "ephemeral_dh_i_public": "7b0d47d93427f8311160781c7c733fd89f88970aef490d8aa0ee19a4cb8a1b14", + "ephemeral_dh_i_private": "3333333333333333333333333333333333333333333333333333333333333333", + "ephemeral_dh_r_public": "ffc951aa6f2fa03096d1d1b579735b2f6f84019fe2f617aa65ff3d68705f2527", + "ephemeral_dh_r_private": "3434343434343434343434343434343434343434343434343434343434343434", + "ephemeral_kem_i_public": "92816f2d066f8d31208a565398f513451932a7056198f477ad12a682f70a7469aaded08c23ba650b62c005b55e138bb2bc39463cfb1e6613489f2733a53239ff1376ea91cfa4b214596a769c3ab16409130ba03860d94f0110bc5ec71dc18027cd230677a68e5a54abb250952680927a76b3f87c521b342ba78a83acbbc36f213f6532ca98d7999a05471ea7a42866b5b479a9d6894ad3f11565dac97a403c1a5831d2d0997802199eb6a29af37a42fac350a24b97042057d4c899037552e53022236694f8a4cf16c97823901b9195fc059b3b9408648cb0afba4450717669b74ae47ab12bb83fe8333f39350734fa78aca26022ecce56d8582b080d198391ac085f5a5940a1825fd9b921a30cc13be45c8caa661e68b0f0a83df80a5d7762a660d084d1e8a019ec2e1a5723dc0946c41c2f923c282130323859601f72a2b59a84b0e3ad49e516fb7374e943843fc6964280a55dc25ac678478c764e99d930201c7babd9c40b3c28e8ba5880c96c11a9a71e65c43903276be9a846f9981cb8126f4871ef93c56c2170ae369c925468d7b6c327ca83687b754399bae2a690c4c663bcd9c604882f07d065b2481d465a0dba1b55fbeb8534084c78d163b67c28e54b0d2022a760f924a246343e025a94bb08b23130980652ea9919267819e81339979291706b4acd283e7e58758cca5252fcaa6f0070134b09e12b305bfaa01a16524b513079d950f9f66ac887b59ef062e1908f7845c27b352fa4a46493a3aaddc2a4f7975b66e04c0d47bbb94723d71b821c4b8fabb881e737a39c3b54fedc7642795922e7ae80db5fd4d62d3a5a9f9977966fe67f71a90516b7aec6b39134dc0346daccde19c309f373bb359e4e2c154cb990f934251afc367f2a738dfb6186dca647e4257c1500344614206bc41e7c4ee80b3bb340450c8554464c3400d56d4cd42da1e0bbb67632ec324a4253886bc9614fac5e550476f4a714d7846067253046129c6ff283e49934c58c3be2393f35b4c244409c1b0148ed9269b9046dedb942b39163f5b8aad9b2778bc849bc382b74e49b650c9b61e0b702f149da785a25d94ad36792bd872ab672b13c8404f4204b87107b7ffc1e99923d65623222c90556d605dbfbb3e1f090193096a9975105b4457ab259ca6b7c5ec6859b0a3383954198707a2a991e7445af8669a246006883e510f45c585cb0682970abc70b3c27d8408155a8597041165a2622091492fabba4a065c8510c0b2b067a0788c35202e417c72ee30c388c3b95c8230db65df00b1e31846adf9a52a3ab49e2d1c4165c7952663c4953b016853166d6374fd66bd39454342495588595041587dd50b8ce883df6983b0b450aa0c904314781afc0be37e06bc3a677bc08794c6c8d646499cdd15e8a7976f780a03d49065ca06dc12b73d33390228c34336b86059a9b31e7b321413966741af2e3651970a10b33101373097289b9010bc9420ca2362899e1335ddf59062c140d1e0bb18d579656665eeabbc084f3b9a47a22a2c285ebb10707a48d80254eb1a581d35801a5eb1e6385546688b8d325b548caa3ed60a6f38cccb4d084098bcee5026aa96bc70085ae6bc175ecf96d39c09bdfbca88d2566e4649ff817c6d0fdba852e8af0b991e9afef8d605d99dabfb135bf08e61f32e2", + "ephemeral_kem_i_secret": "2a5a12f3d99650c838bfe122b969b645b01952176f2c577010304479b97bfdd7a1d2a64ef8f7808716406835ac0061205886b631a90242702efa8421cb7110802c319f7768bb092ef77687c2d624310c992404ce8bf870b69911d5a3b05ae8213d6958502b88584666431ca94495ca9d25992078a46df28a1ee38f51650675703a22426d0eb9b508238d60694707b71a149b144e1346ac8830e5156ece4b61ad198edfb82ec4a51d76116dbbe6727fa56bd089446c9b510f876b271715e8bb8b7f86544d1c0a87c52f5daa497007185aa03f4a2b71551ab07367c5cb40b6de86824acab1a9684e3a115feebcb38efbb3e54b7da0505b415ac61616017a9387ee0581304b324017be3094856633b77c90610601adede3c87fa27c0f992f94430119e6417d3365bc92b266bbc0048abcf40558729345547296ebd6c9260262d86006e5fa14d66b4caf605935b0bb43a0ce9155c782c9719f53650d495d3860a584167913f20e0da2980f3625186018e473c09e69752bc79903ca7294844c3c3c90c1c3ab98049b1edc3c79499fd97627d868b8e28b1479f081189a55185c90ab1bccb8b848a314b15b8a3f3cb815ffbc1cf4092ea96a8239734da7a8c03bbc57f78036646609cccc8836f83003303d6a7a6051213376da37d401788bb9090be98b6a43926f2a86fdcb92585528b3a11452d646bc93394b267ac07b3796d9600244992c734ba00714176839a794b1c792a36057835ad37a77c996e0927c7f575fee1b2d338a2f011136f8876d8bd575ba76111d109b32302f20701e032b1e6373b8aa55200db81a9c297d0ba9a84417a941c91f83abb16b1b2a2643bb84c173670641c4462acb393656145487a96aa5b15c02d754f0a30fab855723605e42cc660ea00dec0ccfd563a769b45ae1454090e393e7b16750610b7c57878caaa89a8414d22514eb799599459d4bebb8f0e1a90f5aae9850786d1ac22e921fadfc2a344b937c1aae7d940ea10c7acf1b1e0643bcf6521b4c7c5258c60139b4b768741a95817e6e20bf429375f0339e3fa55cfd53a55ecbbfe36b277b66ac3cb828e2845705c0806a82654980ba0d432f2b80a7f1ba365abbbf8e7c4647121229014d3699674c15af885c49d2d22be40ab6e8991e4c643dc94979045b2f120770eb1b6a94e58a3e28ad971693e9b5a1fc6a9318e334b397336da23e7cfbbd774277b07c188614c81ed21b0cd1c4a9300524ea24c8456b39620c3dea221956aa27406e5f431267f566fc380f9f769a3e33ace3d4cdbb021fee4a9b65903aa764928b076dd5a853e9232f67e6b185ab6c4f7c4cd6ba06ed4952b9a23be8a021e1c0582217c8b6f740a069531a0a1e16863b91a7be16a92858b62131189560823fa3d769693784622a27cd9a2d6171a2eeec1bd6137532c59c8cc038d8382b3c835f01ac08a469aeee6c77da127055c31c8ad94aa8f8565eb96d86c57f57e8b723208868f467a8652c61524986637348972059a3c2ddc38c4573157ab2c61df904c82700df72959d38071a146143fcafd072b3e2b4ac59758b5a3c1e3f0489a721ca5a331be405255c4aa5e0e642afbb7182212e45026016d7526e7571df79aeddc67dfab7a692816f2d066f8d31208a565398f513451932a7056198f477ad12a682f70a7469aaded08c23ba650b62c005b55e138bb2bc39463cfb1e6613489f2733a53239ff1376ea91cfa4b214596a769c3ab16409130ba03860d94f0110bc5ec71dc18027cd230677a68e5a54abb250952680927a76b3f87c521b342ba78a83acbbc36f213f6532ca98d7999a05471ea7a42866b5b479a9d6894ad3f11565dac97a403c1a5831d2d0997802199eb6a29af37a42fac350a24b97042057d4c899037552e53022236694f8a4cf16c97823901b9195fc059b3b9408648cb0afba4450717669b74ae47ab12bb83fe8333f39350734fa78aca26022ecce56d8582b080d198391ac085f5a5940a1825fd9b921a30cc13be45c8caa661e68b0f0a83df80a5d7762a660d084d1e8a019ec2e1a5723dc0946c41c2f923c282130323859601f72a2b59a84b0e3ad49e516fb7374e943843fc6964280a55dc25ac678478c764e99d930201c7babd9c40b3c28e8ba5880c96c11a9a71e65c43903276be9a846f9981cb8126f4871ef93c56c2170ae369c925468d7b6c327ca83687b754399bae2a690c4c663bcd9c604882f07d065b2481d465a0dba1b55fbeb8534084c78d163b67c28e54b0d2022a760f924a246343e025a94bb08b23130980652ea9919267819e81339979291706b4acd283e7e58758cca5252fcaa6f0070134b09e12b305bfaa01a16524b513079d950f9f66ac887b59ef062e1908f7845c27b352fa4a46493a3aaddc2a4f7975b66e04c0d47bbb94723d71b821c4b8fabb881e737a39c3b54fedc7642795922e7ae80db5fd4d62d3a5a9f9977966fe67f71a90516b7aec6b39134dc0346daccde19c309f373bb359e4e2c154cb990f934251afc367f2a738dfb6186dca647e4257c1500344614206bc41e7c4ee80b3bb340450c8554464c3400d56d4cd42da1e0bbb67632ec324a4253886bc9614fac5e550476f4a714d7846067253046129c6ff283e49934c58c3be2393f35b4c244409c1b0148ed9269b9046dedb942b39163f5b8aad9b2778bc849bc382b74e49b650c9b61e0b702f149da785a25d94ad36792bd872ab672b13c8404f4204b87107b7ffc1e99923d65623222c90556d605dbfbb3e1f090193096a9975105b4457ab259ca6b7c5ec6859b0a3383954198707a2a991e7445af8669a246006883e510f45c585cb0682970abc70b3c27d8408155a8597041165a2622091492fabba4a065c8510c0b2b067a0788c35202e417c72ee30c388c3b95c8230db65df00b1e31846adf9a52a3ab49e2d1c4165c7952663c4953b016853166d6374fd66bd39454342495588595041587dd50b8ce883df6983b0b450aa0c904314781afc0be37e06bc3a677bc08794c6c8d646499cdd15e8a7976f780a03d49065ca06dc12b73d33390228c34336b86059a9b31e7b321413966741af2e3651970a10b33101373097289b9010bc9420ca2362899e1335ddf59062c140d1e0bb18d579656665eeabbc084f3b9a47a22a2c285ebb10707a48d80254eb1a581d35801a5eb1e6385546688b8d325b548caa3ed60a6f38cccb4d084098bcee5026aa96bc70085ae6bc175ecf96d39c09bdfbca88d2566e4649ff817c6d0fdba852e8af0b991e9afef8d605d99dabfb135bf08e61f32e26477a0a1a4af19173bcec94302357abf994c1e263bafeae9839f6a3c331285653535353535353535353535353535353535353535353535353535353535353535", + "encap_seed_hex": "3636363636363636363636363636363636363636363636363636363636363636", + "prologue": "", + "msg_a": "7b0d47d93427f8311160781c7c733fd89f88970aef490d8aa0ee19a4cb8a1b1492816f2d066f8d31208a565398f513451932a7056198f477ad12a682f70a7469aaded08c23ba650b62c005b55e138bb2bc39463cfb1e6613489f2733a53239ff1376ea91cfa4b214596a769c3ab16409130ba03860d94f0110bc5ec71dc18027cd230677a68e5a54abb250952680927a76b3f87c521b342ba78a83acbbc36f213f6532ca98d7999a05471ea7a42866b5b479a9d6894ad3f11565dac97a403c1a5831d2d0997802199eb6a29af37a42fac350a24b97042057d4c899037552e53022236694f8a4cf16c97823901b9195fc059b3b9408648cb0afba4450717669b74ae47ab12bb83fe8333f39350734fa78aca26022ecce56d8582b080d198391ac085f5a5940a1825fd9b921a30cc13be45c8caa661e68b0f0a83df80a5d7762a660d084d1e8a019ec2e1a5723dc0946c41c2f923c282130323859601f72a2b59a84b0e3ad49e516fb7374e943843fc6964280a55dc25ac678478c764e99d930201c7babd9c40b3c28e8ba5880c96c11a9a71e65c43903276be9a846f9981cb8126f4871ef93c56c2170ae369c925468d7b6c327ca83687b754399bae2a690c4c663bcd9c604882f07d065b2481d465a0dba1b55fbeb8534084c78d163b67c28e54b0d2022a760f924a246343e025a94bb08b23130980652ea9919267819e81339979291706b4acd283e7e58758cca5252fcaa6f0070134b09e12b305bfaa01a16524b513079d950f9f66ac887b59ef062e1908f7845c27b352fa4a46493a3aaddc2a4f7975b66e04c0d47bbb94723d71b821c4b8fabb881e737a39c3b54fedc7642795922e7ae80db5fd4d62d3a5a9f9977966fe67f71a90516b7aec6b39134dc0346daccde19c309f373bb359e4e2c154cb990f934251afc367f2a738dfb6186dca647e4257c1500344614206bc41e7c4ee80b3bb340450c8554464c3400d56d4cd42da1e0bbb67632ec324a4253886bc9614fac5e550476f4a714d7846067253046129c6ff283e49934c58c3be2393f35b4c244409c1b0148ed9269b9046dedb942b39163f5b8aad9b2778bc849bc382b74e49b650c9b61e0b702f149da785a25d94ad36792bd872ab672b13c8404f4204b87107b7ffc1e99923d65623222c90556d605dbfbb3e1f090193096a9975105b4457ab259ca6b7c5ec6859b0a3383954198707a2a991e7445af8669a246006883e510f45c585cb0682970abc70b3c27d8408155a8597041165a2622091492fabba4a065c8510c0b2b067a0788c35202e417c72ee30c388c3b95c8230db65df00b1e31846adf9a52a3ab49e2d1c4165c7952663c4953b016853166d6374fd66bd39454342495588595041587dd50b8ce883df6983b0b450aa0c904314781afc0be37e06bc3a677bc08794c6c8d646499cdd15e8a7976f780a03d49065ca06dc12b73d33390228c34336b86059a9b31e7b321413966741af2e3651970a10b33101373097289b9010bc9420ca2362899e1335ddf59062c140d1e0bb18d579656665eeabbc084f3b9a47a22a2c285ebb10707a48d80254eb1a581d35801a5eb1e6385546688b8d325b548caa3ed60a6f38cccb4d084098bcee5026aa96bc70085ae6bc175ecf96d39c09bdfbca88d2566e4649ff817c6d0fdba852e8af0b991e9afef8d605d99dabfb135bf08e61f32e2", + "msg_b": "ffc951aa6f2fa03096d1d1b579735b2f6f84019fe2f617aa65ff3d68705f2527309da9a93709e5472f52afdface3ffbf4644c9b88dc134fe02487a4da3638bb17eb825d404a0a4e68c62e770f765e4488e1403f6b69dafa50675efae7a161d1284a09508dfaedc25fb2f5a97d7a902ca9674043eef8f7231557649416bc4310bd08e78f48e757d216db21e00e5dfd76dec4832b30b9360afdd358f3f22203a007190f3b093f626af6395bacf504374076f10bd97a0cfc788f8c0822aa5cebfc6499792cf5d7160feb0ab0e7506a0ce21a4b30bdc4d880f5c21b70030cabb50261f9efe230a59cb84ab697bdb47456a1df6ab9d932035629d4d1b83d5f4fab7082740cc6425f71203ee966f2d0a0c13064f570e180a74e3dc13835e3beab663ea4d27c3a4003d01ccc5f1b1e83c1a26003cbb9c0662b067ffd08dd8effb2574433921045a2edbce34fc8ed53ea65b2c2a879cc66a4442e63080ebdb5a00f9b4586490f65343c8459c7830a8ac539af9ec3836b044bfdc204617718d5b3ee0cac335789a5cf8a6e0f0dee0d8bbd13f47aa3c6176f699b7531313daaaad61fdf073e0eae238855060609cd97f697d727c0bbc91da9dd290776e2d7f3dfaa516eb23040911def2d26f09a5d4f23b5cabfd21552955284c2eb9dbd0a6b85d109fe561274a65c0e8ca9c849b95f006793ac29603b3db009db97e83116a300ef4e4410c43cad5f32d0d442369ee640d4d1918f11dd4fd27acdfe0e9d3770d50ad599849b42b2c5a9083b252cc993ad22e262ae8e6ac56c34bb3888ab8dbeddfd798ddf75b389923555e13afef1921af7a696d174be216e5f1c47d2839380df2d362aaf7090afc01d684993a405cd047a3d8d462f8d2c277e5424c4f1bf5318dd64af5e48e7436f5cab9a7b4b9fdd978e13627a39083deb8d7e1d3200740f2a527d670da68cbe58517690fd7900e57b629439ffe223b2bb35281a5cefcac3809485394fb49f98e09c4210e66085aad9e69b9927dc754fc8eb62e9074f56f28aebd64b56d03365219bb82bcd492e719db0883ffd441fe1ae017bb62982fa79b76349771a1c1a84739c559c0aa5ca3901b9582de5eea525b7bdbce829ebc2609068f1661470ae78e21fb2c8ec948e79915d94acbe638db999dd67c475669d42ffaef7b95d9da028ae5aced7173665ba0ffb3939c0cc781b71b80037a51a1c68df115b550eef53f56d50d99b835b36641a14351443afcc1d2128bc361aa5fdf1eb3d0dd681ca99612f21e76e8d9eb67b88a52d9c42dc9cb5b4878b1b9e66197e8dc9a5919d108e78d886dae120063d9875a52ca9692c0ac091793eab066f043b43d183c5c5217bf809a80697529b19ed5251a2ae2a69218886ef6469811f9189c7d329880ab19c5a23b60b85b3960b9cc0f16f3f15e7fef2093b8ad0d448e58198221c9de9a28110266fc708549fb57579ee58c9868de9e25fdd65e5a25776f44613043dd7daefc4972453c9a58cae1fa47683791189ccb16885f6cf8f6d9c35eea72c533172d4300625c319b8c66743ef0b6f9e7fc8c309f53a7b6eb6e54ef324a87620ada1000ced5aa7a4ea2229e53a179b87e3f94880c62388c9d1a5747d03c6b79c5dd49a4ffd10374d0d92c4cfd49f073abc878372002645fc9b8dcba101743f18cf3af45f5d4079b354b7bef6aa6343f0add", + "msg_c": "52ab9734672240d1e780c55787fe4a9609159dceb3b138673ae2cdce3c4bde1c8f6385fd2ebc05ab56c0a0b4a8ee1196d6219c3a8bf33938eb25367451527250", + "msg_a_bytes": 1216, + "msg_b_bytes": 1200, + "msg_c_bytes": 64, + "handshake_hash": "b1da5746b0b26aaac64a5f61a251f530dab713ab33e4d33af9348379bf34120f", + "cs1_k": "3b8e4ffd64595bf640b1b964906e187a4e422645d0ba1b4c9d8a5836fedaec57", + "cs2_k": "46ab1e20bf72c3d6bc2cd6b9158c40a74bc08cdb6cedae63b83550071a1aea02" + }, + { + "vector_index": 4, + "description": "Noise_XXhfs vector 4 — all keys seeded from base byte 0x40", + "static_i_public": "7a1a4e709bf085ac494aba0469b9b1eda0ab1f78b16aabb79ffeda90623e8522", + "static_i_private": "4141414141414141414141414141414141414141414141414141414141414141", + "static_r_public": "132c442be010fbd57e72603328aa76e71fccc1503aae219327d14d9c9993f472", + "static_r_private": "4242424242424242424242424242424242424242424242424242424242424242", + "ephemeral_dh_i_public": "cdefd8783a91b446640e2e1f95599db35e484a0071bd2182b3b60d0812c10c70", + "ephemeral_dh_i_private": "4343434343434343434343434343434343434343434343434343434343434343", + "ephemeral_dh_r_public": "ff2ee45601ec1b67310c7790404585ae697331eee1c1f8cf2419731c1fff3e6b", + "ephemeral_dh_r_private": "4444444444444444444444444444444444444444444444444444444444444444", + "ephemeral_kem_i_public": "b721b37a39842d837d65f61376545810672ca9da91cc733a61908690188b0827289212324303ad85aab057b006a4c099ed207703a57f89d5cfdd902046d7338e845ba1807cb9f4331710c134e13987e3612c143a162c73b93850fcda28ef81859d60262c40502d9c6f1ac52180f31d63c29cdab7070ebba1fd2816b506cc4e0bad99905b6f572d8d3baef30437e99862c379c310ea4ddbd1a1e73695b294590c80743c0ad0ffc57711014afdeaab386623b0f3680da09feda834d0c6bf4eeb7ca9405606fab57b1943b6b8584541c64b5b989e1a8e8ae28f51d372abcb2086202fea58891073b775b5a40d6801e4ea35880a499929c638d54d08d03ef99954494357b1b9198e95c882943bd743890c65550559b61ea789db02662d5aad6989136384b773a57ae779c43c062fca11955ea24d7dab282038a53451224bab5fa2bc662f36ab172a67350b24d219272b6b813cf8a4e9da59a6ea53da251120c23627101b0b34019517b627f4a0c676ce1b4c2047f3c030fc58992637bb3465c3a343a3b059591cc1cae67f3134830e13a6d01a3b0688b06f372da8852286281cf230adb672867c295361ac5ca383020ca23ba45b8726176683d8120014048d454809778e02f3b4d86b88cbf66558514e97298f09d9cf3fc19dd9082bb21c29339a2f8f1240df3c906b1b2b01c95a8bc77ec78253e98357b9db956504c533e01a02b6b5f5e084a0b7bec7d3249248238aea6032e2c774130985f28e7da04444ba43297353234117afe0c98c82391aa1389c060a7c9576b3e9b4b75852c4d2459c34019ecb515f2bc5b1961c8e336c762a72a7c7963a0a6e74916473200f81231edc46bbe223604626ce37b498a46c9dc827bff9e50c31402ee3586690fb29e8b3478eec42a2171c9a133ce45a1f07e17430d86fe86a701ee5ac8c15c66d33a644f79ede300490c27e85527997656bc953934231749ff87df23ccb89957b4835c93fe08cfa3916770637a56123f5958ffdf2481d995fc23532f6a6526ed22553fc442e1815e4397f84326a2bf9a61b1367d8e2c9e4c0563854aba792cb05e31ef57ba3633976de50c4f0e1b35ea37b93416ea4b96fcc77cf05b44e1b615b0fcbc7a67a3f24958799d561d56565b12c65d3c7b9e91a5f5595ccf00a33b675bce77c61bac87817b563ea899cef096ada8352c77807bfe4b48f7c34992a275e4bbe79c359dc52652e631f411c6f6dd5a138784924bbbec5251e43853becf771ab666f67659d5e8c73424cc5802c307790b0c4d061261008fcbcb2117934e48a277bca3c64e19a75c3896fe69e0b01b946b66b5f3020154354dfc3a81d8a9ee9904cb6a37141412a0051c5c987ce88f504174962ac95a2af5930e069b267a1810628086e3b9ee2eac1ed07ce9419b6f52c6c80c9ceeddb80beca5b43888b92dac94474c69f1b50d7b6a5ea508ad585b06f939af207576ba89c9108672769b18460c366dbb507020619d0627db5617b690351eb0c3b09b4b8b11f53a2a046001ae2ea89723817bcc0430be6383ff772d78006c62855afdb6f6c944a1167935c6664ee143dd70c556fb34e06c91d3e788826b5998a08a2f6094a6a73879ad903728c68777b8aaadd02f378a1f3945683c51a96f110f214cc35462a26fae49e96344bc7a7235c", + "ephemeral_kem_i_secret": "b4f974fc669e3b864702d28f836034c27b5e63159f43f030477c90605abdc2942c75435d94d64b30848436736b0c152bdc4208dba6c2dd5c942e616cc925a208a6b158935f8790a4517b045b679962a27c9c8c938c725324225b35364781c8778facc664470fb5d7c9ad506025fc8f94282269a19477335ec3fbaa1181351fdca51abc85c4d422de8325c2156b2d35068498993e24a7140431ccab4238171d899b6971a98a2c072f2ad31eee452a8d2676e356c459d508596babad0924ceda1c2d77b929b25d6de55aab83097e1051e3b684ffb7a46369580ce0113d68384d52774fd72b347030ad3b6f1da43becbb9c09a6c95d759c3d5049f1799a7999ce47cc1e59649df81cb57f09d0b18770e4e444d36929364bbfa1851cf32c8a332c33f70607db488f0f178cdb3a5177e7aff1e1718f59670092a1445a5de80949ffca13492b19f2634af64b4f935c27063665b712503f765e4ee89f6a48a5c7b122305933a29a96dce30b4e2b32d9ba47f6a89afaf1297554157b591cd57acb15e185245021cce74947d43882979f14d300043888cb836b2918baf6146a8cf965d074986c03044e0c46f9d88458bb722a20523b90363ff67e540607bb0aa0fe4c48fd61b8b2f00b9fc7925198a394c52ac024b36f33a588d477bca1823e9b2e3762c725d0a7ec90226f4960d3721980e33fc2d84c02a70688e8243711cb0e910c5a8628267a97469a487c6b6ae5990cfafa1c43f34ab0fa5df4a759e7d023574414cb65cfb167a53bb9029166ad9d8901400cc9f41a1dc511bff706caad3261c57cbfacaa0f2e83b63a5a30d70682bd645371f7cd68b2274a336cdd0021eac7400e57bc4ea11d09462ad2cb1c261102e1b1685b53927359c810451ba071000d52b7e5d9a64eacbbea4710b4d75222169407d291c9042026115fb9ab806514c8252835db35c7f9bc21cf856c79572162463ab854b597988bc3f142f5e122c2a108f7f7c1acc801a1636b7fa406da71821bb438ee32c1f4651c68140678f70cde5607e55152a61820cf053a47d1517efb35fb39a10b0476b907085f1c30a65073d42c811eda9afd3b4bb767584ca297e276841c3ba1c887ac7e5895824abbc3aaa69fe46d527708936b646432bff96a98841459ff952770b9327e71080fe317d4f950dde24075454730f279b9545e9d0210d166a8c803b03968559e5ca047f1c4ddfb5f8b15b781065d4a6195bd99b7922c5994104bfd1955cabb043241b1ee649048721ce4861cf06489dc935c0a4088d361a56c99406367b6ae22863de1665b846ced8cab8f72c41029800363272ef64949dc360f9714ec30666b31170a373df6bcb797057f53e5b992670822142ba1a288aba6ad982c10dca17351c5b48fd2a38d367319393cc307a87fe3aaf27a3cd052aa04546213a5baaf27751e33ad331cc1227c3ab020388c46ab2c12562881a44a425938e27dfe9833dd4329b3ab3365103750055ee4e01adf768cf0f3b05c177f4203558728ac7a74b8bdd86aa241824cacb245d8b8bdfc1e292310e2c4ba88eaaee4f06b447165051b3fbb50355c1264b20166ea39c83421249deb9241eab10b875ed753abd3a74aa51a91d074a35a5615b721b37a39842d837d65f61376545810672ca9da91cc733a61908690188b0827289212324303ad85aab057b006a4c099ed207703a57f89d5cfdd902046d7338e845ba1807cb9f4331710c134e13987e3612c143a162c73b93850fcda28ef81859d60262c40502d9c6f1ac52180f31d63c29cdab7070ebba1fd2816b506cc4e0bad99905b6f572d8d3baef30437e99862c379c310ea4ddbd1a1e73695b294590c80743c0ad0ffc57711014afdeaab386623b0f3680da09feda834d0c6bf4eeb7ca9405606fab57b1943b6b8584541c64b5b989e1a8e8ae28f51d372abcb2086202fea58891073b775b5a40d6801e4ea35880a499929c638d54d08d03ef99954494357b1b9198e95c882943bd743890c65550559b61ea789db02662d5aad6989136384b773a57ae779c43c062fca11955ea24d7dab282038a53451224bab5fa2bc662f36ab172a67350b24d219272b6b813cf8a4e9da59a6ea53da251120c23627101b0b34019517b627f4a0c676ce1b4c2047f3c030fc58992637bb3465c3a343a3b059591cc1cae67f3134830e13a6d01a3b0688b06f372da8852286281cf230adb672867c295361ac5ca383020ca23ba45b8726176683d8120014048d454809778e02f3b4d86b88cbf66558514e97298f09d9cf3fc19dd9082bb21c29339a2f8f1240df3c906b1b2b01c95a8bc77ec78253e98357b9db956504c533e01a02b6b5f5e084a0b7bec7d3249248238aea6032e2c774130985f28e7da04444ba43297353234117afe0c98c82391aa1389c060a7c9576b3e9b4b75852c4d2459c34019ecb515f2bc5b1961c8e336c762a72a7c7963a0a6e74916473200f81231edc46bbe223604626ce37b498a46c9dc827bff9e50c31402ee3586690fb29e8b3478eec42a2171c9a133ce45a1f07e17430d86fe86a701ee5ac8c15c66d33a644f79ede300490c27e85527997656bc953934231749ff87df23ccb89957b4835c93fe08cfa3916770637a56123f5958ffdf2481d995fc23532f6a6526ed22553fc442e1815e4397f84326a2bf9a61b1367d8e2c9e4c0563854aba792cb05e31ef57ba3633976de50c4f0e1b35ea37b93416ea4b96fcc77cf05b44e1b615b0fcbc7a67a3f24958799d561d56565b12c65d3c7b9e91a5f5595ccf00a33b675bce77c61bac87817b563ea899cef096ada8352c77807bfe4b48f7c34992a275e4bbe79c359dc52652e631f411c6f6dd5a138784924bbbec5251e43853becf771ab666f67659d5e8c73424cc5802c307790b0c4d061261008fcbcb2117934e48a277bca3c64e19a75c3896fe69e0b01b946b66b5f3020154354dfc3a81d8a9ee9904cb6a37141412a0051c5c987ce88f504174962ac95a2af5930e069b267a1810628086e3b9ee2eac1ed07ce9419b6f52c6c80c9ceeddb80beca5b43888b92dac94474c69f1b50d7b6a5ea508ad585b06f939af207576ba89c9108672769b18460c366dbb507020619d0627db5617b690351eb0c3b09b4b8b11f53a2a046001ae2ea89723817bcc0430be6383ff772d78006c62855afdb6f6c944a1167935c6664ee143dd70c556fb34e06c91d3e788826b5998a08a2f6094a6a73879ad903728c68777b8aaadd02f378a1f3945683c51a96f110f214cc35462a26fae49e96344bc7a7235c093be15be24774179f27694dd9f1fc0bac548d074ffd56225953dcc7f8da2a8b4545454545454545454545454545454545454545454545454545454545454545", + "encap_seed_hex": "4646464646464646464646464646464646464646464646464646464646464646", + "prologue": "", + "msg_a": "cdefd8783a91b446640e2e1f95599db35e484a0071bd2182b3b60d0812c10c70b721b37a39842d837d65f61376545810672ca9da91cc733a61908690188b0827289212324303ad85aab057b006a4c099ed207703a57f89d5cfdd902046d7338e845ba1807cb9f4331710c134e13987e3612c143a162c73b93850fcda28ef81859d60262c40502d9c6f1ac52180f31d63c29cdab7070ebba1fd2816b506cc4e0bad99905b6f572d8d3baef30437e99862c379c310ea4ddbd1a1e73695b294590c80743c0ad0ffc57711014afdeaab386623b0f3680da09feda834d0c6bf4eeb7ca9405606fab57b1943b6b8584541c64b5b989e1a8e8ae28f51d372abcb2086202fea58891073b775b5a40d6801e4ea35880a499929c638d54d08d03ef99954494357b1b9198e95c882943bd743890c65550559b61ea789db02662d5aad6989136384b773a57ae779c43c062fca11955ea24d7dab282038a53451224bab5fa2bc662f36ab172a67350b24d219272b6b813cf8a4e9da59a6ea53da251120c23627101b0b34019517b627f4a0c676ce1b4c2047f3c030fc58992637bb3465c3a343a3b059591cc1cae67f3134830e13a6d01a3b0688b06f372da8852286281cf230adb672867c295361ac5ca383020ca23ba45b8726176683d8120014048d454809778e02f3b4d86b88cbf66558514e97298f09d9cf3fc19dd9082bb21c29339a2f8f1240df3c906b1b2b01c95a8bc77ec78253e98357b9db956504c533e01a02b6b5f5e084a0b7bec7d3249248238aea6032e2c774130985f28e7da04444ba43297353234117afe0c98c82391aa1389c060a7c9576b3e9b4b75852c4d2459c34019ecb515f2bc5b1961c8e336c762a72a7c7963a0a6e74916473200f81231edc46bbe223604626ce37b498a46c9dc827bff9e50c31402ee3586690fb29e8b3478eec42a2171c9a133ce45a1f07e17430d86fe86a701ee5ac8c15c66d33a644f79ede300490c27e85527997656bc953934231749ff87df23ccb89957b4835c93fe08cfa3916770637a56123f5958ffdf2481d995fc23532f6a6526ed22553fc442e1815e4397f84326a2bf9a61b1367d8e2c9e4c0563854aba792cb05e31ef57ba3633976de50c4f0e1b35ea37b93416ea4b96fcc77cf05b44e1b615b0fcbc7a67a3f24958799d561d56565b12c65d3c7b9e91a5f5595ccf00a33b675bce77c61bac87817b563ea899cef096ada8352c77807bfe4b48f7c34992a275e4bbe79c359dc52652e631f411c6f6dd5a138784924bbbec5251e43853becf771ab666f67659d5e8c73424cc5802c307790b0c4d061261008fcbcb2117934e48a277bca3c64e19a75c3896fe69e0b01b946b66b5f3020154354dfc3a81d8a9ee9904cb6a37141412a0051c5c987ce88f504174962ac95a2af5930e069b267a1810628086e3b9ee2eac1ed07ce9419b6f52c6c80c9ceeddb80beca5b43888b92dac94474c69f1b50d7b6a5ea508ad585b06f939af207576ba89c9108672769b18460c366dbb507020619d0627db5617b690351eb0c3b09b4b8b11f53a2a046001ae2ea89723817bcc0430be6383ff772d78006c62855afdb6f6c944a1167935c6664ee143dd70c556fb34e06c91d3e788826b5998a08a2f6094a6a73879ad903728c68777b8aaadd02f378a1f3945683c51a96f110f214cc35462a26fae49e96344bc7a7235c", + "msg_b": "ff2ee45601ec1b67310c7790404585ae697331eee1c1f8cf2419731c1fff3e6b4d3dcd2811a58e0db7209c4e8319f3355c521578724d59e7a3a454fd405bfb97498181273b6f1ee96322949b8394b55cb13ed613496f77239e2893668f7b925e946b4998d6814f22d08ebfb89855191870b2c0ee00a81039aa0acdb93750ff6d5b897b40c14c156244c67ed268283e18aa9698dbe881e9202a8a2e952a4b003cf36834e2fd7c1e9f3c4a6ee931214dfa259a381326a948a69f3632110c083db115a8d8a65d26068211ccc13aa7d31f2c6c994c662d8d790a10323df852aa8e7ba1229bff26a67a85f1c2583f2aacfc2f804657b1b4640fb4f82261d0e69970a348788494d204805156ab04f9a9ebc0fc017e818ffa73074b09387ec05f16e68b522e03f1fa547a615df9422eb3ed37e25611bb8aa45f434325dd4e911e43d2140d215999f396dc4e68f4af19c9fd2b4dfc8d2368bb110ebaa5540d8864caedfa02e8a825ab677b14e13754a04f270c41de716c9276fa98e8d32f945b7c3c3d4efbd8c021d7ac681698de42f69e74cc9b692ee9dd796064fd1ef50d4c35205fdbe7056175993dea08041837bc005ec34c6d140d7423cdd4b38a55203d70e92c0ed48a6969d771802c59af6cadf6b018458172e25152b17fe748240e9094578340c42ed99247a3748674f7f8be2842c27039303ca880c9bd2580ac7929a893cb50a624639db00c525561e0525612361d53a0e92a4d50c5a1314290e370ca82f8bf99cb3ee9037b1bff995bb0cdf234400524d2156328139ccfdb41a20733631f0ddb0614c751b6ede7bfdf0a5ee88b15e3b0f00549dde965787ed952ea0ff93698fd9678b73e3528ff46fbcf6b93a8dc7daf1a005b2911dd683067cebc86f135b45420b79551703aeba53293f9bc3978862ffe56081491316510337c4b798df21f786461e649a3292e740bb28f5f026a12e7df8eeb6e4eb5621201c0c08010dd5d9c4054fcb11030e25c1e3dec10cc6806660595606a7bc6e35f00f775d201fefd1e9a7ee6f7f24a94ec20a49d49c7485161ebeba496e814ce1e92bad929f8ccdb4c148ce1c448c714cf0ae7874c5d3369862ea0f7836746b3744aa193399e28ae3d528ef58f0da2f74f15f67f1083bd2ba4b69a0a0ba22fec29cf7b9f169e73314c33bd4315d78e0c1d45c9a177fceab6f19d5672592b3f5c440ad17eb2c50c70cfcabf5fe6271424bd1d0271368242e6d889e1b0758608fa4774bc456c153ea858b3da6289115d9e22422afe4c8d45a2af09d1c7321afe5f0ecdffbfa712837e0ed566e96db503fd5ab4270d59133b46aa0d675d46fb7a53dcc937c6483aa0a2c90930d3f68c83ca66e4359ac37ef58e027ff0f551ee589add528f40fb50cf30301e05954e621bee2386eb3b149688b28711ea545304a7640da1d0b68e1d872ef5ea2050004e806f83a340e0786564d51488da3de37cbad68628b4c9d817e068bf8a92e70b62baac2e47575f29c272ebaa43443d47cf0be7050929e8ef5f349b514eb668117324563415a9d98b41c88811c8466fa26e2cbcbfcf5ab7ed6606198b602ea91b2dfca00c949b9dafdbccaf4c220981f0dd1e590bd3240444e6670ddc4d91e9e3fc4ea68cb9b7ae266938f7b406299e2bdc3577d377faa8e161df95e4f2d15e8fc80fc1bd7b53c8742d8e77", + "msg_c": "bdb8a5f3c3e4d697614b9e774dcc777b40a4736523798e25b18e082812ce3d85ba8527bb6dc5a43ab9d5ddabc88298dafdbcb78f0912b4ab7a2fa93f40a087ae", + "msg_a_bytes": 1216, + "msg_b_bytes": 1200, + "msg_c_bytes": 64, + "handshake_hash": "8dba1d14111c067a030d21cece8b7badd4d35923ce06f1a1a713c10bae86624e", + "cs1_k": "fe5d1ecff40defd5270a389fcf32df5a983b7fb31207faf2fc1e40648099cb7d", + "cs2_k": "557e1fa63637c7a0a743dcc37dbbd7665b8dab7ccc70dfdcf8be863c937c39dc" + }, + { + "vector_index": 5, + "description": "Noise_XXhfs vector 5 — all keys seeded from base byte 0x50", + "static_i_public": "ad908a8a708aca07588cda7c4ed3e44d4966a80a9abb2f1e4bbac53c67414e34", + "static_i_private": "5151515151515151515151515151515151515151515151515151515151515151", + "static_r_public": "f68b05ba03f7185e1ba88878682f8dd0b15158f6050889c9481d79c2d7d2fa07", + "static_r_private": "5252525252525252525252525252525252525252525252525252525252525252", + "ephemeral_dh_i_public": "261cd9cd2e935f9c2455876a80f02a4d6786b8ab877f07227737ca0b577bf161", + "ephemeral_dh_i_private": "5353535353535353535353535353535353535353535353535353535353535353", + "ephemeral_dh_r_public": "94e9c71ccacddd2c6fbf529e263f0d39baf0fed469de0d227d24ad81a4394b70", + "ephemeral_dh_r_private": "5454545454545454545454545454545454545454545454545454545454545454", + "ephemeral_kem_i_public": "12ec5425115d3a565e93cb462979079a28c33b7377d456873e743e3e3279b1d6c3ae0c988305ce4e725cc9f001930594aeb9a592862f7d930735566de8cc77baf3a2c8b585ba0c414d586f2d478fae77b48329c2b50a78fed476ee88945674039885b1ce2524d1072d922aaf78fb7611c1762bb19bbec81da769924c6b350b32a7dbeb4e2cbc391f061dc9b05af1da4d493773e308b9ff80a95b3453ab6953b84a786e26a785b9c7301010921cc13202a73bab2bf52b836e49aa5e3baa39e0580938a7c212663ed87bc9d1538881ca780453c123aa733556718778415a74d8c75a39635e3d78b03f50c452ba64ef7c404a455f48a2595a939a9db589f155410098beeef27e380b9dafcca0a4603700584c8122aa42ec2c7a6b7d58ac3f79718f17f5113efaab68d29574c7b0813912982985e023338293925eb72cd835ade3ab4c39840316c48af5482deefa63e955608e8a5f9fe354d02a92f32b4c6bb679bb081d7b904d7eda033029347412968379252a189fc58807b6bacadeb96e7e9a2ad4e57e9564ba3b920ad17ac20090b021c12f42b2651e816c36b1481f370bbb221062925e15555496d7481dd976d6557b94c933330358d4f1821588313b78c21e35a55859af7cb406157b77378062d92c2c163466c7a2bc080108d3f945e3c6c4cb0982c33cbc6dea16bb867ea01953e7c6545e79c6273a255f81cc3c70982fe4884e9137c7cc8670069a99b6511be33244d0af0330c544e554d047c1616b8f73ab36f1b89999747362417bb57c71b8e779600cbb845224f0d389c650738475222eab495dc99b94a2a546a9cf20077f33833eed3651d4500b85d5577eb1cbcf424a118bcc15496ae930264a61ba39828e344c249831092f580ccd66361202c5fd12c445bc1a7e02702aa62cd030518fc67d867b864c8449eac69584893a1873382258bb1d2bc03fc98ab4877a5684c895a8b797b552e8b6a656808729a3b18a20208d825323f14abcf2b8be1226b3b564fdd15b8022204d934ace106d741bab577b3c0c3299f4a3a90c9b59e882109bd91a601409b705905e78a5c587b062c894ca537744c46785f2cf34c8c6685b69ade45b918ca1ee9a8ad726323b928c2af6568eec19aa13cdf72531772c425667b327e15e6babbe97b679d98a7e50300bf0c4a71df342fd161f8940876ce66683c898695b4f149338b3b9921738c0475c50874b85049c256b510ae18985b3c4c44722126a794c91e264694c1f1c928aa2d57f68497faa3468ae97bbf33b3cd2d2aec7315fcd0265ba0a4e3557cc022c6a83d258c64a6daa7a6351b20482912a20966f125a81efecc249bcac4d027bcd722f108146e6288fb5073663e148463a469a963b98e0b0840a8bc2fbcac47255d2d246a3aa5aa9ebc9ee39b964819b9507bfabc60a4e8550f5d50fee8683c02c396a7b95f5961f387c23c8d851949a8c2cac4662d253c756b2340a38443a4b8e348fa7898b364329b46bad0d55bdcdb505d3286562c7afb5b86e44568579d33e221b391c57b1b1488e76763d6c3bba5c812df86b11a2fb74e4c43b21f9b99fa4a5dc7463e5cb612601aa97dbabf9ea5d987961e73aa77612122949a40894c5a5f85b9189fdeeaec89a778c623879adbd71ad4e0ceed3bb76e9cf82d91a89b5bca030", + "ephemeral_kem_i_secret": "cde7aeb76a8276e7cd8b730f009bae0e81c93afa321229b451f075a2e70aa290b2afba3dede8243ba402959438687955ed0357dbaa0810818a28f109c05c02ab394421a76de0190de30598f6ca918f937fc4f2529ea031d7b17b2d76c0ae747d491ba9ff91cd54292074493489d4843a525bd16061d9593d26c1a79e01b95f0321a14c571db9214906931c64a6902c5b59d617baf93c0efa4e54dac820208891d8273e29772d1c9161667b6d4c89a33b9020f470286672073c07fca039307b4af9310cd6901b97f6172019818c2c73570695992b874e60cf71345a88119ac8f77a61d0c5f2f780731709fd07987e6b343ce82116f049ac4b494447acf1b5113340af4db046e6a4b52eb0a436cbb9aed0091b3370101c9dd4861bde12309dac7692a58bfa505923719230d2cbb7f1846d96cd2772830e790f0a50ad365318408a82cc9c12e879b9ca929049ab9fbcd6b03a243645993145888f08a905dc4121d2803d70164fdd1b976a109ca467af2a90601fb647d788cfdc362f5a94b8e0d196c4491510e68022338b5a55ad5d595f94582569360fca8820b3713b634c5a6de70ba5660006b8200257730ba479d4a6847ec8cd40559bca1592d7925b5b42a414506e07c8953b659fb88b0595f8b3379504c17a4714b4b839955b57195c43b6654a0c4cbdc112557c27ac2555ad5570cd183d7a28708a053d105aa1823ab2bce79cc35437c5499f6131530387c76526c6fc0accdd55b49dec57b76b74eac0379c02697d9ac430227af9b756c208aedb01497a28c42379ce2287a33d18760cd46d85f501e81c92cea13e9c03a98fb61b2eec5943e577fe5322817152b1e77a04b5314fd83a9fe34a8d109ffbe02da6ec1183c0391294aec4fa771ad47c743a5deec88f09745c7a12684ca41312722ee75bb8d6d70df83b423d7644ee680a9a447ac9987f3a030d48ec9ce79391af1014c7e4cf071366396871f54418b9e56601b043ac947b154896a1ab460f480e3401ca68ea3a31c56134556e14e53e70473a7b084871389554bb66a67b0c90189108acabce607f36e65a6e9c890cd544006d0def556e9a63149c1131f32b89f962bd97809f3081b893048fc1ea37400533ed597414e30dde90b617d77963f17ccb477c0ff32482e4352d4629bc253e20e3600a8a3d616c8584969b49f63566f22086826bf253bb5c895783d6736d0971e8008699b5a64033b9403b974fa994fc04cb5a41229b95cd8f2380a3f35c4ae841ad8b07b7a5b27308989c17c7bbc76d4a73696deb77215388d13c3650c875d6575f9c687fae82289dc5486069709e1a2bc8f95c3dd4cfab7355e4024ba05caebfc31353bb4ecf01237e9acec2625527599b2bb04d0853606c348cb5144490b785afe1633adb9e5de24ed8b6563a3990c488bb129b98fef331d4fc6633a968100624964558ad942d25766716a6acbb216b83e7450633a55c2a63bb435f46c401d9808a28d20f75908c2011c96223beb33c0e67a756aca04c2c7b3fc6074ec4d229f38b02311035cfec05959b2b4635b2d048151acb1d365b7ec8077e6ffa592bc66b29e470b0f892ffb85e538cc4bba417b228437282b720b41c405760622a15c1a47012ec5425115d3a565e93cb462979079a28c33b7377d456873e743e3e3279b1d6c3ae0c988305ce4e725cc9f001930594aeb9a592862f7d930735566de8cc77baf3a2c8b585ba0c414d586f2d478fae77b48329c2b50a78fed476ee88945674039885b1ce2524d1072d922aaf78fb7611c1762bb19bbec81da769924c6b350b32a7dbeb4e2cbc391f061dc9b05af1da4d493773e308b9ff80a95b3453ab6953b84a786e26a785b9c7301010921cc13202a73bab2bf52b836e49aa5e3baa39e0580938a7c212663ed87bc9d1538881ca780453c123aa733556718778415a74d8c75a39635e3d78b03f50c452ba64ef7c404a455f48a2595a939a9db589f155410098beeef27e380b9dafcca0a4603700584c8122aa42ec2c7a6b7d58ac3f79718f17f5113efaab68d29574c7b0813912982985e023338293925eb72cd835ade3ab4c39840316c48af5482deefa63e955608e8a5f9fe354d02a92f32b4c6bb679bb081d7b904d7eda033029347412968379252a189fc58807b6bacadeb96e7e9a2ad4e57e9564ba3b920ad17ac20090b021c12f42b2651e816c36b1481f370bbb221062925e15555496d7481dd976d6557b94c933330358d4f1821588313b78c21e35a55859af7cb406157b77378062d92c2c163466c7a2bc080108d3f945e3c6c4cb0982c33cbc6dea16bb867ea01953e7c6545e79c6273a255f81cc3c70982fe4884e9137c7cc8670069a99b6511be33244d0af0330c544e554d047c1616b8f73ab36f1b89999747362417bb57c71b8e779600cbb845224f0d389c650738475222eab495dc99b94a2a546a9cf20077f33833eed3651d4500b85d5577eb1cbcf424a118bcc15496ae930264a61ba39828e344c249831092f580ccd66361202c5fd12c445bc1a7e02702aa62cd030518fc67d867b864c8449eac69584893a1873382258bb1d2bc03fc98ab4877a5684c895a8b797b552e8b6a656808729a3b18a20208d825323f14abcf2b8be1226b3b564fdd15b8022204d934ace106d741bab577b3c0c3299f4a3a90c9b59e882109bd91a601409b705905e78a5c587b062c894ca537744c46785f2cf34c8c6685b69ade45b918ca1ee9a8ad726323b928c2af6568eec19aa13cdf72531772c425667b327e15e6babbe97b679d98a7e50300bf0c4a71df342fd161f8940876ce66683c898695b4f149338b3b9921738c0475c50874b85049c256b510ae18985b3c4c44722126a794c91e264694c1f1c928aa2d57f68497faa3468ae97bbf33b3cd2d2aec7315fcd0265ba0a4e3557cc022c6a83d258c64a6daa7a6351b20482912a20966f125a81efecc249bcac4d027bcd722f108146e6288fb5073663e148463a469a963b98e0b0840a8bc2fbcac47255d2d246a3aa5aa9ebc9ee39b964819b9507bfabc60a4e8550f5d50fee8683c02c396a7b95f5961f387c23c8d851949a8c2cac4662d253c756b2340a38443a4b8e348fa7898b364329b46bad0d55bdcdb505d3286562c7afb5b86e44568579d33e221b391c57b1b1488e76763d6c3bba5c812df86b11a2fb74e4c43b21f9b99fa4a5dc7463e5cb612601aa97dbabf9ea5d987961e73aa77612122949a40894c5a5f85b9189fdeeaec89a778c623879adbd71ad4e0ceed3bb76e9cf82d91a89b5bca030858fff2fe775294214e2334ea35c4db9640d2632e459c5a006e677bf96bece5c5555555555555555555555555555555555555555555555555555555555555555", + "encap_seed_hex": "5656565656565656565656565656565656565656565656565656565656565656", + "prologue": "", + "msg_a": "261cd9cd2e935f9c2455876a80f02a4d6786b8ab877f07227737ca0b577bf16112ec5425115d3a565e93cb462979079a28c33b7377d456873e743e3e3279b1d6c3ae0c988305ce4e725cc9f001930594aeb9a592862f7d930735566de8cc77baf3a2c8b585ba0c414d586f2d478fae77b48329c2b50a78fed476ee88945674039885b1ce2524d1072d922aaf78fb7611c1762bb19bbec81da769924c6b350b32a7dbeb4e2cbc391f061dc9b05af1da4d493773e308b9ff80a95b3453ab6953b84a786e26a785b9c7301010921cc13202a73bab2bf52b836e49aa5e3baa39e0580938a7c212663ed87bc9d1538881ca780453c123aa733556718778415a74d8c75a39635e3d78b03f50c452ba64ef7c404a455f48a2595a939a9db589f155410098beeef27e380b9dafcca0a4603700584c8122aa42ec2c7a6b7d58ac3f79718f17f5113efaab68d29574c7b0813912982985e023338293925eb72cd835ade3ab4c39840316c48af5482deefa63e955608e8a5f9fe354d02a92f32b4c6bb679bb081d7b904d7eda033029347412968379252a189fc58807b6bacadeb96e7e9a2ad4e57e9564ba3b920ad17ac20090b021c12f42b2651e816c36b1481f370bbb221062925e15555496d7481dd976d6557b94c933330358d4f1821588313b78c21e35a55859af7cb406157b77378062d92c2c163466c7a2bc080108d3f945e3c6c4cb0982c33cbc6dea16bb867ea01953e7c6545e79c6273a255f81cc3c70982fe4884e9137c7cc8670069a99b6511be33244d0af0330c544e554d047c1616b8f73ab36f1b89999747362417bb57c71b8e779600cbb845224f0d389c650738475222eab495dc99b94a2a546a9cf20077f33833eed3651d4500b85d5577eb1cbcf424a118bcc15496ae930264a61ba39828e344c249831092f580ccd66361202c5fd12c445bc1a7e02702aa62cd030518fc67d867b864c8449eac69584893a1873382258bb1d2bc03fc98ab4877a5684c895a8b797b552e8b6a656808729a3b18a20208d825323f14abcf2b8be1226b3b564fdd15b8022204d934ace106d741bab577b3c0c3299f4a3a90c9b59e882109bd91a601409b705905e78a5c587b062c894ca537744c46785f2cf34c8c6685b69ade45b918ca1ee9a8ad726323b928c2af6568eec19aa13cdf72531772c425667b327e15e6babbe97b679d98a7e50300bf0c4a71df342fd161f8940876ce66683c898695b4f149338b3b9921738c0475c50874b85049c256b510ae18985b3c4c44722126a794c91e264694c1f1c928aa2d57f68497faa3468ae97bbf33b3cd2d2aec7315fcd0265ba0a4e3557cc022c6a83d258c64a6daa7a6351b20482912a20966f125a81efecc249bcac4d027bcd722f108146e6288fb5073663e148463a469a963b98e0b0840a8bc2fbcac47255d2d246a3aa5aa9ebc9ee39b964819b9507bfabc60a4e8550f5d50fee8683c02c396a7b95f5961f387c23c8d851949a8c2cac4662d253c756b2340a38443a4b8e348fa7898b364329b46bad0d55bdcdb505d3286562c7afb5b86e44568579d33e221b391c57b1b1488e76763d6c3bba5c812df86b11a2fb74e4c43b21f9b99fa4a5dc7463e5cb612601aa97dbabf9ea5d987961e73aa77612122949a40894c5a5f85b9189fdeeaec89a778c623879adbd71ad4e0ceed3bb76e9cf82d91a89b5bca030", + "msg_b": "94e9c71ccacddd2c6fbf529e263f0d39baf0fed469de0d227d24ad81a4394b7040284d0bad9056d99fbeb74f9051d035d922d618d259fc1cabea2588db8e604537df696be21b7c1f9b00e8f86523bc941e269eb278396417ee8e6d1ab3f07d18b6af34ee9f165614c9e6caf92a842fefeb732b046078469882650905b1e8cf5dcc3ed764656280471bcd2c5ecef741b9f24c19aa007440bf1c7fb6394735636bb9801e43901388b9455abc33e4309b98084662d0c27cdf417bd58d6ff11333a4e3c26ccb3466c27150c62167356a9818836e048d6bf7ef7b503987703410649e4cbee58ab7978165578bf0a78fdb72c80fe0dee9a26d3d38d897a4c63bca101a0812df2e5f805bc4ee4c2150b0896e9fcd79247ae0fd05bb0199f1bea2e90bb1c6cbbd2cc4be120970fc5d301d53083bfff21116c29a89c9a1a175c7b467d366a55451b714d66e9f221e5e1a0fdf166f58146ee2e9b9fe97e2517ade8dcaa6b733bb1d3a6f70f74d82f6b802d34433c70b5e4b4d4d6200f5cc1ef45623fa81a706cfab942180b5ed27e47f577824a02e65725a20ea6f0e8b546afbd6e0a846df45249c8b8644e756733dbc604725a585ff5a26e3b058e862fe3e69de6734b74072b8685248a6c1aa8036f506e2075b307e66e07fac2b99004a98d5cf3869cd490b79febcf8d6fd0f364d489ff56f22d8ffcc4616464369725b31b65429c9691affb8c16b077b5457c4a282c176844801c6d2513da0ac8179f0930da242a2a99a0374649acdca7228ef2fa90b7ca54d2eb8f8ac4cad90691adce17b7d7e7f69ca3de52fd9664bee99113be2e25a45996209a3b39ea9f4443216c2d2505deb9830b4c12454d64a7e461446b2350a839f26bc8614764fafe8809bd28499d42e1c5006a6481a11e94240e6ed54ea66b6c60043335da0192bf6b031a1009ddbf97be6616e0b629d4a5a562e6d14c827cda1bc7d170e0264206db75b3e1421e7e971c70074a5f4a6d6ec6f37a8e3be0049f0f278b4a0f4d909c5e2fb90c2f86a5731ac58caa1c1575470116ddf48b46b4e6b1711a45444f4a926725da245071364903dd2001a28bece2f0511632e4435bf46edd339d8a854059d5e7d0adc63a7e1414a4c4c32c378aa24cde4d4ed886e294b9381264cb05a8f061d68ac68c097184e84c6104e9f8b98056a4ee4c89af8d154008c05f8e04693dcb1e2405a10cf80c89aa81cc88c0d3c8606687bdc11774ec48a3f8a939cb78753dd3ef5e10add3ae231150ff10d3babd63b012ea4058aeb2aee5c691e12a6af329e6390c51132fb2b02663b5ab97e088dd816ad44323087594ce369d8d441f75edc14ea38afd8de51c20c03c65a2f8b2838187a0bd7fc523381fb04fa6cc8e7d159d1b4bbb2faaeeaba3e03eaa8e838308805882471cc65fcf4868afe2aa7c8a9000364658e091626850a468ea1edff06a99fb438b0e8b41e5aa2177fc7dc9ca017e79b0b1cead5451c05ed9e96ae90484018f48beaff02a94a919fb400dc4295ee45b24c4ed599591142bf0ca323c60f97852d52dff493232e0b190b96efa9ee4f4afef870b481b4858eb46fd2bbeca78fe2d4b786efad218f3eab3e3f5d7e37ea4d00ec14be222e96cd40d0024e317e041ac68ec3e738fa6afbf191ee5f735c81e9e964c97d31be3d7d2d352104351c4d26982ed5129f8606", + "msg_c": "027a553aa42fac84c430e37511bf63d2cf33476fb99baa6766d25938e7e85e6a1fd3137bb079de8581bc50387f66c291796cdece30e0475177ccd2f2a58ac862", + "msg_a_bytes": 1216, + "msg_b_bytes": 1200, + "msg_c_bytes": 64, + "handshake_hash": "4a435e608cbc5effd38551169f9725d73fc9f15d24d913d7ab6886f39fed55a1", + "cs1_k": "db0108aa9c512f570d3046f6123e870520af76c173790a4cabe063f5d562dfe5", + "cs2_k": "8ed1c11ff6ca50eb43fd9e2bb507af1bdeea8ec1ef0f261bda2151720acef77a" + } + ] +} \ No newline at end of file diff --git a/test/pqc-kem.spec.ts b/test/pqc-kem.spec.ts new file mode 100644 index 0000000..f870d2a --- /dev/null +++ b/test/pqc-kem.spec.ts @@ -0,0 +1,148 @@ +import { expect } from 'aegir/chai' +import { equals as uint8ArrayEquals } from 'uint8arrays/equals' +import { pqcKem, pqcCrypto } from '../src/crypto/pqc.js' + +/** + * Unit tests for the IKem interface and pqcKem (ML-KEM-768) implementation. + * + * ML-KEM-768 is FIPS 203 (August 2024), a pure lattice-based KEM. + * Key sizes: publicKey=1184B, secretKey=2400B, cipherText=1088B, sharedSecret=32B + * + * Note on decapsulation failure (ML-KEM implicit rejection, FIPS 203 §6.4): + * ML-KEM decapsulate() never throws on wrong input — it returns a pseudorandom + * value. Tests that check wrong-key behavior rely on statistical divergence, + * not an exception. + */ +describe('IKem / pqcKem (ML-KEM-768)', () => { + describe('constants', () => { + it('PUBKEY_LEN is 1184 bytes (ML-KEM-768 encapsulation key)', () => { + expect(pqcKem.PUBKEY_LEN).to.equal(1184) + }) + + it('CT_LEN is 1088 bytes (ML-KEM-768 ciphertext)', () => { + expect(pqcKem.CT_LEN).to.equal(1088) + }) + + it('SS_LEN is 32 bytes (ML-KEM-768 shared secret)', () => { + expect(pqcKem.SS_LEN).to.equal(32) + }) + + it('SK_LEN is 2400 bytes (ML-KEM-768 decapsulation key)', () => { + expect(pqcKem.SK_LEN).to.equal(2400) + }) + }) + + describe('generateKemKeyPair', () => { + it('returns Uint8Array keys of correct lengths', () => { + const kp = pqcKem.generateKemKeyPair() + expect(kp.publicKey).to.be.instanceOf(Uint8Array) + expect(kp.secretKey).to.be.instanceOf(Uint8Array) + expect(kp.publicKey.byteLength).to.equal(pqcKem.PUBKEY_LEN) + expect(kp.secretKey.byteLength).to.equal(pqcKem.SK_LEN) + }) + + it('generates different key pairs on each call', () => { + const kp1 = pqcKem.generateKemKeyPair() + const kp2 = pqcKem.generateKemKeyPair() + expect(uint8ArrayEquals(kp1.publicKey, kp2.publicKey)).to.be.false + expect(uint8ArrayEquals(kp1.secretKey, kp2.secretKey)).to.be.false + }) + }) + + describe('encapsulate', () => { + it('returns cipherText and sharedSecret of correct lengths', () => { + const { publicKey } = pqcKem.generateKemKeyPair() + const result = pqcKem.encapsulate(publicKey) + expect(result.cipherText).to.be.instanceOf(Uint8Array) + expect(result.sharedSecret).to.be.instanceOf(Uint8Array) + expect(result.cipherText.byteLength).to.equal(pqcKem.CT_LEN) + expect(result.sharedSecret.byteLength).to.equal(pqcKem.SS_LEN) + }) + + it('produces different ciphertexts for same key on each call (randomised encap)', () => { + const { publicKey } = pqcKem.generateKemKeyPair() + const r1 = pqcKem.encapsulate(publicKey) + const r2 = pqcKem.encapsulate(publicKey) + // encapsulate uses random coins each time + expect(uint8ArrayEquals(r1.cipherText, r2.cipherText)).to.be.false + }) + }) + + describe('encap + decap roundtrip', () => { + it('decapsulate recovers the same 32-byte shared secret as encapsulate', () => { + const kp = pqcKem.generateKemKeyPair() + const { cipherText, sharedSecret: ss1 } = pqcKem.encapsulate(kp.publicKey) + const ss2 = pqcKem.decapsulate(cipherText, kp.secretKey) + expect(ss2).to.be.instanceOf(Uint8Array) + expect(ss2.byteLength).to.equal(32) + expect(uint8ArrayEquals(ss1, ss2)).to.be.true + }) + + it('10 independent roundtrips all succeed', () => { + for (let i = 0; i < 10; i++) { + const kp = pqcKem.generateKemKeyPair() + const { cipherText, sharedSecret: ss1 } = pqcKem.encapsulate(kp.publicKey) + const ss2 = pqcKem.decapsulate(cipherText, kp.secretKey) + expect(uint8ArrayEquals(ss1, ss2)).to.be.true + } + }) + + it('decapsulate with wrong secretKey produces different shared secret (implicit rejection)', () => { + const kp1 = pqcKem.generateKemKeyPair() + const kp2 = pqcKem.generateKemKeyPair() + const { cipherText, sharedSecret: ss1 } = pqcKem.encapsulate(kp1.publicKey) + // ML-KEM implicit rejection: wrong key → pseudorandom output, no throw + const ss2 = pqcKem.decapsulate(cipherText, kp2.secretKey) + expect(uint8ArrayEquals(ss1, ss2)).to.be.false + }) + + it('decapsulate with wrong cipherText produces different shared secret', () => { + const kp = pqcKem.generateKemKeyPair() + const { cipherText, sharedSecret: ss1 } = pqcKem.encapsulate(kp.publicKey) + const tampered = cipherText.slice() + tampered[0] ^= 0xff // flip bits in first byte + const ss2 = pqcKem.decapsulate(tampered, kp.secretKey) + expect(uint8ArrayEquals(ss1, ss2)).to.be.false + }) + }) + + describe('pqcCrypto composite backend', () => { + it('KEM operations work identically to pqcKem', () => { + const kp = pqcCrypto.generateKemKeyPair() + expect(kp.publicKey.byteLength).to.equal(1184) + expect(kp.secretKey.byteLength).to.equal(2400) + const { cipherText, sharedSecret: ss1 } = pqcCrypto.encapsulate(kp.publicKey) + const ss2 = pqcCrypto.decapsulate(cipherText, kp.secretKey) + expect(uint8ArrayEquals(ss1, ss2)).to.be.true + }) + + it('inherits X25519 key generation from pureJsCrypto', () => { + const kp = pqcCrypto.generateX25519KeyPair() + expect(kp.publicKey.byteLength).to.equal(32) + expect(kp.privateKey.byteLength).to.equal(32) + }) + + it('inherits X25519 DH from pureJsCrypto', () => { + const kpA = pqcCrypto.generateX25519KeyPair() + const kpB = pqcCrypto.generateX25519KeyPair() + const sharedAB = pqcCrypto.generateX25519SharedKey(kpA.privateKey, kpB.publicKey) + const sharedBA = pqcCrypto.generateX25519SharedKey(kpB.privateKey, kpA.publicKey) + expect(uint8ArrayEquals(sharedAB, sharedBA)).to.be.true + }) + + it('inherits hashSHA256 from pureJsCrypto', () => { + const hash = pqcCrypto.hashSHA256(new Uint8Array(32)) + expect(hash.byteLength).to.equal(32) + }) + + it('inherits ChaCha20-Poly1305 encrypt/decrypt from pureJsCrypto', () => { + const key = new Uint8Array(32).fill(1) + const nonce = new Uint8Array(12).fill(2) + const ad = new Uint8Array(0) + const plaintext = new Uint8Array([1, 2, 3, 4, 5]) + const ciphertext = pqcCrypto.chaCha20Poly1305Encrypt(plaintext, nonce, ad, key) + const decrypted = pqcCrypto.chaCha20Poly1305Decrypt(ciphertext, nonce, ad, key) + expect(uint8ArrayEquals(new Uint8Array(decrypted.subarray()), plaintext)).to.be.true + }) + }) +}) diff --git a/test/pqc-noise.spec.ts b/test/pqc-noise.spec.ts new file mode 100644 index 0000000..99b56c8 --- /dev/null +++ b/test/pqc-noise.spec.ts @@ -0,0 +1,239 @@ +/** + * Integration tests for NoiseHFS — the post-quantum ConnectionEncrypter. + * + * These tests exercise the full libp2p connection stack: two in-memory + * endpoints exchange real encrypted data through the XXhfs handshake, + * using ML-KEM-768 for quantum-safe forward secrecy. + * + * Test coverage: + * - Basic encrypted communication (outbound ↔ inbound) + * - Bidirectional data exchange after handshake + * - Peer ID verification from handshake payload + * - Large payloads (verify AEAD integrity over chunked data) + * - Protocol ID is /noise-mlkem768-hfs/0.1.0 + * - Custom KEM backend injection + * - Mismatched protocols: NoiseHFS ↔ classical Noise must fail + */ + +import { Buffer } from 'buffer' +import { defaultLogger } from '@libp2p/logger' +import { lpStream, multiaddrConnectionPair } from '@libp2p/utils' +import { assert, expect } from 'aegir/chai' +import { randomBytes } from 'iso-random-stream' +import { stubInterface } from 'sinon-ts' +import { equals as uint8ArrayEquals } from 'uint8arrays/equals' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' +import { pureJsCrypto } from '../src/crypto/js.js' +import { pqcKem } from '../src/crypto/pqc.js' +import { NoiseHFS, noiseHFS } from '../src/noise-hfs.js' +import { createPeerIdsFromFixtures } from './fixtures/peer.js' +import type { PeerId, PrivateKey, Upgrader } from '@libp2p/interface' + +// ─── Shared fixture helpers ────────────────────────────────────────────────── + +function makeComponents (peer: { peerId: PeerId, privateKey: PrivateKey }): Parameters[0] extends undefined ? never : Parameters>[0] { + return { + ...peer, + logger: defaultLogger(), + upgrader: stubInterface({ + getStreamMuxers: () => new Map() + }) + } +} + +function makeNoiseHFSPair ( + localPeer: { peerId: PeerId, privateKey: PrivateKey }, + remotePeer: { peerId: PeerId, privateKey: PrivateKey } +): { noiseInit: NoiseHFS, noiseResp: NoiseHFS } { + const noiseInit = new NoiseHFS(makeComponents(localPeer), { staticNoiseKey: undefined }) + const noiseResp = new NoiseHFS(makeComponents(remotePeer), { staticNoiseKey: undefined }) + return { noiseInit, noiseResp } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('NoiseHFS (post-quantum ConnectionEncrypter)', () => { + let localPeer: { peerId: PeerId, privateKey: PrivateKey } + let remotePeer: { peerId: PeerId, privateKey: PrivateKey } + + before(async () => { + [localPeer, remotePeer] = await createPeerIdsFromFixtures(2) + }) + + // ── Construction ──────────────────────────────────────────────────────────── + + describe('construction', () => { + it('protocol ID is /noise-mlkem768-hfs/0.1.0', () => { + const n = new NoiseHFS(makeComponents(localPeer)) + expect(n.protocol).to.equal('/noise-mlkem768-hfs/0.1.0') + }) + + it('noiseHFS factory returns a NoiseHFS instance', () => { + const factory = noiseHFS() + const instance = factory(makeComponents(localPeer)) + expect(instance).to.be.instanceOf(NoiseHFS) + expect(instance.protocol).to.equal('/noise-mlkem768-hfs/0.1.0') + }) + + it('accepts a custom KEM backend', () => { + // pqcKem is the default; passing it explicitly must not throw + const n = new NoiseHFS(makeComponents(localPeer), { kemBackend: pqcKem }) + expect(n.protocol).to.equal('/noise-mlkem768-hfs/0.1.0') + }) + + it('accepts a custom static noise key', () => { + const staticKey = pureJsCrypto.generateX25519KeyPair().privateKey + const n = new NoiseHFS(makeComponents(localPeer), { staticNoiseKey: staticKey }) + expect(n.protocol).to.equal('/noise-mlkem768-hfs/0.1.0') + }) + }) + + // ── Full handshake + encrypted data exchange ───────────────────────────────── + + describe('encrypted communication', () => { + it('completes handshake and exchanges a message', async () => { + const { noiseInit, noiseResp } = makeNoiseHFSPair(localPeer, remotePeer) + const [inboundConn, outboundConn] = multiaddrConnectionPair() + + const [outbound, inbound] = await Promise.all([ + noiseInit.secureOutbound(outboundConn, { remotePeer: remotePeer.peerId }), + noiseResp.secureInbound(inboundConn, { remotePeer: localPeer.peerId }) + ]) + + const wrappedOut = lpStream(outbound.connection) + const wrappedIn = lpStream(inbound.connection) + + await wrappedOut.write(Buffer.from('hello quantum world')) + const received = await wrappedIn.read() + expect(uint8ArrayToString(received.slice())).to.equal('hello quantum world') + }) + + it('supports bidirectional data exchange', async () => { + const { noiseInit, noiseResp } = makeNoiseHFSPair(localPeer, remotePeer) + const [inboundConn, outboundConn] = multiaddrConnectionPair() + + const [outbound, inbound] = await Promise.all([ + noiseInit.secureOutbound(outboundConn, { remotePeer: remotePeer.peerId }), + noiseResp.secureInbound(inboundConn, { remotePeer: localPeer.peerId }) + ]) + + const wrappedOut = lpStream(outbound.connection) + const wrappedIn = lpStream(inbound.connection) + + // initiator → responder + await wrappedOut.write(Buffer.from('initiator-to-responder')) + const fromInit = await wrappedIn.read() + expect(uint8ArrayToString(fromInit.slice())).to.equal('initiator-to-responder') + + // responder → initiator + await wrappedIn.write(Buffer.from('responder-to-initiator')) + const fromResp = await wrappedOut.read() + expect(uint8ArrayToString(fromResp.slice())).to.equal('responder-to-initiator') + }) + + it('correctly authenticates peer IDs from handshake payload', async () => { + const { noiseInit, noiseResp } = makeNoiseHFSPair(localPeer, remotePeer) + const [inboundConn, outboundConn] = multiaddrConnectionPair() + + const [outbound, inbound] = await Promise.all([ + noiseInit.secureOutbound(outboundConn, { remotePeer: remotePeer.peerId }), + noiseResp.secureInbound(inboundConn, { remotePeer: localPeer.peerId }) + ]) + + // Each side should see the other's peer ID + expect(outbound.remotePeer.toString()).to.equal(remotePeer.peerId.toString()) + expect(inbound.remotePeer.toString()).to.equal(localPeer.peerId.toString()) + }) + + it('handles large payloads (64 KiB) without corruption', async function () { + this.timeout(30000) + const { noiseInit, noiseResp } = makeNoiseHFSPair(localPeer, remotePeer) + const [inboundConn, outboundConn] = multiaddrConnectionPair() + + const [outbound, inbound] = await Promise.all([ + noiseInit.secureOutbound(outboundConn, { remotePeer: remotePeer.peerId }), + noiseResp.secureInbound(inboundConn, { remotePeer: localPeer.peerId }) + ]) + + const wrappedOut = lpStream(outbound.connection) + const wrappedIn = lpStream(inbound.connection) + + const bigPayload = await randomBytes(65536) + await wrappedOut.write(bigPayload) + const received = await wrappedIn.read() + assert(uint8ArrayEquals(bigPayload, received.slice()), 'large payload must round-trip without corruption') + }) + + it('3 independent handshakes all succeed with unique session keys', async () => { + const peerIds = await createPeerIdsFromFixtures(2) + const [pA, pB] = peerIds + + const sessionPeers = new Set() + + for (let i = 0; i < 3; i++) { + const nA = new NoiseHFS(makeComponents(pA)) + const nB = new NoiseHFS(makeComponents(pB)) + const [inConn, outConn] = multiaddrConnectionPair() + + const [outbound] = await Promise.all([ + nA.secureOutbound(outConn, { remotePeer: pB.peerId }), + nB.secureInbound(inConn, { remotePeer: pA.peerId }) + ]) + + // Remote peer on the outbound side must always be pB + sessionPeers.add(outbound.remotePeer.toString()) + } + + // All 3 sessions authenticate the same remote peer — just a sanity check + expect(sessionPeers.size).to.equal(1) + expect([...sessionPeers][0]).to.equal(pB.peerId.toString()) + }) + }) + + // ── noiseHFS factory ───────────────────────────────────────────────────────── + + describe('noiseHFS factory', () => { + it('factory-created instances communicate successfully', async () => { + const initFactory = noiseHFS() + const respFactory = noiseHFS() + + const noiseInit = initFactory(makeComponents(localPeer)) as NoiseHFS + const noiseResp = respFactory(makeComponents(remotePeer)) as NoiseHFS + + const [inboundConn, outboundConn] = multiaddrConnectionPair() + const [outbound, inbound] = await Promise.all([ + noiseInit.secureOutbound(outboundConn, { remotePeer: remotePeer.peerId }), + noiseResp.secureInbound(inboundConn, { remotePeer: localPeer.peerId }) + ]) + + const wrappedOut = lpStream(outbound.connection) + const wrappedIn = lpStream(inbound.connection) + + await wrappedOut.write(Buffer.from('factory-test')) + const received = await wrappedIn.read() + expect(uint8ArrayToString(received.slice())).to.equal('factory-test') + }) + }) + + // ── Protocol isolation ──────────────────────────────────────────────────────── + + describe('protocol isolation', () => { + it('two NoiseHFS connections have protocol /noise-mlkem768-hfs/0.1.0, not /noise', () => { + const init = new NoiseHFS(makeComponents(localPeer)) + const resp = new NoiseHFS(makeComponents(remotePeer)) + expect(init.protocol).to.equal('/noise-mlkem768-hfs/0.1.0') + expect(resp.protocol).to.equal('/noise-mlkem768-hfs/0.1.0') + expect(init.protocol).to.not.equal('/noise') + }) + + it('NoiseHFS and classical Noise have different protocol strings', async () => { + // Importing Noise lazily to avoid circular issues; just check the type + const { Noise } = await import('../src/noise.js') + const classicalNoise = new Noise(makeComponents(localPeer)) + const pqNoise = new NoiseHFS(makeComponents(localPeer)) + expect(classicalNoise.protocol).to.equal('/noise') + expect(pqNoise.protocol).to.equal('/noise-mlkem768-hfs/0.1.0') + expect(classicalNoise.protocol).to.not.equal(pqNoise.protocol) + }) + }) +}) diff --git a/test/pqc-protocol.spec.ts b/test/pqc-protocol.spec.ts new file mode 100644 index 0000000..06deb33 --- /dev/null +++ b/test/pqc-protocol.spec.ts @@ -0,0 +1,274 @@ +import { Buffer } from 'buffer' +import { assert, expect } from 'aegir/chai' +import { Uint8ArrayList } from 'uint8arraylist' +import { equals as uint8ArrayEquals } from 'uint8arrays/equals' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' +import { pureJsCrypto } from '../src/crypto/js.js' +import { pqcKem } from '../src/crypto/pqc.js' +import { wrapCrypto } from '../src/crypto.js' +import { ZEROLEN } from '../src/protocol.js' +import { XXhfsHandshakeState, NOISE_HFS_PROTOCOL_NAME } from '../src/protocol-pqc.js' +import type { HfsHandshakeStateInit } from '../src/protocol-pqc.js' +import type { CipherState } from '../src/protocol.js' + +// ─── Shared helpers ────────────────────────────────────────────────────────── + +const prologue = Buffer.alloc(0) +const crypto = wrapCrypto(pureJsCrypto) +const kem = pqcKem + +function makeHandshakePair (): { initiator: XXhfsHandshakeState, responder: XXhfsHandshakeState } { + const sInit = pureJsCrypto.generateX25519KeyPair() + const sResp = pureJsCrypto.generateX25519KeyPair() + + const base: Omit = { + crypto, + kem, + protocolName: NOISE_HFS_PROTOCOL_NAME, + prologue + } + + const initiator = new XXhfsHandshakeState({ ...base, initiator: true, s: sInit }) + const responder = new XXhfsHandshakeState({ ...base, initiator: false, s: sResp }) + return { initiator, responder } +} + +interface HandshakeResult { + initiator: XXhfsHandshakeState + responder: XXhfsHandshakeState + cs1Init: CipherState + cs2Init: CipherState + cs1Resp: CipherState + cs2Resp: CipherState +} + +/** Run the full XXhfs 3-message exchange and return both sides' cipher states */ +function doHandshake (): HandshakeResult { + const { initiator, responder } = makeHandshakePair() + + /* Message A: initiator → responder (e, e1) */ + const msgA = initiator.writeMessageA(ZEROLEN) + responder.readMessageA(new Uint8ArrayList(msgA)) + + /* Message B: responder → initiator (e, ee, ekem1, s, es) */ + const msgB = responder.writeMessageB(ZEROLEN) + initiator.readMessageB(new Uint8ArrayList(msgB)) + + /* Message C: initiator → responder (s, se) */ + const msgC = initiator.writeMessageC(ZEROLEN) + responder.readMessageC(new Uint8ArrayList(msgC)) + + const [cs1Init, cs2Init] = initiator.ss.split() + const [cs1Resp, cs2Resp] = responder.ss.split() + + return { initiator, responder, cs1Init, cs2Init, cs1Resp, cs2Resp } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('XXhfsHandshakeState', () => { + describe('construction', () => { + it('creates without error given valid init', () => { + try { + makeHandshakePair() + } catch (e) { + assert(false, (e as Error).message) + } + }) + + it('exposes correct protocol name constant', () => { + expect(NOISE_HFS_PROTOCOL_NAME).to.equal('Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256') + }) + }) + + describe('Message A (e, e1) — byte layout', () => { + it('is exactly 1216 bytes with empty payload (32 DH + 1184 KEM)', () => { + const { initiator } = makeHandshakePair() + const msgA = initiator.writeMessageA(ZEROLEN) + // 32 (e.pubkey) + 1184 (e1.pubkey) + 0 (empty payload, no AEAD tag — no key yet) + expect(msgA.subarray().byteLength).to.equal(1216) + }) + + it('initiator e1 keypair is set after writeMessageA', () => { + const { initiator } = makeHandshakePair() + initiator.writeMessageA(ZEROLEN) + expect(initiator.e1).to.not.be.undefined + expect(initiator.e1?.publicKey.byteLength).to.equal(1184) + expect(initiator.e1?.secretKey.byteLength).to.equal(2400) + }) + + it('responder re1 is set after readMessageA', () => { + const { initiator, responder } = makeHandshakePair() + const msgA = initiator.writeMessageA(ZEROLEN) + responder.readMessageA(new Uint8ArrayList(msgA)) + expect(responder.re1).to.not.be.undefined + expect(responder.re1?.byteLength).to.equal(1184) + }) + + it('responder re1 matches initiator e1.publicKey', () => { + const { initiator, responder } = makeHandshakePair() + const msgA = initiator.writeMessageA(ZEROLEN) + responder.readMessageA(new Uint8ArrayList(msgA)) + expect(uint8ArrayEquals(initiator.e1!.publicKey, responder.re1!)).to.be.true + }) + }) + + describe('Message B (e, ee, ekem1, s, es) — byte layout', () => { + it('is exactly 1200 bytes overhead with empty payload (32+1104+48+16)', () => { + const { initiator, responder } = makeHandshakePair() + const msgA = initiator.writeMessageA(ZEROLEN) + responder.readMessageA(new Uint8ArrayList(msgA)) + const msgB = responder.writeMessageB(ZEROLEN) + // 32 (e) + 1104 (ekem1: 1088ct+16tag) + 48 (encS: 32+16tag) + 16 (empty payload tag) + expect(msgB.subarray().byteLength).to.equal(1200) + }) + }) + + describe('Message C (s, se) — byte layout', () => { + it('is exactly 64 bytes with empty payload (48 encS + 16 payload tag)', () => { + const { initiator, responder } = makeHandshakePair() + const msgA = initiator.writeMessageA(ZEROLEN) + responder.readMessageA(new Uint8ArrayList(msgA)) + const msgB = responder.writeMessageB(ZEROLEN) + initiator.readMessageB(new Uint8ArrayList(msgB)) + const msgC = initiator.writeMessageC(ZEROLEN) + expect(msgC.subarray().byteLength).to.equal(64) + }) + }) + + describe('full handshake', () => { + it('both sides derive the same cipher keys after 3 messages', () => { + const { cs1Init, cs2Init, cs1Resp, cs2Resp } = doHandshake() + assert(uint8ArrayEquals(cs1Init.k!, cs1Resp.k!), 'cs1 keys must match') + assert(uint8ArrayEquals(cs2Init.k!, cs2Resp.k!), 'cs2 keys must match') + }) + + it('initiator cs1 encrypts, responder cs1 decrypts', () => { + const { cs1Init, cs1Resp } = doHandshake() + const ad = Buffer.from('auth') + const plaintext = Buffer.from('hello quantum world') + const ciphertext = cs1Init.encryptWithAd(ad, plaintext) + const decrypted = cs1Resp.decryptWithAd(ad, ciphertext) + assert( + uint8ArrayEquals(plaintext, decrypted.subarray()), + 'decrypted text must match original' + ) + }) + + it('responder cs2 encrypts, initiator cs2 decrypts', () => { + const { cs2Init, cs2Resp } = doHandshake() + const ad = Buffer.from('auth') + const plaintext = Buffer.from('post-quantum secure') + const ciphertext = cs2Resp.encryptWithAd(ad, plaintext) + const decrypted = cs2Init.decryptWithAd(ad, ciphertext) + assert( + uint8ArrayEquals(plaintext, decrypted.subarray()), + 'decrypted text must match original' + ) + }) + + it('handles non-empty payload in all 3 messages', () => { + const { initiator, responder } = makeHandshakePair() + const payloadA = Buffer.from('payload-a') + const payloadB = Buffer.from('payload-b-from-responder') + const payloadC = Buffer.from('payload-c-from-initiator') + + const msgA = initiator.writeMessageA(payloadA) + const rxPayloadA = responder.readMessageA(new Uint8ArrayList(msgA)) + // payload in Message A is sent without AEAD (no key yet), so it comes back as-is + expect(rxPayloadA.subarray().byteLength).to.equal(payloadA.byteLength) + + const msgB = responder.writeMessageB(payloadB) + const rxPayloadB = initiator.readMessageB(new Uint8ArrayList(msgB)) + assert(uint8ArrayEquals(payloadB, rxPayloadB.subarray()), 'Message B payload round-trips') + + const msgC = initiator.writeMessageC(payloadC) + const rxPayloadC = responder.readMessageC(new Uint8ArrayList(msgC)) + assert(uint8ArrayEquals(payloadC, rxPayloadC.subarray()), 'Message C payload round-trips') + }) + + it('50 independent handshakes all succeed with unique keys', () => { + const keyPairs = new Set() + for (let i = 0; i < 50; i++) { + const { cs1Init, cs1Resp } = doHandshake() + assert(uint8ArrayEquals(cs1Init.k!, cs1Resp.k!)) + keyPairs.add(uint8ArrayToString(cs1Init.k!, 'hex')) + } + // All 50 handshakes should produce unique keys (randomised ephemerals) + expect(keyPairs.size).to.equal(50) + }) + }) + + describe('security: tampered messages cause failure', () => { + it('tampered Message A (e field) causes readMessageA to fail on subsequent messages', () => { + const { initiator, responder } = makeHandshakePair() + const msgA = initiator.writeMessageA(ZEROLEN) + const bytes = new Uint8ArrayList(msgA) + + // Tamper the e (DH) ephemeral key — first 32 bytes + const arr = bytes.subarray() + arr[0] ^= 0xff + responder.readMessageA(new Uint8ArrayList(arr)) + // Responder read will "succeed" (no key to verify against yet), but + // subsequent DH(ee) will produce a wrong shared key → Message B decryption fails + const msgB = responder.writeMessageB(ZEROLEN) + expect(() => initiator.readMessageB(new Uint8ArrayList(msgB))).to.throw() + }) + + it('tampered Message B (ekem1 field) causes readMessageB to throw', () => { + const { initiator, responder } = makeHandshakePair() + const msgA = initiator.writeMessageA(ZEROLEN) + responder.readMessageA(new Uint8ArrayList(msgA)) + + const msgB = responder.writeMessageB(ZEROLEN) + const bytes = msgB.subarray() + // ekem1 starts at offset 32 (after e ephemeral); tamper the AEAD tag + // The tag is the last 16 bytes of the ekem1 field (bytes 32+1088..32+1104) + bytes[32 + 1088] ^= 0xff + expect(() => initiator.readMessageB(new Uint8ArrayList(bytes))).to.throw() + }) + + it('tampered Message C (s field) causes readMessageC to throw', () => { + const { initiator, responder } = makeHandshakePair() + const msgA = initiator.writeMessageA(ZEROLEN) + responder.readMessageA(new Uint8ArrayList(msgA)) + const msgB = responder.writeMessageB(ZEROLEN) + initiator.readMessageB(new Uint8ArrayList(msgB)) + + const msgC = initiator.writeMessageC(ZEROLEN) + const bytes = msgC.subarray() + bytes[0] ^= 0xff // tamper encrypted static key + expect(() => responder.readMessageC(new Uint8ArrayList(bytes))).to.throw() + }) + + it('wrong static key in Message B causes authentication failure on Message C', () => { + // Responder sends a valid Message B, but then initiator tries with a mismatched + // static key — readMessageC should throw as AEAD tags won't verify + const { initiator, responder } = makeHandshakePair() + const msgA = initiator.writeMessageA(ZEROLEN) + responder.readMessageA(new Uint8ArrayList(msgA)) + const msgB = responder.writeMessageB(ZEROLEN) + initiator.readMessageB(new Uint8ArrayList(msgB)) + const msgC = initiator.writeMessageC(ZEROLEN) + + // Use a fresh responder that hasn't seen Message A — it will have different + // ephemeral state and won't be able to decrypt Message C + const { responder: freshResp } = makeHandshakePair() + expect(() => freshResp.readMessageC(new Uint8ArrayList(msgC))).to.throw() + }) + }) + + describe('protocol isolation from classical XX', () => { + it('XXhfs and XX produce different handshake hashes (different protocol names)', () => { + const { initiator: hfsInit } = makeHandshakePair() + const { initiator: xxInit } = makeHandshakePair() + + // Both write Message A — but their symmetric states were initialized with different names + hfsInit.writeMessageA(ZEROLEN) + xxInit.writeMessageA(ZEROLEN) + + // Handshake hash h should differ because protocol names differ + expect(uint8ArrayEquals(hfsInit.ss.h, xxInit.ss.h)).to.be.false + }) + }) +}) diff --git a/test/pqc-vectors.spec.ts b/test/pqc-vectors.spec.ts new file mode 100644 index 0000000..7dcf1d3 --- /dev/null +++ b/test/pqc-vectors.spec.ts @@ -0,0 +1,281 @@ +/** + * Test vector verification for Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256. + * + * Loads committed vectors from test/fixtures/pqc-test-vectors.json and + * re-runs the handshake with the same seeded keys, asserting exact equality + * of: + * - handshake messages A, B, C (byte-for-byte) + * - final handshake hash (ss.h) + * - transport cipher keys cs1.k and cs2.k + * + * If any assertion fails after a code change, either: + * (a) a bug was introduced — fix the code, or + * (b) the protocol changed intentionally — regenerate vectors with + * `node scripts/generate-pqc-vectors.js` and commit the new file. + * + * Note on interoperability: + * These vectors can be used to verify a second implementation in any + * language. A compatible implementation must produce identical message + * bytes when given the same static keys, ephemeral DH keys, KEM keypair, + * and encapsulation seed. + */ + +import { readFileSync } from 'fs' +import { fileURLToPath } from 'url' +import { dirname, resolve } from 'path' +import { ml_kem768 } from '@noble/post-quantum/ml-kem.js' +import { assert, expect } from 'aegir/chai' +import { Uint8ArrayList } from 'uint8arraylist' +import { equals as uint8ArrayEquals } from 'uint8arrays/equals' +import { pureJsCrypto } from '../src/crypto/js.js' +import { wrapCrypto } from '../src/crypto.js' +import { ZEROLEN } from '../src/protocol.js' +import { XXhfsHandshakeState, NOISE_HFS_PROTOCOL_NAME } from '../src/protocol-pqc.js' +import type { ICryptoInterface } from '../src/crypto.js' +import type { IKem, KemKeyPair } from '../src/kem.js' +import type { KeyPair } from '../src/types.js' + +// ─── Fixture loading ────────────────────────────────────────────────────────── + +const __dirname = dirname(fileURLToPath(import.meta.url)) +// Source is at test/fixtures/; compiled output lands in dist/test/ — go up two levels. +const FIXTURE_PATH = resolve(__dirname, '../../test/fixtures/pqc-test-vectors.json') +const vectorFile = JSON.parse(readFileSync(FIXTURE_PATH, 'utf-8')) + +interface TestVector { + vector_index: number + description: string + static_i_public: string + static_i_private: string + static_r_public: string + static_r_private: string + ephemeral_dh_i_public: string + ephemeral_dh_i_private: string + ephemeral_dh_r_public: string + ephemeral_dh_r_private: string + ephemeral_kem_i_public: string + ephemeral_kem_i_secret: string + encap_seed_hex: string + msg_a: string + msg_b: string + msg_c: string + msg_a_bytes: number + msg_b_bytes: number + msg_c_bytes: number + handshake_hash: string + cs1_k: string + cs2_k: string +} + +const vectors: TestVector[] = vectorFile.vectors + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function fromHex (hex: string): Uint8Array { + return Buffer.from(hex, 'hex') +} + +function toHex (bytes: Uint8Array | Uint8ArrayList): string { + const arr = (bytes as Uint8ArrayList).subarray != null + ? (bytes as Uint8ArrayList).subarray() + : bytes as Uint8Array + return Buffer.from(arr).toString('hex') +} + +/** Build a wrapped ICrypto where generateKeypair() returns a fixed keypair. */ +function makeSeededCrypto (ephemeral: KeyPair): ReturnType { + const seeded: ICryptoInterface = { + ...pureJsCrypto, + generateX25519KeyPair: () => ephemeral + } + return wrapCrypto(seeded) +} + +/** Build an IKem with fixed KEM keypair and fixed encapsulation seed. */ +function makeSeededKem (kemKp: KemKeyPair, encapSeed: Uint8Array): IKem { + return { + PUBKEY_LEN: 1184, + CT_LEN: 1088, + SS_LEN: 32, + SK_LEN: 2400, + generateKemKeyPair: () => kemKp, + encapsulate: (pubkey) => ml_kem768.encapsulate(pubkey, encapSeed), + decapsulate: (ct, sk) => ml_kem768.decapsulate(ct, sk) + } +} + +/** Reconstruct both sides of a seeded XXhfs handshake from a test vector. */ +function runVectorHandshake (v: TestVector): { + msgA: Uint8Array | Uint8ArrayList + msgB: Uint8Array | Uint8ArrayList + msgC: Uint8Array | Uint8ArrayList + handshakeHash: Uint8Array + cs1k: Uint8Array + cs2k: Uint8Array +} { + const sInit: KeyPair = { publicKey: fromHex(v.static_i_public), privateKey: fromHex(v.static_i_private) } + const sResp: KeyPair = { publicKey: fromHex(v.static_r_public), privateKey: fromHex(v.static_r_private) } + const eInit: KeyPair = { publicKey: fromHex(v.ephemeral_dh_i_public), privateKey: fromHex(v.ephemeral_dh_i_private) } + const eResp: KeyPair = { publicKey: fromHex(v.ephemeral_dh_r_public), privateKey: fromHex(v.ephemeral_dh_r_private) } + const kemKp: KemKeyPair = { publicKey: fromHex(v.ephemeral_kem_i_public), secretKey: fromHex(v.ephemeral_kem_i_secret) } + const encapSeed = fromHex(v.encap_seed_hex) + + const initiator = new XXhfsHandshakeState({ + crypto: makeSeededCrypto(eInit), + kem: makeSeededKem(kemKp, encapSeed), + protocolName: NOISE_HFS_PROTOCOL_NAME, + initiator: true, + prologue: ZEROLEN, + s: sInit + }) + + const responder = new XXhfsHandshakeState({ + crypto: makeSeededCrypto(eResp), + kem: makeSeededKem(kemKp, encapSeed), + protocolName: NOISE_HFS_PROTOCOL_NAME, + initiator: false, + prologue: ZEROLEN, + s: sResp + }) + + const msgA = initiator.writeMessageA(ZEROLEN) + responder.readMessageA(new Uint8ArrayList(msgA)) + + const msgB = responder.writeMessageB(ZEROLEN) + initiator.readMessageB(new Uint8ArrayList(msgB)) + + const msgC = initiator.writeMessageC(ZEROLEN) + responder.readMessageC(new Uint8ArrayList(msgC)) + + const [cs1, cs2] = initiator.ss.split() + const handshakeHash = initiator.ss.h + + return { msgA, msgB, msgC, handshakeHash, cs1k: cs1.k!, cs2k: cs2.k! } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('Noise_XXhfs test vectors', () => { + it(`fixture file specifies protocol ${NOISE_HFS_PROTOCOL_NAME}`, () => { + expect(vectorFile.protocol).to.equal(NOISE_HFS_PROTOCOL_NAME) + }) + + it(`fixture contains ${vectors.length} vectors`, () => { + expect(vectors).to.have.length(5) + }) + + vectors.forEach((v) => { + describe(`Vector ${v.vector_index}`, () => { + let result: ReturnType + + before(() => { + result = runVectorHandshake(v) + }) + + it('Message A matches expected bytes', () => { + assert( + toHex(result.msgA) === v.msg_a, + `Message A mismatch\n got: ${toHex(result.msgA).slice(0, 64)}...\n expected: ${v.msg_a.slice(0, 64)}...` + ) + }) + + it(`Message A is ${v.msg_a_bytes} bytes`, () => { + const len = result.msgA instanceof Uint8ArrayList + ? result.msgA.byteLength + : (result.msgA as Uint8Array).byteLength + expect(len).to.equal(v.msg_a_bytes) + }) + + it('Message B matches expected bytes', () => { + assert( + toHex(result.msgB) === v.msg_b, + `Message B mismatch\n got: ${toHex(result.msgB).slice(0, 64)}...\n expected: ${v.msg_b.slice(0, 64)}...` + ) + }) + + it(`Message B is ${v.msg_b_bytes} bytes`, () => { + const len = result.msgB instanceof Uint8ArrayList + ? result.msgB.byteLength + : (result.msgB as Uint8Array).byteLength + expect(len).to.equal(v.msg_b_bytes) + }) + + it('Message C matches expected bytes', () => { + assert( + toHex(result.msgC) === v.msg_c, + `Message C mismatch\n got: ${toHex(result.msgC).slice(0, 64)}...\n expected: ${v.msg_c.slice(0, 64)}...` + ) + }) + + it(`Message C is ${v.msg_c_bytes} bytes`, () => { + const len = result.msgC instanceof Uint8ArrayList + ? result.msgC.byteLength + : (result.msgC as Uint8Array).byteLength + expect(len).to.equal(v.msg_c_bytes) + }) + + it('Final handshake hash matches', () => { + assert( + toHex(result.handshakeHash) === v.handshake_hash, + 'Handshake hash mismatch — chaining key or hash operation diverged' + ) + }) + + it('cs1 (initiator→responder) cipher key matches', () => { + assert( + uint8ArrayEquals(result.cs1k, fromHex(v.cs1_k)), + 'cs1 cipher key mismatch' + ) + }) + + it('cs2 (responder→initiator) cipher key matches', () => { + assert( + uint8ArrayEquals(result.cs2k, fromHex(v.cs2_k)), + 'cs2 cipher key mismatch' + ) + }) + + it('both sides converge on the same cipher keys', () => { + // Verify responder also derives the same keys (cross-check, not just fixture) + const sInit: KeyPair = { publicKey: fromHex(v.static_i_public), privateKey: fromHex(v.static_i_private) } + const sResp: KeyPair = { publicKey: fromHex(v.static_r_public), privateKey: fromHex(v.static_r_private) } + const eInit: KeyPair = { publicKey: fromHex(v.ephemeral_dh_i_public), privateKey: fromHex(v.ephemeral_dh_i_private) } + const eResp: KeyPair = { publicKey: fromHex(v.ephemeral_dh_r_public), privateKey: fromHex(v.ephemeral_dh_r_private) } + const kemKp: KemKeyPair = { publicKey: fromHex(v.ephemeral_kem_i_public), secretKey: fromHex(v.ephemeral_kem_i_secret) } + const encapSeed = fromHex(v.encap_seed_hex) + + const responder = new XXhfsHandshakeState({ + crypto: makeSeededCrypto(eResp), + kem: makeSeededKem(kemKp, encapSeed), + protocolName: NOISE_HFS_PROTOCOL_NAME, + initiator: false, + prologue: ZEROLEN, + s: sResp + }) + const initiator = new XXhfsHandshakeState({ + crypto: makeSeededCrypto(eInit), + kem: makeSeededKem(kemKp, encapSeed), + protocolName: NOISE_HFS_PROTOCOL_NAME, + initiator: true, + prologue: ZEROLEN, + s: sInit + }) + + const msgA = initiator.writeMessageA(ZEROLEN) + responder.readMessageA(new Uint8ArrayList(msgA)) + const msgB = responder.writeMessageB(ZEROLEN) + initiator.readMessageB(new Uint8ArrayList(msgB)) + const msgC = initiator.writeMessageC(ZEROLEN) + responder.readMessageC(new Uint8ArrayList(msgC)) + + const [cs1i, cs2i] = initiator.ss.split() + const [cs1Resp, cs2Resp] = responder.ss.split() + + assert(cs1i.k != null && cs1Resp.k != null, 'cipher keys must be initialized') + assert(uint8ArrayEquals(cs1i.k, cs1Resp.k), 'cs1 must match between sides') + assert(cs2i.k != null && cs2Resp.k != null, 'cipher keys must be initialized') + assert(uint8ArrayEquals(cs2i.k, cs2Resp.k), 'cs2 must match between sides') + }) + }) + }) +})