From c18784a11c6c610c99a942dea939834cfa9eb574 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Wed, 3 Jun 2026 00:23:53 +0200 Subject: [PATCH 01/17] F-3888: add negative tests for tampered RSA signatures/hashes Extend test_wolfSSL_RSA_verify and test_wolfSSL_RSA_padding_add_PKCS1_PSS with negative cases that flip a byte in the signature/encoding and in the hash, asserting verification fails. This guards the XMEMCMP-based signature acceptance decision in wolfSSL_RSA_verify_mgf against regressions that would let any decryption result of matching length pass as valid. --- tests/api/test_ossl_rsa.c | 40 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/tests/api/test_ossl_rsa.c b/tests/api/test_ossl_rsa.c index 26cf360105..dc0cee665b 100644 --- a/tests/api/test_ossl_rsa.c +++ b/tests/api/test_ossl_rsa.c @@ -520,6 +520,24 @@ int test_wolfSSL_RSA_padding_add_PKCS1_PSS(void) ExpectIntEQ(RSA_verify_PKCS1_PSS(rsa, mHash, EVP_sha256(), em, RSA_PSS_SALTLEN_DIGEST), 1); + /* Negative test: a tampered PSS encoding must be rejected. Flip a byte in + * the encoded message, confirm failure, then restore and re-verify. */ + em[0] ^= 0xFFU; + ExpectIntEQ(RSA_verify_PKCS1_PSS(rsa, mHash, EVP_sha256(), em, + RSA_PSS_SALTLEN_DIGEST), 0); + em[0] ^= 0xFFU; + ExpectIntEQ(RSA_verify_PKCS1_PSS(rsa, mHash, EVP_sha256(), em, + RSA_PSS_SALTLEN_DIGEST), 1); + + /* Negative test: a tampered hash must be rejected by PSS verification. */ + { + unsigned char badHash[WC_SHA256_DIGEST_SIZE]; + XMEMCPY(badHash, mHash, sizeof(badHash)); + badHash[0] ^= 0xFFU; + ExpectIntEQ(RSA_verify_PKCS1_PSS(rsa, badHash, EVP_sha256(), em, + RSA_PSS_SALTLEN_DIGEST), 0); + } + ExpectIntEQ(RSA_padding_add_PKCS1_PSS(rsa, em, mHash, EVP_sha256(), RSA_PSS_SALTLEN_MAX_SIGN), 1); ExpectIntEQ(RSA_verify_PKCS1_PSS(rsa, mHash, EVP_sha256(), em, @@ -696,8 +714,8 @@ int test_wolfSSL_RSA_verify(void) RSA *pubKey = NULL; X509 *cert = NULL; const char *text = "Hello wolfSSL !"; - unsigned char hash[SHA256_DIGEST_LENGTH]; - unsigned char signature[2048/8]; + unsigned char hash[SHA256_DIGEST_LENGTH] = {0}; + unsigned char signature[2048/8] = {0}; unsigned int signatureLength; byte *buf = NULL; BIO *bio = NULL; @@ -747,6 +765,24 @@ int test_wolfSSL_RSA_verify(void) ExpectIntEQ(RSA_verify(NID_sha256, hash, SHA256_DIGEST_LENGTH, signature, signatureLength, pubKey), SSL_SUCCESS); + /* Negative test: a tampered signature must be rejected. Flip a byte in the + * signature, confirm verification fails, then restore it. */ + signature[0] ^= 0xFFU; + ExpectIntEQ(RSA_verify(NID_sha256, hash, SHA256_DIGEST_LENGTH, signature, + signatureLength, pubKey), WC_NO_ERR_TRACE(WOLFSSL_FAILURE)); + signature[0] ^= 0xFFU; + /* Sanity: the restored signature verifies again. */ + ExpectIntEQ(RSA_verify(NID_sha256, hash, SHA256_DIGEST_LENGTH, signature, + signatureLength, pubKey), SSL_SUCCESS); + + /* Negative test: a tampered hash must be rejected (the encoded comparison + * string differs). Flip a byte in the hash, confirm failure, then + * restore it. */ + hash[0] ^= 0xFFU; + ExpectIntEQ(RSA_verify(NID_sha256, hash, SHA256_DIGEST_LENGTH, signature, + signatureLength, pubKey), WC_NO_ERR_TRACE(WOLFSSL_FAILURE)); + hash[0] ^= 0xFFU; + ExpectIntEQ(RSA_verify(NID_sha256, NULL, SHA256_DIGEST_LENGTH, NULL, signatureLength, NULL), WC_NO_ERR_TRACE(WOLFSSL_FAILURE)); ExpectIntEQ(RSA_verify(NID_sha256, NULL, SHA256_DIGEST_LENGTH, signature, From e4007a8956a571c5e32a1bba3ef3f44f573e1cef Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Wed, 3 Jun 2026 00:24:25 +0200 Subject: [PATCH 02/17] F-4867: reject trailing bytes in TLS 1.3 EncryptedExtensions DoTls13EncryptedExtensions only bounds-checked the extensions length against the message size, silently ignoring any trailing bytes. RFC 8446 Section 4.3.1 defines the message as solely the extensions block, so enforce length equality and return BUFFER_ERROR (decode_error) on a mismatch. --- src/tls13.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tls13.c b/src/tls13.c index 5e377f40ef..4bc22f1cb1 100644 --- a/src/tls13.c +++ b/src/tls13.c @@ -6030,7 +6030,7 @@ static int DoTls13EncryptedExtensions(WOLFSSL* ssl, const byte* input, i += OPAQUE16_LEN; /* Extension data. */ - if (i - begin + totalExtSz > totalSz) + if (i - begin + totalExtSz != totalSz) return BUFFER_ERROR; if ((ret = TLSX_Parse(ssl, input + i, totalExtSz, encrypted_extensions, NULL))) { From 2d36eca90e9409915cd1c9e808d1d70616aece29 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Wed, 3 Jun 2026 00:24:46 +0200 Subject: [PATCH 03/17] F-4868: reject trailing bytes in TLS 1.3 CertificateRequest DoTls13CertificateRequest advanced past the certificate_request_context and extensions blocks but never verified the whole message body was consumed, silently ignoring trailing bytes. RFC 8446 Section 4.3.2 fixes the wire format; enforce that the consumed length equals the message size and return BUFFER_ERROR (decode_error) otherwise. --- src/tls13.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tls13.c b/src/tls13.c index 4bc22f1cb1..4f6e1d3f64 100644 --- a/src/tls13.c +++ b/src/tls13.c @@ -6168,6 +6168,10 @@ static int DoTls13CertificateRequest(WOLFSSL* ssl, const byte* input, } *inOutIdx += len; + /* No trailing bytes allowed (RFC 8446 4.3.2). */ + if ((*inOutIdx - begin) != size) + return BUFFER_ERROR; + /* RFC 8446 Section 4.3.2: the signature_algorithms extension MUST be * present in a CertificateRequest. */ if (peerSuites.hashSigAlgoSz == 0) { @@ -6175,7 +6179,6 @@ static int DoTls13CertificateRequest(WOLFSSL* ssl, const byte* input, WOLFSSL_ERROR_VERBOSE(INVALID_PARAMETER); return INVALID_PARAMETER; } - #ifdef WOLFSSL_CERT_SETUP_CB if ((ret = CertSetupCbWrapper(ssl)) != 0) return ret; From 0269b58400aa759e3e86685f90fb2d90092210a9 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Wed, 3 Jun 2026 00:25:17 +0200 Subject: [PATCH 04/17] F-5633: zeroize DTLS 1.3 ChaCha record-number keys before free FreeCiphers released the DTLS 1.3 record-number protection ChaCha contexts with XFREE only, leaving key material in freed heap memory. ForceZero both contexts before freeing, matching the regular TLS ChaCha path in FreeCiphersSide, and also zeroize a partially-set key in Dtls13InitChaChaCipher when wc_Chacha_SetKey fails. --- src/dtls13.c | 1 + src/internal.c | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/dtls13.c b/src/dtls13.c index 0de419def8..ad69f2612a 100644 --- a/src/dtls13.c +++ b/src/dtls13.c @@ -2225,6 +2225,7 @@ static int Dtls13InitChaChaCipher(RecordNumberCiphers* c, byte* key, ret = wc_Chacha_SetKey(c->chacha, key, keySize); if (ret != 0) { + ForceZero(c->chacha, sizeof(ChaCha)); XFREE(c->chacha, heap, DYNAMIC_TYPE_CIPHER); c->chacha = NULL; } diff --git a/src/internal.c b/src/internal.c index a3be7ee448..4fc9ca00f7 100644 --- a/src/internal.c +++ b/src/internal.c @@ -3343,6 +3343,10 @@ void FreeCiphers(WOLFSSL* ssl) ssl->dtlsRecordNumberDecrypt.aes = NULL; #endif /* BUILD_AES */ #ifdef HAVE_CHACHA + if (ssl->dtlsRecordNumberEncrypt.chacha) + ForceZero(ssl->dtlsRecordNumberEncrypt.chacha, sizeof(ChaCha)); + if (ssl->dtlsRecordNumberDecrypt.chacha) + ForceZero(ssl->dtlsRecordNumberDecrypt.chacha, sizeof(ChaCha)); XFREE(ssl->dtlsRecordNumberEncrypt.chacha, ssl->heap, DYNAMIC_TYPE_CIPHER); XFREE(ssl->dtlsRecordNumberDecrypt.chacha, ssl->heap, DYNAMIC_TYPE_CIPHER); ssl->dtlsRecordNumberEncrypt.chacha = NULL; From b2c80eae15248a038d30d4dd94293852c2b0a734 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Wed, 3 Jun 2026 00:25:46 +0200 Subject: [PATCH 05/17] F-5807: enforce EMS consistency on client session resumption CompleteServerHello's resumption branch derived keys from the cached master secret without checking the resumed session's extended_master_secret state against the abbreviated ServerHello, letting a MITM strip EMS on resumption. Per RFC 7627 Section 5.3, abort with a fatal handshake_failure when the cached session's EMS flag does not match the ServerHello EMS state. --- src/internal.c | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/internal.c b/src/internal.c index 4fc9ca00f7..04185f1ffd 100644 --- a/src/internal.c +++ b/src/internal.c @@ -32263,6 +32263,22 @@ static void MakePSKPreMasterSecret(Arrays* arrays, byte use_psk_key) } else { if (DSH_CheckSessionId(ssl)) { + /* RFC 7627 5.3: resumed session EMS state must match the + * ServerHello; abort on mismatch. Stateless (session-ticket) + * resumption - e.g. EAP-FAST, whose PAC is a TLS ticket - binds + * the EMS state in the ticket and need not re-advertise the + * extension, so this applies only to session-ID resumption. */ + if ( + #ifdef HAVE_SESSION_TICKET + ssl->session->ticketLen == 0 && + #endif + ssl->session->haveEMS != ssl->options.haveEMS) { + WOLFSSL_MSG("Resumed session EMS state does not match " + "ServerHello EMS state"); + SendAlert(ssl, alert_fatal, handshake_failure); + WOLFSSL_ERROR_VERBOSE(EXT_MASTER_SECRET_NEEDED_E); + return EXT_MASTER_SECRET_NEEDED_E; + } if (SetCipherSpecs(ssl) == 0) { if (!HaveUniqueSessionObj(ssl)) { WOLFSSL_MSG("Unable to have unique session object"); From a3a2609b185ef1d321b69212ae3d78579ce7cc59 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Wed, 3 Jun 2026 00:26:18 +0200 Subject: [PATCH 06/17] F-5811: verify resumed cipher suite matches cached session On session-ID resumption the client only checked that the server's selected suite was in its offered list, not that it equaled the resumed session's suite, so a server could resume the session ID under a different cipher suite. Per RFC 5246 Section 7.4.1.2 / F.1.4 a resumed session reuses its negotiated suite; abort with a fatal illegal_parameter on mismatch. --- src/internal.c | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/internal.c b/src/internal.c index 04185f1ffd..317c4fed28 100644 --- a/src/internal.c +++ b/src/internal.c @@ -32279,6 +32279,25 @@ static void MakePSKPreMasterSecret(Arrays* arrays, byte use_psk_key) WOLFSSL_ERROR_VERBOSE(EXT_MASTER_SECRET_NEEDED_E); return EXT_MASTER_SECRET_NEEDED_E; } +#ifndef NO_RESUME_SUITE_CHECK + /* RFC 5246 Section 7.4.1.3: on resumption the ServerHello + * reuses the previously negotiated cipher suite. Reject a + * server that resumes the session but selects a different + * suite. Skipped for ticket resumption (suite is bound in the + * ticket), consistent with the EMS check above. */ + if ( + #ifdef HAVE_SESSION_TICKET + ssl->session->ticketLen == 0 && + #endif + (ssl->options.cipherSuite0 != ssl->session->cipherSuite0 || + ssl->options.cipherSuite != ssl->session->cipherSuite)) { + WOLFSSL_MSG("Resumed session cipher suite does not match " + "ServerHello cipher suite"); + SendAlert(ssl, alert_fatal, illegal_parameter); + WOLFSSL_ERROR_VERBOSE(MATCH_SUITE_ERROR); + return MATCH_SUITE_ERROR; + } +#endif /* NO_RESUME_SUITE_CHECK */ if (SetCipherSpecs(ssl) == 0) { if (!HaveUniqueSessionObj(ssl)) { WOLFSSL_MSG("Unable to have unique session object"); From 0ec0db93570d909212ea393cbed7523908d80299 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Wed, 3 Jun 2026 00:27:25 +0200 Subject: [PATCH 07/17] F-5813: fail TLS 1.2 record send before the sequence number wraps GetSEQIncrement silently rolled the 64-bit write sequence counter from 2^64-1 back to 0, reusing sequence number 0 with the same keys. Per RFC 5246 Section 6.1 sequence numbers MUST NOT wrap. BuildMessage now refuses to emit a TLS 1.2 record once the write sequence number has reached its maximum, returning the new SEQUENCE_NUMBER_E error so the caller renegotiates or closes instead. --- src/internal.c | 16 ++++++++++++++++ wolfssl/error-ssl.h | 4 +++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/internal.c b/src/internal.c index 317c4fed28..9dd5c00dcf 100644 --- a/src/internal.c +++ b/src/internal.c @@ -24662,6 +24662,19 @@ int BuildMessage(WOLFSSL* ssl, byte* output, int outSz, const byte* input, #endif #ifndef WOLFSSL_NO_TLS12 + /* RFC 5246 6.1: record sequence numbers MUST NOT wrap. Refuse to emit a + * record once the write sequence number has reached its maximum value + * (2^64-1); reusing sequence number 0 with the same keys would break the + * record protection. The caller must renegotiate or close the connection + * instead. DTLS sequence numbers are epoch-scoped and handled elsewhere. */ + if (!sizeOnly && !ssl->options.dtls && + ssl->keys.sequence_number_hi == 0xFFFFFFFFU && + ssl->keys.sequence_number_lo == 0xFFFFFFFFU) { + WOLFSSL_MSG("TLS write sequence number would wrap"); + WOLFSSL_ERROR_VERBOSE(SEQUENCE_NUMBER_E); + return SEQUENCE_NUMBER_E; + } + #ifdef WOLFSSL_ASYNC_CRYPT ret = WC_NO_PENDING_E; if (asyncOkay) { @@ -28108,6 +28121,9 @@ const char* wolfSSL_ERR_reason_error_string(unsigned long e) case ECH_REQUIRED_E: return "ECH offered but rejected by server"; + + case SEQUENCE_NUMBER_E: + return "Record sequence number would wrap"; } return "unknown error number"; diff --git a/wolfssl/error-ssl.h b/wolfssl/error-ssl.h index b98a52d40c..379008afb0 100644 --- a/wolfssl/error-ssl.h +++ b/wolfssl/error-ssl.h @@ -244,7 +244,9 @@ enum wolfSSL_ErrorCodes { ECH_REQUIRED_E = -519, /* ECH offered but rejected by server */ - WOLFSSL_LAST_E = -519 + SEQUENCE_NUMBER_E = -520, /* Record sequence number would wrap */ + + WOLFSSL_LAST_E = -520 /* codes -1000 to -1999 are reserved for wolfCrypt. */ }; From 6e1ca6bc702cfb5f43a64e31d47117df304de7b3 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Wed, 3 Jun 2026 00:28:26 +0200 Subject: [PATCH 08/17] F-5818: invalidate cached session on fatal alert DoAlert marked a connection closed on a received fatal alert but left the established session in the resumption cache, and the send path did the same, so a session whose connection ended in a fatal alert remained resumable. Per RFC 5246 Section 7.2.2 the session identifier MUST be invalidated; evict the established session from the cache on both receipt and transmission of a fatal alert via the new InvalidateSessionOnFatalAlert helper. --- src/internal.c | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/internal.c b/src/internal.c index 9dd5c00dcf..c3d6379eac 100644 --- a/src/internal.c +++ b/src/internal.c @@ -22489,6 +22489,24 @@ static void LogAlert(int type) } /* process alert, return level */ +#ifndef NO_SESSION_CACHE +/* RFC 5246 Section 7.2.2: a TLS 1.2 session whose connection is terminated by a + * fatal alert MUST be invalidated so it cannot be resumed. (TLS 1.3 RFC 8446 + * Section 6.2 only requires closing the connection, but evicting here too is + * sound defense-in-depth.) Evict the cached session (which also drops any + * associated ticket). Acts on an established connection or an in-progress + * resumption - both reference a cached session; a brand-new full handshake has + * no cached session to remove. */ +static void InvalidateSessionOnFatalAlert(WOLFSSL* ssl) +{ + if (ssl == NULL || ssl->ctx == NULL || ssl->session == NULL) + return; + if (!ssl->options.handShakeDone && !ssl->options.resuming) + return; + (void)wolfSSL_SSL_CTX_remove_session(ssl->ctx, ssl->session); +} +#endif /* !NO_SESSION_CACHE */ + static int DoAlert(WOLFSSL* ssl, byte* input, word32* inOutIdx, int* type) { byte level; @@ -22551,6 +22569,15 @@ static int DoAlert(WOLFSSL* ssl, byte* input, word32* inOutIdx, int* type) code != close_notify && code != user_canceled) { ssl->options.isClosed = 1; } +#ifndef NO_SESSION_CACHE + /* A fatal alert immediately terminates the connection; invalidate the + * session so it cannot be used to establish new connections. In TLS 1.3 + * all error alerts are implicitly fatal (RFC 8446 6.2). */ + if (code != close_notify && + (level == alert_fatal || + (IsAtLeastTLSv1_3(ssl->version) && code != user_canceled))) + InvalidateSessionOnFatalAlert(ssl); +#endif } if (++ssl->options.alertCount >= WOLFSSL_ALERT_COUNT_MAX) { @@ -27439,6 +27466,17 @@ int SendAlert(WOLFSSL* ssl, int severity, int type) return BAD_FUNC_ARG; } + /* InvalidateSessionOnFatalAlert() is defined in the !NO_TLS section, so the + * guard here must match (with NO_TLS there are no TLS sessions to evict). */ +#if !defined(NO_SESSION_CACHE) && !defined(NO_TLS) + /* RFC 5246 Section 7.2.2: a fatal alert terminates the connection; + * invalidate the established session so it cannot be resumed. Do this as + * soon as the fatal alert is generated, before the pendingAlert/backpressure + * handling below which can return early without sending the alert now. */ + if (severity == alert_fatal) + InvalidateSessionOnFatalAlert(ssl); +#endif + if (ssl->pendingAlert.level != alert_none) { ret = RetrySendAlert(ssl); if (ret != 0) { From 1173a365fea27e70d593be2e7d25225b806d08ac Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Wed, 3 Jun 2026 00:29:19 +0200 Subject: [PATCH 09/17] F-4144: honor WOLFSSL_OP_NO_RENEGOTIATION The documented 'reject peer-initiated renegotiation' option was accepted and stored but never consulted. Now DoHelloRequest replies with a no_renegotiation warning instead of starting SCR when the bit is set (client side), and the server refuses a renegotiation ClientHello with a no_renegotiation warning instead of resetting handshake state. --- src/internal.c | 21 ++++++ tests/api.c | 2 + tests/api/test_tls_ext.c | 139 +++++++++++++++++++++++++++++++++++++++ tests/api/test_tls_ext.h | 2 + 4 files changed, 164 insertions(+) diff --git a/src/internal.c b/src/internal.c index c3d6379eac..b27403ad3b 100644 --- a/src/internal.c +++ b/src/internal.c @@ -18051,6 +18051,15 @@ static int DoHelloRequest(WOLFSSL* ssl, word32 size) } #ifdef HAVE_SECURE_RENEGOTIATION else if (ssl->secure_renegotiation && ssl->secure_renegotiation->enabled) { + /* WOLFSSL_OP_NO_RENEGOTIATION: caller opted into rejecting + * peer-initiated renegotiation. Respond with a no_renegotiation + * warning alert instead of starting a secure renegotiation. */ + if (ssl->options.mask & WOLFSSL_OP_NO_RENEGOTIATION) { + WOLFSSL_MSG("Rejecting HelloRequest: WOLFSSL_OP_NO_RENEGOTIATION"); + WOLFSSL_LEAVE("DoHelloRequest", 0); + WOLFSSL_END(WC_FUNC_HELLO_REQUEST_DO); + return SendAlert(ssl, alert_warning, no_renegotiation); + } ssl->secure_renegotiation->startScr = 1; WOLFSSL_LEAVE("DoHelloRequest", 0); WOLFSSL_END(WC_FUNC_HELLO_REQUEST_DO); @@ -18783,6 +18792,17 @@ int DoHandShakeMsgType(WOLFSSL* ssl, byte* input, word32* inOutIdx, ssl->secure_renegotiation && ssl->secure_renegotiation->enabled) { + /* WOLFSSL_OP_NO_RENEGOTIATION: caller opted into rejecting + * peer-initiated renegotiation. RFC 5246 7.2.2: no_renegotiation is a + * warning-level alert, so refuse the renegotiation but keep the + * established connection rather than aborting it. Skip the ClientHello + * body and leave handshake state untouched, mirroring the client-side + * HelloRequest refusal in DoHelloRequest(). */ + if (ssl->options.mask & WOLFSSL_OP_NO_RENEGOTIATION) { + WOLFSSL_MSG("Refusing renegotiation: WOLFSSL_OP_NO_RENEGOTIATION"); + *inOutIdx = expectedIdx; + return SendAlert(ssl, alert_warning, no_renegotiation); + } WOLFSSL_MSG("Reset handshake state"); XMEMSET(&ssl->msgsReceived, 0, sizeof(MsgsReceived)); ssl->options.serverState = NULL_STATE; @@ -23240,6 +23260,7 @@ static int DoProcessReplyEx(WOLFSSL* ssl, int allowSocketErr) /* see if sending SSLv2 client hello */ if ( ssl->options.side == WOLFSSL_SERVER_END && ssl->options.clientState == NULL_STATE && + !ssl->options.handShakeDone && ssl->buffers.inputBuffer.buffer[ssl->buffers.inputBuffer.idx] != handshake && /* change_cipher_spec here is an error but we want to handle diff --git a/tests/api.c b/tests/api.c index 6208cc2b2d..96718dc334 100644 --- a/tests/api.c +++ b/tests/api.c @@ -35075,6 +35075,8 @@ TEST_CASE testCases[] = { TEST_DECL(test_tls12_chacha20_poly1305_bad_tag), TEST_DECL(test_tls13_null_cipher_bad_hmac), TEST_DECL(test_scr_verify_data_mismatch), + TEST_DECL(test_scr_no_renegotiation_option), + TEST_DECL(test_helloRequest_no_renegotiation_option), TEST_DECL(test_tls13_hrr_cipher_suite_mismatch), TEST_DECL(test_tls13_ticket_age_out_of_window), TEST_DECL(test_wolfSSL_DisableExtendedMasterSecret), diff --git a/tests/api/test_tls_ext.c b/tests/api/test_tls_ext.c index 294fa9c903..7da0976eb4 100644 --- a/tests/api/test_tls_ext.c +++ b/tests/api/test_tls_ext.c @@ -350,6 +350,145 @@ int test_scr_verify_data_mismatch(void) return EXPECT_RESULT(); } +/* F-4144: WOLFSSL_OP_NO_RENEGOTIATION on the server must refuse a + * client-initiated renegotiation with a no_renegotiation *warning* while + * keeping the established connection alive, rather than aborting it. */ +int test_scr_no_renegotiation_option(void) +{ + EXPECT_DECLS; +#if defined(HAVE_SECURE_RENEGOTIATION) && !defined(WOLFSSL_NO_TLS12) && \ + defined(BUILD_TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256) && \ + defined(HAVE_MANUAL_MEMIO_TESTS_DEPENDENCIES) + struct test_memio_ctx test_ctx; + WOLFSSL_CTX *ctx_c = NULL; + WOLFSSL_CTX *ctx_s = NULL; + WOLFSSL *ssl_c = NULL; + WOLFSSL *ssl_s = NULL; + WOLFSSL_ALERT_HISTORY history; + byte readBuf[16]; + int ret = WC_NO_ERR_TRACE(WOLFSSL_FATAL_ERROR); + int i; + + XMEMSET(&test_ctx, 0, sizeof(test_ctx)); + XMEMSET(&history, 0, sizeof(history)); + test_ctx.c_ciphers = test_ctx.s_ciphers = "ECDHE-RSA-AES128-GCM-SHA256"; + + ExpectIntEQ(test_memio_setup(&test_ctx, &ctx_c, &ctx_s, &ssl_c, + &ssl_s, wolfTLSv1_2_client_method, + wolfTLSv1_2_server_method), 0); + ExpectIntEQ(wolfSSL_CTX_UseSecureRenegotiation(ctx_c), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_CTX_UseSecureRenegotiation(ctx_s), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_UseSecureRenegotiation(ssl_c), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_UseSecureRenegotiation(ssl_s), WOLFSSL_SUCCESS); + + /* Server opts into rejecting peer-initiated renegotiation. */ + wolfSSL_set_options(ssl_s, WOLFSSL_OP_NO_RENEGOTIATION); + + ExpectIntEQ(test_memio_do_handshake(ssl_c, ssl_s, 10, NULL), 0); + + /* Client initiates renegotiation: it sends a ClientHello and waits for a + * ServerHello that never comes. */ + ExpectIntLT(wolfSSL_Rehandshake(ssl_c), 0); + ExpectIntEQ(wolfSSL_get_error(ssl_c, -1), WOLFSSL_ERROR_WANT_READ); + + /* Server processes the renegotiation ClientHello. It must refuse without + * aborting: the read returns WANT_READ (connection still alive), not a + * SECURE_RENEGOTIATION_E fatal error. */ + ExpectIntLT(wolfSSL_read(ssl_s, readBuf, sizeof(readBuf)), 0); + ExpectIntEQ(wolfSSL_get_error(ssl_s, -1), WOLFSSL_ERROR_WANT_READ); + + /* The refusal was a warning-level no_renegotiation alert. */ + ExpectIntEQ(wolfSSL_get_alert_history(ssl_s, &history), WOLFSSL_SUCCESS); + ExpectIntEQ(history.last_tx.level, alert_warning); + ExpectIntEQ(history.last_tx.code, no_renegotiation); + + /* The connection is still active and passes data: the server sends + * application data which the client receives and decrypts correctly, even + * though the client's renegotiation attempt was refused. The client + * surfaces the data once it has processed the no_renegotiation warning. */ + ExpectIntEQ(wolfSSL_write(ssl_s, "hello", 5), 5); + for (i = 0; i < 10 && ret != 5; i++) + ret = wolfSSL_read(ssl_c, readBuf, sizeof(readBuf)); + ExpectIntEQ(ret, 5); + ExpectIntEQ(XMEMCMP(readBuf, "hello", 5), 0); + + wolfSSL_free(ssl_c); + wolfSSL_free(ssl_s); + wolfSSL_CTX_free(ctx_c); + wolfSSL_CTX_free(ctx_s); +#endif + return EXPECT_RESULT(); +} + +/* F-4144: WOLFSSL_OP_NO_RENEGOTIATION on the client must refuse a + * server-initiated renegotiation (HelloRequest) with a no_renegotiation + * *warning* while keeping the established connection alive, rather than + * starting a secure renegotiation. */ +int test_helloRequest_no_renegotiation_option(void) +{ + EXPECT_DECLS; +#if defined(HAVE_SECURE_RENEGOTIATION) && !defined(WOLFSSL_NO_TLS12) && \ + defined(BUILD_TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256) && \ + defined(HAVE_MANUAL_MEMIO_TESTS_DEPENDENCIES) + struct test_memio_ctx test_ctx; + WOLFSSL_CTX *ctx_c = NULL; + WOLFSSL_CTX *ctx_s = NULL; + WOLFSSL *ssl_c = NULL; + WOLFSSL *ssl_s = NULL; + WOLFSSL_ALERT_HISTORY history; + byte readBuf[16]; + int ret = WC_NO_ERR_TRACE(WOLFSSL_FATAL_ERROR); + int i; + + XMEMSET(&test_ctx, 0, sizeof(test_ctx)); + XMEMSET(&history, 0, sizeof(history)); + test_ctx.c_ciphers = test_ctx.s_ciphers = "ECDHE-RSA-AES128-GCM-SHA256"; + + ExpectIntEQ(test_memio_setup(&test_ctx, &ctx_c, &ctx_s, &ssl_c, + &ssl_s, wolfTLSv1_2_client_method, + wolfTLSv1_2_server_method), 0); + ExpectIntEQ(wolfSSL_CTX_UseSecureRenegotiation(ctx_c), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_CTX_UseSecureRenegotiation(ctx_s), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_UseSecureRenegotiation(ssl_c), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_UseSecureRenegotiation(ssl_s), WOLFSSL_SUCCESS); + + /* Client opts into rejecting peer-initiated renegotiation. */ + wolfSSL_set_options(ssl_c, WOLFSSL_OP_NO_RENEGOTIATION); + + ExpectIntEQ(test_memio_do_handshake(ssl_c, ssl_s, 10, NULL), 0); + + /* Server asks the client to renegotiate by sending a HelloRequest, then + * waits for the ClientHello that never comes. */ + ExpectIntLT(wolfSSL_Rehandshake(ssl_s), 0); + ExpectIntEQ(wolfSSL_get_error(ssl_s, -1), WOLFSSL_ERROR_WANT_READ); + + /* Client processes the HelloRequest. It must refuse without starting a + * renegotiation: the read returns WANT_READ (connection still alive). */ + ExpectIntLT(wolfSSL_read(ssl_c, readBuf, sizeof(readBuf)), 0); + ExpectIntEQ(wolfSSL_get_error(ssl_c, -1), WOLFSSL_ERROR_WANT_READ); + + /* The refusal was a warning-level no_renegotiation alert. */ + ExpectIntEQ(wolfSSL_get_alert_history(ssl_c, &history), WOLFSSL_SUCCESS); + ExpectIntEQ(history.last_tx.level, alert_warning); + ExpectIntEQ(history.last_tx.code, no_renegotiation); + + /* The connection is still active and passes data: the client sends + * application data which the server receives and decrypts correctly, even + * though its renegotiation request was refused. */ + ExpectIntEQ(wolfSSL_write(ssl_c, "hello", 5), 5); + for (i = 0; i < 10 && ret != 5; i++) + ret = wolfSSL_read(ssl_s, readBuf, sizeof(readBuf)); + ExpectIntEQ(ret, 5); + ExpectIntEQ(XMEMCMP(readBuf, "hello", 5), 0); + + wolfSSL_free(ssl_c); + wolfSSL_free(ssl_s); + wolfSSL_CTX_free(ctx_c); + wolfSSL_CTX_free(ctx_s); +#endif + return EXPECT_RESULT(); +} + /* F-2126: DoTls13ClientHello must reject a second ClientHello whose * cipher suite does not match the server's HelloRetryRequest. The * client offers two suites in CH1 and only a different one in CH2. */ diff --git a/tests/api/test_tls_ext.h b/tests/api/test_tls_ext.h index e9d07d81ec..eda61aeb14 100644 --- a/tests/api/test_tls_ext.h +++ b/tests/api/test_tls_ext.h @@ -27,6 +27,8 @@ int test_tls_ems_resumption_downgrade(void); int test_tls12_chacha20_poly1305_bad_tag(void); int test_tls13_null_cipher_bad_hmac(void); int test_scr_verify_data_mismatch(void); +int test_scr_no_renegotiation_option(void); +int test_helloRequest_no_renegotiation_option(void); int test_tls13_hrr_cipher_suite_mismatch(void); int test_tls13_ticket_age_out_of_window(void); int test_wolfSSL_DisableExtendedMasterSecret(void); From 1f4afe9ccc7d98b3e38b89acbb387f93179f2536 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Wed, 3 Jun 2026 00:32:43 +0200 Subject: [PATCH 10/17] F-5810: require renegotiation_info on renegotiation ClientHello The server validated client_verify_data only inside TLSX_SecureRenegotiation_Parse, which never runs when the renegotiation_info extension is absent, so a renegotiation ClientHello that omitted it was never checked. Track a per-handshake renegInfoSeen flag and, after parsing the renegotiation ClientHello extensions, abort with handshake_failure if the extension was absent (RFC 5746 3.7). Also reject an SCSV received during renegotiation (RFC 5746 3.5). --- src/internal.c | 26 ++++++++++++++++++++++++++ src/tls.c | 3 +++ wolfssl/internal.h | 11 +++++++---- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/internal.c b/src/internal.c index b27403ad3b..221ab40a94 100644 --- a/src/internal.c +++ b/src/internal.c @@ -18811,6 +18811,8 @@ int DoHandShakeMsgType(WOLFSSL* ssl, byte* input, word32* inOutIdx, ssl->options.acceptState = ACCEPT_FIRST_REPLY_DONE; ssl->options.handShakeState = NULL_STATE; ssl->secure_renegotiation->cache_status = SCR_CACHE_NEEDED; + /* Reset for the renegotiation_info presence check below. */ + ssl->secure_renegotiation->renegInfoSeen = 0; ret = InitHandshakeHashes(ssl); if (ret != 0) @@ -38745,6 +38747,17 @@ static int AddPSKtoPreMasterSecret(WOLFSSL* ssl) 0) { TLSX* extension; +#ifdef HAVE_SECURE_RENEGOTIATION + /* SCSV not allowed on a renegotiation ClientHello (RFC 5746 3.5). */ + if (ssl->secure_renegotiation && + ssl->secure_renegotiation->enabled && + ssl->secure_renegotiation->verifySet) { + WOLFSSL_MSG("SCSV received on renegotiation ClientHello"); + SendAlert(ssl, alert_fatal, handshake_failure); + ret = SECURE_RENEGOTIATION_E; + goto out; + } +#endif /* check for TLS_EMPTY_RENEGOTIATION_INFO_SCSV suite */ ret = TLSX_AddEmptyRenegotiationInfo(&ssl->extensions, ssl->heap); if (ret != WOLFSSL_SUCCESS) { @@ -38989,6 +39002,19 @@ static int AddPSKtoPreMasterSecret(WOLFSSL* ssl) *inOutIdx = begin + helloSz; /* skip extensions */ } +#ifdef HAVE_SECURE_RENEGOTIATION + /* renegotiation_info MUST be present on a renegotiation (RFC 5746 3.7). */ + if (ssl->secure_renegotiation && + ssl->secure_renegotiation->enabled && + ssl->secure_renegotiation->verifySet && + !ssl->secure_renegotiation->renegInfoSeen) { + WOLFSSL_MSG("Renegotiation ClientHello missing renegotiation_info"); + SendAlert(ssl, alert_fatal, handshake_failure); + ret = SECURE_RENEGOTIATION_E; + goto out; + } +#endif /* HAVE_SECURE_RENEGOTIATION */ + #ifdef WOLFSSL_DTLS_CID if (ssl->options.useDtlsCID) DtlsCIDOnExtensionsParsed(ssl); diff --git a/src/tls.c b/src/tls.c index f9ca896ae3..023a610e30 100644 --- a/src/tls.c +++ b/src/tls.c @@ -6234,6 +6234,9 @@ static int TLSX_SecureRenegotiation_Parse(WOLFSSL* ssl, const byte* input, if (ret == WOLFSSL_SUCCESS) ret = 0; } + /* renegotiation_info seen (checked by DoClientHello, RFC 5746 3.7) */ + if (ssl->secure_renegotiation != NULL) + ssl->secure_renegotiation->renegInfoSeen = 1; if (ret != 0 && ret != WC_NO_ERR_TRACE(SECURE_RENEGOTIATION_E)) { } else if (ssl->secure_renegotiation == NULL) { diff --git a/wolfssl/internal.h b/wolfssl/internal.h index 8b3231227b..899e695ffc 100644 --- a/wolfssl/internal.h +++ b/wolfssl/internal.h @@ -3498,13 +3498,16 @@ enum key_cache_state { /* Additional Connection State according to rfc5746 section 3.1 */ typedef struct SecureRenegotiation { - byte enabled; /* secure_renegotiation flag in rfc */ - byte verifySet; - byte startScr; /* server requested client to start scr */ + /* Single-bit flags grouped together so they pack into one storage unit. */ + WC_BITFIELD enabled:1; /* secure_renegotiation flag in rfc */ + WC_BITFIELD verifySet:1; + WC_BITFIELD startScr:1; /* server requested client to start scr */ + WC_BITFIELD renegInfoSeen:1; /* renegotiation_info ext seen this + * handshake (RFC 5746 3.7) */ + WC_BITFIELD subject_hash_set:1; /* if peer cert hash is set */ enum key_cache_state cache_status; /* track key cache state */ byte client_verify_data[TLS_FINISHED_SZ]; /* cached */ byte server_verify_data[TLS_FINISHED_SZ]; /* cached */ - byte subject_hash_set; /* if peer cert hash is set */ byte subject_hash[KEYID_SIZE]; /* peer cert hash */ Keys tmp_keys; /* can't overwrite real keys yet */ } SecureRenegotiation; From 1a2fcb8607efe480b88269696eceb65f90abc695 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Mon, 8 Jun 2026 19:22:42 +0000 Subject: [PATCH 11/17] F-4144: propagate SendAlert result in DoHelloRequest no-reneg trace In the WOLFSSL_OP_NO_RENEGOTIATION refusal path, WOLFSSL_LEAVE logged a hard-coded 0 while the function actually returned SendAlert()'s result. Capture the return value first so the trace reflects reality (e.g. when SendAlert fails due to write backpressure) and return it. --- src/internal.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/internal.c b/src/internal.c index 221ab40a94..e44cc41678 100644 --- a/src/internal.c +++ b/src/internal.c @@ -18055,10 +18055,12 @@ static int DoHelloRequest(WOLFSSL* ssl, word32 size) * peer-initiated renegotiation. Respond with a no_renegotiation * warning alert instead of starting a secure renegotiation. */ if (ssl->options.mask & WOLFSSL_OP_NO_RENEGOTIATION) { + int ret; WOLFSSL_MSG("Rejecting HelloRequest: WOLFSSL_OP_NO_RENEGOTIATION"); - WOLFSSL_LEAVE("DoHelloRequest", 0); + ret = SendAlert(ssl, alert_warning, no_renegotiation); + WOLFSSL_LEAVE("DoHelloRequest", ret); WOLFSSL_END(WC_FUNC_HELLO_REQUEST_DO); - return SendAlert(ssl, alert_warning, no_renegotiation); + return ret; } ssl->secure_renegotiation->startScr = 1; WOLFSSL_LEAVE("DoHelloRequest", 0); From 2da5b244386522d5675417e135d7fe453955810f Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Mon, 8 Jun 2026 21:02:29 +0000 Subject: [PATCH 12/17] F-5811: enforce resumed cipher suite match for ticket resumption The TLS 1.2 client only compared the ServerHello suite against the cached session suite for session-ID resumption; ticket resumption was skipped on the assumption the suite is bound in the ticket. But the ticket is opaque to the client, so it must enforce the match itself - otherwise a server could resume a ticket under a different (weaker) suite the client offered and the downgrade would go undetected (RFC 5246 7.4.1.3). The check is skipped only when the client retained no suite for the session (cipherSuite0/cipherSuite both zero), so there is nothing to compare against - as for EAP-FAST, whose PAC is a TLS ticket whose keys come from the session-secret callback and which never populates the cached suite. (0,0) is TLS_NULL_WITH_NULL_NULL, never negotiated, so it unambiguously means "no retained suite". The EMS check remains ticket-gated. Add memio regression tests: a ticket resumption under a different (retained) suite is rejected with MATCH_SUITE_ERROR, and a resumption whose cached suite was not retained still succeeds. --- src/internal.c | 28 +++++++++---- tests/api/test_tls.c | 93 ++++++++++++++++++++++++++++++++++++++++++++ tests/api/test_tls.h | 2 + 3 files changed, 115 insertions(+), 8 deletions(-) diff --git a/src/internal.c b/src/internal.c index e44cc41678..c4fcac5742 100644 --- a/src/internal.c +++ b/src/internal.c @@ -32362,14 +32362,26 @@ static void MakePSKPreMasterSecret(Arrays* arrays, byte use_psk_key) /* RFC 5246 Section 7.4.1.3: on resumption the ServerHello * reuses the previously negotiated cipher suite. Reject a * server that resumes the session but selects a different - * suite. Skipped for ticket resumption (suite is bound in the - * ticket), consistent with the EMS check above. */ - if ( - #ifdef HAVE_SESSION_TICKET - ssl->session->ticketLen == 0 && - #endif - (ssl->options.cipherSuite0 != ssl->session->cipherSuite0 || - ssl->options.cipherSuite != ssl->session->cipherSuite)) { + * suite. Unlike the EMS check above this also covers ticket + * resumption: the ticket is opaque to the client, so it cannot + * rely on the suite being bound inside the ticket and must + * enforce the match against the suite retained in the cached + * session (SetupSession stores it for ticket sessions too). + * Otherwise a server could resume a ticket under a different, + * weaker suite the client offered and the downgrade would go + * undetected. + * + * Skip only when the client retained no suite for the session + * (cipherSuite0/cipherSuite both zero): then there is nothing to + * compare against. This is the case for EAP-FAST, whose PAC is a + * TLS ticket whose keys are supplied through the session-secret + * callback and which never populates the cached suite. (0,0) is + * TLS_NULL_WITH_NULL_NULL, never a negotiated suite, so it + * unambiguously means "no retained suite". */ + if ((ssl->session->cipherSuite0 != 0 || + ssl->session->cipherSuite != 0) && + (ssl->options.cipherSuite0 != ssl->session->cipherSuite0 || + ssl->options.cipherSuite != ssl->session->cipherSuite)) { WOLFSSL_MSG("Resumed session cipher suite does not match " "ServerHello cipher suite"); SendAlert(ssl, alert_fatal, illegal_parameter); diff --git a/tests/api/test_tls.c b/tests/api/test_tls.c index 9053aef376..9a567125e4 100644 --- a/tests/api/test_tls.c +++ b/tests/api/test_tls.c @@ -876,6 +876,99 @@ int test_tls12_etm_failed_resumption(void) return EXPECT_RESULT(); } +/* RFC 5246 7.4.1.3: a server resuming a TLS 1.2 session ticket MUST reuse the + * session's cipher suite. The ticket is opaque to the client, so the client + * cannot rely on the suite being bound inside it and must compare the + * ServerHello suite against the suite retained in the cached session (F-5811 + * does this for session-ID resumption; it must hold for tickets too). This + * test establishes a ticket-based session, rewrites the cached session's suite + * to emulate a server that resumes the ticket under a different suite, and + * asserts the client aborts the resumption with MATCH_SUITE_ERROR. The same + * server CTX is reused for the second handshake so its ticket key persists. */ +int test_tls12_resume_ticket_wrong_suite(void) +{ + EXPECT_DECLS; +#if defined(HAVE_MANUAL_MEMIO_TESTS_DEPENDENCIES) && \ + !defined(WOLFSSL_NO_TLS12) && defined(HAVE_SESSION_TICKET) && \ + !defined(NO_RESUME_SUITE_CHECK) && !defined(NO_RSA) && defined(HAVE_ECC) && \ + !defined(NO_AES) && defined(HAVE_AESGCM) && !defined(NO_SHA256) && \ + defined(BUILD_TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256) + const char* suite = "ECDHE-RSA-AES128-GCM-SHA256"; + WOLFSSL_CTX *ctx_c = NULL, *ctx_s = NULL; + WOLFSSL *ssl_c = NULL, *ssl_s = NULL; + WOLFSSL *ssl_c2 = NULL, *ssl_s2 = NULL; + WOLFSSL *ssl_c3 = NULL, *ssl_s3 = NULL; + WOLFSSL_SESSION *sess = NULL; + struct test_memio_ctx test_ctx; + struct test_memio_ctx test_ctx2; + struct test_memio_ctx test_ctx3; + int ret; + + /* First handshake: establish a ticket-based TLS 1.2 session. */ + XMEMSET(&test_ctx, 0, sizeof(test_ctx)); + ExpectIntEQ(test_memio_setup(&test_ctx, &ctx_c, &ctx_s, &ssl_c, &ssl_s, + wolfTLSv1_2_client_method, wolfTLSv1_2_server_method), 0); + ExpectIntEQ(wolfSSL_set_cipher_list(ssl_c, suite), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_set_cipher_list(ssl_s, suite), WOLFSSL_SUCCESS); + /* Opt the client into TLS 1.2 session tickets so the server issues one. */ + ExpectIntEQ(wolfSSL_UseSessionTicket(ssl_c), WOLFSSL_SUCCESS); + ExpectIntEQ(test_memio_do_handshake(ssl_c, ssl_s, 10, NULL), 0); + ExpectNotNull(sess = wolfSSL_get1_session(ssl_c)); + /* Must be a ticket session to exercise the ticket path. */ + ExpectIntGT(sess->ticketLen, 0); + + /* Case 1 - downgrading server: change the cached suite so it no longer + * matches the suite the server reuses from the ticket, but keep it + * non-zero so it still counts as a retained suite. The value only feeds + * the comparison (the real keys come from the ServerHello suite), so + * flipping it is sufficient and safe. The client must reject the + * resumption against the same server CTX (ticket key persists). */ + if (sess != NULL) + sess->cipherSuite = (byte)(sess->cipherSuite ^ 0xFF); + + XMEMSET(&test_ctx2, 0, sizeof(test_ctx2)); + ExpectIntEQ(test_memio_setup(&test_ctx2, &ctx_c, &ctx_s, &ssl_c2, &ssl_s2, + wolfTLSv1_2_client_method, wolfTLSv1_2_server_method), 0); + ExpectIntEQ(wolfSSL_set_cipher_list(ssl_c2, suite), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_set_cipher_list(ssl_s2, suite), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_UseSessionTicket(ssl_c2), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_set_session(ssl_c2, sess), WOLFSSL_SUCCESS); + ret = test_memio_do_handshake(ssl_c2, ssl_s2, 10, NULL); + ExpectIntNE(ret, 0); + ExpectIntEQ(ssl_c2->error, WC_NO_ERR_TRACE(MATCH_SUITE_ERROR)); + + /* Case 2 - session that retained no suite (cipherSuite0/cipherSuite both + * zero), as for an EAP-FAST PAC whose keys come from the session-secret + * callback. There is nothing to compare against, so the check must be + * skipped and the resumption must still succeed. */ + if (sess != NULL) { + sess->cipherSuite0 = 0; + sess->cipherSuite = 0; + } + + XMEMSET(&test_ctx3, 0, sizeof(test_ctx3)); + ExpectIntEQ(test_memio_setup(&test_ctx3, &ctx_c, &ctx_s, &ssl_c3, &ssl_s3, + wolfTLSv1_2_client_method, wolfTLSv1_2_server_method), 0); + ExpectIntEQ(wolfSSL_set_cipher_list(ssl_c3, suite), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_set_cipher_list(ssl_s3, suite), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_UseSessionTicket(ssl_c3), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_set_session(ssl_c3, sess), WOLFSSL_SUCCESS); + ExpectIntEQ(test_memio_do_handshake(ssl_c3, ssl_s3, 10, NULL), 0); + ExpectIntEQ(wolfSSL_session_reused(ssl_c3), 1); + + wolfSSL_SESSION_free(sess); + wolfSSL_free(ssl_c); + wolfSSL_free(ssl_s); + wolfSSL_free(ssl_c2); + wolfSSL_free(ssl_s2); + wolfSSL_free(ssl_c3); + wolfSSL_free(ssl_s3); + wolfSSL_CTX_free(ctx_c); + wolfSSL_CTX_free(ctx_s); +#endif + return EXPECT_RESULT(); +} + /* wolfSSL_set_session() must reject a TLS 1.2 session when minDowngrade is * set to TLS 1.3. */ int test_tls_set_session_min_downgrade(void) diff --git a/tests/api/test_tls.h b/tests/api/test_tls.h index 0e140af98c..eecafab511 100644 --- a/tests/api/test_tls.h +++ b/tests/api/test_tls.h @@ -32,6 +32,7 @@ int test_tls_certreq_order(void); int test_tls12_bad_cv_sig_alg(void); int test_tls12_no_null_compression(void); int test_tls12_etm_failed_resumption(void); +int test_tls12_resume_ticket_wrong_suite(void); int test_tls_set_session_min_downgrade(void); int test_tls12_session_id_resumption_sni_mismatch(void); int test_tls13_session_resumption_sni_mismatch(void); @@ -55,6 +56,7 @@ int test_record_size_cache_invalidated_on_renegotiation(void); TEST_DECL_GROUP("tls", test_tls12_bad_cv_sig_alg), \ TEST_DECL_GROUP("tls", test_tls12_no_null_compression), \ TEST_DECL_GROUP("tls", test_tls12_etm_failed_resumption), \ + TEST_DECL_GROUP("tls", test_tls12_resume_ticket_wrong_suite), \ TEST_DECL_GROUP("tls", test_tls_set_session_min_downgrade), \ TEST_DECL_GROUP("tls", test_tls12_session_id_resumption_sni_mismatch), \ TEST_DECL_GROUP("tls", test_tls13_session_resumption_sni_mismatch), \ From 748678715ad7ad77c112096c2a90230cfbd65ea7 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Wed, 10 Jun 2026 20:28:59 +0000 Subject: [PATCH 13/17] F-5807: extend EMS resumption check to ticket resumption Address review on PR #10582: - The client-side extended_master_secret consistency check skipped all session-ticket resumptions, leaving a generic ticket resumption open to an undetected EMS downgrade by a malicious server or MITM. The client retains the EMS state for ticket sessions too (SetupSession), so the check now applies to ticket resumption as well, mirroring the adjacent cipher-suite check. Only EAP-FAST style resumption - where the session-secret callback supplies the master secret for an opaque PAC ticket - is exempt, matched precisely via ssl->sessionSecretCb just as the callback invocation in DoServerHello does. - Add test_tls_ems_resumption_server_downgrade, exercising the client-direction downgrade (server resumes but omits EMS from its ServerHello) for both session-ID and session-ticket resumption. This client-side branch previously had no test coverage. --- src/internal.c | 14 ++-- tests/api.c | 1 + tests/api/test_tls_ext.c | 160 +++++++++++++++++++++++++++++++++++++++ tests/api/test_tls_ext.h | 1 + 4 files changed, 171 insertions(+), 5 deletions(-) diff --git a/src/internal.c b/src/internal.c index c4fcac5742..00335f4fd9 100644 --- a/src/internal.c +++ b/src/internal.c @@ -32343,13 +32343,17 @@ static void MakePSKPreMasterSecret(Arrays* arrays, byte use_psk_key) else { if (DSH_CheckSessionId(ssl)) { /* RFC 7627 5.3: resumed session EMS state must match the - * ServerHello; abort on mismatch. Stateless (session-ticket) - * resumption - e.g. EAP-FAST, whose PAC is a TLS ticket - binds - * the EMS state in the ticket and need not re-advertise the - * extension, so this applies only to session-ID resumption. */ + * ServerHello; abort on mismatch. Covers ticket resumption + * too. Skip only EAP-FAST (sessionSecretCb supplied the PAC + * ticket's secret in DoServerHello), whose synthetic session + * has no negotiated EMS state to compare. */ if ( + #ifdef HAVE_SECRET_CALLBACK + !(ssl->sessionSecretCb != NULL #ifdef HAVE_SESSION_TICKET - ssl->session->ticketLen == 0 && + && ssl->session->ticketLen > 0 + #endif + ) && #endif ssl->session->haveEMS != ssl->options.haveEMS) { WOLFSSL_MSG("Resumed session EMS state does not match " diff --git a/tests/api.c b/tests/api.c index 96718dc334..8cf5d32fb0 100644 --- a/tests/api.c +++ b/tests/api.c @@ -35072,6 +35072,7 @@ TEST_CASE testCases[] = { #endif TEST_DECL(test_tls_ems_downgrade), TEST_DECL(test_tls_ems_resumption_downgrade), + TEST_DECL(test_tls_ems_resumption_server_downgrade), TEST_DECL(test_tls12_chacha20_poly1305_bad_tag), TEST_DECL(test_tls13_null_cipher_bad_hmac), TEST_DECL(test_scr_verify_data_mismatch), diff --git a/tests/api/test_tls_ext.c b/tests/api/test_tls_ext.c index 7da0976eb4..a1ac92656e 100644 --- a/tests/api/test_tls_ext.c +++ b/tests/api/test_tls_ext.c @@ -153,6 +153,166 @@ int test_tls_ems_resumption_downgrade(void) } +#if !defined(WOLFSSL_NO_TLS12) && defined(HAVE_EXTENDED_MASTER) && \ + defined(HAVE_MANUAL_MEMIO_TESTS_DEPENDENCIES) && \ + !defined(NO_SESSION_CACHE) +/* Remove the extended_master_secret extension from the ServerHello record at + * the head of the server-to-client memio buffer, patching up the record, + * handshake and extension-block lengths so the message still parses. + * Returns 0 on success. */ +static int StripEmsFromServerHello(struct test_memio_ctx* test_ctx) +{ + byte* buf = test_ctx->c_buff; + int len = test_ctx->c_len; + int recLen; + int hsLen; + int extsLenIdx; + int extsLen; + int idx; + int extsEnd; + + /* Record header: type(1) version(2) length(2) */ + if (len < 5 || buf[0] != handshake) + return -1; + recLen = (buf[3] << 8) | buf[4]; + if (5 + recLen > len) + return -1; + /* Handshake header: type(1) length(3) */ + if (recLen < HANDSHAKE_HEADER_SZ || buf[5] != server_hello) + return -1; + hsLen = (buf[6] << 16) | (buf[7] << 8) | buf[8]; + /* Skip version(2), random(32) to the session ID length, then skip the + * session ID, cipher suite(2) and compression(1) to the extensions + * length. */ + extsLenIdx = 5 + HANDSHAKE_HEADER_SZ + OPAQUE16_LEN + RAN_LEN + + OPAQUE8_LEN + buf[5 + HANDSHAKE_HEADER_SZ + OPAQUE16_LEN + + RAN_LEN] + + OPAQUE16_LEN + OPAQUE8_LEN; + if (extsLenIdx + OPAQUE16_LEN > 5 + recLen) + return -1; + extsLen = (buf[extsLenIdx] << 8) | buf[extsLenIdx + 1]; + idx = extsLenIdx + OPAQUE16_LEN; + extsEnd = idx + extsLen; + if (extsEnd > 5 + recLen) + return -1; + while (idx + 4 <= extsEnd) { + int extType = (buf[idx] << 8) | buf[idx + 1]; + int extLen = (buf[idx + 2] << 8) | buf[idx + 3]; + int rmLen = 4 + extLen; + + if (idx + rmLen > extsEnd) + return -1; + if (extType == HELLO_EXT_EXTMS) { + XMEMMOVE(buf + idx, buf + idx + rmLen, + (size_t)(len - idx - rmLen)); + recLen -= rmLen; + hsLen -= rmLen; + extsLen -= rmLen; + buf[3] = (byte)(recLen >> 8); + buf[4] = (byte)recLen; + buf[6] = (byte)(hsLen >> 16); + buf[7] = (byte)(hsLen >> 8); + buf[8] = (byte)hsLen; + buf[extsLenIdx] = (byte)(extsLen >> 8); + buf[extsLenIdx + 1] = (byte)extsLen; + test_ctx->c_len -= rmLen; + /* The ServerHello record sits wholly inside the first buffered + * message. */ + test_ctx->c_msg_sizes[0] -= rmLen; + return 0; + } + idx += rmLen; + } + return -1; +} + +/* Full handshake with EMS, then resume and strip the EMS extension from the + * ServerHello in transit. The client must catch the downgrade and abort + * (RFC 7627 Section 5.3). useTicket selects session-ticket resumption + * instead of session-ID resumption. */ +static int test_tls_ems_resumption_server_downgrade_ex(int useTicket) +{ + EXPECT_DECLS; + struct test_memio_ctx test_ctx; + WOLFSSL_CTX *ctx_c = NULL; + WOLFSSL_CTX *ctx_s = NULL; + WOLFSSL *ssl_c = NULL; + WOLFSSL *ssl_s = NULL; + WOLFSSL_SESSION *session = NULL; + +#ifndef HAVE_SESSION_TICKET + (void)useTicket; +#endif + + XMEMSET(&test_ctx, 0, sizeof(test_ctx)); + + ExpectIntEQ(test_memio_setup(&test_ctx, &ctx_c, &ctx_s, &ssl_c, &ssl_s, + wolfTLSv1_2_client_method, wolfTLSv1_2_server_method), 0); +#ifdef HAVE_SESSION_TICKET + if (useTicket) + ExpectIntEQ(wolfSSL_UseSessionTicket(ssl_c), WOLFSSL_SUCCESS); +#endif + ExpectIntEQ(test_memio_do_handshake(ssl_c, ssl_s, 10, NULL), 0); + + ExpectNotNull(session = wolfSSL_get1_session(ssl_c)); + ExpectTrue(session->haveEMS); +#ifdef HAVE_SESSION_TICKET + if (useTicket) + ExpectIntGT(session->ticketLen, 0); +#endif + + wolfSSL_free(ssl_c); + ssl_c = NULL; + wolfSSL_free(ssl_s); + ssl_s = NULL; + test_memio_clear_buffer(&test_ctx, 0); + test_memio_clear_buffer(&test_ctx, 1); + + ExpectIntEQ(test_memio_setup(&test_ctx, &ctx_c, &ctx_s, &ssl_c, &ssl_s, + wolfTLSv1_2_client_method, wolfTLSv1_2_server_method), 0); + ExpectIntEQ(wolfSSL_set_session(ssl_c, session), WOLFSSL_SUCCESS); + + /* ClientHello */ + ExpectIntEQ(wolfSSL_connect(ssl_c), -1); + ExpectIntEQ(wolfSSL_get_error(ssl_c, -1), WOLFSSL_ERROR_WANT_READ); + /* Server flight accepting the resumption */ + ExpectIntEQ(wolfSSL_accept(ssl_s), -1); + ExpectIntEQ(wolfSSL_get_error(ssl_s, -1), WOLFSSL_ERROR_WANT_READ); + /* Drop EMS from the ServerHello to simulate a downgrading server. */ + ExpectIntEQ(StripEmsFromServerHello(&test_ctx), 0); + /* The client must refuse to resume without EMS. */ + ExpectIntEQ(wolfSSL_connect(ssl_c), -1); + ExpectIntEQ(wolfSSL_get_error(ssl_c, -1), + WC_NO_ERR_TRACE(EXT_MASTER_SECRET_NEEDED_E)); + + wolfSSL_SESSION_free(session); + wolfSSL_free(ssl_c); + wolfSSL_free(ssl_s); + wolfSSL_CTX_free(ctx_c); + wolfSSL_CTX_free(ctx_s); + return EXPECT_RESULT(); +} +#endif + +/* F-5807: a server that resumes an EMS session but omits the + * extended_master_secret extension from its ServerHello must be rejected by + * the client with EXT_MASTER_SECRET_NEEDED_E (RFC 7627 Section 5.3), on both + * session-ID and session-ticket resumption. */ +int test_tls_ems_resumption_server_downgrade(void) +{ + EXPECT_DECLS; +#if !defined(WOLFSSL_NO_TLS12) && defined(HAVE_EXTENDED_MASTER) && \ + defined(HAVE_MANUAL_MEMIO_TESTS_DEPENDENCIES) && \ + !defined(NO_SESSION_CACHE) + ExpectIntEQ(test_tls_ems_resumption_server_downgrade_ex(0), TEST_SUCCESS); +#if defined(HAVE_SESSION_TICKET) && !defined(WOLFSSL_NO_DEF_TICKET_ENC_CB) + ExpectIntEQ(test_tls_ems_resumption_server_downgrade_ex(1), TEST_SUCCESS); +#endif +#endif + return EXPECT_RESULT(); +} + + #if !defined(WOLFSSL_NO_TLS12) && \ defined(BUILD_TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256) && \ defined(HAVE_MANUAL_MEMIO_TESTS_DEPENDENCIES) diff --git a/tests/api/test_tls_ext.h b/tests/api/test_tls_ext.h index eda61aeb14..9a0d838606 100644 --- a/tests/api/test_tls_ext.h +++ b/tests/api/test_tls_ext.h @@ -24,6 +24,7 @@ int test_tls_ems_downgrade(void); int test_tls_ems_resumption_downgrade(void); +int test_tls_ems_resumption_server_downgrade(void); int test_tls12_chacha20_poly1305_bad_tag(void); int test_tls13_null_cipher_bad_hmac(void); int test_scr_verify_data_mismatch(void); From 2352d73f7f41efa8f15f1b3aadbf1818df4a4fb4 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 11 Jun 2026 19:22:35 +0000 Subject: [PATCH 14/17] F-5811: defer resumed-session consistency checks to confirmed resumption The client's resumed-session EMS (F-5807) and cipher-suite (F-5811) checks were enforced in CompleteServerHello at ServerHello-parse time. For stateless ticket resumption the client sends an empty session ID and cannot yet tell whether the server accepted the ticket (RFC 5077 3.4): a server that declines the ticket falls back to a full handshake under a freshly negotiated suite/EMS state, which these checks wrongly aborted with MATCH_SUITE_ERROR, breaking the RFC 5077 ticket-decline fallback to a full handshake. Move both checks into CheckResumptionConsistency and run it only once resumption is confirmed - from whichever the server sends first in the abbreviated flight: a renewed NewSessionTicket (before SetupSession refreshes the cached suite/EMS to the current values) or its ChangeCipherSpec. By then the "Not resuming as thought" path has cleared 'resuming' for any ticket decline, so the full-handshake fallback proceeds. Add test_tls12_resume_ticket_decline_fallback (ticket declined by a fresh server CTX, full handshake under a different suite must succeed) and gate test_tls12_resume_ticket_wrong_suite on WOLFSSL_NO_DEF_TICKET_ENC_CB so it skips rather than fails in builds without the default ticket encryption callback. --- src/internal.c | 118 ++++++++++++++++++++++++------------------- tests/api/test_tls.c | 69 +++++++++++++++++++++++++ tests/api/test_tls.h | 2 + 3 files changed, 138 insertions(+), 51 deletions(-) diff --git a/src/internal.c b/src/internal.c index 00335f4fd9..e946ecb2b1 100644 --- a/src/internal.c +++ b/src/internal.c @@ -23150,6 +23150,52 @@ static void DropAndRestartProcessReply(WOLFSSL* ssl) #endif /* WOLFSSL_DTLS_DROP_STATS */ } #endif /* WOLFSSL_DTLS */ + +#ifndef WOLFSSL_NO_TLS12 +/* On a confirmed TLS 1.2 / DTLS 1.2 client resumption, check the abbreviated + * ServerHello's EMS state (RFC 7627 5.3) and cipher suite (RFC 5246 7.4.1.3) + * match the resumed session. Called once resumption is confirmed - at a renewed + * NewSessionTicket (before SetupSession refreshes the cached values) or the + * server ChangeCipherSpec. Deferred from ServerHello because a declined ticket + * (RFC 5077 3.4) falls back to a full handshake that must not be rejected. + * Returns 0 if consistent, else sends a fatal alert and returns an error. */ +static int CheckResumptionConsistency(WOLFSSL* ssl) +{ + if (ssl->session == NULL) /* nothing to compare against */ + return 0; + /* EMS must match (RFC 7627 5.3); skip EAP-FAST (session-secret callback). */ + if ( +#ifdef HAVE_SECRET_CALLBACK + !(ssl->sessionSecretCb != NULL +#ifdef HAVE_SESSION_TICKET + && ssl->session->ticketLen > 0 +#endif + ) && +#endif + ssl->session->haveEMS != ssl->options.haveEMS) { + WOLFSSL_MSG("Resumed session EMS state does not match " + "ServerHello EMS state"); + SendAlert(ssl, alert_fatal, handshake_failure); + WOLFSSL_ERROR_VERBOSE(EXT_MASTER_SECRET_NEEDED_E); + return EXT_MASTER_SECRET_NEEDED_E; + } +#ifndef NO_RESUME_SUITE_CHECK + /* Suite must match (RFC 5246 7.4.1.3), tickets included. Skip when no suite + * was retained (both zero = TLS_NULL_WITH_NULL_NULL, e.g. EAP-FAST PAC). */ + if ((ssl->session->cipherSuite0 != 0 || ssl->session->cipherSuite != 0) && + (ssl->options.cipherSuite0 != ssl->session->cipherSuite0 || + ssl->options.cipherSuite != ssl->session->cipherSuite)) { + WOLFSSL_MSG("Resumed session cipher suite does not match " + "ServerHello cipher suite"); + SendAlert(ssl, alert_fatal, illegal_parameter); + WOLFSSL_ERROR_VERBOSE(MATCH_SUITE_ERROR); + return MATCH_SUITE_ERROR; + } +#endif /* NO_RESUME_SUITE_CHECK */ + return 0; +} +#endif /* !WOLFSSL_NO_TLS12 */ + /* Process input requests. Return 0 is done, 1 is call again to complete, and negative number is error. If allowSocketErr is set, SOCKET_ERROR_E in ssl->error will be whitelisted. This is useful when the connection has been @@ -23980,6 +24026,15 @@ default: } } + /* Server CCS confirms the abbreviated handshake: validate + * the resumed session before installing keys. */ + if (ssl->options.side == WOLFSSL_CLIENT_END && + ssl->options.resuming) { + ret = CheckResumptionConsistency(ssl); + if (ret != 0) + return ret; + } + ssl->keys.encryptionOn = 1; /* setup decrypt keys for following messages */ @@ -32342,57 +32397,9 @@ static void MakePSKPreMasterSecret(Arrays* arrays, byte use_psk_key) } else { if (DSH_CheckSessionId(ssl)) { - /* RFC 7627 5.3: resumed session EMS state must match the - * ServerHello; abort on mismatch. Covers ticket resumption - * too. Skip only EAP-FAST (sessionSecretCb supplied the PAC - * ticket's secret in DoServerHello), whose synthetic session - * has no negotiated EMS state to compare. */ - if ( - #ifdef HAVE_SECRET_CALLBACK - !(ssl->sessionSecretCb != NULL - #ifdef HAVE_SESSION_TICKET - && ssl->session->ticketLen > 0 - #endif - ) && - #endif - ssl->session->haveEMS != ssl->options.haveEMS) { - WOLFSSL_MSG("Resumed session EMS state does not match " - "ServerHello EMS state"); - SendAlert(ssl, alert_fatal, handshake_failure); - WOLFSSL_ERROR_VERBOSE(EXT_MASTER_SECRET_NEEDED_E); - return EXT_MASTER_SECRET_NEEDED_E; - } -#ifndef NO_RESUME_SUITE_CHECK - /* RFC 5246 Section 7.4.1.3: on resumption the ServerHello - * reuses the previously negotiated cipher suite. Reject a - * server that resumes the session but selects a different - * suite. Unlike the EMS check above this also covers ticket - * resumption: the ticket is opaque to the client, so it cannot - * rely on the suite being bound inside the ticket and must - * enforce the match against the suite retained in the cached - * session (SetupSession stores it for ticket sessions too). - * Otherwise a server could resume a ticket under a different, - * weaker suite the client offered and the downgrade would go - * undetected. - * - * Skip only when the client retained no suite for the session - * (cipherSuite0/cipherSuite both zero): then there is nothing to - * compare against. This is the case for EAP-FAST, whose PAC is a - * TLS ticket whose keys are supplied through the session-secret - * callback and which never populates the cached suite. (0,0) is - * TLS_NULL_WITH_NULL_NULL, never a negotiated suite, so it - * unambiguously means "no retained suite". */ - if ((ssl->session->cipherSuite0 != 0 || - ssl->session->cipherSuite != 0) && - (ssl->options.cipherSuite0 != ssl->session->cipherSuite0 || - ssl->options.cipherSuite != ssl->session->cipherSuite)) { - WOLFSSL_MSG("Resumed session cipher suite does not match " - "ServerHello cipher suite"); - SendAlert(ssl, alert_fatal, illegal_parameter); - WOLFSSL_ERROR_VERBOSE(MATCH_SUITE_ERROR); - return MATCH_SUITE_ERROR; - } -#endif /* NO_RESUME_SUITE_CHECK */ + /* EMS/suite consistency is checked once resumption is confirmed + * (CheckResumptionConsistency), not here: a ticket the server + * declines (RFC 5077 3.4) must fall back to a full handshake. */ if (SetCipherSpecs(ssl) == 0) { if (!HaveUniqueSessionObj(ssl)) { WOLFSSL_MSG("Unable to have unique session object"); @@ -35641,6 +35648,15 @@ static int DoSessionTicket(WOLFSSL* ssl, const byte* input, word32* inOutIdx, return SESSION_TICKET_EXPECT_E; } + /* A renewed ticket while resuming confirms resumption; check before the + * SetupSession() below refreshes the cached suite/EMS and masks a downgrade. + * (The ChangeCipherSpec check covers the no-renewal case.) */ + if (ssl->options.resuming) { + ret = CheckResumptionConsistency(ssl); + if (ret != 0) + return ret; + } + if (OPAQUE32_LEN > size) return BUFFER_ERROR; diff --git a/tests/api/test_tls.c b/tests/api/test_tls.c index 9a567125e4..960c3bf9cb 100644 --- a/tests/api/test_tls.c +++ b/tests/api/test_tls.c @@ -890,6 +890,7 @@ int test_tls12_resume_ticket_wrong_suite(void) EXPECT_DECLS; #if defined(HAVE_MANUAL_MEMIO_TESTS_DEPENDENCIES) && \ !defined(WOLFSSL_NO_TLS12) && defined(HAVE_SESSION_TICKET) && \ + !defined(WOLFSSL_NO_DEF_TICKET_ENC_CB) && \ !defined(NO_RESUME_SUITE_CHECK) && !defined(NO_RSA) && defined(HAVE_ECC) && \ !defined(NO_AES) && defined(HAVE_AESGCM) && !defined(NO_SHA256) && \ defined(BUILD_TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256) @@ -969,6 +970,74 @@ int test_tls12_resume_ticket_wrong_suite(void) return EXPECT_RESULT(); } +/* A ticket the server can't honor must fall back to a full handshake (RFC 5077 + * 3.4), even under a different suite than the cached ticket session - the + * F-5811 suite check must not abort it. The second handshake uses a fresh + * server CTX (new ticket key -> decline) offering only suite B while the client + * offers B and the session's suite A. */ +int test_tls12_resume_ticket_decline_fallback(void) +{ + EXPECT_DECLS; +#if defined(HAVE_MANUAL_MEMIO_TESTS_DEPENDENCIES) && \ + !defined(WOLFSSL_NO_TLS12) && defined(HAVE_SESSION_TICKET) && \ + !defined(WOLFSSL_NO_DEF_TICKET_ENC_CB) && !defined(NO_SESSION_CACHE) && \ + !defined(NO_RESUME_SUITE_CHECK) && !defined(NO_RSA) && defined(HAVE_ECC) && \ + !defined(NO_AES) && defined(HAVE_AESGCM) && !defined(NO_SHA256) && \ + defined(BUILD_TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256) && \ + defined(BUILD_TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) + const char* suiteA = "ECDHE-RSA-AES128-GCM-SHA256"; + const char* suiteB = "ECDHE-RSA-AES256-GCM-SHA384"; + const char* suiteBA = + "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256"; + WOLFSSL_CTX *ctx_c = NULL, *ctx_s = NULL, *ctx_s2 = NULL; + WOLFSSL *ssl_c = NULL, *ssl_s = NULL; + WOLFSSL *ssl_c2 = NULL, *ssl_s2 = NULL; + WOLFSSL_SESSION *sess = NULL; + struct test_memio_ctx test_ctx; + struct test_memio_ctx test_ctx2; + + /* First handshake: establish a ticket-based TLS 1.2 session on suite A. */ + XMEMSET(&test_ctx, 0, sizeof(test_ctx)); + ExpectIntEQ(test_memio_setup(&test_ctx, &ctx_c, &ctx_s, &ssl_c, &ssl_s, + wolfTLSv1_2_client_method, wolfTLSv1_2_server_method), 0); + ExpectIntEQ(wolfSSL_set_cipher_list(ssl_c, suiteA), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_set_cipher_list(ssl_s, suiteA), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_UseSessionTicket(ssl_c), WOLFSSL_SUCCESS); + ExpectIntEQ(test_memio_do_handshake(ssl_c, ssl_s, 10, NULL), 0); + ExpectNotNull(sess = wolfSSL_get1_session(ssl_c)); + ExpectIntGT(sess->ticketLen, 0); + + /* Second handshake: fresh server CTX (NULL ctx_s2 -> new ticket key) so the + * ticket is declined and the server does a full handshake on suite B. */ + XMEMSET(&test_ctx2, 0, sizeof(test_ctx2)); + ExpectIntEQ(test_memio_setup(&test_ctx2, &ctx_c, &ctx_s2, &ssl_c2, &ssl_s2, + wolfTLSv1_2_client_method, wolfTLSv1_2_server_method), 0); + ExpectIntEQ(wolfSSL_set_cipher_list(ssl_c2, suiteBA), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_set_cipher_list(ssl_s2, suiteB), WOLFSSL_SUCCESS); + /* Session cache off so the declining server emits an empty session ID and + * the client takes the graceful full-handshake fallback (set on the SSL as + * the flag is copied from the CTX at wolfSSL_new() time). */ + if (ssl_s2 != NULL) + ssl_s2->options.sessionCacheOff = 1; + ExpectIntEQ(wolfSSL_UseSessionTicket(ssl_c2), WOLFSSL_SUCCESS); + ExpectIntEQ(wolfSSL_set_session(ssl_c2, sess), WOLFSSL_SUCCESS); + /* Fallback must succeed (no MATCH_SUITE_ERROR), not resume, and use B. */ + ExpectIntEQ(test_memio_do_handshake(ssl_c2, ssl_s2, 10, NULL), 0); + ExpectIntEQ(wolfSSL_session_reused(ssl_c2), 0); + ExpectStrEQ(wolfSSL_get_cipher_name(ssl_c2), suiteB); + + wolfSSL_SESSION_free(sess); + wolfSSL_free(ssl_c); + wolfSSL_free(ssl_s); + wolfSSL_free(ssl_c2); + wolfSSL_free(ssl_s2); + wolfSSL_CTX_free(ctx_c); + wolfSSL_CTX_free(ctx_s); + wolfSSL_CTX_free(ctx_s2); +#endif + return EXPECT_RESULT(); +} + /* wolfSSL_set_session() must reject a TLS 1.2 session when minDowngrade is * set to TLS 1.3. */ int test_tls_set_session_min_downgrade(void) diff --git a/tests/api/test_tls.h b/tests/api/test_tls.h index eecafab511..28fe41abfe 100644 --- a/tests/api/test_tls.h +++ b/tests/api/test_tls.h @@ -33,6 +33,7 @@ int test_tls12_bad_cv_sig_alg(void); int test_tls12_no_null_compression(void); int test_tls12_etm_failed_resumption(void); int test_tls12_resume_ticket_wrong_suite(void); +int test_tls12_resume_ticket_decline_fallback(void); int test_tls_set_session_min_downgrade(void); int test_tls12_session_id_resumption_sni_mismatch(void); int test_tls13_session_resumption_sni_mismatch(void); @@ -57,6 +58,7 @@ int test_record_size_cache_invalidated_on_renegotiation(void); TEST_DECL_GROUP("tls", test_tls12_no_null_compression), \ TEST_DECL_GROUP("tls", test_tls12_etm_failed_resumption), \ TEST_DECL_GROUP("tls", test_tls12_resume_ticket_wrong_suite), \ + TEST_DECL_GROUP("tls", test_tls12_resume_ticket_decline_fallback), \ TEST_DECL_GROUP("tls", test_tls_set_session_min_downgrade), \ TEST_DECL_GROUP("tls", test_tls12_session_id_resumption_sni_mismatch), \ TEST_DECL_GROUP("tls", test_tls13_session_resumption_sni_mismatch), \ From 5e76c669777b8679c8700b8b899464ceab9912a4 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 11 Jun 2026 19:22:35 +0000 Subject: [PATCH 15/17] F-5818: don't invalidate the session on an unauthenticated alert DoAlert evicted the cached session from the fatal-alert handling that runs before the plaintext-under-encryption validation, so a forged TLS 1.3 plaintext alert injected on an established connection evicted the session (forcing a full handshake on reconnect) even though the alert is then rejected as PARSE_ERROR. The unexpected_message teardown sent in response also evicted through the SendAlert hook. Move the receive-side eviction past the validation, into the branch that processes a genuine alert, and have InvalidateSessionOnFatalAlert refuse to evict for a TLS 1.3 plaintext alert received while encryption is on (the current record was not decrypted) - covering both the receive path and the unexpected_message teardown sent in response. RFC 8446 6.2 does not require TLS 1.3 invalidation, so this loses nothing; TLS 1.2 (RFC 5246 7.2.2) is unaffected. --- src/internal.c | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/internal.c b/src/internal.c index e946ecb2b1..36b0e0903c 100644 --- a/src/internal.c +++ b/src/internal.c @@ -22527,6 +22527,14 @@ static void InvalidateSessionOnFatalAlert(WOLFSSL* ssl) return; if (!ssl->options.handShakeDone && !ssl->options.resuming) return; + /* Don't evict on an unauthenticated record: a TLS 1.3 plaintext alert + * received under encryption (current record not decrypted) is rejected (or + * ignored) by DoAlert, and the teardown alert routes back here. RFC 8446 + * 6.2 doesn't require TLS 1.3 eviction; TLS 1.2 alerts are plaintext so are + * unaffected. */ + if (IsAtLeastTLSv1_3(ssl->version) && IsEncryptionOn(ssl, 0) && + !ssl->keys.decryptedCur) + return; (void)wolfSSL_SSL_CTX_remove_session(ssl->ctx, ssl->session); } #endif /* !NO_SESSION_CACHE */ @@ -22593,15 +22601,6 @@ static int DoAlert(WOLFSSL* ssl, byte* input, word32* inOutIdx, int* type) code != close_notify && code != user_canceled) { ssl->options.isClosed = 1; } -#ifndef NO_SESSION_CACHE - /* A fatal alert immediately terminates the connection; invalidate the - * session so it cannot be used to establish new connections. In TLS 1.3 - * all error alerts are implicitly fatal (RFC 8446 6.2). */ - if (code != close_notify && - (level == alert_fatal || - (IsAtLeastTLSv1_3(ssl->version) && code != user_canceled))) - InvalidateSessionOnFatalAlert(ssl); -#endif } if (++ssl->options.alertCount >= WOLFSSL_ALERT_COUNT_MAX) { @@ -22646,6 +22645,15 @@ static int DoAlert(WOLFSSL* ssl, byte* input, word32* inOutIdx, int* type) */ WOLFSSL_ERROR(*type); } +#ifndef NO_SESSION_CACHE + /* Validated fatal alert: invalidate the session so it can't be resumed + * (RFC 5246 7.2.2; in TLS 1.3 all error alerts are fatal, RFC 8446 + * 6.2). */ + if (*type != close_notify && + (level == alert_fatal || + (IsAtLeastTLSv1_3(ssl->version) && *type != user_canceled))) + InvalidateSessionOnFatalAlert(ssl); +#endif } return level; } From 108afdf1c36c38eb6a7ca89681fd98fef95f8461 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 11 Jun 2026 19:22:36 +0000 Subject: [PATCH 16/17] F-5633: use explicit NULL comparison in FreeCiphers Use the project's preferred `ptr != NULL` form for the new DTLS 1.3 ChaCha record-number zeroization guards instead of relying on truthiness. --- src/internal.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/internal.c b/src/internal.c index 36b0e0903c..8fd6e50db2 100644 --- a/src/internal.c +++ b/src/internal.c @@ -3343,9 +3343,9 @@ void FreeCiphers(WOLFSSL* ssl) ssl->dtlsRecordNumberDecrypt.aes = NULL; #endif /* BUILD_AES */ #ifdef HAVE_CHACHA - if (ssl->dtlsRecordNumberEncrypt.chacha) + if (ssl->dtlsRecordNumberEncrypt.chacha != NULL) ForceZero(ssl->dtlsRecordNumberEncrypt.chacha, sizeof(ChaCha)); - if (ssl->dtlsRecordNumberDecrypt.chacha) + if (ssl->dtlsRecordNumberDecrypt.chacha != NULL) ForceZero(ssl->dtlsRecordNumberDecrypt.chacha, sizeof(ChaCha)); XFREE(ssl->dtlsRecordNumberEncrypt.chacha, ssl->heap, DYNAMIC_TYPE_CIPHER); XFREE(ssl->dtlsRecordNumberDecrypt.chacha, ssl->heap, DYNAMIC_TYPE_CIPHER); From e68cc75ecdd1cfad78e586c40e19b134e2bafa53 Mon Sep 17 00:00:00 2001 From: Juliusz Sosinowicz Date: Thu, 11 Jun 2026 19:22:36 +0000 Subject: [PATCH 17/17] F-5813: clarify BuildMessage sequence-number wrap comment The sequence number 2^64-1 is itself RFC 5246 6.1-legal; only the wrap to 0 is forbidden. GetSEQIncrement reads the current counter then post-increments it, so the check refuses the final legal sequence number to avoid the wrapping post-increment. Document that this last value is deliberately sacrificed rather than implying 2^64-1 is itself unusable. --- src/internal.c | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/internal.c b/src/internal.c index 8fd6e50db2..a825f3ff42 100644 --- a/src/internal.c +++ b/src/internal.c @@ -24777,11 +24777,11 @@ int BuildMessage(WOLFSSL* ssl, byte* output, int outSz, const byte* input, #endif #ifndef WOLFSSL_NO_TLS12 - /* RFC 5246 6.1: record sequence numbers MUST NOT wrap. Refuse to emit a - * record once the write sequence number has reached its maximum value - * (2^64-1); reusing sequence number 0 with the same keys would break the - * record protection. The caller must renegotiate or close the connection - * instead. DTLS sequence numbers are epoch-scoped and handled elsewhere. */ + /* RFC 5246 6.1: sequence numbers MUST NOT wrap. GetSEQIncrement post- + * increments, so refuse at hi == lo == 0xFFFFFFFF (2^64-1): that last legal + * value is deliberately sacrificed to avoid wrapping to 0 and reusing + * sequence number 0. The caller must renegotiate or close. DTLS sequence + * numbers are epoch-scoped and handled elsewhere. */ if (!sizeOnly && !ssl->options.dtls && ssl->keys.sequence_number_hi == 0xFFFFFFFFU && ssl->keys.sequence_number_lo == 0xFFFFFFFFU) {