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 }