mic.git


mic

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

ja4.go (9068B)

      1 package fingerprint
      2 
      3 import (
      4 	"crypto/sha256"
      5 	"encoding/binary"
      6 	"encoding/hex"
      7 	"fmt"
      8 	"slices"
      9 	"strings"
     10 )
     11 
     12 // noValues is the JA4 placeholder for an empty field. The spec uses it instead
     13 // of sha256 of the empty string.
     14 const noValues = "000000000000"
     15 
     16 // ClientHelloFields holds the parsed TLS ClientHello data used to compute JA4.
     17 type ClientHelloFields struct {
     18 	LegacyVersion     uint16
     19 	CipherSuites      []uint16 // non-GREASE, wire order
     20 	Extensions        []uint16 // non-GREASE extension types, wire order
     21 	SNIHost           string
     22 	SupportedVersions []uint16 // from ext 0x002b, non-GREASE
     23 	ALPNValues        []string // from ext 0x0010
     24 	SigAlgs           []uint16 // from ext 0x000d, wire order
     25 }
     26 
     27 // isGREASE reports whether v is a GREASE value (RFC 8701).
     28 // The pattern: high byte == low byte, low nibble of each == 0xA.
     29 func isGREASE(v uint16) bool {
     30 	return v&0x0f == 0x0a && v>>8 == v&0xff
     31 }
     32 
     33 // handshakeBody unwraps the TLS record and handshake headers from raw, verifying
     34 // content type, handshake type and lengths. tag prefixes errors ("ja4" or "ja4s").
     35 func handshakeBody(raw []byte, hsType byte, tag string) ([]byte, error) {
     36 	if len(raw) < 5 {
     37 		return nil, fmt.Errorf("%s: record too short (%d bytes)", tag, len(raw))
     38 	}
     39 	if raw[0] != 0x16 {
     40 		return nil, fmt.Errorf("%s: not a handshake record (type=0x%02x)", tag, raw[0])
     41 	}
     42 	recLen := int(binary.BigEndian.Uint16(raw[3:5]))
     43 	if len(raw) < 5+recLen {
     44 		return nil, fmt.Errorf("%s: record body truncated (need %d, have %d)", tag, 5+recLen, len(raw))
     45 	}
     46 	hs := raw[5 : 5+recLen]
     47 
     48 	if len(hs) < 4 {
     49 		return nil, fmt.Errorf("%s: handshake header too short", tag)
     50 	}
     51 	if hs[0] != hsType {
     52 		return nil, fmt.Errorf("%s: unexpected handshake type 0x%02x (want 0x%02x)", tag, hs[0], hsType)
     53 	}
     54 	// Handshake length is 3 bytes, unlike most other TLS length fields.
     55 	bodyLen := int(hs[1])<<16 | int(hs[2])<<8 | int(hs[3])
     56 	if len(hs) < 4+bodyLen {
     57 		return nil, fmt.Errorf("%s: handshake body truncated", tag)
     58 	}
     59 	return hs[4 : 4+bodyLen], nil
     60 }
     61 
     62 // ParseClientHello parses the first TLS handshake record from raw.
     63 // raw must start with a handshake record header (content type 0x16).
     64 func ParseClientHello(raw []byte) (*ClientHelloFields, error) {
     65 	r, err := handshakeBody(raw, 0x01, "ja4")
     66 	if err != nil {
     67 		return nil, err
     68 	}
     69 
     70 	fields := &ClientHelloFields{}
     71 
     72 	if len(r) < 2 {
     73 		return nil, fmt.Errorf("ja4: too short for legacy version")
     74 	}
     75 	fields.LegacyVersion = binary.BigEndian.Uint16(r[:2])
     76 	r = r[2:]
     77 
     78 	if len(r) < 32 {
     79 		return nil, fmt.Errorf("ja4: too short for random")
     80 	}
     81 	r = r[32:]
     82 
     83 	if len(r) < 1 {
     84 		return nil, fmt.Errorf("ja4: too short for session ID length")
     85 	}
     86 	sidLen := int(r[0])
     87 	r = r[1:]
     88 	if len(r) < sidLen {
     89 		return nil, fmt.Errorf("ja4: too short for session ID")
     90 	}
     91 	r = r[sidLen:]
     92 
     93 	if len(r) < 2 {
     94 		return nil, fmt.Errorf("ja4: too short for cipher suites length")
     95 	}
     96 	csLen := int(binary.BigEndian.Uint16(r[:2]))
     97 	r = r[2:]
     98 	if len(r) < csLen || csLen%2 != 0 {
     99 		return nil, fmt.Errorf("ja4: cipher suites truncated or odd length")
    100 	}
    101 	for i := 0; i < csLen; i += 2 {
    102 		cs := binary.BigEndian.Uint16(r[i : i+2])
    103 		if !isGREASE(cs) {
    104 			fields.CipherSuites = append(fields.CipherSuites, cs)
    105 		}
    106 	}
    107 	r = r[csLen:]
    108 
    109 	if len(r) < 1 {
    110 		return nil, fmt.Errorf("ja4: too short for compression methods length")
    111 	}
    112 	cmLen := int(r[0])
    113 	r = r[1:]
    114 	if len(r) < cmLen {
    115 		return nil, fmt.Errorf("ja4: too short for compression methods")
    116 	}
    117 	r = r[cmLen:]
    118 
    119 	// Extensions are absent in very old TLS. Not an error.
    120 	if len(r) < 2 {
    121 		return fields, nil
    122 	}
    123 	extsLen := int(binary.BigEndian.Uint16(r[:2]))
    124 	r = r[2:]
    125 	if len(r) < extsLen {
    126 		return nil, fmt.Errorf("ja4: extensions block truncated")
    127 	}
    128 	exts := r[:extsLen]
    129 
    130 	for len(exts) >= 4 {
    131 		extType := binary.BigEndian.Uint16(exts[:2])
    132 		extLen := int(binary.BigEndian.Uint16(exts[2:4]))
    133 		exts = exts[4:]
    134 		if len(exts) < extLen {
    135 			return nil, fmt.Errorf("ja4: extension 0x%04x data truncated", extType)
    136 		}
    137 		extData := exts[:extLen]
    138 		exts = exts[extLen:]
    139 
    140 		if isGREASE(extType) {
    141 			continue
    142 		}
    143 		fields.Extensions = append(fields.Extensions, extType)
    144 
    145 		switch extType {
    146 		case 0x0000:
    147 			parseSNI(extData, fields)
    148 		case 0x0010:
    149 			parseALPN(extData, fields)
    150 		case 0x002b:
    151 			parseSupportedVersions(extData, fields)
    152 		case 0x000d:
    153 			parseSigAlgs(extData, fields)
    154 		}
    155 	}
    156 
    157 	return fields, nil
    158 }
    159 
    160 func parseSNI(data []byte, f *ClientHelloFields) {
    161 	if len(data) < 2 {
    162 		return
    163 	}
    164 	listLen := int(binary.BigEndian.Uint16(data[:2]))
    165 	data = data[2:]
    166 	if len(data) < listLen {
    167 		return
    168 	}
    169 	data = data[:listLen]
    170 	for len(data) >= 3 {
    171 		nameType := data[0]
    172 		nameLen := int(binary.BigEndian.Uint16(data[1:3]))
    173 		data = data[3:]
    174 		if len(data) < nameLen {
    175 			break
    176 		}
    177 		name := string(data[:nameLen])
    178 		data = data[nameLen:]
    179 		if nameType == 0 { // host_name (the only defined type)
    180 			f.SNIHost = name
    181 			return
    182 		}
    183 	}
    184 }
    185 
    186 func parseALPN(data []byte, f *ClientHelloFields) {
    187 	if len(data) < 2 {
    188 		return
    189 	}
    190 	listLen := int(binary.BigEndian.Uint16(data[:2]))
    191 	data = data[2:]
    192 	if len(data) < listLen {
    193 		return
    194 	}
    195 	data = data[:listLen]
    196 	for len(data) >= 1 {
    197 		pLen := int(data[0])
    198 		data = data[1:]
    199 		if len(data) < pLen {
    200 			break
    201 		}
    202 		f.ALPNValues = append(f.ALPNValues, string(data[:pLen]))
    203 		data = data[pLen:]
    204 	}
    205 }
    206 
    207 func parseSupportedVersions(data []byte, f *ClientHelloFields) {
    208 	// ClientHello supported_versions uses a 1-byte list length, unlike most extensions.
    209 	if len(data) < 1 {
    210 		return
    211 	}
    212 	listLen := int(data[0])
    213 	data = data[1:]
    214 	if len(data) < listLen || listLen%2 != 0 {
    215 		return
    216 	}
    217 	for i := 0; i < listLen; i += 2 {
    218 		v := binary.BigEndian.Uint16(data[i : i+2])
    219 		if !isGREASE(v) {
    220 			f.SupportedVersions = append(f.SupportedVersions, v)
    221 		}
    222 	}
    223 }
    224 
    225 func parseSigAlgs(data []byte, f *ClientHelloFields) {
    226 	if len(data) < 2 {
    227 		return
    228 	}
    229 	listLen := int(binary.BigEndian.Uint16(data[:2]))
    230 	data = data[2:]
    231 	if len(data) < listLen || listLen%2 != 0 {
    232 		return
    233 	}
    234 	for i := 0; i < listLen; i += 2 {
    235 		// RFC 8701 permits GREASE here too.
    236 		if a := binary.BigEndian.Uint16(data[i : i+2]); !isGREASE(a) {
    237 			f.SigAlgs = append(f.SigAlgs, a)
    238 		}
    239 	}
    240 }
    241 
    242 func ComputeJA4(ch *ClientHelloFields) string {
    243 	return buildJA4a(ch) + "_" + buildJA4b(ch.CipherSuites) + "_" + buildJA4c(ch.Extensions, ch.SigAlgs)
    244 }
    245 
    246 func buildJA4a(ch *ClientHelloFields) string {
    247 	tlsVer := tlsVersionStr(ch.LegacyVersion)
    248 	if len(ch.SupportedVersions) > 0 {
    249 		tlsVer = tlsVersionStr(slices.Max(ch.SupportedVersions))
    250 	}
    251 
    252 	// The spec keys on presence of the SNI extension, not on what it contains.
    253 	sniChar := "i"
    254 	if slices.Contains(ch.Extensions, 0x0000) {
    255 		sniChar = "d"
    256 	}
    257 
    258 	nc := min99(len(ch.CipherSuites))
    259 	ne := min99(len(ch.Extensions))
    260 
    261 	alpn := "00"
    262 	if len(ch.ALPNValues) > 0 {
    263 		alpn = alpnChars(ch.ALPNValues[0])
    264 	}
    265 
    266 	return fmt.Sprintf("t%s%s%02d%02d%s", tlsVer, sniChar, nc, ne, alpn)
    267 }
    268 
    269 // alpnChars is the two-character ALPN field: first and last byte of the value.
    270 // If either end byte is not ASCII alphanumeric, hex-encode the whole value and
    271 // take the first and last hex characters instead.
    272 func alpnChars(v string) string {
    273 	if v == "" {
    274 		return "00"
    275 	}
    276 	first, last := v[0], v[len(v)-1]
    277 	if !isALPNAlnum(first) || !isALPNAlnum(last) {
    278 		h := hex.EncodeToString([]byte(v))
    279 		first, last = h[0], h[len(h)-1]
    280 	}
    281 	return string([]byte{first, last})
    282 }
    283 
    284 func isALPNAlnum(b byte) bool {
    285 	return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z'
    286 }
    287 
    288 // min99 caps n at 99, the max for the two-digit JA4_a counter.
    289 func min99(n int) int {
    290 	if n > 99 {
    291 		return 99
    292 	}
    293 	return n
    294 }
    295 
    296 func tlsVersionStr(v uint16) string {
    297 	switch v {
    298 	case 0x0304:
    299 		return "13"
    300 	case 0x0303:
    301 		return "12"
    302 	case 0x0302:
    303 		return "11"
    304 	case 0x0301:
    305 		return "10"
    306 	case 0x0300:
    307 		return "s3"
    308 	case 0x0002:
    309 		return "s2"
    310 	case 0xfeff:
    311 		return "d1"
    312 	case 0xfefd:
    313 		return "d2"
    314 	case 0xfefc:
    315 		return "d3"
    316 	default:
    317 		return "00"
    318 	}
    319 }
    320 
    321 func buildJA4b(ciphers []uint16) string {
    322 	if len(ciphers) == 0 {
    323 		return noValues
    324 	}
    325 	sorted := slices.Clone(ciphers)
    326 	slices.Sort(sorted)
    327 
    328 	parts := make([]string, len(sorted))
    329 	for i, c := range sorted {
    330 		parts[i] = fmt.Sprintf("%04x", c)
    331 	}
    332 	h := sha256.Sum256([]byte(strings.Join(parts, ",")))
    333 	return fmt.Sprintf("%x", h)[:12]
    334 }
    335 
    336 // buildJA4c hashes sorted extension types (SNI and ALPN excluded) then signature
    337 // algorithms in wire order. Sorting is what distinguishes JA4 from JA4_r.
    338 func buildJA4c(exts []uint16, sigAlgs []uint16) string {
    339 	var filtered []uint16
    340 	for _, e := range exts {
    341 		if e != 0x0000 && e != 0x0010 {
    342 			filtered = append(filtered, e)
    343 		}
    344 	}
    345 	if len(filtered) == 0 {
    346 		return noValues
    347 	}
    348 	slices.Sort(filtered)
    349 
    350 	extParts := make([]string, len(filtered))
    351 	for i, e := range filtered {
    352 		extParts[i] = fmt.Sprintf("%04x", e)
    353 	}
    354 	preimage := strings.Join(extParts, ",")
    355 
    356 	if len(sigAlgs) > 0 {
    357 		sigParts := make([]string, len(sigAlgs))
    358 		for i, s := range sigAlgs {
    359 			sigParts[i] = fmt.Sprintf("%04x", s)
    360 		}
    361 		preimage += "_" + strings.Join(sigParts, ",")
    362 	}
    363 
    364 	h := sha256.Sum256([]byte(preimage))
    365 	return fmt.Sprintf("%x", h)[:12]
    366 }