Commit a1420a699d for openssl.org

commit a1420a699d2589c2c524ea1f569747f6aaa738f3
Author: mcrmck <willmccormack14@gmail.com>
Date:   Sat Mar 7 21:51:17 2026 -0500

    Implement RFC 8701 GREASE for TLS ClientHello

    Add client-side GREASE (Generate Random Extensions And Sustain
    Extensibility) support per RFC 8701. When SSL_OP_GREASE is set,
    the TLS client injects reserved 0x?A?A-pattern values into the
    ClientHello to prevent ecosystem ossification caused by servers
    that reject unknown values.

    GREASE values are injected into:
    - Cipher suites (prepended)
    - Supported versions extension (prepended)
    - Supported groups extension (prepended)
    - Signature algorithms extension (appended)
    - Key share extension (prepended, 1 zero byte)
    - Two standalone extensions (one empty, one with 1 zero byte)

    The implementation uses lazy-seeded random values that remain
    consistent across HelloRetryRequest retransmissions. GREASE values
    from server responses are rejected as illegal parameters.

    Add -grease option to s_client to enable GREASE from the command line.

    Closes #9660

    Reviewed-by: Tomas Mraz <tomas@openssl.org>
    Reviewed-by: Matt Caswell <matt@openssl.foundation>
    Reviewed-by: Neil Horman <nhorman@openssl.org>
    MergeDate: Tue Mar 17 14:58:25 2026
    (Merged from https://github.com/openssl/openssl/pull/30303)

diff --git a/CHANGES.md b/CHANGES.md
index 88b6a0ebde..90069f94d7 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -61,6 +61,26 @@ OpenSSL Releases

    *Daniel Kubec*

+ * Added support for RFC 8701 GREASE (Generate Random Extensions And Sustain
+   Extensibility). When `SSL_OP_GREASE` is set, the TLS client injects
+   reserved GREASE values into cipher suites, supported versions, supported
+   groups, signature algorithms, key share, and extensions in the ClientHello
+   to prevent ecosystem ossification. The `openssl s_client` command gains a
+   `-grease` option to enable this.
+
+   *William McCormack*
+
+### Changes between 3.6 and 4.0 [xx XXX xxxx]
+
+ * Added support for RFC 8701 GREASE (Generate Random Extensions And Sustain
+   Extensibility). When `SSL_OP_GREASE` is set, the TLS client injects
+   reserved GREASE values into cipher suites, supported versions, supported
+   groups, signature algorithms, key share, and extensions in the ClientHello
+   to prevent ecosystem ossification. The `openssl s_client` command gains a
+   `-grease` option to enable this.
+
+   *William McCormack*
+
 ### Changes between 3.6 and 4.0 [xx XXX xxxx]

  * Added `-expected-rpks` option to the `openssl s_client`
diff --git a/apps/s_client.c b/apps/s_client.c
index b48b296966..592e3da79f 100644
--- a/apps/s_client.c
+++ b/apps/s_client.c
@@ -598,6 +598,7 @@ typedef enum OPTION_choice {
     OPT_S_ENUM,
     OPT_IGNORE_UNEXPECTED_EOF,
     OPT_FALLBACKSCSV,
+    OPT_GREASE,
     OPT_NOCMDS,
     OPT_ADV,
     OPT_PROXY,
@@ -671,6 +672,7 @@ const OPTIONS s_client_options[] = {
     { "read_buf", OPT_READ_BUF, 'p',
         "Default read buffer size to be used for connections" },
     { "fallback_scsv", OPT_FALLBACKSCSV, '-', "Send the fallback SCSV" },
+    { "grease", OPT_GREASE, '-', "Send GREASE values in ClientHello (RFC 8701)" },

     OPT_SECTION("Identity"),
     { "cert", OPT_CERT, '<', "Client certificate file to use" },
@@ -1031,6 +1033,7 @@ int s_client_main(int argc, char **argv)
 #endif
     int read_buf_len = 0;
     int fallback_scsv = 0;
+    int grease = 0;
     OPTION_CHOICE o;
 #ifndef OPENSSL_NO_DTLS
     int enable_timeouts = 0;
@@ -1518,6 +1521,9 @@ int s_client_main(int argc, char **argv)
         case OPT_FALLBACKSCSV:
             fallback_scsv = 1;
             break;
+        case OPT_GREASE:
+            grease = 1;
+            break;
         case OPT_KEYFORM:
             if (!opt_format(opt_arg(), OPT_FMT_ANY, &key_format))
                 goto opthelp;
@@ -2304,6 +2310,8 @@ int s_client_main(int argc, char **argv)

     if (fallback_scsv)
         SSL_set_mode(con, SSL_MODE_SEND_FALLBACK_SCSV);
+    if (grease)
+        SSL_set_options(con, SSL_OP_GREASE);

     if (!noservername && (servername != NULL || dane_tlsa_domain == NULL)) {
         if (servername == NULL) {
diff --git a/doc/man1/openssl-s_client.pod.in b/doc/man1/openssl-s_client.pod.in
index 635da52be7..3e53b4d9a0 100644
--- a/doc/man1/openssl-s_client.pod.in
+++ b/doc/man1/openssl-s_client.pod.in
@@ -76,6 +76,7 @@ B<openssl> B<s_client>
 [B<-sctp>]
 [B<-sctp_label_bug>]
 [B<-fallback_scsv>]
+[B<-grease>]
 [B<-async>]
 [B<-maxfraglen> I<len>]
 [B<-max_send_frag>]
@@ -573,6 +574,13 @@ available where OpenSSL has support for SCTP enabled.

 Send TLS_FALLBACK_SCSV in the ClientHello.

+=item B<-grease>
+
+Send GREASE (Generate Random Extensions And Sustain Extensibility) values in
+the ClientHello as defined in RFC 8701. This injects random reserved values
+into cipher suites, supported groups, supported versions, signature algorithms,
+key share, and extensions to prevent ecosystem ossification.
+
 =item B<-async>

 Switch on asynchronous mode. Cryptographic operations will be performed
diff --git a/doc/man3/SSL_CTX_set_options.pod b/doc/man3/SSL_CTX_set_options.pod
index d9aecb280b..ec66190d7a 100644
--- a/doc/man3/SSL_CTX_set_options.pod
+++ b/doc/man3/SSL_CTX_set_options.pod
@@ -408,6 +408,16 @@ ECH.
 If set, TLS ClientHello messages emitted by the client will ignore the
 ECHConfig config_id chosen by the server and use a random octet.

+=item SSL_OP_GREASE
+
+If set, TLS ClientHello messages will include GREASE (Generate Random
+Extensions And Sustain Extensibility) values as defined in RFC 8701. This
+injects random reserved values (matching the 0x?A?A pattern) into cipher
+suites, supported groups, supported versions, signature algorithms, key share
+entries, and extensions. GREASE values help prevent ecosystem ossification by
+ensuring servers and middleboxes tolerate unknown values. The injected values
+are consistent across HelloRetryRequest replays within the same connection.
+
 =back

 The following options no longer have any effect but their identifiers are
diff --git a/include/openssl/ssl.h.in b/include/openssl/ssl.h.in
index d7915db1aa..cba2cd80ba 100644
--- a/include/openssl/ssl.h.in
+++ b/include/openssl/ssl.h.in
@@ -453,6 +453,9 @@ typedef int (*SSL_async_callback_fn)(SSL *s, void *arg);
 #define SSL_OP_ECH_GREASE_RETRY_CONFIG SSL_OP_BIT(40)
 #endif

+/* RFC 8701: Send GREASE values in ClientHello */
+#define SSL_OP_GREASE SSL_OP_BIT(41)
+
 /*
  * Option "collections."
  */
diff --git a/ssl/ssl_ciph.c b/ssl/ssl_ciph.c
index d0e95f0011..fc12efaae1 100644
--- a/ssl/ssl_ciph.c
+++ b/ssl/ssl_ciph.c
@@ -2261,6 +2261,17 @@ int ssl_cipher_list_to_bytes(SSL_CONNECTION *s, STACK_OF(SSL_CIPHER) *sk,
     if (s->mode & SSL_MODE_SEND_FALLBACK_SCSV)
         maxlen -= 2;

+    /* RFC 8701: prepend a GREASE cipher suite value */
+    if ((s->options & SSL_OP_GREASE) && !s->server) {
+        uint16_t grease_cs = ossl_grease_value(s, OSSL_GREASE_CIPHER);
+
+        if (!WPACKET_put_bytes_u16(pkt, grease_cs)) {
+            SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
+            return 0;
+        }
+        totlen += 2;
+    }
+
     for (i = 0; i < sk_SSL_CIPHER_num(sk) && totlen < maxlen; i++) {
         const SSL_CIPHER *c;

diff --git a/ssl/ssl_lib.c b/ssl/ssl_lib.c
index b3de7a0982..be5f4a20f0 100644
--- a/ssl/ssl_lib.c
+++ b/ssl/ssl_lib.c
@@ -8496,3 +8496,37 @@ int SSL_CTX_get0_server_cert_type(const SSL_CTX *ctx, unsigned char **t, size_t
     *len = ctx->server_cert_type_len;
     return 1;
 }
+
+/*
+ * RFC 8701 GREASE - returns a GREASE value (0x?A?A pattern) for the given
+ * index.  Seeds are generated lazily on first use and remain stable for the
+ * lifetime of the connection so that HelloRetryRequest replays get identical
+ * values.
+ */
+uint16_t ossl_grease_value(SSL_CONNECTION *s, int index)
+{
+    uint16_t ret;
+
+    if (index < 0 || index > OSSL_GREASE_LAST_INDEX)
+        return 0x0A0A;
+
+    if (!s->ext.grease_seeded) {
+        if (RAND_bytes_ex(SSL_CONNECTION_GET_CTX(s)->libctx,
+                s->ext.grease_seed,
+                sizeof(s->ext.grease_seed), 0)
+            <= 0)
+            memset(s->ext.grease_seed, 0x42, sizeof(s->ext.grease_seed));
+        s->ext.grease_seeded = 1;
+    }
+
+    /* Map seed byte to 0x?A?A pattern */
+    ret = (s->ext.grease_seed[index] & 0xf0) | 0x0a;
+    ret |= ret << 8;
+
+    /* Ensure EXT2 differs from EXT1 */
+    if (index == OSSL_GREASE_EXT2
+        && ret == ossl_grease_value(s, OSSL_GREASE_EXT1))
+        ret ^= 0x1010;
+
+    return ret;
+}
diff --git a/ssl/ssl_local.h b/ssl/ssl_local.h
index e28cc2aa22..a7159d3c19 100644
--- a/ssl/ssl_local.h
+++ b/ssl/ssl_local.h
@@ -697,12 +697,23 @@ typedef enum tlsext_index_en {
     TLSEXT_IDX_certificate_authorities,
     TLSEXT_IDX_ech,
     TLSEXT_IDX_outer_extensions,
+    TLSEXT_IDX_grease1,
+    TLSEXT_IDX_grease2,
     TLSEXT_IDX_padding,
     TLSEXT_IDX_psk,
     /* Dummy index - must always be the last entry */
     TLSEXT_IDX_num_builtins
 } TLSEXT_INDEX;

+/* RFC 8701 GREASE seed indices */
+#define OSSL_GREASE_CIPHER 0
+#define OSSL_GREASE_GROUP 1
+#define OSSL_GREASE_EXT1 2
+#define OSSL_GREASE_EXT2 3
+#define OSSL_GREASE_VERSION 4
+#define OSSL_GREASE_SIGALG 5
+#define OSSL_GREASE_LAST_INDEX 5
+
 DEFINE_LHASH_OF_EX(SSL_SESSION);
 /* Needed in ssl_cert.c */
 DEFINE_LHASH_OF_EX(X509_NAME);
@@ -1741,6 +1752,10 @@ struct ssl_connection_st {
 #ifndef OPENSSL_NO_ECH
         OSSL_ECH_CONN ech;
 #endif
+
+        /* RFC 8701 GREASE */
+        uint8_t grease_seed[OSSL_GREASE_LAST_INDEX + 1];
+        int grease_seeded;
     } ext;

     /*
@@ -2505,6 +2520,11 @@ void ssl_sort_cipher_list(void);
 int ssl_load_ciphers(SSL_CTX *ctx);
 int ssl_cipher_list_to_bytes(SSL_CONNECTION *s, STACK_OF(SSL_CIPHER) *sk,
     WPACKET *pkt);
+uint16_t ossl_grease_value(SSL_CONNECTION *s, int index);
+static ossl_inline int ossl_is_grease_value(uint16_t val)
+{
+    return (val & 0x0f0f) == 0x0a0a && (val >> 8) == (val & 0xff);
+}
 __owur int ssl_setup_sigalgs(SSL_CTX *ctx);
 int ssl_load_groups(SSL_CTX *ctx);
 int ssl_load_sigalgs(SSL_CTX *ctx);
diff --git a/ssl/statem/extensions.c b/ssl/statem/extensions.c
index 16467f782f..417255241c 100644
--- a/ssl/statem/extensions.c
+++ b/ssl/statem/extensions.c
@@ -464,6 +464,18 @@ static const EXTENSION_DEFINITION ext_defs[] = {
     INVALID_EXTENSION,
     INVALID_EXTENSION,
 #endif /* END_OPENSSL_NO_ECH */
+    { /* RFC 8701 GREASE extension 1 - type is dynamic */
+        TLSEXT_TYPE_grease1,
+        SSL_EXT_CLIENT_HELLO,
+        0,
+        NULL,
+        NULL, NULL, NULL, tls_construct_ctos_grease1, NULL },
+    { /* RFC 8701 GREASE extension 2 - type is dynamic */
+        TLSEXT_TYPE_grease2,
+        SSL_EXT_CLIENT_HELLO,
+        0,
+        NULL,
+        NULL, NULL, NULL, tls_construct_ctos_grease2, NULL },
     { /* Must be immediately before pre_shared_key */
         TLSEXT_TYPE_padding,
         SSL_EXT_CLIENT_HELLO,
diff --git a/ssl/statem/extensions_clnt.c b/ssl/statem/extensions_clnt.c
index 7cdb594e8e..b2a5de6a87 100644
--- a/ssl/statem/extensions_clnt.c
+++ b/ssl/statem/extensions_clnt.c
@@ -335,6 +335,14 @@ EXT_RETURN tls_construct_ctos_supported_groups(SSL_CONNECTION *s, WPACKET *pkt,
         SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
         return EXT_RETURN_FAIL;
     }
+    /* RFC 8701: prepend a GREASE group value */
+    if ((s->options & SSL_OP_GREASE) && !s->server) {
+        if (!WPACKET_put_bytes_u16(pkt,
+                ossl_grease_value(s, OSSL_GREASE_GROUP))) {
+            SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
+            return EXT_RETURN_FAIL;
+        }
+    }
     /* Copy group ID if supported */
     for (i = 0; i < num_groups; i++) {
         const TLS_GROUP_INFO *ginfo = NULL;
@@ -452,8 +460,19 @@ EXT_RETURN tls_construct_ctos_sig_algs(SSL_CONNECTION *s, WPACKET *pkt,
         || !WPACKET_start_sub_packet_u16(pkt)
         /* Sub-packet for the actual list */
         || !WPACKET_start_sub_packet_u16(pkt)
-        || !tls12_copy_sigalgs(s, pkt, salg, salglen)
-        || !WPACKET_close(pkt)
+        || !tls12_copy_sigalgs(s, pkt, salg, salglen)) {
+        SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
+        return EXT_RETURN_FAIL;
+    }
+    /* RFC 8701: append a GREASE signature algorithm value */
+    if ((s->options & SSL_OP_GREASE) && !s->server) {
+        if (!WPACKET_put_bytes_u16(pkt,
+                ossl_grease_value(s, OSSL_GREASE_SIGALG))) {
+            SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
+            return EXT_RETURN_FAIL;
+        }
+    }
+    if (!WPACKET_close(pkt)
         || !WPACKET_close(pkt)) {
         SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
         return EXT_RETURN_FAIL;
@@ -740,6 +759,14 @@ EXT_RETURN tls_construct_ctos_supported_versions(SSL_CONNECTION *s, WPACKET *pkt
         return EXT_RETURN_FAIL;
     }

+    /* RFC 8701: prepend a GREASE version value */
+    if ((s->options & SSL_OP_GREASE) && !s->server) {
+        if (!WPACKET_put_bytes_u16(pkt,
+                ossl_grease_value(s, OSSL_GREASE_VERSION))) {
+            SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
+            return EXT_RETURN_FAIL;
+        }
+    }
     for (currv = max_version; currv >= min_version; currv--) {
         if (!WPACKET_put_bytes_u16(pkt, currv)) {
             SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
@@ -873,6 +900,19 @@ EXT_RETURN tls_construct_ctos_key_share(SSL_CONNECTION *s, WPACKET *pkt,
         return EXT_RETURN_FAIL;
     }

+    /* RFC 8701: prepend a GREASE key share entry (1 byte of 0x00) */
+    if ((s->options & SSL_OP_GREASE) && !s->server) {
+        uint16_t grease_group = ossl_grease_value(s, OSSL_GREASE_GROUP);
+
+        if (!WPACKET_put_bytes_u16(pkt, grease_group)
+            || !WPACKET_start_sub_packet_u16(pkt)
+            || !WPACKET_put_bytes_u8(pkt, 0)
+            || !WPACKET_close(pkt)) {
+            SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
+            return EXT_RETURN_FAIL;
+        }
+    }
+
     tls1_get_requested_keyshare_groups(s, &pgroups, &num_groups);
     if (num_groups == 1 && pgroups[0] == 0) { /* Indication that no * prefix was used */
         tls1_get_supported_groups(s, &pgroups, &num_groups);
@@ -2174,6 +2214,12 @@ int tls_parse_stoc_key_share(SSL_CONNECTION *s, PACKET *pkt,
         return 0;
     }

+    /* RFC 8701: reject GREASE values selected by the server */
+    if (ossl_is_grease_value(group_id)) {
+        SSLfatal(s, SSL_AD_ILLEGAL_PARAMETER, SSL_R_BAD_KEY_SHARE);
+        return 0;
+    }
+
     if ((context & SSL_EXT_TLS1_3_HELLO_RETRY_REQUEST) != 0) {
         const uint16_t *pgroups = NULL;
         size_t num_groups;
@@ -2818,3 +2864,55 @@ int tls_parse_stoc_ech(SSL_CONNECTION *s, PACKET *pkt, unsigned int context,
     return 1;
 }
 #endif /* END_OPENSSL_NO_ECH */
+
+/*
+ * RFC 8701 GREASE extension constructors.  Each writes an empty extension
+ * whose type is a GREASE value (0x?A?A pattern).
+ */
+EXT_RETURN tls_construct_ctos_grease1(SSL_CONNECTION *s, WPACKET *pkt,
+    unsigned int context, X509 *x,
+    size_t chainidx)
+{
+    uint16_t grease_type;
+
+    if (!(s->options & SSL_OP_GREASE) || s->server)
+        return EXT_RETURN_NOT_SENT;
+
+    grease_type = ossl_grease_value(s, OSSL_GREASE_EXT1);
+
+    if (!WPACKET_put_bytes_u16(pkt, grease_type)
+        || !WPACKET_put_bytes_u16(pkt, 0)) {
+        SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
+        return EXT_RETURN_FAIL;
+    }
+
+    return EXT_RETURN_SENT;
+}
+
+EXT_RETURN tls_construct_ctos_grease2(SSL_CONNECTION *s, WPACKET *pkt,
+    unsigned int context, X509 *x,
+    size_t chainidx)
+{
+    uint16_t grease_type;
+
+    if (!(s->options & SSL_OP_GREASE) || s->server)
+        return EXT_RETURN_NOT_SENT;
+
+    grease_type = ossl_grease_value(s, OSSL_GREASE_EXT2);
+
+    /*
+     * RFC 8701 recommends "varying length and contents" for GREASE
+     * extensions.  Extension 1 is empty; extension 2 carries one zero byte
+     * so that servers are tested against both empty and non-empty unknown
+     * extensions.  This mirrors the BoringSSL behaviour.
+     */
+    if (!WPACKET_put_bytes_u16(pkt, grease_type)
+        || !WPACKET_start_sub_packet_u16(pkt)
+        || !WPACKET_put_bytes_u8(pkt, 0)
+        || !WPACKET_close(pkt)) {
+        SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
+        return EXT_RETURN_FAIL;
+    }
+
+    return EXT_RETURN_SENT;
+}
diff --git a/ssl/statem/statem_local.h b/ssl/statem/statem_local.h
index 0c3cd3ac08..03e15f888c 100644
--- a/ssl/statem/statem_local.h
+++ b/ssl/statem/statem_local.h
@@ -43,6 +43,9 @@
 /* Invalid extension ID for non-supported extensions */
 #define TLSEXT_TYPE_invalid 0x10000
 #define TLSEXT_TYPE_out_of_range 0x10001
+/* RFC 8701 GREASE extension placeholders (actual type is dynamic) */
+#define TLSEXT_TYPE_grease1 0x10002
+#define TLSEXT_TYPE_grease2 0x10003
 unsigned int ossl_get_extension_type(size_t idx);

 extern const unsigned char hrrrandom[];
@@ -478,6 +481,12 @@ EXT_RETURN tls_construct_ctos_psk_kex_modes(SSL_CONNECTION *s, WPACKET *pkt,
 EXT_RETURN tls_construct_ctos_cookie(SSL_CONNECTION *s, WPACKET *pkt,
     unsigned int context,
     X509 *x, size_t chainidx);
+EXT_RETURN tls_construct_ctos_grease1(SSL_CONNECTION *s, WPACKET *pkt,
+    unsigned int context, X509 *x,
+    size_t chainidx);
+EXT_RETURN tls_construct_ctos_grease2(SSL_CONNECTION *s, WPACKET *pkt,
+    unsigned int context, X509 *x,
+    size_t chainidx);
 EXT_RETURN tls_construct_ctos_padding(SSL_CONNECTION *s, WPACKET *pkt,
     unsigned int context, X509 *x,
     size_t chainidx);
diff --git a/test/ext_internal_test.c b/test/ext_internal_test.c
index 6c1aaed9f1..c88f026dee 100644
--- a/test/ext_internal_test.c
+++ b/test/ext_internal_test.c
@@ -79,6 +79,8 @@ static EXT_LIST ext_list[] = {
     EXT_EXCEPTION(ech),
     EXT_EXCEPTION(outer_extensions),
 #endif
+    EXT_ENTRY(grease1),
+    EXT_ENTRY(grease2),
     EXT_ENTRY(padding),
     EXT_ENTRY(psk),
     EXT_END(num_builtins)
diff --git a/test/sslapitest.c b/test/sslapitest.c
index 622e2a8469..de60ed8ada 100644
--- a/test/sslapitest.c
+++ b/test/sslapitest.c
@@ -10308,7 +10308,7 @@ static int test_session_cache_overflow(int idx)
      * would free the get_sess_val, causing a use-after-free error.
      */
     if (!TEST_true(CRYPTO_GET_REF(&get_sess_val->references, &references))
-            || !TEST_int_ge(references, 2))
+        || !TEST_int_ge(references, 2))
         goto end;
     sess = SSL_get1_session(clientssl);
     if (!TEST_ptr(sess))
@@ -14144,6 +14144,227 @@ err:
 }
 #endif

+#if !defined(OSSL_NO_USABLE_TLS1_3)
+/*
+ * RFC 8701 GREASE test helpers.
+ * We capture the raw ClientHello via msg_callback and then parse it with
+ * PACKET functions to confirm that GREASE values (matching 0x?A?A) are
+ * present in the expected fields.
+ */
+static unsigned char *grease_ch_buf;
+static size_t grease_ch_len;
+
+static int is_grease(unsigned int v)
+{
+    return (v & 0x0f0f) == 0x0a0a && (v >> 8) == (v & 0xff);
+}
+
+static void grease_msg_cb(int write_p, int version, int content_type,
+    const void *buf, size_t len, SSL *ssl, void *arg)
+{
+    const unsigned char *p = buf;
+
+    /*
+     * We want the outgoing (write_p == 1) handshake (content_type == 22)
+     * ClientHello (msg_type == 1).  The buf starts at the handshake header:
+     *   byte 0: msg_type, bytes 1-3: length
+     */
+    if (write_p != 1 || content_type != SSL3_RT_HANDSHAKE
+        || len < SSL3_HM_HEADER_LENGTH
+        || p[0] != SSL3_MT_CLIENT_HELLO)
+        return;
+
+    /* Only capture the first ClientHello (not HRR retry) */
+    if (grease_ch_buf != NULL)
+        return;
+
+    grease_ch_buf = OPENSSL_memdup(buf, len);
+    grease_ch_len = len;
+}
+
+/*
+ * Parse a captured ClientHello (starting from handshake header) and check
+ * that it contains GREASE values in cipher suites, extensions, supported
+ * groups, key shares, and signature algorithms.
+ * Returns 1 on success, 0 on failure.
+ */
+static int check_grease_in_client_hello(void)
+{
+    PACKET pkt, ciphers, session, compression, exts, ext_data;
+    PACKET inner;
+    unsigned int ext_type = 0, val = 0;
+    int found_grease_cipher = 0;
+    int found_grease_ext = 0;
+    int found_grease_group = 0;
+    int found_grease_kshare = 0;
+    int found_grease_sigalg = 0;
+    int found_grease_version = 0;
+
+    memset(&pkt, 0, sizeof(pkt));
+    memset(&ciphers, 0, sizeof(ciphers));
+    memset(&session, 0, sizeof(session));
+    memset(&compression, 0, sizeof(compression));
+    memset(&exts, 0, sizeof(exts));
+    memset(&ext_data, 0, sizeof(ext_data));
+    memset(&inner, 0, sizeof(inner));
+
+    if (!TEST_ptr(grease_ch_buf)
+        || !TEST_true(PACKET_buf_init(&pkt, grease_ch_buf,
+            grease_ch_len))
+        /* Skip handshake message header */
+        || !TEST_true(PACKET_forward(&pkt, SSL3_HM_HEADER_LENGTH))
+        /* Skip client_version + random */
+        || !TEST_true(PACKET_forward(&pkt,
+            CLIENT_VERSION_LEN + SSL3_RANDOM_SIZE))
+        /* Skip session_id */
+        || !TEST_true(PACKET_get_length_prefixed_1(&pkt, &session))
+        /* Get cipher suites */
+        || !TEST_true(PACKET_get_length_prefixed_2(&pkt, &ciphers))
+        /* Skip compression */
+        || !TEST_true(PACKET_get_length_prefixed_1(&pkt, &compression))
+        /* Get extensions */
+        || !TEST_true(PACKET_as_length_prefixed_2(&pkt, &exts)))
+        return 0;
+
+    /* Scan cipher suites for GREASE */
+    while (PACKET_remaining(&ciphers) > 0) {
+        if (!TEST_true(PACKET_get_net_2(&ciphers, &val)))
+            return 0;
+        if (is_grease(val))
+            found_grease_cipher = 1;
+    }
+
+    /* Scan extensions */
+    while (PACKET_remaining(&exts) > 0) {
+        if (!TEST_true(PACKET_get_net_2(&exts, &ext_type))
+            || !TEST_true(PACKET_get_length_prefixed_2(&exts,
+                &ext_data)))
+            return 0;
+
+        if (is_grease(ext_type))
+            found_grease_ext++;
+
+        /* Check for GREASE inside supported_versions */
+        if (ext_type == TLSEXT_TYPE_supported_versions) {
+            if (!TEST_true(PACKET_get_length_prefixed_1(&ext_data,
+                    &inner)))
+                return 0;
+            while (PACKET_remaining(&inner) > 0) {
+                if (!TEST_true(PACKET_get_net_2(&inner, &val)))
+                    return 0;
+                if (is_grease(val))
+                    found_grease_version = 1;
+            }
+        }
+
+        /* Check for GREASE inside supported_groups */
+        if (ext_type == TLSEXT_TYPE_supported_groups) {
+            if (!TEST_true(PACKET_get_length_prefixed_2(&ext_data,
+                    &inner)))
+                return 0;
+            while (PACKET_remaining(&inner) > 0) {
+                if (!TEST_true(PACKET_get_net_2(&inner, &val)))
+                    return 0;
+                if (is_grease(val))
+                    found_grease_group = 1;
+            }
+        }
+
+        /* Check for GREASE inside key_share */
+        if (ext_type == TLSEXT_TYPE_key_share) {
+            PACKET ks_entry;
+
+            memset(&ks_entry, 0, sizeof(ks_entry));
+            if (!TEST_true(PACKET_get_length_prefixed_2(&ext_data,
+                    &inner)))
+                return 0;
+            while (PACKET_remaining(&inner) > 0) {
+                if (!TEST_true(PACKET_get_net_2(&inner, &val))
+                    || !TEST_true(PACKET_get_length_prefixed_2(
+                        &inner, &ks_entry)))
+                    return 0;
+                if (is_grease(val))
+                    found_grease_kshare = 1;
+            }
+        }
+
+        /* Check for GREASE inside signature_algorithms */
+        if (ext_type == TLSEXT_TYPE_signature_algorithms) {
+            if (!TEST_true(PACKET_get_length_prefixed_2(&ext_data,
+                    &inner)))
+                return 0;
+            while (PACKET_remaining(&inner) > 0) {
+                if (!TEST_true(PACKET_get_net_2(&inner, &val)))
+                    return 0;
+                if (is_grease(val))
+                    found_grease_sigalg = 1;
+            }
+        }
+    }
+
+    if (!TEST_true(found_grease_cipher))
+        return 0;
+    if (!TEST_int_eq(found_grease_ext, 2))
+        return 0;
+    if (!TEST_true(found_grease_version))
+        return 0;
+    if (!TEST_true(found_grease_group))
+        return 0;
+    if (!TEST_true(found_grease_kshare))
+        return 0;
+    if (!TEST_true(found_grease_sigalg))
+        return 0;
+
+    return 1;
+}
+
+static int test_grease(void)
+{
+    SSL_CTX *sctx = NULL, *cctx = NULL;
+    SSL *serverssl = NULL, *clientssl = NULL;
+    int testresult = 0;
+
+    grease_ch_buf = NULL;
+    grease_ch_len = 0;
+
+    if (!TEST_true(create_ssl_ctx_pair(libctx, TLS_server_method(),
+            TLS_client_method(),
+            TLS1_3_VERSION, TLS1_3_VERSION,
+            &sctx, &cctx, cert, privkey)))
+        goto end;
+
+    SSL_CTX_set_options(cctx, SSL_OP_GREASE);
+
+    if (!TEST_true(create_ssl_objects(sctx, cctx, &serverssl,
+            &clientssl, NULL, NULL)))
+        goto end;
+
+    SSL_set_msg_callback(clientssl, grease_msg_cb);
+
+    /* A full handshake should succeed - server must tolerate GREASE */
+    if (!TEST_true(create_ssl_connection(serverssl, clientssl,
+            SSL_ERROR_NONE)))
+        goto end;
+
+    /* Now verify the captured ClientHello contains GREASE values */
+    if (!TEST_true(check_grease_in_client_hello()))
+        goto end;
+
+    testresult = 1;
+
+end:
+    OPENSSL_free(grease_ch_buf);
+    grease_ch_buf = NULL;
+    grease_ch_len = 0;
+    SSL_free(serverssl);
+    SSL_free(clientssl);
+    SSL_CTX_free(sctx);
+    SSL_CTX_free(cctx);
+
+    return testresult;
+}
+#endif /* !defined(OSSL_NO_USABLE_TLS1_3) */
+
 static int test_ssl_conf_flags(void)
 {
     SSL_CONF_CTX *cctx = NULL;
@@ -14655,6 +14876,9 @@ int setup_tests(void)
     ADD_ALL_TESTS(test_ssl_set_groups_unsupported_keyshare, 2);
     ADD_TEST(test_ssl_conf_flags);
     ADD_ALL_TESTS(test_http_verbs, 3);
+#if !defined(OSSL_NO_USABLE_TLS1_3)
+    ADD_TEST(test_grease);
+#endif
     return 1;

 err: