Enhancement: Public extension manager for ECH#10568
Enhancement: Public extension manager for ECH#10568sebastian-carpenter wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR refactors TLS 1.3 ECH public-vs-private extension handling by introducing a dedicated “public extensions” list on the WOLFSSL_ECH object and swapping extensions by type, replacing the previous SNI-only in-place string swap approach. It also updates SNI/ECH parsing and handshake flow to install the correct extensions on accept/reject paths, and adds/updates tests to validate wire visibility of public vs private SNI.
Changes:
- Add
WOLFSSL_ECH::extensionsto hold “public” extensions and implement swapping/replacement helpers used during ClientHello size/write and accept/reject handling. - Rework SNI parsing and ECH handshake transitions to remove the old
sniState/privateNamemachinery and support public-name matching for outer ClientHello. - Update API tests to cover new behaviors (wire-SNI visibility, no-inner-SNI behavior, new bad config cases) and relocate long-SNI length validation coverage.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
wolfssl/internal.h |
Removes old SNI-state fields and adds WOLFSSL_ECH::extensions plus TLSX_EchReplaceExtensions() prototype. |
src/tls.c |
Implements public-extension swapping/replacement, updates SNI parsing, and updates ECH lifecycle management. |
src/tls13.c |
Adjusts ClientHello padding derivation and installs correct extension sets at ECH accept/reject points (incl. HRR). |
tests/api.c |
Adds/updates ECH/SNI tests (wire visibility, no-private-name behavior, new config cases) and relocates length checks. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
2e125d1 to
aa4831f
Compare
|
retest this please |
|
8c2fa10 to
61e1638
Compare
|
Jenkins retest this please. |
dgarske
left a comment
There was a problem hiding this comment.
Skoll Code Review
Scan type: reviewOverall recommendation: COMMENT
Findings: 4 total — 4 posted, 0 skipped
4 finding(s) posted as inline comments (see file-level comments below)
Posted findings
- [Medium] Multi-public-extension swap/reversal path is untested —
src/tls.c:16682-16734 - [Low] ECH padding length only considers ssl-extensions, not ctx-level inner SNI —
src/tls13.c:4844-4855 - [Info] Pointer declaration style inconsistent with repo convention —
src/tls.c:2378 - [Info] TLSX_EchShouldHideInner can be a single boolean expression —
src/tls.c:16665-16671
Review generated by Skoll
4eef82d to
b4058e9
Compare
dgarske
left a comment
There was a problem hiding this comment.
Skoll Code Review
Scan type: reviewOverall recommendation: COMMENT
Findings: 2 total — 2 posted, 0 skipped
2 finding(s) posted as inline comments (see file-level comments below)
Posted findings
- [Low] TLSX_FreeAll echList tracks only a single ECH extension per list —
src/tls.c:15190-15216 - [Low] BAD_STATE_E on restore-swap leaves ssl-extensions inconsistent without repair —
src/tls.c:16895-16901, 17111-17117
Review generated by Skoll
b4058e9 to
a965758
Compare
dgarske
left a comment
There was a problem hiding this comment.
Skoll Code Review
Scan type: reviewOverall recommendation: REQUEST_CHANGES
Findings: 6 total — 6 posted, 0 skipped
5 finding(s) posted as inline comments (see file-level comments below)
Posted findings
- [High] Servers with WOLFSSL_SNI_ABORT_ON_ABSENCE now abort every ECH handshake at the outer ClientHello, even when a valid inner SNI is present —
src/tls.c:2520-2557 - [Medium] test_wolfSSL_Tls13_ECH_no_private_name ABORT_ON_ABSENCE case encodes the outer-parse abort; missing test for ABORT_ON_ABSENCE with inner SNI present —
tests/api.c:14625-14639 - [Low] BAD_STATE_E restore-failure paths log with WOLFSSL_MSG only, no WOLFSSL_ERROR_VERBOSE —
src/tls.c:16900-16907, src/tls.c:17117-17124 - [Low] Inline reimplementation of TLSX_SetResponse in TLSX_SNI_Parse —
src/tls.c:2550-2557 - [Low] EchCheckAcceptance calls TLSX_EchReplaceExtensions even when EchCalcAcceptance failed —
src/tls13.c:5310-5314 - [Info] PUB_NAME changed to example.com for all interop scenarios, not just the new reject tests —
.github/scripts/openssl-ech.sh:97-99
Review generated by Skoll
| if (!matched) | ||
| matched = cacheOnly; | ||
|
|
||
| if (matched || |
There was a problem hiding this comment.
🔴 [High] Servers with WOLFSSL_SNI_ABORT_ON_ABSENCE now abort every ECH handshake at the outer ClientHello, even when a valid…
The PR redirects a publicName-matched outer SNI into ech->extensions (writeList = &ech->extensions when workingConfig != NULL), and TLSX_SNI_SetStatus(*writeList, ...) records WOLFSSL_SNI_REAL_MATCH only on that private copy. The configured SNI node in ssl->extensions/ctx->extensions is left at WOLFSSL_SNI_NO_MATCH. TLSX_SNI_VerifyParse (called unconditionally at the end of every TLSX_Parse, src/tls.c:18685) only consults ssl->extensions/ctx->extensions, so when the server has WOLFSSL_SNI_ABORT_ON_ABSENCE set it returns SNI_ABSENT_ERROR while parsing the OUTER ClientHello -- before the inner hello (which may carry a perfectly valid private SNI) is ever processed. In the old code the publicName match was written into ssl->extensions via TLSX_UseSNI(&ssl->extensions, ...) with REAL_MATCH, so the outer VerifyParse passed and the inner SNI was then evaluated. EMPIRICALLY VERIFIED on this branch (--enable-ech build): adding wolfSSL_UseSNI(c_ssl, WOLFSSL_SNI_HOST_NAME, echCbTestPrivateName, ...) to the ABORT_ON_ABSENCE case of test_wolfSSL_Tls13_ECH_no_private_name still fails the handshake with error = -402, No Server Name Indication extension Error, i.e. the abort fires on CH1's outer parse regardless of the inner SNI. This affects both the accept path (server can never accept ECH when ABORT_ON_ABSENCE is set) and the reject path (server aborts instead of continuing the outer handshake with retry configs).
Fix: Make the outer-CH ABORT_ON_ABSENCE check aware of the public-SNI match now stored in ech->extensions (or defer SNI_VERIFY_PARSE enforcement to the inner CH when ECH decryption succeeded, or mirror the match status onto the ssl-level SNI for the outer pass). Add a regression test: server with ABORT_ON_ABSENCE + ECH, client with private inner SNI -> handshake must succeed with ECH ACCEPTED.
There was a problem hiding this comment.
Addressed. Moved the block that replaces the ech extensions to happen before the SNI_VERIFY.
Added WOLFSSL_SNI_ABORT_ON_ABSENCE to the wire_sni test which is expected to pass, thus testing the desired accept case. Correct connections should no longer fail.
|
|
||
| ExpectIntEQ(test_ssl_memio_setup(&test_ctx), TEST_SUCCESS); | ||
|
|
||
| /* SNI is permissive by default, force a failure when SNI is absent */ |
There was a problem hiding this comment.
🟠 [Medium] test_wolfSSL_Tls13_ECH_no_private_name ABORT_ON_ABSENCE case encodes the outer-parse abort; missing test for…
The comment says 'SNI is permissive by default, force a failure when SNI is absent', implying the failure is caused by the missing inner SNI. In reality the SNI_ABSENT_ERROR triggers during the OUTER ClientHello parse (the inner hello is never consulted), so the same failure occurs even when an inner SNI is present and valid (verified empirically). The test as written passes for the wrong reason and locks in the regressed behavior described in the BLOCK finding. There is no test covering the combination ABORT_ON_ABSENCE + inner SNI present, which is exactly the case that regressed.
Fix: After fixing the BLOCK finding, add the ABORT_ON_ABSENCE + inner-SNI-present scenario asserting handshake success and ECH ACCEPTED on both sides, and fix the misleading comment on the absent-SNI case.
There was a problem hiding this comment.
added sni option to wire_sni test.
| TLSX_SNI_SetStatus(ssl->extensions, type, (byte)matchStat); | ||
| TLSX_SNI_SetStatus(*writeList, type, (byte)matchStat); | ||
|
|
||
| if (!cacheOnly) { |
There was a problem hiding this comment.
🔵 [Low] Inline reimplementation of TLSX_SetResponse in TLSX_SNI_Parse
The code comments 'This is just a reimplementation of TLSX_SetResponse to accept a TLSX* instead' and then open-codes the find-and-set. A small list-based helper (e.g. TLSX_SetResponseInList(TLSX* list, TLSX_Type type)) that TLSX_SetResponse also calls would remove the duplication and keep response-flag semantics in one place for future public-extension types (the PR's stated follow-up direction).
Fix: Extract a shared helper; not blocking.
There was a problem hiding this comment.
wrote the given helper function, removed inline code and replaced.
| ssl->hsHashes = tmpHashes; | ||
| } | ||
|
|
||
| /* Skip only when the HRR signals ECH acceptance |
There was a problem hiding this comment.
🔵 [Low] EchCheckAcceptance calls TLSX_EchReplaceExtensions even when EchCalcAcceptance failed
The new replace call at the end of EchCheckAcceptance runs even when EchCalcAcceptance returned an error (the if (ret == 0) block above was skipped, echAccepted may be stale). It is harmless because the handshake aborts on the non-zero return, but swapping the public extensions into ssl->extensions on a hard-error path is unnecessary state mutation and makes the function's failure behavior harder to reason about.
Fix: Gate the replace on ret == 0 for clarity; behavior is unchanged either way since the connection aborts on error.
There was a problem hiding this comment.
added the ret == 0 guard
| TMP_LOG="$WORKSPACE/tmp_file.log" | ||
| PRIV_NAME="ech-private-name.com" | ||
| PUB_NAME="ech-public-name.com" | ||
| PUB_NAME="example.com" |
There was a problem hiding this comment.
⚪ [Info] PUB_NAME changed to example.com for all interop scenarios, not just the new reject tests
PUB_NAME was changed from "ech-public-name.com" to "example.com" so it matches the public name embedded in the hard-coded REJECT_ECH_CONFIG. This silently changes the public name used by every pre-existing interop scenario (suite tests, HRR, PQC), slightly reducing name diversity in the non-reject coverage. If intentional, a brief comment tying PUB_NAME to REJECT_ECH_CONFIG would help; otherwise the reject path could use its own name variable. The set -e at the top of the script does make the chained grep -q assertions effective, so the new reject verification logic is sound.
Fix: Add a comment documenting the coupling between PUB_NAME and REJECT_ECH_CONFIG, or scope the example.com name to the reject paths.
There was a problem hiding this comment.
documented. Also explained why PRIV_NAME is what it is
dgarske
left a comment
There was a problem hiding this comment.
See these skoll review items -> #10568 (review)
- *_wire_sni test is now more efficient - openssl-ech workflow now does interop with ECH rejection extra improvements: - tested TLSX_EchSwapExtensions - added ctx level SNI to padding calculation - Improvement of SNI handling for ECH
a965758 to
ee10978
Compare
Description
Reworks ECH public-vs-private extension handling. The old path swapped a single SNI string in place on the live extension list - this was fragile and SNI-only. This PR generalizes it to a public-extension list on the ECH struct that's swapped by extension type, so the outer ClientHello can carry arbitrary public extensions.
ZD#21931
Public extension manager
TLSX* extensionstoWOLFSSL_ECHholding the public extensions (e.g. the public-name SNI), populated inTLSX_ECH_UsefromechConfig->publicName.TLSX_EchChangeSNI/TLSX_EchRestoreSNIwith:TLSX_EchShouldHideInner- true when ECH is the outer type.TLSX_EchSwapExtensions- swaps matching extension types betweenssl->extensionsandech->extensions; unmatched public exts are prepended;popCountreverses leading nodes to preserve OuterExtensions ordering.TLSX_EchReplaceExtensions- accept: free the public list; reject: swap public exts intossl->extensions.TLSX_GetSizeWithEch/TLSX_WriteWithEchinstall the public exts before size/write and swap back after.ForceZerothe hpke; freeech->extensionsinTLSX_ECH_Free.SNI changes
EchStateSNIenum and thesniState/privateNamefields, plus the sniState transitions inDoTls13HandShakeMsgType.TLSX_SNI_Parse: drop the sniState-based private-name save/restore. On outer-CH parse, match against anyechConfig->publicNameand write a matched public SNI toech->extensions, notssl->extensions; simplify thematched/cacheOnlyand response-flag logic.TLSX_EchChangeSNI.SendTls13ClientHello: read padding length viaTLSX_SNI_GetRequestinstead ofech->privateName.TLSX_EchReplaceExtensionsat the accept/reject points (EchCheckAcceptance,DoTls13ServerHelloreject fallback,DoTls13ClientHello). This installs the correct SNI for use later in the handshake.Padding
Testing
test_wolfSSL_Tls13_ECH_wire_sni: drive the handshake manually and assert the public name is present and the private name absent in raw CH1/CH2 bytes, across accept/reject.test_wolfSSL_Tls13_ECH_no_private_name: no-inner-SNI now succeeds against a permissive server (ACCEPTED); rejected only withABORT_ON_ABSENCE.b64MandatoryFirstconfig case;test_ech_server_sni_callbackrejects a wrong public name; add a double-public-SNI scenario tobad_configs_ex.test_wolfSSL_Tls13_ECH_long_SNI(its overflow target - the deleted swap helpers - no longer exist) and relocate the over-long-SNIBAD_LENGTH_Echeck intotest_wolfSSL_UseSNI_params.TLSX_EchSwapExtensionsfunction:test_TLSX_EchSwapExtensions. This emulates the swap between ssl->extensions and ech->extensions with various configs: 'match', 'no match', 'match and non-match', 'match and no-match + out of order'. This required qualifying the function withWOLFSSL_TEST_VIS.Added testing from #10542:
Follow-ups
Checklist