Files
Ronni Skansing 07c8adaf76 update vendor deps
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2025-11-06 23:31:08 +01:00

155 lines
4.9 KiB
Go

package ja4plus
import (
"crypto/sha256"
"crypto/tls"
"encoding/binary"
"encoding/hex"
"fmt"
"slices"
"strings"
)
// greaseFilter returns true if the provided value is a GREASE entry as defined in
// https://www.rfc-editor.org/rfc/rfc8701.html
func greaseFilter(suite uint16) bool {
return suite&0x000F == 0x000A && // low word is 0x*A
suite>>8 == (suite&0x00FF) // high word is equal to low word
}
// JA4 generates a JA4 fingerprint from the given [tls.ClientHelloInfo].
// It extracts TLS Version, Cipher Suites, Extensions, and ALPN Protocols.
func JA4(hello *tls.ClientHelloInfo) string {
out := make([]byte, 0, 36)
// Determine protocol type based on the network type
if hello.Conn != nil {
switch hello.Conn.LocalAddr().Network() {
case "udp", "sctp":
out = append(out, 'd')
case "quic":
out = append(out, 'q')
default:
out = append(out, 't')
}
} else {
out = append(out, 't')
}
// 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:
out = append(out, '0', '0')
}
// Check for presence of SNI
if hello.ServerName != "" {
out = append(out, 'd')
} else {
out = append(out, 'i')
}
// 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))
// 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))
// Extract first ALPN value
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')
}
out = append(out, '_')
out = hex.AppendEncode(out, cipherSuiteHash(filteredCipherSuites))
out = append(out, '_')
out = hex.AppendEncode(out, extensionHash(filteredExtensions, hello.SignatureSchemes))
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))
}
cipherSuiteHash := sha256.Sum256([]byte(strings.Join(cipherSuiteList, ",")))
return cipherSuiteHash[:6]
} else {
return []byte{0, 0, 0, 0, 0, 0}
}
}
// 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 {
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
}
extensionsList = append(extensionsList, fmt.Sprintf("%04x", ext))
}
if len(extensionsList) == 0 {
return []byte{0, 0, 0, 0, 0, 0}
}
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)))
}
extensionsListRendered += "_" + strings.Join(signatureSchemeList, ",")
}
extensionsHash := sha256.Sum256([]byte(extensionsListRendered))
return extensionsHash[:6]
}