Unencrypted record-layer version is still checked instead of ignored
Summary
This report covers the TLS 1.3 requirement to ignore the record-layer version on unencrypted records. After rechecking RFC 8446, re-reading the parser, and rerunning a live differential probe on August 3, 2026, the issue remains real on the client receive path: a valid HelloRetryRequest is accepted when the outer record version is 0x0303 or 0x0304, but the same message is rejected with VERSION_ERROR when only that outer record version is changed to 0x0301 or 0x0302.
Standard Requirement
- Official standard link: https://www.rfc-editor.org/rfc/rfc8446.html
- Primary normative text: Section 5.1
Record Layer
- Supporting checklist text: Appendix C.3
Implementation Pitfalls
- Supporting compatibility text: Appendix D
Backward Compatibility
- Related but distinct handshake checks: Section 4.1.2
Client Hello, Section 4.1.3 Server Hello, Section 4.1.4 Hello Retry Request
Relevant RFC 8446 text:
legacy_record_version: MUST be set to 0x0303 for all records
generated by a TLS 1.3 implementation other than an initial
ClientHello ... This field is deprecated and MUST be ignored for all purposes.
- Do you ignore the TLS record layer version number in all
unencrypted TLS records (see Appendix D)?
Prior versions of TLS used the record layer version number
(TLSPlaintext.legacy_record_version and
TLSCiphertext.legacy_record_version) for various purposes. As of
TLS 1.3, this field is deprecated. The value of
TLSPlaintext.legacy_record_version MUST be ignored by all
implementations.
By contrast, the handshake-layer legacy_version fields in ClientHello, ServerHello, and HelloRetryRequest are still meaningful and still have to be checked as specified by Sections 4.1.2 through 4.1.4. The requirement here is therefore narrow but clear: a TLS 1.3 implementation may validate the handshake-layer legacy_version fields, but it must not reject an otherwise valid unencrypted TLS record solely because of the outer TLSPlaintext record header version bytes.
Relevant Source Code
The decisive parser logic is still in src/internal.c.
src/internal.c:12724-12770
/* catch version mismatch */
#ifndef WOLFSSL_TLS13
if (rh->pvMajor != ssl->version.major || rh->pvMinor != ssl->version.minor)
#else
if (rh->pvMajor != ssl->version.major ||
(rh->pvMinor != ssl->version.minor &&
(!IsAtLeastTLSv1_3(ssl->version) || rh->pvMinor != tls12minor)
))
#endif
{
if (ssl->options.side == WOLFSSL_SERVER_END &&
ssl->options.acceptState < ACCEPT_FIRST_REPLY_DONE)
WOLFSSL_MSG("Client attempting to connect with different version");
else if (ssl->options.side == WOLFSSL_CLIENT_END &&
ssl->options.downgrade &&
ssl->options.connectState < FIRST_REPLY_DONE)
WOLFSSL_MSG("Server attempting to accept with different version");
...
else if (!(ssl->options.side == WOLFSSL_CLIENT_END &&
ssl->options.connectState == CLIENT_HELLO_SENT &&
rh->type == alert &&
rh->pvMajor == ssl->version.major &&
(rh->pvMinor < ssl->version.minor)
)) {
WOLFSSL_MSG("SSL version error");
WOLFSSL_ERROR_VERBOSE(VERSION_ERROR);
return VERSION_ERROR;
}
}
On TLS 1.3 paths, GetRecordHeader() still treats the outer record header version as a gate. It accepts only the negotiated version or the special TLS 1.2 compatibility minor, and otherwise returns VERSION_ERROR. That is selective validation, not "ignore for all purposes".
Two details matter for reproducing the issue correctly:
- The server path has an early-handshake softening branch before
ACCEPT_FIRST_REPLY_DONE, so a first-ClientHello server probe is not the cleanest differentiator.
- The TLS 1.3 client path reaches this record-header check before
FIRST_REPLY_DONE, which makes HelloRetryRequest a clean place to test whether only the outer record version changes behavior.
For the client state transition used in the rerun, src/tls13.c moves the TLS 1.3 client from CLIENT_HELLO_SENT to FIRST_REPLY_DONE only after processing the first server flight.
Implementation Behavior
The implementation behavior is:
- Parse the outer record header into
rh->pvMajor and rh->pvMinor.
- On TLS 1.3, accept only
ssl->version.minor or TLSv1_2_MINOR.
- Otherwise return
VERSION_ERROR, except for a few special cases such as early server-side handling and pre-negotiation alerts.
That means the record parser still branches on specific unencrypted record-layer version values instead of ignoring them and leaving version semantics to the handshake-layer fields.
Runtime Evidence
Focused rerun date: 2026-08-03
Round 1: existing family reproducer
The preexisting automated round still completed:
tls13_positive_control: passed
family_reproducer: passed
However, that family reproducer only emitted the structured status unencrypted_record_version_is_checked_not_ignored; it did not perform a live peer-side differential test that varied only the outer record-layer version while keeping the handshake-layer version fields valid. It was useful as a source-backed hint, but not the decisive runtime proof.
Round 2: live differential HelloRetryRequest probe
I reran the requirement family with a focused live probe against the TLS 1.3 client path.
Setup:
- Start
client.exe -v 4 -d -J -h 127.0.0.1 -p <port>.
- Capture the client's first
ClientHello.
- Synthesize a valid
HelloRetryRequest that preserves the correct handshake-layer fields:
ServerHello/HelloRetryRequest.legacy_version = 0x0303
- echoed session ID from the client's first
ClientHello
- one cipher suite that the client actually offered
supported_versions = 0x0304
- one key-share group that the client actually advertised
- Change only the outer TLSPlaintext record header version.
- Observe whether the client sends a second
ClientHello or immediately sends a fatal alert.
Observed results:
| Outer record version |
Handshake legacy_version |
Observed behavior |
0x0303 |
0x0303 |
Accepted the HelloRetryRequest and sent a second ClientHello |
0x0304 |
0x0303 |
Accepted the HelloRetryRequest and sent a second ClientHello |
0x0301 |
0x0303 |
Rejected it, sent fatal alert 15 03 03 00 02 02 46, and logged wolfSSL_connect error -326, record layer version error |
0x0302 |
0x0303 |
Rejected it, sent fatal alert 15 03 03 00 02 02 46, and logged wolfSSL_connect error -326, record layer version error |
Acceptance note:
For the accepted cases, the harness intentionally stopped after observing the second ClientHello, so the client later reported a socket shutdown rather than a completed handshake. That does not weaken the differential result: the acceptance criterion was whether the client advanced to the second ClientHello, and the rejection criterion was whether it immediately sent a fatal alert with VERSION_ERROR.
Why this is decisive:
The handshake-layer legacy_version stayed correct at 0x0303 in every case. The session ID, offered cipher, supported_versions, and selected key-share group were also kept consistent. Only the outer unencrypted record header version changed, and that alone flipped the result from "continue handshake" to "fatal alert". That is direct runtime evidence that the implementation is still checking the unencrypted record-layer version instead of ignoring it.
Inconsistency Reason
RFC 8446 requires the outer TLSPlaintext.legacy_record_version field to be ignored on unencrypted TLS 1.3 records, while wolfSSL's GetRecordHeader() still treats that field as a validity condition. Because a valid HelloRetryRequest is rejected when only the outer record version changes to other TLS 1.x values, the implementation does not satisfy the standard's ignore rule.
Decision Reason
This is a real issue. The standard text is explicit, the parser still returns VERSION_ERROR from the record-header version check, and the live differential rerun shows that changing only the outer unencrypted record version is enough to make the TLS 1.3 client reject an otherwise valid HelloRetryRequest.
Fix Direction
The fix should make TLS 1.3 unencrypted record processing ignore TLSPlaintext.legacy_record_version on receipt and leave version validation to the handshake-layer fields that RFC 8446 still defines:
- keep validating
ClientHello.legacy_version, ServerHello.legacy_version, and HelloRetryRequest fields as required by Sections 4.1.2 through 4.1.4;
- stop rejecting an unencrypted TLS 1.3 record only because its outer
TLSPlaintext.legacy_record_version is not one of the parser's currently preferred values;
- add a regression test that repeats the differential probe above and proves the same valid
HelloRetryRequest is processed identically across alternate outer record-header versions.
Unencrypted record-layer version is still checked instead of ignored
Summary
This report covers the TLS 1.3 requirement to ignore the record-layer version on unencrypted records. After rechecking RFC 8446, re-reading the parser, and rerunning a live differential probe on August 3, 2026, the issue remains real on the client receive path: a valid HelloRetryRequest is accepted when the outer record version is
0x0303or0x0304, but the same message is rejected withVERSION_ERRORwhen only that outer record version is changed to0x0301or0x0302.Standard Requirement
Record LayerImplementation PitfallsBackward CompatibilityClient Hello, Section 4.1.3Server Hello, Section 4.1.4Hello Retry RequestRelevant RFC 8446 text:
By contrast, the handshake-layer
legacy_versionfields inClientHello,ServerHello, andHelloRetryRequestare still meaningful and still have to be checked as specified by Sections 4.1.2 through 4.1.4. The requirement here is therefore narrow but clear: a TLS 1.3 implementation may validate the handshake-layerlegacy_versionfields, but it must not reject an otherwise valid unencrypted TLS record solely because of the outer TLSPlaintext record header version bytes.Relevant Source Code
The decisive parser logic is still in
src/internal.c.src/internal.c:12724-12770On TLS 1.3 paths,
GetRecordHeader()still treats the outer record header version as a gate. It accepts only the negotiated version or the special TLS 1.2 compatibility minor, and otherwise returnsVERSION_ERROR. That is selective validation, not "ignore for all purposes".Two details matter for reproducing the issue correctly:
ACCEPT_FIRST_REPLY_DONE, so a first-ClientHello server probe is not the cleanest differentiator.FIRST_REPLY_DONE, which makes HelloRetryRequest a clean place to test whether only the outer record version changes behavior.For the client state transition used in the rerun,
src/tls13.cmoves the TLS 1.3 client fromCLIENT_HELLO_SENTtoFIRST_REPLY_DONEonly after processing the first server flight.Implementation Behavior
The implementation behavior is:
rh->pvMajorandrh->pvMinor.ssl->version.minororTLSv1_2_MINOR.VERSION_ERROR, except for a few special cases such as early server-side handling and pre-negotiation alerts.That means the record parser still branches on specific unencrypted record-layer version values instead of ignoring them and leaving version semantics to the handshake-layer fields.
Runtime Evidence
Focused rerun date:
2026-08-03Round 1: existing family reproducer
The preexisting automated round still completed:
tls13_positive_control:passedfamily_reproducer:passedHowever, that family reproducer only emitted the structured status
unencrypted_record_version_is_checked_not_ignored; it did not perform a live peer-side differential test that varied only the outer record-layer version while keeping the handshake-layer version fields valid. It was useful as a source-backed hint, but not the decisive runtime proof.Round 2: live differential HelloRetryRequest probe
I reran the requirement family with a focused live probe against the TLS 1.3 client path.
Setup:
client.exe -v 4 -d -J -h 127.0.0.1 -p <port>.ClientHello.HelloRetryRequestthat preserves the correct handshake-layer fields:ServerHello/HelloRetryRequest.legacy_version = 0x0303ClientHellosupported_versions = 0x0304ClientHelloor immediately sends a fatal alert.Observed results:
legacy_version0x03030x0303HelloRetryRequestand sent a secondClientHello0x03040x0303HelloRetryRequestand sent a secondClientHello0x03010x030315 03 03 00 02 02 46, and loggedwolfSSL_connect error -326, record layer version error0x03020x030315 03 03 00 02 02 46, and loggedwolfSSL_connect error -326, record layer version errorAcceptance note:
For the accepted cases, the harness intentionally stopped after observing the second
ClientHello, so the client later reported a socket shutdown rather than a completed handshake. That does not weaken the differential result: the acceptance criterion was whether the client advanced to the secondClientHello, and the rejection criterion was whether it immediately sent a fatal alert withVERSION_ERROR.Why this is decisive:
The handshake-layer
legacy_versionstayed correct at0x0303in every case. The session ID, offered cipher,supported_versions, and selected key-share group were also kept consistent. Only the outer unencrypted record header version changed, and that alone flipped the result from "continue handshake" to "fatal alert". That is direct runtime evidence that the implementation is still checking the unencrypted record-layer version instead of ignoring it.Inconsistency Reason
RFC 8446 requires the outer
TLSPlaintext.legacy_record_versionfield to be ignored on unencrypted TLS 1.3 records, while wolfSSL'sGetRecordHeader()still treats that field as a validity condition. Because a validHelloRetryRequestis rejected when only the outer record version changes to other TLS 1.x values, the implementation does not satisfy the standard's ignore rule.Decision Reason
This is a real issue. The standard text is explicit, the parser still returns
VERSION_ERRORfrom the record-header version check, and the live differential rerun shows that changing only the outer unencrypted record version is enough to make the TLS 1.3 client reject an otherwise validHelloRetryRequest.Fix Direction
The fix should make TLS 1.3 unencrypted record processing ignore
TLSPlaintext.legacy_record_versionon receipt and leave version validation to the handshake-layer fields that RFC 8446 still defines:ClientHello.legacy_version,ServerHello.legacy_version, andHelloRetryRequestfields as required by Sections 4.1.2 through 4.1.4;TLSPlaintext.legacy_record_versionis not one of the parser's currently preferred values;HelloRetryRequestis processed identically across alternate outer record-header versions.