Skip to content

Commit 7a0c8a0

Browse files
committed
feat: add TCP listener script and update benchmark results
- scripts/node-listener.mjs: standalone TCP listener for interop testing with the Python py-libp2p implementation. Accepts one connection, performs the NoiseHFS (XXhfs) responder handshake, exchanges a greeting message, and reports success. Referenced in py-libp2p PR #1310. - package.json: add prepare script (aegir build) so the package builds automatically when installed from GitHub via npm/yarn/pnpm. - benchmarks/results.md: updated with April 2026 benchmark run showing current X-Wing and full handshake latency figures.
1 parent cfd6d4d commit 7a0c8a0

3 files changed

Lines changed: 199 additions & 14 deletions

File tree

benchmarks/results.md

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,34 @@
11
# PQC Benchmark Results
22

3-
**Date:** 2026-04-04
4-
**Node.js:** v22.17.1
5-
**Platform:** win32 x64 (Windows 11 Pro)
6-
**KEM:** X-Wing (ML-KEM-768 + X25519) via `@noble/post-quantum` v0.6.0
3+
**KEM:** X-Wing (ML-KEM-768 + X25519) via `@noble/post-quantum` v0.6.0
4+
**Platform:** win32 x64 (Windows 11 Pro), Node.js v22.17.1
5+
**Note:** All operations use pure JavaScript, no WASM or native bindings.
76

87
---
98

10-
## KEM Micro-benchmarks (X-Wing)
9+
## Latest Run: 2026-04-11
10+
11+
### KEM Micro-benchmarks (X-Wing)
12+
13+
| Operation | ops/s | ms/op |
14+
|-----------|------:|------:|
15+
| `generateKemKeyPair` | 201 | 4.96 |
16+
| `encapsulate(publicKey)` | 90 | 11.10 |
17+
| `decapsulate(cipherText, secretKey)` | 118 | 8.51 |
18+
| Full round-trip (keygen + enc + dec) | 49 | 20.35 |
19+
20+
### Full Handshake Latency
21+
22+
| Protocol | ops/s | ms/handshake | Overhead |
23+
|----------|------:|-------------:|----------:|
24+
| `Noise_XX_25519_ChaChaPoly_SHA256` (classical) | 110 | 9.07 | baseline |
25+
| `Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256` (PQ hybrid) | 23 | 44.16 | +4.9x |
26+
27+
---
28+
29+
## Previous Run: 2026-04-04
30+
31+
### KEM Micro-benchmarks (X-Wing)
1132

1233
| Operation | ops/s | ms/op |
1334
|-----------|------:|------:|
@@ -16,20 +37,23 @@
1637
| `decapsulate(cipherText, secretKey)` | 136 | 7.33 |
1738
| Full round-trip (keygen + enc + dec) | 47 | 21.43 |
1839

19-
> X-Wing uses pure-JS (@noble/post-quantum) — no WASM or native bindings.
20-
> Native WASM ML-KEM implementations typically achieve 3–10× better throughput.
40+
### Full Handshake Latency
41+
42+
| Protocol | ops/s | ms/handshake | Overhead |
43+
|----------|------:|-------------:|----------:|
44+
| `Noise_XX_25519_ChaChaPoly_SHA256` (classical) | 114 | 8.75 | baseline |
45+
| `Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256` (PQ hybrid) | 23 | 44.18 | +5.0x |
2146

2247
---
2348

24-
## Full Handshake Latency
49+
## Consistency Notes
2550

26-
| Protocol | ops/s | ms/handshake | Overhead |
27-
|----------|------:|-------------:|----------:|
28-
| `Noise_XX_25519_ChaChaPoly_SHA256` (classical) | 114 | 8.75 ||
29-
| `Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256` (PQ hybrid) | 23 | 44.18 | +5.0× |
51+
Across both runs the hybrid handshake holds steady at 44 ms and the KEM round-trip at 20-21 ms.
52+
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.
53+
The handshake latency is the more meaningful number and it is consistent.
3054

31-
The ~slowdown is dominated by the X-Wing KEM (keygen + encapsulate + decapsulate ≈ 21 ms).
32-
The classical DH and AEAD operations account for the remaining 8–9 ms.
55+
The approximately 5x slowdown is dominated by the X-Wing KEM (keygen + encapsulate + decapsulate, roughly 20 ms).
56+
The classical DH and AEAD operations account for the remaining 9 ms, which matches the classical baseline exactly.
3357

3458
---
3559

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@
163163
"test:interop": "aegir test -t node -f dist/test/interop.js",
164164
"docs": "aegir docs",
165165
"proto:gen": "protons ./src/proto/payload.proto",
166+
"prepare": "aegir build",
166167
"prepublish": "pnpm build",
167168
"release": "aegir release"
168169
},

scripts/node-listener.mjs

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* Standalone TCP listener for Phase 5 live interop testing.
3+
*
4+
* Listens on TCP port 8000, performs a NoiseHFS (XXhfs) handshake as the
5+
* RESPONDER for each incoming connection, then:
6+
* 1. Sends "hello from JS" to the peer
7+
* 2. Reads back whatever the peer sends and prints it
8+
*
9+
* Usage:
10+
* cd js-libp2p-noise
11+
* node scripts/node-listener.mjs
12+
*
13+
* Then in another terminal:
14+
* cd py-libp2p && python scripts/interop_dial.py
15+
*/
16+
17+
import net from 'net'
18+
import { generateKeyPair } from '@libp2p/crypto/keys'
19+
import { defaultLogger } from '@libp2p/logger'
20+
import { peerIdFromPrivateKey } from '@libp2p/peer-id'
21+
import { AbstractMultiaddrConnection, ipPortToMultiaddr } from '@libp2p/utils'
22+
import { multiaddr } from '@multiformats/multiaddr'
23+
import { NoiseHFS } from '../dist/src/noise-hfs.js'
24+
25+
const PORT = 8000
26+
27+
// ─── Inline TCP socket → MultiaddrConnection adapter ────────────────────────
28+
// @libp2p/tcp does not export socket-to-conn directly; we inline it here.
29+
// Based on @libp2p/tcp dist/src/socket-to-conn.js
30+
class TCPSocketConnection extends AbstractMultiaddrConnection {
31+
#socket
32+
33+
constructor (init) {
34+
super(init)
35+
this.#socket = init.socket
36+
37+
this.#socket.on('data', buf => this.onData(buf))
38+
this.#socket.on('error', err => this.abort(err))
39+
this.#socket.setTimeout(120_000)
40+
this.#socket.once('timeout', () => this.abort(new Error('TCP timeout')))
41+
this.#socket.once('end', () => this.onTransportClosed())
42+
this.#socket.once('close', hadError => {
43+
if (hadError) {
44+
this.abort(new Error('TCP transmission error'))
45+
} else {
46+
this.onTransportClosed()
47+
}
48+
})
49+
this.#socket.on('drain', () => this.safeDispatchEvent('drain'))
50+
}
51+
52+
sendData (data) {
53+
let sentBytes = 0
54+
let canSendMore = true
55+
for (const buf of data) {
56+
sentBytes += buf.byteLength
57+
canSendMore = this.#socket.write(buf)
58+
}
59+
return { sentBytes, canSendMore }
60+
}
61+
62+
async sendClose (options) {
63+
if (this.#socket.destroyed) return
64+
await new Promise((resolve) => {
65+
this.#socket.once('close', resolve)
66+
this.#socket.destroySoon()
67+
})
68+
}
69+
70+
sendReset () {
71+
this.#socket.resetAndDestroy()
72+
}
73+
74+
sendPause () { this.#socket.pause() }
75+
sendResume () { this.#socket.resume() }
76+
}
77+
78+
function socketToMultiaddrConn (socket, log, localAddr) {
79+
const remoteAddr = ipPortToMultiaddr(socket.remoteAddress, socket.remotePort)
80+
return new TCPSocketConnection({
81+
socket,
82+
remoteAddr,
83+
localAddr,
84+
direction: 'inbound',
85+
log: log.newScope('tcp-conn')
86+
})
87+
}
88+
89+
// ─── Main ────────────────────────────────────────────────────────────────────
90+
91+
async function main () {
92+
const privateKey = await generateKeyPair('Ed25519')
93+
const peerId = peerIdFromPrivateKey(privateKey)
94+
const log = defaultLogger().forComponent('noise-hfs:listener')
95+
96+
console.log(`Listener peer ID: ${peerId.toString()}`)
97+
98+
const components = {
99+
privateKey,
100+
peerId,
101+
logger: defaultLogger(),
102+
upgrader: { getStreamMuxers: () => new Map() }
103+
}
104+
105+
const noiseHfs = new NoiseHFS(components)
106+
console.log(`Protocol: ${noiseHfs.protocol}`)
107+
108+
const localAddr = multiaddr(`/ip4/127.0.0.1/tcp/${PORT}`)
109+
110+
const server = net.createServer(async (socket) => {
111+
console.log(`\nIncoming TCP connection from ${socket.remoteAddress}:${socket.remotePort}`)
112+
113+
const maConn = socketToMultiaddrConn(socket, log, localAddr)
114+
115+
try {
116+
console.log('Starting NoiseHFS responder handshake...')
117+
const { connection, remotePeer } = await noiseHfs.secureInbound(maConn)
118+
console.log(`Handshake complete! Remote peer: ${remotePeer.toString()}`)
119+
120+
// Send greeting — connection.send() sends uint16(ct_len) || AEAD(plaintext)
121+
// which matches Python's NoisePacketReadWriter framing exactly.
122+
const greeting = new TextEncoder().encode('hello from JS\n')
123+
connection.send(greeting)
124+
console.log('Sent: "hello from JS"')
125+
126+
// Read Python reply — iterate the async stream for one decrypted message
127+
for await (const chunk of connection) {
128+
const replyStr = new TextDecoder().decode(chunk instanceof Uint8Array ? chunk : chunk.slice())
129+
console.log(`Received: "${replyStr.trim()}"`)
130+
131+
if (replyStr.trim() === 'hello from Python') {
132+
console.log('\n✅ INTEROP SUCCESS: Both sides exchanged messages through NoiseHFS!')
133+
} else {
134+
console.log('\n⚠️ Unexpected reply:', JSON.stringify(replyStr))
135+
}
136+
break // one message is enough
137+
}
138+
139+
connection.close()
140+
} catch (err) {
141+
console.error('Handshake or messaging error:', err.message)
142+
socket.destroy()
143+
}
144+
})
145+
146+
server.listen(PORT, '127.0.0.1', () => {
147+
console.log(`\nListening on tcp://127.0.0.1:${PORT}`)
148+
console.log('Waiting for Python dialer...\n')
149+
})
150+
151+
server.on('error', err => {
152+
console.error('Server error:', err)
153+
process.exit(1)
154+
})
155+
}
156+
157+
main().catch(err => {
158+
console.error(err)
159+
process.exit(1)
160+
})

0 commit comments

Comments
 (0)