diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index 64051130a..611aec05a 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: python: ["3.10", "3.11", "3.12", "3.13"] - toxenv: [core, demos, interop, lint, utils, wheel] + toxenv: [core, demos, interop, lint, utils, wheel, pq] include: - python: "3.10" toxenv: docs diff --git a/benchmarks/bench_noise_pq.py b/benchmarks/bench_noise_pq.py new file mode 100644 index 000000000..2bff66e36 --- /dev/null +++ b/benchmarks/bench_noise_pq.py @@ -0,0 +1,560 @@ +""" +Noise PQ vs classical Noise benchmarks for py-libp2p. + +Measures: + 1. X-Wing KEM micro-benchmarks: keygen, encapsulate, decapsulate + 2. Classical Noise XX handshake latency (round-trip) + 3. Noise XXhfs (PQ) handshake latency (round-trip) + 4. Post-handshake transport throughput: 1 KB, 10 KB, 100 KB + +All latencies are median over N_HANDSHAKES iterations. +Throughput is measured as MB/s sustained over N_THROUGHPUT rounds. + +Run: + cd py-libp2p + python benchmarks/bench_noise_pq.py +""" + +import math +from pathlib import Path +import statistics +import time + +import trio + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +N_WARMUP = 3 # warm-up rounds discarded before measurement +N_HANDSHAKES = 50 # handshake latency iterations +N_THROUGHPUT = 200 # throughput iterations per message size +N_KEM = 200 # KEM micro-benchmark iterations + +# --------------------------------------------------------------------------- +# In-memory connection (same as test helpers) +# --------------------------------------------------------------------------- + + +class _MemoryConn: + def __init__(self, send_chan, recv_chan) -> None: + self._send = send_chan + self._recv = recv_chan + self._buf = bytearray() + + async def read(self, n: int | None = None) -> bytes: + while not self._buf: + try: + chunk = await self._recv.receive() + except trio.EndOfChannel: + return b"" + self._buf.extend(chunk) + if n is None: + data = bytes(self._buf) + self._buf.clear() + return data + data = bytes(self._buf[:n]) + del self._buf[:n] + return data + + async def write(self, data: bytes) -> None: + await self._send.send(bytes(data)) + + async def close(self) -> None: + await self._send.aclose() + + def get_remote_address(self) -> None: + return None + + def get_transport_addresses(self) -> list: + return [] + + def get_connection_type(self): + from libp2p.connection_types import ConnectionType + + return ConnectionType.UNKNOWN + + +def _make_conn_pair() -> tuple[_MemoryConn, _MemoryConn]: + a_to_b_s, a_to_b_r = trio.open_memory_channel(math.inf) + b_to_a_s, b_to_a_r = trio.open_memory_channel(math.inf) + return _MemoryConn(a_to_b_s, b_to_a_r), _MemoryConn(b_to_a_s, a_to_b_r) + + +# --------------------------------------------------------------------------- +# Key helpers +# --------------------------------------------------------------------------- + + +def _make_pq_pair(): + """Return (transport_local, peer_remote, transport_remote).""" + from libp2p.crypto.ed25519 import create_new_key_pair + from libp2p.crypto.keys import KeyPair + from libp2p.crypto.x25519 import X25519PrivateKey + from libp2p.peer.id import ID + from libp2p.security.noise.pq.transport_pq import TransportPQ + + kp_l = create_new_key_pair() + kp_r = create_new_key_pair() + t_l = TransportPQ( + KeyPair(kp_l.private_key, kp_l.public_key), X25519PrivateKey.new() + ) + t_r = TransportPQ( + KeyPair(kp_r.private_key, kp_r.public_key), X25519PrivateKey.new() + ) + peer_r = ID.from_pubkey(kp_r.public_key) + return t_l, peer_r, t_r + + +def _make_classical_pair(): + """Return (transport_local, peer_remote, transport_remote).""" + from libp2p.crypto.ed25519 import create_new_key_pair + from libp2p.crypto.keys import KeyPair + from libp2p.crypto.x25519 import X25519PrivateKey + from libp2p.peer.id import ID + from libp2p.security.noise.transport import Transport + + kp_l = create_new_key_pair() + kp_r = create_new_key_pair() + t_l = Transport(KeyPair(kp_l.private_key, kp_l.public_key), X25519PrivateKey.new()) + t_r = Transport(KeyPair(kp_r.private_key, kp_r.public_key), X25519PrivateKey.new()) + peer_r = ID.from_pubkey(kp_r.public_key) + return t_l, peer_r, t_r + + +# --------------------------------------------------------------------------- +# Benchmark helpers +# --------------------------------------------------------------------------- + + +def _fmt(ms: float, ops_s: float) -> str: + return f"{ms:7.2f} ms/op ({ops_s:8.1f} ops/s)" + + +def _stats(samples_s: list[float]) -> tuple[float, float]: + """Return (median_ms, ops_per_sec) from list of elapsed seconds.""" + med_ms = statistics.median(samples_s) * 1000 + ops_s = 1.0 / statistics.median(samples_s) + return med_ms, ops_s + + +# --------------------------------------------------------------------------- +# 1. KEM micro-benchmarks (synchronous, no trio needed) +# --------------------------------------------------------------------------- + + +def _bench_one_kem(kem, n_warmup: int = None, n_iter: int = None) -> dict: + """Run keygen/encap/decap micro-benchmarks for any IKem backend.""" + n_warmup = n_warmup or N_WARMUP + n_iter = n_iter or N_KEM + + for _ in range(n_warmup): + kem.keygen() + samples: list[float] = [] + for _ in range(n_iter): + t0 = time.perf_counter() + pk, sk = kem.keygen() + samples.append(time.perf_counter() - t0) + keygen_ms, keygen_ops = _stats(samples) + + pk, sk = kem.keygen() + for _ in range(n_warmup): + kem.encapsulate(pk) + samples = [] + for _ in range(n_iter): + t0 = time.perf_counter() + ct, ss = kem.encapsulate(pk) + samples.append(time.perf_counter() - t0) + encap_ms, encap_ops = _stats(samples) + + ct, _ = kem.encapsulate(pk) + for _ in range(n_warmup): + kem.decapsulate(ct, sk) + samples = [] + for _ in range(n_iter): + t0 = time.perf_counter() + kem.decapsulate(ct, sk) + samples.append(time.perf_counter() - t0) + decap_ms, decap_ops = _stats(samples) + + return { + "keygen_ms": keygen_ms, + "keygen_ops": keygen_ops, + "encap_ms": encap_ms, + "encap_ops": encap_ops, + "decap_ms": decap_ms, + "decap_ops": decap_ops, + } + + +def bench_kem() -> dict: + from libp2p.security.noise.pq.kem import XWingKem + + return _bench_one_kem(XWingKem()) + + +def bench_kem_backends() -> dict: + """ + Compare all available KEM backends. + + Returns a dict keyed by backend name, each value is a _bench_one_kem dict. + """ + from libp2p.security.noise.pq.kem import XWingKem + from libp2p.security.noise.pq.kem_backends import LibOQSXWingKem + + results: dict[str, dict] = {} + + # kyber-py baseline (always available, pure Python) + print(" Benchmarking kyber-py (pure Python)…") + results["kyber-py"] = _bench_one_kem(XWingKem()) + + # liboqs C backend (available if liboqs shared library is installed) + try: + liboqs_kem = LibOQSXWingKem() + print(" Benchmarking liboqs (C library)…") + results["liboqs"] = _bench_one_kem(liboqs_kem) + except (ImportError, RuntimeError, OSError) as e: + print(f" liboqs not available: {e}") + print(" Install the liboqs shared library to enable this backend.") + results["liboqs"] = None + + return results + + +# --------------------------------------------------------------------------- +# 2. Handshake latency benchmarks (async) +# --------------------------------------------------------------------------- + + +async def _one_pq_handshake(t_l, peer_r, t_r) -> float: + conn_l, conn_r = _make_conn_pair() + t0 = time.perf_counter() + async with trio.open_nursery() as n: + n.start_soon(t_l.secure_outbound, conn_l, peer_r) + n.start_soon(t_r.secure_inbound, conn_r) + return time.perf_counter() - t0 + + +async def _one_classical_handshake(t_l, peer_r, t_r) -> float: + conn_l, conn_r = _make_conn_pair() + t0 = time.perf_counter() + async with trio.open_nursery() as n: + n.start_soon(t_l.secure_outbound, conn_l, peer_r) + n.start_soon(t_r.secure_inbound, conn_r) + return time.perf_counter() - t0 + + +async def bench_handshakes() -> dict: + # --- classical XX --- + t_l, peer_r, t_r = _make_classical_pair() + for _ in range(N_WARMUP): + await _one_classical_handshake(t_l, peer_r, t_r) + samples_classical: list[float] = [] + for _ in range(N_HANDSHAKES): + t_l, peer_r, t_r = _make_classical_pair() # fresh keys each run + samples_classical.append(await _one_classical_handshake(t_l, peer_r, t_r)) + xx_ms, xx_ops = _stats(samples_classical) + + # --- XXhfs (PQ) --- + t_l, peer_r, t_r = _make_pq_pair() + for _ in range(N_WARMUP): + await _one_pq_handshake(t_l, peer_r, t_r) + samples_pq: list[float] = [] + for _ in range(N_HANDSHAKES): + t_l, peer_r, t_r = _make_pq_pair() # fresh keys each run + samples_pq.append(await _one_pq_handshake(t_l, peer_r, t_r)) + xxhfs_ms, xxhfs_ops = _stats(samples_pq) + + overhead = xxhfs_ms / xx_ms if xx_ms > 0 else float("inf") + + return { + "xx_ms": xx_ms, + "xx_ops": xx_ops, + "xxhfs_ms": xxhfs_ms, + "xxhfs_ops": xxhfs_ops, + "overhead_x": overhead, + } + + +# --------------------------------------------------------------------------- +# 3. Transport throughput (MB/s) after handshake completes +# --------------------------------------------------------------------------- + + +async def _throughput_one(session_out, session_in, payload: bytes) -> float: + t0 = time.perf_counter() + await session_out.write(payload) + await session_in.read(len(payload)) + return time.perf_counter() - t0 + + +async def _bench_throughput_one_size(make_pair_fn, size: int, n_rounds: int) -> float: + """Return throughput in MB/s for a single payload size.""" + payload = b"X" * size + + # One handshake to get the sessions + t_l, peer_r, t_r = make_pair_fn() + conn_l, conn_r = _make_conn_pair() + sessions: list = [None, None] + + async def do_out() -> None: + sessions[0] = await t_l.secure_outbound(conn_l, peer_r) + + async def do_in() -> None: + sessions[1] = await t_r.secure_inbound(conn_r) + + async with trio.open_nursery() as n: + n.start_soon(do_out) + n.start_soon(do_in) + + sess_out, sess_in = sessions + + # Warm-up + for _ in range(N_WARMUP): + await sess_out.write(payload) + await sess_in.read(size) + + # Timed rounds + elapsed: list[float] = [] + for _ in range(n_rounds): + t0 = time.perf_counter() + await sess_out.write(payload) + await sess_in.read(size) + elapsed.append(time.perf_counter() - t0) + + med_s = statistics.median(elapsed) + return (size / (1024 * 1024)) / med_s # MB/s + + +async def bench_throughput() -> dict: + # Noise spec caps single messages at 65535 bytes; 60 KB stays safely under + # the per-frame limit of both transports (both use 2-byte length prefixes). + sizes = [1024, 10 * 1024, 60 * 1024] + results: dict = {"classical": {}, "pq": {}} + + for size in sizes: + results["classical"][size] = await _bench_throughput_one_size( + _make_classical_pair, size, N_THROUGHPUT + ) + results["pq"][size] = await _bench_throughput_one_size( + _make_pq_pair, size, N_THROUGHPUT + ) + + return results + + +# --------------------------------------------------------------------------- +# Wire-size accounting (no runtime measurement needed — pure arithmetic) +# --------------------------------------------------------------------------- + + +def wire_sizes() -> dict: + from libp2p.security.noise.pq.kem import XWING_CT_SIZE, XWING_PK_SIZE + + x25519 = 32 + aead_tag = 16 + + # Classical XX (Noise spec, no libp2p payload for size accounting) + # Msg 1: e (32) + # Msg 2: e (32) + enc_s (48) + enc_payload (variable — use 0 here) + # Msg 3: enc_s (48) + enc_payload (variable) + classical_fixed = 32 + (32 + 48) + 48 # = 160 B fixed; payload adds ~32+ per side + + # XXhfs + # Msg A: e_pk (32) + e1_pk (1216) = 1248 + # Msg B: e (32) + enc_ct (1120+16=1136) + enc_s (48) + enc_payload + # Msg C: enc_s (48) + enc_payload + msg_a = x25519 + XWING_PK_SIZE # 32 + 1216 = 1248 + msg_b_fixed = x25519 + (XWING_CT_SIZE + aead_tag) + (x25519 + aead_tag) # 1216 + msg_c_fixed = x25519 + aead_tag # 48 + + return { + "classical_msg1": 32, + "classical_msg2_fixed": 32 + 48, + "classical_msg3_fixed": 48, + "xxhfs_msg_a": msg_a, + "xxhfs_msg_b_fixed": msg_b_fixed, + "xxhfs_msg_c_fixed": msg_c_fixed, + "xxhfs_total_fixed": msg_a + msg_b_fixed + msg_c_fixed, + "classical_total_fixed": classical_fixed, + } + + +# --------------------------------------------------------------------------- +# Main entry point + pretty-print results +# --------------------------------------------------------------------------- + + +def print_section(title: str) -> None: + print() + print("=" * 60) + print(f" {title}") + print("=" * 60) + + +async def run_all() -> dict: + print("Running benchmarks … (this may take ~30–60 seconds)") + print(f" KEM iterations: {N_KEM}") + print(f" Handshake iterations: {N_HANDSHAKES}") + print(f" Throughput rounds: {N_THROUGHPUT}") + + kem = bench_kem() + backends = bench_kem_backends() + handshakes = await bench_handshakes() + throughput = await bench_throughput() + wires = wire_sizes() + + # ---- print ---- + + print_section("X-Wing KEM backend comparison") + header = ( + f" {'Backend':<20} {'keygen':>10} {'encap':>10} {'decap':>10} {'speedup':>10}" + ) + print(header) + print(" " + "-" * (len(header) - 2)) + baseline_keygen = backends["kyber-py"]["keygen_ms"] + for name, b in backends.items(): + if b is None: + print(f" {name:<20} {'not available':>10}") + continue + speedup = ( + baseline_keygen / b["keygen_ms"] if b["keygen_ms"] > 0 else float("inf") + ) + print( + f" {name:<20} {b['keygen_ms']:>9.2f}ms" + f" {b['encap_ms']:>9.2f}ms" + f" {b['decap_ms']:>9.2f}ms" + f" {speedup:>6.1f}x" + ) + + print_section("X-Wing KEM micro-benchmarks (kyber-py baseline)") + print(f" keygen : {_fmt(kem['keygen_ms'], kem['keygen_ops'])}") + print(f" encapsulate: {_fmt(kem['encap_ms'], kem['encap_ops'])}") + print(f" decapsulate: {_fmt(kem['decap_ms'], kem['decap_ops'])}") + kem_round_trip = kem["encap_ms"] + kem["decap_ms"] + print(f" round-trip (encap+decap): {kem_round_trip:.2f} ms") + + print_section("Handshake latency (in-memory, round-trip)") + print(f" Classical XX : {_fmt(handshakes['xx_ms'], handshakes['xx_ops'])}") + print(f" XXhfs (PQ) : {_fmt(handshakes['xxhfs_ms'], handshakes['xxhfs_ops'])}") + print(f" Overhead : {handshakes['overhead_x']:.1f}x") + + print_section("Transport throughput (after handshake)") + print(f" {'Size':>8} {'Classical':>12} {'XXhfs (PQ)':>12} {'Ratio':>8}") + for size in [1024, 10 * 1024, 60 * 1024]: + label = f"{size // 1024} KB" + c = throughput["classical"][size] + p = throughput["pq"][size] + ratio = p / c if c > 0 else float("inf") + print(f" {label:>8} {c:>10.1f} MB/s {p:>10.1f} MB/s {ratio:>7.2f}x") + + print_section("Wire sizes (handshake bytes, excluding libp2p payload)") + print(f" Classical XX total (fixed): {wires['classical_total_fixed']} B") + print(f" Msg 1: {wires['classical_msg1']} B") + print(f" Msg 2: {wires['classical_msg2_fixed']} B (fixed, + payload)") + print(f" Msg 3: {wires['classical_msg3_fixed']} B (fixed, + payload)") + print() + print(f" XXhfs total (fixed): {wires['xxhfs_total_fixed']} B") + print(f" Msg A: {wires['xxhfs_msg_a']} B (e + e1_pk)") + print(f" Msg B: {wires['xxhfs_msg_b_fixed']} B (e + enc_ct + enc_s)") + print(f" Msg C: {wires['xxhfs_msg_c_fixed']} B (enc_s, fixed)") + overhead_b = wires["xxhfs_total_fixed"] - wires["classical_total_fixed"] + overhead_x = overhead_b / wires["classical_total_fixed"] + print(f" Wire overhead vs classical: +{overhead_b} B ({overhead_x:.0f}x)") + + print() + return { + "kem": kem, + "handshakes": handshakes, + "throughput": throughput, + "wire_sizes": wires, + } + + +def save_results(results: dict) -> None: + """Save a markdown results file, mirroring js-libp2p-noise/benchmarks/results.md.""" + kem = results["kem"] + hs = results["handshakes"] + tp = results["throughput"] + ws = results["wire_sizes"] + + lines = [ + "# py-libp2p Noise PQ Benchmark Results", + "", + "> Generated by `benchmarks/bench_noise_pq.py` ", + f"> Iterations: KEM={N_KEM}, handshake={N_HANDSHAKES}," + f" throughput={N_THROUGHPUT}", + "", + "## X-Wing KEM Micro-benchmarks", + "", + "| Operation | Median (ms) | Throughput (ops/s) |", + "|-----------|-------------|--------------------|", + f"| keygen | {kem['keygen_ms']:.2f} | {kem['keygen_ops']:.0f} |", + f"| encapsulate | {kem['encap_ms']:.2f} | {kem['encap_ops']:.0f} |", + f"| decapsulate | {kem['decap_ms']:.2f} | {kem['decap_ops']:.0f} |", + f"| round-trip (encap+decap) | {kem['encap_ms'] + kem['decap_ms']:.2f} | — |", + "", + "## Handshake Latency (in-memory, round-trip)", + "", + "| Pattern | Median (ms) | Throughput (ops/s) |", + "|---------|-------------|--------------------|", + f"| Classical Noise XX | {hs['xx_ms']:.2f} | {hs['xx_ops']:.0f} |", + f"| Noise XXhfs (X-Wing) | {hs['xxhfs_ms']:.2f} | {hs['xxhfs_ops']:.0f} |", + f"| Overhead | {hs['overhead_x']:.1f}x | — |", + "", + "## Transport Throughput (post-handshake)", + "", + "| Payload | Classical (MB/s) | XXhfs (MB/s) | Ratio |", + "|---------|-----------------|--------------|-------|", + ] + for size in [1024, 10 * 1024, 60 * 1024]: + label = f"{size // 1024} KB" + c = tp["classical"][size] + p = tp["pq"][size] + ratio = p / c if c > 0 else float("inf") + lines.append(f"| {label} | {c:.1f} | {p:.1f} | {ratio:.2f}x |") + + overhead_b = ws["xxhfs_total_fixed"] - ws["classical_total_fixed"] + lines += [ + "", + "## Wire Sizes (fixed handshake bytes, excluding libp2p payload)", + "", + "| Pattern | Msg 1 | Msg 2 | Msg 3 | Total |", + "|---------|-------|-------|-------|-------|", + ( + f"| Classical XX | {ws['classical_msg1']} B" + f" | {ws['classical_msg2_fixed']} B + payload" + f" | {ws['classical_msg3_fixed']} B + payload" + f" | {ws['classical_total_fixed']} B |" + ), + ( + f"| XXhfs | {ws['xxhfs_msg_a']} B" + f" | {ws['xxhfs_msg_b_fixed']} B + payload" + f" | {ws['xxhfs_msg_c_fixed']} B + payload" + f" | {ws['xxhfs_total_fixed']} B |" + ), + "", + ( + f"KEM ciphertext overhead vs classical:" + f" +{overhead_b} B" + f" (+{overhead_b / ws['classical_total_fixed']:.0f}x fixed bytes)" + ), + "", + "## Comparison with js-libp2p-noise", + "", + "| Metric | js-libp2p (XXhfs) | py-libp2p (XXhfs) |", + "|--------|-------------------|-------------------|", + f"| Handshake latency | ~44 ms | {hs['xxhfs_ms']:.1f} ms |", + f"| vs classical overhead | ~5x | {hs['overhead_x']:.1f}x |", + f"| KEM round-trip | ~20 ms | {kem['encap_ms'] + kem['decap_ms']:.1f} ms |", + "", + ] + + out_path = Path(__file__).parent / "results.md" + out_path.write_text("\n".join(lines), encoding="utf-8") + print(f"\nResults saved to {out_path}") + + +if __name__ == "__main__": + results = trio.run(run_all) + save_results(results) diff --git a/benchmarks/results.md b/benchmarks/results.md new file mode 100644 index 000000000..497605a2e --- /dev/null +++ b/benchmarks/results.md @@ -0,0 +1,46 @@ +# py-libp2p Noise PQ Benchmark Results + +> Generated by `benchmarks/bench_noise_pq.py` +> Iterations: KEM=200, handshake=50, throughput=200 + +## X-Wing KEM Micro-benchmarks + +| Operation | Median (ms) | Throughput (ops/s) | +| ------------------------ | ----------- | ------------------ | +| keygen | 8.09 | 124 | +| encapsulate | 8.30 | 121 | +| decapsulate | 10.76 | 93 | +| round-trip (encap+decap) | 19.06 | — | + +## Handshake Latency (in-memory, round-trip) + +| Pattern | Median (ms) | Throughput (ops/s) | +| -------------------- | ----------- | ------------------ | +| Classical Noise XX | 3.32 | 301 | +| Noise XXhfs (X-Wing) | 42.96 | 23 | +| Overhead | 12.9x | — | + +## Transport Throughput (post-handshake) + +| Payload | Classical (MB/s) | XXhfs (MB/s) | Ratio | +| ------- | ---------------- | ------------ | ----- | +| 1 KB | 8.0 | 8.1 | 1.02x | +| 10 KB | 61.0 | 62.7 | 1.03x | +| 60 KB | 215.9 | 227.9 | 1.06x | + +## Wire Sizes (fixed handshake bytes, excluding libp2p payload) + +| Pattern | Msg 1 | Msg 2 | Msg 3 | Total | +| ------------ | ------ | ---------------- | -------------- | ------ | +| Classical XX | 32 B | 80 B + payload | 48 B + payload | 160 B | +| XXhfs | 1248 B | 1216 B + payload | 48 B + payload | 2512 B | + +KEM ciphertext overhead vs classical: +2352 B (+15x fixed bytes) + +## Comparison with js-libp2p-noise + +| Metric | js-libp2p (XXhfs) | py-libp2p (XXhfs) | +| --------------------- | ----------------- | ----------------- | +| Handshake latency | ~44 ms | 43.0 ms | +| vs classical overhead | ~5x | 12.9x | +| KEM round-trip | ~20 ms | 19.1 ms | diff --git a/docs/conf.py b/docs/conf.py index 8fb3ac5ea..b986e33fa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -111,6 +111,9 @@ "_build", "modules.rst", "libp2p.crypto.pb.rst", + # examples.pq_noise.rst is generated by sphinx-apidoc but never committed; + # exclude it so Sphinx doesn't warn "not in any toctree" on local builds. + "examples.pq_noise.rst", ] # The reST default role (used for this markup: `text`) to use for all diff --git a/docs/libp2p.security.noise.pq.rst b/docs/libp2p.security.noise.pq.rst new file mode 100644 index 000000000..71f23786e --- /dev/null +++ b/docs/libp2p.security.noise.pq.rst @@ -0,0 +1,53 @@ +libp2p.security.noise.pq package +================================= + +Submodules +---------- + +libp2p.security.noise.pq.kem module +------------------------------------ + +.. automodule:: libp2p.security.noise.pq.kem + :members: + :undoc-members: + :show-inheritance: + +libp2p.security.noise.pq.kem\_backends module +---------------------------------------------- + +.. automodule:: libp2p.security.noise.pq.kem_backends + :members: + :undoc-members: + :show-inheritance: + +libp2p.security.noise.pq.noise\_state module +--------------------------------------------- + +.. automodule:: libp2p.security.noise.pq.noise_state + :members: + :undoc-members: + :show-inheritance: + +libp2p.security.noise.pq.patterns\_pq module +--------------------------------------------- + +.. automodule:: libp2p.security.noise.pq.patterns_pq + :members: + :undoc-members: + :show-inheritance: + +libp2p.security.noise.pq.transport\_pq module +---------------------------------------------- + +.. automodule:: libp2p.security.noise.pq.transport_pq + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: libp2p.security.noise.pq + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/libp2p.security.noise.rst b/docs/libp2p.security.noise.rst index 6a9276f8f..7284c8bf9 100644 --- a/docs/libp2p.security.noise.rst +++ b/docs/libp2p.security.noise.rst @@ -8,6 +8,7 @@ Subpackages :maxdepth: 4 libp2p.security.noise.pb + libp2p.security.noise.pq Submodules ---------- diff --git a/examples/pq_noise/__init__.py b/examples/pq_noise/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/pq_noise/pq_demo.py b/examples/pq_noise/pq_demo.py new file mode 100644 index 000000000..b8344bf9a --- /dev/null +++ b/examples/pq_noise/pq_demo.py @@ -0,0 +1,121 @@ +""" +Live two-node demo: Noise XXhfs (X-Wing KEM) over TCP. + +Starts a listener and a dialer in the same process using a trio nursery, +connects them over loopback TCP, performs the PQ Noise handshake, and +exchanges a round-trip message. + +Run with: + python examples/pq_noise/pq_demo.py +""" + +import logging +import time + +import multiaddr +import trio + +from libp2p import new_host +from libp2p.crypto.ed25519 import create_new_key_pair +from libp2p.crypto.keys import KeyPair +from libp2p.crypto.x25519 import X25519PrivateKey +from libp2p.custom_types import TProtocol +from libp2p.network.stream.net_stream import INetStream +from libp2p.peer.peerinfo import info_from_p2p_addr +from libp2p.security.noise.pq.transport_pq import ( + PROTOCOL_ID as PQ_PROTOCOL_ID, + TransportPQ, +) +from libp2p.utils.address_validation import find_free_port + +logging.basicConfig(level=logging.WARNING) + +STREAM_PROTO = TProtocol("/pq-demo/1.0.0") +MSG = b"post-quantum hello!" + + +def _make_pq_host(listen_port: int | None = None): + kp_raw = create_new_key_pair() + kp = KeyPair(kp_raw.private_key, kp_raw.public_key) + noise_key = X25519PrivateKey.new() + transport = TransportPQ(libp2p_keypair=kp, noise_privkey=noise_key) + sec_opt = {PQ_PROTOCOL_ID: transport} + + listen_addrs = None + if listen_port is not None: + listen_addrs = [multiaddr.Multiaddr(f"/ip4/127.0.0.1/tcp/{listen_port}")] + + return new_host(key_pair=kp, sec_opt=sec_opt, listen_addrs=listen_addrs) + + +async def run() -> None: + port = find_free_port() + + listener = _make_pq_host(listen_port=port) + dialer = _make_pq_host() + + received: list[bytes] = [] + handshake_done = trio.Event() + + async def handle_stream(stream: INetStream) -> None: + data = await stream.read(len(MSG)) + received.append(data) + await stream.write(b"pq-ack:" + data) + await stream.close() + handshake_done.set() + + t_start = time.perf_counter() + + addr = multiaddr.Multiaddr(f"/ip4/127.0.0.1/tcp/{port}") + async with listener.run(listen_addrs=[addr]): + listener.set_stream_handler(STREAM_PROTO, handle_stream) + listener_id = listener.get_id().to_string() + listener_addr = f"/ip4/127.0.0.1/tcp/{port}/p2p/{listener_id}" + + print(f"[listener] PeerID : {listener_id}") + print(f"[listener] Address: {listener_addr}") + + async with dialer.run(listen_addrs=[]): + dialer_id = dialer.get_id().to_string() + print(f"[dialer] PeerID : {dialer_id}") + + info = info_from_p2p_addr(multiaddr.Multiaddr(listener_addr)) + # Note: liboqs-python auto-installer runs once per process on systems + # without a pre-built liboqs binary. After the ~7s countdown it falls + # back to the pure-Python kyber-py backend. The handshake itself is + # correct either way. + print("[dialer] Connecting (XXhfs handshake starting)...") + t_connect = time.perf_counter() + await dialer.connect(info) + t_connected = time.perf_counter() + print(f"[dialer] Connected in {(t_connected - t_connect) * 1000:.1f} ms") + + stream = await dialer.new_stream(info.peer_id, [STREAM_PROTO]) + await stream.write(MSG) + reply = await stream.read(len(b"pq-ack:") + len(MSG)) + await stream.close() + t_done = time.perf_counter() + + await handshake_done.wait() + + total_ms = (t_done - t_start) * 1000 + connect_ms = (t_connected - t_connect) * 1000 + + print() + print("=" * 55) + print(" PQ Noise XXhfs (X-Wing KEM) -- live node integration") + print("=" * 55) + print(f" Handshake + connect : {connect_ms:6.1f} ms") + print(f" Total (start->ack) : {total_ms:6.1f} ms") + print(f" Message sent : {MSG!r}") + print(f" Reply received : {reply!r}") + print() + + assert received == [MSG], f"Listener got {received!r}, expected {[MSG]!r}" + assert reply == b"pq-ack:" + MSG, f"Unexpected reply: {reply!r}" + print(" PASS -- PQ-secured round-trip succeeded") + print("=" * 55) + + +if __name__ == "__main__": + trio.run(run) diff --git a/libp2p/security/noise/pq/__init__.py b/libp2p/security/noise/pq/__init__.py new file mode 100644 index 000000000..f6e304e76 --- /dev/null +++ b/libp2p/security/noise/pq/__init__.py @@ -0,0 +1,40 @@ +"""Post-quantum Noise security for py-libp2p. + +Public API:: + + from libp2p.security.noise.pq import TransportPQ, PROTOCOL_ID + + # Default (pure-Python kyber-py backend): + security_options = {PROTOCOL_ID: TransportPQ(libp2p_keypair, noise_privkey)} + + # Fast backends (when liboqs C library is installed): + from libp2p.security.noise.pq import make_fast_kem, KeypairPool + + kem = make_fast_kem() # auto-selects liboqs or kyber-py + pool = await KeypairPool.create(kem, min_size=3) # pre-compute keypairs +""" + +__all__ = [ + "PROTOCOL_ID", + "TransportPQ", + "KeypairPool", + "LibOQSXWingKem", + "make_fast_kem", +] + + +def __getattr__(name: str) -> object: + if name in ("PROTOCOL_ID", "TransportPQ"): + from .transport_pq import PROTOCOL_ID, TransportPQ + + globals()["PROTOCOL_ID"] = PROTOCOL_ID + globals()["TransportPQ"] = TransportPQ + return globals()[name] + if name in ("KeypairPool", "LibOQSXWingKem", "make_fast_kem"): + from .kem_backends import KeypairPool, LibOQSXWingKem, make_fast_kem + + globals()["KeypairPool"] = KeypairPool + globals()["LibOQSXWingKem"] = LibOQSXWingKem + globals()["make_fast_kem"] = make_fast_kem + return globals()[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/libp2p/security/noise/pq/_xwing.py b/libp2p/security/noise/pq/_xwing.py new file mode 100644 index 000000000..d6ccdaef2 --- /dev/null +++ b/libp2p/security/noise/pq/_xwing.py @@ -0,0 +1,31 @@ +""" +X-Wing combining function shared across KEM backends. + +Single source of truth for the X-Wing domain separation label and the +SHA3-256 combiner used by both XWingKem (kyber-py) and LibOQSXWingKem. +""" + +import hashlib + +# X-Wing domain separation label: ASCII bytes for "\.//^\" +_XWING_LABEL = bytes([0x5C, 0x2E, 0x2F, 0x2F, 0x5E, 0x5C]) + + +def _xwing_combine( + ss_mlkem: bytes, + ss_x25519: bytes, + ct_x25519: bytes, + pk_x25519: bytes, +) -> bytes: + r""" + Combine ML-KEM and X25519 shared secrets per @noble/post-quantum 0.6.0. + + SHA3-256(ss_mlkem || ss_x25519 || ct_x25519 || pk_x25519 || label) + where label = b'\\.//' + b'^\\' (6 bytes, domain separation). + + Note: label is appended LAST to match @noble/post-quantum 0.6.0 combiner: + sha3_256(concatBytes(ss[0], ss[1], ct[1], pk[1], asciiToBytes('\\.//^\\'))) + """ + return hashlib.sha3_256( + ss_mlkem + ss_x25519 + ct_x25519 + pk_x25519 + _XWING_LABEL + ).digest() diff --git a/libp2p/security/noise/pq/kem.py b/libp2p/security/noise/pq/kem.py new file mode 100644 index 000000000..9a7102f35 --- /dev/null +++ b/libp2p/security/noise/pq/kem.py @@ -0,0 +1,191 @@ +""" +X-Wing KEM for the Noise XXhfs handshake. + +X-Wing is a hybrid KEM combining ML-KEM-768 and X25519: + - Public key: ML-KEM-768 encapsulation key (1184 B) || X25519 public key (32 B) + - Secret key: ML-KEM-768 decapsulation key (2400 B) || X25519 private key (32 B) + - Ciphertext: ML-KEM-768 ciphertext (1088 B) || X25519 ephemeral public key (32 B) + - Shared secret: SHA3-256(ss_mlkem || ss_x25519 || ct_x25519 || pk_x25519 || label) + +Reference: draft-connolly-cfrg-xwing-kem +""" + +from typing import Protocol, runtime_checkable + +from nacl.bindings import crypto_scalarmult, crypto_scalarmult_base +import nacl.utils + +from ._xwing import _xwing_combine + +# Key and ciphertext size constants +_ML_KEM_PK_SIZE = 1184 +_ML_KEM_SK_SIZE = 2400 +_ML_KEM_CT_SIZE = 1088 +_X25519_KEY_SIZE = 32 + +XWING_PK_SIZE = _ML_KEM_PK_SIZE + _X25519_KEY_SIZE # 1216 +XWING_SK_SIZE = _ML_KEM_SK_SIZE + _X25519_KEY_SIZE # 2432 +XWING_CT_SIZE = _ML_KEM_CT_SIZE + _X25519_KEY_SIZE # 1120 + + +@runtime_checkable +class IKem(Protocol): + """Backend-agnostic KEM interface for the XXhfs handshake.""" + + def keygen(self) -> tuple[bytes, bytes]: + """ + Generate a KEM key pair. + + Returns: + (public_key, secret_key) as raw bytes. + + """ + ... + + def encapsulate(self, pk: bytes) -> tuple[bytes, bytes]: + """ + Encapsulate a shared secret to a public key. + + Args: + pk: Recipient's public key. + + Returns: + (ciphertext, shared_secret) as raw bytes. + + """ + ... + + def decapsulate(self, ct: bytes, sk: bytes) -> bytes: + """ + Decapsulate a shared secret from a ciphertext. + + Args: + ct: Ciphertext from the encapsulator. + sk: Local secret key. + + Returns: + Shared secret as 32 raw bytes. + + """ + ... + + +class XWingKem: + """ + X-Wing hybrid KEM using ML-KEM-768 and X25519. + + Uses kyber-py as the ML-KEM-768 backend and PyNaCl for X25519. + Implements the IKem protocol. + + Requires the ``kyber-py`` package (``pip install 'libp2p[pq]'``). + The import is deferred to construction time so that modules importing + this class do not require kyber-py to be installed. + """ + + def __init__(self) -> None: + try: + from kyber_py.ml_kem import ML_KEM_768 + except ImportError as exc: + raise ImportError( + "XWingKem requires the 'kyber-py' package. " + "Install it with: pip install 'libp2p[pq]'" + ) from exc + self._ml_kem = ML_KEM_768 + + def keygen(self) -> tuple[bytes, bytes]: + """ + Generate an X-Wing key pair. + + Returns: + (pk, sk) where: + pk = ml_kem_ek (1184 B) || x25519_pk (32 B) -- 1216 bytes total + sk = ml_kem_dk (2400 B) || x25519_sk (32 B) -- 2432 bytes total + + """ + ml_kem_pk, ml_kem_sk = self._ml_kem.keygen() + + x25519_sk = nacl.utils.random(_X25519_KEY_SIZE) + x25519_pk = bytes(crypto_scalarmult_base(x25519_sk)) + + pk = ml_kem_pk + x25519_pk + sk = ml_kem_sk + x25519_sk + return pk, sk + + def encapsulate(self, pk: bytes) -> tuple[bytes, bytes]: + """ + Encapsulate a shared secret to an X-Wing public key. + + Generates a fresh X25519 ephemeral key pair each call. + + Args: + pk: X-Wing public key (1216 bytes). + + Returns: + (ct, ss) where: + ct = ml_kem_ct (1088 B) || x25519_eph_pk (32 B) -- 1120 bytes total + ss = 32-byte combined shared secret + + Raises: + ValueError: If pk is not 1216 bytes. + + """ + if len(pk) != XWING_PK_SIZE: + raise ValueError( + f"X-Wing public key must be {XWING_PK_SIZE} bytes, got {len(pk)}" + ) + + ml_kem_pk = pk[:_ML_KEM_PK_SIZE] + x25519_pk_r = pk[_ML_KEM_PK_SIZE:] + + # ML-KEM-768 encapsulation + ss_mlkem, ml_kem_ct = self._ml_kem.encaps(ml_kem_pk) + + # X25519 ephemeral key exchange + x25519_eph_sk = nacl.utils.random(_X25519_KEY_SIZE) + x25519_eph_pk = bytes(crypto_scalarmult_base(x25519_eph_sk)) + ss_x25519 = bytes(crypto_scalarmult(x25519_eph_sk, x25519_pk_r)) + + ss = _xwing_combine(ss_mlkem, ss_x25519, x25519_eph_pk, x25519_pk_r) + ct = ml_kem_ct + x25519_eph_pk + return ct, ss + + def decapsulate(self, ct: bytes, sk: bytes) -> bytes: + """ + Decapsulate a shared secret from an X-Wing ciphertext. + + Args: + ct: X-Wing ciphertext (1120 bytes). + sk: X-Wing secret key (2432 bytes). + + Returns: + 32-byte combined shared secret. + + Raises: + ValueError: If ct or sk have unexpected lengths. + + """ + if len(ct) != XWING_CT_SIZE: + raise ValueError( + f"X-Wing ciphertext must be {XWING_CT_SIZE} bytes, got {len(ct)}" + ) + if len(sk) != XWING_SK_SIZE: + raise ValueError( + f"X-Wing secret key must be {XWING_SK_SIZE} bytes, got {len(sk)}" + ) + + ml_kem_sk = sk[:_ML_KEM_SK_SIZE] + x25519_sk_r = sk[_ML_KEM_SK_SIZE:] + + ml_kem_ct = ct[:_ML_KEM_CT_SIZE] + x25519_eph_pk = ct[_ML_KEM_CT_SIZE:] + + # ML-KEM-768 decapsulation + ss_mlkem = self._ml_kem.decaps(ml_kem_sk, ml_kem_ct) + + # X25519 DH using our static private key and the ephemeral public key + ss_x25519 = bytes(crypto_scalarmult(x25519_sk_r, x25519_eph_pk)) + + # Reconstruct our X25519 public key for the combiner + x25519_pk_r = bytes(crypto_scalarmult_base(x25519_sk_r)) + + return _xwing_combine(ss_mlkem, ss_x25519, x25519_eph_pk, x25519_pk_r) diff --git a/libp2p/security/noise/pq/kem_backends.py b/libp2p/security/noise/pq/kem_backends.py new file mode 100644 index 000000000..e2de9a7b6 --- /dev/null +++ b/libp2p/security/noise/pq/kem_backends.py @@ -0,0 +1,281 @@ +""" +Fast KEM backends for the Noise XXhfs handshake. + +Two components are provided: + +LibOQSXWingKem + X-Wing built on liboqs (Open Quantum Safe C library). + Requires `pip install liboqs-python` AND the liboqs shared library. + Typical performance: keygen ~0.15 ms, encap ~0.12 ms, decap ~0.10 ms — + roughly 100-200x faster than the pure-Python kyber-py backend. + + Installation: + # Windows: download the prebuilt DLL from the OQS releases page and + # place liboqs.dll in the same directory as oqs/__init__.py, then: + pip install liboqs-python + + # Linux/macOS: + sudo apt install liboqs-dev # or brew install liboqs + pip install liboqs-python + +KeypairPool + Pre-computes KEM keypairs during idle time so keygen does not fall on + the connection critical path. Works with any IKem backend. With the + pure-Python kyber-py backend, keygen costs ~20 ms; a pool of 3 pre-generated + keypairs means 3 connections can complete their KEM handshake without paying + any keygen cost at all. Background refill uses asyncio.to_thread() on + Python ≥ 3.9 so the event loop is never blocked. + + Usage: + from libp2p.security.noise.pq.kem import XWingKem + from libp2p.security.noise.pq.kem_backends import KeypairPool + + pool = await KeypairPool.create(XWingKem(), min_size=3) + pk, sk = pool.acquire() # instant — no keygen on critical path +""" + +from __future__ import annotations + +import asyncio +from collections import deque +import logging +from typing import TYPE_CHECKING + +from ._xwing import _xwing_combine + +if TYPE_CHECKING: + from .kem import IKem + +logger = logging.getLogger(__name__) + +# Cached result of the liboqs availability probe. None = not yet checked, +# True = available, False = unavailable. Avoids re-running the 5-second +# oqs auto-install wait on every make_fast_kem() / LibOQSXWingKem() call. +_LIBOQS_AVAILABLE: bool | None = None + +_ML_KEM_768_PK_SIZE = 1184 +_ML_KEM_768_CT_SIZE = 1088 +_X25519_KEY_SIZE = 32 + + +class LibOQSXWingKem: + """ + X-Wing KEM built on liboqs (C library via liboqs-python). + + Uses OQS_KEM_ml_kem_768 for the ML-KEM-768 component and + PyNaCl (libsodium) for X25519 — both C extensions, so no + Python bottleneck in the inner loops. + + Raises ImportError on construction if liboqs-python or liboqs.dll + is not available; fall back to XWingKem (kyber-py) in that case. + """ + + PUBKEY_LEN = 1216 # 1184 + 32 + CT_LEN = 1120 # 1088 + 32 + SS_LEN = 32 + SK_LEN = 2432 # 2400 ML-KEM-768 dk + 32 X25519 sk + + def __init__(self) -> None: + global _LIBOQS_AVAILABLE + # Fast path: skip the 5-second auto-install wait if we already know + # the C library is absent. + if _LIBOQS_AVAILABLE is False: + raise ImportError( + "LibOQSXWingKem requires the liboqs C library (not available). " + "See: https://github.com/open-quantum-safe/liboqs-python" + ) + # liboqs-python may raise ImportError, RuntimeError, SystemExit(1) + # (git-clone auto-install), or OSError (Windows temp-dir cleanup race). + # Catch all and convert to ImportError so callers can gracefully fall back. + try: + from nacl.bindings import crypto_scalarmult, crypto_scalarmult_base + import nacl.utils + import oqs # type: ignore[import-error] + + _LIBOQS_AVAILABLE = True + except (ImportError, RuntimeError, SystemExit, OSError) as e: + _LIBOQS_AVAILABLE = False + raise ImportError( + "LibOQSXWingKem requires the liboqs C library and PyNaCl. " + "See: https://github.com/open-quantum-safe/liboqs-python. " + f"Original error: {e}" + ) from e + + self._oqs = oqs # type: ignore[name-defined] + self._scalarmult = crypto_scalarmult # type: ignore[name-defined] + self._scalarmult_base = crypto_scalarmult_base # type: ignore[name-defined] + self._nacl_random = nacl.utils.random # type: ignore[name-defined] + + def keygen(self) -> tuple[bytes, bytes]: + """ + Generate an X-Wing keypair via liboqs + libsodium. + + Returns: + (pk, sk) where pk = ml_kem_ek || x25519_pk (1216 B), + sk = ml_kem_dk || x25519_sk (2432 B). + + """ + with self._oqs.KeyEncapsulation("ML-KEM-768") as kem: # type: ignore[arg-type] + ml_pk = kem.generate_keypair() # type: ignore[union-attr] + ml_sk = kem.export_secret_key() # type: ignore[union-attr] + + x_sk = self._nacl_random(_X25519_KEY_SIZE) + x_pk = bytes(self._scalarmult_base(x_sk)) + return ml_pk + x_pk, ml_sk + x_sk + + def encapsulate(self, pk: bytes) -> tuple[bytes, bytes]: + """ + Encapsulate to an X-Wing public key. + + Args: + pk: 1216-byte X-Wing public key. + + Returns: + (ct, ss) where ct = ml_kem_ct || x25519_eph_pk (1120 B), + ss = 32-byte X-Wing shared secret. + + """ + if len(pk) != self.PUBKEY_LEN: + raise ValueError(f"pk must be {self.PUBKEY_LEN} bytes, got {len(pk)}") + + ml_pk = pk[:_ML_KEM_768_PK_SIZE] + x_pk_r = pk[_ML_KEM_768_PK_SIZE:] + + with self._oqs.KeyEncapsulation("ML-KEM-768") as kem: # type: ignore[arg-type] + ml_ct, ss_mlkem = kem.encap_secret(ml_pk) # type: ignore[union-attr] + + x_eph_sk = self._nacl_random(_X25519_KEY_SIZE) + x_eph_pk = bytes(self._scalarmult_base(x_eph_sk)) + ss_x25519 = bytes(self._scalarmult(x_eph_sk, x_pk_r)) + + ss = _xwing_combine(ss_mlkem, ss_x25519, x_eph_pk, x_pk_r) + return ml_ct + x_eph_pk, ss + + def decapsulate(self, ct: bytes, sk: bytes) -> bytes: + """ + Decapsulate an X-Wing ciphertext. + + Args: + ct: 1120-byte X-Wing ciphertext. + sk: 2432-byte X-Wing secret key. + + Returns: + 32-byte X-Wing shared secret. + + """ + if len(ct) != self.CT_LEN: + raise ValueError(f"ct must be {self.CT_LEN} bytes, got {len(ct)}") + if len(sk) != self.SK_LEN: + raise ValueError(f"sk must be {self.SK_LEN} bytes, got {len(sk)}") + + ml_sk = sk[:2400] + x_sk_r = sk[2400:] + ml_ct = ct[:_ML_KEM_768_CT_SIZE] + x_eph_pk = ct[_ML_KEM_768_CT_SIZE:] + + with self._oqs.KeyEncapsulation("ML-KEM-768", secret_key=ml_sk) as kem: # type: ignore[arg-type] + ss_mlkem = kem.decap_secret(ml_ct) # type: ignore[union-attr] + + ss_x25519 = bytes(self._scalarmult(x_sk_r, x_eph_pk)) + x_pk_r = bytes(self._scalarmult_base(x_sk_r)) + return _xwing_combine(ss_mlkem, ss_x25519, x_eph_pk, x_pk_r) + + +def make_fast_kem() -> IKem: + """ + Return the fastest available X-Wing KEM backend. + + Tries LibOQSXWingKem first (C library, ~100x faster). Falls back to + XWingKem (pure-Python kyber-py) if liboqs is not installed. + """ + try: + kem = LibOQSXWingKem() + logger.info("Using LibOQSXWingKem (liboqs C backend)") + return kem + except (ImportError, RuntimeError, OSError) as e: + logger.info("liboqs not available (%s), falling back to kyber-py", e) + from .kem import XWingKem + + return XWingKem() + + +class KeypairPool: + """ + Pre-computes KEM keypairs during idle time to eliminate keygen latency + on the connection critical path. + + With kyber-py, keygen costs ~20 ms. A pool of 3 pre-generated keypairs + means 3 handshakes can proceed without paying any keygen cost. Background + refill uses asyncio.to_thread() so the event loop is not blocked. + + Usage: + pool = await KeypairPool.create(kem, min_size=3) + pk, sk = pool.acquire() # instant + + Note: The pool is not thread-safe. All calls must be made from the same + event loop thread (which is the norm for asyncio Python code). + """ + + def __init__(self, kem: IKem, min_size: int = 3) -> None: + self._kem = kem + self._min_size = min_size + self._pool: deque[tuple[bytes, bytes]] = deque() + self._refill_task: asyncio.Task[None] | None = None + + @classmethod + async def create(cls, kem: IKem, min_size: int = 3) -> KeypairPool: + """ + Create a pool and fill it with pre-generated keypairs. + + Uses asyncio.to_thread() for the blocking keygen calls so the event + loop stays responsive during construction. + """ + pool = cls(kem, min_size) + await pool._async_fill() + return pool + + def acquire(self) -> tuple[bytes, bytes]: + """ + Return a pre-generated keypair. If the pool is empty, generates + one synchronously (fallback — should not happen in normal operation). + Schedules an async refill if the pool drops below min_size. + """ + if self._pool: + kp = self._pool.popleft() + else: + logger.warning("KeypairPool exhausted — generating synchronously") + kp = self._kem.keygen() + + if len(self._pool) < self._min_size and self._refill_task is None: + self._schedule_refill() + return kp + + @property + def size(self) -> int: + return len(self._pool) + + def _schedule_refill(self) -> None: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return # no event loop — skip background refill + self._refill_task = loop.create_task(self._async_fill()) + self._refill_task.add_done_callback(self._on_refill_done) + + def _on_refill_done(self, task: asyncio.Task[None]) -> None: + self._refill_task = None + if task.cancelled(): + return # event loop shutting down — normal + exc = task.exception() + if exc: + logger.error("KeypairPool refill failed: %s", exc) + + async def _async_fill(self) -> None: + needed = self._min_size - len(self._pool) + if needed <= 0: + return + loop = asyncio.get_running_loop() + keypairs = await asyncio.gather( + *(loop.run_in_executor(None, self._kem.keygen) for _ in range(needed)) # type: ignore[arg-type] + ) + self._pool.extend(keypairs) diff --git a/libp2p/security/noise/pq/noise_state.py b/libp2p/security/noise/pq/noise_state.py new file mode 100644 index 000000000..b4b498209 --- /dev/null +++ b/libp2p/security/noise/pq/noise_state.py @@ -0,0 +1,161 @@ +""" +Noise symmetric state for the XXhfs handshake. + +Implements CipherState and SymmetricState as defined in the Noise protocol spec +(https://noiseprotocol.org/noise.html) with ChaCha20-Poly1305 and SHA-256, +extended for the HFS (Hybrid Forward Secrecy) pattern. + +Protocol name: Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256 +""" + +import hashlib +import hmac +import struct + +from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 + +# Full protocol name -- cryptographically bound to every derived key +PROTOCOL_NAME = b"Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256" + +_HASH_LEN = 32 # SHA-256 output length +_KEY_LEN = 32 # ChaCha20 key length +_TAG_LEN = 16 # Poly1305 tag length + + +def _hmac_sha256(key: bytes, data: bytes) -> bytes: + return hmac.new(key, data, hashlib.sha256).digest() + + +def _hkdf(chaining_key: bytes, input_key_material: bytes, n: int) -> tuple[bytes, ...]: + """ + Noise HKDF producing n outputs (2 or 3), each 32 bytes. + + temp_k = HMAC-SHA256(ck, ikm) + out_i = HMAC-SHA256(temp_k, out_{i-1} || byte(i)) + """ + temp_k = _hmac_sha256(chaining_key, input_key_material) + out1 = _hmac_sha256(temp_k, b"\x01") + out2 = _hmac_sha256(temp_k, out1 + b"\x02") + if n == 2: + return out1, out2 + out3 = _hmac_sha256(temp_k, out2 + b"\x03") + return out1, out2, out3 + + +def _nonce_bytes(n: int) -> bytes: + """Encode Noise nonce: 4 zero bytes + 8-byte little-endian counter = 12 bytes.""" + return b"\x00" * 4 + struct.pack(" None: + if len(key) != _KEY_LEN: + raise ValueError(f"Key must be {_KEY_LEN} bytes, got {len(key)}") + self._cipher = ChaCha20Poly1305(key) + self.n = 0 + + def encrypt_with_ad(self, ad: bytes, plaintext: bytes) -> bytes: + """Encrypt plaintext with associated data. Increments nonce counter.""" + ct = self._cipher.encrypt(_nonce_bytes(self.n), plaintext, ad) + self.n += 1 + return ct + + def decrypt_with_ad(self, ad: bytes, ciphertext: bytes) -> bytes: + """Decrypt ciphertext with associated data. Increments nonce counter.""" + plaintext = self._cipher.decrypt(_nonce_bytes(self.n), ciphertext, ad) + self.n += 1 + return plaintext + + +class SymmetricState: + """ + Noise SymmetricState for Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256. + + Maintains the chaining key (ck) and handshake hash (h) across all + message tokens. Both are initialised to SHA-256(PROTOCOL_NAME). + """ + + ck: bytes # chaining key + h: bytes # handshake hash (running transcript) + _cs: CipherState | None + + def __init__(self) -> None: + # Protocol name > 32 bytes so h = HASH(protocol_name) + digest = hashlib.sha256(PROTOCOL_NAME).digest() + self.ck = digest + self.h = digest + self._cs = None + + def mix_hash(self, data: bytes) -> None: + """H = SHA-256(h || data)""" + self.h = hashlib.sha256(self.h + data).digest() + + def mix_key(self, input_key_material: bytes) -> None: + """Update chaining key and cipher key via HKDF.""" + self.ck, temp_k = _hkdf(self.ck, input_key_material, 2) + self._cs = CipherState(temp_k) + + def mix_key_and_hash(self, input_key_material: bytes) -> None: + """ + 3-output HKDF for ``psk`` tokens (Noise spec section 5.2). + + ck, temp_h, temp_k = HKDF(ck, ikm, 3) + MixHash(temp_h) + + The extra ``temp_h`` output folds the pre-shared key into the + handshake transcript hash, binding it to all prior messages. + + **Not used by the XXhfs token sequence.** The ``ekem1`` KEM token + uses ``mix_key`` (2-output HKDF), identical to how DH tokens are + processed. This method is present for full Noise spec API + completeness and would be needed if a ``psk`` modifier were added + to the pattern (e.g., ``XXhfs+psk2``). + """ + self.ck, temp_h, temp_k = _hkdf(self.ck, input_key_material, 3) + self.mix_hash(temp_h) + self._cs = CipherState(temp_k) + + def encrypt_and_hash(self, plaintext: bytes) -> bytes: + """ + AEAD-encrypt plaintext, then mix the ciphertext into h. + + Returns the ciphertext (plaintext + 16-byte tag). + """ + if self._cs is None: + # No key yet -- send in the clear (used for early tokens) + self.mix_hash(plaintext) + return plaintext + ct = self._cs.encrypt_with_ad(self.h, plaintext) + self.mix_hash(ct) + return ct + + def decrypt_and_hash(self, ciphertext: bytes) -> bytes: + """ + AEAD-decrypt ciphertext, then mix the ciphertext into h. + + Returns the plaintext. + """ + if self._cs is None: + self.mix_hash(ciphertext) + return ciphertext + plaintext = self._cs.decrypt_with_ad(self.h, ciphertext) + self.mix_hash(ciphertext) + return plaintext + + def split(self) -> tuple[CipherState, CipherState]: + """ + Derive two transport CipherStates at the end of the handshake. + + Returns (initiator_cs, responder_cs). + """ + temp_k1, temp_k2 = _hkdf(self.ck, b"", 2) + return CipherState(temp_k1), CipherState(temp_k2) diff --git a/libp2p/security/noise/pq/patterns_pq.py b/libp2p/security/noise/pq/patterns_pq.py new file mode 100644 index 000000000..ee0959b81 --- /dev/null +++ b/libp2p/security/noise/pq/patterns_pq.py @@ -0,0 +1,394 @@ +""" +XXhfs Noise handshake pattern for post-quantum security. + +Implements Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256: a three-message +handshake that adds X-Wing KEM tokens to the classical Noise XX pattern +for hybrid post-quantum forward secrecy. + +Message layout (wire bytes, inside 2-byte length-prefixed frames):: + + A (initiator -> responder): e_pk(32) || e1_pk(1216) = 1248 B + B (responder -> initiator): e_pk(32) || enc_ct(1136) + || enc_s(48) || enc_payload + C (initiator -> responder): enc_s(48) || enc_payload + +Token sequence:: + + A: e, e1 (no symmetric key yet, plain mix_hash) + B: e, ee, ekem1, s, es (ekem1 = encrypt(ct) then mix_key(ss_kem)) + C: s, se + +Transport keys after ``split()``:: + + Initiator: encrypt=cs1, decrypt=cs2 + Responder: encrypt=cs2, decrypt=cs1 +""" + +import logging +from typing import cast + +from nacl.bindings import ( + crypto_scalarmult, + crypto_scalarmult_base, +) +import nacl.utils + +from libp2p.abc import ( + IRawConnection, + ISecureConn, +) +from libp2p.crypto.keys import PrivateKey +from libp2p.crypto.x25519 import X25519PublicKey +from libp2p.io.abc import ( + EncryptedMsgReadWriter, + ReadWriteCloser, +) +from libp2p.peer.id import ID +from libp2p.security.secure_session import SecureSession + +from ..exceptions import ( + InvalidSignature, + PeerIDMismatchesPubkey, +) +from ..io import NoisePacketReadWriter +from ..messages import ( + NoiseHandshakePayload, + make_handshake_payload_sig, + verify_handshake_payload_sig, +) +from .kem import ( + XWING_CT_SIZE, + XWING_PK_SIZE, + IKem, +) +from .kem_backends import make_fast_kem +from .noise_state import ( + CipherState, + SymmetricState, +) + +logger = logging.getLogger(__name__) + +# Size constants +_X25519_SIZE = 32 +_AEAD_TAG = 16 +_KEM_CT_ENC_SIZE = XWING_CT_SIZE + _AEAD_TAG # 1120 + 16 = 1136 +_S_ENC_SIZE = _X25519_SIZE + _AEAD_TAG # 32 + 16 = 48 + + +class PQTransportReadWriter(EncryptedMsgReadWriter): + """ + Post-handshake transport that encrypts/decrypts with PQC CipherStates. + + Each direction uses its own CipherState with an independent nonce counter, + matching the Noise spec for transport-phase messages. + """ + + def __init__( + self, + conn: IRawConnection, + send_cs: CipherState, + recv_cs: CipherState, + ) -> None: + super().__init__(conn) # sets self.conn for address delegation + self._packet_rw = NoisePacketReadWriter(cast(ReadWriteCloser, conn)) + self._send_cs = send_cs + self._recv_cs = recv_cs + + def encrypt(self, data: bytes) -> bytes: + return self._send_cs.encrypt_with_ad(b"", data) + + def decrypt(self, data: bytes) -> bytes: + return self._recv_cs.decrypt_with_ad(b"", data) + + async def write_msg(self, msg: bytes) -> None: + await self._packet_rw.write_msg(self.encrypt(msg)) + + async def read_msg(self) -> bytes: + return self.decrypt(await self._packet_rw.read_msg()) + + async def close(self) -> None: + await self._packet_rw.close() + + +class PatternXXhfs: + """ + Noise XXhfs handshake pattern with X-Wing hybrid KEM. + + Provides mutual authentication (libp2p identity signatures) and + hybrid post-quantum forward secrecy via the X-Wing KEM (ML-KEM-768 + + X25519) alongside the classical X25519 DH exchange. + """ + + PROTOCOL_NAME = b"Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256" + + def __init__( + self, + local_peer: ID, + libp2p_privkey: PrivateKey, + noise_static_key: PrivateKey, + kem: IKem | None = None, + early_data: bytes | None = None, + ) -> None: + self.local_peer = local_peer + self.libp2p_privkey = libp2p_privkey + self.noise_static_key = noise_static_key + self.kem: IKem = kem if kem is not None else make_fast_kem() + self.early_data = early_data + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _static_pk_bytes(self) -> bytes: + """Return the raw 32-byte X25519 static public key.""" + return self.noise_static_key.get_public_key().to_bytes() + + def _static_sk_bytes(self) -> bytes: + """Return the raw 32-byte X25519 static private key.""" + return self.noise_static_key.to_bytes() + + def _make_payload(self) -> bytes: + """Serialize a libp2p NoiseHandshakePayload (id_pubkey + id_sig).""" + static_pubkey = self.noise_static_key.get_public_key() + sig = make_handshake_payload_sig(self.libp2p_privkey, static_pubkey) + return NoiseHandshakePayload( + id_pubkey=self.libp2p_privkey.get_public_key(), + id_sig=sig, + ).serialize() + + # ------------------------------------------------------------------ + # Initiator (outbound) + # ------------------------------------------------------------------ + + async def handshake_outbound( + self, conn: IRawConnection, remote_peer: ID | None + ) -> ISecureConn: + """ + Run the initiator side of the XXhfs handshake. + + The responder's libp2p identity signature is always verified. When + ``remote_peer`` is ``None`` the peer ID check is skipped, which is + useful for interop tests where the remote ID is not known in advance. + + .. warning:: + + Passing ``remote_peer=None`` disables peer-identity binding. + The responder's signature is still verified (proving key + ownership), but the caller cannot confirm which peer they + connected to. Only use ``None`` in interop tests or when + peer discovery happens out-of-band. + + Args: + conn: Raw underlying connection. + remote_peer: Expected responder peer ID, or ``None`` to skip check. + + Returns: + SecureSession ready for post-handshake transport. + + Raises: + PeerIDMismatchesPubkey: Responder peer ID mismatch (non-None only). + InvalidSignature: Responder identity signature is invalid. + + """ + ss = SymmetricState() + ss.mix_hash( + b"" + ) # MixHash(prologue=empty) — required by Noise spec even when empty + pkt = NoisePacketReadWriter(cast(ReadWriteCloser, conn)) + + # ---- Message A: e, e1 ---------------------------------------- + # e: ephemeral X25519 + e_sk = nacl.utils.random(_X25519_SIZE) + e_pk = bytes(crypto_scalarmult_base(e_sk)) + ss.mix_hash(e_pk) + logger.debug("handshake_outbound: msg A – generated ephemeral X25519") + + # e1: ephemeral X-Wing KEM keypair + e1_pk, e1_sk = self.kem.keygen() + ss.mix_hash(e1_pk) + logger.debug("handshake_outbound: msg A – generated X-Wing KEM keypair") + + # Empty payload (no cipher key yet; encrypt_and_hash = mix_hash + identity) + enc_payload_a = ss.encrypt_and_hash(b"") + await pkt.write_msg(e_pk + e1_pk + enc_payload_a) + logger.debug("handshake_outbound: msg A sent (%d B)", len(e_pk) + len(e1_pk)) + + # ---- Message B: e, ee, ekem1, s, es -------------------------- + msg_b = await pkt.read_msg() + offset = 0 + logger.debug("handshake_outbound: msg B received (%d B)", len(msg_b)) + + # e: responder's ephemeral X25519 public key + resp_e_pk = msg_b[offset : offset + _X25519_SIZE] + offset += _X25519_SIZE + ss.mix_hash(resp_e_pk) + + # ee: DH(e_init, e_resp) + dh_ee = bytes(crypto_scalarmult(e_sk, resp_e_pk)) + ss.mix_key(dh_ee) + + # ekem1: decrypt KEM ciphertext, then mix KEM shared secret + enc_ct = msg_b[offset : offset + _KEM_CT_ENC_SIZE] + offset += _KEM_CT_ENC_SIZE + ct = ss.decrypt_and_hash(enc_ct) # decrypt with ee-derived key + ss_kem = self.kem.decapsulate(ct, e1_sk) # recover KEM shared secret + # ekem1 uses mix_key (2-output HKDF), same as DH tokens per the XXhfs spec. + # mix_key_and_hash (3-output) is only for psk tokens and is not used here. + ss.mix_key(ss_kem) + + # s: decrypt responder's static public key + enc_s = msg_b[offset : offset + _S_ENC_SIZE] + offset += _S_ENC_SIZE + resp_s_pk_bytes = ss.decrypt_and_hash(enc_s) + resp_s_pk = X25519PublicKey.from_bytes(resp_s_pk_bytes) + + # es: DH(e_init, s_resp) + dh_es = bytes(crypto_scalarmult(e_sk, resp_s_pk_bytes)) + ss.mix_key(dh_es) + + # Decrypt responder's handshake payload + resp_payload_bytes = ss.decrypt_and_hash(msg_b[offset:]) + resp_payload = NoiseHandshakePayload.deserialize(resp_payload_bytes) + + # Verify responder's libp2p identity signature + if not verify_handshake_payload_sig(resp_payload, resp_s_pk): + raise InvalidSignature + resp_peer_id = ID.from_pubkey(resp_payload.id_pubkey) + if remote_peer is not None and resp_peer_id != remote_peer: + raise PeerIDMismatchesPubkey( + f"peer ID mismatch: expected {remote_peer}, got {resp_peer_id}" + ) + + # ---- Message C: s, se ---------------------------------------- + # s: encrypt our static public key + enc_s_c = ss.encrypt_and_hash(self._static_pk_bytes()) + + # se: DH(s_init, e_resp) + dh_se = bytes(crypto_scalarmult(self._static_sk_bytes(), resp_e_pk)) + ss.mix_key(dh_se) + + # Encrypt our handshake payload + enc_payload_c = ss.encrypt_and_hash(self._make_payload()) + await pkt.write_msg(enc_s_c + enc_payload_c) + logger.debug("handshake_outbound: msg C sent") + + # ---- Split and return ---------------------------------------- + cs1, cs2 = ss.split() + transport = PQTransportReadWriter(conn, send_cs=cs1, recv_cs=cs2) + return SecureSession( + local_peer=self.local_peer, + local_private_key=self.libp2p_privkey, + remote_peer=resp_peer_id, + remote_permanent_pubkey=resp_s_pk, + is_initiator=True, + conn=transport, + ) + + # ------------------------------------------------------------------ + # Responder (inbound) + # ------------------------------------------------------------------ + + async def handshake_inbound(self, conn: IRawConnection) -> ISecureConn: + """ + Run the responder side of the XXhfs handshake. + + Args: + conn: Raw underlying connection. + + Returns: + SecureSession ready for post-handshake transport. + + Raises: + InvalidSignature: If the initiator's identity signature is invalid. + + """ + ss = SymmetricState() + ss.mix_hash( + b"" + ) # MixHash(prologue=empty) — required by Noise spec even when empty + pkt = NoisePacketReadWriter(cast(ReadWriteCloser, conn)) + + # ---- Message A: receive e, e1 -------------------------------- + msg_a = await pkt.read_msg() + logger.debug("handshake_inbound: msg A received (%d B)", len(msg_a)) + offset = 0 + + # e: initiator's ephemeral X25519 public key + init_e_pk = msg_a[offset : offset + _X25519_SIZE] + offset += _X25519_SIZE + ss.mix_hash(init_e_pk) + + # e1: initiator's X-Wing KEM public key + init_e1_pk = msg_a[offset : offset + XWING_PK_SIZE] + offset += XWING_PK_SIZE + ss.mix_hash(init_e1_pk) + + # Empty payload (no cipher key yet) + ss.decrypt_and_hash(msg_a[offset:]) # mix_hash(b"") + + # ---- Message B: e, ee, ekem1, s, es -------------------------- + # e: generate our ephemeral X25519 + e_sk = nacl.utils.random(_X25519_SIZE) + e_pk = bytes(crypto_scalarmult_base(e_sk)) + ss.mix_hash(e_pk) + + # ee: DH(e_resp, e_init) + dh_ee = bytes(crypto_scalarmult(e_sk, init_e_pk)) + ss.mix_key(dh_ee) + + # ekem1: encapsulate to initiator's e1, encrypt ct, then mix ss_kem + ct, ss_kem_bytes = self.kem.encapsulate(init_e1_pk) + enc_ct = ss.encrypt_and_hash(ct) # encrypt with ee-derived key + # ekem1 uses mix_key (2-output HKDF), same as DH tokens per the XXhfs spec. + # mix_key_and_hash (3-output) is only for psk tokens and is not used here. + ss.mix_key(ss_kem_bytes) + + # s: encrypt our static public key + enc_s = ss.encrypt_and_hash(self._static_pk_bytes()) + + # es: DH(s_resp, e_init) + dh_es = bytes(crypto_scalarmult(self._static_sk_bytes(), init_e_pk)) + ss.mix_key(dh_es) + + # Encrypt our handshake payload + enc_payload_b = ss.encrypt_and_hash(self._make_payload()) + + await pkt.write_msg(e_pk + enc_ct + enc_s + enc_payload_b) + logger.debug("handshake_inbound: msg B sent") + + # ---- Message C: receive s, se -------------------------------- + msg_c = await pkt.read_msg() + logger.debug("handshake_inbound: msg C received (%d B)", len(msg_c)) + offset = 0 + + # s: decrypt initiator's static public key + enc_s_c = msg_c[offset : offset + _S_ENC_SIZE] + offset += _S_ENC_SIZE + init_s_pk_bytes = ss.decrypt_and_hash(enc_s_c) + + # se: DH(e_resp, s_init) + dh_se = bytes(crypto_scalarmult(e_sk, init_s_pk_bytes)) + ss.mix_key(dh_se) + + # Decrypt initiator's handshake payload + init_payload_bytes = ss.decrypt_and_hash(msg_c[offset:]) + init_payload = NoiseHandshakePayload.deserialize(init_payload_bytes) + + # Verify initiator's libp2p identity signature + init_s_pk = X25519PublicKey.from_bytes(init_s_pk_bytes) + if not verify_handshake_payload_sig(init_payload, init_s_pk): + raise InvalidSignature + init_peer_id = ID.from_pubkey(init_payload.id_pubkey) + + # ---- Split and return ---------------------------------------- + cs1, cs2 = ss.split() + transport = PQTransportReadWriter(conn, send_cs=cs2, recv_cs=cs1) + return SecureSession( + local_peer=self.local_peer, + local_private_key=self.libp2p_privkey, + remote_peer=init_peer_id, + remote_permanent_pubkey=init_s_pk, + is_initiator=False, + conn=transport, + ) diff --git a/libp2p/security/noise/pq/transport_pq.py b/libp2p/security/noise/pq/transport_pq.py new file mode 100644 index 000000000..eb86bfe0c --- /dev/null +++ b/libp2p/security/noise/pq/transport_pq.py @@ -0,0 +1,60 @@ +""" +Post-quantum Noise transport for py-libp2p. + +Wraps PatternXXhfs as an ISecureTransport so it integrates with the +standard py-libp2p security negotiation stack. + +Protocol ID: /noise-pq/1.0.0 +""" + +from libp2p.abc import ( + IRawConnection, + ISecureConn, + ISecureTransport, +) +from libp2p.crypto.keys import ( + KeyPair, + PrivateKey, +) +from libp2p.custom_types import TProtocol +from libp2p.peer.id import ID + +from .kem_backends import make_fast_kem +from .patterns_pq import PatternXXhfs + +PROTOCOL_ID = TProtocol("/noise-pq/1.0.0") + + +class TransportPQ(ISecureTransport): + """ + ISecureTransport backed by the Noise XXhfs + X-Wing handshake. + + Drop-in replacement for the classical Noise ``Transport``; pass it + as a security option to ``BasicHost`` under the key ``PROTOCOL_ID``. + """ + + def __init__( + self, + libp2p_keypair: KeyPair, + noise_privkey: PrivateKey, + ) -> None: + self.libp2p_privkey = libp2p_keypair.private_key + self.noise_privkey = noise_privkey + self.local_peer = ID.from_pubkey(libp2p_keypair.public_key) + + def get_pattern(self) -> PatternXXhfs: + """Return a fresh PatternXXhfs for a single handshake.""" + return PatternXXhfs( + local_peer=self.local_peer, + libp2p_privkey=self.libp2p_privkey, + noise_static_key=self.noise_privkey, + kem=make_fast_kem(), + ) + + async def secure_inbound(self, conn: IRawConnection) -> ISecureConn: + """Upgrade an inbound raw connection to a PQC-secured session.""" + return await self.get_pattern().handshake_inbound(conn) + + async def secure_outbound(self, conn: IRawConnection, peer_id: ID) -> ISecureConn: + """Upgrade an outbound raw connection to a PQC-secured session.""" + return await self.get_pattern().handshake_outbound(conn, peer_id) diff --git a/pyproject.toml b/pyproject.toml index a95a1ebe7..fa2fde260 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,12 @@ filecoin-connect-demo = "examples.filecoin.filecoin_connect_demo:main" filecoin-ping-identify-demo = "examples.filecoin.filecoin_ping_identify_demo:main" filecoin-pubsub-demo = "examples.filecoin.filecoin_pubsub_demo:main" +[project.optional-dependencies] +pq = ["kyber-py>=0.9.0"] +pq-fast = [ + "liboqs-python>=0.12.0", +] + [dependency-groups] dev = [ { include-group = "docs" }, @@ -115,6 +121,7 @@ test = [ "pytest-xdist>=2.4.0", "pytest-mock>=3.15.1", "pytest-rerunfailures>=12.0", + "kyber-py>=0.9.0", ] a2a-demo = [ "a2a-sdk[http-server]>=1.0.0", diff --git a/scripts/interop_dial.py b/scripts/interop_dial.py new file mode 100644 index 000000000..29cb9ca0c --- /dev/null +++ b/scripts/interop_dial.py @@ -0,0 +1,138 @@ +""" +Phase 5 live interop: Python dialer connecting to the JS NoiseHFS listener. + +Usage: + # Terminal 1 — start the JS listener: + node js-libp2p-noise/scripts/node-listener.mjs + + # Terminal 2 — run this dialer: + cd py-libp2p + python scripts/interop_dial.py + +Verifies that Python (py-libp2p) and JavaScript (js-libp2p-noise) can +complete a Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256 handshake over a +real TCP connection and exchange encrypted messages. +""" + +import logging +import sys + +import anyio +import anyio.abc + +from libp2p.crypto.ed25519 import create_new_key_pair as ed25519_keypair +from libp2p.crypto.x25519 import create_new_key_pair as x25519_keypair +from libp2p.peer.id import ID +from libp2p.security.noise.pq.kem import XWingKem +from libp2p.security.noise.pq.patterns_pq import PatternXXhfs + +HOST = "127.0.0.1" +PORT = 8000 + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s %(message)s", + stream=sys.stdout, +) +logger = logging.getLogger("interop_dial") + + +class RawTCPConn: + """ + Minimal IRawConnection wrapping an anyio SocketStream. + + Adapts anyio's ByteStream to the read/write/close interface that + NoisePacketReadWriter (and thus PatternXXhfs) expects. + """ + + is_initiator: bool = True + + def __init__(self, stream: anyio.abc.ByteStream) -> None: + self._stream = stream + + async def read(self, n: int | None = None) -> bytes: + if n is None: + return await self._stream.receive(65536) + return await self._stream.receive(n) + + async def write(self, data: bytes) -> None: + await self._stream.send(data) + + async def close(self) -> None: + await self._stream.aclose() + + def get_transport_addresses(self) -> list: + return [] + + +async def main() -> None: + # ── Key material ──────────────────────────────────────────────────────────── + # libp2p identity (Ed25519) — used in the Noise handshake payload signature + libp2p_kp = ed25519_keypair() + local_peer = ID.from_pubkey(libp2p_kp.public_key) + + # Noise static key (X25519) — used in the XX handshake s/se tokens + noise_kp = x25519_keypair() + noise_static = noise_kp.private_key + + logger.info("Local peer ID: %s", local_peer) + + # ── Connect ───────────────────────────────────────────────────────────────── + logger.info("Connecting to JS listener at %s:%d", HOST, PORT) + async with await anyio.connect_tcp(HOST, PORT) as stream: + conn = RawTCPConn(stream) + logger.info("TCP connection established") + + # ── Handshake ─────────────────────────────────────────────────────────── + # We don't know the JS peer ID in advance (it's freshly generated each + # run), so we pass a dummy peer ID and rely on signature verification. + # For a production scenario you'd pass the actual expected peer ID here. + pattern = PatternXXhfs( + local_peer=local_peer, + libp2p_privkey=libp2p_kp.private_key, + noise_static_key=noise_static, + kem=XWingKem(), + ) + + logger.info("Starting XXhfs handshake (Python = initiator)...") + + # Pass None for remote_peer — the JS listener is freshly keyed each run + # so its peer ID isn't known in advance. The signature is still fully + # verified; we just don't constrain which peer ID is acceptable. + try: + secure_conn = await pattern.handshake_outbound(conn, None) + except Exception as exc: + logger.error("Handshake failed: %s", exc) + raise + + logger.info( + "Handshake complete! Remote peer: %s", + secure_conn.get_remote_peer(), + ) + + # ── Exchange messages ──────────────────────────────────────────────────── + # Read greeting from JS — SecureSession.read() decrypts one Noise message + js_greeting_raw = await secure_conn.read() + js_greeting = js_greeting_raw.decode().strip() + logger.info('Received from JS: "%s"', js_greeting) + + if js_greeting != "hello from JS": + logger.error("Unexpected greeting from JS: %r", js_greeting) + sys.exit(1) + + # Send reply to JS — SecureSession.write() encrypts one Noise message + await secure_conn.write(b"hello from Python\n") + logger.info('Sent to JS: "hello from Python"') + + print() + print("=" * 60) + print("INTEROP SUCCESS") + print("Python <-> JavaScript NoiseHFS handshake complete.") + print("Protocol: Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256") + print(f"Local peer: {local_peer}") + print(f"Remote peer: {secure_conn.get_remote_peer()}") + print("=" * 60) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/tests/fixtures/pqc-test-vectors.json b/tests/fixtures/pqc-test-vectors.json new file mode 100644 index 000000000..51601bde0 --- /dev/null +++ b/tests/fixtures/pqc-test-vectors.json @@ -0,0 +1,135 @@ +{ + "protocol": "Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256", + "description": "Deterministic test vectors for Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256. All keypairs seeded for reproducibility. Do NOT use seeded keys in production.", + "generated_by": "@chainsafe/libp2p-noise (js-libp2p-noise)", + "kem": "X-Wing (ML-KEM-768 + X25519) 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": "edba537079cc09e51d948a3890f7161e9a8d5af3afcba53e994b353b861d7cab0dfa5a4c33ca3531907b17998780e10e797a28a42b433e0671e21b59ecd0c6ac1b6f1c1a8817b2c34eb454a3615101d4624dd454a4dc2ab54bc40be90bcef55684bacde462271c52ce9a4c05d5d01584a36ea8545799fb12c00a1fb311821908c7aa42846cf31519695f0d8a7e11504512c33508e94da973092604a16747bf94d2c3508a684d7c07b71c4c9e0ca5c3c03182967cc1551afe37350e922922aa5cf44148017714c7095c97711bc989997a0427b8324d6a7915057481013a23d978b0bc6a903dd1bc38732e4ab5c64b8ba9103305259a89f1143f32641bc54080fb04c7d04a6fbcd12bbc90b8232c15893b1314b35d0910552afaab03d9736c66c8e3f5417b98a6ea0acc975a2d40d1a09b53792389244dd3c9f2dc4a02e31fb66166f9ba60cd9387b2258dccaa763fe96ccbd87bce3811bae108f0aaa2c0e8ce84e55677b66bac25047bf1864b91ca75b74e454190a5763cf5dc3ca1e0bbad527d4f8a8f5223178c8a47d1948d9f008295648518497fd3463dc390897c843b22c4c3ae528e50cc1d32e487387045788b0d54f8a7fc9476049b0cbd401197b67de816087b7b656a862d8638ce9ffb9ad58c980b08cc3e47086066164f1779cee361b558498f8557f46417612693f0042e6e7a49083ab34cb7b99366c61cf8cc31256f10c476bca7371d8b0ff0f3bf599c48ad24cfb22b6950dc1958e369ac3130f58277e6c8b3d94a9f0394290d5445619bc1c9870e48870cb79391f0fa1c77fba3b2e9b0b5780619dc9b3e4b7ca2fb88959b7e35d21ec433978f2602390631b2f331820c5211d2bbc9348125409441444721002edadb71731b5688e059a7d91852a0485dd885cac52c41b17f0e9836db67607dda79fd5043736c929fea072bc0a569331ebe389e1a4022e029bb54e264eae323e288a1cb3ba5f85b50f47ac2780b5b3c5285b2888ddcec39e85639f8184ba794a7608658d255be9534bc9b6594b1720c05b72b801021052bc83733c4d891b456793d0b85883abc56dfc43181f007b835847aba0a71710091b14a4469461728024e8cae87e657e514335766bc6587a3b6b31f4742b4dae2053e7b3af88a0b8a081ff57b8cf0e709d2a01a7cf7ba1a04525b628209b6cd4542caf9a14974cc6fc3c957dd7870fe51571de818f573cda6e7aca3c78cb7d532a348604ab13744047ec5c04593389ddda2a4accc14161cc03feb072d3a03eec6ca23683df3709eb352492934c37f1cc6b71810bbd4002626271128843c976723620f3d634fd1666723d24300b3b20ad7c3ac8b4df87012f9281d0a4b026cb91545c95baeb69a612b336dc83690eb8d75fa3371aa8781273fa3d24e8d66a5eff8adcc881387a9b35c4452e978c75a0aa67b37a5d8688c18129181cbb171201b04d7b649291e477baa24a37bea219491568e36612f237106c160bd97b149533a33d0ea322a3cb9131c391b93bbcd1032060474feb963f652b84b39b91482a2f5e0a201b9c48bb59dd2bccc612b989059a2005d027580c6c8227e33591559278131493a9237c7759aa4c7841e10d7bfb5bc59ce979b1678874dbac5c6e619697ad0c37b30508f0fba355a5563e71e5b9c7c9d2eaffe2b8e5a0c98e12d9b8b1d55839b04276105ba1d91eba20f4bccb9df0cb2d10461d2b363", + "ephemeral_kem_i_secret": "1515151515151515151515151515151515151515151515151515151515151515", + "encap_seed_hex": "16161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616", + "prologue": "", + "msg_a": "197fc2c567dc03ee2aadf0ed86681dac24daa76e83ca555875dd3be7376e5306edba537079cc09e51d948a3890f7161e9a8d5af3afcba53e994b353b861d7cab0dfa5a4c33ca3531907b17998780e10e797a28a42b433e0671e21b59ecd0c6ac1b6f1c1a8817b2c34eb454a3615101d4624dd454a4dc2ab54bc40be90bcef55684bacde462271c52ce9a4c05d5d01584a36ea8545799fb12c00a1fb311821908c7aa42846cf31519695f0d8a7e11504512c33508e94da973092604a16747bf94d2c3508a684d7c07b71c4c9e0ca5c3c03182967cc1551afe37350e922922aa5cf44148017714c7095c97711bc989997a0427b8324d6a7915057481013a23d978b0bc6a903dd1bc38732e4ab5c64b8ba9103305259a89f1143f32641bc54080fb04c7d04a6fbcd12bbc90b8232c15893b1314b35d0910552afaab03d9736c66c8e3f5417b98a6ea0acc975a2d40d1a09b53792389244dd3c9f2dc4a02e31fb66166f9ba60cd9387b2258dccaa763fe96ccbd87bce3811bae108f0aaa2c0e8ce84e55677b66bac25047bf1864b91ca75b74e454190a5763cf5dc3ca1e0bbad527d4f8a8f5223178c8a47d1948d9f008295648518497fd3463dc390897c843b22c4c3ae528e50cc1d32e487387045788b0d54f8a7fc9476049b0cbd401197b67de816087b7b656a862d8638ce9ffb9ad58c980b08cc3e47086066164f1779cee361b558498f8557f46417612693f0042e6e7a49083ab34cb7b99366c61cf8cc31256f10c476bca7371d8b0ff0f3bf599c48ad24cfb22b6950dc1958e369ac3130f58277e6c8b3d94a9f0394290d5445619bc1c9870e48870cb79391f0fa1c77fba3b2e9b0b5780619dc9b3e4b7ca2fb88959b7e35d21ec433978f2602390631b2f331820c5211d2bbc9348125409441444721002edadb71731b5688e059a7d91852a0485dd885cac52c41b17f0e9836db67607dda79fd5043736c929fea072bc0a569331ebe389e1a4022e029bb54e264eae323e288a1cb3ba5f85b50f47ac2780b5b3c5285b2888ddcec39e85639f8184ba794a7608658d255be9534bc9b6594b1720c05b72b801021052bc83733c4d891b456793d0b85883abc56dfc43181f007b835847aba0a71710091b14a4469461728024e8cae87e657e514335766bc6587a3b6b31f4742b4dae2053e7b3af88a0b8a081ff57b8cf0e709d2a01a7cf7ba1a04525b628209b6cd4542caf9a14974cc6fc3c957dd7870fe51571de818f573cda6e7aca3c78cb7d532a348604ab13744047ec5c04593389ddda2a4accc14161cc03feb072d3a03eec6ca23683df3709eb352492934c37f1cc6b71810bbd4002626271128843c976723620f3d634fd1666723d24300b3b20ad7c3ac8b4df87012f9281d0a4b026cb91545c95baeb69a612b336dc83690eb8d75fa3371aa8781273fa3d24e8d66a5eff8adcc881387a9b35c4452e978c75a0aa67b37a5d8688c18129181cbb171201b04d7b649291e477baa24a37bea219491568e36612f237106c160bd97b149533a33d0ea322a3cb9131c391b93bbcd1032060474feb963f652b84b39b91482a2f5e0a201b9c48bb59dd2bccc612b989059a2005d027580c6c8227e33591559278131493a9237c7759aa4c7841e10d7bfb5bc59ce979b1678874dbac5c6e619697ad0c37b30508f0fba355a5563e71e5b9c7c9d2eaffe2b8e5a0c98e12d9b8b1d55839b04276105ba1d91eba20f4bccb9df0cb2d10461d2b363", + "msg_b": "18a6f8c1a7fddf22bd410138f79f7298cd38d1d0a542d4266d556be8609d88625a170a9a79e49aa2ca86048377e59849bc7503baa93bfb7c480b569380658b684e12f86fa294ed8002683255fac29c4ce7f3bcc5d757748067bde614568df3f5dbb523451903181ee03d4553ecc1d1c86bf72a11c33361b3c3cf18dae457d36ebfd7657e0dbc80e46f213e0fc84ad3d31f6efd77b3da56cdd31a42213e11fe6b732b3cd7f659d1e82358425300bf37e08ac5d6c85327344e9876af0440a93c2d124c0fd665c2bb6934fdcc38b1e841383c9884aa244273c6c4c04690c1158cc356650e96127b7ac0190e87f23bb82d6f00e56d8ee6c3549c5a42d38304786cd032875e2be15671ac2e6f3843561459c9d368eb452e0649b544d738989d50bd3cb7fa2c174e7d416a084cdfa3267e3bdfbcc2f92f85cc9168a80b76716c9eced40a94d3dc16fd312685c2b9be5c3435491a39a4438d2ed41c250b99fa1b66c69e2a3cfc7bf8f2fdfd23779a31cbb1a6293d641531f6607efbc29743d8f1a6d25f287160d1af37b150a9d550dc6f5fda2fc7db34bb3113c896c9f80a006e6ceffa9985820a1c48b72c946cba6281fe93b5608a0c8f164ca23426f69c63d276e6c9015ad703a75c659e2bf597737963cef708991b32b3473f76af279a853e1de9d9a0a0240134d6198a161b1d7025844b4f6bb8bf55dc146f5befd495664e5b69cd163b4d9effc803047035da89a147fb8c0f85c7c89bf25184993950aa89f08c263e90927a2c58752b61b12055c0a82fef1b72b648b6c67b06366ab27354ecb2c6a115020354052f5a19984424550d1269c011ceec73bac7721f938b1c0c8f77d389bffa59374141dc51e3cbf21fafdc99a434344e0a416a02e31991eb2e6a2308abe8a1e0dd5db9ff9595f2b45b2c1e3e5c80d2c5e4285a507b5829bbfd677da65726d19888630b948e67964eb0b7ea6a226d38b1b5918d2e6febfe38569304a359961fb0c78fd9dc1ef0b00cf542589cd7d62d9cf3ff99e19afe4aba749f369749de7ade9acf7dc05b8ccc1b44fcd341f581ef5cdc768693c5f70307e7246fb971298afb9135803748b0549aded570b6b77d940e0ebca2849b4b033702a89899b7ee9042d3cc86b1824445a36995f511c752cc7c3c2c56f53bb0d17ba101ac4ee53567290beb50089424d2faea0f9a9ec546979d40d9a69fe2063c6b6cd38d219ca61aa11f8a864fc0740540069ba56ebd07a620bb69e8f9d5638233a6e5860f11951dba11cccb3c1c55d6dfc10418fdbee226bb5dd4df7db3fee7f8d4955d05cfbc695353a1481dbf244f1400803759dfd2cd474e43825761dac41aaa8b28be49bc320526d3dba7dcdca739906a43f162dea4faa10cfc9bf72b3eaf1fd9d56db37a078f6c01b6747ede50558bf4636d73cb274203aa205aec2288af8350761a73462b5e6b8de8ac876d900446d69ed5bee4f69429843797b44b88d3b92361c2227a6aa7347e4b58bba632e05c49c02f11e33fc210b5d206f942fb5f99a6ef5f0d89acfd2f91b20d23e737618e7d49f7ed031be0ec40adb38e94b1e6de8dc53398665f52b23cd26d1e8b7d5316f7c991c2163f247eb6d39c5a9facd7601dd78242821f2eb58c79a41bec1b5c162c7f4721747bbd9bc6dd363c1d5795bf6f25719b73e1a9fa490ff5a9e400a8e3f7cc562e228ca1aff02d83b1d960c59364ea32234c6cf8a8c17b1433ab9e64e313a4bd", + "msg_c": "7810f3d562fb35a2f65c516be2836e7903cdd3930745c63ab80f6ed9dd3d4142907f033ebd8eade44137a2544681fd9c805b41e6cfcafe616f4bdd5f99ffbd36", + "msg_a_bytes": 1248, + "msg_b_bytes": 1232, + "msg_c_bytes": 64, + "handshake_hash": "fb4a5488ad66a46c7e6b71926e327467fc74bc17317127c6c1d2309199e1eddd", + "cs1_k": "b1a322cfc138811fa2ff44a03adac79472e74e7899f256df27dac3e62c95428d", + "cs2_k": "37a6893025d103651eba521f49e351fb032e1a9e29cf9b8e8135f2e0fcf71c73" + }, + { + "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": "a655a1d56b623e394908612583d2b7dba73d4b785156a0a836bca35432c767131b7408bd0ec186bdf9051a681692e407fa9647d5434c2f25ab0d6b35aeea80196c67c1fa084216326bbc5319d1c60da654d9f8996b683405040591b8750ab0940f4a30718532c031af39f1aa36d04e815aad85b77940ea864900a2df4505eee7886fdc9087222a5038726b78767121ca8477bb42671c63f47511c9005403bb1b48b344549af2533abe0c3ab453293ca80fc9162c007ac81899c9fdc6610e3935a2ea5a98588f3b924f2bb034654b7d25b455946751f3ea72dbdc9923cc563f178d61f97524427b2769b4459c991fb7c868e198a4852e5a086d0a6841e3b332b7b497a2756cd676c580ec703ed9a7137b84ef3478a70191be2397794240053c9f78894f9758800cf1c415e51ec9ab2c5b07c46ca5c8e50026a664198e894e8cc09045b964e3571db2e45e8fe07e208151977a74c16ac1d9985cd66ca0fe3c323e894970b8636d3b0ed7629e245b33a5bc061ee77cbaeb504c5aa033e3c5ba7a757b59c8a3d8c34ed5091ac89d44191469910fc3ca911f123088d7980ebbcd42132ad7b785939945a767781619964008053f66373d305cbc5c99eaa19c40ab33edf40bc89293a7639c7eb1b13a078f3f45c8cce8cce7f3427df65567d045aac71668132c0a598d08646984eb0ac5510d18c6a263d33ba9ea5e6c30b67b61aac5704a9a23933a11750aa9c3cfbac1f1b06d9563898e204e67eb0cd8389146a2864a632bdf7380b909ab6d8246ba3aa512b9356dd616619406f39197393a374761c91ad4981b265acc62896d783ed66759b223b615a3c9a667bd13da39ebfb0841268a82187117d291946ba6a8a0879de8a1da8110442a26c77a228816294954528d878457331043a6c514a86d430c30d4fb8929c47c2646aa0e1b024fd6b0b8fa06c9ba380111c5a61843e37582a8d02d011364273318c83a97beab1720f0431024b41703c5f21153c478ac8a47107c2711aa467464d69560f788c39c0ad937a9f4c304a73a9fc7c51b84b0ad608526feb7685c2936fd917d8878bc654ab1d428291507a4dd168be9960ac5527c6f2ab8ef839afcc42b7667be8902844cb31b46e83a62dca23788089148ca1da1450627625ba7a1f7c875e0cac092f4b9f5747606eca481a607cd810b3cb2200616334991578a06c671c711444053cbda97fa97ae139aa34d621f67067c9251a715842234216d9b7775c917b539974c575b28c1e306e1f32625d313272c3082b9bbf482c2617b1d7e0002596c52542894fbdc47063458057072cb13a91876304de4c85ddb3a8e4b6fa08475b8eac5308b3cbe5204afa18e09b87e15248c86888948e445c9cb6f9eb9696c46bf1208908ffc173a3034a3a36277d1c48faaca0eb5c5d98507fba1783530739bd591c7faae2a325aae84415a8c9d01fcadead153cfb5b3b4b1cc3a70292896773b24b9f67c5e17a056f7580a65c41dd3ea3cd607313f6513a5eacfbd65868d17b4582b5342c17caca22ddc6accd097563f3577d15c7d01e02a78c22ddad84bdac2966d439817a69fcab11d08662499a247ff761f18a92a477a00bd9ab1de8991b3584929f065b3caa78aa765332dc6fa0a167908dba69172ddcf6daec08a40cc083ae90d71a159912b3f0af4dc977216c9ebff82222b7e20125daa6882ccb60d932889010465786155", + "ephemeral_kem_i_secret": "2525252525252525252525252525252525252525252525252525252525252525", + "encap_seed_hex": "26262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626", + "prologue": "", + "msg_a": "9a4503a98ab10fe8d354c9c42cbd0c9d7944f52e7d14d8ea59775e7dc9e3bf4ba655a1d56b623e394908612583d2b7dba73d4b785156a0a836bca35432c767131b7408bd0ec186bdf9051a681692e407fa9647d5434c2f25ab0d6b35aeea80196c67c1fa084216326bbc5319d1c60da654d9f8996b683405040591b8750ab0940f4a30718532c031af39f1aa36d04e815aad85b77940ea864900a2df4505eee7886fdc9087222a5038726b78767121ca8477bb42671c63f47511c9005403bb1b48b344549af2533abe0c3ab453293ca80fc9162c007ac81899c9fdc6610e3935a2ea5a98588f3b924f2bb034654b7d25b455946751f3ea72dbdc9923cc563f178d61f97524427b2769b4459c991fb7c868e198a4852e5a086d0a6841e3b332b7b497a2756cd676c580ec703ed9a7137b84ef3478a70191be2397794240053c9f78894f9758800cf1c415e51ec9ab2c5b07c46ca5c8e50026a664198e894e8cc09045b964e3571db2e45e8fe07e208151977a74c16ac1d9985cd66ca0fe3c323e894970b8636d3b0ed7629e245b33a5bc061ee77cbaeb504c5aa033e3c5ba7a757b59c8a3d8c34ed5091ac89d44191469910fc3ca911f123088d7980ebbcd42132ad7b785939945a767781619964008053f66373d305cbc5c99eaa19c40ab33edf40bc89293a7639c7eb1b13a078f3f45c8cce8cce7f3427df65567d045aac71668132c0a598d08646984eb0ac5510d18c6a263d33ba9ea5e6c30b67b61aac5704a9a23933a11750aa9c3cfbac1f1b06d9563898e204e67eb0cd8389146a2864a632bdf7380b909ab6d8246ba3aa512b9356dd616619406f39197393a374761c91ad4981b265acc62896d783ed66759b223b615a3c9a667bd13da39ebfb0841268a82187117d291946ba6a8a0879de8a1da8110442a26c77a228816294954528d878457331043a6c514a86d430c30d4fb8929c47c2646aa0e1b024fd6b0b8fa06c9ba380111c5a61843e37582a8d02d011364273318c83a97beab1720f0431024b41703c5f21153c478ac8a47107c2711aa467464d69560f788c39c0ad937a9f4c304a73a9fc7c51b84b0ad608526feb7685c2936fd917d8878bc654ab1d428291507a4dd168be9960ac5527c6f2ab8ef839afcc42b7667be8902844cb31b46e83a62dca23788089148ca1da1450627625ba7a1f7c875e0cac092f4b9f5747606eca481a607cd810b3cb2200616334991578a06c671c711444053cbda97fa97ae139aa34d621f67067c9251a715842234216d9b7775c917b539974c575b28c1e306e1f32625d313272c3082b9bbf482c2617b1d7e0002596c52542894fbdc47063458057072cb13a91876304de4c85ddb3a8e4b6fa08475b8eac5308b3cbe5204afa18e09b87e15248c86888948e445c9cb6f9eb9696c46bf1208908ffc173a3034a3a36277d1c48faaca0eb5c5d98507fba1783530739bd591c7faae2a325aae84415a8c9d01fcadead153cfb5b3b4b1cc3a70292896773b24b9f67c5e17a056f7580a65c41dd3ea3cd607313f6513a5eacfbd65868d17b4582b5342c17caca22ddc6accd097563f3577d15c7d01e02a78c22ddad84bdac2966d439817a69fcab11d08662499a247ff761f18a92a477a00bd9ab1de8991b3584929f065b3caa78aa765332dc6fa0a167908dba69172ddcf6daec08a40cc083ae90d71a159912b3f0af4dc977216c9ebff82222b7e20125daa6882ccb60d932889010465786155", + "msg_b": "04bcd2e0d00f2cce5fe8f1c6c2fbec5c07fa56e3aa5c88a5689975d88b3fce05244e3f741e62fde3c98d881d3d2742db263de9c7fcc6636a0eae89a0143ac032644fd667bd7a1cd6a771662b649a975de125f1b0faeae743ce3768f5c3981bdd99d48651761ec8483ac0e41e1b9f194e1efc705ba4b21c90b1fd9f872533d95f7385a5a65011149be98174eaf85159e5e7d24a8090f71a3f850e640f9af5ff2da6fad329626f4e6042b19893f4fa19c254d4c0525934da872a42836db5d893a19d8b8708cb16c0d6ed7f56cbeadbd2a541e59f3f48d06992702562c7f3105f362625adfd8be1d86680f3ae38b990784511a8e35e3c2f0ec4f558ac7f9d4a786bc90f54d8facfd8a9985dd340fc6900f85abbddc16ca889168306d9fbf379f9ad7b783d2720bb475236ffdb955dc4c86aab24235e5c985d13dfc5a9945808cbac2aa772d9b5b49acd7d2a08cc1cd86aa4eb5371b98b73f6c9703050ddf922937e6442f208217f1cb4eb728619acb6074b5ab70c707c4737d286c5626d4b150bf072a4535c70ec4d45aa38d02f8021295f1c0088341341bad73b733b0c309341d669d859039d19528c4ea466953d64bc99a4b3485b8c77f10d696624716706e26a9c132fe1343e2aa70f2c59065614e5295214aa099d0992ca36fecff05b9a52d41be2c40db31fab1214c60471c12f8ea30cbef8edb948950c173772d56d92497e863cac77b3727b17ba3014099c42a39847b6d9829c586798c599c06ae3b878651a418dae8f29f98af5208e642955d06175396d853ecc096c370ddb4d251a2023ae2c7edd4d0fcb176ebcceb0f9733c0e0caaa754ccdbeb52907a6eb414d95c07ea6b433c9df8bb5b833ed9d8ac57e3d8f364502d8a0225a8ad212451c408919dba0964c466638190be61882c4929f40118f7da22b44e4e75557b9c3e6e5d5b2ec49bf3580ab98d566cf2b26a18d8eb4d4465a63da83492ddf4c1bada48e1a1b1a62a5ec841e2513b3c36a110a607a5519116888bb831b50f7daf7effabddb5374f977e906229fafbc5ca38dd864fd709c663fb35627ce0905008ffc470b5a8c8df56bb5a6532baf0a216b2d0bc8c5eec416e0eaee1bfbb8ca12523c3737f4acd47acb99818a52f030a6092e2e786e388996781a8fb20dc15d9aeeb2e56bcef0d8d2dadd526249c31df3c4ee34813b2b52155ce51c4efc425ea459ae3fe0ef8c6d2d30d1414059a0d80c4e324ae10f490acf99f6148080533b7c6c41b4eb071c6dcd2027f1b16349902f255571ef13d3d9c75f7ee8fbaab0eec09babed770f0afbc9fe523bc64146eaa82cb6ac2545894990dcde8ce4293151c43ba24173c5197868cf40d16b20b6e64b8c001c7d6da08bb7a06a2c7f215003be2c4eccdb65a8fc9638867be26ad3f9ee1428a7be17b963773a6d5d56e7bdf7154dbba7fa2cb41da3afaeac8ea64b99f31e712880afed4741911394bcd283a627a24332770b959136868b1033c357bdf3d7b386f4e15639abc608cd37c2dd56569b82def43ed23ada7a4842c7fe350515b4cbf92a099c56af8f05cb4edac8dd236fe06f662d30ed4b50a19e2bbef64ca9d98f551003d5aad846fec6d801101e74250cfadd30c08af3d98b13e6bb4d1e92ec12ebea321d6b5e31a0aed9e3eaf46d3fc96b5c67178bf462aa44df128781318a42d055b68f09283f4a1ab4014c1c714374cab7582f077cd175cec3ba6354a00cd5a3f9c5734", + "msg_c": "3f903c612db898f47b098bb3514ef81e002291f4984c710985aadc5a102ae548a8f9f8a5e5b570af79a71b64bbea3207f7eb51d4b7eaa6a3ceb6113051d5877d", + "msg_a_bytes": 1248, + "msg_b_bytes": 1232, + "msg_c_bytes": 64, + "handshake_hash": "9a6e3d34bb4fea80f1b300f5c1800db0a5b8a100ee2ea4926df4493809e4267b", + "cs1_k": "7e2148673e63fc5edd8b777d1e4b7ff04a62077142547b08ff3131f1043bbbe6", + "cs2_k": "b12582855a5823e75536f88ac4a9976b6c48e8cd84fa676a00be775fed583d92" + }, + { + "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": "e97c239d3761fad320a9acb3a0c215ed630977133fb1d29172c60633c5a1c700c089c297d05c2a614ba42d512347fc6e23c1119416c24511af19f5890f6a6cbc8bb4b07034a3486364c13a671807b3251f26a53d4c42b6d2a46716584a0c9bc1ab01013cb50a22e94132c404d1855b5f3599afeaaa2718c9b04c879a1559d9fc96a16a681fb36ed43ab4719b7cbd7762f4957ee7a71824730f866191335b982296736d50716c071ba1d689cb7572221a6223992f632c597e3536b6f00dc17cb72f24ace0676f1fcab27c5c9971c797bfe62986a9b051b637da8c237c6596aec1142e588f659579ed7893b2ba6bcb37852511ce5e00a11281212a8562bde06d1efc0e18a34ebdf7adc4547b8e115e5b42ad02d267efb365c55434be0650205c21f0627b7efa109a988f1347994f484ad98a4e102b804f19158d6135e1b07de773ce03a1c84abc9bd859be7bd7ada3a0a50481b71399b6d0310afc688303e48df0920fe93775737ab34c19857516c36dea9372cc05184c4130e9503625cb82b07f7e9a9533ebc4d579b8d6cb514c3832f06492fc5b9864325c676952d39746e60770398a91462a6e60d221180778cd92cc03919ada59594860a670985b6c705bce147e8a7c2ac7173989bcb40ea63af850ab3681c3b97198befc5d9d20cac126a636901363ec3f8a53338a72c0e4a5495c03ceb9c2b8d9016782ea475f4c7693006d91a9544cbb6fa9c12e40814139c6accf242dc260a28e2c003fe63d06a242209b72dc770b42a03e89ca8f7c11634cabbed3d50c264c02629829890bb719710e2672196676578d5738585ab46acc97bf7000ad7244a6304d2a601d736a8e12d66718b199da16092a934fae4c9b04451bcd4244daf03fcb94ba4f2a443a4894bb49976ec343a5b5853197ca9cc35c37e12e1e7a389c903ac708469f837d9b402bf4fb3a567921bdd68d25f7c52df53f2cfc363250622e638aa010a1538b922fb13b36c09a8e7b226259c3ae6b6f74a09f8b90ad377401f7873b083ab0ea291d3f8a0b4a9aa99b84cae20ab49a4b6cc8d06a9f1295b25227153747bb712c4d85778cb74579c64210ccaaef70c4dcfbbdaaec89c50c9dd288501b98626aa28abcd9863cd5241abb76322807694ccca03425ace7aaa0857109101046ba4a381ac74ca68e5af7c210bb391b4829c76c988ed9bb404a20dfda0aee6c632ea5647746ab361b577b5cc6a02379230a04b0b21737f9a0083280b4017c917c0ed8260734484af49c5f762a00eba51a30930beb67bfe97683b2d20fcd64cbda03c33596b490dbbcaaa93a6a5a839f8c34753131a0e438151c488f40ce9b07bf15498f466545158255b18722e65688c265156124bcab29cba7011d8da77599668b66e962469782ed9cb16b9b6e430b64bc734e3a41b409129bd77b7c22927c3ba9b091f78ce7539fecf6a523b2ce63b6a73d6b738b8731158462bd52c766731270f58fa48029dda928a663a134431037cacdeabc0afa3a2caa92a7db143f45cc56c70443d09c93456a02215895a5373b847712b1653056786bec043f86e7a8cc10b71ab705dc91bdb8d74148bb0039552c54796f780896ef5a608ed0a47f68ba20db7c762753f3fc94211412ae0ccf59a9cdeae502fee1c9a6f7002c3bed459d2887e73a83aaf7e6ed5a63dc67b1a20f1e4f0fed9324c2b898e92ff8d1adea4b10336fde4e00", + "ephemeral_kem_i_secret": "3535353535353535353535353535353535353535353535353535353535353535", + "encap_seed_hex": "36363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636", + "prologue": "", + "msg_a": "7b0d47d93427f8311160781c7c733fd89f88970aef490d8aa0ee19a4cb8a1b14e97c239d3761fad320a9acb3a0c215ed630977133fb1d29172c60633c5a1c700c089c297d05c2a614ba42d512347fc6e23c1119416c24511af19f5890f6a6cbc8bb4b07034a3486364c13a671807b3251f26a53d4c42b6d2a46716584a0c9bc1ab01013cb50a22e94132c404d1855b5f3599afeaaa2718c9b04c879a1559d9fc96a16a681fb36ed43ab4719b7cbd7762f4957ee7a71824730f866191335b982296736d50716c071ba1d689cb7572221a6223992f632c597e3536b6f00dc17cb72f24ace0676f1fcab27c5c9971c797bfe62986a9b051b637da8c237c6596aec1142e588f659579ed7893b2ba6bcb37852511ce5e00a11281212a8562bde06d1efc0e18a34ebdf7adc4547b8e115e5b42ad02d267efb365c55434be0650205c21f0627b7efa109a988f1347994f484ad98a4e102b804f19158d6135e1b07de773ce03a1c84abc9bd859be7bd7ada3a0a50481b71399b6d0310afc688303e48df0920fe93775737ab34c19857516c36dea9372cc05184c4130e9503625cb82b07f7e9a9533ebc4d579b8d6cb514c3832f06492fc5b9864325c676952d39746e60770398a91462a6e60d221180778cd92cc03919ada59594860a670985b6c705bce147e8a7c2ac7173989bcb40ea63af850ab3681c3b97198befc5d9d20cac126a636901363ec3f8a53338a72c0e4a5495c03ceb9c2b8d9016782ea475f4c7693006d91a9544cbb6fa9c12e40814139c6accf242dc260a28e2c003fe63d06a242209b72dc770b42a03e89ca8f7c11634cabbed3d50c264c02629829890bb719710e2672196676578d5738585ab46acc97bf7000ad7244a6304d2a601d736a8e12d66718b199da16092a934fae4c9b04451bcd4244daf03fcb94ba4f2a443a4894bb49976ec343a5b5853197ca9cc35c37e12e1e7a389c903ac708469f837d9b402bf4fb3a567921bdd68d25f7c52df53f2cfc363250622e638aa010a1538b922fb13b36c09a8e7b226259c3ae6b6f74a09f8b90ad377401f7873b083ab0ea291d3f8a0b4a9aa99b84cae20ab49a4b6cc8d06a9f1295b25227153747bb712c4d85778cb74579c64210ccaaef70c4dcfbbdaaec89c50c9dd288501b98626aa28abcd9863cd5241abb76322807694ccca03425ace7aaa0857109101046ba4a381ac74ca68e5af7c210bb391b4829c76c988ed9bb404a20dfda0aee6c632ea5647746ab361b577b5cc6a02379230a04b0b21737f9a0083280b4017c917c0ed8260734484af49c5f762a00eba51a30930beb67bfe97683b2d20fcd64cbda03c33596b490dbbcaaa93a6a5a839f8c34753131a0e438151c488f40ce9b07bf15498f466545158255b18722e65688c265156124bcab29cba7011d8da77599668b66e962469782ed9cb16b9b6e430b64bc734e3a41b409129bd77b7c22927c3ba9b091f78ce7539fecf6a523b2ce63b6a73d6b738b8731158462bd52c766731270f58fa48029dda928a663a134431037cacdeabc0afa3a2caa92a7db143f45cc56c70443d09c93456a02215895a5373b847712b1653056786bec043f86e7a8cc10b71ab705dc91bdb8d74148bb0039552c54796f780896ef5a608ed0a47f68ba20db7c762753f3fc94211412ae0ccf59a9cdeae502fee1c9a6f7002c3bed459d2887e73a83aaf7e6ed5a63dc67b1a20f1e4f0fed9324c2b898e92ff8d1adea4b10336fde4e00", + "msg_b": "ffc951aa6f2fa03096d1d1b579735b2f6f84019fe2f617aa65ff3d68705f25278faac0740da445533dca70aa8507e8815e00b0c94e0557b1b8bb79b272c7803a2f34fa12dc2fa09763ccbc634970279860125e1ebb97fb45a6777462e9b14376cb9c63b3189d73f910e61450af7cd52d68b5f76663bf362c0f3a18a9798b3022957b5e65a8edb2f5fe8947aea7fe85cb921efff74c766940d3b82055830b9b719159cfb7890ae509078fd5a9d70a89a73c8a9f9b73ea09da5456b9029acf8910185359adc05f8ec1503b55251d7134b6b7445ab172b3ec1e4693b5f5a1163d87013d5649914458ffa37118438e0e4dad9e32176f302f11b71122349be7404511d71b6f3e4396cc4f8daa9faf64d82d361842b02dcde99028edcaed51287297cd739d64b7a42bba2ff5e7c2dbc6ee9e0fccad0616e1bef8858ba4599ecff71dfd88e463db7203a3b146d47a642225fa4a67b1068ecf4922c29f93a859a18be6f89886376ab878da436567bb3d37c84a0e194317700aed9a5379131cb5f05c74ab39fd00f9841aac90938fc2370982fb7976afa22216ba1831f018e1fd4fa6530f6416dcacd7cb13bd3044480e72f430bd7cd1c57e0b8711e004c03e6ea4628403c0aaa77c3ed6e7c620f8473601bb5039395c67e1b9195e046c66c4ba174f61350b280ee024124c8ff869df06f2d09330370f66d084770a223958f625cb07628fcd28a1f24cda0122aa1471ceecb36885b47d00611cac44c32cb214edf8601a7111931db1a539cb32438b1fb8eaaa631726a0494fb5bff89359990eb5851da0a0cc2c8fda84a8ca813d160b629cb0bde63a503202f139c9e62dc4333f1620cb729f3c03134ba4efea60a59afb5022ba536c4239e7203a51df38daeeb99818dfd03832d066fd0b4492d34e3fd2d7390b60dcf005f6e16f40bba928a20f2fe5cc004dee6cc9f03ce3610ee1eca2bab2d82b19f78bf9b369be2776618d7c99e9030b4f615432b5e3a3b2d1c1bc15d5ead477379d7603f79ebcd7112ca519fe1374a12a373311787f71c64a58b74b80da1b87236a871d81fa1ca6f7842b1ffd924d9f1ee37376c48841f20ac77b5525a190da33e41ff5bbc6c6d6858a420b87f567c124875c664d6475364190c9ba951ff76dc0ed71f651797d1aa31ccd54d72d1a80fac7959f1a39fce53bcd9c742c24f3c271e0fc577a25a9313a112557b8b8e21f6de41ae791e264f15ef1ce9eab5942868e7963803e458497684783afb13d105880797607c6bdb1cacc9f77c0de51efdd98e6252435ba3412b08adbc478f24a2c257443b278b4d7fee1865401cb856ceddabc9d94e3c9cd8d1400fe2a349a1cc5f74101b9e939c3498b0993fa41083ba6f701d9114e653bafe1b58d863861fb4577e927d167b288fd8a21dd0f1f0de09629e446843bf10439babd615587599d1cd5a27478e5c712881f84ed1d23c2cdc4812444802baaa3bea9f72f72386114e8af84f0c4e9234617c9203c52e9f4447b177c255d8980080d5e3a6d8185bf67e2de1ca07604c20a3028be836d54a3f9452c8acc243666a8dd429c6ccc0ca7ba032524adc4bb5340552eca46fee9467bd22a3014ec70591e3656a74e74e3a8d3c89793adbd000abb253bb3eb924978db8dbbd92af9704aa260e6f2750b02cbfe2e60a6e47dff257d31f0846230b807766cd6042998bce2a873e7b0f2bcf073638b7b8710170a3411504bcb811735dd5b84", + "msg_c": "dd95519ae203ad349cd5544620d77f5edd11ba436914884938d667857779dea69912f78f570b59b6a2a85d878117f458c726b7282d2375c58b0eff3f0b5d593f", + "msg_a_bytes": 1248, + "msg_b_bytes": 1232, + "msg_c_bytes": 64, + "handshake_hash": "c50346544f1cdae2acec2ee4550000de86df5986a8ede64b86ff9e126bba3019", + "cs1_k": "efd0106d16ff2e9a50e4a8c6da617258caaf02af3a0310e54579f9a938a84113", + "cs2_k": "5255839c1e34ba32fdb3d8a8300f11a05cd9447c2ded2a2c2a408605b25682bc" + }, + { + "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": "0c2120325b25154c3acedc0820525f3468a444030ff6e42a078a4d42e37068b57ae337a92b10bf5185be21e78e4b07a845686419766816c7076bdb3cf9e5401bc4430589b5fc4c23ee282c0f091a312635b2498770d141acca25f2d3a4f0475810e0a283eb4ea5d8a5139c3fdcab0e98887a1d97a9a5cbbbed7701bbec9a4e855160419c648113e670207d9c67c7997228078952cc0f623bc6869c989146977e55c8fcdca679132c111ba3453416efb58588711172f53559f04a7a4a5e6e24c3cff15b2b3520cb381f89d48e2e51255cc04b0e6876c9344a99564070a21987e428a4ec870be0cab6b447db68644a1a9819c83678c95228f884ac4659432815a5535ba5ea6c84a79ce2c75ce9e0822082cb71e3ba38172ea332b4972bcdf1bca4ad392deee4beea0b0fdff70f6cdcc50d73a4c35c54515c2c62084f45998bace09898ab4bbd83516fe5a671bbab87e0b2898315d6e2502b565407546a6a171d3ac95730cb95430cab0ac2b6210176ed8a434f604f1721a215646e598053f101ced64a5d008a40519c87ede39bed29b799519c2a102f1892744e298fdc532730987bda13afde5242800b74088c55363571fcf856aac061bea112f7cab7f018aeb20345d9b0a269d372d34456da5342f5262124a03f5e69c49f904e91546c630a95f0d61afc18b2dec623cc133ba3e52ea98cad2915c0b22a5aebe8c3142395ff802ebf61cd2b4abb6d29396e9633bf6572224721bf90400d81875f7a1624b392523330573926b3f1c733b7ad703c0cc1bb532dbc7aac866db90529dec122814aa0346c995c92cbdc6756befa5af3282ab2328f0552abb5f78b58c3ccd822a1f76943d0579d3daa20feac9c74eb61126876890808a9e8cf1c5b4c609c9790f00caf6816ef6537d9c4a83359cf44e11e926a2546029c8e419145057410dcbcad52997d1c68c9bc7b721124a3f736357655adf74eda4aa4f3a81ab3d3b65cd33a00bc321e8c6763609294ec245908425cc4a65d9bc119f4b1e73c78b056497491809191985971b29ed696fb0256d98b78a2da82042a3dd67131410a2088e185d4221317a446e4521e427787f2f596223840ca5cbb3ec29dd6582a6a52127d6108d6b73af2d7a2a188719f85a7ebbaa6d1c7c4c64790d7f0168464909e1c844e8702e35c60aa623fbac69b168a59cdac19fe2c9aa2fb273e3159e6a7ce6a19b86229cfe02c9c66811677563b297c0a0730825c1769fdd76e97eb68b1d7a3b8c3c3dc690cd5647e4462b4217a484c2a17b1620c20c20b9358c2e0295ffda94a7806251bb54f6b9ca2160b2f8efc3a9a974c84398a1e9b78c10596d5483c9366cb83aa052cf7c3a4c88db410bb23f47425ca659c59b33e62b7d18935e3a8641cb66b1b743561301956956e297a16a80b5ea888161b499e6b6b4a47374543695da81aafc14984ed1945d7f711b8471dc163bd6b8c0ee408b6ad1a1bf9f57019479f83f48f1b20c2e6161d00c13be80072969aa41141307a9a9383a71142f698a0e2495f106459f6a37624cfa9491f194a4ca9a9560fea6d0917b6166a595954a0ed4ba8344a7977040bbf34a2c53497a82a590c0c625f7b7711a38bc4d62a68717c83a83d8e4c66adfbaa4f9694161ad7561cc78f083a11222b9238134d25ceef898a7a17870df4777d4771873a609549c48d7572bc21795137ed1df41a3421b042413f71", + "ephemeral_kem_i_secret": "4545454545454545454545454545454545454545454545454545454545454545", + "encap_seed_hex": "46464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646", + "prologue": "", + "msg_a": "cdefd8783a91b446640e2e1f95599db35e484a0071bd2182b3b60d0812c10c700c2120325b25154c3acedc0820525f3468a444030ff6e42a078a4d42e37068b57ae337a92b10bf5185be21e78e4b07a845686419766816c7076bdb3cf9e5401bc4430589b5fc4c23ee282c0f091a312635b2498770d141acca25f2d3a4f0475810e0a283eb4ea5d8a5139c3fdcab0e98887a1d97a9a5cbbbed7701bbec9a4e855160419c648113e670207d9c67c7997228078952cc0f623bc6869c989146977e55c8fcdca679132c111ba3453416efb58588711172f53559f04a7a4a5e6e24c3cff15b2b3520cb381f89d48e2e51255cc04b0e6876c9344a99564070a21987e428a4ec870be0cab6b447db68644a1a9819c83678c95228f884ac4659432815a5535ba5ea6c84a79ce2c75ce9e0822082cb71e3ba38172ea332b4972bcdf1bca4ad392deee4beea0b0fdff70f6cdcc50d73a4c35c54515c2c62084f45998bace09898ab4bbd83516fe5a671bbab87e0b2898315d6e2502b565407546a6a171d3ac95730cb95430cab0ac2b6210176ed8a434f604f1721a215646e598053f101ced64a5d008a40519c87ede39bed29b799519c2a102f1892744e298fdc532730987bda13afde5242800b74088c55363571fcf856aac061bea112f7cab7f018aeb20345d9b0a269d372d34456da5342f5262124a03f5e69c49f904e91546c630a95f0d61afc18b2dec623cc133ba3e52ea98cad2915c0b22a5aebe8c3142395ff802ebf61cd2b4abb6d29396e9633bf6572224721bf90400d81875f7a1624b392523330573926b3f1c733b7ad703c0cc1bb532dbc7aac866db90529dec122814aa0346c995c92cbdc6756befa5af3282ab2328f0552abb5f78b58c3ccd822a1f76943d0579d3daa20feac9c74eb61126876890808a9e8cf1c5b4c609c9790f00caf6816ef6537d9c4a83359cf44e11e926a2546029c8e419145057410dcbcad52997d1c68c9bc7b721124a3f736357655adf74eda4aa4f3a81ab3d3b65cd33a00bc321e8c6763609294ec245908425cc4a65d9bc119f4b1e73c78b056497491809191985971b29ed696fb0256d98b78a2da82042a3dd67131410a2088e185d4221317a446e4521e427787f2f596223840ca5cbb3ec29dd6582a6a52127d6108d6b73af2d7a2a188719f85a7ebbaa6d1c7c4c64790d7f0168464909e1c844e8702e35c60aa623fbac69b168a59cdac19fe2c9aa2fb273e3159e6a7ce6a19b86229cfe02c9c66811677563b297c0a0730825c1769fdd76e97eb68b1d7a3b8c3c3dc690cd5647e4462b4217a484c2a17b1620c20c20b9358c2e0295ffda94a7806251bb54f6b9ca2160b2f8efc3a9a974c84398a1e9b78c10596d5483c9366cb83aa052cf7c3a4c88db410bb23f47425ca659c59b33e62b7d18935e3a8641cb66b1b743561301956956e297a16a80b5ea888161b499e6b6b4a47374543695da81aafc14984ed1945d7f711b8471dc163bd6b8c0ee408b6ad1a1bf9f57019479f83f48f1b20c2e6161d00c13be80072969aa41141307a9a9383a71142f698a0e2495f106459f6a37624cfa9491f194a4ca9a9560fea6d0917b6166a595954a0ed4ba8344a7977040bbf34a2c53497a82a590c0c625f7b7711a38bc4d62a68717c83a83d8e4c66adfbaa4f9694161ad7561cc78f083a11222b9238134d25ceef898a7a17870df4777d4771873a609549c48d7572bc21795137ed1df41a3421b042413f71", + "msg_b": "ff2ee45601ec1b67310c7790404585ae697331eee1c1f8cf2419731c1fff3e6b237e79d8898ab5730c7cc82881730acc51a9b72557d4c012c81f08913d99309998388e9fa306e52fa1d5a05f47b42080d7da39c25e5e3478dc10f5f8c0ca9a5238bb871e13e0a6891a7a076aa940fcbf44a5f891a9ed1062ebab38231753943eae357ede26b55fad115e619471b911910f28e65ec9bb6f7c8180b99f8d07c5da687ad4a7d1b21272dac625c91f3f102a98f000b6b8c6a5e40cc058e36376fd115a95c6c6a306cc5724c791c6afa691891f3efea454da9e22a32abebc6e29d31ddc7f57e3215af76c38862866e08a90b1d9a2a035841636e3259815f8706b3fbc8a908568e235be6de664bdb3713521495f215e1a461926aeca6f1b7ec5933e9fb32aef5d376364dfea9dbef12bafbc56a26baf3148095706d05683e58d61b6a4b6dba3e20a75ffbf00cc80fa6818e7a838d7ed3fc4cefe5a7f1151a43f98dfd6e877f61bd35465e86678e75f8e2dac96ceb7b4f68104ac7bae136dfad19f231221d3d53377c485d14c2916c19b9888a5661173483290ca1413cb3c98f6c67991b0575bc31b24e78f071611252f99572f2bf8f9ca11dc82621262944c11e241b8522286cf4338d9dc939012090ecf93d5ef753757579cfecaaf59b4e9f5f97c4f77c2aed38ac0c64e7a1522367bd892db62d565e1975a63e9c371736969c683188f8f212967456e09bb7f5eef2acc6cd0c261f0ccaf19cbdef997429f23311b12f6bb7d7656be03c5fb8a9cfb38c72c600fce0f04412766f0bfa58f2804d44e0221da147d26d43aa119e1700a699e4bdb820ed7a77535fca7f72de83fc42b38c981f84c8424f60055637211a63077f676df03e9f5ed85dde2b2c9229e88215f66f727bc9379d2230035bd5d0e6ba339080a950f6237fe184b609676ab53365a58be30ac332b23755743cb72e1b6498eeb754a16758e68682d37e1aa4e0677f9679192366df120df0bc3feda9ec4c8ca6b063a80814b0b42df5cf977b39e224a28a84ae413639d5d48914dfe78b8656557bd8fc69a40e415c8cf50120fb751c862532dcff8498b382686a0527df248bcb8ac199003be38a8b5f9ab253b1529498fe31b24a8060c02dbb9d741f8b907494b3ad4cfcae740f3dd1fd8a3b95755d25c2576f2977d9e0f0dacd108f5612f8bbc201fd1bf402108363f2f88a46d386e4d943a335185f8326b50e1893c6e287fc920239e1c5814f0e24b055bd1731bdce1fb37a771988fb0da5548e56fc6d1967474855fc587a634caadf10f93bb0e21bf16de69ca4bc25d1c6aac4c25ef68c29a136177eb555f61fb4172c083d92661ed00178a619ab695441760d02c4114a24185bac023ffe36af6dfd736545b83e3640942b5ac277cf9f1539b27286d01e5bd5b50de6d768f7cedc4e22482a075b90eb48900d61e409bfa9d245a0de6101de88d5b934243b2f82f88137f6686985137b28930637309057184eba54789d1b33f2768a319376d5d0b66f3f76bdee1a0bae3f6cf74afd8c6dd2ad016b68dcf38292a398257128fe3275c02736b48e27819b92c713fcfcd95d58e5c8d3b317c3680497644595340a270e4091dbae19f6cde204c03841fe29a8cb28ef67937ee2ddd25d8111888cf52d4a8d07c7207968b13cbd4d728364b13d125b978930024506042e71b4447a4f48c966a1a4aef9e26d1522d1497cef042835427063496e488d2", + "msg_c": "287cf9ce63c3d8575edf219b3bc6d008e82827fd8006501839743f6663e6884cce042f7624a6a402cda794db816878c4a56bf5d37990719cd6eea215dd5e11ec", + "msg_a_bytes": 1248, + "msg_b_bytes": 1232, + "msg_c_bytes": 64, + "handshake_hash": "9ea689cdb412feb7db3e7bc33c6f5d385f3660073fc5920154416b6b78045c66", + "cs1_k": "d089815921e9894fbc708ac389fbd33f60473fb63ac4e28eae4c53313e2bbde0", + "cs2_k": "c58b7ad2707da3f73209bcf9e20fba9f365ce102fc150fbffe78662de04bcfc8" + }, + { + "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": "f77276d5b75e5c59a55fd576e9a94e3c3b2fb67c8237ea613f9910e590232d0773ab72277f4982a6b0ccc5c55150f655fabb5ea07483788814d7a315b4fbc9f62c2d38ac323a99b668b8850b940f2cc23f54c9c13ec9cb75a7bf07bc37f6169ecc7116b0015409152acee445a3d94b164a806e42a3990458a6d8b12662ce89412d885cae9c28c1f5417c5510883444c4ca1b6c14d56d32d64516a28a89361902308d43876fa4a138ca3297f453aff0a50064a708645ba2fdb6236b313f25e7c02f5c28bc7bb5ed7baa09558ed8442d99039c53e3bb5f50064f535e05757ff8f26caf0685ba965061e8326f4507f13801aa664c50f392225b6218dc7a08f783d6da34f0c438f34317c0a6b360c475c9224711927694855fc479b747c8c7179b1e5142ba758b4f680c0dc9057a04c5968a930167933ae6dc4e346071845156b0f8581c5711b818b0d88b110d6189eee623f73406e6b89d707bb3eb74024193b9cc6ba9f3b62eb7ea888b7a3bce297b45ac83889188a3b732a71616436512651b925fd91430c0c5f6e452362b7e0f75a616fc504960488244c546655c25c95beee69bc72ba13fd663ed6804b82c0f14b79557eaac1f4b01ac0816e7d17a515199a845b28ea987604b226f78286b8a461940ccece1a117d33a80c16797908533d53e5a5170fe389804166dc2c39db1913755b1b39889453e126fec785bd6337ef63061fe021fadfc9b76a7531e6b191e79106921a4925208be68bf599b83bf5c74f31500bc467a18b98ca1f05ddbcc1b80bc54f6c0481aa12abe750853220fb587292645537ea06c21ba8f42d143d5796a2af80850562534b3199e142928c08bcbd06e91e03c675ab21fa86a7483cc6c772a29235aba228dfc754029b1896bb06c4cb54eb48aae1027861cd345fe03806d86b9bbebb5dd5a923215332e4674e9e70ebf3b8a603b54ff44bec2ca28c25525dbd7c120b79821e262e504499f94a5f250c601c51f8da66f82810141eb43e9274c92d2662503a993762e03c9908682c87ea4372db146877331a5f4a2c71ab900ea1b44b6a9b18936cd512e4cb62a63284e08084a3ed8a83f8aac2da835ceeb9732a4ccbc9b779090c2e0447b1fe6181217241ff12598823811984c8968c18e2622616c5f70d508d14045351c13bbb152eb2c97b8a6c0baa700403bb34eb021d7db2cd3330945ac27e04c9888e9b999fb23a4a5b1fcd0bbcf5320136822178a239acb443cf2aabc318685fbc6bba1bd2dc52187957e20435aeb8b9c78d1119cc2750c6877e57b3703904aaf6b54820ab7499237be7a7df40b6f2d6005dc6ca5b197aeaada6ff5120517ab68247159231ac0b9132452e61172bc05dcf2381b964ef0370015136a43e65cac9565f744346b254706c0651d6211cf0080a9d10aa022b5ea2bbda518486e656505b60382618fa84202dd0799c803007d39c58b5047f863c85303b86707922e1698010635d9384c8b1391e33a9de2767e95877c96941723202bbd825d7226ce0bd9891adbc0f5d2672eca804bf1cd418c77efcc47fac29c57e9cbb3628adc050d03588aab923d886518a9328b9097334e499d0cc618a2bb6f86549c5309649571857ba1811fd9b0087ecb4b9272d6a29321bdaff4a7789535b44a47a0b8fe76246716b28a6e4607588bb83c357f9b5fdc01b562d61c5d13df86b76fb053a7c8e585c319b7a55609", + "ephemeral_kem_i_secret": "5555555555555555555555555555555555555555555555555555555555555555", + "encap_seed_hex": "56565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656565656", + "prologue": "", + "msg_a": "261cd9cd2e935f9c2455876a80f02a4d6786b8ab877f07227737ca0b577bf161f77276d5b75e5c59a55fd576e9a94e3c3b2fb67c8237ea613f9910e590232d0773ab72277f4982a6b0ccc5c55150f655fabb5ea07483788814d7a315b4fbc9f62c2d38ac323a99b668b8850b940f2cc23f54c9c13ec9cb75a7bf07bc37f6169ecc7116b0015409152acee445a3d94b164a806e42a3990458a6d8b12662ce89412d885cae9c28c1f5417c5510883444c4ca1b6c14d56d32d64516a28a89361902308d43876fa4a138ca3297f453aff0a50064a708645ba2fdb6236b313f25e7c02f5c28bc7bb5ed7baa09558ed8442d99039c53e3bb5f50064f535e05757ff8f26caf0685ba965061e8326f4507f13801aa664c50f392225b6218dc7a08f783d6da34f0c438f34317c0a6b360c475c9224711927694855fc479b747c8c7179b1e5142ba758b4f680c0dc9057a04c5968a930167933ae6dc4e346071845156b0f8581c5711b818b0d88b110d6189eee623f73406e6b89d707bb3eb74024193b9cc6ba9f3b62eb7ea888b7a3bce297b45ac83889188a3b732a71616436512651b925fd91430c0c5f6e452362b7e0f75a616fc504960488244c546655c25c95beee69bc72ba13fd663ed6804b82c0f14b79557eaac1f4b01ac0816e7d17a515199a845b28ea987604b226f78286b8a461940ccece1a117d33a80c16797908533d53e5a5170fe389804166dc2c39db1913755b1b39889453e126fec785bd6337ef63061fe021fadfc9b76a7531e6b191e79106921a4925208be68bf599b83bf5c74f31500bc467a18b98ca1f05ddbcc1b80bc54f6c0481aa12abe750853220fb587292645537ea06c21ba8f42d143d5796a2af80850562534b3199e142928c08bcbd06e91e03c675ab21fa86a7483cc6c772a29235aba228dfc754029b1896bb06c4cb54eb48aae1027861cd345fe03806d86b9bbebb5dd5a923215332e4674e9e70ebf3b8a603b54ff44bec2ca28c25525dbd7c120b79821e262e504499f94a5f250c601c51f8da66f82810141eb43e9274c92d2662503a993762e03c9908682c87ea4372db146877331a5f4a2c71ab900ea1b44b6a9b18936cd512e4cb62a63284e08084a3ed8a83f8aac2da835ceeb9732a4ccbc9b779090c2e0447b1fe6181217241ff12598823811984c8968c18e2622616c5f70d508d14045351c13bbb152eb2c97b8a6c0baa700403bb34eb021d7db2cd3330945ac27e04c9888e9b999fb23a4a5b1fcd0bbcf5320136822178a239acb443cf2aabc318685fbc6bba1bd2dc52187957e20435aeb8b9c78d1119cc2750c6877e57b3703904aaf6b54820ab7499237be7a7df40b6f2d6005dc6ca5b197aeaada6ff5120517ab68247159231ac0b9132452e61172bc05dcf2381b964ef0370015136a43e65cac9565f744346b254706c0651d6211cf0080a9d10aa022b5ea2bbda518486e656505b60382618fa84202dd0799c803007d39c58b5047f863c85303b86707922e1698010635d9384c8b1391e33a9de2767e95877c96941723202bbd825d7226ce0bd9891adbc0f5d2672eca804bf1cd418c77efcc47fac29c57e9cbb3628adc050d03588aab923d886518a9328b9097334e499d0cc618a2bb6f86549c5309649571857ba1811fd9b0087ecb4b9272d6a29321bdaff4a7789535b44a47a0b8fe76246716b28a6e4607588bb83c357f9b5fdc01b562d61c5d13df86b76fb053a7c8e585c319b7a55609", + "msg_b": "94e9c71ccacddd2c6fbf529e263f0d39baf0fed469de0d227d24ad81a4394b7015c68c76fb76f9c3a2f9cb567fbab6d4b2101d079575192945072fc922ad6fdae65f5a83364837c06c7e4b06c8c9589b6700082467367460112bf3b96131fee3608a9c9d1729837694a5ad52aeeef4bfbd76f46a0a52d53f440327112937a41f47a553dfc883f3361a5975e2e6435e51199c70fb539ebdc2b9534975cdf9681539f7c575c24a5e81b85b686546d5004a0f4e97e6ae5ebd4fcc1b8779aefb43ab95917990b7e3755ed6504f31f36b132cf4d78b08ccccdadeb7fa63d29eeaafa1b6cde671c808bf2c08a1f44037017f3bcfb2e323bdc3ce879f1066391bf3b9f7e5063f0bc21fa5a962fe0d2f6d625009c2e741399f54f38bc8a661413f4461b6bb5606eefbf00d3b24aaf1ea34690f130aadeb022661df110fffe41b2ef15f9b879887c56216febbe14712ae2235ffec0d40250c55547cea1aafd85269f94a26f3490717832a9c53c0c304c720c35de2acb96df00c4009875f1ce00f376ece3a328d484dabb9f31efb3e149c41756d51db2448e0f4c657ca4f0a005a91f39b0239f035ff5856092478d88da4abadde34ff056b0e81725f3f137eeed17d900d39f64100ab947e88439751bc1515cfa32618d3fec6470f544561ca31fc0c19cf1c702c1c7ca8e8711860ad5e2613b68f0516ea0ed3ad1d9b4db9fa5ad93e6aadbba044299474a7b32c890d350a51fb303af73070cedbe32a2bac13a629d297f6f0d90a0710775885699ec299b36d276efb8a9fc5ecc5f8722321c7634b9b5167901a0647fe56d36f7b1a8ed980cdb2258765235c607b2767d0ff3d7b52dd552ecb5119d8a2d70d9872b888b49423e140a737bc4892d7aaf1306a1c775885174099c0f935533a436055a2494fd2977102c13a7ae5026b0d1a316e74b8adabff3b5371cbc1324ac4a60ba44e4f52b961adaffb0de3912beadcfea3b2697014067ea5b8402e5de6e7b31c9cd407fb58327c7bf369c0dd99f83eed947ff6245a0138bef60d7952c6fd2084cd54ba5fa9160de1b3c961563b9bad39f6ac65d89f19dbe33eac0ccd696548706abf52e75a5e226cd54e34b42daae5ddc02ac8d716b1e5edae476837c93680d15010164325e8707788eebc122bcfaf3c0c83d3aa9db9e94c5c7d76881ab5057dbdab6590a2f583d61d98c9a6def23688449872a117e2908b341e2086a0bcbbc93b431d4c48255731b462ad738fcf5511bb72a301d9554464078e0b60254948f6b5c1a2cd3ace3a5f7ad69dbed1cb17afdadce3d3cb58f130539513c58c39c0f8386ef40e5f0dd14f4ea22388d7c5f19a2beb173f5579a6291430acbab1c3913e8b2924a429cdc5ef44bf978cdde332859ff02555d5758d9eeb08f0f28a1a0c98df28c17fe9276359764b2cdce861e155bb2a8a0d0e3566293478d1b1ea28fb0cc2cb26b993c31da8d2b21a5e1754f8475d3b59de36667e9a2294845ecf599d37510fa569bd75ed182a0d856d17a9262b6a7316186503be988922c5c91e11941fae9c92e5adcf1014409fa9aa094fb1b76faf721e8dec26c08dcf273193feb45b278cc4eccfff880a095bbc9819d588bf5d188dcafb14c30a1f5723a4e919eecd5f52e342e4d78ed01ca3834d616ecb8982673b4bd842e27d7a6d8d4571101fc64e865ab68a0d8233e1e4e1b11cb6c2814c24f3c075de09c9a96c2e557e1e206db2b2b6c7d503db3b", + "msg_c": "fc48ae440806ec75c989094d1720132ca95ce5943af47ddc3f95611bad082037ee170b4c535212ad3e62d3901251b854e100b5ff017129c154f4fcb808d058ba", + "msg_a_bytes": 1248, + "msg_b_bytes": 1232, + "msg_c_bytes": 64, + "handshake_hash": "2f956d8f8f068034ae949d7506d217c67e030523b1ad0566fe268eae7593963a", + "cs1_k": "371c538bc7cccc7935e7ed393690a0d5e6babfc5572c6af27523692cec8242a5", + "cs2_k": "90b5af4a5122c2d8cc12e39ec34f3bfd015d21073f117cd1a580216086db272a" + } + ] +} diff --git a/tests/security/__init__.py b/tests/security/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/security/noise/__init__.py b/tests/security/noise/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/security/noise/pq/__init__.py b/tests/security/noise/pq/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/security/noise/pq/test_kem.py b/tests/security/noise/pq/test_kem.py new file mode 100644 index 000000000..05f40c2c5 --- /dev/null +++ b/tests/security/noise/pq/test_kem.py @@ -0,0 +1,100 @@ +"""Tests for the IKem interface and XWingKem implementation.""" + +from libp2p.security.noise.pq.kem import XWingKem + + +class TestXWingKemKeySizes: + """Verify X-Wing key and ciphertext sizes match the spec.""" + + kem: XWingKem + + def setup_method(self) -> None: + self.kem = XWingKem() + + def test_keygen_public_key_size(self) -> None: + pk, _ = self.kem.keygen() + # ML-KEM-768 ek (1184) + X25519 pk (32) = 1216 + assert len(pk) == 1216 + + def test_keygen_secret_key_size(self) -> None: + _, sk = self.kem.keygen() + # ML-KEM-768 dk (2400) + X25519 sk (32) = 2432 + assert len(sk) == 2432 + + def test_encapsulate_ciphertext_size(self) -> None: + pk, _ = self.kem.keygen() + ct, _ = self.kem.encapsulate(pk) + # ML-KEM-768 ct (1088) + X25519 ephemeral pk (32) = 1120 + assert len(ct) == 1120 + + def test_encapsulate_shared_secret_size(self) -> None: + pk, _ = self.kem.keygen() + _, ss = self.kem.encapsulate(pk) + assert len(ss) == 32 + + def test_decapsulate_shared_secret_size(self) -> None: + pk, sk = self.kem.keygen() + ct, _ = self.kem.encapsulate(pk) + ss = self.kem.decapsulate(ct, sk) + assert len(ss) == 32 + + +class TestXWingKemRoundTrip: + """Verify encapsulate and decapsulate produce the same shared secret.""" + + kem: XWingKem + + def setup_method(self) -> None: + self.kem = XWingKem() + + def test_round_trip(self) -> None: + pk, sk = self.kem.keygen() + ct, ss_enc = self.kem.encapsulate(pk) + ss_dec = self.kem.decapsulate(ct, sk) + assert ss_enc == ss_dec + + def test_round_trip_produces_32_byte_secret(self) -> None: + pk, sk = self.kem.keygen() + ct, ss_enc = self.kem.encapsulate(pk) + ss_dec = self.kem.decapsulate(ct, sk) + assert len(ss_enc) == 32 + assert len(ss_dec) == 32 + + def test_different_keys_produce_different_secrets(self) -> None: + pk1, sk1 = self.kem.keygen() + pk2, sk2 = self.kem.keygen() + ct1, ss1 = self.kem.encapsulate(pk1) + ct2, ss2 = self.kem.encapsulate(pk2) + assert ss1 != ss2 + + def test_wrong_secret_key_produces_different_secret(self) -> None: + pk, sk = self.kem.keygen() + _, wrong_sk = self.kem.keygen() + ct, ss_enc = self.kem.encapsulate(pk) + ss_wrong = self.kem.decapsulate(ct, wrong_sk) + assert ss_enc != ss_wrong + + +class TestXWingKemCombiner: + """Verify the X-Wing combiner produces deterministic output.""" + + kem: XWingKem + + def setup_method(self) -> None: + self.kem = XWingKem() + + def test_same_inputs_produce_same_output(self) -> None: + pk, sk = self.kem.keygen() + ct, ss1 = self.kem.encapsulate(pk) + # Encapsulate is non-deterministic (uses fresh ephemeral each time) + # but decapsulate must be deterministic + ss_dec1 = self.kem.decapsulate(ct, sk) + ss_dec2 = self.kem.decapsulate(ct, sk) + assert ss_dec1 == ss_dec2 + + def test_encapsulate_is_non_deterministic(self) -> None: + pk, _ = self.kem.keygen() + ct1, _ = self.kem.encapsulate(pk) + ct2, _ = self.kem.encapsulate(pk) + # Different ephemeral X25519 keys each time + assert ct1 != ct2 diff --git a/tests/security/noise/pq/test_noise_state.py b/tests/security/noise/pq/test_noise_state.py new file mode 100644 index 000000000..6db15c743 --- /dev/null +++ b/tests/security/noise/pq/test_noise_state.py @@ -0,0 +1,156 @@ +"""Tests for the Noise CipherState and SymmetricState.""" + +import hashlib + +import pytest + +from libp2p.security.noise.pq.noise_state import ( + PROTOCOL_NAME, + CipherState, + SymmetricState, +) + + +class TestCipherState: + """Tests for CipherState (ChaCha20-Poly1305 with nonce counter).""" + + def test_encrypt_decrypt_round_trip(self) -> None: + key = bytes(range(32)) + cs = CipherState(key) + plaintext = b"hello noise" + ad = b"associated data" + ct = cs.encrypt_with_ad(ad, plaintext) + cs2 = CipherState(key) + result = cs2.decrypt_with_ad(ad, ct) + assert result == plaintext + + def test_nonce_increments_on_encrypt(self) -> None: + key = bytes(range(32)) + cs = CipherState(key) + assert cs.n == 0 + cs.encrypt_with_ad(b"", b"msg1") + assert cs.n == 1 + cs.encrypt_with_ad(b"", b"msg2") + assert cs.n == 2 + + def test_nonce_increments_on_decrypt(self) -> None: + key = bytes(range(32)) + cs_enc = CipherState(key) + cs_dec = CipherState(key) + ct1 = cs_enc.encrypt_with_ad(b"", b"msg1") + ct2 = cs_enc.encrypt_with_ad(b"", b"msg2") + cs_dec.decrypt_with_ad(b"", ct1) + assert cs_dec.n == 1 + cs_dec.decrypt_with_ad(b"", ct2) + assert cs_dec.n == 2 + + def test_wrong_ad_fails_decryption(self) -> None: + key = bytes(range(32)) + cs_enc = CipherState(key) + ct = cs_enc.encrypt_with_ad(b"correct ad", b"msg") + cs_dec = CipherState(key) + with pytest.raises(Exception): + cs_dec.decrypt_with_ad(b"wrong ad", ct) + + def test_empty_plaintext(self) -> None: + key = bytes(range(32)) + cs = CipherState(key) + ct = cs.encrypt_with_ad(b"ad", b"") + cs2 = CipherState(key) + result = cs2.decrypt_with_ad(b"ad", ct) + assert result == b"" + + def test_ciphertext_is_longer_than_plaintext(self) -> None: + """AEAD tag adds 16 bytes.""" + key = bytes(range(32)) + cs = CipherState(key) + plaintext = b"hello" + ct = cs.encrypt_with_ad(b"", plaintext) + assert len(ct) == len(plaintext) + 16 + + +class TestSymmetricState: + """Tests for SymmetricState (HKDF, MixKey, MixHash, EncryptAndHash).""" + + def test_initial_hash_is_protocol_name_hash(self) -> None: + ss = SymmetricState() + # h = SHA256(PROTOCOL_NAME) since len(PROTOCOL_NAME) > 32 + expected_h = hashlib.sha256(PROTOCOL_NAME).digest() + assert ss.h == expected_h + + def test_initial_chaining_key_equals_h(self) -> None: + ss = SymmetricState() + assert ss.ck == ss.h + + def test_mix_hash_updates_h(self) -> None: + ss = SymmetricState() + old_h = ss.h + ss.mix_hash(b"some data") + assert ss.h != old_h + # h = SHA256(h || data) + expected_h = hashlib.sha256(old_h + b"some data").digest() + assert ss.h == expected_h + + def test_mix_key_updates_ck(self) -> None: + ss = SymmetricState() + old_ck = ss.ck + ss.mix_key(b"shared secret" + bytes(19)) # 32 bytes + assert ss.ck != old_ck + + def test_encrypt_and_hash_round_trip(self) -> None: + ss_enc = SymmetricState() + ss_dec = SymmetricState() + + # Give both states a key via mix_key + ikm = bytes(32) + ss_enc.mix_key(ikm) + ss_dec.mix_key(ikm) + + plaintext = b"handshake payload" + ct = ss_enc.encrypt_and_hash(plaintext) + result = ss_dec.decrypt_and_hash(ct) + assert result == plaintext + + def test_encrypt_and_hash_updates_h(self) -> None: + ss = SymmetricState() + ss.mix_key(bytes(32)) + old_h = ss.h + ss.encrypt_and_hash(b"payload") + assert ss.h != old_h + + def test_split_returns_two_cipher_states(self) -> None: + ss = SymmetricState() + ss.mix_key(bytes(32)) + c1, c2 = ss.split() + # Both should be usable cipher states + ct = c1.encrypt_with_ad(b"", b"msg") + assert len(ct) > 0 + result = c2.encrypt_with_ad(b"", b"msg") + assert len(result) > 0 + + def test_split_produces_different_keys(self) -> None: + ss = SymmetricState() + ss.mix_key(bytes(32)) + c1, c2 = ss.split() + # Encrypting same data with different keys gives different results + ct1 = c1.encrypt_with_ad(b"", b"test") + ct2 = c2.encrypt_with_ad(b"", b"test") + assert ct1 != ct2 + + def test_identical_states_split_identically(self) -> None: + """Two symmetric states with the same transcript split to the same keys.""" + ikm = bytes(range(32)) + ss1 = SymmetricState() + ss2 = SymmetricState() + ss1.mix_hash(b"ephemeral key") + ss2.mix_hash(b"ephemeral key") + ss1.mix_key(ikm) + ss2.mix_key(ikm) + + c1_init, c1_resp = ss1.split() + c2_init, c2_resp = ss2.split() + + # Same input → same output + ct1 = c1_init.encrypt_with_ad(b"", b"msg") + ct2 = c2_init.encrypt_with_ad(b"", b"msg") + assert ct1 == ct2 diff --git a/tests/security/noise/pq/test_patterns_pq.py b/tests/security/noise/pq/test_patterns_pq.py new file mode 100644 index 000000000..1d2c0646b --- /dev/null +++ b/tests/security/noise/pq/test_patterns_pq.py @@ -0,0 +1,396 @@ +""" +Tests for PatternXXhfs: the Noise XXhfs handshake with X-Wing KEM. + +Follows TDD: these tests are written before the implementation and initially fail. +""" + +import math + +import pytest +from multiaddr import Multiaddr +import trio + +from libp2p.abc import IRawConnection +from libp2p.connection_types import ConnectionType +from libp2p.crypto.ed25519 import create_new_key_pair +from libp2p.crypto.x25519 import X25519PrivateKey +from libp2p.peer.id import ID +from libp2p.security.noise.exceptions import ( + PeerIDMismatchesPubkey, +) +from libp2p.security.noise.pq.patterns_pq import PatternXXhfs + +# --------------------------------------------------------------------------- +# In-memory connection helpers +# --------------------------------------------------------------------------- + + +class _MemoryConn(IRawConnection): + """ + Async in-memory bidirectional stream backed by trio memory channels. + + Implements IRawConnection for use in handshake tests. + """ + + is_initiator: bool = False + + def __init__(self, send_chan, recv_chan) -> None: + self._send = send_chan + self._recv = recv_chan + self._buf = bytearray() + + async def read(self, n: int | None = None) -> bytes: + while not self._buf: + try: + chunk = await self._recv.receive() + except trio.EndOfChannel: + return b"" + self._buf.extend(chunk) + if n is None: + data = bytes(self._buf) + self._buf.clear() + return data + data = bytes(self._buf[:n]) + del self._buf[:n] + return data + + async def write(self, data: bytes) -> None: + await self._send.send(bytes(data)) + + async def close(self) -> None: + await self._send.aclose() + + def get_remote_address(self) -> tuple[str, int] | None: + return None + + def get_transport_addresses(self) -> list[Multiaddr]: + return [] + + def get_connection_type(self) -> ConnectionType: + return ConnectionType.UNKNOWN + + +class _WriteCapture(IRawConnection): + """Wraps a connection and records every call to write().""" + + is_initiator: bool = False + + def __init__(self, inner: _MemoryConn) -> None: + self._inner = inner + self.writes: list[bytes] = [] + + async def read(self, n: int | None = None) -> bytes: + return await self._inner.read(n) + + async def write(self, data: bytes) -> None: + self.writes.append(bytes(data)) + await self._inner.write(data) + + async def close(self) -> None: + await self._inner.close() + + def get_remote_address(self) -> tuple[str, int] | None: + return None + + def get_transport_addresses(self) -> list[Multiaddr]: + return [] + + def get_connection_type(self) -> ConnectionType: + return ConnectionType.UNKNOWN + + +def _make_conn_pair() -> tuple[_MemoryConn, _MemoryConn]: + """Create a pair of in-memory connections wired together.""" + a_to_b_send, a_to_b_recv = trio.open_memory_channel(math.inf) + b_to_a_send, b_to_a_recv = trio.open_memory_channel(math.inf) + init_conn = _MemoryConn(a_to_b_send, b_to_a_recv) + resp_conn = _MemoryConn(b_to_a_send, a_to_b_recv) + return init_conn, resp_conn + + +def _make_pattern() -> tuple[PatternXXhfs, object, object, ID]: + """Create a fresh PatternXXhfs with newly-generated keys.""" + kp = create_new_key_pair() + noise_key = X25519PrivateKey.new() + peer = ID.from_pubkey(kp.public_key) + pattern = PatternXXhfs( + local_peer=peer, + libp2p_privkey=kp.private_key, + noise_static_key=noise_key, + ) + return pattern, kp, noise_key, peer + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestPatternXXhfsInit: + """Basic construction and attribute checks.""" + + def test_instantiation_stores_fields(self) -> None: + kp = create_new_key_pair() + noise_key = X25519PrivateKey.new() + peer = ID.from_pubkey(kp.public_key) + pattern = PatternXXhfs( + local_peer=peer, + libp2p_privkey=kp.private_key, + noise_static_key=noise_key, + ) + assert pattern.local_peer is peer + assert pattern.libp2p_privkey is kp.private_key + assert pattern.noise_static_key is noise_key + assert pattern.early_data is None + + def test_protocol_name(self) -> None: + pattern, _, _, _ = _make_pattern() + assert pattern.PROTOCOL_NAME == b"Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256" + + def test_default_kem_is_xwing(self) -> None: + from libp2p.security.noise.pq.kem import XWingKem + + pattern, _, _, _ = _make_pattern() + assert isinstance(pattern.kem, XWingKem) + + +class TestPatternXXhfsHandshake: + """Full-handshake integration tests.""" + + @pytest.mark.trio + async def test_handshake_completes(self) -> None: + """Both sides return a SecureSession after the handshake.""" + init_pat, _, _, _ = _make_pattern() + resp_pat, _, _, resp_peer = _make_pattern() + init_conn, resp_conn = _make_conn_pair() + + sessions: list = [None, None] + + async def run_init() -> None: + sessions[0] = await init_pat.handshake_outbound(init_conn, resp_peer) + + async def run_resp() -> None: + sessions[1] = await resp_pat.handshake_inbound(resp_conn) + + async with trio.open_nursery() as nursery: + nursery.start_soon(run_init) + nursery.start_soon(run_resp) + + assert sessions[0] is not None + assert sessions[1] is not None + + @pytest.mark.trio + async def test_bidirectional_data_exchange(self) -> None: + """Data written by each side is received correctly by the other.""" + init_pat, _, _, _ = _make_pattern() + resp_pat, _, _, resp_peer = _make_pattern() + init_conn, resp_conn = _make_conn_pair() + + sessions: list = [None, None] + + async def run_init() -> None: + sessions[0] = await init_pat.handshake_outbound(init_conn, resp_peer) + + async def run_resp() -> None: + sessions[1] = await resp_pat.handshake_inbound(resp_conn) + + async with trio.open_nursery() as nursery: + nursery.start_soon(run_init) + nursery.start_soon(run_resp) + + init_sess, resp_sess = sessions + + # Initiator → Responder + msg_i = b"hello from initiator" + await init_sess.write(msg_i) + assert await resp_sess.read(len(msg_i)) == msg_i + + # Responder → Initiator + msg_r = b"hello from responder" + await resp_sess.write(msg_r) + assert await init_sess.read(len(msg_r)) == msg_r + + @pytest.mark.trio + async def test_peer_ids_are_correct(self) -> None: + """Both sides see the correct remote peer ID after the handshake.""" + init_pat, _, _, init_peer = _make_pattern() + resp_pat, _, _, resp_peer = _make_pattern() + init_conn, resp_conn = _make_conn_pair() + + sessions: list = [None, None] + + async def run_init() -> None: + sessions[0] = await init_pat.handshake_outbound(init_conn, resp_peer) + + async def run_resp() -> None: + sessions[1] = await resp_pat.handshake_inbound(resp_conn) + + async with trio.open_nursery() as nursery: + nursery.start_soon(run_init) + nursery.start_soon(run_resp) + + init_sess, resp_sess = sessions + assert init_sess.remote_peer == resp_peer + assert resp_sess.remote_peer == init_peer + + @pytest.mark.trio + async def test_peer_id_mismatch_raises(self) -> None: + """Initiator raises PeerIDMismatchesPubkey when peer ID is wrong.""" + init_pat, _, _, _ = _make_pattern() + resp_pat, _, _, resp_peer = _make_pattern() + _, _, _, wrong_peer = _make_pattern() + init_conn, resp_conn = _make_conn_pair() + + init_error: Exception | None = None + + async def run_init() -> None: + nonlocal init_error + try: + await init_pat.handshake_outbound(init_conn, wrong_peer) + except PeerIDMismatchesPubkey as e: + init_error = e + except Exception: + pass + finally: + # Closing the send side unblocks the responder waiting for Msg C. + await init_conn.close() + + async def run_resp() -> None: + try: + await resp_pat.handshake_inbound(resp_conn) + except Exception: + pass + + async with trio.open_nursery() as nursery: + nursery.start_soon(run_init) + nursery.start_soon(run_resp) + + assert isinstance(init_error, PeerIDMismatchesPubkey) + + @pytest.mark.trio + async def test_large_payload_exchange(self) -> None: + """Transport handles payloads larger than a single cipher block.""" + init_pat, _, _, _ = _make_pattern() + resp_pat, _, _, resp_peer = _make_pattern() + init_conn, resp_conn = _make_conn_pair() + + sessions: list = [None, None] + + async def run_init() -> None: + sessions[0] = await init_pat.handshake_outbound(init_conn, resp_peer) + + async def run_resp() -> None: + sessions[1] = await resp_pat.handshake_inbound(resp_conn) + + async with trio.open_nursery() as nursery: + nursery.start_soon(run_init) + nursery.start_soon(run_resp) + + large_msg = b"Z" * 8192 + await sessions[0].write(large_msg) + received = await sessions[1].read(8192) + assert received == large_msg + + @pytest.mark.trio + async def test_independent_sessions_dont_interfere(self) -> None: + """Two simultaneous handshakes produce independent, non-interfering sessions.""" + ip1, _, _, _ = _make_pattern() + rp1, _, _, rp1_peer = _make_pattern() + ip2, _, _, _ = _make_pattern() + rp2, _, _, rp2_peer = _make_pattern() + + ic1, rc1 = _make_conn_pair() + ic2, rc2 = _make_conn_pair() + + sessions: list = [None] * 4 + + async def h1_init() -> None: + sessions[0] = await ip1.handshake_outbound(ic1, rp1_peer) + + async def h1_resp() -> None: + sessions[1] = await rp1.handshake_inbound(rc1) + + async def h2_init() -> None: + sessions[2] = await ip2.handshake_outbound(ic2, rp2_peer) + + async def h2_resp() -> None: + sessions[3] = await rp2.handshake_inbound(rc2) + + async with trio.open_nursery() as nursery: + nursery.start_soon(h1_init) + nursery.start_soon(h1_resp) + nursery.start_soon(h2_init) + nursery.start_soon(h2_resp) + + assert all(s is not None for s in sessions) + + # Both pairs exchange data independently + await sessions[0].write(b"pair1") + await sessions[2].write(b"pair2") + assert await sessions[1].read(5) == b"pair1" + assert await sessions[3].read(5) == b"pair2" + + +class TestPatternXXhfsWireFormat: + """Verify the on-wire message layout.""" + + @pytest.mark.trio + async def test_message_a_is_1248_bytes(self) -> None: + """Message A = e_pk(32) + e1_pk(1216) = 1248 bytes payload.""" + init_pat, _, _, _ = _make_pattern() + resp_pat, _, _, resp_peer = _make_pattern() + + inner_conn, resp_conn = _make_conn_pair() + spy = _WriteCapture(inner_conn) + + sessions: list = [None, None] + + async def run_init() -> None: + sessions[0] = await init_pat.handshake_outbound(spy, resp_peer) + + async def run_resp() -> None: + sessions[1] = await resp_pat.handshake_inbound(resp_conn) + + async with trio.open_nursery() as nursery: + nursery.start_soon(run_init) + nursery.start_soon(run_resp) + + # spy.writes[0] = 2-byte length prefix + message A bytes + assert len(spy.writes) >= 1 + frame = spy.writes[0] + msg_len = int.from_bytes(frame[:2], "big") + assert msg_len == 1248, f"Expected 1248, got {msg_len}" + + @pytest.mark.trio + async def test_message_b_overhead(self) -> None: + """Message B = e(32) + enc_ct(1136) + enc_s(48) + enc_payload(len+16).""" + init_pat, _, _, _ = _make_pattern() + resp_pat, _, _, resp_peer = _make_pattern() + + init_conn, inner_resp_conn = _make_conn_pair() + spy = _WriteCapture(inner_resp_conn) + + sessions: list = [None, None] + + async def run_init() -> None: + sessions[0] = await init_pat.handshake_outbound(init_conn, resp_peer) + + async def run_resp() -> None: + sessions[1] = await resp_pat.handshake_inbound(spy) + + async with trio.open_nursery() as nursery: + nursery.start_soon(run_init) + nursery.start_soon(run_resp) + + # spy.writes[0] = framed message B + assert len(spy.writes) >= 1 + frame = spy.writes[0] + msg_len = int.from_bytes(frame[:2], "big") + + # Fixed overhead: 32 (e) + 1136 (enc_ct) + 48 (enc_s) + 16 (AEAD tag) + # Payload size varies (protobuf-serialised NoiseHandshakePayload) but + # total fixed overhead is constant + fixed_overhead = 32 + 1136 + 48 + 16 + assert msg_len >= fixed_overhead, ( + f"Message B too short: {msg_len} < {fixed_overhead}" + ) diff --git a/tests/security/noise/pq/test_transport_pq.py b/tests/security/noise/pq/test_transport_pq.py new file mode 100644 index 000000000..4ae5730b7 --- /dev/null +++ b/tests/security/noise/pq/test_transport_pq.py @@ -0,0 +1,206 @@ +""" +Tests for TransportPQ: the ISecureTransport wrapper for XXhfs. + +Follows TDD: these tests are written before the implementation. +""" + +import math + +import pytest +from multiaddr import Multiaddr +import trio + +from libp2p.abc import IRawConnection +from libp2p.connection_types import ConnectionType +from libp2p.crypto.ed25519 import create_new_key_pair +from libp2p.crypto.keys import KeyPair +from libp2p.crypto.x25519 import X25519PrivateKey +from libp2p.peer.id import ID +from libp2p.security.noise.pq.transport_pq import PROTOCOL_ID, TransportPQ + +# --------------------------------------------------------------------------- +# Shared in-memory connection helpers (mirrors test_patterns_pq.py) +# --------------------------------------------------------------------------- + + +class _MemoryConn(IRawConnection): + is_initiator: bool = False + + def __init__(self, send_chan, recv_chan) -> None: + self._send = send_chan + self._recv = recv_chan + self._buf = bytearray() + + async def read(self, n: int | None = None) -> bytes: + while not self._buf: + try: + chunk = await self._recv.receive() + except trio.EndOfChannel: + return b"" + self._buf.extend(chunk) + if n is None: + data = bytes(self._buf) + self._buf.clear() + return data + data = bytes(self._buf[:n]) + del self._buf[:n] + return data + + async def write(self, data: bytes) -> None: + await self._send.send(bytes(data)) + + async def close(self) -> None: + await self._send.aclose() + + def get_remote_address(self) -> tuple[str, int] | None: + return None + + def get_transport_addresses(self) -> list[Multiaddr]: + return [] + + def get_connection_type(self) -> ConnectionType: + return ConnectionType.UNKNOWN + + +def _make_conn_pair() -> tuple[_MemoryConn, _MemoryConn]: + a_to_b_send, a_to_b_recv = trio.open_memory_channel(math.inf) + b_to_a_send, b_to_a_recv = trio.open_memory_channel(math.inf) + return ( + _MemoryConn(a_to_b_send, b_to_a_recv), + _MemoryConn(b_to_a_send, a_to_b_recv), + ) + + +def _make_transport() -> tuple[TransportPQ, ID]: + kp = create_new_key_pair() + noise_key = X25519PrivateKey.new() + peer = ID.from_pubkey(kp.public_key) + transport = TransportPQ( + libp2p_keypair=KeyPair(kp.private_key, kp.public_key), + noise_privkey=noise_key, + ) + return transport, peer + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestTransportPQInit: + def test_protocol_id(self) -> None: + assert PROTOCOL_ID == "/noise-pq/1.0.0" + + def test_instantiation(self) -> None: + transport, peer = _make_transport() + assert transport.local_peer == peer + + def test_get_pattern_returns_xxhfs(self) -> None: + from libp2p.security.noise.pq.patterns_pq import PatternXXhfs + + transport, _ = _make_transport() + pattern = transport.get_pattern() + assert isinstance(pattern, PatternXXhfs) + + def test_get_pattern_protocol_name(self) -> None: + transport, _ = _make_transport() + pattern = transport.get_pattern() + assert pattern.PROTOCOL_NAME == b"Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256" + + +class TestTransportPQHandshake: + @pytest.mark.trio + async def test_secure_inbound_and_outbound_complete(self) -> None: + """secure_outbound + secure_inbound both return a SecureSession.""" + local_transport, local_peer = _make_transport() + remote_transport, remote_peer = _make_transport() + local_conn, remote_conn = _make_conn_pair() + + sessions: list = [None, None] + + async def do_outbound() -> None: + sessions[0] = await local_transport.secure_outbound(local_conn, remote_peer) + + async def do_inbound() -> None: + sessions[1] = await remote_transport.secure_inbound(remote_conn) + + async with trio.open_nursery() as nursery: + nursery.start_soon(do_outbound) + nursery.start_soon(do_inbound) + + assert sessions[0] is not None + assert sessions[1] is not None + + @pytest.mark.trio + async def test_data_exchange_after_secure_transport(self) -> None: + """Data written via secure_outbound is readable via secure_inbound.""" + local_transport, _ = _make_transport() + remote_transport, remote_peer = _make_transport() + local_conn, remote_conn = _make_conn_pair() + + sessions: list = [None, None] + + async def do_outbound() -> None: + sessions[0] = await local_transport.secure_outbound(local_conn, remote_peer) + + async def do_inbound() -> None: + sessions[1] = await remote_transport.secure_inbound(remote_conn) + + async with trio.open_nursery() as nursery: + nursery.start_soon(do_outbound) + nursery.start_soon(do_inbound) + + outbound_sess, inbound_sess = sessions + + msg = b"post-quantum hello" + await outbound_sess.write(msg) + assert await inbound_sess.read(len(msg)) == msg + + reply = b"pq reply" + await inbound_sess.write(reply) + assert await outbound_sess.read(len(reply)) == reply + + @pytest.mark.trio + async def test_peer_ids_correct_after_transport(self) -> None: + """Both sides see the correct remote peer ID after the secure upgrade.""" + local_transport, local_peer = _make_transport() + remote_transport, remote_peer = _make_transport() + local_conn, remote_conn = _make_conn_pair() + + sessions: list = [None, None] + + async def do_outbound() -> None: + sessions[0] = await local_transport.secure_outbound(local_conn, remote_peer) + + async def do_inbound() -> None: + sessions[1] = await remote_transport.secure_inbound(remote_conn) + + async with trio.open_nursery() as nursery: + nursery.start_soon(do_outbound) + nursery.start_soon(do_inbound) + + outbound_sess, inbound_sess = sessions + assert outbound_sess.remote_peer == remote_peer + assert inbound_sess.remote_peer == local_peer + + @pytest.mark.trio + async def test_is_initiator_flag(self) -> None: + """secure_outbound returns is_initiator=True, secure_inbound returns False.""" + local_transport, _ = _make_transport() + remote_transport, remote_peer = _make_transport() + local_conn, remote_conn = _make_conn_pair() + + sessions: list = [None, None] + + async def do_outbound() -> None: + sessions[0] = await local_transport.secure_outbound(local_conn, remote_peer) + + async def do_inbound() -> None: + sessions[1] = await remote_transport.secure_inbound(remote_conn) + + async with trio.open_nursery() as nursery: + nursery.start_soon(do_outbound) + nursery.start_soon(do_inbound) + + assert sessions[0].is_initiator is True + assert sessions[1].is_initiator is False diff --git a/tests/security/noise/pq/test_vectors_pq.py b/tests/security/noise/pq/test_vectors_pq.py new file mode 100644 index 000000000..fbd4847fd --- /dev/null +++ b/tests/security/noise/pq/test_vectors_pq.py @@ -0,0 +1,385 @@ +""" +Cross-implementation test vectors for Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256. + +Loads the 5 vendored vectors (tests/fixtures/pqc-test-vectors.json) and +reproduces each handshake with the exact same fixed seeds. Asserts +byte-exact equality of: + +- messages A, B, C +- final handshake hash (ss.h after Message C) +- transport cipher keys cs1.k and cs2.k + +All 5 vectors passing proves the Python and JavaScript implementations are +wire-compatible. Vectors were generated by js-libp2p-noise and committed +here so they run in CI on every clean checkout. + +Key seeding: + +- Initiator ephemeral DH : ephemeral_dh_i_{public,private} +- Responder ephemeral DH : ephemeral_dh_r_{public,private} +- Initiator KEM keypair : ephemeral_kem_i_{public,secret} +- Responder encap seed : encap_seed_hex (64 bytes) + [0:32] = ML-KEM randomness m + [32:64] = X25519 ephemeral private key + +Prologue for all vectors: empty (ZEROLEN = b""), which the Noise spec requires +to be mixed even when empty: h = SHA256(SHA256(protocol_name)). +""" + +import hashlib +import json +from pathlib import Path + +import pytest +from kyber_py.ml_kem import ML_KEM_768 +from nacl.bindings import crypto_scalarmult, crypto_scalarmult_base + +from libp2p.security.noise.pq.kem import ( + _ML_KEM_CT_SIZE, + _ML_KEM_PK_SIZE, + _X25519_KEY_SIZE, + _xwing_combine, +) +from libp2p.security.noise.pq.noise_state import SymmetricState, _hkdf + +# --------------------------------------------------------------------------- +# Vector file location +# --------------------------------------------------------------------------- + +_VECTORS_PATH = Path(__file__).parents[3] / "fixtures" / "pqc-test-vectors.json" + +# Size constants (mirror patterns_pq.py) +_AEAD_TAG = 16 +_KEM_CT_ENC_SIZE = _ML_KEM_CT_SIZE + _X25519_KEY_SIZE + _AEAD_TAG # 1136 +_S_ENC_SIZE = _X25519_KEY_SIZE + _AEAD_TAG # 48 + + +# --------------------------------------------------------------------------- +# Seeded X-Wing encapsulate (for responder with fixed seed) +# --------------------------------------------------------------------------- + + +def _xwing_encapsulate_seeded(pk: bytes, encap_seed: bytes) -> tuple[bytes, bytes]: + """ + Deterministic X-Wing encapsulation using a 64-byte seed. + + seed[0:32] = ML-KEM randomness m (passed to _encaps_internal) + seed[32:64] = X25519 ephemeral private key + + Matches XWing.encapsulate(pubkey, seed) from @noble/post-quantum. + + Returns: + (ciphertext, shared_secret) — same layout as XWingKem.encapsulate() + + """ + assert len(pk) == _ML_KEM_PK_SIZE + _X25519_KEY_SIZE, f"bad pk len: {len(pk)}" + assert len(encap_seed) == 64, f"seed must be 64 bytes, got {len(encap_seed)}" + + ml_kem_pk = pk[:_ML_KEM_PK_SIZE] + x25519_pk_r = pk[_ML_KEM_PK_SIZE:] + + # ML-KEM with deterministic randomness + m = encap_seed[:32] + ss_mlkem, ml_kem_ct = ML_KEM_768._encaps_internal(ml_kem_pk, m) + + # X25519 with fixed ephemeral private key + x25519_eph_sk = encap_seed[32:] + x25519_eph_pk = bytes(crypto_scalarmult_base(x25519_eph_sk)) + ss_x25519 = bytes(crypto_scalarmult(x25519_eph_sk, x25519_pk_r)) + + ss = _xwing_combine(ss_mlkem, ss_x25519, x25519_eph_pk, x25519_pk_r) + ct = ml_kem_ct + x25519_eph_pk + return ct, ss + + +# --------------------------------------------------------------------------- +# X-Wing seed expansion (matches @noble/post-quantum combineKEMS + expandSeedXof) +# --------------------------------------------------------------------------- + + +def _xwing_sk_from_seed(seed: bytes) -> bytes: + """ + Expand a 32-byte X-Wing root seed into the full 2432-byte secret key. + + @noble/post-quantum stores secretKey as the 32-byte root seed and + re-expands on each decapsulate call using: + expanded = SHAKE-256(seed, 96 bytes) + expanded[0:32] = ML-KEM-768 d (randomness) + expanded[32:64] = ML-KEM-768 z (implicit rejection randomness) + expanded[64:96] = X25519 private key + + Returns: + 2432-byte X-Wing secret key (ml_kem_dk || x25519_sk) + + """ + assert len(seed) == 32, f"seed must be 32 bytes, got {len(seed)}" + expanded = hashlib.shake_256(seed).digest(96) + d = expanded[0:32] + z = expanded[32:64] + x25519_sk = expanded[64:96] + _ml_kem_pk, ml_kem_sk = ML_KEM_768._keygen_internal(d, z) + return ml_kem_sk + x25519_sk + + +# --------------------------------------------------------------------------- +# Deterministic handshake reproducer +# --------------------------------------------------------------------------- + + +def _run_vector_handshake(v: dict) -> dict: + """ + Reproduce the XXhfs handshake with all keys fixed from a test vector. + + Returns: + dict with keys: msg_a, msg_b, msg_c, handshake_hash, cs1_k, cs2_k + + """ + # Load fixed values + e_i_sk = bytes.fromhex(v["ephemeral_dh_i_private"]) + e_i_pk = bytes.fromhex(v["ephemeral_dh_i_public"]) + e_r_sk = bytes.fromhex(v["ephemeral_dh_r_private"]) + e_r_pk = bytes.fromhex(v["ephemeral_dh_r_public"]) + static_i_sk = bytes.fromhex(v["static_i_private"]) + static_i_pk = bytes.fromhex(v["static_i_public"]) + static_r_sk = bytes.fromhex(v["static_r_private"]) + static_r_pk = bytes.fromhex(v["static_r_public"]) + e1_pk = bytes.fromhex(v["ephemeral_kem_i_public"]) + # ephemeral_kem_i_secret is a 32-byte root seed (not the full 2432-byte key) + # @noble/post-quantum stores the root seed as secretKey and re-expands via SHAKE-256 + e1_sk = _xwing_sk_from_seed(bytes.fromhex(v["ephemeral_kem_i_secret"])) + encap_seed = bytes.fromhex(v["encap_seed_hex"]) + # Prologue: ZEROLEN = b"" + + # ---- Initiator SymmetricState ---------------------------------------- + ss_i = SymmetricState() + ss_i.mix_hash(b"") # MixHash(prologue=empty) + + # ---- Responder SymmetricState ---------------------------------------- + ss_r = SymmetricState() + ss_r.mix_hash(b"") # MixHash(prologue=empty) + + # ====================================================================== + # Message A: initiator sends e_pk || e1_pk (no payload, no AEAD yet) + # ====================================================================== + ss_i.mix_hash(e_i_pk) # writeE token + ss_i.mix_hash(e1_pk) # writeE1 token (encryptAndHash = mixHash when no key) + enc_payload_a = ss_i.encrypt_and_hash(b"") # empty payload → b"" + msg_a = e_i_pk + e1_pk + enc_payload_a # 32 + 1216 + 0 = 1248 B + + # Responder processes Message A + ss_r.mix_hash(e_i_pk) + ss_r.mix_hash(e1_pk) + ss_r.decrypt_and_hash(enc_payload_a) # mix_hash(b"") — keeps states in sync + + # ====================================================================== + # Message B: responder sends e_pk || enc_ct || enc_s || enc_payload + # Tokens: e, ee, ekem1, s, es + # ====================================================================== + ss_r.mix_hash(e_r_pk) # writeE + + dh_ee = bytes(crypto_scalarmult(e_r_sk, e_i_pk)) + ss_r.mix_key(dh_ee) # writeEE + + # writeEkem1: encapsulate, encrypt ct, then mix KEM ss + ct, ss_kem_r = _xwing_encapsulate_seeded(e1_pk, encap_seed) + enc_ct = ss_r.encrypt_and_hash(ct) # encrypted under ee-derived key + ss_r.mix_key(ss_kem_r) # then strengthen with KEM output + + # writeS: encrypt responder static pubkey + enc_s_r = ss_r.encrypt_and_hash(static_r_pk) + + # writeES (responder role): MixKey(DH(s_responder, e_initiator)) + dh_es_r = bytes(crypto_scalarmult(static_r_sk, e_i_pk)) + ss_r.mix_key(dh_es_r) + + # payload = ZEROLEN + enc_payload_b = ss_r.encrypt_and_hash(b"") + msg_b = e_r_pk + enc_ct + enc_s_r + enc_payload_b # 32+1136+48+16 = 1232 B + + # Initiator processes Message B + ss_i.mix_hash(e_r_pk) + + dh_ee_i = bytes(crypto_scalarmult(e_i_sk, e_r_pk)) + ss_i.mix_key(dh_ee_i) # ee token + + # readEkem1: decrypt ct, then decapsulate, then mix KEM ss + from libp2p.security.noise.pq.kem import XWingKem + + ct_dec = ss_i.decrypt_and_hash(enc_ct) + ss_kem_i = XWingKem().decapsulate(ct_dec, e1_sk) + ss_i.mix_key(ss_kem_i) + + # readS: decrypt responder static pubkey + dec_s_r = ss_i.decrypt_and_hash(enc_s_r) + + # readES (initiator role): MixKey(DH(e_initiator, s_responder)) + dh_es_i = bytes(crypto_scalarmult(e_i_sk, dec_s_r)) + ss_i.mix_key(dh_es_i) + + ss_i.decrypt_and_hash(enc_payload_b) # empty payload + + # ====================================================================== + # Message C: initiator sends enc_s || enc_payload + # Tokens: s, se + # ====================================================================== + # writeS: encrypt initiator static pubkey + enc_s_i = ss_i.encrypt_and_hash(static_i_pk) + + # writeSE (initiator role): MixKey(DH(s_initiator, e_responder)) + dh_se_i = bytes(crypto_scalarmult(static_i_sk, e_r_pk)) + ss_i.mix_key(dh_se_i) + + enc_payload_c = ss_i.encrypt_and_hash(b"") + msg_c = enc_s_i + enc_payload_c # 48 + 16 = 64 B + + # Responder processes Message C + dec_s_i = ss_r.decrypt_and_hash(enc_s_i) + + # readSE (responder role): MixKey(DH(e_responder, s_initiator)) + dh_se_r = bytes(crypto_scalarmult(e_r_sk, dec_s_i)) + ss_r.mix_key(dh_se_r) + + ss_r.decrypt_and_hash(enc_payload_c) + + # ====================================================================== + # Split — both sides must derive the same cipher keys + # ====================================================================== + cs1_k, cs2_k = _hkdf(ss_i.ck, b"", 2) + cs1_k_r, cs2_k_r = _hkdf(ss_r.ck, b"", 2) + assert cs1_k == cs1_k_r, "cs1 key mismatch between initiator and responder" + assert cs2_k == cs2_k_r, "cs2 key mismatch between initiator and responder" + + return { + "msg_a": msg_a, + "msg_b": msg_b, + "msg_c": msg_c, + "handshake_hash": ss_i.h, + "cs1_k": cs1_k, + "cs2_k": cs2_k, + } + + +# --------------------------------------------------------------------------- +# Test fixture loading +# --------------------------------------------------------------------------- + + +def _load_vectors() -> list[dict]: + if not _VECTORS_PATH.exists(): + pytest.skip(f"Test vectors not found at {_VECTORS_PATH}") + with open(_VECTORS_PATH) as f: + data = json.load(f) + assert data["protocol"] == "Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256" + return data["vectors"] + + +# --------------------------------------------------------------------------- +# Parameterised tests +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def vectors() -> list[dict]: + return _load_vectors() + + +class TestVectorsMeta: + def test_protocol_name(self) -> None: + if not _VECTORS_PATH.exists(): + pytest.skip(f"Test vectors not found at {_VECTORS_PATH}") + with open(_VECTORS_PATH) as f: + data = json.load(f) + assert data["protocol"] == "Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256" + + def test_five_vectors_present(self, vectors: list[dict]) -> None: + assert len(vectors) == 5 + + +class TestVectorHandshake: + """One test class; vectors are parameterised inside each test method.""" + + @pytest.mark.parametrize("idx", range(5)) + def test_msg_a_bytes(self, vectors: list[dict], idx: int) -> None: + v = vectors[idx] + result = _run_vector_handshake(v) + assert len(result["msg_a"]) == v["msg_a_bytes"], ( + f"Vector {idx}: msg_a length {len(result['msg_a'])} != {v['msg_a_bytes']}" + ) + + @pytest.mark.parametrize("idx", range(5)) + def test_msg_a_content(self, vectors: list[dict], idx: int) -> None: + v = vectors[idx] + result = _run_vector_handshake(v) + got = result["msg_a"].hex() + assert got == v["msg_a"], ( + f"Vector {idx}: msg_a mismatch\n" + f" got: {got[:64]}...\n" + f" expected: {v['msg_a'][:64]}..." + ) + + @pytest.mark.parametrize("idx", range(5)) + def test_msg_b_bytes(self, vectors: list[dict], idx: int) -> None: + v = vectors[idx] + result = _run_vector_handshake(v) + assert len(result["msg_b"]) == v["msg_b_bytes"], ( + f"Vector {idx}: msg_b length {len(result['msg_b'])} != {v['msg_b_bytes']}" + ) + + @pytest.mark.parametrize("idx", range(5)) + def test_msg_b_content(self, vectors: list[dict], idx: int) -> None: + v = vectors[idx] + result = _run_vector_handshake(v) + got = result["msg_b"].hex() + assert got == v["msg_b"], ( + f"Vector {idx}: msg_b mismatch\n" + f" got: {got[:64]}...\n" + f" expected: {v['msg_b'][:64]}..." + ) + + @pytest.mark.parametrize("idx", range(5)) + def test_msg_c_bytes(self, vectors: list[dict], idx: int) -> None: + v = vectors[idx] + result = _run_vector_handshake(v) + assert len(result["msg_c"]) == v["msg_c_bytes"], ( + f"Vector {idx}: msg_c length {len(result['msg_c'])} != {v['msg_c_bytes']}" + ) + + @pytest.mark.parametrize("idx", range(5)) + def test_msg_c_content(self, vectors: list[dict], idx: int) -> None: + v = vectors[idx] + result = _run_vector_handshake(v) + got = result["msg_c"].hex() + assert got == v["msg_c"], ( + f"Vector {idx}: msg_c mismatch\n" + f" got: {got[:64]}...\n" + f" expected: {v['msg_c'][:64]}..." + ) + + @pytest.mark.parametrize("idx", range(5)) + def test_handshake_hash(self, vectors: list[dict], idx: int) -> None: + v = vectors[idx] + result = _run_vector_handshake(v) + got = result["handshake_hash"].hex() + assert got == v["handshake_hash"], ( + f"Vector {idx}: handshake_hash mismatch — transcript diverged\n" + f" got: {got}\n" + f" expected: {v['handshake_hash']}" + ) + + @pytest.mark.parametrize("idx", range(5)) + def test_cs1_k(self, vectors: list[dict], idx: int) -> None: + v = vectors[idx] + result = _run_vector_handshake(v) + got = result["cs1_k"].hex() + assert got == v["cs1_k"], ( + f"Vector {idx}: cs1_k mismatch\n got: {got}\n expected: {v['cs1_k']}" + ) + + @pytest.mark.parametrize("idx", range(5)) + def test_cs2_k(self, vectors: list[dict], idx: int) -> None: + v = vectors[idx] + result = _run_vector_handshake(v) + got = result["cs2_k"].hex() + assert got == v["cs2_k"], ( + f"Vector {idx}: cs2_k mismatch\n got: {got}\n expected: {v['cs2_k']}" + ) diff --git a/tox.ini b/tox.ini index 8b532dfa4..688a2bdce 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,7 @@ envlist= py{310,311,312,313}-wheel py{310,311,312,313}-interop py{310,311,312,313}-attack-simulation + py{310,311,312,313}-pq windows-wheel docs @@ -25,6 +26,7 @@ commands= docs: make check-docs-ci interop: pytest -n auto --timeout=1200 {posargs:tests/interop} utils: pytest -n auto --timeout=1200 {posargs:tests/utils} + pq: pytest --timeout=120 {posargs:tests/security/noise/pq} basepython= docs: python windows-wheel: python