Commit 17bf6c48ea for openssl.org

commit 17bf6c48ea8a6af1cb90f2f2aab4bb4b9e2adfc5
Author: Daniel Kubec <kubec@openssl.foundation>
Date:   Thu Jul 9 08:25:35 2026 +0000

    TLS 1.3: Suppress early_data on PSK ticket age mismatch

    tls13_check_psk() mirrors the gating checks in tls_construct_ctos_psk() so that
    early_data is only advertised when the PSK will actually be sent. Normally we
    stamp the state back to SSL_EARLY_DATA_CONNECT_RETRY so the next
    SSL_write_early_data() call resumes here. However
    tls_construct_ctos_early_data() may have reset early_data_state to
    SSL_EARLY_DATA_NONE because it decided not to send the early_data extension.
    In that case leave the state alone so the handshake can complete without 0-RTT.

    - RFC 9846 4.3.10: When a PSK is used and early data is allowed for that PSK,
    the client can send Application Data in its first flight of messages. If the
    client opts to do so, it MUST supply both the "pre_shared_key" and
    "early_data" extensions. The PSK used to encrypt the early data MUST be the
    first PSK listed in the client's "pre_shared_key" extension.

    - RFC 9846 4.3.11.1: Clients MUST NOT attempt to use tickets which have ages
    greater than the "ticket_lifetime" value which was provided with the ticket.

    - RFC 9846 4.3.10: For PSKs provisioned via NewSessionTicket, a server MUST
    validate that the ticket age for the selected PSK identity is within a small
    tolerance of the time since the ticket was issued. If it is not, the server
    SHOULD proceed with the handshake but reject 0-RTT.

    Fixes #13395

    Reviewed-by: Bob Beck <beck@openssl.org>
    Reviewed-by: Neil Horman <nhorman@openssl.org>
    Reviewed-by: Viktor Dukhovni <viktor@openssl.org>
    MergeDate: Sun Aug 16 13:18:16 2026
    (Merged from https://github.com/openssl/openssl/pull/32202)

diff --git a/CHANGES.md b/CHANGES.md
index 15b07be6be..a467cbe9b5 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -31,6 +31,20 @@ OpenSSL Releases

 ### Changes between 4.0 and 4.1 [xx XXX xxxx]

+ * Fixed TLS 1.3 clients to encrypt 0-RTT early data with the first offered
+   PSK identity (RFC 9846 section 4.3.10) when a 0-RTT-capable resumption
+   ticket has aged out and an external PSK is offered in its place. The early
+   data was being encrypted with the retired ticket's secret rather than the
+   external PSK's, causing the server to reject it with a bad record MAC.
+
+   *Viktor Dukhovni*
+
+ * Fixed TLS 1.3 servers to reject early data when a resumed PSK's
+   ticket age is outside tolerance, per RFC 9846, instead of accepting
+   0-RTT data from a ticket that has aged out.
+
+   *Daniel Kubec*
+
  * Repeated fields in the `basicConstraints`, `basicAttConstraints`,
    and `policyConstraints` X.509v3 extension configurations are now rejected
    instead of silently using the last value.
diff --git a/doc/man3/SSL_read_early_data.pod b/doc/man3/SSL_read_early_data.pod
index ec71c6eba9..273afe05d8 100644
--- a/doc/man3/SSL_read_early_data.pod
+++ b/doc/man3/SSL_read_early_data.pod
@@ -116,6 +116,10 @@ complete the handshake by calling a function such as L<SSL_connect(3)> or
 L<SSL_do_handshake(3)>. Alternatively you can call a standard write function
 such as L<SSL_write_ex(3)>, which will transparently complete the connection and
 write the requested data.
+Once the client has completed the handshake or written ordinary application
+data, it has left the early data write sequence, and a subsequent
+SSL_write_early_data() call fails; calling it at that point is a programming
+error.

 A server may choose to ignore early data that has been sent to it. Once the
 connection has been completed you can determine whether the server accepted or
@@ -123,6 +127,10 @@ rejected the early data by calling SSL_get_early_data_status(). This will return
 SSL_EARLY_DATA_ACCEPTED if the data was accepted, SSL_EARLY_DATA_REJECTED if it
 was rejected or SSL_EARLY_DATA_NOT_SENT if no early data was sent. This function
 may be called by either the client or the server.
+A client cannot distinguish early data that it declined to send locally (for
+example because 0-RTT was not viable for the offered PSK; see L</NOTES>) from
+early data that the server rejected.
+Both cases are reported as SSL_EARLY_DATA_REJECTED.

 A server uses the SSL_read_early_data() function to receive early data on a
 connection for which early data has been enabled using
diff --git a/ssl/record/rec_layer_s3.c b/ssl/record/rec_layer_s3.c
index d87001ad7b..39bd2a8e89 100644
--- a/ssl/record/rec_layer_s3.c
+++ b/ssl/record/rec_layer_s3.c
@@ -126,14 +126,8 @@ static uint32_t ossl_get_max_early_data(SSL_CONNECTION *s)
      * session/psksession. Otherwise we go with the lowest out of the max early
      * data set in the session and the configured max_early_data.
      */
-    if (!s->server && sess->ext.max_early_data == 0) {
-        if (!ossl_assert(s->psksession != NULL
-                && s->psksession->ext.max_early_data > 0)) {
-            SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
-            return 0;
-        }
-        sess = s->psksession;
-    }
+    if (!s->server && s->ext.early_data_session != NULL)
+        sess = s->ext.early_data_session;

     if (!s->server)
         max_early_data = sess->ext.max_early_data;
diff --git a/ssl/ssl_lib.c b/ssl/ssl_lib.c
index 57d74f9091..362c720bcf 100644
--- a/ssl/ssl_lib.c
+++ b/ssl/ssl_lib.c
@@ -586,6 +586,11 @@ int ossl_ssl_connection_reset(SSL *s)
     sc->error = 0;
     sc->hit = 0;
     sc->shutdown = 0;
+    sc->ext.early_data_suppressed = 0;
+    SSL_SESSION_free(sc->ext.early_data_session);
+    sc->ext.early_data_session = NULL;
+    sc->ext.tick_age_checked = 0;
+    sc->ext.tick_age_ms = 0;

     if (sc->renegotiate) {
         ERR_raise(ERR_LIB_SSL, ERR_R_INTERNAL_ERROR);
@@ -1510,6 +1515,7 @@ void ossl_ssl_connection_free(SSL *ssl)
         SSL_SESSION_free(s->session);
     }
     SSL_SESSION_free(s->psksession);
+    SSL_SESSION_free(s->ext.early_data_session);
     OPENSSL_free(s->psksession_id);

     ssl_cert_free(s->cert);
@@ -2858,6 +2864,32 @@ int SSL_write_early_data(SSL *s, const void *buf, size_t num, size_t *written)

     switch (sc->early_data_state) {
     case SSL_EARLY_DATA_NONE:
+        /*
+         * tls_construct_ctos_early_data() decided not to advertise the
+         * early_data extension.
+         *
+         * Succeed and report num bytes as written so the handshake can
+         * continue without 0-RTT; early data rejection can be detected via
+         * SSL_get_early_data_status().
+         */
+        if (!sc->server && sc->ext.early_data_suppressed && !SSL_in_before(s)) {
+            /*
+             * 0-RTT was suppressed. Complete the handshake behind the scenes
+             * and report success so the application's early data write loop
+             * finishes. Run the internal SSL_connect() in the CONNECTING state,
+             * as the non-suppressed path does, so ossl_statem_check_finish_init()
+             * does not mistake it for the application leaving the early data
+             * write sequence.
+             */
+            sc->early_data_state = SSL_EARLY_DATA_CONNECTING;
+            ret = SSL_connect(s);
+            sc->early_data_state = SSL_EARLY_DATA_NONE;
+            if (ret <= 0)
+                return 0;
+            *written = num;
+            return 1;
+        }
+
         if (sc->server
             || !SSL_in_before(s)
             || ((sc->session == NULL || sc->session->ext.max_early_data == 0)
@@ -2872,10 +2904,25 @@ int SSL_write_early_data(SSL *s, const void *buf, size_t num, size_t *written)
         sc->early_data_state = SSL_EARLY_DATA_CONNECTING;
         ret = SSL_connect(s);
         if (ret <= 0) {
-            /* NBIO or error */
-            sc->early_data_state = SSL_EARLY_DATA_CONNECT_RETRY;
+            /*
+             * NBIO or error. Normally we stamp the state back to
+             * SSL_EARLY_DATA_CONNECT_RETRY so the next SSL_write_early_data()
+             * call resumes here. However tls_construct_ctos_early_data() may
+             * have reset early_data_state to SSL_EARLY_DATA_NONE because it
+             * decided not to send the early_data extension.
+             *
+             * In that case leave the state alone so the handshake can complete
+             * normally without 0-RTT.
+             */
+            if (!sc->ext.early_data_suppressed)
+                sc->early_data_state = SSL_EARLY_DATA_CONNECT_RETRY;
             return 0;
         }
+        if (sc->early_data_state == SSL_EARLY_DATA_NONE
+            && sc->ext.early_data_suppressed) {
+            *written = num;
+            return 1;
+        }
         /* fall through */

     case SSL_EARLY_DATA_WRITE_RETRY:
diff --git a/ssl/ssl_local.h b/ssl/ssl_local.h
index 2cfc5cb815..ab31dde3c8 100644
--- a/ssl/ssl_local.h
+++ b/ssl/ssl_local.h
@@ -1739,6 +1739,23 @@ struct ssl_connection_st {
          */
         int tick_identity;

+        /*
+         * Cached result of the resumption ticket age/lifetime check for the
+         * ClientHello under construction. Time-dependent, double-checked
+         * within the same flight (see tls13_check_tick_lifetime_hint()).
+         */
+        uint32_t tick_age_ms;
+
+        /*
+         * The first-offered PSK, the one that keys any 0-RTT, recorded while
+         * the ClientHello is built -- the resumption session when we offer it,
+         * else the external psksession; NULL when no 0-RTT is offered. Held
+         * (up-ref'd) so it stays valid across the post-ServerHello swap, and
+         * read by the binder, the early-key derivation, the early exporter and
+         * the byte-budget lookup. Freed at handshake reset and connection free.
+         */
+        SSL_SESSION *early_data_session;
+
         /* This is the list of algorithms the peer supports that we also support */
         int compress_certificate_from_peer[TLSEXT_comp_cert_limit];

@@ -1766,9 +1783,20 @@ struct ssl_connection_st {
         /* Set to one if we have negotiated ETM */
         bool use_etm;

-        /* Is the session suitable for early data? */
+        /* Is the session perhaps suitable for early data? */
         bool early_data_ok;

+        /* Was the session found unsuitable for early data? */
+        bool early_data_suppressed;
+
+        /*
+         * Cached result of the resumption ticket age/lifetime check for the
+         * ClientHello under construction. Time-dependent, double-checked
+         * within the same flight (see tls13_check_tick_lifetime_hint()).
+         */
+        bool tick_age_checked;
+        bool tick_age_ok;
+
         /* Have we received a cookie from the client? */
         bool cookieok;

diff --git a/ssl/statem/extensions.c b/ssl/statem/extensions.c
index 753402b84f..d75de303b3 100644
--- a/ssl/statem/extensions.c
+++ b/ssl/statem/extensions.c
@@ -1885,8 +1885,7 @@ int tls_psk_do_binder(SSL_CONNECTION *s, const EVP_MD *md,

     if (external
         && s->early_data_state == SSL_EARLY_DATA_CONNECTING
-        && s->session->ext.max_early_data == 0
-        && sess->ext.max_early_data > 0)
+        && sess == s->ext.early_data_session)
         usepskfored = 1;

     if (external) {
diff --git a/ssl/statem/extensions_clnt.c b/ssl/statem/extensions_clnt.c
index 2d19a7ae8b..556103e30a 100644
--- a/ssl/statem/extensions_clnt.c
+++ b/ssl/statem/extensions_clnt.c
@@ -8,13 +8,13 @@
  */

 #include <openssl/ocsp.h>
+#include <openssl/rand.h>
 #include "../ssl_local.h"
 #include "internal/cryptlib.h"
 #include "internal/ssl_unwrap.h"
 #include "internal/tlsgroups.h"
 #include "statem_local.h"
 #ifndef OPENSSL_NO_ECH
-#include <openssl/rand.h>
 #include "internal/ech_helpers.h"
 #endif

@@ -1032,6 +1032,83 @@ end:
     return ret;
 }

+static int tls13_check_tick_lifetime_hint(SSL_CONNECTION *s)
+{
+    OSSL_TIME t;
+    uint32_t agesec;
+
+    if (s->ext.tick_age_checked)
+        return s->ext.tick_age_ok;
+    s->ext.tick_age_ok = 1;
+
+    /*
+     * Technically the C standard just says time() returns a time_t and says
+     * nothing about the encoding of that type. In practice most
+     * implementations follow POSIX which holds it as an integral type in
+     * seconds since epoch. We've already made the assumption that we can do
+     * this in multiple places in the code, so portability shouldn't be an
+     * issue.
+     */
+    t = ossl_time_subtract(ossl_time_now(), s->session->time);
+    agesec = (uint32_t)ossl_time2seconds(t);
+
+    /*
+     * We calculate the age in seconds but the server may work in ms. Due to
+     * rounding errors we could overestimate the age by up to 1s. It is
+     * better to underestimate it. Otherwise, if the RTT is very short, when
+     * the server calculates the age reported by the client it could be
+     * bigger than the age calculated on the server - which should never
+     * happen.
+     */
+    if (agesec > 0)
+        agesec--;
+
+    /*
+     * Calculate age in ms. We're just doing it to nearest second. Should be
+     * good enough.
+     */
+    s->ext.tick_age_ms = agesec * (uint32_t)1000;
+
+    /*
+     * Ticket is too old. Ignore it. Overflow. Shouldn't happen unless this is a
+     * *really* old session. If so we just ignore it.
+     */
+    if (s->session->ext.tick_lifetime_hint < agesec)
+        s->ext.tick_age_ok = 0;
+    else if (agesec != 0 && s->ext.tick_age_ms / (uint32_t)1000 != agesec)
+        s->ext.tick_age_ok = 0;
+
+    s->ext.tick_age_checked = 1;
+    return s->ext.tick_age_ok;
+}
+
+/*
+ * Mirrors the ticket-resumption gating checks in tls_construct_ctos_psk() so
+ * that early_data is only advertised when the resumption PSK will actually
+ * be sent.
+ */
+static int tls13_check_resumption_psk(SSL_CONNECTION *s, const EVP_MD *handmd)
+{
+    SSL_CTX *sctx = SSL_CONNECTION_GET_CTX(s);
+    const EVP_MD *mdres;
+
+    if (s->session == NULL
+        || s->session->ssl_version != TLS1_3_VERSION
+        || s->session->ext.ticklen == 0
+        || s->session->cipher == NULL)
+        return 0;
+
+    mdres = ssl_md(sctx, s->session->cipher->algorithm2);
+    if (mdres == NULL)
+        return 0;
+    if (s->hello_retry_request == SSL_HRR_PENDING && mdres != handmd)
+        return 0;
+    if (tls13_check_tick_lifetime_hint(s) == 0)
+        return 0;
+
+    return 1;
+}
+
 EXT_RETURN tls_construct_ctos_early_data(SSL_CONNECTION *s, WPACKET *pkt,
     unsigned int context, X509 *x,
     size_t chainidx)
@@ -1046,6 +1123,8 @@ EXT_RETURN tls_construct_ctos_early_data(SSL_CONNECTION *s, WPACKET *pkt,
     const EVP_MD *handmd = NULL;
     SSL *ussl = SSL_CONNECTION_GET_USER_SSL(s);

+    s->ext.tick_age_checked = 0;
+
 #ifndef OPENSSL_NO_ECH
     /*
      * If we're attempting ECH and processing the outer CH
@@ -1147,14 +1226,65 @@ EXT_RETURN tls_construct_ctos_early_data(SSL_CONNECTION *s, WPACKET *pkt,
         s->psksession_id_len = idlen;
     }

+    /*
+     * Suppress early_data unless a PSK is available and will be sent.
+     *
+     * RFC 9846 4.3.10: When a PSK is used and early data is allowed for that
+     * PSK, the client can send Application Data in its first flight of
+     * messages. If the client opts to do so, it MUST supply both the
+     * "pre_shared_key" and "early_data" extensions.
+     *
+     * The PSK used to encrypt the early data MUST be the first PSK listed in
+     * the client's "pre_shared_key" extension.
+     */
+    /*
+     * Slot 0 -- the first identity we will offer -- is the only one that can
+     * key 0-RTT. It is the resumption session when we are offering it, else
+     * the external psksession. Offer early_data only when that slot-0 PSK is
+     * itself 0-RTT-capable; never key it off a PSK in a later slot.
+     */
+    edsess = tls13_check_resumption_psk(s, handmd) ? s->session : psksess;
     if (s->early_data_state != SSL_EARLY_DATA_CONNECTING
-        || (s->session->ext.max_early_data == 0
-            && (psksess == NULL || psksess->ext.max_early_data == 0))) {
+        || edsess == NULL
+        || edsess->ext.max_early_data == 0) {
         s->max_early_data = 0;
+        if (s->early_data_state == SSL_EARLY_DATA_CONNECTING) {
+            s->ext.early_data_suppressed = 1;
+            s->ext.early_data = SSL_EARLY_DATA_REJECTED;
+            /*
+             * We report REJECTED (not NOT_SENT), so
+             * SSL_export_keying_material_early() stays callable as it is for a
+             * server-rejected attempt -- but no early exporter secret was
+             * derived here. Randomise it so any such export yields a harmless
+             * per-connection orphan, not an all-zero (predictable) or stale
+             * (prior-handshake) value.
+             */
+            if (RAND_bytes_ex(SSL_CONNECTION_GET_CTX(s)->libctx,
+                    s->early_exporter_master_secret,
+                    sizeof(s->early_exporter_master_secret), 0)
+                <= 0) {
+                SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
+                return EXT_RETURN_FAIL;
+            }
+        }
+        s->early_data_state = SSL_EARLY_DATA_NONE;
         return EXT_RETURN_NOT_SENT;
     }
-    edsess = s->session->ext.max_early_data != 0 ? s->session : psksess;
     s->max_early_data = edsess->ext.max_early_data;
+    /*
+     * Freeze slot 0 (candidate_at(0)) so the binder, the early-key derivation,
+     * the early exporter, the byte-budget lookup and the post-ServerHello fixup
+     * all key off the actual first-offered PSK rather than guessing the source
+     * from s->session->ext.max_early_data. Held (up-ref'd) so it stays valid
+     * across the swap that later folds a selected psksession into s->session.
+     */
+    SSL_SESSION_free(s->ext.early_data_session);
+    s->ext.early_data_session = edsess;
+    if (!SSL_SESSION_up_ref(edsess)) {
+        s->ext.early_data_session = NULL;
+        SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
+        return EXT_RETURN_FAIL;
+    }

     if (edsess->ext.hostname != NULL) {
         if (s->ext.hostname == NULL
@@ -1315,14 +1445,13 @@ EXT_RETURN tls_construct_ctos_psk(SSL_CONNECTION *s, WPACKET *pkt,
     X509 *x, size_t chainidx)
 {
 #ifndef OPENSSL_NO_TLS1_3
-    uint32_t agesec, agems = 0;
+    uint32_t agems = 0;
     size_t binderoffset, msglen;
     int reshashsize = 0, pskhashsize = 0;
     unsigned char *resbinder = NULL, *pskbinder = NULL, *msgstart = NULL;
     const EVP_MD *handmd = NULL, *mdres = NULL, *mdpsk = NULL;
     int dores = 0;
     SSL_CTX *sctx = SSL_CONNECTION_GET_CTX(s);
-    OSSL_TIME t;

     s->ext.tick_identity = 0;

@@ -1379,46 +1508,10 @@ EXT_RETURN tls_construct_ctos_psk(SSL_CONNECTION *s, WPACKET *pkt,
         }
 #endif

-        /*
-         * Technically the C standard just says time() returns a time_t and says
-         * nothing about the encoding of that type. In practice most
-         * implementations follow POSIX which holds it as an integral type in
-         * seconds since epoch. We've already made the assumption that we can do
-         * this in multiple places in the code, so portability shouldn't be an
-         * issue.
-         */
-        t = ossl_time_subtract(ossl_time_now(), s->session->time);
-        agesec = (uint32_t)ossl_time2seconds(t);
-
-        /*
-         * We calculate the age in seconds but the server may work in ms. Due to
-         * rounding errors we could overestimate the age by up to 1s. It is
-         * better to underestimate it. Otherwise, if the RTT is very short, when
-         * the server calculates the age reported by the client it could be
-         * bigger than the age calculated on the server - which should never
-         * happen.
-         */
-        if (agesec > 0)
-            agesec--;
-
-        if (s->session->ext.tick_lifetime_hint < agesec) {
-            /* Ticket is too old. Ignore it. */
-            goto dopsksess;
-        }
-
-        /*
-         * Calculate age in ms. We're just doing it to nearest second. Should be
-         * good enough.
-         */
-        agems = agesec * (uint32_t)1000;
-
-        if (agesec != 0 && agems / (uint32_t)1000 != agesec) {
-            /*
-             * Overflow. Shouldn't happen unless this is a *really* old session.
-             * If so we just ignore it.
-             */
+        if (tls13_check_tick_lifetime_hint(s) == 0)
             goto dopsksess;
-        }
+        /* tls13_check_tick_lifetime_hint() updates the tick_age_ms value. */
+        agems = s->ext.tick_age_ms;

         /*
          * Obfuscate the age. Overflow here is fine, this addition is supposed
@@ -2455,8 +2548,7 @@ int tls_parse_stoc_psk(SSL_CONNECTION *s, PACKET *pkt,
      */
     if ((s->early_data_state != SSL_EARLY_DATA_WRITE_RETRY
             && s->early_data_state != SSL_EARLY_DATA_FINISHED_WRITING)
-        || s->session->ext.max_early_data > 0
-        || s->psksession->ext.max_early_data == 0)
+        || s->ext.early_data_session != s->psksession)
         memcpy(s->early_secret, s->psksession->early_secret, EVP_MAX_MD_SIZE);

     /*
diff --git a/ssl/statem/statem.c b/ssl/statem/statem.c
index 04887bc9d1..e14c5cf642 100644
--- a/ssl/statem/statem.c
+++ b/ssl/statem/statem.c
@@ -245,6 +245,20 @@ int ossl_statem_skip_early_data(SSL_CONNECTION *s)
  */
 int ossl_statem_check_finish_init(SSL_CONNECTION *s, int sending)
 {
+    /*
+     * A client that suppressed 0-RTT keeps SSL_write_early_data() reporting
+     * success while the application streams early data. Any ordinary read,
+     * write, or handshake call waits for the handshake to complete and so ends
+     * that sequence: clear the suppressed flag, after which a further
+     * SSL_write_early_data() returns the normal error instead of masking a
+     * state-machine mistake. The internal SSL_connect() issued from
+     * SSL_write_early_data() runs in the CONNECTING state and is excluded.
+     */
+    if (!s->server
+        && s->ext.early_data_suppressed
+        && s->early_data_state == SSL_EARLY_DATA_NONE)
+        s->ext.early_data_suppressed = 0;
+
     if (sending == -1) {
         if (s->statem.hand_state == TLS_ST_PENDING_EARLY_DATA_END
             || s->statem.hand_state == TLS_ST_EARLY_DATA) {
diff --git a/ssl/tls13_enc.c b/ssl/tls13_enc.c
index 2ac8eeddc7..4c341153c3 100644
--- a/ssl/tls13_enc.c
+++ b/ssl/tls13_enc.c
@@ -552,20 +552,18 @@ int tls13_change_cipher_state(SSL_CONNECTION *s, int which)
                 }
             }

+            /*
+             * 0-RTT keys off the frozen slot-0 PSK (candidate_at(0)), which
+             * may be the external psksession rather than s->session.
+             */
             if (s->early_data_state == SSL_EARLY_DATA_CONNECTING
-                && s->max_early_data > 0
-                && s->session->ext.max_early_data == 0) {
-                /*
-                 * If we are attempting to send early data, and we've decided to
-                 * actually do it but max_early_data in s->session is 0 then we
-                 * must be using an external PSK.
-                 */
-                if (!ossl_assert(s->psksession != NULL
-                        && s->max_early_data == s->psksession->ext.max_early_data)) {
+                && s->ext.early_data_session != NULL) {
+                if (!ossl_assert(s->max_early_data
+                        == s->ext.early_data_session->ext.max_early_data)) {
                     SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
                     goto err;
                 }
-                sslcipher = SSL_SESSION_get0_cipher(s->psksession);
+                sslcipher = SSL_SESSION_get0_cipher(s->ext.early_data_session);
             }
             if (sslcipher == NULL) {
                 SSLfatal(s, SSL_AD_INTERNAL_ERROR, SSL_R_BAD_PSK);
@@ -923,9 +921,8 @@ int tls13_export_keying_material_early(SSL_CONNECTION *s,
     if (ctx == NULL || !ossl_statem_export_early_allowed(s))
         goto err;

-    if (!s->server && s->max_early_data > 0
-        && s->session->ext.max_early_data == 0)
-        sslcipher = SSL_SESSION_get0_cipher(s->psksession);
+    if (!s->server && s->ext.early_data_session != NULL)
+        sslcipher = SSL_SESSION_get0_cipher(s->ext.early_data_session);
     else
         sslcipher = SSL_SESSION_get0_cipher(s->session);

diff --git a/test/tls13tickettest.c b/test/tls13tickettest.c
index f761419a56..ab15ec73cb 100644
--- a/test/tls13tickettest.c
+++ b/test/tls13tickettest.c
@@ -7,6 +7,7 @@
  * https://www.openssl.org/source/license.html
  */

+#include <string.h>
 #include <openssl/ssl.h>
 #include <openssl/ssl3.h>
 #include <openssl/tls1.h>
@@ -27,7 +28,7 @@
  * such as new_session_count = 0 or resumption_count = 0, is effectively
  * signaling no interest in session tickets or resumption.
  *
- * RFC 8446 section 4.2.9: Servers MUST NOT select a key exchange mode that is
+ * RFC 9846 section 4.3.9: Servers MUST NOT select a key exchange mode that is
  * not listed by the client. This extension also restricts the modes for use
  * with PSK resumption. Servers SHOULD NOT send NewSessionTicket with tickets
  * that are not compatible with the advertised modes; however, if a server does
@@ -57,8 +58,10 @@ struct stats {
     unsigned int ch_has_psk;
     unsigned int ch_has_psk_kex_modes;
     unsigned int ch_has_session_ticket;
+    unsigned int ch_has_early_data;
     unsigned int sh_has_psk;
     unsigned int sh_has_supported_versions;
+    unsigned int ee_has_early_data;
 };

 struct tls13_endpoint {
@@ -137,10 +140,15 @@ static void parse_ch_exts(const unsigned char *buf, size_t len, struct stats *x)
         case TLSEXT_TYPE_session_ticket:
             x->ch_has_session_ticket = 1;
             break;
+        case TLSEXT_TYPE_early_data:
+            x->ch_has_early_data = 1;
+            break;
         }
     }
-    TEST_info("ch extensions: psk=%d psk_kex_modes=%d session_ticket=%d",
-        x->ch_has_psk, x->ch_has_psk_kex_modes, x->ch_has_session_ticket);
+    TEST_info("ch extensions: psk=%d psk_kex_modes=%d session_ticket=%d"
+              " early_data=%d",
+        x->ch_has_psk, x->ch_has_psk_kex_modes, x->ch_has_session_ticket,
+        x->ch_has_early_data);
 }

 static void parse_sh_exts(const unsigned char *buf, size_t len, struct stats *x)
@@ -171,6 +179,28 @@ static void parse_sh_exts(const unsigned char *buf, size_t len, struct stats *x)
         x->sh_has_psk, x->sh_has_supported_versions);
 }

+static void parse_ee_exts(const unsigned char *buf, size_t len, struct stats *x)
+{
+    PACKET pkt, e, ex;
+    unsigned int v;
+
+    if (!PACKET_buf_init(&pkt, buf, len)
+        || !PACKET_forward(&pkt, 4)
+        || !PACKET_as_length_prefixed_2(&pkt, &e))
+        return;
+
+    while (PACKET_remaining(&e) > 0) {
+        if (!PACKET_get_net_2(&e, &v) || !PACKET_get_length_prefixed_2(&e, &ex))
+            return;
+        switch (v) {
+        case TLSEXT_TYPE_early_data:
+            x->ee_has_early_data = 1;
+            break;
+        }
+    }
+    TEST_info("ee extensions: early_data=%d", x->ee_has_early_data);
+}
+
 static void msg_cb(int write_p, int version, int content_type,
     const void *buf, size_t len, SSL *ssl, void *arg)
 {
@@ -185,6 +215,8 @@ static void msg_cb(int write_p, int version, int content_type,
             parse_ch_exts(buf, len, stats);
         if (mt == SSL3_MT_SERVER_HELLO && stats != NULL)
             parse_sh_exts(buf, len, stats);
+        if (mt == SSL3_MT_ENCRYPTED_EXTENSIONS && stats != NULL)
+            parse_ee_exts(buf, len, stats);
     }
 }

@@ -250,6 +282,95 @@ static int ticket_disable(SSL_CTX *ctx)
     return 1;
 }

+/*
+ * A fixed, 0-RTT-capable external PSK (RFC 9846), offered via the
+ * psk_use_session (client) and psk_find_session (server) callbacks. Used to
+ * exercise 0-RTT keyed off an external PSK while a retired resumption ticket is
+ * also present: the external PSK is the first offered identity (slot 0).
+ */
+static const unsigned char ext_psk_id[] = {
+    'e', 'x', 't', '-', 'p', 's', 'k'
+};
+static const unsigned char ext_psk_key[32] = {
+    0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
+    0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
+    0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
+    0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20
+};
+
+static SSL_SESSION *ext_psk_session(SSL *ssl)
+{
+    static const unsigned char tls13_aes128gcmsha256_id[] = { 0x13, 0x01 };
+    SSL_SESSION *sess = SSL_SESSION_new();
+    const SSL_CIPHER *cipher = SSL_CIPHER_find(ssl, tls13_aes128gcmsha256_id);
+
+    if (sess == NULL
+        || cipher == NULL
+        || !SSL_SESSION_set1_master_key(sess, ext_psk_key, sizeof(ext_psk_key))
+        || !SSL_SESSION_set_cipher(sess, cipher)
+        || !SSL_SESSION_set_protocol_version(sess, TLS1_3_VERSION)
+        || !SSL_SESSION_set_max_early_data(sess, SSL3_RT_MAX_PLAIN_LENGTH)) {
+        SSL_SESSION_free(sess);
+        return NULL;
+    }
+    return sess;
+}
+
+static int ext_psk_use_cb(SSL *ssl, const EVP_MD *md, const unsigned char **id,
+    size_t *idlen, SSL_SESSION **sess)
+{
+    (void)md;
+    if ((*sess = ext_psk_session(ssl)) == NULL)
+        return 0;
+    *id = ext_psk_id;
+    *idlen = sizeof(ext_psk_id);
+    return 1;
+}
+
+static int ext_psk_find_cb(SSL *ssl, const unsigned char *id, size_t idlen,
+    SSL_SESSION **sess)
+{
+    if (idlen != sizeof(ext_psk_id) || memcmp(id, ext_psk_id, idlen) != 0) {
+        *sess = NULL;
+        return 1;
+    }
+    return (*sess = ext_psk_session(ssl)) != NULL;
+}
+
+static int enable_external_psk(SSL *cssl, SSL *sssl)
+{
+    SSL_set_psk_use_session_callback(cssl, ext_psk_use_cb);
+    SSL_set_psk_find_session_callback(sssl, ext_psk_find_cb);
+    return 1;
+}
+
+/*
+ * A client psk_use_session callback that hands back a single shared
+ * SSL_SESSION on every call (up-ref'd, as the API permits) so we can check
+ * that connection-local sid_ctx provenance is not written into it.
+ */
+static SSL_SESSION *shared_psk_sess = NULL;
+
+static int shared_psk_use_cb(SSL *ssl, const EVP_MD *md, const unsigned char **id,
+    size_t *idlen, SSL_SESSION **sess)
+{
+    (void)ssl;
+    (void)md;
+    if (shared_psk_sess == NULL || !SSL_SESSION_up_ref(shared_psk_sess))
+        return 0;
+    *sess = shared_psk_sess;
+    *id = ext_psk_id;
+    *idlen = sizeof(ext_psk_id);
+    return 1;
+}
+
+static int enable_shared_psk(SSL *cssl, SSL *sssl)
+{
+    SSL_set_psk_use_session_callback(cssl, shared_psk_use_cb);
+    SSL_set_psk_find_session_callback(sssl, ext_psk_find_cb);
+    return 1;
+}
+
 /*
  * RFC 5077 3.1: The server sends an empty SessionTicket extension to indicate
  * that it will send a new session ticket using the NewSessionTicket handshake
@@ -652,8 +773,530 @@ static int test_tls13_ticket_no_decrypt(void)
     return test;
 }

+/*
+ * TLS 1.3 0-RTT early_data accepted
+ *
+ * Complements the suppression/rejection tests above: with a fresh resumption
+ * ticket the client advertises both pre_shared_key and early_data, the server
+ * accepts 0-RTT.
+ */
+static int test_tls13_ticket_early_data_accepted(void)
+{
+    const unsigned char m[] = "message";
+    unsigned char buf[256];
+    SSL_CTX *c = NULL, *s = NULL;
+    struct tls13_channel initial = { .c.ssl = NULL, .s.ssl = NULL };
+    struct tls13_channel resumed = { .c.ssl = NULL, .s.ssl = NULL };
+    SSL_SESSION *sess = NULL;
+    size_t w = 0, r = 0;
+    int test;
+
+    test = TEST_true(create_ssl_ctx_pair(NULL, TLS_server_method(), TLS_client_method(),
+               TLS1_3_VERSION, TLS1_3_VERSION, &s, &c, cert, pkey))
+        && TEST_true(set_ctx_callbacks(c, s))
+        && TEST_true(ticket_enable(s))
+        && TEST_true(ticket_enable(c))
+        && TEST_true(SSL_CTX_set_max_early_data(s, SSL3_RT_MAX_PLAIN_LENGTH))
+        && TEST_true(SSL_CTX_set_options(s, SSL_OP_NO_ANTI_REPLAY) != 0)
+        && TEST_true(tls_channel_init(c, s, &initial))
+        && TEST_true(create_ssl_connection(initial.s.ssl, initial.c.ssl, 0))
+        && TEST_true(tls_shutdown(&initial))
+        && TEST_uint_eq(initial.c.stats.nst_msgs, 2)
+        && TEST_uint_eq(initial.s.stats.nst_msgs, 2)
+        && TEST_uint_eq(initial.c.stats.tickets, 2)
+        && TEST_uint_eq(initial.s.stats.tickets, 2)
+        && TEST_uint_eq(initial.c.stats.ch_has_psk, 0)
+        && TEST_uint_eq(initial.s.stats.ch_has_psk, 0)
+        && TEST_uint_eq(initial.c.stats.ch_has_early_data, 0)
+        && TEST_uint_eq(initial.s.stats.ch_has_early_data, 0)
+        && TEST_uint_eq(initial.c.stats.ch_has_psk_kex_modes, 1)
+        && TEST_uint_eq(initial.s.stats.ch_has_psk_kex_modes, 1)
+        && TEST_ptr(sess = SSL_get1_session(initial.c.ssl))
+        && TEST_true(tls_channel_init(c, s, &resumed))
+        && TEST_true(SSL_set_session(resumed.c.ssl, sess))
+        && TEST_true(SSL_write_early_data(resumed.c.ssl, m, sizeof(m), &w))
+        && TEST_size_t_eq(w, sizeof(m))
+        && TEST_int_eq(SSL_read_early_data(resumed.s.ssl, buf, sizeof(buf), &r),
+            SSL_READ_EARLY_DATA_SUCCESS)
+        && TEST_mem_eq(buf, r, m, sizeof(m))
+        && TEST_int_gt(SSL_connect(resumed.c.ssl), 0)
+        && TEST_int_eq(SSL_read_early_data(resumed.s.ssl, buf, sizeof(buf), &r),
+            SSL_READ_EARLY_DATA_FINISH)
+        && TEST_size_t_eq(r, 0)
+        && TEST_int_eq(SSL_get_early_data_status(resumed.s.ssl), SSL_EARLY_DATA_ACCEPTED)
+        && TEST_true(create_ssl_connection(resumed.s.ssl, resumed.c.ssl, 0))
+        && TEST_int_eq(SSL_get_early_data_status(resumed.c.ssl), SSL_EARLY_DATA_ACCEPTED)
+        && TEST_true(SSL_session_reused(resumed.c.ssl))
+        && TEST_uint_eq(resumed.c.stats.nst_msgs, 1)
+        && TEST_uint_eq(resumed.s.stats.nst_msgs, 1)
+        && TEST_uint_eq(resumed.c.stats.tickets, 1)
+        && TEST_uint_eq(resumed.s.stats.tickets, 1)
+        && TEST_uint_eq(resumed.c.stats.ch_has_psk, 1)
+        && TEST_uint_eq(resumed.s.stats.ch_has_psk, 1)
+        && TEST_uint_eq(resumed.c.stats.ch_has_early_data, 1)
+        && TEST_uint_eq(resumed.s.stats.ch_has_early_data, 1)
+        && TEST_uint_eq(resumed.c.stats.ch_has_psk_kex_modes, 1)
+        && TEST_uint_eq(resumed.s.stats.ch_has_psk_kex_modes, 1)
+        && TEST_uint_eq(resumed.s.stats.ee_has_early_data, 1)
+        && TEST_uint_eq(resumed.c.stats.ee_has_early_data, 1);
+
+    SSL_SESSION_free(sess);
+    tls_channel_fini(&initial);
+    tls_channel_fini(&resumed);
+    SSL_CTX_free(c);
+    SSL_CTX_free(s);
+    return test;
+}
+
+enum endpoint_state {
+    ENDPOINT_WRITE_EARLY_DATA,
+    ENDPOINT_READ_EARLY_DATA,
+    ENDPOINT_HANDSHAKE,
+    ENDPOINT_READ_APP_DATA,
+    ENDPOINT_DONE,
+    ENDPOINT_ERROR
+};
+
+/* Retry logic switch extracted from s_client. */
+static int is_retryable(SSL *ssl, int ret)
+{
+    switch (SSL_get_error(ssl, ret)) {
+    case SSL_ERROR_WANT_WRITE:
+    case SSL_ERROR_WANT_ASYNC:
+    case SSL_ERROR_WANT_READ:
+        return 1;
+    default:
+        return 0;
+    }
+}
+
+/*
+ * The client follows the s_client retry loop; the server skips early data and
+ * completes the handshake.
+ *
+ * SSL_write_early_data() states the call behaves like SSL_write_ex(): if it
+ * fails, the caller must consult SSL_get_error() and, while it reports a
+ * retryable WANT_READ/WANT_WRITE condition, keep calling SSL_write_early_data()
+ * with the same arguments until it succeeds. This helper encodes that decision.
+ */
+static int tls_early_data_retry(struct tls13_channel *x)
+{
+    const unsigned char m[] = "message";
+    unsigned char buf[256];
+    enum endpoint_state c = ENDPOINT_WRITE_EARLY_DATA;
+    enum endpoint_state s = ENDPOINT_READ_EARLY_DATA;
+    size_t w = SIZE_MAX, r = SIZE_MAX;
+
+    for (int i = 0; i < 100 && (c != ENDPOINT_DONE || s != ENDPOINT_DONE); i++) {
+        if (c == ENDPOINT_WRITE_EARLY_DATA) {
+            if (SSL_write_early_data(x->c.ssl, m, sizeof(m), &w) > 0)
+                c = ENDPOINT_DONE;
+            else if (!is_retryable(x->c.ssl, 0))
+                c = ENDPOINT_ERROR;
+        }
+        if (s == ENDPOINT_READ_EARLY_DATA) {
+            switch (SSL_read_early_data(x->s.ssl, buf, sizeof(buf), &r)) {
+            case SSL_READ_EARLY_DATA_FINISH:
+                s = ENDPOINT_HANDSHAKE;
+                break;
+            default:
+                s = ENDPOINT_ERROR;
+            }
+        }
+        if (s == ENDPOINT_HANDSHAKE) {
+            if (SSL_is_init_finished(x->s.ssl))
+                s = ENDPOINT_DONE;
+            else if (SSL_accept(x->s.ssl) <= 0 && !is_retryable(x->s.ssl, 0))
+                s = ENDPOINT_ERROR;
+        }
+        if (c == ENDPOINT_ERROR || s == ENDPOINT_ERROR)
+            break;
+    }
+
+    return TEST_int_eq(c, ENDPOINT_DONE)
+        && TEST_int_eq(s, ENDPOINT_DONE)
+        && TEST_size_t_eq(w, sizeof(m))
+        && TEST_size_t_eq(r, 0);
+}
+
+/*
+ * TLS 1.3 Client-side Ticket Age Mismatch 0-RTT Rejection (API retry test)
+ *
+ * This test exercises the case where the client does not send a PSK due to a
+ * ticket age mismatch, and verifies that the client suppresses the early_data
+ * as a result.
+ *
+ * RFC 9846 4.3.10: When a PSK is used and early data is allowed for that PSK,
+ * the client can send Application Data in its first flight of messages. If the
+ * client opts to do so, it MUST supply both the "pre_shared_key" and
+ * "early_data" extensions. The PSK used to encrypt the early data MUST be the
+ * first PSK listed in the client's "pre_shared_key" extension.
+ *
+ * RFC 9846 4.3.11.1: Clients MUST NOT attempt to use tickets which have ages
+ * greater than the "ticket_lifetime" value which was provided with the ticket.
+ */
+static int test_tls13_ticket_client_age_mismatch_reject_early_data_retry(void)
+{
+    SSL_CTX *c = NULL, *s = NULL;
+    struct tls13_channel initial = { .c.ssl = NULL, .s.ssl = NULL };
+    struct tls13_channel resumed = { .c.ssl = NULL, .s.ssl = NULL };
+    SSL_SESSION *sess = NULL;
+    int test;
+
+    test = TEST_true(create_ssl_ctx_pair(NULL, TLS_server_method(), TLS_client_method(),
+               TLS1_3_VERSION, TLS1_3_VERSION, &s, &c, cert, pkey))
+        && TEST_true(set_ctx_callbacks(c, s))
+        && TEST_true(ticket_enable(s))
+        && TEST_true(ticket_enable(c))
+        && TEST_true(SSL_CTX_set_max_early_data(s, SSL3_RT_MAX_PLAIN_LENGTH))
+        && TEST_true(SSL_CTX_set_timeout(s, 1) > 0)
+        && TEST_true(tls_channel_init(c, s, &initial))
+        && TEST_true(create_ssl_connection(initial.s.ssl, initial.c.ssl, 0))
+        && TEST_true(tls_shutdown(&initial))
+        && TEST_uint_eq(initial.c.stats.nst_msgs, 2)
+        && TEST_uint_eq(initial.s.stats.nst_msgs, 2)
+        && TEST_uint_eq(initial.c.stats.tickets, 2)
+        && TEST_uint_eq(initial.s.stats.tickets, 2)
+        && TEST_uint_eq(initial.c.stats.ch_has_psk, 0)
+        && TEST_uint_eq(initial.s.stats.ch_has_psk, 0)
+        && TEST_uint_eq(initial.c.stats.ch_has_early_data, 0)
+        && TEST_uint_eq(initial.s.stats.ch_has_early_data, 0)
+        && TEST_uint_eq(initial.c.stats.ch_has_psk_kex_modes, 1)
+        && TEST_uint_eq(initial.s.stats.ch_has_psk_kex_modes, 1)
+        && TEST_ptr(sess = SSL_get1_session(initial.c.ssl))
+        && TEST_int_gt((int)SSL_SESSION_set_time_ex(sess, time(NULL) - 10), 0)
+        && TEST_true(tls_channel_init(c, s, &resumed))
+        && TEST_true(SSL_set_session(resumed.c.ssl, sess))
+        && TEST_true(tls_early_data_retry(&resumed))
+        && TEST_int_eq(SSL_get_early_data_status(resumed.c.ssl), SSL_EARLY_DATA_REJECTED)
+        && TEST_false(SSL_session_reused(resumed.c.ssl))
+        && TEST_uint_eq(resumed.c.stats.nst_msgs, 0)
+        && TEST_uint_eq(resumed.s.stats.nst_msgs, 2)
+        && TEST_uint_eq(resumed.c.stats.tickets, 0)
+        && TEST_uint_eq(resumed.s.stats.tickets, 2)
+        && TEST_uint_eq(resumed.c.stats.ch_has_psk, 0)
+        && TEST_uint_eq(resumed.s.stats.ch_has_psk, 0)
+        && TEST_uint_eq(resumed.c.stats.ch_has_early_data, 0)
+        && TEST_uint_eq(resumed.s.stats.ch_has_early_data, 0)
+        && TEST_uint_eq(resumed.c.stats.ch_has_psk_kex_modes, 1)
+        && TEST_uint_eq(resumed.s.stats.ch_has_psk_kex_modes, 1)
+        && TEST_uint_eq(resumed.s.stats.ee_has_early_data, 0)
+        && TEST_uint_eq(resumed.c.stats.ee_has_early_data, 0);
+
+    SSL_SESSION_free(sess);
+    tls_channel_fini(&initial);
+    tls_channel_fini(&resumed);
+    SSL_CTX_free(c);
+    SSL_CTX_free(s);
+    return test;
+}
+
+/*
+ * TLS 1.3 Server-side Ticket Age Mismatch 0-RTT Rejection
+ *
+ * Exercises the server-side ticket age validation. The client considers the
+ * ticket fresh and proceeds with PSK + 0-RTT, but the transmitted
+ * obfuscated_ticket_age indicates a ticket roughly 10s old. Since the apparent
+ * ticket age exceeds TICKET_AGE_ALLOWANCE, the server rejects early data.
+ *
+ * RFC 9846 4.3.10: For PSKs provisioned via NewSessionTicket, a server MUST
+ * validate that the ticket age for the selected PSK identity is within a small
+ * tolerance of the time since the ticket was issued. If it is not, the server
+ * SHOULD proceed with the handshake but reject 0-RTT.
+ */
+static int test_tls13_ticket_server_age_mismatch_reject_early_data(void)
+{
+    const unsigned char m[] = "message";
+    unsigned char buf[256];
+    SSL_CTX *c = NULL, *s = NULL;
+    struct tls13_channel initial = { .c.ssl = NULL, .s.ssl = NULL };
+    struct tls13_channel resumed = { .c.ssl = NULL, .s.ssl = NULL };
+    SSL_SESSION *sess = NULL;
+    size_t w = 0, r = 0;
+    int test;
+
+    test = TEST_true(create_ssl_ctx_pair(NULL, TLS_server_method(), TLS_client_method(),
+               TLS1_3_VERSION, TLS1_3_VERSION, &s, &c, cert, pkey))
+        && TEST_true(set_ctx_callbacks(c, s))
+        && TEST_true(ticket_enable(s))
+        && TEST_true(ticket_enable(c))
+        && TEST_true(SSL_CTX_set_max_early_data(s, SSL3_RT_MAX_PLAIN_LENGTH))
+        && TEST_true(SSL_CTX_set_options(s, SSL_OP_NO_ANTI_REPLAY) != 0)
+        && TEST_true(tls_channel_init(c, s, &initial))
+        && TEST_true(create_ssl_connection(initial.s.ssl, initial.c.ssl, 0))
+        && TEST_true(tls_shutdown(&initial))
+        && TEST_uint_eq(initial.c.stats.nst_msgs, 2)
+        && TEST_uint_eq(initial.s.stats.nst_msgs, 2)
+        && TEST_uint_eq(initial.c.stats.tickets, 2)
+        && TEST_uint_eq(initial.s.stats.tickets, 2)
+        && TEST_uint_eq(initial.c.stats.ch_has_psk, 0)
+        && TEST_uint_eq(initial.s.stats.ch_has_psk, 0)
+        && TEST_uint_eq(initial.c.stats.ch_has_early_data, 0)
+        && TEST_uint_eq(initial.s.stats.ch_has_early_data, 0)
+        && TEST_uint_eq(initial.c.stats.ch_has_psk_kex_modes, 1)
+        && TEST_uint_eq(initial.s.stats.ch_has_psk_kex_modes, 1)
+        && TEST_ptr(sess = SSL_get1_session(initial.c.ssl))
+        && TEST_int_gt((int)SSL_SESSION_set_time_ex(sess, time(NULL) - 10), 0)
+        && TEST_true(tls_channel_init(c, s, &resumed))
+        && TEST_true(SSL_set_session(resumed.c.ssl, sess))
+        && TEST_true(SSL_write_early_data(resumed.c.ssl, m, sizeof(m), &w))
+        && TEST_size_t_eq(w, sizeof(m))
+        && TEST_int_eq(SSL_read_early_data(resumed.s.ssl, buf, sizeof(buf), &r),
+            SSL_READ_EARLY_DATA_FINISH)
+        && TEST_size_t_eq(r, 0)
+        && TEST_true(create_ssl_connection(resumed.s.ssl, resumed.c.ssl, 0))
+        && TEST_int_eq(SSL_get_early_data_status(resumed.c.ssl), SSL_EARLY_DATA_REJECTED)
+        && TEST_true(SSL_session_reused(resumed.c.ssl))
+        && TEST_uint_eq(resumed.c.stats.nst_msgs, 1)
+        && TEST_uint_eq(resumed.s.stats.nst_msgs, 1)
+        && TEST_uint_eq(resumed.c.stats.tickets, 1)
+        && TEST_uint_eq(resumed.s.stats.tickets, 1)
+        && TEST_uint_eq(resumed.c.stats.ch_has_psk, 1)
+        && TEST_uint_eq(resumed.s.stats.ch_has_psk, 1)
+        && TEST_uint_eq(resumed.c.stats.ch_has_early_data, 1)
+        && TEST_uint_eq(resumed.s.stats.ch_has_early_data, 1)
+        && TEST_uint_eq(resumed.c.stats.ch_has_psk_kex_modes, 1)
+        && TEST_uint_eq(resumed.s.stats.ch_has_psk_kex_modes, 1)
+        && TEST_uint_eq(resumed.s.stats.ee_has_early_data, 0)
+        && TEST_uint_eq(resumed.c.stats.ee_has_early_data, 0);
+
+    SSL_SESSION_free(sess);
+    tls_channel_fini(&initial);
+    tls_channel_fini(&resumed);
+    SSL_CTX_free(c);
+    SSL_CTX_free(s);
+    return test;
+}
+
+/*
+ * TLS 1.3 Client-side Ticket Age Mismatch 0-RTT Rejection (outer test)
+ */
+static int test_tls13_ticket_client_age_mismatch_reject_early_data_outer(void)
+{
+    const unsigned char m[] = "message";
+    unsigned char buf[256];
+    SSL_CTX *c = NULL, *s = NULL;
+    struct tls13_channel initial = { .c.ssl = NULL, .s.ssl = NULL };
+    struct tls13_channel resumed = { .c.ssl = NULL, .s.ssl = NULL };
+    SSL_SESSION *sess = NULL;
+    size_t r = 0, w = 0;
+    int test;
+
+    test = TEST_true(create_ssl_ctx_pair(NULL, TLS_server_method(), TLS_client_method(),
+               TLS1_3_VERSION, TLS1_3_VERSION, &s, &c, cert, pkey))
+        && TEST_true(set_ctx_callbacks(c, s))
+        && TEST_true(ticket_enable(s))
+        && TEST_true(ticket_enable(c))
+        && TEST_true(SSL_CTX_set_max_early_data(s, SSL3_RT_MAX_PLAIN_LENGTH))
+        && TEST_true(SSL_CTX_set_timeout(s, 1) > 0)
+        && TEST_true(tls_channel_init(c, s, &initial))
+        && TEST_true(create_ssl_connection(initial.s.ssl, initial.c.ssl, 0))
+        && TEST_true(tls_shutdown(&initial))
+        && TEST_uint_eq(initial.c.stats.nst_msgs, 2)
+        && TEST_uint_eq(initial.s.stats.nst_msgs, 2)
+        && TEST_uint_eq(initial.c.stats.tickets, 2)
+        && TEST_uint_eq(initial.s.stats.tickets, 2)
+        && TEST_uint_eq(initial.c.stats.ch_has_psk, 0)
+        && TEST_uint_eq(initial.s.stats.ch_has_psk, 0)
+        && TEST_uint_eq(initial.c.stats.ch_has_early_data, 0)
+        && TEST_uint_eq(initial.s.stats.ch_has_early_data, 0)
+        && TEST_uint_eq(initial.c.stats.ch_has_psk_kex_modes, 1)
+        && TEST_uint_eq(initial.s.stats.ch_has_psk_kex_modes, 1)
+        && TEST_ptr(sess = SSL_get1_session(initial.c.ssl))
+        && TEST_int_gt((int)SSL_SESSION_set_time_ex(sess, time(NULL) - 10), 0)
+        && TEST_true(tls_channel_init(c, s, &resumed))
+        && TEST_true(SSL_set_session(resumed.c.ssl, sess))
+        && TEST_true(tls_early_data_retry(&resumed))
+        && TEST_int_eq(SSL_get_early_data_status(resumed.c.ssl), SSL_EARLY_DATA_REJECTED)
+        && TEST_false(SSL_session_reused(resumed.c.ssl))
+        && TEST_uint_eq(resumed.c.stats.nst_msgs, 0)
+        && TEST_uint_eq(resumed.s.stats.nst_msgs, 2)
+        && TEST_uint_eq(resumed.c.stats.tickets, 0)
+        && TEST_uint_eq(resumed.s.stats.tickets, 2)
+        && TEST_uint_eq(resumed.c.stats.ch_has_psk, 0)
+        && TEST_uint_eq(resumed.s.stats.ch_has_psk, 0)
+        && TEST_uint_eq(resumed.c.stats.ch_has_early_data, 0)
+        && TEST_uint_eq(resumed.s.stats.ch_has_early_data, 0)
+        && TEST_uint_eq(resumed.c.stats.ch_has_psk_kex_modes, 1)
+        && TEST_uint_eq(resumed.s.stats.ch_has_psk_kex_modes, 1)
+        && TEST_uint_eq(resumed.s.stats.ee_has_early_data, 0)
+        && TEST_uint_eq(resumed.c.stats.ee_has_early_data, 0)
+        /*
+         * While the application is still in the early data write sequence,
+         * further suppressed SSL_write_early_data() calls keep succeeding.
+         */
+        && TEST_size_t_eq((w = SIZE_MAX), SIZE_MAX)
+        && TEST_true(SSL_write_early_data(resumed.c.ssl, m, sizeof(m), &w))
+        && TEST_size_t_eq(w, sizeof(m))
+        /* Ordinary application I/O ends that sequence. */
+        && TEST_size_t_eq((w = SIZE_MAX), SIZE_MAX)
+        && TEST_int_gt(SSL_write_ex(resumed.c.ssl, m, sizeof(m), &w), 0)
+        && TEST_size_t_eq(w, sizeof(m))
+        && TEST_size_t_eq((r = SIZE_MAX), SIZE_MAX)
+        && TEST_int_gt(SSL_read_ex(resumed.s.ssl, buf, sizeof(buf), &r), 0)
+        && TEST_size_t_eq(r, sizeof(m))
+        && TEST_mem_eq(buf, r, m, sizeof(m))
+        && TEST_size_t_eq((w = SIZE_MAX), SIZE_MAX)
+        && TEST_int_gt(SSL_write_ex(resumed.s.ssl, m, sizeof(m), &w), 0)
+        && TEST_size_t_eq(w, sizeof(m))
+        && TEST_size_t_eq((r = SIZE_MAX), SIZE_MAX)
+        && TEST_int_gt(SSL_read_ex(resumed.c.ssl, buf, sizeof(buf), &r), 0)
+        && TEST_size_t_eq(r, sizeof(m))
+        && TEST_mem_eq(buf, r, m, sizeof(m))
+        /*
+         * Having left the early data write sequence, a further
+         * SSL_write_early_data() reports the normal error rather than masking
+         * the application's state-machine mistake as success.
+         */
+        && TEST_size_t_eq((w = SIZE_MAX), SIZE_MAX)
+        && TEST_false(SSL_write_early_data(resumed.c.ssl, m, sizeof(m), &w))
+        && TEST_int_eq(SSL_get_error(resumed.c.ssl, 0), SSL_ERROR_SSL)
+        && TEST_int_eq(ERR_GET_REASON(ERR_get_error()),
+            ERR_R_SHOULD_NOT_HAVE_BEEN_CALLED)
+        && TEST_size_t_eq(w, SIZE_MAX);
+
+    SSL_SESSION_free(sess);
+    tls_channel_fini(&initial);
+    tls_channel_fini(&resumed);
+    SSL_CTX_free(c);
+    SSL_CTX_free(s);
+    return test;
+}
+
 OPT_TEST_DECLARE_USAGE("\n")

+/*
+ * TLS 1.3 0-RTT keyed off an external PSK past a retired resumption ticket.
+ *
+ * The client holds a 0-RTT-capable resumption ticket that has aged past its
+ * lifetime, so tls_construct_ctos_psk() does not offer it; an external PSK
+ * from the psk_use_session callback therefore occupies identity 0 and keys the
+ * early data. The keying sites must follow that slot-0 PSK, not the retired
+ * ticket (whose max_early_data is still non-zero) -- otherwise client and
+ * server derive different CLIENT_EARLY_TRAFFIC_SECRET values and the server
+ * fails with a bad record MAC. Regression test for the mixed aged-ticket +
+ * external-PSK 0-RTT keying bug.
+ */
+static int test_tls13_aged_ticket_external_psk_early_data(void)
+{
+    const unsigned char m[] = "message";
+    unsigned char buf[256];
+    SSL_CTX *c = NULL, *s = NULL;
+    struct tls13_channel initial = { .c.ssl = NULL, .s.ssl = NULL };
+    struct tls13_channel resumed = { .c.ssl = NULL, .s.ssl = NULL };
+    SSL_SESSION *sess = NULL;
+    size_t w = 0, r = 0;
+    unsigned char ceed[32], seed[32];
+    int test;
+
+    test = TEST_true(create_ssl_ctx_pair(NULL, TLS_server_method(), TLS_client_method(),
+               TLS1_3_VERSION, TLS1_3_VERSION, &s, &c, cert, pkey))
+        && TEST_true(set_ctx_callbacks(c, s))
+        && TEST_true(ticket_enable(s))
+        && TEST_true(ticket_enable(c))
+        /*
+         * Pin the ciphersuite to the external PSK's committed cipher so the
+         * negotiated cipher matches it; 0-RTT on an external PSK requires that
+         * (RFC 9846 4.3.10). Orthogonal to the bug under test, which is about
+         * which PSK's secret keys the early data, not the cipher.
+         */
+        && TEST_true(SSL_CTX_set_ciphersuites(s, "TLS_AES_128_GCM_SHA256"))
+        && TEST_true(SSL_CTX_set_ciphersuites(c, "TLS_AES_128_GCM_SHA256"))
+        && TEST_true(SSL_CTX_set_max_early_data(s, SSL3_RT_MAX_PLAIN_LENGTH))
+        && TEST_true(SSL_CTX_set_options(s, SSL_OP_NO_ANTI_REPLAY) != 0)
+        /* Short ticket lifetime so the backdated ticket ages out client-side
+         * and is not offered, leaving the external PSK at slot 0. */
+        && TEST_true(SSL_CTX_set_timeout(s, 1) > 0)
+        && TEST_true(tls_channel_init(c, s, &initial))
+        && TEST_true(create_ssl_connection(initial.s.ssl, initial.c.ssl, 0))
+        && TEST_true(tls_shutdown(&initial))
+        && TEST_ptr(sess = SSL_get1_session(initial.c.ssl))
+        /* Retire the (0-RTT-capable) ticket so it is not offered at slot 0. */
+        && TEST_int_gt((int)SSL_SESSION_set_time_ex(sess, time(NULL) - 10), 0)
+        && TEST_true(tls_channel_init(c, s, &resumed))
+        && TEST_true(SSL_set_session(resumed.c.ssl, sess))
+        && TEST_true(enable_external_psk(resumed.c.ssl, resumed.s.ssl))
+        && TEST_true(SSL_write_early_data(resumed.c.ssl, m, sizeof(m), &w))
+        && TEST_size_t_eq(w, sizeof(m))
+        && TEST_int_eq(SSL_read_early_data(resumed.s.ssl, buf, sizeof(buf), &r),
+            SSL_READ_EARLY_DATA_SUCCESS)
+        && TEST_mem_eq(buf, r, m, sizeof(m))
+        && TEST_int_gt(SSL_connect(resumed.c.ssl), 0)
+        && TEST_int_eq(SSL_read_early_data(resumed.s.ssl, buf, sizeof(buf), &r),
+            SSL_READ_EARLY_DATA_FINISH)
+        && TEST_size_t_eq(r, 0)
+        && TEST_int_eq(SSL_get_early_data_status(resumed.s.ssl),
+            SSL_EARLY_DATA_ACCEPTED)
+        && TEST_true(create_ssl_connection(resumed.s.ssl, resumed.c.ssl, 0))
+        && TEST_int_eq(SSL_get_early_data_status(resumed.c.ssl),
+            SSL_EARLY_DATA_ACCEPTED)
+        /*
+         * The early exporter secret must match on both ends -- an independent
+         * check (separate from the decrypted early data) that both sides keyed
+         * 0-RTT from the same slot-0 PSK.
+         */
+        && TEST_int_eq(SSL_export_keying_material_early(resumed.c.ssl, ceed,
+                           sizeof(ceed), "label", 5, (const unsigned char *)"ctx", 3),
+            1)
+        && TEST_int_eq(SSL_export_keying_material_early(resumed.s.ssl, seed,
+                           sizeof(seed), "label", 5, (const unsigned char *)"ctx", 3),
+            1)
+        && TEST_mem_eq(ceed, sizeof(ceed), seed, sizeof(seed))
+        /* The external PSK (slot 0) keyed 0-RTT, not the retired ticket. */
+        && TEST_uint_eq(resumed.c.stats.ch_has_psk, 1)
+        && TEST_uint_eq(resumed.s.stats.ch_has_psk, 1)
+        && TEST_uint_eq(resumed.c.stats.ch_has_early_data, 1)
+        && TEST_uint_eq(resumed.s.stats.ch_has_early_data, 1)
+        && TEST_uint_eq(resumed.s.stats.ee_has_early_data, 1)
+        && TEST_uint_eq(resumed.c.stats.ee_has_early_data, 1);
+
+    SSL_SESSION_free(sess);
+    tls_channel_fini(&initial);
+    tls_channel_fini(&resumed);
+    SSL_CTX_free(c);
+    SSL_CTX_free(s);
+    return test;
+}
+
+/*
+ * TLS 1.3 external-PSK sid_ctx must not mutate a callback-shared session.
+ *
+ * The psk_use_session callback is entitled to return the same SSL_SESSION on
+ * every call (up-ref'd). When the client resumes via that PSK it stamps its
+ * connection-local sid_ctx onto the session; it must do so on a private
+ * duplicate, never on the shared object, or the sid_ctx bleeds across
+ * connections (and races under concurrency). After a handshake with a
+ * non-empty client sid_ctx, the shared session's sid_ctx must be untouched.
+ */
+static int test_tls13_external_psk_sid_ctx_not_shared(void)
+{
+    SSL_CTX *c = NULL, *s = NULL;
+    struct tls13_channel conn = { .c.ssl = NULL, .s.ssl = NULL };
+    static const unsigned char sidctx[] = { 'S', 'I', 'D' };
+    int test;
+
+    test = TEST_true(create_ssl_ctx_pair(NULL, TLS_server_method(), TLS_client_method(),
+               TLS1_3_VERSION, TLS1_3_VERSION, &s, &c, cert, pkey))
+        && TEST_true(set_ctx_callbacks(c, s))
+        && TEST_true(SSL_CTX_set_ciphersuites(s, "TLS_AES_128_GCM_SHA256"))
+        && TEST_true(SSL_CTX_set_ciphersuites(c, "TLS_AES_128_GCM_SHA256"))
+        && TEST_true(tls_channel_init(c, s, &conn))
+        && TEST_ptr(shared_psk_sess = ext_psk_session(conn.c.ssl))
+        && TEST_true(SSL_set_session_id_context(conn.c.ssl, sidctx, sizeof(sidctx)))
+        && TEST_true(enable_shared_psk(conn.c.ssl, conn.s.ssl))
+        && TEST_true(create_ssl_connection(conn.s.ssl, conn.c.ssl, 0))
+        && TEST_true(SSL_session_reused(conn.c.ssl))
+        /* Our sid_ctx must not have been written into the shared session. */
+        && TEST_size_t_eq(shared_psk_sess->sid_ctx_length, 0);
+
+    SSL_SESSION_free(shared_psk_sess);
+    shared_psk_sess = NULL;
+    tls_channel_fini(&conn);
+    SSL_CTX_free(c);
+    SSL_CTX_free(s);
+    return test;
+}
+
 int setup_tests(void)
 {
     if (!test_skip_common_options()) {
@@ -674,6 +1317,12 @@ int setup_tests(void)
     ADD_TEST(test_tls13_ticket_resumed_set_num_tickets_zero);
     ADD_TEST(test_tls13_ticket_disable_server);
     ADD_TEST(test_tls13_ticket_no_decrypt);
+    ADD_TEST(test_tls13_ticket_early_data_accepted);
+    ADD_TEST(test_tls13_ticket_client_age_mismatch_reject_early_data_retry);
+    ADD_TEST(test_tls13_ticket_client_age_mismatch_reject_early_data_outer);
+    ADD_TEST(test_tls13_ticket_server_age_mismatch_reject_early_data);
+    ADD_TEST(test_tls13_aged_ticket_external_psk_early_data);
+    ADD_TEST(test_tls13_external_psk_sid_ctx_not_shared);

     return 1;
 }