TLS ECH Compliance Fixes#10141
Conversation
There was a problem hiding this comment.
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_requiredalert (121) +ECH_REQUIRED_Eerror 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
echConfigson the client context (c_ctx), but the implementation changes in this PR free/discard configs viassl->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.bufferto decide whether to run name checks is brittle and non-obvious (it depends on whetherdomainNamewas reassigned and on pointer identity rather than intent). Prefer an explicit boolean (e.g.,usingEchPublicName) that is set when switching toECHConfig.public_name, and use that to drive the conditional.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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; | ||
| } |
There was a problem hiding this comment.
*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.
| * the client MUST respond with an empty Certificate message. */ | ||
| if (ssl->echConfigs != NULL && !ssl->options.echAccepted) { |
There was a problem hiding this comment.
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.
| * 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) { |
| WOLFSSL_ERROR_VERBOSE(VERIFY_FINISHED_ERROR); | ||
| ret = VERIFY_FINISHED_ERROR; |
There was a problem hiding this comment.
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).
| WOLFSSL_ERROR_VERBOSE(VERIFY_FINISHED_ERROR); | |
| ret = VERIFY_FINISHED_ERROR; | |
| WOLFSSL_ERROR_VERBOSE(DECRYPT_ERROR); | |
| ret = DECRYPT_ERROR; |
| if (echX == NULL) | ||
| return BAD_FUNC_ARG; |
There was a problem hiding this comment.
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.
| if (echX == NULL) | |
| return BAD_FUNC_ARG; | |
| if (echX == NULL) { | |
| SendAlert(ssl, alert_fatal, unsupported_extension); | |
| WOLFSSL_ERROR_VERBOSE(UNSUPPORTED_EXTENSION); | |
| return UNSUPPORTED_EXTENSION; | |
| } |
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)
mandatory
ech_requiredfatal alert (121) and aborts with a newECH_REQUIRED_Eerror code.ECHConfig.contents.public_nameinstead of the SNI hostnamewhen ECH is rejected.
CertificateRequestwith an emptyCertificatemessage on ECH rejection.ECH rejection through HRR (§6.1.4 / §6.1.5)
the outer hello.
Inner ClientHello integrity (§6.1)
downgradedisabled, preventing TLS < 1.3 from beingadvertised or negotiated.
echProcessingInnerflag to correctly validate INNER/OUTER ECH extension types in theirrespective contexts.
innerafterdecryption, and enforces its presence in ClientHello2 when ECH was accepted in ClientHello1.
ECHConfig parsing (§4.2 / §6.2)
SetEchConfigsExwith a single index variable, eliminating fragile dual-pointer boundstracking.
EchConfigCheckExtensions: configs with unsupported mandatory extensions (high bit set intype) are now correctly skipped.
EncryptedExtensionsare rejected when ECH was already accepted, and discardedon GREASE connections.
ech_outer_extensionshandling (§5.1)TLSX_ECH_CopyOuterExtensionsinto one, with a propercheck that
ech_outer_extensionsnever references the ECH extension itself.Error codes and alerts (§11.2)
ECH_REQUIRED_E = -518towolfSSL_ErrorCodes.ech_required = 121toAlertDescription.AlertTypeToStringandwolfSSL_ERR_reason_error_string.Partly addresses zd#21504
Tests
test_wolfSSL_Tls13_ECH_rejected_cert_valid: cert authentication againstpublic_nameonrejection; 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 duringmutual auth (§6.1.7).
WOLFSSL_TEST-onlyechInnerHelloCbhook (§6.1).Checklist