Commit 3d3a147a81 for openssl.org

commit 3d3a147a814ec8fd62c3080247faf17320714e6a
Author: Mounir IDRASSI <mounir.idrassi@idrix.fr>
Date:   Mon Aug 31 22:34:51 2026 +0900

    test: DTLS 1.3 forged DTLSPlaintext alert regression tests

    Add regression coverage for post-handshake and buffered mid-handshake
    forged DTLSPlaintext alerts, including shutdown, replay-window,
    framing, malformed, and overlong cases. Also cover plaintext handshake
    and ACK records. Verify keyed epochs silently discard every record
    while application traffic continues.

    Keep an epoch-0 control proving legitimate plaintext alerts are still
    delivered. Change TLSProxy's DTLS 1.3 teardown close_notify to
    DTLSCiphertext using p_ossltest's passthrough AEAD; its former
    DTLSPlaintext record is now correctly discarded and caused proxy
    recipes to hang.

    All forged-record cases fail without the record-layer fix.

    Assisted-by: pi:kimi-k3
    Reviewed-by: Ryan Hooper <ryanh@openssl.foundation>
    Reviewed-by: Frederik Wedel-Heinen <fwh.openssl@gmail.com>
    Merge-date: Mon Sep  7 14:41:17 2026
    Merged-from: https://github.com/openssl/openssl/pull/32622

diff --git a/test/dtlstest.c b/test/dtlstest.c
index 96ad66bef8..acc9a72048 100644
--- a/test/dtlstest.c
+++ b/test/dtlstest.c
@@ -1087,6 +1087,393 @@ end:

     return testresult;
 }
+
+/*
+ * Keyed DTLS 1.3 epochs must silently discard DTLSPlaintext records
+ * (RFC 9147 sections 4 and 4.5.2).
+ */
+
+/*
+ * RFC 9147 section 6.1 assigns epoch 2 to handshake traffic and epoch 3 to
+ * the first application traffic keys.
+ */
+#define DTLS13_HANDSHAKE_EPOCH 2
+#define DTLS13_APPLICATION_EPOCH 3
+
+/*
+ * Genuine sequence numbers are close to zero in these tests. A sequence
+ * number of 100 is beyond the 64 record replay window, so accepting it would
+ * make the next genuine record stale.
+ */
+#define DTLS13_FAR_AHEAD_SEQUENCE 100
+
+/*
+ * DTLSPlaintext has a 48 bit sequence number. Its maximum moves the replay
+ * window as far forward as the record format permits. Sequence zero is the
+ * first protected record in a newly installed epoch.
+ */
+#define DTLS13_MAX_PLAINTEXT_SEQUENCE ((((uint64_t)1) << 48) - 1)
+#define DTLS13_FIRST_PROTECTED_SEQUENCE 0
+
+static size_t make_forged_plaintext_record(unsigned char *out,
+    unsigned int epoch, uint64_t seq,
+    unsigned int type,
+    const unsigned char *body,
+    size_t bodylen)
+{
+    out[0] = (unsigned char)type;
+    out[1] = 0xfe;
+    out[2] = 0xfd;
+    out[3] = (unsigned char)(epoch >> 8);
+    out[4] = (unsigned char)epoch;
+    out[5] = (unsigned char)(seq >> 40);
+    out[6] = (unsigned char)(seq >> 32);
+    out[7] = (unsigned char)(seq >> 24);
+    out[8] = (unsigned char)(seq >> 16);
+    out[9] = (unsigned char)(seq >> 8);
+    out[10] = (unsigned char)seq;
+    out[11] = (unsigned char)(bodylen >> 8);
+    out[12] = (unsigned char)bodylen;
+    memcpy(out + 13, body, bodylen);
+    return 13 + bodylen;
+}
+
+static size_t make_forged_alert(unsigned char *out, unsigned int epoch,
+    uint64_t seq, unsigned int level,
+    unsigned int descr)
+{
+    unsigned char body[2];
+
+    body[0] = (unsigned char)level;
+    body[1] = (unsigned char)descr;
+    return make_forged_plaintext_record(out, epoch, seq, SSL3_RT_ALERT,
+        body, sizeof(body));
+}
+
+static int do_dtls13_handshake(SSL *sssl, SSL *cssl)
+{
+    int i;
+
+    /*
+     * An in memory DTLS 1.3 handshake completes in far fewer than 64 calls,
+     * even when its flights are fragmented. This isn't a protocol limit: it
+     * leaves ample room while making a stalled handshake fail promptly.
+     */
+    for (i = 0; i < 64; i++) {
+        int rc = SSL_connect(cssl);
+        int rs = SSL_accept(sssl);
+
+        if (SSL_is_init_finished(cssl) && SSL_is_init_finished(sssl))
+            return 1;
+        if (rc <= 0) {
+            int e = SSL_get_error(cssl, rc);
+
+            if (e != SSL_ERROR_WANT_READ && e != SSL_ERROR_WANT_WRITE)
+                return 0;
+        }
+        if (rs <= 0) {
+            int e = SSL_get_error(sssl, rs);
+
+            if (e != SSL_ERROR_WANT_READ && e != SSL_ERROR_WANT_WRITE)
+                return 0;
+        }
+    }
+    return 0;
+}
+
+/* Drain pending ACK and NewSessionTicket records. */
+static int drain_ssl(SSL *ssl)
+{
+    unsigned char buf[256];
+    int ret;
+
+    do {
+        ret = SSL_read(ssl, buf, sizeof(buf));
+    } while (ret > 0);
+
+    if (!TEST_int_eq(SSL_get_error(ssl, ret), SSL_ERROR_WANT_READ))
+        return 0;
+    ERR_clear_error();
+    return 1;
+}
+
+static int inject_client_datagram(SSL *cssl, const unsigned char *pkt,
+    size_t pktlen)
+{
+    BIO *bio = SSL_get_wbio(cssl);
+
+    if (!TEST_ptr(bio))
+        return 0;
+    return TEST_int_eq(mempacket_test_inject(bio, (const char *)pkt,
+                           (int)pktlen, -1,
+                           INJECT_PACKET_IGNORE_REC_SEQ),
+        (int)pktlen);
+}
+
+/* Rejected plaintext must not change state or disrupt application data. */
+static int test_dtls13_forged_plaintext_alert(int idx)
+{
+    SSL_CTX *sctx = NULL, *cctx = NULL;
+    SSL *sssl = NULL, *cssl = NULL;
+    unsigned char pkt[5 * 15];
+    size_t pktlen = 0;
+    char msg[] = { 0x00, 0x01, 0x02, 0x03 };
+    char buf[16];
+    int ret, i, testresult = 0;
+
+    if (!TEST_true(create_ssl_ctx_pair(NULL, DTLS_server_method(),
+            DTLS_client_method(),
+            DTLS1_3_VERSION, DTLS1_3_VERSION,
+            &sctx, &cctx, cert, privkey)))
+        return 0;
+
+    if (!TEST_true(create_ssl_objects(sctx, cctx, &sssl, &cssl, NULL, NULL)))
+        goto end;
+
+    if (!TEST_true(do_dtls13_handshake(sssl, cssl)))
+        goto end;
+
+    /* Consume any post-handshake ACKs and NewSessionTickets */
+    if (!TEST_true(drain_ssl(cssl)) || !TEST_true(drain_ssl(sssl)))
+        goto end;
+
+    switch (idx) {
+    case 0:
+        /* Spoofed close_notify */
+        pktlen = make_forged_alert(pkt, DTLS13_APPLICATION_EPOCH,
+            DTLS13_FAR_AHEAD_SEQUENCE, SSL3_AL_WARNING,
+            SSL3_AD_CLOSE_NOTIFY);
+        break;
+    case 1:
+        /* Spoofed fatal alert (handshake_failure) */
+        pktlen = make_forged_alert(pkt, DTLS13_APPLICATION_EPOCH,
+            DTLS13_FAR_AHEAD_SEQUENCE, SSL3_AL_FATAL,
+            SSL3_AD_HANDSHAKE_FAILURE);
+        break;
+    case 2:
+        /* user_cancelled with seq 2^48-1 (replay-window poison) */
+        pktlen = make_forged_alert(pkt, DTLS13_APPLICATION_EPOCH,
+            DTLS13_MAX_PLAINTEXT_SEQUENCE, SSL3_AL_WARNING,
+            SSL_AD_USER_CANCELLED);
+        break;
+    case 3:
+        /* Trip the warning limit while preserving record framing. */
+        for (i = 0; i < 5; i++)
+            pktlen += make_forged_alert(pkt + pktlen,
+                DTLS13_APPLICATION_EPOCH, 10 + i, SSL3_AL_WARNING,
+                SSL_AD_USER_CANCELLED);
+        break;
+    case 4:
+        /* Malformed alert body (fragment length 3) */
+        pktlen = make_forged_alert(pkt, DTLS13_APPLICATION_EPOCH,
+            DTLS13_FAR_AHEAD_SEQUENCE, SSL3_AL_FATAL,
+            SSL3_AD_HANDSHAKE_FAILURE);
+        pkt[12] = 3;
+        pkt[15] = 0xff;
+        pktlen = 16;
+        break;
+    case 5:
+        /* Alert with an overlong body (longer than content + tag) */
+        pktlen = make_forged_alert(pkt, DTLS13_APPLICATION_EPOCH,
+            DTLS13_FAR_AHEAD_SEQUENCE, SSL3_AL_WARNING,
+            SSL3_AD_CLOSE_NOTIFY);
+        pkt[11] = 0;
+        pkt[12] = 40;
+        memset(pkt + 15, 0xaa, 40 - 2);
+        pktlen = 13 + 40;
+        break;
+    case 6:
+        /* Type 22 used to reach AAD setup with an uninitialised WPACKET. */
+        {
+            unsigned char body[40];
+
+            memset(body, 0xbb, sizeof(body));
+            pktlen = make_forged_plaintext_record(pkt,
+                DTLS13_APPLICATION_EPOCH, DTLS13_FAR_AHEAD_SEQUENCE,
+                SSL3_RT_HANDSHAKE, body, sizeof(body));
+        }
+        break;
+    case 7:
+        /* Same as case 6 with outer type ack(26) */
+        {
+            unsigned char body[40];
+
+            memset(body, 0xcc, sizeof(body));
+            pktlen = make_forged_plaintext_record(pkt,
+                DTLS13_APPLICATION_EPOCH, DTLS13_FAR_AHEAD_SEQUENCE,
+                SSL3_RT_ACK, body, sizeof(body));
+        }
+        break;
+    default:
+        goto end;
+    }
+
+    if (!inject_client_datagram(cssl, pkt, pktlen))
+        goto end;
+
+    /* It must be silently discarded without changing shutdown state. */
+    ret = SSL_read(sssl, buf, sizeof(buf));
+    if (!TEST_int_le(ret, 0)
+        || !TEST_int_eq(SSL_get_error(sssl, ret), SSL_ERROR_WANT_READ)
+        || !TEST_int_eq(SSL_get_shutdown(sssl), 0))
+        goto end;
+    ERR_clear_error();
+
+    /* The association must still work: application data keeps flowing */
+    if (!TEST_int_eq(SSL_write(cssl, msg, sizeof(msg)), (int)sizeof(msg))
+        || !TEST_int_eq(ret = SSL_read(sssl, buf, sizeof(buf)),
+            (int)sizeof(msg))
+        || !TEST_mem_eq(buf, sizeof(msg), msg, sizeof(msg)))
+        goto end;
+
+    testresult = 1;
+end:
+    SSL_free(cssl);
+    SSL_free(sssl);
+    SSL_CTX_free(cctx);
+    SSL_CTX_free(sctx);
+
+    return testresult;
+}
+
+/*
+ * Plant epoch 2 plaintext after ClientHello and verify that it is discarded
+ * when reparsed under epoch 2.
+ *
+ * idx 0: far ahead sequence makes Finished stale
+ * idx 1: fatal alert aborts the handshake
+ * idx 2: sequence 0 makes the first protected record look replayed
+ */
+static int test_dtls13_forged_plaintext_alert_plant(int idx)
+{
+    SSL_CTX *sctx = NULL, *cctx = NULL;
+    SSL *sssl = NULL, *cssl = NULL;
+    unsigned char pkt[15];
+    size_t pktlen = 0;
+    char msg[] = { 0x00, 0x01, 0x02, 0x03 };
+    char buf[16];
+    int testresult = 0;
+
+    if (!TEST_true(create_ssl_ctx_pair(NULL, DTLS_server_method(),
+            DTLS_client_method(),
+            DTLS1_3_VERSION, DTLS1_3_VERSION,
+            &sctx, &cctx, cert, privkey)))
+        return 0;
+
+    if (!TEST_true(create_ssl_objects(sctx, cctx, &sssl, &cssl, NULL, NULL)))
+        goto end;
+
+    /* Send flight 1: ClientHello */
+    if (!TEST_int_le(SSL_connect(cssl), 0))
+        goto end;
+
+    switch (idx) {
+    case 0:
+        pktlen = make_forged_alert(pkt, DTLS13_HANDSHAKE_EPOCH,
+            DTLS13_MAX_PLAINTEXT_SEQUENCE, SSL3_AL_WARNING,
+            SSL_AD_USER_CANCELLED);
+        break;
+    case 1:
+        pktlen = make_forged_alert(pkt, DTLS13_HANDSHAKE_EPOCH,
+            DTLS13_FIRST_PROTECTED_SEQUENCE, SSL3_AL_FATAL,
+            SSL3_AD_HANDSHAKE_FAILURE);
+        break;
+    case 2:
+        pktlen = make_forged_alert(pkt, DTLS13_HANDSHAKE_EPOCH,
+            DTLS13_FIRST_PROTECTED_SEQUENCE, SSL3_AL_WARNING,
+            SSL_AD_USER_CANCELLED);
+        break;
+    default:
+        goto end;
+    }
+
+    if (!inject_client_datagram(cssl, pkt, pktlen))
+        goto end;
+
+    /* The handshake must complete despite the plant */
+    if (!TEST_true(do_dtls13_handshake(sssl, cssl)))
+        goto end;
+
+    /* Application data must flow in both directions */
+    if (!TEST_int_eq(SSL_write(cssl, msg, sizeof(msg)), (int)sizeof(msg))
+        || !TEST_int_eq(SSL_read(sssl, buf, sizeof(buf)), (int)sizeof(msg))
+        || !TEST_mem_eq(buf, sizeof(msg), msg, sizeof(msg))
+        || !TEST_int_eq(SSL_write(sssl, msg, sizeof(msg)), (int)sizeof(msg))
+        || !TEST_int_eq(SSL_read(cssl, buf, sizeof(buf)), (int)sizeof(msg))
+        || !TEST_mem_eq(buf, sizeof(msg), msg, sizeof(msg)))
+        goto end;
+
+    testresult = 1;
+end:
+    SSL_free(cssl);
+    SSL_free(sssl);
+    SSL_CTX_free(cctx);
+    SSL_CTX_free(sctx);
+
+    return testresult;
+}
+
+/* Epoch 0 plaintext alerts must still reach the handshake. */
+static int test_dtls13_epoch0_plaintext_alert(void)
+{
+#ifdef OPENSSL_NO_EC
+    const char *group = "ffdhe3072";
+#else
+    const char *group = "P-256";
+#endif
+    SSL_CTX *sctx = NULL, *cctx = NULL;
+    SSL *sssl = NULL, *cssl = NULL;
+    unsigned char pkt[15];
+    size_t pktlen;
+    int ret, i, testresult = 0;
+
+    if (!TEST_true(create_ssl_ctx_pair(NULL, DTLS_server_method(),
+            DTLS_client_method(),
+            DTLS1_3_VERSION, DTLS1_3_VERSION,
+            &sctx, &cctx, cert, privkey)))
+        return 0;
+
+    /* Keep ClientHello in one record so sequence number 1 remains unused. */
+    if (!TEST_true(SSL_CTX_set1_groups_list(sctx, group))
+        || !TEST_true(SSL_CTX_set1_groups_list(cctx, group)))
+        goto end;
+
+    if (!TEST_true(create_ssl_objects(sctx, cctx, &sssl, &cssl, NULL, NULL)))
+        goto end;
+
+    /* Send flight 1: ClientHello */
+    if (!TEST_int_le(SSL_connect(cssl), 0))
+        goto end;
+
+    /* ClientHello used sequence 0, so inject the alert with sequence 1. */
+    pktlen = make_forged_alert(pkt, 0, 1, SSL3_AL_FATAL,
+        SSL3_AD_HANDSHAKE_FAILURE);
+    if (!inject_client_datagram(cssl, pkt, pktlen))
+        goto end;
+
+    /* The epoch 0 alert must fail the handshake. */
+    ret = SSL_accept(sssl);
+    for (i = 0; i < 3 && ret <= 0
+        && SSL_get_error(sssl, ret) == SSL_ERROR_WANT_READ;
+        i++)
+        ret = SSL_accept(sssl);
+    if (!TEST_int_le(ret, 0)
+        || !TEST_int_eq(SSL_get_error(sssl, ret), SSL_ERROR_SSL)
+        || !TEST_int_eq(ERR_GET_REASON(ERR_peek_last_error()),
+            SSL_AD_REASON_OFFSET + SSL3_AD_HANDSHAKE_FAILURE)
+        || !TEST_true((SSL_get_shutdown(sssl) & SSL_RECEIVED_SHUTDOWN) != 0))
+        goto end;
+    ERR_clear_error();
+
+    testresult = 1;
+end:
+    SSL_free(cssl);
+    SSL_free(sssl);
+    SSL_CTX_free(cctx);
+    SSL_CTX_free(sctx);
+
+    return testresult;
+}
 #endif /* OPENSSL_NO_DTLS1_3 */

 /* Confirm that we can create a connections using DTLSv1_listen() */
@@ -1171,6 +1558,9 @@ int setup_tests(void)
 #ifndef OPENSSL_NO_DTLS1_3
     ADD_TEST(test_duplicate_app_data_dtls13);
     ADD_ALL_TESTS(test_seq_num_wrap, OSSL_NELEM(seqnum_tests));
+    ADD_ALL_TESTS(test_dtls13_forged_plaintext_alert, 8);
+    ADD_ALL_TESTS(test_dtls13_forged_plaintext_alert_plant, 3);
+    ADD_TEST(test_dtls13_epoch0_plaintext_alert);
 #endif

     return 1;
diff --git a/util/perl/TLSProxy/Proxy.pm b/util/perl/TLSProxy/Proxy.pm
index 783794e25d..f1d9c2bf97 100644
--- a/util/perl/TLSProxy/Proxy.pm
+++ b/util/perl/TLSProxy/Proxy.pm
@@ -690,32 +690,38 @@ sub construct_alert_message
     die "construct_alert_message only valid for DTLSv1.3 tests\n"
         if !$self->{isdtls} || !$self->is_tls13();

-    my $seqhi = ($sequence_number >> 32) & 0xffff;
-    my $seqmi = ($sequence_number >> 16) & 0xffff;
-    my $seqlo = ($sequence_number >> 0) & 0xffff;
-
-    # DTLS Record Layer Header
-    my $content_type = pack("C", 21);              # Alert (21)
-    my $legacy_version = pack("n", 0xFEFD);        # DTLS 1.2 (0xFEFD)
-    my $epoch_bytes = pack("n", $epoch);           # 2 bytes
-    my $sequence_bytes = pack('nnn', $seqhi, $seqmi, $seqlo);
-
-    my $length = pack("n", 2);                     # 2 bytes for alert payload
-
-    # Alert Message
     my $alert_level = pack("C", 1);                # Warning (1)
     my $alert_description = pack("C", 0);          # close_notify (0)
+    my $alert_payload = $alert_level.$alert_description;
+
+    if ($epoch == 0) {
+        # Epoch 0 uses DTLSPlaintext.
+        my $seqhi = ($sequence_number >> 32) & 0xffff;
+        my $seqmi = ($sequence_number >> 16) & 0xffff;
+        my $seqlo = ($sequence_number >> 0) & 0xffff;
+
+        return pack("C", 21).                 # Alert (21)
+               pack("n", 0xFEFD).             # legacy version DTLS 1.2
+               pack("n", $epoch).
+               pack('nnn', $seqhi, $seqmi, $seqlo).
+               pack("n", length($alert_payload)).
+               $alert_payload;
+    }

-    # Combine all parts
-    my $packet = $content_type.
-                 $legacy_version.
-                 $epoch_bytes.
-                 $sequence_bytes.
-                 $length.
-                 $alert_level.
-                 $alert_description;
-
-    return $packet;
+    # Keyed epochs use DTLSCiphertext. The p_ossltest cipher leaves the
+    # payload unchanged and does not verify the tag. The inner plaintext
+    # holds close_notify and its content type; a zero tag follows.
+    # The header uses a 16 bit sequence number and a length field. Mask the
+    # sequence number with the first two payload bytes, as in Record.pm.
+    my $payload = $alert_payload.pack("C", 21).("\x00" x 16);
+    my $maskhi = unpack("n", substr($payload, 0, 2));
+    my $seqlo = ($sequence_number & 0xffff) ^ $maskhi;
+    my $first = 0x20 | 0x08 | 0x04 | ($epoch & 0x03);
+
+    return pack("C", $first).
+           pack("n", $seqlo).
+           pack("n", length($payload)).
+           $payload;
 }

 sub process_packet