Resume the TLS 1.3 certificate chain send after a WANT_WRITE

SendTls13Certificate keeps its chain walk cursor in the function locals len,
idx, offset and p, but the only state that survives the WANT_WRITE return of a
non-blocking send is ssl->fragOffset, and that is consulted for the leaf
certificate alone. A send that blocked part way through the chain therefore
re-primed the walk on the next call and copied the chain from its first byte
again. The byte count still matched the announced payload size, so the message
stayed well formed on the wire while the tail of the chain was replaced by a
repeat of its head, and the peer rejected it with BUFFER_ERROR.

Rebuild the cursor from ssl->fragOffset when a resume lands inside the chain.
NextCert reads each entry's three byte length prefix and skips it, so passing
over the entries already sent costs one hop per certificate and only happens on
a resume. Guard the extension index bump the same way the send loop does, so
builds without certificate status request keep the leaf extension size.

Track the size of the chain entry being written in its own variable rather than
folding the extension size into len once the entry completes. The send loop
detected completion with offset == len + OPAQUE16_LEN and kept that check
honest by adding extSz[extIdx] - OPAQUE16_LEN to len at the end of an entry, so
until then len held the raw certificate length and the check read as complete
whenever a fragment boundary landed exactly OPAQUE16_LEN bytes into a real
extension. The walk then jumped to the next certificate in the middle of the
current one. entrySz records len + extSz[extIdx] when the entry is picked up,
len keeps the raw certificate length AddCertExt expects, and both the resume
and the ordinary multi fragment path test the same condition.

The stapled chain in scripts/ocsp-stapling_tls13multi.test reproduces the entry
size case with the server records held to 1482 bytes: the boundary falls two
bytes into an 1837 byte OCSP extension and the handshake fails, while 1480,
1481, 1483 and 1484 all pass.

Reaching this needs a certificate message larger than one record, which is why
it stayed dormant with classic certificates. Add SLH-DSA scenarios with
simulated WANT_WRITE, for server and for mutual authentication, to
tests/test-tls13-slhdsa-entity-128s.conf.

The same resume path mishandles the stapled OCSP responses. WriteCSRToBuffer
fills extSz[] only for the entries whose buffer it allocates, so on a resumed
call every entry that still held a buffer, the one being written and all that
follow it, kept the OPAQUE16_LEN default of an empty extension. The message
length, the entry sizes and the extension bytes written for those entries were
all derived from that default. Recover the size from the extension length
already written into the buffer instead.

SetupOcspResp appends a fresh request per certificate on every call, so a
message that resumed often enough exhausted the extension array and the
handshake ended with MAX_CERT_EXTENSIONS_ERR. Look the responses up once, when
the message starts, and reuse them for the rest of it.

A resumed call also reallocates the extension buffers of the entries it has
already sent, and the walk passes over those entries without writing them
again, so free them there. Free the array in wolfSSL_ResourceFree as well:
nothing released it when a connection ended part way through a Certificate
message, which leaked one OCSP response per unsent entry.

Test case 8 of scripts/ocsp-stapling_tls13multi.test covers all three. A
maximum fragment length of 512 bytes splits the stapled message over about
twenty records and the server blocks on every one of them; without these fixes
the handshake fails with MAX_CERT_EXTENSIONS_ERR.
This commit is contained in:
Tobias Frauenschläger
2026-08-07 08:14:25 +02:00
parent e260a8e103
commit 16cc1f28bb
4 changed files with 179 additions and 33 deletions
+33
View File
@@ -60,6 +60,18 @@ else
dtls13=yes
fi
# The fragmented-send case needs a client that can request a small maximum
# fragment length and a server that can simulate WANT_WRITE. Both options are
# silently ignored otherwise, which would turn the case into a no-op that still
# reports PASSED.
if ! ./examples/client/client -? 2>&1 | grep -q 'Maximum Fragment Length'; then
fragmented_send=no
elif ./examples/server/server -6 -? 2>&1 | grep -q 'Ignoring -6'; then
fragmented_send=no
else
fragmented_send=yes
fi
if [[ ("$tls13" == "no") && ("$dtls13" == "no") ]]; then
echo 'skipping ocsp-stapling_tls13multi.test because TLS1.3 is not available.' 1>&2
exit 77
@@ -492,6 +504,27 @@ if [ "$tls13" == "yes" ]; then
exit 1
fi
printf '%s\n\n' "Test successfully REVOKED!"
printf '%s\n\n' "------------- TEST CASE 8 FRAGMENTED SEND --------------------"
if [ "$fragmented_send" == "no" ]; then
printf '%s\n\n' "Test SKIPPED: needs HAVE_MAX_FRAGMENT and async I/O."
else
# A small maximum fragment length (-F 1) splits the stapled Certificate
# message over many records, and the server (-6) blocks on every one of
# them, so the send resumes from a WANT_WRITE inside the chain.
remove_single_rF "$ready_file5"
./examples/server/server -c certs/ocsp/server3-cert.pem \
-k certs/ocsp/server3-key.pem -R "$ready_file5" \
-p "$port5" -v 4 -6 &
server_pid5=$!
wait_for_readyFile "$ready_file5" "$server_pid5" "$port5"
./examples/client/client -C -A certs/ocsp/root-ca-cert.pem -W 1 -v 4 \
-F 1 -p "$port5"
RESULT=$?
[ "$RESULT" -ne 0 ] && printf '\n\n%s\n' "Client connection 8 failed" \
&& exit 1
printf '%s\n\n' "Test PASSED!"
fi
fi
if [ "$dtls13" == "yes" ]; then
+11
View File
@@ -9738,6 +9738,17 @@ void wolfSSL_ResourceFree(WOLFSSL* ssl)
XFREE(ssl->buffers.tls13CookieSecret.buffer, ssl->heap,
DYNAMIC_TYPE_COOKIE_PWD);
#endif
#if !defined(NO_CERTS) && defined(WOLFSSL_TLS13) && \
defined(HAVE_CERTIFICATE_STATUS_REQUEST) && !defined(NO_WOLFSSL_SERVER)
{
/* Release the certificate status extensions of a Certificate message
* that was never sent in full. */
int extIdx;
for (extIdx = 0; extIdx < MAX_CERT_EXTENSIONS; extIdx++)
FreeDer(&ssl->buffers.certExts[extIdx]);
}
#endif
#ifdef WOLFSSL_TLS13_STREAM_CERT_VERIFY
/* Release any in-progress streamed CertificateVerify body (e.g. a
* connection torn down mid-send). */
+96 -33
View File
@@ -9664,33 +9664,46 @@ static int WriteCSRToBuffer(WOLFSSL* ssl, DerBuffer** certExts,
for (extIdx = 0; extIdx < (word16)(extSz_num); extIdx++) {
tmpSz = TLSX_CSR_GetSize_ex(csr, 0, (int)extIdx);
if (tmpSz > (OPAQUE8_LEN + OPAQUE24_LEN) &&
certExts[extIdx] == NULL) {
/* csr extension is not zero */
if (tmpSz > WOLFSSL_MAX_16BIT)
return BUFFER_E;
extSz[extIdx] = (word16)tmpSz;
if (ssl->fragOffset != 0 && certExts[extIdx] != NULL) {
/* A fragmented send is being resumed and this buffer was
* written by the earlier call. extSz starts over on every
* call, so recover this entry's size from the length written
* into the buffer. */
ato16(certExts[extIdx]->buffer, &extSz[extIdx]);
extSz[extIdx] += OPAQUE16_LEN;
}
else {
/* Not a resume, so anything still allocated here is left over
* from a completed message and must not be reused. */
FreeDer(&certExts[extIdx]);
ret = AllocDer(&certExts[extIdx], extSz[extIdx] + ex_offset,
CERT_TYPE, ssl->heap);
if (ret < 0)
return ret;
der = certExts[extIdx];
if (tmpSz > (OPAQUE8_LEN + OPAQUE24_LEN)) {
/* csr extension is not zero */
if (tmpSz > WOLFSSL_MAX_16BIT)
return BUFFER_E;
extSz[extIdx] = (word16)tmpSz;
/* write extension type */
c16toa(ext->type, der->buffer
+ OPAQUE16_LEN);
/* writes extension data length. */
c16toa(extSz[extIdx], der->buffer
+ HELLO_EXT_TYPE_SZ + OPAQUE16_LEN);
/* write extension data */
extSz[extIdx] = (word16)TLSX_CSR_Write_ex(csr,
der->buffer + ex_offset, 0, extIdx);
/* add extension offset */
extSz[extIdx] += (word16)ex_offset;
/* extension length */
c16toa(extSz[extIdx] - OPAQUE16_LEN,
der->buffer);
ret = AllocDer(&certExts[extIdx], extSz[extIdx] + ex_offset,
CERT_TYPE, ssl->heap);
if (ret < 0)
return ret;
der = certExts[extIdx];
/* write extension type */
c16toa(ext->type, der->buffer
+ OPAQUE16_LEN);
/* writes extension data length. */
c16toa(extSz[extIdx], der->buffer
+ HELLO_EXT_TYPE_SZ + OPAQUE16_LEN);
/* write extension data */
extSz[extIdx] = (word16)TLSX_CSR_Write_ex(csr,
der->buffer + ex_offset, 0, extIdx);
/* add extension offset */
extSz[extIdx] += (word16)ex_offset;
/* extension length */
c16toa(extSz[extIdx] - OPAQUE16_LEN,
der->buffer);
}
}
totalSz += extSz[extIdx];
}
@@ -9877,7 +9890,8 @@ static int SendTls13Certificate(WOLFSSL* ssl)
word32 totalextSz = 0;
word32 len = 0;
word32 idx = 0;
word32 offset = OPAQUE16_LEN;
word32 offset = 0;
word32 entrySz = 0;
byte* p = NULL;
byte certReqCtxLen = 0;
sword32 length;
@@ -9957,9 +9971,14 @@ static int SendTls13Certificate(WOLFSSL* ssl)
&& ssl->options.handShakeDone)
#endif
) {
ret = SetupOcspResp(ssl);
if (ret != 0)
return ret;
/* Build the responses once. A resumed send reuses them: looking
* them up again appends another set of requests to the extension
* until it overflows with MAX_CERT_EXTENSIONS_ERR. */
if (ssl->fragOffset == 0) {
ret = SetupOcspResp(ssl);
if (ret != 0)
return ret;
}
if ((1 + ssl->buffers.certChainCnt) > MAX_CERT_EXTENSIONS)
ret = MAX_CERT_EXTENSIONS_ERR;
@@ -10007,6 +10026,50 @@ static int SendTls13Certificate(WOLFSSL* ssl)
extIdx = 0;
/* Only ssl->fragOffset survives a WANT_WRITE, so a resume inside the chain
* has to rebuild the walk cursor from it. */
if (certChainSz > 0 && ssl->fragOffset >= certSz + extSz[0]) {
word32 chainPos = ssl->fragOffset - (certSz + extSz[0]);
#if defined(HAVE_CERTIFICATE_STATUS_REQUEST) && !defined(NO_WOLFSSL_SERVER)
/* The leaf is behind us and its buffer was rebuilt above. */
FreeDer(&ssl->buffers.certExts[0]);
#endif
while (chainPos > 0) {
word32 prevIdx = idx;
len = NextCert(ssl->buffers.certChain->buffer,
ssl->buffers.certChain->length, &idx);
if (len == 0)
break;
#if defined(HAVE_CERTIFICATE_STATUS_REQUEST) && \
!defined(NO_WOLFSSL_SERVER)
if (extIdx + 1 < MAX_CERT_EXTENSIONS)
extIdx++;
#endif
entrySz = len + extSz[extIdx];
if (chainPos < entrySz) {
/* Resume part way through this entry. */
p = ssl->buffers.certChain->buffer + prevIdx;
offset = chainPos;
chainPos = 0;
}
else {
/* Entry already sent in full; stay primed for the next one. */
#if defined(HAVE_CERTIFICATE_STATUS_REQUEST) && \
!defined(NO_WOLFSSL_SERVER)
/* Its buffer was rebuilt above and nothing writes it again. */
FreeDer(&ssl->buffers.certExts[extIdx]);
#endif
chainPos -= entrySz;
offset = 0;
entrySz = 0;
}
}
}
while (length > 0 && ret == 0) {
byte* output = NULL;
word32 fragSz = 0;
@@ -10107,7 +10170,7 @@ static int SendTls13Certificate(WOLFSSL* ssl)
while (fragSz > 0) {
word32 l;
if (offset == len + OPAQUE16_LEN) {
if (offset == entrySz) {
/* Find next CA certificate to write out. */
offset = 0;
/* Point to the start of current cert in chain buffer. */
@@ -10121,6 +10184,8 @@ static int SendTls13Certificate(WOLFSSL* ssl)
if (extIdx + 1 < MAX_CERT_EXTENSIONS)
extIdx++;
#endif
/* Certificate and its extensions make up the entry. */
entrySz = len + extSz[extIdx];
}
/* Write out certificate and extension. */
l = AddCertExt(ssl, p, len, extSz[extIdx], offset, fragSz,
@@ -10133,10 +10198,8 @@ static int SendTls13Certificate(WOLFSSL* ssl)
if (extIdx != 0 && extIdx < MAX_CERT_EXTENSIONS &&
ssl->buffers.certExts[extIdx] != NULL &&
offset == len + extSz[extIdx]) {
offset == entrySz) {
FreeDer(&ssl->buffers.certExts[extIdx]);
/* for next chain cert */
len += extSz[extIdx] - OPAQUE16_LEN;
}
}
}
+39
View File
@@ -39,3 +39,42 @@
-k ./certs/slhdsa/client-slhdsa-shake-128s-priv.pem
-A ./certs/slhdsa/root-slhdsa-shake-128s.pem
-C
# Server auth with simulated WANT_WRITE (-6): leaf plus root push the
# Certificate message past one record, so the send resumes mid chain.
# server TLSv1.3 TLS13-AES128-GCM-SHA256
-v 4
-l TLS13-AES128-GCM-SHA256
-c ./certs/slhdsa/server-slhdsa-shake-128s.pem
-k ./certs/slhdsa/server-slhdsa-shake-128s-priv.pem
-d
-6
# client TLSv1.3 TLS13-AES128-GCM-SHA256
-v 4
-l TLS13-AES128-GCM-SHA256
-A ./certs/slhdsa/root-slhdsa-shake-128s.pem
-C
-6
# Mutual auth with simulated WANT_WRITE, so the client's own Certificate
# send resumes mid chain too.
# server TLSv1.3 TLS13-AES128-GCM-SHA256
-v 4
-l TLS13-AES128-GCM-SHA256
-c ./certs/slhdsa/server-slhdsa-shake-128s.pem
-k ./certs/slhdsa/server-slhdsa-shake-128s-priv.pem
-A ./certs/slhdsa/root-slhdsa-shake-128s.pem
-V
-6
# client TLSv1.3 TLS13-AES128-GCM-SHA256
-v 4
-l TLS13-AES128-GCM-SHA256
-c ./certs/slhdsa/client-slhdsa-shake-128s.pem
-k ./certs/slhdsa/client-slhdsa-shake-128s-priv.pem
-A ./certs/slhdsa/root-slhdsa-shake-128s.pem
-C
-6