From 64ff0fb1b3cabfebb96a0c080fa5f0585e41dc66 Mon Sep 17 00:00:00 2001 From: nihiloid Date: Sat, 29 Aug 2026 13:35:07 +1000 Subject: [PATCH 01/12] feat: add Chrome 150 and Safari 18.5 profiles with JA4 tests 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. --- u_common.go | 8 +- u_parrots.go | 264 +++++++++++++++++++++++++++++++- u_parrots_ja4_test.go | 339 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 609 insertions(+), 2 deletions(-) create mode 100644 u_parrots_ja4_test.go diff --git a/u_common.go b/u_common.go index a9f90f75e3..23c7d50f55 100644 --- a/u_common.go +++ b/u_common.go @@ -613,7 +613,7 @@ var ( HelloFirefox_120 = ClientHelloID{helloFirefox, "120", nil, nil} HelloFirefox_148 = ClientHelloID{helloFirefox, "148", nil, nil} - HelloChrome_Auto = HelloChrome_133 + HelloChrome_Auto = HelloChrome_150 HelloChrome_58 = ClientHelloID{helloChrome, "58", nil, nil} HelloChrome_62 = ClientHelloID{helloChrome, "62", nil, nil} HelloChrome_70 = ClientHelloID{helloChrome, "70", nil, nil} @@ -645,6 +645,9 @@ var ( HelloChrome_131 = ClientHelloID{helloChrome, "131", nil, nil} // Chrome w/ New ALPS codepoint HelloChrome_133 = ClientHelloID{helloChrome, "133", nil, nil} + // Chrome w/ ML-DSA signature algorithms + HelloChrome_150 = ClientHelloID{helloChrome, "150", nil, nil} + HelloChrome_150_PSK = ClientHelloID{helloChrome, "150_PSK", nil, nil} HelloIOS_Auto = HelloIOS_14 HelloIOS_11_1 = ClientHelloID{helloIOS, "111", nil, nil} // legacy "111" means 11.1 @@ -660,6 +663,9 @@ var ( HelloSafari_Auto = HelloSafari_26_3 HelloSafari_16_0 = ClientHelloID{helloSafari, "16.0", nil, nil} + HelloSafari_18_5 = ClientHelloID{helloSafari, "18.5", nil, nil} + // Safari 26.0 sends the same ClientHello as Safari 26.3. + HelloSafari_26_0 = ClientHelloID{helloSafari, "26.0", nil, nil} HelloSafari_26_3 = ClientHelloID{helloSafari, "26.3", nil, nil} Hello360_Auto = Hello360_7_5 // Hello360_11_0 seems to be incompatible with this library diff --git a/u_parrots.go b/u_parrots.go index 8416fa3363..1648f0ac52 100644 --- a/u_parrots.go +++ b/u_parrots.go @@ -2239,7 +2239,116 @@ func utlsIdToSpec(id ClientHelloID) (ClientHelloSpec, error) { }, }, }, nil - case HelloSafari_26_3: + case HelloSafari_18_5: + return ClientHelloSpec{ + TLSVersMin: VersionTLS10, + TLSVersMax: VersionTLS13, + CipherSuites: []uint16{ + GREASE_PLACEHOLDER, + TLS_AES_128_GCM_SHA256, + TLS_AES_256_GCM_SHA384, + TLS_CHACHA20_POLY1305_SHA256, + TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, + TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, + TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, + TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + TLS_RSA_WITH_AES_256_GCM_SHA384, + TLS_RSA_WITH_AES_128_GCM_SHA256, + TLS_RSA_WITH_AES_256_CBC_SHA, + TLS_RSA_WITH_AES_128_CBC_SHA, + FAKE_TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA, + TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA, + TLS_RSA_WITH_3DES_EDE_CBC_SHA, + }, + CompressionMethods: []uint8{ + compressionNone, + }, + Extensions: []TLSExtension{ + &UtlsGREASEExtension{}, + &SNIExtension{}, + &ExtendedMasterSecretExtension{}, + &RenegotiationInfoExtension{ + Renegotiation: RenegotiateOnceAsClient, + }, + &SupportedCurvesExtension{ + Curves: []CurveID{ + GREASE_PLACEHOLDER, + X25519, + CurveP256, + CurveP384, + CurveP521, + }, + }, + &SupportedPointsExtension{ + SupportedPoints: []uint8{ + pointFormatUncompressed, + }, + }, + &ALPNExtension{ + AlpnProtocols: []string{ + "h2", + "http/1.1", + }, + }, + &StatusRequestExtension{}, + &SignatureAlgorithmsExtension{ + SupportedSignatureAlgorithms: []SignatureScheme{ + ECDSAWithP256AndSHA256, + PSSWithSHA256, + PKCS1WithSHA256, + ECDSAWithP384AndSHA384, + PSSWithSHA384, + PSSWithSHA384, + PKCS1WithSHA384, + PSSWithSHA512, + PKCS1WithSHA512, + PKCS1WithSHA1, + }, + }, + &SCTExtension{}, + &KeyShareExtension{ + KeyShares: []KeyShare{ + { + Group: GREASE_PLACEHOLDER, + Data: []byte{ + 0, + }, + }, + { + Group: X25519, + }, + }, + }, + &PSKKeyExchangeModesExtension{ + Modes: []uint8{ + PskModeDHE, + }, + }, + &SupportedVersionsExtension{ + Versions: []uint16{ + GREASE_PLACEHOLDER, + VersionTLS13, + VersionTLS12, + VersionTLS11, + VersionTLS10, + }, + }, + &UtlsCompressCertExtension{ + Algorithms: []CertCompressionAlgo{ + CertCompressionZlib, + }, + }, + &UtlsGREASEExtension{}, + &UtlsPaddingExtension{GetPaddingLen: BoringPaddingStyle}, + }, + }, nil + case HelloSafari_26_0, HelloSafari_26_3: return ClientHelloSpec{ TLSVersMin: VersionTLS12, TLSVersMax: VersionTLS13, @@ -2927,6 +3036,159 @@ func utlsIdToSpec(id ClientHelloID) (ClientHelloSpec, error) { &UtlsPreSharedKeyExtension{}, }), }, nil + case HelloChrome_150: + return ClientHelloSpec{ + CipherSuites: []uint16{ + GREASE_PLACEHOLDER, + TLS_AES_128_GCM_SHA256, + TLS_AES_256_GCM_SHA384, + TLS_CHACHA20_POLY1305_SHA256, + TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, + TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + TLS_RSA_WITH_AES_128_GCM_SHA256, + TLS_RSA_WITH_AES_256_GCM_SHA384, + TLS_RSA_WITH_AES_128_CBC_SHA, + TLS_RSA_WITH_AES_256_CBC_SHA, + }, + CompressionMethods: []byte{ + compressionNone, + }, + Extensions: ShuffleChromeTLSExtensions([]TLSExtension{ + &UtlsGREASEExtension{}, + &SNIExtension{}, + &ExtendedMasterSecretExtension{}, + &RenegotiationInfoExtension{Renegotiation: RenegotiateOnceAsClient}, + &SupportedCurvesExtension{[]CurveID{ + GREASE_PLACEHOLDER, + X25519MLKEM768, + X25519, + CurveP256, + CurveP384, + }}, + &SupportedPointsExtension{SupportedPoints: []byte{ + pointFormatUncompressed, + }}, + &SessionTicketExtension{}, + &ALPNExtension{AlpnProtocols: []string{"h2", "http/1.1"}}, + &StatusRequestExtension{}, + &SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: []SignatureScheme{ + SignatureScheme(0x0904), + SignatureScheme(0x0905), + SignatureScheme(0x0906), + ECDSAWithP256AndSHA256, + PSSWithSHA256, + PKCS1WithSHA256, + ECDSAWithP384AndSHA384, + PSSWithSHA384, + PKCS1WithSHA384, + PSSWithSHA512, + PKCS1WithSHA512, + }}, + &SCTExtension{}, + &KeyShareExtension{[]KeyShare{ + {Group: CurveID(GREASE_PLACEHOLDER), Data: []byte{0}}, + {Group: X25519MLKEM768}, + {Group: X25519}, + }}, + &PSKKeyExchangeModesExtension{[]uint8{ + PskModeDHE, + }}, + &SupportedVersionsExtension{[]uint16{ + GREASE_PLACEHOLDER, + VersionTLS13, + VersionTLS12, + }}, + &UtlsCompressCertExtension{[]CertCompressionAlgo{ + CertCompressionBrotli, + }}, + &ApplicationSettingsExtensionNew{SupportedProtocols: []string{"h2"}}, + BoringGREASEECH(), + &UtlsGREASEExtension{}, + }), + }, nil + case HelloChrome_150_PSK: + return ClientHelloSpec{ + CipherSuites: []uint16{ + GREASE_PLACEHOLDER, + TLS_AES_128_GCM_SHA256, + TLS_AES_256_GCM_SHA384, + TLS_CHACHA20_POLY1305_SHA256, + TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, + TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + TLS_RSA_WITH_AES_128_GCM_SHA256, + TLS_RSA_WITH_AES_256_GCM_SHA384, + TLS_RSA_WITH_AES_128_CBC_SHA, + TLS_RSA_WITH_AES_256_CBC_SHA, + }, + CompressionMethods: []byte{ + compressionNone, + }, + Extensions: ShuffleChromeTLSExtensions([]TLSExtension{ + &UtlsGREASEExtension{}, + &SNIExtension{}, + &ExtendedMasterSecretExtension{}, + &RenegotiationInfoExtension{Renegotiation: RenegotiateOnceAsClient}, + &SupportedCurvesExtension{[]CurveID{ + GREASE_PLACEHOLDER, + X25519MLKEM768, + X25519, + CurveP256, + CurveP384, + }}, + &SupportedPointsExtension{SupportedPoints: []byte{ + pointFormatUncompressed, + }}, + &SessionTicketExtension{}, + &ALPNExtension{AlpnProtocols: []string{"h2", "http/1.1"}}, + &StatusRequestExtension{}, + &SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: []SignatureScheme{ + SignatureScheme(0x0904), + SignatureScheme(0x0905), + SignatureScheme(0x0906), + ECDSAWithP256AndSHA256, + PSSWithSHA256, + PKCS1WithSHA256, + ECDSAWithP384AndSHA384, + PSSWithSHA384, + PKCS1WithSHA384, + PSSWithSHA512, + PKCS1WithSHA512, + }}, + &SCTExtension{}, + &KeyShareExtension{[]KeyShare{ + {Group: CurveID(GREASE_PLACEHOLDER), Data: []byte{0}}, + {Group: X25519MLKEM768}, + {Group: X25519}, + }}, + &PSKKeyExchangeModesExtension{[]uint8{ + PskModeDHE, + }}, + &SupportedVersionsExtension{[]uint16{ + GREASE_PLACEHOLDER, + VersionTLS13, + VersionTLS12, + }}, + &UtlsCompressCertExtension{[]CertCompressionAlgo{ + CertCompressionBrotli, + }}, + &ApplicationSettingsExtensionNew{SupportedProtocols: []string{"h2"}}, + BoringGREASEECH(), + &UtlsGREASEExtension{}, + &UtlsPreSharedKeyExtension{}, + }), + }, nil default: if id.Client == helloRandomized || id.Client == helloRandomizedALPN || id.Client == helloRandomizedNoALPN { // Use empty values as they can be filled later by UConn.ApplyPreset or manually. diff --git a/u_parrots_ja4_test.go b/u_parrots_ja4_test.go new file mode 100644 index 0000000000..5548cf1f34 --- /dev/null +++ b/u_parrots_ja4_test.go @@ -0,0 +1,339 @@ +package tls + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "net" + "sort" + "strings" + "testing" +) + +// JA4 is a fingerprint of a TLS ClientHello. It has three parts, joined by underscores. +// https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md +// +// a: the transport, the TLS version, whether SNI is present, the number of cipher +// suites, the number of extensions, and the first and last character of the first +// ALPN protocol. +// b: the first 12 hex characters of the SHA-256 of the sorted cipher suites, without +// GREASE. +// c: the first 12 hex characters of the SHA-256 of the sorted extensions, without +// GREASE, SNI and ALPN, then an underscore, then the signature algorithms in the +// order that the ClientHello sends them. +// +// The sort makes JA4 the same for every connection of one profile, although +// ShuffleChromeTLSExtensions gives the extensions a new order for each connection. + +// ja4Captures holds the captured JA4 of each profile. Add a line to test another +// profile. +var ja4Captures = []struct { + id ClientHelloID + ja4 string +}{ + {id: HelloChrome_150, ja4: "t13d1516h2_8daaf6152771_806a8c22fdea"}, + {id: HelloChrome_150_PSK, ja4: "t13d1517h2_8daaf6152771_a87ad97598a9"}, + {id: HelloChrome_133, ja4: "t13d1516h2_8daaf6152771_d8a2da3f94cd"}, + {id: HelloSafari_26_3, ja4: "t13d2013h2_a09f3c656075_7f0f34a4126d"}, + {id: HelloSafari_26_0, ja4: "t13d2013h2_a09f3c656075_7f0f34a4126d"}, + {id: HelloSafari_18_5, ja4: "t13d2014h2_a09f3c656075_e42f34c56612"}, + {id: HelloSafari_16_0, ja4: "t13d2014h2_a09f3c656075_14788d8d241b"}, +} + +// TestProfilesJA4 compares the ClientHello of each profile against its captured +// JA4. A change of a cipher suite, an extension, or a signature algorithm changes the +// JA4, so this test fails if a profile changes by accident. +func TestProfilesJA4(t *testing.T) { + for _, capture := range ja4Captures { + t.Run(capture.id.Str(), func(t *testing.T) { + got := ja4(t, buildJA4ClientHello(t, capture.id)) + if got != capture.ja4 { + t.Errorf("JA4 = %s, want %s", got, capture.ja4) + } + }) + } +} + +// TestProfilesJA4IsStable checks that the JA4 of a profile is the same on every +// connection. Chrome shuffles its extensions, and a fingerprint that changed with the +// shuffle would identify utls rather than the browser. +func TestProfilesJA4IsStable(t *testing.T) { + const connections = 16 + + for _, capture := range ja4Captures { + t.Run(capture.id.Str(), func(t *testing.T) { + for i := 0; i < connections; i++ { + got := ja4(t, buildJA4ClientHello(t, capture.id)) + if got != capture.ja4 { + t.Fatalf("connection %d gave JA4 %s, want %s", i, got, capture.ja4) + } + } + }) + } +} + +// TestChrome150PSKWithoutSession covers the PSK profile without a session. utls then +// conceals the empty pre_shared_key extension, so the ClientHello holds one extension +// less and the fingerprint becomes the one of Chrome 150 without PSK. +func TestChrome150PSKWithoutSession(t *testing.T) { + want := ja4Captures[0].ja4 // Chrome 150 without PSK + + if got := ja4(t, buildJA4ClientHelloWithoutSession(t, HelloChrome_150_PSK)); got != want { + t.Errorf("JA4 = %s, want %s", got, want) + } +} + +// buildJA4ClientHello returns the ClientHello bytes that the given profile produces. A +// profile that holds a pre_shared_key extension gets a session, so that the extension +// goes on the wire with a fixed identity and a fixed binder, which is what a resumed +// connection sends. +func buildJA4ClientHello(t *testing.T, id ClientHelloID) []byte { + t.Helper() + + config := &Config{ServerName: "example.com"} + if profileSendsPSK(t, id) { + config.ClientSessionCache = NewLRUClientSessionCache(1) + } + + uconn := UClient(&net.TCPConn{}, config, id) + if config.ClientSessionCache != nil { + psk := &FakePreSharedKeyExtension{ + Identities: []PskIdentity{{Label: []byte("ja4-test-ticket"), ObfuscatedTicketAge: 0x1234}}, + Binders: [][]byte{make([]byte, 32)}, + } + if err := uconn.SetPskExtension(psk); err != nil { + t.Fatalf("SetPskExtension: %v", err) + } + } + if err := uconn.BuildHandshakeState(); err != nil { + t.Fatalf("BuildHandshakeState: %v", err) + } + + return uconn.HandshakeState.Hello.Raw +} + +// buildJA4ClientHelloWithoutSession returns the ClientHello bytes of a profile that +// carries no session. OmitEmptyPsk lets a PSK profile build a ClientHello although no +// session exists. +func buildJA4ClientHelloWithoutSession(t *testing.T, id ClientHelloID) []byte { + t.Helper() + + uconn := UClient(&net.TCPConn{}, &Config{ServerName: "example.com", OmitEmptyPsk: true}, id) + if err := uconn.BuildHandshakeState(); err != nil { + t.Fatalf("BuildHandshakeState: %v", err) + } + + return uconn.HandshakeState.Hello.Raw +} + +// profileSendsPSK reports whether the spec of a profile holds a pre_shared_key +// extension. +func profileSendsPSK(t *testing.T, id ClientHelloID) bool { + t.Helper() + + spec, err := UTLSIdToSpec(id) + if err != nil { + t.Fatalf("UTLSIdToSpec: %v", err) + } + + for _, extension := range spec.Extensions { + if _, ok := extension.(PreSharedKeyExtension); ok { + return true + } + } + + return false +} + +// ja4 returns the JA4 of a ClientHello. +func ja4(t *testing.T, raw []byte) string { + t.Helper() + + hello := parseJA4ClientHello(t, raw) + + var cipherSuites []string + for _, cipherSuite := range hello.cipherSuites { + if isGREASEUint16(cipherSuite) { + continue + } + cipherSuites = append(cipherSuites, fmt.Sprintf("%04x", cipherSuite)) + } + + var ( + extensions []string + sigAlgs []string + alpn string + version = "12" + sni = "i" + count int + ) + for _, extension := range hello.extensions { + if isGREASEUint16(extension.id) { + continue + } + count++ + + switch extension.id { + case extensionServerName: + sni = "d" + case extensionALPN: + alpn = ja4FirstALPNProtocol(t, extension.body) + case extensionSignatureAlgorithms: + for _, sigAlg := range ja4SignatureAlgorithms(t, extension.body) { + if isGREASEUint16(sigAlg) { + continue + } + sigAlgs = append(sigAlgs, fmt.Sprintf("%04x", sigAlg)) + } + case extensionSupportedVersions: + if ja4SendsTLS13(extension.body) { + version = "13" + } + } + + // JA4 counts SNI and ALPN, but it keeps them out of the sorted list. + if extension.id != extensionServerName && extension.id != extensionALPN { + extensions = append(extensions, fmt.Sprintf("%04x", extension.id)) + } + } + + sort.Strings(cipherSuites) + sort.Strings(extensions) + + alpnCode := "00" + if alpn != "" { + alpnCode = string(alpn[0]) + string(alpn[len(alpn)-1]) + } + + return fmt.Sprintf("t%s%s%02d%02d%s_%s_%s", + version, sni, len(cipherSuites), count, alpnCode, + ja4Hash(strings.Join(cipherSuites, ",")), + ja4Hash(strings.Join(extensions, ",")+"_"+strings.Join(sigAlgs, ","))) +} + +// ja4Hash returns the first 12 hex characters of the SHA-256 of s. +func ja4Hash(s string) string { + sum := sha256.Sum256([]byte(s)) + + return hex.EncodeToString(sum[:])[:12] +} + +// ja4SignatureAlgorithms returns the algorithms of a signature_algorithms extension. +func ja4SignatureAlgorithms(t *testing.T, body []byte) []uint16 { + t.Helper() + + if len(body) < 2 { + t.Fatal("signature_algorithms extension is shorter than its length field") + } + + var sigAlgs []uint16 + for i := 2; i+1 < len(body); i += 2 { + sigAlgs = append(sigAlgs, binary.BigEndian.Uint16(body[i:i+2])) + } + + return sigAlgs +} + +// ja4FirstALPNProtocol returns the first protocol of an ALPN extension. +func ja4FirstALPNProtocol(t *testing.T, body []byte) string { + t.Helper() + + if len(body) < 3 { + t.Fatal("ALPN extension is shorter than its length fields") + } + length := int(body[2]) + if len(body) < 3+length { + t.Fatal("ALPN protocol runs past the end of the extension") + } + + return string(body[3 : 3+length]) +} + +// ja4SendsTLS13 reports whether a supported_versions extension holds TLS 1.3. +func ja4SendsTLS13(body []byte) bool { + for i := 1; i+1 < len(body); i += 2 { + if binary.BigEndian.Uint16(body[i:i+2]) == VersionTLS13 { + return true + } + } + + return false +} + +// ja4ClientHello holds the parts of a ClientHello that JA4 needs. +type ja4ClientHello struct { + cipherSuites []uint16 + extensions []ja4Extension +} + +type ja4Extension struct { + id uint16 + body []byte +} + +// parseJA4ClientHello reads a ClientHello handshake message. It fails the test on a +// short or malformed message, which can only come from this package. +func parseJA4ClientHello(t *testing.T, raw []byte) ja4ClientHello { + t.Helper() + + reader := &ja4ByteReader{t: t, data: raw} + reader.skip(4) // handshake type and length + reader.skip(2 + 32) // legacy version and random + reader.skip(int(reader.uint8())) + cipherSuites := reader.bytes(int(reader.uint16())) + reader.skip(int(reader.uint8())) + extensions := reader.bytes(int(reader.uint16())) + + hello := ja4ClientHello{} + for i := 0; i+1 < len(cipherSuites); i += 2 { + hello.cipherSuites = append(hello.cipherSuites, binary.BigEndian.Uint16(cipherSuites[i:i+2])) + } + + extensionReader := &ja4ByteReader{t: t, data: extensions} + for len(extensionReader.data) > 0 { + id := extensionReader.uint16() + hello.extensions = append(hello.extensions, ja4Extension{ + id: id, + body: extensionReader.bytes(int(extensionReader.uint16())), + }) + } + + return hello +} + +// ja4ByteReader reads the fields of a handshake message in order. Every method fails +// the test if the message is too short. +type ja4ByteReader struct { + t *testing.T + data []byte +} + +func (r *ja4ByteReader) bytes(n int) []byte { + r.t.Helper() + + if len(r.data) < n { + r.t.Fatalf("ClientHello ends after %d bytes, want %d more", len(r.data), n) + } + head := r.data[:n] + r.data = r.data[n:] + + return head +} + +func (r *ja4ByteReader) skip(n int) { + r.t.Helper() + r.bytes(n) +} + +func (r *ja4ByteReader) uint8() uint8 { + r.t.Helper() + + return r.bytes(1)[0] +} + +func (r *ja4ByteReader) uint16() uint16 { + r.t.Helper() + + return binary.BigEndian.Uint16(r.bytes(2)) +} From a335c307ce745f2617fbd96cdca1f08fe64808a9 Mon Sep 17 00:00:00 2001 From: nihiloid Date: Sat, 29 Aug 2026 14:36:48 +1000 Subject: [PATCH 02/12] test: check that PSK profiles resume a session 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. --- u_psk_resumption_test.go | 158 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 u_psk_resumption_test.go diff --git a/u_psk_resumption_test.go b/u_psk_resumption_test.go new file mode 100644 index 0000000000..d703e759a4 --- /dev/null +++ b/u_psk_resumption_test.go @@ -0,0 +1,158 @@ +package tls + +import ( + "net" + "testing" +) + +// pskHandshakeResult holds what one connection of the resumption test showed. +type pskHandshakeResult struct { + didResume bool + sentPSK bool +} + +// pskProfiles holds the PSK profiles that this test runs. HelloChrome_150_PSK covers +// the current Chrome ClientHello. HelloChrome_114_Padding_PSK_Shuf covers the two +// features that change how the binders are written: a padding extension, whose length +// comes from the length of the other extensions, and the extension shuffle, which must +// keep pre_shared_key last. The other PSK profiles follow the same path as +// HelloChrome_150_PSK. +var pskProfiles = []ClientHelloID{ + HelloChrome_150_PSK, + HelloChrome_114_Padding_PSK_Shuf, +} + +// TestPSKProfilesResumeSession runs two connections of each PSK profile against a TLS +// 1.3 server. The first connection gets a session ticket. The second connection sends +// the pre_shared_key extension of the profile and resumes the session, which works only +// if utls computes the binders over the ClientHello that goes on the wire. +func TestPSKProfilesResumeSession(t *testing.T) { + for _, id := range pskProfiles { + t.Run(id.Str(), func(t *testing.T) { + serverConfig, clientConfig := pskTestConfigs() + + first := runPSKHandshake(t, clientConfig, serverConfig, id) + if first.didResume { + t.Error("the first connection resumed a session, although no ticket existed") + } + if first.sentPSK { + t.Error("the first connection sent a pre_shared_key extension, although no ticket existed") + } + + second := runPSKHandshake(t, clientConfig, serverConfig, id) + if !second.sentPSK { + t.Fatal("the second connection sent no pre_shared_key extension") + } + if !second.didResume { + t.Error("the second connection did not resume the session") + } + }) + } +} + +// TestProfileWithoutPSKDoesNotResume covers a profile that holds no pre_shared_key +// extension. Such a profile cannot resume, although the session cache holds a ticket. +// This is what makes the resumption of the PSK profiles a property of the extension, +// and not of the session cache. +func TestProfileWithoutPSKDoesNotResume(t *testing.T) { + serverConfig, clientConfig := pskTestConfigs() + + runPSKHandshake(t, clientConfig, serverConfig, HelloChrome_150_PSK) + + withoutPSK := runPSKHandshake(t, clientConfig, serverConfig, HelloChrome_150) + if withoutPSK.sentPSK { + t.Error("the profile without a pre_shared_key extension sent one") + } + if withoutPSK.didResume { + t.Error("the profile without a pre_shared_key extension resumed a session") + } +} + +// pskTestConfigs returns a TLS 1.3 server config, and a client config that keeps +// sessions. +func pskTestConfigs() (serverConfig, clientConfig *Config) { + serverConfig = testConfig.Clone() + serverConfig.MinVersion = VersionTLS13 + serverConfig.MaxVersion = VersionTLS13 + + clientConfig = testConfig.Clone() + clientConfig.MinVersion = VersionTLS13 + clientConfig.MaxVersion = VersionTLS13 + clientConfig.ServerName = "example.go.dev" + clientConfig.ClientSessionCache = NewLRUClientSessionCache(4) + // A connection that holds no session yet would fail on the empty pre_shared_key + // extension. OmitEmptyPsk conceals that extension instead. + clientConfig.OmitEmptyPsk = true + + return serverConfig, clientConfig +} + +// runPSKHandshake runs one client connection against a server that this function +// starts. It returns whether the connection resumed a session, and whether the +// ClientHello held a pre_shared_key extension. +func runPSKHandshake(t *testing.T, clientConfig, serverConfig *Config, id ClientHelloID) pskHandshakeResult { + t.Helper() + + listener := newLocalListener(t) + defer listener.Close() + + serverErr := make(chan error, 1) + go func() { + serverConn, err := listener.Accept() + if err != nil { + serverErr <- err + + return + } + defer serverConn.Close() + + server := Server(serverConn, serverConfig) + if err := server.Handshake(); err != nil { + serverErr <- err + + return + } + + // The write lets the client read, and the read of the client processes the + // session tickets that the server sent after the handshake. + _, err = server.Write([]byte{'x'}) + serverErr <- err + }() + + clientConn, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer clientConn.Close() + + client := UClient(clientConn, clientConfig, id) + if err := client.Handshake(); err != nil { + t.Fatalf("client handshake with %s: %v", id.Str(), err) + } + + buf := make([]byte, 1) + if _, err := client.Read(buf); err != nil { + t.Fatalf("client read with %s: %v", id.Str(), err) + } + if err := <-serverErr; err != nil { + t.Fatalf("server with %s: %v", id.Str(), err) + } + + return pskHandshakeResult{ + didResume: client.ConnectionState().DidResume, + sentPSK: clientHelloHoldsExtension(t, client.HandshakeState.Hello.Raw, extensionPreSharedKey), + } +} + +// clientHelloHoldsExtension reports whether a ClientHello holds one extension. +func clientHelloHoldsExtension(t *testing.T, raw []byte, want uint16) bool { + t.Helper() + + for _, extension := range parseJA4ClientHello(t, raw).extensions { + if extension.id == want { + return true + } + } + + return false +} From 11811a452d52c134b96b9d8b81e5f352a16655d2 Mon Sep 17 00:00:00 2001 From: nihiloid Date: Sat, 29 Aug 2026 15:48:08 +1000 Subject: [PATCH 03/12] chore: raise the minimum Go version to 1.27 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. --- .github/workflows/go.yml | 2 +- go.mod | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 6fb71cdd4c..dec51a06a2 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -15,7 +15,7 @@ jobs: fail-fast: false matrix: os: ["ubuntu-latest", "windows-latest", "macos-latest"] - go: ["1.24.x"] + go: ["1.27.x"] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 diff --git a/go.mod b/go.mod index 6cc2d35620..4d61daf73b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/refraction-networking/utls -go 1.24 +go 1.27 retract ( v1.4.1 // #218 From 1af24b8f705865d25467aea751294c7426e858fe Mon Sep 17 00:00:00 2001 From: nihiloid Date: Sat, 29 Aug 2026 15:49:58 +1000 Subject: [PATCH 04/12] feat: add the ML-DSA signature schemes 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. --- auth.go | 7 +++++++ common.go | 7 +++++++ common_string.go | 46 ++++++++++++++++++++++++++++++++++++++++++---- u_mldsa_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 u_mldsa_test.go diff --git a/auth.go b/auth.go index 998deb9b21..a1bc0fbb1c 100644 --- a/auth.go +++ b/auth.go @@ -10,6 +10,7 @@ import ( "crypto/ecdsa" "crypto/ed25519" "crypto/elliptic" + "crypto/mldsa" "crypto/rsa" "errors" "fmt" @@ -105,6 +106,8 @@ func typeAndHashFromSignatureScheme(signatureAlgorithm SignatureScheme) (sigType sigType = signatureECDSA case Ed25519: sigType = signatureEd25519 + case MLDSA44, MLDSA65, MLDSA87: + sigType = signatureMLDSA default: return 0, 0, fmt.Errorf("unsupported signature algorithm: %v", signatureAlgorithm) } @@ -119,6 +122,8 @@ func typeAndHashFromSignatureScheme(signatureAlgorithm SignatureScheme) (sigType hash = crypto.SHA512 case Ed25519: hash = directSigning + case MLDSA44, MLDSA65, MLDSA87: + hash = directSigning default: return 0, 0, fmt.Errorf("unsupported signature algorithm: %v", signatureAlgorithm) } @@ -140,6 +145,8 @@ func legacyTypeAndHashFromPublicKey(pub crypto.PublicKey) (sigType uint8, hash c // full signature, and not even OpenSSL bothers with the // complexity, so we can't even test it properly. return 0, 0, fmt.Errorf("tls: Ed25519 public keys are not supported before TLS 1.2") + case *mldsa.PublicKey: + return 0, 0, fmt.Errorf("tls: ML-DSA public keys are not supported before TLS 1.3") default: return 0, 0, fmt.Errorf("tls: unsupported public key: %T", pub) } diff --git a/common.go b/common.go index 73b6dad51c..3f9c7adcb3 100644 --- a/common.go +++ b/common.go @@ -207,6 +207,7 @@ const ( signatureECDSA signatureEd25519 signatureEdDilithium3 + signatureMLDSA ) // directSigning is a standard Hash value that signals that no pre-hashing @@ -414,6 +415,12 @@ const ( // EdDSA algorithms. Ed25519 SignatureScheme = 0x0807 + // ML-DSA signature algorithms (FIPS 204, RFC 9881). These codepoints are + // defined for TLS 1.3 only. + MLDSA44 SignatureScheme = 0x0904 + MLDSA65 SignatureScheme = 0x0905 + MLDSA87 SignatureScheme = 0x0906 + // Legacy signature and hash algorithms for TLS 1.2. PKCS1WithSHA1 SignatureScheme = 0x0201 ECDSAWithSHA1 SignatureScheme = 0x0203 diff --git a/common_string.go b/common_string.go index e15dd48838..2e5aefb470 100644 --- a/common_string.go +++ b/common_string.go @@ -18,6 +18,9 @@ func _() { _ = x[ECDSAWithP384AndSHA384-1283] _ = x[ECDSAWithP521AndSHA512-1539] _ = x[Ed25519-2055] + _ = x[MLDSA44-2308] + _ = x[MLDSA65-2309] + _ = x[MLDSA87-2310] _ = x[PKCS1WithSHA1-513] _ = x[ECDSAWithSHA1-515] } @@ -32,10 +35,12 @@ const ( _SignatureScheme_name_6 = "PKCS1WithSHA512" _SignatureScheme_name_7 = "ECDSAWithP521AndSHA512" _SignatureScheme_name_8 = "PSSWithSHA256PSSWithSHA384PSSWithSHA512Ed25519" + _SignatureScheme_name_9 = "MLDSA44MLDSA65MLDSA87" ) var ( _SignatureScheme_index_8 = [...]uint8{0, 13, 26, 39, 46} + _SignatureScheme_index_9 = [...]uint8{0, 7, 14, 21} ) func (i SignatureScheme) String() string { @@ -59,6 +64,9 @@ func (i SignatureScheme) String() string { case 2052 <= i && i <= 2055: i -= 2052 return _SignatureScheme_name_8[_SignatureScheme_index_8[i]:_SignatureScheme_index_8[i+1]] + case 2308 <= i && i <= 2310: + i -= 2308 + return _SignatureScheme_name_9[_SignatureScheme_index_9[i]:_SignatureScheme_index_9[i+1]] default: return "SignatureScheme(" + strconv.FormatInt(int64(i), 10) + ")" } @@ -72,16 +80,37 @@ func _() { _ = x[CurveP521-25] _ = x[X25519-29] _ = x[X25519MLKEM768-4588] + _ = x[CurveSECP256R1-23] + _ = x[CurveSECP384R1-24] + _ = x[CurveSECP521R1-25] + _ = x[CurveX25519-29] + _ = x[FakeCurveFFDHE2048-256] + _ = x[FakeCurveFFDHE3072-257] + _ = x[FakeCurveFFDHE4096-258] + _ = x[FakeCurveFFDHE6144-259] + _ = x[FakeCurveFFDHE8192-260] + _ = x[X25519Kyber768Draft00-25497] + _ = x[FakeCurveX25519Kyber512Draft00-65072] + _ = x[FakeCurveX25519Kyber768Draft00Old-65073] + _ = x[FakeCurveP256Kyber768Draft00-65074] + _ = x[X25519Kyber512Draft00-65072] + _ = x[X25519Kyber768Draft00Old-65073] + _ = x[P256Kyber768Draft00-65074] } const ( _CurveID_name_0 = "CurveP256CurveP384CurveP521" _CurveID_name_1 = "X25519" - _CurveID_name_2 = "X25519MLKEM768" + _CurveID_name_2 = "FakeCurveFFDHE2048FakeCurveFFDHE3072FakeCurveFFDHE4096FakeCurveFFDHE6144FakeCurveFFDHE8192" + _CurveID_name_3 = "X25519MLKEM768" + _CurveID_name_4 = "X25519Kyber768Draft00" + _CurveID_name_5 = "FakeCurveX25519Kyber512Draft00FakeCurveX25519Kyber768Draft00OldFakeCurveP256Kyber768Draft00" ) var ( _CurveID_index_0 = [...]uint8{0, 9, 18, 27} + _CurveID_index_2 = [...]uint8{0, 18, 36, 54, 72, 90} + _CurveID_index_5 = [...]uint8{0, 30, 63, 91} ) func (i CurveID) String() string { @@ -91,8 +120,16 @@ func (i CurveID) String() string { return _CurveID_name_0[_CurveID_index_0[i]:_CurveID_index_0[i+1]] case i == 29: return _CurveID_name_1 + case 256 <= i && i <= 260: + i -= 256 + return _CurveID_name_2[_CurveID_index_2[i]:_CurveID_index_2[i+1]] case i == 4588: - return _CurveID_name_2 + return _CurveID_name_3 + case i == 25497: + return _CurveID_name_4 + case 65072 <= i && i <= 65074: + i -= 65072 + return _CurveID_name_5[_CurveID_index_5[i]:_CurveID_index_5[i+1]] default: return "CurveID(" + strconv.FormatInt(int64(i), 10) + ")" } @@ -113,8 +150,9 @@ const _ClientAuthType_name = "NoClientCertRequestClientCertRequireAnyClientCertV var _ClientAuthType_index = [...]uint8{0, 12, 29, 49, 72, 98} func (i ClientAuthType) String() string { - if i < 0 || i >= ClientAuthType(len(_ClientAuthType_index)-1) { + idx := int(i) - 0 + if i < 0 || idx >= len(_ClientAuthType_index)-1 { return "ClientAuthType(" + strconv.FormatInt(int64(i), 10) + ")" } - return _ClientAuthType_name[_ClientAuthType_index[i]:_ClientAuthType_index[i+1]] + return _ClientAuthType_name[_ClientAuthType_index[idx]:_ClientAuthType_index[idx+1]] } diff --git a/u_mldsa_test.go b/u_mldsa_test.go new file mode 100644 index 0000000000..3136ad5203 --- /dev/null +++ b/u_mldsa_test.go @@ -0,0 +1,41 @@ +package tls + +import "testing" + +// TestMLDSASignatureSchemeValues checks the codepoints of the ML-DSA schemes. The +// values come from RFC 9881 and agree with Go 1.27 crypto/tls. +func TestMLDSASignatureSchemeValues(t *testing.T) { + for _, tc := range []struct { + scheme SignatureScheme + want uint16 + }{ + {MLDSA44, 0x0904}, + {MLDSA65, 0x0905}, + {MLDSA87, 0x0906}, + } { + if uint16(tc.scheme) != tc.want { + t.Errorf("%v holds the value 0x%04x, but the test expects 0x%04x", + tc.scheme, uint16(tc.scheme), tc.want) + } + } +} + +// TestTypeAndHashFromMLDSAScheme checks that the 3 ML-DSA schemes map to the ML-DSA +// signature type, and that they use no pre-hash. +func TestTypeAndHashFromMLDSAScheme(t *testing.T) { + for _, scheme := range []SignatureScheme{MLDSA44, MLDSA65, MLDSA87} { + sigType, hash, err := typeAndHashFromSignatureScheme(scheme) + if err != nil { + t.Errorf("typeAndHashFromSignatureScheme(%v) gives the error %v", scheme, err) + + continue + } + if sigType != signatureMLDSA { + t.Errorf("%v maps to the signature type %d, but the test expects signatureMLDSA (%d)", + scheme, sigType, signatureMLDSA) + } + if hash != directSigning { + t.Errorf("%v maps to the hash %v, but the test expects directSigning", scheme, hash) + } + } +} From fbff9db49467f7636af2cd31c379fb460fc42e0c Mon Sep 17 00:00:00 2001 From: nihiloid Date: Sat, 29 Aug 2026 15:50:22 +1000 Subject: [PATCH 05/12] feat: verify ML-DSA handshake signatures Add an ML-DSA case to verifyHandshakeSignature. The case calls mldsa.Verify with an empty context. --- auth.go | 8 ++++++++ u_mldsa_test.go | 31 ++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/auth.go b/auth.go index a1bc0fbb1c..bc1f06063f 100644 --- a/auth.go +++ b/auth.go @@ -38,6 +38,14 @@ func verifyHandshakeSignature(sigType uint8, pubkey crypto.PublicKey, hashFunc c if !ed25519.Verify(pubKey, signed, sig) { return errors.New("Ed25519 verification failure") } + case signatureMLDSA: + pubKey, ok := pubkey.(*mldsa.PublicKey) + if !ok { + return fmt.Errorf("expected an ML-DSA public key, got %T", pubkey) + } + if err := mldsa.Verify(pubKey, signed, sig, nil); err != nil { + return fmt.Errorf("ML-DSA verification failure: %w", err) + } case signaturePKCS1v15: pubKey, ok := pubkey.(*rsa.PublicKey) if !ok { diff --git a/u_mldsa_test.go b/u_mldsa_test.go index 3136ad5203..dd27292205 100644 --- a/u_mldsa_test.go +++ b/u_mldsa_test.go @@ -1,6 +1,10 @@ package tls -import "testing" +import ( + "crypto/mldsa" + "crypto/rand" + "testing" +) // TestMLDSASignatureSchemeValues checks the codepoints of the ML-DSA schemes. The // values come from RFC 9881 and agree with Go 1.27 crypto/tls. @@ -39,3 +43,28 @@ func TestTypeAndHashFromMLDSAScheme(t *testing.T) { } } } + +// TestVerifyMLDSAHandshakeSignature signs a message with an ML-DSA key, then verifies +// the signature through the handshake code. The test uses no certificate, because it +// covers the signature step only. +func TestVerifyMLDSAHandshakeSignature(t *testing.T) { + key, err := mldsa.GenerateKey(mldsa.MLDSA65()) + if err != nil { + t.Fatalf("cannot make an ML-DSA key: %v", err) + } + + signed := []byte("the transcript that the CertificateVerify message signs") + signature, err := key.Sign(rand.Reader, signed, &mldsa.Options{}) + if err != nil { + t.Fatalf("cannot sign: %v", err) + } + + if err := verifyHandshakeSignature(signatureMLDSA, key.PublicKey(), directSigning, signed, signature); err != nil { + t.Errorf("the correct signature does not verify: %v", err) + } + + signed[0] ^= 0xff + if err := verifyHandshakeSignature(signatureMLDSA, key.PublicKey(), directSigning, signed, signature); err == nil { + t.Error("a signature over different data verifies, but the test expects an error") + } +} From 043c2602a27e1cc1a31bbc4c9d4f5f2ed18273e9 Mon Sep 17 00:00:00 2001 From: nihiloid Date: Sat, 29 Aug 2026 15:50:53 +1000 Subject: [PATCH 06/12] feat: add a client-only signature algorithm list 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. --- u_mldsa.go | 29 ++++++++++++++++++++++ u_mldsa_test.go | 65 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 u_mldsa.go diff --git a/u_mldsa.go b/u_mldsa.go new file mode 100644 index 0000000000..9d73789575 --- /dev/null +++ b/u_mldsa.go @@ -0,0 +1,29 @@ +package tls + +// clientSupportedSignatureAlgorithms returns the signature algorithms that a client of +// the given maximum version can advertise and can accept. +// +// It adds the ML-DSA schemes to supportedSignatureAlgorithms for TLS 1.3 only, for 2 +// reasons. The ML-DSA codepoints are defined for TLS 1.3 only. And this fork verifies +// ML-DSA signatures for the client role only. +// +// The 3 server call sites of supportedSignatureAlgorithms keep that function. Thus a +// uTLS server does not advertise the ML-DSA codepoints, and rejects an ML-DSA client +// certificate. +// +// Go 1.27 crypto/tls does this differently. It puts ML-DSA in the shared list and +// removes it again in isDisabledSignatureAlgorithm. The base of this fork is older and +// has no such function. +func clientSupportedSignatureAlgorithms(vers uint16) []SignatureScheme { + shared := supportedSignatureAlgorithms() + if vers < VersionTLS13 { + return shared + } + + // Make a new slice. An append to shared can write into the array behind + // defaultSupportedSignatureAlgorithms, which the server call sites read. + algorithms := make([]SignatureScheme, 0, len(shared)+3) + algorithms = append(algorithms, MLDSA44, MLDSA65, MLDSA87) + + return append(algorithms, shared...) +} diff --git a/u_mldsa_test.go b/u_mldsa_test.go index dd27292205..d49f2e4816 100644 --- a/u_mldsa_test.go +++ b/u_mldsa_test.go @@ -68,3 +68,68 @@ func TestVerifyMLDSAHandshakeSignature(t *testing.T) { t.Error("a signature over different data verifies, but the test expects an error") } } + +// holdsScheme reports whether a list holds a scheme. +func holdsScheme(list []SignatureScheme, want SignatureScheme) bool { + for _, s := range list { + if s == want { + return true + } + } + + return false +} + +// TestClientSupportedSignatureAlgorithms checks that a client offers the ML-DSA schemes +// for TLS 1.3 only. The ML-DSA codepoints are defined for TLS 1.3 only. +func TestClientSupportedSignatureAlgorithms(t *testing.T) { + tls13 := clientSupportedSignatureAlgorithms(VersionTLS13) + for _, scheme := range []SignatureScheme{MLDSA44, MLDSA65, MLDSA87} { + if !holdsScheme(tls13, scheme) { + t.Errorf("the TLS 1.3 client list holds no %v", scheme) + } + } + + tls12 := clientSupportedSignatureAlgorithms(VersionTLS12) + for _, scheme := range []SignatureScheme{MLDSA44, MLDSA65, MLDSA87} { + if holdsScheme(tls12, scheme) { + t.Errorf("the TLS 1.2 client list holds %v, but ML-DSA needs TLS 1.3", scheme) + } + } +} + +// TestServerListHoldsNoMLDSA checks the shared list that the 3 server call sites read. +// A uTLS server must not advertise the ML-DSA codepoints, and must not accept an ML-DSA +// client certificate. This test fails if a later change puts ML-DSA in the shared list. +func TestServerListHoldsNoMLDSA(t *testing.T) { + shared := supportedSignatureAlgorithms() + for _, scheme := range []SignatureScheme{MLDSA44, MLDSA65, MLDSA87} { + if holdsScheme(shared, scheme) { + t.Errorf("supportedSignatureAlgorithms holds %v, thus a server advertises it", scheme) + } + // This is the guard at handshake_server_tls13.go:1092. + if isSupportedSignatureAlgorithm(scheme, shared) { + t.Errorf("a server accepts a client certificate that uses %v", scheme) + } + } +} + +// TestClientListDoesNotChangeSharedList checks that the client list makes a new slice. +// An append to the array behind defaultSupportedSignatureAlgorithms would put ML-DSA in +// the server list as well. +func TestClientListDoesNotChangeSharedList(t *testing.T) { + before := append([]SignatureScheme(nil), supportedSignatureAlgorithms()...) + + clientSupportedSignatureAlgorithms(VersionTLS13) + + after := supportedSignatureAlgorithms() + if len(before) != len(after) { + t.Fatalf("the shared list holds %d algorithms after the call, but held %d before", + len(after), len(before)) + } + for i := range before { + if before[i] != after[i] { + t.Errorf("the shared list changed at index %d: %v became %v", i, before[i], after[i]) + } + } +} From 7b912d3b64eaaa1117e16444547fa03308714ffc Mon Sep 17 00:00:00 2001 From: nihiloid Date: Sat, 29 Aug 2026 15:51:34 +1000 Subject: [PATCH 07/12] feat: offer and accept ML-DSA in the client role 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. --- handshake_client.go | 2 +- handshake_client_tls13.go | 2 +- u_handshake_client.go | 2 +- u_mldsa_test.go | 57 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 3 deletions(-) diff --git a/handshake_client.go b/handshake_client.go index bba58a5a9e..cc929c4cdb 100644 --- a/handshake_client.go +++ b/handshake_client.go @@ -136,7 +136,7 @@ func (c *Conn) makeClientHello() (*clientHelloMsg, *keySharePrivateKeys, *echCli } if maxVersion >= VersionTLS12 { - hello.supportedSignatureAlgorithms = supportedSignatureAlgorithms() + hello.supportedSignatureAlgorithms = clientSupportedSignatureAlgorithms(maxVersion) } if testingOnlyForceClientHelloSignatureAlgorithms != nil { hello.supportedSignatureAlgorithms = testingOnlyForceClientHelloSignatureAlgorithms diff --git a/handshake_client_tls13.go b/handshake_client_tls13.go index 01c2756c4c..e1a22592d2 100644 --- a/handshake_client_tls13.go +++ b/handshake_client_tls13.go @@ -850,7 +850,7 @@ func (hs *clientHandshakeStateTLS13) readServerCertificate() error { } // See RFC 8446, Section 4.4.3. - if !isSupportedSignatureAlgorithm(certVerify.signatureAlgorithm, supportedSignatureAlgorithms()) { + if !isSupportedSignatureAlgorithm(certVerify.signatureAlgorithm, clientSupportedSignatureAlgorithms(c.vers)) { c.sendAlert(alertIllegalParameter) return errors.New("tls: certificate used with invalid signature algorithm") } diff --git a/u_handshake_client.go b/u_handshake_client.go index 9928f0c300..13d7039b52 100644 --- a/u_handshake_client.go +++ b/u_handshake_client.go @@ -258,7 +258,7 @@ func (c *Conn) makeClientHelloForApplyPreset() (*clientHelloMsg, *keySharePrivat } if maxVersion >= VersionTLS12 { - hello.supportedSignatureAlgorithms = supportedSignatureAlgorithms() + hello.supportedSignatureAlgorithms = clientSupportedSignatureAlgorithms(maxVersion) } if testingOnlyForceClientHelloSignatureAlgorithms != nil { hello.supportedSignatureAlgorithms = testingOnlyForceClientHelloSignatureAlgorithms diff --git a/u_mldsa_test.go b/u_mldsa_test.go index d49f2e4816..5cf34b897f 100644 --- a/u_mldsa_test.go +++ b/u_mldsa_test.go @@ -3,6 +3,8 @@ package tls import ( "crypto/mldsa" "crypto/rand" + ctls "crypto/tls" + "net" "testing" ) @@ -133,3 +135,58 @@ func TestClientListDoesNotChangeSharedList(t *testing.T) { } } } + +// TestServerCertificateRequestHoldsNoMLDSA runs a uTLS server that asks for a client +// certificate. The CertificateRequest message must hold no ML-DSA codepoint, because +// this fork supports ML-DSA for the client role only. The client is the standard +// library, which reports the advertised list in CertificateRequestInfo. +func TestServerCertificateRequestHoldsNoMLDSA(t *testing.T) { + serverConfig := testConfig.Clone() + serverConfig.MinVersion = VersionTLS13 + serverConfig.MaxVersion = VersionTLS13 + serverConfig.ClientAuth = RequireAnyClientCert + + listener := newLocalListener(t) + defer listener.Close() + + go func() { + serverConn, err := listener.Accept() + if err != nil { + return + } + defer serverConn.Close() + + // The handshake fails, because the client sends an empty certificate. The + // test reads the CertificateRequest message before that point. + Server(serverConn, serverConfig).Handshake() + }() + + clientConn, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer clientConn.Close() + + var offered []uint16 + client := ctls.Client(clientConn, &ctls.Config{ + InsecureSkipVerify: true, + MinVersion: ctls.VersionTLS13, + GetClientCertificate: func(cri *ctls.CertificateRequestInfo) (*ctls.Certificate, error) { + for _, scheme := range cri.SignatureSchemes { + offered = append(offered, uint16(scheme)) + } + + return &ctls.Certificate{}, nil + }, + }) + client.Handshake() + + if len(offered) == 0 { + t.Fatal("the server sent no CertificateRequest message, thus the test proves nothing") + } + for _, scheme := range offered { + if scheme == 0x0904 || scheme == 0x0905 || scheme == 0x0906 { + t.Errorf("the server CertificateRequest message holds the ML-DSA codepoint 0x%04x", scheme) + } + } +} From fe89eb95cdcad353c431b2912087e657808d42aa Mon Sep 17 00:00:00 2001 From: nihiloid Date: Sat, 29 Aug 2026 15:52:06 +1000 Subject: [PATCH 08/12] refactor: name the ML-DSA codepoints in the Chrome 150 profile Replace the raw values 0x0904, 0x0905, and 0x0906 with MLDSA44, MLDSA65, and MLDSA87. The bytes of the ClientHello do not change. --- u_parrots.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/u_parrots.go b/u_parrots.go index 1648f0ac52..73ee23247b 100644 --- a/u_parrots.go +++ b/u_parrots.go @@ -3078,9 +3078,9 @@ func utlsIdToSpec(id ClientHelloID) (ClientHelloSpec, error) { &ALPNExtension{AlpnProtocols: []string{"h2", "http/1.1"}}, &StatusRequestExtension{}, &SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: []SignatureScheme{ - SignatureScheme(0x0904), - SignatureScheme(0x0905), - SignatureScheme(0x0906), + MLDSA44, + MLDSA65, + MLDSA87, ECDSAWithP256AndSHA256, PSSWithSHA256, PKCS1WithSHA256, @@ -3154,9 +3154,9 @@ func utlsIdToSpec(id ClientHelloID) (ClientHelloSpec, error) { &ALPNExtension{AlpnProtocols: []string{"h2", "http/1.1"}}, &StatusRequestExtension{}, &SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: []SignatureScheme{ - SignatureScheme(0x0904), - SignatureScheme(0x0905), - SignatureScheme(0x0906), + MLDSA44, + MLDSA65, + MLDSA87, ECDSAWithP256AndSHA256, PSSWithSHA256, PKCS1WithSHA256, From 58ffaee780580c3c748e1b7518c8cbb9c7161522 Mon Sep 17 00:00:00 2001 From: nihiloid Date: Sat, 29 Aug 2026 15:53:32 +1000 Subject: [PATCH 09/12] feat: accept an ML-DSA server certificate 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. --- handshake_client.go | 6 ++++ u_mldsa_test.go | 86 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/handshake_client.go b/handshake_client.go index cc929c4cdb..2459409046 100644 --- a/handshake_client.go +++ b/handshake_client.go @@ -10,6 +10,7 @@ import ( "crypto" "crypto/ecdsa" "crypto/ed25519" + "crypto/mldsa" "crypto/mlkem" "crypto/rsa" "crypto/subtle" @@ -1220,6 +1221,11 @@ func (c *Conn) verifyServerCertificate(certificates [][]byte) error { switch certs[0].PublicKey.(type) { case *rsa.PublicKey, *ecdsa.PublicKey, ed25519.PublicKey: break + case *mldsa.PublicKey: + if c.vers < VersionTLS13 { + c.sendAlert(alertIllegalParameter) + return errors.New("tls: server's certificate uses ML-DSA, which requires TLS 1.3") + } default: c.sendAlert(alertUnsupportedCertificate) return fmt.Errorf("tls: server's certificate contains an unsupported type of public key: %T", certs[0].PublicKey) diff --git a/u_mldsa_test.go b/u_mldsa_test.go index 5cf34b897f..4bee39adac 100644 --- a/u_mldsa_test.go +++ b/u_mldsa_test.go @@ -4,8 +4,12 @@ import ( "crypto/mldsa" "crypto/rand" ctls "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" "net" "testing" + "time" ) // TestMLDSASignatureSchemeValues checks the codepoints of the ML-DSA schemes. The @@ -190,3 +194,85 @@ func TestServerCertificateRequestHoldsNoMLDSA(t *testing.T) { } } } + +// TestClientVerifiesMLDSAServerCertificate runs a full TLS 1.3 handshake against a +// standard library server that holds an ML-DSA certificate. The uTLS client must verify +// the CertificateVerify message of the server, and crypto/x509 must verify the chain. +// +// The server is the standard library, because the uTLS server cannot sign with ML-DSA. +func TestClientVerifiesMLDSAServerCertificate(t *testing.T) { + key, err := mldsa.GenerateKey(mldsa.MLDSA65()) + if err != nil { + t.Fatalf("cannot make an ML-DSA key: %v", err) + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "mldsa.example"}, + DNSNames: []string{"mldsa.example"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, key.PublicKey(), key) + if err != nil { + t.Fatalf("cannot make an ML-DSA certificate: %v", err) + } + leaf, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("cannot parse the ML-DSA certificate: %v", err) + } + + roots := x509.NewCertPool() + roots.AddCert(leaf) + + listener := newLocalListener(t) + defer listener.Close() + + serverErr := make(chan error, 1) + go func() { + serverConn, err := listener.Accept() + if err != nil { + serverErr <- err + + return + } + defer serverConn.Close() + + server := ctls.Server(serverConn, &ctls.Config{ + Certificates: []ctls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, + MinVersion: ctls.VersionTLS13, + }) + serverErr <- server.Handshake() + }() + + clientConn, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer clientConn.Close() + + client := UClient(clientConn, &Config{ + ServerName: "mldsa.example", + RootCAs: roots, + OmitEmptyPsk: true, + }, HelloChrome_150_PSK) + if err := client.Handshake(); err != nil { + t.Fatalf("the client handshake failed: %v", err) + } + if err := <-serverErr; err != nil { + t.Fatalf("the server handshake failed: %v", err) + } + + state := client.ConnectionState() + if len(state.PeerCertificates) == 0 { + t.Fatal("the connection holds no peer certificate") + } + got := state.PeerCertificates[0].SignatureAlgorithm + if got != x509.MLDSA65 { + t.Errorf("the certificate uses the signature algorithm %v, but the test expects MLDSA65", got) + } +} From e2ee545e62a69f5367039d00fe95ed7c7b99f7a6 Mon Sep 17 00:00:00 2001 From: nihiloid Date: Sat, 29 Aug 2026 16:24:55 +1000 Subject: [PATCH 10/12] test: add connectivity tests against real servers 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. --- u_connectivity_test.go | 285 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 u_connectivity_test.go diff --git a/u_connectivity_test.go b/u_connectivity_test.go new file mode 100644 index 0000000000..3d46fb733b --- /dev/null +++ b/u_connectivity_test.go @@ -0,0 +1,285 @@ +package tls + +import ( + "context" + "crypto/mldsa" + ctls "crypto/tls" + "crypto/x509" + "io" + "net" + "net/http" + "strconv" + "testing" + "time" + + "golang.org/x/net/http2" +) + +// The connectivity test uses a well-known endpoint that answers with status 204 and an +// empty body. A short answer keeps the test independent of page content. +const ( + connectivityHost = "www.google.com" + connectivityAddr = connectivityHost + ":443" + connectivityURL = "https://" + connectivityHost + "/generate_204" + + // Chrome sends this User-Agent with the profile that the test uses. + connectivityUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36" + + connectivityTimeout = 30 * time.Second +) + +// connectivityResult holds what one request of the connectivity test showed. +type connectivityResult struct { + state ConnectionState + statusCode int + bodyLen int +} + +// TestChrome150PSKConnectsToGoogle makes two real connections to www.google.com with +// the HelloChrome_150_PSK profile. The unit tests show that a server in the same +// process accepts the profile. This test shows that a production server, which applies +// its own rules to the ClientHello, also accepts it. +// +// The first connection makes a full handshake and receives a session ticket. The second +// connection sends the pre_shared_key extension of the profile, and the server must +// resume the session. Both connections must receive status 204. +// +// The test needs internet access. Use "go test -short" to skip it. +func TestChrome150PSKConnectsToGoogle(t *testing.T) { + if testing.Short() { + t.Skipf("skip: the test connects to %s", connectivityAddr) + } + + cache := NewLRUClientSessionCache(0) + + first := getGenerate204(t, cache) + if first.state.DidResume { + t.Error("the first connection resumed a session, although the cache was empty") + } + if first.statusCode != http.StatusNoContent { + t.Errorf("the first connection got status %d, but the test expects %d", + first.statusCode, http.StatusNoContent) + } + + second := getGenerate204(t, cache) + if !second.state.DidResume { + t.Error("the second connection did not resume the session, although the cache held a ticket") + } + if second.statusCode != http.StatusNoContent { + t.Errorf("the second connection got status %d, but the test expects %d", + second.statusCode, http.StatusNoContent) + } +} + +// getGenerate204 makes one request to connectivityURL through a new connection. The +// connection uses the HelloChrome_150_PSK profile and the given session cache. The +// cache is the only state that the calls share. +func getGenerate204(t *testing.T, cache ClientSessionCache) connectivityResult { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), connectivityTimeout) + defer cancel() + + var result connectivityResult + + // The profile offers h2 and http/1.1. Google selects h2, thus the request goes + // through an HTTP/2 transport that dials with utls. + transport := &http2.Transport{ + DialTLSContext: func(ctx context.Context, network, addr string, _ *ctls.Config) (net.Conn, error) { + tcpConn, err := (&net.Dialer{}).DialContext(ctx, network, connectivityAddr) + if err != nil { + return nil, err + } + + uconn := UClient(tcpConn, &Config{ + ServerName: connectivityHost, + NextProtos: []string{"h2", "http/1.1"}, + ClientSessionCache: cache, + OmitEmptyPsk: true, + }, HelloChrome_150_PSK) + + if err := uconn.HandshakeContext(ctx); err != nil { + tcpConn.Close() + return nil, err + } + + result.state = uconn.ConnectionState() + return uconn, nil + }, + } + defer transport.CloseIdleConnections() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, connectivityURL, nil) + if err != nil { + t.Fatalf("cannot make the request: %v", err) + } + req.Header.Set("User-Agent", connectivityUserAgent) + + resp, err := transport.RoundTrip(req) + if err != nil { + t.Fatalf("cannot connect to %s: %v (the test needs internet access)", connectivityAddr, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("cannot read the body: %v", err) + } + + result.statusCode = resp.StatusCode + result.bodyLen = len(body) + + t.Logf("%s: version 0x%04x, cipher 0x%04x, alpn %q, resumed %v, status %d, body %d bytes", + connectivityAddr, result.state.Version, result.state.CipherSuite, + result.state.NegotiatedProtocol, result.state.DidResume, + result.statusCode, result.bodyLen) + + return result +} + +// The Open Quantum Safe project runs an NGINX server that holds a separate port for +// each signature and key exchange combination. These 3 ports use ML-DSA server +// authentication. The port numbers come from the table at +// https://test.openquantumsafe.org/, and the server selects X25519MLKEM768 on each one. +const oqsHost = "test.openquantumsafe.org" + +// oqsRootCA is the root of the test server, from https://test.openquantumsafe.org/CA.crt. +// The root uses RSA-4096. The intermediate certificate and the leaf certificate use +// ML-DSA. Thus the chain verification of this test exercises ML-DSA in crypto/x509. +// +// The certificate expires on 30 November 2026. Get the file again after that date. +const oqsRootCA = `-----BEGIN CERTIFICATE----- +MIIFTjCCAzagAwIBAgIUeg1oLTvTiGcNxrOde3kvBXtIAmkwDQYJKoZIhvcNAQEL +BQAwFTETMBEGA1UEAwwKb3FzdGVzdF9DQTAeFw0yNTA3MTgwOTI2NDFaFw0yNjEx +MzAwOTI2NDFaMBUxEzARBgNVBAMMCm9xc3Rlc3RfQ0EwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQDiuz/TmHvGcRRdtLmDV1UWwt6Z9/IBUG54G+WVfq8d +uVYsOoCzh6N85GbQOIbM6raEoepAaqpaKEYoi7UGkVzpWnbOTscWq71lJ+T9t+TF +KXXws2xVjTJCJYrKit1taCgO777wwdfE4JzHGGTiWl/2aXJsMNqQ46kpWMu57TgS +XIpUJo95aEGO3MG56IiO5kyvqAHtfCXJiH3CmGqpqGCK30uXeKBi9fqU6xUzrJsL +Om9djmgbqeun/TG83c5mJ7jMOnCzjl0eaZDoHRZgedSyjOLpAu2JZT4DZly9b+oR +xQg9VSFKrojOvZsDkHyuxoUsTTxU2GnUec8ygUWsw8YdbCUf0OWsYStkOgnqnXnC +8ZgUxHgqlJccXpRY1+b+IfWzkmWWTpD6043d+8J+5nyfWlaTyPlq7zvEyz7kCgmq +FjF2in0/Qw7Xe8JRpr3VzKgzsb+plnj7MCkxLzo/f2A45E2N3VP99jap7EuuyQ/J +FjMOKtaggep0fubT4OjWyicDz6sh/uWDGGWW1YZoI9uX7Xdiky5wCxoUSrRetv/h +PZtExudRS9OGsC57kb9vwQSkwyKcXGnQiIrMjBUe8GUP4yu8umXj6ei10s1TysUB +iSRQW2kNN3StGE6sTDuT5X+Knng6ciOVuW/XrsPP3TklQqBLo+ArGXV2+Q/IW7HJ +twIDAQABo4GVMIGSMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFIGnIxlFxqHD +68PdPBIbGne/LZ+GMFAGA1UdIwRJMEeAFIGnIxlFxqHD68PdPBIbGne/LZ+GoRmk +FzAVMRMwEQYDVQQDDApvcXN0ZXN0X0NBghR6DWgtO9OIZw3Gs517eS8Fe0gCaTAO +BgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQADggIBACQfDyP6jpizi0foDNx0 +evj2vfxNaOxBttOB1mImtUncfTIHFAXG0BldFJJs8NPeyDRn2/7xZ9KpHWTRyIqM +WZfd0fi2bj2+22BGNtmcIAdEuquhP25Mi5N8eT5eBD35Fp5M2ryECpl44R50mDkW +/8Gt+jtIany0ZFKqVZ/0ZsYgC94bx1rp6ZMm67IoxUrG0v7xDTunDOVX+SYVD02E ++ggx3bgUEdFKT9G4NxcZoDAOWiNVj5P5KTv4qoAB6J3pTUFqM9bbjpLLSKh6F752 +fHN/SZBB2aZ0ittVwtgs7NEKqoNpTgfYXX+eAIWnmLU9P6S++xHt1Jvyb/z4hQCn +EeOIm8IzmIqZ07ov1viPtN92Ra1EehikdJTvMMBVA0GttXB4054v6Ro7bfgu04Xr +iICIquMwx4/qxiMFvujP2KMbI2VPhGQi2dhI1ho+Yv9nY9sgc36nWDlQF2OUwrRy +FY4GOve0Dxcwv23oD7GbKgE7fCQ1z78ccLFagzuwTHNPoFL8SrczPJF5/0K8N4GH +5r8GsajDh5AH+plRkq5UObOBh8RLqinUucraF9FCZV6xZ00gqRY43ZqR5VxmH2la +7pxJQCce++0kxgeuFDA9jr5ng8pnYqVSy+vfDy0VseqVq83UwLxtEoGfO7S4Jh/u +gP3YMSSdsj+fvqmrI6j3C6md +-----END CERTIFICATE-----` + +// oqsMLDSAPorts maps each ML-DSA parameter set to its port on the test server. +var oqsMLDSAPorts = []struct { + name string + port int + signature x509.SignatureAlgorithm + parameters mldsa.Parameters +}{ + {"ML-DSA-44", 6184, x509.MLDSA44, mldsa.MLDSA44()}, + {"ML-DSA-65", 6197, x509.MLDSA65, mldsa.MLDSA65()}, + {"ML-DSA-87", 6212, x509.MLDSA87, mldsa.MLDSA87()}, +} + +// TestChrome150PSKConnectsToMLDSAServer makes a real connection to each ML-DSA port of +// the Open Quantum Safe test server with the HelloChrome_150_PSK profile. +// +// The unit tests show that the client verifies ML-DSA against a server in the same +// process. This test shows that the client verifies ML-DSA against an NGINX server that +// uses OpenSSL 3.5 or later. +// +// Each connection proves 3 things. The client offers the ML-DSA codepoints, thus the +// server selects one. The client verifies the CertificateVerify signature of the +// server, which uses the ML-DSA key of the leaf certificate. And crypto/x509 verifies a +// chain in which the intermediate certificate signs the leaf with ML-DSA. +// +// The test needs internet access. Use "go test -short" to skip it. +func TestChrome150PSKConnectsToMLDSAServer(t *testing.T) { + if testing.Short() { + t.Skipf("skip: the test connects to %s", oqsHost) + } + + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM([]byte(oqsRootCA)) { + t.Fatal("cannot read the root certificate of the test server") + } + + for _, target := range oqsMLDSAPorts { + t.Run(target.name, func(t *testing.T) { + address := net.JoinHostPort(oqsHost, strconv.Itoa(target.port)) + + tcpConn, err := net.DialTimeout("tcp", address, connectivityTimeout) + if err != nil { + t.Fatalf("cannot connect to %s: %v (the test needs internet access)", address, err) + } + defer tcpConn.Close() + + uconn := UClient(tcpConn, &Config{ + ServerName: oqsHost, + RootCAs: roots, + OmitEmptyPsk: true, + }, HelloChrome_150_PSK) + + ctx, cancel := context.WithTimeout(context.Background(), connectivityTimeout) + defer cancel() + + if err := uconn.HandshakeContext(ctx); err != nil { + t.Fatalf("the handshake with %s failed: %v", address, err) + } + defer uconn.Close() + + state := uconn.ConnectionState() + if state.Version != VersionTLS13 { + t.Errorf("the connection uses the version 0x%04x, but ML-DSA needs TLS 1.3", state.Version) + } + if state.testingOnlyCurveID != X25519MLKEM768 { + t.Errorf("the connection uses the group %v, but the test expects X25519MLKEM768", + state.testingOnlyCurveID) + } + + if len(state.PeerCertificates) == 0 { + t.Fatal("the connection holds no peer certificate") + } + leaf := state.PeerCertificates[0] + + // The leaf certificate holds an ML-DSA key. RFC 8446 makes the server sign + // the CertificateVerify message with the key of the leaf certificate. Thus + // this check shows that the client verified an ML-DSA signature. + if leaf.PublicKeyAlgorithm != x509.MLDSA { + t.Errorf("the leaf certificate holds a %v key, but the test expects an ML-DSA key", + leaf.PublicKeyAlgorithm) + } + publicKey, ok := leaf.PublicKey.(*mldsa.PublicKey) + if !ok { + t.Fatalf("the leaf certificate holds the key type %T, but the test expects *mldsa.PublicKey", + leaf.PublicKey) + } + if got := publicKey.Parameters().String(); got != target.parameters.String() { + t.Errorf("the leaf key uses the parameter set %s, but the test expects %s", + got, target.parameters.String()) + } + + // The intermediate certificate signs the leaf certificate with ML-DSA. + if leaf.SignatureAlgorithm != target.signature { + t.Errorf("the intermediate certificate signs the leaf with %v, but the test expects %v", + leaf.SignatureAlgorithm, target.signature) + } + + t.Logf("%s: version 0x%04x, cipher 0x%04x, group %v, leaf key %v, leaf signature %v", + address, state.Version, state.CipherSuite, state.testingOnlyCurveID, + publicKey.Parameters(), leaf.SignatureAlgorithm) + }) + } +} From 3070fde8a4c11b1935ed9b02859a1ae3bfc0f075 Mon Sep 17 00:00:00 2001 From: nihiloid Date: Sat, 29 Aug 2026 16:46:59 +1000 Subject: [PATCH 11/12] test: make the reference tests deterministic on Go 1.27 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. --- u_cryptocustomrand_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 u_cryptocustomrand_test.go diff --git a/u_cryptocustomrand_test.go b/u_cryptocustomrand_test.go new file mode 100644 index 0000000000..0ca5aecb09 --- /dev/null +++ b/u_cryptocustomrand_test.go @@ -0,0 +1,13 @@ +// The reference tests replay a recorded transcript from testdata. They compare the +// bytes that the client writes against the recorded bytes. Thus the client must make +// the same ephemeral keys on each run. +// +// Go 1.27 gives the io.Reader of the caller to rand.CustomReader in +// ecdh.GenerateKey. That function returns the system source, and not the reader of the +// caller. Thus the deterministic reader of the tests has no effect, and each run makes +// a different key. The setting cryptocustomrand=1 makes rand.CustomReader give the +// reader of the caller through, which makes the tests deterministic again. +// +//go:debug cryptocustomrand=1 + +package tls From ce27ba16cb2ba43fc0736e3d40e92ae272c4c010 Mon Sep 17 00:00:00 2001 From: nihiloid Date: Sat, 29 Aug 2026 16:46:59 +1000 Subject: [PATCH 12/12] fix: keep ML-DSA out of the default ClientHello 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. --- handshake_client.go | 2 +- u_handshake_client.go | 2 +- u_mldsa.go | 21 +++++++++++++-------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/handshake_client.go b/handshake_client.go index 2459409046..fe1e388366 100644 --- a/handshake_client.go +++ b/handshake_client.go @@ -137,7 +137,7 @@ func (c *Conn) makeClientHello() (*clientHelloMsg, *keySharePrivateKeys, *echCli } if maxVersion >= VersionTLS12 { - hello.supportedSignatureAlgorithms = clientSupportedSignatureAlgorithms(maxVersion) + hello.supportedSignatureAlgorithms = supportedSignatureAlgorithms() } if testingOnlyForceClientHelloSignatureAlgorithms != nil { hello.supportedSignatureAlgorithms = testingOnlyForceClientHelloSignatureAlgorithms diff --git a/u_handshake_client.go b/u_handshake_client.go index 13d7039b52..9928f0c300 100644 --- a/u_handshake_client.go +++ b/u_handshake_client.go @@ -258,7 +258,7 @@ func (c *Conn) makeClientHelloForApplyPreset() (*clientHelloMsg, *keySharePrivat } if maxVersion >= VersionTLS12 { - hello.supportedSignatureAlgorithms = clientSupportedSignatureAlgorithms(maxVersion) + hello.supportedSignatureAlgorithms = supportedSignatureAlgorithms() } if testingOnlyForceClientHelloSignatureAlgorithms != nil { hello.supportedSignatureAlgorithms = testingOnlyForceClientHelloSignatureAlgorithms diff --git a/u_mldsa.go b/u_mldsa.go index 9d73789575..2fbbf06523 100644 --- a/u_mldsa.go +++ b/u_mldsa.go @@ -1,19 +1,24 @@ package tls -// clientSupportedSignatureAlgorithms returns the signature algorithms that a client of -// the given maximum version can advertise and can accept. +// clientSupportedSignatureAlgorithms returns the signature algorithms that a client +// accepts in the CertificateVerify message of a server. // -// It adds the ML-DSA schemes to supportedSignatureAlgorithms for TLS 1.3 only, for 2 -// reasons. The ML-DSA codepoints are defined for TLS 1.3 only. And this fork verifies -// ML-DSA signatures for the client role only. +// It adds the ML-DSA schemes to supportedSignatureAlgorithms for TLS 1.3 only, because +// the ML-DSA codepoints are defined for TLS 1.3 only. +// +// The client does not advertise these schemes in a default ClientHello. Only a profile +// that holds the ML-DSA codepoints in its own signature_algorithms extension offers +// them, such as HelloChrome_150. Thus the bytes of a default ClientHello, and the +// fingerprint of HelloGolang, do not change. The reference tests in +// handshake_client_test.go hold this line. // // The 3 server call sites of supportedSignatureAlgorithms keep that function. Thus a // uTLS server does not advertise the ML-DSA codepoints, and rejects an ML-DSA client // certificate. // -// Go 1.27 crypto/tls does this differently. It puts ML-DSA in the shared list and -// removes it again in isDisabledSignatureAlgorithm. The base of this fork is older and -// has no such function. +// Go 1.27 crypto/tls does this differently. It advertises ML-DSA in the default +// ClientHello, and removes the schemes again in isDisabledSignatureAlgorithm. The base +// of this fork is older and has no such function. func clientSupportedSignatureAlgorithms(vers uint16) []SignatureScheme { shared := supportedSignatureAlgorithms() if vers < VersionTLS13 {