TLS 1.3: evict session from cache after accepted 0-RTT resumption

Per RFC 8446 section 8, a server MUST ensure that any instance of it
would accept 0-RTT for the same 0-RTT handshake at most once. Without
this, the same ClientHello could be replayed to re-accept early data on
a subsequent connection.

After the PSK is authenticated (binder verified) in DoPreSharedKeys,
call wolfSSL_SSL_CTX_remove_session on ssl->session when the client
offered 0-RTT and the session permits it. That evicts the entry from
the internal cache (under the row's write lock) and invokes the
application's ctx->rem_sess_cb so any external cache can drop its copy
too. The session's timeout is also cleared so the live reference held
by the current handshake cannot be resumed again.

The mutation is paid only when the client actually included the
early_data extension on a 0-RTT-capable session, so normal resumptions
are unaffected and the existing remove-callback counts in
test_wolfSSL_CTX_add_session_ext_{tls13,dtls13} stay correct.

wolfSSL_SSL_CTX_remove_session was previously declared and defined only
under the OpenSSL compatibility layer. Because it is now called from
the core TLS 1.3 PSK path, the declaration in wolfssl/ssl.h and the
definition in src/ssl_sess.c are moved out of that block to match the
existing !NO_SESSION_CACHE gate under which the function is meaningful.
wolfSSL_SSL_get0_session stays in the compat block.

test_tls13_early_data_0rtt_replay verifies the behaviour. It does a
full TLS 1.3 handshake with stateful tickets (SSL_OP_NO_TICKET) and
max_early_data > 0, then tries to resume the saved session twice while
offering 0-RTT each time. A minimal single-slot external session cache
is wired up via wolfSSL_CTX_sess_set_{new,get,remove}_cb to confirm
both caches are cleared. Round 0 must resume and deliver the early
data, and rem_calls must hit 1 (the fix's single eviction). Round 1
must fall back to a full handshake (session_reused == 0), deliver no
early data, and leave rem_calls at 1.

Verified against multiple configurations (incl. --enable-all
--enable-earlydata, the no-compat -DHAVE_EXT_CACHE build, and the
os-check.yml combo). Valgrind under -g2 -O0 with OPENSSL_EXTRA +
HAVE_EXT_CACHE + HAVE_EX_DATA reports no errors and no
definitely-lost bytes.

Refs wolfSSL/wolfssl#10197
This commit is contained in:
Juliusz Sosinowicz
2026-04-14 15:35:50 +00:00
parent 9176185d66
commit e5f569ad7c
5 changed files with 214 additions and 8 deletions
+4 -6
View File
@@ -3242,9 +3242,6 @@ static void SESSION_ex_data_cache_update(WOLFSSL_SESSION* session, int idx,
#endif
#if defined(OPENSSL_ALL) || defined(WOLFSSL_NGINX) || defined(WOLFSSL_HAPROXY) \
|| defined(OPENSSL_EXTRA) || defined(HAVE_LIGHTY)
#ifndef NO_SESSION_CACHE
int wolfSSL_SSL_CTX_remove_session(WOLFSSL_CTX *ctx, WOLFSSL_SESSION *s)
{
@@ -3320,18 +3317,19 @@ int wolfSSL_SSL_CTX_remove_session(WOLFSSL_CTX *ctx, WOLFSSL_SESSION *s)
return 0;
}
#if defined(OPENSSL_ALL) || defined(WOLFSSL_NGINX) || defined(WOLFSSL_HAPROXY) \
|| defined(OPENSSL_EXTRA) || defined(HAVE_LIGHTY)
WOLFSSL_SESSION *wolfSSL_SSL_get0_session(const WOLFSSL *ssl)
{
WOLFSSL_ENTER("wolfSSL_SSL_get0_session");
return ssl->session;
}
#endif /* NO_SESSION_CACHE */
#endif /* OPENSSL_ALL || WOLFSSL_NGINX || WOLFSSL_HAPROXY ||
OPENSSL_EXTRA || HAVE_LIGHTY */
#endif /* NO_SESSION_CACHE */
#ifdef WOLFSSL_SESSION_EXPORT
/* Used to import a serialized TLS session.
* WARNING: buf contains sensitive information about the state and is best to be
+12
View File
@@ -6287,6 +6287,18 @@ static int DoPreSharedKeys(WOLFSSL* ssl, const byte* input, word32 inputSz,
/* This PSK works, no need to try any more. */
current->chosen = 1;
ext->resp = 1;
#if defined(WOLFSSL_EARLY_DATA) && defined(HAVE_SESSION_TICKET) && \
!defined(NO_SESSION_CACHE)
/* RFC 8446 section 8: accept 0-RTT for a given handshake at most
* once. Evict the session from both the internal cache (under a
* write lock) and any external cache (via ctx->rem_sess_cb) so
* the same ClientHello cannot replay early data. Only when the
* client offered 0-RTT on a session that permits it. */
if (ssl->earlyData != no_early_data &&
ssl->session->maxEarlyDataSz != 0) {
(void)wolfSSL_SSL_CTX_remove_session(ssl->ctx, ssl->session);
}
#endif
break;
}
+192
View File
@@ -2401,6 +2401,198 @@ int test_tls13_early_data(void)
}
#if defined(HAVE_MANUAL_MEMIO_TESTS_DEPENDENCIES) && \
defined(WOLFSSL_TLS13) && defined(WOLFSSL_EARLY_DATA) && \
defined(HAVE_SESSION_TICKET) && defined(WOLFSSL_TICKET_HAVE_ID) && \
!defined(NO_SESSION_CACHE) && defined(HAVE_EXT_CACHE)
/* Single-slot external session cache keyed by altSessionID, used by
* test_tls13_early_data_0rtt_replay to assert the 0-RTT anti-replay
* fix clears both caches. */
static struct {
byte id[ID_LEN];
byte has_entry;
WOLFSSL_SESSION* sess;
int new_calls;
int get_calls;
int rem_calls;
} test_tls13_0rtt_replay_cache;
static void test_tls13_0rtt_replay_cache_reset(void)
{
/* wolfSSL_SESSION_free is NULL-safe, so unconditionally drop any
* stored session without touching has_entry first. */
wolfSSL_SESSION_free(test_tls13_0rtt_replay_cache.sess);
XMEMSET(&test_tls13_0rtt_replay_cache, 0,
sizeof(test_tls13_0rtt_replay_cache));
}
/* Stateful-ticket sessions always have haveAltSessionID set, so key the
* cache on altSessionID directly (wolfSSL_SESSION_get_id is only
* declared under the OpenSSL compatibility layer). */
static int test_tls13_0rtt_replay_new_cb(WOLFSSL* ssl, WOLFSSL_SESSION* s)
{
(void)ssl;
test_tls13_0rtt_replay_cache.new_calls++;
if (s == NULL || !s->haveAltSessionID)
return 0;
wolfSSL_SESSION_free(test_tls13_0rtt_replay_cache.sess);
XMEMCPY(test_tls13_0rtt_replay_cache.id, s->altSessionID, ID_LEN);
test_tls13_0rtt_replay_cache.sess = s;
test_tls13_0rtt_replay_cache.has_entry = 1;
return 1; /* retain the reference; freed in the rem callback */
}
static WOLFSSL_SESSION* test_tls13_0rtt_replay_get_cb(WOLFSSL* ssl,
const byte* id, int idLen, int* ref)
{
(void)ssl;
test_tls13_0rtt_replay_cache.get_calls++;
*ref = 1; /* keep ownership; wolfSSL duplicates from us */
if (!test_tls13_0rtt_replay_cache.has_entry || idLen != ID_LEN)
return NULL;
if (XMEMCMP(test_tls13_0rtt_replay_cache.id, id, ID_LEN) != 0)
return NULL;
return test_tls13_0rtt_replay_cache.sess;
}
static void test_tls13_0rtt_replay_rem_cb(WOLFSSL_CTX* ctx,
WOLFSSL_SESSION* s)
{
const byte* id;
(void)ctx;
if (!test_tls13_0rtt_replay_cache.has_entry || s == NULL)
return;
/* Internal-cache-evicted sessions have haveAltSessionID cleared
* (that field sits before the DupSession copy offset), so fall
* back to sessionID when altSessionID is not set. Both carry the
* ID_LEN lookup key. */
if (s->haveAltSessionID)
id = s->altSessionID;
else if (s->sessionIDSz == ID_LEN)
id = s->sessionID;
else
return;
if (XMEMCMP(test_tls13_0rtt_replay_cache.id, id, ID_LEN) != 0)
return;
wolfSSL_SESSION_free(test_tls13_0rtt_replay_cache.sess);
test_tls13_0rtt_replay_cache.sess = NULL;
test_tls13_0rtt_replay_cache.has_entry = 0;
test_tls13_0rtt_replay_cache.rem_calls++;
}
/* RFC 8446 section 8 anti-replay: a 0-RTT-eligible session must be
* evicted from both the internal and external caches on resumption so
* the same ClientHello cannot replay early data. */
int test_tls13_early_data_0rtt_replay(void)
{
EXPECT_DECLS;
struct test_memio_ctx test_ctx;
WOLFSSL_CTX *ctx_c = NULL, *ctx_s = NULL;
WOLFSSL *ssl_c = NULL, *ssl_s = NULL;
WOLFSSL_SESSION *sess = NULL;
char buf[64];
int round;
XMEMSET(&test_ctx, 0, sizeof(test_ctx));
test_tls13_0rtt_replay_cache_reset();
/* Step 1: full handshake populates both caches. */
ExpectIntEQ(test_memio_setup(&test_ctx, &ctx_c, &ctx_s, &ssl_c, &ssl_s,
wolfTLSv1_3_client_method, wolfTLSv1_3_server_method),
0);
/* Stateful tickets + 0-RTT enabled. */
ExpectTrue(wolfSSL_set_options(ssl_s, WOLFSSL_OP_NO_TICKET) != 0);
#if defined(OPENSSL_EXTRA) || defined(WOLFSSL_ERROR_CODE_OPENSSL)
ExpectIntEQ(wolfSSL_set_max_early_data(ssl_s, 128), WOLFSSL_SUCCESS);
#else
ExpectIntEQ(wolfSSL_set_max_early_data(ssl_s, 128), 0);
#endif
wolfSSL_CTX_sess_set_new_cb(ctx_s, test_tls13_0rtt_replay_new_cb);
wolfSSL_CTX_sess_set_get_cb(ctx_s, test_tls13_0rtt_replay_get_cb);
wolfSSL_CTX_sess_set_remove_cb(ctx_s, test_tls13_0rtt_replay_rem_cb);
ExpectIntEQ(test_memio_do_handshake(ssl_c, ssl_s, 10, NULL), 0);
/* Let the client consume NewSessionTicket. */
ExpectIntEQ(wolfSSL_read(ssl_c, buf, sizeof(buf)), -1);
ExpectIntEQ(wolfSSL_get_error(ssl_c, -1), WOLFSSL_ERROR_WANT_READ);
ExpectNotNull(sess = wolfSSL_get1_session(ssl_c));
ExpectIntEQ(wolfSSL_SessionIsSetup(sess), 1);
/* Stateful (ID-only) ticket on the client side. */
ExpectIntEQ(sess->ticketLen, ID_LEN);
ExpectIntEQ((int)sess->maxEarlyDataSz, 128);
/* External cache saw the add. */
ExpectIntGT(test_tls13_0rtt_replay_cache.new_calls, 0);
ExpectIntEQ(test_tls13_0rtt_replay_cache.has_entry, 1);
wolfSSL_free(ssl_c); ssl_c = NULL;
wolfSSL_free(ssl_s); ssl_s = NULL;
/* Resume the same session twice, offering 0-RTT each time. */
for (round = 0; round < 2 && !EXPECT_FAIL(); round++) {
const char earlyMsg[] = "early-data-0rtt";
int written = 0;
int earlyRead = 0;
char earlyBuf[sizeof(earlyMsg)];
XMEMSET(&test_ctx, 0, sizeof(test_ctx));
XMEMSET(earlyBuf, 0, sizeof(earlyBuf));
/* Reuse the CTXs so both caches survive (test_memio_setup
* leaves *ctx alone when non-NULL). */
ExpectIntEQ(test_memio_setup(&test_ctx, &ctx_c, &ctx_s, &ssl_c,
&ssl_s, wolfTLSv1_3_client_method,
wolfTLSv1_3_server_method), 0);
ExpectTrue(wolfSSL_set_options(ssl_s, WOLFSSL_OP_NO_TICKET) != 0);
#if defined(OPENSSL_EXTRA) || defined(WOLFSSL_ERROR_CODE_OPENSSL)
ExpectIntEQ(wolfSSL_set_max_early_data(ssl_s, 128),
WOLFSSL_SUCCESS);
#else
ExpectIntEQ(wolfSSL_set_max_early_data(ssl_s, 128), 0);
#endif
ExpectIntEQ(wolfSSL_SessionIsSetup(sess), 1);
ExpectIntEQ(wolfSSL_set_session(ssl_c, sess), WOLFSSL_SUCCESS);
ExpectIntEQ(test_tls13_early_data_write_until_write_ok(ssl_c,
earlyMsg, (int)sizeof(earlyMsg), &written),
sizeof(earlyMsg));
ExpectIntEQ(written, sizeof(earlyMsg));
(void)test_tls13_early_data_read_until_write_ok(ssl_s, earlyBuf,
sizeof(earlyBuf), &earlyRead);
ExpectIntEQ(test_memio_do_handshake(ssl_c, ssl_s, 10, NULL), 0);
if (round == 0) {
ExpectTrue(wolfSSL_session_reused(ssl_s));
ExpectIntEQ(earlyRead, sizeof(earlyMsg));
ExpectStrEQ(earlyMsg, earlyBuf);
/* Fix fired exactly once to evict the cached entry. */
ExpectIntEQ(test_tls13_0rtt_replay_cache.rem_calls, 1);
}
else {
ExpectFalse(wolfSSL_session_reused(ssl_s));
ExpectIntEQ(earlyRead, 0);
/* No additional eviction in the replay round. */
ExpectIntEQ(test_tls13_0rtt_replay_cache.rem_calls, 1);
}
wolfSSL_free(ssl_c); ssl_c = NULL;
wolfSSL_free(ssl_s); ssl_s = NULL;
}
wolfSSL_SESSION_free(sess);
wolfSSL_CTX_free(ctx_c);
wolfSSL_CTX_free(ctx_s);
test_tls13_0rtt_replay_cache_reset();
return EXPECT_RESULT();
}
#else
int test_tls13_early_data_0rtt_replay(void)
{
EXPECT_DECLS;
return EXPECT_RESULT();
}
#endif
/* Check that the client won't send the same CH after a HRR. An HRR without
* a KeyShare or a Cookie extension will trigger the error. */
int test_tls13_same_ch(void)
+2
View File
@@ -45,6 +45,7 @@ int test_tls13_cert_req_sigalgs(void);
int test_tls13_derive_keys_no_key(void);
int test_tls13_pqc_hybrid_truncated_keyshare(void);
int test_tls13_short_session_ticket(void);
int test_tls13_early_data_0rtt_replay(void);
#define TEST_TLS13_DECLS \
TEST_DECL_GROUP("tls13", test_tls13_apis), \
@@ -67,6 +68,7 @@ int test_tls13_short_session_ticket(void);
TEST_DECL_GROUP("tls13", test_tls13_derive_keys_no_key), \
TEST_DECL_GROUP("tls13", test_tls13_pqc_hybrid_truncated_keyshare), \
TEST_DECL_GROUP("tls13", test_tls13_short_session_ticket), \
TEST_DECL_GROUP("tls13", test_tls13_early_data_0rtt_replay), \
TEST_DECL_GROUP("tls13", test_tls13_unknown_ext_rejected)
#endif /* WOLFCRYPT_TEST_TLS13_H */
+4 -2
View File
@@ -2940,6 +2940,10 @@ WOLFSSL_API WOLFSSL_SESSION* wolfSSL_SESSION_new_ex(void* heap);
WOLFSSL_API void wolfSSL_SESSION_free(WOLFSSL_SESSION* session);
WOLFSSL_API int wolfSSL_CTX_add_session(WOLFSSL_CTX* ctx,
WOLFSSL_SESSION* session);
#ifndef NO_SESSION_CACHE
WOLFSSL_API int wolfSSL_SSL_CTX_remove_session(WOLFSSL_CTX* ctx,
WOLFSSL_SESSION *c);
#endif
WOLFSSL_API int wolfSSL_SESSION_set_cipher(WOLFSSL_SESSION* session,
const WOLFSSL_CIPHER* cipher);
WOLFSSL_API int wolfSSL_is_init_finished(const WOLFSSL* ssl);
@@ -5858,8 +5862,6 @@ WOLFSSL_API int wolfSSL_SSL_in_before(const WOLFSSL* ssl);
WOLFSSL_API int wolfSSL_SSL_in_connect_init(WOLFSSL* ssl);
#ifndef NO_SESSION_CACHE
WOLFSSL_API int wolfSSL_SSL_CTX_remove_session(WOLFSSL_CTX* ctx,
WOLFSSL_SESSION *c);
WOLFSSL_API WOLFSSL_SESSION *wolfSSL_SSL_get0_session(const WOLFSSL *s);
#endif