Add SSL_get_early_data_reason.

This is to help servers diagnose 0-RTT rejects. (QUIC has a similar
feature, and this will help determine if we need to adjust the ticket
age skew.)

Bug: 113
Change-Id: Icc7e5df326b5fa82e744605021b1205298efba6a
Reviewed-on: https://boringssl-review.googlesource.com/c/boringssl/+/35885
Reviewed-by: Adam Langley <agl@google.com>
diff --git a/include/openssl/base.h b/include/openssl/base.h
index 41e9305..cb1affa 100644
--- a/include/openssl/base.h
+++ b/include/openssl/base.h
@@ -297,13 +297,14 @@
 // to do this only for C++. However, the ABI type between C and C++ need to have
 // equal sizes, which is confirmed in a unittest.
 #define BORINGSSL_ENUM_INT : int
-enum ssl_private_key_result_t BORINGSSL_ENUM_INT;
-enum ssl_ticket_aead_result_t BORINGSSL_ENUM_INT;
-enum ssl_verify_result_t BORINGSSL_ENUM_INT;
+enum ssl_early_data_reason_t BORINGSSL_ENUM_INT;
 enum ssl_encryption_level_t BORINGSSL_ENUM_INT;
+enum ssl_private_key_result_t BORINGSSL_ENUM_INT;
 enum ssl_renegotiate_mode_t BORINGSSL_ENUM_INT;
 enum ssl_select_cert_result_t BORINGSSL_ENUM_INT;
 enum ssl_select_cert_result_t BORINGSSL_ENUM_INT;
+enum ssl_ticket_aead_result_t BORINGSSL_ENUM_INT;
+enum ssl_verify_result_t BORINGSSL_ENUM_INT;
 #else
 #define BORINGSSL_ENUM_INT
 #endif
diff --git a/include/openssl/ssl.h b/include/openssl/ssl.h
index 8e02084..b936efa 100644
--- a/include/openssl/ssl.h
+++ b/include/openssl/ssl.h
@@ -3311,6 +3311,44 @@
     SSL *ssl, uint8_t *out, size_t out_len, const char *label, size_t label_len,
     const uint8_t *context, size_t context_len);
 
+// SSL_get_ticket_age_skew returns the difference, in seconds, between the
+// client-sent ticket age and the server-computed value in TLS 1.3 server
+// connections which resumed a session.
+OPENSSL_EXPORT int32_t SSL_get_ticket_age_skew(const SSL *ssl);
+
+enum ssl_early_data_reason_t BORINGSSL_ENUM_INT {
+  // The handshake has not progressed far enough for the 0-RTT status to be
+  // known.
+  ssl_early_data_unknown,
+  // 0-RTT is disabled for this connection.
+  ssl_early_data_disabled,
+  // 0-RTT was accepted.
+  ssl_early_data_accepted,
+  // The negotiated protocol version does not support 0-RTT.
+  ssl_early_data_protocol_version,
+  // The peer declined to offer or accept 0-RTT for an unknown reason.
+  ssl_early_data_peer_declined,
+  // The client did not offer a session.
+  ssl_early_data_no_session_offered,
+  // The server declined to resume the session.
+  ssl_early_data_session_not_resumed,
+  // The session does not support 0-RTT.
+  ssl_early_data_unsupported_for_session,
+  // The server sent a HelloRetryRequest.
+  ssl_early_data_hello_retry_request,
+  // The negotiated ALPN protocol did not match the session.
+  ssl_early_data_alpn_mismatch,
+  // The connection negotiated Channel ID, which is incompatible with 0-RTT.
+  ssl_early_data_channel_id,
+  // The connection negotiated token binding, which is incompatible with 0-RTT.
+  ssl_early_data_token_binding,
+};
+
+// SSL_get_early_data_reason returns details why 0-RTT was accepted or rejected
+// on |ssl|. This is primarily useful on the server.
+OPENSSL_EXPORT enum ssl_early_data_reason_t SSL_get_early_data_reason(
+    const SSL *ssl);
+
 
 // Alerts.
 //
@@ -3815,11 +3853,6 @@
 // record with |ssl|.
 OPENSSL_EXPORT size_t SSL_max_seal_overhead(const SSL *ssl);
 
-// SSL_get_ticket_age_skew returns the difference, in seconds, between the
-// client-sent ticket age and the server-computed value in TLS 1.3 server
-// connections which resumed a session.
-OPENSSL_EXPORT int32_t SSL_get_ticket_age_skew(const SSL *ssl);
-
 // SSL_CTX_set_false_start_allowed_without_alpn configures whether connections
 // on |ctx| may use False Start (if |SSL_MODE_ENABLE_FALSE_START| is enabled)
 // without negotiating ALPN.
diff --git a/ssl/handoff.cc b/ssl/handoff.cc
index 0928015..db5886a 100644
--- a/ssl/handoff.cc
+++ b/ssl/handoff.cc
@@ -450,6 +450,10 @@
   s3->aead_write_ctx->SetVersionIfNullCipher(ssl->version);
   s3->hs->cert_request = cert_request;
 
+  // TODO(davidben): When handoff for TLS 1.3 is added, serialize
+  // |early_data_reason| and stabilize the constants.
+  s3->early_data_reason = ssl_early_data_protocol_version;
+
   Array<uint8_t> key_block;
   if ((type == handback_after_session_resumption ||
        type == handback_after_handshake) &&
diff --git a/ssl/handshake.cc b/ssl/handshake.cc
index 89be48f..b8e0070 100644
--- a/ssl/handshake.cc
+++ b/ssl/handshake.cc
@@ -648,6 +648,7 @@
         return -1;
 
       case ssl_hs_early_data_rejected:
+        assert(ssl->s3->early_data_reason != ssl_early_data_unknown);
         ssl->s3->rwstate = SSL_EARLY_DATA_REJECTED;
         // Cause |SSL_write| to start failing immediately.
         hs->can_early_write = false;
diff --git a/ssl/handshake_server.cc b/ssl/handshake_server.cc
index 4622ad0..f7e5df7 100644
--- a/ssl/handshake_server.cc
+++ b/ssl/handshake_server.cc
@@ -635,6 +635,8 @@
     return ssl_hs_ok;
   }
 
+  ssl->s3->early_data_reason = ssl_early_data_protocol_version;
+
   SSL_CLIENT_HELLO client_hello;
   if (!ssl_client_hello_init(ssl, &client_hello, msg)) {
     return ssl_hs_error;
diff --git a/ssl/internal.h b/ssl/internal.h
index 22e12b5..2a173c1 100644
--- a/ssl/internal.h
+++ b/ssl/internal.h
@@ -2266,6 +2266,9 @@
   // which resumed a session.
   int32_t ticket_age_skew = 0;
 
+  // ssl_early_data_reason stores details on why 0-RTT was accepted or rejected.
+  enum ssl_early_data_reason_t early_data_reason = ssl_early_data_unknown;
+
   // aead_read_ctx is the current read cipher state.
   UniquePtr<SSLAEADContext> aead_read_ctx;
 
diff --git a/ssl/ssl_lib.cc b/ssl/ssl_lib.cc
index f9910f7..95177e0 100644
--- a/ssl/ssl_lib.cc
+++ b/ssl/ssl_lib.cc
@@ -1294,6 +1294,10 @@
   ssl->s3->wpend_pending = false;
 }
 
+enum ssl_early_data_reason_t SSL_get_early_data_reason(const SSL *ssl) {
+  return ssl->s3->early_data_reason;
+}
+
 static int bio_retry_reason_to_error(int reason) {
   switch (reason) {
     case BIO_RR_CONNECT:
diff --git a/ssl/t1_lib.cc b/ssl/t1_lib.cc
index add2eb7..f79ce9b 100644
--- a/ssl/t1_lib.cc
+++ b/ssl/t1_lib.cc
@@ -2057,20 +2057,46 @@
 
 static bool ext_early_data_add_clienthello(SSL_HANDSHAKE *hs, CBB *out) {
   SSL *const ssl = hs->ssl;
-  if (!ssl->enable_early_data ||
-      // Session must be 0-RTT capable.
-      ssl->session == nullptr ||
-      ssl_session_protocol_version(ssl->session.get()) < TLS1_3_VERSION ||
-      ssl->session->ticket_max_early_data == 0 ||
-      // The second ClientHello never offers early data.
-      hs->received_hello_retry_request ||
-      // In case ALPN preferences changed since this session was established,
-      // avoid reporting a confusing value in |SSL_get0_alpn_selected|.
-      (!ssl->session->early_alpn.empty() &&
-       !ssl_is_alpn_protocol_allowed(hs, ssl->session->early_alpn))) {
+  // The second ClientHello never offers early data, and we must have already
+  // filled in |early_data_reason| by this point.
+  if (hs->received_hello_retry_request) {
+    assert(ssl->s3->early_data_reason != ssl_early_data_unknown);
     return true;
   }
 
+  if (!ssl->enable_early_data) {
+    ssl->s3->early_data_reason = ssl_early_data_disabled;
+    return true;
+  }
+
+  if (hs->max_version < TLS1_3_VERSION) {
+    // We discard inapplicable sessions, so this is redundant with the session
+    // checks below, but we check give a more useful reason.
+    ssl->s3->early_data_reason = ssl_early_data_protocol_version;
+    return true;
+  }
+
+  if (ssl->session == nullptr) {
+    ssl->s3->early_data_reason = ssl_early_data_no_session_offered;
+    return true;
+  }
+
+  if (ssl_session_protocol_version(ssl->session.get()) < TLS1_3_VERSION ||
+      ssl->session->ticket_max_early_data == 0) {
+    ssl->s3->early_data_reason = ssl_early_data_unsupported_for_session;
+    return true;
+  }
+
+  // In case ALPN preferences changed since this session was established, avoid
+  // reporting a confusing value in |SSL_get0_alpn_selected| and sending early
+  // data we know will be rejected.
+  if (!ssl->session->early_alpn.empty() &&
+      !ssl_is_alpn_protocol_allowed(hs, ssl->session->early_alpn)) {
+    ssl->s3->early_data_reason = ssl_early_data_alpn_mismatch;
+    return true;
+  }
+
+  // |early_data_reason| will be filled in later when the server responds.
   hs->early_data_offered = true;
 
   if (!CBB_add_u16(out, TLSEXT_TYPE_early_data) ||
@@ -2083,12 +2109,27 @@
 }
 
 static bool ext_early_data_parse_serverhello(SSL_HANDSHAKE *hs,
-                                             uint8_t *out_alert, CBS *contents) {
+                                             uint8_t *out_alert,
+                                             CBS *contents) {
   SSL *const ssl = hs->ssl;
   if (contents == NULL) {
+    if (hs->early_data_offered && !hs->received_hello_retry_request) {
+      ssl->s3->early_data_reason = ssl->s3->session_reused
+                                       ? ssl_early_data_peer_declined
+                                       : ssl_early_data_session_not_resumed;
+    } else {
+      // We already filled in |early_data_reason| when declining to offer 0-RTT
+      // or handling the implicit HelloRetryRequest reject.
+      assert(ssl->s3->early_data_reason != ssl_early_data_unknown);
+    }
     return true;
   }
 
+  // If we received an HRR, the second ClientHello never offers early data, so
+  // the extensions logic will automatically reject early data extensions as
+  // unsolicited. This covered by the ServerAcceptsEarlyDataOnHRR test.
+  assert(!hs->received_hello_retry_request);
+
   if (CBS_len(contents) != 0) {
     *out_alert = SSL_AD_DECODE_ERROR;
     return false;
@@ -2100,6 +2141,7 @@
     return false;
   }
 
+  ssl->s3->early_data_reason = ssl_early_data_accepted;
   ssl->s3->early_data_accepted = true;
   return true;
 }
@@ -3061,6 +3103,9 @@
     return false;
   }
 
+  // Note we may send multiple ClientHellos for DTLS HelloVerifyRequest and TLS
+  // 1.3 HelloRetryRequest. For the latter, the extensions may change, so it is
+  // important to reset this value.
   hs->extensions.sent = 0;
 
   for (size_t i = 0; i < kNumExtensions; i++) {
diff --git a/ssl/test/bssl_shim.cc b/ssl/test/bssl_shim.cc
index d61a2a6..e7e5dec 100644
--- a/ssl/test/bssl_shim.cc
+++ b/ssl/test/bssl_shim.cc
@@ -389,6 +389,37 @@
   return true;
 }
 
+static const char *EarlyDataReasonToString(ssl_early_data_reason_t reason) {
+  switch (reason) {
+    case ssl_early_data_unknown:
+      return "unknown";
+    case ssl_early_data_disabled:
+      return "disabled";
+    case ssl_early_data_accepted:
+      return "accepted";
+    case ssl_early_data_protocol_version:
+      return "protocol_version";
+    case ssl_early_data_peer_declined:
+      return "peer_declined";
+    case ssl_early_data_no_session_offered:
+      return "no_session_offered";
+    case ssl_early_data_session_not_resumed:
+      return "session_not_resumed";
+    case ssl_early_data_unsupported_for_session:
+      return "unsupported_for_session";
+    case ssl_early_data_hello_retry_request:
+      return "hello_retry_request";
+    case ssl_early_data_alpn_mismatch:
+      return "alpn_mismatch";
+    case ssl_early_data_channel_id:
+      return "channel_id";
+    case ssl_early_data_token_binding:
+      return "token_binding";
+  }
+
+  abort();
+}
+
 // CheckHandshakeProperties checks, immediately after |ssl| completes its
 // initial handshake (or False Starts), whether all the properties are
 // consistent with the test configuration and invariants.
@@ -596,6 +627,15 @@
               SSL_early_data_accepted(ssl) ? "" : " not");
       return false;
     }
+
+    const char *early_data_reason =
+        EarlyDataReasonToString(SSL_get_early_data_reason(ssl));
+    if (!config->expect_early_data_reason.empty() &&
+        config->expect_early_data_reason != early_data_reason) {
+      fprintf(stderr, "Early data reason was \"%s\", expected \"%s\"\n",
+              early_data_reason, config->expect_early_data_reason.c_str());
+      return false;
+    }
   }
 
   if (!config->psk.empty()) {
@@ -702,6 +742,16 @@
       return false;
     }
 
+    // Client pre- and post-0-RTT reject states are considered logically
+    // different connections with different test expections. Check that the test
+    // did not mistakenly configure reason expectations on the wrong one.
+    if (!config->expect_early_data_reason.empty()) {
+      fprintf(stderr,
+              "Test error: client reject -expect-early-data-reason flags "
+              "should be configured with -on-retry, not -on-resume.\n");
+      return false;
+    }
+
     // Reset the connection and try again at 1-RTT.
     SSL_reset_early_data_reject(ssl.get());
     GetTestState(ssl.get())->cert_verified = false;
diff --git a/ssl/test/runner/fuzzer_mode.json b/ssl/test/runner/fuzzer_mode.json
index 1a154c2..78639e2 100644
--- a/ssl/test/runner/fuzzer_mode.json
+++ b/ssl/test/runner/fuzzer_mode.json
@@ -30,6 +30,7 @@
     "Resume-Server-DeclineCrossVersion*": "Fuzzer mode does not encrypt tickets.",
     "TicketCallback-SingleCall-*": "Fuzzer mode does not encrypt tickets.",
     "CorruptTicket-*": "Fuzzer mode does not encrypt tickets.",
+    "*RejectTicket-Server-*": "Fuzzer mode does not encrypt tickets.",
     "ShimTicketRewritable*": "Fuzzer mode does not encrypt tickets.",
 
     "Resume-Server-*Binder*": "Fuzzer mode does not check binders.",
diff --git a/ssl/test/runner/runner.go b/ssl/test/runner/runner.go
index d27b094..f5f0285 100644
--- a/ssl/test/runner/runner.go
+++ b/ssl/test/runner/runner.go
@@ -4466,6 +4466,8 @@
 			},
 			resumeSession:        true,
 			resumeRenewedSession: true,
+			// 0-RTT being disabled overrides all other 0-RTT reasons.
+			flags: []string{"-expect-early-data-reason", "disabled"},
 		})
 
 		tests = append(tests, testCase{
@@ -4477,9 +4479,13 @@
 			},
 			resumeSession:        true,
 			resumeRenewedSession: true,
-			// TLS 1.3 uses tickets, so the session should not be
-			// cached statefully.
-			flags: []string{"-expect-no-session-id"},
+			flags: []string{
+				// TLS 1.3 uses tickets, so the session should not be
+				// cached statefully.
+				"-expect-no-session-id",
+				// 0-RTT being disabled overrides all other 0-RTT reasons.
+				"-expect-early-data-reason", "disabled",
+			},
 		})
 
 		tests = append(tests, testCase{
@@ -7018,6 +7024,8 @@
 					"-expect-ticket-supports-early-data",
 					"-token-binding-params",
 					base64.StdEncoding.EncodeToString([]byte{2, 1, 0}),
+					"-expect-reject-early-data",
+					"-on-retry-expect-early-data-reason", "token_binding",
 				},
 			})
 		}
@@ -12329,7 +12337,9 @@
 		flags: []string{
 			"-enable-early-data",
 			"-expect-ticket-supports-early-data",
+			"-on-initial-expect-early-data-reason", "no_session_offered",
 			"-on-resume-expect-accept-early-data",
+			"-on-resume-expect-early-data-reason", "accept",
 			"-on-resume-shim-writes-first",
 		},
 	})
@@ -12354,6 +12364,7 @@
 			"-expect-ticket-supports-early-data",
 			"-expect-reject-early-data",
 			"-on-resume-shim-writes-first",
+			"-on-retry-expect-early-data-reason", "peer_declined",
 		},
 	})
 
@@ -12373,10 +12384,14 @@
 		resumeSession: true,
 		flags: []string{
 			"-enable-early-data",
+			"-on-initial-expect-early-data-reason", "no_session_offered",
 			"-on-resume-expect-accept-early-data",
+			"-on-resume-expect-early-data-reason", "accept",
 		},
 	})
 
+	// The above tests the most recent ticket. Additionally test that 0-RTT
+	// works on the first ticket issued by the server.
 	testCases = append(testCases, testCase{
 		testType: serverTest,
 		name:     "EarlyData-FirstTicket-Server-TLS13",
@@ -12395,6 +12410,7 @@
 		flags: []string{
 			"-enable-early-data",
 			"-on-resume-expect-accept-early-data",
+			"-on-resume-expect-early-data-reason", "accept",
 		},
 	})
 
@@ -12463,6 +12479,9 @@
 			},
 			DefaultCurves: []CurveID{},
 		},
+		// Though the session is not resumed and we send HelloRetryRequest,
+		// early data being disabled takes priority as the reject reason.
+		flags: []string{"-expect-early-data-reason", "disabled"},
 	})
 
 	testCases = append(testCases, testCase{
@@ -12979,6 +12998,8 @@
 			},
 		},
 	})
+
+	// Test the client handles 0-RTT being rejected by a full handshake.
 	testCases = append(testCases, testCase{
 		testType: clientTest,
 		name:     "EarlyData-RejectTicket-Client-TLS13",
@@ -13000,6 +13021,9 @@
 			"-expect-ticket-supports-early-data",
 			"-expect-reject-early-data",
 			"-on-resume-shim-writes-first",
+			"-on-retry-expect-early-data-reason", "session_not_resumed",
+			// Test the peer certificate is reported correctly in each of the
+			// three logical connections.
 			"-on-initial-expect-peer-cert-file", path.Join(*resourceDir, rsaCertificateFile),
 			"-on-resume-expect-peer-cert-file", path.Join(*resourceDir, rsaCertificateFile),
 			"-on-retry-expect-peer-cert-file", path.Join(*resourceDir, ecdsaP256CertificateFile),
@@ -13008,6 +13032,34 @@
 		},
 	})
 
+	// Test the server rejects 0-RTT if it does not recognize the ticket.
+	testCases = append(testCases, testCase{
+		testType: serverTest,
+		name:     "EarlyData-RejectTicket-Server-TLS13",
+		config: Config{
+			MaxVersion: VersionTLS13,
+			MinVersion: VersionTLS13,
+			Bugs: ProtocolBugs{
+				SendEarlyData:           [][]byte{{1, 2, 3, 4}},
+				ExpectEarlyDataAccepted: false,
+				// Corrupt the ticket.
+				FilterTicket: func(in []byte) ([]byte, error) {
+					in[len(in)-1] ^= 1
+					return in, nil
+				},
+			},
+		},
+		messageCount:         2,
+		resumeSession:        true,
+		expectResumeRejected: true,
+		flags: []string{
+			"-enable-early-data",
+			"-on-resume-expect-reject-early-data",
+			"-on-resume-expect-early-data-reason", "session_not_resumed",
+		},
+	})
+
+	// Test the client handles 0-RTT being rejected via a HelloRetryRequest.
 	testCases = append(testCases, testCase{
 		testType: clientTest,
 		name:     "EarlyData-HRR-Client-TLS13",
@@ -13027,6 +13079,99 @@
 			"-enable-early-data",
 			"-expect-ticket-supports-early-data",
 			"-expect-reject-early-data",
+			"-on-retry-expect-early-data-reason", "hello_retry_request",
+		},
+	})
+
+	// Test the server rejects 0-RTT if it needs to send a HelloRetryRequest.
+	testCases = append(testCases, testCase{
+		testType: serverTest,
+		name:     "EarlyData-HRR-Server-TLS13",
+		config: Config{
+			MaxVersion: VersionTLS13,
+			MinVersion: VersionTLS13,
+			// Require a HelloRetryRequest for every curve.
+			DefaultCurves: []CurveID{},
+			Bugs: ProtocolBugs{
+				SendEarlyData:           [][]byte{{1, 2, 3, 4}},
+				ExpectEarlyDataAccepted: false,
+			},
+		},
+		messageCount:  2,
+		resumeSession: true,
+		flags: []string{
+			"-enable-early-data",
+			"-on-resume-expect-reject-early-data",
+			"-on-resume-expect-early-data-reason", "hello_retry_request",
+		},
+	})
+
+	// Test the client handles a 0-RTT reject from both ticket rejection and
+	// HelloRetryRequest.
+	testCases = append(testCases, testCase{
+		testType: clientTest,
+		name:     "EarlyData-HRR-RejectTicket-Client-TLS13",
+		config: Config{
+			MaxVersion:       VersionTLS13,
+			MaxEarlyDataSize: 16384,
+			Certificates:     []Certificate{rsaCertificate},
+		},
+		resumeConfig: &Config{
+			MaxVersion:             VersionTLS13,
+			MaxEarlyDataSize:       16384,
+			Certificates:           []Certificate{ecdsaP256Certificate},
+			SessionTicketsDisabled: true,
+			Bugs: ProtocolBugs{
+				SendHelloRetryRequestCookie: []byte{1, 2, 3, 4},
+			},
+		},
+		resumeSession:        true,
+		expectResumeRejected: true,
+		flags: []string{
+			"-enable-early-data",
+			"-expect-ticket-supports-early-data",
+			"-expect-reject-early-data",
+			// The client sees HelloRetryRequest before the resumption result,
+			// though neither value is inherently preferable.
+			"-on-retry-expect-early-data-reason", "hello_retry_request",
+			// Test the peer certificate is reported correctly in each of the
+			// three logical connections.
+			"-on-initial-expect-peer-cert-file", path.Join(*resourceDir, rsaCertificateFile),
+			"-on-resume-expect-peer-cert-file", path.Join(*resourceDir, rsaCertificateFile),
+			"-on-retry-expect-peer-cert-file", path.Join(*resourceDir, ecdsaP256CertificateFile),
+			// Session tickets are disabled, so the runner will not send a ticket.
+			"-on-retry-expect-no-session",
+		},
+	})
+
+	// Test the server rejects 0-RTT if it needs to send a HelloRetryRequest.
+	testCases = append(testCases, testCase{
+		testType: serverTest,
+		name:     "EarlyData-HRR-RejectTicket-Server-TLS13",
+		config: Config{
+			MaxVersion: VersionTLS13,
+			MinVersion: VersionTLS13,
+			// Require a HelloRetryRequest for every curve.
+			DefaultCurves: []CurveID{},
+			Bugs: ProtocolBugs{
+				SendEarlyData:           [][]byte{{1, 2, 3, 4}},
+				ExpectEarlyDataAccepted: false,
+				// Corrupt the ticket.
+				FilterTicket: func(in []byte) ([]byte, error) {
+					in[len(in)-1] ^= 1
+					return in, nil
+				},
+			},
+		},
+		messageCount:         2,
+		resumeSession:        true,
+		expectResumeRejected: true,
+		flags: []string{
+			"-enable-early-data",
+			"-on-resume-expect-reject-early-data",
+			// The server sees the missed resumption before HelloRetryRequest,
+			// though neither value is inherently preferable.
+			"-on-resume-expect-early-data-reason", "session_not_resumed",
 		},
 	})
 
@@ -13192,6 +13337,10 @@
 			"-enable-early-data",
 			"-expect-ticket-supports-early-data",
 			"-expect-reject-early-data",
+			// The client does not learn ALPN was the cause.
+			"-on-retry-expect-early-data-reason", "peer_declined",
+			// In the 0-RTT state, we surface the predicted ALPN. After
+			// processing the reject, we surface the real one.
 			"-on-initial-expect-alpn", "foo",
 			"-on-resume-expect-alpn", "foo",
 			"-on-retry-expect-alpn", "bar",
@@ -13218,6 +13367,10 @@
 			"-enable-early-data",
 			"-expect-ticket-supports-early-data",
 			"-expect-reject-early-data",
+			// The client does not learn ALPN was the cause.
+			"-on-retry-expect-early-data-reason", "peer_declined",
+			// In the 0-RTT state, we surface the predicted ALPN. After
+			// processing the reject, we surface the real one.
 			"-on-initial-expect-alpn", "",
 			"-on-resume-expect-alpn", "",
 			"-on-retry-expect-alpn", "foo",
@@ -13245,6 +13398,10 @@
 			"-enable-early-data",
 			"-expect-ticket-supports-early-data",
 			"-expect-reject-early-data",
+			// The client does not learn ALPN was the cause.
+			"-on-retry-expect-early-data-reason", "peer_declined",
+			// In the 0-RTT state, we surface the predicted ALPN. After
+			// processing the reject, we surface the real one.
 			"-on-initial-expect-alpn", "foo",
 			"-on-resume-expect-alpn", "foo",
 			"-on-retry-expect-alpn", "",
@@ -13299,10 +13456,31 @@
 			"-enable-early-data",
 			"-expect-ticket-supports-early-data",
 			"-expect-no-offer-early-data",
+			// Offer different ALPN values in the initial and resumption.
 			"-on-initial-advertise-alpn", "\x03foo",
-			"-on-resume-advertise-alpn", "\x03bar",
 			"-on-initial-expect-alpn", "foo",
+			"-on-resume-advertise-alpn", "\x03bar",
 			"-on-resume-expect-alpn", "bar",
+			// The ALPN mismatch comes from the client, so it reports it as the
+			// reason.
+			"-on-resume-expect-early-data-reason", "alpn_mismatch",
+		},
+	})
+
+	// Test that the client does not offer 0-RTT to servers which never
+	// advertise it.
+	testCases = append(testCases, testCase{
+		testType: clientTest,
+		name:     "EarlyData-NonZeroRTTSession-Client-TLS13",
+		config: Config{
+			MaxVersion: VersionTLS13,
+		},
+		resumeSession: true,
+		flags: []string{
+			"-enable-early-data",
+			"-on-resume-expect-no-offer-early-data",
+			// The client declines to offer 0-RTT because of the session.
+			"-on-resume-expect-early-data-reason", "unsupported_for_session",
 		},
 	})
 
@@ -13325,6 +13503,8 @@
 		flags: []string{
 			"-on-resume-enable-early-data",
 			"-expect-reject-early-data",
+			// The server rejects 0-RTT because of the session.
+			"-on-resume-expect-early-data-reason", "unsupported_for_session",
 		},
 	})
 
@@ -13350,6 +13530,7 @@
 			"-enable-early-data",
 			"-on-initial-select-alpn", "",
 			"-on-resume-select-alpn", "foo",
+			"-on-resume-expect-early-data-reason", "alpn_mismatch",
 		},
 	})
 
@@ -13375,6 +13556,7 @@
 			"-enable-early-data",
 			"-on-initial-select-alpn", "foo",
 			"-on-resume-select-alpn", "",
+			"-on-resume-expect-early-data-reason", "alpn_mismatch",
 		},
 	})
 
@@ -13399,6 +13581,7 @@
 			"-enable-early-data",
 			"-on-initial-select-alpn", "foo",
 			"-on-resume-select-alpn", "bar",
+			"-on-resume-expect-early-data-reason", "alpn_mismatch",
 		},
 	})
 
@@ -13443,6 +13626,8 @@
 			"-expect-ticket-supports-early-data",
 			"-send-channel-id", path.Join(*resourceDir, channelIDKeyFile),
 			"-expect-reject-early-data",
+			// The client never learns the reason was Channel ID.
+			"-on-retry-expect-early-data-reason", "peer_declined",
 		},
 	})
 
@@ -13461,6 +13646,7 @@
 			"-expect-ticket-supports-early-data",
 			"-send-channel-id", path.Join(*resourceDir, channelIDKeyFile),
 			"-on-resume-expect-accept-early-data",
+			"-on-resume-expect-early-data-reason", "accept",
 		},
 	})
 
@@ -13484,6 +13670,7 @@
 			"-expect-reject-early-data",
 			"-expect-channel-id",
 			base64.StdEncoding.EncodeToString(channelIDBytes),
+			"-on-resume-expect-early-data-reason", "channel_id",
 		},
 	})
 
@@ -13506,6 +13693,7 @@
 			"-enable-early-data",
 			"-on-resume-expect-accept-early-data",
 			"-enable-channel-id",
+			"-on-resume-expect-early-data-reason", "accept",
 		},
 	})
 
@@ -13687,6 +13875,45 @@
 		expectedLocalError: "remote error: unexpected message",
 	})
 
+	// If the client or server has 0-RTT enabled but disabled TLS 1.3, it should
+	// report a reason of protocol_version.
+	testCases = append(testCases, testCase{
+		testType:        clientTest,
+		name:            "EarlyDataEnabled-Client-MaxTLS12",
+		expectedVersion: VersionTLS12,
+		flags: []string{
+			"-enable-early-data",
+			"-max-version", strconv.Itoa(VersionTLS12),
+			"-expect-early-data-reason", "protocol_version",
+		},
+	})
+	testCases = append(testCases, testCase{
+		testType:        serverTest,
+		name:            "EarlyDataEnabled-Server-MaxTLS12",
+		expectedVersion: VersionTLS12,
+		flags: []string{
+			"-enable-early-data",
+			"-max-version", strconv.Itoa(VersionTLS12),
+			"-expect-early-data-reason", "protocol_version",
+		},
+	})
+
+	// The server additionally reports protocol_version if it enabled TLS 1.3,
+	// but the peer negotiated TLS 1.2. (The corresponding situation does not
+	// exist on the client because negotiating TLS 1.2 with a 0-RTT ClientHello
+	// is a fatal error.)
+	testCases = append(testCases, testCase{
+		testType: serverTest,
+		name:     "EarlyDataEnabled-Server-NegotiateTLS12",
+		config: Config{
+			MaxVersion: VersionTLS12,
+		},
+		expectedVersion: VersionTLS12,
+		flags: []string{
+			"-enable-early-data",
+			"-expect-early-data-reason", "protocol_version",
+		},
+	})
 }
 
 func addTLS13CipherPreferenceTests() {
diff --git a/ssl/test/test_config.cc b/ssl/test/test_config.cc
index 70e061b..4c43be8 100644
--- a/ssl/test/test_config.cc
+++ b/ssl/test/test_config.cc
@@ -179,6 +179,7 @@
   { "-expect-msg-callback", &TestConfig::expect_msg_callback },
   { "-handshaker-path", &TestConfig::handshaker_path },
   { "-delegated-credential", &TestConfig::delegated_credential },
+  { "-expect-early-data-reason", &TestConfig::expect_early_data_reason },
 };
 
 const Flag<std::string> kBase64Flags[] = {
diff --git a/ssl/test/test_config.h b/ssl/test/test_config.h
index 9221d6f..e0ea7a7 100644
--- a/ssl/test/test_config.h
+++ b/ssl/test/test_config.h
@@ -174,6 +174,7 @@
   bool export_traffic_secrets = false;
   bool key_update = false;
   std::string delegated_credential;
+  std::string expect_early_data_reason;
 
   int argc;
   char **argv;
diff --git a/ssl/tls13_client.cc b/ssl/tls13_client.cc
index ac97165..80918ad 100644
--- a/ssl/tls13_client.cc
+++ b/ssl/tls13_client.cc
@@ -188,6 +188,7 @@
   hs->tls13_state = state_send_second_client_hello;
   // 0-RTT is rejected if we receive a HelloRetryRequest.
   if (hs->in_early_data) {
+    ssl->s3->early_data_reason = ssl_early_data_hello_retry_request;
     return ssl_hs_early_data_rejected;
   }
   return ssl_hs_ok;
diff --git a/ssl/tls13_server.cc b/ssl/tls13_server.cc
index 13a89c0..82c9689 100644
--- a/ssl/tls13_server.cc
+++ b/ssl/tls13_server.cc
@@ -307,16 +307,15 @@
 
 static enum ssl_ticket_aead_result_t select_session(
     SSL_HANDSHAKE *hs, uint8_t *out_alert, UniquePtr<SSL_SESSION> *out_session,
-    int32_t *out_ticket_age_skew, const SSLMessage &msg,
-    const SSL_CLIENT_HELLO *client_hello) {
+    int32_t *out_ticket_age_skew, bool *out_offered_ticket,
+    const SSLMessage &msg, const SSL_CLIENT_HELLO *client_hello) {
   SSL *const ssl = hs->ssl;
-  *out_session = NULL;
+  *out_session = nullptr;
 
-  // Decode the ticket if we agreed on a PSK key exchange mode.
   CBS pre_shared_key;
-  if (!hs->accept_psk_mode ||
-      !ssl_client_hello_get_extension(client_hello, &pre_shared_key,
-                                      TLSEXT_TYPE_pre_shared_key)) {
+  *out_offered_ticket = ssl_client_hello_get_extension(
+      client_hello, &pre_shared_key, TLSEXT_TYPE_pre_shared_key);
+  if (!*out_offered_ticket) {
     return ssl_ticket_aead_ignore_ticket;
   }
 
@@ -337,6 +336,11 @@
     return ssl_ticket_aead_error;
   }
 
+  // If the peer did not offer psk_dhe, ignore the resumption.
+  if (!hs->accept_psk_mode) {
+    return ssl_ticket_aead_ignore_ticket;
+  }
+
   // TLS 1.3 session tickets are renewed separately as part of the
   // NewSessionTicket.
   bool unused_renew;
@@ -406,10 +410,18 @@
 
   uint8_t alert = SSL_AD_DECODE_ERROR;
   UniquePtr<SSL_SESSION> session;
-  switch (select_session(hs, &alert, &session, &ssl->s3->ticket_age_skew, msg,
-                         &client_hello)) {
+  bool offered_ticket = false;
+  switch (select_session(hs, &alert, &session, &ssl->s3->ticket_age_skew,
+                         &offered_ticket, msg, &client_hello)) {
     case ssl_ticket_aead_ignore_ticket:
       assert(!session);
+      if (!ssl->enable_early_data) {
+        ssl->s3->early_data_reason = ssl_early_data_disabled;
+      } else if (!offered_ticket) {
+        ssl->s3->early_data_reason = ssl_early_data_no_session_offered;
+      } else {
+        ssl->s3->early_data_reason = ssl_early_data_session_not_resumed;
+      }
       if (!ssl_get_new_session(hs, 1 /* server */)) {
         ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_INTERNAL_ERROR);
         return ssl_hs_error;
@@ -422,17 +434,23 @@
       hs->new_session =
           SSL_SESSION_dup(session.get(), SSL_SESSION_DUP_AUTH_ONLY);
 
-      if (ssl->enable_early_data &&
-          // Early data must be acceptable for this ticket.
-          session->ticket_max_early_data != 0 &&
-          // The client must have offered early data.
-          hs->early_data_offered &&
+      if (!ssl->enable_early_data) {
+        ssl->s3->early_data_reason = ssl_early_data_disabled;
+      } else if (session->ticket_max_early_data == 0) {
+        ssl->s3->early_data_reason = ssl_early_data_unsupported_for_session;
+      } else if (!hs->early_data_offered) {
+        ssl->s3->early_data_reason = ssl_early_data_peer_declined;
+      } else if (ssl->s3->channel_id_valid) {
           // Channel ID is incompatible with 0-RTT.
-          !ssl->s3->channel_id_valid &&
-          // If Token Binding is negotiated, reject 0-RTT.
-          !ssl->s3->token_binding_negotiated &&
-          // The negotiated ALPN must match the one in the ticket.
-          MakeConstSpan(ssl->s3->alpn_selected) == session->early_alpn) {
+        ssl->s3->early_data_reason = ssl_early_data_channel_id;
+      } else if (ssl->s3->token_binding_negotiated) {
+          // Token Binding is incompatible with 0-RTT.
+        ssl->s3->early_data_reason = ssl_early_data_token_binding;
+      } else if (MakeConstSpan(ssl->s3->alpn_selected) != session->early_alpn) {
+        // The negotiated ALPN must match the one in the ticket.
+        ssl->s3->early_data_reason = ssl_early_data_alpn_mismatch;
+      } else {
+        ssl->s3->early_data_reason = ssl_early_data_accepted;
         ssl->s3->early_data_accepted = true;
       }
 
@@ -499,7 +517,10 @@
   bool need_retry;
   if (!resolve_ecdhe_secret(hs, &need_retry, &client_hello)) {
     if (need_retry) {
-      ssl->s3->early_data_accepted = false;
+      if (ssl->s3->early_data_accepted) {
+        ssl->s3->early_data_reason = ssl_early_data_hello_retry_request;
+        ssl->s3->early_data_accepted = false;
+      }
       ssl->s3->skip_early_data = true;
       ssl->method->next_message(ssl);
       if (!hs->transcript.UpdateForHelloRetryRequest()) {