// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-License-Identifier: GPL-3.0-or-later package tcpecho import ( "crypto/sha256" "encoding/binary" "encoding/hex" "fmt" "sort" "strings" ) // clientHello holds the fields JA4 needs from a parsed TLS ClientHello. type clientHello struct { legacyVersion uint16 cipherSuites []uint16 extensions []uint16 // in wire order hasSNI bool alpns []string supportedVersions []uint16 sigAlgs []uint16 // in wire order } // isGREASE reports whether a code point is a GREASE value (RFC 8701): both // bytes equal and of the form 0x?a. JA4 excludes these everywhere. func isGREASE(v uint16) bool { return v&0x0f0f == 0x0a0a && v>>8 == v&0xff } // parseClientHello parses a full TLS record (starting at the 0x16 record // header) and extracts the ClientHello fields. Returns false if the bytes are // not a well-formed ClientHello. func parseClientHello(rec []byte) (*clientHello, bool) { // Record header: type(1)=0x16, version(2), length(2). if len(rec) < 5 || rec[0] != 0x16 { return nil, false } recLen := int(binary.BigEndian.Uint16(rec[3:5])) if len(rec) < 5+recLen { return nil, false } b := rec[5 : 5+recLen] // Handshake header: msg_type(1)=0x01 ClientHello, length(3). if len(b) < 4 || b[0] != 0x01 { return nil, false } hsLen := int(b[1])<<16 | int(b[2])<<8 | int(b[3]) b = b[4:] if len(b) < hsLen { return nil, false } b = b[:hsLen] h := &clientHello{} // client_version(2), random(32). if len(b) < 34 { return nil, false } h.legacyVersion = binary.BigEndian.Uint16(b[0:2]) b = b[34:] // session_id. if len(b) < 1 || len(b) < 1+int(b[0]) { return nil, false } b = b[1+int(b[0]):] // cipher_suites. if len(b) < 2 { return nil, false } cslen := int(binary.BigEndian.Uint16(b[0:2])) b = b[2:] if len(b) < cslen || cslen%2 != 0 { return nil, false } for i := 0; i < cslen; i += 2 { h.cipherSuites = append(h.cipherSuites, binary.BigEndian.Uint16(b[i:i+2])) } b = b[cslen:] // compression_methods. if len(b) < 1 || len(b) < 1+int(b[0]) { return nil, false } b = b[1+int(b[0]):] // extensions (optional). if len(b) < 2 { return h, true } extTotal := int(binary.BigEndian.Uint16(b[0:2])) b = b[2:] if len(b) < extTotal { return nil, false } ext := b[:extTotal] for len(ext) >= 4 { etype := binary.BigEndian.Uint16(ext[0:2]) elen := int(binary.BigEndian.Uint16(ext[2:4])) if len(ext) < 4+elen { break } data := ext[4 : 4+elen] h.extensions = append(h.extensions, etype) switch etype { case 0x0000: // server_name h.hasSNI = true case 0x0010: // ALPN h.alpns = append(h.alpns, parseALPN(data)...) case 0x002b: // supported_versions h.supportedVersions = parseSupportedVersions(data) case 0x000d: // signature_algorithms h.sigAlgs = parseU16List(data) } ext = ext[4+elen:] } return h, true } func parseALPN(d []byte) []string { if len(d) < 2 { return nil } listLen := int(binary.BigEndian.Uint16(d[0:2])) d = d[2:] if len(d) < listLen { return nil } var out []string for len(d) >= 1 { n := int(d[0]) if len(d) < 1+n { break } out = append(out, string(d[1:1+n])) d = d[1+n:] } return out } func parseSupportedVersions(d []byte) []uint16 { if len(d) < 1 { return nil } n := int(d[0]) d = d[1:] if len(d) < n || n%2 != 0 { return nil } var out []uint16 for i := 0; i < n; i += 2 { out = append(out, binary.BigEndian.Uint16(d[i:i+2])) } return out } // parseU16List parses a 2-byte-length-prefixed list of u16 values (used for // signature_algorithms). func parseU16List(d []byte) []uint16 { if len(d) < 2 { return nil } n := int(binary.BigEndian.Uint16(d[0:2])) d = d[2:] if len(d) < n || n%2 != 0 { return nil } var out []uint16 for i := 0; i < n; i += 2 { out = append(out, binary.BigEndian.Uint16(d[i:i+2])) } return out } // ja4 computes the JA4 TLS client fingerprint (FoxIO spec) from a parsed // ClientHello: a_b_c where a is a human-readable prefix, b hashes the sorted // cipher list, c hashes the sorted extensions + signature algorithms. func ja4(h *clientHello) string { // --- a --- ver := ja4Version(h) sni := "i" if h.hasSNI { sni = "d" } nCiphers := countNonGREASE(h.cipherSuites) nExts := countNonGREASE(h.extensions) // count includes SNI + ALPN alpn := "00" if len(h.alpns) > 0 && h.alpns[0] != "" { a := h.alpns[0] alpn = string(a[0]) + string(a[len(a)-1]) } a := fmt.Sprintf("t%s%s%02d%02d%s", ver, sni, capAt99(nCiphers), capAt99(nExts), alpn) // --- b: sorted non-GREASE cipher suites, lowercase hex, comma-joined --- b := hash12(strings.Join(sortedHex(nonGREASE(h.cipherSuites)), ",")) // --- c: sorted non-GREASE extensions (minus SNI 0000 and ALPN 0010), // then "_", then signature algorithms IN ORDER (non-GREASE) --- extsForC := filterOut(nonGREASE(h.extensions), 0x0000, 0x0010) cInput := strings.Join(sortedHex(extsForC), ",") + "_" + strings.Join(hexList(nonGREASE(h.sigAlgs)), ",") c := hash12(cInput) return a + "_" + b + "_" + c } // ja4Version picks the highest offered version (supported_versions if present, // else the legacy field) mapped to JA4's two-char code. func ja4Version(h *clientHello) string { best := h.legacyVersion for _, v := range h.supportedVersions { if isGREASE(v) { continue } if v > best { best = v } } switch best { case 0x0304: return "13" case 0x0303: return "12" case 0x0302: return "11" case 0x0301: return "10" case 0x0300: return "s3" } return "00" } func nonGREASE(in []uint16) []uint16 { out := make([]uint16, 0, len(in)) for _, v := range in { if !isGREASE(v) { out = append(out, v) } } return out } func countNonGREASE(in []uint16) int { return len(nonGREASE(in)) } func filterOut(in []uint16, drop ...uint16) []uint16 { out := make([]uint16, 0, len(in)) for _, v := range in { skip := false for _, d := range drop { if v == d { skip = true } } if !skip { out = append(out, v) } } return out } func sortedHex(in []uint16) []string { cp := append([]uint16(nil), in...) sort.Slice(cp, func(i, j int) bool { return cp[i] < cp[j] }) return hexList(cp) } func hexList(in []uint16) []string { out := make([]string, len(in)) for i, v := range in { var b [2]byte binary.BigEndian.PutUint16(b[:], v) out[i] = hex.EncodeToString(b[:]) } return out } func hash12(s string) string { sum := sha256.Sum256([]byte(s)) return hex.EncodeToString(sum[:])[:12] } func capAt99(n int) int { if n > 99 { return 99 } return n }