diff --git a/ja4plus.go b/ja4plus.go index 1270d18..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') @@ -89,7 +98,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) } @@ -111,15 +120,16 @@ 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 { - sortedExtensions := slices.Clone(extensions) - slices.Sort(sortedExtensions) - extensionsList := make([]string, 0, len(sortedExtensions)) - for _, ext := range sortedExtensions { +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 greaseFilter(ext) || ext == 0x0000 /* SNI */ || ext == 0x0010 /* ALPN */ { + if ext == 0x0000 /* SNI */ || ext == 0x0010 /* ALPN */ { continue } extensionsList = append(extensionsList, fmt.Sprintf("%04x", ext)) 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},