From: sftcd Date: Thu, 14 Aug 2025 18:17:07 +0000 (+0100) Subject: s_client and s_server options for ECH X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=f00ca8cd6b1dc4e2d7cdaf5864f41f414791b0d6;p=thirdparty%2Fopenssl.git s_client and s_server options for ECH Reviewed-by: Matt Caswell Reviewed-by: Tomas Mraz (Merged from https://github.com/openssl/openssl/pull/28270) --- diff --git a/apps/s_client.c b/apps/s_client.c index f9f11f64279..9fcf533ed9d 100644 --- a/apps/s_client.c +++ b/apps/s_client.c @@ -108,7 +108,12 @@ static BIO *bio_c_out = NULL; static int c_quiet = 0; static char *sess_out = NULL; # ifndef OPENSSL_NO_ECH -static char *ech_config_list = NULL; +static char *ech_config_list = NULL, *ech_grease_suite = NULL; +static const char *sni_outer_name = NULL; +static int ech_grease = 0, ech_ignore_cid = 0; +static int ech_select = OSSL_ECHSTORE_ALL; +static int ech_grease_type = OSSL_ECH_CURRENT_VERSION; +static int ech_no_outer_sni = 0; # endif static SSL_SESSION *psksess = NULL; @@ -526,7 +531,10 @@ typedef enum OPTION_choice { OPT_SCTP_LABEL_BUG, OPT_KTLS, # ifndef OPENSSL_NO_ECH - OPT_ECHCONFIGLIST, + OPT_ECHCONFIGLIST, OPT_SNIOUTER, OPT_ALPN_OUTER, + OPT_ECH_SELECT, OPT_ECH_IGNORE_CONFIG_ID, + OPT_ECH_GREASE, OPT_ECH_GREASE_SUITE, OPT_ECH_GREASE_TYPE, + OPT_ECH_NO_OUTER_SNI, # endif OPT_R_ENUM, OPT_PROV_ENUM } OPTION_CHOICE; @@ -728,14 +736,31 @@ const OPTIONS s_client_options[] = { {"enable_pha", OPT_ENABLE_PHA, '-', "Enable post-handshake-authentication"}, {"enable_server_rpk", OPT_ENABLE_SERVER_RPK, '-', "Enable raw public keys (RFC7250) from the server"}, {"enable_client_rpk", OPT_ENABLE_CLIENT_RPK, '-', "Enable raw public keys (RFC7250) from the client"}, -# ifndef OPENSSL_NO_ECH - {"ech_config_list", OPT_ECHCONFIGLIST, 's', - "Set ECHConfigList, value is base 64 encoded ECHConfigList"}, -# endif #ifndef OPENSSL_NO_SRTP {"use_srtp", OPT_USE_SRTP, 's', "Offer SRTP key management with a colon-separated profile list"}, #endif + +# ifndef OPENSSL_NO_ECH + {"ech_config_list", OPT_ECHCONFIGLIST, 's', + "Set ECHConfigList, value is base64-encoded ECHConfigList"}, + {"ech_outer_alpn", OPT_ALPN_OUTER, 's', + "Specify outer ALPN value, when using ECH (comma-separated list)"}, + {"ech_outer_sni", OPT_SNIOUTER, 's', + "The name to put in the outer CH when overriding the server's choice"}, + {"ech_no_outer_sni", OPT_ECH_NO_OUTER_SNI, '-', + "Do not send the server name (SNI) extension in the outer ClientHello"}, + {"ech_select", OPT_ECH_SELECT, 'n', + "Select one ECHConfig from the set provided via -ech_config_list"}, + {"ech_grease", OPT_ECH_GREASE, '-', + "Send GREASE values when not really using ECH"}, + {"ech_grease_suite", OPT_ECH_GREASE_SUITE, 's', + "Use this HPKE suite for GREASE values when not really using ECH"}, + {"ech_grease_type", OPT_ECH_GREASE_TYPE, 'n', + "Use this TLS extension type for GREASE values when not really using ECH"}, + {"ech_ignore_cid", OPT_ECH_IGNORE_CONFIG_ID, '-', + "Ignore the server-chosen ECH config ID and send a random value"}, +# endif #ifndef OPENSSL_NO_SRP {"srpuser", OPT_SRPUSER, 's', "(deprecated) SRP authentication for 'user'"}, {"srppass", OPT_SRPPASS, 's', "(deprecated) Password for 'user'"}, @@ -934,6 +959,11 @@ int s_client_main(int argc, char **argv) char *sname_alloc = NULL; int noservername = 0; const char *alpn_in = NULL; +# ifndef OPENSSL_NO_ECH + const char *alpn_outer_in = NULL; + int rv = 0; + OSSL_ECHSTORE *es = NULL; +# endif tlsextctx tlsextcbp = { NULL, 0 }; const char *ssl_config = NULL; #define MAX_SI_TYPES 100 @@ -1541,6 +1571,30 @@ int s_client_main(int argc, char **argv) case OPT_ECHCONFIGLIST: ech_config_list = opt_arg(); break; + case OPT_ALPN_OUTER: + alpn_outer_in = opt_arg(); + break; + case OPT_SNIOUTER: + sni_outer_name = opt_arg(); + break; + case OPT_ECH_SELECT: + ech_select = atoi(opt_arg()); + break; + case OPT_ECH_GREASE: + ech_grease = 1; + break; + case OPT_ECH_GREASE_SUITE: + ech_grease_suite = opt_arg(); + break; + case OPT_ECH_GREASE_TYPE: + ech_grease_type = atoi(opt_arg()); + break; + case OPT_ECH_IGNORE_CONFIG_ID: + ech_ignore_cid = 1; + break; + case OPT_ECH_NO_OUTER_SNI: + ech_no_outer_sni = 1; + break; # endif case OPT_NOSERVERNAME: noservername = 1; @@ -1654,7 +1708,16 @@ int s_client_main(int argc, char **argv) goto opthelp; } } - +# ifndef OPENSSL_NO_ECH + if ((alpn_outer_in != NULL || sni_outer_name != NULL + || ech_no_outer_sni == 1) + && ech_config_list == NULL) { + BIO_printf(bio_err, "%s: Can't use -ech_outer_sni nor " + "-ech_outer_alpn nor -no_ech_outer_sni without " + "-ech_config_list\n", prog); + goto opthelp; + } +# endif #ifndef OPENSSL_NO_NEXTPROTONEG if (min_version == TLS1_3_VERSION && next_proto_neg_in != NULL) { BIO_printf(bio_err, "Cannot supply -nextprotoneg with TLSv1.3\n"); @@ -1885,6 +1948,13 @@ int s_client_main(int argc, char **argv) SSL_CTX_set_options(ctx, SSL_OP_ENABLE_KTLS); #endif +# ifndef OPENSSL_NO_ECH + if (ech_grease != 0) + SSL_CTX_set_options(ctx, SSL_OP_ECH_GREASE); + if (ech_ignore_cid != 0) + SSL_CTX_set_options(ctx, SSL_OP_ECH_IGNORE_CID); +# endif + if (vpmtouched && !SSL_CTX_set1_param(ctx, vpm)) { BIO_printf(bio_err, "Error setting verify params\n"); goto end; @@ -2090,6 +2160,26 @@ int s_client_main(int argc, char **argv) if (set_keylog_file(ctx, keylog_file)) goto end; +# ifndef OPENSSL_NO_ECH + if (alpn_outer_in != NULL) { + size_t alpn_outer_len; + unsigned char *alpn_outer = NULL; + + alpn_outer = next_protos_parse(&alpn_outer_len, alpn_outer_in); + if (alpn_outer == NULL) { + BIO_printf(bio_err, "Error parsing -ech_outer_alpn argument\n"); + goto end; + } + if (SSL_CTX_ech_set1_outer_alpn_protos(ctx, alpn_outer, + alpn_outer_len) != 1) { + BIO_printf(bio_err, "Error setting ALPN-OUTER\n"); + OPENSSL_free(alpn_outer); + goto end; + } + OPENSSL_free(alpn_outer); + } +# endif + con = SSL_new(ctx); if (con == NULL) goto end; @@ -2109,6 +2199,25 @@ int s_client_main(int argc, char **argv) } } +# ifndef OPENSSL_NO_ECH + if (ech_grease_suite != NULL) { + if (SSL_ech_set1_grease_suite(con, ech_grease_suite) != 1) { + ERR_print_errors(bio_err); + goto end; + } + } + /* no point in setting to our default */ + if (ech_grease_type != OSSL_ECH_CURRENT_VERSION) { + BIO_printf(bio_err, "Setting GREASE ECH type 0x%4x\n", ech_grease_type); + if (SSL_ech_set_grease_type(con, ech_grease_type) != 1) { + BIO_printf(bio_err, "Can't set GREASE ECH type 0x%4x\n", + ech_grease_type); + ERR_print_errors(bio_err); + goto end; + } + } +# endif + if (sess_in != NULL) { SSL_SESSION *sess; BIO *stmp = BIO_new_file(sess_in, "r"); @@ -2123,6 +2232,7 @@ int s_client_main(int argc, char **argv) goto end; } if (!SSL_set_session(con, sess)) { + SSL_SESSION_free(sess); BIO_printf(bio_err, "Can't set session\n"); goto end; } @@ -2145,10 +2255,47 @@ int s_client_main(int argc, char **argv) } # ifndef OPENSSL_NO_ECH - if (ech_config_list != NULL - && SSL_set1_ech_config_list(con, (unsigned char *)ech_config_list, - strlen(ech_config_list)) != 1) - goto end; + if (ech_config_list != NULL) { + if (SSL_set1_ech_config_list(con, (unsigned char *)ech_config_list, + strlen(ech_config_list)) != 1) { + BIO_printf(bio_err, "%s: error setting ECHConfigList.\n", prog); + goto end; + } + if (ech_no_outer_sni == 1) { + if (sni_outer_name != NULL) { + BIO_printf(bio_err, "%s: can't set -ech_no_outer_sni and " + "-ech_outer_sni together.\n", prog); + goto end; + } + if (SSL_ech_set1_outer_server_name(con, NULL, 1) != 1) { + BIO_printf(bio_err, "%s: setting no ECH outer name failed.\n", + prog); + ERR_print_errors(bio_err); + goto end; + } + } + if (sni_outer_name != NULL) { + rv = SSL_ech_set1_outer_server_name(con, sni_outer_name, 0); + if (rv != 1) { + BIO_printf(bio_err, "%s: setting ECH outer name to %s failed.\n", + prog, sni_outer_name); + ERR_print_errors(bio_err); + goto end; + } + } + } + if (ech_select != OSSL_ECHSTORE_ALL) { + if ((es = SSL_get1_echstore(con)) == NULL + || OSSL_ECHSTORE_downselect(es, ech_select) != 1 + || SSL_set1_echstore(con, es) != 1) { + BIO_printf(bio_err, "%s: ECH downselect to (%d) failed.\n", + prog, ech_select); + ERR_print_errors(bio_err); + goto end; + } + OSSL_ECHSTORE_free(es); + es = NULL; + } # endif if (dane_tlsa_domain != NULL) { @@ -3371,6 +3518,9 @@ int s_client_main(int argc, char **argv) bio_c_out = NULL; BIO_free(bio_c_msg); bio_c_msg = NULL; +# ifndef OPENSSL_NO_ECH + OSSL_ECHSTORE_free(es); +# endif return ret; } @@ -3446,11 +3596,12 @@ static void print_ech_retry_configs(BIO *bio, SSL *s) for (ind = 0; ind != cnt; ind++) { if (OSSL_ECHSTORE_get1_info(es, ind, &secs, &pn, &ec, &has_priv, &for_retry) != 1) { - BIO_printf(bio, "ECH: Error getting retry-config %d\n", ind); + BIO_printf(bio, "ECH: Error getting retry-config %d.\n", ind); goto end; } - BIO_printf(bio, "ECH: entry: %d public_name: %s age: %d%s\n", - ind, pn, (int)secs, has_priv ? " (has private key)" : ""); + BIO_printf(bio, "ECH: entry: %d public_name: %s age: %lld%s\n", + ind, pn, (long long)secs, + has_priv ? " (has private key)" : ""); BIO_printf(bio, "ECH: \t%s\n", ec); OPENSSL_free(pn); pn = NULL; @@ -3466,6 +3617,7 @@ end: return; } +/* outcomes marked as "odd" shouldn't happen in s_client */ static void print_ech_status(BIO *bio, SSL *s, int estat) { switch (estat) { @@ -3482,7 +3634,7 @@ static void print_ech_status(BIO *bio, SSL *s, int estat) BIO_printf(bio, "ECH: success: %d\n", estat); break; case SSL_ECH_STATUS_GREASE_ECH: - BIO_printf(bio, "ECH: GREASE+retry-configs%d\n", estat); + BIO_printf(bio, "ECH: GREASE+retry-configs: %d\n", estat); break; case SSL_ECH_STATUS_BACKEND: BIO_printf(bio, "ECH: BACKEND: %d\n", estat); @@ -3523,6 +3675,10 @@ static void print_stuff(BIO *bio, SSL *s, int full) #ifndef OPENSSL_NO_CT const SSL_CTX *ctx = SSL_get_SSL_CTX(s); #endif +# ifndef OPENSSL_NO_ECH + char *inner = NULL, *outer = NULL; + int estat = 0; +# endif if (full) { int got_a_chain = 0; @@ -3753,22 +3909,17 @@ static void print_stuff(BIO *bio, SSL *s, int full) } BIO_printf(bio, "---\n"); # ifndef OPENSSL_NO_ECH - { - char *inner = NULL, *outer = NULL; - int estat = 0; - - estat = SSL_ech_get1_status(s, &inner, &outer); - print_ech_status(bio, s, estat); - if (estat == SSL_ECH_STATUS_SUCCESS) { - BIO_printf(bio, "ECH: inner: %s\n", inner); - BIO_printf(bio, "ECH: outer: %s\n", outer); - } - if (estat == SSL_ECH_STATUS_FAILED_ECH - || estat == SSL_ECH_STATUS_FAILED_ECH_BAD_NAME) - print_ech_retry_configs(bio, s); - OPENSSL_free(inner); - OPENSSL_free(outer); + estat = SSL_ech_get1_status(s, &inner, &outer); + print_ech_status(bio, s, estat); + if (estat == SSL_ECH_STATUS_SUCCESS) { + BIO_printf(bio, "ECH: inner: %s\n", inner); + BIO_printf(bio, "ECH: outer: %s\n", outer); } + if (estat == SSL_ECH_STATUS_FAILED_ECH + || estat == SSL_ECH_STATUS_FAILED_ECH_BAD_NAME) + print_ech_retry_configs(bio, s); + OPENSSL_free(inner); + OPENSSL_free(outer); BIO_printf(bio, "---\n"); # endif diff --git a/apps/s_server.c b/apps/s_server.c index 82590f9adbb..572787edde1 100644 --- a/apps/s_server.c +++ b/apps/s_server.c @@ -18,6 +18,9 @@ #if defined(_WIN32) /* Included before async.h to avoid some warnings */ # include +# if !defined(OPENSSL_NO_ECH) && !defined(PATH_MAX) +# define PATH_MAX 4096 +# endif #endif #include @@ -26,6 +29,27 @@ #include #include "internal/sockets.h" /* for openssl_fdset() */ +#ifndef OPENSSL_NO_ECH +/* to use tracing, if configured and requested */ +# ifndef OPENSSL_NO_SSL_TRACE +# include +# endif +/* sockaddr stuff */ +# if defined(_WIN32) +# include +# include +# include +# else +# include +# include +# include +# include +# endif +/* for timing in some TRACE statements */ +# include +# include "internal/o_dir.h" /* for OPENSSL_DIR_read */ +#endif + #ifndef OPENSSL_NO_SOCK /* @@ -59,6 +83,11 @@ typedef unsigned int u_int; #include "internal/sockets.h" #include "internal/statem.h" +# ifndef OPENSSL_NO_ECH +/* needed for X509_check_host in some CI builds "no-http" */ +# include +# endif + static int not_resumable_sess_cb(SSL *s, int is_forward_secure); static int sv_body(int s, int stype, int prot, unsigned char *context); static int www_body(int s, int stype, int prot, unsigned char *context); @@ -72,6 +101,10 @@ static void init_session_cache_ctx(SSL_CTX *sctx); static void free_sessions(void); static void print_connection_info(SSL *con); +# ifndef OPENSSL_NO_ECH +static unsigned int ech_print_cb(SSL *s, const char *str); +# endif + static const int bufsize = 16 * 1024; static int accept_socket = -1; @@ -420,8 +453,194 @@ typedef struct tlsextctx_st { char *servername; BIO *biodebug; int extension_error; + X509 *scert; /* ECH needs 2nd cert for testing */ } tlsextctx; +# ifndef OPENSSL_NO_ECH +static unsigned int ech_print_cb(SSL *s, const char *str) +{ + if (str != NULL) + BIO_printf(bio_s_out, "ECH Server callback printing: \n%s\n", str); + return 1; +} + +/* + * The server has possibly 2 TLS server names basically in ctx and ctx2. So we + * need to check if any client-supplied SNI in the inner/outer matches either + * and serve whichever is appropriate. X509_check_host is the way to do that, + * given an X509* pointer. + * + * We default to the "main" ctx if the client-supplied SNI does not match the + * ctx2 certificate. We don't fail if the client-supplied SNI matches neither, + * but just continue with the "main" ctx. If the client-supplied SNI matches + * both ctx and ctx2, then we'll switch to ctx2 anyway - we don't try for a + * "best" match in that case. + * + * Note that since we attempt ECH decryption whenever configured to do that, + * the only way to get the "outer" SNI is via SSL_ech_get1_status. + */ + +/* apparently 26 is all we need, but round it up to 32 to be on the safe side */ +# define ECH_TIME_STR_LEN 32 + +static int ssl_ech_servername_cb(SSL *s, int *ad, void *arg) +{ + tlsextctx *p = (tlsextctx *) arg; + time_t now = time(0); /* For a bit of basic logging */ + int sockfd = 0, res = 0, echrv = 0; + size_t srv = 0; + struct sockaddr_storage ss; + socklen_t salen = sizeof(ss); + struct sockaddr *sa; + char clientip[INET6_ADDRSTRLEN], lstr[ECH_TIME_STR_LEN]; + const char *servername = NULL; + char *inner_sni = NULL, *outer_sni = NULL; + struct tm local; +# if !defined(OPENSSL_SYS_WINDOWS) + struct tm *local_p = NULL; +# else + errno_t grv; +# endif + +# if !defined(OPENSSL_SYS_WINDOWS) + local_p = gmtime_r(&now, &local); + if (local_p != &local) { + strcpy(lstr, "sometime"); + } else { + srv = strftime(lstr, ECH_TIME_STR_LEN, "%c", &local); + if (srv == 0) + strcpy(lstr, "sometime"); + } +# else + grv = gmtime_s(&local, &now); + if (grv != 0) { + strcpy(lstr, "sometime"); + } else { + srv = strftime(lstr, ECH_TIME_STR_LEN, "%c", &local); + if (srv == 0) + strcpy(lstr, "sometime"); + } +# endif + memset(clientip, 0, INET6_ADDRSTRLEN); + strncpy(clientip, "unknown", INET6_ADDRSTRLEN); + memset(&ss, 0, salen); + sa = (struct sockaddr *)&ss; + res = BIO_get_fd(SSL_get_wbio(s), &sockfd); + if (res != -1) { +# if !defined(_WIN32) + res = getpeername(sockfd, sa, &salen); +# else + res = getpeername(sockfd, sa, (int *)&salen); +# endif + if (res == 0) + res = getnameinfo(sa, salen, clientip, INET6_ADDRSTRLEN, + 0, 0, NI_NUMERICHOST); + } + /* Name that matches "main" ctx */ + servername = SSL_get_servername(s, TLSEXT_NAMETYPE_host_name); + echrv = SSL_ech_get1_status(s, &inner_sni, &outer_sni); + if (p->biodebug != NULL) { + /* spit out basic logging */ + BIO_printf(p->biodebug, + "ssl_ech_servername_cb: connection from %s at %s\n", + clientip, lstr); + /* Client supplied SNI from inner and outer */ + switch (echrv) { + case SSL_ECH_STATUS_BACKEND: + BIO_printf(p->biodebug, + "ssl_ech_servername_cb: ECH backend got inner ECH\n"); + break; + case SSL_ECH_STATUS_NOT_CONFIGURED: + BIO_printf(p->biodebug, + "ssl_ech_servername_cb: ECH not configured\n"); + break; + case SSL_ECH_STATUS_GREASE: + BIO_printf(p->biodebug, + "ssl_ech_servername_cb: attempt we think is GREASE\n"); + break; + case SSL_ECH_STATUS_NOT_TRIED: + BIO_printf(p->biodebug, + "ssl_ech_servername_cb: not attempted\n"); + break; + case SSL_ECH_STATUS_FAILED: + BIO_printf(p->biodebug, + "ssl_ech_servername_cb: tried but failed\n"); + break; + case SSL_ECH_STATUS_BAD_CALL: + BIO_printf(p->biodebug, + "ssl_ech_servername_cb: bad input to API\n"); + break; + case SSL_ECH_STATUS_BAD_NAME: + BIO_printf(p->biodebug, + "ssl_ech_servername_cb: worked but bad name\n"); + break; + case SSL_ECH_STATUS_SUCCESS: + BIO_printf(p->biodebug, + "ssl_ech_servername_cb: success: outer %s, inner: %s\n", + (outer_sni == NULL ? "none" : outer_sni), + (inner_sni == NULL ? "none" : inner_sni)); + break; + default: + BIO_printf(p->biodebug, + "ssl_ech_servername_cb: Error getting ECH status\n"); + break; + } + } + OPENSSL_free(inner_sni); + OPENSSL_free(outer_sni); + if (servername != NULL && p->biodebug != NULL) { + const char *cp = servername; + unsigned char uc; + + BIO_printf(p->biodebug, + "ssl_ech_servername_cb: Hostname in TLS extension: \""); + while ((uc = *cp++) != 0) + BIO_printf(p->biodebug, + isascii(uc) && isprint(uc) ? "%c" : "\\x%02x", uc); + BIO_printf(p->biodebug, "\"\n"); + if (p->servername != NULL) + BIO_printf(p->biodebug, + "ssl_ech_servername_cb: ctx servername: %s\n", + p->servername); + else + BIO_printf(p->biodebug, + "ssl_ech_servername_cb: ctx servername is NULL\n"); + if (p->scert == NULL) + BIO_printf(p->biodebug, + "ssl_ech_servername_cb: No 2nd cert! That's bad.\n"); + } + if (p->servername == NULL) + return SSL_TLSEXT_ERR_NOACK; + if (p->scert == NULL) + return SSL_TLSEXT_ERR_NOACK; + if (echrv == SSL_ECH_STATUS_SUCCESS && servername != NULL) { + if (ctx2 != NULL) { + int check_host = X509_check_host(p->scert, servername, 0, 0, NULL); + + if (check_host == 1) { + if (p->biodebug != NULL) + BIO_printf(p->biodebug, + "ssl_ech_servername_cb: Switching context.\n"); + SSL_set_SSL_CTX(s, ctx2); + } else { + if (p->biodebug != NULL) + BIO_printf(p->biodebug, + "ssl_ech_servername_cb: Not switching context " + "- no name match (%d).\n", check_host); + } + } + } else { + if (p->biodebug != NULL) + BIO_printf(p->biodebug, + "ssl_ech_servername_cb: Not switching context " + "- no ECH SUCCESS\n"); + } + return SSL_TLSEXT_ERR_OK; +} +/* Below is the "original" ssl_servername_cb, before ECH */ + +# else + static int ssl_servername_cb(SSL *s, int *ad, void *arg) { tlsextctx *p = (tlsextctx *) arg; @@ -452,6 +671,8 @@ static int ssl_servername_cb(SSL *s, int *ad, void *arg) return SSL_TLSEXT_ERR_OK; } +# endif + /* Structure passed to cert status callback */ typedef struct tlsextstatusctx_st { int timeout; @@ -960,6 +1181,10 @@ typedef enum OPTION_choice { OPT_TFO, OPT_CERT_COMP, OPT_ENABLE_SERVER_RPK, OPT_ENABLE_CLIENT_RPK, +# ifndef OPENSSL_NO_ECH + OPT_ECH_PEM, OPT_ECH_DIR, OPT_ECH_NORETRY, + OPT_ECH_TRIALDECRYPT, OPT_ECH_GREASE_RT, +# endif OPT_R_ENUM, OPT_S_ENUM, OPT_V_ENUM, @@ -1209,6 +1434,19 @@ const OPTIONS s_server_options[] = { #endif {"alpn", OPT_ALPN, 's', "Set the advertised protocols for the ALPN extension (comma-separated list)"}, + +# ifndef OPENSSL_NO_ECH + {"ech_key", OPT_ECH_PEM, 's', "Load ECH PEM-formatted key pair"}, + {"ech_dir", OPT_ECH_DIR, 's', "Load ECH key pairs (for retries) " \ + "from the specified directory"}, + {"ech_noretry_dir", OPT_ECH_NORETRY, 's', "Load ECH key pairs (not " \ + "for retry) from the specified directory"}, + {"ech_trialdecrypt", OPT_ECH_TRIALDECRYPT, '-', + "Do trial decryption even if ECH record_digest matching fails"}, + {"ech_greaseretries", OPT_ECH_GREASE_RT, '-', + "Set server to GREASE retry_config values"}, +# endif + #ifndef OPENSSL_NO_KTLS {"ktls", OPT_KTLS, '-', "Enable Kernel TLS for sending and receiving"}, {"sendfile", OPT_SENDFILE, '-', "Use sendfile to response file with -WWW"}, @@ -1224,6 +1462,64 @@ const OPTIONS s_server_options[] = { {NULL} }; +# ifndef OPENSSL_NO_ECH +static int ech_load_dir(SSL_CTX *lctx, const char *thedir, + int for_retry, int *nloaded) +{ + size_t elen = strlen(thedir); + OPENSSL_DIR_CTX *d = NULL; + const char *thisfile = NULL; + OSSL_ECHSTORE *es = NULL; + BIO *in = NULL; + int loaded = 0; + + if ((elen + 7) >= PATH_MAX) { /* too long, go away */ + BIO_printf(bio_err, "'%s' too long - exiting\n", thedir); + return 0; + } + if (app_isdir(thedir) <= 0) { /* if not a directory, ignore it */ + BIO_printf(bio_err, "'%s' not a directory - exiting\n", thedir); + return 0; + } + if ((es = SSL_CTX_get1_echstore(lctx)) == NULL + && (es = OSSL_ECHSTORE_new(app_get0_libctx(), + app_get0_propq())) == NULL) { + BIO_printf(bio_err, "internal error\n"); + return 0; + } + while ((thisfile = OPENSSL_DIR_read(&d, thedir))) { + char filepath[PATH_MAX]; + int r; + +# ifdef OPENSSL_SYS_VMS + r = BIO_snprintf(filepath, sizeof(filepath), "%s%s", thedir, thisfile); +# else + r = BIO_snprintf(filepath, sizeof(filepath), "%s/%s", thedir, thisfile); +# endif + if (r < 0 + || app_isdir(filepath) > 0 + || (in = BIO_new_file(filepath, "r")) == NULL + || OSSL_ECHSTORE_read_pem(es, in, for_retry) != 1) { + BIO_printf(bio_err, "Failed reading from: %s\n", thisfile); + continue; + } + BIO_free_all(in); + if (bio_s_out != NULL) + BIO_printf(bio_s_out, "Added ECH key pair from: %s\n", thisfile); + loaded++; + } + if (SSL_CTX_set1_echstore(lctx, es) != 1) { + BIO_printf(bio_err, "internal error\n"); + return 0; + } + if (bio_s_out != NULL) + BIO_printf(bio_s_out, "Added %d ECH key pairs from: %s\n", + loaded, thedir); + *nloaded = loaded; + return 1; +} +# endif + #define IS_PROT_FLAG(o) \ (o == OPT_SSL3 || o == OPT_TLS1 || o == OPT_TLS1_1 || o == OPT_TLS1_2 \ || o == OPT_TLS1_3 || o == OPT_DTLS || o == OPT_DTLS1 || o == OPT_DTLS1_2) @@ -1266,7 +1562,7 @@ int s_server_main(int argc, char *argv[]) OPTION_CHOICE o; EVP_PKEY *s_key2 = NULL; X509 *s_cert2 = NULL; - tlsextctx tlsextcbp = { NULL, NULL, SSL_TLSEXT_ERR_ALERT_WARNING }; + tlsextctx tlsextcbp = { NULL, NULL, SSL_TLSEXT_ERR_ALERT_WARNING, NULL }; const char *ssl_config = NULL; int read_buf_len = 0; #ifndef OPENSSL_NO_NEXTPROTONEG @@ -1304,6 +1600,14 @@ int s_server_main(int argc, char *argv[]) int max_early_data = -1, recv_max_early_data = -1; char *psksessf = NULL; int no_ca_names = 0; +# ifndef OPENSSL_NO_ECH + char *echkeyfile = NULL; + char *echkeydir = NULL; + char *echnoretrydir = NULL; + int ech_files_loaded = 0; + int echtrialdecrypt = 0; /* trial decryption off by default */ + int echgrease_rc = 0; /* retry_config GREASEing off by default */ +# endif #ifndef OPENSSL_NO_SCTP int sctp_label_bug = 0; #endif @@ -1904,6 +2208,23 @@ int s_server_main(int argc, char *argv[]) case OPT_HTTP_SERVER_BINMODE: http_server_binmode = 1; break; +# ifndef OPENSSL_NO_ECH + case OPT_ECH_PEM: + echkeyfile = opt_arg(); + break; + case OPT_ECH_DIR: + echkeydir = opt_arg(); + break; + case OPT_ECH_NORETRY: + echnoretrydir = opt_arg(); + break; + case OPT_ECH_TRIALDECRYPT: + echtrialdecrypt = 1; + break; + case OPT_ECH_GREASE_RT: + echgrease_rc = 1; + break; +# endif case OPT_NOCANAMES: no_ca_names = 1; break; @@ -2063,6 +2384,9 @@ int s_server_main(int argc, char *argv[]) if (s_cert2 == NULL) goto end; +# ifndef OPENSSL_NO_ECH + tlsextcbp.scert = s_cert2; +# endif } } #if !defined(OPENSSL_NO_NEXTPROTONEG) @@ -2275,12 +2599,69 @@ int s_server_main(int argc, char *argv[]) goto end; } +# ifndef OPENSSL_NO_ECH + if (echtrialdecrypt != 0) + SSL_CTX_set_options(ctx, SSL_OP_ECH_TRIALDECRYPT); + if (echgrease_rc != 0) + SSL_CTX_set_options(ctx, SSL_OP_ECH_GREASE_RETRY_CONFIG); + if (echkeyfile != NULL) { + OSSL_ECHSTORE *es = NULL; + BIO *in = NULL; + + if ((in = BIO_new_file(echkeyfile, "r")) == NULL + || (es = OSSL_ECHSTORE_new(app_get0_libctx(), + app_get0_propq())) == 0 + || OSSL_ECHSTORE_read_pem(es, in, OSSL_ECH_FOR_RETRY) != 1 + || SSL_CTX_set1_echstore(ctx, es) != 1) { + BIO_printf(bio_err, "Failed reading: %s\n", echkeyfile); + OSSL_ECHSTORE_free(es); + BIO_free_all(in); + goto end; + } + OSSL_ECHSTORE_free(es); + BIO_free_all(in); + if (bio_s_out != NULL) + BIO_printf(bio_s_out, "Added ECH key pair from: %s\n", echkeyfile); + ech_files_loaded++; + } + if (echkeydir != NULL) { + int nloaded = 0; + + if (ech_load_dir(ctx, echkeydir, OSSL_ECH_FOR_RETRY, &nloaded) != 1) { + BIO_printf(bio_err, "error loading from %s\n", echkeydir); + goto end; + } + ech_files_loaded += nloaded; + } + if (echnoretrydir != NULL) { + int nloaded = 0; + + if (ech_load_dir(ctx, echnoretrydir, OSSL_ECH_NO_RETRY, + &nloaded) != 1) { + BIO_printf(bio_err, "error loading from %s\n", echnoretrydir); + goto end; + } + ech_files_loaded += nloaded; + } + if ((echkeyfile != NULL || echkeydir != NULL || echnoretrydir != NULL) + && bio_s_out != NULL) { + BIO_printf(bio_s_out, "Loaded %d ECH key pairs in total\n", + ech_files_loaded); + } +# endif + if (s_cert2) { ctx2 = SSL_CTX_new_ex(app_get0_libctx(), app_get0_propq(), meth); if (ctx2 == NULL) { ERR_print_errors(bio_err); goto end; } +# ifndef OPENSSL_NO_ECH + if (echtrialdecrypt != 0) + SSL_CTX_set_options(ctx2, SSL_OP_ECH_TRIALDECRYPT); + if (echgrease_rc != 0) + SSL_CTX_set_options(ctx, SSL_OP_ECH_GREASE_RETRY_CONFIG); +# endif } if (ctx2 != NULL) { @@ -2339,6 +2720,13 @@ int s_server_main(int argc, char *argv[]) if (alpn_ctx.data) SSL_CTX_set_alpn_select_cb(ctx, alpn_cb, &alpn_ctx); + /* + * If we have a 2nd context to which we might switch, then set + * the same alpn callback for that too. + */ + if (s_cert2 != NULL && alpn_ctx.data != NULL) + SSL_CTX_set_alpn_select_cb(ctx2, alpn_cb, &alpn_ctx); + if (!no_dhe) { EVP_PKEY *dhpkey = NULL; @@ -2413,9 +2801,21 @@ int s_server_main(int argc, char *argv[]) goto end; } +# ifndef OPENSSL_NO_ECH + /* + * Giving the same chain to the 2nd key pair works for our tests. + * It would be better to supply s_chain_file2 as a new CLA in case + * the paths are very different but as that's not needed for tests, + * I didn't do it. + */ + if (ctx2 != NULL + && !set_cert_key_stuff(ctx2, s_cert2, s_key2, s_chain, build_chain)) + goto end; +# else if (ctx2 != NULL && !set_cert_key_stuff(ctx2, s_cert2, s_key2, NULL, build_chain)) goto end; +# endif if (s_dcert != NULL) { if (!set_cert_key_stuff(ctx, s_dcert, s_dkey, s_dchain, build_chain)) @@ -2497,10 +2897,19 @@ int s_server_main(int argc, char *argv[]) goto end; } tlsextcbp.biodebug = bio_s_out; +# ifndef OPENSSL_NO_ECH + SSL_CTX_set_tlsext_servername_callback(ctx2, ssl_ech_servername_cb); + SSL_CTX_set_tlsext_servername_arg(ctx2, &tlsextcbp); + SSL_CTX_set_tlsext_servername_callback(ctx, ssl_ech_servername_cb); + SSL_CTX_set_tlsext_servername_arg(ctx, &tlsextcbp); + SSL_CTX_ech_set_callback(ctx2, ech_print_cb); + SSL_CTX_ech_set_callback(ctx, ech_print_cb); +# else SSL_CTX_set_tlsext_servername_callback(ctx2, ssl_servername_cb); SSL_CTX_set_tlsext_servername_arg(ctx2, &tlsextcbp); SSL_CTX_set_tlsext_servername_callback(ctx, ssl_servername_cb); SSL_CTX_set_tlsext_servername_arg(ctx, &tlsextcbp); +# endif } #ifndef OPENSSL_NO_SRP @@ -2528,6 +2937,11 @@ int s_server_main(int argc, char *argv[]) #endif if (set_keylog_file(ctx, keylog_file)) goto end; +# ifndef OPENSSL_NO_ECH + /* not really an ECH issue but needed */ + if (ctx2 != NULL && set_keylog_file(ctx2, keylog_file)) + goto end; +# endif if (max_early_data >= 0) SSL_CTX_set_max_early_data(ctx, max_early_data); @@ -3542,6 +3956,10 @@ static int www_body(int s, int stype, int prot, unsigned char *context) X509 *peer = NULL; STACK_OF(SSL_CIPHER) *sk; static const char *space = " "; +# ifndef OPENSSL_NO_ECH + char *ech_inner = NULL, *ech_outer = NULL; + int echrv = 0; +# endif if (www == 1 && HAS_PREFIX(buf, "GET /reneg")) { if (HAS_PREFIX(buf, "GET /renegcert")) @@ -3605,6 +4023,80 @@ static int www_body(int s, int stype, int prot, unsigned char *context) } BIO_puts(io, "\n"); +# ifndef OPENSSL_NO_ECH + /* Customise output a bit to show ECH info at top */ + BIO_puts(io, "

OpenSSL with ECH

\n"); + BIO_puts(io, "

\n"); + echrv = SSL_ech_get1_status(con, &ech_inner, &ech_outer); + switch (echrv) { + case SSL_ECH_STATUS_NOT_TRIED: + BIO_puts(io, "ECH not attempted\n"); + break; + case SSL_ECH_STATUS_FAILED: + BIO_puts(io, "ECH tried but failed\n"); + break; + case SSL_ECH_STATUS_FAILED_ECH: + BIO_puts(io, "ECH tried but we got ECH which is weird\n"); + break; + case SSL_ECH_STATUS_BAD_NAME: + BIO_puts(io, "ECH worked but bad name\n"); + break; + case SSL_ECH_STATUS_BACKEND: + BIO_printf(io, "ECH acting as backend\n"); + break; + case SSL_ECH_STATUS_NOT_CONFIGURED: + BIO_printf(io, "ECH not configured\n"); + break; + case SSL_ECH_STATUS_GREASE: + BIO_printf(io, "ECH attempt we interpret as GREASE\n"); + break; + case SSL_ECH_STATUS_GREASE_ECH: + BIO_printf(io, "ECH attempt we interpret as GREASE, + ECH\n"); + break; + case SSL_ECH_STATUS_BAD_CALL: + BIO_printf(io, "ECH bad input to API\n"); + break; + case SSL_ECH_STATUS_SUCCESS: + BIO_printf(io, "ECH success: outer sni: %s, inner sni: %s\n", + (ech_outer == NULL ? "none" : ech_outer), + (ech_inner == NULL ? "none" : ech_inner)); + break; + default: + BIO_printf(io, " Error getting ECH status\n"); + break; + } + BIO_puts(io, "

\n"); + BIO_puts(io, "

TLS Session details

\n"); + BIO_puts(io, "
\n");
+            /*
+             * also dump session info to server stdout for debugging
+             */
+            SSL_SESSION_print(bio_s_out, SSL_get_session(con));
+            BIO_puts(io, "
\n");
+            BIO_puts(io, "\n");
+            for (i = 0; i < local_argc; i++) {
+                const char *myp;
+
+                for (myp = local_argv[i]; *myp; myp++)
+                    switch (*myp) {
+                    case '<':
+                        BIO_puts(io, "<");
+                        break;
+                    case '>':
+                        BIO_puts(io, ">");
+                        break;
+                    case '&':
+                        BIO_puts(io, "&");
+                        break;
+                    default:
+                        BIO_write(io, myp, 1);
+                        break;
+                    }
+                BIO_write(io, " ", 1);
+            }
+            BIO_puts(io, "\n");
+# endif
+
             ssl_print_secure_renegotiation_notes(io, con);
 
             /*
diff --git a/doc/man1/openssl-s_client.pod.in b/doc/man1/openssl-s_client.pod.in
index 5579a9c85f5..8de5efb7e18 100644
--- a/doc/man1/openssl-s_client.pod.in
+++ b/doc/man1/openssl-s_client.pod.in
@@ -125,6 +125,14 @@ B B
 [B<-enable_client_rpk>]
 [I:I]
 [B<-ech_config_list>]
+[B<-ech_outer_alpn> I]
+[B<-ech_grease>]
+[B<-ech_grease_suite> I]
+[B<-ech_grease_type> I]
+[B<-ech_ignore_cid>]
+[B<-ech_outer_sni> I]
+[B<-ech_no_outer_sni>]
+[B<-ech_select> I]
 
 =head1 DESCRIPTION
 
@@ -832,6 +840,63 @@ nor B<-connect> are provided, falls back to attempting to connect to
 I on port I<4433>.
 If the host string is an IPv6 address, it must be enclosed in C<[> and C<]>.
 
+=item B<-ech_outer_alpn> I
+
+When doing Encrypted Client Hello (ECH), this allows the caller to specify
+ALPN values to use in the outer ClientHello. (A "normal" ALPN value
+specified via -alpn will be used in the inner ClientHello.)
+
+=item B<-ech_grease>
+
+When not really doing Encrypted Client Hello (ECH), one can emit a so-called
+GREASE value, which is essentially a random value in order to try ensure that
+server code is less likely to ossify.
+
+=item B<-ech_grease_suite> I
+
+When B<-ech_grease> is specified, one can choose which ECH ciphersuite to use
+via this parameter.
+
+The comma-separated suite string names an HPKE suite in the form of
+I,I,I, e.g. "x25519,hkdf-sha256,aes256gcm" or can use
+the numeric values (in decimal or hexadecimal form) from the HPKE specification
+so "0x20,0x01,0x02" is the same as the previous example.
+
+KEM values supported: p256 or 0x10; p384 or 0x11, p521 or 0x12, x25519 or 0x20, x448 or 0x21
+
+KDF values supported: hkdf-sha256 or 0x01, hkdf-sha384 or 0x02, hkdf-sha512 or 0x03
+
+AEAD values supported: aes128gcm or 0x01, aes256gcm or 0x02, chachapoly1305 or 0x03
+
+=item B<-ech_grease_type> I
+
+Allows the client to set the TLS extension type for a GREASEd ECH value
+(currently equivalent to the ECH version number).  The current default is
+0xfe0d.
+
+=item B<-ech_ignore_cid>
+
+Encrypted Client Hello (ECH) extensions contain a configuration identifier
+(cid) taken from the ECHConfigList usually found in the domain name system
+(DNS). As those identifiers could be revealing, the client has the option to
+use a random value instead.
+
+=item B<-ech_outer_sni> I
+
+When doing Encrypted Client Hello (ECH), this allows the caller to specify a
+subject name indication (SNI) value to use in the outer ClientHello over-riding
+the public_name value from the relevant ECHConfigList.
+
+=item B<-ech_no_outer_sni>
+
+Setting this flag means no SNI will be emitted in the outer ClientHello.
+
+=item B<-ech_select> I
+
+If an ECHConfigList contains more than one ECHConfig then the client will by
+default use the first that works. This allows the caller to specify which
+ECHConfig to use (using a zero-based index).
+
 =back
 
 =head1 CONNECTED COMMANDS (BASIC)
@@ -1058,6 +1123,8 @@ The
 and B<-ocsp_check_all>
 options were added in OpenSSL 3.6.
 
+The B options were added in OpenSSL 4.0.
+
 =head1 COPYRIGHT
 
 Copyright 2000-2025 The OpenSSL Project Authors. All Rights Reserved.
diff --git a/doc/man1/openssl-s_server.pod.in b/doc/man1/openssl-s_server.pod.in
index 4c30c9c6283..c3bb6d3bf39 100644
--- a/doc/man1/openssl-s_server.pod.in
+++ b/doc/man1/openssl-s_server.pod.in
@@ -135,6 +135,11 @@ B B
 {- $OpenSSL::safe::opt_engine_synopsis -}{- $OpenSSL::safe::opt_provider_synopsis -}
 [B<-enable_server_rpk>]
 [B<-enable_client_rpk>]
+[B<-ech_key> I]
+[B<-ech_dir> I]
+[B<-ech_noretry_dir> I]
+[B<-ech_trialdecrypt>]
+[B<-ech_greaseretries>]
 
 =head1 DESCRIPTION
 
@@ -824,6 +829,32 @@ certificates can still elect to send X.509 certificates as usual.
 
 Raw public keys are extracted from the configured certificate/private key.
 
+=item B<-ech_key> I
+
+Load one Encrypted Client Hello (ECH) key pair.
+
+=item B<-ech_dir> I
+
+Attempt to load an ECH key pair from every file in the named directory.
+Any keys successfully loaded will be returned in 'retry_configs'.
+
+=item B<-ech_noretry_dir> I
+
+Attempt to load an ECH key pair from every file in the named directory.
+Keys loaded will not be returned in 'retry_configs'.
+
+=item B<-ech_trialdecrypt>
+
+When an Encrypted Client Hello (ECH) extension is seen in a ClientHello,
+attempt to decrypt with all known ECH private keys if necessary. Without
+this, the ECH "config_id" is used to match against the loaded ECH private
+keys and decryption is only attempted when there's a match.
+
+=item B<-ech_greaseretries>
+
+If set, servers will add GREASEy ECHConfig values to those sent
+in retry_configs.
+
 =back
 
 =head1 CONNECTED COMMANDS
@@ -938,6 +969,8 @@ options were added in OpenSSL 3.2.
 
 The B<-status_all> option was added in OpenSSL 3.6.
 
+The B options were added in OpenSSL 4.0.
+
 =head1 COPYRIGHT
 
 Copyright 2000-2025 The OpenSSL Project Authors. All Rights Reserved.
diff --git a/ssl/ech/ech_internal.c b/ssl/ech/ech_internal.c
index 89fc63d19b0..233536e59ab 100644
--- a/ssl/ech/ech_internal.c
+++ b/ssl/ech/ech_internal.c
@@ -388,7 +388,7 @@ int ossl_ech_pick_matching_cfg(SSL_CONNECTION *s, OSSL_ECHSTORE_ENTRY **ee,
     num = sk_OSSL_ECHSTORE_ENTRY_num(es->entries);
     /* allow API-set pref to override */
     hn = s->ext.ech.outer_hostname;
-    hnlen = (hn == NULL ? 0 : strlen(hn));
+    hnlen = (hn == NULL ? 0 : (unsigned int)strlen(hn));
     if (hnlen != 0)
         nameoverride = 1;
     if (s->ext.ech.no_outer == 1) {
@@ -513,7 +513,7 @@ int ossl_ech_encode_inner(SSL_CONNECTION *s, unsigned char **encoded,
     }
     /* now copy the rest, as "proper" exts, into encoded inner */
     for (ind = 0; ind < TLSEXT_IDX_num_builtins; ind++) {
-        if (raws[ind].present == 0 || ossl_ech_2bcompressed(ind) == 1)
+        if (raws[ind].present == 0 || ossl_ech_2bcompressed((int)ind) == 1)
             continue;
         if (!WPACKET_put_bytes_u16(&inner, raws[ind].type)
             || !WPACKET_sub_memcpy_u16(&inner, PACKET_data(&raws[ind].data),
@@ -632,15 +632,16 @@ size_t ossl_ech_calc_padding(SSL_CONNECTION *s, OSSL_ECHSTORE_ENTRY *ee,
         /* do weirder padding if SNI present in inner */
         if (s->ext.hostname != NULL) {
             isnilen = strlen(s->ext.hostname) + 9;
-            innersnipadding = (mnl > isnilen) ? mnl - isnilen : 0;
+            innersnipadding = (mnl > isnilen) ? (int)(mnl - isnilen) : 0;
         } else {
-            innersnipadding = mnl + 9;
+            innersnipadding = (int)mnl + 9;
         }
     }
     /* padding is after the inner client hello has been encoded */
-    length_with_snipadding = innersnipadding + encoded_len;
+    length_with_snipadding = innersnipadding + (int)encoded_len;
     length_of_padding = 31 - ((length_with_snipadding - 1) % 32);
-    length_with_padding = encoded_len + length_of_padding + innersnipadding;
+    length_with_padding = (int)encoded_len + length_of_padding
+        + innersnipadding;
     /*
      * Finally - make sure final result is longer than padding target
      * and a multiple of our padding increment.
diff --git a/ssl/ech/ech_ssl_apis.c b/ssl/ech/ech_ssl_apis.c
index 45d04c616e9..45e1ead6164 100644
--- a/ssl/ech/ech_ssl_apis.c
+++ b/ssl/ech/ech_ssl_apis.c
@@ -260,6 +260,7 @@ int SSL_ech_set1_grease_suite(SSL *ssl, const char *suite)
     if (s->ext.ech.grease_suite == NULL)
         return 0;
     s->ext.ech.attempted = 1;
+    s->ext.ech.grease = OSSL_ECH_IS_GREASE;
     return 1;
 }
 
@@ -272,6 +273,7 @@ int SSL_ech_set_grease_type(SSL *ssl, uint16_t type)
         return 0;
     s->ext.ech.attempted_type = type;
     s->ext.ech.attempted = 1;
+    s->ext.ech.grease = OSSL_ECH_IS_GREASE;
     return 1;
 }
 
diff --git a/ssl/ech/ech_store.c b/ssl/ech/ech_store.c
index ee9fbd76fec..a9a1b32561f 100644
--- a/ssl/ech/ech_store.c
+++ b/ssl/ech/ech_store.c
@@ -271,7 +271,7 @@ static int ech_final_config_checks(OSSL_ECHSTORE_ENTRY *ee)
     /* check no mandatory exts (with high bit set in type) */
     num = (ee->exts == NULL ? 0 : sk_OSSL_ECHEXT_num(ee->exts));
     for (ind = 0; ind != num; ind++) {
-        OSSL_ECHEXT *oe = sk_OSSL_ECHEXT_value(ee->exts, ind);
+        OSSL_ECHEXT *oe = sk_OSSL_ECHEXT_value(ee->exts, (int)ind);
 
         if (oe->type & 0x8000) {
             ERR_raise(ERR_LIB_SSL, ERR_R_PASSED_INVALID_ARGUMENT);
@@ -331,7 +331,7 @@ static int ech_decode_one_entry(OSSL_ECHSTORE_ENTRY **rent, PACKET *pkt,
         ERR_raise(ERR_LIB_SSL, SSL_R_ECH_DECODE_ERROR);
         goto err;
     }
-    ech_content_length = PACKET_remaining(&ver_pkt);
+    ech_content_length = (unsigned int)PACKET_remaining(&ver_pkt);
     switch (ee->version) {
     case OSSL_ECH_RFCXXXX_VERSION:
         break;
diff --git a/ssl/statem/extensions.c b/ssl/statem/extensions.c
index ba78bcedeab..deed81e4b79 100644
--- a/ssl/statem/extensions.c
+++ b/ssl/statem/extensions.c
@@ -1144,7 +1144,7 @@ int tls_construct_extensions(SSL_CONNECTION *s, WPACKET *pkt,
 
 #ifndef OPENSSL_NO_ECH
             /* do compressed in pass 0, non-compressed in pass 1 */
-            if (ossl_ech_2bcompressed(i) == pass)
+            if (ossl_ech_2bcompressed((int)i) == pass)
                 continue;
             /* stash index - needed for COMPRESS ECH handling */
             s->ext.ech.ext_ind = (int)i;
diff --git a/ssl/statem/extensions_clnt.c b/ssl/statem/extensions_clnt.c
index a77943ace01..b1f6c70be72 100644
--- a/ssl/statem/extensions_clnt.c
+++ b/ssl/statem/extensions_clnt.c
@@ -2526,14 +2526,14 @@ EXT_RETURN tls_construct_ctos_ech(SSL_CONNECTION *s, WPACKET *pkt,
     size_t cipherlen = 0, aad_len = 0, lenclen = 0, mypub_len = 0;
     size_t info_len = OSSL_ECH_MAX_INFO_LEN, clear_len = 0, encoded_len = 0;
     /* whether or not we've been asked to GREASE, one way or another */
-    int grease_opt_set = (s->ext.ech.grease == OSSL_ECH_IS_GREASE
+    int grease_opt_set = ((s->ext.ech.grease == OSSL_ECH_IS_GREASE)
                           || ((s->options & SSL_OP_ECH_GREASE) != 0));
 
     /* if we're not doing real ECH and not GREASEing then exit */
     if (s->ext.ech.attempted_type != TLSEXT_TYPE_ech && grease_opt_set == 0)
         return EXT_RETURN_NOT_SENT;
     /* send grease if not really attempting ECH */
-    if (s->ext.ech.attempted == 0 && grease_opt_set == 1) {
+    if (grease_opt_set == 1) {
         if (s->hello_retry_request == SSL_HRR_PENDING
             && s->ext.ech.sent != NULL) {
             /* re-tx already sent GREASEy ECH */
@@ -2732,7 +2732,7 @@ int tls_parse_stoc_ech(SSL_CONNECTION *s, PACKET *pkt, unsigned int context,
         return 0;
     }
     rval = PACKET_data(&rcfgs_pkt);
-    rlen = PACKET_remaining(&rcfgs_pkt);
+    rlen = (unsigned int)PACKET_remaining(&rcfgs_pkt);
     OPENSSL_free(s->ext.ech.returned);
     s->ext.ech.returned = NULL;
     srval = OPENSSL_malloc(rlen + 2);
diff --git a/test/ech_test.c b/test/ech_test.c
index 07fd9bddf49..fcd4795aa58 100644
--- a/test/ech_test.c
+++ b/test/ech_test.c
@@ -11,6 +11,7 @@
 #include 
 #include "testutil.h"
 #include "helpers/ssltestlib.h"
+#include "internal/packet.h"
 
 #ifndef OPENSSL_NO_ECH
 
@@ -23,15 +24,58 @@ static char *certsdir = NULL;
 static char *cert = NULL;
 static char *privkey = NULL;
 static char *rootcert = NULL;
+static int ch_test_cb_ok = 0;
 
 /* TODO(ECH): add some testing of SSL_OP_ECH_IGNORE_CID */
 
-/* callback */
-static unsigned int test_cb(SSL *s, const char *str)
+/* ECH callback */
+static unsigned int ech_test_cb(SSL *s, const char *str)
 {
+    if (verbose)
+        TEST_info("ech_test_cb called");
     return 1;
 }
 
+/* ClientHello callback */
+static int ch_test_cb(SSL *ssl, int *al, void *arg)
+{
+    char *servername = NULL;
+    const unsigned char *pos;
+    size_t remaining;
+    unsigned int servname_type;
+    PACKET pkt, sni, hostname;
+
+    if (verbose) {
+        TEST_info("ch_test_cb called");
+        if (SSL_client_hello_get0_ext(ssl, TLSEXT_TYPE_ech, &pos, &remaining)) {
+            TEST_info("there is an ECH extension");
+        } else {
+            TEST_info("there is NO ECH extension");
+        }
+    }
+    if (!SSL_client_hello_get0_ext(ssl, TLSEXT_TYPE_server_name, &pos,
+                                   &remaining)
+            || remaining <= 2)
+        goto give_up;
+    if (!PACKET_buf_init(&pkt, pos, remaining)
+        || !PACKET_as_length_prefixed_2(&pkt, &sni)
+        || !PACKET_get_1(&sni, &servname_type)
+        || servname_type != TLSEXT_NAMETYPE_host_name
+        || !PACKET_as_length_prefixed_2(&sni, &hostname)
+        || (PACKET_remaining(&hostname) > TLSEXT_MAXLEN_host_name)
+        || PACKET_contains_zero_byte(&hostname)
+        || !PACKET_strndup(&hostname, &servername))
+        goto give_up;
+    if (verbose)
+        TEST_info("servername: %s", servername);
+    OPENSSL_free(servername);
+    /* signal to caller all is good */
+    ch_test_cb_ok = 1;
+    return 1;
+give_up:
+    return 0;
+}
+
 /*
  * The define/vars below and the 3 callback functions are modified
  * from test/sslapitest.c
@@ -1095,8 +1139,8 @@ static int ech_api_basic_calls(void)
         || !TEST_false(rclen)
         || !TEST_ptr_eq(rc, NULL))
         goto end;
-    SSL_CTX_ech_set_callback(ctx, test_cb);
-    SSL_ech_set_callback(s, test_cb);
+    SSL_CTX_ech_set_callback(ctx, ech_test_cb);
+    SSL_ech_set_callback(s, ech_test_cb);
 
     /* all good */
     rv = 1;
@@ -1145,6 +1189,7 @@ end:
 # define OSSL_ECH_TEST_EARLY    2
 # define OSSL_ECH_TEST_CUSTOM   3
 # define OSSL_ECH_TEST_ENOE     4 /* early + no-ech */
+# define OSSL_ECH_TEST_CBS      5 /* test callbacks */
 /* note: early-data is prohibited after HRR so no tests for that */
 
 /*
@@ -1224,6 +1269,10 @@ static int test_ech_roundtrip_helper(int idx, int combo)
                                                  &server, NULL, &server)))
             goto end;
     }
+    if (combo == OSSL_ECH_TEST_CBS) {
+        SSL_CTX_ech_set_callback(sctx, ech_test_cb);
+        SSL_CTX_set_client_hello_cb(sctx, ch_test_cb, NULL);
+    }
     if (combo != OSSL_ECH_TEST_ENOE
         && !TEST_true(SSL_CTX_set1_echstore(cctx, es)))
         goto end;
@@ -1259,9 +1308,11 @@ static int test_ech_roundtrip_helper(int idx, int combo)
     if (combo == OSSL_ECH_TEST_ENOE
         && !TEST_int_eq(clientstatus, SSL_ECH_STATUS_NOT_CONFIGURED))
         goto end;
+    if (combo == OSSL_ECH_TEST_CBS && !TEST_int_eq(ch_test_cb_ok, 1))
+        goto end;
     /* all good */
     if (combo == OSSL_ECH_TEST_BASIC || combo == OSSL_ECH_TEST_HRR
-        || combo == OSSL_ECH_TEST_CUSTOM) {
+        || combo == OSSL_ECH_TEST_CUSTOM || combo == OSSL_ECH_TEST_CBS) {
         res = 1;
         goto end;
     }
@@ -1334,6 +1385,7 @@ end:
     SSL_free(serverssl);
     SSL_CTX_free(cctx);
     SSL_CTX_free(sctx);
+    ch_test_cb_ok = 0;
     return res;
 }
 
@@ -1377,6 +1429,14 @@ static int ech_enoe_test(int idx)
     return test_ech_roundtrip_helper(idx, OSSL_ECH_TEST_ENOE);
 }
 
+/* Test a roundtrip with ECH, and callbacks */
+static int ech_cb_test(int idx)
+{
+    if (verbose)
+        TEST_info("Doing: ech + callbacks test ");
+    return test_ech_roundtrip_helper(idx, OSSL_ECH_TEST_CBS);
+}
+
 #endif
 
 int setup_tests(void)
@@ -1420,6 +1480,7 @@ int setup_tests(void)
     ADD_ALL_TESTS(test_ech_early, suite_combos);
     ADD_ALL_TESTS(ech_custom_test, suite_combos);
     ADD_ALL_TESTS(ech_enoe_test, suite_combos);
+    ADD_ALL_TESTS(ech_cb_test, suite_combos);
     /* TODO(ECH): add more test code as other PRs done */
     return 1;
 err:
diff --git a/test/recipes/82-test_ech_client_server.t b/test/recipes/82-test_ech_client_server.t
new file mode 100644
index 00000000000..9197c52f651
--- /dev/null
+++ b/test/recipes/82-test_ech_client_server.t
@@ -0,0 +1,342 @@
+#! /usr/bin/env perl
+# Copyright 2023-2025 The OpenSSL Project Authors. All Rights Reserved.
+#
+# Licensed under the Apache License 2.0 (the "License").  You may not use
+# this file except in compliance with the License.  You can obtain a copy
+# in the file LICENSE in the source distribution or at
+# https://www.openssl.org/source/license.html
+
+use strict;
+use warnings;
+
+use IPC::Open3;
+use OpenSSL::Test qw/:DEFAULT srctop_file bldtop_file/;
+use OpenSSL::Test::Utils;
+use Symbol 'gensym';
+
+# servers randomly pick a port, then set this for clients to use
+# we also record the pid so we can kill it later if needed
+my $s_server_port = 0;
+my $s_server_pid = 0;
+my $s_client_match = 0;
+
+my $test_name = "test_ech_client_server";
+setup($test_name);
+
+plan skip_all => "$test_name requires EC cryptography"
+    if disabled("ec") || disabled("ecx");
+plan skip_all => "$test_name requires sock enabled"
+    if disabled("sock");
+plan skip_all => "$test_name requires TLSv1.3 enabled"
+    if disabled("tls1_3");
+plan skip_all => "$test_name is not available Windows or VMS"
+    if $^O =~ /^(VMS|MSWin32|msys)$/;
+
+plan tests => 18;
+
+my $shlib_wrap   = bldtop_file("util", "shlib_wrap.sh");
+my $apps_openssl = bldtop_file("apps", "openssl");
+
+my $echconfig_pem         = srctop_file("test", "certs", "ech-eg.pem");
+my $badconfig_pem         = srctop_file("test", "certs", "ech-mid.pem");
+my $server_pem            = srctop_file("test", "certs", "echserver.pem");
+my $server_key            = srctop_file("test", "certs", "echserver.key");
+my $root_pem              = srctop_file("test", "certs", "rootcert.pem");
+
+sub extract_ecl()
+{
+    # extract b64 encoded ECHConfigList from pem file
+    my $lb64 = "";
+    my $inwanted = 0;
+    open( my $fh, '<', $echconfig_pem ) or die "Can't open $echconfig_pem $!";
+    while( my $line = <$fh>) {
+        chomp $line;
+        if ( $line =~ /^-----BEGIN ECHCONFIG/) {
+            $inwanted = 1;
+        } elsif ( $line =~ /^-----END ECHCONFIG/) {
+            $inwanted = 0;
+        } elsif ($inwanted == 1) {
+            $lb64 .= $line;
+        }
+    }
+    print("base64 ECHConfigList: $lb64\n");
+    return($lb64);
+}
+
+my $good_b64 = extract_ecl();
+
+sub start_ech_client_server
+{
+    my ( $test_type, $winpattern ) = @_;
+
+    # start an s_server listening on some random port, with ECH enabled
+    # and willing to accept one request
+
+    # openssl s_server -accept 0 -naccept 1
+    #                  -key $server_key -cert $server_cert
+    #                  -key2 $server_key -cert2 $server_cert
+    #                  -ech_key $echconfig_pem
+    #                  -servername example.com
+    #                  -tls1_3
+    my @s_server_cmd;
+    if ($test_type eq "cid-free" ) {
+        # turn on trial-decrypt, so client can use random CID
+        @s_server_cmd = ("s_server", "-accept", "0", "-naccept", "1",
+                         "-cert", $server_pem, "-key", $server_key,
+                         "-cert2", $server_pem, "-key2", $server_key,
+                         "-ech_key", $echconfig_pem,
+                         "-servername", "example.com",
+                         "-ech_trialdecrypt",
+                         "-tls1_3");
+    } else {
+        # default for all other tests (for now)
+        @s_server_cmd = ("s_server", "-accept", "0", "-naccept", "1",
+                         "-cert", $server_pem, "-key", $server_key,
+                         "-cert2", $server_pem, "-key2", $server_key,
+                         "-ech_key", $echconfig_pem,
+                         "-servername", "example.com",
+                         "-ech_greaseretries",
+                         "-tls1_3");
+    }
+    print("@s_server_cmd\n");
+    $s_server_pid = open3(my $s_server_i, my $s_server_o,
+                             my $s_server_e = gensym,
+                             $shlib_wrap, $apps_openssl, @s_server_cmd);
+    # we're looking for...
+    # ACCEPT 0.0.0.0:45921
+    # ACCEPT [::]:45921
+    $s_server_port = "0";
+    while (<$s_server_o>) {
+        print($_);
+        chomp;
+        if (/^ACCEPT 0.0.0.0:(\d+)/) {
+            $s_server_port = $1;
+            last;
+        } elsif (/^ACCEPT \[::\]:(\d+)/) {
+            $s_server_port = $1;
+            last;
+        } elsif (/^Using default/) {
+            ;
+        } elsif (/^Added ECH key pair/) {
+            ;
+        } elsif (/^Loaded/) {
+            ;
+        } elsif (/^Setting secondary/) {
+            ;
+        } else {
+            last;
+        }
+    }
+    # openssl s_client -connect localhost:NNNNN
+    #                  -servername server.example
+    #                  -CAfile test/certs/rootcert.pem
+    #                  -ech_config_list "ADn+...AA="
+    #                  -prexit
+    my @s_client_cmd;
+    if ($test_type eq "GREASE-suite" ) {
+        # GREASE
+        @s_client_cmd = ("s_client",
+                         "-connect", "localhost:$s_server_port",
+                         "-servername", "server.example",
+                         "-CAfile", $root_pem,
+                         "-ech_grease_suite", "0x21,2,3",
+                         "-prexit");
+    } elsif ($test_type eq "lots-of-options" ) {
+        # real ECH with lots of options
+        @s_client_cmd = ("s_client",
+                         "-connect", "localhost:$s_server_port",
+                         "-servername", "server.example",
+                         "-CAfile", $root_pem,
+                         "-ech_config_list", $good_b64,
+                         "-ech_outer_sni", "foodle.doodle",
+                         "-ech_select", "0",
+                         "-alpn", "http/1.1",
+                         "-ech_outer_alpn", "http451",
+                         "-prexit");
+    } elsif ($test_type eq "GREASE-type" ) {
+        # GREASE with suite
+        @s_client_cmd = ("s_client",
+                         "-connect", "localhost:$s_server_port",
+                         "-servername", "server.example",
+                         "-CAfile", $root_pem,
+                         "-ech_grease_type", "12345",
+                         "-prexit");
+    } elsif ($test_type eq "GREASE" ) {
+        # GREASE with suite
+        @s_client_cmd = ("s_client",
+                         "-connect", "localhost:$s_server_port",
+                         "-servername", "server.example",
+                         "-CAfile", $root_pem,
+                         "-ech_grease",
+                         "-prexit");
+    } elsif ($test_type eq "no-outer" ) {
+        # Real ECH, no outer SNI
+        @s_client_cmd = ("s_client",
+                         "-connect", "localhost:$s_server_port",
+                         "-servername", "server.example",
+                         "-CAfile", $root_pem,
+                         "-ech_config_list", $good_b64,
+                         "-ech_no_outer_sni",
+                         "-prexit");
+    } elsif ($test_type eq "bad-ech" ) {
+        # bad ECH
+        @s_client_cmd = ("s_client",
+                         "-connect", "localhost:$s_server_port",
+                         "-servername", "server.example",
+                         "-CAfile", $root_pem,
+                         "-ech_config_list", "AEH+DQA91wAgACCBdNrnZxqNrUXSyimqqnfmNG4lHtVsbmaaIeRoUoFWFQAEAAEAAQAOc2VydmVyLmV4YW1wbGUAAA==",
+                         "-prexit");
+    } elsif ($test_type eq "cid-free" ) {
+        # Real ECH, ignore CID
+        @s_client_cmd = ("s_client",
+                         "-connect", "localhost:$s_server_port",
+                         "-servername", "server.example",
+                         "-CAfile", $root_pem,
+                         "-ech_config_list", $good_b64,
+                         "-ech_ignore_cid",
+                         "-prexit");
+    } elsif ($test_type eq "cid-wrong" ) {
+        # Real ECH, ignore CID, no trial decrypt
+        @s_client_cmd = ("s_client",
+                         "-connect", "localhost:$s_server_port",
+                         "-servername", "server.example",
+                         "-CAfile", $root_pem,
+                         "-ech_config_list", $good_b64,
+                         "-ech_ignore_cid",
+                         "-prexit");
+    } else {
+        # Real ECH, and default
+        @s_client_cmd = ("s_client",
+                         "-connect", "localhost:$s_server_port",
+                         "-servername", "server.example",
+                         "-CAfile", $root_pem,
+                         "-ech_config_list", $good_b64,
+                         "-prexit");
+    }
+    print("@s_client_cmd\n");
+    local (*sc_input);
+    my $s_client_pid = open3(*sc_input, my $s_client_o,
+                             my $s_client_e = gensym,
+                             $shlib_wrap, $apps_openssl, @s_client_cmd);
+    print sc_input "Q\n";
+    close(sc_input);
+    waitpid($s_client_pid, 0);
+    # the output from s_client that we want to check is written to its
+    # stdout, e.g: "^ECH: success, yay!"
+    $s_client_match = 0;
+    while (<$s_client_o>) {
+        print($_);
+        chomp;
+        if (/$winpattern/) {
+            $s_client_match = 1;
+            last;
+        }
+    }
+    my $stillthere = kill 0, $s_server_pid;
+    if ($stillthere) {
+       print("s_server process ($s_server_pid) is not dead yet.\n");
+       kill 'HUP', $s_server_pid;
+    }
+}
+
+sub basic_test {
+    print("\n\nBasic test.\n");
+    my $tt = "basic";
+    my $win = "^ECH: success";
+    start_ech_client_server($tt, $win);
+    ok($s_server_port ne "0", "s_server port check");
+    print("s_server ready, on port $s_server_port pid: $s_server_pid\n");
+    ok($s_client_match == 1, "s_client with ECH on command line");
+}
+
+sub wrong_test {
+    print("\n\nWrong ECHConfig test.\n");
+    # hardcoded 'cause we want a fail
+    my $tt="bad-ech",
+    my $win="^ECH: failed.retry-configs: -105";
+    start_ech_client_server($tt, $win);
+    ok($s_server_port ne "0", "s_server port check");
+    print("s_server ready, on port $s_server_port pid: $s_server_pid\n");
+    ok($s_client_match == 1, "s_client with bad ECH");
+}
+
+sub grease_test {
+    print("\n\nGREASE ECHConfig test.\n");
+    my $tt="GREASE";
+    my $win="^ECH: GREASE";
+    start_ech_client_server($tt, $win);
+    ok($s_server_port ne "0", "s_server port check");
+    print("s_server ready, on port $s_server_port pid: $s_server_pid\n");
+    ok($s_client_match == 1, "s_client with GREASE ECH");
+}
+
+sub grease_suite_test {
+    print("\n\nGREASE suite ECHConfig test.\n");
+    my $tt="GREASE-suite";
+    my $win="^ECH: GREASE";
+    start_ech_client_server($tt, $win);
+    ok($s_server_port ne "0", "s_server port check");
+    print("s_server ready, on port $s_server_port pid: $s_server_pid\n");
+    ok($s_client_match == 1, "s_client with GREASE-suite ECH");
+}
+
+sub grease_type_test {
+    print("\n\nGREASE type ECH test.\n");
+    my $tt="GREASE-type";
+    my $win="^ECH: GREASE";
+    start_ech_client_server($tt, $win);
+    ok($s_server_port ne "0", "s_server port check");
+    print("s_server ready, on port $s_server_port pid: $s_server_pid\n");
+    ok($s_client_match == 1, "s_client with GREASE-type ECH");
+}
+
+sub lots_of_options_test {
+    print("\n\nLots of options ECH test.\n");
+    my $tt="lots-of-options";
+    my $win="^ECH: success";
+    start_ech_client_server($tt, $win);
+    ok($s_server_port ne "0", "s_server port check");
+    print("s_server ready, on port $s_server_port pid: $s_server_pid\n");
+    ok($s_client_match == 1, "s_client with lots of ECH options");
+}
+
+sub no_outer_test {
+    print("\n\nNo outer SNI test.\n");
+    my $tt = "no-outer";
+    my $win = "^ECH: success";
+    start_ech_client_server($tt, $win);
+    ok($s_server_port ne "0", "s_server port check");
+    print("s_server ready, on port $s_server_port pid: $s_server_pid\n");
+    ok($s_client_match == 1, "s_client with no outer SNI ECH");
+}
+
+sub cid_free_test {
+    print("\n\nIgnore CIDs test.\n");
+    my $tt = "cid-free";
+    my $win = "^ECH: success";
+    start_ech_client_server($tt, $win);
+    ok($s_server_port ne "0", "s_server port check");
+    print("s_server ready, on port $s_server_port pid: $s_server_pid\n");
+    ok($s_client_match == 1, "s_client/s_server with no CID/trial decrypt");
+}
+
+sub cid_wrong_test {
+    print("\n\nIgnore CIDs test.\n");
+    my $tt = "cid-wrong";
+    my $win = "^ECH: failed";
+    start_ech_client_server($tt, $win);
+    ok($s_server_port ne "0", "s_server port check");
+    print("s_server ready, on port $s_server_port pid: $s_server_pid\n");
+    ok($s_client_match == 1, "s_client/s_server with no CID/no trial decrypt");
+}
+
+basic_test();
+wrong_test();
+grease_test();
+grease_suite_test();
+grease_type_test();
+lots_of_options_test();
+no_outer_test();
+cid_free_test();
+cid_wrong_test();
+