diff --git a/cmd/client.go b/cmd/client.go
@@ -10,8 +10,6 @@ import (
"github.com/spf13/cobra"
)
-// proxyURL renders a listen address as something a user can paste into curl -x.
-// A bare ":8080" needs a host.
func proxyURL(listen string) string {
if strings.HasPrefix(listen, ":") {
return "localhost" + listen
diff --git a/cmd/client_test.go b/cmd/client_test.go
@@ -7,8 +7,7 @@ import (
"testing"
)
-// run executes the client command with args, using an unroutable listen port so
-// that anything reaching p.Run() fails there instead of binding a real socket.
+// runClient uses an invalid port so p.Run() fails instead of binding a socket.
func runClient(t *testing.T, args ...string) error {
t.Helper()
c := newClientCmd()
diff --git a/cmd/probe/main.go b/cmd/probe/main.go
@@ -1,5 +1,5 @@
-// probe dials tlsinfo.me/json with each known utls preset and prints the
-// JA4 hash that the server observes. Run once to populate the fingerprint table.
+// probe prints the JA4 hash tlsinfo.me observes for each mic profile, to
+// populate the fingerprint table.
package main
import (
@@ -8,40 +8,16 @@ import (
"encoding/json"
"fmt"
"io"
+ "maps"
"net"
"net/http"
+ "slices"
utls "github.com/bogdanfinn/utls"
"golang.org/x/net/http2"
-)
-var presets = []struct {
- name string
- id utls.ClientHelloID
-}{
- // Chrome
- {"HelloChrome_120", utls.HelloChrome_120},
- {"HelloChrome_120_PQ", utls.HelloChrome_120_PQ},
- {"HelloChrome_131", utls.HelloChrome_131},
- {"HelloChrome_133", utls.HelloChrome_133},
- // Firefox
- {"HelloFirefox_120", utls.HelloFirefox_120},
- // Safari / iOS
- {"HelloSafari_15_6_1", utls.HelloSafari_15_6_1},
- {"HelloSafari_16_0", utls.HelloSafari_16_0},
- {"HelloIOS_15_5", utls.HelloIOS_15_5},
- {"HelloIOS_15_6", utls.HelloIOS_15_6},
- {"HelloIOS_16_0", utls.HelloIOS_16_0},
- // Edge
- {"HelloEdge_85", utls.HelloEdge_85},
- {"HelloEdge_106", utls.HelloEdge_106},
- // Opera
- {"HelloOpera_89", utls.HelloOpera_89},
- {"HelloOpera_90", utls.HelloOpera_90},
- {"HelloOpera_91", utls.HelloOpera_91},
- // Android
- {"HelloAndroid_11_OkHttp", utls.HelloAndroid_11_OkHttp},
-}
+ "github.com/djnnvx/mic/fingerprint"
+)
func dialUTLS(ctx context.Context, id utls.ClientHelloID) (*utls.UConn, error) {
tcpConn, err := (&net.Dialer{}).DialContext(ctx, "tcp", "tlsinfo.me:443")
@@ -75,9 +51,7 @@ func probe(id utls.ClientHelloID) (string, error) {
defer uconn.Close()
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
- // want to measure.
+ // Reuse the handshaked uconn. A Transport redial would measure a different handshake.
cc, err := (&http2.Transport{}).NewClientConn(uconn)
if err != nil {
return "", err
@@ -106,12 +80,12 @@ func probe(id utls.ClientHelloID) (string, error) {
}
func main() {
- for _, p := range presets {
- ja4, err := probe(p.id)
+ for _, name := range slices.Sorted(maps.Keys(fingerprint.NameTable)) {
+ ja4, err := probe(fingerprint.NameTable[name])
if err != nil {
- fmt.Printf("%-22s ERROR: %v\n", p.name, err)
+ fmt.Printf("%-22s ERROR: %v\n", name, err)
continue
}
- fmt.Printf("%-22s %s\n", p.name, ja4)
+ fmt.Printf("%-22s %s\n", name, ja4)
}
}
diff --git a/cmd/root.go b/cmd/root.go
@@ -8,9 +8,6 @@ import (
"github.com/spf13/cobra"
)
-// loadCAPool reads a PEM bundle into a cert pool. AppendCertsFromPEM reports
-// failure only through its return value, and an empty pool makes every upstream
-// handshake fail with "unknown authority" instead of naming the real cause.
func loadCAPool(path string) (*x509.CertPool, error) {
pem, err := os.ReadFile(path)
if err != nil {
diff --git a/fingerprint/ja4.go b/fingerprint/ja4.go
@@ -9,8 +9,8 @@ import (
"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.
+// noValues is the JA4 placeholder for an empty field. The spec uses it instead
+// of sha256 of the empty string.
const noValues = "000000000000"
// ClientHelloFields holds the parsed TLS ClientHello data used to compute JA4.
@@ -30,9 +30,8 @@ func isGREASE(v uint16) bool {
return v&0x0f == 0x0a && v>>8 == v&0xff
}
-// 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").
+// handshakeBody unwraps the TLS record and handshake headers from raw, verifying
+// content type, handshake type and lengths. tag prefixes errors ("ja4" or "ja4s").
func handshakeBody(raw []byte, hsType byte, tag string) ([]byte, error) {
if len(raw) < 5 {
return nil, fmt.Errorf("%s: record too short (%d bytes)", tag, len(raw))
@@ -267,10 +266,9 @@ func buildJA4a(ch *ClientHelloFields) string {
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.
+// alpnChars is the two-character ALPN field: 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.
func alpnChars(v string) string {
if v == "" {
return "00"
@@ -287,7 +285,7 @@ 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.
+// min99 caps n at 99, the max for the two-digit JA4_a counter.
func min99(n int) int {
if n > 99 {
return 99
@@ -335,9 +333,8 @@ func buildJA4b(ciphers []uint16) string {
return fmt.Sprintf("%x", h)[:12]
}
-// buildJA4c hashes sorted extension types (excluding SNI and ALPN, which vary
-// per-connection) then signature algorithms in wire order. Sorting is what
-// distinguishes JA4 from the raw JA4_r variant.
+// buildJA4c hashes sorted extension types (SNI and ALPN excluded) then signature
+// algorithms in wire order. Sorting is what distinguishes JA4 from JA4_r.
func buildJA4c(exts []uint16, sigAlgs []uint16) string {
var filtered []uint16
for _, e := range exts {
@@ -356,7 +353,6 @@ func buildJA4c(exts []uint16, sigAlgs []uint16) string {
}
preimage := strings.Join(extParts, ",")
- // No separator at all when there are no signature algorithms.
if len(sigAlgs) > 0 {
sigParts := make([]string, len(sigAlgs))
for i, s := range sigAlgs {
diff --git a/fingerprint/ja4_test.go b/fingerprint/ja4_test.go
@@ -8,9 +8,8 @@ import (
"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).
+// buildClientHello synthesizes a TLS handshake record carrying a ClientHello.
+// extensions is the wire-encoded extensions block (see ext() and the *Data helpers).
func buildClientHello(legacyVer uint16, ciphers []uint16, extensions []byte) []byte {
var body bytes.Buffer
@@ -108,8 +107,8 @@ func TestParseClientHello_Fields(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.
+// The spec keys on SNI extension presence, never on the value, so an IP literal
+// still yields "d".
func TestComputeJA4_a_SNI_IPLiteral(t *testing.T) {
exts := ext(0x0000, sniData("192.0.2.1"))
ch, err := ParseClientHello(buildClientHello(0x0303, []uint16{0x1301}, exts))
@@ -150,7 +149,6 @@ func TestTLSVersionStr(t *testing.T) {
}
// 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
@@ -190,7 +188,7 @@ func TestBuildJA4b_EmptyCiphers(t *testing.T) {
}
}
-// Only SNI and ALPN present: both are filtered out, so JA4_c has no values.
+// 0x0000 and 0x0010 are both filtered out, leaving JA4_c with 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)
@@ -243,9 +241,8 @@ func TestParseClientHello_Errors(t *testing.T) {
}
}
-// 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.
+// 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 {
@@ -342,8 +339,8 @@ 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.
+// Both hash fields hit the no-values sentinel: no ciphers, and every extension
+// is one 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))
diff --git a/fingerprint/ja4s.go b/fingerprint/ja4s.go
@@ -136,11 +136,9 @@ func buildJA4Sa(sh *ServerHelloFields) string {
return fmt.Sprintf("t%s%02d%s", ver, nn, ja4sALPNCode(sh.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.
+// 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.
func ja4sALPNCode(v string) string {
if v == "" {
return "00"
@@ -158,8 +156,8 @@ 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.
+// buildJA4Sc hashes ServerHello extension types in wire order. JA4_c sorts, this
+// must not.
func buildJA4Sc(exts []uint16) string {
if len(exts) == 0 {
return "000000000000"
diff --git a/fingerprint/ja4s_test.go b/fingerprint/ja4s_test.go
@@ -7,11 +7,8 @@ import (
"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.
+// buildServerHello synthesizes a TLS handshake record carrying a ServerHello.
+// extensions is the wire-encoded extensions block (type + len + data tuples).
func buildServerHello(legacyVer uint16, sessionIDLen int, cipher uint16, extensions []byte) []byte {
var body bytes.Buffer
@@ -46,8 +43,6 @@ func ext(t uint16, data []byte) []byte {
}
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)
@@ -74,7 +69,6 @@ func TestParseServerHello_TLS13_Minimal(t *testing.T) {
}
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
@@ -94,8 +88,7 @@ func TestParseServerHello_TLS13_WithALPN_h2(t *testing.T) {
}
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.
+ // Pre-1.3, legacy_version is the chosen version.
exts := ext(0x0023, []byte{}) // session_ticket, empty
raw := buildServerHello(0x0303, 32, 0xc02f, exts)
@@ -115,7 +108,6 @@ func TestParseServerHello_TLS12_NoSupportedVersions(t *testing.T) {
}
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})...)
@@ -165,8 +157,7 @@ func TestParseServerHello_Errors(t *testing.T) {
}
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>
+ // Shape: t<ver><nn><alpn>_<cipher>_<12hex>
sh := &ServerHelloFields{
LegacyVersion: 0x0303,
CipherSuite: 0x1301,
diff --git a/fingerprint/tls.go b/fingerprint/tls.go
@@ -8,7 +8,7 @@ import (
utls "github.com/bogdanfinn/utls"
)
-// NameTable maps human-readable profile names to utls ClientHelloID presets.
+// NameTable maps profile names to utls ClientHelloID presets.
var NameTable = map[string]utls.ClientHelloID{
"chrome-120": utls.HelloChrome_120,
"chrome-120-pq": utls.HelloChrome_120_PQ,
@@ -21,6 +21,10 @@ var NameTable = map[string]utls.ClientHelloID{
"edge-106": utls.HelloEdge_106,
"opera-91": utls.HelloOpera_91,
"android-11-okhttp": utls.HelloAndroid_11_OkHttp,
+ "ipad-15": utls.HelloIPad_15_6,
+ "qq-11": utls.HelloQQ_11_1,
+ "360-7": utls.Hello360_7_5,
+ "360-11": utls.Hello360_11_0,
}
type TLSFingerprint struct {
diff --git a/fingerprint/tls_test.go b/fingerprint/tls_test.go
@@ -41,6 +41,8 @@ func TestNameTable_HasExpectedProfiles(t *testing.T) {
"edge-85", "edge-106",
"opera-91",
"android-11-okhttp",
+ "ipad-15",
+ "qq-11", "360-7", "360-11",
}
for _, name := range expected {
if _, ok := fingerprint.NameTable[name]; !ok {
diff --git a/proxy/ca.go b/proxy/ca.go
@@ -53,8 +53,6 @@ func LoadOrGenerateCA(certPath, keyPath string) (*LocalCA, error) {
if certPath == "" && keyPath == "" {
return GenerateCA()
}
- // A half-specified pair used to silently return an ephemeral CA that was
- // never written, so callers told users to import a file that never existed.
if certPath == "" || keyPath == "" {
return nil, fmt.Errorf("CA cert path and key path must both be set or both be empty")
}
@@ -147,22 +145,21 @@ func (ca *LocalCA) Save(certPath, keyPath string) error {
return err
}
defer kf.Close()
- // O_CREATE's mode is ignored when the file already exists, and the mode of a
- // new file is masked by umask, so set it explicitly either way.
+ // O_CREATE's mode is ignored for an existing file and masked by umask for a
+ // new one, so set 0600 explicitly.
if err := kf.Chmod(0o600); err != nil {
return err
}
return pem.Encode(kf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
}
-// CertPEM returns the CA certificate as PEM, suitable for curl --cacert or
-// importing into a system trust store.
+// CertPEM returns the CA certificate as PEM for curl --cacert or a trust store.
func (ca *LocalCA) CertPEM() []byte {
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.derCert})
}
-// issueCert returns a leaf certificate for host signed by the CA, issuing a new
-// one when the host is unseen or its cached leaf is at or near expiry.
+// issueCert returns a cached leaf for host, re-issuing when it is unseen or
+// near expiry.
func (ca *LocalCA) issueCert(host string) (tls.Certificate, error) {
ca.mu.Lock()
defer ca.mu.Unlock()
diff --git a/proxy/ca_test.go b/proxy/ca_test.go
@@ -107,8 +107,8 @@ func TestIssueCert_Caches(t *testing.T) {
}
}
-// A MitM leaf must never be usable as a CA. This is the property that lets an
-// attacker with the leaf key mint certs for any host, so assert it directly.
+// A MitM leaf must never be usable as a CA: the leaf key could then mint certs
+// for any host.
func TestIssueCert_LeafIsNotACA(t *testing.T) {
ca, err := GenerateCA()
if err != nil {
diff --git a/proxy/https.go b/proxy/https.go
@@ -10,13 +10,10 @@ import (
)
// Bounds the CONNECT header read only, never the proxied stream.
-// Tunable: tests lower it to keep the suite fast.
var headerReadTimeout = 10 * time.Second
-// 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,
-// since CONNECT requires one), 443 is assumed. IPv6 literals in brackets are
-// handled (e.g. "[::1]:443" -> "::1", "[::1]:443").
+// parseAuthority splits a CONNECT authority into a server name and a dial-ready
+// "host:port", assuming port 443 and stripping IPv6 brackets when needed.
func parseAuthority(authority string) (server, hostPort string) {
if h, _, err := net.SplitHostPort(authority); err == nil {
return h, authority
@@ -64,10 +61,8 @@ func HttpsHandler(clientConn net.Conn, p *Proxy) {
return
}
- // Mirror the ALPN negotiated with the target so the client uses the same
- // application protocol. Without this, Chrome-fingerprint uTLS often
- // negotiates h2 with the target while the client sends HTTP/1.1, causing
- // an immediate protocol error.
+ // Mirror the ALPN negotiated with the target: otherwise uTLS often
+ // negotiates h2 upstream while the client speaks HTTP/1.1, which fails.
nextProtos := []string{"http/1.1"}
if proto := targetConn.ConnectionState().NegotiatedProtocol; proto != "" {
nextProtos = []string{proto}
diff --git a/proxy/integration_test.go b/proxy/integration_test.go
@@ -76,9 +76,7 @@ func writeTempCertKey(t *testing.T) (certFile, keyFile string, pool *x509.CertPo
return certFile, keyFile, pool
}
-// TestClientFront_Integration tests the full client-front (HTTP CONNECT) flow:
-//
-// client → TCP CONNECT → proxy → uTLS(Chrome120) → httptest.TLSServer
+// client → TCP CONNECT → proxy → uTLS(Chrome120) → httptest.TLSServer
func TestClientFront_Integration(t *testing.T) {
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "close")
@@ -116,8 +114,8 @@ func TestClientFront_Integration(t *testing.T) {
t.Fatalf("CONNECT status: %d; want 200", resp.StatusCode)
}
- // The proxy has already done uTLS to the target; raw bytes we send here are
- // wrapped in that TLS connection on our behalf.
+ // The proxy already handshaked uTLS to the target, so these raw bytes travel
+ // inside that TLS connection.
fmt.Fprintf(conn, "GET / HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", targetAddr)
resp, err = http.ReadResponse(reader, nil)
@@ -133,9 +131,7 @@ func TestClientFront_Integration(t *testing.T) {
t.Logf("response body: %s", body)
}
-// TestServerFront_Integration tests the server-front flow:
-//
-// client → TLS → proxy (terminates TLS) → uTLS(Chrome120) → httptest.TLSServer
+// client → TLS → proxy (terminates TLS) → uTLS(Chrome120) → httptest.TLSServer
func TestServerFront_Integration(t *testing.T) {
backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "close")
@@ -174,8 +170,6 @@ func TestServerFront_Integration(t *testing.T) {
defer tlsConn.Close()
tlsConn.SetDeadline(time.Now().Add(testTimeout))
- // The proxy decrypts our TLS and re-encrypts with the configured fingerprint
- // before forwarding to the backend.
fmt.Fprintf(tlsConn, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
reader := bufio.NewReader(tlsConn)
diff --git a/proxy/ja4_integration_test.go b/proxy/ja4_integration_test.go
@@ -15,10 +15,11 @@ import (
)
// Values measured empirically via cmd/probe against tlsinfo.me.
-// chrome-120-pq and chrome-131 are absent: they emit the same JA4 as chrome-120
-// because JA4 does not distinguish the X25519MLKEM768 key share.
+// chrome-120, chrome-120-pq and chrome-131 share a hash. JA4 ignores key share entries.
var expectedJA4 = map[string]string{
"chrome-120": "t13d1516h2_8daaf6152771_02713d6af862",
+ "chrome-120-pq": "t13d1516h2_8daaf6152771_02713d6af862",
+ "chrome-131": "t13d1516h2_8daaf6152771_02713d6af862",
"chrome-133": "t13d1516h2_8daaf6152771_d8a2da3f94cd",
"firefox-120": "t13d1715h2_5b57614c22b0_5c2c66f702b0",
"safari-16": "t13d2014h2_a09f3c656075_14788d8d241b",
@@ -27,12 +28,13 @@ var expectedJA4 = map[string]string{
"edge-106": "t13d1516h2_8daaf6152771_e5627efa2ab1",
"opera-91": "t13d1516h2_8daaf6152771_e5627efa2ab1",
"android-11-okhttp": "t12d120700_d34a8e72043a_036209cd1ead",
+ "ipad-15": "t13d2014h2_a09f3c656075_14788d8d241b",
+ "qq-11": "t13d1516h2_8daaf6152771_e5627efa2ab1",
+ "360-7": "t12d2010s2_0bf03fa604e3_736b2a1ed4d3",
+ "360-11": "t13d1616h2_46e7e9700bed_4551aecd7b38",
}
-// 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
+// test client → TCP CONNECT → proxy (HttpsHandler) → uTLS(profile) → captureJA4Server
func TestClientFront_JA4(t *testing.T) {
for name, want := range expectedJA4 {
name, want := name, want
@@ -60,7 +62,6 @@ func TestClientFront_JA4(t *testing.T) {
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
@@ -71,10 +72,7 @@ func TestClientFront_JA4(t *testing.T) {
}
}
-// 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
+// test client → TLS → proxy (ServerFrontHandler) → uTLS(profile) → captureJA4Server
func TestServerFront_JA4(t *testing.T) {
for name, want := range expectedJA4 {
name, want := name, want
diff --git a/proxy/ja4capture_test.go b/proxy/ja4capture_test.go
@@ -16,8 +16,8 @@ import (
"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.
+// teeConn copies every byte read into an io.Writer, capturing the raw
+// ClientHello while tls.Server reads normally.
type teeConn struct {
net.Conn
r io.Reader
@@ -25,15 +25,10 @@ type teeConn struct {
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.
+// captureJA4Server starts a TLS listener that computes the JA4 of each incoming
+// ClientHello and sends it on the returned channel. It answers HTTP/1.1 200 so
+// callers do not stall. addr is "localhost:PORT" so dialTarget sends a hostname
+// SNI, matching the "d" (domain) indicator in the stored JA4 table.
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
@@ -10,9 +10,8 @@ import (
"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.
+// teeWriteConn tees every Write into a buffer and emits the JA4S once the
+// buffer holds a complete ServerHello.
type teeWriteConn struct {
net.Conn
mu sync.Mutex
@@ -24,10 +23,8 @@ type teeWriteConn struct {
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.
+ // In client-front MitM the proxy writes a plaintext "HTTP/1.1 200" line on
+ // this conn first, so only collect from the first TLS handshake record.
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 {
@@ -51,9 +48,8 @@ func captureToJA4S(raw []byte) (string, error) {
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.
+// captureJA4SListener wraps each accepted conn so the ServerHello mic writes
+// back is captured and emitted on the returned channel.
func captureJA4SListener(ln net.Listener, p *Proxy, h Handler) <-chan string {
ch := make(chan string, 16)
go func() {
diff --git a/proxy/ja4s_integration_test.go b/proxy/ja4s_integration_test.go
@@ -20,18 +20,15 @@ import (
// 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.
+// Baselines pinned from stdlib crypto/tls (Go 1.25). Drift means stdlib TLS
+// behavior changed or the parser broke. Verify 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
+// 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")
@@ -86,11 +83,7 @@ func TestServerFront_JA4S_Baseline(t *testing.T) {
}
}
-// 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
+// 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")
@@ -119,8 +112,6 @@ func TestClientFront_JA4S_Baseline(t *testing.T) {
// 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)
@@ -145,8 +136,6 @@ func TestClientFront_JA4S_Baseline(t *testing.T) {
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,
diff --git a/proxy/proxy.go b/proxy/proxy.go
@@ -32,8 +32,6 @@ type Proxy struct {
type Handler func(conn net.Conn, p *Proxy)
-// dialTarget dials host (host:port) and returns a uTLS connection after a
-// successful handshake. Falls back to HelloRandomized when no fingerprint is set.
func (p *Proxy) dialTarget(host string) (*utls.UConn, error) {
tcpConn, err := net.DialTimeout("tcp", host, dialTimeout)
if err != nil {
@@ -64,9 +62,8 @@ func (p *Proxy) dialTarget(host string) (*utls.UConn, error) {
return uconn, nil
}
-// bufferedConn pairs a net.Conn with a bufio.Reader that may already contain
-// data read ahead from the underlying conn (e.g. after http.ReadRequest).
-// Reads come from the buffer; writes and Close go to the conn.
+// bufferedConn reads from a bufio.Reader that may already hold bytes read ahead
+// from the conn (e.g. by http.ReadRequest). Writes and Close go to the conn.
type bufferedConn struct {
r *bufio.Reader
net.Conn
@@ -88,8 +85,8 @@ type halfCloser interface {
CloseWrite() error
}
-// closeWrite shuts down only the write side so the peer still sees the data
-// already sent. Falls back to a full Close when half-close is unavailable.
+// closeWrite shuts down only the write side so the peer still receives the data
+// already sent.
func closeWrite(c io.Closer) {
if hc, ok := c.(halfCloser); ok && hc.CloseWrite() == nil {
return
@@ -97,10 +94,9 @@ func closeWrite(c io.Closer) {
c.Close()
}
-// pipe copies bidirectionally between a and b. A finished direction only
-// half-closes its write side: a client that shuts down its write end after
-// sending a request must still receive the response. Both ends are fully
-// closed once both directions are done.
+// pipe copies both directions. A finished direction only half-closes its write
+// side: a client that shut its write end after sending a request must still
+// receive the response.
func pipe(a, b io.ReadWriteCloser) {
var wg sync.WaitGroup
wg.Add(2)
diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go
@@ -127,7 +127,6 @@ func TestDialTarget_FallbackToRandomized(t *testing.T) {
conn, err := p.dialTarget(ln.Addr().String())
if err != nil {
// HelloRandomized sometimes picks a curve the local TLS stack rejects.
- // That is the only tolerated failure, anything else is a real bug.
if !strings.Contains(err.Error(), "CurvePreferences includes unsupported curve") {
t.Fatalf("dialTarget with randomized preset: %v", err)
}
diff --git a/proxy/serverfront.go b/proxy/serverfront.go
@@ -22,7 +22,6 @@ func ServerFrontHandler(certFile, keyFile string) (Handler, error) {
return func(clientConn net.Conn, p *Proxy) {
defer clientConn.Close()
- // Terminate incoming TLS with stdlib crypto/tls.
tlsConn := tls.Server(clientConn, tlsCfg)
if err := tlsConn.Handshake(); err != nil {
log.Printf("ServerFront: TLS handshake with client failed: %v", err)
| | | | | | | | | | | | | | | | | | | | |