From 24bcc14513d9ceacde369b76483bf3cbb4798a7b Mon Sep 17 00:00:00 2001 From: John Safranek Date: Tue, 23 Jun 2026 15:36:35 -0700 Subject: [PATCH 1/4] Discard guessed KEX packet in DoKexDhGexGroup - DoKexDhGexGroup didn't check ignoreNextKexMsg, so a wrong first_kex_packet_follows guess was parsed as a real group and errored instead of being silently discarded (RFC 4253 7.1). - Add the guard already used by DoKexDhReply / DoKexDhInit: consume the packet, clear the flag, return WS_SUCCESS. - Extend TestFirstPacketFollowsSkipped via a new wolfSSH_TestDoKexDhGexGroup wrapper. Issue: F-2866 --- src/internal.c | 18 +++++++++++++++++- tests/regress.c | 2 ++ wolfssh/internal.h | 2 ++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/internal.c b/src/internal.c index 8b7fd28cc..c05ea5950 100644 --- a/src/internal.c +++ b/src/internal.c @@ -7411,10 +7411,20 @@ static int DoKexDhGexGroup(WOLFSSH* ssh, word32 begin; int ret = WS_SUCCESS; - if (ssh == NULL || buf == NULL || len == 0 || idx == NULL) + if (ssh == NULL || ssh->handshake == NULL || buf == NULL || len == 0 || + idx == NULL) ret = WS_BAD_ARGUMENT; if (ret == WS_SUCCESS) { + if (ssh->handshake->ignoreNextKexMsg) { + /* skip this message. */ + WLOG(WS_LOG_DEBUG, "Skipping server's KEXDH_GEX_GROUP message due " + "to first_packet_follows guess mismatch."); + ssh->handshake->ignoreNextKexMsg = 0; + *idx += len; + return WS_SUCCESS; + } + begin = *idx; ret = GetMpint(&primeGroupSz, &primeGroup, buf, len, &begin); if (ret == WS_SUCCESS && primeGroupSz > (MAX_KEX_KEY_SZ + 1)) { @@ -20078,6 +20088,12 @@ int wolfSSH_TestDoKexDhGexRequest(WOLFSSH* ssh, byte* buf, word32 len, return DoKexDhGexRequest(ssh, buf, len, idx); } +int wolfSSH_TestDoKexDhGexGroup(WOLFSSH* ssh, byte* buf, word32 len, + word32* idx) +{ + return DoKexDhGexGroup(ssh, buf, len, idx); +} + int wolfSSH_TestValidateKexDhGexGroup(const byte* primeGroup, word32 primeGroupSz, const byte* generator, word32 generatorSz, word32 minBits, word32 maxBits, WC_RNG* rng) diff --git a/tests/regress.c b/tests/regress.c index 00a407fcd..6c6c7a86f 100644 --- a/tests/regress.c +++ b/tests/regress.c @@ -2948,6 +2948,8 @@ static void TestFirstPacketFollowsSkipped(void) #ifndef WOLFSSH_NO_DH_GEX_SHA256 RunFirstPacketFollowsSkipCase(wolfSSH_TestDoKexDhGexRequest, "DoKexDhGexRequest", WOLFSSH_ENDPOINT_SERVER, CLIENT_KEXINIT_DONE); + RunFirstPacketFollowsSkipCase(wolfSSH_TestDoKexDhGexGroup, + "DoKexDhGexGroup", WOLFSSH_ENDPOINT_CLIENT, SERVER_KEXINIT_DONE); #endif RunFirstPacketFollowsSkipCase(wolfSSH_TestDoKexDhReply, "DoKexDhReply", WOLFSSH_ENDPOINT_CLIENT, SERVER_KEXINIT_DONE); diff --git a/wolfssh/internal.h b/wolfssh/internal.h index 7a3c31ca8..bc3f68d5e 100644 --- a/wolfssh/internal.h +++ b/wolfssh/internal.h @@ -1496,6 +1496,8 @@ enum WS_MessageIdLimits { #ifndef WOLFSSH_NO_DH_GEX_SHA256 WOLFSSH_API int wolfSSH_TestDoKexDhGexRequest(WOLFSSH* ssh, byte* buf, word32 len, word32* idx); + WOLFSSH_API int wolfSSH_TestDoKexDhGexGroup(WOLFSSH* ssh, byte* buf, + word32 len, word32* idx); WOLFSSH_API int wolfSSH_TestValidateKexDhGexGroup(const byte* primeGroup, word32 primeGroupSz, const byte* generator, word32 generatorSz, word32 minBits, word32 maxBits, WC_RNG* rng); From 4bc2f242667cd77a89631930d74bbebe6284b8a6 Mon Sep 17 00:00:00 2001 From: John Safranek Date: Tue, 23 Jun 2026 15:43:33 -0700 Subject: [PATCH 2/4] Select DH GEX group from client size window - Server GEX ignored the client's min/preferred/max and always sent group 14, silently downgrading a 4096-min client (RFC 4419). - Add SelectKexDhGexGroup(): pick the built-in group within [min, max] closest to preferred, ties favoring the smaller. - Reject with WS_DH_SIZE_E when no built-in group fits. - GetDHPrimeGroup() now takes the WOLFSSH so the group sent, the exchange hash, and the shared secret all reselect consistently. - Add a unit test (default, max-cap, out-of-window, 4096-min, 1024-only). Issue: F-55 --- src/internal.c | 141 ++++++++++++++++++++++++++++--- tests/unit.c | 204 +++++++++++++++++++++++++++++++++++++++++++++ wolfssh/internal.h | 6 ++ 3 files changed, 341 insertions(+), 10 deletions(-) diff --git a/src/internal.c b/src/internal.c index c05ea5950..603f5725d 100644 --- a/src/internal.c +++ b/src/internal.c @@ -12764,11 +12764,77 @@ static int BuildRFC6187Info(WOLFSSH* ssh, int pubKeyID, #endif /* WOLFSSH_CERTS */ +#ifndef WOLFSSH_NO_DH_GEX_SHA256 +/* Pick the in-window group closest to preferredBits, ties favoring the + * smaller. WS_DH_SIZE_E if none fits. */ +static int SelectKexDhGexGroup(word32 minBits, word32 preferredBits, + word32 maxBits, const byte** primeGroup, word32* primeGroupSz) +{ + static const struct { + word32 bits; + const byte* group; + const word32* groupSz; + } candidates[] = { + #ifndef WOLFSSH_NO_DH_GROUP1_SHA1 + { 1024, dhPrimeGroup1, &dhPrimeGroup1Sz }, + #endif + { 2048, dhPrimeGroup14, &dhPrimeGroup14Sz }, + #ifndef WOLFSSH_NO_DH_GROUP16_SHA512 + { 4096, dhPrimeGroup16, &dhPrimeGroup16Sz }, + #endif + }; + word32 i; + word32 best = 0; + word32 bestDelta = 0; + int haveBest = 0; + int ret = WS_SUCCESS; + + if (primeGroup == NULL || primeGroupSz == NULL) + return WS_BAD_ARGUMENT; + + for (i = 0; i < (word32)(sizeof(candidates) / sizeof(candidates[0])); i++) { + word32 bits = candidates[i].bits; + word32 delta; + + if (bits < minBits || bits > maxBits) + continue; + delta = (bits > preferredBits) ? (bits - preferredBits) + : (preferredBits - bits); + /* Ascending scan with a strict '<' keeps the smaller group on a tie. */ + if (!haveBest || delta < bestDelta) { + best = i; + bestDelta = delta; + haveBest = 1; + } + } + + if (!haveBest) { + WLOG(WS_LOG_DEBUG, + "DH GEX: no built-in group within client window [%u, %u]", + minBits, maxBits); + ret = WS_DH_SIZE_E; + } + else { + *primeGroup = candidates[best].group; + *primeGroupSz = *candidates[best].groupSz; + } + + return ret; +} +#endif /* !WOLFSSH_NO_DH_GEX_SHA256 */ + + #ifndef WOLFSSH_NO_DH -static int GetDHPrimeGroup(int kexId, const byte** primeGroup, +static int GetDHPrimeGroup(WOLFSSH* ssh, const byte** primeGroup, word32* primeGroupSz, const byte** generator, word32* generatorSz) { int ret = WS_SUCCESS; + int kexId; + + if (ssh == NULL || ssh->handshake == NULL) + return WS_BAD_ARGUMENT; + + kexId = ssh->handshake->kexId; switch (kexId) { #ifndef WOLFSSH_NO_DH_GROUP1_SHA1 @@ -12805,10 +12871,27 @@ static int GetDHPrimeGroup(int kexId, const byte** primeGroup, #endif #ifndef WOLFSSH_NO_DH_GEX_SHA256 case ID_DH_GEX_SHA256: - *primeGroup = dhPrimeGroup14; - *primeGroupSz = dhPrimeGroup14Sz; - *generator = dhGenerator; - *generatorSz = dhGeneratorSz; + /* Reuse the group SendKexDhGexGroup cached on the handshake so the + * exchange hash and the shared secret match the group that was put + * on the wire. Fall back to selecting from the client's window if + * the cache is somehow unset, so this path can never desynchronize + * from the wire group. */ + if (ssh->handshake->primeGroup != NULL) { + *primeGroup = ssh->handshake->primeGroup; + *primeGroupSz = ssh->handshake->primeGroupSz; + *generator = dhGenerator; + *generatorSz = dhGeneratorSz; + } + else { + ret = SelectKexDhGexGroup(ssh->handshake->dhGexMinSz, + ssh->handshake->dhGexPreferredSz, + ssh->handshake->dhGexMaxSz, + primeGroup, primeGroupSz); + if (ret == WS_SUCCESS) { + *generator = dhGenerator; + *generatorSz = dhGeneratorSz; + } + } break; #endif default: @@ -13222,7 +13305,7 @@ static int SendKexGetSigningKey(WOLFSSH* ssh, if (ssh->handshake->kexId == ID_DH_GEX_SHA256) { byte primeGroupPad = 0, generatorPad = 0; - if (GetDHPrimeGroup(ssh->handshake->kexId, &primeGroup, + if (GetDHPrimeGroup(ssh, &primeGroup, &primeGroupSz, &generator, &generatorSz) != WS_SUCCESS) { ret = WS_BAD_ARGUMENT; } @@ -13470,7 +13553,7 @@ static int KeyAgreeDh_server(WOLFSSH* ssh, byte hashId, byte* f, word32* fSz) WOLFSSH_UNUSED(hashId); if (ret == WS_SUCCESS) { - ret = GetDHPrimeGroup(ssh->handshake->kexId, &primeGroup, + ret = GetDHPrimeGroup(ssh, &primeGroup, &primeGroupSz, &generator, &generatorSz); if (ret == WS_SUCCESS) { @@ -14974,8 +15057,8 @@ int SendKexDhGexGroup(WOLFSSH* ssh) byte* output; word32 idx = 0; word32 payloadSz; - const byte* primeGroup = dhPrimeGroup14; - word32 primeGroupSz = dhPrimeGroup14Sz; + const byte* primeGroup = NULL; + word32 primeGroupSz = 0; const byte* generator = dhGenerator; word32 generatorSz = dhGeneratorSz; byte primePad = 0; @@ -14986,6 +15069,30 @@ int SendKexDhGexGroup(WOLFSSH* ssh) if (ssh == NULL || ssh->handshake == NULL) ret = WS_BAD_ARGUMENT; + /* Pick a group that satisfies the client's requested min/preferred/max + * rather than always sending the 2048-bit group 14. */ + if (ret == WS_SUCCESS) { + ret = SelectKexDhGexGroup(ssh->handshake->dhGexMinSz, + ssh->handshake->dhGexPreferredSz, + ssh->handshake->dhGexMaxSz, + &primeGroup, &primeGroupSz); + } + + /* Cache the selected group so the exchange hash and shared secret + * (GetDHPrimeGroup) reuse exactly what goes on the wire here. */ + if (ret == WS_SUCCESS) { + if (ssh->handshake->primeGroup != NULL) + WFREE(ssh->handshake->primeGroup, ssh->ctx->heap, DYNTYPE_MPINT); + ssh->handshake->primeGroup = + (byte*)WMALLOC(primeGroupSz, ssh->ctx->heap, DYNTYPE_MPINT); + if (ssh->handshake->primeGroup == NULL) + ret = WS_MEMORY_E; + else { + WMEMCPY(ssh->handshake->primeGroup, primeGroup, primeGroupSz); + ssh->handshake->primeGroupSz = primeGroupSz; + } + } + if (ret == WS_SUCCESS) { if (primeGroup[0] & 0x80) primePad = 1; @@ -20034,7 +20141,7 @@ int wolfSSH_TestSetDhKexKey(WOLFSSH* ssh) word32 eSz = (word32)sizeof(e); int keyInited = 0; - ret = GetDHPrimeGroup(ssh->handshake->kexId, &primeGroup, &primeGroupSz, + ret = GetDHPrimeGroup(ssh, &primeGroup, &primeGroupSz, &generator, &generatorSz); if (ret == WS_SUCCESS) { ret = wc_InitDhKey(privKey); @@ -20064,6 +20171,13 @@ int wolfSSH_TestSetDhKexKey(WOLFSSH* ssh) return ret; } +int wolfSSH_TestGetDHPrimeGroup(WOLFSSH* ssh, const byte** primeGroup, + word32* primeGroupSz, const byte** generator, word32* generatorSz) +{ + return GetDHPrimeGroup(ssh, primeGroup, primeGroupSz, + generator, generatorSz); +} + #endif /* !WOLFSSH_NO_DH */ #ifndef WOLFSSH_NO_ECDH @@ -20102,6 +20216,13 @@ int wolfSSH_TestValidateKexDhGexGroup(const byte* primeGroup, generator, generatorSz, minBits, maxBits, rng); } +int wolfSSH_TestSelectKexDhGexGroup(word32 minBits, word32 preferredBits, + word32 maxBits, const byte** primeGroup, word32* primeGroupSz) +{ + return SelectKexDhGexGroup(minBits, preferredBits, maxBits, + primeGroup, primeGroupSz); +} + #endif /* !WOLFSSH_NO_DH_GEX_SHA256 */ #ifndef WOLFSSH_NO_RSA diff --git a/tests/unit.c b/tests/unit.c index 536999c14..dd27f15d5 100644 --- a/tests/unit.c +++ b/tests/unit.c @@ -1269,6 +1269,200 @@ static int test_DhGexGroupValidate(void) return result; } +/* Server-side group selection for DH GEX. Confirms the server honors the + * client's requested min/preferred/max window instead of always handing back + * the 2048-bit group 14, and rejects windows no built-in group can satisfy. */ +static int test_DhGexGroupSelect(void) +{ + const byte* group; + word32 groupSz; + int ret; + + /* Default-ish window: with group 16 enabled, preferred 3072 is equidistant + * from 2048 and 4096 and the tie favors the smaller group; without group 16, + * 2048 is simply the closest in-window candidate. Either way a stock client + * still receives the historical 2048-bit group (256 bytes). */ + group = NULL; groupSz = 0; + ret = wolfSSH_TestSelectKexDhGexGroup(1024, 3072, 8192, &group, &groupSz); + if (ret != WS_SUCCESS || groupSz != 256) { + printf("DhGexGroupSelect: default window got ret %d sz %u\n", + ret, groupSz); + return -200; + } + + /* A max below 4096 must cap the choice even when preferred is huge. */ + group = NULL; groupSz = 0; + ret = wolfSSH_TestSelectKexDhGexGroup(1024, 8192, 2048, &group, &groupSz); + if (ret != WS_SUCCESS || groupSz != 256) { + printf("DhGexGroupSelect: max-cap window got ret %d sz %u\n", + ret, groupSz); + return -201; + } + + /* A window no built-in group falls inside must be rejected, not silently + * served a group outside it. */ + group = NULL; groupSz = 0; + ret = wolfSSH_TestSelectKexDhGexGroup(3000, 3000, 3500, &group, &groupSz); + if (ret != WS_DH_SIZE_E) { + printf("DhGexGroupSelect: impossible window got ret %d\n", ret); + return -202; + } + +#ifndef WOLFSSH_NO_DH_GROUP16_SHA512 + /* A client demanding a 4096-bit minimum gets the 4096-bit group (512 + * bytes), never a silent downgrade to 2048. */ + group = NULL; groupSz = 0; + ret = wolfSSH_TestSelectKexDhGexGroup(4096, 4096, 8192, &group, &groupSz); + if (ret != WS_SUCCESS || groupSz != 512) { + printf("DhGexGroupSelect: 4096 min got ret %d sz %u\n", ret, groupSz); + return -203; + } +#else + /* Without group 16 there is nothing >= 4096, so the request is rejected + * rather than downgraded. */ + group = NULL; groupSz = 0; + ret = wolfSSH_TestSelectKexDhGexGroup(4096, 4096, 8192, &group, &groupSz); + if (ret != WS_DH_SIZE_E) { + printf("DhGexGroupSelect: 4096 min (no group16) got ret %d\n", ret); + return -203; + } +#endif + +#ifndef WOLFSSH_NO_DH_GROUP1_SHA1 + /* A window only the 1024-bit group fits returns group 1 (128 bytes). */ + group = NULL; groupSz = 0; + ret = wolfSSH_TestSelectKexDhGexGroup(1024, 1024, 1536, &group, &groupSz); + if (ret != WS_SUCCESS || groupSz != 128) { + printf("DhGexGroupSelect: 1024 window got ret %d sz %u\n", + ret, groupSz); + return -204; + } +#endif + + return 0; +} + +/* Send sink so SendKexDhGexGroup can run the real server path (select the + * group, build the KEXDH_GEX_GROUP packet, drain it) without a live transport. + * We only care that the group it selects gets cached on the handshake. */ +static int GexSinkSend(WOLFSSH* ssh, void* buf, word32 sz, void* ctx) +{ + (void)ssh; + (void)buf; + (void)ctx; + return (int)sz; +} + +/* Write a big-endian uint32 without the internal-only c32toa(). */ +static void PutU32BE(byte* out, word32 v) +{ + out[0] = (byte)(v >> 24); + out[1] = (byte)(v >> 16); + out[2] = (byte)(v >> 8); + out[3] = (byte)(v); +} + +/* One send/hash consistency case. Runs the real server request path for the + * given client window, then confirms GetDHPrimeGroup hands the exchange-hash + * and key-agreement path the exact group SendKexDhGexGroup cached and put on + * the wire. A zero expectSz skips the size assertion. Failure codes are + * offset from base so each caller reports distinctly. */ +static int DhGexSendHashConsistencyCase(word32 minBits, word32 prefBits, + word32 maxBits, word32 expectSz, int base) +{ + WOLFSSH_CTX* ctx = NULL; + WOLFSSH* ssh = NULL; + byte request[UINT32_SZ * 3]; + word32 idx = 0; + const byte* hashGroup = NULL; + word32 hashGroupSz = 0; + const byte* generator = NULL; + word32 generatorSz = 0; + int ret; + int result = 0; + + ctx = wolfSSH_CTX_new(WOLFSSH_ENDPOINT_SERVER, NULL); + if (ctx == NULL) + return base; + wolfSSH_SetIOSend(ctx, GexSinkSend); + ssh = wolfSSH_new(ctx); + if (ssh == NULL || ssh->handshake == NULL) { + result = base - 1; + goto out; + } + ssh->handshake->kexId = ID_DH_GEX_SHA256; + + PutU32BE(request, minBits); + PutU32BE(request + UINT32_SZ, prefBits); + PutU32BE(request + UINT32_SZ * 2, maxBits); + + /* Real server entry point: parse the window, select the group, send it on + * the wire, and cache it on the handshake. */ + ret = wolfSSH_TestDoKexDhGexRequest(ssh, request, sizeof(request), &idx); + if (ret != WS_SUCCESS) { + printf("DhGexSendHashConsistency: request ret %d\n", ret); + result = base - 2; + goto out; + } + if (ssh->handshake->primeGroup == NULL || + (expectSz != 0 && ssh->handshake->primeGroupSz != expectSz)) { + printf("DhGexSendHashConsistency: wire group sz %u want %u\n", + ssh->handshake->primeGroupSz, expectSz); + result = base - 3; + goto out; + } + + /* The exchange-hash / key-agreement path must hand back the identical group + * -- same pointer, same size -- not an independently re-selected one. */ + ret = wolfSSH_TestGetDHPrimeGroup(ssh, &hashGroup, &hashGroupSz, + &generator, &generatorSz); + if (ret != WS_SUCCESS) { + printf("DhGexSendHashConsistency: hash group ret %d\n", ret); + result = base - 4; + goto out; + } + if (hashGroup != ssh->handshake->primeGroup || + hashGroupSz != ssh->handshake->primeGroupSz) { + printf("DhGexSendHashConsistency: hash group sz %u != wire %u\n", + hashGroupSz, ssh->handshake->primeGroupSz); + result = base - 5; + goto out; + } + +out: + if (ssh != NULL) + wolfSSH_free(ssh); + if (ctx != NULL) + wolfSSH_CTX_free(ctx); + return result; +} + +/* End-to-end consistency check for DH GEX server group selection: the group + * SendKexDhGexGroup puts on the wire must be the exact group GetDHPrimeGroup + * hands the exchange hash. */ +static int test_DhGexGroupSendHashConsistency(void) +{ + int ret; + + /* Default client window. Covers the cache plumbing on every build, + * including group14-only ones. */ + ret = DhGexSendHashConsistencyCase(WOLFSSH_DEFAULT_GEXDH_MIN, + WOLFSSH_DEFAULT_GEXDH_PREFERRED, WOLFSSH_DEFAULT_GEXDH_MAX, + 0, -210); + if (ret != 0) + return ret; + +#ifndef WOLFSSH_NO_DH_GROUP16_SHA512 + /* Force the 4096-bit group 16 (512 bytes) so a send/hash divergence can't + * hide behind both paths independently landing on the default group 14. */ + ret = DhGexSendHashConsistencyCase(4096, 4096, 8192, 512, -230); + if (ret != 0) + return ret; +#endif + + return 0; +} + #endif /* WOLFSSH_TEST_INTERNAL && !WOLFSSH_NO_DH_GEX_SHA256 */ @@ -7651,6 +7845,16 @@ int wolfSSH_UnitTest(int argc, char** argv) printf("DhGexGroupValidate: %s\n", (unitResult == 0 ? "SUCCESS" : "FAILED")); testResult = testResult || unitResult; + + unitResult = test_DhGexGroupSelect(); + printf("DhGexGroupSelect: %s\n", + (unitResult == 0 ? "SUCCESS" : "FAILED")); + testResult = testResult || unitResult; + + unitResult = test_DhGexGroupSendHashConsistency(); + printf("DhGexGroupSendHashConsistency: %s\n", + (unitResult == 0 ? "SUCCESS" : "FAILED")); + testResult = testResult || unitResult; #endif #ifdef WOLFSSH_TEST_INTERNAL diff --git a/wolfssh/internal.h b/wolfssh/internal.h index bc3f68d5e..692df6561 100644 --- a/wolfssh/internal.h +++ b/wolfssh/internal.h @@ -1486,6 +1486,9 @@ enum WS_MessageIdLimits { WOLFSSH_API int wolfSSH_TestKeyAgreeDh_server(WOLFSSH* ssh, byte hashId, byte* f, word32* fSz); WOLFSSH_API int wolfSSH_TestSetDhKexKey(WOLFSSH* ssh); + WOLFSSH_API int wolfSSH_TestGetDHPrimeGroup(WOLFSSH* ssh, + const byte** primeGroup, word32* primeGroupSz, + const byte** generator, word32* generatorSz); #endif /* !WOLFSSH_NO_DH */ #ifndef WOLFSSH_NO_ECDH WOLFSSH_API int wolfSSH_TestKeyAgreeEcdh_server(WOLFSSH* ssh, byte hashId, @@ -1501,6 +1504,9 @@ enum WS_MessageIdLimits { WOLFSSH_API int wolfSSH_TestValidateKexDhGexGroup(const byte* primeGroup, word32 primeGroupSz, const byte* generator, word32 generatorSz, word32 minBits, word32 maxBits, WC_RNG* rng); + WOLFSSH_API int wolfSSH_TestSelectKexDhGexGroup(word32 minBits, + word32 preferredBits, word32 maxBits, const byte** primeGroup, + word32* primeGroupSz); #endif /* !WOLFSSH_NO_DH_GEX_SHA256 */ #ifndef WOLFSSH_NO_RSA WOLFSSH_API int wolfSSH_TestRsaVerify(const byte* sig, word32 sigSz, From 1d2d2a08ef1dff9f514b790f9887bd6f417bfab3 Mon Sep 17 00:00:00 2001 From: John Safranek Date: Tue, 23 Jun 2026 17:19:38 -0700 Subject: [PATCH 3/4] Enforce 2048-bit floor on DH GEX group select - Clamp client's lower bound up to WOLFSSH_DH_GEX_MIN_BITS (default 2048) before the candidate scan; prevents downgrade to group 1. - Define WOLFSSH_DH_GEX_MIN_BITS in wolfssh/internal.h so both the build and the unit tests can see the configured floor. - Reject a window whose max is below the floor with WS_DH_SIZE_E. - Keep group 1 in the candidate set so a lowered floor stays usable. - Cap the client window to MAX_KEX_KEY_SZ so selection can never pick a group larger than the server's own key buffers (defense-in-depth for builds enabling group 16 with a lowered WOLFSSH_DEFAULT_GEXDH_MAX). - Relabel the no-candidate log "effective window" so the clamped min is not mistaken for the raw client request. - Note the client-side DoKexDhGexGroup ignoreNextKexMsg skip as defensive symmetry not expected to fire. - Update test_DhGexGroupSelect for rejection and clamp-up cases, gating the 2048-specific expectations on the configured floor; add test_DhGexGroupCacheMissFallback covering the GetDHPrimeGroup cache-miss re-selection path. Issue: F-55 --- src/internal.c | 20 +++++-- tests/unit.c | 126 ++++++++++++++++++++++++++++++++++++++++++--- wolfssh/internal.h | 5 ++ 3 files changed, 140 insertions(+), 11 deletions(-) diff --git a/src/internal.c b/src/internal.c index 603f5725d..f1338acfa 100644 --- a/src/internal.c +++ b/src/internal.c @@ -7417,7 +7417,10 @@ static int DoKexDhGexGroup(WOLFSSH* ssh, if (ret == WS_SUCCESS) { if (ssh->handshake->ignoreNextKexMsg) { - /* skip this message. */ + /* A conformant server sends GROUP only in response to the client's + * REQUEST, so it should never set first_packet_follows here. + * Discard the message defensively if a peer sets it anyway (RFC + * 4253 7.1), mirroring the other Do* handlers. */ WLOG(WS_LOG_DEBUG, "Skipping server's KEXDH_GEX_GROUP message due " "to first_packet_follows guess mismatch."); ssh->handshake->ignoreNextKexMsg = 0; @@ -12765,8 +12768,9 @@ static int BuildRFC6187Info(WOLFSSH* ssh, int pubKeyID, #ifndef WOLFSSH_NO_DH_GEX_SHA256 + /* Pick the in-window group closest to preferredBits, ties favoring the - * smaller. WS_DH_SIZE_E if none fits. */ + * smaller. WS_DH_SIZE_E if none fits. See WOLFSSH_DH_GEX_MIN_BITS. */ static int SelectKexDhGexGroup(word32 minBits, word32 preferredBits, word32 maxBits, const byte** primeGroup, word32* primeGroupSz) { @@ -12776,6 +12780,7 @@ static int SelectKexDhGexGroup(word32 minBits, word32 preferredBits, const word32* groupSz; } candidates[] = { #ifndef WOLFSSH_NO_DH_GROUP1_SHA1 + /* Only reachable with a lowered WOLFSSH_DH_GEX_MIN_BITS. */ { 1024, dhPrimeGroup1, &dhPrimeGroup1Sz }, #endif { 2048, dhPrimeGroup14, &dhPrimeGroup14Sz }, @@ -12792,6 +12797,15 @@ static int SelectKexDhGexGroup(word32 minBits, word32 preferredBits, if (primeGroup == NULL || primeGroupSz == NULL) return WS_BAD_ARGUMENT; + /* Clamp up to the floor; a max below it leaves no candidate, so we reject + * rather than downgrade. */ + if (minBits < WOLFSSH_DH_GEX_MIN_BITS) + minBits = WOLFSSH_DH_GEX_MIN_BITS; + + /* Cap to the shared secret buffer in KeyAgreeDh_server. */ + if (maxBits > (word32)(MAX_KEX_KEY_SZ * 8)) + maxBits = (word32)(MAX_KEX_KEY_SZ * 8); + for (i = 0; i < (word32)(sizeof(candidates) / sizeof(candidates[0])); i++) { word32 bits = candidates[i].bits; word32 delta; @@ -12810,7 +12824,7 @@ static int SelectKexDhGexGroup(word32 minBits, word32 preferredBits, if (!haveBest) { WLOG(WS_LOG_DEBUG, - "DH GEX: no built-in group within client window [%u, %u]", + "DH GEX: no built-in group within effective window [%u, %u]", minBits, maxBits); ret = WS_DH_SIZE_E; } diff --git a/tests/unit.c b/tests/unit.c index dd27f15d5..2b0b3a740 100644 --- a/tests/unit.c +++ b/tests/unit.c @@ -1271,13 +1271,17 @@ static int test_DhGexGroupValidate(void) /* Server-side group selection for DH GEX. Confirms the server honors the * client's requested min/preferred/max window instead of always handing back - * the 2048-bit group 14, and rejects windows no built-in group can satisfy. */ + * the 2048-bit group 14, and rejects windows no built-in group can satisfy. + * The concrete group sizes below are only meaningful at the default 2048-bit + * floor; a build that overrides WOLFSSH_DH_GEX_MIN_BITS gets the + * floor-relative checks instead. */ static int test_DhGexGroupSelect(void) { const byte* group; word32 groupSz; int ret; +#if WOLFSSH_DH_GEX_MIN_BITS == 2048 /* Default-ish window: with group 16 enabled, preferred 3072 is equidistant * from 2048 and 4096 and the tie favors the smaller group; without group 16, * 2048 is simply the closest in-window candidate. Either way a stock client @@ -1328,16 +1332,47 @@ static int test_DhGexGroupSelect(void) } #endif -#ifndef WOLFSSH_NO_DH_GROUP1_SHA1 - /* A window only the 1024-bit group fits returns group 1 (128 bytes). */ + /* A sub-2048 window must be rejected, not downgraded to the 1024-bit group + * 1, even when group 1 is compiled in for direct group1-sha1: the GEX path + * enforces the WOLFSSH_DH_GEX_MIN_BITS (2048) floor from RFC 8270. The + * client's max (1536) is below the floor, so no candidate fits. */ group = NULL; groupSz = 0; ret = wolfSSH_TestSelectKexDhGexGroup(1024, 1024, 1536, &group, &groupSz); - if (ret != WS_SUCCESS || groupSz != 128) { - printf("DhGexGroupSelect: 1024 window got ret %d sz %u\n", + if (ret != WS_DH_SIZE_E) { + printf("DhGexGroupSelect: sub-2048 window got ret %d sz %u\n", ret, groupSz); return -204; } -#endif + + /* A window whose minimum is below the floor but whose max admits 2048 must + * clamp up to the 2048-bit group (256 bytes), never the 1024-bit group. */ + group = NULL; groupSz = 0; + ret = wolfSSH_TestSelectKexDhGexGroup(1024, 1024, 4096, &group, &groupSz); + if (ret != WS_SUCCESS || groupSz != 256) { + printf("DhGexGroupSelect: floor-clamp window got ret %d sz %u\n", + ret, groupSz); + return -205; + } +#else + /* A window topping out below the floor is rejected, never downgraded. */ + group = NULL; groupSz = 0; + ret = wolfSSH_TestSelectKexDhGexGroup(1, 1, WOLFSSH_DH_GEX_MIN_BITS - 1, + &group, &groupSz); + if (ret != WS_DH_SIZE_E) { + printf("DhGexGroupSelect: sub-floor window got ret %d sz %u\n", + ret, groupSz); + return -206; + } + + /* A wide-open window never selects a group below the floor. */ + group = NULL; groupSz = 0; + ret = wolfSSH_TestSelectKexDhGexGroup(1, 1, 8192, &group, &groupSz); + if (ret == WS_SUCCESS && (groupSz * 8) < (word32)WOLFSSH_DH_GEX_MIN_BITS) { + printf("DhGexGroupSelect: floor %d violated, sz %u\n", + WOLFSSH_DH_GEX_MIN_BITS, groupSz); + return -207; + } +#endif /* WOLFSSH_DH_GEX_MIN_BITS == 2048 */ return 0; } @@ -1445,14 +1480,15 @@ static int test_DhGexGroupSendHashConsistency(void) int ret; /* Default client window. Covers the cache plumbing on every build, - * including group14-only ones. */ + * including group14-only ones. A floor raised above the largest built-in + * group leaves nothing to select, and this case reports that. */ ret = DhGexSendHashConsistencyCase(WOLFSSH_DEFAULT_GEXDH_MIN, WOLFSSH_DEFAULT_GEXDH_PREFERRED, WOLFSSH_DEFAULT_GEXDH_MAX, 0, -210); if (ret != 0) return ret; -#ifndef WOLFSSH_NO_DH_GROUP16_SHA512 +#if !defined(WOLFSSH_NO_DH_GROUP16_SHA512) && WOLFSSH_DH_GEX_MIN_BITS == 2048 /* Force the 4096-bit group 16 (512 bytes) so a send/hash divergence can't * hide behind both paths independently landing on the default group 14. */ ret = DhGexSendHashConsistencyCase(4096, 4096, 8192, 512, -230); @@ -1463,6 +1499,75 @@ static int test_DhGexGroupSendHashConsistency(void) return 0; } +/* Exercise the GetDHPrimeGroup cache-miss fallback: with the cached group + * cleared, it must re-select the same group from the client window (and set a + * generator), not desync from the wire. */ +static int test_DhGexGroupCacheMissFallback(void) +{ + WOLFSSH_CTX* ctx = NULL; + WOLFSSH* ssh = NULL; + byte request[UINT32_SZ * 3]; + word32 idx = 0; + const byte* group = NULL; + word32 groupSz = 0; + const byte* generator = NULL; + word32 generatorSz = 0; + word32 wireSz = 0; + int ret; + int result = 0; + + ctx = wolfSSH_CTX_new(WOLFSSH_ENDPOINT_SERVER, NULL); + if (ctx == NULL) + return -240; + wolfSSH_SetIOSend(ctx, GexSinkSend); + ssh = wolfSSH_new(ctx); + if (ssh == NULL || ssh->handshake == NULL) { + result = -241; + goto out; + } + ssh->handshake->kexId = ID_DH_GEX_SHA256; + + PutU32BE(request, WOLFSSH_DEFAULT_GEXDH_MIN); + PutU32BE(request + UINT32_SZ, WOLFSSH_DEFAULT_GEXDH_PREFERRED); + PutU32BE(request + UINT32_SZ * 2, WOLFSSH_DEFAULT_GEXDH_MAX); + + ret = wolfSSH_TestDoKexDhGexRequest(ssh, request, sizeof(request), &idx); + if (ret != WS_SUCCESS || ssh->handshake->primeGroup == NULL) { + printf("DhGexGroupCacheMissFallback: request ret %d\n", ret); + result = -242; + goto out; + } + wireSz = ssh->handshake->primeGroupSz; + + /* Drop the cache to force the fallback branch. primeGroup is a WMALLOC'd + * copy; the fallback returns a pointer into static group data. */ + WFREE(ssh->handshake->primeGroup, ssh->ctx->heap, DYNTYPE_MPINT); + ssh->handshake->primeGroup = NULL; + + ret = wolfSSH_TestGetDHPrimeGroup(ssh, &group, &groupSz, + &generator, &generatorSz); + if (ret != WS_SUCCESS) { + printf("DhGexGroupCacheMissFallback: fallback ret %d\n", ret); + result = -243; + goto out; + } + /* Must re-select the group the wire used, with a non-NULL generator. */ + if (group == NULL || groupSz != wireSz || generator == NULL || + generatorSz == 0) { + printf("DhGexGroupCacheMissFallback: group sz %u wire %u gen %p\n", + groupSz, wireSz, (void*)generator); + result = -244; + goto out; + } + +out: + if (ssh != NULL) + wolfSSH_free(ssh); + if (ctx != NULL) + wolfSSH_CTX_free(ctx); + return result; +} + #endif /* WOLFSSH_TEST_INTERNAL && !WOLFSSH_NO_DH_GEX_SHA256 */ @@ -7855,6 +7960,11 @@ int wolfSSH_UnitTest(int argc, char** argv) printf("DhGexGroupSendHashConsistency: %s\n", (unitResult == 0 ? "SUCCESS" : "FAILED")); testResult = testResult || unitResult; + + unitResult = test_DhGexGroupCacheMissFallback(); + printf("DhGexGroupCacheMissFallback: %s\n", + (unitResult == 0 ? "SUCCESS" : "FAILED")); + testResult = testResult || unitResult; #endif #ifdef WOLFSSH_TEST_INTERNAL diff --git a/wolfssh/internal.h b/wolfssh/internal.h index 692df6561..42283646d 100644 --- a/wolfssh/internal.h +++ b/wolfssh/internal.h @@ -519,6 +519,11 @@ enum NameIdType { #ifndef WOLFSSH_DEFAULT_GEXDH_MAX #define WOLFSSH_DEFAULT_GEXDH_MAX 8192 #endif +/* Floor on the GEX modulus the server selects (RFC 8270). Lowering it below + * 2048 re-enables group 1, when group 1 is compiled in. */ +#ifndef WOLFSSH_DH_GEX_MIN_BITS + #define WOLFSSH_DH_GEX_MIN_BITS 2048 +#endif #ifndef MAX_KEX_KEY_SZ #ifndef WOLFSSH_NO_NISTP384_MLKEM1024_SHA384 /* Private key size of ML-KEM 1024. Biggest artifact. */ From e78e6046aaccc6ec0a6efd281872441ee674fc0b Mon Sep 17 00:00:00 2001 From: John Safranek Date: Thu, 9 Jul 2026 00:41:02 -0700 Subject: [PATCH 4/4] Enforce the DH GEX floor on both ends of the exchange The floor added in 1d2d2a08 only ran on the server's group selection. A wolfSSH client would still accept whatever group a server handed back, and would advertise a minimum it did not itself enforce. - Raise WOLFSSH_DEFAULT_GEXDH_MIN 1024 -> 2048 and enforce WOLFSSH_DH_GEX_MIN_BITS in DoKexDhGexGroup, so a server cannot hand the client a weak group by ignoring the requested min. - Clamp the advertised min up to the floor in SendKexDhGexRequest, and pull preferred up with it so the advertised triple stays min <= preferred. Reject with WS_DH_SIZE_E when the configured max is below the floor, rather than emitting a request whose only valid replies the accept path would refuse. Previously a lowered WOLFSSH_DEFAULT_GEXDH_MIN advertised a min DoKexDhGexGroup rejected. - Cache the selected group on the handshake in SendKexDhGexGroup and reuse it from GetDHPrimeGroup, so the group on the wire, the exchange hash, and the shared secret cannot diverge. - Move KEX_F_SIZE to wolfssh/internal.h behind an #ifndef guard, so SelectKexDhGexGroup and the unit tests can bound the window by the server's f buffer. - Add wolfSSH_TestSendKexDhGexRequest and wolfSSH_TestGetDHPrimeGroup wrappers; cover the request clamp, the sub-floor rejection, the send/hash consistency of the selected group, and a real 4096-bit group 16 key agreement. Issue: F-55 --- src/internal.c | 92 ++++++---- tests/unit.c | 406 +++++++++++++++++++++++++++++++++++++-------- wolfssh/internal.h | 25 ++- 3 files changed, 415 insertions(+), 108 deletions(-) diff --git a/src/internal.c b/src/internal.c index f1338acfa..70df0f06f 100644 --- a/src/internal.c +++ b/src/internal.c @@ -7439,9 +7439,16 @@ static int DoKexDhGexGroup(WOLFSSH* ssh, ret = GetMpint(&generatorSz, &generator, buf, len, &begin); if (ret == WS_SUCCESS) { + /* Enforce the floor on accept as well as on select, so a server can't + * hand us a weak group by ignoring the min we asked for (RFC 8270). */ + word32 minBits = ssh->handshake->dhGexMinSz; + + if (minBits < WOLFSSH_DH_GEX_MIN_BITS) + minBits = WOLFSSH_DH_GEX_MIN_BITS; + ret = ValidateKexDhGexGroup(primeGroup, primeGroupSz, generator, generatorSz, - ssh->handshake->dhGexMinSz, + minBits, ssh->handshake->dhGexMaxSz, ssh->rng); } @@ -12630,19 +12637,6 @@ struct wolfSSH_sigKeyBlockFull { } sk; }; -#ifndef WOLFSSH_NO_NISTP384_MLKEM1024_SHA384 - /* Size of ML-KEM-1024 ciphertext (1568) plus ECC P-384 component (97). */ - #define KEX_F_SIZE 1700 -#elif !defined(WOLFSSH_NO_NISTP256_MLKEM768_SHA256) || \ - !defined(WOLFSSH_NO_CURVE25519_MLKEM768_SHA256) - /* Size of ML-KEM-768 public key (1184) plus ECC/X25519 component. */ - #define KEX_F_SIZE 1300 -#elif !defined(WOLFSSH_NO_DH_GROUP16_SHA512) - #define KEX_F_SIZE (512 + 1) -#else - #define KEX_F_SIZE (256 + 1) -#endif - #ifdef WOLFSSH_NO_MLDSA #define KEX_SIG_SIZE (512) #else @@ -12769,28 +12763,31 @@ static int BuildRFC6187Info(WOLFSSH* ssh, int pubKeyID, #ifndef WOLFSSH_NO_DH_GEX_SHA256 -/* Pick the in-window group closest to preferredBits, ties favoring the - * smaller. WS_DH_SIZE_E if none fits. See WOLFSSH_DH_GEX_MIN_BITS. */ +/* Pick the smallest in-window group >= preferredBits, else the largest + * in-window group (RFC 4419 sec. 3, as OpenSSH's choose_dh() reads it: the + * comparison is >=, not the RFC's literal >). WS_DH_SIZE_E if none fits. + * See WOLFSSH_DH_GEX_MIN_BITS. */ static int SelectKexDhGexGroup(word32 minBits, word32 preferredBits, word32 maxBits, const byte** primeGroup, word32* primeGroupSz) { + /* Ascending by bits; the scan below depends on that order. No entry may + * exceed (KEX_F_SIZE - 1) * 8 bits, the server's f buffer. */ static const struct { word32 bits; const byte* group; - const word32* groupSz; + word32 groupSz; } candidates[] = { #ifndef WOLFSSH_NO_DH_GROUP1_SHA1 /* Only reachable with a lowered WOLFSSH_DH_GEX_MIN_BITS. */ - { 1024, dhPrimeGroup1, &dhPrimeGroup1Sz }, + { 1024, dhPrimeGroup1, sizeof(dhPrimeGroup1) }, #endif - { 2048, dhPrimeGroup14, &dhPrimeGroup14Sz }, + { 2048, dhPrimeGroup14, sizeof(dhPrimeGroup14) }, #ifndef WOLFSSH_NO_DH_GROUP16_SHA512 - { 4096, dhPrimeGroup16, &dhPrimeGroup16Sz }, + { 4096, dhPrimeGroup16, sizeof(dhPrimeGroup16) }, #endif }; word32 i; word32 best = 0; - word32 bestDelta = 0; int haveBest = 0; int ret = WS_SUCCESS; @@ -12802,24 +12799,24 @@ static int SelectKexDhGexGroup(word32 minBits, word32 preferredBits, if (minBits < WOLFSSH_DH_GEX_MIN_BITS) minBits = WOLFSSH_DH_GEX_MIN_BITS; - /* Cap to the shared secret buffer in KeyAgreeDh_server. */ + /* Cap to the smaller of the shared secret buffer in KeyAgreeDh_server and + * the f buffer in SendKexDhReply, less its mpint sign pad. */ if (maxBits > (word32)(MAX_KEX_KEY_SZ * 8)) maxBits = (word32)(MAX_KEX_KEY_SZ * 8); + if (maxBits > (word32)((KEX_F_SIZE - 1) * 8)) + maxBits = (word32)((KEX_F_SIZE - 1) * 8); + /* Ascending scan: keep the last in-window candidate, and stop at the first + * one that reaches preferredBits. */ for (i = 0; i < (word32)(sizeof(candidates) / sizeof(candidates[0])); i++) { word32 bits = candidates[i].bits; - word32 delta; if (bits < minBits || bits > maxBits) continue; - delta = (bits > preferredBits) ? (bits - preferredBits) - : (preferredBits - bits); - /* Ascending scan with a strict '<' keeps the smaller group on a tie. */ - if (!haveBest || delta < bestDelta) { - best = i; - bestDelta = delta; - haveBest = 1; - } + best = i; + haveBest = 1; + if (bits >= preferredBits) + break; } if (!haveBest) { @@ -12830,7 +12827,7 @@ static int SelectKexDhGexGroup(word32 minBits, word32 preferredBits, } else { *primeGroup = candidates[best].group; - *primeGroupSz = *candidates[best].groupSz; + *primeGroupSz = candidates[best].groupSz; } return ret; @@ -12887,9 +12884,9 @@ static int GetDHPrimeGroup(WOLFSSH* ssh, const byte** primeGroup, case ID_DH_GEX_SHA256: /* Reuse the group SendKexDhGexGroup cached on the handshake so the * exchange hash and the shared secret match the group that was put - * on the wire. Fall back to selecting from the client's window if - * the cache is somehow unset, so this path can never desynchronize - * from the wire group. */ + * on the wire. An unset cache means no GROUP was ever sent (the + * peer skipped GEX_REQUEST); there is no wire group to match, so + * re-select from whatever window the handshake holds. */ if (ssh->handshake->primeGroup != NULL) { *primeGroup = ssh->handshake->primeGroup; *primeGroupSz = ssh->handshake->primeGroupSz; @@ -15028,6 +15025,21 @@ int SendKexDhGexRequest(WOLFSSH* ssh) if (ssh == NULL || ssh->handshake == NULL) ret = WS_BAD_ARGUMENT; + /* Advertise the floor DoKexDhGexGroup enforces on the reply, so a lowered + * WOLFSSH_DEFAULT_GEXDH_MIN can't ask for a group we would then reject. */ + if (ret == WS_SUCCESS) { + if (ssh->handshake->dhGexMinSz < WOLFSSH_DH_GEX_MIN_BITS) + ssh->handshake->dhGexMinSz = WOLFSSH_DH_GEX_MIN_BITS; + if (ssh->handshake->dhGexPreferredSz < ssh->handshake->dhGexMinSz) + ssh->handshake->dhGexPreferredSz = ssh->handshake->dhGexMinSz; + if (ssh->handshake->dhGexMaxSz < ssh->handshake->dhGexMinSz) { + WLOG(WS_LOG_DEBUG, "DH GEX: max %u below the %u-bit floor", + ssh->handshake->dhGexMaxSz, + (word32)WOLFSSH_DH_GEX_MIN_BITS); + ret = WS_DH_SIZE_E; + } + } + if (ret == WS_SUCCESS) { payloadSz = MSG_ID_SZ + (UINT32_SZ * 3); ret = PreparePacket(ssh, payloadSz); @@ -15099,8 +15111,11 @@ int SendKexDhGexGroup(WOLFSSH* ssh) WFREE(ssh->handshake->primeGroup, ssh->ctx->heap, DYNTYPE_MPINT); ssh->handshake->primeGroup = (byte*)WMALLOC(primeGroupSz, ssh->ctx->heap, DYNTYPE_MPINT); - if (ssh->handshake->primeGroup == NULL) + if (ssh->handshake->primeGroup == NULL) { + /* Keep the pointer and size consistent. */ + ssh->handshake->primeGroupSz = 0; ret = WS_MEMORY_E; + } else { WMEMCPY(ssh->handshake->primeGroup, primeGroup, primeGroupSz); ssh->handshake->primeGroupSz = primeGroupSz; @@ -20210,6 +20225,11 @@ int wolfSSH_TestKeyAgreeEcdh_client(WOLFSSH* ssh, byte hashId, #ifndef WOLFSSH_NO_DH_GEX_SHA256 +int wolfSSH_TestSendKexDhGexRequest(WOLFSSH* ssh) +{ + return SendKexDhGexRequest(ssh); +} + int wolfSSH_TestDoKexDhGexRequest(WOLFSSH* ssh, byte* buf, word32 len, word32* idx) { diff --git a/tests/unit.c b/tests/unit.c index 2b0b3a740..b20049a92 100644 --- a/tests/unit.c +++ b/tests/unit.c @@ -1269,6 +1269,51 @@ static int test_DhGexGroupValidate(void) return result; } +#if WOLFSSH_DH_GEX_MIN_BITS == 2048 +/* Assert the selector returns a group of exactly expectSz bytes, and that the + * bytes really are a built-in DH prime: every one of RFC 2409/3526 group 1, + * 14, and 16 begins and ends with 0xFF. Checking the pointer and not just the + * size keeps a mispaired (group, sizeof) entry in candidates[] from passing. + * Returns 0, or code on mismatch. */ +static int DhGexExpectGroup(word32 minBits, word32 prefBits, word32 maxBits, + word32 expectSz, int code, const char* what) +{ + const byte* group = NULL; + word32 groupSz = 0; + int ret; + + ret = wolfSSH_TestSelectKexDhGexGroup(minBits, prefBits, maxBits, + &group, &groupSz); + if (ret != WS_SUCCESS || groupSz != expectSz || group == NULL) { + printf("DhGexGroupSelect: %s got ret %d sz %u want %u\n", + what, ret, groupSz, expectSz); + return code; + } + if (group[0] != 0xFF || group[groupSz - 1] != 0xFF) { + printf("DhGexGroupSelect: %s group is not a built-in prime\n", what); + return code; + } + return 0; +} +#endif /* WOLFSSH_DH_GEX_MIN_BITS == 2048 */ + +/* The selector must reject the window rather than serve a group outside it. */ +static int DhGexExpectReject(word32 minBits, word32 prefBits, word32 maxBits, + int code, const char* what) +{ + const byte* group = NULL; + word32 groupSz = 0; + int ret; + + ret = wolfSSH_TestSelectKexDhGexGroup(minBits, prefBits, maxBits, + &group, &groupSz); + if (ret != WS_DH_SIZE_E) { + printf("DhGexGroupSelect: %s got ret %d sz %u\n", what, ret, groupSz); + return code; + } + return 0; +} + /* Server-side group selection for DH GEX. Confirms the server honors the * client's requested min/preferred/max window instead of always handing back * the 2048-bit group 14, and rejects windows no built-in group can satisfy. @@ -1277,103 +1322,103 @@ static int test_DhGexGroupValidate(void) * floor-relative checks instead. */ static int test_DhGexGroupSelect(void) { - const byte* group; - word32 groupSz; int ret; #if WOLFSSH_DH_GEX_MIN_BITS == 2048 - /* Default-ish window: with group 16 enabled, preferred 3072 is equidistant - * from 2048 and 4096 and the tie favors the smaller group; without group 16, - * 2048 is simply the closest in-window candidate. Either way a stock client - * still receives the historical 2048-bit group (256 bytes). */ - group = NULL; groupSz = 0; - ret = wolfSSH_TestSelectKexDhGexGroup(1024, 3072, 8192, &group, &groupSz); - if (ret != WS_SUCCESS || groupSz != 256) { - printf("DhGexGroupSelect: default window got ret %d sz %u\n", - ret, groupSz); - return -200; + /* NULL out-params are rejected before anything else. */ + if (wolfSSH_TestSelectKexDhGexGroup(2048, 2048, 8192, NULL, NULL) + != WS_BAD_ARGUMENT) { + printf("DhGexGroupSelect: NULL out-params not rejected\n"); + return -199; } + /* Default-ish window. RFC 4419 sec. 3: return the smallest group at or + * above the client's preferred size. With group 16 that is 4096 (512 + * bytes) for a preferred of 3072; without it, nothing reaches 3072 and the + * largest in-window group, 2048 (256 bytes), is returned instead. */ +#ifndef WOLFSSH_NO_DH_GROUP16_SHA512 + ret = DhGexExpectGroup(1024, 3072, 8192, 512, -200, "default window"); +#else + ret = DhGexExpectGroup(1024, 3072, 8192, 256, -200, "default window"); +#endif + if (ret != 0) + return ret; + + /* A preferred size that a group matches exactly takes that group: the + * comparison is >=, not the RFC's literal "larger than". */ + ret = DhGexExpectGroup(2048, 2048, 8192, 256, -201, "exact-preferred"); + if (ret != 0) + return ret; + /* A max below 4096 must cap the choice even when preferred is huge. */ - group = NULL; groupSz = 0; - ret = wolfSSH_TestSelectKexDhGexGroup(1024, 8192, 2048, &group, &groupSz); - if (ret != WS_SUCCESS || groupSz != 256) { - printf("DhGexGroupSelect: max-cap window got ret %d sz %u\n", - ret, groupSz); - return -201; - } + ret = DhGexExpectGroup(1024, 8192, 2048, 256, -202, "max-cap window"); + if (ret != 0) + return ret; /* A window no built-in group falls inside must be rejected, not silently * served a group outside it. */ - group = NULL; groupSz = 0; - ret = wolfSSH_TestSelectKexDhGexGroup(3000, 3000, 3500, &group, &groupSz); - if (ret != WS_DH_SIZE_E) { - printf("DhGexGroupSelect: impossible window got ret %d\n", ret); - return -202; - } + ret = DhGexExpectReject(3000, 3000, 3500, -203, "impossible window"); + if (ret != 0) + return ret; #ifndef WOLFSSH_NO_DH_GROUP16_SHA512 /* A client demanding a 4096-bit minimum gets the 4096-bit group (512 * bytes), never a silent downgrade to 2048. */ - group = NULL; groupSz = 0; - ret = wolfSSH_TestSelectKexDhGexGroup(4096, 4096, 8192, &group, &groupSz); - if (ret != WS_SUCCESS || groupSz != 512) { - printf("DhGexGroupSelect: 4096 min got ret %d sz %u\n", ret, groupSz); - return -203; - } + ret = DhGexExpectGroup(4096, 4096, 8192, 512, -204, "4096 min"); #else /* Without group 16 there is nothing >= 4096, so the request is rejected * rather than downgraded. */ - group = NULL; groupSz = 0; - ret = wolfSSH_TestSelectKexDhGexGroup(4096, 4096, 8192, &group, &groupSz); - if (ret != WS_DH_SIZE_E) { - printf("DhGexGroupSelect: 4096 min (no group16) got ret %d\n", ret); - return -203; - } + ret = DhGexExpectReject(4096, 4096, 8192, -204, "4096 min (no group16)"); #endif + if (ret != 0) + return ret; /* A sub-2048 window must be rejected, not downgraded to the 1024-bit group * 1, even when group 1 is compiled in for direct group1-sha1: the GEX path * enforces the WOLFSSH_DH_GEX_MIN_BITS (2048) floor from RFC 8270. The * client's max (1536) is below the floor, so no candidate fits. */ - group = NULL; groupSz = 0; - ret = wolfSSH_TestSelectKexDhGexGroup(1024, 1024, 1536, &group, &groupSz); - if (ret != WS_DH_SIZE_E) { - printf("DhGexGroupSelect: sub-2048 window got ret %d sz %u\n", - ret, groupSz); - return -204; - } + ret = DhGexExpectReject(1024, 1024, 1536, -205, "sub-2048 window"); + if (ret != 0) + return ret; /* A window whose minimum is below the floor but whose max admits 2048 must - * clamp up to the 2048-bit group (256 bytes), never the 1024-bit group. */ - group = NULL; groupSz = 0; - ret = wolfSSH_TestSelectKexDhGexGroup(1024, 1024, 4096, &group, &groupSz); - if (ret != WS_SUCCESS || groupSz != 256) { - printf("DhGexGroupSelect: floor-clamp window got ret %d sz %u\n", - ret, groupSz); - return -205; - } + * clamp up to the 2048-bit group (256 bytes), never the 1024-bit group. + * The low preferred size keeps the choice at 2048 rather than 4096. */ + ret = DhGexExpectGroup(1024, 1024, 4096, 256, -206, "floor-clamp window"); + if (ret != 0) + return ret; #else - /* A window topping out below the floor is rejected, never downgraded. */ - group = NULL; groupSz = 0; - ret = wolfSSH_TestSelectKexDhGexGroup(1, 1, WOLFSSH_DH_GEX_MIN_BITS - 1, - &group, &groupSz); - if (ret != WS_DH_SIZE_E) { - printf("DhGexGroupSelect: sub-floor window got ret %d sz %u\n", - ret, groupSz); - return -206; - } + /* No built-in group lies in [3000, 3500] at any floor: either the window + * is empty on its own, or the clamped min exceeds the max. */ + ret = DhGexExpectReject(3000, 3000, 3500, -207, "impossible window"); + if (ret != 0) + return ret; + + /* A window topping out below the floor is rejected, never downgraded. + * Guarded so a zero floor cannot underflow the max into 0xFFFFFFFF. */ +#if WOLFSSH_DH_GEX_MIN_BITS >= 2 + ret = DhGexExpectReject(1, 1, WOLFSSH_DH_GEX_MIN_BITS - 1, -209, + "sub-floor window"); + if (ret != 0) + return ret; +#endif /* A wide-open window never selects a group below the floor. */ - group = NULL; groupSz = 0; - ret = wolfSSH_TestSelectKexDhGexGroup(1, 1, 8192, &group, &groupSz); - if (ret == WS_SUCCESS && (groupSz * 8) < (word32)WOLFSSH_DH_GEX_MIN_BITS) { - printf("DhGexGroupSelect: floor %d violated, sz %u\n", - WOLFSSH_DH_GEX_MIN_BITS, groupSz); - return -207; + { + const byte* group = NULL; + word32 groupSz = 0; + + ret = wolfSSH_TestSelectKexDhGexGroup(1, 1, 8192, &group, &groupSz); + if (ret == WS_SUCCESS && + (groupSz * 8) < (word32)WOLFSSH_DH_GEX_MIN_BITS) { + printf("DhGexGroupSelect: floor %d violated, sz %u\n", + WOLFSSH_DH_GEX_MIN_BITS, groupSz); + return -208; + } } #endif /* WOLFSSH_DH_GEX_MIN_BITS == 2048 */ + (void)ret; return 0; } @@ -1488,7 +1533,9 @@ static int test_DhGexGroupSendHashConsistency(void) if (ret != 0) return ret; -#if !defined(WOLFSSH_NO_DH_GROUP16_SHA512) && WOLFSSH_DH_GEX_MIN_BITS == 2048 +#if !defined(WOLFSSH_NO_DH_GROUP16_SHA512) && \ + WOLFSSH_DH_GEX_MIN_BITS == 2048 && \ + (MAX_KEX_KEY_SZ * 8) >= 4096 && ((KEX_F_SIZE - 1) * 8) >= 4096 /* Force the 4096-bit group 16 (512 bytes) so a send/hash divergence can't * hide behind both paths independently landing on the default group 14. */ ret = DhGexSendHashConsistencyCase(4096, 4096, 8192, 512, -230); @@ -1555,7 +1602,7 @@ static int test_DhGexGroupCacheMissFallback(void) if (group == NULL || groupSz != wireSz || generator == NULL || generatorSz == 0) { printf("DhGexGroupCacheMissFallback: group sz %u wire %u gen %p\n", - groupSz, wireSz, (void*)generator); + groupSz, wireSz, (const void*)generator); result = -244; goto out; } @@ -1568,6 +1615,217 @@ static int test_DhGexGroupCacheMissFallback(void) return result; } +#if WOLFSSH_DH_GEX_MIN_BITS == 2048 +/* Drive SendKexDhGexRequest with the window preset on the handshake. The send + * sink swallows the packet; the caller inspects the window it advertised. */ +static int DhGexRequestCase(word32 minBits, word32 prefBits, word32 maxBits, + int expectRet, WOLFSSH_CTX* ctx, WOLFSSH** sshOut) +{ + WOLFSSH* ssh = wolfSSH_new(ctx); + + *sshOut = ssh; + if (ssh == NULL || ssh->handshake == NULL) + return -1; + + ssh->handshake->kexId = ID_DH_GEX_SHA256; + ssh->handshake->dhGexMinSz = minBits; + ssh->handshake->dhGexPreferredSz = prefBits; + ssh->handshake->dhGexMaxSz = maxBits; + + return (wolfSSH_TestSendKexDhGexRequest(ssh) == expectRet) ? 0 : -1; +} + +/* The client must advertise the same minimum DoKexDhGexGroup enforces on the + * reply. Otherwise a lowered WOLFSSH_DEFAULT_GEXDH_MIN asks for a group that + * the accept path then rejects. */ +static int test_DhGexRequestFloorClamp(void) +{ + WOLFSSH_CTX* ctx = NULL; + WOLFSSH* ssh = NULL; + int result = 0; + + ctx = wolfSSH_CTX_new(WOLFSSH_ENDPOINT_CLIENT, NULL); + if (ctx == NULL) + return -260; + wolfSSH_SetIOSend(ctx, GexSinkSend); + + /* A sub-floor min is raised to the floor, and the preferred size rides up + * with it so the advertised triple stays min <= preferred. */ + if (DhGexRequestCase(1024, 1024, 8192, WS_SUCCESS, ctx, &ssh) != 0) { + result = -261; + goto out; + } + if (ssh->handshake->dhGexMinSz != WOLFSSH_DH_GEX_MIN_BITS || + ssh->handshake->dhGexPreferredSz != WOLFSSH_DH_GEX_MIN_BITS) { + printf("DhGexRequestFloorClamp: min %u pref %u want %u\n", + ssh->handshake->dhGexMinSz, ssh->handshake->dhGexPreferredSz, + (word32)WOLFSSH_DH_GEX_MIN_BITS); + result = -262; + goto out; + } + wolfSSH_free(ssh); + ssh = NULL; + + /* A window topping out below the floor has nothing to ask for. Reject it + * here rather than emit a request whose reply we would refuse. */ + if (DhGexRequestCase(1024, 1024, WOLFSSH_DH_GEX_MIN_BITS - 1, + WS_DH_SIZE_E, ctx, &ssh) != 0) { + result = -263; + goto out; + } + wolfSSH_free(ssh); + ssh = NULL; + + /* A window already at or above the floor is advertised untouched. */ + if (DhGexRequestCase(WOLFSSH_DH_GEX_MIN_BITS, 3072, 8192, WS_SUCCESS, + ctx, &ssh) != 0) { + result = -264; + goto out; + } + if (ssh->handshake->dhGexMinSz != WOLFSSH_DH_GEX_MIN_BITS || + ssh->handshake->dhGexPreferredSz != 3072 || + ssh->handshake->dhGexMaxSz != 8192) { + result = -265; + goto out; + } + +out: + if (ssh != NULL) + wolfSSH_free(ssh); + if (ctx != NULL) + wolfSSH_CTX_free(ctx); + return result; +} +#else +/* The cases below pin the 2048-bit floor against concrete window sizes. */ +static int test_DhGexRequestFloorClamp(void) +{ + return 0; +} +#endif /* WOLFSSH_DH_GEX_MIN_BITS == 2048 */ + +/* Drive a real 4096-bit GEX key agreement on the server. Selecting group 16 is + * now the ordinary outcome for a stock client window (preferred 3072), so the + * 512-byte modulus has to survive KeyAgreeDh_server's f buffer (KEX_F_SIZE) + * and shared-secret buffer (ssh->k). Confirms the server's f and k are a real + * DH pair by recomputing the secret from the client side. */ +static int test_DhGexGroup16KeyAgree(void) +{ +#if !defined(WOLFSSH_NO_DH_GROUP16_SHA512) && \ + WOLFSSH_DH_GEX_MIN_BITS == 2048 && \ + (MAX_KEX_KEY_SZ * 8) >= 4096 && ((KEX_F_SIZE - 1) * 8) >= 4096 + WOLFSSH_CTX* ctx = NULL; + WOLFSSH* ssh = NULL; + byte request[UINT32_SZ * 3]; + word32 idx = 0; + const byte* group = NULL; + word32 groupSz = 0; + const byte* generator = NULL; + word32 generatorSz = 0; + DhKey clientKey; + int keyInited = 0; + byte f[KEX_F_SIZE]; + word32 fSz = (word32)sizeof(f); + byte cPriv[MAX_KEX_KEY_SZ]; + word32 cPrivSz = (word32)sizeof(cPriv); + byte cPub[KEX_F_SIZE]; + word32 cPubSz = (word32)sizeof(cPub); + byte cSecret[MAX_KEX_KEY_SZ]; + word32 cSecretSz = (word32)sizeof(cSecret); + int ret; + int result = 0; + + ctx = wolfSSH_CTX_new(WOLFSSH_ENDPOINT_SERVER, NULL); + if (ctx == NULL) + return -250; + wolfSSH_SetIOSend(ctx, GexSinkSend); + ssh = wolfSSH_new(ctx); + if (ssh == NULL || ssh->handshake == NULL) { + result = -251; + goto out; + } + ssh->handshake->kexId = ID_DH_GEX_SHA256; + + /* Window that resolves to group 16 under the RFC 4419 rule. */ + PutU32BE(request, 2048); + PutU32BE(request + UINT32_SZ, 4096); + PutU32BE(request + UINT32_SZ * 2, 8192); + + ret = wolfSSH_TestDoKexDhGexRequest(ssh, request, sizeof(request), &idx); + if (ret != WS_SUCCESS || ssh->handshake->primeGroupSz != 512) { + printf("DhGexGroup16KeyAgree: request ret %d sz %u\n", + ret, ssh->handshake->primeGroupSz); + result = -252; + goto out; + } + + /* Build a client key pair over the very group the server put on the wire. */ + ret = wolfSSH_TestGetDHPrimeGroup(ssh, &group, &groupSz, + &generator, &generatorSz); + if (ret != WS_SUCCESS) { + result = -253; + goto out; + } + if (wc_InitDhKey(&clientKey) != 0) { + result = -254; + goto out; + } + keyInited = 1; + if (wc_DhSetKey(&clientKey, group, groupSz, generator, generatorSz) != 0) { + result = -255; + goto out; + } + if (wc_DhGenerateKeyPair(&clientKey, ssh->rng, cPriv, &cPrivSz, + cPub, &cPubSz) != 0) { + result = -256; + goto out; + } + + /* Hand the client's public value to the server and run the agreement. */ + WMEMCPY(ssh->handshake->e, cPub, cPubSz); + ssh->handshake->eSz = cPubSz; + ssh->kSz = MAX_KEX_KEY_SZ; + + ret = wolfSSH_TestKeyAgreeDh_server(ssh, WC_HASH_TYPE_SHA256, f, &fSz); + if (ret != WS_SUCCESS || fSz == 0 || fSz > 512) { + printf("DhGexGroup16KeyAgree: agree ret %d fSz %u\n", ret, fSz); + result = -257; + goto out; + } + if (ssh->primeGroupSz != 512 || ssh->kSz == 0) { + printf("DhGexGroup16KeyAgree: primeGroupSz %u kSz %u\n", + ssh->primeGroupSz, ssh->kSz); + result = -258; + goto out; + } + + /* The server's k must be the shared secret the client derives from f. */ + if (wc_DhAgree(&clientKey, cSecret, &cSecretSz, cPriv, cPrivSz, + f, fSz) != 0) { + result = -259; + goto out; + } + if (cSecretSz != ssh->kSz || + WMEMCMP(cSecret, ssh->k, cSecretSz) != 0) { + printf("DhGexGroup16KeyAgree: secret mismatch, client %u server %u\n", + cSecretSz, ssh->kSz); + result = -260; + goto out; + } + +out: + if (keyInited) + wc_FreeDhKey(&clientKey); + if (ssh != NULL) + wolfSSH_free(ssh); + if (ctx != NULL) + wolfSSH_CTX_free(ctx); + return result; +#else + return 0; +#endif +} + #endif /* WOLFSSH_TEST_INTERNAL && !WOLFSSH_NO_DH_GEX_SHA256 */ @@ -7956,6 +8214,11 @@ int wolfSSH_UnitTest(int argc, char** argv) (unitResult == 0 ? "SUCCESS" : "FAILED")); testResult = testResult || unitResult; + unitResult = test_DhGexRequestFloorClamp(); + printf("DhGexRequestFloorClamp: %s\n", + (unitResult == 0 ? "SUCCESS" : "FAILED")); + testResult = testResult || unitResult; + unitResult = test_DhGexGroupSendHashConsistency(); printf("DhGexGroupSendHashConsistency: %s\n", (unitResult == 0 ? "SUCCESS" : "FAILED")); @@ -7965,6 +8228,11 @@ int wolfSSH_UnitTest(int argc, char** argv) printf("DhGexGroupCacheMissFallback: %s\n", (unitResult == 0 ? "SUCCESS" : "FAILED")); testResult = testResult || unitResult; + + unitResult = test_DhGexGroup16KeyAgree(); + printf("DhGexGroup16KeyAgree: %s\n", + (unitResult == 0 ? "SUCCESS" : "FAILED")); + testResult = testResult || unitResult; #endif #ifdef WOLFSSH_TEST_INTERNAL diff --git a/wolfssh/internal.h b/wolfssh/internal.h index 42283646d..d81c800e9 100644 --- a/wolfssh/internal.h +++ b/wolfssh/internal.h @@ -511,7 +511,7 @@ enum NameIdType { #define WOLFSSH_MAX_NAMELIST_CNT 64 #endif #ifndef WOLFSSH_DEFAULT_GEXDH_MIN - #define WOLFSSH_DEFAULT_GEXDH_MIN 1024 + #define WOLFSSH_DEFAULT_GEXDH_MIN 2048 #endif #ifndef WOLFSSH_DEFAULT_GEXDH_PREFERRED #define WOLFSSH_DEFAULT_GEXDH_PREFERRED 3072 @@ -519,11 +519,29 @@ enum NameIdType { #ifndef WOLFSSH_DEFAULT_GEXDH_MAX #define WOLFSSH_DEFAULT_GEXDH_MAX 8192 #endif -/* Floor on the GEX modulus the server selects (RFC 8270). Lowering it below - * 2048 re-enables group 1, when group 1 is compiled in. */ +/* Floor on the GEX modulus, enforced when the server selects a group and when + * the client accepts one (RFC 8270). Lowering it below 2048 re-enables group + * 1, when group 1 is compiled in. */ #ifndef WOLFSSH_DH_GEX_MIN_BITS #define WOLFSSH_DH_GEX_MIN_BITS 2048 #endif +/* Size of the buffer holding the server's KEX public value f. For DH this + * bounds the modulus: a p of n bytes needs n+1 here for the mpint sign pad. */ +#ifndef KEX_F_SIZE + #ifndef WOLFSSH_NO_NISTP384_MLKEM1024_SHA384 + /* Size of ML-KEM-1024 ciphertext (1568) plus ECC P-384 component + * (97). */ + #define KEX_F_SIZE 1700 + #elif !defined(WOLFSSH_NO_NISTP256_MLKEM768_SHA256) || \ + !defined(WOLFSSH_NO_CURVE25519_MLKEM768_SHA256) + /* Size of ML-KEM-768 public key (1184) plus ECC/X25519 component. */ + #define KEX_F_SIZE 1300 + #elif !defined(WOLFSSH_NO_DH_GROUP16_SHA512) + #define KEX_F_SIZE (512 + 1) + #else + #define KEX_F_SIZE (256 + 1) + #endif +#endif #ifndef MAX_KEX_KEY_SZ #ifndef WOLFSSH_NO_NISTP384_MLKEM1024_SHA384 /* Private key size of ML-KEM 1024. Biggest artifact. */ @@ -1502,6 +1520,7 @@ enum WS_MessageIdLimits { const byte* f, word32 fSz); #endif /* !WOLFSSH_NO_ECDH */ #ifndef WOLFSSH_NO_DH_GEX_SHA256 + WOLFSSH_API int wolfSSH_TestSendKexDhGexRequest(WOLFSSH* ssh); WOLFSSH_API int wolfSSH_TestDoKexDhGexRequest(WOLFSSH* ssh, byte* buf, word32 len, word32* idx); WOLFSSH_API int wolfSSH_TestDoKexDhGexGroup(WOLFSSH* ssh, byte* buf,