Skip to content

Send server ChangeCipherSpec when client offers a session id - #11306

Open
Frauschi wants to merge 1 commit into
wolfSSL:masterfrom
Frauschi:erlang-interop
Open

Send server ChangeCipherSpec when client offers a session id#11306
Frauschi wants to merge 1 commit into
wolfSSL:masterfrom
Frauschi:erlang-interop

Conversation

@Frauschi

Copy link
Copy Markdown
Contributor

Description

RFC 8446 Appendix D.4 says a TLS 1.3 server answers a ClientHello carrying a non-empty legacy_session_id with a ChangeCipherSpec. Both server send paths sat behind WOLFSSL_TLS13_MIDDLEBOX_COMPAT, which only --enable-tls13-middlebox-compat and --enable-jni define and neither enables by default, so a default-built server never sent one. Peers that require it, such as Erlang's ssl, abort with an unexpected_message alert.

Only wolfSSL-as-server was affected: the wolfSSL client sends an empty session id by default, so no peer is ever obliged to answer back. Reported as an SLH-DSA interop bug, but nothing in this path touches the certificate or sigalg and the regression test reproduces it with the stock test certs.

Changes

Both server sends now compile unconditionally, driven by the runtime tls13MiddleBoxCompat flag the server already clears for an empty session id, and are factored into one static SendTls13ServerChangeCipher().

  • sentChangeCipher is compiled in for every TLS 1.3 build. SendChangeCipher() is now compiled for a TLS 1.3 server (or a middlebox-compat client), where before it went missing from a TLS 1.3-only build such as examples/configs/user_settings_tls13.h.
  • Under TLS 1.3 the record no longer moves serverState in the OPENSSL_EXTRA block. That ran before any send could fail, leaving the server not waiting for the second ClientHello.
  • A HelloRetryRequest is flushed as it is built, so the record behind it needs its own flush; without one a server grouping its messages sent a bare HelloRetryRequest.
  • wolfSSL_clear() resets the flag, or a reused server answers only its first peer.
  • The client sites take the same ordering, and the one answering a HelloRetryRequest keeps buildingMsg set when the send stops short; without that a refused write stranded the client handshake.

DTLS stays excluded, QUIC clears the flag per RFC 9001 Section 8.4, and with ECH the outer ClientHello decides since the inner hello is rebuilt carrying its session id. The configure and CMake option text now says client-side, since only that half stays behind the macro.

Behavioral change

A TLS 1.3 server now sends a ChangeCipherSpec it did not before, whenever the client offered a non-empty legacy_session_id; an empty one still gets none. One API difference: wolfSSL_get_state() no longer passes through WOLFSSL_SS_SERVER_CHANGECIPHERSPEC or WOLFSSL_SS_CLIENT_CHANGECIPHERSPEC on TLS 1.3, though the info callback still fires. What a client puts on the wire is unchanged. Written up in ChangeLog.md.

Testing

Eight tests in tests/api/test_tls13.c, seven of them ungated since default-build behaviour is the point: a non-empty session id gets exactly one record, an empty one gets none (#9156), a cleared server still answers its second peer, the HelloRetryRequest flight carries the record grouped and ungrouped while the flight after it carries none, and a refused-then-resumed write leaves exactly one on the wire on the straight-through, retry and client paths.

Each was verified to fail without its fix: reverting the source fails the two server tests, dropping the grouped-messages flush fails the HelloRetryRequest test, marking sentChangeCipher after a successful send fails the server retry test, and reverting the client HelloRetryRequest site fails the client test with the handshake never completing.

Clean in --enable-all, a default build, --enable-jni, --enable-tls13-middlebox-compat --enable-opensslextra --enable-hrrcookie, --enable-ech --enable-quic --enable-hrrcookie, a client-only TLS 1.3 build, user_settings_tls13.h, and CMake. make check passes on --enable-all.

Notes for reviewers

Two pre-existing things this surfaces but does not fix:

  • ECH: ssl->session->sessionIDSz is reassigned on every ClientHello parse including the inner one, so with ECH accepted and a non-empty outer session id the ServerHello echoes an empty legacy_session_id_echo alongside the record. Predates this change, a conformant client rejects on that mismatch alone, and it is unreachable wolfSSL-to-wolfSSL.
  • Sniffer: sniffer.c's change_cipher_spec case sets handShakeDone with no TLS 1.3 exclusion, but the branch above it sets serverCipherOn = 0 when srvKs.key_len == 0, so the recovery path it looks like it would trip never fires. Checked against a real capture carrying both a server and a client ChangeCipherSpec.

@Frauschi Frauschi self-assigned this Aug 28, 2026
@wolfSSL-Bot

Copy link
Copy Markdown

Can one of the admins verify this patch?

@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown

MemBrowse Memory Report

gcc-arm-cortex-m4-dtls13

  • FLASH: .text +512 B (+0.3%, 186,748 B / 1,048,576 B, total: 18% used)

gcc-arm-cortex-m4-openssl-compat

  • FLASH: .text +192 B (+0.0%, 780,540 B / 1,048,576 B, total: 74% used)

gcc-arm-cortex-m4-pq

  • FLASH: .text +384 B (+0.1%, 301,404 B / 1,048,576 B, total: 29% used)

gcc-arm-cortex-m4-rsa-only

  • FLASH: .text +192 B (+0.1%, 331,632 B / 1,048,576 B, total: 32% used)

gcc-arm-cortex-m4-tls13

  • FLASH: .text +384 B (+0.2%, 242,017 B / 262,144 B, total: 92% used)

gcc-arm-cortex-m7-pq

  • FLASH: .text +320 B (+0.1%, 302,300 B / 1,048,576 B, total: 29% used)

gcc-arm-cortex-m7-tls13

  • FLASH: .text +384 B (+0.2%, 242,081 B / 262,144 B, total: 92% used)

linuxkm-standard

@wolfSSL-Fenrir-bot wolfSSL-Fenrir-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fenrir Automated Review — PR #11306

Scan targets checked: wolfcrypt-rs-bugs, wolfssl-bugs, wolfssl-src

Fenrir result: Approved ✅

No new issues found in the changed files.

Advisory only — this automated result does not count as a GitHub approval.

@Frauschi

Frauschi commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Jenkins retest this please.

@Frauschi Frauschi assigned wolfSSL-Bot and unassigned Frauschi Aug 28, 2026
@philljj
philljj self-requested a review August 28, 2026 15:24

@philljj philljj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skoll had a testing coverage nit (see below), otherwise ran clean.

Would like @julek-wolfssl to review this because he also just worked on middlebox-compat.

MEDIUM-1: Exercise the changed early-data CCS retry path

  • File: src/tls13.c:15778-15784
  • Function: wolfSSL_connect_TLSv13

Description: The PR changes the early-data path to guard and set sentChangeCipher before calling SendChangeCipher(), but the new client retry test covers only ordinary and HelloRetryRequest handshakes. It never enables early data or enters this branch. Moreover, the existing early-data WANT_WRITE callback in tests/api/test_tls13.c deliberately lets a standalone CCS write pass, so the default non-grouped configuration cannot detect a duplicate or stranded CCS here.

Code:

if (!ssl->options.dtls && !ssl->options.sentChangeCipher
        && ssl->options.tls13MiddleBoxCompat) {
    ssl->options.sentChangeCipher = 1;
    if ((ssl->error = SendChangeCipher(ssl)) != 0) {
        return WOLFSSL_FATAL_ERROR;
    }
}

Recommendation: Cover this third modified client send site with the same retry assertions already applied to the normal and HelloRetryRequest paths.

@philljj
philljj requested a review from julek-wolfssl August 28, 2026 15:48
@philljj philljj assigned Frauschi and unassigned wolfSSL-Bot Aug 28, 2026
RFC 8446 Appendix D.4 describes a TLS 1.3 server answering a ClientHello
that carries a non-empty legacy_session_id with a ChangeCipherSpec. Both
server send paths sat behind WOLFSSL_TLS13_MIDDLEBOX_COMPAT, which only
--enable-tls13-middlebox-compat and --enable-jni define and which
neither enables by default, so a default-built server never sent one.
Peers
that require the record, such as Erlang's ssl, abort the handshake with
an unexpected_message alert.

Compile the two server accept-path sends unconditionally and drive them
from the runtime tls13MiddleBoxCompat flag, which the server already
clears when the client's session id is empty. This needs the
sentChangeCipher option bit compiled in for every TLS 1.3 build, and
SendChangeCipher itself, whose definition was guarded on TLS 1.2 or old
TLS or middlebox compat and so went missing from a TLS 1.3-only build
such as examples/configs/user_settings_tls13.h. That definition is now
compiled for a TLS 1.3 server, or a TLS 1.3 client in a middlebox compat
build, so a client-only build that can never call it does not carry it.

DTLS stays excluded by the existing guard. QUIC clears the flag on its
own rather than trusting the peer to honour the empty session id RFC
9001 Section 8.4 calls for, since a ChangeCipherSpec record has no way
to travel a QUIC CRYPTO stream. With ECH the outer ClientHello decides
on its own, since TLSX_ECH_Parse rebuilds the inner hello carrying the
outer session id, so no carve-out is needed for the inner re-parse.
wolfSSL_clear()
resets the flag and sentChangeCipher, or a reused server object would
answer only its first peer.

Under TLS 1.3 the record is a dummy and must not move handshake state,
so the OPENSSL_EXTRA block no longer assigns serverState for it. It ran
before any send could fail, so a retryable send left serverState reading
as ChangeCipherSpec complete and the server stopped waiting for the
second ClientHello. The info callback still fires on both sides, and the
client's clientState is untouched, so nothing an application can observe
changes. Both server sends go through one static helper,
SendTls13ServerChangeCipher, so the guard, the mark-before-send ordering
and the flush are stated once, with the flush an argument because only
the HelloRetryRequest needs it. The mark matters on the
HelloRetryRequest path, where a resumed accept comes back through the
prologue at
TLS13_ACCEPT_FIRST_REPLY_DONE and falls into TLS13_SERVER_HELLO_SENT,
and that flag is all that stops a second record going out.

SendTls13ServerHello flushes a HelloRetryRequest as soon as it is
built, so the ChangeCipherSpec queued behind it needs a flush of its
own. Without one, a server grouping its messages, which every
OPENSSL_COMPATIBLE_DEFAULTS build does, leaves the record in the output
buffer and the peer sees a bare HelloRetryRequest.

The three client send sites in wolfSSL_connect_TLSv13 take the same
ordering. The one answering a HelloRetryRequest also keeps buildingMsg
set when the send stops short, since the second ClientHello still has to
follow and the connect state machine must stay on that case while the
record drains; without it a refused write stranded the handshake.

What a client puts on the wire is unchanged. Without
WOLFSSL_TLS13_MIDDLEBOX_COMPAT it still sends an empty session id and no
ChangeCipherSpec of its own.

The test helper counts ChangeCipherSpec records rather than answering
whether there is one, so that a second record going out reads
differently from the one record that is owed.
test_tls13_middlebox_compat_hrr_ccs covers the HelloRetryRequest path
with messages grouped and ungrouped, and checks the flight answering the
second ClientHello carries no further record.
test_tls13_middlebox_compat_server_ccs_retry refuses the write carrying
the record and resumes the handshake, so a send that stops part way
still leaves exactly one on the wire, and
test_tls13_middlebox_compat_hrr_ccs_retry does the same on the retry
path, where the flight answering the second ClientHello must carry
none. test_tls13_middlebox_compat_client_ccs_retry refuses the client's
own record on both of its handshake flights, and
test_tls13_middlebox_compat_client_ccs_retry_early_data on the third
client send site, the early data one, where the record goes out ahead of
the 0-RTT records rather than as part of a handshake flight and a
resumed wolfSSL_write_early_data() would otherwise repeat it.

Those client-side tests need WOLFSSL_TLS13_MIDDLEBOX_COMPAT, which no
os-check-linux entry defined: only --enable-tls13-middlebox-compat and
--enable-jni set it, and configure.ac turns it back off when ECH is on,
as it is under --enable-all. The all-tls13-middlebox-compat entry is
--enable-all with ECH off and the option on, so they run somewhere.

Also correct the macro's doc comment, which claimed the feature defaults
to on, reword the --enable-tls13-middlebox-compat help text and the
matching CMake option description, which described the whole feature
rather than the client half they now control,
and drop a duplicated dtls test in the same condition.
@Frauschi

Copy link
Copy Markdown
Contributor Author

@philljj fixed your finding.

@Frauschi
Frauschi requested a review from philljj August 28, 2026 19:14
@Frauschi Frauschi assigned wolfSSL-Bot and unassigned Frauschi Aug 28, 2026
@Frauschi

Copy link
Copy Markdown
Contributor Author

Jenkins retest this please

@philljj

philljj commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Might be a real unit test failure:

FAILURES:
   1686: test_tls13_early_data_0rtt_replay
 End API Tests
 Failed/Skipped/Passed/All: 1/396/1942/2339
FAIL scripts/unit.test (exit status: 1)

@Frauschi

Copy link
Copy Markdown
Contributor Author

I cannot reproduce it. Jenkins retest this please.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants