From 2835f8d298cf7625f1fe80a84753dc0170ff8f7e Mon Sep 17 00:00:00 2001 From: sebastian-carpenter Date: Wed, 25 Mar 2026 12:04:42 -0600 Subject: [PATCH 01/17] callback for testing TLS ECH: client side hook --- src/tls.c | 74 +++++------- src/tls13.c | 24 +++- tests/api.c | 274 ++++++++++++++++++++++++++++++++++++++++++--- wolfssl/internal.h | 7 ++ 4 files changed, 317 insertions(+), 62 deletions(-) diff --git a/src/tls.c b/src/tls.c index b854b8f8cd..4938cf25fd 100644 --- a/src/tls.c +++ b/src/tls.c @@ -13814,7 +13814,6 @@ static int TLSX_ECH_CheckInnerPadding(WOLFSSL* ssl, WOLFSSL_ECH* ech) acc |= innerCh[i]; } if (acc != 0) { - SendAlert(ssl, alert_fatal, illegal_parameter); return INVALID_PARAMETER; } @@ -13921,51 +13920,35 @@ static int TLSX_ECH_CopyOuterExtensions(const byte* outerCh, word32 outerChLen, word16 extsLen; const byte* outerExtData; - if (newInnerCh == NULL) { - *newInnerChLen = 0; + 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; } - } - else { - while (numOuterRefs-- > 0) { - ato16(outerRefTypes, &refType); - - outerExtData = TLSX_ECH_FindOuterExtension(outerCh, outerChLen, - refType, &outerExtLen, &outerExtOffset, - &extsStart, &extsLen); - - if (outerExtData == NULL) { - ret = INVALID_PARAMETER; - break; - } - + else { XMEMCPY(*newInnerCh, outerExtData, outerExtLen); *newInnerCh += outerExtLen; - - outerRefTypes += OPAQUE16_LEN; } + + outerRefTypes += OPAQUE16_LEN; } return ret; @@ -14314,9 +14297,10 @@ static int TLSX_ECH_Parse(WOLFSSL* ssl, const byte* readBuf, word16 size, ech->state = ECH_PARSED_INTERNAL; return 0; } - else if (ech->type != ECH_TYPE_OUTER) { + else if (ssl->options.echProcessingInner || + ech->type != ECH_TYPE_OUTER) { /* type MUST be INNER or OUTER */ - return BAD_FUNC_ARG; + return INVALID_PARAMETER; } /* Must have kdfId, aeadId, configId, enc len and payload len. */ if (size < offset + 2 + 2 + 1 + 2 + 2) { @@ -14438,7 +14422,14 @@ static int TLSX_ECH_Parse(WOLFSSL* ssl, const byte* readBuf, word16 size, echConfig = echConfig->next; } } - if (ret == 0) { + /* if we failed to extract/expand, set state to retry configs */ + if (ret != 0) { + XFREE(ech->innerClientHello, ssl->heap, DYNAMIC_TYPE_TMP_BUFFER); + ech->innerClientHello = NULL; + ech->state = ECH_WRITE_RETRY_CONFIGS; + ret = 0; + } + else { ret = TLSX_ECH_CheckInnerPadding(ssl, ech); if (ret == 0) { /* expand EchOuterExtensions if present. @@ -14446,14 +14437,11 @@ static int TLSX_ECH_Parse(WOLFSSL* ssl, const byte* readBuf, word16 size, ret = TLSX_ECH_ExpandOuterExtensions(ssl, ech, ssl->heap); } } - /* if we failed to extract/expand, set state to retry configs */ if (ret != 0) { XFREE(ech->innerClientHello, ssl->heap, DYNAMIC_TYPE_TMP_BUFFER); ech->innerClientHello = NULL; - ech->state = ECH_WRITE_RETRY_CONFIGS; } XFREE(aadCopy, ssl->heap, DYNAMIC_TYPE_TMP_BUFFER); - return 0; } return ret; diff --git a/src/tls13.c b/src/tls13.c index a591532be2..076be045d6 100644 --- a/src/tls13.c +++ b/src/tls13.c @@ -4955,6 +4955,14 @@ int SendTls13ClientHello(WOLFSSL* ssl) /* encrypt and pack the ech innerClientHello */ if (ssl->echConfigs != NULL && !ssl->options.disableECH && (ssl->options.echAccepted || args->ech->innerCount == 0)) { +#if defined(WOLFSSL_TEST) + if (ssl->echInnerHelloCb != NULL) { + ret = ssl->echInnerHelloCb(args->ech->innerClientHello, + args->ech->innerClientHelloLen - args->ech->hpke->Nt); + if (ret != 0) + return ret; + } +#endif ret = TLSX_FinalizeEch(args->ech, args->output + RECORD_HEADER_SZ + HANDSHAKE_HEADER_SZ, (word32)(args->sendSz - (RECORD_HEADER_SZ + HANDSHAKE_HEADER_SZ))); @@ -7053,6 +7061,13 @@ int DoTls13ClientHello(WOLFSSL* ssl, const byte* input, word32* inOutIdx, } if (wantDowngrade) { +#if defined(HAVE_ECH) + if (ssl->options.echProcessingInner) { + WOLFSSL_MSG("ECH: inner client hello does not support version " + "less than TLS v1.3"); + ERROR_OUT(INVALID_PARAMETER, exit_dch); + } +#endif #ifndef WOLFSSL_NO_TLS12 byte realMinor; if (!ssl->options.downgrade) { @@ -7213,7 +7228,8 @@ int DoTls13ClientHello(WOLFSSL* ssl, const byte* input, word32* inOutIdx, } #if defined(HAVE_ECH) - if (echX != NULL && ((WOLFSSL_ECH*)echX->data)->state == ECH_WRITE_NONE) { + if (!ssl->options.echProcessingInner && echX != NULL && + ((WOLFSSL_ECH*)echX->data)->state == ECH_WRITE_NONE) { if (((WOLFSSL_ECH*)echX->data)->innerClientHello != NULL) { /* Client sent real ECH and inner hello was decrypted, jump to * exit so the caller can re-invoke with the inner hello */ @@ -13211,12 +13227,16 @@ int DoTls13HandShakeMsgType(WOLFSSL* ssl, byte* input, word32* inOutIdx, *inOutIdx = echInOutIdx; /* call again with the inner hello */ if (ret == 0) { - ((WOLFSSL_ECH*)echX->data)->sniState = ECH_INNER_SNI; + if (((WOLFSSL_ECH*)echX->data)->sniState == ECH_OUTER_SNI) { + ((WOLFSSL_ECH*)echX->data)->sniState = ECH_INNER_SNI; + } + ssl->options.echProcessingInner = 1; ret = DoTls13ClientHello(ssl, ((WOLFSSL_ECH*)echX->data)->innerClientHello, &echInOutIdx, ((WOLFSSL_ECH*)echX->data)->innerClientHelloLen); + ssl->options.echProcessingInner = 0; ((WOLFSSL_ECH*)echX->data)->sniState = ECH_SNI_DONE; } diff --git a/tests/api.c b/tests/api.c index 8f561b0648..5272fad4a9 100644 --- a/tests/api.c +++ b/tests/api.c @@ -14429,22 +14429,6 @@ static int test_wolfSSL_Tls13_ECH_HRR(void) return test_wolfSSL_ECH_conn_ex(wolfTLSv1_3_server_method, wolfTLSv1_3_client_method, 1); } - -static int test_wolfSSL_SubTls13_ECH(void) -{ - EXPECT_DECLS; - -#ifndef WOLFSSL_NO_TLS12 - ExpectIntNE(test_wolfSSL_ECH_conn_ex(wolfTLSv1_3_server_method, - wolfTLSv1_2_client_method, 0), WOLFSSL_SUCCESS); - ExpectIntNE(test_wolfSSL_ECH_conn_ex(wolfTLSv1_2_server_method, - wolfTLSv1_3_client_method, 0), WOLFSSL_SUCCESS); - ExpectIntNE(test_wolfSSL_ECH_conn_ex(wolfSSLv23_server_method, - wolfTLSv1_2_client_method, 0), WOLFSSL_SUCCESS); -#endif - - return EXPECT_RESULT(); -} #endif /* HAVE_IO_TESTS_DEPENDENCIES */ #ifdef HAVE_SSL_MEMIO_TESTS_DEPENDENCIES @@ -15082,6 +15066,259 @@ static int test_wolfSSL_Tls13_ECH_enable_disable(void) return EXPECT_RESULT(); } +#if defined(WOLFSSL_TLS13) && defined(HAVE_ECH) && defined(WOLFSSL_TEST) && \ + defined(HAVE_SSL_MEMIO_TESTS_DEPENDENCIES) && !defined(WOLFSSL_NO_TLS12) +static int ech_tamper_seek_extension(byte* innerCh, word16* innerExtLen) +{ + word16 idx; + byte sessionIdLen; + word16 cipherSuitesLen; + byte compressionLen; + + idx = OPAQUE16_LEN + RAN_LEN; + + sessionIdLen = innerCh[idx++]; + idx += sessionIdLen; + + ato16(innerCh + idx, &cipherSuitesLen); + idx += OPAQUE16_LEN + cipherSuitesLen; + + compressionLen = innerCh[idx++]; + idx += compressionLen; + + ato16(innerCh + idx, innerExtLen); + idx += OPAQUE16_LEN; + + return idx; +} + +static int ech_tamper_find_extension(byte* innerCh, word16* idx_p, + word16 extType) +{ + word16 idx; + word16 innerExtIdx; + word16 innerExtLen; + + idx = innerExtIdx = ech_tamper_seek_extension(innerCh, &innerExtLen); + + while (idx - innerExtIdx < innerExtLen) { + word16 type; + word16 len; + + ato16(innerCh + idx, &type); + if (type == extType) { + *idx_p = idx; + return 0; + } + + idx += OPAQUE16_LEN; + ato16(innerCh + idx, &len); + idx += OPAQUE16_LEN + len; + } + + return BAD_FUNC_ARG; +} + +static int ech_tamper_downgrade(byte* innerCh, word32 innerChLen) +{ + int ret; + word16 idx; + + (void)innerChLen; + + ret = ech_tamper_find_extension(innerCh, &idx, TLSXT_SUPPORTED_VERSIONS); + if (ret == 0) { + /* change extension type to something unknown */ + innerCh[idx] = 0xFA; + innerCh[idx + 1] = 0xFA; + return 0; + } + else { + return ret; + } +} + +static int ech_tamper_padding(byte* innerCh, word32 innerChLen) +{ + word16 idx; + word16 innerExtLen; + + /* get the unpadded length */ + idx = ech_tamper_seek_extension(innerCh, &innerExtLen); + idx += innerExtLen; + + /* no padding, but the test would fail if the message is not incorrect... + * so fail the callback */ + if (idx == innerChLen) { + return BAD_FUNC_ARG; + } + else { + innerCh[idx] = '\x01'; + return 0; + } +} + +static int ech_tamper_type(byte* innerCh, word32 innerChLen) +{ + int ret; + word16 idx; + + (void)innerChLen; + + ret = ech_tamper_find_extension(innerCh, &idx, TLSXT_ECH); + if (ret == 0) { + /* change type to outer */ + innerCh[idx + 4] = ECH_TYPE_OUTER; + return 0; + } + else { + return ret; + } +} + +static int ech_tamper_key_share(byte* innerCh, word32 innerChLen) +{ + int ret; + word16 idx; + word16 len; + + (void)innerChLen; + + ret = ech_tamper_find_extension(innerCh, &idx, TLSXT_KEY_SHARE); + if (ret == 0) { + ato16(innerCh + idx + 8, &len); + if (len == 0) { + return BAD_FUNC_ARG; + } + else { + /* tamper with public key data */ + innerCh[idx + 10] ^= 0xFF; + return 0; + } + } + else { + return ret; + } +} + +static int ech_tamper_ciphersuite(byte* innerCh, word32 innerChLen) +{ + + word16 idx; + byte sessionIdLen; + word16 cipherSuitesLen; + + (void)innerChLen; + + idx = OPAQUE16_LEN + RAN_LEN; + + sessionIdLen = innerCh[idx++]; + idx += sessionIdLen; + + ato16(innerCh + idx, &cipherSuitesLen); + idx += OPAQUE16_LEN; + + if (cipherSuitesLen < 2) { + return BAD_FUNC_ARG; + } + else { + /* change all ciphersuites to unknown value */ + while (cipherSuitesLen > 0) { + innerCh[idx] = '\xFA'; + innerCh[idx + 1] = '\xFA'; + idx += OPAQUE16_LEN; + cipherSuitesLen -= OPAQUE16_LEN; + } + return 0; + } +} + +static int test_wolfSSL_Tls13_ECH_tamper_ex(struct test_ssl_memio_ctx* test_ctx) +{ + EXPECT_DECLS; + + test_ssl_memio_cleanup(test_ctx); + XMEMSET(test_ctx, 0, sizeof(struct test_ssl_memio_ctx)); + + test_ctx->s_cb.method = wolfTLSv1_3_server_method; + test_ctx->c_cb.method = wolfTLSv1_3_client_method; + + test_ctx->s_cb.ctx_ready = test_ech_server_ctx_ready; + test_ctx->s_cb.ssl_ready = test_ech_server_ssl_ready; + test_ctx->c_cb.ssl_ready = test_ech_client_ssl_ready; + + ExpectIntEQ(test_ssl_memio_setup(test_ctx), TEST_SUCCESS); + return EXPECT_RESULT(); +} + +static int test_wolfSSL_Tls13_ECH_tamper(void) +{ + EXPECT_DECLS; + int err; + struct test_ssl_memio_ctx test_ctx; + + XMEMSET(&test_ctx, 0, sizeof(test_ctx)); + + /* try to downgrade to TLS 1.2 in the inner hello */ + test_ctx.s_cb.method = wolfSSLv23_server_method; + test_ctx.c_cb.method = wolfSSLv23_client_method; + + test_ctx.s_cb.ctx_ready = test_ech_server_ctx_ready; + test_ctx.s_cb.ssl_ready = test_ech_server_ssl_ready; + test_ctx.c_cb.ssl_ready = test_ech_client_ssl_ready; + + ExpectIntEQ(test_ssl_memio_setup(&test_ctx), TEST_SUCCESS); + + /* change supported_versions extension type to 0xFAFA: this will encourage a + * downgrade to TLS 1.2 */ + test_ctx.c_ssl->echInnerHelloCb = ech_tamper_downgrade; + + /* the server MUST reject an inner ClientHello that tries to negotiate + * TLS 1.2 or below */ + ExpectIntNE(test_ssl_memio_do_handshake(&test_ctx, 10, NULL), TEST_SUCCESS); + ExpectIntEQ(wolfSSL_get_error(test_ctx.s_ssl, 0), + WC_NO_ERR_TRACE(INVALID_PARAMETER)); + ExpectIntEQ(wolfSSL_get_error(test_ctx.c_ssl, 0), + WC_NO_ERR_TRACE(FATAL_ERROR)); + + /* non-zero padding byte */ + ExpectIntEQ(test_wolfSSL_Tls13_ECH_tamper_ex(&test_ctx), TEST_SUCCESS); + test_ctx.c_ssl->echInnerHelloCb = ech_tamper_padding; + ExpectIntNE(test_ssl_memio_do_handshake(&test_ctx, 10, NULL), TEST_SUCCESS); + if (EXPECT_SUCCESS()) { + /* padding may have a length of zero which is not an error but the + * callback will treat it as such (thus the BAD_FUNC_ARG) */ + err = wolfSSL_get_error(test_ctx.s_ssl, 0); + ExpectTrue(err == WC_NO_ERR_TRACE(INVALID_PARAMETER) || + err == WC_NO_ERR_TRACE(BAD_FUNC_ARG)); + } + ExpectIntEQ(wolfSSL_get_error(test_ctx.c_ssl, 0), + WC_NO_ERR_TRACE(FATAL_ERROR)); + + /* bad ECH type */ + ExpectIntEQ(test_wolfSSL_Tls13_ECH_tamper_ex(&test_ctx), TEST_SUCCESS); + test_ctx.c_ssl->echInnerHelloCb = ech_tamper_type; + ExpectIntNE(test_ssl_memio_do_handshake(&test_ctx, 10, NULL), TEST_SUCCESS); + ExpectIntEQ(wolfSSL_get_error(test_ctx.s_ssl, 0), + WC_NO_ERR_TRACE(INVALID_PARAMETER)); + ExpectIntEQ(wolfSSL_get_error(test_ctx.c_ssl, 0), + WC_NO_ERR_TRACE(FATAL_ERROR)); + + /* corrupted key share (checks that inner key_share is used, not outer) */ + ExpectIntEQ(test_wolfSSL_Tls13_ECH_tamper_ex(&test_ctx), TEST_SUCCESS); + test_ctx.c_ssl->echInnerHelloCb = ech_tamper_key_share; + ExpectIntNE(test_ssl_memio_do_handshake(&test_ctx, 10, NULL), TEST_SUCCESS); + + /* bad ciphersuite */ + ExpectIntEQ(test_wolfSSL_Tls13_ECH_tamper_ex(&test_ctx), TEST_SUCCESS); + test_ctx.c_ssl->echInnerHelloCb = ech_tamper_ciphersuite; + ExpectIntNE(test_ssl_memio_do_handshake(&test_ctx, 10, NULL), TEST_SUCCESS); + + test_ssl_memio_cleanup(&test_ctx); + return EXPECT_RESULT(); +} +#endif /* WOLFSSL_TLS13 && HAVE_ECH && WOLFSSL_TEST && ... */ + #endif /* HAVE_ECH && WOLFSSL_TLS13 */ #if defined(HAVE_IO_TESTS_DEPENDENCIES) && \ @@ -35343,7 +35580,6 @@ TEST_CASE testCases[] = { /* Uses Assert in handshake callback. */ TEST_DECL(test_wolfSSL_Tls13_ECH), TEST_DECL(test_wolfSSL_Tls13_ECH_HRR), - TEST_DECL(test_wolfSSL_SubTls13_ECH), #endif #if defined(HAVE_SSL_MEMIO_TESTS_DEPENDENCIES) TEST_DECL(test_wolfSSL_Tls13_ECH_all_algos), @@ -35352,6 +35588,10 @@ TEST_CASE testCases[] = { TEST_DECL(test_wolfSSL_Tls13_ECH_new_config), TEST_DECL(test_wolfSSL_Tls13_ECH_GREASE), TEST_DECL(test_wolfSSL_Tls13_ECH_disable_conn), +#endif +#if defined(HAVE_SSL_MEMIO_TESTS_DEPENDENCIES) && defined(WOLFSSL_TEST) && \ + !defined(WOLFSSL_NO_TLS12) + TEST_DECL(test_wolfSSL_Tls13_ECH_tamper), #endif TEST_DECL(test_wolfSSL_Tls13_ECH_enable_disable), #endif /* WOLFSSL_TLS13 && HAVE_ECH */ diff --git a/wolfssl/internal.h b/wolfssl/internal.h index 5e512f76eb..e5b4966606 100644 --- a/wolfssl/internal.h +++ b/wolfssl/internal.h @@ -5163,6 +5163,7 @@ struct Options { #if defined(WOLFSSL_TLS13) && defined(HAVE_ECH) word16 echAccepted:1; byte disableECH:1; /* Did the user disable ech */ + byte echProcessingInner:1; /* Processing the inner hello */ #endif #ifdef WOLFSSL_SEND_HRR_COOKIE word16 cookieGood:1; @@ -6482,6 +6483,12 @@ struct WOLFSSL { #if defined(WOLFSSL_TLS13) && defined(HAVE_ECH) WOLFSSL_EchConfig* echConfigs; #endif +#if defined(WOLFSSL_TLS13) && defined(HAVE_ECH) && defined(WOLFSSL_TEST) + /* Test-only hook: called on the client before ECH encryption, after the + * inner ClientHello body is fully constructed. The callback may modify + * innerCh in-place (length stays the same). */ + int (*echInnerHelloCb)(byte* innerCh, word32 innerChLen); +#endif #if defined(WOLFSSL_SNIFFER) && defined(WOLFSSL_SNIFFER_KEYLOGFILE) SSLSnifferSecretCb snifferSecretCb; From fda5538579ad41361542c5cc21e20c5f47bfb20f Mon Sep 17 00:00:00 2001 From: sebastian-carpenter Date: Fri, 27 Mar 2026 09:10:04 -0600 Subject: [PATCH 02/17] add ech_required abort --- src/internal.c | 10 ++++++++ src/tls.c | 10 +++++--- src/tls13.c | 17 +++++++++++++ tests/api.c | 59 ++++++++++++++++++++++++++++++++++++++++++--- wolfssl/error-ssl.h | 4 ++- wolfssl/ssl.h | 5 ++-- 6 files changed, 94 insertions(+), 11 deletions(-) diff --git a/src/internal.c b/src/internal.c index 067b7a6c08..490ac2aac2 100644 --- a/src/internal.c +++ b/src/internal.c @@ -22098,6 +22098,13 @@ const char* AlertTypeToString(int type) return no_application_protocol_str; } + case ech_required: + { + static const char ech_required_str[] = + "ech_required"; + return ech_required_str; + } + default: WOLFSSL_MSG("Unknown Alert"); return NULL; @@ -27710,6 +27717,9 @@ const char* wolfSSL_ERR_reason_error_string(unsigned long e) case SESSION_TICKET_NONCE_OVERFLOW: return "Session ticket nonce overflow"; + + case ECH_REQUIRED_E: + return "ECH offered but rejected by server"; } return "unknown error number"; diff --git a/src/tls.c b/src/tls.c index 4938cf25fd..5815651079 100644 --- a/src/tls.c +++ b/src/tls.c @@ -14293,13 +14293,15 @@ static int TLSX_ECH_Parse(WOLFSSL* ssl, const byte* readBuf, word16 size, ech->type = *readBuf_p; readBuf_p++; offset += 1; - if (ech->type == ECH_TYPE_INNER) { + if (ssl->options.echProcessingInner && ech->type == ECH_TYPE_INNER) { ech->state = ECH_PARSED_INTERNAL; return 0; } - else if (ssl->options.echProcessingInner || - ech->type != ECH_TYPE_OUTER) { - /* type MUST be INNER or OUTER */ + else if ((!ssl->options.echProcessingInner && + ech->type != ECH_TYPE_OUTER) || + (ssl->options.echProcessingInner && + ech->type != ECH_TYPE_INNER)) { + /* MUST process INNER in inner hello and OUTER in outer hello */ return INVALID_PARAMETER; } /* Must have kdfId, aeadId, configId, enc len and payload len. */ diff --git a/src/tls13.c b/src/tls13.c index 076be045d6..6374b2a3a8 100644 --- a/src/tls13.c +++ b/src/tls13.c @@ -7810,6 +7810,11 @@ int SendTls13ServerHello(WOLFSSL* ssl, byte extMsgType) acceptOffset - RECORD_HEADER_SZ, sendSz - RECORD_HEADER_SZ, extMsgType); } +#if defined(WOLFSSL_TEST) + if (ret == 0 && ssl->echInnerHelloCb != NULL) { + ret = ssl->echInnerHelloCb(output, (word32)sendSz); + } +#endif if (extMsgType == hello_retry_request) { /* reset the ech state for round 2 */ ((WOLFSSL_ECH*)echX->data)->state = ECH_WRITE_NONE; @@ -14024,6 +14029,18 @@ int wolfSSL_connect_TLSv13(WOLFSSL* ssl) } #endif /* NO_HANDSHAKE_DONE_CB */ + #if defined(HAVE_ECH) + /* RFC 9849 s6.1.6: if we offered ECH but the server rejected it, + * send ech_required alert and abort before returning to the app */ + if (ssl->echConfigs != NULL && !ssl->options.disableECH && + !ssl->options.echAccepted) { + SendAlert(ssl, alert_fatal, ech_required); + ssl->error = ECH_REQUIRED_E; + WOLFSSL_ERROR_VERBOSE(ECH_REQUIRED_E); + return WOLFSSL_FATAL_ERROR; + } + #endif /* HAVE_ECH */ + if (!ssl->options.keepResources) { FreeHandshakeResources(ssl); } diff --git a/tests/api.c b/tests/api.c index 5272fad4a9..4ab7672470 100644 --- a/tests/api.c +++ b/tests/api.c @@ -15013,6 +15013,56 @@ static int test_wolfSSL_Tls13_ECH_disable_conn(void) return EXPECT_RESULT(); } + +static int test_wolfSSL_Tls13_ECH_ech_required() +{ + EXPECT_DECLS; + test_ssl_memio_ctx test_ctx; + int checkPublic = 1; + + XMEMSET(&test_ctx, 0, sizeof(test_ctx)); + + test_ctx.s_cb.method = wolfTLSv1_3_server_method; + test_ctx.c_cb.method = wolfTLSv1_3_client_method; + + /* both server and client will be setup to use ECH */ + test_ctx.s_cb.ctx_ready = test_ech_server_ctx_ready; + test_ctx.s_cb.ssl_ready = test_ech_server_ssl_ready; + test_ctx.c_cb.ssl_ready = test_ech_client_ssl_ready; + + ExpectIntEQ(test_ssl_memio_setup(&test_ctx), TEST_SUCCESS); + + /* this callback will ensure that the correct SNI is being held */ + wolfSSL_CTX_set_servername_callback(test_ctx.s_ctx, + test_ech_server_sni_callback); + ExpectIntEQ(wolfSSL_CTX_set_servername_arg(test_ctx.s_ctx, &checkPublic), + WOLFSSL_SUCCESS); + + /* disable ECH on the server side so ECH will fail */ + wolfSSL_SetEchEnable(test_ctx.s_ssl, 0); + + /* Reconfigure the server SNI to match the public name */ + ExpectIntEQ(wolfSSL_UseSNI(test_ctx.s_ssl, WOLFSSL_SNI_HOST_NAME, + echCbTestPublicName, (word16)XSTRLEN(echCbTestPublicName)), + WOLFSSL_SUCCESS); + + /* client sends ECH but server can't process it */ + ExpectIntNE(test_ssl_memio_do_handshake(&test_ctx, 10, NULL), + TEST_SUCCESS); + ExpectIntEQ(test_ctx.c_ssl->options.echAccepted, 0); + + /* the server should see the handshake as successful + * the client should abort because the server did not use ECH */ + ExpectIntEQ(wolfSSL_get_error(test_ctx.c_ssl, 0), + WC_NO_ERR_TRACE(ECH_REQUIRED_E)); + ExpectIntEQ(wolfSSL_get_error(test_ctx.s_ssl, 0), + WC_NO_ERR_TRACE(WOLFSSL_ERROR_NONE)); + + test_ssl_memio_cleanup(&test_ctx); + + return EXPECT_RESULT(); +} + #endif /* HAVE_SSL_MEMIO_TESTS_DEPENDENCIES */ /* verify that ECH can be enabled/disabled without issue */ @@ -15203,7 +15253,6 @@ static int ech_tamper_key_share(byte* innerCh, word32 innerChLen) static int ech_tamper_ciphersuite(byte* innerCh, word32 innerChLen) { - word16 idx; byte sessionIdLen; word16 cipherSuitesLen; @@ -15251,7 +15300,7 @@ static int test_wolfSSL_Tls13_ECH_tamper_ex(struct test_ssl_memio_ctx* test_ctx) return EXPECT_RESULT(); } -static int test_wolfSSL_Tls13_ECH_tamper(void) +static int test_wolfSSL_Tls13_ECH_tamper_client(void) { EXPECT_DECLS; int err; @@ -15317,7 +15366,8 @@ static int test_wolfSSL_Tls13_ECH_tamper(void) test_ssl_memio_cleanup(&test_ctx); return EXPECT_RESULT(); } -#endif /* WOLFSSL_TLS13 && HAVE_ECH && WOLFSSL_TEST && ... */ +#endif /* WOLFSSL_TLS13 && HAVE_ECH && WOLFSSL_TEST && + * HAVE_SSL_MEMIO_TESTS_DEPENDENCIES && !WOLFSSL_NO_TLS12 */ #endif /* HAVE_ECH && WOLFSSL_TLS13 */ @@ -35588,10 +35638,11 @@ TEST_CASE testCases[] = { TEST_DECL(test_wolfSSL_Tls13_ECH_new_config), TEST_DECL(test_wolfSSL_Tls13_ECH_GREASE), TEST_DECL(test_wolfSSL_Tls13_ECH_disable_conn), + TEST_DECL(test_wolfSSL_Tls13_ECH_ech_required), #endif #if defined(HAVE_SSL_MEMIO_TESTS_DEPENDENCIES) && defined(WOLFSSL_TEST) && \ !defined(WOLFSSL_NO_TLS12) - TEST_DECL(test_wolfSSL_Tls13_ECH_tamper), + TEST_DECL(test_wolfSSL_Tls13_ECH_tamper_client), #endif TEST_DECL(test_wolfSSL_Tls13_ECH_enable_disable), #endif /* WOLFSSL_TLS13 && HAVE_ECH */ diff --git a/wolfssl/error-ssl.h b/wolfssl/error-ssl.h index 832ae9f440..d6d40f7748 100644 --- a/wolfssl/error-ssl.h +++ b/wolfssl/error-ssl.h @@ -240,7 +240,9 @@ enum wolfSSL_ErrorCodes { SESSION_TICKET_NONCE_OVERFLOW = -517, /* Session ticket nonce overflow */ - WOLFSSL_LAST_E = -517 + ECH_REQUIRED_E = -518, /* ECH offered but rejected by server */ + + WOLFSSL_LAST_E = -518 /* codes -1000 to -1999 are reserved for wolfCrypt. */ }; diff --git a/wolfssl/ssl.h b/wolfssl/ssl.h index df5e272365..fab417a1f8 100644 --- a/wolfssl/ssl.h +++ b/wolfssl/ssl.h @@ -948,7 +948,7 @@ typedef struct WOLFSSL_ALERT_HISTORY { /* Valid Alert types from page 16/17 - * Add alert string to the function AlertTypeToString in src/ssl.c + * Add alert string to the function AlertTypeToString in src/internal.c */ enum AlertDescription { invalid_alert = -1, @@ -986,7 +986,8 @@ enum AlertDescription { bad_certificate_status_response = 113, /**< RFC 6066, section 8 */ unknown_psk_identity = 115, /**< RFC 4279, section 2 */ certificate_required = 116, /**< RFC 8446, section 8.2 */ - no_application_protocol = 120 + no_application_protocol = 120, + ech_required = 121 /**< RFC 9849, section 5 */ }; #ifdef WOLFSSL_MYSQL_COMPATIBLE From 957153f5f664853686e836cf98a200f9537ad63a Mon Sep 17 00:00:00 2001 From: sebastian-carpenter Date: Tue, 31 Mar 2026 10:30:59 -0600 Subject: [PATCH 03/17] fix return value --- src/tls.c | 2 +- src/tls13.c | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/tls.c b/src/tls.c index 5815651079..0a959a083d 100644 --- a/src/tls.c +++ b/src/tls.c @@ -14272,7 +14272,7 @@ static int TLSX_ECH_Parse(WOLFSSL* ssl, const byte* readBuf, word16 size, else if (msgType == hello_retry_request && ssl->echConfigs != NULL) { /* length must be 8 */ if (size != ECH_ACCEPT_CONFIRMATION_SZ) - return BAD_FUNC_ARG; + return DECODE_E; /* get extension */ echX = TLSX_Find(ssl->extensions, TLSX_ECH); diff --git a/src/tls13.c b/src/tls13.c index 6374b2a3a8..b6e7448d17 100644 --- a/src/tls13.c +++ b/src/tls13.c @@ -5142,8 +5142,15 @@ static int EchCheckAcceptance(WOLFSSL* ssl, byte* label, word16 labelSz, } } else { - ssl->options.echAccepted = 0; - ret = 0; + if (msgType != hello_retry_request && ssl->options.echAccepted) { + /* the SH has rejected ECH after the HRR has accepted it + * RFC 9849, section 6.1.5 */ + ssl->options.echAccepted = 0; + ret = INVALID_PARAMETER; + } + else { + ret = 0; + } /* ECH rejected, continue with outer transcript */ FreeHandshakeHashes(ssl); From 9067d1e39ab4a2bffd644a369d043eea953e7d72 Mon Sep 17 00:00:00 2001 From: sebastian-carpenter Date: Tue, 31 Mar 2026 17:10:32 -0600 Subject: [PATCH 04/17] reject configs on grease connection --- src/tls.c | 9 ++++++++- tests/api.c | 9 +++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/tls.c b/src/tls.c index 0a959a083d..ca75640127 100644 --- a/src/tls.c +++ b/src/tls.c @@ -14263,7 +14263,14 @@ static int TLSX_ECH_Parse(WOLFSSL* ssl, const byte* readBuf, word16 size, /* retry configs */ if (msgType == encrypted_extensions) { - ret = wolfSSL_SetEchConfigs(ssl, readBuf, size); + echX = TLSX_Find(ssl->extensions, TLSX_ECH); + if (echX == NULL) + return BAD_FUNC_ARG; + ech = (WOLFSSL_ECH*)echX->data; + + if (ech->state != ECH_WRITE_GREASE) { + ret = wolfSSL_SetEchConfigs(ssl, readBuf, size); + } if (ret == WOLFSSL_SUCCESS) ret = 0; diff --git a/tests/api.c b/tests/api.c index 4ab7672470..2bef79d067 100644 --- a/tests/api.c +++ b/tests/api.c @@ -14872,9 +14872,10 @@ static int test_wolfSSL_Tls13_ECH_new_config(void) /* Test GREASE ECH: * 1. client sends GREASE ECH extension but server has no ECH configs so it - * ignores it, handshake succeeds normally, no ECH configs received + * ignores it, handshake succeeds normally * 2. client sends GREASE ECH extensions and server has ECH configs, handshake - * succeeds and client receives ECH configs */ + * succeeds + * configs should never be received */ static int test_wolfSSL_Tls13_ECH_GREASE(void) { EXPECT_DECLS; @@ -14944,8 +14945,8 @@ static int test_wolfSSL_Tls13_ECH_GREASE(void) * However, configs will be present this time */ ExpectIntEQ(test_ctx.s_ssl->options.echAccepted, 0); ExpectIntEQ(test_ctx.c_ssl->options.echAccepted, 0); - ExpectIntEQ(wolfSSL_GetEchConfigs(test_ctx.c_ssl, greaseConfigs, - &greaseConfigsLen), WOLFSSL_SUCCESS); + /* verify no ECH configs are received */ + ExpectNull(test_ctx.c_ctx->echConfigs); test_ssl_memio_cleanup(&test_ctx); From 220c5a9c440f6604da4599cfb93264d93cf68a63 Mon Sep 17 00:00:00 2001 From: sebastian-carpenter Date: Tue, 31 Mar 2026 17:10:53 -0600 Subject: [PATCH 05/17] error when missing ECH extension after HRR --- src/tls13.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tls13.c b/src/tls13.c index b6e7448d17..d158b88706 100644 --- a/src/tls13.c +++ b/src/tls13.c @@ -7243,6 +7243,10 @@ int DoTls13ClientHello(WOLFSSL* ssl, const byte* input, word32* inOutIdx, goto exit_dch; } else { + if (ssl->options.serverState == + SERVER_HELLO_RETRY_REQUEST_COMPLETE) { + ERROR_OUT(EXT_MISSING, exit_dch); + } /* Server has ECH but client did not send ECH. Clear the * response flag so the empty ECH extension is not written * in EncryptedExtensions. */ From 0dca292892eb952ab29e2ce1f9316ff68a40070d Mon Sep 17 00:00:00 2001 From: sebastian-carpenter Date: Wed, 1 Apr 2026 11:50:49 -0600 Subject: [PATCH 06/17] more compliance fixes for ECH --- src/tls.c | 44 +++++++++++++++++++++++++++++++------------- src/tls13.c | 9 +++++++-- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/tls.c b/src/tls.c index ca75640127..22a463beda 100644 --- a/src/tls.c +++ b/src/tls.c @@ -14263,6 +14263,13 @@ static int TLSX_ECH_Parse(WOLFSSL* ssl, const byte* readBuf, word16 size, /* retry configs */ if (msgType == encrypted_extensions) { + /* configs should only be sent on ECH rejection (RFC9849, Section 5) */ + if (ssl->options.echAccepted) { + SendAlert(ssl, alert_fatal, unsupported_extension); + WOLFSSL_ERROR_VERBOSE(UNSUPPORTED_EXTENSION); + return UNSUPPORTED_EXTENSION; + } + echX = TLSX_Find(ssl->extensions, TLSX_ECH); if (echX == NULL) return BAD_FUNC_ARG; @@ -14279,7 +14286,7 @@ static int TLSX_ECH_Parse(WOLFSSL* ssl, const byte* readBuf, word16 size, else if (msgType == hello_retry_request && ssl->echConfigs != NULL) { /* length must be 8 */ if (size != ECH_ACCEPT_CONFIRMATION_SZ) - return DECODE_E; + return BUFFER_ERROR; /* get extension */ echX = TLSX_Find(ssl->extensions, TLSX_ECH); @@ -14336,10 +14343,10 @@ static int TLSX_ECH_Parse(WOLFSSL* ssl, const byte* readBuf, word16 size, /* Check encLen isn't more than remaining bytes minus * payload length. */ if (len > size - offset - 2) { - return BAD_FUNC_ARG; + return BUFFER_ERROR; } if (len > HPKE_Npk_MAX) { - return BAD_FUNC_ARG; + return BUFFER_ERROR; } /* read enc */ XMEMCPY(ech->enc, readBuf_p, len); @@ -14350,27 +14357,27 @@ static int TLSX_ECH_Parse(WOLFSSL* ssl, const byte* readBuf, word16 size, /* kdfId */ ato16(readBuf_p, &tmpVal16); if (tmpVal16 != ech->cipherSuite.kdfId) { - return BAD_FUNC_ARG; + return INVALID_PARAMETER; } readBuf_p += 2; offset += 2; /* aeadId */ ato16(readBuf_p, &tmpVal16); if (tmpVal16 != ech->cipherSuite.aeadId) { - return BAD_FUNC_ARG; + return INVALID_PARAMETER; } readBuf_p += 2; offset += 2; /* configId */ if (*readBuf_p != ech->configId) { - return BAD_FUNC_ARG; + return INVALID_PARAMETER; } readBuf_p++; offset++; /* on an HRR the enc value MUST be empty */ ato16(readBuf_p, &len); if (len != 0) { - return BAD_FUNC_ARG; + return INVALID_PARAMETER; } readBuf_p += 2; offset += 2; @@ -14383,7 +14390,7 @@ static int TLSX_ECH_Parse(WOLFSSL* ssl, const byte* readBuf, word16 size, offset += 2; /* Check payload is no bigger than remaining bytes. */ if (ech->innerClientHelloLen > size - offset) { - return BAD_FUNC_ARG; + return BUFFER_ERROR; } if (ech->innerClientHelloLen < WC_AES_BLOCK_SIZE) { return BUFFER_ERROR; @@ -14431,12 +14438,23 @@ static int TLSX_ECH_Parse(WOLFSSL* ssl, const byte* readBuf, word16 size, echConfig = echConfig->next; } } - /* if we failed to extract/expand, set state to retry configs */ + /* if we failed to extract/expand */ if (ret != 0) { - XFREE(ech->innerClientHello, ssl->heap, DYNAMIC_TYPE_TMP_BUFFER); - ech->innerClientHello = NULL; - ech->state = ECH_WRITE_RETRY_CONFIGS; - ret = 0; + WOLFSSL_MSG("Failed to decrypt InnerHello"); + if (ech->hpkeContext != NULL) { + /* on SH2 this is fatal */ + SendAlert(ssl, alert_fatal, decrypt_error); + WOLFSSL_ERROR_VERBOSE(VERIFY_FINISHED_ERROR); + ret = VERIFY_FINISHED_ERROR; + } + else { + /* on SH1 prepare to write retry configs */ + XFREE(ech->innerClientHello, ssl->heap, + DYNAMIC_TYPE_TMP_BUFFER); + ech->innerClientHello = NULL; + ech->state = ECH_WRITE_RETRY_CONFIGS; + ret = 0; + } } else { ret = TLSX_ECH_CheckInnerPadding(ssl, ech); diff --git a/src/tls13.c b/src/tls13.c index d158b88706..c6026b8c4d 100644 --- a/src/tls13.c +++ b/src/tls13.c @@ -7243,9 +7243,14 @@ int DoTls13ClientHello(WOLFSSL* ssl, const byte* input, word32* inOutIdx, goto exit_dch; } else { + /* If ECH was accepted in ClientHello1 then ClientHello2 MUST + * contain an ECH extension */ if (ssl->options.serverState == - SERVER_HELLO_RETRY_REQUEST_COMPLETE) { - ERROR_OUT(EXT_MISSING, exit_dch); + SERVER_HELLO_RETRY_REQUEST_COMPLETE && + ssl->options.echAccepted) { + WOLFSSL_MSG("Client did not send an EncryptedClientHello " + "extension"); + ERROR_OUT(INCOMPLETE_DATA, exit_dch); } /* Server has ECH but client did not send ECH. Clear the * response flag so the empty ECH extension is not written From 6bc64abf2f249fd3e875e88c04b8902b62015efa Mon Sep 17 00:00:00 2001 From: sebastian-carpenter Date: Thu, 2 Apr 2026 15:30:38 -0600 Subject: [PATCH 07/17] return empty cert --- src/tls13.c | 8 ++++++++ tests/api.c | 51 ++++++++++++++++++++++++++++----------------------- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/src/tls13.c b/src/tls13.c index c6026b8c4d..32229ad8c8 100644 --- a/src/tls13.c +++ b/src/tls13.c @@ -5965,6 +5965,14 @@ static int DoTls13CertificateRequest(WOLFSSL* ssl, const byte* input, return ret; #endif +#if defined(HAVE_ECH) + /* RFC 9849 s6.1.7: ECH was offered but rejected by the server... + * the client MUST respond with an empty Certificate message. */ + if (ssl->echConfigs != NULL && !ssl->options.echAccepted) { + ssl->options.sendVerify = SEND_BLANK_CERT; + } + else +#endif if ((ssl->buffers.certificate && ssl->buffers.certificate->buffer && ((ssl->buffers.key && ssl->buffers.key->buffer) #ifdef HAVE_PK_CALLBACKS diff --git a/tests/api.c b/tests/api.c index 2bef79d067..0bc42b4b22 100644 --- a/tests/api.c +++ b/tests/api.c @@ -15015,55 +15015,60 @@ static int test_wolfSSL_Tls13_ECH_disable_conn(void) return EXPECT_RESULT(); } -static int test_wolfSSL_Tls13_ECH_ech_required() +/* when ECH is rejected and the server requests a client certificate the client + * must respond with an empty cert */ +static int test_wolfSSL_Tls13_ECH_rejected_empty_client_cert(void) { EXPECT_DECLS; test_ssl_memio_ctx test_ctx; - int checkPublic = 1; + byte echConfigs[512]; + word32 echConfigsLen = sizeof(echConfigs); + const char* publicName = "example.com"; XMEMSET(&test_ctx, 0, sizeof(test_ctx)); test_ctx.s_cb.method = wolfTLSv1_3_server_method; test_ctx.c_cb.method = wolfTLSv1_3_client_method; - /* both server and client will be setup to use ECH */ - test_ctx.s_cb.ctx_ready = test_ech_server_ctx_ready; - test_ctx.s_cb.ssl_ready = test_ech_server_ssl_ready; - test_ctx.c_cb.ssl_ready = test_ech_client_ssl_ready; - ExpectIntEQ(test_ssl_memio_setup(&test_ctx), TEST_SUCCESS); - /* this callback will ensure that the correct SNI is being held */ - wolfSSL_CTX_set_servername_callback(test_ctx.s_ctx, - test_ech_server_sni_callback); - ExpectIntEQ(wolfSSL_CTX_set_servername_arg(test_ctx.s_ctx, &checkPublic), + /* Generate ECH config with public_name matching the server cert SAN */ + ExpectIntEQ(wolfSSL_CTX_GenerateEchConfig(test_ctx.s_ctx, publicName, + 0, 0, 0), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_CTX_GetEchConfigs(test_ctx.s_ctx, echConfigs, + &echConfigsLen), WOLFSSL_SUCCESS); + + /* Client loads ECH configs and sets a private SNI */ + ExpectIntEQ(wolfSSL_SetEchConfigs(test_ctx.c_ssl, echConfigs, + echConfigsLen), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_UseSNI(test_ctx.c_ssl, WOLFSSL_SNI_HOST_NAME, + "ech-private.com", (word16)XSTRLEN("ech-private.com")), WOLFSSL_SUCCESS); - /* disable ECH on the server side so ECH will fail */ + /* Disable ECH on the server ssl so ECH is rejected */ wolfSSL_SetEchEnable(test_ctx.s_ssl, 0); - /* Reconfigure the server SNI to match the public name */ + /* Match the Server SNI to the ECH public_name */ ExpectIntEQ(wolfSSL_UseSNI(test_ctx.s_ssl, WOLFSSL_SNI_HOST_NAME, - echCbTestPublicName, (word16)XSTRLEN(echCbTestPublicName)), - WOLFSSL_SUCCESS); + publicName, (word16)XSTRLEN(publicName)), WOLFSSL_SUCCESS); - /* client sends ECH but server can't process it */ - ExpectIntNE(test_ssl_memio_do_handshake(&test_ctx, 10, NULL), - TEST_SUCCESS); + ExpectIntNE(test_ssl_memio_do_handshake(&test_ctx, 10, NULL), TEST_SUCCESS); ExpectIntEQ(test_ctx.c_ssl->options.echAccepted, 0); - /* the server should see the handshake as successful - * the client should abort because the server did not use ECH */ + /* Server cert is valid for public_name, cert check passes, ech_required + * is sent on the client side. */ ExpectIntEQ(wolfSSL_get_error(test_ctx.c_ssl, 0), WC_NO_ERR_TRACE(ECH_REQUIRED_E)); + + /* The server received an empty Certificate from the client. + * With FAIL_IF_NO_PEER_CERT set, the server aborts with NO_PEER_CERT. */ ExpectIntEQ(wolfSSL_get_error(test_ctx.s_ssl, 0), - WC_NO_ERR_TRACE(WOLFSSL_ERROR_NONE)); + WC_NO_ERR_TRACE(NO_PEER_CERT)); test_ssl_memio_cleanup(&test_ctx); return EXPECT_RESULT(); } - #endif /* HAVE_SSL_MEMIO_TESTS_DEPENDENCIES */ /* verify that ECH can be enabled/disabled without issue */ @@ -35639,7 +35644,7 @@ TEST_CASE testCases[] = { TEST_DECL(test_wolfSSL_Tls13_ECH_new_config), TEST_DECL(test_wolfSSL_Tls13_ECH_GREASE), TEST_DECL(test_wolfSSL_Tls13_ECH_disable_conn), - TEST_DECL(test_wolfSSL_Tls13_ECH_ech_required), + TEST_DECL(test_wolfSSL_Tls13_ECH_rejected_empty_client_cert), #endif #if defined(HAVE_SSL_MEMIO_TESTS_DEPENDENCIES) && defined(WOLFSSL_TEST) && \ !defined(WOLFSSL_NO_TLS12) From 85fb7e4996bb41e713e203852a9ca7649b6d3b14 Mon Sep 17 00:00:00 2001 From: sebastian-carpenter Date: Thu, 2 Apr 2026 16:25:12 -0600 Subject: [PATCH 08/17] remove server ech testing callback --- src/tls13.c | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/tls13.c b/src/tls13.c index 32229ad8c8..30389826f2 100644 --- a/src/tls13.c +++ b/src/tls13.c @@ -7834,11 +7834,6 @@ int SendTls13ServerHello(WOLFSSL* ssl, byte extMsgType) acceptOffset - RECORD_HEADER_SZ, sendSz - RECORD_HEADER_SZ, extMsgType); } -#if defined(WOLFSSL_TEST) - if (ret == 0 && ssl->echInnerHelloCb != NULL) { - ret = ssl->echInnerHelloCb(output, (word32)sendSz); - } -#endif if (extMsgType == hello_retry_request) { /* reset the ech state for round 2 */ ((WOLFSSL_ECH*)echX->data)->state = ECH_WRITE_NONE; From c9cc6e79316634aec249e631c6fc4786cba6e9c0 Mon Sep 17 00:00:00 2001 From: sebastian-carpenter Date: Fri, 3 Apr 2026 10:42:54 -0600 Subject: [PATCH 09/17] prevent client inner from offering downgrade --- src/tls13.c | 15 ++++++++++----- tests/api.c | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/tls13.c b/src/tls13.c index 30389826f2..fa110061dc 100644 --- a/src/tls13.c +++ b/src/tls13.c @@ -4889,7 +4889,8 @@ int SendTls13ClientHello(WOLFSSL* ssl) #if defined(HAVE_ECH) /* write inner then outer */ if (ssl->echConfigs != NULL && !ssl->options.disableECH && - (ssl->options.echAccepted || args->ech->innerCount == 0)) { + (ssl->options.echAccepted || args->ech->innerCount == 0)) { + byte downgrade; /* set the type to inner */ args->ech->type = ECH_TYPE_INNER; /* innerClientHello may already exist from hrr, free if it does */ @@ -4930,11 +4931,15 @@ int SendTls13ClientHello(WOLFSSL* ssl) /* copy the new client random */ XMEMCPY(ssl->arrays->clientRandom, args->output + args->clientRandomOffset, RAN_LEN); - /* write the extensions for inner */ + /* write the extensions for inner + * ensuring that a version less than TLS1.3 is never offered */ args->length = 0; - ret = TLSX_WriteRequest(ssl, args->ech->innerClientHello + args->idx - - (RECORD_HEADER_SZ + HANDSHAKE_HEADER_SZ), client_hello, - &args->length); + downgrade = ssl->options.downgrade; + ssl->options.downgrade = 0; + ret = TLSX_WriteRequest(ssl, args->ech->innerClientHello + + args->idx - (RECORD_HEADER_SZ + HANDSHAKE_HEADER_SZ), + client_hello, &args->length); + ssl->options.downgrade = downgrade; if (ret != 0) return ret; /* set the type to outer */ diff --git a/tests/api.c b/tests/api.c index 0bc42b4b22..b8640bdf31 100644 --- a/tests/api.c +++ b/tests/api.c @@ -15359,7 +15359,7 @@ static int test_wolfSSL_Tls13_ECH_tamper_client(void) ExpectIntEQ(wolfSSL_get_error(test_ctx.c_ssl, 0), WC_NO_ERR_TRACE(FATAL_ERROR)); - /* corrupted key share (checks that inner key_share is used, not outer) */ + /* corrupted key share */ ExpectIntEQ(test_wolfSSL_Tls13_ECH_tamper_ex(&test_ctx), TEST_SUCCESS); test_ctx.c_ssl->echInnerHelloCb = ech_tamper_key_share; ExpectIntNE(test_ssl_memio_do_handshake(&test_ctx, 10, NULL), TEST_SUCCESS); From 2ec83556ef46716dbc8fdf825bfab3b50cfb981b Mon Sep 17 00:00:00 2001 From: sebastian-carpenter Date: Fri, 3 Apr 2026 14:07:36 -0600 Subject: [PATCH 10/17] parse ech config extensions --- src/ssl_ech.c | 192 ++++++++++++++++++++++++++------------------------ tests/api.c | 8 +++ 2 files changed, 108 insertions(+), 92 deletions(-) diff --git a/src/ssl_ech.c b/src/ssl_ech.c index eb9b9a6a5a..aa6593ab2b 100644 --- a/src/ssl_ech.c +++ b/src/ssl_ech.c @@ -446,6 +446,34 @@ void wolfSSL_SetEchEnable(WOLFSSL* ssl, byte enable) } } +/* Walk the ECHConfigExtension list and check for mandatory extensions. + * Returns: + * 0 if all extensions are known/optional, + * 1 if an unsupported mandatory extension (high bit set) is present, + * error otherwise. */ +static int EchConfigCheckExtensions(const byte* exts, word16 extsLen) +{ + word16 bytesLeft = extsLen; + word16 extType; + word16 extDataLen; + + while (bytesLeft >= 4) { + ato16(exts, &extType); + ato16(exts + 2, &extDataLen); + if (bytesLeft - 4 < extDataLen) + return BUFFER_E; + if (extType & 0x8000) + return 1; + exts += 4 + extDataLen; + bytesLeft -= 4 + extDataLen; + } + + if (bytesLeft != 0) + return BUFFER_E; + + return 0; +} + int SetEchConfigsEx(WOLFSSL_EchConfig** outputConfigs, void* heap, const byte* echConfigs, word32 echConfigsLen) { @@ -470,12 +498,13 @@ int SetEchConfigsEx(WOLFSSL_EchConfig** outputConfigs, void* heap, /* check that the total length is well formed */ ato16(echConfigs, &totalLength); - if (totalLength != echConfigsLen - 2) { + if (totalLength != echConfigsLen - 2) return WOLFSSL_FATAL_ERROR; - } + configIdx = 2; do { + /* version (2) + length (2) */ if (configIdx + 4 > echConfigsLen) { ret = BUFFER_E; break; @@ -484,90 +513,86 @@ int SetEchConfigsEx(WOLFSSL_EchConfig** outputConfigs, void* heap, ato16(echConfig, &version); ato16(echConfig + 2, &length); - if (configIdx + length + 4 > echConfigsLen) { + if (configIdx + 4 + length > echConfigsLen) { ret = BUFFER_E; break; } - else if (version != TLSX_ECH) { - /* skip this config and try the next one */ - configIdx += length + 4; + if (version != TLSX_ECH) { + configIdx += 4 + length; continue; } if (workingConfig == NULL) { - workingConfig = - (WOLFSSL_EchConfig*)XMALLOC(sizeof(WOLFSSL_EchConfig), heap, - DYNAMIC_TYPE_TMP_BUFFER); + workingConfig = (WOLFSSL_EchConfig*)XMALLOC( + sizeof(WOLFSSL_EchConfig), heap, DYNAMIC_TYPE_TMP_BUFFER); configList = workingConfig; } else { lastConfig = workingConfig; - workingConfig->next = - (WOLFSSL_EchConfig*)XMALLOC(sizeof(WOLFSSL_EchConfig), heap, - DYNAMIC_TYPE_TMP_BUFFER); + workingConfig->next = (WOLFSSL_EchConfig*)XMALLOC( + sizeof(WOLFSSL_EchConfig), heap, DYNAMIC_TYPE_TMP_BUFFER); workingConfig = workingConfig->next; } - if (workingConfig == NULL) { ret = MEMORY_E; break; } - XMEMSET(workingConfig, 0, sizeof(WOLFSSL_EchConfig)); - /* rawLen */ - workingConfig->rawLen = length + 4; - - /* raw body */ + workingConfig->rawLen = 4 + length; workingConfig->raw = (byte*)XMALLOC(workingConfig->rawLen, heap, - DYNAMIC_TYPE_TMP_BUFFER); + DYNAMIC_TYPE_TMP_BUFFER); if (workingConfig->raw == NULL) { ret = MEMORY_E; break; } - XMEMCPY(workingConfig->raw, echConfig, workingConfig->rawLen); - /* skip over version and length */ + /* version and length already checked */ echConfig += 4; + idx = 0; - idx = 5; - if (idx >= length) { + /* configId */ + if (idx + 1 > length) { ret = BUFFER_E; break; } + workingConfig->configId = echConfig[idx]; + idx += 1; - /* configId, 1 byte */ - workingConfig->configId = *echConfig; - echConfig++; - /* kemId, 2 bytes */ - ato16(echConfig, &workingConfig->kemId); - echConfig += 2; - /* hpke public_key length, 2 bytes */ - ato16(echConfig, &hpkePubkeyLen); - echConfig += 2; + /* kemId */ + if (idx + 2 > length) { + ret = BUFFER_E; + break; + } + ato16(echConfig + idx, &workingConfig->kemId); + idx += 2; /* hpke public_key */ - if (hpkePubkeyLen > HPKE_Npk_MAX || hpkePubkeyLen == 0) { + if (idx + 2 > length) { ret = BUFFER_E; break; } - idx += hpkePubkeyLen; - if (idx >= length) { + ato16(echConfig + idx, &hpkePubkeyLen); + idx += 2; + if (hpkePubkeyLen == 0 || hpkePubkeyLen > HPKE_Npk_MAX) { ret = BUFFER_E; break; } + if (idx + hpkePubkeyLen > length) { + ret = BUFFER_E; + break; + } + XMEMCPY(workingConfig->receiverPubkey, echConfig + idx, hpkePubkeyLen); + idx += hpkePubkeyLen; - XMEMCPY(workingConfig->receiverPubkey, echConfig, hpkePubkeyLen); - echConfig += hpkePubkeyLen; - - /* cipherSuitesLen */ - idx += 2; - if (idx >= length) { + /* cipher suites */ + if (idx + 2 > length) { ret = BUFFER_E; break; } - ato16(echConfig, &cipherSuitesLen); + ato16(echConfig + idx, &cipherSuitesLen); + idx += 2; if (cipherSuitesLen == 0 || cipherSuitesLen % 4 != 0 || cipherSuitesLen >= 1024) { /* numCipherSuites is a byte so only 256 ciphersuites (each 4 bytes) @@ -575,108 +600,91 @@ int SetEchConfigsEx(WOLFSSL_EchConfig** outputConfigs, void* heap, ret = BUFFER_E; break; } - - idx += cipherSuitesLen; - if (idx >= length) { + if (idx + cipherSuitesLen > length) { ret = BUFFER_E; break; } - workingConfig->cipherSuites = (EchCipherSuite*)XMALLOC(cipherSuitesLen, heap, DYNAMIC_TYPE_TMP_BUFFER); if (workingConfig->cipherSuites == NULL) { ret = MEMORY_E; break; } - - echConfig += 2; workingConfig->numCipherSuites = (byte)(cipherSuitesLen / 4); - /* cipherSuites */ for (j = 0; j < workingConfig->numCipherSuites; j++) { - ato16(echConfig, &workingConfig->cipherSuites[j].kdfId); - ato16(echConfig + 2, &workingConfig->cipherSuites[j].aeadId); - echConfig += 4; + ato16(echConfig + idx, &workingConfig->cipherSuites[j].kdfId); + ato16(echConfig + idx + 2, &workingConfig->cipherSuites[j].aeadId); + idx += 4; } - /* ignore the maximum name length */ - idx++; - if (idx >= length) { + /* ignore maximum name length */ + if (idx + 1 > length) { ret = BUFFER_E; break; } - echConfig++; + idx += 1; - /* publicNameLen */ - idx++; - if (idx >= length) { + /* publicName */ + if (idx + 1 > length) { ret = BUFFER_E; break; } - - publicNameLen = *echConfig; + publicNameLen = echConfig[idx]; + idx += 1; if (publicNameLen == 0) { ret = BUFFER_E; break; } - - idx += publicNameLen; - if (idx >= length) { + if (idx + publicNameLen > length) { ret = BUFFER_E; break; } - echConfig++; - - workingConfig->publicName = (char*)XMALLOC(publicNameLen + 1, - heap, DYNAMIC_TYPE_TMP_BUFFER); + workingConfig->publicName = (char*)XMALLOC(publicNameLen + 1, heap, + DYNAMIC_TYPE_TMP_BUFFER); if (workingConfig->publicName == NULL) { ret = MEMORY_E; break; } - - /* publicName */ - XMEMCPY(workingConfig->publicName, echConfig, publicNameLen); + XMEMCPY(workingConfig->publicName, echConfig + idx, publicNameLen); workingConfig->publicName[publicNameLen] = '\0'; - echConfig += publicNameLen; + idx += publicNameLen; - /* TODO: Parse ECHConfigExtension */ - /* --> for now just ignore it */ - idx += 2; - if (idx > length) { + /* extensions */ + if (idx + 2 > length) { ret = BUFFER_E; break; } - ato16(echConfig, &extensionsLen); - - idx += extensionsLen; - if (idx != length) { + ato16(echConfig + idx, &extensionsLen); + idx += 2; + if (idx + extensionsLen != length) { ret = BUFFER_E; break; } - /* KEM or ciphersuite not supported, free this config and then try to - * parse another */ - if (EchConfigGetSupportedCipherSuite(workingConfig) < 0) { + ret = EchConfigCheckExtensions(echConfig + idx, extensionsLen); + if (ret < 0) + break; + + /* KEM, ciphersuite, or mandatory extension not supported, free this + * config and then try to parse another */ + if (ret > 0 || EchConfigGetSupportedCipherSuite(workingConfig) < 0) { + ret = 0; XFREE(workingConfig->cipherSuites, heap, DYNAMIC_TYPE_TMP_BUFFER); XFREE(workingConfig->publicName, heap, DYNAMIC_TYPE_TMP_BUFFER); XFREE(workingConfig->raw, heap, DYNAMIC_TYPE_TMP_BUFFER); XFREE(workingConfig, heap, DYNAMIC_TYPE_TMP_BUFFER); workingConfig = lastConfig; - - if (workingConfig != NULL) { + if (workingConfig != NULL) workingConfig->next = NULL; - } - else { - /* if one (or more) of the leading configs are unsupported then - * this case will be hit */ + else configList = NULL; - } } configIdx += 4 + length; } while (configIdx < echConfigsLen); - if (ret == 0 && configIdx != echConfigsLen){ + + if (ret == 0 && configIdx != echConfigsLen) ret = BUFFER_E; - } /* if we found valid configs */ if (ret == 0 && configList != NULL) { diff --git a/tests/api.c b/tests/api.c index b8640bdf31..6a5d53163c 100644 --- a/tests/api.c +++ b/tests/api.c @@ -14228,6 +14228,8 @@ static int test_wolfSSL_Tls13_ECH_params_b64(void) const char* b64BadAlgo = "AEX+DQBBFP7+ACBuAoQI8+liEVYQbXKBDeVgTmF2rfXuKO2knhwrN7jgTgAEAAEAAQASY2xvdWRmbGFyZS1lY2guY29tAAA="; /* ech configs with bad/unsupported ciphersuite */ const char* b64BadCiph = "AEX+DQBBFAAgACBuAoQI8+liEVYQbXKBDeVgTmF2rfXuKO2knhwrN7jgTgAE/v4AAQASY2xvdWRmbGFyZS1lY2guY29tAAA="; + /* ech configs with unrecognized mandatory extension */ + const char* b64Mandatory = "AEn+DQBFFAAgACBuAoQI8+liEVYQbXKBDeVgTmF2rfXuKO2knhwrN7jgTgAEAAEAAQASY2xvdWRmbGFyZS1lY2guY29tAAT6+gAA"; /* ech configs with bad version first */ const char* b64BadVers1 = "AIz+HQBCAQAgACCjR6+Qn9UYkMaWdXZzsby88vXFhPHJ2tWCDHQJLvMkEgAEAAEAAQATZWNoLXB1YmxpYy1uYW1lLmNvbQAA/g0AQgIAIAAgMM6vLrTbOfsfA6fTbJY/Iu0Lj2xeHEPGUJeUwQGAYF4ABAABAAEAE2VjaC1wdWJsaWMtbmFtZS5jb20AAA=="; /* ech configs with bad version second */ @@ -14271,6 +14273,12 @@ static int test_wolfSSL_Tls13_ECH_params_b64(void) ExpectIntNE(WOLFSSL_SUCCESS, wolfSSL_SetEchConfigsBase64(ssl, b64BadCiph, (word32)XSTRLEN(b64BadCiph))); + /* unrecognized mandatory extension */ + ExpectIntNE(WOLFSSL_SUCCESS, wolfSSL_CTX_SetEchConfigsBase64(ctx, + b64Mandatory, (word32)XSTRLEN(b64Mandatory))); + ExpectIntNE(WOLFSSL_SUCCESS, wolfSSL_SetEchConfigsBase64(ssl, + b64Mandatory, (word32)XSTRLEN(b64Mandatory))); + /* bad version first, should only have config 2 set */ ExpectIntEQ(WOLFSSL_SUCCESS, wolfSSL_CTX_SetEchConfigsBase64(ctx, b64BadVers1, (word32)XSTRLEN(b64BadVers1))); From 6711f0eed390e5cfc8d2e75a599f36a88a96e0fd Mon Sep 17 00:00:00 2001 From: sebastian-carpenter Date: Fri, 3 Apr 2026 15:06:28 -0600 Subject: [PATCH 11/17] verify ech config on grease, ignore session ticket --- src/tls.c | 12 ++++++++---- src/tls13.c | 9 +++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/tls.c b/src/tls.c index 22a463beda..41b48c3767 100644 --- a/src/tls.c +++ b/src/tls.c @@ -14275,12 +14275,16 @@ static int TLSX_ECH_Parse(WOLFSSL* ssl, const byte* readBuf, word16 size, return BAD_FUNC_ARG; ech = (WOLFSSL_ECH*)echX->data; - if (ech->state != ECH_WRITE_GREASE) { - ret = wolfSSL_SetEchConfigs(ssl, readBuf, size); - } - + ret = wolfSSL_SetEchConfigs(ssl, readBuf, size); if (ret == WOLFSSL_SUCCESS) ret = 0; + + if (ret == 0 && ech->state == ECH_WRITE_GREASE) { + /* the configs need to be checked syntactically but must not be + * saved on grease connection */ + FreeEchConfigs(ssl->echConfigs, ssl->heap); + ssl->echConfigs = NULL; + } } /* HRR with special confirmation */ else if (msgType == hello_retry_request && ssl->echConfigs != NULL) { diff --git a/src/tls13.c b/src/tls13.c index fa110061dc..43c0e4f65f 100644 --- a/src/tls13.c +++ b/src/tls13.c @@ -12151,6 +12151,15 @@ static int DoTls13NewSessionTicket(WOLFSSL* ssl, const byte* input, WOLFSSL_START(WC_FUNC_NEW_SESSION_TICKET_DO); WOLFSSL_ENTER("DoTls13NewSessionTicket"); +#ifdef HAVE_ECH + /* ignore session ticket when ECH is rejected */ + if (ssl->echConfigs != NULL && !ssl->options.disableECH && + !ssl->options.echAccepted) { + *inOutIdx += size + ssl->keys.padSz; + return 0; + } +#endif + /* Lifetime hint. */ if ((*inOutIdx - begin) + SESSION_HINT_SZ > size) return BUFFER_ERROR; From b02ae9cef690c76635a71e27796204b4485c11b3 Mon Sep 17 00:00:00 2001 From: sebastian-carpenter Date: Fri, 3 Apr 2026 16:13:59 -0600 Subject: [PATCH 12/17] allow ech rejection through hrr --- src/tls.c | 13 ------------ src/tls13.c | 58 +++++++++++++++++++++++++++++++++-------------------- 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/src/tls.c b/src/tls.c index 41b48c3767..f236e89f5e 100644 --- a/src/tls.c +++ b/src/tls.c @@ -17721,19 +17721,6 @@ int TLSX_Parse(WOLFSSL* ssl, const byte* input, word16 length, byte msgType, if (ret == 0) ret = TCA_VERIFY_PARSE(ssl, isRequest); -#if defined(WOLFSSL_TLS13) && defined(HAVE_ECH) - /* If client used ECH, server HRR must include ECH confirmation */ - if (ret == 0 && msgType == hello_retry_request && ssl->echConfigs != NULL && - !ssl->options.disableECH) { - TLSX* echX = TLSX_Find(ssl->extensions, TLSX_ECH); - if (echX == NULL || ((WOLFSSL_ECH*)echX->data)->confBuf == NULL) { - WOLFSSL_MSG("ECH used but HRR missing ECH confirmation"); - WOLFSSL_ERROR_VERBOSE(EXT_MISSING); - ret = EXT_MISSING; - } - } -#endif - WOLFSSL_LEAVE("Leaving TLSX_Parse", ret); return ret; } diff --git a/src/tls13.c b/src/tls13.c index 43c0e4f65f..c0e54134ba 100644 --- a/src/tls13.c +++ b/src/tls13.c @@ -5711,31 +5711,40 @@ int DoTls13ServerHello(WOLFSSL* ssl, const byte* input, word32* inOutIdx, #if defined(HAVE_ECH) /* check for acceptConfirmation */ - if (ssl->echConfigs != NULL && !ssl->options.disableECH) { + if (ssl->echConfigs != NULL && !ssl->options.disableECH && + ssl->hsHashesEch != NULL) { args->echX = TLSX_Find(ssl->extensions, TLSX_ECH); - /* account for hrr extension instead of server random */ - if (args->extMsgType == hello_retry_request) { - args->acceptOffset = - (word32)(((WOLFSSL_ECH*)args->echX->data)->confBuf - input); - args->acceptLabel = (byte*)echHrrAcceptConfirmationLabel; - args->acceptLabelSz = ECH_HRR_ACCEPT_CONFIRMATION_LABEL_SZ; + if (args->extMsgType == hello_retry_request && + ((WOLFSSL_ECH*)args->echX->data)->confBuf == NULL) { + /* server rejected ECH, fallback to outer */ + Free_HS_Hashes(ssl->hsHashesEch, ssl->heap); + ssl->hsHashesEch = NULL; } else { - args->acceptLabel = (byte*)echAcceptConfirmationLabel; - args->acceptLabelSz = ECH_ACCEPT_CONFIRMATION_LABEL_SZ; - } - /* check acceptance */ - if (ret == 0) { - ret = EchCheckAcceptance(ssl, args->acceptLabel, - args->acceptLabelSz, input, args->acceptOffset, helloSz, - args->extMsgType); - } - if (ret != 0) - return ret; - /* use the inner random for client random */ - if (args->extMsgType != hello_retry_request) { - XMEMCPY(ssl->arrays->clientRandom, ssl->arrays->clientRandomInner, - RAN_LEN); + /* account for hrr extension instead of server random */ + if (args->extMsgType == hello_retry_request) { + args->acceptOffset = + (word32)(((WOLFSSL_ECH*)args->echX->data)->confBuf - input); + args->acceptLabel = (byte*)echHrrAcceptConfirmationLabel; + args->acceptLabelSz = ECH_HRR_ACCEPT_CONFIRMATION_LABEL_SZ; + } + else { + args->acceptLabel = (byte*)echAcceptConfirmationLabel; + args->acceptLabelSz = ECH_ACCEPT_CONFIRMATION_LABEL_SZ; + } + /* check acceptance */ + if (ret == 0) { + ret = EchCheckAcceptance(ssl, args->acceptLabel, + args->acceptLabelSz, input, args->acceptOffset, helloSz, + args->extMsgType); + } + if (ret != 0) + return ret; + /* use the inner random for client random */ + if (args->extMsgType != hello_retry_request) { + XMEMCPY(ssl->arrays->clientRandom, + ssl->arrays->clientRandomInner, RAN_LEN); + } } } #endif /* HAVE_ECH */ @@ -13278,6 +13287,11 @@ int DoTls13HandShakeMsgType(WOLFSSL* ssl, byte* input, word32* inOutIdx, ((WOLFSSL_ECH*)echX->data)->sniState = ECH_SNI_DONE; } + if (ret == 0 && ((WOLFSSL_ECH*)echX->data)->state != + ECH_PARSED_INTERNAL) { + WOLFSSL_MSG("ECH: inner ClientHello missing ECH extension"); + ret = INVALID_PARAMETER; + } /* if the inner ech parsed successfully we have successfully * handled the hello and can skip the whole message */ if (ret == 0) { From f13176450c93d86de42ba7a858c730f0e7451623 Mon Sep 17 00:00:00 2001 From: sebastian-carpenter Date: Fri, 3 Apr 2026 17:02:19 -0600 Subject: [PATCH 13/17] do not resume in outer hello --- src/tls13.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/tls13.c b/src/tls13.c index c0e54134ba..c0b83c5ba0 100644 --- a/src/tls13.c +++ b/src/tls13.c @@ -5763,6 +5763,14 @@ int DoTls13ServerHello(WOLFSSL* ssl, const byte* input, word32* inOutIdx, XMEMSET(ssl->arrays->psk_key, 0, MAX_PSK_KEY_LEN); } else { +#if defined(HAVE_ECH) + /* do not resume when outerHandshake will be negotiated */ + if (ssl->echConfigs != NULL && !ssl->options.disableECH && + !ssl->options.echAccepted) { + WOLFSSL_MSG("ECH rejected but server negotiated PSK"); + return INVALID_PARAMETER; + } +#endif if ((ret = SetupPskKey(ssl, psk, 0)) != 0) return ret; ssl->options.pskNegotiated = 1; From 4a65cf373c9c4c48504781d3b4027a5955eb8ffe Mon Sep 17 00:00:00 2001 From: sebastian-carpenter Date: Mon, 6 Apr 2026 13:37:02 -0600 Subject: [PATCH 14/17] cert authentication on echConfig publicName --- src/internal.c | 44 +++++++++++++++++--------- tests/api.c | 84 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 16 deletions(-) diff --git a/src/internal.c b/src/internal.c index 490ac2aac2..9c93a93dff 100644 --- a/src/internal.c +++ b/src/internal.c @@ -15660,6 +15660,8 @@ int ProcessPeerCerts(WOLFSSL* ssl, byte* input, word32* inOutIdx, byte* subjectHash = NULL; int alreadySigner = 0; + char* domainName = NULL; + #if defined(HAVE_CERTIFICATE_STATUS_REQUEST_V2) int addToPendingCAs = 0; #endif @@ -16848,17 +16850,33 @@ int ProcessPeerCerts(WOLFSSL* ssl, byte* input, word32* inOutIdx, } #endif - if (!ssl->options.verifyNone && ssl->buffers.domainName.buffer) { + domainName = (char*)ssl->buffers.domainName.buffer; + #if !defined(NO_WOLFSSL_CLIENT) && defined(HAVE_ECH) + /* RFC 9849 s6.1.7: ECH was offered but rejected by the server.. + * verify cert is valid for ECHConfig.public_name */ + if (ssl->options.side == WOLFSSL_CLIENT_END && + ssl->echConfigs != NULL && + !ssl->options.echAccepted) { + TLSX* echX = TLSX_Find(ssl->extensions, TLSX_ECH); + if (echX != NULL) { + WOLFSSL_ECH* ech = (WOLFSSL_ECH*)echX->data; + domainName = ech->echConfig ? + ech->echConfig->publicName : NULL; + } + } + #endif + + if ((!ssl->options.verifyNone || + domainName != (char*)ssl->buffers.domainName.buffer) && + domainName) { #ifndef WOLFSSL_ALLOW_NO_CN_IN_SAN /* Per RFC 5280 section 4.2.1.6, "Whenever such identities * are to be bound into a certificate, the subject * alternative name extension MUST be used." */ if (args->dCert->altNames) { if (CheckForAltNames(args->dCert, - (char*)ssl->buffers.domainName.buffer, - (ssl->buffers.domainName.buffer == NULL ? 0 : - (word32)XSTRLEN( - (const char *)ssl->buffers.domainName.buffer)), + domainName, (domainName == NULL ? 0 : + (word32)XSTRLEN((const char *)domainName)), NULL, 0, 0) != 1) { WOLFSSL_MSG("DomainName match on alt names failed"); /* try to get peer key still */ @@ -16871,10 +16889,8 @@ int ProcessPeerCerts(WOLFSSL* ssl, byte* input, word32* inOutIdx, if (MatchDomainName( args->dCert->subjectCN, args->dCert->subjectCNLen, - (char*)ssl->buffers.domainName.buffer, - (ssl->buffers.domainName.buffer == NULL ? 0 : - (word32)XSTRLEN( - (const char *)ssl->buffers.domainName.buffer) + domainName, (domainName == NULL ? 0 : + (word32)XSTRLEN((const char *)domainName) ), 0) == 0) #endif { @@ -16888,15 +16904,13 @@ int ProcessPeerCerts(WOLFSSL* ssl, byte* input, word32* inOutIdx, #ifndef WOLFSSL_HOSTNAME_VERIFY_ALT_NAME_ONLY if (MatchDomainName(args->dCert->subjectCN, args->dCert->subjectCNLen, - (char*)ssl->buffers.domainName.buffer, - (ssl->buffers.domainName.buffer == NULL ? 0 : - (word32)XSTRLEN(ssl->buffers.domainName.buffer)), 0) == 0) + domainName, (domainName == NULL ? 0 : + (word32)XSTRLEN(domainName)), 0) == 0) #endif { if (CheckForAltNames(args->dCert, - (char*)ssl->buffers.domainName.buffer, - (ssl->buffers.domainName.buffer == NULL ? 0 : - (word32)XSTRLEN(ssl->buffers.domainName.buffer)), + domainName, (domainName == NULL ? 0 : + (word32)XSTRLEN(domainName)), NULL, 0, 0) != 1) { WOLFSSL_MSG("DomainName match failed"); /* try to get peer key still */ diff --git a/tests/api.c b/tests/api.c index 6a5d53163c..a3f797cfb9 100644 --- a/tests/api.c +++ b/tests/api.c @@ -15023,6 +15023,87 @@ static int test_wolfSSL_Tls13_ECH_disable_conn(void) return EXPECT_RESULT(); } +/* when ECH is rejected the certificate must match the public name of the chosen + * ech config + * the cert check should pass and the client aborts with ech_required */ +static int test_wolfSSL_Tls13_ECH_rejected_cert_valid_ex(const char* publicName, + int validName) +{ + EXPECT_DECLS; + test_ssl_memio_ctx test_ctx; + byte echConfigs[512]; + word32 echConfigsLen = sizeof(echConfigs); + + XMEMSET(&test_ctx, 0, sizeof(test_ctx)); + + test_ctx.s_cb.method = wolfTLSv1_3_server_method; + test_ctx.c_cb.method = wolfTLSv1_3_client_method; + + ExpectIntEQ(test_ssl_memio_setup(&test_ctx), TEST_SUCCESS); + + /* Generate ECH config with given public_name */ + ExpectIntEQ(wolfSSL_CTX_GenerateEchConfig(test_ctx.s_ctx, publicName, + 0, 0, 0), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_CTX_GetEchConfigs(test_ctx.s_ctx, echConfigs, + &echConfigsLen), WOLFSSL_SUCCESS); + + /* Client loads ECH configs and sets a private SNI */ + ExpectIntEQ(wolfSSL_SetEchConfigs(test_ctx.c_ssl, echConfigs, + echConfigsLen), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_UseSNI(test_ctx.c_ssl, WOLFSSL_SNI_HOST_NAME, + "ech-private.com", (word16)XSTRLEN("ech-private.com")), + WOLFSSL_SUCCESS); + + /* Do not require client cert on server so it does not send + * CertificateRequest */ + wolfSSL_set_verify(test_ctx.s_ssl, WOLFSSL_VERIFY_NONE, NULL); + + /* Disable ECH on the server side so ECH is rejected */ + wolfSSL_SetEchEnable(test_ctx.s_ssl, 0); + + /* Match the server SNI to the ECH public_name */ + ExpectIntEQ(wolfSSL_UseSNI(test_ctx.s_ssl, WOLFSSL_SNI_HOST_NAME, + publicName, (word16)XSTRLEN(publicName)), WOLFSSL_SUCCESS); + + /* client sends ECH but server can't process it, however it is possible to + * fall back to the outer handshake */ + ExpectIntNE(test_ssl_memio_do_handshake(&test_ctx, 10, NULL), TEST_SUCCESS); + ExpectIntEQ(test_ctx.c_ssl->options.echAccepted, 0); + + if (validName) { + /* the server should see the handshake as successful + * the client should abort because the server did not use ECH */ + ExpectIntEQ(wolfSSL_get_error(test_ctx.s_ssl, 0), + WC_NO_ERR_TRACE(WOLFSSL_ERROR_NONE)); + ExpectIntEQ(wolfSSL_get_error(test_ctx.c_ssl, 0), + WC_NO_ERR_TRACE(ECH_REQUIRED_E)); + } + else { + /* the client should abort with cert mismatch + * the server error is then dependent on whether that cert mismatch + * results in an abort */ + ExpectIntEQ(wolfSSL_get_error(test_ctx.c_ssl, 0), + WC_NO_ERR_TRACE(DOMAIN_NAME_MISMATCH)); + } + + test_ssl_memio_cleanup(&test_ctx); + + return EXPECT_RESULT(); +} + +static int test_wolfSSL_Tls13_ECH_rejected_cert_valid(void) +{ + EXPECT_DECLS; + + /* "example.com" appears in the SAN of certs/server-cert.pem */ + ExpectIntEQ(test_wolfSSL_Tls13_ECH_rejected_cert_valid_ex("example.com", 1), + TEST_SUCCESS); + ExpectIntEQ(test_wolfSSL_Tls13_ECH_rejected_cert_valid_ex("badname.com", 0), + TEST_SUCCESS); + + return EXPECT_RESULT(); +} + /* when ECH is rejected and the server requests a client certificate the client * must respond with an empty cert */ static int test_wolfSSL_Tls13_ECH_rejected_empty_client_cert(void) @@ -15053,7 +15134,7 @@ static int test_wolfSSL_Tls13_ECH_rejected_empty_client_cert(void) "ech-private.com", (word16)XSTRLEN("ech-private.com")), WOLFSSL_SUCCESS); - /* Disable ECH on the server ssl so ECH is rejected */ + /* Disable ECH on the server so ECH is rejected */ wolfSSL_SetEchEnable(test_ctx.s_ssl, 0); /* Match the Server SNI to the ECH public_name */ @@ -35652,6 +35733,7 @@ TEST_CASE testCases[] = { TEST_DECL(test_wolfSSL_Tls13_ECH_new_config), TEST_DECL(test_wolfSSL_Tls13_ECH_GREASE), TEST_DECL(test_wolfSSL_Tls13_ECH_disable_conn), + TEST_DECL(test_wolfSSL_Tls13_ECH_rejected_cert_valid), TEST_DECL(test_wolfSSL_Tls13_ECH_rejected_empty_client_cert), #endif #if defined(HAVE_SSL_MEMIO_TESTS_DEPENDENCIES) && defined(WOLFSSL_TEST) && \ From 6563b569b4f10ecf17aad6d44a8a4ae8ef3e1cae Mon Sep 17 00:00:00 2001 From: sebastian-carpenter Date: Mon, 6 Apr 2026 14:46:44 -0600 Subject: [PATCH 15/17] no ech_outer_extensions in inner or outer hello --- src/tls.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/tls.c b/src/tls.c index f236e89f5e..ff4e9ba2e5 100644 --- a/src/tls.c +++ b/src/tls.c @@ -17670,6 +17670,12 @@ int TLSX_Parse(WOLFSSL* ssl, const byte* input, word16 length, byte msgType, WOLFSSL_MSG("ECH extension received"); ret = ECH_PARSE(ssl, input + offset, size, msgType); break; + case TLSXT_ECH_OUTER_EXTENSIONS: + /* RFC 9849 s5.1: ech_outer_extensions MUST only appear in + * the EncodedClientHelloInner */ + WOLFSSL_MSG("ech_outer_extensions in plaintext message"); + WOLFSSL_ERROR_VERBOSE(INVALID_PARAMETER); + return INVALID_PARAMETER; #endif default: WOLFSSL_MSG("Unknown TLS extension type"); From 2f1f5b76657f4ed73dfa8fc31e9c8e67eab61a23 Mon Sep 17 00:00:00 2001 From: sebastian-carpenter Date: Tue, 7 Apr 2026 14:51:18 -0600 Subject: [PATCH 16/17] tweaks to errors, tests, c89 declarations --- src/tls.c | 8 +++++--- src/tls13.c | 7 +++++-- tests/api.c | 15 ++++++--------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/tls.c b/src/tls.c index ff4e9ba2e5..160bbcd05d 100644 --- a/src/tls.c +++ b/src/tls.c @@ -13771,6 +13771,8 @@ static int TLSX_ECH_CheckInnerPadding(WOLFSSL* ssl, WOLFSSL_ECH* ech) byte acc = 0; word32 i; + (void)ssl; + #ifdef WOLFSSL_DTLS13 headerSz = ssl->options.dtls ? DTLS13_HANDSHAKE_HEADER_SZ : HANDSHAKE_HEADER_SZ; @@ -13898,7 +13900,7 @@ static const byte* TLSX_ECH_FindOuterExtension(const byte* outerCh, /* If newinnerCh is NULL, validate ordering and existence of references * - updates newInnerChLen with total length of selected extensions - * If newinnerCh in not NULL, copy extensions into newInnerCh + * If newinnerCh is not NULL, copy extensions into newInnerCh * * outerCh The outer ClientHello buffer. * outerChLen Outer ClientHello length. @@ -14448,8 +14450,8 @@ static int TLSX_ECH_Parse(WOLFSSL* ssl, const byte* readBuf, word16 size, if (ech->hpkeContext != NULL) { /* on SH2 this is fatal */ SendAlert(ssl, alert_fatal, decrypt_error); - WOLFSSL_ERROR_VERBOSE(VERIFY_FINISHED_ERROR); - ret = VERIFY_FINISHED_ERROR; + WOLFSSL_ERROR_VERBOSE(DECRYPT_ERROR); + ret = DECRYPT_ERROR; } else { /* on SH1 prepare to write retry configs */ diff --git a/src/tls13.c b/src/tls13.c index c0b83c5ba0..a6679b2805 100644 --- a/src/tls13.c +++ b/src/tls13.c @@ -5990,7 +5990,8 @@ static int DoTls13CertificateRequest(WOLFSSL* ssl, const byte* input, #if defined(HAVE_ECH) /* RFC 9849 s6.1.7: ECH was offered but rejected by the server... * the client MUST respond with an empty Certificate message. */ - if (ssl->echConfigs != NULL && !ssl->options.echAccepted) { + if (ssl->echConfigs != NULL && !ssl->options.disableECH && + !ssl->options.echAccepted) { ssl->options.sendVerify = SEND_BLANK_CERT; } else @@ -7098,6 +7099,9 @@ int DoTls13ClientHello(WOLFSSL* ssl, const byte* input, word32* inOutIdx, } if (wantDowngrade) { +#ifndef WOLFSSL_NO_TLS12 + byte realMinor; +#endif #if defined(HAVE_ECH) if (ssl->options.echProcessingInner) { WOLFSSL_MSG("ECH: inner client hello does not support version " @@ -7106,7 +7110,6 @@ int DoTls13ClientHello(WOLFSSL* ssl, const byte* input, word32* inOutIdx, } #endif #ifndef WOLFSSL_NO_TLS12 - byte realMinor; if (!ssl->options.downgrade) { WOLFSSL_MSG("Client trying to connect with lesser version than " "TLS v1.3"); diff --git a/tests/api.c b/tests/api.c index a3f797cfb9..86cb753ca9 100644 --- a/tests/api.c +++ b/tests/api.c @@ -14888,8 +14888,6 @@ static int test_wolfSSL_Tls13_ECH_GREASE(void) { EXPECT_DECLS; test_ssl_memio_ctx test_ctx; - byte greaseConfigs[512]; - word32 greaseConfigsLen = sizeof(greaseConfigs); /* GREASE when server has no ECH configs */ @@ -14909,7 +14907,7 @@ static int test_wolfSSL_Tls13_ECH_GREASE(void) ExpectIntEQ(test_ctx.c_ssl->options.disableECH, 0); /* verify no ECH configs are set */ ExpectNull(test_ctx.s_ctx->echConfigs); - ExpectNull(test_ctx.c_ctx->echConfigs); + ExpectNull(test_ctx.c_ssl->echConfigs); /* handshake should succeed - server ignores the GREASE ECH extension */ ExpectIntEQ(test_ssl_memio_do_handshake(&test_ctx, 10, NULL), TEST_SUCCESS); @@ -14917,8 +14915,8 @@ static int test_wolfSSL_Tls13_ECH_GREASE(void) /* ECH should NOT be accepted since this was GREASE */ ExpectIntEQ(test_ctx.s_ssl->options.echAccepted, 0); ExpectIntEQ(test_ctx.c_ssl->options.echAccepted, 0); - ExpectIntNE(wolfSSL_GetEchConfigs(test_ctx.c_ssl, greaseConfigs, - &greaseConfigsLen), WOLFSSL_SUCCESS); + /* verify no ECH configs are received */ + ExpectNull(test_ctx.c_ssl->echConfigs); test_ssl_memio_cleanup(&test_ctx); @@ -14944,17 +14942,16 @@ static int test_wolfSSL_Tls13_ECH_GREASE(void) ExpectIntEQ(test_ctx.c_ssl->options.disableECH, 0); /* verify ECH configs are set on server */ ExpectNotNull(test_ctx.s_ctx->echConfigs); - ExpectNull(test_ctx.c_ctx->echConfigs); + ExpectNull(test_ctx.c_ssl->echConfigs); /* handshake should succeed - server responds to the GREASE ECH extension */ ExpectIntEQ(test_ssl_memio_do_handshake(&test_ctx, 10, NULL), TEST_SUCCESS); - /* ECH should NOT be accepted since this was GREASE - * However, configs will be present this time */ + /* ECH should NOT be accepted since this was GREASE */ ExpectIntEQ(test_ctx.s_ssl->options.echAccepted, 0); ExpectIntEQ(test_ctx.c_ssl->options.echAccepted, 0); /* verify no ECH configs are received */ - ExpectNull(test_ctx.c_ctx->echConfigs); + ExpectNull(test_ctx.c_ssl->echConfigs); test_ssl_memio_cleanup(&test_ctx); From a1d20e170af7ab1a8ac7bf3d634be7c4ace9949b Mon Sep 17 00:00:00 2001 From: sebastian-carpenter Date: Tue, 7 Apr 2026 16:16:50 -0600 Subject: [PATCH 17/17] potentially uninitialized var --- src/tls.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tls.c b/src/tls.c index 160bbcd05d..e5733ef569 100644 --- a/src/tls.c +++ b/src/tls.c @@ -13918,8 +13918,8 @@ static int TLSX_ECH_CopyOuterExtensions(const byte* outerCh, word32 outerChLen, word16 refType; word32 outerExtLen; word32 outerExtOffset = 0; - word16 extsStart; - word16 extsLen; + word16 extsStart = 0; + word16 extsLen = 0; const byte* outerExtData; while (numOuterRefs-- > 0) {