feat: verify ML-DSA server certificates (requires Go 1.27) - #414
Open
nihiloid wants to merge 12 commits into
Open
feat: verify ML-DSA server certificates (requires Go 1.27)#414nihiloid wants to merge 12 commits into
nihiloid wants to merge 12 commits into
Conversation
added 12 commits
August 29, 2026 13:35
Add HelloChrome_150 and HelloChrome_150_PSK. Chrome 150 sends the ML-DSA signature algorithms 0x0904, 0x0905 and 0x0906 before the classical ones. HelloChrome_Auto now selects HelloChrome_150. Add HelloSafari_18_5. Safari 18.5 sends no ML-KEM group, it offers TLS 1.0 and TLS 1.1 in supported_versions, and it ends with a padding extension. Add HelloSafari_26_0 to the HelloSafari_26_3 case. Safari 26.0 sends the same ClientHello as Safari 26.3. Add u_parrots_ja4_test.go. It computes the JA4 of each profile and compares it against a captured value. A second test checks that the extension shuffle keeps the JA4 the same on every connection.
Add u_psk_resumption_test.go. It runs two connections of a PSK profile against a TLS 1.3 server of this package. The first connection gets a session ticket. The second connection sends the pre_shared_key extension and resumes the session, which works only if utls computes the binders over the ClientHello that goes on the wire. The test runs HelloChrome_150_PSK and HelloChrome_114_Padding_PSK_Shuf. The second profile holds a padding extension, whose length comes from the length of the other extensions, and it shuffles its extensions, which must keep pre_shared_key last. A third connection uses HelloChrome_150, which holds no pre_shared_key extension. It must not resume, although the session cache holds a ticket.
The ML-DSA code needs the standard library package crypto/mldsa. That package starts with Go 1.27. Go 1.27 ignores the io.Reader that the code gives to ecdh.GenerateKey. Thus 64 tests that replay a recorded transcript from testdata fail. A later change must call cryptotest.SetGlobalRandom and record the transcripts again with OpenSSL 1.1.1. The JA4 tests and the PSK resumption tests pass.
Add MLDSA44, MLDSA65, and MLDSA87 with the codepoints 0x0904, 0x0905, and 0x0906. Map the 3 schemes to a new internal signature type, and to no pre-hash. Regenerate common_string.go. The file was not current. Thus the new output also adds the names of the fake curves and the Kyber curves from u_common.go. The names of the other values do not change.
Add an ML-DSA case to verifyHandshakeSignature. The case calls mldsa.Verify with an empty context.
Add clientSupportedSignatureAlgorithms. It adds the 3 ML-DSA schemes for TLS 1.3 only, and it makes a new slice. The shared list that the server call sites read does not change.
Use clientSupportedSignatureAlgorithms at the 3 client call sites. The client offers the ML-DSA schemes for TLS 1.3, and accepts a server CertificateVerify message that uses them. The server call sites do not change.
Replace the raw values 0x0904, 0x0905, and 0x0906 with MLDSA44, MLDSA65, and MLDSA87. The bytes of the ClientHello do not change.
Add an ML-DSA case to the public key check in processCertsFromServer. The case rejects ML-DSA below TLS 1.3. Without this case the client stops before it verifies the signature. Add a test that runs a full handshake. The server is the standard library with an ML-DSA certificate. The client is uTLS with the Chrome 150 PSK profile.
Add 2 tests that make real network connections with the HelloChrome_150_PSK profile. Both tests skip with "go test -short". The first test gets HTTP 204 from www.google.com twice. The second connection resumes the session with the pre_shared_key extension. The second test connects to the 3 ML-DSA ports of the Open Quantum Safe test server. Each connection proves that the client offers the ML-DSA codepoints, that the client verifies the ML-DSA CertificateVerify signature of the server, and that crypto/x509 verifies a chain in which the intermediate certificate signs the leaf with ML-DSA. The root certificate of the test server is in the file. It expires on 30 November 2026.
Set cryptocustomrand=1 with a go:debug line. The reference tests replay a recorded transcript and compare the bytes of the client against the record. Thus the client must make the same ephemeral key on each run. Go 1.27 gives the io.Reader of the caller to rand.CustomReader in ecdh.GenerateKey, which returns the system source. The setting makes that function give the reader of the caller through. This repairs 64 tests. No transcript needs a new record.
Give supportedSignatureAlgorithms back to the 2 call sites that build the hello. Only clientSupportedSignatureAlgorithms stays at the site that accepts the CertificateVerify message of the server. Thus the bytes of a default ClientHello do not change, and the fingerprint of HelloGolang stays the same. A profile that holds the ML-DSA codepoints in its own signature_algorithms extension still offers them, such as HelloChrome_150. This repairs 32 reference tests.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why this needs Go 1.27, and why that breaks consumers
This pull request raises the minimum Go version from 1.24 to 1.27. A consumer on Go
1.24, 1.25, or 1.26 cannot build the library after this change. The reasons come
first, because this cost must be clear before the feature.
crypto/mldsa. That package starts with Go1.27. No earlier release holds it.
crypto/x509. Go 1.27 adds theMLDSApublic key algorithm, theMLDSA44,MLDSA65, andMLDSA87signaturealgorithms, and the PKIX and PKCS#8 key formats of RFC 9881. See
golang/go#78888, accepted with the
milestone Go1.27, and golang/go#77626.
crypto/x509. All 8 call sites importthe standard library package. Thus the certificate half of ML-DSA is impossible on an
older toolchain, with or without a third-party ML-DSA library. A Go 1.24 build gives
UnknownSignatureAlgorithmfor an ML-DSA certificate, and the chain does not verify.Two alternatives keep Go 1.24, and this pull request rejects both:
github.com/cloudflare/circlfor the primitivecrypto/x509still cannot parse or verify an ML-DSA chain. It also adds a third-party dependency and moves the code away from the upstreamcrypto/mldsaAPI, thus each later rebase costs more.crypto/mldsaabove Go 1.27 and CIRCL belowWhy now. The Chrome 150 profiles already send the ML-DSA codepoints 0x0904, 0x0905,
and 0x0906 in
signature_algorithms(u_parrots.go). The library could not honor them.A server that selected one ended the handshake with an alert. This change closes that
gap between what the profile claims and what the library can do.
InsecureSkipVerifyis not a workaroundWithout this pull request, a client cannot complete an ML-DSA handshake at any trust
setting.
InsecureSkipVerifydoes not help. A test on the commita335c30, with Go1.24,
InsecureSkipVerify: true, and the profileHelloChrome_150_PSK, gives thisresult on both ports:
InsecureSkipVerifyremoves the chain verification only, which is the callcerts[0].Verify(opts). The switch on the public key type athandshake_client.go:1221runs after it, and always. That switch accepts RSA, ECDSA, and Ed25519 only.
The value
<nil>in the message shows a second part of the same problem. Go 1.24crypto/x509reads the ML-DSA certificate without an error, but gives no public key,because it does not know the algorithm OID.
2 independent gates stop the handshake, and a change of the trust settings opens
neither:
handshake_client.go:1221handshake_client_tls13.go:853tls: certificate used with invalid signature algorithm, becausesupportedSignatureAlgorithmsholds no ML-DSA scheme.Thus this work is not only about trust in a chain. The library cannot speak the protocol
with an ML-DSA server until it holds these changes.
Prerequisite: #413, the Chrome 150 profiles
Merge #413 first. This work needs
HelloChrome_150andHelloChrome_150_PSK, which#413 adds. The base of this pull request is
master, because a pull request cannot usea branch of a fork as its base. Thus the diff below also holds the 2 commits of #413.
After #413 merges, only the 10 commits of this work stay.
The dependency is not a question of order alone. It holds for 3 reasons.
0x0904,0x0905, and0x0906are in the profiles of Add Chrome 150 and Safari 18.5 profiles, with JA4 tests #413. This pull request givesthem the names
MLDSA44,MLDSA65, andMLDSA87.HelloChrome_150_PSK. The client does not advertiseML-DSA in a default ClientHello, as the section "What this does not change" explains.
Only a profile that holds the codepoints in its own
signature_algorithmsextensionoffers them.
nothing that a client can reach, because no hello in the library would offer ML-DSA.
Note that #404 adds Chrome 150 profiles as well, from a different author. If #404 merges
in place of #413, this work needs a rebase. The changes here stay correct, because they
need only a profile that holds the 3 ML-DSA codepoints.
What this adds
MLDSA44,MLDSA65, andMLDSA87of typeSignatureScheme, with the same names andvalues as Go 1.27
crypto/tls.verifyHandshakeSignature, and the map from the 3 schemes to nopre-hash in
typeAndHashFromSignatureScheme.processCertsFromServer, which rejects ML-DSA below TLS 1.3.clientSupportedSignatureAlgorithms, which the client uses to accept theCertificateVerify message of a server.
do not change, which the JA4 tests show.
What this does not change
supportedSignatureAlgorithms. Thus the fingerprint ofHelloGolangstays the same.Only a profile that holds the ML-DSA codepoints in its own extension offers them.
Go 1.27
crypto/tlsadvertises ML-DSA in its default hello. This library does not,because a change of the default fingerprint costs more here than the match gives.
supportedSignatureAlgorithmskeepthat function. A uTLS server does not advertise the ML-DSA codepoints, and rejects an
ML-DSA client certificate.
defaults.goholds no ML-DSA. 3 tests hold this line.The 64 reference tests
The Go version bump alone breaks 64 tests that replay a recorded transcript from
testdata. Go 1.27 gives theio.Readerof the caller torand.CustomReaderinecdh.GenerateKey, which returns the system source. Thus each run makes a differentephemeral key, and no record can match.
The repair is 1 line: a
go:debug cryptocustomrand=1directive inu_cryptocustomrand_test.go. That option makesrand.CustomReadergive the reader ofthe caller through.
cryptocustomrand=1No transcript needs a new record, and the tests need no OpenSSL.
Test evidence
Unit tests for the codepoints, the scheme map, and a sign and verify cycle.
3 server regression tests, which fail if ML-DSA enters the shared list.
An end-to-end test in 1 process: a standard library TLS 1.3 server with a
generated ML-DSA certificate, and a uTLS client with
HelloChrome_150_PSK.Network tests against the Open Quantum Safe test server, which is NGINX with OpenSSL
3.5 or later. They skip with
go test -short. All 3 parameter sets pass:Each connection selects X25519MLKEM768 for the key exchange. Thus 1 connection covers
post-quantum key exchange and post-quantum authentication.
The full test suite passes:
ok github.com/refraction-networking/utls.