mic.git


mic

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

commit 2755fcab5a991fde9ac1ddd2dae631fc5bd7f852
parent 63ac6e99f29fe0225de78d8e691ad2dec2d5757a
Author: djnn <[email protected]>
Date: Fri, 20 Feb 2026 11:14:11 +0900

feat(proxy): JA4 fingerprint verification integration tests

Diffstat:
Aproxy/ja4_integration_test.go | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aproxy/ja4capture_test.go | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

2 files changed, 238 insertions(+), 0 deletions(-)

diff --git a/proxy/ja4_integration_test.go b/proxy/ja4_integration_test.go
@@ -0,0 +1,138 @@
+//go:build integration
+
+package proxy
+
+import (
+	"bufio"
+	"crypto/tls"
+	"fmt"
+	"io"
+	"net"
+	"testing"
+	"time"
+
+	"github.com/djnnvx/mic/fingerprint"
+)
+
+// expectedJA4 maps fingerprint profile names to their known JA4 strings.
+// Values come from fingerprint.Table (measured empirically via cmd/probe against
+// tlsinfo.me). chrome-120-pq is absent: it emits the same JA4 as chrome-120
+// because JA4 does not distinguish the X25519MLKEM768 key share.
+var expectedJA4 = map[string]string{
+	"chrome-120":  "t13d1516h2_8daaf6152771_02713d6af862",
+	"firefox-120": "t13d1715h2_5b57614c22b0_5c2c66f702b0",
+	"safari-16":   "t13d2014h2_a09f3c656075_14788d8d241b",
+	"edge-106":    "t13d1516h2_8daaf6152771_e5627efa2ab1",
+}
+
+// TestClientFront_JA4 verifies that each fingerprint profile produces the
+// expected JA4 on outbound connections in client-front (HTTP CONNECT) mode.
+//
+//	test client → TCP CONNECT → proxy (HttpsHandler) → uTLS(profile) → captureJA4Server
+func TestClientFront_JA4(t *testing.T) {
+	for name, want := range expectedJA4 {
+		name, want := name, want
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			captureAddr, capturePool, ja4s := captureJA4Server(t)
+
+			fp, err := fingerprint.ByName(name)
+			if err != nil {
+				t.Fatalf("fingerprint.ByName(%q): %v", name, err)
+			}
+
+			proxyAddr := serveHandler(t, &Proxy{CAPool: capturePool, Fingerprint: fp}, HttpsHandler)
+
+			conn, err := net.DialTimeout("tcp", proxyAddr, testTimeout)
+			if err != nil {
+				t.Fatalf("dial proxy: %v", err)
+			}
+			defer conn.Close()
+			conn.SetDeadline(time.Now().Add(testTimeout)) //nolint:errcheck
+
+			fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", captureAddr, captureAddr)
+			if status, err := readHTTPStatus(bufio.NewReader(conn)); err != nil || status != 200 {
+				t.Fatalf("CONNECT: status=%d err=%v", status, err)
+			}
+
+			// Send a plain HTTP request through the uTLS tunnel the proxy established.
+			fmt.Fprintf(conn, "GET / HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", captureAddr)
+			io.Copy(io.Discard, conn) //nolint:errcheck
+
+			if got := receiveJA4(t, ja4s); got != want {
+				t.Errorf("JA4 mismatch for %s:\n  got  %s\n  want %s", name, got, want)
+			}
+		})
+	}
+}
+
+// TestServerFront_JA4 verifies that each fingerprint profile produces the
+// expected JA4 on outbound connections in server-front (TLS termination) mode.
+//
+//	test client → TLS → proxy (ServerFrontHandler) → uTLS(profile) → captureJA4Server
+func TestServerFront_JA4(t *testing.T) {
+	for name, want := range expectedJA4 {
+		name, want := name, want
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			captureAddr, capturePool, ja4s := captureJA4Server(t)
+			proxyCertFile, proxyKeyFile, proxyPool := writeTempCertKey(t)
+
+			fp, err := fingerprint.ByName(name)
+			if err != nil {
+				t.Fatalf("fingerprint.ByName(%q): %v", name, err)
+			}
+
+			p := &Proxy{BackendAddr: captureAddr, CAPool: capturePool, Fingerprint: fp}
+			proxyAddr := serveHandler(t, p, ServerFrontHandler(proxyCertFile, proxyKeyFile))
+
+			tlsConn, err := tls.DialWithDialer(
+				&net.Dialer{Timeout: testTimeout},
+				"tcp", proxyAddr,
+				&tls.Config{RootCAs: proxyPool, ServerName: "localhost"},
+			)
+			if err != nil {
+				t.Fatalf("tls.Dial to proxy: %v", err)
+			}
+			defer tlsConn.Close()
+			tlsConn.SetDeadline(time.Now().Add(testTimeout)) //nolint:errcheck
+
+			fmt.Fprintf(tlsConn, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
+			io.Copy(io.Discard, tlsConn) //nolint:errcheck
+
+			if got := receiveJA4(t, ja4s); got != want {
+				t.Errorf("JA4 mismatch for %s:\n  got  %s\n  want %s", name, got, want)
+			}
+		})
+	}
+}
+
+func readHTTPStatus(r *bufio.Reader) (int, error) {
+	line, err := r.ReadString('\n')
+	if err != nil {
+		return 0, err
+	}
+	var proto string
+	var status int
+	fmt.Sscanf(line, "%s %d", &proto, &status)
+	for {
+		h, err := r.ReadString('\n')
+		if err != nil || h == "\r\n" {
+			break
+		}
+	}
+	return status, nil
+}
+
+func receiveJA4(t *testing.T, ja4s <-chan string) string {
+	t.Helper()
+	select {
+	case ja4 := <-ja4s:
+		return ja4
+	case <-time.After(testTimeout):
+		t.Fatal("timed out waiting for captured JA4")
+		return ""
+	}
+}
diff --git a/proxy/ja4capture_test.go b/proxy/ja4capture_test.go
@@ -0,0 +1,100 @@
+//go:build integration
+
+package proxy
+
+import (
+	"bufio"
+	"bytes"
+	"crypto/tls"
+	"crypto/x509"
+	"fmt"
+	"io"
+	"net"
+	"testing"
+	"time"
+
+	"github.com/djnnvx/mic/fingerprint"
+)
+
+// teeConn tees all bytes read from it into an io.Writer, letting us capture
+// the raw ClientHello while tls.Server reads from the connection normally.
+type teeConn struct {
+	net.Conn
+	r io.Reader
+}
+
+func (tc *teeConn) Read(b []byte) (int, error) { return tc.r.Read(b) }
+
+// captureJA4Server starts a TLS listener that captures the JA4 fingerprint
+// from each incoming connection's ClientHello and sends it on the returned channel.
+// The server also serves a minimal HTTP/1.1 200 response so callers can complete
+// their request without stalling.
+//
+// The returned addr is "localhost:PORT" (not "127.0.0.1:PORT") so that
+// dialTarget uses a hostname SNI rather than an IP address. This matches the
+// "d" (domain) indicator in the stored JA4 fingerprint table, which was
+// measured against tlsinfo.me — also a hostname.
+func captureJA4Server(t *testing.T) (addr string, certPool *x509.CertPool, ja4s <-chan string) {
+	t.Helper()
+
+	pool, tlsCert := generateSelfSignedCert(t)
+	tlsCfg := &tls.Config{Certificates: []tls.Certificate{tlsCert}}
+
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("captureJA4Server: listen: %v", err)
+	}
+	t.Cleanup(func() { ln.Close() })
+
+	_, port, _ := net.SplitHostPort(ln.Addr().String())
+
+	ch := make(chan string, 16)
+	go func() {
+		for {
+			conn, err := ln.Accept()
+			if err != nil {
+				return
+			}
+			go serveCapture(conn, tlsCfg, ch)
+		}
+	}()
+
+	return "localhost:" + port, pool, ch
+}
+
+func serveCapture(conn net.Conn, cfg *tls.Config, ch chan<- string) {
+	defer conn.Close()
+	conn.SetDeadline(time.Now().Add(10 * time.Second)) //nolint:errcheck
+
+	var buf bytes.Buffer
+	tc := &teeConn{Conn: conn, r: io.TeeReader(conn, &buf)}
+
+	srv := tls.Server(tc, cfg)
+	if err := srv.Handshake(); err != nil {
+		if ja4, err2 := captureToJA4(buf.Bytes()); err2 == nil {
+			ch <- ja4
+		}
+		return
+	}
+
+	if ja4, err := captureToJA4(buf.Bytes()); err == nil {
+		ch <- ja4
+	}
+
+	reader := bufio.NewReader(srv)
+	for {
+		line, err := reader.ReadString('\n')
+		if err != nil || line == "\r\n" {
+			break
+		}
+	}
+	fmt.Fprintf(srv, "HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nOK\n")
+}
+
+func captureToJA4(raw []byte) (string, error) {
+	ch, err := fingerprint.ParseClientHello(raw)
+	if err != nil {
+		return "", err
+	}
+	return fingerprint.ComputeJA4(ch), nil
+}