From c66a76448fdce0bc56dd2ae7f9a833c355aacb18 Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Fri, 18 Jul 2025 12:05:01 +0200 Subject: [PATCH 1/2] feat: skip cloning extensions for hash calculation --- ja4plus.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ja4plus.go b/ja4plus.go index 1270d18..488d084 100644 --- a/ja4plus.go +++ b/ja4plus.go @@ -89,7 +89,7 @@ func JA4(hello *tls.ClientHelloInfo) string { out = append(out, '_') - out = hex.AppendEncode(out, extensionHash(hello.Extensions, hello.SignatureSchemes)) + out = hex.AppendEncode(out, extensionHash(filteredExtensions, hello.SignatureSchemes)) return string(out) } @@ -112,14 +112,14 @@ func cipherSuiteHash(filteredCipherSuites []uint16) []byte { } // extensionHash computes the truncated SHA256 of sorted extensions and unsorted signature algorithms. +// It sorts the provided extensions in-place. // The return value is an unencoded byte slice of the hash. func extensionHash(extensions []uint16, signatureSchemes []tls.SignatureScheme) []byte { - sortedExtensions := slices.Clone(extensions) - slices.Sort(sortedExtensions) - extensionsList := make([]string, 0, len(sortedExtensions)) - for _, ext := range sortedExtensions { + slices.Sort(extensions) + extensionsList := make([]string, 0, len(extensions)) + for _, ext := range extensions { // SNI and ALPN are counted above, but MUST be ignored for the hash. - if greaseFilter(ext) || ext == 0x0000 /* SNI */ || ext == 0x0010 /* ALPN */ { + if ext == 0x0000 /* SNI */ || ext == 0x0010 /* ALPN */ { continue } extensionsList = append(extensionsList, fmt.Sprintf("%04x", ext)) From 3939be06dca3c882ae7fc089bf416d1c858c4aff Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Fri, 18 Jul 2025 12:05:12 +0200 Subject: [PATCH 2/2] feat: also filter grease in SupportedProtos --- ja4plus.go | 24 +++++++++++++++++------- ja4plus_test.go | 27 +++++++++++++++++++++------ 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/ja4plus.go b/ja4plus.go index 488d084..76ed938 100644 --- a/ja4plus.go +++ b/ja4plus.go @@ -3,6 +3,7 @@ package ja4plus import ( "crypto/sha256" "crypto/tls" + "encoding/binary" "encoding/hex" "fmt" "slices" @@ -76,8 +77,16 @@ func JA4(hello *tls.ClientHelloInfo) string { out = fmt.Appendf(out, "%02d", min(len(filteredExtensions), 99)) // Extract first ALPN value - if len(hello.SupportedProtos) > 0 { - firstALPN := hello.SupportedProtos[0] + var firstALPN string + for _, proto := range hello.SupportedProtos { + // Protocols are tecnically strings, but grease values are 2-byte non-printable, so we convert. + // see: https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#alpn-protocol-ids + if len(proto) >= 2 && !greaseFilter(binary.BigEndian.Uint16([]byte(proto[:2]))) { + firstALPN = proto + break + } + } + if firstALPN != "" { out = append(out, firstALPN[0], firstALPN[len(firstALPN)-1]) } else { out = append(out, '0', '0') @@ -111,13 +120,14 @@ func cipherSuiteHash(filteredCipherSuites []uint16) []byte { } } -// extensionHash computes the truncated SHA256 of sorted extensions and unsorted signature algorithms. +// extensionHash computes the truncated SHA256 of sorted and filtered extensions and unsorted signature algorithms. +// The provided extensions must be filtered for GREASE values. // It sorts the provided extensions in-place. // The return value is an unencoded byte slice of the hash. -func extensionHash(extensions []uint16, signatureSchemes []tls.SignatureScheme) []byte { - slices.Sort(extensions) - extensionsList := make([]string, 0, len(extensions)) - for _, ext := range extensions { +func extensionHash(filteredExtensions []uint16, signatureSchemes []tls.SignatureScheme) []byte { + slices.Sort(filteredExtensions) + extensionsList := make([]string, 0, len(filteredExtensions)) + for _, ext := range filteredExtensions { // SNI and ALPN are counted above, but MUST be ignored for the hash. if ext == 0x0000 /* SNI */ || ext == 0x0010 /* ALPN */ { continue diff --git a/ja4plus_test.go b/ja4plus_test.go index 6739b98..19b97b8 100644 --- a/ja4plus_test.go +++ b/ja4plus_test.go @@ -75,6 +75,27 @@ func TestJA4(t *testing.T) { }, expected: "t13i0000h1_000000000000_000000000000", }, + { + name: "ClientHelloInfo with cipher suites, extensions and signature schemes, all greased", + hello: &tls.ClientHelloInfo{ + SupportedVersions: []uint16{0x1A1A /* GREASE */, tls.VersionTLS13}, + SupportedProtos: []string{string([]byte{0x1A, 0x1A}) /* GREASE */, "http/1.1"}, + CipherSuites: []uint16{tls.TLS_AES_128_GCM_SHA256, tls.TLS_AES_256_GCM_SHA384}, + Extensions: []uint16{0x0000 /* SNI */, 0x1A1A /* GREASE */, 0x0042 /* "early data" */}, + SignatureSchemes: []tls.SignatureScheme{0x1A1A /* GREASE */, tls.PKCS1WithSHA256, tls.ECDSAWithP256AndSHA256}, + SupportedCurves: []tls.CurveID{tls.CurveP256}, + }, + expected: "t13i0202h1_62ed6f6ca7ad_5b56ea7744b1", + }, + { + // the TLS stack should not allow this, but we ensure we're defensive. + name: "Do not panic on invalid proto version", + hello: &tls.ClientHelloInfo{ + SupportedVersions: []uint16{tls.VersionTLS13}, + SupportedProtos: []string{"a"}, // invalid! + }, + expected: "t13i000000_000000000000_000000000000", + }, } for _, tt := range tests { @@ -143,12 +164,6 @@ func TestExtensionHash(t *testing.T) { signatureSchemes: []tls.SignatureScheme{}, expected: "000000000000", }, - { - name: "Only GREASE extensions", - extensions: []uint16{0x0A0A, 0x1A1A}, - signatureSchemes: []tls.SignatureScheme{}, - expected: "000000000000", - }, { name: "Extensions with SNI and ALPN", extensions: []uint16{0x0000, 0x0010},