Commit e1466fa8d4 for openssl.org

commit e1466fa8d49831bfb0b9280d068ed433215673f3
Author: Ryan Hooper <ryanh@openssl.foundation>
Date:   Wed Sep 23 15:34:20 2026 -0400

    DTLS 1.3: Don't use a KeyUpdate's new keys before it is acknowledged

    Preserving a client's unacknowledged KeyUpdate in the retransmit queue
    across an unrelated exchange (a NewSessionTicket or CertificateRequest)
    was not enough on its own: ossl_statem_client13_write_transition()
    still moved on through the ticket's or request's own reply, using
    whatever keys were currently installed. Once tls13_update_key()
    installs the new epoch's keys as soon as the KeyUpdate is written, an
    unrelated ticket's ACK, or a post-handshake auth response to a
    CertificateRequest, went out under those new keys before the
    KeyUpdate confirming them was ever acknowledged, and the client's own
    SSL_key_update() would accept a second KeyUpdate in the meantime too.

    RFC9147 section 8 requires a KeyUpdate's new keys to be used for
    nothing else until it is acknowledged, and section 5.8.4 requires it
    to be acknowledged before another one is sent.

    Add dtls_has_unacked_key_update() and a deferred_key_update_state
    field on OSSL_STATEM. When the client would otherwise send a
    KeyUpdate-unrelated write (an ACK for a received ticket or KeyUpdate,
    or a CertificateRequest's response) while its own KeyUpdate is still
    outstanding, hold that write back and resume it once the KeyUpdate is
    acknowledged. Reject a second SSL_key_update() call under the same
    condition.

    Fixes: #32878
    Assisted-by: Claude:claude-sonnet-5
    Reviewed-by: Tomas Mraz <tomas@openssl.foundation>
    Reviewed-by: Matt Caswell <matt@openssl.foundation>
    Merge-date: Fri Nov 13 17:22:22 2026
    Merged-from: https://github.com/openssl/openssl/pull/32934

diff --git a/include/internal/statem.h b/include/internal/statem.h
index 027853eccd..1a2f5c85dc 100644
--- a/include/internal/statem.h
+++ b/include/internal/statem.h
@@ -121,6 +121,8 @@ struct ossl_statem_st {
     OSSL_HANDSHAKE_STATE deferred_ack_state;
     /* The handshake state before receiving an ACK */
     OSSL_HANDSHAKE_STATE pre_ack_hand_state;
+    /* The handshake state to resume once our own KeyUpdate is acknowledged */
+    OSSL_HANDSHAKE_STATE deferred_key_update_state;
     ERROR_STATE error_state;
     int in_init;
     int read_state_first_init;
diff --git a/ssl/d1_lib.c b/ssl/d1_lib.c
index 2e5fa9e27c..94f3fb61d9 100644
--- a/ssl/d1_lib.c
+++ b/ssl/d1_lib.c
@@ -344,6 +344,22 @@ int dtls_any_sent_messages_are_missing_acknowledge(SSL_CONNECTION *s)
     return 0;
 }

+int dtls_has_unacked_key_update(SSL_CONNECTION *s)
+{
+    pitem *item;
+    piterator iter = pqueue_iterator(&s->d1->sent_messages);
+
+    while ((item = pqueue_next(&iter)) != NULL) {
+        dtls_sent_msg *msg = (dtls_sent_msg *)item->data;
+
+        if (msg->msg_info.msg_type == SSL3_MT_KEY_UPDATE
+            && !ossl_list_record_number_is_empty(&msg->rec_nums))
+            return 1;
+    }
+
+    return 0;
+}
+
 void dtls1_free(SSL *ssl)
 {
     SSL_CONNECTION *s;
diff --git a/ssl/ssl_lib.c b/ssl/ssl_lib.c
index 77bc5d7251..0572bd0630 100644
--- a/ssl/ssl_lib.c
+++ b/ssl/ssl_lib.c
@@ -3214,6 +3214,15 @@ int SSL_key_update(SSL *s, int updatetype)
         return 0;
     }

+    /*
+     * RFC 9147 section 5.8.4: implementations MUST NOT send a KeyUpdate if
+     * an earlier one has not yet been acknowledged.
+     */
+    if (SSL_CONNECTION_IS_DTLS13(sc) && dtls_has_unacked_key_update(sc)) {
+        ERR_raise(ERR_LIB_SSL, SSL_R_REQUEST_PENDING);
+        return 0;
+    }
+
     ossl_statem_set_in_init(sc, 1);
     sc->key_update = updatetype;
     return 1;
diff --git a/ssl/ssl_local.h b/ssl/ssl_local.h
index e2ff48fd4a..f5f31e1384 100644
--- a/ssl/ssl_local.h
+++ b/ssl/ssl_local.h
@@ -2221,6 +2221,7 @@ typedef struct dtls_sent_msg_st {
 } dtls_sent_msg;

 int dtls_any_sent_messages_are_missing_acknowledge(SSL_CONNECTION *s);
+int dtls_has_unacked_key_update(SSL_CONNECTION *s);

 static ossl_inline int dtls_msg_needs_ack(int sentbyserver, unsigned char msgtype)
 {
diff --git a/ssl/statem/statem_clnt.c b/ssl/statem/statem_clnt.c
index 6563a37087..3bdb4c3c2a 100644
--- a/ssl/statem/statem_clnt.c
+++ b/ssl/statem/statem_clnt.c
@@ -431,6 +431,19 @@ static WRITE_TRAN ossl_statem_client13_write_transition(SSL_CONNECTION *s)
         return WRITE_TRAN_CONTINUE;
     }

+    /*
+     * Resume a write we held back because our own KeyUpdate was still
+     * unacknowledged (see the TLS_ST_CR_SESSION_TICKET/TLS_ST_CR_KEY_UPDATE/
+     * TLS_ST_CR_CERT_REQ cases below). Once that ACK has arrived, fall
+     * through into the switch below as if we were still at the deferred
+     * read state, so it can now proceed to construct that response.
+     */
+    if (st->deferred_key_update_state != TLS_ST_BEFORE
+        && !dtls_has_unacked_key_update(s)) {
+        st->hand_state = st->deferred_key_update_state;
+        st->deferred_key_update_state = TLS_ST_BEFORE;
+    }
+
     /*
      * Note: There are no cases for TLS_ST_BEFORE because we haven't negotiated
      * TLSv1.3 yet at that point. They are handled by
@@ -444,6 +457,16 @@ static WRITE_TRAN ossl_statem_client13_write_transition(SSL_CONNECTION *s)

     case TLS_ST_CR_CERT_REQ:
         if (s->post_handshake_auth == SSL_PHA_REQUESTED) {
+            if (SSL_CONNECTION_IS_DTLS13(s) && dtls_has_unacked_key_update(s)) {
+                /*
+                 * RFC 9147 section 8: our own KeyUpdate's new keys must not
+                 * be used for anything else until it is acknowledged. Hold
+                 * this response back rather than send it now.
+                 */
+                st->deferred_key_update_state = st->hand_state;
+                st->hand_state = TLS_ST_CW_KEY_UPDATE;
+                return WRITE_TRAN_FINISHED;
+            }
             if (do_compressed_cert(s))
                 st->hand_state = TLS_ST_CW_COMP_CERT;
             else
@@ -520,6 +543,16 @@ static WRITE_TRAN ossl_statem_client13_write_transition(SSL_CONNECTION *s)
     case TLS_ST_CR_KEY_UPDATE:
     case TLS_ST_CR_SESSION_TICKET:
         if (SSL_CONNECTION_IS_DTLS13(s)) {
+            if (dtls_has_unacked_key_update(s)) {
+                /*
+                 * RFC 9147 section 8: our own KeyUpdate's new keys must not
+                 * be used for anything else until it is acknowledged. Hold
+                 * this ACK back rather than send it now.
+                 */
+                st->deferred_key_update_state = st->hand_state;
+                st->hand_state = TLS_ST_CW_KEY_UPDATE;
+                return WRITE_TRAN_FINISHED;
+            }
             st->hand_state = TLS_ST_CW_ACK;
             return WRITE_TRAN_CONTINUE;
         }
diff --git a/test/dtls13_internal_test.c b/test/dtls13_internal_test.c
index 977697edb0..3366ad458d 100644
--- a/test/dtls13_internal_test.c
+++ b/test/dtls13_internal_test.c
@@ -3810,11 +3810,34 @@ static int test_dtls13_keyupdate_preserves_flight(int idx)
             goto end;

         /*
-         * Recovery by retransmitting the KeyUpdate is deliberately not
-         * asserted here. The server advanced its read epoch when it processed
-         * the original, so the retransmission arrives in the previous epoch
-         * and is dropped before it can be re-ACKed. That is issue #32891.
+         * Preserving the queued KeyUpdate and its timer is not enough on its
+         * own. RFC 9147 section 8 requires the KeyUpdate to be acknowledged
+         * before its new keys are used for anything else, and section 5.8.4
+         * requires it to be acknowledged before another KeyUpdate is sent --
+         * both restrictions must survive the unrelated ticket too.
          */
+        if (!TEST_int_eq(SSL_get_state(client), TLS_ST_CW_KEY_UPDATE))
+            goto end;
+
+        /* Nothing should have gone out at the new epoch yet. */
+        if (!TEST_size_t_eq(BIO_ctrl_pending(SSL_get_rbio(server)), 0))
+            goto end;
+
+        /* A second KeyUpdate must be refused while the first is unacked. */
+        if (!TEST_false(SSL_key_update(client, SSL_KEY_UPDATE_NOT_REQUESTED)))
+            goto end;
+
+        /*
+         * Application data must not leak out under the new epoch's keys
+         * either: an attempt to write should not succeed while the
+         * KeyUpdate is unacknowledged, and nothing new should reach the
+         * wire.
+         */
+        ret = SSL_write(client, "x", 1);
+        if (!TEST_int_le(ret, 0)
+            || !TEST_int_eq(SSL_get_error(client, ret), SSL_ERROR_WANT_READ)
+            || !TEST_size_t_eq(BIO_ctrl_pending(SSL_get_rbio(server)), 0))
+            goto end;
     }

     testresult = 1;
@@ -3861,20 +3884,19 @@ static int test_dtls13_cert_req_preserves_flight(void)
         || !TEST_size_t_eq(pqueue_size(&cc->d1->sent_messages), 0))
         goto end;

-    /* The client sends a KeyUpdate and waits for its ACK. */
+    /*
+     * The client sends a KeyUpdate and waits for its ACK. Lose the
+     * KeyUpdate itself, not just its ACK, so the server's own read epoch
+     * never advances
+     */
     if (!TEST_true(SSL_key_update(client, SSL_KEY_UPDATE_NOT_REQUESTED)))
         goto end;
     ret = SSL_do_handshake(client);
     if (!TEST_int_eq(SSL_get_error(client, ret), SSL_ERROR_WANT_READ)
         || !TEST_size_t_eq(pqueue_size(&cc->d1->sent_messages), 1))
         goto end;
-    ret = SSL_read(server, buf, 1);
-    if (!TEST_int_eq(SSL_get_error(server, ret), SSL_ERROR_WANT_READ))
-        goto end;
-
-    /* Lose the ACK, leaving the KeyUpdate outstanding on the client. */
     dropped = 0;
-    while (BIO_read(SSL_get_rbio(client), buf, sizeof(buf)) > 0)
+    while (BIO_read(SSL_get_rbio(server), buf, sizeof(buf)) > 0)
         dropped++;
     if (!TEST_int_gt(dropped, 0)
         || !TEST_size_t_eq(pqueue_size(&cc->d1->sent_messages), 1))
@@ -3888,14 +3910,15 @@ static int test_dtls13_cert_req_preserves_flight(void)
         goto end;

     /*
-     * The client answers with an empty Certificate and a Finished, so its
-     * queue must hold those two plus the still unacknowledged KeyUpdate.
-     * Note the timer cannot be compared against a pinned value here: sending
-     * the response re-arms it through dtls1_start_timer().
+     * The client does not answer with its empty Certificate and Finished
+     * yet: RFC 9147 section 8 means the still-unacknowledged KeyUpdate's new
+     * keys must not be used for anything else, so that response is held
+     * back rather than built and sent unacknowledged. Only the KeyUpdate is
+     * still outstanding.
      */
     ret = SSL_read(client, buf, 1);
     if (!TEST_int_eq(SSL_get_error(client, ret), SSL_ERROR_WANT_READ)
-        || !TEST_size_t_eq(pqueue_size(&cc->d1->sent_messages), 3)
+        || !TEST_size_t_eq(pqueue_size(&cc->d1->sent_messages), 1)
         || !TEST_false(ossl_time_is_zero(cc->d1->next_timeout)))
         goto end;
     found = 0;
@@ -3910,6 +3933,63 @@ static int test_dtls13_cert_req_preserves_flight(void)
     if (!TEST_int_eq(found, 1))
         goto end;

+    /*
+     * Not discarding the KeyUpdate is not enough on its own: RFC 9147
+     * section 8 requires it to be acknowledged before its new keys are
+     * used for anything else -- the PHA response is no exception, which is
+     * exactly why it was held back rather than sent -- and section 5.8.4
+     * requires it to be acknowledged before another KeyUpdate is sent.
+     */
+    if (!TEST_int_eq(SSL_get_state(client), TLS_ST_CW_KEY_UPDATE))
+        goto end;
+
+    /* Nothing has been transmitted for the deferred PHA response either. */
+    if (!TEST_size_t_eq(BIO_ctrl_pending(SSL_get_rbio(server)), 0))
+        goto end;
+
+    if (!TEST_false(SSL_key_update(client, SSL_KEY_UPDATE_NOT_REQUESTED)))
+        goto end;
+
+    /*
+     * Application data must not leak out under the new epoch's keys
+     * either: an attempt to write should not succeed while the KeyUpdate
+     * is unacknowledged, and nothing new should reach the wire.
+     */
+    ret = SSL_write(client, "x", 1);
+    if (!TEST_int_le(ret, 0)
+        || !TEST_int_eq(SSL_get_error(client, ret), SSL_ERROR_WANT_READ)
+        || !TEST_size_t_eq(BIO_ctrl_pending(SSL_get_rbio(server)), 0))
+        goto end;
+
+    /*
+     * Recover the lost KeyUpdate by retransmission. The server never saw
+     * the original, so its read epoch is still where it started and this
+     * authenticates normally, producing a genuine, fresh ACK.
+     */
+    cc->d1->next_timeout = ossl_time_subtract(ossl_time_now(), ossl_seconds2time(1));
+    if (!TEST_int_gt(DTLSv1_handle_timeout(client), 0))
+        goto end;
+    ret = SSL_read(server, buf, 1);
+    if (!TEST_int_eq(SSL_get_error(server, ret), SSL_ERROR_WANT_READ))
+        goto end;
+
+    /*
+     * Once that ACK is processed, the KeyUpdate is fully acknowledged, and
+     * the held-back PHA response resumes: it is finally built and sent.
+     */
+    ret = SSL_read(client, buf, 1);
+    if (!TEST_int_eq(SSL_get_error(client, ret), SSL_ERROR_WANT_READ)
+        || !TEST_size_t_eq(pqueue_size(&cc->d1->sent_messages), 2)
+        || !TEST_int_eq(SSL_get_state(client), TLS_ST_CW_FINISHED)
+        || !TEST_size_t_gt(BIO_ctrl_pending(SSL_get_rbio(server)), 0))
+        goto end;
+
+    /* Confirm it is a genuine, processable PHA response, not just bytes. */
+    ret = SSL_read(server, buf, 1);
+    if (!TEST_int_eq(SSL_get_error(server, ret), SSL_ERROR_WANT_READ)
+        || !TEST_int_eq(sc->post_handshake_auth, SSL_PHA_EXT_RECEIVED))
+        goto end;
+
     testresult = 1;
 end:
     SSL_free(server);
@@ -3920,29 +4000,18 @@ end:
 }

 /*
- * A post-handshake message's saved_retransmit_state.wrl (ssl_local.h) is a
- * raw OSSL_RECORD_LAYER* captured at send time, not a reference-counted
- * handle. dtls1_clear_sent_buffer() (d1_lib.c) frees it per removed entry
- * whenever the message type is FINISHED/SERVER_HELLO/KEY_UPDATE and the
- * connection's current write layer has moved on, with no check for whether
- * another still-queued entry holds the identical pointer.
- *
- * A post-handshake auth response (Certificate + Finished) and a same-epoch
- * KeyUpdate sent before either is ACKed do share it: both are written
- * before tls13_update_key() installs the next write epoch. Before the
- * dtls1_stop_timer_for_read_flight() fix, an unrelated NewSessionTicket
- * would have discarded the still-unacked PHA response outright, so the two
- * message types never got to coexist in the queue long enough for this to
- * matter. That fix is exactly what lets them survive together now.
+ * A post-handshake authentication response (Certificate + Finished) and a
+ * KeyUpdate sent at the same epoch, before either is acknowledged, are
+ * written under the identical set of keys. An unrelated NewSessionTicket
+ * exchange must not discard a still-unacknowledged flight, so both of these
+ * can end up queued together, unacknowledged, at the same time.
  *
- * idx 0: both are eventually ACKed -- each removal independently sees a
- * superseded write layer and each calls free() on the identical pointer: a
- * double free.
- * idx 1: only the PHA response is ACKed while the KeyUpdate remains
- * outstanding -- a second, unrelated ticket sweeps and frees the write
- * layer through the Finished entry, leaving the still-queued KeyUpdate
- * entry holding a pointer to that now-freed object: a use-after-free when
- * it is later retransmitted.
+ * idx 0: both are eventually acknowledged in a single exchange -- cleaning
+ * them up together must not release whatever they share more than once.
+ * idx 1: only the PHA response is acknowledged while the KeyUpdate remains
+ * outstanding, and a second, unrelated ticket triggers cleanup of the now-
+ * acknowledged response while the KeyUpdate stays queued -- the KeyUpdate
+ * must still retransmit correctly afterward.
  */
 static int test_dtls13_pha_keyupdate_shared_wrl(int idx)
 {
@@ -3992,7 +4061,7 @@ static int test_dtls13_pha_keyupdate_shared_wrl(int idx)

     /*
      * A fresh ticket, processed while the PHA response is still unacked,
-     * must preserve it (the dtls1_stop_timer_for_read_flight() fix).
+     * must not discard it.
      */
     if (!TEST_true(SSL_new_session_ticket(server))
         || !TEST_int_eq(SSL_do_handshake(server), 1))
@@ -4019,9 +4088,9 @@ static int test_dtls13_pha_keyupdate_shared_wrl(int idx)
         goto end;

     /*
-     * Now the application requests a KeyUpdate. It is written -- capturing
-     * cc's current write layer -- before tls13_update_key() installs the
-     * next epoch's.
+     * Now the application requests a KeyUpdate. It is sent under the
+     * current epoch's keys, before the connection moves on to the next
+     * epoch.
      */
     if (!TEST_true(SSL_key_update(client, SSL_KEY_UPDATE_NOT_REQUESTED)))
         goto end;
@@ -4031,8 +4100,9 @@ static int test_dtls13_pha_keyupdate_shared_wrl(int idx)
         goto end;

     /*
-     * Confirm the premise: Finished and KeyUpdate share the same saved
-     * write layer, and it is no longer cc's active one.
+     * Confirm the premise: Finished and KeyUpdate were sent under the
+     * identical epoch's keys, and the connection has since moved on to the
+     * next epoch.
      */
     iter = pqueue_iterator(&cc->d1->sent_messages);
     while ((item = pqueue_next(&iter)) != NULL) {
@@ -4048,7 +4118,7 @@ static int test_dtls13_pha_keyupdate_shared_wrl(int idx)
     /*
      * Read the replacement ACK for Certificate+Finished, pending since
      * before the KeyUpdate was sent. KeyUpdate is still outstanding, so
-     * this alone must not sweep the queue yet.
+     * this alone must not clear anything out yet.
      */
     ret = SSL_read(client, buf, 1);
     if (!TEST_int_eq(SSL_get_error(client, ret), SSL_ERROR_WANT_READ)
@@ -4059,10 +4129,8 @@ static int test_dtls13_pha_keyupdate_shared_wrl(int idx)
         /*
          * Let the server also process and ACK the KeyUpdate. Once the
          * client reads that ACK, every sent message is fully acknowledged
-         * and dtls1_clear_sent_buffer() sweeps Certificate, Finished and
-         * KeyUpdate in one pass -- Finished's and KeyUpdate's removal each
-         * independently see the write layer as superseded and each call
-         * free() on the identical pointer.
+         * at the same time, so Certificate, Finished and KeyUpdate are all
+         * cleaned up together in one pass.
          */
         ret = SSL_read(server, buf, 1);
         if (!TEST_int_eq(SSL_get_error(server, ret), SSL_ERROR_WANT_READ))
@@ -4084,28 +4152,26 @@ static int test_dtls13_pha_keyupdate_shared_wrl(int idx)
         /*
          * Do not let the server see the KeyUpdate yet. A second, distinct
          * ticket, processed while the KeyUpdate is still outstanding,
-         * triggers dtls1_stop_timer_for_read_flight() again: Certificate
-         * and Finished, now fully acked, are swept. Before the
-         * dtls1_wrl_has_other_owner() fix, Finished's removal would have
-         * freed the shared write layer out from under KeyUpdate's still-
-         * queued entry; now it correctly stays alive because that entry
-         * still references it.
+         * causes Certificate and Finished -- now fully acked -- to be
+         * cleaned up while the KeyUpdate stays queued. The client must
+         * still correctly report that it is waiting on the KeyUpdate's own
+         * ACK, rather than treating the connection as idle just because the
+         * ticket's ACK was sent.
          */
         if (!TEST_true(SSL_new_session_ticket(server))
             || !TEST_int_eq(SSL_do_handshake(server), 1))
             goto end;
         ret = SSL_read(client, buf, 1);
         if (!TEST_int_eq(SSL_get_error(client, ret), SSL_ERROR_WANT_READ)
+            || !TEST_int_eq(SSL_get_state(client), TLS_ST_CW_KEY_UPDATE)
             || !TEST_int_eq(ticket_count, 2)
             || !TEST_size_t_eq(pqueue_size(&cc->d1->sent_messages), 1))
             goto end;

         /*
-         * Force a retransmit of the KeyUpdate: it reads
-         * saved_retransmit_state.wrl/wrlmethod to resend under that
-         * epoch's keys. With the fix in place this is no longer a
-         * dangling pointer -- confirm it still isn't by using the
-         * connection afterward.
+         * Force a retransmit of the KeyUpdate, resending it under the
+         * original epoch's keys, then confirm the connection still works
+         * correctly by continuing to use it afterward.
          */
         cc->d1->next_timeout = ossl_time_subtract(ossl_time_now(), ossl_seconds2time(1));
         if (!TEST_int_gt(DTLSv1_handle_timeout(client), 0))