Commit 0152e4f9f7 for openssl.org
commit 0152e4f9f7503d445f13f92670dd6c651361c377
Author: tlhc <tongli2@huawei.com>
Date: Fri Feb 6 16:40:39 2026 +0800
dtls: buffer early CCS to handle UDP reorder
Cache early-arriving CCS in dtls1_state_st and replay it when the
handshake reaches the expected state. Clear the flag in
dtls1_clear_received_buffer().
Add dtls_ccs_reorder_test covering client/server reorder, resumption and mutual TLS.
Reviewed-by: Tomas Mraz <tomas@openssl.org>
Reviewed-by: Matt Caswell <matt@openssl.foundation>
MergeDate: Mon Mar 16 11:01:55 2026
(Merged from https://github.com/openssl/openssl/pull/30225)
diff --git a/CHANGES.md b/CHANGES.md
index 27636e50e7..95e7f7545b 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -365,6 +365,11 @@ OpenSSL Releases
*Bob Beck*
+ * Improved DTLS handshake robustness under UDP reordering by buffering and
+ replaying early ChangeCipherSpec (CCS) records at the expected state.
+
+ *Tong Li*
+
* Many functions accepting `X509 *` arguments, or returning values
from a const `X509 *` have been changed to take/return const
arguments. The most visible changes are places where pointer values
diff --git a/ssl/d1_lib.c b/ssl/d1_lib.c
index 1f77ede0cb..b1cd512510 100644
--- a/ssl/d1_lib.c
+++ b/ssl/d1_lib.c
@@ -116,6 +116,7 @@ void dtls1_clear_received_buffer(SSL_CONNECTION *s)
dtls1_hm_fragment_free(frag);
pitem_free(item);
}
+ s->d1->has_change_cipher_spec = 0;
}
void dtls1_clear_sent_buffer(SSL_CONNECTION *s)
diff --git a/ssl/ssl_local.h b/ssl/ssl_local.h
index c7feaf556f..e28cc2aa22 100644
--- a/ssl/ssl_local.h
+++ b/ssl/ssl_local.h
@@ -1982,6 +1982,7 @@ typedef struct dtls1_state_st {
unsigned int timeout_duration_us;
unsigned int retransmitting;
+ unsigned int has_change_cipher_spec;
#ifndef OPENSSL_NO_SCTP
int shutdown_received;
#endif
diff --git a/ssl/statem/statem_clnt.c b/ssl/statem/statem_clnt.c
index 7d0818e7ab..c2b812bd40 100644
--- a/ssl/statem/statem_clnt.c
+++ b/ssl/statem/statem_clnt.c
@@ -403,20 +403,6 @@ int ossl_statem_client_read_transition(SSL_CONNECTION *s, int mt)
err:
/* No valid transition found */
- if (SSL_CONNECTION_IS_DTLS(s) && mt == SSL3_MT_CHANGE_CIPHER_SPEC) {
- BIO *rbio;
-
- /*
- * CCS messages don't have a message sequence number so this is probably
- * because of an out-of-order CCS. We'll just drop it.
- */
- s->init_num = 0;
- s->rwstate = SSL_READING;
- rbio = SSL_get_rbio(SSL_CONNECTION_GET_SSL(s));
- BIO_clear_retry_flags(rbio);
- BIO_set_retry_read(rbio);
- return 0;
- }
SSLfatal(s, SSL3_AD_UNEXPECTED_MESSAGE, SSL_R_UNEXPECTED_MESSAGE);
return 0;
}
diff --git a/ssl/statem/statem_dtls.c b/ssl/statem/statem_dtls.c
index 4052ef6219..1644c6e47c 100644
--- a/ssl/statem/statem_dtls.c
+++ b/ssl/statem/statem_dtls.c
@@ -1,5 +1,5 @@
/*
- * Copyright 2005-2025 The OpenSSL Project Authors. All Rights Reserved.
+ * Copyright 2005-2026 The OpenSSL Project Authors. All Rights Reserved.
*
* Licensed under the Apache License 2.0 (the "License"). You may not use
* this file except in compliance with the License. You can obtain a copy
@@ -67,6 +67,59 @@ static void dtls1_set_message_header_int(SSL_CONNECTION *s, unsigned char mt,
static int dtls_get_reassembled_message(SSL_CONNECTION *s, int *errtype,
size_t *len);
+/*
+ * Check if CCS is expected in current state.
+ *
+ * RFC 6347 Section 4.1 states DTLS must handle message reordering since UDP
+ * does not guarantee in-order delivery. This function determines when a
+ * buffered CCS should be delivered to the state machine.
+ *
+ * Server states where CCS is expected:
+ * - TLS_ST_SR_KEY_EXCH: After key exchange (anonymous or no_cert_verify)
+ * - TLS_ST_SR_CERT_VRFY: After certificate verify
+ * - TLS_ST_SW_FINISHED: Session resumption (abbreviated handshake)
+ *
+ * Client states where CCS is expected:
+ * - TLS_ST_CR_SRVR_HELLO: Abbreviated handshake without ticket
+ * - TLS_ST_CW_FINISHED: After sending Finished, before server CCS
+ * - TLS_ST_CR_SESSION_TICKET: After receiving session ticket
+ */
+static int dtls_ccs_expected(SSL_CONNECTION *s)
+{
+ OSSL_HANDSHAKE_STATE st = s->statem.hand_state;
+
+ if (s->server) {
+ switch (st) {
+ case TLS_ST_SR_KEY_EXCH:
+ /* Anonymous or no client cert: CCS follows KeyExchange */
+ if (s->session->peer == NULL && s->session->peer_rpk == NULL)
+ return 1;
+ /* Client cert but no verify message required */
+ return s->statem.no_cert_verify;
+ case TLS_ST_SR_CERT_VRFY:
+ return 1;
+ case TLS_ST_SW_FINISHED:
+ /* Abbreviated handshake: server sends first, then receives CCS */
+ return s->hit;
+ default:
+ return 0;
+ }
+ } else {
+ switch (st) {
+ case TLS_ST_CR_SRVR_HELLO:
+ /* Abbreviated handshake without session ticket */
+ return (s->hit && !s->ext.ticket_expected);
+ case TLS_ST_CW_FINISHED:
+ /* Full handshake: waiting for server CCS after sending Finished */
+ return !s->ext.ticket_expected;
+ case TLS_ST_CR_SESSION_TICKET:
+ return 1;
+ default:
+ return 0;
+ }
+ }
+}
+
static hm_fragment *dtls1_hm_fragment_new(size_t frag_len, int reassembly)
{
hm_fragment *frag = NULL;
@@ -830,6 +883,28 @@ static int dtls_get_reassembled_message(SSL_CONNECTION *s, int *errtype,
p = (unsigned char *)s->init_buf->data;
redo:
+ /* Check for buffered CCS */
+ if ((s->version == DTLS1_VERSION || s->version == DTLS1_2_VERSION
+ || s->version == DTLS1_BAD_VER)
+ && s->d1->has_change_cipher_spec && dtls_ccs_expected(s)) {
+ size_t extra = (s->version == DTLS1_BAD_VER) ? 2 : 0;
+
+ s->d1->has_change_cipher_spec = 0;
+ p[0] = SSL3_MT_CCS;
+ /*
+ * The extra 2 bytes are never consumed, only checked for
+ * length -- zero-fill to avoid old init_buf content.
+ */
+ if (extra > 0)
+ memset(p + 1, 0, extra);
+ s->init_num = extra;
+ s->init_msg = p + 1;
+ s->s3.tmp.message_type = SSL3_MT_CHANGE_CIPHER_SPEC;
+ s->s3.tmp.message_size = extra;
+ *len = extra;
+ return 1;
+ }
+
/* see if we have the required fragment already */
ret = dtls1_retrieve_buffered_fragment(s, &frag_len);
if (ret < 0) {
@@ -857,12 +932,22 @@ redo:
goto f_err;
}
- s->init_num = readbytes - 1;
- s->init_msg = s->init_buf->data + 1;
- s->s3.tmp.message_type = SSL3_MT_CHANGE_CIPHER_SPEC;
- s->s3.tmp.message_size = readbytes - 1;
- *len = readbytes - 1;
- return 1;
+ /* Buffer CCS for reorder tolerance */
+ if (s->version == DTLS1_VERSION || s->version == DTLS1_2_VERSION
+ || s->version == DTLS1_BAD_VER) {
+ size_t expected = (s->version == DTLS1_BAD_VER) ? 3 : 1;
+
+ if (readbytes != expected) {
+ SSLfatal(s, SSL_AD_DECODE_ERROR,
+ SSL_R_BAD_CHANGE_CIPHER_SPEC);
+ goto f_err;
+ }
+ s->d1->has_change_cipher_spec = 1;
+ goto redo;
+ }
+ SSLfatal(s, SSL_AD_UNEXPECTED_MESSAGE,
+ SSL_R_BAD_CHANGE_CIPHER_SPEC);
+ goto f_err;
}
/* Handshake fails if message header is incomplete */
diff --git a/ssl/statem/statem_srvr.c b/ssl/statem/statem_srvr.c
index 16fa0b0e38..288fc31f8f 100644
--- a/ssl/statem/statem_srvr.c
+++ b/ssl/statem/statem_srvr.c
@@ -309,20 +309,6 @@ int ossl_statem_server_read_transition(SSL_CONNECTION *s, int mt)
err:
/* No valid transition found */
- if (SSL_CONNECTION_IS_DTLS(s) && mt == SSL3_MT_CHANGE_CIPHER_SPEC) {
- BIO *rbio;
-
- /*
- * CCS messages don't have a message sequence number so this is probably
- * because of an out-of-order CCS. We'll just drop it.
- */
- s->init_num = 0;
- s->rwstate = SSL_READING;
- rbio = SSL_get_rbio(SSL_CONNECTION_GET_SSL(s));
- BIO_clear_retry_flags(rbio);
- BIO_set_retry_read(rbio);
- return 0;
- }
SSLfatal(s, SSL3_AD_UNEXPECTED_MESSAGE, SSL_R_UNEXPECTED_MESSAGE);
return 0;
}
diff --git a/test/bad_dtls_test.c b/test/bad_dtls_test.c
index 904f28e2c2..60d095e839 100644
--- a/test/bad_dtls_test.c
+++ b/test/bad_dtls_test.c
@@ -46,6 +46,15 @@
/* For DTLS1_BAD_VER packets the MAC doesn't include the handshake header */
#define MAC_OFFSET (DTLS1_RT_HEADER_LENGTH + DTLS1_HM_HEADER_LENGTH)
+static unsigned int infinite_timer_cb(SSL *s, unsigned int timer_us)
+{
+ (void)s;
+
+ if (timer_us == 0)
+ return 999999999;
+ return timer_us;
+}
+
static unsigned char client_random[SSL3_RANDOM_SIZE];
static unsigned char server_random[SSL3_RANDOM_SIZE];
@@ -332,7 +341,7 @@ static int send_hello_verify(BIO *rbio)
return 1;
}
-static int send_server_hello(BIO *rbio)
+static int send_server_hello(BIO *rbio, int reorder_ccs)
{
static unsigned char server_hello[] = {
0x16, /* Handshake */
@@ -459,8 +468,13 @@ static int send_server_hello(BIO *rbio)
sizeof(server_hello) - MAC_OFFSET))
return 0;
- BIO_write(rbio, server_hello, sizeof(server_hello));
- BIO_write(rbio, change_cipher_spec, sizeof(change_cipher_spec));
+ if (reorder_ccs) {
+ BIO_write(rbio, change_cipher_spec, sizeof(change_cipher_spec));
+ BIO_write(rbio, server_hello, sizeof(server_hello));
+ } else {
+ BIO_write(rbio, server_hello, sizeof(server_hello));
+ BIO_write(rbio, change_cipher_spec, sizeof(change_cipher_spec));
+ }
return 1;
}
@@ -665,8 +679,9 @@ static struct {
/* The last test should be NODROP, because a DROP wouldn't get tested. */
};
-static int test_bad_dtls(void)
+static int test_bad_dtls(int idx)
{
+ int reorder_ccs = idx;
SSL_SESSION *sess = NULL;
SSL_CTX *ctx = NULL;
SSL *con = NULL;
@@ -734,6 +749,8 @@ static int test_bad_dtls(void)
}
SSL_set_connect_state(con);
+ if (reorder_ccs)
+ DTLS_set_timer_cb(con, infinite_timer_cb);
/* Send initial ClientHello */
ret = SSL_do_handshake(con);
@@ -747,7 +764,7 @@ static int test_bad_dtls(void)
if (!TEST_int_le(ret, 0)
|| !TEST_int_eq(SSL_get_error(con, ret), SSL_ERROR_WANT_READ)
|| !TEST_int_eq(validate_client_hello(wbio), 2)
- || !TEST_true(send_server_hello(rbio)))
+ || !TEST_true(send_server_hello(rbio, reorder_ccs)))
goto end;
ret = SSL_do_handshake(con);
@@ -808,6 +825,6 @@ end:
int setup_tests(void)
{
- ADD_TEST(test_bad_dtls);
+ ADD_ALL_TESTS(test_bad_dtls, 2);
return 1;
}
diff --git a/test/build.info b/test/build.info
index eec0553cc8..c5a7a565b7 100644
--- a/test/build.info
+++ b/test/build.info
@@ -54,7 +54,7 @@ IF[{- !$disabled{tests} -}]
dtlsv1listentest ct_test threadstest d2i_test \
ssl_test_ctx_test ssl_test x509aux cipherlist_test asynciotest \
bio_callback_test bio_memleak_test bio_core_test bio_dgram_test param_build_test \
- bioprinttest sslapitest ssl_handshake_rtt_test dtlstest sslcorrupttest \
+ bioprinttest sslapitest ssl_handshake_rtt_test dtlstest dtls_ccs_reorder_test sslcorrupttest \
bio_base64_test bio_enc_test pkey_meth_kdf_test evp_kdf_test uitest \
cipherbytes_test threadstest_fips threadpool_test \
asn1_encode_test asn1_decode_test asn1_string_table_test asn1_stable_parse_test \
@@ -665,6 +665,10 @@ IF[{- !$disabled{tests} -}]
INCLUDE[dtlstest]=../include ../apps/include
DEPEND[dtlstest]=../libcrypto ../libssl libtestutil.a
+ SOURCE[dtls_ccs_reorder_test]=dtls_ccs_reorder_test.c helpers/ssltestlib.c
+ INCLUDE[dtls_ccs_reorder_test]=../include ../apps/include
+ DEPEND[dtls_ccs_reorder_test]=../libcrypto ../libssl libtestutil.a
+
SOURCE[sslcorrupttest]=sslcorrupttest.c helpers/ssltestlib.c
INCLUDE[sslcorrupttest]=../include ../apps/include
DEPEND[sslcorrupttest]=../libcrypto ../libssl libtestutil.a
diff --git a/test/dtls_ccs_reorder_test.c b/test/dtls_ccs_reorder_test.c
new file mode 100644
index 0000000000..5e145c961d
--- /dev/null
+++ b/test/dtls_ccs_reorder_test.c
@@ -0,0 +1,490 @@
+/*
+ * Copyright 2026 The OpenSSL Project Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License 2.0 (the "License"). You may not use
+ * this file except in compliance with the License. You can obtain a copy
+ * in the file LICENSE in the source distribution or at
+ * https://www.openssl.org/source/license.html
+ */
+
+/* DTLS CCS early-arrival tests */
+
+#include <openssl/aes.h>
+#include <openssl/ssl.h>
+#include <openssl/ssl3.h>
+#include <openssl/evp.h>
+#include <openssl/core_names.h>
+#include <openssl/x509.h>
+
+#include "helpers/ssltestlib.h"
+#include "testutil.h"
+
+static char *cert = NULL;
+static char *privkey = NULL;
+
+#ifndef OPENSSL_NO_DTLS
+
+static unsigned int infinite_timer_cb(SSL *s, unsigned int timer_us)
+{
+ (void)s;
+
+ if (timer_us == 0)
+ return 999999999;
+ return timer_us;
+}
+
+static int verify_accept_cb(int ok, X509_STORE_CTX *ctx)
+{
+ (void)ok;
+ (void)ctx;
+
+ return 1;
+}
+
+static int tick_key_renew_cb(SSL *s, unsigned char key_name[16],
+ unsigned char iv[EVP_MAX_IV_LENGTH],
+ EVP_CIPHER_CTX *ctx, EVP_MAC_CTX *hctx,
+ int enc)
+{
+ const unsigned char tick_aes_key[16] = {
+ '0', '1', '2', '3', '4', '5', '6', '7',
+ '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
+ };
+
+ unsigned char tick_hmac_key[16] = {
+ '0', '1', '2', '3', '4', '5', '6', '7',
+ '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
+ };
+ OSSL_PARAM params[2];
+ EVP_CIPHER *aes128cbc = EVP_CIPHER_fetch(NULL, "AES-128-CBC", NULL);
+ int ret;
+
+ (void)s;
+
+ if (aes128cbc == NULL)
+ return -1;
+
+ memset(key_name, 0, 16);
+ memset(iv, 0, AES_BLOCK_SIZE);
+ params[0] = OSSL_PARAM_construct_utf8_string(OSSL_MAC_PARAM_DIGEST,
+ "SHA256", 0);
+ params[1] = OSSL_PARAM_construct_end();
+
+ if (!EVP_CipherInit_ex(ctx, aes128cbc, NULL, tick_aes_key, iv, enc)
+ || !EVP_MAC_init(hctx, tick_hmac_key, sizeof(tick_hmac_key), params))
+ ret = -1;
+ else
+ ret = enc ? 1 : 2;
+
+ EVP_CIPHER_free(aes128cbc);
+ return ret;
+}
+
+static int verify_data_transfer(SSL *writer, SSL *reader)
+{
+ const char msg[] = "CCS reorder test";
+ char buf[sizeof(msg)];
+
+ if (!TEST_int_eq(SSL_write(writer, msg, sizeof(msg)), (int)sizeof(msg))
+ || !TEST_int_eq(SSL_read(reader, buf, sizeof(buf)), (int)sizeof(msg))
+ || !TEST_mem_eq(buf, sizeof(msg), msg, sizeof(msg)))
+ return 0;
+ return 1;
+}
+
+/* Move CCS just before the handshake message given by before_hs_msg. */
+static int reorder_ccs(BIO *bio, int before_hs_msg)
+{
+ int target_pkt = -1, target_rec = -1;
+ int ccs_pkt = -1, ccs_rec = -1;
+ int p;
+
+ if (!TEST_true(mempacket_find_record(bio, SSL3_RT_HANDSHAKE,
+ before_hs_msg,
+ &target_pkt, &target_rec)))
+ return 0;
+
+ if (target_rec > 0
+ && !TEST_true(mempacket_split_packet_at(bio, target_pkt, target_rec)))
+ return 0;
+
+ if (!TEST_true(mempacket_find_record(bio, SSL3_RT_CHANGE_CIPHER_SPEC, -1,
+ &ccs_pkt, &ccs_rec)))
+ return 0;
+
+ if (ccs_rec > 0
+ && !TEST_true(mempacket_split_packet_at(bio, ccs_pkt, ccs_rec)))
+ return 0;
+
+ if (!TEST_true(mempacket_find_record(bio, SSL3_RT_CHANGE_CIPHER_SPEC, -1,
+ &ccs_pkt, &ccs_rec))
+ || !TEST_int_eq(ccs_rec, 0))
+ return 0;
+
+ if (!TEST_true(mempacket_split_packet_at(bio, ccs_pkt, 1)))
+ return 0;
+
+ if (!TEST_true(mempacket_find_record(bio, SSL3_RT_HANDSHAKE,
+ before_hs_msg,
+ &target_pkt, &target_rec))
+ || !TEST_int_eq(target_rec, 0))
+ return 0;
+
+ if (ccs_pkt == target_pkt)
+ return 0;
+
+ if (ccs_pkt > target_pkt) {
+ if (!TEST_true(mempacket_move_packet(bio, target_pkt, ccs_pkt)))
+ return 0;
+ } else {
+ for (p = ccs_pkt; p + 1 < target_pkt; p++) {
+ if (!TEST_true(mempacket_move_packet(bio, p, p + 1)))
+ return 0;
+ }
+ }
+
+ /* CCS packet should be at position target_pkt - 1 */
+ if (!TEST_true(mempacket_find_record(bio, SSL3_RT_CHANGE_CIPHER_SPEC,
+ -1, &ccs_pkt, &ccs_rec))
+ || !TEST_true(mempacket_find_record(bio, SSL3_RT_HANDSHAKE,
+ before_hs_msg,
+ &target_pkt, &target_rec))
+ || !TEST_int_eq(ccs_pkt + 1, target_pkt)
+ || !TEST_int_eq(ccs_rec, 0)
+ || !TEST_int_eq(target_rec, 0))
+ return 0;
+
+ return 1;
+}
+
+static const struct {
+ int mtls;
+ int reorder_before;
+ int max_version;
+} full_hs_tests[] = {
+#ifndef OPENSSL_NO_DTLS1_2
+ /* DTLS 1.2: [CKE][CCS][Fin] -> [CCS][CKE][Fin] */
+ { 0, SSL3_MT_CLIENT_KEY_EXCHANGE, DTLS1_2_VERSION },
+ /* DTLS 1.2 mTLS: [Cert][CKE][CV][CCS][Fin] -> [CCS][Cert]... */
+ { 1, SSL3_MT_CERTIFICATE, DTLS1_2_VERSION },
+ /* DTLS 1.2 mTLS: [Cert][CKE][CV][CCS][Fin] -> [Cert][CKE][CCS][CV]... */
+ { 1, SSL3_MT_CERTIFICATE_VERIFY, DTLS1_2_VERSION },
+#endif
+#ifndef OPENSSL_NO_DTLS1
+ /* DTLS 1.0: [CKE][CCS][Fin] -> [CCS][CKE][Fin] */
+ { 0, SSL3_MT_CLIENT_KEY_EXCHANGE, DTLS1_VERSION },
+ /* DTLS 1.0 mTLS: [Cert][CKE][CV][CCS][Fin] -> [CCS][Cert]... */
+ { 1, SSL3_MT_CERTIFICATE, DTLS1_VERSION },
+ /* DTLS 1.0 mTLS: [Cert][CKE][CV][CCS][Fin] -> [Cert][CKE][CCS][CV]... */
+ { 1, SSL3_MT_CERTIFICATE_VERIFY, DTLS1_VERSION },
+#endif
+};
+
+/* Full handshake, Flight 3 (C->S): early CCS in the client flight. */
+static int test_dtls_ccs_full_hs(int idx)
+{
+ SSL_CTX *sctx = NULL, *cctx = NULL;
+ SSL *sssl = NULL, *cssl = NULL;
+ BIO *bio;
+ X509 *peer = NULL;
+ int testresult = 0, ret;
+ int mtls = full_hs_tests[idx].mtls;
+ int reorder_before = full_hs_tests[idx].reorder_before;
+ int max_ver = full_hs_tests[idx].max_version;
+
+ if (!TEST_true(create_ssl_ctx_pair(NULL, DTLS_server_method(),
+ DTLS_client_method(),
+ max_ver, max_ver,
+ &sctx, &cctx, cert, privkey)))
+ return 0;
+
+ if (max_ver == DTLS1_VERSION) {
+ SSL_CTX_set_security_level(sctx, 0);
+ SSL_CTX_set_security_level(cctx, 0);
+ }
+
+ if (mtls) {
+ SSL_CTX_set_verify(sctx,
+ SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT,
+ verify_accept_cb);
+
+ if (!TEST_true(SSL_CTX_use_certificate_file(cctx, cert,
+ SSL_FILETYPE_PEM))
+ || !TEST_true(SSL_CTX_use_PrivateKey_file(cctx, privkey,
+ SSL_FILETYPE_PEM)))
+ goto end;
+ }
+
+ if (!TEST_true(create_ssl_objects(sctx, cctx, &sssl, &cssl, NULL, NULL)))
+ goto end;
+
+ DTLS_set_timer_cb(sssl, infinite_timer_cb);
+ DTLS_set_timer_cb(cssl, infinite_timer_cb);
+
+ if (!TEST_int_le(SSL_connect(cssl), 0))
+ goto end;
+
+ if (!TEST_int_le(SSL_accept(sssl), 0))
+ goto end;
+
+ if (!TEST_int_le(SSL_connect(cssl), 0))
+ goto end;
+
+ bio = SSL_get_wbio(cssl);
+ if (!TEST_ptr(bio)
+ || !TEST_true(reorder_ccs(bio, reorder_before)))
+ goto end;
+
+ ret = SSL_accept(sssl);
+ if (!TEST_int_gt(ret, 0)) {
+ TEST_info("SSL_accept: ret=%d err=%d state=%s",
+ ret, SSL_get_error(sssl, ret),
+ SSL_state_string_long(sssl));
+ goto end;
+ }
+
+ ret = SSL_connect(cssl);
+ if (!TEST_int_gt(ret, 0)) {
+ TEST_info("SSL_connect: ret=%d err=%d state=%s",
+ ret, SSL_get_error(cssl, ret),
+ SSL_state_string_long(cssl));
+ goto end;
+ }
+
+ if (mtls) {
+ peer = SSL_get1_peer_certificate(sssl);
+ if (!TEST_ptr(peer))
+ goto end;
+ }
+
+ if (!TEST_true(verify_data_transfer(sssl, cssl)))
+ goto end;
+
+ testresult = 1;
+end:
+ X509_free(peer);
+ SSL_free(sssl);
+ SSL_free(cssl);
+ SSL_CTX_free(sctx);
+ SSL_CTX_free(cctx);
+ return testresult;
+}
+
+static const int nst_versions[] = {
+#ifndef OPENSSL_NO_DTLS1_2
+ DTLS1_2_VERSION,
+#endif
+#ifndef OPENSSL_NO_DTLS1
+ DTLS1_VERSION,
+#endif
+};
+
+/* Flight 4 (S->C): [NST][CCS][Finished] -> [CCS][NST][Finished] */
+static int test_dtls_ccs_before_nst(int idx)
+{
+ SSL_CTX *sctx = NULL, *cctx = NULL;
+ SSL *sssl = NULL, *cssl = NULL;
+ BIO *bio;
+ int testresult = 0, ret;
+ int max_ver = nst_versions[idx];
+
+ if (!TEST_true(create_ssl_ctx_pair(NULL, DTLS_server_method(),
+ DTLS_client_method(),
+ max_ver, max_ver,
+ &sctx, &cctx, cert, privkey)))
+ return 0;
+
+ if (max_ver == DTLS1_VERSION) {
+ SSL_CTX_set_security_level(sctx, 0);
+ SSL_CTX_set_security_level(cctx, 0);
+ }
+
+ if (!TEST_true(create_ssl_objects(sctx, cctx, &sssl, &cssl, NULL, NULL)))
+ goto end;
+
+ DTLS_set_timer_cb(sssl, infinite_timer_cb);
+ DTLS_set_timer_cb(cssl, infinite_timer_cb);
+
+ if (!TEST_int_le(SSL_connect(cssl), 0))
+ goto end;
+
+ if (!TEST_int_le(SSL_accept(sssl), 0))
+ goto end;
+
+ if (!TEST_int_le(SSL_connect(cssl), 0))
+ goto end;
+
+ ret = SSL_accept(sssl);
+ if (!TEST_int_gt(ret, 0)) {
+ TEST_info("SSL_accept: ret=%d err=%d state=%s",
+ ret, SSL_get_error(sssl, ret),
+ SSL_state_string_long(sssl));
+ goto end;
+ }
+
+ bio = SSL_get_wbio(sssl);
+ if (!TEST_ptr(bio)
+ || !TEST_true(reorder_ccs(bio, SSL3_MT_NEWSESSION_TICKET)))
+ goto end;
+
+ ret = SSL_connect(cssl);
+ if (!TEST_int_gt(ret, 0)) {
+ TEST_info("SSL_connect: ret=%d err=%d state=%s",
+ ret, SSL_get_error(cssl, ret),
+ SSL_state_string_long(cssl));
+ goto end;
+ }
+
+ if (!TEST_true(verify_data_transfer(cssl, sssl)))
+ goto end;
+
+ testresult = 1;
+end:
+ SSL_free(sssl);
+ SSL_free(cssl);
+ SSL_CTX_free(sctx);
+ SSL_CTX_free(cctx);
+ return testresult;
+}
+
+static const struct {
+ int use_ticket;
+ int reorder_before;
+ int max_version;
+} resume_tests[] = {
+#ifndef OPENSSL_NO_DTLS1_2
+ /* DTLS 1.2 Session ID: [SH][CCS][Fin] -> [CCS][SH][Fin] */
+ { 0, SSL3_MT_SERVER_HELLO, DTLS1_2_VERSION },
+ /* DTLS 1.2 Ticket renewal: [NST][CCS][Fin] -> [CCS][NST][Fin] */
+ { 1, SSL3_MT_NEWSESSION_TICKET, DTLS1_2_VERSION },
+#endif
+#ifndef OPENSSL_NO_DTLS1
+ /* DTLS 1.0 Session ID: [SH][CCS][Fin] -> [CCS][SH][Fin] */
+ { 0, SSL3_MT_SERVER_HELLO, DTLS1_VERSION },
+ /* DTLS 1.0 Ticket renewal: [NST][CCS][Fin] -> [CCS][NST][Fin] */
+ { 1, SSL3_MT_NEWSESSION_TICKET, DTLS1_VERSION },
+#endif
+};
+
+/* Resumption, Flight 2 (S->C): early CCS in the server flight. */
+static int test_dtls_ccs_resume(int idx)
+{
+ SSL_CTX *sctx = NULL, *cctx = NULL;
+ SSL *sssl = NULL, *cssl = NULL;
+ SSL_SESSION *sess = NULL;
+ BIO *bio;
+ int testresult = 0, ret;
+ int use_ticket = resume_tests[idx].use_ticket;
+ int reorder_before = resume_tests[idx].reorder_before;
+ int max_ver = resume_tests[idx].max_version;
+
+ if (!TEST_true(create_ssl_ctx_pair(NULL, DTLS_server_method(),
+ DTLS_client_method(),
+ max_ver, max_ver,
+ &sctx, &cctx, cert, privkey)))
+ return 0;
+
+ if (max_ver == DTLS1_VERSION) {
+ SSL_CTX_set_security_level(sctx, 0);
+ SSL_CTX_set_security_level(cctx, 0);
+ }
+
+ if (use_ticket) {
+ if (!TEST_true(SSL_CTX_set_tlsext_ticket_key_evp_cb(sctx,
+ tick_key_renew_cb)))
+ goto end;
+ } else {
+ SSL_CTX_set_options(sctx, SSL_OP_NO_TICKET);
+ }
+
+ if (!TEST_true(create_ssl_objects(sctx, cctx, &sssl, &cssl, NULL, NULL)))
+ goto end;
+
+ if (!TEST_true(create_ssl_connection(sssl, cssl, SSL_ERROR_NONE)))
+ goto end;
+
+ sess = SSL_get1_session(cssl);
+ if (!TEST_ptr(sess))
+ goto end;
+
+ shutdown_ssl_connection(sssl, cssl);
+ sssl = cssl = NULL;
+
+ if (!TEST_true(create_ssl_objects(sctx, cctx, &sssl, &cssl, NULL, NULL)))
+ goto end;
+
+ DTLS_set_timer_cb(sssl, infinite_timer_cb);
+ DTLS_set_timer_cb(cssl, infinite_timer_cb);
+
+ if (!TEST_true(SSL_set_session(cssl, sess)))
+ goto end;
+
+ if (!TEST_int_le(SSL_connect(cssl), 0))
+ goto end;
+
+ if (!TEST_int_le(SSL_accept(sssl), 0))
+ goto end;
+
+ bio = SSL_get_wbio(sssl);
+ if (!TEST_ptr(bio)
+ || !TEST_true(reorder_ccs(bio, reorder_before)))
+ goto end;
+
+ ret = SSL_connect(cssl);
+ if (!TEST_int_gt(ret, 0)) {
+ TEST_info("SSL_connect: ret=%d err=%d state=%s",
+ ret, SSL_get_error(cssl, ret),
+ SSL_state_string_long(cssl));
+ goto end;
+ }
+
+ ret = SSL_accept(sssl);
+ if (!TEST_int_gt(ret, 0)) {
+ TEST_info("SSL_accept: ret=%d err=%d state=%s",
+ ret, SSL_get_error(sssl, ret),
+ SSL_state_string_long(sssl));
+ goto end;
+ }
+
+ if (!TEST_true(SSL_session_reused(cssl)))
+ goto end;
+
+ if (!TEST_true(verify_data_transfer(cssl, sssl)))
+ goto end;
+
+ testresult = 1;
+end:
+ SSL_free(sssl);
+ SSL_free(cssl);
+ SSL_CTX_free(sctx);
+ SSL_CTX_free(cctx);
+ SSL_SESSION_free(sess);
+ return testresult;
+}
+
+#endif /* OPENSSL_NO_DTLS */
+
+int setup_tests(void)
+{
+ if (!test_skip_common_options()) {
+ TEST_error("Error parsing test options\n");
+ return 0;
+ }
+
+ if (!TEST_ptr(cert = test_get_argument(0))
+ || !TEST_ptr(privkey = test_get_argument(1)))
+ return 0;
+
+#ifndef OPENSSL_NO_DTLS
+ ADD_ALL_TESTS(test_dtls_ccs_full_hs, OSSL_NELEM(full_hs_tests));
+ ADD_ALL_TESTS(test_dtls_ccs_before_nst, OSSL_NELEM(nst_versions));
+ ADD_ALL_TESTS(test_dtls_ccs_resume, OSSL_NELEM(resume_tests));
+#endif
+
+ return 1;
+}
+
+void cleanup_tests(void)
+{
+ bio_s_mempacket_test_free();
+}
diff --git a/test/helpers/ssltestlib.c b/test/helpers/ssltestlib.c
index 471498cfb8..8e512eaabb 100644
--- a/test/helpers/ssltestlib.c
+++ b/test/helpers/ssltestlib.c
@@ -525,6 +525,117 @@ int mempacket_move_packet(BIO *bio, int d, int s)
return 1;
}
+/*
+ * Find the first DTLS record with content type rectype.
+ * If hs_msg_type >= 0, only match epoch-0 handshake records whose
+ * first handshake byte equals hs_msg_type.
+ */
+int mempacket_find_record(BIO *bio, int rectype, int hs_msg_type,
+ int *pktidx, int *recidx)
+{
+ MEMPACKET_TEST_CTX *ctx = BIO_get_data(bio);
+ int numpkts = sk_MEMPACKET_num(ctx->pkts);
+ int i, j, rem, len, payload_len;
+ unsigned char *rec;
+
+ for (i = 0; i < numpkts; i++) {
+ MEMPACKET *thispkt = sk_MEMPACKET_value(ctx->pkts, i);
+
+ if (thispkt == NULL)
+ continue;
+ for (j = 0, rem = thispkt->len, rec = thispkt->data;
+ rem >= DTLS1_RT_HEADER_LENGTH;
+ j++, rem -= len, rec += len) {
+ payload_len = (rec[RECORD_LEN_HI] << 8) | rec[RECORD_LEN_LO];
+ len = payload_len + DTLS1_RT_HEADER_LENGTH;
+ if (rem < len)
+ return 0;
+ if (rec[RECORD_CONTENT_TYPE] != (unsigned char)rectype)
+ continue;
+ if (hs_msg_type >= 0
+ && ((rec[RECORD_EPOCH_HI] | rec[RECORD_EPOCH_LO]) != 0
+ || payload_len < 1
+ || rec[DTLS1_RT_HEADER_LENGTH]
+ != (unsigned char)hs_msg_type))
+ continue;
+ *pktidx = i;
+ *recidx = j;
+ return 1;
+ }
+ }
+ return 0;
+}
+
+/*
+ * Split packet pktidx before record recidx and insert the tail at pktidx + 1.
+ * Splitting at either boundary (before record 0 or after the last record)
+ * is treated as a successful no-op.
+ */
+int mempacket_split_packet_at(BIO *bio, int pktidx, int recidx)
+{
+ MEMPACKET_TEST_CTX *ctx = BIO_get_data(bio);
+ MEMPACKET *srcpkt, *newpkt;
+ int numpkts = sk_MEMPACKET_num(ctx->pkts);
+ int rem, len, i, split_off = 0;
+ unsigned char *rec;
+
+ if (pktidx < 0 || pktidx >= numpkts || recidx < 0)
+ return 0;
+
+ srcpkt = sk_MEMPACKET_value(ctx->pkts, pktidx);
+ if (srcpkt == NULL)
+ return 0;
+
+ for (i = 0, rem = srcpkt->len, rec = srcpkt->data;
+ rem >= DTLS1_RT_HEADER_LENGTH && i < recidx;
+ i++, rem -= len, rec += len) {
+ len = ((rec[RECORD_LEN_HI] << 8) | rec[RECORD_LEN_LO])
+ + DTLS1_RT_HEADER_LENGTH;
+ if (rem < len)
+ return 0;
+ split_off += len;
+ }
+
+ if (i != recidx)
+ return 0;
+
+ if (split_off == 0 || split_off == srcpkt->len)
+ return 1;
+
+ newpkt = OPENSSL_malloc(sizeof(*newpkt));
+ if (newpkt == NULL)
+ return 0;
+
+ newpkt->len = srcpkt->len - split_off;
+ newpkt->data = OPENSSL_malloc(newpkt->len);
+ if (newpkt->data == NULL) {
+ OPENSSL_free(newpkt);
+ return 0;
+ }
+
+ memcpy(newpkt->data, srcpkt->data + split_off, newpkt->len);
+ newpkt->type = srcpkt->type;
+ if (pktidx + 1 < numpkts
+ && sk_MEMPACKET_value(ctx->pkts, pktidx + 1) != NULL)
+ newpkt->num = sk_MEMPACKET_value(ctx->pkts, pktidx + 1)->num;
+ else
+ newpkt->num = srcpkt->num + 1;
+
+ if (sk_MEMPACKET_insert(ctx->pkts, newpkt, pktidx + 1) <= 0) {
+ OPENSSL_free(newpkt->data);
+ OPENSSL_free(newpkt);
+ return 0;
+ }
+
+ srcpkt->len = split_off;
+
+ numpkts = sk_MEMPACKET_num(ctx->pkts);
+ for (i = pktidx + 2; i < numpkts; i++)
+ sk_MEMPACKET_value(ctx->pkts, i)->num++;
+
+ return 1;
+}
+
int mempacket_dup_last_packet(BIO *bio)
{
MEMPACKET_TEST_CTX *ctx = BIO_get_data(bio);
diff --git a/test/helpers/ssltestlib.h b/test/helpers/ssltestlib.h
index 9477e06b13..54aeb6b50c 100644
--- a/test/helpers/ssltestlib.h
+++ b/test/helpers/ssltestlib.h
@@ -74,6 +74,9 @@ void bio_s_maybe_retry_free(void);
int mempacket_swap_epoch(BIO *bio);
int mempacket_move_packet(BIO *bio, int d, int s);
+int mempacket_find_record(BIO *bio, int rectype, int hs_msg_type,
+ int *pktidx, int *recidx);
+int mempacket_split_packet_at(BIO *bio, int pktidx, int recidx);
int mempacket_dup_last_packet(BIO *bio);
int mempacket_test_inject(BIO *bio, const char *in, int inl, int pktnum,
int type);
diff --git a/test/recipes/80-test_dtls_ccs_reorder.t b/test/recipes/80-test_dtls_ccs_reorder.t
new file mode 100755
index 0000000000..3c703afc34
--- /dev/null
+++ b/test/recipes/80-test_dtls_ccs_reorder.t
@@ -0,0 +1,26 @@
+#! /usr/bin/env perl
+# Copyright 2026 The OpenSSL Project Authors. All Rights Reserved.
+#
+# Licensed under the Apache License 2.0 (the "License"). You may not use
+# this file except in compliance with the License. You can obtain a copy
+# in the file LICENSE in the source distribution or at
+# https://www.openssl.org/source/license.html
+
+use strict;
+use warnings;
+
+use OpenSSL::Test::Simple;
+use OpenSSL::Test::Utils;
+use OpenSSL::Test qw/:DEFAULT srctop_file/;
+
+setup("test_dtls_ccs_reorder");
+
+plan skip_all => "No DTLS protocols are supported"
+ if alldisabled(available_protocols("dtls"));
+
+plan tests => 1;
+
+ok(run(test(["dtls_ccs_reorder_test",
+ srctop_file("test/certs/servercert.pem"),
+ srctop_file("test/certs/serverkey.pem")])),
+ "DTLS CCS reorder tolerance tests");