mic.git


mic

~ proxy for JA4-TLS fingerprinting evasion
git clone https://github.com/djnnvx/mic.git
log | files | refs | archive | README | LICENSE

ja4s.go (4576B)

      1 package fingerprint
      2 
      3 import (
      4 	"crypto/sha256"
      5 	"encoding/binary"
      6 	"encoding/hex"
      7 	"fmt"
      8 	"strings"
      9 )
     10 
     11 // ServerHelloFields holds the parsed TLS ServerHello data used to compute JA4S.
     12 type ServerHelloFields struct {
     13 	LegacyVersion    uint16
     14 	CipherSuite      uint16   // single chosen suite
     15 	Extensions       []uint16 // non-GREASE extension types, wire order
     16 	SupportedVersion uint16   // from ext 0x002b, single chosen version (0 if absent)
     17 	ALPN             string   // from ext 0x0010, single chosen protocol ("" if absent)
     18 }
     19 
     20 // ParseServerHello parses the first TLS handshake record from raw, expecting
     21 // a ServerHello. raw must start with a handshake record header (content type 0x16).
     22 func ParseServerHello(raw []byte) (*ServerHelloFields, error) {
     23 	r, err := handshakeBody(raw, 0x02, "ja4s")
     24 	if err != nil {
     25 		return nil, err
     26 	}
     27 
     28 	fields := &ServerHelloFields{}
     29 
     30 	if len(r) < 2 {
     31 		return nil, fmt.Errorf("ja4s: too short for legacy version")
     32 	}
     33 	fields.LegacyVersion = binary.BigEndian.Uint16(r[:2])
     34 	r = r[2:]
     35 
     36 	if len(r) < 32 {
     37 		return nil, fmt.Errorf("ja4s: too short for random")
     38 	}
     39 	r = r[32:]
     40 
     41 	if len(r) < 1 {
     42 		return nil, fmt.Errorf("ja4s: too short for session_id_echo length")
     43 	}
     44 	sidLen := int(r[0])
     45 	r = r[1:]
     46 	if len(r) < sidLen {
     47 		return nil, fmt.Errorf("ja4s: too short for session_id_echo")
     48 	}
     49 	r = r[sidLen:]
     50 
     51 	if len(r) < 2 {
     52 		return nil, fmt.Errorf("ja4s: too short for cipher suite")
     53 	}
     54 	fields.CipherSuite = binary.BigEndian.Uint16(r[:2])
     55 	r = r[2:]
     56 
     57 	if len(r) < 1 {
     58 		return nil, fmt.Errorf("ja4s: too short for compression method")
     59 	}
     60 	r = r[1:]
     61 
     62 	// Pre-TLS-1.3 ServerHellos may omit the extensions block entirely.
     63 	if len(r) < 2 {
     64 		return fields, nil
     65 	}
     66 	extsLen := int(binary.BigEndian.Uint16(r[:2]))
     67 	r = r[2:]
     68 	if len(r) < extsLen {
     69 		return nil, fmt.Errorf("ja4s: extensions block truncated")
     70 	}
     71 	exts := r[:extsLen]
     72 
     73 	for len(exts) >= 4 {
     74 		extType := binary.BigEndian.Uint16(exts[:2])
     75 		extLen := int(binary.BigEndian.Uint16(exts[2:4]))
     76 		exts = exts[4:]
     77 		if len(exts) < extLen {
     78 			return nil, fmt.Errorf("ja4s: extension 0x%04x data truncated", extType)
     79 		}
     80 		extData := exts[:extLen]
     81 		exts = exts[extLen:]
     82 
     83 		if isGREASE(extType) {
     84 			continue
     85 		}
     86 		fields.Extensions = append(fields.Extensions, extType)
     87 
     88 		switch extType {
     89 		case 0x002b:
     90 			if len(extData) >= 2 {
     91 				if v := binary.BigEndian.Uint16(extData[:2]); !isGREASE(v) {
     92 					fields.SupportedVersion = v
     93 				}
     94 			}
     95 		case 0x0010:
     96 			parseServerALPN(extData, fields)
     97 		}
     98 	}
     99 
    100 	return fields, nil
    101 }
    102 
    103 // parseServerALPN reads the single protocol the server selected. Wire format
    104 // matches ClientHello, but the server lists exactly one.
    105 func parseServerALPN(data []byte, f *ServerHelloFields) {
    106 	if len(data) < 3 {
    107 		return
    108 	}
    109 	listLen := int(binary.BigEndian.Uint16(data[:2]))
    110 	data = data[2:]
    111 	if len(data) < listLen || listLen < 1 {
    112 		return
    113 	}
    114 	pLen := int(data[0])
    115 	if 1+pLen > listLen || len(data) < 1+pLen {
    116 		return
    117 	}
    118 	f.ALPN = string(data[1 : 1+pLen])
    119 }
    120 
    121 // ComputeJA4S builds the JA4S hash from a parsed ServerHello.
    122 // Format: t<version><nn><alpn>_<cipher>_<exthash>
    123 // Always "t" for TCP; QUIC is not produced by mic.
    124 func ComputeJA4S(sh *ServerHelloFields) string {
    125 	return buildJA4Sa(sh) + "_" + fmt.Sprintf("%04x", sh.CipherSuite) + "_" + buildJA4Sc(sh.Extensions)
    126 }
    127 
    128 func buildJA4Sa(sh *ServerHelloFields) string {
    129 	ver := tlsVersionStr(sh.LegacyVersion)
    130 	if sh.SupportedVersion != 0 {
    131 		ver = tlsVersionStr(sh.SupportedVersion)
    132 	}
    133 
    134 	nn := min99(len(sh.Extensions))
    135 
    136 	return fmt.Sprintf("t%s%02d%s", ver, nn, ja4sALPNCode(sh.ALPN))
    137 }
    138 
    139 // ja4sALPNCode encodes the negotiated ALPN as the JA4 two-character code: first
    140 // and last byte of the value. If either end byte is not ASCII alphanumeric,
    141 // hex-encode the whole value and take the first and last hex characters instead.
    142 func ja4sALPNCode(v string) string {
    143 	if v == "" {
    144 		return "00"
    145 	}
    146 	b := []byte(v)
    147 	first, last := b[0], b[len(b)-1]
    148 	if !ja4sIsAlnum(first) || !ja4sIsAlnum(last) {
    149 		h := hex.EncodeToString(b)
    150 		return string(h[0]) + string(h[len(h)-1])
    151 	}
    152 	return string(first) + string(last)
    153 }
    154 
    155 func ja4sIsAlnum(c byte) bool {
    156 	return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
    157 }
    158 
    159 // buildJA4Sc hashes ServerHello extension types in wire order. JA4_c sorts, this
    160 // must not.
    161 func buildJA4Sc(exts []uint16) string {
    162 	if len(exts) == 0 {
    163 		return "000000000000"
    164 	}
    165 	parts := make([]string, len(exts))
    166 	for i, e := range exts {
    167 		parts[i] = fmt.Sprintf("%04x", e)
    168 	}
    169 	h := sha256.Sum256([]byte(strings.Join(parts, ",")))
    170 	return fmt.Sprintf("%x", h)[:12]
    171 }