From 64ff0fb1b3cabfebb96a0c080fa5f0585e41dc66 Mon Sep 17 00:00:00 2001 From: nihiloid Date: Sat, 29 Aug 2026 13:35:07 +1000 Subject: [PATCH 1/2] 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 2/2] 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 +}