mic.git


mic

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

commit 900f09efb89ced82de022378fa3ed2e32bfa3de3
parent deb1a522ed8107488687b0f6ab7a603597050c9f
Author: djnn <[email protected]>
Date: Sat, 20 Jun 2026 11:52:15 +0900

feat(fgp): improving tests & simplifying code

Diffstat:
Mcmd/probe/main.go | 3++-
Dfingerprint/fingerprint.go | 15---------------
Mfingerprint/ja4.go | 53++++++++++++++++++++++++++++-------------------------
Afingerprint/ja4_test.go | 229+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mfingerprint/ja4s.go | 98++++++++++++++++++++++++-------------------------------------------------------
Mfingerprint/ja4s_test.go | 31+++++++++++++++----------------
Mfingerprint/tls.go | 2++
Aproxy/ca_test.go | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproxy/proxy.go | 2+-

9 files changed, 411 insertions(+), 127 deletions(-)

diff --git a/cmd/probe/main.go b/cmd/probe/main.go
@@ -7,6 +7,7 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"io"
 	"net"
 	"net/http"
 
@@ -55,7 +56,7 @@ func dialUTLS(ctx context.Context, id utls.ClientHelloID) (*utls.UConn, error) {
 	return uconn, nil
 }
 
-func decodeJA4(body interface{ Read([]byte) (int, error) }) (string, error) {
+func decodeJA4(body io.Reader) (string, error) {
 	var result struct {
 		JA4 string `json:"ja4"`
 	}
diff --git a/fingerprint/fingerprint.go b/fingerprint/fingerprint.go
@@ -1,15 +0,0 @@
-// Package fingerprint provides interfaces and implementations for controlling
-// outbound TLS connection fingerprints.
-package fingerprint
-
-import utls "github.com/bogdanfinn/utls"
-
-type Applier interface {
-	Name() string
-}
-
-// TLSApplier controls the uTLS ClientHello fingerprint for outbound connections.
-type TLSApplier interface {
-	Applier
-	ClientHelloID() utls.ClientHelloID
-}
diff --git a/fingerprint/ja4.go b/fingerprint/ja4.go
@@ -5,7 +5,7 @@ import (
 	"encoding/binary"
 	"fmt"
 	"net"
-	"sort"
+	"slices"
 	"strings"
 )
 
@@ -27,33 +27,43 @@ func isGREASE(v uint16) bool {
 	return v&0x0f == 0x0a && v>>8 == v&0xff
 }
 
-// ParseClientHello parses the first TLS handshake record from raw.
-// raw must start with a handshake record header (content type 0x16).
-func ParseClientHello(raw []byte) (*ClientHelloFields, error) {
+// handshakeBody unwraps the TLS record and handshake headers from raw,
+// verifying the record content type, the handshake type, and length fields.
+// It returns the handshake body. tag prefixes errors ("ja4" or "ja4s").
+func handshakeBody(raw []byte, hsType byte, tag string) ([]byte, error) {
 	if len(raw) < 5 {
-		return nil, fmt.Errorf("ja4: record too short (%d bytes)", len(raw))
+		return nil, fmt.Errorf("%s: record too short (%d bytes)", tag, len(raw))
 	}
 	if raw[0] != 0x16 {
-		return nil, fmt.Errorf("ja4: not a handshake record (type=0x%02x)", raw[0])
+		return nil, fmt.Errorf("%s: not a handshake record (type=0x%02x)", tag, raw[0])
 	}
 	recLen := int(binary.BigEndian.Uint16(raw[3:5]))
 	if len(raw) < 5+recLen {
-		return nil, fmt.Errorf("ja4: record body truncated (need %d, have %d)", 5+recLen, len(raw))
+		return nil, fmt.Errorf("%s: record body truncated (need %d, have %d)", tag, 5+recLen, len(raw))
 	}
 	hs := raw[5 : 5+recLen]
 
 	if len(hs) < 4 {
-		return nil, fmt.Errorf("ja4: handshake header too short")
+		return nil, fmt.Errorf("%s: handshake header too short", tag)
 	}
-	if hs[0] != 0x01 {
-		return nil, fmt.Errorf("ja4: not a ClientHello (handshake type=0x%02x)", hs[0])
+	if hs[0] != hsType {
+		return nil, fmt.Errorf("%s: unexpected handshake type 0x%02x (want 0x%02x)", tag, hs[0], hsType)
 	}
 	// Handshake length is 3 bytes, unlike most other TLS length fields.
-	chLen := int(hs[1])<<16 | int(hs[2])<<8 | int(hs[3])
-	if len(hs) < 4+chLen {
-		return nil, fmt.Errorf("ja4: ClientHello body truncated")
+	bodyLen := int(hs[1])<<16 | int(hs[2])<<8 | int(hs[3])
+	if len(hs) < 4+bodyLen {
+		return nil, fmt.Errorf("%s: handshake body truncated", tag)
+	}
+	return hs[4 : 4+bodyLen], nil
+}
+
+// ParseClientHello parses the first TLS handshake record from raw.
+// raw must start with a handshake record header (content type 0x16).
+func ParseClientHello(raw []byte) (*ClientHelloFields, error) {
+	r, err := handshakeBody(raw, 0x01, "ja4")
+	if err != nil {
+		return nil, err
 	}
-	r := hs[4 : 4+chLen]
 
 	fields := &ClientHelloFields{}
 
@@ -232,13 +242,7 @@ func ComputeJA4(ch *ClientHelloFields) string {
 func buildJA4a(ch *ClientHelloFields) string {
 	tlsVer := tlsVersionStr(ch.LegacyVersion)
 	if len(ch.SupportedVersions) > 0 {
-		max := uint16(0)
-		for _, v := range ch.SupportedVersions {
-			if v > max {
-				max = v
-			}
-		}
-		tlsVer = tlsVersionStr(max)
+		tlsVer = tlsVersionStr(slices.Max(ch.SupportedVersions))
 	}
 
 	sniChar := "n"
@@ -291,9 +295,8 @@ func tlsVersionStr(v uint16) string {
 }
 
 func buildJA4b(ciphers []uint16) string {
-	sorted := make([]uint16, len(ciphers))
-	copy(sorted, ciphers)
-	sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
+	sorted := slices.Clone(ciphers)
+	slices.Sort(sorted)
 
 	parts := make([]string, len(sorted))
 	for i, c := range sorted {
@@ -313,7 +316,7 @@ func buildJA4c(exts []uint16, sigAlgs []uint16) string {
 			filtered = append(filtered, e)
 		}
 	}
-	sort.Slice(filtered, func(i, j int) bool { return filtered[i] < filtered[j] })
+	slices.Sort(filtered)
 
 	extParts := make([]string, len(filtered))
 	for i, e := range filtered {
diff --git a/fingerprint/ja4_test.go b/fingerprint/ja4_test.go
@@ -0,0 +1,229 @@
+package fingerprint
+
+import (
+	"bytes"
+	"crypto/sha256"
+	"encoding/binary"
+	"fmt"
+	"testing"
+)
+
+// buildClientHello synthesizes a TLS handshake record carrying a ClientHello,
+// returned as raw bytes ready for ParseClientHello. extensions is the
+// wire-encoded extensions block (use ext() + the *Data helpers below).
+func buildClientHello(legacyVer uint16, ciphers []uint16, extensions []byte) []byte {
+	var body bytes.Buffer
+
+	binary.Write(&body, binary.BigEndian, legacyVer)
+	body.Write(make([]byte, 32)) // random
+	body.WriteByte(0x00)         // session_id length = 0
+
+	binary.Write(&body, binary.BigEndian, uint16(len(ciphers)*2))
+	for _, c := range ciphers {
+		binary.Write(&body, binary.BigEndian, c)
+	}
+
+	body.WriteByte(0x01) // compression_methods length
+	body.WriteByte(0x00) // null
+
+	binary.Write(&body, binary.BigEndian, uint16(len(extensions)))
+	body.Write(extensions)
+
+	hsLen := body.Len()
+	hs := []byte{0x01, byte(hsLen >> 16), byte(hsLen >> 8), byte(hsLen)}
+	hs = append(hs, body.Bytes()...)
+
+	rec := []byte{0x16, 0x03, 0x01, byte(len(hs) >> 8), byte(len(hs))}
+	return append(rec, hs...)
+}
+
+func sniData(host string) []byte {
+	name := []byte(host)
+	entry := append([]byte{0x00, byte(len(name) >> 8), byte(len(name))}, name...)
+	return append([]byte{byte(len(entry) >> 8), byte(len(entry))}, entry...)
+}
+
+func alpnData(protos ...string) []byte {
+	var list []byte
+	for _, p := range protos {
+		list = append(list, byte(len(p)))
+		list = append(list, p...)
+	}
+	return append([]byte{byte(len(list) >> 8), byte(len(list))}, list...)
+}
+
+// clientSuppVerData uses the ClientHello 1-byte list length (the ServerHello
+// variant is a bare uint16).
+func clientSuppVerData(vers ...uint16) []byte {
+	out := []byte{byte(len(vers) * 2)}
+	for _, v := range vers {
+		out = append(out, byte(v>>8), byte(v))
+	}
+	return out
+}
+
+func sigAlgsData(algs ...uint16) []byte {
+	out := []byte{byte(len(algs) * 2 >> 8), byte(len(algs) * 2)}
+	for _, a := range algs {
+		out = append(out, byte(a>>8), byte(a))
+	}
+	return out
+}
+
+func realisticClientHello() []byte {
+	var exts []byte
+	exts = append(exts, ext(0x0000, sniData("example.com"))...)
+	exts = append(exts, ext(0x002b, clientSuppVerData(0x0304))...)
+	exts = append(exts, ext(0x0010, alpnData("h2"))...)
+	exts = append(exts, ext(0x000d, sigAlgsData(0x0403, 0x0804))...)
+	exts = append(exts, ext(0x0033, []byte{})...) // key_share, empty
+	return buildClientHello(0x0303, []uint16{0x1301, 0x1302, 0x1303}, exts)
+}
+
+func TestParseClientHello_Fields(t *testing.T) {
+	ch, err := ParseClientHello(realisticClientHello())
+	if err != nil {
+		t.Fatalf("ParseClientHello: %v", err)
+	}
+	if ch.LegacyVersion != 0x0303 {
+		t.Errorf("legacy version = 0x%04x; want 0x0303", ch.LegacyVersion)
+	}
+	if want := []uint16{0x1301, 0x1302, 0x1303}; !equalU16(ch.CipherSuites, want) {
+		t.Errorf("ciphers = %v; want %v", ch.CipherSuites, want)
+	}
+	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 want := []uint16{0x0304}; !equalU16(ch.SupportedVersions, want) {
+		t.Errorf("supported versions = %v; want %v", ch.SupportedVersions, want)
+	}
+	if len(ch.ALPNValues) != 1 || ch.ALPNValues[0] != "h2" {
+		t.Errorf("alpn = %v; want [h2]", ch.ALPNValues)
+	}
+	if want := []uint16{0x0403, 0x0804}; !equalU16(ch.SigAlgs, want) {
+		t.Errorf("sig algs = %v; want %v", ch.SigAlgs, want)
+	}
+}
+
+func TestParseClientHello_SNI_IP(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)
+	}
+}
+
+func TestParseClientHello_GREASEFiltered(t *testing.T) {
+	exts := ext(0x1a1a, []byte{}) // GREASE extension
+	exts = append(exts, ext(0x002b, clientSuppVerData(0x0304))...)
+	ch, err := ParseClientHello(buildClientHello(0x0303, []uint16{0x0a0a, 0x1301}, exts))
+	if err != nil {
+		t.Fatalf("ParseClientHello: %v", err)
+	}
+	if want := []uint16{0x1301}; !equalU16(ch.CipherSuites, want) {
+		t.Errorf("ciphers = %v; want %v (GREASE filtered)", ch.CipherSuites, want)
+	}
+	if want := []uint16{0x002b}; !equalU16(ch.Extensions, want) {
+		t.Errorf("extensions = %v; want %v (GREASE filtered)", ch.Extensions, want)
+	}
+}
+
+func TestParseClientHello_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, 0x01, 0x00, 0x00, 0x00}},
+		{"wrong handshake type (ServerHello)", []byte{0x16, 0x03, 0x03, 0x00, 0x04, 0x02, 0x00, 0x00, 0x00}},
+		{"truncated record", []byte{0x16, 0x03, 0x03, 0x00, 0xff, 0x01, 0x00, 0x00, 0x00}},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if _, err := ParseClientHello(tc.raw); err == nil {
+				t.Errorf("expected error, got nil")
+			}
+		})
+	}
+}
+
+// TestComputeJA4_KnownFixture pins the JA4 of realisticClientHello(). JA4_a is
+// human-readable. The two hashes are checked against their spec preimages,
+// independently verifiable with: printf '%s' '<preimage>' | sha256sum.
+func TestComputeJA4_KnownFixture(t *testing.T) {
+	ch, err := ParseClientHello(realisticClientHello())
+	if err != nil {
+		t.Fatalf("ParseClientHello: %v", err)
+	}
+	got := ComputeJA4(ch)
+
+	const wantA = "t13d0305h2" // tls1.3, domain SNI, 3 ciphers, 5 exts, alpn h2
+	wantB := sha256Hex12("1301,1302,1303")
+	wantC := sha256Hex12("000d,002b,0033_0403,0804") // exts sans SNI+ALPN, sorted; then sig algs
+
+	want := wantA + "_" + wantB + "_" + wantC
+	if got != want {
+		t.Errorf("JA4 = %q; want %q", got, want)
+	}
+}
+
+func TestComputeJA4_a_Indicators(t *testing.T) {
+	tests := []struct {
+		name       string
+		legacyVer  uint16
+		ciphers    []uint16
+		exts       []byte
+		wantPrefix string
+	}{
+		{
+			name:       "no SNI, no versions ext, no alpn",
+			legacyVer:  0x0303,
+			ciphers:    []uint16{0x1301, 0x1302},
+			exts:       ext(0x0033, []byte{}),
+			wantPrefix: "t12n0201", // legacy 1.2, no SNI -> n, 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
+		},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			ch, err := ParseClientHello(buildClientHello(tc.legacyVer, tc.ciphers, tc.exts))
+			if err != nil {
+				t.Fatalf("ParseClientHello: %v", err)
+			}
+			a := ComputeJA4(ch)[:len(tc.wantPrefix)]
+			if a != tc.wantPrefix {
+				t.Errorf("JA4_a = %q; want %q", a, tc.wantPrefix)
+			}
+		})
+	}
+}
+
+func equalU16(a, b []uint16) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for i := range a {
+		if a[i] != b[i] {
+			return false
+		}
+	}
+	return true
+}
+
+func sha256Hex12(s string) string {
+	h := sha256.Sum256([]byte(s))
+	return fmt.Sprintf("%x", h)[:12]
+}
diff --git a/fingerprint/ja4s.go b/fingerprint/ja4s.go
@@ -9,39 +9,20 @@ import (
 
 // 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
+	LegacyVersion    uint16
+	CipherSuite      uint16   // single chosen suite
+	Extensions       []uint16 // non-GREASE extension types, wire order
+	SupportedVersion uint16   // from ext 0x002b, single chosen version (0 if absent)
+	ALPN             string   // from ext 0x0010, single chosen protocol ("" if absent)
 }
 
 // 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))
+	r, err := handshakeBody(raw, 0x02, "ja4s")
+	if err != nil {
+		return nil, err
 	}
-	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{}
 
@@ -105,7 +86,11 @@ func ParseServerHello(raw []byte) (*ServerHelloFields, error) {
 
 		switch extType {
 		case 0x002b:
-			parseServerSupportedVersion(extData, fields)
+			if len(extData) >= 2 {
+				if v := binary.BigEndian.Uint16(extData[:2]); !isGREASE(v) {
+					fields.SupportedVersion = v
+				}
+			}
 		case 0x0010:
 			parseServerALPN(extData, fields)
 		}
@@ -114,75 +99,50 @@ func ParseServerHello(raw []byte) (*ServerHelloFields, error) {
 	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.
+// parseServerALPN reads the single protocol the server selected. Wire format
+// matches ClientHello, but the server lists exactly one.
 func parseServerALPN(data []byte, f *ServerHelloFields) {
-	if len(data) < 2 {
+	if len(data) < 3 {
 		return
 	}
 	listLen := int(binary.BigEndian.Uint16(data[:2]))
 	data = data[2:]
-	if len(data) < listLen {
+	if len(data) < listLen || listLen < 1 {
 		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:]
+	pLen := int(data[0])
+	if 1+pLen > listLen || len(data) < 1+pLen {
+		return
 	}
+	f.ALPN = string(data[1 : 1+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)
+	return buildJA4Sa(sh) + "_" + fmt.Sprintf("%04x", sh.CipherSuite) + "_" + buildJA4Sc(sh.Extensions)
 }
 
 func buildJA4Sa(sh *ServerHelloFields) string {
 	ver := tlsVersionStr(sh.LegacyVersion)
-	if len(sh.SupportedVersions) > 0 {
-		ver = tlsVersionStr(sh.SupportedVersions[0])
+	if sh.SupportedVersion != 0 {
+		ver = tlsVersionStr(sh.SupportedVersion)
 	}
 
 	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"
-		}
+	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, 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 {
diff --git a/fingerprint/ja4s_test.go b/fingerprint/ja4s_test.go
@@ -65,11 +65,11 @@ func TestParseServerHello_TLS13_Minimal(t *testing.T) {
 	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 sh.SupportedVersion != 0x0304 {
+		t.Errorf("supported version = 0x%04x; want 0x0304", sh.SupportedVersion)
 	}
-	if len(sh.ALPNValues) != 0 {
-		t.Errorf("alpn = %v; want none", sh.ALPNValues)
+	if sh.ALPN != "" {
+		t.Errorf("alpn = %q; want none", sh.ALPN)
 	}
 }
 
@@ -88,8 +88,8 @@ func TestParseServerHello_TLS13_WithALPN_h2(t *testing.T) {
 	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)
+	if sh.ALPN != "h2" {
+		t.Fatalf("alpn = %q; want h2", sh.ALPN)
 	}
 }
 
@@ -106,8 +106,8 @@ func TestParseServerHello_TLS12_NoSupportedVersions(t *testing.T) {
 	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.SupportedVersion != 0 {
+		t.Errorf("supported_version present in TLS 1.2 fixture: 0x%04x", sh.SupportedVersion)
 	}
 	if sh.CipherSuite != 0xc02f {
 		t.Errorf("cipher = 0x%04x; want 0xc02f", sh.CipherSuite)
@@ -163,9 +163,8 @@ func TestComputeJA4S_Shape(t *testing.T) {
 	sh := &ServerHelloFields{
 		LegacyVersion:     0x0303,
 		CipherSuite:       0x1301,
-		Extensions:        []uint16{0x002b, 0x0033},
-		SupportedVersions: []uint16{0x0304},
-		ALPNValues:        nil,
+		Extensions:       []uint16{0x002b, 0x0033},
+		SupportedVersion: 0x0304,
 	}
 	got := ComputeJA4S(sh)
 
@@ -181,8 +180,8 @@ func TestComputeJA4S_KnownFixture(t *testing.T) {
 	sh := &ServerHelloFields{
 		LegacyVersion:     0x0303,
 		CipherSuite:       0x1301,
-		Extensions:        []uint16{0x002b, 0x0033},
-		SupportedVersions: []uint16{0x0304},
+		Extensions:       []uint16{0x002b, 0x0033},
+		SupportedVersion: 0x0304,
 	}
 	got := ComputeJA4S(sh)
 	const want = "t130200_1301_" // prefix; suffix is the 12-hex hash we pin below
@@ -200,9 +199,9 @@ func TestComputeJA4S_ALPN_h2(t *testing.T) {
 	sh := &ServerHelloFields{
 		LegacyVersion:     0x0303,
 		CipherSuite:       0x1301,
-		Extensions:        []uint16{0x002b, 0x0010, 0x0033},
-		SupportedVersions: []uint16{0x0304},
-		ALPNValues:        []string{"h2"},
+		Extensions:       []uint16{0x002b, 0x0010, 0x0033},
+		SupportedVersion: 0x0304,
+		ALPN:             "h2",
 	}
 	got := ComputeJA4S(sh)
 	// nn=03, alpn=h2
diff --git a/fingerprint/tls.go b/fingerprint/tls.go
@@ -1,3 +1,5 @@
+// Package fingerprint provides implementations for controlling outbound TLS
+// connection fingerprints.
 package fingerprint
 
 import (
diff --git a/proxy/ca_test.go b/proxy/ca_test.go
@@ -0,0 +1,105 @@
+package proxy
+
+import (
+	"bytes"
+	"crypto/x509"
+	"net"
+	"testing"
+)
+
+func TestIssueCert_SignedChain_DNS(t *testing.T) {
+	ca, err := GenerateCA()
+	if err != nil {
+		t.Fatalf("GenerateCA: %v", err)
+	}
+
+	cert, err := ca.issueCert("example.com")
+	if err != nil {
+		t.Fatalf("issueCert: %v", err)
+	}
+
+	if len(cert.Certificate) != 2 {
+		t.Fatalf("chain length = %d; want 2 (leaf + CA)", len(cert.Certificate))
+	}
+
+	leaf, err := x509.ParseCertificate(cert.Certificate[0])
+	if err != nil {
+		t.Fatalf("parse leaf: %v", err)
+	}
+
+	roots := x509.NewCertPool()
+	roots.AddCert(ca.cert)
+	if _, err := leaf.Verify(x509.VerifyOptions{
+		DNSName:   "example.com",
+		Roots:     roots,
+		KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+	}); err != nil {
+		t.Errorf("leaf does not verify against CA: %v", err)
+	}
+
+	if len(leaf.DNSNames) != 1 || leaf.DNSNames[0] != "example.com" {
+		t.Errorf("DNSNames = %v; want [example.com]", leaf.DNSNames)
+	}
+	if len(leaf.IPAddresses) != 0 {
+		t.Errorf("IPAddresses = %v; want none for a hostname", leaf.IPAddresses)
+	}
+}
+
+func TestIssueCert_IP(t *testing.T) {
+	ca, err := GenerateCA()
+	if err != nil {
+		t.Fatalf("GenerateCA: %v", err)
+	}
+
+	cert, err := ca.issueCert("192.0.2.1")
+	if err != nil {
+		t.Fatalf("issueCert: %v", err)
+	}
+	leaf, err := x509.ParseCertificate(cert.Certificate[0])
+	if err != nil {
+		t.Fatalf("parse leaf: %v", err)
+	}
+
+	roots := x509.NewCertPool()
+	roots.AddCert(ca.cert)
+	if _, err := leaf.Verify(x509.VerifyOptions{
+		DNSName:   "192.0.2.1",
+		Roots:     roots,
+		KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+	}); err != nil {
+		t.Errorf("leaf does not verify for IP SAN: %v", err)
+	}
+
+	if len(leaf.IPAddresses) != 1 || !leaf.IPAddresses[0].Equal(net.ParseIP("192.0.2.1")) {
+		t.Errorf("IPAddresses = %v; want [192.0.2.1]", leaf.IPAddresses)
+	}
+	if len(leaf.DNSNames) != 0 {
+		t.Errorf("DNSNames = %v; want none for an IP", leaf.DNSNames)
+	}
+}
+
+func TestIssueCert_Caches(t *testing.T) {
+	ca, err := GenerateCA()
+	if err != nil {
+		t.Fatalf("GenerateCA: %v", err)
+	}
+
+	first, err := ca.issueCert("example.com")
+	if err != nil {
+		t.Fatalf("issueCert: %v", err)
+	}
+	second, err := ca.issueCert("example.com")
+	if err != nil {
+		t.Fatalf("issueCert (cached): %v", err)
+	}
+
+	if !bytes.Equal(first.Certificate[0], second.Certificate[0]) {
+		t.Error("second issueCert for same host returned a different cert; cache miss")
+	}
+	if _, err := ca.issueCert("other.com"); err != nil {
+		t.Fatalf("issueCert other host: %v", err)
+	}
+	if len(ca.cache) != 2 {
+		t.Errorf("cache size = %d; want 2", len(ca.cache))
+	}
+}
diff --git a/proxy/proxy.go b/proxy/proxy.go
@@ -17,7 +17,7 @@ type Proxy struct {
 	ListenAddr  string
 	BackendAddr string // server-front: backend host:port
 	CAPool      *x509.CertPool
-	Fingerprint fingerprint.TLSApplier
+	Fingerprint *fingerprint.TLSFingerprint
 	LocalCA     *LocalCA // client-front: MitM CA for TLS interception
 	Handler     Handler
 }