mic.git


mic

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

commit 2de3ddab267698f85d1680f1d68d6c5d5906bb12
parent 4380d634e424cbd175dbcf8eea6a54df99a5e683
Author: djnn <[email protected]>
Date: Sun, 17 May 2026 19:23:03 +0900

feat(tls): JA4S measurements

Diffstat:
MREADME.md | 45++++++++++++++++++++++++++++++++++++++-------
Mcmd/client.go | 2+-
Mcmd/probe/main.go | 2+-
Mfingerprint/ja4.go | 2+-
Afingerprint/ja4s.go | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afingerprint/ja4s_test.go | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproxy/https.go | 6+++---
Mproxy/ja4capture_test.go | 2+-
Aproxy/ja4s_capture_test.go | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aproxy/ja4s_integration_test.go | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

10 files changed, 693 insertions(+), 14 deletions(-)

diff --git a/README.md b/README.md
@@ -7,11 +7,11 @@ servers, making your traffic look like a specific browser to any fingerprinting 
 
 Two modes are supported:
 
-- **client-front** — HTTP CONNECT proxy with optional MitM TLS interception. The proxy
+- **client-front**: HTTP CONNECT proxy with optional MitM TLS interception. The proxy
   generates a local CA, issues per-host leaf certs on the fly, terminates TLS from
   the client, and re-dials the target with the configured uTLS fingerprint. Standard
   tools (curl, browsers) work after importing the CA once.
-- **server-front** — the proxy terminates incoming TLS (with your own cert/key), then
+- **server-front**: the proxy terminates incoming TLS (with your own cert/key), then
   re-dials the backend with the configured fingerprint. Useful when the client cannot
   be configured to use a CONNECT proxy.
 
@@ -29,9 +29,11 @@ sequenceDiagram
         note over C,T: client-front mode (MitM TLS)
         C->>P: HTTP CONNECT target:443
         P->>T: TCP + uTLS handshake (configured fingerprint)
+        note right of P: JA4 = configured profile
         P-->>C: 200 Connection Established
         C->>P: TLS handshake (mic-issued cert for target)
         P-->>C: TLS established
+        note left of P: JA4S = stdlib crypto/tls (measured only)
         C->>P: HTTP request (decrypted by proxy)
         P->>T: request bytes (through uTLS tunnel)
         T-->>P: HTTP response (through uTLS)
@@ -42,7 +44,9 @@ sequenceDiagram
         note over C,T: server-front mode
         C->>P: TLS handshake (proxy cert)
         P-->>C: TLS established
+        note left of P: JA4S = stdlib crypto/tls (measured only)
         P->>T: TCP + uTLS handshake (configured fingerprint)
+        note right of P: JA4 = configured profile
         C->>P: HTTP request (decrypted by proxy)
         P->>T: request bytes (through uTLS tunnel)
         T-->>P: HTTP response (through uTLS)
@@ -50,8 +54,10 @@ sequenceDiagram
     end
 ```
 
-In both modes the target sees a TLS handshake that matches the configured fingerprint,
-not the default Go TLS fingerprint.
+In both modes the target sees a TLS handshake that matches the configured fingerprint
+(JA4), not the default Go TLS fingerprint. The ServerHello mic returns to the client
+(JA4S) is currently emitted by stdlib `crypto/tls` and is only measured, not yet
+spoofed. See the JA4S section below for details.
 
 ---
 
@@ -79,7 +85,7 @@ the proxy is transparently intercepted and re-dialled with the configured finger
 **Trust the CA** (pick whichever applies):
 
 ```bash
-# curl — pass on every call, or set CURL_CA_BUNDLE
+# curl: pass on every call, or set CURL_CA_BUNDLE
 curl --cacert ca.pem -x http://localhost:8080 https://tlsinfo.me/json
 
 # Debian / Ubuntu / Kali
@@ -93,7 +99,7 @@ After trusting, standard tools work without extra flags:
 
 ```bash
 curl -x http://localhost:8080 https://tlsinfo.me/json
-# check the "ja4" field — it should match the fingerprint you configured
+# check the "ja4" field. It should match the fingerprint you configured.
 ```
 
 Omit `--intercept-*` to run as a plain CONNECT proxy (no MitM, raw tunnel only).
@@ -154,7 +160,7 @@ preset actually emits. Re-run the probe after upgrading the utls dependency.
 # unit tests
 go test ./...
 
-# integration tests — spin up in-process TLS servers, no network required
+# integration tests: spin up in-process TLS servers, no network required
 go test -tags integration -v ./proxy/... ./fingerprint/...
 ```
 
@@ -164,3 +170,28 @@ by the proxy's uTLS engine and assert the computed JA4 hash matches the expected
 This is the offline equivalent of running `cmd/probe` against tlsinfo.me.
 
 CI runs both suites on every push via `.github/workflows/ci.yml`.
+
+---
+
+## JA4S (server-side fingerprint, measurement only)
+
+mic also computes JA4S, the fingerprint of the ServerHello it emits to clients.
+This is currently **measurement only**: there is no spoofing engine. The
+integration tests `TestServerFront_JA4S_Baseline` and `TestClientFront_JA4S_Baseline`
+capture the bytes mic writes during the TLS handshake and assert the JA4S matches
+a recorded baseline.
+
+The baseline is what stdlib `crypto/tls.Server` emits today: `t130200_1301_a56c5b993250`
+(TLS 1.3, no ALPN, `TLS_AES_128_GCM_SHA256`, default extension set). It applies to
+both modes because both terminate TLS with the same stdlib server.
+
+Why no spoofing yet:
+
+- `bogdanfinn/utls` does not expose server-side fingerprint control. Its `Server()`
+  delegates to stdlib's TLS state machine.
+- TLS 1.3 ServerHello carries only 2-3 extensions, so the JA4S surface is much
+  smaller than JA4.
+
+Spoofing would require either forking utls to add a `ServerHelloSpec`, or writing a
+custom TLS server. The measurement code is in place so we can see if that effort is
+worth it once we have real-world JA4S targets.
diff --git a/cmd/client.go b/cmd/client.go
@@ -51,7 +51,7 @@ func newClientCmd() *cobra.Command {
 					return fmt.Errorf("loading/generating intercept CA: %w", err)
 				}
 				p.LocalCA = ca
-				log.Printf("[+] MitM CA ready — import %s, then: curl --cacert %s -x http://localhost%s https://<target>",
+				log.Printf("[+] MitM CA ready. Import %s, then: curl --cacert %s -x http://localhost%s https://<target>",
 					interceptCert, interceptCert, listen)
 			}
 
diff --git a/cmd/probe/main.go b/cmd/probe/main.go
@@ -75,7 +75,7 @@ func probe(id utls.ClientHelloID) (string, error) {
 
 	if uconn.ConnectionState().NegotiatedProtocol == "h2" {
 		// Reuse the already-established uconn rather than letting the Transport
-		// dial again — the first handshake is the one that produced the JA4 we
+		// dial again. The first handshake is the one that produced the JA4 we
 		// want to measure.
 		cc, err := (&http2.Transport{}).NewClientConn(uconn)
 		if err != nil {
diff --git a/fingerprint/ja4.go b/fingerprint/ja4.go
@@ -104,7 +104,7 @@ func ParseClientHello(raw []byte) (*ClientHelloFields, error) {
 	}
 	r = r[cmLen:]
 
-	// Extensions are absent in very old TLS — not an error.
+	// Extensions are absent in very old TLS. Not an error.
 	if len(r) < 2 {
 		return fields, nil
 	}
diff --git a/fingerprint/ja4s.go b/fingerprint/ja4s.go
@@ -0,0 +1,195 @@
+package fingerprint
+
+import (
+	"crypto/sha256"
+	"encoding/binary"
+	"fmt"
+	"strings"
+)
+
+// ServerHelloFields holds the parsed TLS ServerHello data used to compute JA4S.
+type ServerHelloFields struct {
+	LegacyVersion     uint16
+	CipherSuite       uint16   // single chosen suite
+	Extensions        []uint16 // non-GREASE extension types, wire order
+	SupportedVersions []uint16 // from ext 0x002b: in ServerHello, the single chosen version
+	ALPNValues        []string // from ext 0x0010: in ServerHello, the single chosen protocol
+}
+
+// ParseServerHello parses the first TLS handshake record from raw, expecting
+// a ServerHello. raw must start with a handshake record header (content type 0x16).
+func ParseServerHello(raw []byte) (*ServerHelloFields, error) {
+	if len(raw) < 5 {
+		return nil, fmt.Errorf("ja4s: record too short (%d bytes)", len(raw))
+	}
+	if raw[0] != 0x16 {
+		return nil, fmt.Errorf("ja4s: not a handshake record (type=0x%02x)", raw[0])
+	}
+	recLen := int(binary.BigEndian.Uint16(raw[3:5]))
+	if len(raw) < 5+recLen {
+		return nil, fmt.Errorf("ja4s: record body truncated (need %d, have %d)", 5+recLen, len(raw))
+	}
+	hs := raw[5 : 5+recLen]
+
+	if len(hs) < 4 {
+		return nil, fmt.Errorf("ja4s: handshake header too short")
+	}
+	if hs[0] != 0x02 {
+		return nil, fmt.Errorf("ja4s: not a ServerHello (handshake type=0x%02x)", hs[0])
+	}
+	shLen := int(hs[1])<<16 | int(hs[2])<<8 | int(hs[3])
+	if len(hs) < 4+shLen {
+		return nil, fmt.Errorf("ja4s: ServerHello body truncated")
+	}
+	r := hs[4 : 4+shLen]
+
+	fields := &ServerHelloFields{}
+
+	if len(r) < 2 {
+		return nil, fmt.Errorf("ja4s: too short for legacy version")
+	}
+	fields.LegacyVersion = binary.BigEndian.Uint16(r[:2])
+	r = r[2:]
+
+	if len(r) < 32 {
+		return nil, fmt.Errorf("ja4s: too short for random")
+	}
+	r = r[32:]
+
+	if len(r) < 1 {
+		return nil, fmt.Errorf("ja4s: too short for session_id_echo length")
+	}
+	sidLen := int(r[0])
+	r = r[1:]
+	if len(r) < sidLen {
+		return nil, fmt.Errorf("ja4s: too short for session_id_echo")
+	}
+	r = r[sidLen:]
+
+	if len(r) < 2 {
+		return nil, fmt.Errorf("ja4s: too short for cipher suite")
+	}
+	fields.CipherSuite = binary.BigEndian.Uint16(r[:2])
+	r = r[2:]
+
+	if len(r) < 1 {
+		return nil, fmt.Errorf("ja4s: too short for compression method")
+	}
+	r = r[1:]
+
+	// Pre-TLS-1.3 ServerHellos may omit the extensions block entirely.
+	if len(r) < 2 {
+		return fields, nil
+	}
+	extsLen := int(binary.BigEndian.Uint16(r[:2]))
+	r = r[2:]
+	if len(r) < extsLen {
+		return nil, fmt.Errorf("ja4s: extensions block truncated")
+	}
+	exts := r[:extsLen]
+
+	for len(exts) >= 4 {
+		extType := binary.BigEndian.Uint16(exts[:2])
+		extLen := int(binary.BigEndian.Uint16(exts[2:4]))
+		exts = exts[4:]
+		if len(exts) < extLen {
+			return nil, fmt.Errorf("ja4s: extension 0x%04x data truncated", extType)
+		}
+		extData := exts[:extLen]
+		exts = exts[extLen:]
+
+		if isGREASE(extType) {
+			continue
+		}
+		fields.Extensions = append(fields.Extensions, extType)
+
+		switch extType {
+		case 0x002b:
+			parseServerSupportedVersion(extData, fields)
+		case 0x0010:
+			parseServerALPN(extData, fields)
+		}
+	}
+
+	return fields, nil
+}
+
+// parseServerSupportedVersion reads the single chosen TLS version (2 bytes).
+// Unlike the ClientHello variant which carries a list, the ServerHello variant
+// is a bare uint16.
+func parseServerSupportedVersion(data []byte, f *ServerHelloFields) {
+	if len(data) < 2 {
+		return
+	}
+	v := binary.BigEndian.Uint16(data[:2])
+	if !isGREASE(v) {
+		f.SupportedVersions = append(f.SupportedVersions, v)
+	}
+}
+
+// parseServerALPN reads the ALPN extension as the server emits it. Wire format
+// is the same as in ClientHello, but the server only ever lists one protocol.
+func parseServerALPN(data []byte, f *ServerHelloFields) {
+	if len(data) < 2 {
+		return
+	}
+	listLen := int(binary.BigEndian.Uint16(data[:2]))
+	data = data[2:]
+	if len(data) < listLen {
+		return
+	}
+	data = data[:listLen]
+	for len(data) >= 1 {
+		pLen := int(data[0])
+		data = data[1:]
+		if len(data) < pLen {
+			break
+		}
+		f.ALPNValues = append(f.ALPNValues, string(data[:pLen]))
+		data = data[pLen:]
+	}
+}
+
+// ComputeJA4S builds the JA4S hash from a parsed ServerHello.
+// Format: t<version><nn><alpn>_<cipher>_<exthash>
+// Always "t" for TCP; QUIC is not produced by mic.
+func ComputeJA4S(sh *ServerHelloFields) string {
+	return buildJA4Sa(sh) + "_" + buildJA4Sb(sh.CipherSuite) + "_" + buildJA4Sc(sh.Extensions)
+}
+
+func buildJA4Sa(sh *ServerHelloFields) string {
+	ver := tlsVersionStr(sh.LegacyVersion)
+	if len(sh.SupportedVersions) > 0 {
+		ver = tlsVersionStr(sh.SupportedVersions[0])
+	}
+
+	nn := min99(len(sh.Extensions))
+
+	alpn := "00"
+	if len(sh.ALPNValues) > 0 {
+		v := sh.ALPNValues[0]
+		switch {
+		case len(v) >= 2:
+			alpn = v[:2]
+		case len(v) == 1:
+			alpn = v + "0"
+		}
+	}
+
+	return fmt.Sprintf("t%s%02d%s", ver, nn, alpn)
+}
+
+func buildJA4Sb(cipher uint16) string {
+	return fmt.Sprintf("%04x", cipher)
+}
+
+// buildJA4Sc hashes ServerHello extension types in wire order (no sort).
+// This is the difference from JA4_c, which sorts.
+func buildJA4Sc(exts []uint16) string {
+	parts := make([]string, len(exts))
+	for i, e := range exts {
+		parts[i] = fmt.Sprintf("%04x", e)
+	}
+	h := sha256.Sum256([]byte(strings.Join(parts, ",")))
+	return fmt.Sprintf("%x", h)[:12]
+}
diff --git a/fingerprint/ja4s_test.go b/fingerprint/ja4s_test.go
@@ -0,0 +1,213 @@
+package fingerprint
+
+import (
+	"bytes"
+	"encoding/binary"
+	"regexp"
+	"testing"
+)
+
+// buildServerHello synthesizes a minimal TLS handshake record carrying a
+// ServerHello, returned as raw bytes ready for ParseServerHello.
+//
+// extensions is the wire-encoded extensions block (type + len + data tuples),
+// pre-built so each test can choose exactly what to include and in what order.
+func buildServerHello(legacyVer uint16, sessionIDLen int, cipher uint16, extensions []byte) []byte {
+	var body bytes.Buffer
+
+	binary.Write(&body, binary.BigEndian, legacyVer)
+	body.Write(make([]byte, 32)) // random
+
+	body.WriteByte(byte(sessionIDLen))
+	body.Write(make([]byte, sessionIDLen))
+
+	binary.Write(&body, binary.BigEndian, cipher)
+	body.WriteByte(0x00) // compression_method = null
+
+	binary.Write(&body, binary.BigEndian, uint16(len(extensions)))
+	body.Write(extensions)
+
+	// handshake header: type(0x02) + 3-byte length
+	hsLen := body.Len()
+	hs := []byte{0x02, byte(hsLen >> 16), byte(hsLen >> 8), byte(hsLen)}
+	hs = append(hs, body.Bytes()...)
+
+	// record header: type(0x16) + version(0x0303) + 2-byte length
+	rec := []byte{0x16, 0x03, 0x03, byte(len(hs) >> 8), byte(len(hs))}
+	return append(rec, hs...)
+}
+
+func ext(t uint16, data []byte) []byte {
+	out := make([]byte, 4+len(data))
+	binary.BigEndian.PutUint16(out[0:2], t)
+	binary.BigEndian.PutUint16(out[2:4], uint16(len(data)))
+	copy(out[4:], data)
+	return out
+}
+
+func TestParseServerHello_TLS13_Minimal(t *testing.T) {
+	// TLS 1.3 ServerHello: supported_versions(0x002b) carries the chosen 0x0304,
+	// plus key_share(0x0033) with empty data.
+	var exts []byte
+	exts = append(exts, ext(0x002b, []byte{0x03, 0x04})...) // chosen version
+	exts = append(exts, ext(0x0033, []byte{})...)           // key_share (data irrelevant)
+
+	raw := buildServerHello(0x0303, 32, 0x1301, exts)
+
+	sh, err := ParseServerHello(raw)
+	if err != nil {
+		t.Fatalf("ParseServerHello: %v", err)
+	}
+
+	if sh.CipherSuite != 0x1301 {
+		t.Errorf("cipher = 0x%04x; want 0x1301", sh.CipherSuite)
+	}
+	if got := len(sh.Extensions); got != 2 {
+		t.Errorf("extension count = %d; want 2", got)
+	}
+	if len(sh.SupportedVersions) != 1 || sh.SupportedVersions[0] != 0x0304 {
+		t.Errorf("supported versions = %v; want [0x0304]", sh.SupportedVersions)
+	}
+	if len(sh.ALPNValues) != 0 {
+		t.Errorf("alpn = %v; want none", sh.ALPNValues)
+	}
+}
+
+func TestParseServerHello_TLS13_WithALPN_h2(t *testing.T) {
+	// ALPN extension carrying a single chosen protocol "h2".
+	alpnList := []byte{0x00, 0x03, 0x02, 'h', '2'}
+
+	var exts []byte
+	exts = append(exts, ext(0x002b, []byte{0x03, 0x04})...)
+	exts = append(exts, ext(0x0010, alpnList)...)
+	exts = append(exts, ext(0x0033, []byte{})...)
+
+	raw := buildServerHello(0x0303, 0, 0x1302, exts)
+
+	sh, err := ParseServerHello(raw)
+	if err != nil {
+		t.Fatalf("ParseServerHello: %v", err)
+	}
+	if len(sh.ALPNValues) != 1 || sh.ALPNValues[0] != "h2" {
+		t.Fatalf("alpn = %v; want [h2]", sh.ALPNValues)
+	}
+}
+
+func TestParseServerHello_TLS12_NoSupportedVersions(t *testing.T) {
+	// TLS 1.2 ServerHello: legacy_version is the chosen version. No
+	// supported_versions extension. Often has session_ticket(0x0023) etc.
+	exts := ext(0x0023, []byte{}) // session_ticket, empty
+	raw := buildServerHello(0x0303, 32, 0xc02f, exts)
+
+	sh, err := ParseServerHello(raw)
+	if err != nil {
+		t.Fatalf("ParseServerHello: %v", err)
+	}
+	if sh.LegacyVersion != 0x0303 {
+		t.Errorf("legacy version = 0x%04x; want 0x0303", sh.LegacyVersion)
+	}
+	if len(sh.SupportedVersions) != 0 {
+		t.Errorf("supported_versions present in TLS 1.2 fixture: %v", sh.SupportedVersions)
+	}
+	if sh.CipherSuite != 0xc02f {
+		t.Errorf("cipher = 0x%04x; want 0xc02f", sh.CipherSuite)
+	}
+}
+
+func TestParseServerHello_GREASEFiltered(t *testing.T) {
+	// GREASE extension type 0x0a0a should not appear in the parsed list.
+	var exts []byte
+	exts = append(exts, ext(0x0a0a, []byte{})...) // GREASE
+	exts = append(exts, ext(0x002b, []byte{0x03, 0x04})...)
+	exts = append(exts, ext(0x0033, []byte{})...)
+
+	raw := buildServerHello(0x0303, 0, 0x1301, exts)
+
+	sh, err := ParseServerHello(raw)
+	if err != nil {
+		t.Fatalf("ParseServerHello: %v", err)
+	}
+	for _, e := range sh.Extensions {
+		if e == 0x0a0a {
+			t.Errorf("GREASE extension 0x0a0a leaked into parsed extensions")
+		}
+	}
+	if len(sh.Extensions) != 2 {
+		t.Errorf("extension count = %d; want 2 (GREASE filtered)", len(sh.Extensions))
+	}
+}
+
+func TestParseServerHello_Errors(t *testing.T) {
+	cases := []struct {
+		name string
+		raw  []byte
+	}{
+		{"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}},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if _, err := ParseServerHello(tc.raw); err == nil {
+				t.Errorf("expected error, got nil")
+			}
+		})
+	}
+}
+
+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,
+		Extensions:        []uint16{0x002b, 0x0033},
+		SupportedVersions: []uint16{0x0304},
+		ALPNValues:        nil,
+	}
+	got := ComputeJA4S(sh)
+
+	re := regexp.MustCompile(`^t1302(00|[a-z0-9]{2})_[0-9a-f]{4}_[0-9a-f]{12}$`)
+	if !re.MatchString(got) {
+		t.Errorf("JA4S = %q; does not match expected shape", got)
+	}
+}
+
+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.
+	sh := &ServerHelloFields{
+		LegacyVersion:     0x0303,
+		CipherSuite:       0x1301,
+		Extensions:        []uint16{0x002b, 0x0033},
+		SupportedVersions: []uint16{0x0304},
+	}
+	got := ComputeJA4S(sh)
+	const want = "t130200_1301_" // prefix; suffix is the 12-hex hash we pin below
+	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"
+	if got[len(want):] != wantHash {
+		t.Errorf("JA4S ext hash = %q; want %q", got[len(want):], wantHash)
+	}
+}
+
+func TestComputeJA4S_ALPN_h2(t *testing.T) {
+	sh := &ServerHelloFields{
+		LegacyVersion:     0x0303,
+		CipherSuite:       0x1301,
+		Extensions:        []uint16{0x002b, 0x0010, 0x0033},
+		SupportedVersions: []uint16{0x0304},
+		ALPNValues:        []string{"h2"},
+	}
+	got := ComputeJA4S(sh)
+	// nn=03, alpn=h2
+	const wantPrefix = "t1303h2_1301_"
+	if got[:len(wantPrefix)] != wantPrefix {
+		t.Errorf("JA4S = %q; want prefix %q", got, wantPrefix)
+	}
+}
diff --git a/proxy/https.go b/proxy/https.go
@@ -9,9 +9,9 @@ import (
 )
 
 // parseAuthority extracts the server name and a dial-ready "host:port" from
-// a CONNECT request's authority. If the input lacks an explicit port (rare —
-// CONNECT requires one), 443 is assumed. IPv6 literals in brackets are
-// handled (e.g. "[::1]:443" → "::1", "[::1]:443").
+// a CONNECT request's authority. If the input lacks an explicit port (rare,
+// since CONNECT requires one), 443 is assumed. IPv6 literals in brackets are
+// handled (e.g. "[::1]:443" -> "::1", "[::1]:443").
 func parseAuthority(authority string) (server, hostPort string) {
 	if h, _, err := net.SplitHostPort(authority); err == nil {
 		return h, authority
diff --git a/proxy/ja4capture_test.go b/proxy/ja4capture_test.go
@@ -33,7 +33,7 @@ func (tc *teeConn) Read(b []byte) (int, error) { return tc.r.Read(b) }
 // 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.
+// measured against tlsinfo.me, also a hostname.
 func captureJA4Server(t *testing.T) (addr string, certPool *x509.CertPool, ja4s <-chan string) {
 	t.Helper()
 
diff --git a/proxy/ja4s_capture_test.go b/proxy/ja4s_capture_test.go
@@ -0,0 +1,70 @@
+//go:build integration
+
+package proxy
+
+import (
+	"bytes"
+	"net"
+	"sync"
+
+	"github.com/djnnvx/mic/fingerprint"
+)
+
+// teeWriteConn tees every Write into a buffer. As soon as the buffer contains
+// a complete ServerHello it computes the JA4S and emits it on ch (once). The
+// JA4S is what mic itself writes out: the server-side fingerprint.
+type teeWriteConn struct {
+	net.Conn
+	mu   sync.Mutex
+	buf  bytes.Buffer
+	ch   chan<- string
+	sent bool
+}
+
+func (t *teeWriteConn) Write(p []byte) (int, error) {
+	t.mu.Lock()
+	if !t.sent {
+		// Only start collecting once we see the first TLS handshake record.
+		// In client-front MitM the proxy writes a plaintext "HTTP/1.1 200" line
+		// through the same conn before the TLS handshake; those bytes are not
+		// part of the ServerHello and must not poison the buffer.
+		if t.buf.Len() > 0 || (len(p) >= 2 && p[0] == 0x16 && p[1] == 0x03) {
+			t.buf.Write(p)
+			if ja4s, err := captureToJA4S(t.buf.Bytes()); err == nil {
+				select {
+				case t.ch <- ja4s:
+				default:
+				}
+				t.sent = true
+			}
+		}
+	}
+	t.mu.Unlock()
+	return t.Conn.Write(p)
+}
+
+func captureToJA4S(raw []byte) (string, error) {
+	sh, err := fingerprint.ParseServerHello(raw)
+	if err != nil {
+		return "", err
+	}
+	return fingerprint.ComputeJA4S(sh), nil
+}
+
+// captureJA4SListener wraps every conn accepted on ln so that the ServerHello
+// mic writes back to the client is intercepted and emitted on the returned
+// channel.
+func captureJA4SListener(ln net.Listener, p *Proxy, h Handler) <-chan string {
+	ch := make(chan string, 16)
+	go func() {
+		for {
+			conn, err := ln.Accept()
+			if err != nil {
+				return
+			}
+			wrapped := &teeWriteConn{Conn: conn, ch: ch}
+			go h(wrapped, p)
+		}
+	}()
+	return ch
+}
diff --git a/proxy/ja4s_integration_test.go b/proxy/ja4s_integration_test.go
@@ -0,0 +1,170 @@
+//go:build integration
+
+package proxy
+
+import (
+	"bufio"
+	"crypto/tls"
+	"crypto/x509"
+	"fmt"
+	"net"
+	"net/http"
+	"net/http/httptest"
+	"regexp"
+	"testing"
+	"time"
+
+	"github.com/djnnvx/mic/fingerprint"
+)
+
+// JA4S well-formed shape: t<2-digit ver><2-digit ext-count><alpn>_<4-hex cipher>_<12-hex hash>
+var ja4sShape = regexp.MustCompile(`^t\d{2}\d{2}[a-z0-9]{2}_[0-9a-f]{4}_[0-9a-f]{12}$`)
+
+// Baselines: the JA4S mic emits today using stdlib crypto/tls (Go 1.25).
+// These are observation pins. If they drift, either stdlib's TLS behavior
+// changed or our parser broke. Verify with the captured value before updating.
+const (
+	baselineJA4S_ServerFront = "t130200_1301_a56c5b993250"
+	baselineJA4S_ClientFront = "t130200_1301_a56c5b993250"
+)
+
+// TestServerFront_JA4S_Baseline measures the JA4S mic emits when terminating
+// TLS as a server (server-front mode) using stdlib crypto/tls. No spoofing.
+//
+//	test client → TLS → mic (ServerFrontHandler, tls.Server) → httptest backend
+func TestServerFront_JA4S_Baseline(t *testing.T) {
+	backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Connection", "close")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintln(w, "ok")
+	}))
+	defer backend.Close()
+
+	backendPool := backend.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs
+	certFile, keyFile, proxyPool := writeTempCertKey(t)
+
+	handler, err := ServerFrontHandler(certFile, keyFile)
+	if err != nil {
+		t.Fatalf("ServerFrontHandler: %v", err)
+	}
+
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("listen: %v", err)
+	}
+	t.Cleanup(func() { ln.Close() })
+
+	p := &Proxy{BackendAddr: backend.Listener.Addr().String(), CAPool: backendPool}
+	ja4sCh := captureJA4SListener(ln, p, handler)
+
+	go func() {
+		tlsConn, err := tls.DialWithDialer(
+			&net.Dialer{Timeout: testTimeout},
+			"tcp", ln.Addr().String(),
+			&tls.Config{RootCAs: proxyPool, ServerName: "localhost"},
+		)
+		if err != nil {
+			return
+		}
+		defer tlsConn.Close()
+		fmt.Fprintf(tlsConn, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
+		_, _ = bufio.NewReader(tlsConn).ReadString('\n')
+	}()
+
+	select {
+	case got := <-ja4sCh:
+		t.Logf("server-front JA4S: %s", got)
+		if !ja4sShape.MatchString(got) {
+			t.Errorf("JA4S = %q; does not match expected shape", got)
+		}
+		if got != baselineJA4S_ServerFront {
+			t.Errorf("JA4S drift: got %q, baseline %q (update baseline if stdlib behavior changed intentionally)",
+				got, baselineJA4S_ServerFront)
+		}
+	case <-time.After(testTimeout):
+		t.Fatal("timeout waiting for JA4S")
+	}
+}
+
+// TestClientFront_JA4S_Baseline measures the JA4S mic emits when serving the
+// MitM cert in client-front mode (the proxy presents an issued leaf cert to
+// the client after the CONNECT).
+//
+//	test client → TCP CONNECT → mic (HttpsHandler, tls.Server with issued cert) → upstream
+func TestClientFront_JA4S_Baseline(t *testing.T) {
+	upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Connection", "close")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintln(w, "ok")
+	}))
+	defer upstream.Close()
+
+	upstreamPool := upstream.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs
+
+	ca, err := GenerateCA()
+	if err != nil {
+		t.Fatalf("GenerateCA: %v", err)
+	}
+
+	micCAPool := x509.NewCertPool()
+	if !micCAPool.AppendCertsFromPEM(ca.CertPEM()) {
+		t.Fatalf("failed to append mic CA to pool")
+	}
+
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("listen: %v", err)
+	}
+	t.Cleanup(func() { ln.Close() })
+
+	// Pin a deterministic upstream fingerprint so dialTarget never picks a
+	// randomized ClientHello with curves the test backend can't negotiate.
+	// The choice is irrelevant to JA4S, which only measures the ServerHello
+	// mic writes back to the test client.
+	fp, err := fingerprint.ByName("chrome-120")
+	if err != nil {
+		t.Fatalf("fingerprint.ByName: %v", err)
+	}
+
+	p := &Proxy{CAPool: upstreamPool, LocalCA: ca, Fingerprint: fp}
+	ja4sCh := captureJA4SListener(ln, p, HttpsHandler)
+
+	go func() {
+		conn, err := net.DialTimeout("tcp", ln.Addr().String(), testTimeout)
+		if err != nil {
+			return
+		}
+		defer conn.Close()
+		conn.SetDeadline(time.Now().Add(testTimeout))
+
+		upstreamAddr := upstream.Listener.Addr().String()
+		fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", upstreamAddr, upstreamAddr)
+
+		reader := bufio.NewReader(conn)
+		if _, err := http.ReadResponse(reader, nil); err != nil {
+			return
+		}
+
+		// Now do the TLS handshake with mic; mic will emit a ServerHello with
+		// the per-host cert it just issued.
+		tlsConn := tls.Client(conn, &tls.Config{
+			ServerName: "127.0.0.1",
+			RootCAs:    micCAPool,
+		})
+		_ = tlsConn.Handshake()
+	}()
+
+	select {
+	case got := <-ja4sCh:
+		t.Logf("client-front JA4S: %s", got)
+		if !ja4sShape.MatchString(got) {
+			t.Errorf("JA4S = %q; does not match expected shape", got)
+		}
+		if got != baselineJA4S_ClientFront {
+			t.Errorf("JA4S drift: got %q, baseline %q (update baseline if stdlib behavior changed intentionally)",
+				got, baselineJA4S_ClientFront)
+		}
+	case <-time.After(testTimeout):
+		t.Fatal("timeout waiting for JA4S")
+	}
+}