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")
+ }
+}
| | | | | | | | | |