diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ada0208..3c84e00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: - go-version: [1.24] + go-version: [1.24, 1.25] steps: - name: Checkout code diff --git a/.gitignore b/.gitignore index d4554c4..771b8e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .aider* +*.bench *.tmp \ No newline at end of file diff --git a/examples_test.go b/examples_test.go index b818193..1cc6202 100644 --- a/examples_test.go +++ b/examples_test.go @@ -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 } diff --git a/ja4plus.go b/ja4plus.go index 76ed938..aedc1c9 100644 --- a/ja4plus.go +++ b/ja4plus.go @@ -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 @@ -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 @@ -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 @@ -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], + ) } diff --git a/ja4plus_test.go b/ja4plus_test.go index 19b97b8..cb399a8 100644 --- a/ja4plus_test.go +++ b/ja4plus_test.go @@ -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) } }) }