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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:

strategy:
matrix:
go-version: [1.24]
go-version: [1.24, 1.25]

steps:
- name: Checkout code
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.aider*

*.bench
*.tmp
3 changes: 2 additions & 1 deletion examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,6 @@ func ExampleJA4() {
return
}
defer resp.Body.Close()
// Output: t13i1310h2_f57a46bbacb6_e7c285222651
// Note this may change with different go versions (caused by e.g. changes in default cipher suites)
// Output: t13i1311h2_f57a46bbacb6_e5728521abd4
}
165 changes: 109 additions & 56 deletions ja4plus.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ import (
"crypto/tls"
"encoding/binary"
"encoding/hex"
"fmt"
"slices"
"strings"
)

// greaseFilter returns true if the provided value is a GREASE entry as defined in
Expand Down Expand Up @@ -37,28 +35,44 @@ func JA4(hello *tls.ClientHelloInfo) string {
}

// Extract TLS version
supportedVersions := slices.DeleteFunc(slices.Sorted(slices.Values(hello.SupportedVersions)), greaseFilter)
switch supportedVersions[len(supportedVersions)-1] {
case tls.VersionTLS10:
out = append(out, '1', '0')
case tls.VersionTLS11:
out = append(out, '1', '1')
case tls.VersionTLS12:
out = append(out, '1', '2')
case tls.VersionTLS13:
out = append(out, '1', '3')
case tls.VersionSSL30: // deprecated, but still seen in the wild
out = append(out, 's', '3')
case 0x0002: // unsupported by go; still seen in the wild
out = append(out, 's', '2')
case 0xfeff: // DTLS 1.0
out = append(out, 'd', '1')
case 0xfefd: // DTLS 1.2
out = append(out, 'd', '2')
case 0xfefc: // DTLS 1.3
out = append(out, 'd', '3')
default:
var (
maxVersion uint16
hasVersion bool
)
for _, version := range hello.SupportedVersions {
if greaseFilter(version) {
continue
}
if !hasVersion || version > maxVersion {
maxVersion = version
hasVersion = true
}
}
if !hasVersion {
out = append(out, '0', '0')
} else {
switch maxVersion {
case tls.VersionTLS10:
out = append(out, '1', '0')
case tls.VersionTLS11:
out = append(out, '1', '1')
case tls.VersionTLS12:
out = append(out, '1', '2')
case tls.VersionTLS13:
out = append(out, '1', '3')
case tls.VersionSSL30: // deprecated, but still seen in the wild
out = append(out, 's', '3')
case 0x0002: // unsupported by go; still seen in the wild
out = append(out, 's', '2')
case 0xfeff: // DTLS 1.0
out = append(out, 'd', '1')
case 0xfefd: // DTLS 1.2
out = append(out, 'd', '2')
case 0xfefc: // DTLS 1.3
out = append(out, 'd', '3')
default:
out = append(out, '0', '0')
}
}

// Check for presence of SNI
Expand All @@ -69,12 +83,24 @@ func JA4(hello *tls.ClientHelloInfo) string {
}

// Count cipher suites; copy to avoid modifying the original
filteredCipherSuites := slices.DeleteFunc(slices.Clone(hello.CipherSuites), greaseFilter)
out = fmt.Appendf(out, "%02d", min(len(filteredCipherSuites), 99))
filteredCipherSuites := make([]uint16, 0, len(hello.CipherSuites))
for _, suite := range hello.CipherSuites {
if !greaseFilter(suite) {
filteredCipherSuites = append(filteredCipherSuites, suite)
}
}
cipherCount := min(len(filteredCipherSuites), 99)
out = appendTwoDigits(out, cipherCount)

// Count extensions; copy to avoid modifying the original
filteredExtensions := slices.DeleteFunc(slices.Clone(hello.Extensions), greaseFilter)
out = fmt.Appendf(out, "%02d", min(len(filteredExtensions), 99))
filteredExtensions := make([]uint16, 0, len(hello.Extensions))
for _, ext := range hello.Extensions {
if !greaseFilter(ext) {
filteredExtensions = append(filteredExtensions, ext)
}
}
extensionCount := min(len(filteredExtensions), 99)
out = appendTwoDigits(out, extensionCount)

// Extract first ALPN value
var firstALPN string
Expand All @@ -94,61 +120,88 @@ func JA4(hello *tls.ClientHelloInfo) string {

out = append(out, '_')

out = hex.AppendEncode(out, cipherSuiteHash(filteredCipherSuites))
ciphersHash := cipherSuiteHash(filteredCipherSuites)
out = hex.AppendEncode(out, ciphersHash[:])

out = append(out, '_')

out = hex.AppendEncode(out, extensionHash(filteredExtensions, hello.SignatureSchemes))
extensionsHash := extensionHash(filteredExtensions, hello.SignatureSchemes)
out = hex.AppendEncode(out, extensionsHash[:])

return string(out)
}

// cipherSuiteHash computes the truncated SHA256 of sorted cipher suites.
// The input must be filtered for GREASE values.
// The return value is an unencoded byte slice of the hash.
func cipherSuiteHash(filteredCipherSuites []uint16) []byte {
if len(filteredCipherSuites) > 0 {
slices.Sort(filteredCipherSuites)
cipherSuiteList := make([]string, 0, len(filteredCipherSuites))
for _, suite := range filteredCipherSuites {
cipherSuiteList = append(cipherSuiteList, fmt.Sprintf("%04x", suite))
// The return value is an unencoded byte array of the hash.
func cipherSuiteHash(filteredCipherSuites []uint16) [6]byte {
if len(filteredCipherSuites) == 0 {
return [6]byte{}
}
slices.Sort(filteredCipherSuites)
cipherSuiteList := make([]byte, 0, len(filteredCipherSuites) /* 4 chars + comma */ *5 /* last comma */ -1)
for i, suite := range filteredCipherSuites {
if i > 0 {
cipherSuiteList = append(cipherSuiteList, ',')
}
cipherSuiteHash := sha256.Sum256([]byte(strings.Join(cipherSuiteList, ",")))
return cipherSuiteHash[:6]
} else {
return []byte{0, 0, 0, 0, 0, 0}
cipherSuiteList = appendHexUint16(cipherSuiteList, suite)
}
cipherSuiteHash := sha256.Sum256(cipherSuiteList)
var truncated [6]byte
copy(truncated[:], cipherSuiteHash[:6])
return truncated
}

// 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(filteredExtensions []uint16, signatureSchemes []tls.SignatureScheme) []byte {
// The return value is an unencoded byte array of the hash.
func extensionHash(filteredExtensions []uint16, signatureSchemes []tls.SignatureScheme) [6]byte {
slices.Sort(filteredExtensions)
extensionsList := make([]string, 0, len(filteredExtensions))
extensionsList := make([]byte, 0, len(filteredExtensions)*5+len(signatureSchemes)*5+1)
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
}
extensionsList = append(extensionsList, fmt.Sprintf("%04x", ext))
if len(extensionsList) > 0 {
extensionsList = append(extensionsList, ',')
}
extensionsList = appendHexUint16(extensionsList, ext)
}
if len(extensionsList) == 0 {
return []byte{0, 0, 0, 0, 0, 0}
return [6]byte{}
}

extensionsListRendered := strings.Join(extensionsList, ",")
if len(signatureSchemes) > 0 {
signatureSchemeList := make([]string, 0, len(signatureSchemes))
for _, sig := range signatureSchemes {
if greaseFilter(uint16(sig)) {
continue
}
signatureSchemeList = append(signatureSchemeList, fmt.Sprintf("%04x", uint16(sig)))
hasSignature := false
for _, sig := range signatureSchemes {
if greaseFilter(uint16(sig)) {
continue
}
extensionsListRendered += "_" + strings.Join(signatureSchemeList, ",")
if !hasSignature {
extensionsList = append(extensionsList, '_')
hasSignature = true
} else {
extensionsList = append(extensionsList, ',')
}
extensionsList = appendHexUint16(extensionsList, uint16(sig))
}
extensionsHash := sha256.Sum256([]byte(extensionsListRendered))
return extensionsHash[:6]
extensionsHash := sha256.Sum256(extensionsList)
var truncated [6]byte
copy(truncated[:], extensionsHash[:6])
return truncated
}

func appendTwoDigits(dst []byte, v int) []byte {
return append(dst, byte('0'+v/10), byte('0'+v%10))
}

func appendHexUint16(dst []byte, v uint16) []byte {
const hex = "0123456789abcdef"
return append(dst,
hex[v>>12],
hex[(v>>8)&0xF],
hex[(v>>4)&0xF],
hex[v&0xF],
)
}
6 changes: 3 additions & 3 deletions ja4plus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,9 @@ func TestExtensionHash(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hash := hex.EncodeToString(extensionHash(tt.extensions, tt.signatureSchemes))
if hash != tt.expected {
t.Errorf("Expected %s, but got %s", tt.expected, hash)
hash := extensionHash(tt.extensions, tt.signatureSchemes)
if got := hex.EncodeToString(hash[:]); got != tt.expected {
t.Errorf("Expected %s, but got %s", tt.expected, got)
}
})
}
Expand Down