feat(security): Noise XXhfs post-quantum handshake for py-libp2p (research/WIP) - #1310
paschal533 wants to merge 61 commits into
Conversation
Implements Noise_XXhfs_25519+XWing_ChaChaPoly_SHA256 as a new optional security transport under protocol ID /noise-pq/1.0.0. New modules under libp2p/security/noise/pq/: - kem.py: X-Wing hybrid KEM (ML-KEM-768 + X25519), IKem protocol - noise_state.py: SymmetricState and CipherState for XXhfs - patterns_pq.py: PatternXXhfs three-message handshake state machine - transport_pq.py: TransportPQ implementing ISecureTransport Test coverage (92 tests, all passing): - test_kem.py: X-Wing keygen, encapsulate, decapsulate round-trips - test_noise_state.py: SymmetricState primitives and HKDF split - test_patterns_pq.py: full in-memory handshake, peer ID verification - test_transport_pq.py: TransportPQ integration with SecureSession - test_vectors_pq.py: 47 cross-implementation vector tests against 5 deterministic vectors from js-libp2p-noise; all pass byte-for-byte scripts/interop_dial.py: live TCP dialer connecting to the JS node-listener, completes a real handshake and exchanges encrypted messages (Python initiator, JS responder). benchmarks/bench_noise_pq.py + results.md: handshake latency, KEM micro-benchmarks, and wire size comparison vs classical Noise XX. The existing /noise transport and PatternXX are untouched.
Fix remaining 8 ruff violations that ruff --fix could not auto-correct: - kem.py: add r-prefix to _xwing_combine docstring (D301) to satisfy ruff's requirement that docstrings with backslash escapes use raw strings - bench_noise_pq.py: wrap five long f-string lines to stay within the 88-char limit (E501); extract overhead_x local to avoid repetition - test_patterns_pq.py: wrap two long inline comments (E501) All 46 ruff errors are now resolved.
|
Just ran the live interop test end-to-end... wanted to confirm it actually works before people try to reproduce it. Setup:
Output: Two completely separate runtimes, one real TCP socket, same handshake keys on both ends. The cross-language test vectors in The JS listener script ( Node.js v22, Python 3.13, Windows 11, both sides happy. |
Performance update: WASM KEM results from the JS side and what they mean hereI ran more detailed performance work on the JS implementation this week and wanted to share the findings here since they are relevant to both PRs. What I measured on the JS sideBuilt a Rust WASM module for X-Wing (58 KB binary, KEM micro-benchmarks (Node.js v22.17.1):
The WASM KEM is 3.2x faster on the KEM operations. But the full handshake barely moves:
Less than 2% improvement on the full handshake even though the KEM is 3x faster. This is Amdahl's Law. The KEM accounts for maybe 25-30% of total handshake time. The rest is SHA-256, ChaCha20-Poly1305, HKDF, X25519, Ed25519, Protobuf, and async scheduling overhead, all of which are still running in interpreted code regardless of what the KEM is doing. What this means for the Python benchmarksThe Python numbers in this PR (10x overhead vs classical, ~40 ms for XXhfs) are in a similar position. Swapping From a rough breakdown: the KEM round-trip in Python is about 38 ms (10.5 + 12.3 + 15.5 ms). The full handshake is 40.7 ms, which means the non-KEM overhead is only about 2-3 ms. So actually the Python situation is the inverse of JS - the KEM dominates much more strongly here (around 90% of handshake time vs maybe 30% in JS). That means a fast KEM backend like liboqs would make a much bigger difference in Python than it does in JS. If liboqs-python is available, switching to it for the KEM path should bring the XXhfs handshake close to the classical baseline. The Summary
Happy to share the full benchmark script if useful. |
Introduces LibOQSXWingKem (oqs.KeyEncapsulation("ML-KEM-768") + PyNaCl
X25519) and make_fast_kem() which auto-selects the liboqs backend when
available, falling back to the pure-Python XWingKem silently.
- transport_pq.py and patterns_pq.py now use make_fast_kem() as the
default KEM instead of hardcoding XWingKem()
- Added pq-fast optional dependency group to pyproject.toml so users
can install liboqs support with: pip install libp2p[pq-fast]
- KeypairPool pre-generates keypairs in background threads to amortize
liboqs keygen latency under concurrent load
- bench_noise_pq.py updated to benchmark both backends and report the
speedup ratio
Predicted improvement: ~92% reduction in XXhfs handshake latency
(40.7 ms kyber-py to ~3.2 ms liboqs), bringing it below the classical
Noise baseline of 4.0 ms.
On systems where liboqs C library is absent, liboqs-python's auto-install attempts git clone and waits 5 seconds per call. Without caching, every make_fast_kem() call in the benchmark (50 handshake + 200 throughput iterations) triggered the wait. - Add module-level _LIBOQS_AVAILABLE flag in kem_backends.py; set once on first LibOQSXWingKem() construction, fast-path thereafter - Broaden except clauses to catch OSError (Windows temp-dir cleanup race that fires when oqs auto-install fails) - Update bench_noise_pq.py with the same OSError coverage - Commit measured benchmark results to benchmarks/results.md
|
Posting actual benchmark numbers from the bench_noise_pq.py suite (Python 3.13.1, Windows 11 Pro x64, in-memory connections, kyber-py baseline). KEM micro-benchmarks
Handshake latency (round-trip, in-memory)
Transport throughput (post-handshake)
A few notes on these numbers: The KEM round-trip accounts for 63% of total XXhfs handshake time (27.15 ms out of 42.96 ms). The remaining 15.81 ms is non-KEM overhead, which is notably higher than the 3.32 ms classical baseline. The extra cost comes from ChaCha20-Poly1305 encryption of the 1,120-byte KEM ciphertext, HKDF key derivation, and Ed25519 signing, all running in CPython without native acceleration. Using Amdahl's Law with f = 0.632 and a ~50x speedup from liboqs: So the liboqs backend is predicted to bring XXhfs down to roughly 16 ms, about 5x over the classical baseline rather than matching it. Still a substantial win (12.9x to 5x), and transport throughput is already on par with classical since both paths use the same ChaCha20-Poly1305 cipher state after the handshake. The |
- Fix asyncio.Task -> asyncio.Task[None] in kem_backends.py (mypy type-arg) - Break overlong assert line in test_noise_state.py (E501) - Apply ruff-format to test_vectors_pq, test_transport_pq, test_kem, test_noise_state, and scripts/interop_dial to match CI formatter version
AI PR Review — #1310PR: feat(security): Noise XXhfs post-quantum handshake for py-libp2p (research/WIP) 1. Summary of ChangesThis PR adds exploratory support for a post-quantum Noise handshake under protocol ID New modules (all under
Supporting additions:
Related context (not issues):
Breaking changes: None. This is additive and opt-in via a new protocol ID. Author intent: Explicitly marked as draft/research — "Nothing here is intended for merge right now." 2. Branch Sync Status and Merge ConflictsBranch Sync Status
Merge Conflict Analysis✅ No merge conflicts detected. The PR branch merges cleanly into 3. Strengths
4. Issues FoundCritical
dependencies = [
...
"pynacl>=1.3.0",
# kyber-py is MISSING — required by libp2p/security/noise/pq/kem.py
...
]
[project.optional-dependencies]
pq-fast = [
"liboqs-python>=0.12.0",
"PyNaCl>=1.5.0", # already a core dep
]
_VECTORS_PATH = (
Path(__file__).parents[4].parent # PQC-Research/
/ "js-libp2p-noise"
/ "test"
/ "fixtures"
/ "pqc-test-vectors.json"
)
Major
from .transport_pq import PROTOCOL_ID, TransportPQ
from .kem_backends import KeypairPool, LibOQSXWingKem, make_fast_kem
Minor
5. Security ReviewOverall: The cryptographic design appears sound for a research implementation. No critical vulnerabilities identified in the handshake logic itself.
Items to monitor:
Security Impact: Low (for draft/research scope) 6. Documentation and Examples
Recommendation: For eventual merge, add a short guide under 7. Newsfragment Requirement
8. Tests and ValidationLinting (
|
| Check | Result |
|---|---|
| yaml, toml, whitespace, pyupgrade | ✅ Passed |
| ruff + ruff format | ✅ Passed |
| mdformat | ✅ Passed |
| mypy | ✅ Passed |
| pyrefly | ❌ Failed (exit code 1) |
| Cross-platform path audit | ✅ Passed |
pyrefly errors (33 shown):
- 3×
[import-error]—kyber_py.ml_kemnot found (2 files),oqsnot found (1 file) - 1×
[bad-argument-type]—kem_backends.py:290run_in_executorbound method - 3×
[implicitly-defined-attribute]—test_kem.pysetup_methodattributes - 26×
[bad-argument-type]— test mock connections not typed asIRawConnection
Overall lint: ❌ Failed due to pyrefly
Type Checking (make typecheck)
- mypy: ✅ Passed
- pyrefly: ❌ Failed (same 33 errors as above)
Test Execution (make test)
| Metric | Value |
|---|---|
| Passed | 2795 |
| Skipped | 16 |
| Errors | 5 (all PQ test collection — ModuleNotFoundError: kyber_py) |
| Failed | 0 |
| Duration | ~106 s |
PQ tests (with kyber-py manually installed):
| Metric | Value |
|---|---|
| Passed | 45 |
| Skipped | 47 (vector tests — fixture file not present) |
| Failed | 0 |
| Duration | ~0.5 s |
Key observation: The 47 skipped vector tests are the PR's primary interop proof. They do not run in default CI or clean checkouts.
Documentation Build (make linux-docs)
❌ Failed (warnings treated as errors)
Errors:
patterns_pq.pymodule docstring:Unexpected indentation(lines 10–11)patterns_pq.pyhandshake_outbounddocstring:Unexpected indentation(line 6)libp2p.security.noise.pq.rst:document isn't included in any toctree
CI Status (GitHub Actions)
| Job | Result |
|---|---|
| tox core (3.10–3.13) | ✅ Pass |
| tox interop, demos, utils, wheel | ✅ Pass |
| tox lint (3.10–3.13) | ❌ Fail |
| tox docs (3.10) | ❌ Fail |
| windows core (3.11) | ❌ Fail |
| Read the Docs | ✅ Pass |
9. Recommendations for Improvement
- Declare
kyber-pyinpyproject.toml(test group at minimum; considerpqoptional extra for runtime). - Vendor test vectors into the repo and fix
_VECTORS_PATH— this is the highest-value test asset. - Add PQ tests to CI via a dedicated tox env with
kyber-pyinstalled. - Fix Sphinx docstrings in
patterns_pq.pyto unblock docs build. - Lazy-load heavy imports in
pq/__init__.pyto decouplenoise_statefrom KEM dependencies. - Open tracking issue when spec discussion allows (per @acul71 guidance); link in PR and add newsfragment.
- Wire or document
KeypairPool— either integrate into handshake or mark as experimental. - Deduplicate
_xwing_combineinto a shared module. - Add pyrefly stubs for
kyber_pyandoqs(or# pyrefly: ignorewith justification) to unblock lint CI. - Add user documentation for
/noise-pq/1.0.0setup before marking PR ready for review.
10. Questions for the Author
- Was the decision to use
mix_key()rather thanmix_key_and_hash()for theekem1token verified against the latest Noise HFS spec draft and js-libp2p-noise#665? The unusedmix_key_and_hash()method suggests possible spec ambiguity. - Can the cross-implementation vector file be committed to this repo (or fetched as a test fixture submodule) so CI can run the 47 vector assertions?
- Is
KeypairPoolintended to be integrated intoPatternXXhfsbefore merge, or kept as optional infrastructure for callers to wire manually? - Should
kyber-pybe a hard dependency, or only pulled in via an optionalpqextra to keep the default install lean? - What is the plan for protocol ID alignment if noise-pq: add Noise_XXhfs_25519+MLKEM768_ChaChaPoly_SHA256 spec (Stage 1 Working Draft) specs#716 settles on a different string than
/noise-pq/1.0.0? - Has Go interop been attempted or planned, as mentioned in the PR body's merge criteria?
11. Overall Assessment
| Criterion | Rating |
|---|---|
| Quality Rating | Good (for research/exploratory draft) |
| Security Impact | Low |
| Merge Readiness | Not ready (by author intent + project blockers) |
| Confidence | High |
Summary: This is high-quality exploratory work that makes a concrete, reviewable contribution to the libp2p PQC handshake discussion. The modular design, pluggable KEM backends, layered tests, and live JS interop are strong foundations. The author correctly labels it as draft/WIP, and maintainer guidance to wait for spec stabilization is appropriate.
For merge readiness, the blockers are primarily process and infrastructure rather than cryptographic correctness: missing issue/newsfragment (expected for now), undeclared kyber-py dependency, non-vendored test vectors silently skipped in CI, PQ tests excluded from tox, and docs/lint CI failures. None of these diminish the research value of the PR, but all must be addressed before it can graduate from draft to mergeable.
Recommended next step: Continue using this PR as a feedback vehicle. Address dependency/CI/vector vendoring when the author is ready to move toward merge, coordinated with libp2p/specs#716 and a tracking issue.
Copies pqc-test-vectors.json from js-libp2p-noise into tests/fixtures/ so the 47 vector assertions run in CI on every clean checkout instead of silently skipping due to a missing sibling-repo path. Updates _VECTORS_PATH in test_vectors_pq.py from the external PQC-Research/js-libp2p-noise path to the repo-relative tests/fixtures/pqc-test-vectors.json. Addresses review finding #3 (critical) from PR libp2p#1310.
Adds kyber-py>=0.9.0 to: - [dependency-groups.test]: ensures tox -e pq and uv install always pulls it in, stopping the 5x ModuleNotFoundError: kyber_py in CI - [project.optional-dependencies] as a new pq extra so end-users can pip install libp2p[pq] for the pure-Python KEM backend Addresses review finding #1 (critical) from PR libp2p#1310.
Adds py{310,311,312,313}-pq to the tox envlist and a pq: command line
that runs tests/security/noise/pq with a 120s timeout.
Also adds pq to the Ubuntu CI matrix in tox.yml so the 92 PQ tests
(including the 47 cross-implementation vector assertions) run on every
pull request. Windows CI is intentionally excluded since liboqs is not
required and the pure-Python path is covered by Ubuntu.
Addresses review finding #4 (critical) from PR libp2p#1310.
- Convert indented pseudo-code blocks in patterns_pq module docstring to RST literal-block syntax (::) so Sphinx parses them correctly - Rewrite handshake_outbound docstring: keep Args/Raises entries single-line (napoleon not configured; multi-line continuations inside block-quote Args: sections trigger Unexpected indentation in docutils) - Move the remote_peer=None explanation into the body paragraph - Create docs/libp2p.security.noise.pq.rst with automodule entries for all five pq submodules - Add libp2p.security.noise.pq to the Subpackages toctree in libp2p.security.noise.rst (fixes document not in toctree warning) Sphinx dummy build: 0 warnings, 0 errors.
kem.py: remove module-level `from kyber_py.ml_kem import ML_KEM_768`. Add XWingKem.__init__ that defers the import to instantiation time and raises ImportError with a clear install hint if kyber-py is absent. Size constants (XWING_PK_SIZE etc.) and the IKem Protocol are all compile-time values that need no kyber-py, so they remain at module level. pq/__init__.py: replace eager imports with a PEP 562 module __getattr__ that defers transport_pq and kem_backends imports until a name is first accessed. globals() caching ensures the second access is free. Effect: `import libp2p.security.noise.pq` and `import libp2p.security.noise.pq.noise_state` now succeed without kyber-py installed. kyber-py is only required the moment XWingKem() is instantiated (i.e., when an actual PQ handshake is initiated). Tests: 92 passed, 0 failed.
_xwing_combine() and _XWING_LABEL were defined independently in both kem.py and kem_backends.py with identical logic. A divergence would silently break cross-backend interoperability, since both XWingKem and LibOQSXWingKem must produce the same shared secret for the same inputs. Extract to libp2p/security/noise/pq/_xwing.py (leading underscore = private to the pq package). Both modules now import from this single source of truth. Remove hashlib from kem.py and kem_backends.py since it was only needed for the combiner. Tests: 92 passed, 0 failed.
mix_key_and_hash (3-output HKDF) is defined for psk tokens per the Noise spec section 5.2, not for KEM tokens. The ekem1 token in the XXhfs pattern uses mix_key (2-output HKDF), identical to how DH tokens (ee, es, se) are processed. Added explanatory note to the mix_key_and_hash docstring and inline comments at both ekem1 call sites to prevent reviewer confusion.
- kem_backends.py: add # type: ignore[arg-type/union-attr] to oqs KeyEncapsulation context manager calls (oqs stubs TypeVar limitation); add # type: ignore[arg-type] to run_in_executor Protocol method call - test_kem.py: add kem: XWingKem class-level annotation to suppress implicitly-defined-attribute errors from setup_method assignment - test_patterns_pq.py / test_transport_pq.py: make _MemoryConn and _WriteCapture inherit from IRawConnection; add is_initiator class attr; fix get_remote_address / get_transport_addresses / get_connection_type return type annotations; import Multiaddr, IRawConnection, ConnectionType
…security warning to handshake_outbound - pyproject.toml: drop PyNaCl>=1.5.0 from [pq-fast] extra; PyNaCl is already a core dependency (pynacl>=1.3.0) so listing it again in the optional extra was redundant and misleading - patterns_pq.py: add Sphinx '.. warning::' directive to handshake_outbound docstring explaining that remote_peer=None disables peer-identity binding (signature is still verified; peer-ID check is not)
examples/pq_noise/pq_demo.py starts a listener and a dialer in the
same process, connects them over loopback TCP using TransportPQ as
the only security transport, and verifies a round-trip message after
the PQ Noise handshake completes.
Demonstrates: new_host(sec_opt={PROTOCOL_ID: TransportPQ(...)}) wiring,
multistream /noise-pq/1.0.0 negotiation, and application-layer data
flow through the X-Wing encrypted channel.
|
Thanks for the thorough review @acul71 this is exactly the kind of structured feedback that turns a draft into something mergeable. I've gone through every recommendation and question. Here's a full accounting of what's been addressed. Response to Section 9: Recommendations1. Declare 2. Vendor test vectors ✅ 3. Add PQ tests to CI ✅ 4. Fix Sphinx docstrings ✅ 5. Lazy-load in 6. Open tracking issue ⏳ Pending spec stabilisation (per @acul71's guidance, acknowledged). 7. Wire or document 8. Deduplicate 9. Add pyrefly stubs / suppress with justification ✅
10. Add user documentation ✅ Response to Section 10: QuestionsQ1. Yes, verified. Noise spec §5.2 specifies that Q2. Can the cross-implementation vector file be committed? Done see recommendation 2 above. The vectors are committed as Q3. Is No. it stays caller-wired. The handshake is correct and complete without it; Q4. Should Optional extra, but required for the test suite. The install footprint of Q5. Protocol ID alignment with libp2p/specs#716?
Q6. Go interop planned? Not yet attempted programmatically. The cross-language static test vectors (same vectors that now run in CI) cover byte-level correctness for the handshake transcript, key schedule, and cipher state, which is the meaningful interop claim. Dynamic Go interop would require a matching Additional: live runtime integration testBeyond unit tests, I ran a live in-process node test: two Output (kyber-py backend, Windows 11): The 7-second figure includes the Current CI status post-push: tox |
- ruff: fix import ordering (I001) and line length (E501) in pq_demo.py - docs: add examples.pq_noise to toctree so sphinx-apidoc output is linked - fixtures: add missing trailing newline to pqc-test-vectors.json
AI PR Review — #1310 (v1)PR: feat(security): Noise XXhfs post-quantum handshake for py-libp2p (research/WIP) 1. Summary of ChangesThis PR adds exploratory support for a post-quantum Noise handshake under protocol ID New modules (all under
Supporting additions:
Related context (not issues):
Breaking changes: None. Additive and opt-in via a new protocol ID. Author intent: Explicitly marked as draft/research — "Nothing here is intended for merge right now." Since review v0: The author addressed most infrastructure feedback from @acul71's posted review (dependencies, vectors, CI, docs, lazy imports, deduplication). 2. Branch Sync Status and Merge ConflictsBranch Sync Status
Merge Conflict Analysis✅ No merge conflicts detected. The PR branch merges cleanly into 3. Strengths
4. Issues FoundCritical
Major
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(),
)
Minor
5. Security ReviewOverall: Cryptographic design appears sound for a research implementation. No new vulnerabilities identified beyond items already noted in v0.
Items to monitor:
Security Impact: Low (for draft/research scope) 6. Documentation and Examples
7. Newsfragment Requirement
8. Tests and ValidationValidation was run on branch Linting (
|
| Check | Result |
|---|---|
| yaml, toml, whitespace, pyupgrade | ✅ Passed |
| ruff + ruff format | ✅ Passed |
| mdformat | ✅ Passed |
| mypy | ✅ Passed |
| pyrefly | ✅ Passed (with liboqs-python installed) |
| Cross-platform path audit | ✅ Passed |
Overall lint: ✅ Passed
Type Checking (make typecheck)
- mypy: ✅ Passed
- pyrefly: ✅ Passed
Test Execution (make test)
| Metric | Value |
|---|---|
| Passed | 2887 |
| Skipped | 16 |
| Failed | 0 |
| Errored | 0 |
| Duration | ~116 s |
PQ tests are included in the full tests/ tree when kyber-py is installed (via dev test dependency group).
PQ Test Suite (pytest tests/security/noise/pq/)
| Metric | Value |
|---|---|
| Passed | 92 |
| Skipped | 0 |
| Failed | 0 |
| Duration | ~0.7 s |
All 47 cross-implementation vector assertions pass against vendored fixtures.
Documentation Build (make linux-docs)
✅ Passed locally — 106 source files, 0 Sphinx warnings/errors
CI Status (GitHub Actions, latest push)
| Job | Result |
|---|---|
| tox pq (3.10–3.13) | ✅ Pass |
| tox core, interop, demos, utils, wheel (3.10–3.13) | ✅ Pass |
| tox docs (3.10) | ✅ Pass |
| tox lint (3.10–3.13) | liboqs-python or stubs/oqs/ for pyrefly on minimal installs |
| windows core/demos/utils/wheel | ✅ Pass |
| Read the Docs | ❌ Fail (build 33042886 — may be unrelated to PQ changes; tox docs passed) |
9. Recommendations for Improvement
- Open tracking issue + newsfragment when @acul71 signals spec readiness (per maintainer guidance).
- Revisit default KEM selection — consider
XWingKem()as transport default to avoid liboqs probe latency in dev/demo paths. - Decouple vector tests from private
kem.pysymbols — import from_xwing.py/ public constants. - Add user-facing setup guide before marking PR ready — short section in docs covering
pip install libp2p[pq], host wiring, and interop pointers. - Optional: add
stubs/oqs/__init__.pyiso pyrefly passes on minimal dev installs withoutpq-fast.
Resolved since v0 (no further action)
- ✅
kyber-pydeclared inpqextra andtestdependency group - ✅ Test vectors vendored at
tests/fixtures/pqc-test-vectors.json - ✅
toxpqenv in CI - ✅ Sphinx docstring fixes
- ✅ PEP 562 lazy imports in
pq/__init__.py - ✅
_xwing_combinededuplicated to_xwing.py - ✅
mix_key()vsmix_key_and_hash()documented forekem1 - ✅ Test doubles implement
IRawConnection - ✅ Redundant PyNaCl removed from
pq-fast - ✅
remote_peer=Nonesecurity warning added
10. Questions for the Author
- Is the ~5 s liboqs probe on first
TransportPQhandshake acceptable for the default path, or shouldXWingKem()be the default with liboqs as explicit opt-in? - Has Read the Docs build 33042886 been investigated? tox
docspasses; the RTD failure may be environmental but should be confirmed before merge. - When noise-pq: add Noise_XXhfs_25519+MLKEM768_ChaChaPoly_SHA256 spec (Stage 1 Working Draft) specs#716 settles on a protocol ID, will you coordinate the one-line
PROTOCOL_IDchange with js-libp2p-noise and go implementations?
11. Overall Assessment
| Criterion | Rating |
|---|---|
| Quality Rating | Good (improved since v0) |
| Security Impact | Low |
| Merge Readiness | Not ready (draft by author intent + process blockers) |
| Confidence | High |
Summary: Substantial progress since review v0. The author systematically addressed maintainer feedback on dependencies, vectors, CI coverage, documentation, and code structure. Cryptographic correctness is well evidenced by 92 passing tests including byte-level JS interop vectors and live TCP interop. Lint, typecheck, tests, and docs all pass with standard dev dependencies plus the optional pq-fast extra where needed for pyrefly. Process blockers (tracking issue, newsfragment) remain appropriately deferred per @acul71's guidance until the libp2p PQC spec discussion matures.
Recommended next step: Continue using this PR as a feedback vehicle until specs and a tracking issue are ready.
- docs/examples.rst: remove examples.pq_noise from toctree; the RST is generated by sphinx-apidoc at build time but never committed, so RTD's HTML builder can't find it and fails with fail_on_warning=true - kem_backends.py:93: add # type: ignore[import-error] on `import oqs`; pyrefly treats optional C-extension imports inside try/except as errors when liboqs-python is not installed in the lint venv
sphinx-apidoc generates docs/examples.pq_noise.rst for the new pq demo package, but the file is not committed. On local tox-docs runs Sphinx finds the generated RST as an orphan (not in any toctree) and warns, which fails fail_on_warning=true. Adding it to exclude_patterns silences the orphan warning for both local and CI builds.
|
Hi @acul71 Thanks for the thorough v1 review addressing the open items inline. CI is now fully green (as of commit 4a9460f): Answers to the questions: Q1 liboqs probe latency as default: Agreed. I'll change transport_pq.py to default to XWingKem() and document make_fast_kem() / pq-fast as an explicit opt-in for production. The 5-second probe on first call in the default path is bad ergonomics and the benchmark analysis in this thread already shows liboqs matters more on Python than JS anyway, that deserves to be a deliberate choice, not an automatic one. Q2 RTD build 33042886: Investigated and fixed. The failure was specific to this PR's changes, not an environmental fluke. Q3 Protocol ID coordination: Yes, the plan is to treat PROTOCOL_ID = "/noise-pq/1.0.0" as a single constant to sync across py-libp2p, js-libp2p-noise (#665), and any go implementation once libp2p/specs#716 stabilises on an identifier. The constant is isolated to one line in transport_pq.py so the change is a one-liner when specs are ready. On the remaining major items: Vector test decoupling (import _xwing_combine from _xwing directly, surface size constants publicly) and the user-facing setup guide are the next two I'll address on this branch. Newsfragment and tracking issue remain deferred per your earlier guidance. |
Speaks Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256 directly using kyber-py ML_KEM_768 (no liboqs required). Verified live interop with the royzah/rust-libp2p feat/noise-mlkem-hfs listener binary: msg1=1216B, msg2=1304B, msg3=168B, mutual auth handshake complete.
… mypy-clean send_greeting/read_greeting took an untyped session, and both harnesses read SecureSession.remote_peer, which ISecureConn does not declare. Annotate the helpers with ISecureConn and use get_remote_peer().
…of raising QueueFull The accept callback did put_nowait on a maxsize=1 queue, so a second connection arriving before server.close() raised QueueFull from inside the asyncio callback, outside the ERROR contract. Resolve a future with the first connection and close any later one.
…68 vectors The Python fixture pinned only the wire bytes, so a change to split() or to the final handshake hash went undetected. The generator now records h and the two keys split() passes to CipherState (field names handshake_hash, cs1_k, cs2_k, as in the JS fixture), and the replay test compares them. Existing fields in the regenerated fixture are unchanged.
…ed medians The overhead row is the median of paired per-iteration ratios, which need not equal xxhfs_ms / xx_ms. State that directly under the row in both the console output and results.md.
Rewrite each em dash in comments, docstrings, a log message and a test assertion message as a colon, semicolon, comma or a new sentence. The vector generator's description strings now use a colon and a semicolon. tests/fixtures/mlkem768-xxhfs-vectors.json was regenerated with `python scripts/gen_pq_test_vectors.py`; only the top-level description and the five vector descriptions changed. Every key, seed, message, hash and cipher-key field is byte-identical. The benchmark template now prints 'n/a' for its not-applicable cells. In benchmarks/results.md, the '—' empty-cell placeholder in captured output was replaced with 'n/a'; no values changed.
ChainSafe/js-libp2p-noise#665, libp2p/py-libp2p#1310 and libp2p/rust-libp2p#6481 are open draft pull requests (gh pr view --json isDraft,state), matching the existing Nim row.
|
Pushed the protocol rename, the id bump, library-based interop harnesses and a benchmark refresh. This is a breaking change on the wire. Rename. Id. Vectors. Interop, 48/48. Negative controls. A TypeScript build with only the old name fails against this branch in both roles: Correction. The June 2026 interop in the description was handshake-only. Benchmarks. |
No file changes. The windows (3.12, core) job failed on tests/core/transport/webrtc/test_webrtc_direct_loopback.py::test_harness_retries_when_udp_collides_after_tcp with WinError 10013 while binding a listener on the runner. That test is unrelated to this branch, which touches no webrtc files, and the same core suite passed on Windows 3.11 and 3.13 in the same run.
libp2p/specs#727 (Stage 1A Working Draft, by royzah) is the spec for Noise_XXhfs_25519+MLKEM768_ChaChaPoly_SHA256. The kem.py docstring cited the tracking issue libp2p/specs#723 in a way that read as if the spec endorsed /noise-mlkem768-hfs/0.2.0; it does not. libp2p#727 writes 0.1.0 and lists the identifier string as its first open issue, so both docstrings now state 0.2.0 as what this implementation ships. No behaviour change.
The XXhfs parser extracted every field with a bare Python slice and never checked a single message length. Python slices do not raise, so a short message silently yielded a short field. PyNaCl's crypto_scalarmult performs no length validation of its own either: it hands both operands straight to libsodium, which unconditionally reads 32 bytes from each. A remote peer could therefore drive an out-of-bounds read in native code (audit F-001), and a truncated message surfaced as nacl.exceptions.RuntimeError, cryptography.exceptions.InvalidTag or a bare ValueError crossing the ISecureTransport boundary (audit F-004). Two attacker-controlled paths reached the sink. Message A is entirely pre-authentication. Message C is worse in kind: the static key length there is chosen after a successful AEAD decryption, so a peer that completes messages A and B honestly can forge a valid 20-byte ciphertext whose plaintext is only 4 bytes and still reach crypto_scalarmult. Changes: - Add HandshakeMalformed(NoiseFailure), matching how classical libp2p/security/noise/patterns.py reports handshake failures. - Check the total length of messages A, B and C before parsing: at least the fixed-token size, and no more than that plus a 4096-byte payload ceiling, so the unauthenticated trailing blob mixed into the transcript hash is bounded well below the 65535-byte frame limit. - Replace every bare slice with _take(), which slices exactly or raises. - Route every X25519 exchange through _dh(), which validates both operand lengths before calling crypto_scalarmult and rejects an all-zero shared secret per RFC 7748 section 6.1 (audit F-005). PyNaCl already refuses the degenerate result, but as an untyped nacl.exceptions.RuntimeError. - Parse the peer static key into an X25519PublicKey before the se exchange rather than after, matching the initiator's ordering. - Wrap both handshake entry points so no backend exception escapes as itself; py-libp2p's own BaseLibp2pError subclasses pass through unchanged and the original cause is preserved via "raise from". Tests first: 21 new cases in tests/security/noise/pq/test_handshake_validation.py drive truncated, oversized and zero-length messages A, B and C at both roles, plus the specific 20-byte-s-in-message-C case and the all-zero DH output. 20 of them fail against the unfixed parser with exactly the backend exceptions the audit describes; all 21 pass after the fix, and the valid handshake still completes. Connection scaffolding shared with test_patterns_pq.py moved to tests/security/noise/pq/helpers.py. PQ suite: 56 -> 77 tests, all passing.
Three hardening fixes to the Python interop tooling, from the harness audit. Greeting cap (harness F-001). read_greeting() looped until it saw a newline with no bound on how much it would buffer first, so a peer that completes the handshake and then streams newline-free data grows the buffer until the process is OOM-killed. Completing the handshake is not a barrier: XXhfs proves the peer holds some libp2p identity key, not that it is friendly. Cap the accumulated buffer at 1 KB and raise "truncated greeting", matching the Nim harness, which reads exactly one framed message and raises the same way if that frame has no newline. Run deadline (harness F-002). The dial and listen scripts waited forever at three separate points: for a connection, for the handshake, and for the greeting. A peer that connects and then sends nothing pinned the process and its port indefinitely. Both scripts now run under an overall 120 s deadline via with_deadline(), which names the phase that ran out of time. 120 s is comfortably longer than the 60 s the matrix runner allows a dialer, so a slow but healthy run is never failed by this bound. The stdout contract is unchanged: a timeout surfaces through the existing harness boundary as an ERROR line and exit 1, so the runner's greps still work. Generator RNG restore (harness F-007). gen_pq_test_vectors.py assigned nacl.utils.random before the try whose finally restores it, so a failure between the assignment and the guard would have left the process-wide RNG seeded with the committed test seeds. Move the assignment inside the try. The regenerated fixture is byte-identical (sha256 65eb88c5e67e09b213565dd644947adf04f1247d8c283c5788441913f3081001), confirming the move changed no behaviour. Tests first: 5 new cases in tests/security/noise/pq/test_interop_io.py load the harness helper by path and cover the flooding peer, a normal greeting, the deadline firing, a result passing through, and the deadline being at least as generous as the runner's. 4 of the 5 fail before the fix (the flooding case only terminates because the fake peer refuses to be read more than 100 times). Self-pair interop smoke run on port 9505 prints INTEROP_OK on both sides with cross-matching PEER and LOCAL.
The pq-fast extra declared liboqs-python, but nothing in the tree imports liboqs and make_fast_kem() always returns the kyber-py MLKEM768Kem. The extra arrived with 8cbb102 ("add liboqs C backend and make it the default KEM"); 14575cf removed that backend when the suite moved to raw ML-KEM-768 and left the extra behind. Installing it therefore pulled a native dependency that no code path could reach, which is a supply-chain surface for no benefit and a promise the package does not keep (supply-chain SC-002/SC-003). Removing it is the smaller honest option. Documenting it instead would still leave a working install command that installs an unused native library, and the pq extra (kyber-py) already covers what the suite needs. A comment in pyproject.toml records why it went and that make_fast_kem() is still a factory, so a native backend can be added later without touching call sites. No liboqs backend is implemented here. Also corrects examples/pq_noise/pq_demo.py, the one user-facing file that still described the removed backend: it told readers a liboqs-python auto-installer runs once per process and falls back to kyber-py after a 7 s countdown. Neither happens. The comment now says plainly that the KEM is pure-Python kyber-py and that its roughly 20 ms keygen dominates the handshake timing the demo prints. Checked for other claims: kem_backends.py and benchmarks/bench_noise_pq.py already say only kyber-py is wired up, and no doc or README promises a native backend.
MLKEM768NativeKem wraps cryptography's hazmat ML-KEM, which is roughly 35x faster than kyber-py for a keygen plus encapsulate plus decapsulate cycle on this machine, and make_fast_kem() now prefers it. kyber-py stays as the pure-Python fallback, chosen when cryptography predates 48.0.0 or was built without ML-KEM support, so the suite still runs where the native path does not exist. The two backends interoperate: only the 1184-byte encapsulation key and the 1088-byte ciphertext go on the wire and those are identical, so a peer on either backend can talk to a peer on the other. tests/security/noise/pq/ test_kem_native.py pins that in both directions, plus a fixed ciphertext that both decapsulate to the same pinned secret, and the deterministic implicit rejection value FIPS 203 produces for a corrupt ciphertext. Two asymmetries are deliberate and pinned by tests: * cryptography's encapsulate() returns (shared_secret, ciphertext), the reverse of the IKem contract, so encapsulate() swaps it. Transposing the pair would put a 1088-byte ciphertext where a 32-byte key belongs and would only surface as an opaque failure at the far end, so TestEncapsulateReturnOrder fails on every way of getting it wrong. * The native secret key is the 64-byte FIPS 203 seed (d || z), because cryptography exposes no API for the 2400-byte expanded decapsulation key. The secret key never leaves the process, so this does not affect the wire, but a secret key is not portable between backends. make_fast_kem() probes the backend once at construction so a build of cryptography without ML-KEM falls back immediately instead of failing mid-handshake. The two tests that asserted make_fast_kem() returns kyber-py now assert the behaviour that matters, an ML-KEM-768 KEM with the right wire sizes that round trips, since either backend is correct there.
17 modules already imported cryptography (TLS, QUIC, WebRTC, WebTransport, RSA and X25519 keys) but it was never declared, arriving transitively through aioquic. The native ML-KEM-768 backend makes that latent packaging bug worse, because it needs a version floor that a transitive dependency cannot promise. 48.0.0 is the floor, established from the upstream CHANGELOG rather than from what happens to be installed. hazmat.primitives.asymmetric.mlkem was added in 47.0.0 (2026-04-24) but only with AWS-LC or BoringSSL underneath, which the published wheels are not built with. 48.0.0 (2026-05-04) added OpenSSL 3.5.0+ support, which is the first release where a wheel user can actually run ML-KEM. Below that floor make_fast_kem() falls back to kyber-py, so the floor is about honest declaration rather than about breaking anyone. kyber-py stays in the pq extra as the pure-Python fallback and as the second implementation the cross-backend parity tests check against.
The runtime default is now the native backend, so the generator's choice needs a reason rather than an absence of alternatives. Keygen would port cleanly. MLKEM768PrivateKey.from_seed_bytes(d || z) was checked against ML_KEM_768.key_derive(d || z) on the same 64-byte seed and gives a byte-identical 1184-byte encapsulation key, which is now pinned by tests/security/noise/pq/test_kem_native.py. Encapsulation would not. cryptography's encapsulate() takes no randomness argument and has no deterministic equivalent of FIPS 203 _encaps_internal, so the encapsulation randomness cannot be fixed and the vectors could not be reproduced. Generation therefore stays on kyber-py. The committed fixture regenerates byte-identical after this change (sha256 65eb88c5e67e09b213565dd644947adf04f1247d8c283c5788441913f3081001).
bench_kem_backends() benchmarked only kyber-py and said in its docstring that a native backend would slot in here. It now benchmarks both and skips the native one on the same condition make_fast_kem() falls back on, so the comparison table reports a real speedup column instead of a lone 1.0x row. bench_kem() calls make_fast_kem() rather than constructing kyber-py directly, so the headline micro-benchmark describes the backend a handshake would actually use. Measured here over four paired passes, 50 handshakes each: KEM round trip (encap + decap): 0.42 to 0.52 ms native, 14.2 to 15.8 ms kyber-py. XXhfs handshake: 3.26 to 3.55 ms native, 21.8 to 22.7 ms kyber-py, against a classical XX baseline of 1.9 to 2.1 ms. Handshake overhead versus classical XX: 1.7x on all four native passes, 10.7x to 10.9x on the kyber-py passes. benchmarks/results.md is left at its 2026-09-17 content; regenerating the published numbers is a separate step.
make_fast_kem() prefers the native backend, so once it landed every end-to-end PQ test resolved to it and kyber-py kept only unit-level coverage. Nothing exercised two peers on different backends either, which is the property the interop story actually rests on. helpers.make_pattern() now takes an optional kem, helpers exports KEM_BACKENDS and make_kem(), and the full-handshake and wire-format classes are parametrised over both backends, skipping the native one where this build of cryptography cannot run ML-KEM. Adds a mixed-backend handshake in both directions (native initiator against a kyber-py responder and the reverse), which asserts a round trip in each direction so the transport keys have to match, not just the handshake to complete. Verified it has teeth by corrupting the native ciphertext and watching it fail. Also adds a responder-side case for a message A of the correct length whose 1184-byte e1 is not a valid encapsulation key: the backend's own ValueError must surface as HandshakeMalformed rather than crossing the ISecureTransport boundary as itself.
transport_pq.get_pattern() called make_fast_kem() on every connection, and make_fast_kem() probed cryptography for ML-KEM support by generating a throwaway keypair. That measured 297 us per call against 107 us for a real encapsulation, all of it spent before the peer had authenticated anything. Three changes: * The selection is memoised in _select_kem_class(), since it is a property of the build and not of the connection, and TransportPQ resolves it in __init__ and reuses one IKem for every handshake. An IKem holds no per-handshake state, and a host with no working backend now fails at construction rather than on its first dial. TransportPQ also takes an explicit kem, which is what lets the transport tests cover both backends. * The support probe parses an all-zero encapsulation key instead of generating a keypair. cryptography gates generate(), from_seed_bytes() and from_public_bytes() on the same internal mlkem_supported() check, and parsing is the cheapest of the three and allocates no key material. mlkem_supported() itself is cheaper still but lives on the hazmat OpenSSL backend object, which is not public API. make_fast_kem() now costs ~43 us instead of ~297 us, on top of being called once per transport instead of once per connection. * The selection guard catches Exception rather than ImportError, so an AttributeError from a renamed upstream symbol or an OpenSSL InternalError falls back instead of escaping raw from connection setup, and the both-backends-missing case raises a single ImportError naming both causes and pointing at libp2p[pq]. Falling back to kyber-py is now a warning rather than a DEBUG line. Its own package metadata says it is not constant time and must not be used for cryptographic applications, so the fallback changes this peer's security properties and should say so. Test-side, in the same files: the fail-closed expectations now match this wrapper's own wording instead of the bare sizes, which also appear in cryptography's messages and let those tests pass with our guards deleted (verified: 15 of 17 fail once the guards go). Adds a cross-backend parity case for correct-length but structurally invalid encapsulation keys, and extends implicit rejection from one flipped bit to eight kinds of corrupt ciphertext, requiring both backends to return the same rejection secret each time. Also documents that an IKem secret key is opaque and backend-specific, annotates MLKEM768_SK_SIZE as the kyber-py representation only, moves the cryptography.exceptions import to module scope, and notes that the KeypairPool rationale weakens under the native backend, whose keygen is around 0.3 ms rather than a few milliseconds.
A hard cryptography>=48.0.0 in the core dependencies makes every environment held below 47 by pyOpenSSL <= 25.3.0 unresolvable, and it raises the floor for every user who never touches the post-quantum transport. Only the native ML-KEM-768 backend needs 48, so that is where the requirement belongs. The core floor is now cryptography>=42.0.0, established from the code rather than guessed: the newest API the 17 core importers use unguarded is x509.Certificate.not_valid_before_utc / not_valid_after_utc, in libp2p/transport/quic/security.py and libp2p/transport/webtransport/certificate.py, added in cryptography 42.0.0. Nothing in core reaches for x509.verification, hazmat.decrepit or anything else newer. 42.0.0 is also exactly what aioquic declares, which already pulled cryptography in transitively, so this adds no new constraint in practice. The test dependency group gains cryptography>=48.0.0 alongside kyber-py, so CI exercises both backends instead of skipping the native half of the parity and mixed-backend tests.
The demo said the KEM is kyber-py and that its roughly 20 ms keygen dominates the printed handshake time. Both stopped being true when the native backend became the default: keygen there is around 0.3 ms, and which backend loads is now what the number mostly reports. In the benchmark, bench_kem() now records and prints which backend make_fast_kem() actually selected, in the console section and in the generated markdown, since the two differ by more than an order of magnitude on keygen and an unattributed number is not usable. Drops the unreachable "not available" branch in the comparison table, which could never fire because bench_kem_backends() omits a missing backend rather than storing None for it, replaces the None-as-sentinel default arguments in _bench_one_kem(), and widens its skip guard to Exception to match the selection path.
On Python 3.10 asyncio.TimeoutError is a distinct class from the builtin TimeoutError; they were unified only in 3.11. Catching the builtin alone let the raw asyncio error escape, so the harness deadline reported the wrong type and lost the phase name. CI caught it on the 3.10 job while 3.12 and 3.13 passed, which is the signature of exactly this difference. Verified with pyrefly 0.17.1 run directly: zero errors under libp2p/security/noise/pq and tests/security/noise/pq.
Three CI failures, all introduced by the native backend work and all missed locally because the pyrefly hook was being skipped and the docs were never built. pyrefly: _select_kem_class returned type[IKem], but IKem is a Protocol and a protocol has no __init__, so that type promises a class nothing may construct; it now returns Callable[[], IKem], which is the contract both backends meet. The helpers read loop accumulated into bytes through a Sized-typed local, the cache-clearing fixture was not annotated as a Generator, and a counter in a closure needed rebinding rather than augmented assignment. Sphinx: three Args and Raises entries wrapped onto a continuation line indented deeper than the entry, which docutils reads as a block quote and reports as unexpected indentation. Warnings are errors in the docs build, so each is now a single line within the 88 column limit. Verified locally: pyrefly clean over both pq trees, ruff check and format clean, 162 tests pass, and sphinx-build -W succeeds.
|
Update on this branch, and the description above has been rewritten to match. The ML-KEM-768 backend is no longer pure Python. The performance claim this branch has carried is now a before and after rather than a
The Dependencies moved. A pre-authentication memory-safety fix. The XXhfs parser previously sliced every field Nothing on the wire changed, which is the other thing worth saying: same 1184-byte CI is green at Still research/WIP and still not proposed for merge. |
The audit excluded venv, site-packages and friends with patterns written using forward slashes, matched against str(Path). On Windows that string uses backslashes, so none of the exclusions matched and the audit walked the entire virtualenv: 63,201 findings, 6,631 of them P0/P1, which fails the pre-commit hook on every commit. Matching against a normalised forward-slash form fixes it and keeps every existing pattern as written. Two smaller Windows failures in the same script: The report prints non-ASCII status characters. A Windows console defaults to a legacy codepage, so the first print raised UnicodeEncodeError and took the hook down before any scanning happened. stdout is now reconfigured to UTF-8 when the stream supports it. build/ is setuptools output, gitignored and untracked, but the audit still walked it and reported 9 P0/P1 issues from generated copies of source files. It is excluded now.
multistream-select chooses the connection encrypter in plaintext, before
any handshake, so an on-path attacker can strip a proposal or forge an
`na` and push two peers onto a weaker protocol. Both sides complete a
valid, mutually authenticated session and neither can tell.
Each peer now optionally states its configured security protocols inside
the encrypted handshake payload, bound to the Noise transcript hash, and
both sides recompute what the negotiation should have produced:
expected = the first protocol the dialer offered that the listener also
supports
A mismatch raises SecurityProtocolDowngrade. Off by default, enabled per
transport with `transcript_binding`.
Both bindings from the design are implemented. The extension variant adds
a separate transcript_sig field and stays wire compatible with peers that
do not implement it. The identity variant folds the binding into
identity_sig, which is one signature rather than two but cannot connect
to a peer that does not implement it, in any mode, so it needs its own
protocol identifier. Tests pin that incompatibility rather than leaving
it to be discovered.
NoiseExtensions gains security_protocols (field 4) and transcript_sig
(field 5). early_data keeps field 3, so no existing wire format changes.
Regenerated with protoc 6.32.1 to match the committed gencode version.
tests/fixtures/transcript-binding-vectors.json is shared with the
TypeScript implementation, which generated it. Verifying signatures it
produced, rather than round-tripping our own, is what proves the two
agree on the bytes; a wire-format drift fails there instead of in the
field.
XXhfs owns its SymmetricState so it reads `h` directly. The classical XX
path runs on the noiseprotocol package, whose write_message encrypts the
payload in the same call that runs the tokens, so the payload cannot be
built after `h` is final without a scoped hook on encrypt_and_hash. The
hook refuses to nest, removes itself with dict.pop so it cannot mask an
in-flight exception, and raises if it never fired rather than silently
shipping the empty placeholder as the payload.
Building the signed data from a remote protocol list is attacker
reachable, since the canonical encoding rejects a list longer than
MAX_PROTOCOLS by raising. Under the identity variant that ValueError
escaped a function documented to return bool; it is now caught and the
signature rejected, matching what the extension variant already did.
Mirrors the split made on the TypeScript side. The two variants were selectable on both transports, which made one of the four combinations unusable: the identity variant on /noise. It widens what identity_sig covers, and /noise is an identifier every libp2p implementation already answers to, so such a peer negotiates /noise successfully and then fails signature verification against all of them, in any mode. Offering it there is offering a network partition behind a flag, so Transport now refuses it at construction and says where it is available instead. TransportPQ gains IDENTITY_BOUND_PROTOCOL_ID, /noise-mlkem768-hfs/0.3.0, and a protocol_id attribute derived from the configuration by the new protocol_id_for(). The identifier is the mechanism rather than bookkeeping: a peer verifying a different message must not answer to the identifier used by peers that do not, or multistream-select pairs them and the difference surfaces as a signature failure instead of as no protocol in common. Because a caller builds TranscriptBindingConfig here rather than the transport deriving it, the two can disagree. The downgrade check compares the negotiated protocol against actual_protocol, so a config naming an identifier this transport does not advertise would compare against the wrong thing and either miss a downgrade or invent one. TransportPQ now rejects that at construction. 0.3.0 derives from the 0.2.0 this implementation ships and follows whatever libp2p/specs#727 settles on for the base identifier.
The identity variant moves the protocol identifier, so both constants now live in every implementation as a string literal. A typo in one of them fails no implementation's own tests: the two peers simply find no protocol in common, which reads as a configuration problem rather than a code one, and only in the field. The fixture already carried the signature prefixes and the protobuf field numbers for that reason, so the identifiers go in beside them. Verified by changing 0.3.0 to 0.3.1 here, which fails with the exact mismatch rather than passing quietly.
The harnesses could only run with the mechanism off, so nothing had ever shown two implementations completing a handshake with it on. Adds --transcript-binding off|extension|identity and --simulate-downgrade, and prints which mode and identifier a run used so a log can be read without guessing. The offered protocol lists live in scripts/_interop_binding.py rather than in each script, because the check compares what one peer says it offered against what the other concluded: if the dialer and listener disagreed about the list, the check would fire and look exactly like a detected downgrade. The other implementations' harnesses have to match these lists, so they are written out rather than derived. --simulate-downgrade offers a protocol neither side runs but both claim to prefer, so each concludes the other should have negotiated it. That is the negative control, and it is the more important half: without it a passing run cannot be distinguished from a run where the check never fired. Verified live, both directions and both variants: Python to TypeScript and TypeScript to Python complete under the flag, and both refuse under --simulate-downgrade with SecurityProtocolDowngrade naming the protocol that should have been negotiated.
|
Updated the description with a 2026-09-25 section. Two things, one of which corrects what I wrote there on the 22nd. Each binding variant now only exists where it can work. The identity variant is refused at construction on The interop harnesses have a flag now, so the gate I listed as open is closed: 48/48 with the mechanism off, 48/48 with the extension variant where only JS and Python are bound and Nim and Rust ran unbound, 12/12 for the identity variant, and 0/4 passing in each simulated-downgrade control, which is the pass condition there. Runs and logs are in pq-noise-artifacts/interop. The extension result is the one I would point at: a bound peer completed with three implementations that know nothing about the mechanism. That claim was previously an argument about protobuf semantics. |
Noise XXhfs post-quantum handshake for py-libp2p (research/WIP)
Implements
Noise_XXhfs_25519+MLKEM768_ChaChaPoly_SHA256, the hybrid Noise handshake combining X25519 (classical) with ML-KEM-768 (post-quantum) in the KEM slot, as proposed in libp2p/specs#723. The wire format is specified in libp2p/specs#727, a Stage 1A Working Draft by @royzah. My own draft, libp2p/specs#716, was closed on 2026-09-18 in favour of #727 so that one document rather than two carries the proposal; its text stays readable at https://github.com/paschal533/specs/tree/master/noise-pq, and the material it covered (test vectors, the interoperability matrix, wire sizes and the downgrade analysis) was offered to #727.Status: Research/WIP, not ready for merge, intended to demonstrate the integration point and validate cross-language interop.
Breaking change (2026-09-17): the suite was renamed from
Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256, because Noise (revision 34, §8.2) allows only alphanumerics and/in algorithm names. The name is hashed into the handshake hash, so old-name and new-name peers cannot complete a handshake, and the protocol id moved from/noise-mlkem768-hfs/0.1.0to/noise-mlkem768-hfs/0.2.0. Message sizes are unchanged./noise-mlkem768-hfs/0.2.0is the id this branch ships, not a spec-endorsed one: #727 writes/noise-mlkem768-hfs/0.1.0and lists the identifier string as the first of its open issues, so this will follow whatever #727 settles on.Update, 2026-09-19: a C-backed ML-KEM-768 backend is now the default
The branch previously had one KEM backend,
kyber-py, which is pure Python. It is no longer the default.MLKEM768NativeKemimplements the sameIKemcontract overcryptography.hazmat.primitives.asymmetric.mlkem, which reaches ML-KEM-768 in C through OpenSSL 3.5+, AWS-LC or BoringSSL depending on howcryptographywas built.make_fast_kem()selects it whenever it can be constructed, and falls back tokyber-pyotherwise. Taking the fallback logs a warning that names the cause and says plainly thatkyber-py's own package metadata states it is not constant time and must not be used for cryptographic applications.TransportPQconstruction, so a host with no working backend fails at construction rather than on its first connection.kyber-pystays in thepqextra as the pure-Python fallback and as the second implementation the cross-backend parity tests check against.Two asymmetries between the backends are deliberate and pinned by tests.
cryptography'sencapsulate()returns(shared_secret, ciphertext)while theIKemcontract is(ciphertext, shared_secret), so the adapter swaps the pair; transposing it would put a 1088-byte ciphertext where a 32-byte key belongs, so there is an explicit guard for it. Andcryptographyexposes only the 64-byte FIPS 203 seed (d || z) as the private key, wherekyber-pyexposes the 2400-byte expanded decapsulation key. The secret key never goes on the wire, so onlypk(1184 B) andct(1088 B) are interoperability surfaces, and both are identical under the two backends.Nothing on the wire changes. Both backends produce a 1184-byte encapsulation key, a 1088-byte ciphertext and a 32-byte shared secret; a ciphertext from either decapsulates to the same shared secret under the other; and the deterministic vector fixture is byte-identical before and after. The vector generator deliberately stays on
kyber-py, which is recorded in the script header.Performance
Measured as alternating paired arms of one session, four passes of thirty iterations, each pass interleaving the classical and hybrid handshakes and alternating which runs first. Python 3.13.14,
cryptography50.0.1, Windows 11 Pro x64, in-memorytriochannel pairs, this branch atc8d16e63:Noise_XXNoise_XXhfskyber-py(pure Python)MLKEM768NativeKem(C-backed)Ranges are the two pass medians for that arm; the overhead is the median of the per-iteration paired ratios within each pass. The
kyber-pyarm is the control, and it matters more than the headline: it reproduces the 10.7x and the ~91% published from a session nine days earlier, which is what makes this a before and after of one change rather than two benchmarks on two days. An earlier run of the same change on the same machine, with a slightly faster classical baseline, put the ratio nearer 1.7x; the two sessions bracket it between roughly 1.4x and 1.7x, and what does not move is the order of magnitude.Standalone KEM microbenchmarks are unstable in both absolute terms and ratio, and should be read with more caution than the handshake figures above. Across four measurement sessions the C-backed-to-
kyber-pyratio on an encapsulate-plus-decapsulate round trip came out at 33x, 37x, 38x and 43x, whilekyber-py's own absolute round trip swung by roughly a factor of two within a single day: about 16.6 ms in the block adjacent to the paired passes against 34.8 ms in a later block (0.45 ms and 0.91 ms C-backed respectively). Read it as one to two orders of magnitude, not as a figure. An earlier one-off probe reported 47.8x; it does not reproduce and is withdrawn.The contrast with the handshake table is methodological rather than incidental. The handshake ratios are paired: every iteration measures the classical and hybrid handshakes next to each other and the ratio is formed within the pass, so drift is common mode and largely cancels, which is why they hold to 1.42x to 1.44x and 10.76x to 10.87x. The KEM microbenchmarks are not paired against anything, so each one absorbs whatever state the machine was in. An earlier version of this description claimed the KEM ratio was stable while the absolutes moved; that was inferred from the two blocks that happen to agree at 37x and 38x, and the 33x and 43x sessions show it does not hold, so the claim is withdrawn. All of these are performance measurements only and say nothing about side-channel resistance.
All figures are from one machine. There is no second-hardware measurement, and
benchmarks/bench_noise_pq.pynow records and prints which backendmake_fast_kem()actually selected, because the two differ by more than an order of magnitude and an unattributed number is not usable.Dependency changes
cryptography>=42.0.0. It was previously undeclared and arrived transitively throughaioquic. 42.0.0 is established from the code rather than guessed: the newest API the core importers use unguarded isx509.Certificate.not_valid_before_utc/not_valid_after_utc, inlibp2p/transport/quic/security.pyandlibp2p/transport/webtransport/certificate.py, added in 42.0.0. It is also exactly whataioquicalready declares, so it adds no new constraint in practice.pqextra is["cryptography>=48.0.0", "kyber-py>=0.9.0"]. The 48.0.0 floor is where the native backend's requirement belongs rather than in core, becausepyOpenSSL <= 25.3.0capscryptographybelow 47 and a hard core floor of 48 would make those environments unresolvable.cryptography>=48.0.0alongsidekyber-py, so CI exercises both backends instead of skipping the native half of the parity and mixed-backend tests.pq-fastextra is removed. It installedliboqs-python, which nothing in the branch ever selected.Security hardening, 2026-09-18 and 2026-09-19
A white-box audit of this branch found issues the 48-run interop matrix did not catch, because interop tests exercise well-formed peers.
crypto_scalarmultdoes no length validation either: it hands both operands to libsodium, which unconditionally reads 32 bytes from each. A remote peer could therefore drive an out-of-bounds read in native code. Two attacker-controlled paths reached it. Message A is entirely pre-authentication. Message C is worse in kind, because the static key length there is chosen after a successful AEAD decryption, so a peer that completes A and B honestly can forge a valid 20-byte ciphertext whose plaintext is only 4 bytes and still reachcrypto_scalarmult. Messages A, B and C are now length-checked before parsing, against both the fixed-token minimum and a maximum of that plus a 4096-byte payload ceiling, which bounds the unauthenticated trailing blob mixed into the transcript hash well below the 65535-byte frame limit.HandshakeMalformed(NoiseFailure)is raised for these rejections, matching how classicallibp2p/security/noise/patterns.pyreports handshake failures. Previously a truncated message surfaced asnacl.exceptions.RuntimeError,cryptography.exceptions.InvalidTagor a bareValueErrorcrossing theISecureTransportboundary.To be clear about what is not done: this is a research branch and has had no third-party security audit. The findings above were found by reading the code, not by fuzzing, and there is no fuzz harness in the branch.
Update, 2026-09-22: transcript-bound security protocol negotiation
multistream-select chooses the connection encrypter in plaintext, before any handshake runs, so an on-path attacker can strip a proposal or forge an
naand push two peers that both prefer the hybrid suite onto classical/noise. Both sides then complete a valid, mutually authenticated session and neither can tell. The interop matrix above does not cover this, because those harnesses start the handshake directly on TCP.This branch now carries a defence for it, off by default and enabled per transport with
transcript_binding. Each peer states the security protocols it has configured inside the encrypted handshake payload, bound to the Noise transcript hash, and both peers recompute what the negotiation should have produced:The listener catches a stripped proposal, the dialer catches a forged
na, and the selected protocol never goes on the wire, because each peer already knows which one it is running. A mismatch raisesSecurityProtocolDowngrade(NoiseFailure). Both Noise transports have it, classical and XXhfs.Two bindings are implemented. The
extensionvariant adds a separatetranscript_sigfield and stays wire compatible with peers that do not implement it. Theidentityvariant folds the binding intoidentity_sig, which is one signature rather than two.identityis not incrementally deployable, and not only in the sense of missing a feature. The protocol list is insideidentity_sig, so a peer that sends no list signed a different message and the handshake fails during signature verification, before any mode is consulted: warn mode does not soften it. A node configured that way cannot connect to a stock libp2p peer at all, nor to a peer usingextension. Falling back to the unbound form on failure would let an attacker strip the binding at will, so the incompatibility is deliberate, and it is pinned by tests rather than left to be discovered. Deployingidentitymeans giving it its own protocol identifier.Why the extra signature is kept, although it is redundant against the attacker it was written for. The handshake payload is AEAD-encrypted with
has associated data, andidentity_sigbinds the peer's static key to its identity key, so a list carried inside that payload already cannot be altered, reordered, replayed across sessions or spliced between the classical and hybrid suites; the protocol name seedshand therefore the whole key schedule. Against the multistream-select attacker,transcript_sigadds nothing.The reason to keep it is that
identity_sigis session-independent. It coversSIGNED_DATA_PREFIXfollowed by the Noise static public key and nothing else: no transcript, no nonce. It is a replayable statement about a long-lived key, so the authentication of everything in the payload rests on possession of the X25519 static key rather than the identity key. A transcript-bound signature is the only element in the handshake that shows the identity key is live in this session, which is what an ML-DSA identity key would need in order to make the hybrid suite post-quantum authenticated rather than post-quantum confidential only.Where the transcript hash comes from, and why the two paths differ.
PatternXXhfsowns itsSymmetricState, so it readsss.hdirectly at the point the payload is encrypted or decrypted. The classical path runs on the third-partynoiseprotocolpackage, whosewrite_messageruns the tokens and encrypts the payload in one call, so the payload cannot be built afterhis final without a scoped hook onencrypt_and_hash. The hook is installed only when binding is enabled, refuses to nest, removes itself withdict.popso it cannot mask an in-flight exception from afinallyblock, and raises if it never fired rather than silently shipping the empty placeholder as the payload. The alternative is owning an XX state machine the waypq/noise_state.pydoes, which is a much larger change; the asymmetry is deliberate and commented.A bug found in review, before this was committed. Under the
identityvariant, building the signed data from the remote peer's protocol list was attacker reachable: the canonical encoding rejects a list longer thanMAX_PROTOCOLSby raising, and that call sat outside thetry, so a peer sending 33 short protocol strings madeverify_handshake_payload_sigraiseValueErrorout of a function documented to returnbool. A caller catchingNoiseFailureto reject a peer and keep serving would have seen an unclassified exception instead. It is now caught and the signature rejected, which is what theextensionvariant already did for the same input, and a test covers both variants.Wire format.
NoiseExtensionsgainssecurity_protocols(field 4) andtranscript_sig(field 5).early_datakeeps field 3, so no existing wire format changes. Fields 4 and 5 rather than 3 and 4 because field 3 was already taken here, and renumbering a field this implementation already ships would break its own wire format for no gain. Regenerated with protoc 6.32.1, matching the committed gencode version.Cross-implementation vectors. Two implementations can agree on every word of the design and still fail to interoperate over one byte: a different canonical encoding, a different signature prefix, or different protobuf field numbers. Each side's own tests pass and the handshake fails in the field.
tests/fixtures/transcript-binding-vectors.jsonis generated by the TypeScript implementation in ChainSafe/js-libp2p-noise PR #665, andtests/security/noise/test_transcript_vectors.pyverifies signatures it produced rather than round-tripping our own, which is what proves the two agree on the bytes. Two of the five vectors are['ab', 'c']and['a', 'bc'], which collide under naive concatenation, so an implementation that drops the length prefixes passes its own round-trip tests and fails this file.What it does not do. It cannot see a downgrade out of Noise entirely. A real node's offered set may include
/tls/1.0.0, and an attacker who strips every Noise proposal forces TLS, where no Noise handshake runs and nothing checks anything. That is the attacker's best move, and covering it needs the check in the shared upgrader, where the full offered set lives, rather than inside one secure transport. It also cannot see an attacker who strips the extension itself, since that is indistinguishable from talking to an older peer. And it protects a session only when both peers implement it. (Superseded: see the 2026-09-25 update below, which closes the cross-language interop gate this sentence described as open.)_PatternXXWithCerthashesin the WebTransport session reimplementshandshake_outboundinline and does not route through the new helpers, so the binding is neither sent nor checked there. Nothing passes it a config today, so it is not live, but it is a fail-open by construction and is the next thing to fix on this path.Unrelated fix in the same branch.
scripts/audit_paths.pyexcludedvenv,site-packagesand friends with patterns written using forward slashes, matched againststr(Path). On Windows that string uses backslashes, so none of the exclusions matched and the audit walked the entire virtualenv: 63,201 findings, 6,631 of them P0 or P1, which fails the pre-commit hook on every commit. It now matches against a normalised forward-slash form, reconfigures stdout to UTF-8 so the non-ASCII status characters do not raise on a legacy Windows codepage, and excludesbuild/, which is setuptools output and gitignored.Tests. 92 added.
tests/core/securitywithtests/security/noisecollects 419, of which 418 pass; the one failure is a pre-existinglru_cacheinteraction intest_kem_native.pythat predates this change and is unrelated to it.tests/core/security/noise/test_transcript_binding.pytests/security/noise/test_transcript_vectors.pytests/security/noise/pq/test_transcript_binding_pq.pyUpdate, 2026-09-25: the two binding variants are deployed separately, and the interop gate is met
Two changes since the section above, one of which corrects it.
Each variant now only exists where it can work. Both were selectable on both encrypters, which made one of the four combinations unusable: the identity variant on
/noise. It widens whatidentity_sigcovers, and/noiseis an identifier every libp2p implementation already answers to, so such a peer negotiates/noisesuccessfully and then fails signature verification against all of them, in any mode. Offering that behind a flag is offering a network partition behind a flag, so it is refused at construction now, with a message saying where the variant is available instead.The extension variant stays on
/noise, since it is the only one that can ship incrementally. The identity variant lives on the hybrid suite and moves the protocol identifier to/noise-mlkem768-hfs/0.3.0. The identifier is the mechanism rather than bookkeeping: a peer verifying a different message must not answer to the identifier used by peers that do not, or multistream-select pairs them and the difference surfaces as a signature error instead of as no protocol in common.0.3.0derives from the0.2.0this branch ships and follows whatever #727 settles on for the base.Because that identifier is now a string literal in two implementations, the cross-implementation vector file records both identifiers alongside the signature prefixes and field numbers, and each implementation asserts its own constants against it. A typo there fails no implementation's own tests: it shows up in the field as two peers with no protocol in common. Verified by changing
0.3.0to0.3.1on one side and watching it fail with the exact mismatch.The interop harnesses now have a flag, so the gate the section above said was open is closed.
--transcript-binding off|extension|identityand--simulate-downgrade, driven from the matrix runner byTRANSCRIPT_BINDINGandSIMULATE_DOWNGRADE. Four runs, all in pq-noise-artifacts:20260925T123204Z20260925T124411Z20260925T125139Znegative-controls/C-downgrade-extensionnegative-controls/C-downgrade-identityThe off run is the regression check: an off run invokes exactly what it always did, so 48 of 48 says this feature changed nothing for the four implementations.
The extension run is the one worth reading. Only JS and Python are bound; the Nim and Rust harnesses have no flag and ran unbound. That is the experiment rather than a gap: a peer that binds its handshake completed with three implementations that know nothing about the mechanism, because an older peer ignores the unknown extension fields. Incremental deployability was previously an argument about protobuf semantics and is now a measured outcome.
The identity run is restricted to JS and Python, and the runner refuses Nim or Rust by name for that mode, because a bound peer cannot talk to an unbound one at all there. Producing 48 failures instead would read as a protocol bug rather than a configuration error.
The negative controls are the half that makes the rest mean anything, and 0 of 4 passing is the pass condition. Both peers offer a protocol neither runs but both claim to prefer, so each concludes the other should have negotiated it. Every run fails naming
/noise-interop-phantom/1.0.0as what should have been negotiated, on both sides and in both languages.That control is not ceremony. The first version of the harness flag reported success in every mode while exercising nothing, because the encrypter takes
(components, init)and the option had been placed incomponents, where it is silently ignored. Same-implementation runs passed because both sides were equally unbound. Only the cross-language run exposed it.Correction to the section above. It said a live cross-language handshake with the flag on was not done, and that the agreement so far was on the bytes rather than on a connection. That is no longer true, in either direction and in both variants.
Still not covered, unchanged: a downgrade out of Noise entirely, to
/tls, is invisible to this, and it needs the check in the shared upgrader rather than inside one encrypter. The matrix harnesses start the handshake directly on TCP, so they exercise the binding but not multistream-select negotiation itself;experiments/downgrade-demois what covers the negotiation path against real libp2p nodes.What this adds
libp2p/security/noise/pq/kem.py:MLKEM768Kem(kyber-py) andMLKEM768NativeKem(cryptography, C-backed)libp2p/security/noise/pq/patterns_pq.py:PatternXXhfshandshake state machine, with length validation andHandshakeMalformedlibp2p/security/noise/pq/noise_state.py:NoiseStateXXhfs(HKDF chain, cipher state)libp2p/security/noise/pq/transport_pq.py:TransportPQISecureTransport, protocol ID/noise-mlkem768-hfs/0.2.0, KEM resolved once at constructionlibp2p/security/noise/pq/kem_backends.py:make_fast_kem()factory (C-backed by default,kyber-pyfallback behind a warning) andKeypairPooltests/security/noise/pq/: 162 tests, includingtest_kem_native.py(57) for the C-backed backend and cross-backend interoperability, andtest_vectors_pq.py, which replaystests/fixtures/mlkem768-xxhfs-vectors.jsonand checks the three handshake messages, the handshake hash and bothsplit()cipher keysscripts/interop_dial_mlkem768.py: TCP dialer harness drivingPatternXXhfsscripts/interop_listen_mlkem768.py: TCP listener harness drivingPatternXXhfsbenchmarks/bench_noise_pq.py: classical vs hybrid handshake benchmark, sampled in interleaved pairs, reporting both KEM backends and naming the one selectedTest count. 162 at
c8d16e63, against 56 when this description was first written. The increase is almost entirely the backend work and the hardening: 57 tests forMLKEM768NativeKemitself, the parametrisation of the pattern and transport suites so the full handshake runs end to end under each backend in turn, and 25 tests for malformed, truncated and oversized handshake messages.test_kem.pytest_kem_native.pytest_noise_state.pytest_patterns_pq.pytest_transport_pq.pytest_vectors_pq.pytest_handshake_validation.pytest_interop_io.pyCross-language interop
On 2026-09-19 a neutral runner drove four implementations: this branch, js-libp2p-noise PR #665 (TypeScript), nim-libp2p PR #2811 (Nim), and rust-libp2p PR #6481 by @royzah (Rust) with the harness from royzah/rust-libp2p PR #1. Every ordered listener/dialer pairing, each implementation against itself included, ran three times with this branch at
c8d16e63: 48 runs, 48 passed.This is the first matrix run with the C-backed ML-KEM-768 backend on the Python side, so it is also the evidence that switching backend changed nothing on the wire. None of the 96 logs carries the fallback warning that backend selection emits when it drops to
kyber-py, which is the positive evidence that the C-backed path was the one under test.A run passes only if both sides exit cleanly, each side reports the other's actual peer id, and one encrypted greeting goes each way, which exercises both
split()cipher states. All runs were over loopback TCP on one Windows 11 machine. The harnesses start the handshake directly on TCP, so multistream-select negotiation of the protocol id is not covered. Results,versions.txtand all 96 logs: pq-noise-artifacts, run 20260919T223056Z. Negative controls (an old-name build that fails against every other implementation, and a fabricated identity that the cross-check catches): interop/negative-controls.Earlier results, corrected. An earlier version of this description showed a June 2026 table (Rust listener + Python dialer, Rust listener + JS dialer, Python listener + JS dialer, all PASS) on
/noise-mlkem768-hfs/0.1.0with the hyphenated name, run byscripts/interop_all.sh. Those runs were handshake-only: the script counted a pair as passing when the dialer exited cleanly and printed a peer id, and no transport frames were exchanged. The Python dialer was a standalone re-implementation of the handshake, notPatternXXhfs. The Rust listener was ours, from royzah/rust-libp2p#1, not part of #6481, and was built against a Junesnowthat still used the hyphenated name.interop_all.shis removed and the matrix above replaces that table. Details: artifacts README.Key design decision
X-Wing (which bundles X25519 + ML-KEM-768 as a single KEM) was considered but rejected: the Noise XXhfs pattern already provides classical security through its own DH tokens (ee, es, se), making the X25519 inside X-Wing redundant. Raw ML-KEM-768 in the
ekem1slot is the architecturally correct choice.KEM API note
Both backends return the shared secret first:
kyber-py'sML_KEM_768.encaps(pk)returns(ss, ct), andcryptography'sencapsulate()returns(shared_secret, ciphertext). Both adapters correct the order to(ct, ss)per theIKeminterface, and a test pins it.ML-KEM-768 sizes
kyber-py, 64 B seedcryptography(never on the wire)