Skip to content

DH GEX group selection hardening#1056

Open
ejohnstown wants to merge 3 commits into
wolfSSL:masterfrom
ejohnstown:dh-gex
Open

DH GEX group selection hardening#1056
ejohnstown wants to merge 3 commits into
wolfSSL:masterfrom
ejohnstown:dh-gex

Conversation

@ejohnstown

Copy link
Copy Markdown
Contributor

This fixes the server-side Diffie-Hellman Group Exchange (RFC 4419) group selection, which previously ignored the client's requested size window and always sent group 14, and adds a 2048-bit floor to prevent downgrade.

  • Discard a guessed KEX packet in DoKexDhGexGroup: honor ignoreNextKexMsg so a wrong first_kex_packet_follows guess is silently discarded rather than parsed as a real group and errored, matching DoKexDhReply / DoKexDhInit (RFC 4253 7.1). (Issue: F-2866)
  • Select the DH GEX group from the client's [min, preferred, max] size window instead of always sending group 14: add SelectKexDhGexGroup() to pick the closest fitting built-in group, reject with WS_DH_SIZE_E when none fits, and thread the WOLFSSH through GetDHPrimeGroup() so the sent group, exchange hash, and shared secret stay consistent. (Issue: F-55)
  • Enforce a 2048-bit floor (WOLFSSH_DH_GEX_MIN_BITS) on group selection: clamp the client's lower bound up before the scan, reject windows whose max is below the floor, cap the window to MAX_KEX_KEY_SZ, and extend the unit tests for the rejection, clamp-up, and cache-miss fallback paths. (Issue: F-55)

- 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
- 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
Copilot AI review requested due to automatic review settings June 24, 2026 00:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Hardens server-side Diffie-Hellman Group Exchange (DH-GEX, RFC 4419/8270) by selecting a built-in DH group that actually matches the client’s requested size window (with a 2048-bit minimum floor), and ensures consistency between the group sent on the wire and the group used for exchange-hash / shared-secret computation. It also aligns DH-GEX message handlers with existing first_packet_follows skip behavior.

Changes:

  • Add server-side DH-GEX group selection (SelectKexDhGexGroup) honoring the client’s [min, preferred, max] window, with a 2048-bit floor and rejection when no built-in group fits.
  • Cache and reuse the exact selected DH group via the handshake to keep wire-group, exchange hash, and key agreement consistent (updates GetDHPrimeGroup to take WOLFSSH*).
  • Extend internal/regression/unit tests to cover selection, skip-on-first_packet_follows, send-vs-hash consistency, and cache-miss fallback.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
wolfssh/internal.h Exposes new internal-test wrappers for DH-GEX group selection, group handler, and DH prime-group retrieval.
src/internal.c Implements DH-GEX group selection + 2048-bit floor, caches selected group for consistency, updates GetDHPrimeGroup signature, and adds ignoreNextKexMsg handling for DoKexDhGexGroup.
tests/unit.c Adds unit tests for selection behavior, wire-vs-hash consistency, and cache-miss fallback.
tests/regress.c Extends first_packet_follows skip regression coverage to include DoKexDhGexGroup.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/internal.c
- Clamp client's lower bound up to WOLFSSH_DH_GEX_MIN_BITS (default
  2048) before the candidate scan; prevents downgrade to group 1.
- 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; add
  test_DhGexGroupCacheMissFallback covering the GetDHPrimeGroup
  cache-miss re-selection path.

Issue: F-55
@ejohnstown ejohnstown marked this pull request as ready for review June 24, 2026 16:47
@aidangarske aidangarske self-requested a review July 7, 2026 22:10

@aidangarske aidangarske left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skoll Multi-Scan Review

Modes: review + review-security + bugsOverall recommendation: COMMENT
Findings: 6 total — 5 posted, 1 skipped
5 finding(s) posted as inline comments (see file-level comments below)

Posted findings

  • [Medium] [review] Server now rejects GEX windows it can't satisfy (behavior change vs. always-group14)src/internal.c:11847-11851
  • [Low] [review] Send/hash consistency and cache-miss tests are compiled out on group14-only buildstests/unit.c:1246-1330
  • [Low] [bugs] DH GEX selection tests ignore configurable floortests/unit.c:1148-1209
  • [Low] [review+review-security] New null checks use implicit-pointer form instead of != NULLsrc/internal.c:13941
  • [Low] [review] 1024-bit group 1 candidate is unreachable (dead entry)src/internal.c:11802-11804

Skipped findings

  • [Info] Client can now steer server to a 4096-bit GEX group (modest CPU amplification)

Review generated by Skoll

Comment thread src/internal.c
}
}

if (!haveBest) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [Medium] Server now rejects GEX windows it can't satisfy (behavior change vs. always-group14) · Behavior change / interop

Previously the server unconditionally sent group 14 (2048-bit) for every GEX request. Now SelectKexDhGexGroup clamps the client's minBits up to WOLFSSH_DH_GEX_MIN_BITS (2048) and returns WS_DH_SIZE_E when no built-in group falls in the effective window. This is the intended hardening, but it is an externally observable behavior change: a client that requests a window not covered by the compiled-in groups now gets a hard handshake failure instead of a working exchange. Two interop cases worth confirming: (a) a client requesting min > 2048 when WOLFSSH_NO_DH_GROUP16_SHA512 is defined (only 2048 available) — the server now fails the handshake; (b) a build where WOLFSSH_DEFAULT_GEXDH_MAX/MAX_KEX_KEY_SZ caps maxBits below a client's minimum. The stock wolfSSH client (min=1024, pref=3072, max=8192) still resolves to 2048, so wolfSSH↔wolfSSH is unaffected. Verified: minBits is unconditionally raised to the 2048 floor (11822-11823), maxBits is capped to MAX_KEX_KEY_SZ*8 (11828-11829), and the empty-window path returns WS_DH_SIZE_E (11847-11851).

Fix: Confirm this rejection path is acceptable for the interop matrix (especially clients configured with min>2048 on builds without group 16). Optionally log at WS_LOG_INFO/WARN rather than DEBUG so the rejection is diagnosable in the field.

Comment thread tests/unit.c
* the 4096-bit group 16 and confirms the cached wire group and the group that
* GetDHPrimeGroup returns are one and the same -- the property the send/hash
* cache exists to guarantee. */
static int test_DhGexGroupSendHashConsistency(void)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [Low] Send/hash consistency and cache-miss tests are compiled out on group14-only builds · Test coverage

Both test_DhGexGroupSendHashConsistency and test_DhGexGroupCacheMissFallback are entirely wrapped in #ifndef WOLFSSH_NO_DH_GROUP16_SHA512 and return 0 otherwise (verified: unit.c:1248/1318-1322 and 1330). Since group 16 (4096-bit) is disabled in many default configurations, the send-vs-hash cache consistency guarantee — the core property this PR adds — goes completely unexercised on those builds. The consistency is trivially true when only group 14 exists, so this is a reduced-coverage note rather than a correctness gap, but the wire-vs-hash divergence the cache exists to prevent is only tested when group 16 is present.

Fix: Consider a group14-only variant that still asserts GetDHPrimeGroup returns the exact cached handshake->primeGroup pointer/size after SendKexDhGexGroup, so the caching path is covered on default builds too.

Comment thread tests/unit.c
* 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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [Low] DH GEX selection tests ignore configurable floor · Logic Errors

The PR adds WOLFSSH_DH_GEX_MIN_BITS as an overridable compile-time floor in src/internal.c, but the new unit tests assume that floor is always exactly 2048 bits and hard-code 256-byte group14 outcomes. With a valid non-default build such as -DWOLFSSH_DH_GEX_MIN_BITS=4096 and group16 enabled, wolfSSH_TestSelectKexDhGexGroup(1024, 3072, 8192, ...) correctly selects the 4096-bit group, but this test fails because it still expects groupSz == 256. With a lower override, the sub-2048 rejection/floor-clamp checks can similarly fail by allowing group1. This is introduced by the PR because it adds both the configurable floor and tests that do not derive expectations from it.

Fix: Make the floor visible to the tests and condition expected results on it, or only run the 2048-specific assertions when the floor is 2048. For example, move the default WOLFSSH_DH_GEX_MIN_BITS definition to a shared internal header, then guard these checks with #if WOLFSSH_DH_GEX_MIN_BITS == 2048 or compute the expected group size from the configured floor.

Comment thread src/internal.c
* making the wire group the single source of truth instead of re-running
* the selection independently. */
if (ret == WS_SUCCESS) {
if (ssh->handshake->primeGroup)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [Low] New null checks use implicit-pointer form instead of != NULL · style

The new caching block null-checks with if (ssh->handshake->primeGroup) (verified at internal.c:13941) rather than the wolfSSL-preferred if (ssh->handshake->primeGroup != NULL). GetDHPrimeGroup (line 11915) correctly uses != NULL. Behavior is identical and it mirrors the adjacent pre-existing style in DoKexDhGexGroup, so it is locally consistent and purely a style-consistency note, not a defect. Both reviewers flagged the same line: the review mode rated it Low/NIT and the review-security mode rated it Info; the stricter Low is retained here.

Fix: Use if (ssh->handshake->primeGroup != NULL) for the new pointer check to match wolfSSL C style; leave the pre-existing block unchanged and do not reformat surrounding lines.

Comment thread src/internal.c
const byte* group;
const word32* groupSz;
} candidates[] = {
#ifndef WOLFSSH_NO_DH_GROUP1_SHA1

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [Low] 1024-bit group 1 candidate is unreachable (dead entry) · convention

The candidates[] table includes the 1024-bit group 1 (guarded by WOLFSSH_NO_DH_GROUP1_SHA1), but minBits is unconditionally raised to WOLFSSH_DH_GEX_MIN_BITS (2048) before the scan (verified at internal.c:11822-11823), so the bits < minBits check at 11835 always skips the 1024 entry. It can never be selected. This is intentional/defensive per the header comment, and harmless, but the entry is effectively dead code that could mislead a future reader into thinking 1024 is selectable via GEX.

Fix: Either drop the group 1 entry (the floor already guarantees it is never chosen) or keep a one-line note that it is present only for documentary parity; no functional change needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants