ML-DSA certificate chains: the 184 bytes that break TLS handshakes
Swap a three-certificate chain from ECDSA to ML-DSA-65 and its TLS 1.3 Certificate message grows from 1,157 bytes to 16,568. The default mbedTLS input buffer holds 16,384. That's 1.1% over, which is enough to fail every handshake that presents the chain. Nothing in your crypto inventory will flag it.
Where the bytes come from
ML-DSA-65 (FIPS 204) has a 1,952-byte public key and a 3,309-byte signature. In an X.509 certificate, each of those sits inside a DER BIT STRING: a tag byte, a length, and one "unused bits" byte before the payload. Once the content passes 255 bytes, the length takes three bytes (0x82 plus two):
1 + 3 + 1 + 1,9521,957 B1 + 3 + 1 + 3,3093,314 Bnames, validity, extensions, AlgorithmIdentifiers244 B5,515 BThe 244 bytes is a deliberately minimal profile, measured against certificates generated with OpenSSL 3.6.1. Real certificates carry SANs, policies, SCTs and AIA extensions, so treat every number below as a lower bound.
TLS 1.3 (RFC 8446 §4.4.2) wraps the chain in a Certificate handshake message. It has an 8-byte header: handshake type (1), length (3), request-context length (1) and certificate-list length (3). Each certificate entry adds 5 bytes: a 3-byte length and 2 bytes of extensions length.
1 + 3 + 1 + 38 B3 × (5 + 5,515)16,560 Bvs. MBEDTLS_SSL_IN_CONTENT_LEN 16,38416,568 BWhy 16,384 is a wall
mbedTLS reassembles each incoming handshake message in its input buffer, and MBEDTLS_SSL_IN_CONTENT_LEN sets that buffer's size. The default is 214 = 16,384 bytes, which is also the largest plaintext record TLS allows (RFC 8446 §5.1). A Certificate message larger than the buffer can't be processed, so the handshake fails.
Tokenkeep classifies this sink as a CRITICAL hard ceiling, because the setting is already at the protocol's record maximum: no config change gets you more room. Embedded builds often shrink the buffer to 4 or 8 KiB to save RAM, and then even an ML-DSA-44 chain breaks.
How the algorithms compare
| Algorithm | One cert | 1-cert chain | 2-cert chain | 3-cert chain | vs. 16,384 B |
|---|---|---|---|---|---|
| Ed25519 | 346 | 359 | 710 | 1,061 | fits |
| ECDSA P-256 | 378 | 391 | 774 | 1,157 | fits |
| RSA-2048 | 766 | 779 | 1,550 | 2,321 | fits |
| FN-DSA-512 | 1,817 | 1,830 | 3,652 | 5,474 | fits |
| ML-DSA-44 | 3,986 | 3,999 | 7,990 | 11,981 | fits |
| ML-DSA-65 | 5,515 | 5,528 | 11,048 | 16,568 | +184 |
| ML-DSA-87 | 7,473 | 7,486 | 14,964 | 22,442 | +6,058 |
| SLH-DSA-128s | 8,140 | 8,153 | 16,298 | 24,443 | +8,059 |
Bytes. Every certificate in the chain uses the same algorithm for its subject key and its issuer's signature, with minimal 244 B metadata.
Why your inventory misses it
A Cryptographic Bill of Materials finds the call that signs a certificate and reports ECDSA. It's right about that. What it doesn't see is the rest of the picture, which is spread across files no crypto scanner reads:
- the buffer size, which is a
#definein a C header; - how many certificates get sent, which is set in deployment config;
- how the DER and TLS framing turn a 3,309-byte signature into a 3,314-byte field inside a 5,520-byte entry.
The failure only shows up when those three facts are put together, which is what Tokenkeep's constraint graph does. The scanner reads MBEDTLS_SSL_IN_CONTENT_LEN from the header and the chain from a [tls_chain] record, then solves the projected message size for any target algorithm.
$ tokenkeep-pqc-scan --path . --target-algo ML-DSA-65
[CRITICAL] tls/mbedtls_config.h:2 16,568 B > 16,384 B +184 B · 1.01× limit
RUNTIME FAILURE IMMINENT: Certificate message exceeds the TLS
stack's record buffer; handshake aborts
Four ways out
- Send the leaf alone. A one-certificate message is 5,528 bytes. Intermediates are fetched through the Authority Information Access extension (RFC 5280 §4.2.2.1). This is the template Tokenkeep PQC Enterprise emits, and it only works if your clients can fetch AIA.
- Shorten the chain. Two ML-DSA-65 certificates come to 11,048 bytes and fit with room to spare. If an intermediate exists only for organisational reasons, dropping it buys back 5,520 bytes.
- Pick the parameter set per tier. ML-DSA-44 at all three tiers is 11,981 bytes. Many PKIs could use ML-DSA-65 for the root and intermediates and a smaller set for short-lived leaves. Run the scan once per candidate before you commit.
- Don't count on compression. TLS certificate compression (RFC 8879) does well on names and extensions, but PQC keys and signatures are high-entropy, so most of the 16,568 bytes won't shrink.
See it for your own stack
Paste your mbedtls_config.h into the checker. It runs the engine's own parser in your browser.