mic~ proxy for JA4-TLS fingerprinting evasion |
| git clone https://github.com/djnnvx/mic.git |
| log | files | refs | archive | README | LICENSE |
ja4_test.go (11251B)
1 package fingerprint 2 3 import ( 4 "bytes" 5 "crypto/sha256" 6 "encoding/binary" 7 "fmt" 8 "testing" 9 ) 10 11 // buildClientHello synthesizes a TLS handshake record carrying a ClientHello. 12 // extensions is the wire-encoded extensions block (see ext() and the *Data helpers). 13 func buildClientHello(legacyVer uint16, ciphers []uint16, extensions []byte) []byte { 14 var body bytes.Buffer 15 16 binary.Write(&body, binary.BigEndian, legacyVer) 17 body.Write(make([]byte, 32)) // random 18 body.WriteByte(0x00) // session_id length = 0 19 20 binary.Write(&body, binary.BigEndian, uint16(len(ciphers)*2)) 21 for _, c := range ciphers { 22 binary.Write(&body, binary.BigEndian, c) 23 } 24 25 body.WriteByte(0x01) // compression_methods length 26 body.WriteByte(0x00) // null 27 28 binary.Write(&body, binary.BigEndian, uint16(len(extensions))) 29 body.Write(extensions) 30 31 hsLen := body.Len() 32 hs := []byte{0x01, byte(hsLen >> 16), byte(hsLen >> 8), byte(hsLen)} 33 hs = append(hs, body.Bytes()...) 34 35 rec := []byte{0x16, 0x03, 0x01, byte(len(hs) >> 8), byte(len(hs))} 36 return append(rec, hs...) 37 } 38 39 func sniData(host string) []byte { 40 name := []byte(host) 41 entry := append([]byte{0x00, byte(len(name) >> 8), byte(len(name))}, name...) 42 return append([]byte{byte(len(entry) >> 8), byte(len(entry))}, entry...) 43 } 44 45 func alpnData(protos ...string) []byte { 46 var list []byte 47 for _, p := range protos { 48 list = append(list, byte(len(p))) 49 list = append(list, p...) 50 } 51 return append([]byte{byte(len(list) >> 8), byte(len(list))}, list...) 52 } 53 54 // clientSuppVerData uses the ClientHello 1-byte list length (the ServerHello 55 // variant is a bare uint16). 56 func clientSuppVerData(vers ...uint16) []byte { 57 out := []byte{byte(len(vers) * 2)} 58 for _, v := range vers { 59 out = append(out, byte(v>>8), byte(v)) 60 } 61 return out 62 } 63 64 func sigAlgsData(algs ...uint16) []byte { 65 out := []byte{byte(len(algs) * 2 >> 8), byte(len(algs) * 2)} 66 for _, a := range algs { 67 out = append(out, byte(a>>8), byte(a)) 68 } 69 return out 70 } 71 72 func realisticClientHello() []byte { 73 var exts []byte 74 exts = append(exts, ext(0x0000, sniData("example.com"))...) 75 exts = append(exts, ext(0x002b, clientSuppVerData(0x0304))...) 76 exts = append(exts, ext(0x0010, alpnData("h2"))...) 77 exts = append(exts, ext(0x000d, sigAlgsData(0x0403, 0x0804))...) 78 exts = append(exts, ext(0x0033, []byte{})...) // key_share, empty 79 return buildClientHello(0x0303, []uint16{0x1301, 0x1302, 0x1303}, exts) 80 } 81 82 func TestParseClientHello_Fields(t *testing.T) { 83 ch, err := ParseClientHello(realisticClientHello()) 84 if err != nil { 85 t.Fatalf("ParseClientHello: %v", err) 86 } 87 if ch.LegacyVersion != 0x0303 { 88 t.Errorf("legacy version = 0x%04x; want 0x0303", ch.LegacyVersion) 89 } 90 if want := []uint16{0x1301, 0x1302, 0x1303}; !equalU16(ch.CipherSuites, want) { 91 t.Errorf("ciphers = %v; want %v", ch.CipherSuites, want) 92 } 93 if want := []uint16{0x0000, 0x002b, 0x0010, 0x000d, 0x0033}; !equalU16(ch.Extensions, want) { 94 t.Errorf("extensions = %v; want %v", ch.Extensions, want) 95 } 96 if ch.SNIHost != "example.com" { 97 t.Errorf("SNI = %q; want example.com", ch.SNIHost) 98 } 99 if want := []uint16{0x0304}; !equalU16(ch.SupportedVersions, want) { 100 t.Errorf("supported versions = %v; want %v", ch.SupportedVersions, want) 101 } 102 if len(ch.ALPNValues) != 1 || ch.ALPNValues[0] != "h2" { 103 t.Errorf("alpn = %v; want [h2]", ch.ALPNValues) 104 } 105 if want := []uint16{0x0403, 0x0804}; !equalU16(ch.SigAlgs, want) { 106 t.Errorf("sig algs = %v; want %v", ch.SigAlgs, want) 107 } 108 } 109 110 // The spec keys on SNI extension presence, never on the value, so an IP literal 111 // still yields "d". 112 func TestComputeJA4_a_SNI_IPLiteral(t *testing.T) { 113 exts := ext(0x0000, sniData("192.0.2.1")) 114 ch, err := ParseClientHello(buildClientHello(0x0303, []uint16{0x1301}, exts)) 115 if err != nil { 116 t.Fatalf("ParseClientHello: %v", err) 117 } 118 if ch.SNIHost != "192.0.2.1" { 119 t.Errorf("SNI = %q; want 192.0.2.1", ch.SNIHost) 120 } 121 if got := ComputeJA4(ch)[:8]; got != "t12d0101" { 122 t.Errorf("JA4_a = %q; want %q", got, "t12d0101") 123 } 124 } 125 126 func TestParseClientHello_SigAlgsGREASEFiltered(t *testing.T) { 127 exts := ext(0x000d, sigAlgsData(0x0a0a, 0x0403, 0x1a1a)) 128 ch, err := ParseClientHello(buildClientHello(0x0303, []uint16{0x1301}, exts)) 129 if err != nil { 130 t.Fatalf("ParseClientHello: %v", err) 131 } 132 if want := []uint16{0x0403}; !equalU16(ch.SigAlgs, want) { 133 t.Errorf("sig algs = %v; want %v (GREASE filtered)", ch.SigAlgs, want) 134 } 135 } 136 137 func TestTLSVersionStr(t *testing.T) { 138 cases := map[uint16]string{ 139 0x0304: "13", 0x0303: "12", 0x0302: "11", 0x0301: "10", 140 0x0300: "s3", 0x0002: "s2", 141 0xfeff: "d1", 0xfefd: "d2", 0xfefc: "d3", 142 0x0999: "00", 143 } 144 for v, want := range cases { 145 if got := tlsVersionStr(v); got != want { 146 t.Errorf("tlsVersionStr(0x%04x) = %q; want %q", v, got, want) 147 } 148 } 149 } 150 151 // The 8 hex-fallback examples from the FoxIO spec, plus the plain cases. 152 func TestComputeJA4_a_ALPNValues(t *testing.T) { 153 cases := []struct { 154 in string 155 want string 156 }{ 157 {"http/1.1", "h1"}, 158 {"h2", "h2"}, 159 {"x", "xx"}, 160 {"", "00"}, 161 {"\xab", "ab"}, 162 {"\x20", "20"}, 163 {"\xab\xcd", "ad"}, 164 {"\x20\x61", "21"}, 165 {"\x30\xab", "3b"}, 166 {"\x61\x20", "60"}, 167 {"\x30\x31\xab\xcd", "3d"}, 168 {"\x30\xab\xcd\x31", "01"}, // both end bytes alphanumeric, middle ignored 169 } 170 for _, tc := range cases { 171 t.Run(fmt.Sprintf("%x", tc.in), func(t *testing.T) { 172 exts := ext(0x0010, alpnData(tc.in)) 173 ch, err := ParseClientHello(buildClientHello(0x0303, []uint16{0x1301}, exts)) 174 if err != nil { 175 t.Fatalf("ParseClientHello: %v", err) 176 } 177 want := "t12i0101" + tc.want 178 if got := ComputeJA4(ch)[:len(want)]; got != want { 179 t.Errorf("JA4_a = %q; want %q", got, want) 180 } 181 }) 182 } 183 } 184 185 func TestBuildJA4b_EmptyCiphers(t *testing.T) { 186 if got := buildJA4b(nil); got != "000000000000" { 187 t.Errorf("buildJA4b(nil) = %q; want 000000000000", got) 188 } 189 } 190 191 // 0x0000 and 0x0010 are both filtered out, leaving JA4_c with no values. 192 func TestBuildJA4c_EmptyFilteredExts(t *testing.T) { 193 if got := buildJA4c([]uint16{0x0000, 0x0010}, []uint16{0x0403}); got != "000000000000" { 194 t.Errorf("buildJA4c = %q; want 000000000000", got) 195 } 196 } 197 198 // Spec vector: with no signature algorithms the trailing "_" is omitted. 199 func TestBuildJA4c_NoSigAlgsOmitsSeparator(t *testing.T) { 200 exts := []uint16{ 201 0x0005, 0x000a, 0x000b, 0x000d, 0x0012, 0x0015, 0x0017, 0x001b, 202 0x0023, 0x002b, 0x002d, 0x0033, 0x4469, 0xff01, 203 } 204 if got := buildJA4c(exts, nil); got != "6d807ffa2a79" { 205 t.Errorf("buildJA4c = %q; want 6d807ffa2a79", got) 206 } 207 } 208 209 func TestParseClientHello_GREASEFiltered(t *testing.T) { 210 exts := ext(0x1a1a, []byte{}) // GREASE extension 211 exts = append(exts, ext(0x002b, clientSuppVerData(0x0304))...) 212 ch, err := ParseClientHello(buildClientHello(0x0303, []uint16{0x0a0a, 0x1301}, exts)) 213 if err != nil { 214 t.Fatalf("ParseClientHello: %v", err) 215 } 216 if want := []uint16{0x1301}; !equalU16(ch.CipherSuites, want) { 217 t.Errorf("ciphers = %v; want %v (GREASE filtered)", ch.CipherSuites, want) 218 } 219 if want := []uint16{0x002b}; !equalU16(ch.Extensions, want) { 220 t.Errorf("extensions = %v; want %v (GREASE filtered)", ch.Extensions, want) 221 } 222 } 223 224 func TestParseClientHello_Errors(t *testing.T) { 225 cases := []struct { 226 name string 227 raw []byte 228 }{ 229 {"empty", nil}, 230 {"too short", []byte{0x16, 0x03}}, 231 {"wrong content type", []byte{0x17, 0x03, 0x03, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00}}, 232 {"wrong handshake type (ServerHello)", []byte{0x16, 0x03, 0x03, 0x00, 0x04, 0x02, 0x00, 0x00, 0x00}}, 233 {"truncated record", []byte{0x16, 0x03, 0x03, 0x00, 0xff, 0x01, 0x00, 0x00, 0x00}}, 234 } 235 for _, tc := range cases { 236 t.Run(tc.name, func(t *testing.T) { 237 if _, err := ParseClientHello(tc.raw); err == nil { 238 t.Errorf("expected error, got nil") 239 } 240 }) 241 } 242 } 243 244 // Hashes are checked against their spec preimages, independently verifiable 245 // with: printf '%s' '<preimage>' | sha256sum. 246 func TestComputeJA4_KnownFixture(t *testing.T) { 247 ch, err := ParseClientHello(realisticClientHello()) 248 if err != nil { 249 t.Fatalf("ParseClientHello: %v", err) 250 } 251 got := ComputeJA4(ch) 252 253 const wantA = "t13d0305h2" // tls1.3, domain SNI, 3 ciphers, 5 exts, alpn h2 254 wantB := sha256Hex12("1301,1302,1303") 255 wantC := sha256Hex12("000d,002b,0033_0403,0804") // exts sans SNI+ALPN, sorted; then sig algs 256 257 want := wantA + "_" + wantB + "_" + wantC 258 if got != want { 259 t.Errorf("JA4 = %q; want %q", got, want) 260 } 261 } 262 263 func TestComputeJA4_a_Indicators(t *testing.T) { 264 tests := []struct { 265 name string 266 legacyVer uint16 267 ciphers []uint16 268 exts []byte 269 wantPrefix string 270 }{ 271 { 272 name: "no SNI, no versions ext, no alpn", 273 legacyVer: 0x0303, 274 ciphers: []uint16{0x1301, 0x1302}, 275 exts: ext(0x0033, []byte{}), 276 wantPrefix: "t12i0201", // legacy 1.2, no SNI ext -> i, 2 ciphers, 1 ext, alpn 00 277 }, 278 { 279 name: "supported_versions max wins over legacy", 280 legacyVer: 0x0301, 281 ciphers: []uint16{0x1301}, 282 exts: ext(0x002b, clientSuppVerData(0x0303, 0x0304)), 283 wantPrefix: "t13i0101", // max(1.2,1.3)=1.3 284 }, 285 { 286 name: "SNI extension present", 287 legacyVer: 0x0303, 288 ciphers: []uint16{0x1301}, 289 exts: ext(0x0000, sniData("example.com")), 290 wantPrefix: "t12d0101", 291 }, 292 { 293 name: "empty SNI extension still counts as present", 294 legacyVer: 0x0303, 295 ciphers: []uint16{0x1301}, 296 exts: ext(0x0000, []byte{}), 297 wantPrefix: "t12d0101", 298 }, 299 { 300 name: "alpn http/1.1 uses first and last", 301 legacyVer: 0x0303, 302 ciphers: []uint16{0x1301}, 303 exts: ext(0x0010, alpnData("http/1.1", "h2")), 304 wantPrefix: "t12i0101h1", 305 }, 306 { 307 name: "single-character alpn is doubled", 308 legacyVer: 0x0303, 309 ciphers: []uint16{0x1301}, 310 exts: ext(0x0010, alpnData("x")), 311 wantPrefix: "t12i0101xx", 312 }, 313 { 314 name: "non-alphanumeric alpn falls back to hex", 315 legacyVer: 0x0303, 316 ciphers: []uint16{0x1301}, 317 exts: ext(0x0010, alpnData("\x20\x61")), 318 wantPrefix: "t12i010121", 319 }, 320 { 321 name: "sslv3 legacy version", 322 legacyVer: 0x0300, 323 ciphers: []uint16{0x1301}, 324 exts: ext(0x0033, []byte{}), 325 wantPrefix: "ts3i0101", 326 }, 327 } 328 for _, tc := range tests { 329 t.Run(tc.name, func(t *testing.T) { 330 ch, err := ParseClientHello(buildClientHello(tc.legacyVer, tc.ciphers, tc.exts)) 331 if err != nil { 332 t.Fatalf("ParseClientHello: %v", err) 333 } 334 a := ComputeJA4(ch)[:len(tc.wantPrefix)] 335 if a != tc.wantPrefix { 336 t.Errorf("JA4_a = %q; want %q", a, tc.wantPrefix) 337 } 338 }) 339 } 340 } 341 342 // Both hash fields hit the no-values sentinel: no ciphers, and every extension 343 // is one JA4_c filters out. 344 func TestComputeJA4_BothSentinels(t *testing.T) { 345 exts := append(ext(0x0000, sniData("example.com")), ext(0x0010, alpnData("h2"))...) 346 ch, err := ParseClientHello(buildClientHello(0x0303, nil, exts)) 347 if err != nil { 348 t.Fatalf("ParseClientHello: %v", err) 349 } 350 const want = "t12d0002h2_000000000000_000000000000" 351 if got := ComputeJA4(ch); got != want { 352 t.Errorf("JA4 = %q; want %q", got, want) 353 } 354 } 355 356 func equalU16(a, b []uint16) bool { 357 if len(a) != len(b) { 358 return false 359 } 360 for i := range a { 361 if a[i] != b[i] { 362 return false 363 } 364 } 365 return true 366 } 367 368 func sha256Hex12(s string) string { 369 h := sha256.Sum256([]byte(s)) 370 return fmt.Sprintf("%x", h)[:12] 371 }