From cf123ae8b944a1ec44ce90ad46224a45e56798d4 Mon Sep 17 00:00:00 2001 From: John Safranek Date: Thu, 24 Sep 2026 16:25:54 -0700 Subject: [PATCH 1/3] internal: fix hybrid ML-KEM client secret sizing KeyAgreeEcdhMlKem_client() writes the classical shared secret after the ML-KEM secret in ssh->k, so it now offers the classical call only the capacity left after that prefix, as the server side does. The ML-KEM private key decode result is now checked before decapsulation. - reject a ssh->k capacity that cannot hold the ML-KEM secret - add wolfSSH_TestKeyAgreeEcdhMlKem_client() and a unit test that runs each hybrid KEX at, below and above the capacity bound, and with a corrupted private key, and checks the derived secret Issue: F-14225, F-10559 --- src/internal.c | 31 ++++- tests/unit.c | 314 +++++++++++++++++++++++++++++++++++++++++++++ wolfssh/internal.h | 6 + 3 files changed, 344 insertions(+), 7 deletions(-) diff --git a/src/internal.c b/src/internal.c index 3416a7871..b1d4ec6c7 100644 --- a/src/internal.c +++ b/src/internal.c @@ -8301,6 +8301,11 @@ static int KeyAgreeEcdhMlKem_client(WOLFSSH* ssh, byte hashId, ret = WS_BUFFER_E; } + /* ssh->k holds the ML-KEM secret first, then the classical secret. */ + if ((ret == 0) && (ssh->kSz <= length_sharedsecret)) { + ret = WS_BUFFER_E; + } + #ifndef WOLFSSH_NO_CURVE25519_MLKEM768_SHA256 if (kexId == ID_CURVE25519_MLKEM768_SHA256) { /* Handle Curve25519 variant */ @@ -8329,12 +8334,14 @@ static int KeyAgreeEcdhMlKem_client(WOLFSSH* ssh, byte hashId, EC25519_LITTLE_ENDIAN); } if (ret == 0) { + word32 tmp_kSz = ssh->kSz - length_sharedsecret; PRIVATE_KEY_UNLOCK(); ret = wc_curve25519_shared_secret_ex( &ssh->handshake->privKey.curve25519, x25519_key_ptr, ssh->k + length_sharedsecret, - &ssh->kSz, EC25519_LITTLE_ENDIAN); + &tmp_kSz, EC25519_LITTLE_ENDIAN); PRIVATE_KEY_LOCK(); + ssh->kSz = length_sharedsecret + tmp_kSz; } if (x25519KeyInited) wc_curve25519_free(x25519_key_ptr); @@ -8382,11 +8389,13 @@ static int KeyAgreeEcdhMlKem_client(WOLFSSH* ssh, byte hashId, } if (ret == 0) { + word32 tmp_kSz = ssh->kSz - length_sharedsecret; PRIVATE_KEY_UNLOCK(); ret = wc_ecc_shared_secret(&ssh->handshake->privKey.ecc, key_ptr, ssh->k + length_sharedsecret, - &ssh->kSz); + &tmp_kSz); PRIVATE_KEY_LOCK(); + ssh->kSz = length_sharedsecret + tmp_kSz; } if (eccKeyInited) wc_ecc_free(key_ptr); @@ -8400,17 +8409,15 @@ static int KeyAgreeEcdhMlKem_client(WOLFSSH* ssh, byte hashId, } if (ret == 0) { - wc_MlKemKey_DecodePrivateKey(&kem, ssh->handshake->x, - length_privatekey); + ret = wc_MlKemKey_DecodePrivateKey(&kem, ssh->handshake->x, + length_privatekey); } if (ret == 0) { ret = wc_MlKemKey_Decapsulate(&kem, ssh->k, f, length_ciphertext); } - if (ret == 0) { - ssh->kSz += length_sharedsecret; - } else { + if (ret != 0) { ssh->kSz = 0; /* Local faults (RNG, HSM, memory) are logged at ERROR; * peer-driven rejects stay at DEBUG so a remote peer @@ -25981,6 +25988,16 @@ int wolfSSH_TestKeyAgreeEcdh_client(WOLFSSH* ssh, byte hashId, } #endif /* !WOLFSSH_NO_ECDH */ +#if !defined(WOLFSSH_NO_NISTP256_MLKEM768_SHA256) || \ + !defined(WOLFSSH_NO_NISTP384_MLKEM1024_SHA384) || \ + !defined(WOLFSSH_NO_CURVE25519_MLKEM768_SHA256) +int wolfSSH_TestKeyAgreeEcdhMlKem_client(WOLFSSH* ssh, byte hashId, + const byte* f, word32 fSz) +{ + return KeyAgreeEcdhMlKem_client(ssh, hashId, f, fSz); +} +#endif + #ifndef WOLFSSH_NO_DH_GEX_SHA256 int wolfSSH_TestSendKexDhGexRequest(WOLFSSH* ssh) diff --git a/tests/unit.c b/tests/unit.c index e98000624..387ec6ed6 100644 --- a/tests/unit.c +++ b/tests/unit.c @@ -38,6 +38,9 @@ #include #include #include +#ifdef WOLFSSL_HAVE_MLKEM + #include +#endif #ifndef WOLFSSH_NO_RSA #include #include @@ -19601,6 +19604,308 @@ static int test_KeyAgreeEcdh_client_rejectsOffCurvePoint(void) } #endif /* !WOLFSSH_NO_ECDH && !WOLFSSH_NO_ECDH_SHA2_NISTP256 */ +#if !defined(WOLFSSH_NO_NISTP256_MLKEM768_SHA256) || \ + !defined(WOLFSSH_NO_NISTP384_MLKEM1024_SHA384) || \ + !defined(WOLFSSH_NO_CURVE25519_MLKEM768_SHA256) +typedef struct { + byte kexId; + int mlKemType; + enum wc_HashType hashId; + word32 classicSz; /* classical shared secret size */ +} MlKemClientCase; + +static const MlKemClientCase mlKemClientCases[] = { +#ifndef WOLFSSH_NO_CURVE25519_MLKEM768_SHA256 + { ID_CURVE25519_MLKEM768_SHA256, WC_ML_KEM_768, WC_HASH_TYPE_SHA256, + CURVE25519_KEYSIZE }, +#endif +#ifndef WOLFSSH_NO_NISTP256_MLKEM768_SHA256 + { ID_NISTP256_MLKEM768_SHA256, WC_ML_KEM_768, WC_HASH_TYPE_SHA256, 32 }, +#endif +#ifndef WOLFSSH_NO_NISTP384_MLKEM1024_SHA384 + { ID_NISTP384_MLKEM1024_SHA384, WC_ML_KEM_1024, WC_HASH_TYPE_SHA384, 48 }, +#endif +}; + +/* Largest classical public key in f: an uncompressed P-384 point. */ +#define MLKEM_CLIENT_PEER_SZ (1 + 2 * 48) + +/* Run KeyAgreeEcdhMlKem_client for tc with a fresh client key pair, ssh->kSz + * set to kSz on entry. With corrupt set, one byte of the encoded private + * key's stored H(ek) is flipped and *decodeRet gets what + * wc_MlKemKey_DecodePrivateKey returns for those bytes. *kMatch is set when + * ssh->k is Hash(ss || classical secret), the secrets computed peer side. */ +static int mlKemClientAgree(const MlKemClientCase* tc, word32 kSz, + int corrupt, int* agreeRet, int* decodeRet, int* kMatch) +{ + WOLFSSH_CTX* ctx = NULL; + WOLFSSH* ssh = NULL; + MlKemKey kem; + MlKemKey scratch; + byte f[WC_ML_KEM_MAX_CIPHER_TEXT_SIZE + MLKEM_CLIENT_PEER_SZ]; + byte cPub[MLKEM_CLIENT_PEER_SZ]; + byte expected[WC_ML_KEM_SS_SZ + MLKEM_CLIENT_PEER_SZ]; + byte digest[WC_MAX_DIGEST_SIZE]; + word32 ctSz = 0, privSz = 0; + word32 pubSz = MLKEM_CLIENT_PEER_SZ, cPubSz = sizeof(cPub); + word32 classicSz = MLKEM_CLIENT_PEER_SZ; + int digestSz; + int kemInit = 0; + int result = 0; +#ifndef WOLFSSH_NO_CURVE25519_MLKEM768_SHA256 + curve25519_key peer25519, client25519; + int peer25519Init = 0, client25519Init = 0; +#endif +#if !defined(WOLFSSH_NO_NISTP256_MLKEM768_SHA256) || \ + !defined(WOLFSSH_NO_NISTP384_MLKEM1024_SHA384) + ecc_key peerEcc, clientEcc; + int peerEccInit = 0, clientEccInit = 0; + int curveId = (tc->classicSz == 48) ? ECC_SECP384R1 : ECC_SECP256R1; +#endif + + *agreeRet = WS_FATAL_ERROR; + *decodeRet = 0; + *kMatch = 0; + ctx = wolfSSH_CTX_new(WOLFSSH_ENDPOINT_CLIENT, NULL); + if (ctx == NULL) + return -7300; + ssh = wolfSSH_new(ctx); + if (ssh == NULL || ssh->handshake == NULL) { + result = -7301; + goto out; + } + ssh->handshake->kexId = tc->kexId; + ssh->handshake->useMlKem = 1; + + /* Client ML-KEM key, encoded into handshake->x. */ + if (wc_MlKemKey_Init(&kem, tc->mlKemType, NULL, INVALID_DEVID) != 0) { + result = -7303; + goto out; + } + kemInit = 1; + if (wc_MlKemKey_MakeKey(&kem, ssh->rng) != 0 + || wc_MlKemKey_PrivateKeySize(&kem, &privSz) != 0 + || wc_MlKemKey_CipherTextSize(&kem, &ctSz) != 0 + || privSz > sizeof(ssh->handshake->x) + || wc_MlKemKey_EncodePrivateKey(&kem, ssh->handshake->x, + privSz) != 0 + || wc_MlKemKey_Encapsulate(&kem, f, expected, ssh->rng) != 0) { + result = -7304; + goto out; + } + ssh->handshake->xSz = privSz; + + /* Client ephemeral classical key, as SendKexDhInit makes it, and the + * peer key whose public part follows the ciphertext in f. The expected + * classical secret is computed from the peer side. */ +#ifndef WOLFSSH_NO_CURVE25519_MLKEM768_SHA256 + if (tc->kexId == ID_CURVE25519_MLKEM768_SHA256) { + ssh->handshake->useCurve25519 = 1; + if (wc_curve25519_init(&ssh->handshake->privKey.curve25519) != 0 + || wc_curve25519_make_key(ssh->rng, CURVE25519_KEYSIZE, + &ssh->handshake->privKey.curve25519) != 0) { + result = -7302; + goto out; + } + if (wc_curve25519_init(&peer25519) != 0) { + result = -7305; + goto out; + } + peer25519Init = 1; + if (wc_curve25519_init(&client25519) != 0) { + result = -7305; + goto out; + } + client25519Init = 1; + if (wc_curve25519_make_key(ssh->rng, CURVE25519_KEYSIZE, + &peer25519) != 0 + || wc_curve25519_export_public_ex(&peer25519, f + ctSz, + &pubSz, EC25519_LITTLE_ENDIAN) != 0 + || wc_curve25519_export_public_ex( + &ssh->handshake->privKey.curve25519, cPub, &cPubSz, + EC25519_LITTLE_ENDIAN) != 0 + || wc_curve25519_import_public_ex(cPub, cPubSz, + &client25519, EC25519_LITTLE_ENDIAN) != 0 + || wc_curve25519_shared_secret_ex(&peer25519, &client25519, + expected + WC_ML_KEM_SS_SZ, &classicSz, + EC25519_LITTLE_ENDIAN) != 0) { + result = -7306; + goto out; + } + } + else +#endif + { +#if !defined(WOLFSSH_NO_NISTP256_MLKEM768_SHA256) || \ + !defined(WOLFSSH_NO_NISTP384_MLKEM1024_SHA384) + ssh->handshake->useEcdh = 1; + if (wc_ecc_init(&ssh->handshake->privKey.ecc) != 0) { + result = -7302; + goto out; + } + #ifdef HAVE_WC_ECC_SET_RNG + if (wc_ecc_set_rng(&ssh->handshake->privKey.ecc, ssh->rng) != 0) { + result = -7302; + goto out; + } + #endif + if (wc_ecc_make_key_ex(ssh->rng, (int)tc->classicSz, + &ssh->handshake->privKey.ecc, curveId) != 0) { + result = -7302; + goto out; + } + if (wc_ecc_init(&peerEcc) != 0) { + result = -7305; + goto out; + } + peerEccInit = 1; + if (wc_ecc_init(&clientEcc) != 0) { + result = -7305; + goto out; + } + clientEccInit = 1; + #ifdef HAVE_WC_ECC_SET_RNG + if (wc_ecc_set_rng(&peerEcc, ssh->rng) != 0) { + result = -7305; + goto out; + } + #endif + if (wc_ecc_make_key_ex(ssh->rng, (int)tc->classicSz, &peerEcc, + curveId) != 0 + || wc_ecc_export_x963(&peerEcc, f + ctSz, &pubSz) != 0 + || wc_ecc_export_x963(&ssh->handshake->privKey.ecc, cPub, + &cPubSz) != 0 + || wc_ecc_import_x963_ex(cPub, cPubSz, &clientEcc, + curveId) != 0 + || wc_ecc_shared_secret(&peerEcc, &clientEcc, + expected + WC_ML_KEM_SS_SZ, &classicSz) != 0) { + result = -7306; + goto out; + } +#endif + } + if (classicSz != tc->classicSz) { + result = -7308; + goto out; + } + + if (corrupt) { + /* dk ends with H(ek) || z; flip a byte of H(ek). */ + ssh->handshake->x[privSz - 2 * WC_ML_KEM_SYM_SZ] ^= 0x01; + if (wc_MlKemKey_Init(&scratch, tc->mlKemType, NULL, + INVALID_DEVID) != 0) { + result = -7307; + goto out; + } + *decodeRet = wc_MlKemKey_DecodePrivateKey(&scratch, + ssh->handshake->x, privSz); + wc_MlKemKey_Free(&scratch); + } + + ssh->kSz = kSz; + *agreeRet = wolfSSH_TestKeyAgreeEcdhMlKem_client(ssh, (byte)tc->hashId, + f, ctSz + pubSz); + + digestSz = wc_HashGetDigestSize(tc->hashId); + if (digestSz <= 0 || wc_Hash(tc->hashId, expected, + WC_ML_KEM_SS_SZ + classicSz, digest, (word32)digestSz) != 0) { + result = -7309; + goto out; + } + *kMatch = (ssh->kSz == (word32)digestSz) + && (WMEMCMP(ssh->k, digest, (word32)digestSz) == 0); + +out: +#ifndef WOLFSSH_NO_CURVE25519_MLKEM768_SHA256 + if (peer25519Init) + wc_curve25519_free(&peer25519); + if (client25519Init) + wc_curve25519_free(&client25519); +#endif +#if !defined(WOLFSSH_NO_NISTP256_MLKEM768_SHA256) || \ + !defined(WOLFSSH_NO_NISTP384_MLKEM1024_SHA384) + if (peerEccInit) + wc_ecc_free(&peerEcc); + if (clientEccInit) + wc_ecc_free(&clientEcc); +#endif + if (kemInit) + wc_MlKemKey_Free(&kem); + if (ssh != NULL) + wolfSSH_free(ssh); + if (ctx != NULL) + wolfSSH_CTX_free(ctx); + return result; +} + +/* The classical secret lands after the ML-KEM secret in ssh->k, so only the + * remaining capacity may be offered to it, and a private-key decode failure + * must be the error returned rather than a later decapsulation error. */ +static int test_KeyAgreeEcdhMlKem_client(void) +{ + word32 i; + int agreeRet; + int decodeRet; + int kMatch; + int ret; + + for (i = 0; i < sizeof(mlKemClientCases) / sizeof(mlKemClientCases[0]); + i++) { + const MlKemClientCase* tc = &mlKemClientCases[i]; + word32 fitSz = WC_ML_KEM_SS_SZ + tc->classicSz; + + /* Control: full capacity agrees. */ + ret = mlKemClientAgree(tc, MAX_KEX_KEY_SZ, 0, &agreeRet, &decodeRet, + &kMatch); + if (ret != 0) + return ret; + if (agreeRet != WS_SUCCESS || !kMatch) + return -7310; + + /* Exact fit for both secrets agrees. */ + ret = mlKemClientAgree(tc, fitSz, 0, &agreeRet, &decodeRet, &kMatch); + if (ret != 0) + return ret; + if (agreeRet != WS_SUCCESS || !kMatch) + return -7311; + + /* No room past the ML-KEM secret is rejected before any agreement. */ + ret = mlKemClientAgree(tc, WC_ML_KEM_SS_SZ, 0, &agreeRet, &decodeRet, + &kMatch); + if (ret != 0) + return ret; + if (agreeRet != WS_BUFFER_E) + return -7314; + ret = mlKemClientAgree(tc, 0, 0, &agreeRet, &decodeRet, &kMatch); + if (ret != 0) + return ret; + if (agreeRet != WS_BUFFER_E) + return -7315; + + /* One byte short for the classical secret must fail. */ + ret = mlKemClientAgree(tc, fitSz - 1, 0, &agreeRet, &decodeRet, + &kMatch); + if (ret != 0) + return ret; + if (agreeRet == WS_SUCCESS) + return -7312; + + /* Corrupt private key: a decode error is returned as is. A decode + * that does not check H(ek) leaves decapsulation to reject + * implicitly, which agrees on a different secret. */ + ret = mlKemClientAgree(tc, MAX_KEX_KEY_SZ, 1, &agreeRet, &decodeRet, + &kMatch); + if (ret != 0) + return ret; + if (kMatch || (decodeRet != 0 ? agreeRet != decodeRet + : agreeRet != WS_SUCCESS)) + return -7313; + } + + return 0; +} +#endif + #if defined(WOLFSSH_SCP) && !defined(WOLFSSH_SCP_USER_CALLBACKS) && \ !defined(NO_FILESYSTEM) && !defined(WOLFSSL_NUCLEUS) && \ !defined(_WIN32) && !defined(WOLFSSH_ZEPHYR) @@ -23584,6 +23889,15 @@ int wolfSSH_UnitTest(int argc, char** argv) testResult = testResult || unitResult; #endif /* !WOLFSSH_NO_ECDH && !WOLFSSH_NO_ECDH_SHA2_NISTP256 */ +#if !defined(WOLFSSH_NO_NISTP256_MLKEM768_SHA256) || \ + !defined(WOLFSSH_NO_NISTP384_MLKEM1024_SHA384) || \ + !defined(WOLFSSH_NO_CURVE25519_MLKEM768_SHA256) + unitResult = test_KeyAgreeEcdhMlKem_client(); + printf("KeyAgreeEcdhMlKem_client: %s\n", + (unitResult == 0 ? "SUCCESS" : "FAILED")); + testResult = testResult || unitResult; +#endif + #endif #ifdef WOLFSSH_TEST_SET_CERTMAN diff --git a/wolfssh/internal.h b/wolfssh/internal.h index d0b39c7ee..5e8d25f75 100644 --- a/wolfssh/internal.h +++ b/wolfssh/internal.h @@ -2128,6 +2128,12 @@ enum WS_MessageIdLimits { WOLFSSH_API int wolfSSH_TestKeyAgreeEcdh_client(WOLFSSH* ssh, byte hashId, const byte* f, word32 fSz); #endif /* !WOLFSSH_NO_ECDH */ +#if !defined(WOLFSSH_NO_NISTP256_MLKEM768_SHA256) || \ + !defined(WOLFSSH_NO_NISTP384_MLKEM1024_SHA384) || \ + !defined(WOLFSSH_NO_CURVE25519_MLKEM768_SHA256) + WOLFSSH_API int wolfSSH_TestKeyAgreeEcdhMlKem_client(WOLFSSH* ssh, + byte hashId, const byte* f, word32 fSz); +#endif #ifndef WOLFSSH_NO_DH_GEX_SHA256 WOLFSSH_API int wolfSSH_TestSendKexDhGexRequest(WOLFSSH* ssh); WOLFSSH_API int wolfSSH_TestDoKexDhGexRequest(WOLFSSH* ssh, byte* buf, From aa658554b1585aa33480b6b36b433798a5eb5b43 Mon Sep 17 00:00:00 2001 From: John Safranek Date: Tue, 29 Sep 2026 15:52:30 -0700 Subject: [PATCH 2/3] internal.h: gate hybrid KEXes on ML-KEM sets wolfSSL can build ML-KEM without one of its parameter sets, or without FIPS 203 ML-KEM at all. The hybrid KEX gates now follow the set each one needs, so a missing set is neither advertised nor negotiated. - add WOLFSSH_NO_MLKEM768 and WOLFSSH_NO_MLKEM1024 from WOLFSSL_NO_ML_KEM, WOLFSSL_NO_ML_KEM_768 and WOLFSSL_NO_ML_KEM_1024 - derive the three hybrid KEX gates from them --- wolfssh/internal.h | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/wolfssh/internal.h b/wolfssh/internal.h index 5e8d25f75..ca3279653 100644 --- a/wolfssh/internal.h +++ b/wolfssh/internal.h @@ -241,17 +241,28 @@ extern "C" { #undef WOLFSSH_NO_ECDH_SHA2_NISTP521 #define WOLFSSH_NO_ECDH_SHA2_NISTP521 #endif -#if !defined(WOLFSSL_HAVE_MLKEM) || defined(NO_SHA256) \ +/* wolfSSL can leave out FIPS 203 ML-KEM or any one parameter set. */ +#if !defined(WOLFSSL_HAVE_MLKEM) || defined(WOLFSSL_NO_ML_KEM) \ + || defined(WOLFSSL_NO_ML_KEM_768) + #undef WOLFSSH_NO_MLKEM768 + #define WOLFSSH_NO_MLKEM768 +#endif +#if !defined(WOLFSSL_HAVE_MLKEM) || defined(WOLFSSL_NO_ML_KEM) \ + || defined(WOLFSSL_NO_ML_KEM_1024) + #undef WOLFSSH_NO_MLKEM1024 + #define WOLFSSH_NO_MLKEM1024 +#endif +#if defined(WOLFSSH_NO_MLKEM768) || defined(NO_SHA256) \ || defined(WOLFSSH_NO_ECDH_SHA2_NISTP256) #undef WOLFSSH_NO_NISTP256_MLKEM768_SHA256 #define WOLFSSH_NO_NISTP256_MLKEM768_SHA256 #endif -#if !defined(WOLFSSL_HAVE_MLKEM) || !defined(WOLFSSL_SHA384) \ +#if defined(WOLFSSH_NO_MLKEM1024) || !defined(WOLFSSL_SHA384) \ || defined(WOLFSSH_NO_ECDH_SHA2_NISTP384) #undef WOLFSSH_NO_NISTP384_MLKEM1024_SHA384 #define WOLFSSH_NO_NISTP384_MLKEM1024_SHA384 #endif -#if !defined(WOLFSSL_HAVE_MLKEM) || defined(NO_SHA256) \ +#if defined(WOLFSSH_NO_MLKEM768) || defined(NO_SHA256) \ || !defined(HAVE_CURVE25519) #undef WOLFSSH_NO_CURVE25519_MLKEM768_SHA256 #define WOLFSSH_NO_CURVE25519_MLKEM768_SHA256 From 03072156cbd65ffd69c26a9bbf3715a1185cd35e Mon Sep 17 00:00:00 2001 From: John Safranek Date: Tue, 29 Sep 2026 15:55:08 -0700 Subject: [PATCH 3/3] regress: cover the hybrid KEX failure disconnect DoKexDhReply() maps any KeyAgree_client() failure to WS_CRYPTO_FAILED and sends KEY_EXCHANGE_FAILED. Pin that for the hybrid ML-KEM KEXes, whose client agreement returns raw wolfCrypt codes. - truncate f in a hybrid KEXDH_REPLY and check the client's error and disconnect reason --- tests/regress.c | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/regress.c b/tests/regress.c index 991d03052..d445e0535 100644 --- a/tests/regress.c +++ b/tests/regress.c @@ -721,6 +721,15 @@ static word32 LoadFileBuffer(const char* path, byte* buf, word32 bufSz) #define REGRESS_TRUNC_KEX_ALGO "ecdh-sha2-nistp256" #endif +/* Hybrid KEX algorithm for the truncated hybrid f test */ +#if !defined(WOLFSSH_NO_CURVE25519_MLKEM768_SHA256) + #define REGRESS_HYBRID_KEX_ALGO "mlkem768x25519-sha256" +#elif !defined(WOLFSSH_NO_NISTP256_MLKEM768_SHA256) + #define REGRESS_HYBRID_KEX_ALGO "mlkem768nistp256-sha256" +#elif !defined(WOLFSSH_NO_NISTP384_MLKEM1024_SHA384) + #define REGRESS_HYBRID_KEX_ALGO "mlkem1024nistp384-sha384" +#endif + /* KEX algorithm for the GEX group test */ #ifndef WOLFSSH_NO_DH_GEX_SHA256 #define REGRESS_GEX_KEX_ALGO "diffie-hellman-group-exchange-sha256" @@ -2051,6 +2060,33 @@ static void TestKexDhInitEmptyESendsDisconnect(void) } #endif /* REGRESS_TRUNC_KEX_ALGO */ +#ifdef REGRESS_HYBRID_KEX_ALGO +/* A hybrid KEX failure on the client also ends with KEY_EXCHANGE_FAILED. The + * short f leaves the classical peer key one byte short. */ +static void TestKexHybridReplyTruncatedFSendsDisconnect(void) +{ + KexReplyHarness harness; + KexReplyRunResult result; + + InitKexReplyHarnessKex(&harness, REGRESS_HYBRID_KEX_ALGO, + REGRESS_DEFAULT_KEY_ALGO, REGRESS_DEFAULT_KEY_PATH, 1, + REGRESS_MUTATE_F_TRUNC, NULL, 0); + RunKexReplyHandshake(&harness, &result); + + AssertIntEQ(harness.mutator.parseError, 0); + AssertIntEQ(harness.mutator.mutatedPackets, 1); + AssertFalse(result.clientSuccess); + AssertFalse(harness.client->connectState >= CONNECT_KEYED); + AssertTrue(result.clientRet == WS_FATAL_ERROR); + AssertIntEQ(result.clientErr, WS_CRYPTO_FAILED); + AssertTrue(harness.clientIo.sawDisconnect); + AssertIntEQ(harness.clientIo.disconnectReason, + WOLFSSH_DISCONNECT_KEY_EXCHANGE_FAILED); + + FreeKexReplyHarness(&harness); +} +#endif /* REGRESS_HYBRID_KEX_ALGO */ + #ifdef REGRESS_GEX_KEX_ALGO /* A GEX group below the floor this client enforces (RFC 8270) ends the key * exchange. Covers the WS_DH_SIZE_E arm of the client's guard, which no @@ -17411,6 +17447,9 @@ int main(int argc, char** argv) TestKexDhInitTruncatedESendsDisconnect(); TestKexDhInitEmptyESendsDisconnect(); #endif + #ifdef REGRESS_HYBRID_KEX_ALGO + TestKexHybridReplyTruncatedFSendsDisconnect(); + #endif #ifdef REGRESS_GEX_KEX_ALGO TestKexDhGexGroupShrunkPrimeSendsDisconnect(); TestKexDhGexGroupBadGeneratorSendsDisconnect();