diff --git a/interop/noise-pq/README.md b/interop/noise-pq/README.md new file mode 100644 index 0000000000..0d99f3c2c6 --- /dev/null +++ b/interop/noise-pq/README.md @@ -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=` 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. diff --git a/interop/noise-pq/interop_dial.nim b/interop/noise-pq/interop_dial.nim new file mode 100644 index 0000000000..91c483fec3 --- /dev/null +++ b/interop/noise-pq/interop_dial.nim @@ -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()) diff --git a/interop/noise-pq/interop_listen.nim b/interop/noise-pq/interop_listen.nim new file mode 100644 index 0000000000..52135b5937 --- /dev/null +++ b/interop/noise-pq/interop_listen.nim @@ -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()) diff --git a/libp2p/builders.nim b/libp2p/builders.nim index 86ece1ff3a..d78d596f86 100644 --- a/libp2p/builders.nim +++ b/libp2p/builders.nim @@ -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, @@ -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 @@ -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) @@ -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, diff --git a/libp2p/crypto/mlkem768.nim b/libp2p/crypto/mlkem768.nim new file mode 100644 index 0000000000..dc7990cb3e --- /dev/null +++ b/libp2p/crypto/mlkem768.nim @@ -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) diff --git a/libp2p/crypto/mlkem768layout.nim b/libp2p/crypto/mlkem768layout.nim new file mode 100644 index 0000000000..f5198a72e5 --- /dev/null +++ b/libp2p/crypto/mlkem768layout.nim @@ -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 `, 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 \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.} diff --git a/libp2p/protocols/secure/NOISE_HFS_SPEC.md b/libp2p/protocols/secure/NOISE_HFS_SPEC.md new file mode 100644 index 0000000000..3f9a4bbc32 --- /dev/null +++ b/libp2p/protocols/secure/NOISE_HFS_SPEC.md @@ -0,0 +1,159 @@ +# NoiseHFS: post-quantum hybrid Noise for nim-libp2p + +Status: experimental. Protocol identifier `/noise-mlkem768-hfs/0.1.0` is a +working identifier, not yet registered with libp2p-specs or IANA. + +## Motivation + +The classical `/noise` handshake (`Noise_XX_25519_ChaChaPoly_SHA256`) relies +on X25519, which is broken by Shor's algorithm on a sufficiently large +quantum computer. Traffic recorded today can be decrypted retroactively once +such hardware exists (store-now-decrypt-later). `NoiseHFS` adds a +post-quantum key encapsulation mechanism (KEM) alongside the existing X25519 +Diffie-Hellman exchange, so the session stays confidential even if either +component is later broken, without dropping backward compatibility: a +hybrid-capable node can mount both `NoiseHFS` and `Noise`, and multistream-select +negotiates the best protocol either side supports. + +## Algorithm suite + +`Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256` + +| Primitive | Choice | +|---|---| +| Classical DH | X25519 | +| PQC KEM | ML-KEM-768 (FIPS 203), raw - no composite/combiner wrapper | +| AEAD | ChaCha20-Poly1305 | +| Hash | SHA-256 | + +Raw ML-KEM-768 is used instead of a composite KEM (e.g. X-Wing) because the +XXhfs pattern's three DH tokens (`ee`, `es`, `se`) already provide classical +security; embedding a second X25519 operation inside the KEM slot would be +redundant. This mirrors the analysis in `NOISE-HFS` and in the reference +implementations this profile was designed to be wire-compatible with +(ChainSafe/js-libp2p-noise PR #665, libp2p/py-libp2p PR #1310, and +royzah/rust-libp2p PR #1 as of the June 2026 3-way interop test - see +"Interoperability status" below for the current state of that alignment). + +## Handshake pattern + +Applying the Noise HFS extension (`e1`/`ekem1` tokens) to the classical XX +pattern: + +``` +Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256: + + -> e, e1 + <- e, ee, ekem1, s, es + -> s, se +``` + +This keeps XX's three-message structure - no extra round trip versus a pure +post-quantum KEM pattern (e.g. PQNoise's `pqXX`, which needs four messages +because KEM is inherently asymmetric). + +| Token | Operation | +|---|---| +| `e` | Generate/send X25519 ephemeral public key | +| `e1` | Generate/send ML-KEM-768 ephemeral encapsulation key | +| `ee` | `MixKey(DH(e_local, e_remote))` | +| `ekem1` | Encapsulate to `re1`; `EncryptAndHash(ciphertext)` then `MixKey(sharedSecret)` | +| `s` | Encrypt and send X25519 static public key | +| `es` | `MixKey(DH(e, rs))` if initiator, `MixKey(DH(s, re))` if responder | +| `se` | `MixKey(DH(s, re))` if initiator, `MixKey(DH(e, rs))` if responder | + +### Wire format (empty `NoiseHandshakePayload`) + +``` +Message A (initiator -> responder): + [32 B] e.publicKey (plaintext) + [1184 B] e1.publicKey (ML-KEM-768 ek) (plaintext) + [0 B] payload + +Message B (responder -> initiator): + [32 B] e.publicKey (plaintext) + [1104 B] EncryptAndHash(ekem1 ciphertext) (1088 B ct + 16 B AEAD tag) + [48 B] EncryptAndHash(s.publicKey) (32 B key + 16 B AEAD tag) + [var] EncryptAndHash(payload) + +Message C (initiator -> responder): + [48 B] EncryptAndHash(s.publicKey) + [var] EncryptAndHash(payload) +``` + +Total overhead versus classical XX with an empty payload: 192 B -> 2,480 B +(+2,288 B), entirely in messages A and B. + +### `ekem1` token ordering + +The responder's `ekem1` step must run in exactly this order: + +``` +1. (ciphertext, sharedSecret) = Encapsulate(re1) +2. ekem1Bytes = EncryptAndHash(ciphertext) # under the ee-derived key +3. MixKey(sharedSecret) # AFTER encrypting the ciphertext +``` + +and the initiator's read side mirrors it: + +``` +1. ciphertext = DecryptAndHash(ekem1Bytes) +2. sharedSecret = Decapsulate(ciphertext, e1.privateKey) +3. MixKey(sharedSecret) +``` + +Swapping steps 2 and 3 on either side produces divergent chaining keys and +breaks the handshake. The ordering ensures the responder's static key (`s`) +and Message B's payload are protected by a key derived from both `ee` and the +KEM shared secret, not `ee` alone. + +### ML-KEM-768 implicit rejection + +Per FIPS 203 6.4, `Decaps()` never fails, even for a ciphertext produced for +a different key - it returns a pseudorandom shared secret derived from an +implicit rejection value instead. A tampered or mismatched ciphertext +therefore does not raise at the KEM layer; the resulting divergent shared +secret causes every subsequent AEAD operation to fail authentication instead, +so the handshake still aborts. Because the ciphertext itself is AEAD-tagged +before `MixKey` runs, outright tampering is caught by that tag before +decapsulation is even attempted. + +## Implementation + +- `libp2p/crypto/mlkem768.nim` - raw ML-KEM-768 bound directly to the + `MLKEM768_*` C API already vendored into nim-libp2p through its + `boringssl` dependency (`crypto/mlkem/mlkem.cc`), rather than pulling in a + separate PQC library. This is the same ML-KEM-768 implementation shipped in + Chrome's TLS stack. +- `libp2p/protocols/secure/noisehfs.nim` - the `NoiseHFS` connection + encrypter. Reuses `noise.nim`'s `SymmetricState`/`CipherState`/`KeyPair` + and message framing unchanged (`readFrame`, `sendHSMessage`, `dh`, + `mixKey`, `mixHash`, `encryptAndHash`, `decryptAndHash`, `split`); only the + `e1`/`ekem1` token handling and the top-level connection encrypter are new. +- `libp2p/builders.nim` - `SecureProtocol.NoiseHFS` and + `SwitchBuilder.withNoiseHFS()`, so a switch can mount `NoiseHFS` alongside + the default `Noise` and let multistream-select negotiate per peer. + +## Interoperability status + +This profile's wire format was designed to match +`Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256` as published in "Post-Quantum +Cryptography Integration into the Noise Protocol" (Okwuosa, 2026), which +reports a 3-way interop test between TypeScript (ChainSafe/js-libp2p-noise PR +#665), Python (libp2p/py-libp2p PR #1310), and Rust (royzah/rust-libp2p PR +#1) all completing pairwise handshakes on raw ML-KEM-768. + +This nim-libp2p implementation has been verified standalone (KEM round-trip, +implicit-rejection behavior, and a full two-node TCP handshake with peer +authentication - see `tests/libp2p/protocols/test_noisehfs.nim`), and, as of +2026-07-11, **live cross-language interop against py-libp2p is confirmed**: +`interop/noise-pq/interop_dial.nim` against py-libp2p's +`scripts/interop_listen_mlkem768.py` (`feat/pqc-noise-xxhfs` branch, updated +to the raw ML-KEM-768 revision) completed the full three-message handshake +and mutual peer authentication with no changes needed to either +implementation's wire format. Details and the exact run output are in +`interop/noise-pq/README.md`. + +Rust (royzah/rust-libp2p PR #1) and JavaScript (ChainSafe/js-libp2p-noise PR +#665) interop have not been run yet - the same `interop/noise-pq/` scripts +should work against them once those toolchains are available. diff --git a/libp2p/protocols/secure/noise.nim b/libp2p/protocols/secure/noise.nim index cdfa4dfdd9..38612348b0 100644 --- a/libp2p/protocols/secure/noise.nim +++ b/libp2p/protocols/secure/noise.nim @@ -25,7 +25,7 @@ const # https://godoc.org/github.com/libp2p/go-libp2p-noise#pkg-constants NoiseCodec* = "/noise" - PayloadString = toBytes("noise-libp2p-static-key:") + PayloadString* = toBytes("noise-libp2p-static-key:") ProtocolXXName = "Noise_XX_25519_ChaChaPoly_SHA256" @@ -35,23 +35,26 @@ const NoiseSize = 32 MaxPlainSize = int(uint16.high - NoiseSize - ChaChaPolyTag.len) - HandshakeTimeout = 1.minutes + HandshakeTimeout* = 1.minutes type - KeyPair = object - privateKey: Curve25519Key - publicKey: Curve25519Key + # Exported so sibling handshake patterns (e.g. NoiseHFS in noisehfs.nim) can + # reuse the same DH keypair shape, cipher/symmetric state, and message + # framing without duplicating them. + KeyPair* = object + privateKey*: Curve25519Key + publicKey*: Curve25519Key # https://noiseprotocol.org/noise.html#the-cipherstate-object - CipherState = object - k: ChaChaPolyKey - n: uint64 + CipherState* = object + k*: ChaChaPolyKey + n*: uint64 # https://noiseprotocol.org/noise.html#the-symmetricstate-object - SymmetricState = object - cs: CipherState - ck: ChaChaPolyKey - h: MDigest[256] + SymmetricState* = object + cs*: CipherState + ck*: ChaChaPolyKey + h*: MDigest[256] # https://noiseprotocol.org/noise.html#the-handshakestate-object HandshakeState = object @@ -61,11 +64,11 @@ type rs: Curve25519Key re: Curve25519Key - HandshakeResult = object - cs1: CipherState - cs2: CipherState - remoteP2psecret: seq[byte] - rs: Curve25519Key + HandshakeResult* = object + cs1*: CipherState + cs2*: CipherState + remoteP2psecret*: seq[byte] + rs*: Curve25519Key Noise* = ref object of Secure rng: Rng @@ -76,8 +79,8 @@ type outgoing: bool NoiseConnection* = ref object of SecureConn - readCs: CipherState - writeCs: CipherState + readCs*: CipherState + writeCs*: CipherState NoiseError* = object of LPStreamError NoiseHandshakeError* = object of NoiseError @@ -105,11 +108,11 @@ func shortLog*(conn: NoiseConnection): auto = chronicles.formatIt(NoiseConnection): shortLog(it) -proc genKeyPair(rng: Rng): KeyPair = +proc genKeyPair*(rng: Rng): KeyPair = let privateKey = Curve25519Key.random(rng) KeyPair(privateKey: privateKey, publicKey: privateKey.public()) -proc hashProtocol(name: string): MDigest[256] = +proc hashProtocol*(name: string): MDigest[256] = # If protocol_name is less than or equal to HASHLEN bytes in length, # sets h to protocol_name with zero bytes appended to make HASHLEN bytes. # Otherwise sets h = HASH(protocol_name). @@ -121,14 +124,14 @@ proc hashProtocol(name: string): MDigest[256] = h = sha256.digest(name) h -proc dh(priv: Curve25519Key, pub: Curve25519Key): Curve25519Key = +proc dh*(priv: Curve25519Key, pub: Curve25519Key): Curve25519Key = var key = pub Curve25519.mul(key, priv) key # Cipherstate -proc hasKey(cs: CipherState): bool = +proc hasKey*(cs: CipherState): bool = cs.k != EmptyKey proc encrypt( @@ -181,18 +184,21 @@ proc decryptWithAd( # Symmetricstate -proc init(_: type[SymmetricState]): SymmetricState = - let h = ProtocolXXName.hashProtocol +proc init*(_: type[SymmetricState], protocolName: string = ProtocolXXName): SymmetricState = + ## `protocolName` defaults to the classical XX protocol name; other + ## handshake patterns (e.g. NoiseHFS) pass their own protocol name so the + ## initial handshake hash and chaining key derive from a distinct value. + let h = protocolName.hashProtocol SymmetricState(h: h, ck: h.data.intoChaChaPolyKey, cs: CipherState(k: EmptyKey)) -proc mixKey(ss: var SymmetricState, ikm: ChaChaPolyKey) = +proc mixKey*(ss: var SymmetricState, ikm: ChaChaPolyKey) = var temp_keys: array[2, ChaChaPolyKey] sha256.hkdf(ss.ck, ikm, [], temp_keys) ss.ck = temp_keys[0] ss.cs = CipherState(k: temp_keys[1]) trace "mixKey", key = ss.cs.k.shortLog -proc mixHash(ss: var SymmetricState, data: openArray[byte]) = +proc mixHash*(ss: var SymmetricState, data: openArray[byte]) = var ctx: sha256 ctx.init() ctx.update(ss.h.data) @@ -208,7 +214,7 @@ proc mixKeyAndHash(ss: var SymmetricState, ikm: openArray[byte]) {.used.} = ss.mixHash(temp_keys[1]) ss.cs = CipherState(k: temp_keys[2]) -proc encryptAndHash( +proc encryptAndHash*( ss: var SymmetricState, data: openArray[byte] ): seq[byte] {.raises: [NoiseNonceMaxError].} = # according to spec if key is empty leave plaintext @@ -220,7 +226,7 @@ proc encryptAndHash( ss.mixHash(encrypted) encrypted -proc decryptAndHash( +proc decryptAndHash*( ss: var SymmetricState, data: openArray[byte] ): seq[byte] {.raises: [NoiseDecryptTagError, NoiseNonceMaxError].} = # according to spec if key is empty leave plaintext @@ -232,7 +238,7 @@ proc decryptAndHash( ss.mixHash(data) decrypted -proc split(ss: var SymmetricState): tuple[cs1, cs2: CipherState] = +proc split*(ss: var SymmetricState): tuple[cs1, cs2: CipherState] = var temp_keys: array[2, ChaChaPolyKey] sha256.hkdf(ss.ck, [], [], temp_keys) return (CipherState(k: temp_keys[0]), CipherState(k: temp_keys[1])) @@ -312,7 +318,7 @@ template read_s(): untyped = rsLen -proc readFrame( +proc readFrame*( sconn: RawConn ): Future[seq[byte]] {.async: (raises: [CancelledError, LPStreamError]).} = var besize {.noinit.}: array[2, byte] @@ -326,12 +332,12 @@ proc readFrame( await sconn.readExactly(addr buffer[0], buffer.len) return buffer -proc receiveHSMessage( +proc receiveHSMessage*( sconn: RawConn ): Future[seq[byte]] {.async: (raises: [CancelledError, LPStreamError], raw: true).} = readFrame(sconn) -template sendHSMessage(sconn: RawConn, parts: varargs[seq[byte]]): untyped = +template sendHSMessage*(sconn: RawConn, parts: varargs[seq[byte]]): untyped = # sends message (message frame) using multiple seq[byte] that # concatenated represent entire mesage. diff --git a/libp2p/protocols/secure/noisehfs.nim b/libp2p/protocols/secure/noisehfs.nim new file mode 100644 index 0000000000..dde68971ea --- /dev/null +++ b/libp2p/protocols/secure/noisehfs.nim @@ -0,0 +1,324 @@ +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright (c) Status Research & Development GmbH + +## NoiseHFS: a post-quantum hybrid variant of the libp2p Noise handshake. +## +## Implements `Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256`, applying the +## Noise Hybrid Forward Secrecy extension (`e1`/`ekem1` tokens) to the +## classical XX pattern: +## +## -> e, e1 +## <- e, ee, ekem1, s, es +## -> s, se +## +## The three DH tokens (ee, es, se) provide the same classical security as +## plain `/noise`. The `e1`/`ekem1` tokens additionally mix an ML-KEM-768 +## shared secret into the chaining key, so the session remains confidential +## even if X25519 is later broken by a quantum adversary, and remains +## confidential even if ML-KEM-768 is broken, since neither component's +## failure weakens the other's contribution. +## +## This module reuses noise.nim's `SymmetricState`/`CipherState`/`KeyPair` +## and message framing unchanged; only the extra KEM tokens and the +## top-level connection encrypter are new. See `NOISE_HFS_SPEC.md` for the +## full wire format and design rationale. +## +## https://github.com/noiseprotocol/noise_hfs_spec + +{.push raises: [].} + +import chronos, results, chronicles +import protobuf_serialization, protobuf_serialization/pkg/results +import nimcrypto/utils +import ../../stream/connection +import ../../peerid +import ../../peerinfo +import ../../utils/[opt, bytesview] +import ../../crypto/[crypto, chacha20poly1305, curve25519, mlkem768] +import secure, noise + +logScope: + topics = "libp2p noisehfs" + +const + # Working identifier for this profile; not yet IANA/libp2p-specs + # registered. See NOISE_HFS_SPEC.md for the standardization status. + NoiseHFSCodec* = "/noise-mlkem768-hfs/0.1.0" + + ProtocolXXHFSName = "Noise_XXhfs_25519+ML-KEM-768_ChaChaPoly_SHA256" + +type + HandshakeStateHFS = object + ss: SymmetricState + s: noise.KeyPair # local static DH keypair + e: noise.KeyPair # local ephemeral DH keypair + rs: Curve25519Key # remote static DH public key + re: Curve25519Key # remote ephemeral DH public key + e1: MLKEM768KeyPair # local ephemeral KEM keypair + re1: MLKEM768PublicKeyBytes # remote ephemeral KEM public key + + NoiseHFS* = ref object of Secure + rng: Rng + localPrivateKey: PrivateKey + localPublicKey: seq[byte] + noiseKeys: noise.KeyPair + commonPrologue: seq[byte] + + NoiseHFSHandshakeError* = object of NoiseHandshakeError + +proc init(_: type[HandshakeStateHFS]): HandshakeStateHFS = + HandshakeStateHFS(ss: SymmetricState.init(ProtocolXXHFSName)) + +proc handshakeXXHFSOutbound( + p: NoiseHFS, conn: RawConn, p2pSecret: seq[byte] +): Future[HandshakeResult] {.async: (raises: [CancelledError, LPStreamError]).} = + var hs = HandshakeStateHFS.init() + + try: + hs.ss.mixHash(p.commonPrologue) + hs.s = p.noiseKeys + + block: # -> e, e1 + hs.e = genKeyPair(p.rng) + hs.ss.mixHash(hs.e.publicKey) + + hs.e1 = mlkem768.generateKeyPair() + hs.ss.mixHash(hs.e1.publicKey) + + let hbytes = hs.ss.encryptAndHash([]) + conn.sendHSMessage(hs.e.publicKey.getBytes, @(hs.e1.publicKey), hbytes) + + var remoteP2psecret: seq[byte] + block: # <- e, ee, ekem1, s, es + var msg = BytesView.init(await conn.receiveHSMessage()) + + if msg.len < Curve25519Key.len: + raise (ref NoiseHFSHandshakeError)(msg: "NoiseHFS e, expected more data") + hs.re[0 .. Curve25519Key.high] = msg.toOpenArray(0, Curve25519Key.high) + hs.ss.mixHash(hs.re) + msg.consume(Curve25519Key.len) + + hs.ss.mixKey(dh(hs.e.privateKey, hs.re)) # ee + + let ekem1Len = MLKEM768CiphertextLen + ChaChaPolyTag.len + if msg.len < ekem1Len: + raise (ref NoiseHFSHandshakeError)(msg: "NoiseHFS ekem1, expected more data") + let ciphertext = hs.ss.decryptAndHash(msg.toOpenArray(0, ekem1Len - 1)) + msg.consume(ekem1Len) + + var sharedSecret = mlkem768.decapsulate(ciphertext, hs.e1).valueOr: + raise (ref NoiseHFSHandshakeError)(msg: "NoiseHFS ekem1, invalid ciphertext") + hs.ss.mixKey(sharedSecret) # after decrypt, mirroring the sender's order + burnMem(sharedSecret) + + let rsLen = + if hs.ss.cs.hasKey: + Curve25519Key.len + ChaChaPolyTag.len + else: + Curve25519Key.len + if msg.len < rsLen: + raise (ref NoiseHFSHandshakeError)(msg: "NoiseHFS s, expected more data") + hs.rs[0 .. Curve25519Key.high] = hs.ss.decryptAndHash(msg.toOpenArray(0, rsLen - 1)) + msg.consume(rsLen) + + hs.ss.mixKey(dh(hs.e.privateKey, hs.rs)) # es (initiator) + + remoteP2psecret = hs.ss.decryptAndHash(msg.data()) + + block: # -> s, se + let sbytes = hs.ss.encryptAndHash(hs.s.publicKey) + hs.ss.mixKey(dh(hs.s.privateKey, hs.re)) # se (initiator) + let hbytes = hs.ss.encryptAndHash(p2pSecret) + + conn.sendHSMessage(sbytes, hbytes) + + let (cs1, cs2) = hs.ss.split() + return + HandshakeResult(cs1: cs1, cs2: cs2, remoteP2psecret: remoteP2psecret, rs: hs.rs) + finally: + burnMem(hs) + +proc handshakeXXHFSInbound( + p: NoiseHFS, conn: RawConn, p2pSecret: seq[byte] +): Future[HandshakeResult] {.async: (raises: [CancelledError, LPStreamError]).} = + var hs = HandshakeStateHFS.init() + + try: + hs.ss.mixHash(p.commonPrologue) + hs.s = p.noiseKeys + + block: # <- e, e1 + var msg = BytesView.init(await conn.receiveHSMessage()) + + if msg.len < Curve25519Key.len: + raise (ref NoiseHFSHandshakeError)(msg: "NoiseHFS e, expected more data") + hs.re[0 .. Curve25519Key.high] = msg.toOpenArray(0, Curve25519Key.high) + hs.ss.mixHash(hs.re) + msg.consume(Curve25519Key.len) + + if msg.len < MLKEM768PublicKeyLen: + raise (ref NoiseHFSHandshakeError)(msg: "NoiseHFS e1, expected more data") + hs.re1[0 .. MLKEM768PublicKeyLen - 1] = msg.toOpenArray(0, MLKEM768PublicKeyLen - 1) + hs.ss.mixHash(hs.re1) + msg.consume(MLKEM768PublicKeyLen) + + # we might use this early data one day, keeping it here for clarity + let earlyData {.used.} = hs.ss.decryptAndHash(msg.data()) + + block: # -> e, ee, ekem1, s, es + hs.e = genKeyPair(p.rng) + hs.ss.mixHash(hs.e.publicKey) + let ebytes = hs.e.publicKey.getBytes + + hs.ss.mixKey(dh(hs.e.privateKey, hs.re)) # ee + + var encapRes = mlkem768.encapsulate(hs.re1).valueOr: + raise (ref NoiseHFSHandshakeError)(msg: "NoiseHFS ekem1, invalid remote e1") + let ekem1bytes = hs.ss.encryptAndHash(encapRes.ciphertext) + hs.ss.mixKey(encapRes.sharedSecret) # after encrypt, per the wire spec + burnMem(encapRes.sharedSecret) + + let sbytes = hs.ss.encryptAndHash(hs.s.publicKey) + hs.ss.mixKey(dh(hs.s.privateKey, hs.re)) # es (responder) + let hbytes = hs.ss.encryptAndHash(p2pSecret) + + conn.sendHSMessage(ebytes, ekem1bytes, sbytes, hbytes) + + var remoteP2psecret: seq[byte] + block: # <- s, se + var msg = BytesView.init(await conn.receiveHSMessage()) + let rsLen = + if hs.ss.cs.hasKey: + Curve25519Key.len + ChaChaPolyTag.len + else: + Curve25519Key.len + if msg.len < rsLen: + raise (ref NoiseHFSHandshakeError)(msg: "NoiseHFS s, expected more data") + hs.rs[0 .. Curve25519Key.high] = hs.ss.decryptAndHash(msg.toOpenArray(0, rsLen - 1)) + msg.consume(rsLen) + + hs.ss.mixKey(dh(hs.e.privateKey, hs.rs)) # se (responder) + + remoteP2psecret = hs.ss.decryptAndHash(msg.data()) + + let (cs1, cs2) = hs.ss.split() + return + HandshakeResult(cs1: cs1, cs2: cs2, remoteP2psecret: remoteP2psecret, rs: hs.rs) + finally: + burnMem(hs) + +method handshake*( + p: NoiseHFS, conn: RawConn, initiator: bool, peerId: Opt[PeerId] +): Future[SecureConn] {.async: (raises: [CancelledError, LPStreamError]).} = + trace "Starting NoiseHFS handshake", conn, initiator + + let timeout = conn.timeout + conn.timeout = HandshakeTimeout + + let signedPayload = + p.localPrivateKey.sign(PayloadString & p.noiseKeys.publicKey.getBytes) + if signedPayload.isErr(): + raise (ref NoiseHFSHandshakeError)( + msg: "Failed to sign public key: " & $signedPayload.error() + ) + + let msg = NoiseHandshakePayloadMsg( + identityKey: Opt.some(p.localPublicKey), + identitySig: Opt.some(signedPayload.get().getBytes()), + ) + + var handshakeRes = + if initiator: + await handshakeXXHFSOutbound(p, conn, msg.encode()) + else: + await handshakeXXHFSInbound(p, conn, msg.encode()) + + var secure = + try: + var + remoteMsg: NoiseHandshakePayloadMsg + remotePubKey: PublicKey + remoteSig: Signature + + remoteMsg = NoiseHandshakePayloadMsg.decode(handshakeRes.remoteP2psecret).valueOr: + raise newException(NoiseHFSHandshakeError, error) + + if remoteMsg.identityKey.isNone or remoteMsg.identitySig.isNone: + raise newException( + NoiseHFSHandshakeError, "NoiseHandshakePayloadMsg fields must be set" + ) + + if not remotePubKey.init(remoteMsg.identityKey.get()): + raise (ref NoiseHFSHandshakeError)( + msg: "Failed to decode remote public key. (initiator: " & $initiator & ")" + ) + if not remoteSig.init(remoteMsg.identitySig.get()): + raise (ref NoiseHFSHandshakeError)( + msg: "Failed to decode remote signature. (initiator: " & $initiator & ")" + ) + + let verifyPayload = PayloadString & handshakeRes.rs.getBytes + if not remoteSig.verify(verifyPayload, remotePubKey): + raise (ref NoiseHFSHandshakeError)(msg: "NoiseHFS handshake signature verify failed.") + else: + trace "Remote signature verified", conn + + let pid = PeerId.init(remotePubKey).valueOr: + raise (ref NoiseHFSHandshakeError)(msg: "Invalid remote peer id: " & $error) + + trace "Remote peer id", pid = $pid + + peerId.withValue(targetPid): + if not targetPid.validate(): + raise (ref NoiseHFSHandshakeError)(msg: "Failed to validate expected peerId.") + + if pid != targetPid: + raise (ref NoiseHFSHandshakeError)( + msg: "NoiseHFS handshake, peer id don't match! " & $pid & " != " & $targetPid + ) + conn.peerId = pid + + var tmp = + NoiseConnection.new(conn, conn.peerId, conn.observedAddr, conn.localAddr) + if initiator: + tmp.readCs = handshakeRes.cs2 + tmp.writeCs = handshakeRes.cs1 + else: + tmp.readCs = handshakeRes.cs1 + tmp.writeCs = handshakeRes.cs2 + tmp + finally: + burnMem(handshakeRes) + + trace "NoiseHFS handshake completed!", initiator, peer = shortLog(secure.peerId) + + conn.timeout = timeout + + return secure + +method init*(p: NoiseHFS) {.gcsafe.} = + procCall Secure(p).init() + p.codec = NoiseHFSCodec + +proc new*( + T: typedesc[NoiseHFS], + rng: Rng, + privateKey: PrivateKey, + commonPrologue: seq[byte] = @[], +): T = + let pkBytes = privateKey + .getPublicKey() + .expect("Expected valid Private Key") + .getBytes() + .expect("Couldn't get public Key bytes") + + var noiseHFS = NoiseHFS( + rng: rng, + localPrivateKey: privateKey, + localPublicKey: pkBytes, + noiseKeys: genKeyPair(rng), + commonPrologue: commonPrologue, + ) + + noiseHFS.init() + noiseHFS diff --git a/tests/libp2p/protocols/test_noisehfs.nim b/tests/libp2p/protocols/test_noisehfs.nim new file mode 100644 index 0000000000..a2dcbcb0a5 --- /dev/null +++ b/tests/libp2p/protocols/test_noisehfs.nim @@ -0,0 +1,146 @@ +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright (c) Status Research & Development GmbH + +{.used.} + +import chronos, stew/byteutils +import + ../../../libp2p/[ + errors, + stream/connection, + transports/transport, + transports/tcptransport, + multiaddress, + peerinfo, + crypto/crypto, + crypto/mlkem768, + protocols/secure/noise, + protocols/secure/noisehfs, + protocols/secure/secure, + upgrademngrs/upgrade, + ] +import ../../tools/[unittest, crypto] + +suite "NoiseHFS": + teardown: + checkTrackers() + + let ma = MultiAddress.init("/ip4/0.0.0.0/tcp/0").get() + + test "protocol id matches the published XXhfs profile identifier": + check NoiseHFSCodec == "/noise-mlkem768-hfs/0.1.0" + + asyncTest "e2e: handle write + NoiseHFS": + let + server = @[ma] + serverPrivKey = PrivateKey.random(ECDSA, rng()).get() + serverInfo = PeerInfo.new(serverPrivKey, server) + serverNoise = NoiseHFS.new(rng(), serverPrivKey) + + let transport1: TcpTransport = TcpTransport.new(upgrade = Upgrade()) + asyncSpawn transport1.start(server) + + proc acceptHandler() {.async.} = + let conn = await transport1.accept() + let sconn = await serverNoise.secure(conn, Opt.none(PeerId)) + try: + await sconn.write("Hello!") + finally: + await sconn.close() + await conn.close() + + let + acceptFut = acceptHandler() + transport2: TcpTransport = TcpTransport.new(upgrade = Upgrade()) + clientPrivKey = PrivateKey.random(ECDSA, rng()).get() + clientNoise = NoiseHFS.new(rng(), clientPrivKey) + conn = await transport2.dial(transport1.addrs[0]) + + let sconn = await clientNoise.secure(conn, Opt.some(serverInfo.peerId)) + + var msg = newSeq[byte](6) + await sconn.readExactly(addr msg[0], 6) + + await sconn.close() + await conn.close() + await acceptFut + await transport1.stop() + await transport2.stop() + + check string.fromBytes(msg) == "Hello!" + + asyncTest "e2e: rejects a peer id mismatch": + let + server = @[ma] + serverPrivKey = PrivateKey.random(ECDSA, rng()).get() + serverNoise = NoiseHFS.new(rng(), serverPrivKey) + + let transport1: TcpTransport = TcpTransport.new(upgrade = Upgrade()) + asyncSpawn transport1.start(server) + + proc acceptHandler() {.async.} = + var conn: RawConn + try: + conn = await transport1.accept() + discard await serverNoise.secure(conn, Opt.none(PeerId)) + except LPStreamError: + discard + finally: + if not conn.isNil: + await conn.close() + + let + handlerWait = acceptHandler() + transport2: TcpTransport = TcpTransport.new(upgrade = Upgrade()) + clientPrivKey = PrivateKey.random(ECDSA, rng()).get() + clientNoise = NoiseHFS.new(rng(), clientPrivKey) + wrongPeer = PeerInfo.new(PrivateKey.random(ECDSA, rng()).get(), server) + conn = await transport2.dial(transport1.addrs[0]) + + expect NoiseHFSHandshakeError: + discard await clientNoise.secure(conn, Opt.some(wrongPeer.peerId)) + + await conn.close() + await handlerWait + await transport1.stop() + await transport2.stop() + +suite "MLKEM768": + test "encapsulate/decapsulate round-trip recovers the shared secret": + let keyPair = mlkem768.generateKeyPair() + let encapRes = mlkem768.encapsulate(keyPair.publicKey).valueOr: + raiseAssert "encapsulate should succeed against a freshly generated key" + let decapSecret = mlkem768.decapsulate(encapRes.ciphertext, keyPair).valueOr: + raiseAssert "decapsulate should succeed for a matching ciphertext" + + check decapSecret == encapRes.sharedSecret + + test "encapsulate rejects a malformed public key length": + let tooShort: seq[byte] = newSeq[byte](10) + check mlkem768.encapsulate(tooShort).isErr + + test "decapsulate rejects a malformed ciphertext length": + let keyPair = mlkem768.generateKeyPair() + let tooShort: seq[byte] = newSeq[byte](10) + check mlkem768.decapsulate(tooShort, keyPair).isErr + + test "decapsulate with the wrong key does not crash and yields a different secret": + # FIPS 203 implicit rejection: a well-formed ciphertext decapsulated with + # an unrelated private key must not raise, and must not (except with + # negligible probability) reproduce the original shared secret. + let + keyPairA = mlkem768.generateKeyPair() + keyPairB = mlkem768.generateKeyPair() + encapRes = mlkem768.encapsulate(keyPairA.publicKey).valueOr: + raiseAssert "encapsulate should succeed" + wrongSecret = mlkem768.decapsulate(encapRes.ciphertext, keyPairB).valueOr: + raiseAssert "decapsulate must not fail on a well-formed ciphertext" + + check wrongSecret != encapRes.sharedSecret + + test "two key pairs produce different public keys": + let + keyPairA = mlkem768.generateKeyPair() + keyPairB = mlkem768.generateKeyPair() + + check keyPairA.publicKey != keyPairB.publicKey