Skip to content

Support post-quantum-only (ML-KEM + ML-DSA/SLH-DSA) TLS 1.3 builds - #11096

Open
Frauschi wants to merge 2 commits into
wolfSSL:masterfrom
Frauschi:pqc_only
Open

Support post-quantum-only (ML-KEM + ML-DSA/SLH-DSA) TLS 1.3 builds#11096
Frauschi wants to merge 2 commits into
wolfSSL:masterfrom
Frauschi:pqc_only

Conversation

@Frauschi

@Frauschi Frauschi commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Description

This PR adds support for a quantum-safe-only TLS 1.3 stack: ML-KEM key exchange with ML-DSA or SLH-DSA authentication, with RSA, ECC and DH all disabled. That is the CNSA 2.0 / EO 14412 posture, and until now such a configure line did not build: the "no cipher suites" #error fired before anything else, and several files failed -Werror once every classic algorithm was compiled out. This is based on initial patches published in our blog.

It also fixes a latent TLS 1.3 bug that only shows up once certificates get large enough to span multiple TLS records, which is exactly what post-quantum certificates do.

Library changes

  • Cipher suite gates (src/ssl.c, wolfssl/internal.h). Treat a usable standalone ML-KEM group as a valid key exchange, and Falcon/ML-DSA/SLH-DSA as valid authentication, so the two "no cipher suites" #errors no longer fire in a PQC-only build.
  • src/tls.c. Advertise the psk_dhe_ke exchange mode when a usable standalone ML-KEM group is available. Without it an ML-KEM-only build fell back to psk_ke, the resumption key schedules diverged, and the first AEAD record failed to authenticate (AES_GCM_AUTH_E), which broke session-ticket resumption.
  • src/internal.c. Extend the CopyDecodedToX509 pkCurveOID guard so it matches the struct member guards.
  • wolfcrypt/src/asn.c. Guard the RSA/ECC-only certificate signing helpers so certificate generation still compiles with RSA and ECC disabled, while keeping MakeSignatureCb available for the WOLFSSL_CERT_SIGN_CB (TPM/HSM) offload path.
  • src/tls13.c. Hoist the hashAlgo void cast in EncodeSigAlg out of the Ed25519 and Ed448 cases so a build with no classic signature algorithm still marks the parameter used.
  • src/internal.c, src/tls13.c, src/ssl_load.c. Mark ssl, sigOut, keyType and keySz used. Every reader of these sits inside a per-algorithm block, so a build with no signing algorithm at all left them unused and -Werror rejected the library.

Certificate chain send fix (src/tls13.c)

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.

The fix rebuilds 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. No new per-connection state is added, which keeps the embedded footprint unchanged.

This is not a regression from this branch. git log -S "offset = OPAQUE16_LEN" -- src/tls13.c dates the code to 2b1e9973e (2016). Reaching it needs a Certificate message larger than one record on a non-blocking socket, and classic chains stay under one record, which is why it lay dormant. CI never caught it either: the SLH-DSA test configurations carry no -6 cases, and every other SLH-DSA configuration keeps RSA and ECC on, so the -6 cases in tests/test-tls13.conf run with small RSA certificates. The new slhdsa-pqc-only configuration is the first to combine large certificates with a non-blocking send.

It is kept as a separate commit so it can be reviewed apart from the post-quantum feature work.

Example and test credentials (wolfssl/test.h, examples/, tests/)

Whether a build can authenticate with a post-quantum certificate is three separate questions: is the algorithm compiled in, are test credentials wired up for the enabled parameter set, and can this build sign. test.h now answers them with three tiers of macro rather than testing WOLFSSL_HAVE_MLDSA and WOLFSSL_HAVE_SLHDSA directly.

Macro Means
TEST_HAVE_MLDSA_CERT_FILES / TEST_HAVE_SLHDSA_CERT_FILES The credential paths name real files. ML-DSA needs an enabled parameter level. SLH-DSA needs a 128s parameter set, since both families share a 128s root and only those certificates are wired up, and PEM decoding, since the entity certificates ship as PEM only. Loading a certificate needs verification support alone, so test_tls13.c uses this tier.
TEST_HAVE_MLDSA_CERTS / TEST_HAVE_SLHDSA_CERTS The above, plus the ability to sign. A verify-only build derives WOLFSSL_MLDSA_NO_SIGN and drops private key support entirely, so it can neither load the key nor produce a CertificateVerify. The example credential ladders use this tier.
TEST_HAVE_PQC_CERT_AUTH Either of those, plus WOLFSSL_TLS13. Both algorithms are TLS 1.3 only, so folding the version into the macro keeps the version defaults and the PSK fallbacks in the client, server and echo examples from disagreeing. Falcon is deliberately absent, having no credentials in the ladders, so a Falcon-only build keeps the historic TLS 1.2 PSK behavior.

A build that fails a gate now falls back to PSK instead of naming credentials it cannot use. That removes two hard #errors that were reachable straight from configure: --enable-slhdsa with only non-128s parameter sets broke every build that includes test.h, even ones with RSA and ECC enabled that would never touch an SLH-DSA credential.

Two credential bugs fixed along the way:

  • test.h. Pair the DER ML-DSA certificate with mldsa<N>-key.der. The mldsa<N>_priv-only.der files are standalone PKCS#8 encoding vectors from an unrelated key pair, so a build without PEM decoding presented a certificate and signed with a key that did not match it, and the peer rejected the CertificateVerify.
  • examples/echoserver/echoserver.c. Load the SLH-DSA key with CERT_FILETYPE like every neighboring branch, rather than hardcoding WOLFSSL_FILETYPE_PEM against a certificate loaded in the build-dependent format.

tests/api.c skips test_wolfSSL_PKCS8 unless RSA, ECC or PEM decoding is available. With none of them every key-loading body compiles out, leaving the file locals unused and nothing for the test to do.

Testing

New CI entries in .github/configs/pq-all.json, one per signature algorithm:

--enable-mlkem --enable-mldsa  --enable-tls-mlkem-standalone \
  --enable-session-ticket --disable-rsa --disable-ecc --disable-dh
--enable-mlkem --enable-slhdsa --enable-tls-mlkem-standalone \
  --enable-session-ticket --disable-rsa --disable-ecc --disable-dh

Each exercises the build, a full TLS 1.3 handshake, and ML-KEM-only session-ticket resumption.

tests/test-tls13-slhdsa-entity-128s.conf gains two scenarios that drive the certificate chain send under simulated WANT_WRITE (-6), one server-auth and one mutual-auth, so the resume path is covered in both directions. Without the src/tls13.c hunk ./tests/unit.test exits 1 at the new case; with it, exit 0.

@Frauschi Frauschi self-assigned this Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

MemBrowse Memory Report

gcc-arm-cortex-m4-dtls13

  • FLASH: .text +128 B (+0.1%, 182,052 B / 1,048,576 B, total: 17% used)

gcc-arm-cortex-m4-openssl-compat

  • FLASH: .text +128 B (+0.0%, 773,164 B / 1,048,576 B, total: 74% used)

gcc-arm-cortex-m4-pq

  • FLASH: .text +128 B (+0.0%, 281,344 B / 1,048,576 B, total: 27% used)

gcc-arm-cortex-m4-rsa-only

  • FLASH: .text +128 B (+0.0%, 327,456 B / 1,048,576 B, total: 31% used)

gcc-arm-cortex-m4-tls13

  • FLASH: .text +128 B (+0.1%, 237,513 B / 262,144 B, total: 91% used)

gcc-arm-cortex-m7-pq

  • FLASH: .text +128 B (+0.0%, 281,920 B / 1,048,576 B, total: 27% used)

gcc-arm-cortex-m7-tls13

@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 #11096

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

Findings: 1
1 finding(s) posted as inline comments (see file-level comments below)

This review was generated automatically by Fenrir. Findings are non-blocking.

Comment thread src/tls13.c
Comment thread src/tls13.c

@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 #11096

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

Findings: 5
5 finding(s) posted as inline comments (see file-level comments below)

This review was generated automatically by Fenrir. Findings are non-blocking.

Comment thread src/tls13.c
Comment thread tests/api/test_tls13.c
Comment thread src/tls13.c
Comment thread src/tls13.c
Comment thread src/tls.c Outdated
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.
Allow a quantum-safe-only TLS 1.3 stack - ML-KEM key exchange with ML-DSA or
SLH-DSA authentication - with RSA, ECC and DH all disabled (the CNSA 2.0 /
EO 14412 posture).

- Treat a usable standalone ML-KEM group as a valid key exchange and
  Falcon/ML-DSA/SLH-DSA as valid authentication, so the "no cipher suites"
  #errors no longer fire (ssl.c, internal.h), and extend the CopyDecodedToX509
  pkCurveOID guard to match the struct member guards (internal.c).
- asn.c: guard the RSA/ECC-only certificate signing helpers so cert generation
  compiles with RSA and ECC disabled, while keeping MakeSignatureCb available
  for the WOLFSSL_CERT_SIGN_CB (TPM/HSM) offload path.
- tls.c: advertise the psk_dhe_ke exchange mode when a standalone ML-KEM group
  is usable on the client side, that is when the build has both key generation
  and decapsulation. Without it an ML-KEM-only build fell back to psk_ke, the
  resumption key schedules diverged, and the first AEAD record failed to
  authenticate (AES_GCM_AUTH_E), breaking session-ticket resumption.
- tls13.c: hoist the hashAlgo void cast in EncodeSigAlg out of the Ed25519 and
  Ed448 cases so a build with no classic signature algorithm still marks the
  parameter used.
- Examples and tests: add ML-DSA and SLH-DSA certificate selection (gated on
  NO_RSA so it only applies in true PQC-only builds) and default to TLS 1.3
  when no classic auth is built in and a post-quantum signature is available
  (test.h, client/server/echo*, test_tls13.c). SLH-DSA uses the SHAKE-128s leaf
  certificates and their shared 128s root.
- CI: add PQC-only entries to the pq-all.json config list (ML-DSA and SLH-DSA),
  each exercising the build, a full TLS 1.3 handshake, and ML-KEM-only
  session-ticket resumption.

Whether a build can authenticate with a post-quantum certificate is three
separate questions - is the algorithm compiled in, are test credentials wired
up for the enabled parameter set, and can this build sign - so test.h answers
them with three tiers of macro rather than testing WOLFSSL_HAVE_MLDSA and
WOLFSSL_HAVE_SLHDSA directly:

- TEST_HAVE_MLDSA_CERT_FILES / TEST_HAVE_SLHDSA_CERT_FILES: the credential
  paths name real files. ML-DSA needs an enabled parameter level; SLH-DSA needs
  a 128s parameter set, since both families share a 128s root and only those
  certificates are wired up, and PEM decoding, since the entity certificates
  ship as PEM only. Loading a certificate needs verification support alone, so
  test_tls13.c uses this tier - it only needs a certificate on the context.
- TEST_HAVE_MLDSA_CERTS / TEST_HAVE_SLHDSA_CERTS: the above, plus the ability
  to sign. A verify-only build derives WOLFSSL_MLDSA_NO_SIGN and drops private
  key support entirely, so it can neither load the key nor produce a
  CertificateVerify. The example credential ladders use this tier.
- TEST_HAVE_PQC_CERT_AUTH: either of those, plus WOLFSSL_TLS13. Both algorithms
  are TLS 1.3 only, so folding the version into the macro keeps the version
  defaults and the PSK fallbacks in the client, server and echo examples from
  disagreeing. Falcon is deliberately absent, having no credentials in the
  ladders, so a Falcon-only build keeps the historic TLS 1.2 PSK behavior.

A build that fails a gate now falls back to PSK instead of naming credentials
it cannot use, which removes two hard #errors that were reachable from
configure: --enable-slhdsa with only non-128s parameter sets broke every build
that includes test.h, even ones with RSA and ECC enabled that would never touch
an SLH-DSA credential.

Also in the credential material and the configurations this opens up:

- test.h: pair the DER ML-DSA certificate with mldsa<N>-key.der. The
  mldsa<N>_priv-only.der files are standalone PKCS#8 encoding vectors from an
  unrelated key pair, so a build without PEM decoding presented a certificate
  and signed with a key that did not match it, and the peer rejected the
  CertificateVerify.
- echoserver.c: load the SLH-DSA key with CERT_FILETYPE like every neighboring
  branch, rather than hardcoding WOLFSSL_FILETYPE_PEM against a certificate
  loaded in the build-dependent format.
- internal.c, tls13.c, ssl_load.c: mark ssl, sigOut, keyType and keySz used.
  Every reader of these sits in a per-algorithm block, so a build with no
  signing algorithm at all left them unused and -Werror rejected the library.
- tests/api.c: skip test_wolfSSL_PKCS8 unless RSA, ECC or PEM decoding is
  available. With none of them every key-loading body compiles out, leaving the
  file locals unused and nothing for the test to do.
Comment thread src/tls13.c
c16toa(extSz[extIdx] - OPAQUE16_LEN,
der->buffer);
}
else if (certExts[extIdx] != NULL) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

WriteCSRToBuffer infers "resumed send" from a non-NULL certExts[] pointer, which is also true for a buffer left over from a completed message

Consider using ssl->fragOffset != 0 instead.

Recommendation: Key the branch off ssl->fragOffset != 0, which is the actual resume signal and is already what gates SetupOcspResp at src/tls13.c:9970. Separately, relax the resume block's condition at src/tls13.c:10024 from > to >= so certExts[0] is always released once the cursor is past the leaf; I traced that change and it is safe (with chainPos == 0 the while loop does not execute, and idx/offset/entrySz/extIdx all stay at the values the send loop needs).

Comment thread src/ssl.c
&& !defined(HAVE_CURVE25519) && !defined(HAVE_CURVE448)
&& !defined(HAVE_CURVE25519) && !defined(HAVE_CURVE448) \
&& (!defined(WOLFSSL_HAVE_MLKEM) || \
defined(WOLFSSL_TLS_NO_MLKEM_STANDALONE))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

"No cipher suites defined" #error escape for ML-KEM does not require TLS 1.3

Recommendation: Add a !defined(WOLFSSL_TLS13) || term to the ML-KEM escape so the diagnostic still fires for a TLS 1.2-only build. Consider also gating configure.ac's standalone auto-enable on ENABLED_TLS13 — it currently prints "auto-enabling standalone ML-KEM for TLS 1.3" even when --disable-tls13 was given.

Comment thread wolfssl/internal.h
!defined(HAVE_ED25519) && !defined(HAVE_ED448)
!defined(HAVE_ED25519) && !defined(HAVE_ED448) && \
!defined(HAVE_FALCON) && !defined(WOLFSSL_HAVE_MLDSA) && \
!defined(WOLFSSL_HAVE_SLHDSA)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

"No cipher suites available" #error accepts Falcon/ML-DSA/SLH-DSA auth without requiring TLS 1.3

Recommendation: Gate the three post-quantum terms on WOLFSSL_TLS13 so a TLS 1.2-only build with only PQC signatures still gets the compile-time diagnostic.

Comment thread wolfssl/test.h

/* The same credentials, plus the ability to sign: a verify-only build can
* neither load the key nor produce a CertificateVerify. */
#if defined(TEST_HAVE_MLDSA_CERT_FILES) && \

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Add && !defined(WOLFSSL_MLDSA_NO_VERIFY) — the examples need both halves, since they sign their own CertificateVerify and verify the peer's chain. Reproduce with ./configure --disable-rsa --disable-ecc --disable-dh --enable-mlkem --enable-tls-mlkem-standalone --enable-psk --enable-mldsa=44,make,sign.

-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 \

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

New OCSP fragmented-send test silently no-ops in builds without HAVE_MAX_FRAGMENT

Recommendation: Detect HAVE_MAX_FRAGMENT with the script's existing client -? grep idiom and skip the case loudly rather than running a no-op that reports PASSED. The comment "Both options are ignored by builds that do not support them" documents the hole but does not make the coverage loss visible in CI output.

Comment thread src/ssl.c
&& !defined(HAVE_CURVE25519) && !defined(HAVE_CURVE448)
&& !defined(HAVE_CURVE25519) && !defined(HAVE_CURVE448) \
&& (!defined(WOLFSSL_HAVE_MLKEM) || \
defined(WOLFSSL_TLS_NO_MLKEM_STANDALONE))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Consider moving tls.c:4677 into internal.h or settings.h and using that gate.

/* src/tls.c:4677 */
    #if defined(WOLFSSL_HAVE_MLKEM) && !defined(WOLFSSL_MLKEM_NO_MAKE_KEY) && \
         !defined(WOLFSSL_MLKEM_NO_DECAPSULATE)
        #define WOLFSSL_HAVE_MLKEM_CLIENT_SUPPORT
    #endif

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