Implement draft-ietf-tls-cross-sni-resumption

Although we only need a subset of draft-ietf-tls-tlsflags, go ahead and
implement helper functions good enough for response flags to get some
experience with the extension.

Change-Id: Iba1581686c9d1883439cfd6445e98801f8fad098
Reviewed-on: https://boringssl-review.googlesource.com/c/boringssl/+/77128
Reviewed-by: Bob Beck <bbe@google.com>
Commit-Queue: David Benjamin <davidben@google.com>
diff --git a/include/openssl/ssl.h b/include/openssl/ssl.h
index ec5e198..ca7b665 100644
--- a/include/openssl/ssl.h
+++ b/include/openssl/ssl.h
@@ -2055,6 +2055,12 @@
                                                  const uint8_t **out_ptr,
                                                  size_t *out_len);
 
+// SSL_SESSION_is_resumable_across_names returns one if |session| may be resumed
+// with any identity in the server certificate and zero otherwise. See
+// draft-ietf-tls-cross-sni-resumption.
+OPENSSL_EXPORT int SSL_SESSION_is_resumable_across_names(
+    const SSL_SESSION *session);
+
 
 // Session caching.
 //
@@ -2304,6 +2310,32 @@
 // when the lookup has completed.
 OPENSSL_EXPORT SSL_SESSION *SSL_magic_pending_session_ptr(void);
 
+// SSL_CTX_set_resumption_across_names_enabled configures whether |ctx|, as a
+// TLS 1.3 server, signals its sessions are compatible with any identity in the
+// server certificate, e.g. all DNS names in the subjectAlternateNames list.
+// This does not change BoringSSL's resumption behavior, only whether it signals
+// this to the client. See draft-ietf-tls-cross-sni-resumption.
+//
+// When this is enabled, all identities in the server certificate should by
+// hosted by servers that accept TLS 1.3 tickets issued by |ctx|. The connection
+// will otherwise function, but performance may suffer from clients wasting
+// single-use tickets.
+OPENSSL_EXPORT void SSL_CTX_set_resumption_across_names_enabled(SSL_CTX *ctx,
+                                                                int enabled);
+
+// SSL_set_resumption_across_names_enabled configures whether |ssl|, as a
+// TLS 1.3 server, signals its sessions are compatible with any identity in the
+// server certificate, e.g. all DNS names in the subjectAlternateNames list.
+// This does not change BoringSSL's resumption behavior, only whether it signals
+// this to the client. See draft-ietf-tls-cross-sni-resumption.
+//
+// When this is enabled, all identities in the server certificate should by
+// hosted by servers that accept TLS 1.3 tickets issued by |ssl|. The connection
+// will otherwise function, but performance may suffer from clients wasting
+// single-use tickets.
+OPENSSL_EXPORT void SSL_set_resumption_across_names_enabled(SSL *ssl,
+                                                            int enabled);
+
 
 // Session tickets.
 //
diff --git a/include/openssl/tls1.h b/include/openssl/tls1.h
index ff408f8..59e6360 100644
--- a/include/openssl/tls1.h
+++ b/include/openssl/tls1.h
@@ -137,6 +137,9 @@
 // standardization completes.
 #define TLSEXT_TYPE_trust_anchors 0xca34
 
+// ExtensionType value from draft-ietf-tls-tlsflags.
+#define TLSEXT_TYPE_tls_flags 62
+
 // status request value from RFC 3546
 #define TLSEXT_STATUSTYPE_nothing (-1)
 #define TLSEXT_STATUSTYPE_ocsp 1
diff --git a/ssl/extensions.cc b/ssl/extensions.cc
index a5370fd..c19310a 100644
--- a/ssl/extensions.cc
+++ b/ssl/extensions.cc
@@ -361,6 +361,96 @@
   return true;
 }
 
+// Flags.
+//
+// https://www.ietf.org/archive/id/draft-ietf-tls-tlsflags-14.html
+
+bool ssl_add_flags_extension(CBB *cbb, SSLFlags flags) {
+  if (flags == 0) {
+    return true;
+  }
+
+  CBB body, child;
+  if (!CBB_add_u16(cbb, TLSEXT_TYPE_tls_flags) ||
+      !CBB_add_u16_length_prefixed(cbb, &body) ||
+      !CBB_add_u8_length_prefixed(&body, &child)) {
+    return false;
+  }
+
+  while (flags != 0) {
+    if (!CBB_add_u8(&child, static_cast<uint8_t>(flags))) {
+      return false;
+    }
+    flags >>= 8;
+  }
+
+  return CBB_flush(cbb);
+}
+
+static bool ssl_parse_flags_extension(const CBS *cbs, SSLFlags *out,
+                                      uint8_t *out_alert, bool allow_unknown) {
+  CBS copy = *cbs, flags;
+  if (!CBS_get_u8_length_prefixed(&copy, &flags) ||  //
+      CBS_len(&copy) != 0 ||                         //
+      CBS_len(&flags) == 0) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+    *out_alert = SSL_AD_DECODE_ERROR;
+    return false;
+  }
+
+  // There may not be any trailing zeros.
+  if (CBS_data(&flags)[CBS_len(&flags) - 1] == 0) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+    *out_alert = SSL_AD_ILLEGAL_PARAMETER;
+    return false;
+  }
+
+  // We can only represent flags that fit in SSLFlags, so any bits beyond that
+  // are necessarily unsolicited. Unsolicited flags are allowed in CH, CR, and
+  // NST, but forbidden in SH, EE, CT, and HRR. See Section 3 of
+  // draft-ietf-tls-tlsflags-14.
+  if (!allow_unknown && CBS_len(&flags) > sizeof(SSLFlags)) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_UNEXPECTED_EXTENSION);
+    *out_alert = SSL_AD_ILLEGAL_PARAMETER;
+    return false;
+  }
+
+  // We currently use the same in-memory and wire representation for flags.
+  uint8_t padded[sizeof(SSLFlags)] = {0};
+  OPENSSL_memcpy(padded, CBS_data(&flags),
+                 std::min(CBS_len(&flags), size_t{4}));
+  static_assert(sizeof(SSLFlags) == sizeof(uint32_t),
+                "We currently assume SSLFlags is 32-bit");
+  *out = CRYPTO_load_u32_le(padded);
+  return true;
+}
+
+bool ssl_parse_flags_extension_request(const CBS *cbs, SSLFlags *out,
+                                       uint8_t *out_alert) {
+  // In a request message, unsolicited flags are allowed and ignored.
+  return ssl_parse_flags_extension(cbs, out, out_alert,
+                                   /*allow_unknown=*/true);
+}
+
+bool ssl_parse_flags_extension_response(const CBS *cbs, SSLFlags *out,
+                                        uint8_t *out_alert,
+                                        SSLFlags allowed_flags) {
+  // In a response message, unsolicited flags are not allowed.
+  if (!ssl_parse_flags_extension(cbs, out, out_alert,
+                                 /*allow_unknown=*/false)) {
+    return false;
+  }
+
+  // Check for unsolicited flags that fit in |SSLFlags|.
+  if ((*out & allowed_flags) != *out) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_UNEXPECTED_EXTENSION);
+    *out_alert = SSL_AD_ILLEGAL_PARAMETER;
+    return false;
+  }
+
+  return true;
+}
+
 // tls_extension represents a TLS extension that is handled internally.
 //
 // The parse callbacks receive a |CBS| that contains the contents of the
diff --git a/ssl/internal.h b/ssl/internal.h
index d5bb96e..a4e07b0 100644
--- a/ssl/internal.h
+++ b/ssl/internal.h
@@ -2702,6 +2702,43 @@
 void ssl_done_writing_client_hello(SSL_HANDSHAKE *hs);
 
 
+// Flags.
+
+// SSLFlags is a bitmask of flags that can be encoded with the TLS flags
+// extension, draft-ietf-tls-tlsflags-14. For now, our in-memory representation
+// matches the wire representation, and we only support flags up to 32. If
+// higher values are needed, we can increase the size of the bitmask, or only
+// store the flags we implement in the bitmask.
+using SSLFlags = uint32_t;
+inline constexpr SSLFlags kSSLFlagResumptionAcrossNames = 1 << 8;
+
+// ssl_add_flags_extension encodes a tls_flags extension (including the header)
+// containing the flags in |flags|. It returns true on success and false on
+// error. If |flags| is zero (no flags set), it returns true without adding
+// anything to |cbb|.
+bool ssl_add_flags_extension(CBB *cbb, SSLFlags flags);
+
+// ssl_parse_flags_extension_request parses tls_flags extension value (excluding
+// the header) from |cbs|, for a request message (ClientHello,
+// CertificateRequest, or NewSessionTicket). Unrecognized flags will be ignored.
+//
+// On success, it sets |*out| to the parsed flags and returns true. On error, it
+// sets |*out_alert| to a TLS alert and returns false.
+bool ssl_parse_flags_extension_request(const CBS *cbs, SSLFlags *out,
+                                       uint8_t *out_alert);
+
+// ssl_parse_flags_extension_response parses tls_flags extension value
+// (excluding the header) from |cbs|, for a response message (HelloRetryRequest,
+// ServerHello, EncryptedExtensions, or Certificate). Only the flags in
+// |allowed_flags| may be present.
+//
+// On success, it sets |*out| to the parsed flags and returns true. On error, it
+// sets |*out_alert| to a TLS alert and returns false.
+bool ssl_parse_flags_extension_response(const CBS *cbs, SSLFlags *out,
+                                        uint8_t *out_alert,
+                                        SSLFlags allowed_flags);
+
+
 // SSLKEYLOGFILE functions.
 
 // ssl_log_secret logs |secret| with label |label|, if logging is enabled for
@@ -4387,6 +4424,10 @@
   // |aes_hw_override| is true.
   bool aes_hw_override_value : 1;
 
+  // resumption_across_names_enabled indicates whether a TLS 1.3 server should
+  // signal its sessions may be resumed across names in the server certificate.
+  bool resumption_across_names_enabled : 1;
+
  private:
   friend RefCounted;
   ~ssl_ctx_st();
@@ -4474,6 +4515,10 @@
 
   // If enable_early_data is true, early data can be sent and accepted.
   bool enable_early_data : 1;
+
+  // resumption_across_names_enabled indicates whether a TLS 1.3 server should
+  // signal its sessions may be resumed across names in the server certificate.
+  bool resumption_across_names_enabled : 1;
 };
 
 struct ssl_session_st : public bssl::RefCounted<ssl_session_st> {
@@ -4614,6 +4659,10 @@
   // session.
   bool has_application_settings : 1;
 
+  // is_resumable_across_names indicates whether the session may be resumed for
+  // any of the identities presented in the certificate.
+  bool is_resumable_across_names : 1;
+
   // quic_early_data_context is used to determine whether early data must be
   // rejected when performing a QUIC handshake.
   bssl::Array<uint8_t> quic_early_data_context;
diff --git a/ssl/ssl_asn1.cc b/ssl/ssl_asn1.cc
index f140f5b..037d34b 100644
--- a/ssl/ssl_asn1.cc
+++ b/ssl/ssl_asn1.cc
@@ -68,6 +68,7 @@
 //     peerALPS                [30] OCTET STRING OPTIONAL,
 //     -- Either both or none of localALPS and peerALPS must be present. If both
 //     -- are present, earlyALPN must be present and non-empty.
+//     resumableAcrossNames    [31] BOOLEAN OPTIONAL,
 // }
 //
 // Note: historically this serialization has included other optional
@@ -135,6 +136,9 @@
     CBS_ASN1_CONSTRUCTED | CBS_ASN1_CONTEXT_SPECIFIC | 29;
 static const CBS_ASN1_TAG kPeerALPSTag =
     CBS_ASN1_CONSTRUCTED | CBS_ASN1_CONTEXT_SPECIFIC | 30;
+static const CBS_ASN1_TAG kResumableAcrossNamesTag =
+    CBS_ASN1_CONSTRUCTED | CBS_ASN1_CONTEXT_SPECIFIC | 31;
+
 
 static int SSL_SESSION_to_bytes_full(const SSL_SESSION *in, CBB *cbb,
                                      int for_ticket) {
@@ -342,6 +346,13 @@
     }
   }
 
+  if (in->is_resumable_across_names) {
+    if (!CBB_add_asn1(&session, &child, kResumableAcrossNamesTag) ||
+        !CBB_add_asn1_bool(&child, true)) {
+      return 0;
+    }
+  }
+
   return CBB_flush(cbb);
 }
 
@@ -664,18 +675,22 @@
   }
 
   CBS settings;
-  int has_local_alps, has_peer_alps;
+  int has_local_alps, has_peer_alps, is_resumable_across_names;
   if (!CBS_get_optional_asn1_octet_string(&session, &settings, &has_local_alps,
                                           kLocalALPSTag) ||
       !ret->local_application_settings.CopyFrom(settings) ||
       !CBS_get_optional_asn1_octet_string(&session, &settings, &has_peer_alps,
                                           kPeerALPSTag) ||
       !ret->peer_application_settings.CopyFrom(settings) ||
+      !CBS_get_optional_asn1_bool(&session, &is_resumable_across_names,
+                                  kResumableAcrossNamesTag,
+                                  /*default_value=*/false) ||
       CBS_len(&session) != 0) {
     OPENSSL_PUT_ERROR(SSL, SSL_R_INVALID_SSL_SESSION);
     return nullptr;
   }
   ret->is_quic = is_quic;
+  ret->is_resumable_across_names = is_resumable_across_names;
 
   // The two ALPS values and ALPN must be consistent.
   if (has_local_alps != has_peer_alps ||
diff --git a/ssl/ssl_lib.cc b/ssl/ssl_lib.cc
index c0c6579..10b062a 100644
--- a/ssl/ssl_lib.cc
+++ b/ssl/ssl_lib.cc
@@ -401,7 +401,8 @@
       handoff(false),
       enable_early_data(false),
       aes_hw_override(false),
-      aes_hw_override_value(false) {
+      aes_hw_override_value(false),
+      resumption_across_names_enabled(false) {
   CRYPTO_MUTEX_init(&lock);
   CRYPTO_new_ex_data(&ex_data);
 }
@@ -478,7 +479,8 @@
       max_cert_list(ctx->max_cert_list),
       server(false),
       quiet_shutdown(ctx->quiet_shutdown),
-      enable_early_data(ctx->enable_early_data) {
+      enable_early_data(ctx->enable_early_data),
+      resumption_across_names_enabled(ctx->resumption_across_names_enabled) {
   CRYPTO_new_ex_data(&ex_data);
 }
 
diff --git a/ssl/ssl_session.cc b/ssl/ssl_session.cc
index 6a7fd5b..af2e5a1 100644
--- a/ssl/ssl_session.cc
+++ b/ssl/ssl_session.cc
@@ -815,7 +815,8 @@
       ticket_age_add_valid(false),
       is_server(false),
       is_quic(false),
-      has_application_settings(false) {
+      has_application_settings(false),
+      is_resumable_across_names(false) {
   CRYPTO_new_ex_data(&ex_data);
   time = ::time(nullptr);
 }
@@ -1000,6 +1001,10 @@
   }
 }
 
+int SSL_SESSION_is_resumable_across_names(const SSL_SESSION *session) {
+  return session->is_resumable_across_names;
+}
+
 int SSL_SESSION_early_data_capable(const SSL_SESSION *session) {
   return ssl_session_protocol_version(session) >= TLS1_3_VERSION &&
          session->ticket_max_early_data != 0;
@@ -1197,6 +1202,14 @@
   return ctx->get_session_cb;
 }
 
+void SSL_CTX_set_resumption_across_names_enabled(SSL_CTX *ctx, int enabled) {
+  ctx->resumption_across_names_enabled = !!enabled;
+}
+
+void SSL_set_resumption_across_names_enabled(SSL *ssl, int enabled) {
+  ssl->resumption_across_names_enabled = !!enabled;
+}
+
 void SSL_CTX_set_info_callback(SSL_CTX *ctx, void (*cb)(const SSL *ssl,
                                                         int type, int value)) {
   ctx->info_callback = cb;
diff --git a/ssl/test/bssl_shim.cc b/ssl/test/bssl_shim.cc
index 80514da..1684ad0 100644
--- a/ssl/test/bssl_shim.cc
+++ b/ssl/test/bssl_shim.cc
@@ -1285,6 +1285,18 @@
                 got_early_data ? "" : " not");
         return false;
       }
+
+      if (config->expect_resumable_across_names.has_value()) {
+        bool actual = !!SSL_SESSION_is_resumable_across_names(
+            test_state->new_session.get());
+        if (config->expect_resumable_across_names.value() != actual) {
+          fprintf(stderr,
+                  "new session did%s support cross-name resumption, but we "
+                  "expected the opposite\n",
+                  actual ? "" : " not");
+          return false;
+        }
+      }
     }
   }
 
diff --git a/ssl/test/runner/common.go b/ssl/test/runner/common.go
index 535137e..925d390 100644
--- a/ssl/test/runner/common.go
+++ b/ssl/test/runner/common.go
@@ -172,6 +172,7 @@
 	extensionSignatureAlgorithmsCert    uint16 = 50
 	extensionKeyShare                   uint16 = 51
 	extensionQUICTransportParams        uint16 = 57
+	extensionTLSFlags                   uint16 = 62
 	extensionCustom                     uint16 = 1234  // not IANA assigned
 	extensionNextProtoNeg               uint16 = 13172 // not IANA assigned
 	extensionApplicationSettingsOld     uint16 = 17513 // not IANA assigned
@@ -186,6 +187,10 @@
 	extensionECHOuterExtensions         uint16 = 0xfd00 // not IANA assigned
 )
 
+const (
+	flagResumptionAcrossNames = 8
+)
+
 // TLS signaling cipher suite values
 const (
 	scsvRenegotiation uint16 = 0x00ff
@@ -382,6 +387,7 @@
 	hasApplicationSettingsOld   bool
 	localApplicationSettingsOld []byte
 	peerApplicationSettingsOld  []byte
+	resumptionAcrossNames       bool
 }
 
 // ClientSessionCache is a cache of ClientSessionState objects that can be used
@@ -692,6 +698,10 @@
 	// to report as available in EncryptedExtensions.
 	AvailableTrustAnchors [][]byte
 
+	// ResumptionAcrossNames specifies whether session tickets issued by the TLS
+	// server should be marked as compatable with cross-name resumption.
+	ResumptionAcrossNames bool
+
 	// Bugs specifies optional misbehaviour to be used for testing other
 	// implementations.
 	Bugs ProtocolBugs
@@ -2146,6 +2156,24 @@
 	// CheckClientHello is called on the initial ClientHello received from the
 	// peer, to implement extra checks.
 	CheckClientHello func(*clientHelloMsg) error
+
+	// SendTicketFlags contains a list of flags, represented by bit index, that
+	// the server will send in TLS 1.3 NewSessionTicket.
+	SendTicketFlags []uint
+
+	// AlwaysSendTicketFlags causes the server to send the flags extension in
+	// TLS 1.3 NewSessionTicket even if empty.
+	AlwaysSendTicketFlags bool
+
+	// TicketFlagPadding is the number of extra bytes of padding (giving a
+	// non-minimal encoding) to include in the flags extension in TLS 1.3
+	// NewSessionTicket.
+	TicketFlagPadding int
+
+	// ExpectResumptionAcrossNames, if not nil, causes the client to require all
+	// NewSessionTicket messages to have or not have the resumption_across_names
+	// flag set.
+	ExpectResumptionAcrossNames *bool
 }
 
 func (c *Config) serverInit() {
diff --git a/ssl/test/runner/conn.go b/ssl/test/runner/conn.go
index b75e48b..71a45fe 100644
--- a/ssl/test/runner/conn.go
+++ b/ssl/test/runner/conn.go
@@ -1615,22 +1615,6 @@
 }
 
 func (c *Conn) processTLS13NewSessionTicket(newSessionTicket *newSessionTicketMsg, cipherSuite *cipherSuite) error {
-	if c.config.Bugs.ExpectGREASE && !newSessionTicket.hasGREASEExtension {
-		return errors.New("tls: no GREASE ticket extension found")
-	}
-
-	if c.config.Bugs.ExpectTicketEarlyData && newSessionTicket.maxEarlyDataSize == 0 {
-		return errors.New("tls: no early_data ticket extension found")
-	}
-
-	if c.config.Bugs.ExpectNoNewSessionTicket || c.config.Bugs.ExpectNoNonEmptyNewSessionTicket {
-		return errors.New("tls: received unexpected NewSessionTicket")
-	}
-
-	if c.config.ClientSessionCache == nil || newSessionTicket.ticketLifetime == 0 {
-		return nil
-	}
-
 	session := &ClientSessionState{
 		sessionTicket:               newSessionTicket.ticket,
 		vers:                        c.vers,
@@ -1651,6 +1635,27 @@
 		hasApplicationSettingsOld:   c.hasApplicationSettingsOld,
 		localApplicationSettingsOld: c.localApplicationSettingsOld,
 		peerApplicationSettingsOld:  c.peerApplicationSettingsOld,
+		resumptionAcrossNames:       newSessionTicket.flags.hasFlag(flagResumptionAcrossNames),
+	}
+
+	if c.config.Bugs.ExpectGREASE && !newSessionTicket.hasGREASEExtension {
+		return errors.New("tls: no GREASE ticket extension found")
+	}
+
+	if c.config.Bugs.ExpectTicketEarlyData && newSessionTicket.maxEarlyDataSize == 0 {
+		return errors.New("tls: no early_data ticket extension found")
+	}
+
+	if c.config.Bugs.ExpectNoNewSessionTicket || c.config.Bugs.ExpectNoNonEmptyNewSessionTicket {
+		return errors.New("tls: received unexpected NewSessionTicket")
+	}
+
+	if expect := c.config.Bugs.ExpectResumptionAcrossNames; expect != nil && session.resumptionAcrossNames != *expect {
+		return errors.New("tls: resumption_across_names status of ticket did not match expectation")
+	}
+
+	if c.config.ClientSessionCache == nil || newSessionTicket.ticketLifetime == 0 {
+		return nil
 	}
 
 	cacheKey := clientSessionCacheKey(c.conn.RemoteAddr(), c.config)
@@ -2030,6 +2035,10 @@
 		ticketAgeAdd:                ticketAgeAdd,
 		ticketNonce:                 nonce,
 		maxEarlyDataSize:            c.config.MaxEarlyDataSize,
+		flags: flagSet{
+			mustInclude: c.config.Bugs.AlwaysSendTicketFlags,
+			padding:     c.config.Bugs.TicketFlagPadding,
+		},
 	}
 	if c.config.Bugs.MockQUICTransport != nil && m.maxEarlyDataSize > 0 {
 		m.maxEarlyDataSize = 0xffffffff
@@ -2038,6 +2047,12 @@
 	if c.config.Bugs.SendTicketLifetime != 0 {
 		m.ticketLifetime = uint32(c.config.Bugs.SendTicketLifetime / time.Second)
 	}
+	if c.config.ResumptionAcrossNames {
+		m.flags.setFlag(flagResumptionAcrossNames)
+	}
+	for _, flag := range c.config.Bugs.SendTicketFlags {
+		m.flags.setFlag(flag)
+	}
 
 	state := sessionState{
 		vers:                        c.vers,
diff --git a/ssl/test/runner/handshake_messages.go b/ssl/test/runner/handshake_messages.go
index 0d9ded1..71aa840 100644
--- a/ssl/test/runner/handshake_messages.go
+++ b/ssl/test/runner/handshake_messages.go
@@ -150,6 +150,53 @@
 	msg []byte
 }
 
+type flagSet struct {
+	bytes       []byte
+	mustInclude bool
+	padding     int
+}
+
+func (f *flagSet) hasFlag(bit uint) bool {
+	idx := bit / 8
+	mask := byte(1 << (bit % 8))
+	return idx < uint(len(f.bytes)) && f.bytes[idx]&mask != 0
+}
+
+func (f *flagSet) setFlag(bit uint) {
+	idx := bit / 8
+	mask := byte(1 << (bit % 8))
+	for uint(len(f.bytes)) <= idx {
+		f.bytes = append(f.bytes, 0)
+	}
+	f.bytes[idx] |= mask
+}
+
+func (f *flagSet) unmarshalExtensionValue(s cryptobyte.String) bool {
+	if !readUint8LengthPrefixedBytes(&s, &f.bytes) || !s.Empty() || len(f.bytes) == 0 {
+		return false
+	}
+	// Flags must be minimally-encoded.
+	if f.bytes[len(f.bytes)-1] == 0 {
+		return false
+	}
+	return true
+}
+
+func (f *flagSet) marshalExtension(b *cryptobyte.Builder) {
+	if len(f.bytes) == 0 && !f.mustInclude {
+		return
+	}
+	b.AddUint16(extensionTLSFlags)
+	b.AddUint16LengthPrefixed(func(value *cryptobyte.Builder) {
+		value.AddUint8LengthPrefixed(func(flags *cryptobyte.Builder) {
+			flags.AddBytes(f.bytes)
+			for range f.padding {
+				flags.AddUint8(0)
+			}
+		})
+	})
+}
+
 type clientHelloMsg struct {
 	raw                                      []byte
 	isDTLS                                   bool
@@ -2680,6 +2727,7 @@
 	customExtension             string
 	duplicateEarlyDataExtension bool
 	hasGREASEExtension          bool
+	flags                       flagSet
 }
 
 func (m *newSessionTicketMsg) marshal() []byte {
@@ -2722,6 +2770,7 @@
 					extensions.AddUint16(extensionCustom)
 					addUint16LengthPrefixedBytes(extensions, []byte(m.customExtension))
 				}
+				m.flags.marshalExtension(extensions)
 			})
 		}
 	})
@@ -2774,6 +2823,10 @@
 				if !body.ReadUint32(&m.maxEarlyDataSize) || !body.Empty() {
 					return false
 				}
+			case extensionTLSFlags:
+				if !m.flags.unmarshalExtensionValue(body) {
+					return false
+				}
 			default:
 				if isGREASEValue(extension) {
 					m.hasGREASEExtension = true
diff --git a/ssl/test/runner/resumption_tests.go b/ssl/test/runner/resumption_tests.go
index eacf964..63e609c 100644
--- a/ssl/test/runner/resumption_tests.go
+++ b/ssl/test/runner/resumption_tests.go
@@ -14,7 +14,10 @@
 
 package runner
 
-import "time"
+import (
+	"fmt"
+	"time"
+)
 
 func addResumptionVersionTests() {
 	for _, sessionVers := range tlsVersions {
@@ -972,5 +975,108 @@
 				expectedLocalError: "remote error: illegal parameter",
 			})
 		}
+
+		// Test ticket flags.
+		if ver.version >= VersionTLS13 {
+			// The client should parse and ignore unknown ticket flags. 2039
+			// is the highest possible flag number (8*255 flags total).
+			for i, flags := range [][]uint{{1}, {31}, {100}, {2039}, {1, 31, 100, 2039}} {
+				testCases = append(testCases, testCase{
+					name: fmt.Sprintf("%s-Client-UnknownTicketFlags-%d", ver.name, i),
+					config: Config{
+						MinVersion: ver.version,
+						MaxVersion: ver.version,
+						Bugs: ProtocolBugs{
+							SendTicketFlags: flags,
+						},
+					},
+				})
+				testCases = append(testCases, testCase{
+					name: fmt.Sprintf("%s-Client-KnownAndUnknownTicketFlags-%d", ver.name, i),
+					config: Config{
+						MinVersion:            ver.version,
+						MaxVersion:            ver.version,
+						ResumptionAcrossNames: true,
+						Bugs: ProtocolBugs{
+							SendTicketFlags: flags,
+						},
+					},
+					flags: []string{"-expect-resumable-across-names"},
+				})
+			}
+
+			// The client should reject invalid ticket flag extensions.
+			testCases = append(testCases, testCase{
+				name: ver.name + "-Client-NonminimalTicketFlags",
+				config: Config{
+					MinVersion: ver.version,
+					MaxVersion: ver.version,
+					Bugs: ProtocolBugs{
+						SendTicketFlags:   []uint{1},
+						TicketFlagPadding: 1,
+					},
+				},
+				shouldFail:         true,
+				expectedError:      ":DECODE_ERROR:",
+				expectedLocalError: "remote error: illegal parameter",
+			})
+			testCases = append(testCases, testCase{
+				name: ver.name + "-Client-EmptyTicketFlags",
+				config: Config{
+					MinVersion: ver.version,
+					MaxVersion: ver.version,
+					Bugs: ProtocolBugs{
+						AlwaysSendTicketFlags: true,
+					},
+				},
+				shouldFail:         true,
+				expectedError:      ":DECODE_ERROR:",
+				expectedLocalError: "remote error: error decoding message",
+			})
+
+			// The client should parse the resumption_across_names flag.
+			testCases = append(testCases, testCase{
+				name: ver.name + "-Client-NoResumptionAcrossNames",
+				config: Config{
+					MinVersion: ver.version,
+					MaxVersion: ver.version,
+				},
+				flags: []string{"-expect-not-resumable-across-names"},
+			})
+			testCases = append(testCases, testCase{
+				name: ver.name + "-Client-ResumptionAcrossNames",
+				config: Config{
+					MinVersion:            ver.version,
+					MaxVersion:            ver.version,
+					ResumptionAcrossNames: true,
+				},
+				flags: []string{"-expect-resumable-across-names"},
+			})
+
+			// The server should offer resumption_across_names as configured.
+			testCases = append(testCases, testCase{
+				testType: serverTest,
+				name:     ver.name + "-Server-NoResumptionAcrossNames",
+				config: Config{
+					MinVersion: ver.version,
+					MaxVersion: ver.version,
+					Bugs: ProtocolBugs{
+						ExpectResumptionAcrossNames: ptrTo(false),
+					},
+				},
+			})
+			testCases = append(testCases, testCase{
+				testType: serverTest,
+				name:     ver.name + "-Server-ResumptionAcrossNames",
+				config: Config{
+					MinVersion: ver.version,
+					MaxVersion: ver.version,
+					Bugs: ProtocolBugs{
+						ExpectResumptionAcrossNames: ptrTo(true),
+					},
+				},
+				flags: []string{"-resumption-across-names-enabled"},
+			})
+		}
 	}
 }
diff --git a/ssl/test/test_config.cc b/ssl/test/test_config.cc
index 439286c..4f5cfe5 100644
--- a/ssl/test/test_config.cc
+++ b/ssl/test/test_config.cc
@@ -590,6 +590,12 @@
         CredentialFlag(
             Base64Flag("-trust-anchor-id", &CredentialConfig::trust_anchor_id)),
         IntFlag("-private-key-delay-ms", &TestConfig::private_key_delay_ms),
+        BoolFlag("-resumption-across-names-enabled",
+                 &TestConfig::resumption_across_names_enabled),
+        OptionalBoolTrueFlag("-expect-resumable-across-names",
+                             &TestConfig::expect_resumable_across_names),
+        OptionalBoolFalseFlag("-expect-not-resumable-across-names",
+                              &TestConfig::expect_resumable_across_names),
     };
     std::sort(ret.begin(), ret.end(), FlagNameComparator{});
     return ret;
@@ -2060,6 +2066,10 @@
     SSL_CTX_set_tlsext_status_cb(ssl_ctx.get(), LegacyOCSPCallback);
   }
 
+  if (resumption_across_names_enabled) {
+    SSL_CTX_set_resumption_across_names_enabled(ssl_ctx.get(), 1);
+  }
+
   if (old_ctx) {
     uint8_t keys[48];
     if (!SSL_CTX_get_tlsext_ticket_keys(old_ctx, &keys, sizeof(keys)) ||
diff --git a/ssl/test/test_config.h b/ssl/test/test_config.h
index 08d0f3a..bc5c7fd 100644
--- a/ssl/test/test_config.h
+++ b/ssl/test/test_config.h
@@ -235,6 +235,8 @@
   std::optional<int> expect_selected_credential;
   std::vector<CredentialConfig> credentials;
   int private_key_delay_ms = 0;
+  bool resumption_across_names_enabled = false;
+  std::optional<bool> expect_resumable_across_names;
 
   std::vector<const char *> handshaker_args;
 
diff --git a/ssl/tls13_client.cc b/ssl/tls13_client.cc
index cf36d0c..eca654f 100644
--- a/ssl/tls13_client.cc
+++ b/ssl/tls13_client.cc
@@ -1189,8 +1189,9 @@
   }
 
   SSLExtension early_data(TLSEXT_TYPE_early_data);
+  SSLExtension flags(TLSEXT_TYPE_tls_flags);
   uint8_t alert = SSL_AD_DECODE_ERROR;
-  if (!ssl_parse_extensions(&extensions, &alert, {&early_data},
+  if (!ssl_parse_extensions(&extensions, &alert, {&early_data, &flags},
                             /*ignore_unknown=*/true)) {
     ssl_send_alert(ssl, SSL3_AL_FATAL, alert);
     return nullptr;
@@ -1213,6 +1214,17 @@
     }
   }
 
+  if (flags.present) {
+    SSLFlags parsed;
+    if (!ssl_parse_flags_extension_request(&flags.data, &parsed, &alert)) {
+      ssl_send_alert(ssl, SSL3_AL_FATAL, alert);
+      return nullptr;
+    }
+    if (parsed & kSSLFlagResumptionAcrossNames) {
+      session->is_resumable_across_names = true;
+    }
+  }
+
   // Historically, OpenSSL filled in fake session IDs for ticket-based sessions.
   // Envoy's tests depend on this, although perhaps they shouldn't.
   session->session_id.ResizeForOverwrite(SHA256_DIGEST_LENGTH);
diff --git a/ssl/tls13_server.cc b/ssl/tls13_server.cc
index 8f9a34e..98bac6e 100644
--- a/ssl/tls13_server.cc
+++ b/ssl/tls13_server.cc
@@ -192,6 +192,7 @@
       session->ticket_max_early_data =
           SSL_is_quic(ssl) ? 0xffffffff : kMaxEarlyDataAccepted;
     }
+    session->is_resumable_across_names = ssl->resumption_across_names_enabled;
 
     static_assert(kMaxTickets < 256, "Too many tickets");
     assert(i < 256);
@@ -230,6 +231,14 @@
       }
     }
 
+    SSLFlags flags = 0;
+    if (session->is_resumable_across_names) {
+      flags |= kSSLFlagResumptionAcrossNames;
+    }
+    if (!ssl_add_flags_extension(&extensions, flags)) {
+      return false;
+    }
+
     // Add a fake extension. See RFC 8701.
     if (!CBB_add_u16(&extensions,
                      ssl_get_grease_value(hs, ssl_grease_ticket_extension)) ||