Commit bb105c9d42 for openssl.org
commit bb105c9d4209c3d64648f328af7b62d688d463d3
Author: Viktor Dukhovni <viktor@openssl.org>
Date: Wed Jul 22 13:52:13 2026 +1000
Suppress unusable PSK offers and 0-RTT client-side
Check that offered PSKs and early data can actually be used before
sending them, rather than offering them and letting the server refuse.
- Offer a resumption ticket or accept a psk_use_session callback PSK
only if some offered TLS 1.3 ciphersuite shares the PSK's handshake
digest. Otherwise the PSK is unusable: retire the ticket (falling
through to the next candidate) or drop the callback PSK.
- Attempt 0-RTT only when the first-offered PSK's exact ciphersuite is
offered and its ALPN, if any, is among those offered; otherwise
suppress early data but keep the PSK for a 1-RTT handshake. An ALPN
inconsistency detected by the client was previously fatal
(SSL_R_INCONSISTENT_EARLY_DATA_ALPN); it now suppresses 0-RTT only.
- Reject a psk_use_session callback PSK with an empty master key with
SSL_R_BAD_PSK instead of failing later in binder computation.
Along the way, to compare handshake digests, factor the digest lookup
out of SSL_CIPHER_get_handshake_digest() into a new internal helper
ssl_cipher_get_handshake_digest_nid().
The client now declines PSKs and 0-RTT it cannot use, so several
sslapitest early data tests are adjusted to offer a client configuration
under which 0-RTT still proceeds, keeping their tests of server-side
rejection effective.
Also add test_tls13_ticket_cipher_mismatch_reject_early_data, covering
server-side 0-RTT rejection on a cipher mismatch with a resumption
ticket; only the external-PSK cipher mismatch was previously tested.
All the while keep the client's PSK and 0-RTT offer self-consistent:
When resuming with a PSK and offering 0-RTT early data, the client
decided the same things in more than one place: whether the resumption
ticket is usable, which PSK it offers first, and whether that PSK's
cipher is actually on the wire. These were worked out separately while
building the early_data and pre_shared_key parts of the ClientHello and
could disagree -- notably when the application's security callback does
not give the same answer each time it is consulted.
That disagreement could break confidentiality. If early data was set up
against the resumption ticket but the ticket was then dropped while the
pre_shared_key extension was built, its binder never ran, the early
secret was left all zero, and the client still encrypted its 0-RTT data
under keys derived from that zero value -- which a passive observer can
reconstruct from the visible ClientHello alone.
Decide once and reuse the result:
- Settle whether the ticket is offered when early data is prepared,
and have the pre_shared_key extension follow that decision instead
of computing it again.
- Do not install the client's 0-RTT keys unless the first offered PSK
actually derived the early secret during this handshake, so 0-RTT is
never protected by an all-zero key.
- Count a cipher as offered only if it was placed in the ClientHello,
so 0-RTT is not advertised for a cipher the security callback kept
off the wire.
- After a HelloRetryRequest, do not add a PSK the first ClientHello
did not carry.
Add tests for the suppressed-0-RTT and HelloRetryRequest cases.
Assisted-by: Claude:claude-opus-4-8
Reviewed-by: Mounir Idrassi <mounir.idrassi@idrix.fr>
Reviewed-by: Norbert Pocs <norbertp@openssl.org>
Reviewed-by: Nikola Pajkovsky <nikolap@openssl.org>
MergeDate: Wed Sep 23 14:44:15 2026
(Merged from https://github.com/openssl/openssl/pull/32542)
diff --git a/doc/man3/SSL_export_keying_material.pod b/doc/man3/SSL_export_keying_material.pod
index 3949b1e36e..510c2261a4 100644
--- a/doc/man3/SSL_export_keying_material.pod
+++ b/doc/man3/SSL_export_keying_material.pod
@@ -37,6 +37,20 @@ TLS 1.3 RFC). For the client, the F<early_exporter_master_secret> is only
available when the client attempts to send 0-RTT data. For the server, it is
only available when the server accepts 0-RTT data.
+Calling SSL_write_early_data() does not by itself make the client's
+F<early_exporter_master_secret> available.
+If the PSK that would key the early data cannot be used for 0-RTT then the
+client sends no early data, derives no F<early_exporter_master_secret>, and
+SSL_export_keying_material_early() fails.
+L<SSL_get_early_data_status(3)> reports SSL_EARLY_DATA_REJECTED in that
+case, just as it does for early data that the server declined; but early data
+the server declined was nevertheless sent, so there the secret was derived and
+the export succeeds.
+The secret is likewise unavailable while an SSL_write_early_data() call is
+still incomplete: one that returned a retryable error, such as
+SSL_ERROR_WANT_WRITE on a large ClientHello, must first be retried through to
+success.
+
An application may need to securely establish the context within which this
keying material will be used. For example this may include identifiers for the
application session, application algorithms or parameters, or the lifetime of
@@ -73,7 +87,7 @@ SSL_export_keying_material_early() returns 0 on failure or 1 on success.
=head1 SEE ALSO
-L<ssl(7)>
+L<ssl(7)>, L<SSL_read_early_data(3)>
=head1 HISTORY
diff --git a/ssl/ssl_ciph.c b/ssl/ssl_ciph.c
index ce14497483..1d9f239afe 100644
--- a/ssl/ssl_ciph.c
+++ b/ssl/ssl_ciph.c
@@ -2168,13 +2168,22 @@ int ssl_get_md_idx(int md_nid)
return -1;
}
-const EVP_MD *SSL_CIPHER_get_handshake_digest(const SSL_CIPHER *c)
+int ssl_cipher_get_handshake_digest_nid(const SSL_CIPHER *c)
{
int idx = c->algorithm2 & SSL_HANDSHAKE_MAC_MASK;
if (idx < 0 || idx >= SSL_MD_NUM_IDX)
+ return NID_undef;
+ return ssl_cipher_table_mac[idx].nid;
+}
+
+const EVP_MD *SSL_CIPHER_get_handshake_digest(const SSL_CIPHER *c)
+{
+ int nid = ssl_cipher_get_handshake_digest_nid(c);
+
+ if (nid == NID_undef)
return NULL;
- return EVP_get_digestbynid(ssl_cipher_table_mac[idx].nid);
+ return EVP_get_digestbynid(nid);
}
int SSL_CIPHER_is_aead(const SSL_CIPHER *c)
diff --git a/ssl/ssl_lib.c b/ssl/ssl_lib.c
index 3717deb560..48e3fe19ee 100644
--- a/ssl/ssl_lib.c
+++ b/ssl/ssl_lib.c
@@ -595,6 +595,7 @@ int ossl_ssl_connection_reset(SSL *s)
sc->hit = 0;
sc->shutdown = 0;
sc->ext.early_data_suppressed = 0;
+ sc->ext.early_exporter_ready = 0;
SSL_SESSION_free(sc->ext.early_data_session);
sc->ext.early_data_session = NULL;
sc->ext.tick_age_checked = 0;
diff --git a/ssl/ssl_local.h b/ssl/ssl_local.h
index 9959871ecd..d31574648b 100644
--- a/ssl/ssl_local.h
+++ b/ssl/ssl_local.h
@@ -1889,6 +1889,36 @@ struct ssl_connection_st {
bool tick_age_checked;
bool tick_age_ok;
+ /*
+ * Client-only. Whether the loaded resumption ticket (s->session)
+ * qualifies as an offered PSK for the ClientHello under construction.
+ * Frozen once by tls_construct_ctos_early_data() (the first consumer,
+ * which also decides 0-RTT from it) so tls_construct_ctos_psk() offers
+ * exactly the same identity 0 -- never re-evaluating the SECOP-dependent
+ * tls13_digest_offered() a second time, which could otherwise drop the
+ * ticket after early_data was already committed and leave the early
+ * secret underived.
+ */
+ bool psk_resumption_offered;
+
+ /*
+ * Client-only. Set in tls_psk_do_binder() when the first-offered PSK's
+ * binder derives s->early_secret for the current ClientHello flight;
+ * reset at the top of tls_construct_ctos_early_data(). The early-write
+ * key install (tls13_change_cipher_state()) refuses to proceed unless it
+ * is set, so 0-RTT can never be keyed off an underived (all-zero)
+ * s->early_secret even if the offer and early_data decisions somehow
+ * diverge.
+ */
+ bool early_secret_derived;
+
+ /*
+ * Records that an early exporter secret actually exists. It may not
+ * yet be computed if the CH1 write blocks early enough. Remains
+ * unchanged after CH2.
+ */
+ bool early_exporter_ready;
+
/* Have we received a cookie from the client? */
bool cookieok;
@@ -3306,6 +3336,7 @@ __owur int ssl_handshake_hash(SSL_CONNECTION *s,
unsigned char *out, size_t outlen,
size_t *hashlen);
__owur const EVP_MD *ssl_md(SSL_CTX *ctx, int idx);
+__owur int ssl_cipher_get_handshake_digest_nid(const SSL_CIPHER *c);
int ssl_get_md_idx(int md_nid);
__owur const EVP_MD *ssl_handshake_md(SSL_CONNECTION *s);
__owur const EVP_MD *ssl_prf_md(SSL_CONNECTION *s);
diff --git a/ssl/statem/extensions.c b/ssl/statem/extensions.c
index 7400ec91b0..d09a43d616 100644
--- a/ssl/statem/extensions.c
+++ b/ssl/statem/extensions.c
@@ -1909,6 +1909,16 @@ int tls_psk_do_binder(SSL_CONNECTION *s, const EVP_MD *md,
goto err;
}
+ /*
+ * Client-only: record that this flight's first-offered PSK has derived
+ * s->early_secret, so the early-write key install can refuse to key 0-RTT
+ * off an underived (zero) secret. early_secret aliases s->early_secret
+ * exactly for the identity that 0-RTT is keyed on (the resumption PSK, or
+ * an external PSK selected for early data via usepskfored).
+ */
+ if (!s->server && early_secret == (unsigned char *)s->early_secret)
+ s->ext.early_secret_derived = 1;
+
/*
* Create the handshake hash for the binder key...the messages so far are
* empty!
diff --git a/ssl/statem/extensions_clnt.c b/ssl/statem/extensions_clnt.c
index c9a37512a5..3684360829 100644
--- a/ssl/statem/extensions_clnt.c
+++ b/ssl/statem/extensions_clnt.c
@@ -1097,6 +1097,39 @@ static int tls13_check_tick_lifetime_hint(SSL_CONNECTION *s)
return s->ext.tick_age_ok;
}
+/*
+ * True if a TLS 1.3 ciphersuite carrying the same handshake digest as |cipher|
+ * is being offered on this handshake. |cipher| must itself be a TLS 1.3 cipher:
+ * the algorithm2 handshake-MAC bits read here mean something else in a TLS 1.2
+ * suite, so a non-TLS-1.3 cipher (e.g. from a bogus callback PSK) is never viable.
+ */
+static int tls13_digest_offered(SSL_CONNECTION *s, const SSL_CIPHER *cipher)
+{
+ STACK_OF(SSL_CIPHER) *ciphers;
+ int i, n, want;
+
+ if (cipher == NULL || cipher->min_tls <= TLS1_2_VERSION)
+ return 0;
+ want = ssl_cipher_get_handshake_digest_nid(cipher);
+ if (want == NID_undef)
+ return 0;
+ ciphers = ssl_get_ciphers_by_id(s);
+ n = sk_SSL_CIPHER_num(ciphers);
+ for (i = 0; i < n; i++) {
+ const SSL_CIPHER *c = sk_SSL_CIPHER_value(ciphers, i);
+
+ /*
+ * SSL_SECOP_CIPHER_SUPPORTED matches the ssl_cipher_list_to_bytes()
+ * ciphersuite filter.
+ */
+ if (c->min_tls > TLS1_2_VERSION
+ && ssl_cipher_get_handshake_digest_nid(c) == want
+ && !ssl_cipher_disabled(s, c, SSL_SECOP_CIPHER_SUPPORTED))
+ return 1;
+ }
+ return 0;
+}
+
/*
* Mirrors the ticket-resumption gating checks in tls_construct_ctos_psk() so
* that early_data is only advertised when the resumption PSK will actually
@@ -1119,12 +1152,62 @@ static int tls13_check_resumption_psk(SSL_CONNECTION *s, const EVP_MD *handmd)
return 0;
if (s->hello_retry_request == SSL_HRR_PENDING && mdres != handmd)
return 0;
+ /* An offered TLS 1.3 ciphersuite must carry the ticket's digest. */
+ if (!tls13_digest_offered(s, s->session->cipher))
+ return 0;
if (tls13_check_tick_lifetime_hint(s) == 0)
return 0;
return 1;
}
+/*
+ * 0-RTT early data is protected with the PSK's own cipher, and per RFC 9846
+ * section 4.3.10 the server accepts it only if it negotiates that exact
+ * cipher. So if we are not even offering that cipher on this handshake, 0-RTT
+ * cannot be accepted and early data must be suppressed -- the client-side
+ * mirror of the server's cipher-commitment check. The PSK is still offered for
+ * 1-RTT resumption, which needs only a digest-compatible cipher.
+ */
+static int tls13_early_cipher_offered(SSL_CONNECTION *s, const SSL_SESSION *sess)
+{
+ const SSL_CIPHER *c = sess->cipher;
+
+ /*
+ * SSL_SECOP_CIPHER_SUPPORTED matches the ssl_cipher_list_to_bytes()
+ * ciphersuite filter.
+ */
+ return c != NULL
+ && !ssl_cipher_disabled(s, c, SSL_SECOP_CIPHER_SUPPORTED)
+ && sk_SSL_CIPHER_find(ssl_get_ciphers_by_id(s), c) >= 0;
+}
+
+/*
+ * 0-RTT is bound to the session's ALPN protocol, and the server accepts
+ * it only if it negotiates that protocol. So early data is viable only if that
+ * protocol is among the ones we offer -- we cannot know which the server will
+ * pick, so being present in our list is enough. A session with no ALPN imposes
+ * no constraint; a session with one while we offer none cannot match. ALPN
+ * affects 0-RTT only: resumption itself permits an ALPN change after the
+ * transition.
+ */
+static int tls13_early_alpn_offered(SSL_CONNECTION *s, const SSL_SESSION *sess)
+{
+ PACKET prots, alpnpkt;
+
+ if (sess->ext.alpn_selected == NULL)
+ return 1;
+ if (s->ext.alpn == NULL
+ || !PACKET_buf_init(&prots, s->ext.alpn, s->ext.alpn_len))
+ return 0;
+ while (PACKET_get_length_prefixed_1(&prots, &alpnpkt)) {
+ if (PACKET_equal(&alpnpkt, sess->ext.alpn_selected,
+ sess->ext.alpn_selected_len))
+ return 1;
+ }
+ return 0;
+}
+
EXT_RETURN tls_construct_ctos_early_data(SSL_CONNECTION *s, WPACKET *pkt,
unsigned int context, X509 *x,
size_t chainidx)
@@ -1169,12 +1252,22 @@ EXT_RETURN tls_construct_ctos_early_data(SSL_CONNECTION *s, WPACKET *pkt,
}
}
#endif
+
+ /*
+ * Reset the early-secret-derived marker here, past the ECH outer-CH no-op
+ * above, so the outer pass preserves the value the inner pass's binder set
+ * (the outer CH derives no early secret of its own).
+ */
+ s->ext.early_secret_derived = 0;
+
if (s->hello_retry_request == SSL_HRR_PENDING)
handmd = ssl_handshake_md(s);
if (s->psk_use_session_cb != NULL
&& (!s->psk_use_session_cb(ussl, handmd, &id, &idlen, &psksess)
- || (psksess != NULL && psksess->ssl_version != version1_3))) {
+ || (psksess != NULL
+ && (psksess->ssl_version != version1_3
+ || psksess->master_key_length == 0)))) {
SSL_SESSION_free(psksess);
SSLfatal(s, SSL_AD_INTERNAL_ERROR, SSL_R_BAD_PSK);
return EXT_RETURN_FAIL;
@@ -1230,6 +1323,17 @@ EXT_RETURN tls_construct_ctos_early_data(SSL_CONNECTION *s, WPACKET *pkt,
}
#endif /* OPENSSL_NO_PSK */
+ /*
+ * If no offered ciphersuite carries the callback PSK's digest -- or its
+ * cipher isn't a TLS 1.3 cipher -- it can never be used, so drop it here and
+ * proceed as if the callback had returned no PSK. Every later s->psksession
+ * check then handles it for free.
+ */
+ if (psksess != NULL && !tls13_digest_offered(s, psksess->cipher)) {
+ SSL_SESSION_free(psksess);
+ psksess = NULL;
+ }
+
SSL_SESSION_free(s->psksession);
s->psksession = psksess;
if (psksess != NULL) {
@@ -1255,45 +1359,48 @@ EXT_RETURN tls_construct_ctos_early_data(SSL_CONNECTION *s, WPACKET *pkt,
* the client's "pre_shared_key" extension.
*/
/*
- * Slot 0 -- the first identity we will offer -- is the only one that can
- * key 0-RTT. It is the resumption session when we are offering it, else
- * the external psksession. Offer early_data only when that slot-0 PSK is
- * itself 0-RTT-capable; never key it off a PSK in a later slot.
+ * The first PSK identity we offer is the only one that can key 0-RTT: the
+ * resumption session when we are offering it, else the external psksession.
+ * Offer early_data only when that first PSK is itself 0-RTT-capable; never
+ * key it off a PSK offered later.
+ */
+ /*
+ * Freeze the resumption-ticket offer decision here, at the first consumer,
+ * so tls_construct_ctos_psk() offers the very same identity 0 and its binder
+ * derives s->early_secret for whatever session early_data is keyed on. See
+ * s->ext.psk_resumption_offered.
*/
- edsess = tls13_check_resumption_psk(s, handmd) ? s->session : psksess;
+ s->ext.psk_resumption_offered = tls13_check_resumption_psk(s, handmd);
+ edsess = s->ext.psk_resumption_offered ? s->session : psksess;
if (s->early_data_state != SSL_EARLY_DATA_CONNECTING
|| edsess == NULL
- || edsess->ext.max_early_data == 0) {
+ || edsess->ext.max_early_data == 0
+ || !tls13_early_cipher_offered(s, edsess)
+ || !tls13_early_alpn_offered(s, edsess)) {
s->max_early_data = 0;
if (s->early_data_state == SSL_EARLY_DATA_CONNECTING) {
s->ext.early_data_suppressed = 1;
- s->ext.early_data = SSL_EARLY_DATA_REJECTED;
/*
- * We report REJECTED (not NOT_SENT), so
- * SSL_export_keying_material_early() stays callable as it is for a
- * server-rejected attempt -- but no early exporter secret was
- * derived here. Randomise it so any such export yields a harmless
- * per-connection orphan, not an all-zero (predictable) or stale
- * (prior-handshake) value.
+ * We report REJECTED rather than NOT_SENT, so that suppressing
+ * 0-RTT locally looks to the application just like a server that
+ * declined it.
+ *
+ * The early exporter is a separate question: no early secret is
+ * derived on this path, so s->ext.early_exporter_ready stays clear
+ * and SSL_export_keying_material_early() remains unavailable.
*/
- if (RAND_bytes_ex(SSL_CONNECTION_GET_CTX(s)->libctx,
- s->early_exporter_master_secret,
- sizeof(s->early_exporter_master_secret), 0)
- <= 0) {
- SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
- return EXT_RETURN_FAIL;
- }
+ s->ext.early_data = SSL_EARLY_DATA_REJECTED;
}
s->early_data_state = SSL_EARLY_DATA_NONE;
return EXT_RETURN_NOT_SENT;
}
s->max_early_data = edsess->ext.max_early_data;
/*
- * Freeze slot 0 (candidate_at(0)) so the binder, the early-key derivation,
- * the early exporter, the byte-budget lookup and the post-ServerHello fixup
- * all key off the actual first-offered PSK rather than guessing the source
- * from s->session->ext.max_early_data. Held (up-ref'd) so it stays valid
- * across the swap that later folds a selected psksession into s->session.
+ * Record the first-offered PSK so the binder, the early-key derivation, the
+ * early exporter, the byte-budget lookup and the post-ServerHello fixup all
+ * key off it rather than guessing the source from
+ * s->session->ext.max_early_data. Held (up-ref'd) so it stays valid across
+ * the swap that later folds a selected psksession into s->session.
*/
SSL_SESSION_free(s->ext.early_data_session);
s->ext.early_data_session = edsess;
@@ -1313,37 +1420,6 @@ EXT_RETURN tls_construct_ctos_early_data(SSL_CONNECTION *s, WPACKET *pkt,
}
}
- if ((s->ext.alpn == NULL && edsess->ext.alpn_selected != NULL)) {
- SSLfatal(s, SSL_AD_INTERNAL_ERROR, SSL_R_INCONSISTENT_EARLY_DATA_ALPN);
- return EXT_RETURN_FAIL;
- }
-
- /*
- * Verify that we are offering an ALPN protocol consistent with the early
- * data.
- */
- if (edsess->ext.alpn_selected != NULL) {
- PACKET prots, alpnpkt;
- int found = 0;
-
- if (!PACKET_buf_init(&prots, s->ext.alpn, s->ext.alpn_len)) {
- SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
- return EXT_RETURN_FAIL;
- }
- while (PACKET_get_length_prefixed_1(&prots, &alpnpkt)) {
- if (PACKET_equal(&alpnpkt, edsess->ext.alpn_selected,
- edsess->ext.alpn_selected_len)) {
- found = 1;
- break;
- }
- }
- if (!found) {
- SSLfatal(s, SSL_AD_INTERNAL_ERROR,
- SSL_R_INCONSISTENT_EARLY_DATA_ALPN);
- return EXT_RETURN_FAIL;
- }
- }
-
if (!WPACKET_put_bytes_u16(pkt, TLSEXT_TYPE_early_data)
|| !WPACKET_start_sub_packet_u16(pkt)
|| !WPACKET_close(pkt)) {
@@ -1388,6 +1464,20 @@ EXT_RETURN tls_construct_ctos_psk(SSL_CONNECTION *s, WPACKET *pkt,
|| (s->session->ext.ticklen == 0 && s->psksession == NULL))
return EXT_RETURN_NOT_SENT;
+ /*
+ * After a HelloRetryRequest, do not introduce a pre_shared_key extension
+ * that the first ClientHello (CH1) did not carry. RFC 9846 4.2.2 lets CH2
+ * update or drop PSKs, but not add one -- let alone add the whole
+ * extension. This could otherwise happen if a psk_use_session callback
+ * only yields a digest-compatible PSK once the retry has settled the
+ * ciphersuite. While a misbehaving callback could still introduce a
+ * novel PSK only after HRR, that's an application bug, expected rare and
+ * likely harmless if it occurs.
+ */
+ if (s->hello_retry_request == SSL_HRR_PENDING
+ && (s->ext.extflags[TLSEXT_IDX_psk] & SSL_EXT_FLAG_SENT) == 0)
+ return EXT_RETURN_NOT_SENT;
+
if (s->hello_retry_request == SSL_HRR_PENDING)
handmd = ssl_handshake_md(s);
@@ -1427,6 +1517,15 @@ EXT_RETURN tls_construct_ctos_psk(SSL_CONNECTION *s, WPACKET *pkt,
}
#endif
+ /*
+ * Consume the offer decision frozen by tls_construct_ctos_early_data()
+ * rather than re-running the SECOP-dependent tls13_digest_offered() a
+ * second time: an answer that flipped between the two calls would drop
+ * the ticket after early_data was already committed, leaving
+ * s->early_secret underived and 0-RTT keyed off a zero secret.
+ */
+ if (!s->ext.psk_resumption_offered)
+ goto dopsksess;
if (tls13_check_tick_lifetime_hint(s) == 0)
goto dopsksess;
/* tls13_check_tick_lifetime_hint() updates the tick_age_ms value. */
diff --git a/ssl/statem/statem.c b/ssl/statem/statem.c
index abea35ae80..b8a31857bf 100644
--- a/ssl/statem/statem.c
+++ b/ssl/statem/statem.c
@@ -1085,11 +1085,5 @@ int ossl_statem_export_allowed(SSL_CONNECTION *s)
*/
int ossl_statem_export_early_allowed(SSL_CONNECTION *s)
{
- /*
- * The early exporter secret is only present on the server if we
- * have accepted early_data. It is present on the client as long
- * as we have sent early_data.
- */
- return s->ext.early_data == SSL_EARLY_DATA_ACCEPTED
- || (!s->server && s->ext.early_data != SSL_EARLY_DATA_NOT_SENT);
+ return s->ext.early_exporter_ready;
}
diff --git a/ssl/tls13_enc.c b/ssl/tls13_enc.c
index 4624e9ca05..1dc0f69e02 100644
--- a/ssl/tls13_enc.c
+++ b/ssl/tls13_enc.c
@@ -583,6 +583,19 @@ int tls13_change_cipher_state(SSL_CONNECTION *s, int which)
labellen = sizeof(client_early_traffic) - 1;
log_label = CLIENT_EARLY_LABEL;
+ /*
+ * Client: never install early-write keys from an underived (zero)
+ * s->early_secret. It is set only when the first-offered PSK's
+ * binder derived it this ClientHello flight (see
+ * s->ext.early_secret_derived). If it is not set, the PSK offer and
+ * the early_data decision diverged; fail closed rather than protect
+ * 0-RTT under a secret a passive observer could reconstruct.
+ */
+ if ((which & SSL3_CC_CLIENT) != 0 && !s->ext.early_secret_derived) {
+ SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
+ goto err;
+ }
+
#ifndef OPENSSL_NO_ECH
/* if ECH worked then use the innerch and not the h/s buffer here */
if (((which & SSL3_CC_SERVER) && s->ext.ech.success == 1)
@@ -605,8 +618,8 @@ int tls13_change_cipher_state(SSL_CONNECTION *s, int which)
}
/*
- * 0-RTT keys off the frozen slot-0 PSK (candidate_at(0)), which
- * may be the external psksession rather than s->session.
+ * 0-RTT keys off the recorded first-offered PSK, which may be the
+ * external psksession rather than s->session.
*/
if (s->early_data_state == SSL_EARLY_DATA_CONNECTING
&& s->ext.early_data_session != NULL) {
@@ -697,6 +710,7 @@ int tls13_change_cipher_state(SSL_CONNECTION *s, int which)
/* SSLfatal() already called */
goto err;
}
+ s->ext.early_exporter_ready = 1;
} else if (which & SSL3_CC_HANDSHAKE) {
insecret = s->handshake_secret;
finsecret = s->client_finished_secret;
@@ -1046,6 +1060,8 @@ int tls13_export_keying_material_early(SSL_CONNECTION *s,
sslcipher = SSL_SESSION_get0_cipher(s->ext.early_data_session);
else
sslcipher = SSL_SESSION_get0_cipher(s->session);
+ if (sslcipher == NULL)
+ goto err;
md = ssl_md(SSL_CONNECTION_GET_CTX(s), sslcipher->algorithm2);
diff --git a/test/sslapitest.c b/test/sslapitest.c
index 133e2efb42..f52effc033 100644
--- a/test/sslapitest.c
+++ b/test/sslapitest.c
@@ -5341,9 +5341,29 @@ static int early_data_skip_helper(int testdtls, int testtype, int cipher, int id
SSL_CTX_set_security_level(cctx, 0);
}
- if (!TEST_true(SSL_CTX_set_ciphersuites(sctx, ciphersuites[cipher]))
- || !TEST_true(SSL_CTX_set_ciphersuites(cctx, ciphersuites[cipher])))
+ if (!TEST_true(SSL_CTX_set_ciphersuites(sctx, ciphersuites[cipher])))
goto end;
+ if (idx == 2) {
+ /*
+ * The external PSK (see create_a_psk) is stamped with a fixed
+ * ciphersuite that need not be the one under test. For the client to
+ * offer 0-RTT that ciphersuite must be among those it offers, so offer
+ * it alongside the ciphersuite under test. The server offers only the
+ * ciphersuite under test, so the one it selects differs from the PSK's
+ * whenever the two are not the same.
+ */
+ char clientsuites[128];
+
+ snprintf(clientsuites, sizeof(clientsuites), "%s:%s",
+ ciphersuites[cipher],
+ (cipher == 2 || cipher == 6)
+ ? "TLS_AES_256_GCM_SHA384"
+ : "TLS_AES_128_GCM_SHA256");
+ if (!TEST_true(SSL_CTX_set_ciphersuites(cctx, clientsuites)))
+ goto end;
+ } else if (!TEST_true(SSL_CTX_set_ciphersuites(cctx, ciphersuites[cipher]))) {
+ goto end;
+ }
if (!TEST_true(setupearly_data_test(&cctx, &sctx, &clientssl,
&serverssl, &sess, idx,
@@ -5755,7 +5775,7 @@ static int test_early_data_psk(int idx)
#define BADALPNLEN 8
#define GOODALPN (alpnlist)
#define BADALPN (alpnlist + GOODALPNLEN)
- int err = 0;
+ int err = 0, suppressed = 0;
unsigned char buf[20];
size_t readbytes, written;
int readearlyres = SSL_READ_EARLY_DATA_SUCCESS, connectres = 1;
@@ -5801,8 +5821,12 @@ static int test_early_data_psk(int idx)
break;
case 1:
- /* Set inconsistent ALPN (early client detection) */
- err = SSL_R_INCONSISTENT_EARLY_DATA_ALPN;
+ /*
+ * Inconsistent ALPN, detected by the client before it sends: the
+ * offered ALPN cannot include the session's, so the client suppresses
+ * 0-RTT and completes a full handshake instead of failing.
+ */
+ suppressed = 1;
/* SSL_set_alpn_protos returns 0 for success and 1 for failure */
if (!TEST_true(SSL_SESSION_set1_alpn_selected(sess, GOODALPN,
GOODALPNLEN))
@@ -5903,6 +5927,25 @@ static int test_early_data_psk(int idx)
|| !TEST_int_eq(SSL_get_error(clientssl, 0), SSL_ERROR_SSL)
|| !TEST_int_eq(ERR_GET_REASON(ERR_get_error()), err))
goto end;
+ } else if (suppressed) {
+ /*
+ * The client suppresses 0-RTT: the first SSL_write_early_data() writes
+ * the ClientHello (without early data) and reports "retry". The server
+ * sees no early data, and both ends complete a full handshake with the
+ * early data reported as rejected on the client.
+ */
+ if (!TEST_false(SSL_write_early_data(clientssl, MSG1, strlen(MSG1),
+ &written))
+ || !TEST_int_eq(SSL_get_error(clientssl, 0), SSL_ERROR_WANT_READ)
+ || !TEST_int_eq(SSL_read_early_data(serverssl, buf, sizeof(buf),
+ &readbytes),
+ SSL_READ_EARLY_DATA_FINISH)
+ || !TEST_size_t_eq(readbytes, 0)
+ || !TEST_true(create_ssl_connection(serverssl, clientssl,
+ SSL_ERROR_NONE))
+ || !TEST_int_eq(SSL_get_early_data_status(clientssl),
+ SSL_EARLY_DATA_REJECTED))
+ goto end;
} else {
OSSL_TIME timer = ossl_time_now();
@@ -6243,11 +6286,14 @@ static int test_early_data_psk_cipher_mismatch(void)
goto end;
/*
- * The PSK is bound to AES-128-GCM (SHA256 digest), but both ends can
- * only negotiate ChaCha20-Poly1305 -- same digest, different cipher.
+ * The PSK is bound to AES-128-GCM (SHA256 digest). The client offers
+ * both AES-128-GCM and ChaCha20-Poly1305, so it can (and does) send 0-RTT
+ * keyed with the PSK's cipher; the server offers only ChaCha20-Poly1305.
+ * The server therefore selects a different cipher than the one that keyed
+ * the early data -- same digest -- and rejects the early data.
*/
if (!TEST_true(SSL_set_ciphersuites(clientssl,
- "TLS_CHACHA20_POLY1305_SHA256"))
+ "TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256"))
|| !TEST_true(SSL_set_ciphersuites(serverssl,
"TLS_CHACHA20_POLY1305_SHA256")))
goto end;
@@ -6283,6 +6329,216 @@ end:
return 1;
#endif
}
+
+/*
+ * A security callback that keeps TLS_AES_128_GCM_SHA256 off the wire, while
+ * leaving it in the configured ciphersuite list.
+ */
+static int no_aes128gcm_supported_cb(const SSL *ssl, const SSL_CTX *ctx,
+ int op, int bits, int nid, void *other, void *ex)
+{
+ if (op == SSL_SECOP_CIPHER_SUPPORTED && other != NULL
+ && strcmp(SSL_CIPHER_get_name((const SSL_CIPHER *)other),
+ "TLS_AES_128_GCM_SHA256")
+ == 0)
+ return 0;
+ return 1;
+}
+
+/*
+ * A PSK bound to a SHA-256 cipher is offered, but a security callback drops
+ * that exact cipher from the ClientHello (leaving ChaCha20-Poly1305, same
+ * digest). Per RFC 9846 4.3.10 0-RTT needs the PSK's exact cipher on the wire,
+ * so early_data must be suppressed -- yet the PSK is still digest-compatible
+ * and resumes at 1-RTT. Regression guard for tls13_early_cipher_offered() /
+ * tls13_digest_offered() testing SSL_SECOP_CIPHER_SUPPORTED (what is actually
+ * serialised) rather than the configured list under SSL_SECOP_CIPHER_CHECK.
+ */
+static int test_early_data_psk_cipher_off_wire(void)
+{
+#if !defined(OPENSSL_NO_CHACHA) && !defined(OPENSSL_NO_POLY1305)
+ SSL_CTX *cctx = NULL, *sctx = NULL;
+ SSL *clientssl = NULL, *serverssl = NULL;
+ int testresult = 0;
+ SSL_SESSION *sess = NULL;
+ unsigned char buf[20];
+ unsigned char edexp[32];
+ size_t readbytes, written;
+
+ if (is_fips)
+ return TEST_skip("CHACHA is not supported in FIPS");
+
+ if (!TEST_true(setupearly_data_test(&cctx, &sctx, &clientssl,
+ &serverssl, &sess, 2, SHA256_DIGEST_LENGTH, 0)))
+ goto end;
+
+ if (!TEST_true(SSL_set_ciphersuites(clientssl,
+ "TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256"))
+ || !TEST_true(SSL_set_ciphersuites(serverssl,
+ "TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256")))
+ goto end;
+
+ /* Drop the PSK's exact cipher from the client's wire offer. */
+ SSL_set_security_level(clientssl, 0);
+ SSL_set_security_callback(clientssl, no_aes128gcm_supported_cb);
+
+ SSL_set_connect_state(clientssl);
+
+ /*
+ * The client suppresses 0-RTT (the PSK cipher is not on the wire): the
+ * first SSL_write_early_data() sends the ClientHello without early data and
+ * reports "retry"; the server sees no early data; the connection completes
+ * at 1-RTT with early data rejected on the client.
+ */
+ if (!TEST_false(SSL_write_early_data(clientssl, MSG1, strlen(MSG1),
+ &written))
+ || !TEST_int_eq(SSL_get_error(clientssl, 0), SSL_ERROR_WANT_READ)
+ /*
+ * Suppressing 0-RTT derives no early secrets, so the early exporter
+ * must be unavailable, both before the handshake completes -- where
+ * there is not even a negotiated cipher to take a digest from -- and
+ * after, where the status reads REJECTED as it would for a server
+ * rejection.
+ */
+ || !TEST_false(SSL_export_keying_material_early(clientssl, edexp,
+ sizeof(edexp), "label", 5, NULL, 0))
+ || !TEST_int_eq(SSL_read_early_data(serverssl, buf, sizeof(buf),
+ &readbytes),
+ SSL_READ_EARLY_DATA_FINISH)
+ || !TEST_size_t_eq(readbytes, 0)
+ || !TEST_true(create_ssl_connection(serverssl, clientssl,
+ SSL_ERROR_NONE))
+ || !TEST_int_eq(SSL_get_early_data_status(clientssl),
+ SSL_EARLY_DATA_REJECTED)
+ || !TEST_false(SSL_export_keying_material_early(clientssl, edexp,
+ sizeof(edexp), "label", 5, NULL, 0)))
+ goto end;
+
+ /* But the PSK still resumed, on the digest-compatible cipher. */
+ if (!TEST_true(SSL_session_reused(clientssl))
+ || !TEST_str_eq(SSL_CIPHER_get_name(SSL_get_current_cipher(clientssl)),
+ "TLS_CHACHA20_POLY1305_SHA256"))
+ goto end;
+
+ testresult = 1;
+end:
+ SSL_SESSION_free(sess);
+ SSL_SESSION_free(clientpsk);
+ SSL_SESSION_free(serverpsk);
+ clientpsk = serverpsk = NULL;
+ SSL_free(serverssl);
+ SSL_free(clientssl);
+ SSL_CTX_free(sctx);
+ SSL_CTX_free(cctx);
+ return testresult;
+#else
+ return 1;
+#endif
+}
+
+#ifndef OPENSSL_NO_EC
+/* A raw external PSK bound to |suite_id|'s ciphersuite, master key all 0x01. */
+static SSL_SESSION *make_hrr_psk(SSL *ssl, const unsigned char *suite_id)
+{
+ static const unsigned char key[32] = {
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1
+ };
+ const SSL_CIPHER *cipher = SSL_CIPHER_find(ssl, suite_id);
+ SSL_SESSION *s = SSL_SESSION_new();
+
+ if (s == NULL || cipher == NULL
+ || !SSL_SESSION_set1_master_key(s, key, sizeof(key))
+ || !SSL_SESSION_set_cipher(s, cipher)
+ || !SSL_SESSION_set_protocol_version(s, TLS1_3_VERSION)) {
+ SSL_SESSION_free(s);
+ return NULL;
+ }
+ return s;
+}
+
+static const unsigned char hrr_psk_id[] = "hrrpskid";
+
+/*
+ * A poorly-behaved callback that yields a PSK bound to a SHA-384 cipher on the
+ * first (md == NULL) call and a SHA-256 one once the digest is known.
+ */
+static int hrr_use_session_cb(SSL *ssl, const EVP_MD *md,
+ const unsigned char **id, size_t *idlen, SSL_SESSION **sess)
+{
+ static const unsigned char sha384_id[] = { 0x13, 0x02 };
+ static const unsigned char sha256_id[] = { 0x13, 0x01 };
+
+ *id = hrr_psk_id;
+ *idlen = sizeof(hrr_psk_id) - 1;
+ if (md == NULL)
+ *sess = make_hrr_psk(ssl, sha384_id);
+ else if (EVP_MD_is_a(md, "SHA256"))
+ *sess = make_hrr_psk(ssl, sha256_id);
+ else
+ *sess = NULL;
+ return *sess != NULL;
+}
+
+static int hrr_find_session_cb(SSL *ssl, const unsigned char *identity,
+ size_t identity_len, SSL_SESSION **sess)
+{
+ static const unsigned char sha256_id[] = { 0x13, 0x01 };
+
+ if (identity_len != sizeof(hrr_psk_id) - 1
+ || memcmp(identity, hrr_psk_id, identity_len) != 0) {
+ *sess = NULL;
+ return 1;
+ }
+ *sess = make_hrr_psk(ssl, sha256_id);
+ return *sess != NULL;
+}
+
+/*
+ * After a HelloRetryRequest the second ClientHello must not introduce a
+ * pre_shared_key extension the first lacked (RFC 9846 4.2.2). Here the client
+ * offers only TLS_AES_128_GCM_SHA256, so the callback's first (SHA-384) PSK is
+ * dropped and CH1 carries no PSK; the retry then makes the callback yield a
+ * digest-compatible SHA-256 PSK. Check that no PSK extension is added in CH2.
+ */
+static int test_hrr_psk_no_ch2_add(void)
+{
+ SSL_CTX *cctx = NULL, *sctx = NULL;
+ SSL *clientssl = NULL, *serverssl = NULL;
+ int testresult = 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))
+ || !TEST_true(SSL_CTX_set_ciphersuites(cctx, "TLS_AES_128_GCM_SHA256"))
+ || !TEST_true(SSL_CTX_set_ciphersuites(sctx, "TLS_AES_128_GCM_SHA256")))
+ goto end;
+ SSL_CTX_set_psk_use_session_callback(cctx, hrr_use_session_cb);
+ SSL_CTX_set_psk_find_session_callback(sctx, hrr_find_session_cb);
+
+ if (!TEST_true(create_ssl_objects(sctx, cctx, &serverssl, &clientssl,
+ NULL, NULL))
+ /* Client sends a P-256 key_share; server forces a retry to P-384. */
+ || !TEST_true(SSL_set1_groups_list(clientssl, "P-256:P-384"))
+ || !TEST_true(SSL_set1_groups_list(serverssl, "P-384")))
+ goto end;
+
+ if (!TEST_true(create_ssl_connection(serverssl, clientssl, SSL_ERROR_NONE)))
+ goto end;
+
+ /* CH2 must not have added a PSK, so no resumption happened. */
+ if (!TEST_false(SSL_session_reused(clientssl)))
+ goto end;
+
+ testresult = 1;
+end:
+ SSL_free(serverssl);
+ SSL_free(clientssl);
+ SSL_CTX_free(sctx);
+ SSL_CTX_free(cctx);
+ return testresult;
+}
+#endif /* OPENSSL_NO_EC */
#endif /* !defined(OSSL_NO_USABLE_TLS1_3) */
#if !defined(OSSL_NO_USABLE_TLS1_3) && !defined(OPENSSL_NO_TLS1_2)
@@ -17310,6 +17566,10 @@ int setup_tests(void)
ADD_ALL_TESTS(test_early_data_not_expected, 6);
#if !defined(OSSL_NO_USABLE_TLS1_3)
ADD_TEST(test_early_data_psk_cipher_mismatch);
+ ADD_TEST(test_early_data_psk_cipher_off_wire);
+#ifndef OPENSSL_NO_EC
+ ADD_TEST(test_hrr_psk_no_ch2_add);
+#endif
#endif
#endif /* !defined(OSSL_NO_USABLE_TLS1_3) || !defined(OSSL_NO_USABLE_DTLS1_3) */
#if !defined(OSSL_NO_USABLE_TLS1_3) && !defined(OPENSSL_NO_TLS1_2)
diff --git a/test/tls13tickettest.c b/test/tls13tickettest.c
index fa2a012842..300c6b2dbc 100644
--- a/test/tls13tickettest.c
+++ b/test/tls13tickettest.c
@@ -291,7 +291,7 @@ static int ticket_disable(SSL_CTX *ctx)
* A fixed, 0-RTT-capable external PSK (RFC 9846), offered via the
* psk_use_session (client) and psk_find_session (server) callbacks. Used to
* exercise 0-RTT keyed off an external PSK while a retired resumption ticket is
- * also present: the external PSK is the first offered identity (slot 0).
+ * also present: the external PSK is the first offered identity.
*/
static const unsigned char ext_psk_id[] = {
'e', 'x', 't', '-', 'p', 's', 'k'
@@ -376,6 +376,33 @@ static int enable_shared_psk(SSL *cssl, SSL *sssl)
return 1;
}
+/*
+ * A malformed client psk_use_session callback: a TLS 1.3 session with a cipher
+ * but no master key set. The client must reject it rather than derive a binder
+ * from an empty secret.
+ */
+static int nomasterkey_psk_use_cb(SSL *ssl, const EVP_MD *md,
+ const unsigned char **id, size_t *idlen, SSL_SESSION **sess)
+{
+ static const unsigned char tls13_aes128gcmsha256_id[] = { 0x13, 0x01 };
+ SSL_SESSION *ns = SSL_SESSION_new();
+ const SSL_CIPHER *cipher = SSL_CIPHER_find(ssl, tls13_aes128gcmsha256_id);
+
+ (void)md;
+ if (ns == NULL
+ || cipher == NULL
+ || !SSL_SESSION_set_cipher(ns, cipher)
+ || !SSL_SESSION_set_protocol_version(ns, TLS1_3_VERSION)) {
+ SSL_SESSION_free(ns);
+ return 0;
+ }
+ /* Deliberately leave the master key unset. */
+ *sess = ns;
+ *id = ext_psk_id;
+ *idlen = sizeof(ext_psk_id);
+ return 1;
+}
+
/*
* The server offers a single protocol via server_alpn and selects it when the
* client advertises it. The client advertises a protocol using the
@@ -1241,6 +1268,92 @@ static int test_tls13_ticket_alpn_mismatch_reject_early_data(void)
return test;
}
+/*
+ * TLS 1.3 server-side 0-RTT rejection on a cipher mismatch.
+ *
+ * The early data is protected with the cipher recorded in the PSK, so the
+ * server can only accept 0-RTT if it selects that same cipher. Here the client
+ * offers the ticket's cipher (so it is willing to send early data, keyed with
+ * it) alongside a second cipher of the same digest; the server offers only the
+ * second cipher. PSK resumption still succeeds (the digest matches, so the
+ * binder validates), but the server negotiates a cipher other than the one that
+ * keyed the early data and therefore refuses the early data.
+ *
+ * This is the server-side counterpart to the client-side cipher suppression in
+ * test_tls13_ticket_cipher_mismatch_suppress_early_data(): there the ticket's
+ * cipher is not offered at all, so the client declines 0-RTT before sending;
+ * here it is offered, so the client sends and the server refuses.
+ */
+static int test_tls13_ticket_cipher_mismatch_reject_early_data(void)
+{
+ const unsigned char m[] = "message";
+ unsigned char buf[256];
+ SSL_CTX *c = NULL, *s = NULL;
+ struct tls13_channel initial = { .c.ssl = NULL, .s.ssl = NULL };
+ struct tls13_channel resumed = { .c.ssl = NULL, .s.ssl = NULL };
+ SSL_SESSION *sess = NULL;
+ unsigned char edexp[32];
+ size_t w = 0, r = 0;
+ int test;
+
+ test = TEST_true(create_ssl_ctx_pair(NULL, TLS_server_method(), TLS_client_method(),
+ TLS1_3_VERSION, TLS1_3_VERSION, &s, &c, cert, pkey))
+ && TEST_true(set_ctx_callbacks(c, s))
+ && TEST_true(ticket_enable(s))
+ && TEST_true(ticket_enable(c))
+ && TEST_true(SSL_CTX_set_max_early_data(s, SSL3_RT_MAX_PLAIN_LENGTH))
+ && TEST_true(SSL_CTX_set_options(s, SSL_OP_NO_ANTI_REPLAY) != 0)
+ /* Connection 1: negotiate AES-128-GCM and store it in the ticket. */
+ && TEST_true(SSL_CTX_set_ciphersuites(s, "TLS_AES_128_GCM_SHA256"))
+ && TEST_true(SSL_CTX_set_ciphersuites(c, "TLS_AES_128_GCM_SHA256"))
+ && TEST_true(tls_channel_init(c, s, &initial))
+ && TEST_true(create_ssl_connection(initial.s.ssl, initial.c.ssl, 0))
+ && TEST_true(tls_shutdown(&initial))
+ && TEST_uint_eq(initial.c.stats.nst_msgs, 2)
+ && TEST_uint_eq(initial.s.stats.nst_msgs, 2)
+ && TEST_ptr(sess = SSL_get1_session(initial.c.ssl))
+ /*
+ * Connection 2: attempt 0-RTT. The client offers AES-128-GCM (matching
+ * the ticket, so it is willing to send early data keyed with it) plus
+ * AES-128-CCM; the server offers only AES-128-CCM. Both share the
+ * SHA256 digest.
+ */
+ && TEST_true(SSL_CTX_set_ciphersuites(s, "TLS_AES_128_CCM_SHA256"))
+ && TEST_true(SSL_CTX_set_ciphersuites(c,
+ "TLS_AES_128_GCM_SHA256:TLS_AES_128_CCM_SHA256"))
+ && TEST_true(tls_channel_init(c, s, &resumed))
+ && TEST_true(SSL_set_session(resumed.c.ssl, sess))
+ && TEST_true(SSL_write_early_data(resumed.c.ssl, m, sizeof(m), &w))
+ && TEST_size_t_eq(w, sizeof(m))
+ /* The server skips the early data: nothing is delivered to the app. */
+ && TEST_int_eq(SSL_read_early_data(resumed.s.ssl, buf, sizeof(buf), &r),
+ SSL_READ_EARLY_DATA_FINISH)
+ && TEST_size_t_eq(r, 0)
+ && TEST_true(create_ssl_connection(resumed.s.ssl, resumed.c.ssl, 0))
+ && TEST_int_eq(SSL_get_early_data_status(resumed.c.ssl),
+ SSL_EARLY_DATA_REJECTED)
+ /*
+ * Here the client really did send 0-RTT and so derived the early
+ * secrets, only for the server to decline it. Unlike a client that
+ * suppressed 0-RTT of its own accord -- which reports the same
+ * REJECTED status -- the early exporter therefore stays available.
+ */
+ && TEST_int_eq(SSL_export_keying_material_early(resumed.c.ssl, edexp,
+ sizeof(edexp), "label", 5, NULL, 0),
+ 1)
+ /* PSK resumption still succeeds, only 0-RTT is refused. */
+ && TEST_true(SSL_session_reused(resumed.c.ssl))
+ && TEST_uint_eq(resumed.s.stats.ee_has_early_data, 0)
+ && TEST_uint_eq(resumed.c.stats.ee_has_early_data, 0);
+
+ SSL_SESSION_free(sess);
+ tls_channel_fini(&initial);
+ tls_channel_fini(&resumed);
+ SSL_CTX_free(c);
+ SSL_CTX_free(s);
+ return test;
+}
+
enum endpoint_state {
ENDPOINT_WRITE_EARLY_DATA,
ENDPOINT_READ_EARLY_DATA,
@@ -1564,7 +1677,7 @@ OPT_TEST_DECLARE_USAGE("\n")
* The client holds a 0-RTT-capable resumption ticket that has aged past its
* lifetime, so tls_construct_ctos_psk() does not offer it; an external PSK
* from the psk_use_session callback therefore occupies identity 0 and keys the
- * early data. The keying sites must follow that slot-0 PSK, not the retired
+ * early data. The keying sites must follow that first-offered PSK, not the retired
* ticket (whose max_early_data is still non-zero) -- otherwise client and
* server derive different CLIENT_EARLY_TRAFFIC_SECRET values and the server
* fails with a bad record MAC. Regression test for the mixed aged-ticket +
@@ -1598,13 +1711,13 @@ static int test_tls13_aged_ticket_external_psk_early_data(void)
&& TEST_true(SSL_CTX_set_max_early_data(s, SSL3_RT_MAX_PLAIN_LENGTH))
&& TEST_true(SSL_CTX_set_options(s, SSL_OP_NO_ANTI_REPLAY) != 0)
/* Short ticket lifetime so the backdated ticket ages out client-side
- * and is not offered, leaving the external PSK at slot 0. */
+ * and is not offered, leaving the external PSK first. */
&& TEST_true(SSL_CTX_set_timeout(s, 1) > 0)
&& TEST_true(tls_channel_init(c, s, &initial))
&& TEST_true(create_ssl_connection(initial.s.ssl, initial.c.ssl, 0))
&& TEST_true(tls_shutdown(&initial))
&& TEST_ptr(sess = SSL_get1_session(initial.c.ssl))
- /* Retire the (0-RTT-capable) ticket so it is not offered at slot 0. */
+ /* Retire the (0-RTT-capable) ticket so it is not offered first. */
&& TEST_time_t_gt(SSL_SESSION_set_time_ex(sess, time(NULL) - 10), 0)
&& TEST_true(tls_channel_init(c, s, &resumed))
&& TEST_true(SSL_set_session(resumed.c.ssl, sess))
@@ -1626,7 +1739,7 @@ static int test_tls13_aged_ticket_external_psk_early_data(void)
/*
* The early exporter secret must match on both ends -- an independent
* check (separate from the decrypted early data) that both sides keyed
- * 0-RTT from the same slot-0 PSK.
+ * 0-RTT from the same first-offered PSK.
*/
&& TEST_int_eq(SSL_export_keying_material_early(resumed.c.ssl, ceed,
sizeof(ceed), "label", 5, (const unsigned char *)"ctx", 3),
@@ -1635,7 +1748,7 @@ static int test_tls13_aged_ticket_external_psk_early_data(void)
sizeof(seed), "label", 5, (const unsigned char *)"ctx", 3),
1)
&& TEST_mem_eq(ceed, sizeof(ceed), seed, sizeof(seed))
- /* The external PSK (slot 0) keyed 0-RTT, not the retired ticket. */
+ /* The external PSK (offered first) keyed 0-RTT, not the retired ticket. */
&& TEST_uint_eq(resumed.c.stats.ch_has_psk, 1)
&& TEST_uint_eq(resumed.s.stats.ch_has_psk, 1)
&& TEST_uint_eq(resumed.c.stats.ch_has_early_data, 1)
@@ -1690,6 +1803,308 @@ static int test_tls13_external_psk_sid_ctx_not_shared(void)
return test;
}
+/*
+ * TLS 1.3 0-RTT suppressed when the ticket's exact cipher is no longer offered.
+ *
+ * The 0-RTT-capable ticket was issued under TLS_AES_128_GCM_SHA256; the
+ * resumption offers only TLS_AES_128_CCM_SHA256 -- same digest, different
+ * cipher. Resumption stays viable (digest-compatible), so the ticket is still
+ * offered and 1-RTT resumption succeeds, but 0-RTT is suppressed because the
+ * server can only accept it under the ticket's exact cipher (RFC 9846 4.3.10).
+ */
+static int test_tls13_ticket_cipher_mismatch_suppress_early_data(void)
+{
+ SSL_CTX *c = NULL, *s = NULL;
+ struct tls13_channel initial = { .c.ssl = NULL, .s.ssl = NULL };
+ struct tls13_channel resumed = { .c.ssl = NULL, .s.ssl = NULL };
+ SSL_SESSION *sess = NULL;
+ unsigned char edexp[32];
+ int test;
+
+ test = TEST_true(create_ssl_ctx_pair(NULL, TLS_server_method(), TLS_client_method(),
+ TLS1_3_VERSION, TLS1_3_VERSION, &s, &c, cert, pkey))
+ && TEST_true(set_ctx_callbacks(c, s))
+ && TEST_true(ticket_enable(s))
+ && TEST_true(ticket_enable(c))
+ && TEST_true(SSL_CTX_set_max_early_data(s, SSL3_RT_MAX_PLAIN_LENGTH))
+ && TEST_true(SSL_CTX_set_options(s, SSL_OP_NO_ANTI_REPLAY) != 0)
+ && TEST_true(SSL_CTX_set_ciphersuites(s, "TLS_AES_128_GCM_SHA256"))
+ && TEST_true(SSL_CTX_set_ciphersuites(c, "TLS_AES_128_GCM_SHA256"))
+ && TEST_true(tls_channel_init(c, s, &initial))
+ && TEST_true(create_ssl_connection(initial.s.ssl, initial.c.ssl, 0))
+ && TEST_true(tls_shutdown(&initial))
+ && TEST_ptr(sess = SSL_get1_session(initial.c.ssl))
+ /* Same digest, different cipher: resume-viable but not 0-RTT-viable. */
+ && TEST_true(SSL_CTX_set_ciphersuites(s, "TLS_AES_128_CCM_SHA256"))
+ && TEST_true(SSL_CTX_set_ciphersuites(c, "TLS_AES_128_CCM_SHA256"))
+ && TEST_true(tls_channel_init(c, s, &resumed))
+ && TEST_true(SSL_set_session(resumed.c.ssl, sess))
+ && TEST_true(tls_early_data_retry(&resumed))
+ && TEST_int_eq(SSL_get_early_data_status(resumed.c.ssl),
+ SSL_EARLY_DATA_REJECTED)
+ /*
+ * 0-RTT was suppressed here rather than sent and declined, so no early
+ * secrets were derived and the early exporter is unavailable -- even
+ * though the status matches that of a server rejection.
+ */
+ && TEST_false(SSL_export_keying_material_early(resumed.c.ssl, edexp,
+ sizeof(edexp), "label", 5, NULL, 0))
+ /* Ticket offered and resumed at 1-RTT; only 0-RTT was suppressed. */
+ && TEST_true(SSL_session_reused(resumed.c.ssl))
+ && TEST_uint_eq(resumed.c.stats.ch_has_psk, 1)
+ && TEST_uint_eq(resumed.c.stats.ch_has_early_data, 0)
+ && TEST_uint_eq(resumed.s.stats.ch_has_early_data, 0)
+ && TEST_uint_eq(resumed.s.stats.ee_has_early_data, 0);
+
+ SSL_SESSION_free(sess);
+ tls_channel_fini(&initial);
+ tls_channel_fini(&resumed);
+ SSL_CTX_free(c);
+ SSL_CTX_free(s);
+ return test;
+}
+
+/*
+ * TLS 1.3 0-RTT suppressed when the ticket's ALPN is no longer offered.
+ *
+ * The ticket recorded ALPN "goodalpn"; the resumption offers only "otheralpn".
+ * The ticket's ALPN can never be negotiated, so 0-RTT is impossible and must be
+ * suppressed -- but resumption itself proceeds (ALPN may change across a
+ * resumption, taking effect after the transition), so 1-RTT resumption
+ * succeeds with "otheralpn". Previously the client aborted this with a fatal
+ * alert instead of suppressing.
+ */
+static int test_tls13_ticket_alpn_mismatch_suppress_early_data(void)
+{
+ SSL_CTX *c = NULL, *s = NULL;
+ struct tls13_channel initial = { .c.ssl = NULL, .s.ssl = NULL };
+ struct tls13_channel resumed = { .c.ssl = NULL, .s.ssl = NULL };
+ SSL_SESSION *sess = NULL;
+ unsigned char edexp[32];
+ int test;
+
+ test = TEST_true(create_ssl_ctx_pair(NULL, TLS_server_method(), TLS_client_method(),
+ TLS1_3_VERSION, TLS1_3_VERSION, &s, &c, cert, pkey))
+ && TEST_true(set_ctx_callbacks(c, s))
+ && TEST_true(ticket_enable(s))
+ && TEST_true(ticket_enable(c))
+ && TEST_true(SSL_CTX_set_max_early_data(s, SSL3_RT_MAX_PLAIN_LENGTH))
+ && TEST_true(SSL_CTX_set_options(s, SSL_OP_NO_ANTI_REPLAY) != 0)
+ && TEST_true(alpn_server_enable(s, "goodalpn"))
+ /* Connection 1: record "goodalpn" in the 0-RTT-capable ticket. */
+ && TEST_true(tls_channel_init(c, s, &initial))
+ && TEST_true(alpn_client_offer(initial.c.ssl, "goodalpn"))
+ && TEST_true(create_ssl_connection(initial.s.ssl, initial.c.ssl, 0))
+ && TEST_true(alpn_conn_selected_is(initial.c.ssl, "goodalpn"))
+ && TEST_true(tls_shutdown(&initial))
+ && TEST_ptr(sess = SSL_get1_session(initial.c.ssl))
+ /* Connection 2: offer only "otheralpn" -- the ticket's ALPN isn't on
+ * offer, so 0-RTT is suppressed but 1-RTT resumption still succeeds. */
+ && TEST_true(tls_channel_init(c, s, &resumed))
+ && TEST_true(SSL_set_session(resumed.c.ssl, sess))
+ && TEST_true(alpn_client_offer(resumed.c.ssl, "otheralpn"))
+ && TEST_true(alpn_server_select("otheralpn"))
+ && TEST_true(tls_early_data_retry(&resumed))
+ && TEST_int_eq(SSL_get_early_data_status(resumed.c.ssl),
+ SSL_EARLY_DATA_REJECTED)
+ /* Suppressed 0-RTT derives no early secrets: no early exporter. */
+ && TEST_false(SSL_export_keying_material_early(resumed.c.ssl, edexp,
+ sizeof(edexp), "label", 5, NULL, 0))
+ && TEST_true(SSL_session_reused(resumed.c.ssl))
+ && TEST_true(alpn_conn_selected_is(resumed.c.ssl, "otheralpn"))
+ && TEST_uint_eq(resumed.c.stats.ch_has_psk, 1)
+ && TEST_uint_eq(resumed.c.stats.ch_has_early_data, 0)
+ && TEST_uint_eq(resumed.s.stats.ee_has_early_data, 0);
+
+ SSL_SESSION_free(sess);
+ tls_channel_fini(&initial);
+ tls_channel_fini(&resumed);
+ SSL_CTX_free(c);
+ SSL_CTX_free(s);
+ server_alpn = NULL;
+ return test;
+}
+
+/*
+ * TLS 1.3 0-RTT via an external PSK after the resumption ticket is retired for
+ * lacking an offered digest-compatible cipher.
+ *
+ * The ticket is issued under TLS_AES_256_GCM_SHA384; the resumption offers only
+ * TLS_AES_128_GCM_SHA256, so no offered ciphersuite carries the ticket's SHA384
+ * digest and the ticket is retired (not offered). The external PSK (SHA256)
+ * therefore occupies the first identity and keys 0-RTT -- the cipher analogue
+ * of the aged-ticket cascade.
+ */
+static int test_tls13_ticket_cipher_retire_external_psk_early_data(void)
+{
+ const unsigned char m[] = "message";
+ unsigned char buf[256];
+ SSL_CTX *c = NULL, *s = NULL;
+ struct tls13_channel initial = { .c.ssl = NULL, .s.ssl = NULL };
+ struct tls13_channel resumed = { .c.ssl = NULL, .s.ssl = NULL };
+ SSL_SESSION *sess = NULL;
+ size_t w = 0, r = 0;
+ int test;
+
+ test = TEST_true(create_ssl_ctx_pair(NULL, TLS_server_method(), TLS_client_method(),
+ TLS1_3_VERSION, TLS1_3_VERSION, &s, &c, cert, pkey))
+ && TEST_true(set_ctx_callbacks(c, s))
+ && TEST_true(ticket_enable(s))
+ && TEST_true(ticket_enable(c))
+ && TEST_true(SSL_CTX_set_max_early_data(s, SSL3_RT_MAX_PLAIN_LENGTH))
+ && TEST_true(SSL_CTX_set_options(s, SSL_OP_NO_ANTI_REPLAY) != 0)
+ /* Issue the 0-RTT-capable ticket under SHA384. */
+ && TEST_true(SSL_CTX_set_ciphersuites(s, "TLS_AES_256_GCM_SHA384"))
+ && TEST_true(SSL_CTX_set_ciphersuites(c, "TLS_AES_256_GCM_SHA384"))
+ && TEST_true(tls_channel_init(c, s, &initial))
+ && TEST_true(create_ssl_connection(initial.s.ssl, initial.c.ssl, 0))
+ && TEST_true(tls_shutdown(&initial))
+ && TEST_ptr(sess = SSL_get1_session(initial.c.ssl))
+ /* Resume offering only SHA256: the SHA384 ticket has no offered
+ * digest-compatible cipher, so it is retired and the external PSK
+ * (SHA256) takes the first identity. */
+ && TEST_true(SSL_CTX_set_ciphersuites(s, "TLS_AES_128_GCM_SHA256"))
+ && TEST_true(SSL_CTX_set_ciphersuites(c, "TLS_AES_128_GCM_SHA256"))
+ && TEST_true(tls_channel_init(c, s, &resumed))
+ && TEST_true(SSL_set_session(resumed.c.ssl, sess))
+ && TEST_true(enable_external_psk(resumed.c.ssl, resumed.s.ssl))
+ && TEST_true(SSL_write_early_data(resumed.c.ssl, m, sizeof(m), &w))
+ && TEST_size_t_eq(w, sizeof(m))
+ && TEST_int_eq(SSL_read_early_data(resumed.s.ssl, buf, sizeof(buf), &r),
+ SSL_READ_EARLY_DATA_SUCCESS)
+ && TEST_mem_eq(buf, r, m, sizeof(m))
+ && TEST_int_gt(SSL_connect(resumed.c.ssl), 0)
+ && TEST_int_eq(SSL_read_early_data(resumed.s.ssl, buf, sizeof(buf), &r),
+ SSL_READ_EARLY_DATA_FINISH)
+ && TEST_size_t_eq(r, 0)
+ && TEST_int_eq(SSL_get_early_data_status(resumed.s.ssl),
+ SSL_EARLY_DATA_ACCEPTED)
+ && TEST_true(create_ssl_connection(resumed.s.ssl, resumed.c.ssl, 0))
+ && TEST_int_eq(SSL_get_early_data_status(resumed.c.ssl),
+ SSL_EARLY_DATA_ACCEPTED)
+ && TEST_uint_eq(resumed.c.stats.ch_has_psk, 1)
+ && TEST_uint_eq(resumed.c.stats.ch_has_early_data, 1)
+ && TEST_uint_eq(resumed.s.stats.ee_has_early_data, 1)
+ && TEST_uint_eq(resumed.c.stats.ee_has_early_data, 1);
+
+ SSL_SESSION_free(sess);
+ tls_channel_fini(&initial);
+ tls_channel_fini(&resumed);
+ SSL_CTX_free(c);
+ SSL_CTX_free(s);
+ return test;
+}
+
+/*
+ * TLS 1.3 ticket retired for lack of a digest-compatible cipher, no external
+ * PSK to fall back on: no PSK is offered at all and a full handshake results.
+ */
+static int test_tls13_ticket_cipher_retire_full_handshake(void)
+{
+ SSL_CTX *c = NULL, *s = NULL;
+ struct tls13_channel initial = { .c.ssl = NULL, .s.ssl = NULL };
+ struct tls13_channel resumed = { .c.ssl = NULL, .s.ssl = NULL };
+ SSL_SESSION *sess = NULL;
+ int test;
+
+ test = TEST_true(create_ssl_ctx_pair(NULL, TLS_server_method(), TLS_client_method(),
+ TLS1_3_VERSION, TLS1_3_VERSION, &s, &c, cert, pkey))
+ && TEST_true(set_ctx_callbacks(c, s))
+ && TEST_true(ticket_enable(s))
+ && TEST_true(ticket_enable(c))
+ && TEST_true(SSL_CTX_set_max_early_data(s, SSL3_RT_MAX_PLAIN_LENGTH))
+ && TEST_true(SSL_CTX_set_options(s, SSL_OP_NO_ANTI_REPLAY) != 0)
+ && TEST_true(SSL_CTX_set_ciphersuites(s, "TLS_AES_256_GCM_SHA384"))
+ && TEST_true(SSL_CTX_set_ciphersuites(c, "TLS_AES_256_GCM_SHA384"))
+ && TEST_true(tls_channel_init(c, s, &initial))
+ && TEST_true(create_ssl_connection(initial.s.ssl, initial.c.ssl, 0))
+ && TEST_true(tls_shutdown(&initial))
+ && TEST_ptr(sess = SSL_get1_session(initial.c.ssl))
+ && TEST_true(SSL_CTX_set_ciphersuites(s, "TLS_AES_128_GCM_SHA256"))
+ && TEST_true(SSL_CTX_set_ciphersuites(c, "TLS_AES_128_GCM_SHA256"))
+ && TEST_true(tls_channel_init(c, s, &resumed))
+ && TEST_true(SSL_set_session(resumed.c.ssl, sess))
+ && TEST_true(tls_early_data_retry(&resumed))
+ && TEST_int_eq(SSL_get_early_data_status(resumed.c.ssl),
+ SSL_EARLY_DATA_REJECTED)
+ && TEST_false(SSL_session_reused(resumed.c.ssl))
+ && TEST_uint_eq(resumed.c.stats.ch_has_psk, 0)
+ && TEST_uint_eq(resumed.c.stats.ch_has_early_data, 0);
+
+ SSL_SESSION_free(sess);
+ tls_channel_fini(&initial);
+ tls_channel_fini(&resumed);
+ SSL_CTX_free(c);
+ SSL_CTX_free(s);
+ return test;
+}
+
+/*
+ * TLS 1.3 external PSK from the callback dropped when no offered ciphersuite
+ * carries its digest: it is treated as if the callback returned nothing, so no
+ * PSK is offered and a full handshake results.
+ */
+static int test_tls13_external_psk_digest_not_offered(void)
+{
+ SSL_CTX *c = NULL, *s = NULL;
+ struct tls13_channel conn = { .c.ssl = NULL, .s.ssl = NULL };
+ int test;
+
+ test = TEST_true(create_ssl_ctx_pair(NULL, TLS_server_method(), TLS_client_method(),
+ TLS1_3_VERSION, TLS1_3_VERSION, &s, &c, cert, pkey))
+ && TEST_true(set_ctx_callbacks(c, s))
+ && TEST_true(SSL_CTX_set_max_early_data(s, SSL3_RT_MAX_PLAIN_LENGTH))
+ && TEST_true(SSL_CTX_set_options(s, SSL_OP_NO_ANTI_REPLAY) != 0)
+ /* External PSK is AES_128_GCM_SHA256, but we offer only SHA384. */
+ && TEST_true(SSL_CTX_set_ciphersuites(s, "TLS_AES_256_GCM_SHA384"))
+ && TEST_true(SSL_CTX_set_ciphersuites(c, "TLS_AES_256_GCM_SHA384"))
+ && TEST_true(tls_channel_init(c, s, &conn))
+ && TEST_true(enable_external_psk(conn.c.ssl, conn.s.ssl))
+ && TEST_true(tls_early_data_retry(&conn))
+ && TEST_int_eq(SSL_get_early_data_status(conn.c.ssl),
+ SSL_EARLY_DATA_REJECTED)
+ && TEST_false(SSL_session_reused(conn.c.ssl))
+ && TEST_uint_eq(conn.c.stats.ch_has_psk, 0)
+ && TEST_uint_eq(conn.c.stats.ch_has_early_data, 0);
+
+ tls_channel_fini(&conn);
+ SSL_CTX_free(c);
+ SSL_CTX_free(s);
+ return test;
+}
+
+/*
+ * A client psk_use_session callback that returns a session with no master key
+ * must be rejected (SSL_ERROR_SSL), not used to build a binder from an empty
+ * secret.
+ */
+static int test_tls13_external_psk_no_master_key(void)
+{
+ const unsigned char m[] = "message";
+ size_t w = 0;
+ SSL_CTX *c = NULL, *s = NULL;
+ struct tls13_channel conn = { .c.ssl = NULL, .s.ssl = NULL };
+ int test;
+
+ test = TEST_true(create_ssl_ctx_pair(NULL, TLS_server_method(), TLS_client_method(),
+ TLS1_3_VERSION, TLS1_3_VERSION, &s, &c, cert, pkey))
+ && TEST_true(set_ctx_callbacks(c, s))
+ && TEST_true(SSL_CTX_set_max_early_data(s, SSL3_RT_MAX_PLAIN_LENGTH))
+ && TEST_true(SSL_CTX_set_ciphersuites(s, "TLS_AES_128_GCM_SHA256"))
+ && TEST_true(SSL_CTX_set_ciphersuites(c, "TLS_AES_128_GCM_SHA256"))
+ && TEST_true(tls_channel_init(c, s, &conn))
+ && TEST_true((SSL_set_psk_use_session_callback(conn.c.ssl,
+ nomasterkey_psk_use_cb),
+ 1))
+ && TEST_false(SSL_write_early_data(conn.c.ssl, m, sizeof(m), &w))
+ && TEST_int_eq(SSL_get_error(conn.c.ssl, 0), SSL_ERROR_SSL);
+
+ tls_channel_fini(&conn);
+ SSL_CTX_free(c);
+ SSL_CTX_free(s);
+ return test;
+}
+
int setup_tests(void)
{
if (!test_skip_common_options()) {
@@ -1712,12 +2127,19 @@ int setup_tests(void)
ADD_TEST(test_tls13_ticket_no_decrypt);
ADD_TEST(test_tls13_ticket_alpn_cleared);
ADD_TEST(test_tls13_ticket_alpn_mismatch_reject_early_data);
+ ADD_TEST(test_tls13_ticket_cipher_mismatch_reject_early_data);
ADD_TEST(test_tls13_ticket_early_data_accepted);
ADD_TEST(test_tls13_ticket_client_age_mismatch_reject_early_data_retry);
ADD_TEST(test_tls13_ticket_client_age_mismatch_reject_early_data_outer);
ADD_TEST(test_tls13_ticket_server_age_mismatch_reject_early_data);
ADD_TEST(test_tls13_aged_ticket_external_psk_early_data);
ADD_TEST(test_tls13_external_psk_sid_ctx_not_shared);
+ ADD_TEST(test_tls13_ticket_cipher_mismatch_suppress_early_data);
+ ADD_TEST(test_tls13_ticket_alpn_mismatch_suppress_early_data);
+ ADD_TEST(test_tls13_ticket_cipher_retire_external_psk_early_data);
+ ADD_TEST(test_tls13_ticket_cipher_retire_full_handshake);
+ ADD_TEST(test_tls13_external_psk_digest_not_offered);
+ ADD_TEST(test_tls13_external_psk_no_master_key);
return 1;
}