mic.git


mic

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

commit ff48adf9655379b4df6497123aed4af0bb48d8b5
parent 78cbd270c5ac1cabb896411d8393345928090cb9
Author: djnn <[email protected]>
Date: Wed, 26 Aug 2026 14:58:51 +0900

fix(fgp): conform JA4 and JA4S to foxio spec

Diffstat:
Mfingerprint/ja4.go | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mfingerprint/ja4_test.go | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mfingerprint/ja4s.go | 34++++++++++++++++++++++++++--------
Mfingerprint/ja4s_test.go | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------

4 files changed, 301 insertions(+), 57 deletions(-)

diff --git a/fingerprint/ja4.go b/fingerprint/ja4.go
@@ -3,19 +3,22 @@ package fingerprint
 import (
 	"crypto/sha256"
 	"encoding/binary"
+	"encoding/hex"
 	"fmt"
-	"net"
 	"slices"
 	"strings"
 )
 
+// noValues is the JA4 placeholder for a field with no values. The spec uses it
+// rather than sha256 of the empty string so an empty field is visible.
+const noValues = "000000000000"
+
 // ClientHelloFields holds the parsed TLS ClientHello data used to compute JA4.
 type ClientHelloFields struct {
 	LegacyVersion     uint16
 	CipherSuites      []uint16 // non-GREASE, wire order
 	Extensions        []uint16 // non-GREASE extension types, wire order
 	SNIHost           string
-	SNIIsIP           bool
 	SupportedVersions []uint16 // from ext 0x002b, non-GREASE
 	ALPNValues        []string // from ext 0x0010
 	SigAlgs           []uint16 // from ext 0x000d, wire order
@@ -176,7 +179,6 @@ func parseSNI(data []byte, f *ClientHelloFields) {
 		data = data[nameLen:]
 		if nameType == 0 { // host_name (the only defined type)
 			f.SNIHost = name
-			f.SNIIsIP = net.ParseIP(name) != nil
 			return
 		}
 	}
@@ -231,7 +233,10 @@ func parseSigAlgs(data []byte, f *ClientHelloFields) {
 		return
 	}
 	for i := 0; i < listLen; i += 2 {
-		f.SigAlgs = append(f.SigAlgs, binary.BigEndian.Uint16(data[i:i+2]))
+		// RFC 8701 permits GREASE here too.
+		if a := binary.BigEndian.Uint16(data[i : i+2]); !isGREASE(a) {
+			f.SigAlgs = append(f.SigAlgs, a)
+		}
 	}
 }
 
@@ -245,13 +250,10 @@ func buildJA4a(ch *ClientHelloFields) string {
 		tlsVer = tlsVersionStr(slices.Max(ch.SupportedVersions))
 	}
 
-	sniChar := "n"
-	if ch.SNIHost != "" {
-		if ch.SNIIsIP {
-			sniChar = "i"
-		} else {
-			sniChar = "d"
-		}
+	// The spec keys on presence of the SNI extension, not on what it contains.
+	sniChar := "i"
+	if slices.Contains(ch.Extensions, 0x0000) {
+		sniChar = "d"
 	}
 
 	nc := min99(len(ch.CipherSuites))
@@ -259,18 +261,32 @@ func buildJA4a(ch *ClientHelloFields) string {
 
 	alpn := "00"
 	if len(ch.ALPNValues) > 0 {
-		v := ch.ALPNValues[0]
-		switch {
-		case len(v) >= 2:
-			alpn = v[:2]
-		case len(v) == 1:
-			alpn = v + "0"
-		}
+		alpn = alpnChars(ch.ALPNValues[0])
 	}
 
 	return fmt.Sprintf("t%s%s%02d%02d%s", tlsVer, sniChar, nc, ne, alpn)
 }
 
+// alpnChars is the two-character ALPN field: the first and last bytes of the
+// value. If either end byte is not ASCII alphanumeric, the whole value is
+// hex-encoded and the first and last characters of that hex string are used.
+// This also keeps client-controlled raw bytes out of the fingerprint.
+func alpnChars(v string) string {
+	if v == "" {
+		return "00"
+	}
+	first, last := v[0], v[len(v)-1]
+	if !isALPNAlnum(first) || !isALPNAlnum(last) {
+		h := hex.EncodeToString([]byte(v))
+		first, last = h[0], h[len(h)-1]
+	}
+	return string([]byte{first, last})
+}
+
+func isALPNAlnum(b byte) bool {
+	return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z'
+}
+
 // min99 caps n at 99, the maximum that fits in the two-digit JA4_a counter.
 func min99(n int) int {
 	if n > 99 {
@@ -289,12 +305,25 @@ func tlsVersionStr(v uint16) string {
 		return "11"
 	case 0x0301:
 		return "10"
+	case 0x0300:
+		return "s3"
+	case 0x0002:
+		return "s2"
+	case 0xfeff:
+		return "d1"
+	case 0xfefd:
+		return "d2"
+	case 0xfefc:
+		return "d3"
 	default:
 		return "00"
 	}
 }
 
 func buildJA4b(ciphers []uint16) string {
+	if len(ciphers) == 0 {
+		return noValues
+	}
 	sorted := slices.Clone(ciphers)
 	slices.Sort(sorted)
 
@@ -316,18 +345,26 @@ func buildJA4c(exts []uint16, sigAlgs []uint16) string {
 			filtered = append(filtered, e)
 		}
 	}
+	if len(filtered) == 0 {
+		return noValues
+	}
 	slices.Sort(filtered)
 
 	extParts := make([]string, len(filtered))
 	for i, e := range filtered {
 		extParts[i] = fmt.Sprintf("%04x", e)
 	}
+	preimage := strings.Join(extParts, ",")
 
-	sigParts := make([]string, len(sigAlgs))
-	for i, s := range sigAlgs {
-		sigParts[i] = fmt.Sprintf("%04x", s)
+	// No separator at all when there are no signature algorithms.
+	if len(sigAlgs) > 0 {
+		sigParts := make([]string, len(sigAlgs))
+		for i, s := range sigAlgs {
+			sigParts[i] = fmt.Sprintf("%04x", s)
+		}
+		preimage += "_" + strings.Join(sigParts, ",")
 	}
 
-	h := sha256.Sum256([]byte(strings.Join(extParts, ",") + "_" + strings.Join(sigParts, ",")))
+	h := sha256.Sum256([]byte(preimage))
 	return fmt.Sprintf("%x", h)[:12]
 }
diff --git a/fingerprint/ja4_test.go b/fingerprint/ja4_test.go
@@ -94,8 +94,8 @@ func TestParseClientHello_Fields(t *testing.T) {
 	if want := []uint16{0x0000, 0x002b, 0x0010, 0x000d, 0x0033}; !equalU16(ch.Extensions, want) {
 		t.Errorf("extensions = %v; want %v", ch.Extensions, want)
 	}
-	if ch.SNIHost != "example.com" || ch.SNIIsIP {
-		t.Errorf("SNI = %q (ip=%v); want example.com (ip=false)", ch.SNIHost, ch.SNIIsIP)
+	if ch.SNIHost != "example.com" {
+		t.Errorf("SNI = %q; want example.com", ch.SNIHost)
 	}
 	if want := []uint16{0x0304}; !equalU16(ch.SupportedVersions, want) {
 		t.Errorf("supported versions = %v; want %v", ch.SupportedVersions, want)
@@ -108,14 +108,103 @@ func TestParseClientHello_Fields(t *testing.T) {
 	}
 }
 
-func TestParseClientHello_SNI_IP(t *testing.T) {
+// An IP literal in SNI is still an SNI extension, so JA4_a stays "d".
+// The spec keys on extension presence, never on the value.
+func TestComputeJA4_a_SNI_IPLiteral(t *testing.T) {
 	exts := ext(0x0000, sniData("192.0.2.1"))
 	ch, err := ParseClientHello(buildClientHello(0x0303, []uint16{0x1301}, exts))
 	if err != nil {
 		t.Fatalf("ParseClientHello: %v", err)
 	}
-	if ch.SNIHost != "192.0.2.1" || !ch.SNIIsIP {
-		t.Errorf("SNI = %q (ip=%v); want 192.0.2.1 (ip=true)", ch.SNIHost, ch.SNIIsIP)
+	if ch.SNIHost != "192.0.2.1" {
+		t.Errorf("SNI = %q; want 192.0.2.1", ch.SNIHost)
+	}
+	if got := ComputeJA4(ch)[:8]; got != "t12d0101" {
+		t.Errorf("JA4_a = %q; want %q", got, "t12d0101")
+	}
+}
+
+func TestParseClientHello_SigAlgsGREASEFiltered(t *testing.T) {
+	exts := ext(0x000d, sigAlgsData(0x0a0a, 0x0403, 0x1a1a))
+	ch, err := ParseClientHello(buildClientHello(0x0303, []uint16{0x1301}, exts))
+	if err != nil {
+		t.Fatalf("ParseClientHello: %v", err)
+	}
+	if want := []uint16{0x0403}; !equalU16(ch.SigAlgs, want) {
+		t.Errorf("sig algs = %v; want %v (GREASE filtered)", ch.SigAlgs, want)
+	}
+}
+
+func TestTLSVersionStr(t *testing.T) {
+	cases := map[uint16]string{
+		0x0304: "13", 0x0303: "12", 0x0302: "11", 0x0301: "10",
+		0x0300: "s3", 0x0002: "s2",
+		0xfeff: "d1", 0xfefd: "d2", 0xfefc: "d3",
+		0x0999: "00",
+	}
+	for v, want := range cases {
+		if got := tlsVersionStr(v); got != want {
+			t.Errorf("tlsVersionStr(0x%04x) = %q; want %q", v, got, want)
+		}
+	}
+}
+
+// The 8 hex-fallback examples from the FoxIO spec, plus the plain cases.
+// Checked end to end so the ALPN bytes travel through the parser.
+func TestComputeJA4_a_ALPNValues(t *testing.T) {
+	cases := []struct {
+		in   string
+		want string
+	}{
+		{"http/1.1", "h1"},
+		{"h2", "h2"},
+		{"x", "xx"},
+		{"", "00"},
+		{"\xab", "ab"},
+		{"\x20", "20"},
+		{"\xab\xcd", "ad"},
+		{"\x20\x61", "21"},
+		{"\x30\xab", "3b"},
+		{"\x61\x20", "60"},
+		{"\x30\x31\xab\xcd", "3d"},
+		{"\x30\xab\xcd\x31", "01"}, // both end bytes alphanumeric, middle ignored
+	}
+	for _, tc := range cases {
+		t.Run(fmt.Sprintf("%x", tc.in), func(t *testing.T) {
+			exts := ext(0x0010, alpnData(tc.in))
+			ch, err := ParseClientHello(buildClientHello(0x0303, []uint16{0x1301}, exts))
+			if err != nil {
+				t.Fatalf("ParseClientHello: %v", err)
+			}
+			want := "t12i0101" + tc.want
+			if got := ComputeJA4(ch)[:len(want)]; got != want {
+				t.Errorf("JA4_a = %q; want %q", got, want)
+			}
+		})
+	}
+}
+
+func TestBuildJA4b_EmptyCiphers(t *testing.T) {
+	if got := buildJA4b(nil); got != "000000000000" {
+		t.Errorf("buildJA4b(nil) = %q; want 000000000000", got)
+	}
+}
+
+// Only SNI and ALPN present: both are filtered out, so JA4_c has no values.
+func TestBuildJA4c_EmptyFilteredExts(t *testing.T) {
+	if got := buildJA4c([]uint16{0x0000, 0x0010}, []uint16{0x0403}); got != "000000000000" {
+		t.Errorf("buildJA4c = %q; want 000000000000", got)
+	}
+}
+
+// Spec vector: with no signature algorithms the trailing "_" is omitted.
+func TestBuildJA4c_NoSigAlgsOmitsSeparator(t *testing.T) {
+	exts := []uint16{
+		0x0005, 0x000a, 0x000b, 0x000d, 0x0012, 0x0015, 0x0017, 0x001b,
+		0x0023, 0x002b, 0x002d, 0x0033, 0x4469, 0xff01,
+	}
+	if got := buildJA4c(exts, nil); got != "6d807ffa2a79" {
+		t.Errorf("buildJA4c = %q; want 6d807ffa2a79", got)
 	}
 }
 
@@ -187,14 +276,56 @@ func TestComputeJA4_a_Indicators(t *testing.T) {
 			legacyVer:  0x0303,
 			ciphers:    []uint16{0x1301, 0x1302},
 			exts:       ext(0x0033, []byte{}),
-			wantPrefix: "t12n0201", // legacy 1.2, no SNI -> n, 2 ciphers, 1 ext, alpn 00
+			wantPrefix: "t12i0201", // legacy 1.2, no SNI ext -> i, 2 ciphers, 1 ext, alpn 00
 		},
 		{
 			name:       "supported_versions max wins over legacy",
 			legacyVer:  0x0301,
 			ciphers:    []uint16{0x1301},
 			exts:       ext(0x002b, clientSuppVerData(0x0303, 0x0304)),
-			wantPrefix: "t13n0101", // max(1.2,1.3)=1.3
+			wantPrefix: "t13i0101", // max(1.2,1.3)=1.3
+		},
+		{
+			name:       "SNI extension present",
+			legacyVer:  0x0303,
+			ciphers:    []uint16{0x1301},
+			exts:       ext(0x0000, sniData("example.com")),
+			wantPrefix: "t12d0101",
+		},
+		{
+			name:       "empty SNI extension still counts as present",
+			legacyVer:  0x0303,
+			ciphers:    []uint16{0x1301},
+			exts:       ext(0x0000, []byte{}),
+			wantPrefix: "t12d0101",
+		},
+		{
+			name:       "alpn http/1.1 uses first and last",
+			legacyVer:  0x0303,
+			ciphers:    []uint16{0x1301},
+			exts:       ext(0x0010, alpnData("http/1.1", "h2")),
+			wantPrefix: "t12i0101h1",
+		},
+		{
+			name:       "single-character alpn is doubled",
+			legacyVer:  0x0303,
+			ciphers:    []uint16{0x1301},
+			exts:       ext(0x0010, alpnData("x")),
+			wantPrefix: "t12i0101xx",
+		},
+		{
+			name:       "non-alphanumeric alpn falls back to hex",
+			legacyVer:  0x0303,
+			ciphers:    []uint16{0x1301},
+			exts:       ext(0x0010, alpnData("\x20\x61")),
+			wantPrefix: "t12i010121",
+		},
+		{
+			name:       "sslv3 legacy version",
+			legacyVer:  0x0300,
+			ciphers:    []uint16{0x1301},
+			exts:       ext(0x0033, []byte{}),
+			wantPrefix: "ts3i0101",
 		},
 	}
 	for _, tc := range tests {
@@ -211,6 +342,20 @@ func TestComputeJA4_a_Indicators(t *testing.T) {
 	}
 }
 
+// Both hash fields hit the no-values sentinel: no ciphers, and the only two
+// extensions are the ones JA4_c filters out.
+func TestComputeJA4_BothSentinels(t *testing.T) {
+	exts := append(ext(0x0000, sniData("example.com")), ext(0x0010, alpnData("h2"))...)
+	ch, err := ParseClientHello(buildClientHello(0x0303, nil, exts))
+	if err != nil {
+		t.Fatalf("ParseClientHello: %v", err)
+	}
+	const want = "t12d0002h2_000000000000_000000000000"
+	if got := ComputeJA4(ch); got != want {
+		t.Errorf("JA4 = %q; want %q", got, want)
+	}
+}
+
 func equalU16(a, b []uint16) bool {
 	if len(a) != len(b) {
 		return false
diff --git a/fingerprint/ja4s.go b/fingerprint/ja4s.go
@@ -3,6 +3,7 @@ package fingerprint
 import (
 	"crypto/sha256"
 	"encoding/binary"
+	"encoding/hex"
 	"fmt"
 	"strings"
 )
@@ -132,20 +133,37 @@ func buildJA4Sa(sh *ServerHelloFields) string {
 
 	nn := min99(len(sh.Extensions))
 
-	alpn := "00"
-	switch v := sh.ALPN; {
-	case len(v) >= 2:
-		alpn = v[:2]
-	case len(v) == 1:
-		alpn = v + "0"
-	}
+	return fmt.Sprintf("t%s%02d%s", ver, nn, ja4sALPNCode(sh.ALPN))
+}
 
-	return fmt.Sprintf("t%s%02d%s", ver, nn, alpn)
+// ja4sALPNCode encodes the negotiated ALPN as the JA4 two-character code:
+// first and last byte of the value. If either end byte is not ASCII
+// alphanumeric, hex-encode the whole value and take the first and last hex
+// characters instead. Duplicated from the JA4 side on purpose so the two
+// fingerprints stay independently editable.
+func ja4sALPNCode(v string) string {
+	if v == "" {
+		return "00"
+	}
+	b := []byte(v)
+	first, last := b[0], b[len(b)-1]
+	if !ja4sIsAlnum(first) || !ja4sIsAlnum(last) {
+		h := hex.EncodeToString(b)
+		return string(h[0]) + string(h[len(h)-1])
+	}
+	return string(first) + string(last)
+}
+
+func ja4sIsAlnum(c byte) bool {
+	return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
 }
 
 // buildJA4Sc hashes ServerHello extension types in wire order (no sort).
 // This is the difference from JA4_c, which sorts.
 func buildJA4Sc(exts []uint16) string {
+	if len(exts) == 0 {
+		return "000000000000"
+	}
 	parts := make([]string, len(exts))
 	for i, e := range exts {
 		parts[i] = fmt.Sprintf("%04x", e)
diff --git a/fingerprint/ja4s_test.go b/fingerprint/ja4s_test.go
@@ -139,20 +139,27 @@ func TestParseServerHello_GREASEFiltered(t *testing.T) {
 
 func TestParseServerHello_Errors(t *testing.T) {
 	cases := []struct {
-		name string
-		raw  []byte
+		name    string
+		raw     []byte
+		wantErr bool
 	}{
-		{"empty", nil},
-		{"too short", []byte{0x16, 0x03}},
-		{"wrong content type", []byte{0x17, 0x03, 0x03, 0x00, 0x04, 0x02, 0x00, 0x00, 0x00}},
-		{"wrong handshake type (ClientHello)", []byte{0x16, 0x03, 0x03, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00}},
-		{"truncated record", []byte{0x16, 0x03, 0x03, 0x00, 0xff, 0x02, 0x00, 0x00, 0x00}},
+		{"empty", nil, true},
+		{"too short", []byte{0x16, 0x03}, true},
+		{"wrong content type", []byte{0x17, 0x03, 0x03, 0x00, 0x04, 0x02, 0x00, 0x00, 0x00}, true},
+		{"wrong handshake type (ClientHello)", []byte{0x16, 0x03, 0x03, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00}, true},
+		{"truncated record", []byte{0x16, 0x03, 0x03, 0x00, 0xff, 0x02, 0x00, 0x00, 0x00}, true},
+		// Positive control: without it, a parser that always errors keeps this table green.
+		{"valid ServerHello", buildServerHello(0x0303, 32, 0x1301, ext(0x002b, []byte{0x03, 0x04})), false},
 	}
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			if _, err := ParseServerHello(tc.raw); err == nil {
+			_, err := ParseServerHello(tc.raw)
+			if tc.wantErr && err == nil {
 				t.Errorf("expected error, got nil")
 			}
+			if !tc.wantErr && err != nil {
+				t.Errorf("expected no error, got %v", err)
+			}
 		})
 	}
 }
@@ -161,8 +168,8 @@ func TestComputeJA4S_Shape(t *testing.T) {
 	// Synthesize a typical TLS 1.3 ServerHello with [supported_versions, key_share]
 	// and assert the output shape: t<ver><nn><alpn>_<cipher>_<12hex>
 	sh := &ServerHelloFields{
-		LegacyVersion:     0x0303,
-		CipherSuite:       0x1301,
+		LegacyVersion:    0x0303,
+		CipherSuite:      0x1301,
 		Extensions:       []uint16{0x002b, 0x0033},
 		SupportedVersion: 0x0304,
 	}
@@ -175,30 +182,67 @@ func TestComputeJA4S_Shape(t *testing.T) {
 }
 
 func TestComputeJA4S_KnownFixture(t *testing.T) {
-	// Pinned value computed from this exact input. Acts as a regression guard
-	// against accidental changes to the hashing/format.
+	// The extension list is DESCENDING on purpose. JA4S hashes wire order and
+	// must not sort; an ascending fixture cannot tell the two apart.
 	sh := &ServerHelloFields{
-		LegacyVersion:     0x0303,
-		CipherSuite:       0x1301,
-		Extensions:       []uint16{0x002b, 0x0033},
+		LegacyVersion:    0x0303,
+		CipherSuite:      0x1301,
+		Extensions:       []uint16{0x0033, 0x002b},
 		SupportedVersion: 0x0304,
 	}
 	got := ComputeJA4S(sh)
-	const want = "t130200_1301_" // prefix; suffix is the 12-hex hash we pin below
+	const want = "t130200_1301_"
 	if got[:len(want)] != want {
 		t.Errorf("JA4S prefix = %q; want prefix %q", got, want)
 	}
-	// Hash of "002b,0033". Pinned: if this changes, the algorithm is wrong.
-	const wantHash = "a56c5b993250"
+	// sha256("0033,002b")[:12]. Sorting would give sha256("002b,0033")[:12]
+	// = a56c5b993250 instead, which this pin rejects.
+	const wantHash = "234ea6891581"
 	if got[len(want):] != wantHash {
 		t.Errorf("JA4S ext hash = %q; want %q", got[len(want):], wantHash)
 	}
 }
 
+func TestComputeJA4S_EmptyExtensions(t *testing.T) {
+	// Pre-TLS-1.3 ServerHello with no extensions block: JA4S_c is all zeroes,
+	// not sha256("").
+	sh := &ServerHelloFields{LegacyVersion: 0x0303, CipherSuite: 0xc02f}
+	if got := ComputeJA4S(sh); got != "t120000_c02f_000000000000" {
+		t.Errorf("JA4S = %q; want t120000_c02f_000000000000", got)
+	}
+}
+
+func TestJA4SALPNCode(t *testing.T) {
+	cases := []struct {
+		alpn string
+		want string
+	}{
+		{"", "00"},
+		{"h2", "h2"},
+		{"http/1.1", "h1"},
+		{"h", "hh"},
+		{"h3", "h3"},
+		{"\xab", "ab"},
+		{"\x20", "20"},
+		{"\xab\xcd", "ad"},
+		{"\x20\x61", "21"},
+		{"\x30\xab", "3b"},
+		{"\x61\x20", "60"},
+		{"\x30\x31\xab\xcd", "3d"},
+		// Both end bytes are alphanumeric, so the hex fallback must NOT trigger.
+		{"\x30\xab\xcd\x31", "01"},
+	}
+	for _, tc := range cases {
+		if got := ja4sALPNCode(tc.alpn); got != tc.want {
+			t.Errorf("ja4sALPNCode(%q) = %q; want %q", tc.alpn, got, tc.want)
+		}
+	}
+}
+
 func TestComputeJA4S_ALPN_h2(t *testing.T) {
 	sh := &ServerHelloFields{
-		LegacyVersion:     0x0303,
-		CipherSuite:       0x1301,
+		LegacyVersion:    0x0303,
+		CipherSuite:      0x1301,
 		Extensions:       []uint16{0x002b, 0x0010, 0x0033},
 		SupportedVersion: 0x0304,
 		ALPN:             "h2",