Corrupted accepted 0-RTT data does not produce the required bad_record_mac alert
Problem Description
After wolfSSL accepts TLS 1.3 early data, a corrupted 0-RTT application_data record is treated as ignorable early data instead of causing the required fatal bad_record_mac alert. The focused memio test first minted a session ticket that permits 0-RTT, then resumed with early data, flipped one byte in the first encrypted client application_data record before the server decrypted it, and checked the server's transmitted alert history. wolfSSL returned a fatal error to the API but did not transmit or record bad_record_mac.
Standard Requirement
- Section: 4.2.10. Early Data Indication
- Evidence:
D:\paper\idea\document\rfc8446.txt:3034-3039
If the server chooses to accept the "early_data" extension, then it
MUST comply with the same error-handling requirements specified for
all records when processing early data records. Specifically, if the
server fails to decrypt a 0-RTT record following an accepted
"early_data" extension, it MUST terminate the connection with a
"bad_record_mac" alert as per Section 5.2.
Interpretation:
Once the server accepts the TLS 1.3 early_data extension, a decryption failure on a 0-RTT record is not optional recovery behavior. The server must terminate the connection with a bad_record_mac alert.
Relevant Source Code
wolfSSL accepts TLS 1.3 early data when the ClientHello contains early_data, the server has a nonzero early-data limit, the selected ticket is usable, and the server derives early-data decrypt keys. The record processing path then has a special TLS 1.3 early-data branch on decrypt failure that returns before the generic bad_record_mac alert is sent.
src/tls13.c:6906-6935
extEarlyData = TLSX_Find(ssl->extensions, TLSX_EARLY_DATA);
if (extEarlyData != NULL) {
/* Check if accepting early data and first PSK.
* RFC 9973: early_data is not compatible with
* cert_with_extern_psk, so skip key derivation in that case. */
if (ssl->earlyData != no_early_data && first
&& ssl->options.maxEarlyDataSz > 0
#ifdef WOLFSSL_CERT_WITH_EXTERN_PSK
&& !hasCertWithExternPsk
#endif
#if defined(HAVE_SESSION_TICKET) && !defined(NO_SESSION_CACHE)
/* RFC 8446 section 8: evict the session from the cache.
* Accept 0-RTT only when the eviction found the entry
* (single-use). */
&& wolfSSL_SSL_CTX_remove_session(ssl->ctx, ssl->session)
== 1
#endif
) {
extEarlyData->resp = 1;
/* Derive early data decryption key. */
ret = DeriveTls13Keys(ssl, early_data_key, DECRYPT_SIDE_ONLY,
1);
if (ret != 0)
return ret;
if ((ret = SetKeysSide(ssl, DECRYPT_SIDE_ONLY)) != 0)
return ret;
ssl->keys.encryptionOn = 1;
ssl->earlyData = process_early_data;
This is the server acceptance path: extEarlyData->resp = 1 means the server will signal accepted early data, and ssl->earlyData = process_early_data puts the server into accepted 0-RTT processing with early-data decrypt keys installed.
src/internal.c:24583-24625
else {
WOLFSSL_MSG("Decrypt failed");
#ifdef WOLFSSL_DTLS
/* If in DTLS mode, if the decrypt fails for any
* reason, pretend the datagram never happened. */
if (ssl->options.dtls) {
DropAndRestartProcessReply(ssl);
return HandleDTLSDecryptFailed(ssl);
}
#endif /* WOLFSSL_DTLS */
#ifdef WOLFSSL_EARLY_DATA
if (ssl->options.tls1_3) {
if (ssl->options.side == WOLFSSL_SERVER_END &&
ssl->earlyData != no_early_data &&
ssl->options.clientState <
CLIENT_FINISHED_COMPLETE) {
ssl->earlyDataSz += ssl->curSize;
if (ssl->earlyDataSz <=
ssl->options.maxEarlyDataSz) {
WOLFSSL_MSG("Ignoring EarlyData!");
if (ssl->keys.peer_sequence_number_lo-- == 0)
ssl->keys.peer_sequence_number_hi--;
ssl->options.processReply = doProcessInit;
ssl->buffers.inputBuffer.idx += ssl->curSize;
if (ssl->buffers.inputBuffer.idx >
ssl->buffers.inputBuffer.length) {
WOLFSSL_ERROR(BUFFER_E);
return BUFFER_E;
}
return 0;
}
WOLFSSL_MSG("Too much EarlyData!");
SendAlert(ssl, alert_fatal, unexpected_message);
WOLFSSL_ERROR(TOO_MUCH_EARLY_DATA);
return TOO_MUCH_EARLY_DATA;
}
}
#endif
SendAlert(ssl, alert_fatal, bad_record_mac);
/* Push error once we know that we will error out here */
WOLFSSL_ERROR(ret);
return ret;
For a TLS 1.3 server still before CLIENT_FINISHED_COMPLETE, any decrypt failure on an early-data record within maxEarlyDataSz takes the Ignoring EarlyData! branch and returns 0 before reaching the unconditional SendAlert(ssl, alert_fatal, bad_record_mac) path. This contradicts RFC 8446 once the server has accepted the early_data extension.
tests/api/test_tls13.c:5247-5295
/* Mint a ticket that permits 0-RTT. */
ExpectIntEQ(test_memio_setup(&test_ctx, &ctx_c, &ctx_s, &ssl_c, &ssl_s,
wolfTLSv1_3_client_method, wolfTLSv1_3_server_method),
0);
ExpectIntGE(wolfSSL_CTX_set_max_early_data(ctx_s, MAX_EARLY_DATA_SZ), 0);
ExpectIntGE(wolfSSL_set_max_early_data(ssl_s, MAX_EARLY_DATA_SZ), 0);
ExpectIntEQ(test_memio_do_handshake(ssl_c, ssl_s, 10, NULL), 0);
ExpectIntEQ(wolfSSL_read(ssl_c, msgBuf, (int)sizeof(msgBuf)), -1);
ExpectIntEQ(wolfSSL_get_error(ssl_c, -1), WOLFSSL_ERROR_WANT_READ);
ExpectNotNull(sess = wolfSSL_get1_session(ssl_c));
...
ExpectIntEQ(test_tls13_early_data_write_until_write_ok(ssl_c, earlyMsg,
(int)sizeof(earlyMsg), &written), (int)sizeof(earlyMsg));
ExpectIntEQ(written, (int)sizeof(earlyMsg));
/* Corrupt the first encrypted application_data record in the client's
* outbound buffer before the server attempts to decrypt it. */
...
if (EXPECT_SUCCESS())
test_ctx.s_buff[fuzzOff] ^= 0x01;
ExpectIntEQ(test_tls13_early_data_read_until_write_ok(ssl_s, msgBuf,
(int)sizeof(msgBuf), &read), WOLFSSL_FATAL_ERROR);
ExpectIntEQ(wolfSSL_get_alert_history(ssl_s, &h), WOLFSSL_SUCCESS);
ExpectIntEQ(h.last_tx.code, bad_record_mac);
ExpectIntEQ(h.last_tx.level, alert_fatal);
The focused test uses wolfSSL's in-memory I/O harness, not an abstract adapter: it creates a real TLS 1.3 ticket allowing 0-RTT, resumes with early data, mutates the encrypted record bytes in the client-to-server buffer, and then asserts that the server sent fatal bad_record_mac.
Runtime Evidence
Round 1
- Earlier native handshake probe: positive control passed, but the abstract mutation probe could not inject raw TLS 1.3 bytes and was not decisive for this issue.
- Follow-up focused test: added and ran
test_tls13_early_data_bad_record_mac in wolfSSL's TLS 1.3 API test group.
- Test binary:
harness-build/cmake-mingw-examples-psk-compat/tests/unit.test.exe.
- Relevant build features:
WOLFSSL_TLS13, WOLFSSL_EARLY_DATA, and HAVE_SESSION_TICKET are enabled in harness-build/cmake-mingw-examples-psk-compat/wolfssl/options.h.
- Positive control inside test: the test first completes a TLS 1.3 handshake and obtains a session ticket with nonzero early-data allowance, then successfully calls
wolfSSL_write_early_data() on the resumed client path. This verifies that the reproducer reached the real 0-RTT path rather than only a synthetic record parser.
- Mutation: the test locates the first encrypted client
application_data record in memio and flips one byte at the start of that record body before the server reads it.
- Expected result: server returns fatal error and records/sends fatal
bad_record_mac alert code 20.
- Observed result from the saved run:
tests/api/test_tls13.c line 4891 failed, expected: h.last_tx.code == bad_record_mac, result: -1 != 20. Exit code was 1.
- Artifact:
tasks/cand-1a405ce4b2d3-replay/runtime-artifacts/unit-test.stdout.log.
Round 2
- Recheck command: from
D:\paper\idea\implementions\wolfssl-master, with D:\paper\idea\opt\runs\rfc8446-wolfssl\harness-build\cmake-mingw-examples-psk-compat prepended to PATH, ran unit.test.exe --api -test_tls13_early_data_bad_record_mac.
- Recheck result: exit code
1; the same assertion failed at the current source line tests/api/test_tls13.c:5294.
- Recheck stdout excerpt:
expected: h.last_tx.code == bad_record_mac; result: -1 != 20.
- Interpretation: wolfSSL did return
WOLFSSL_FATAL_ERROR for the corrupted accepted 0-RTT record, but the server's transmitted alert history remained unset instead of containing fatal bad_record_mac.
- Artifacts:
tasks/cand-1a405ce4b2d3-replay-recheck/runtime-artifacts/unit-test-rerun-current.stdout.log and tasks/cand-1a405ce4b2d3-replay-recheck/runtime-artifacts/summary.json.
Inconsistency Reason
RFC 8446 requires fatal bad_record_mac when a server fails to decrypt a 0-RTT record after accepting early data. wolfSSL's accepted early-data decrypt-failure path instead counts the record size, branches as ignored early data, advances the input buffer, and returns before sending bad_record_mac. The focused runtime test reproduced this behavior on the current build: the server reported a fatal error to the API but did not record a transmitted bad_record_mac alert.
Decision Reason
Verified standard evidence, code evidence, and current runtime evidence establish a concrete divergence. The server acceptance path sets extEarlyData->resp = 1 and ssl->earlyData = process_early_data; the record decrypt-failure path then returns through the early-data ignore branch before SendAlert(ssl, alert_fatal, bad_record_mac). The focused unit test exercises that path with a real ticket-based 0-RTT resumption and observes h.last_tx.code == -1 instead of bad_record_mac (20). Final verdict remains issue_found.
Corrupted accepted 0-RTT data does not produce the required bad_record_mac alert
Problem Description
After wolfSSL accepts TLS 1.3 early data, a corrupted 0-RTT application_data record is treated as ignorable early data instead of causing the required fatal bad_record_mac alert. The focused memio test first minted a session ticket that permits 0-RTT, then resumed with early data, flipped one byte in the first encrypted client application_data record before the server decrypted it, and checked the server's transmitted alert history. wolfSSL returned a fatal error to the API but did not transmit or record bad_record_mac.
Standard Requirement
D:\paper\idea\document\rfc8446.txt:3034-3039Interpretation:
Once the server accepts the TLS 1.3
early_dataextension, a decryption failure on a 0-RTT record is not optional recovery behavior. The server must terminate the connection with abad_record_macalert.Relevant Source Code
wolfSSL accepts TLS 1.3 early data when the ClientHello contains
early_data, the server has a nonzero early-data limit, the selected ticket is usable, and the server derives early-data decrypt keys. The record processing path then has a special TLS 1.3 early-data branch on decrypt failure that returns before the genericbad_record_macalert is sent.src/tls13.c:6906-6935This is the server acceptance path:
extEarlyData->resp = 1means the server will signal accepted early data, andssl->earlyData = process_early_dataputs the server into accepted 0-RTT processing with early-data decrypt keys installed.src/internal.c:24583-24625For a TLS 1.3 server still before
CLIENT_FINISHED_COMPLETE, any decrypt failure on an early-data record withinmaxEarlyDataSztakes theIgnoring EarlyData!branch and returns0before reaching the unconditionalSendAlert(ssl, alert_fatal, bad_record_mac)path. This contradicts RFC 8446 once the server has accepted theearly_dataextension.tests/api/test_tls13.c:5247-5295The focused test uses wolfSSL's in-memory I/O harness, not an abstract adapter: it creates a real TLS 1.3 ticket allowing 0-RTT, resumes with early data, mutates the encrypted record bytes in the client-to-server buffer, and then asserts that the server sent fatal
bad_record_mac.Runtime Evidence
Round 1
test_tls13_early_data_bad_record_macin wolfSSL's TLS 1.3 API test group.harness-build/cmake-mingw-examples-psk-compat/tests/unit.test.exe.WOLFSSL_TLS13,WOLFSSL_EARLY_DATA, andHAVE_SESSION_TICKETare enabled inharness-build/cmake-mingw-examples-psk-compat/wolfssl/options.h.wolfSSL_write_early_data()on the resumed client path. This verifies that the reproducer reached the real 0-RTT path rather than only a synthetic record parser.application_datarecord in memio and flips one byte at the start of that record body before the server reads it.bad_record_macalert code20.tests/api/test_tls13.c line 4891 failed,expected: h.last_tx.code == bad_record_mac,result: -1 != 20. Exit code was1.tasks/cand-1a405ce4b2d3-replay/runtime-artifacts/unit-test.stdout.log.Round 2
D:\paper\idea\implementions\wolfssl-master, withD:\paper\idea\opt\runs\rfc8446-wolfssl\harness-build\cmake-mingw-examples-psk-compatprepended toPATH, ranunit.test.exe --api -test_tls13_early_data_bad_record_mac.1; the same assertion failed at the current source linetests/api/test_tls13.c:5294.expected: h.last_tx.code == bad_record_mac;result: -1 != 20.WOLFSSL_FATAL_ERRORfor the corrupted accepted 0-RTT record, but the server's transmitted alert history remained unset instead of containing fatalbad_record_mac.tasks/cand-1a405ce4b2d3-replay-recheck/runtime-artifacts/unit-test-rerun-current.stdout.logandtasks/cand-1a405ce4b2d3-replay-recheck/runtime-artifacts/summary.json.Inconsistency Reason
RFC 8446 requires fatal
bad_record_macwhen a server fails to decrypt a 0-RTT record after accepting early data. wolfSSL's accepted early-data decrypt-failure path instead counts the record size, branches as ignored early data, advances the input buffer, and returns before sendingbad_record_mac. The focused runtime test reproduced this behavior on the current build: the server reported a fatal error to the API but did not record a transmittedbad_record_macalert.Decision Reason
Verified standard evidence, code evidence, and current runtime evidence establish a concrete divergence. The server acceptance path sets
extEarlyData->resp = 1andssl->earlyData = process_early_data; the record decrypt-failure path then returns through the early-data ignore branch beforeSendAlert(ssl, alert_fatal, bad_record_mac). The focused unit test exercises that path with a real ticket-based 0-RTT resumption and observesh.last_tx.code == -1instead ofbad_record_mac(20). Final verdict remainsissue_found.