Skip to content

TLS ECH Compliance Fixes#10141

Open
sebastian-carpenter wants to merge 15 commits intowolfSSL:masterfrom
sebastian-carpenter:tls-ech-downgrade
Open

TLS ECH Compliance Fixes#10141
sebastian-carpenter wants to merge 15 commits intowolfSSL:masterfrom
sebastian-carpenter:tls-ech-downgrade

Conversation

@sebastian-carpenter
Copy link
Copy Markdown
Contributor

@sebastian-carpenter sebastian-carpenter commented Apr 6, 2026

Description

This PR fixes several ECH (Encrypted Client Hello) protocol compliance issues in the TLS
implementation, improving correctness of ECH rejection handling, ECHConfig parsing, and inner
ClientHello validation per RFC 9849.

ECH rejection handling (client, §6.1.6 / §6.1.7)

  • After a successful outer handshake where ECH was offered but rejected, the client now sends the
    mandatory ech_required fatal alert (121) and aborts with a new ECH_REQUIRED_E error code.
  • Certificate verification now uses ECHConfig.contents.public_name instead of the SNI hostname
    when ECH is rejected.
  • Responds to CertificateRequest with an empty Certificate message on ECH rejection.
  • Session tickets and PSK resumption are now ignored/rejected during rejected-ECH handshakes.

ECH rejection through HRR (§6.1.4 / §6.1.5)

  • If the HRR contains no ECH extension, the ECH transcript is freed and processing falls back to
    the outer hello.
  • ServerHello accepting ECH after an HRR rejection is now treated as an error.

Inner ClientHello integrity (§6.1)

  • Inner ClientHello is now built with downgrade disabled, preventing TLS < 1.3 from being
    advertised or negotiated.
  • Added echProcessingInner flag to correctly validate INNER/OUTER ECH extension types in their
    respective contexts.
  • The server now verifies the inner ClientHello contains an ECH extension of type inner after
    decryption, and enforces its presence in ClientHello2 when ECH was accepted in ClientHello1.

ECHConfig parsing (§4.2 / §6.2)

  • Rewrote SetEchConfigsEx with a single index variable, eliminating fragile dual-pointer bounds
    tracking.
  • Added EchConfigCheckExtensions: configs with unsupported mandatory extensions (high bit set in
    type) are now correctly skipped.
  • Retry configs in EncryptedExtensions are rejected when ECH was already accepted, and discarded
    on GREASE connections.

ech_outer_extensions handling (§5.1)

  • Merged two near-identical copy loops in TLSX_ECH_CopyOuterExtensions into one, with a proper
    check that ech_outer_extensions never references the ECH extension itself.

Error codes and alerts (§11.2)

  • Added ECH_REQUIRED_E = -518 to wolfSSL_ErrorCodes.
  • Added ech_required = 121 to AlertDescription.
  • Added string representations in AlertTypeToString and wolfSSL_ERR_reason_error_string.

Partly addresses zd#21504

Tests

  • test_wolfSSL_Tls13_ECH_rejected_cert_valid: cert authentication against public_name on
    rejection; expected to pass with a matching CN and fail with a mismatched one (§6.1.7).
  • test_wolfSSL_Tls13_ECH_rejected_empty_client_cert: empty certificate sent on rejection during
    mutual auth (§6.1.7).
  • Tests for downgrade prevention in the inner ClientHello via a WOLFSSL_TEST-only
    echInnerHelloCb hook (§6.1).
  • Tests for mandatory-extension skipping in ECHConfig parsing (§4.2).

Checklist

  • added tests
  • updated/added doxygen
  • updated appropriate READMEs
  • Updated manual and documentation

@sebastian-carpenter sebastian-carpenter self-assigned this Apr 6, 2026
Copilot AI review requested due to automatic review settings April 6, 2026 21:17
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR updates wolfSSL’s TLS 1.3 ECH implementation to better match RFC 9849 requirements across ECH rejection handling, HRR behavior, inner ClientHello integrity checks, and ECHConfig parsing.

Changes:

  • Adds ech_required alert (121) + ECH_REQUIRED_E error code and wires them into alert/error-string mapping.
  • Tightens ECH acceptance/rejection behavior across HRR and outer vs inner ClientHello processing, including session ticket / PSK restrictions on rejected ECH.
  • Reworks ECHConfig parsing to correctly skip configs with unsupported mandatory extensions and adds/updates tests for the new compliance behavior.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
wolfssl/ssl.h Adds ech_required alert value and updates comment pointing to AlertTypeToString location.
wolfssl/internal.h Adds echProcessingInner state flag and a WOLFSSL_TEST-only hook for tampering inner ClientHello.
wolfssl/error-ssl.h Adds ECH_REQUIRED_E and updates WOLFSSL_LAST_E.
tests/api.c Adds new ECH compliance tests and updates GREASE expectations and tamper tests.
src/tls13.c Enforces no downgrade in inner CH, adds ECH rejection fatal alert behavior, and tightens HRR/PSK/session-ticket handling.
src/tls.c Refines ECH parsing, retry-config behavior, and ech_outer_extensions validation; refactors outer extension copying.
src/ssl_ech.c Rewrites SetEchConfigsEx parsing and adds mandatory-extension detection.
src/internal.c Uses ECH public_name for cert name checks on rejection; adds alert/error string mappings.
Comments suppressed due to low confidence (2)

tests/api.c:1

  • This assertion appears to check echConfigs on the client context (c_ctx), but the implementation changes in this PR free/discard configs via ssl->echConfigs (connection-level). If the intent is to ensure the client connection did not retain retry configs on a GREASE connection, this should assert against the appropriate per-connection storage (or use the public getter and expect failure/empty output).
    src/internal.c:1
  • Using pointer inequality against ssl->buffers.domainName.buffer to decide whether to run name checks is brittle and non-obvious (it depends on whether domainName was reassigned and on pointer identity rather than intent). Prefer an explicit boolean (e.g., usingEchPublicName) that is set when switching to ECHConfig.public_name, and use that to drive the conditional.

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

Comment on lines +13923 to 13945
while (numOuterRefs-- > 0) {
ato16(outerRefTypes, &refType);

while (numOuterRefs-- > 0) {
ato16(outerRefTypes, &refType);

if (refType == TLSXT_ECH) {
WOLFSSL_MSG("ECH: ech_outer_extensions references ECH");
ret = INVALID_PARAMETER;
break;
}
if (refType == TLSXT_ECH) {
WOLFSSL_MSG("ECH: ech_outer_extensions references ECH");
ret = INVALID_PARAMETER;
break;
}

outerExtData = TLSX_ECH_FindOuterExtension(outerCh, outerChLen,
refType, &outerExtLen, &outerExtOffset,
&extsStart, &extsLen);
outerExtData = TLSX_ECH_FindOuterExtension(outerCh, outerChLen,
refType, &outerExtLen, &outerExtOffset,
&extsStart, &extsLen);

if (outerExtData == NULL) {
WOLFSSL_MSG("ECH: referenced extension not in outer CH");
ret = INVALID_PARAMETER;
break;
}
if (outerExtData == NULL) {
WOLFSSL_MSG("ECH: referenced extension not in outer CH or out "
"of order");
ret = INVALID_PARAMETER;
break;
}

if (newInnerCh == NULL) {
*newInnerChLen += outerExtLen;

outerRefTypes += OPAQUE16_LEN;
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

*newInnerChLen is no longer initialized to 0 when newInnerCh == NULL. Previously this function explicitly set *newInnerChLen = 0 before accumulating lengths; without that, callers can get incorrect sizes depending on the incoming value. Reintroduce the initialization at function entry (or at least before the loop) when newInnerCh == NULL.

Copilot uses AI. Check for mistakes.
Comment on lines +5992 to +5993
* the client MUST respond with an empty Certificate message. */
if (ssl->echConfigs != NULL && !ssl->options.echAccepted) {
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

This forces an empty client Certificate whenever echConfigs != NULL && !echAccepted, even if ECH was disabled (disableECH) and therefore never offered on the connection. Gate this behavior on ECH actually being in-use (e.g., include !ssl->options.disableECH, and ideally a state indicating ECH was offered) so mutual-auth isn’t broken by merely preloading ECH configs.

Suggested change
* the client MUST respond with an empty Certificate message. */
if (ssl->echConfigs != NULL && !ssl->options.echAccepted) {
* the client MUST respond with an empty Certificate message.
* Do not force an empty certificate when ECH is disabled on this
* connection and configs are only preloaded. */
if (ssl->echConfigs != NULL && !ssl->options.disableECH &&
!ssl->options.echAccepted) {

Copilot uses AI. Check for mistakes.
Comment on lines +14451 to +14452
WOLFSSL_ERROR_VERBOSE(VERIFY_FINISHED_ERROR);
ret = VERIFY_FINISHED_ERROR;
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

This sends a decrypt_error alert but reports/returns VERIFY_FINISHED_ERROR. That mismatch can confuse callers and diagnostics and may trigger incorrect error handling upstream. Return an error code consistent with decrypt_error (and make WOLFSSL_ERROR_VERBOSE(...) match the returned code).

Suggested change
WOLFSSL_ERROR_VERBOSE(VERIFY_FINISHED_ERROR);
ret = VERIFY_FINISHED_ERROR;
WOLFSSL_ERROR_VERBOSE(DECRYPT_ERROR);
ret = DECRYPT_ERROR;

Copilot uses AI. Check for mistakes.
Comment on lines +14274 to +14275
if (echX == NULL)
return BAD_FUNC_ARG;
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

When parsing retry configs in encrypted_extensions, returning BAD_FUNC_ARG for a missing local ECH extension is an internal-API error and doesn’t clearly represent a peer protocol violation. Consider failing with a protocol-appropriate error (and alert) such as unsupported_extension/illegal_parameter/INVALID_PARAMETER, to make the failure mode consistent and diagnosable.

Suggested change
if (echX == NULL)
return BAD_FUNC_ARG;
if (echX == NULL) {
SendAlert(ssl, alert_fatal, unsupported_extension);
WOLFSSL_ERROR_VERBOSE(UNSUPPORTED_EXTENSION);
return UNSUPPORTED_EXTENSION;
}

Copilot uses AI. Check for mistakes.
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.

2 participants