mic.git


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 }