Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions interop/noise-pq/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# NoiseHFS interop scripts

Standalone dial/listen scripts for `Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256`
(protocol id `/noise-mlkem768-hfs/0.1.0`), independent of the rest of the
nim-libp2p test suite. See `../../libp2p/protocols/secure/NOISE_HFS_SPEC.md`
for the wire format.

## Usage

```bash
nim c -r interop_listen.nim [port] # accepts one connection, then exits
nim c -r interop_dial.nim [port] # dials 127.0.0.1:port
```

Both print `HANDSHAKE_OK remotePeer=<peer id>` on success.

## Verified interop

**nim-libp2p <-> py-libp2p, 2026-07-11**

`interop_dial.nim` against py-libp2p's `scripts/interop_listen_mlkem768.py`
(libp2p/py-libp2p, branch `feat/pqc-noise-xxhfs`), both on the raw
ML-KEM-768 (not X-Wing) revision:

```
# py-libp2p side
READY 9999
Connection from 127.0.0.1:56421
PEER 12D3KooWLitocTge1Lm2TmS3THHfrMWfV3d6UJZpPZNweL3c6CFD

# nim-libp2p side
DIALING port 9999
HANDSHAKE_OK remotePeer=12D3KooWJGqi39m6ykyVhs8K1c1z8eeb4nFEqVxHLPopHdm8rV9h
```

Both sides completed the full three-message XXhfs handshake - X25519 DH,
ML-KEM-768 encapsulate/decapsulate, ChaCha20-Poly1305 AEAD, and Ed25519 peer
identity signature verification - with no changes needed to either
implementation's wire format. The differing peer ids above are expected:
each side reports the *other* side's freshly-generated identity, not its
own.

Note: the identity key used here is Ed25519, not the crypto module's
default ECDSA - as of this writing, py-libp2p's protobuf key-type
deserializer only implements Secp256k1, RSA, and Ed25519, so an ECDSA
identity key fails at the peer-identity-verification step with an unrelated
`MissingDeserializerError`, after the actual Noise/KEM handshake has already
succeeded. That's a py-libp2p key-type support gap, not a NoiseHFS wire
compatibility issue.

## Not yet run

- **Rust** (royzah/rust-libp2p PR #1): not attempted here - would require a
from-scratch rust-libp2p workspace build, which is out of scope for this
session. The crate should be a straightforward target for the same
dial/listen pattern once built.
- **JavaScript** (ChainSafe/js-libp2p-noise PR #665): not attempted here.
59 changes: 59 additions & 0 deletions interop/noise-pq/interop_dial.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# SPDX-License-Identifier: Apache-2.0 OR MIT
# Copyright (c) Status Research & Development GmbH

## Standalone interop dialer for NoiseHFS
## (`Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256`).
##
## Dials a peer speaking the same protocol and completes a real handshake
## over TCP, independent of the rest of the nim-libp2p test suite. Used to
## verify wire-format compatibility against other language implementations
## of the same profile (see NOISE_HFS_SPEC.md).
##
## Usage:
## nim c -r interop_dial.nim [port] (default 9998)
##
## Verified against py-libp2p's `scripts/interop_listen_mlkem768.py`
## (libp2p/py-libp2p, branch feat/pqc-noise-xxhfs) on 2026-07-11: dial ->
## handshake -> peer authentication all completed successfully on both
## sides, with no changes needed to either implementation's wire format.

import std/[os, strutils]
import chronos
import
../../libp2p/[
stream/connection,
transports/transport,
transports/tcptransport,
multiaddress,
peerinfo,
crypto/crypto,
crypto/rng,
protocols/secure/noisehfs,
upgrademngrs/upgrade,
]

proc main() {.async.} =
let port =
if paramCount() >= 1: parseInt(paramStr(1))
else: 9998

let
rng = newRng()
# Ed25519, not the crypto module's default ECDSA: several peer libp2p
# implementations (e.g. py-libp2p as of this writing) only implement
# protobuf key-type deserializers for a subset of libp2p's key types.
privKey = PrivateKey.random(Ed25519, rng).get()
noiseHFS = NoiseHFS.new(rng, privKey)
transport = TcpTransport.new(upgrade = Upgrade())
remoteMa = MultiAddress.init("/ip4/127.0.0.1/tcp/" & $port).get()

echo "DIALING port ", port
let conn = await transport.dial(remoteMa)
let sconn = await noiseHFS.secure(conn, Opt.none(PeerId))

echo "HANDSHAKE_OK remotePeer=", $sconn.peerId
await sconn.close()
await conn.close()
await transport.stop()

waitFor(main())
53 changes: 53 additions & 0 deletions interop/noise-pq/interop_listen.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# SPDX-License-Identifier: Apache-2.0 OR MIT
# Copyright (c) Status Research & Development GmbH

## Standalone interop listener for NoiseHFS
## (`Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256`).
##
## Accepts one connection, completes the handshake as responder, prints the
## remote peer id, and exits. Companion to interop_dial.nim - used to let
## another language's implementation dial into nim-libp2p to verify wire
## compatibility from the other direction.
##
## Usage:
## nim c -r interop_listen.nim [port] (default 9998)

import std/[os, strutils]
import chronos
import
../../libp2p/[
stream/connection,
transports/transport,
transports/tcptransport,
multiaddress,
peerinfo,
crypto/crypto,
crypto/rng,
protocols/secure/noisehfs,
upgrademngrs/upgrade,
]

proc main() {.async.} =
let port =
if paramCount() >= 1: parseInt(paramStr(1))
else: 9998

let
rng = newRng()
privKey = PrivateKey.random(Ed25519, rng).get()
noiseHFS = NoiseHFS.new(rng, privKey)
transport = TcpTransport.new(upgrade = Upgrade())
listenMa = MultiAddress.init("/ip4/127.0.0.1/tcp/" & $port).get()

await transport.start(@[listenMa])
echo "READY ", port

let conn = await transport.accept()
let sconn = await noiseHFS.secure(conn, Opt.none(PeerId))

echo "HANDSHAKE_OK remotePeer=", $sconn.peerId
await sconn.close()
await conn.close()
await transport.stop()

waitFor(main())
15 changes: 14 additions & 1 deletion libp2p/builders.nim
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import
crypto/crypto,
transports/[transport, tcptransport, wstransport, quictransport, memorytransport],
muxers/[muxer, mplex/mplex, yamux/yamux],
protocols/[identify, secure/secure, secure/noise, rendezvous, kademlia],
protocols/[identify, secure/secure, secure/noise, secure/noisehfs, rendezvous, kademlia],
protocols/connectivity/[
autonat/server,
autonatv2/server,
Expand Down Expand Up @@ -59,6 +59,11 @@ type

SecureProtocol* {.pure.} = enum
Noise
NoiseHFS
## Post-quantum hybrid Noise (`Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256`).
## Mount alongside `Noise` (the default) so hybrid-capable peers
## negotiate the quantum-resistant handshake while classical-only
## peers still fall back to `/noise` transparently.

KadInfo = object
config*: KadDHTConfig
Expand Down Expand Up @@ -209,6 +214,12 @@ proc withNoise*(b: SwitchBuilder): SwitchBuilder =
b.secureManagers.add(SecureProtocol.Noise)
b

proc withNoiseHFS*(b: SwitchBuilder): SwitchBuilder =
## Mount the post-quantum hybrid Noise handshake. Typically combined with
## `withNoise` so a hybrid-capable peer still accepts classical-only peers.
b.secureManagers.add(SecureProtocol.NoiseHFS)
b

proc withTransport*(b: SwitchBuilder, prov: TransportBuilder): SwitchBuilder =
## Use a custom transport
b.transports.add(prov)
Expand Down Expand Up @@ -446,6 +457,8 @@ proc buildSwitch(b: SwitchBuilder): Switch {.raises: [LPError].} =
var secureManagerInstances: seq[Secure]
if SecureProtocol.Noise in b.secureManagers:
secureManagerInstances.add(Noise.new(b.rng, seckey).Secure)
if SecureProtocol.NoiseHFS in b.secureManagers:
secureManagerInstances.add(NoiseHFS.new(b.rng, seckey).Secure)

let peerInfo = PeerInfo.new(
seckey,
Expand Down
119 changes: 119 additions & 0 deletions libp2p/crypto/mlkem768.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# SPDX-License-Identifier: Apache-2.0 OR MIT
# Copyright (c) Status Research & Development GmbH

## Raw ML-KEM-768 (FIPS 203) key encapsulation.
##
## This wraps the `MLKEM768_*` C API already vendored into nim-libp2p through
## its `boringssl` dependency (`crypto/mlkem/mlkem.cc`), rather than pulling in
## a separate, unaudited PQC library. BoringSSL's ML-KEM-768 implementation is
## the same one shipped in Chrome's TLS stack.
##
## https://csrc.nist.gov/pubs/fips/203/final

{.push raises: [].}

import boringssl
import results
import ./mlkem768layout
export results

const
MLKEM768PublicKeyLen* = 1184
## Encoded ML-KEM-768 encapsulation (public) key size, in bytes.
MLKEM768CiphertextLen* = 1088
## ML-KEM-768 ciphertext size, in bytes.
MLKEM768SharedSecretLen* = 32
## ML-KEM-768 shared secret size, in bytes.

type
MLKEM768PublicKeyBytes* = array[MLKEM768PublicKeyLen, byte]
MLKEM768CiphertextBytes* = array[MLKEM768CiphertextLen, byte]
MLKEM768SharedSecret* = array[MLKEM768SharedSecretLen, byte]

MLKEM768ParsedPublicKey = array[mlkem768PublicKeyOpaqueLen, byte]
MLKEM768PrivateKeyImpl = array[mlkem768PrivateKeyOpaqueLen, byte]

MLKEM768KeyPair* = object
publicKey*: MLKEM768PublicKeyBytes ## wire-ready encoded public key
privateKey: MLKEM768PrivateKeyImpl

MLKEM768EncapResult* = object
ciphertext*: MLKEM768CiphertextBytes
sharedSecret*: MLKEM768SharedSecret

MLKEM768Error* = enum
MLKEM768InvalidPublicKey
MLKEM768InvalidCiphertextLength

# Mirrors BoringSSL's `struct cbs_st` (a {data, len} byte-string view) from
# `include/openssl/bytestring.h`, so a view can be built without calling
# the header-only inline `CBS_init`.
CbsView {.pure, bycopy.} = object
data: ptr byte
len: csize_t

{.push cdecl.}
proc mlkem768GenerateKeyC(
outEncodedPublicKey: ptr byte, outSeed: pointer, outPrivateKey: ptr byte
) {.importc: "MLKEM768_generate_key".}

proc mlkem768ParsePublicKeyC(
outPublicKey: ptr byte, inCbs: ptr CbsView
): cint {.importc: "MLKEM768_parse_public_key".}

proc mlkem768EncapC(
outCiphertext: ptr byte, outSharedSecret: ptr byte, publicKey: ptr byte
) {.importc: "MLKEM768_encap".}

proc mlkem768DecapC(
outSharedSecret: ptr byte,
ciphertext: ptr byte,
ciphertextLen: csize_t,
privateKey: ptr byte,
): cint {.importc: "MLKEM768_decap".}

{.pop.} # cdecl

proc generateKeyPair*(): MLKEM768KeyPair =
## Generates a fresh ML-KEM-768 keypair. `result.publicKey` is the
## wire-format encapsulation key ready to be sent to a peer.
mlkem768GenerateKeyC(
addr result.publicKey[0], nil, addr result.privateKey[0]
)

proc encapsulate*(
remotePublicKey: openArray[byte]
): Result[MLKEM768EncapResult, MLKEM768Error] =
## Encapsulates a fresh shared secret for `remotePublicKey`, which must be
## the `MLKEM768PublicKeyLen`-byte encoded public key received from a peer.
if remotePublicKey.len != MLKEM768PublicKeyLen:
return err(MLKEM768InvalidPublicKey)

var cbs = CbsView(data: unsafeAddr remotePublicKey[0], len: remotePublicKey.len.csize_t)
var parsed: MLKEM768ParsedPublicKey
if mlkem768ParsePublicKeyC(addr parsed[0], addr cbs) != 1:
return err(MLKEM768InvalidPublicKey)

var res: MLKEM768EncapResult
mlkem768EncapC(addr res.ciphertext[0], addr res.sharedSecret[0], addr parsed[0])
ok(res)

proc decapsulate*(
ciphertext: openArray[byte], keyPair: MLKEM768KeyPair
): Result[MLKEM768SharedSecret, MLKEM768Error] =
## Recovers the shared secret from `ciphertext` using `keyPair`'s private
## key. Per FIPS 203 6.4 implicit rejection, a `ciphertext` of the correct
## length that was not produced for this key does not fail here - it
## silently yields a pseudorandom shared secret, so the handshake's AEAD
## authentication (not this call) is what detects tampering or a mismatched
## key. Only a wrong-length ciphertext is rejected outright.
if ciphertext.len != MLKEM768CiphertextLen:
return err(MLKEM768InvalidCiphertextLength)

var secret: MLKEM768SharedSecret
if mlkem768DecapC(
addr secret[0], unsafeAddr ciphertext[0], ciphertext.len.csize_t,
unsafeAddr keyPair.privateKey[0],
) != 1:
return err(MLKEM768InvalidCiphertextLength)
ok(secret)
36 changes: 36 additions & 0 deletions libp2p/crypto/mlkem768layout.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# SPDX-License-Identifier: Apache-2.0 OR MIT
# Copyright (c) Status Research & Development GmbH

## Opaque BoringSSL `MLKEM768_{public,private}_key` struct sizes, plus a
## compile-time guard that they still match BoringSSL's real layout.
##
## Sizes copied from BoringSSL's `include/openssl/mlkem.h`. These layouts
## are BoringSSL-internal and unstable across versions; they must never be
## serialized, only ever passed back into the MLKEM768_* C API by pointer.
##
## Kept in this standalone module rather than inline in `mlkem768.nim`: the
## guard below needs `#include <openssl/mlkem.h>`, which declares the real,
## strongly-typed `MLKEM768_*` function prototypes. `mlkem768.nim` declares
## its own loosely-typed (`byte*`) `importc` prototypes for those same
## functions; having both sets of declarations land in the same generated C
## file is a hard conflict for the C compiler. This module never calls into
## boringssl itself, so it never emits those prototypes and can safely
## include the real header just for `sizeof`.

const
mlkem768PublicKeyOpaqueLen* = 512 * (3 + 9) + 32 + 32 # 6208
mlkem768PrivateKeyOpaqueLen* = 512 * (3 + 3 + 9) + 32 + 32 + 32 # 7776

# Guards the sizes above against a future BoringSSL bump silently changing
# the opaque layouts: if the real C structs ever grow past our fixed-size
# buffers, this fails the C build instead of letting
# MLKEM768_generate_key/_encap/_decap write out of bounds.
const opaqueSizeAsserts =
"#include <openssl/mlkem.h>\n" &
"_Static_assert(sizeof(struct MLKEM768_public_key) == " &
$mlkem768PublicKeyOpaqueLen &
", \"BoringSSL MLKEM768_public_key size changed - update mlkem768PublicKeyOpaqueLen\");\n" &
"_Static_assert(sizeof(struct MLKEM768_private_key) == " &
$mlkem768PrivateKeyOpaqueLen &
", \"BoringSSL MLKEM768_private_key size changed - update mlkem768PrivateKeyOpaqueLen\");\n"
{.emit: opaqueSizeAsserts.}
Loading