Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions ja4plus.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package ja4plus
import (
"crypto/sha256"
"crypto/tls"
"encoding/binary"
"encoding/hex"
"fmt"
"slices"
Expand Down Expand Up @@ -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')
Expand All @@ -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)
}
Expand All @@ -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))
Expand Down
27 changes: 21 additions & 6 deletions ja4plus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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},
Expand Down