mic.git


mic

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

commit 50bd5cd0a6591d2e626df6d895c4f93dd5dad579
parent 3b287107daa0023ab1198601bace600bfd0a25cd
Author: djnn <[email protected]>
Date: Thu, 19 Feb 2026 21:20:46 +0900

fix(proxy): utls termination on HTTP/2

Diffstat:
MREADME.md | 83++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Acmd/probe/main.go | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcmd/root.go | 9+++++++++
Mconfig/config.go | 8+++++++-
Mfingerprint/tls.go | 24++++++++++++++----------
Mgo.mod | 2++
Mgo.sum | 4++++
Mmic.example.toml | 9++++++++-
Aproxy/ca.go | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproxy/https.go | 44++++++++++++++++++++++++++++++++++++++++++++
Mproxy/proxy.go | 8++++++--
Mscripts/setup.sh | 22+++++++++++++++++++---

12 files changed, 464 insertions(+), 41 deletions(-)

diff --git a/README.md b/README.md
@@ -7,11 +7,13 @@ browser to any fingerprinting system.
 
 Two modes are supported:
 
-- **client-front** — standard HTTP CONNECT proxy. Your tool (curl, browser, etc.)
-  connects through the proxy; the proxy dials upstream with the configured fingerprint.
-- **server-front** — the proxy terminates incoming TLS (with your cert/key), then
-  re-dials the backend with the configured fingerprint. Useful when the client
-  cannot be configured to use a CONNECT proxy.
+- **client-front** — HTTP CONNECT proxy with 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
+  re-dials the backend with the configured fingerprint. Useful when the client cannot
+  be configured to use a CONNECT proxy.
 
 ---
 
@@ -24,14 +26,16 @@ sequenceDiagram
     participant T as Target
 
     rect rgb(30, 30, 60)
-        note over C,T: client-front mode
+        note over C,T: client-front mode (MitM TLS)
         C->>P: HTTP CONNECT target:443
-        P-->>C: 200 Connection Established
         P->>T: TCP + uTLS handshake (configured fingerprint)
-        C->>P: raw HTTP bytes
-        P->>T: bytes encrypted in uTLS tunnel
+        P-->>C: 200 Connection Established
+        C->>P: TLS handshake (mic-issued cert for target)
+        P-->>C: TLS established
+        C->>P: HTTP request (decrypted by proxy)
+        P->>T: request bytes (through uTLS tunnel)
         T-->>P: HTTP response (through uTLS)
-        P-->>C: raw HTTP response
+        P-->>C: HTTP response (re-encrypted for client)
     end
 
     rect rgb(30, 60, 30)
@@ -39,8 +43,8 @@ sequenceDiagram
         C->>P: TLS handshake (proxy cert)
         P-->>C: TLS established
         P->>T: TCP + uTLS handshake (configured fingerprint)
-        C->>P: raw HTTP bytes (decrypted by proxy)
-        P->>T: bytes encrypted in uTLS tunnel
+        C->>P: HTTP request (decrypted by proxy)
+        P->>T: request bytes (through uTLS tunnel)
         T-->>P: HTTP response (through uTLS)
         P-->>C: HTTP response (re-encrypted for client)
     end
@@ -57,7 +61,8 @@ not the default Go TLS fingerprint.
 go build -o mic .
 ```
 
-Or with the helper script (builds and creates `mic.toml` from the example):
+Or with the helper script (builds, creates `mic.toml`, starts the proxy, and prints
+CA trust instructions):
 
 ```bash
 scripts/setup.sh
@@ -86,24 +91,37 @@ addr = "127.0.0.1:443"   # server-front only
 [fingerprint.tls]
 ja4 = "t13d1516h2_8daaf6152771_b0da82dd1658"   # Chrome 120
 
-# server-front only
+# server-front only: certificate mic presents to clients
 [fingerprint.tls.termination]
 cert = "/path/to/cert.pem"
 key  = "/path/to/key.pem"
 
 [ca]
-cert = ""   # optional: custom CA for upstream verification
+cert = ""   # optional: custom CA for verifying upstream targets
+
+# client-front only: local MitM CA.
+# mic creates these files on first run and reuses them across restarts.
+# Import ca.pem into curl / your browser / the system trust store.
+[ca.intercept]
+cert = "ca.pem"
+key  = "ca-key.pem"
 ```
 
 ### Available JA4 fingerprints
 
-| JA4 hash | Browser |
+Hashes are measured by `cmd/probe` against tlsinfo.me and reflect what each utls
+preset actually emits. Re-run the probe after upgrading the utls dependency.
+
+| JA4 hash | Preset |
 |---|---|
-| `t13d1516h2_8daaf6152771_b0da82dd1658` | Chrome 120 |
-| `t13d1516h2_8daaf6152771_e5627efa2ab1` | Chrome 120 (post-quantum) |
-| `t13d1517h2_8daaf6152771_b1ff8ab2d16f` | Firefox 120 |
-| `t13d1516h2_8daaf6152771_4aeede8da0ac` | Safari 16.0 |
-| `t13d1516h2_8daaf6152771_f5b4b24de8b1` | Edge 106 |
+| `t13d1516h2_8daaf6152771_02713d6af862` | Chrome 120 (`HelloChrome_120`) |
+| `t13d1715h2_5b57614c22b0_5c2c66f702b0` | Firefox 120 (`HelloFirefox_120`) |
+| `t13d2014h2_a09f3c656075_14788d8d241b` | Safari 16.0 (`HelloSafari_16_0`) |
+| `t13d1516h2_8daaf6152771_e5627efa2ab1` | Edge 106 (`HelloEdge_106`) |
+
+> `HelloChrome_120_PQ` (post-quantum) produces the same JA4 as Chrome 120 because
+> the X25519MLKEM768 key share is not distinguished by JA4. It is not separately
+> selectable via config hash.
 
 ---
 
@@ -115,11 +133,28 @@ cert = ""   # optional: custom CA for upstream verification
 
 ### client-front
 
-Configure your client to use an HTTP CONNECT proxy at the listen address:
+Set `mode = "client-front"` and configure `[ca.intercept]` with paths for the local
+CA cert and key. mic will create them on first run.
+
+**Trust the CA once** (pick whichever applies):
+
+```bash
+# curl — pass --cacert on every invocation, or set CURL_CA_BUNDLE
+curl --cacert ca.pem -x http://localhost:8080 https://tlsinfo.me/json
+
+# Debian / Ubuntu / Kali — add to system store
+sudo cp ca.pem /usr/local/share/ca-certificates/mic-ca.crt
+sudo update-ca-certificates
+
+# macOS
+sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ca.pem
+```
+
+After trusting the CA, standard tools work without extra flags:
 
 ```bash
-curl -x http://localhost:8080 https://tls.peet.ws/api/all
-# check the "ja4" field in the response
+curl -x http://localhost:8080 https://tlsinfo.me/json
+# check the "ja4" field — it should match your configured fingerprint
 ```
 
 ### server-front
diff --git a/cmd/probe/main.go b/cmd/probe/main.go
@@ -0,0 +1,104 @@
+// 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.
+package main
+
+import (
+	"bufio"
+	"context"
+	"crypto/tls"
+	"encoding/json"
+	"fmt"
+	"net"
+	"net/http"
+
+	utls "github.com/refraction-networking/utls"
+	"golang.org/x/net/http2"
+)
+
+var presets = []struct {
+	name string
+	id   utls.ClientHelloID
+}{
+	{"HelloChrome_120", utls.HelloChrome_120},
+	{"HelloChrome_120_PQ", utls.HelloChrome_120_PQ},
+	{"HelloFirefox_120", utls.HelloFirefox_120},
+	{"HelloSafari_16_0", utls.HelloSafari_16_0},
+	{"HelloEdge_106", utls.HelloEdge_106},
+}
+
+func dialUTLS(ctx context.Context, id utls.ClientHelloID) (*utls.UConn, error) {
+	tcpConn, err := (&net.Dialer{}).DialContext(ctx, "tcp", "tlsinfo.me:443")
+	if err != nil {
+		return nil, err
+	}
+	uconn := utls.UClient(tcpConn, &utls.Config{ServerName: "tlsinfo.me"}, id)
+	if err := uconn.Handshake(); err != nil {
+		tcpConn.Close()
+		return nil, err
+	}
+	return uconn, nil
+}
+
+func decodeJA4(body interface{ Read([]byte) (int, error) }) (string, error) {
+	var result struct {
+		JA4 string `json:"ja4"`
+	}
+	if err := json.NewDecoder(body).Decode(&result); err != nil {
+		return "", err
+	}
+	return result.JA4, nil
+}
+
+func probe(id utls.ClientHelloID) (string, error) {
+	ctx := context.Background()
+	uconn, err := dialUTLS(ctx, id)
+	if err != nil {
+		return "", err
+	}
+	defer uconn.Close()
+
+	proto := uconn.ConnectionState().NegotiatedProtocol
+
+	if proto == "h2" {
+		// Use http2.Transport with the already-dialed connection.
+		h2t := &http2.Transport{
+			DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
+				return dialUTLS(ctx, id)
+			},
+		}
+		// Discard the first connection — the Transport dials its own.
+		uconn.Close()
+		client := &http.Client{Transport: h2t}
+		resp, err := client.Get("https://tlsinfo.me/json")
+		if err != nil {
+			return "", err
+		}
+		defer resp.Body.Close()
+		return decodeJA4(resp.Body)
+	}
+
+	// http/1.1 path: reuse the already-established connection.
+	req, _ := http.NewRequest("GET", "/json", nil)
+	req.Host = "tlsinfo.me"
+	req.Header.Set("Connection", "close")
+	if err := req.Write(uconn); err != nil {
+		return "", err
+	}
+	resp, err := http.ReadResponse(bufio.NewReader(uconn), req)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+	return decodeJA4(resp.Body)
+}
+
+func main() {
+	for _, p := range presets {
+		ja4, err := probe(p.id)
+		if err != nil {
+			fmt.Printf("%-22s  ERROR: %v\n", p.name, err)
+			continue
+		}
+		fmt.Printf("%-22s  %s\n", p.name, ja4)
+	}
+}
diff --git a/cmd/root.go b/cmd/root.go
@@ -86,6 +86,15 @@ func getParser(opts *MicCliOptions) *cobra.Command {
 			switch cfg.Mode {
 			case "client-front":
 				log.Printf("[+] Mode: client-front (HTTP CONNECT proxy)")
+				if cfg.CA.Intercept.Cert != "" || cfg.CA.Intercept.Key != "" {
+					ca, err := proxy.LoadOrGenerateCA(cfg.CA.Intercept.Cert, cfg.CA.Intercept.Key)
+					if err != nil {
+						log.Fatalf("[!] Failed to load/generate intercept CA: %v", err)
+					}
+					p.LocalCA = ca
+					log.Printf("[+] MitM CA ready — import %s, then: curl --cacert %s -x http://localhost%s https://<target>",
+						cfg.CA.Intercept.Cert, cfg.CA.Intercept.Cert, cfg.Listen.Addr)
+				}
 				p.RegisterHandler(proxy.HttpsHandler)
 			case "server-front":
 				log.Printf("[+] Mode: server-front (TLS termination → backend %s)", cfg.Backend.Addr)
diff --git a/config/config.go b/config/config.go
@@ -24,7 +24,13 @@ type Backend struct {
 }
 
 type CAConf struct {
-	Cert string `toml:"cert"`
+	Cert      string          `toml:"cert"`      // upstream CA cert for verifying targets
+	Intercept InterceptCAConf `toml:"intercept"` // client-front: local MitM CA
+}
+
+type InterceptCAConf struct {
+	Cert string `toml:"cert"` // path to local CA cert (created if absent)
+	Key  string `toml:"key"`  // path to local CA key  (created if absent)
 }
 
 type FpConf struct {
diff --git a/fingerprint/tls.go b/fingerprint/tls.go
@@ -9,17 +9,21 @@ import (
 // Table maps known JA4-TLS fingerprint hashes to utls ClientHelloID presets.
 // JA4 hashes are computed from real traffic captures; verify against current
 // browser versions when adding new entries.
+// Table maps JA4-TLS hashes to utls ClientHelloID presets.
+//
+// Hashes are measured empirically against tlsinfo.me using cmd/probe — they
+// reflect what the utls preset actually emits, not captures from real browsers.
+// Re-run cmd/probe after upgrading the utls dependency to verify they still match.
+//
+// Note: HelloChrome_120_PQ produces the same JA4 as HelloChrome_120 because the
+// post-quantum key share (X25519MLKEM768) falls in a key_share extension entry
+// that JA4 does not distinguish. Use HelloChrome_120_PQ directly in dialTarget
+// if you specifically need the PQ key exchange behaviour.
 var Table = map[string]utls.ClientHelloID{
-	// Chrome 120
-	"t13d1516h2_8daaf6152771_b0da82dd1658": utls.HelloChrome_120,
-	// Chrome 120 with post-quantum key exchange
-	"t13d1516h2_8daaf6152771_e5627efa2ab1": utls.HelloChrome_120_PQ,
-	// Firefox 120
-	"t13d1517h2_8daaf6152771_b1ff8ab2d16f": utls.HelloFirefox_120,
-	// Safari 16.0
-	"t13d1516h2_8daaf6152771_4aeede8da0ac": utls.HelloSafari_16_0,
-	// Edge 106
-	"t13d1516h2_8daaf6152771_f5b4b24de8b1": utls.HelloEdge_106,
+	"t13d1516h2_8daaf6152771_02713d6af862": utls.HelloChrome_120,
+	"t13d1715h2_5b57614c22b0_5c2c66f702b0": utls.HelloFirefox_120,
+	"t13d2014h2_a09f3c656075_14788d8d241b": utls.HelloSafari_16_0,
+	"t13d1516h2_8daaf6152771_e5627efa2ab1": utls.HelloEdge_106,
 }
 
 // Lookup returns the utls.ClientHelloID for the given JA4 hash.
diff --git a/go.mod b/go.mod
@@ -14,5 +14,7 @@ require (
 	github.com/klauspost/compress v1.17.4 // indirect
 	github.com/spf13/pflag v1.0.6 // indirect
 	golang.org/x/crypto v0.36.0 // indirect
+	golang.org/x/net v0.38.0 // indirect
 	golang.org/x/sys v0.31.0 // indirect
+	golang.org/x/text v0.23.0 // indirect
 )
diff --git a/go.sum b/go.sum
@@ -16,7 +16,11 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
 github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
 golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
+golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
+golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
 golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
 golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
+golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/mic.example.toml b/mic.example.toml
@@ -9,7 +9,7 @@ addr = "127.0.0.1:443"
 
 [fingerprint]
 [fingerprint.tls]
-ja4 = "t13d1516h2_8daaf6152771_b0da82dd1658"   # Chrome 120
+ja4 = "t13d1516h2_8daaf6152771_02713d6af862"   # Chrome 120 (utls.HelloChrome_120)
 
 # server-front only: certificate for terminating incoming TLS
 [fingerprint.tls.termination]
@@ -19,6 +19,13 @@ key  = "/path/to/key.pem"
 [ca]
 cert = ""   # optional custom CA for upstream verification
 
+# client-front only: MitM interception CA.
+# mic will create the files on first run, then reuse them across restarts.
+# Import ca.pem into curl / your browser / system trust store.
+[ca.intercept]
+cert = "ca.pem"
+key  = "ca-key.pem"
+
 # Future:
 # [fingerprint.tcp]
 # window_size = 65535
diff --git a/proxy/ca.go b/proxy/ca.go
@@ -0,0 +1,188 @@
+package proxy
+
+import (
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
+	"fmt"
+	"math/big"
+	"net"
+	"os"
+	"sync"
+	"time"
+)
+
+// LocalCA is a self-signed CA used to issue per-host certificates for
+// MitM TLS interception in client-front mode.
+type LocalCA struct {
+	cert    *x509.Certificate
+	key     *ecdsa.PrivateKey
+	derCert []byte // raw DER of CA cert, appended as chain in issued certs
+
+	mu    sync.Mutex
+	cache map[string]tls.Certificate
+}
+
+// GenerateCA creates a fresh in-memory self-signed CA.
+func GenerateCA() (*LocalCA, error) {
+	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		return nil, err
+	}
+	return buildCA(key)
+}
+
+// LoadOrGenerateCA loads the CA from certPath + keyPath. If the files do not
+// exist, it generates a new CA and writes it to those paths. Pass empty strings
+// to get an ephemeral in-memory CA.
+func LoadOrGenerateCA(certPath, keyPath string) (*LocalCA, error) {
+	if certPath == "" || keyPath == "" {
+		return GenerateCA()
+	}
+
+	ca, err := loadCA(certPath, keyPath)
+	if err == nil {
+		return ca, nil
+	}
+	if !os.IsNotExist(err) {
+		return nil, fmt.Errorf("loading CA: %w", err)
+	}
+
+	// Files don't exist — generate a new CA and persist it.
+	ca, err = GenerateCA()
+	if err != nil {
+		return nil, err
+	}
+	if err := ca.Save(certPath, keyPath); err != nil {
+		return nil, fmt.Errorf("saving CA: %w", err)
+	}
+	return ca, nil
+}
+
+func loadCA(certPath, keyPath string) (*LocalCA, error) {
+	pair, err := tls.LoadX509KeyPair(certPath, keyPath)
+	if err != nil {
+		return nil, err
+	}
+	cert, err := x509.ParseCertificate(pair.Certificate[0])
+	if err != nil {
+		return nil, err
+	}
+	key, ok := pair.PrivateKey.(*ecdsa.PrivateKey)
+	if !ok {
+		return nil, fmt.Errorf("CA private key must be ECDSA")
+	}
+	return &LocalCA{
+		cert:    cert,
+		key:     key,
+		derCert: pair.Certificate[0],
+		cache:   make(map[string]tls.Certificate),
+	}, nil
+}
+
+func buildCA(key *ecdsa.PrivateKey) (*LocalCA, error) {
+	serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
+	if err != nil {
+		return nil, err
+	}
+	tmpl := &x509.Certificate{
+		SerialNumber:          serial,
+		Subject:               pkix.Name{CommonName: "mic local CA"},
+		NotBefore:             time.Now().Add(-time.Hour),
+		NotAfter:              time.Now().Add(10 * 365 * 24 * time.Hour),
+		IsCA:                  true,
+		KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
+		BasicConstraintsValid: true,
+	}
+	der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+	if err != nil {
+		return nil, err
+	}
+	cert, err := x509.ParseCertificate(der)
+	if err != nil {
+		return nil, err
+	}
+	return &LocalCA{
+		cert:    cert,
+		key:     key,
+		derCert: der,
+		cache:   make(map[string]tls.Certificate),
+	}, nil
+}
+
+// Save writes the CA certificate and private key to PEM files.
+func (ca *LocalCA) Save(certPath, keyPath string) error {
+	cf, err := os.Create(certPath)
+	if err != nil {
+		return err
+	}
+	defer cf.Close()
+	if err := pem.Encode(cf, &pem.Block{Type: "CERTIFICATE", Bytes: ca.derCert}); err != nil {
+		return err
+	}
+
+	keyBytes, err := x509.MarshalECPrivateKey(ca.key)
+	if err != nil {
+		return err
+	}
+	kf, err := os.Create(keyPath)
+	if err != nil {
+		return err
+	}
+	defer kf.Close()
+	return pem.Encode(kf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
+}
+
+// CertPEM returns the CA certificate as a PEM block, ready to be imported
+// into a trust store (e.g. curl --cacert, system store).
+func (ca *LocalCA) CertPEM() []byte {
+	return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.derCert})
+}
+
+// issueCert generates (and caches) a leaf certificate for host, signed by the CA.
+func (ca *LocalCA) issueCert(host string) (tls.Certificate, error) {
+	ca.mu.Lock()
+	defer ca.mu.Unlock()
+
+	if c, ok := ca.cache[host]; ok {
+		return c, nil
+	}
+
+	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		return tls.Certificate{}, err
+	}
+	serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
+	if err != nil {
+		return tls.Certificate{}, err
+	}
+	tmpl := &x509.Certificate{
+		SerialNumber: serial,
+		Subject:      pkix.Name{CommonName: host},
+		NotBefore:    time.Now().Add(-time.Hour),
+		NotAfter:     time.Now().Add(24 * time.Hour),
+		KeyUsage:     x509.KeyUsageDigitalSignature,
+		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+	}
+	if ip := net.ParseIP(host); ip != nil {
+		tmpl.IPAddresses = []net.IP{ip}
+	} else {
+		tmpl.DNSNames = []string{host}
+	}
+
+	der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, &key.PublicKey, ca.key)
+	if err != nil {
+		return tls.Certificate{}, err
+	}
+	cert := tls.Certificate{
+		// Include the CA cert so clients that load the CA can verify the chain.
+		Certificate: [][]byte{der, ca.derCert},
+		PrivateKey:  key,
+	}
+	ca.cache[host] = cert
+	return cert, nil
+}
diff --git a/proxy/https.go b/proxy/https.go
@@ -2,6 +2,7 @@ package proxy
 
 import (
 	"bufio"
+	"crypto/tls"
 	"log"
 	"net"
 	"net/http"
@@ -41,5 +42,48 @@ func HttpsHandler(clientConn net.Conn, p *Proxy) {
 	defer targetConn.Close()
 
 	clientConn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n"))
+
+	// MitM path: terminate TLS from the client using a per-host certificate
+	// issued by the local CA, then pipe decrypted traffic over the already-
+	// established uTLS connection to the target.
+	if p.LocalCA != nil {
+		serverName := host
+		if idx := strings.LastIndex(host, ":"); idx != -1 {
+			serverName = host[:idx]
+		}
+
+		cert, err := p.LocalCA.issueCert(serverName)
+		if err != nil {
+			log.Printf("Failed to issue cert for %s: %v", serverName, err)
+			return
+		}
+
+		// Mirror the ALPN negotiated with the target so the client uses the
+		// same application protocol (e.g. h2 vs http/1.1). Without this,
+		// Chrome-fingerprint uTLS often negotiates h2 with the target while
+		// the client sends HTTP/1.1, causing an immediate protocol error.
+		nextProtos := []string{"http/1.1"}
+		if proto := targetConn.ConnectionState().NegotiatedProtocol; proto != "" {
+			nextProtos = []string{proto}
+		}
+
+		tlsClient := tls.Server(clientConn, &tls.Config{
+			Certificates: []tls.Certificate{cert},
+			NextProtos:   nextProtos,
+		})
+		if err := tlsClient.Handshake(); err != nil {
+			log.Printf("TLS handshake with client failed for %s: %v", serverName, err)
+			return
+		}
+		defer tlsClient.Close()
+
+		log.Printf("MitM tunnel established for %s (proto: %s)", serverName, tlsClient.ConnectionState().NegotiatedProtocol)
+
+		// Both sides are now on the same protocol — pipe plaintext between them.
+		pipe(tlsClient, tlsClient, targetConn)
+		return
+	}
+
+	// No LocalCA: pipe raw bytes (client must not speak TLS after CONNECT).
 	pipe(clientConn, reader, targetConn)
 }
diff --git a/proxy/proxy.go b/proxy/proxy.go
@@ -17,6 +17,7 @@ type Proxy struct {
 	BackendAddr string // server-front: backend host:port
 	CAPool      *x509.CertPool
 	Fingerprint fingerprint.TLSApplier
+	LocalCA     *LocalCA // client-front: MitM CA for TLS interception
 
 	handlers []Handler
 }
@@ -64,19 +65,22 @@ func (p *Proxy) dialTarget(host string) (*utls.UConn, error) {
 }
 
 // pipe copies data bidirectionally between client (reading from r) and target,
-// blocking until both directions are done.
-func pipe(client io.ReadWriter, r io.Reader, target io.ReadWriter) {
+// blocking until both directions are done. When either direction finishes it
+// closes that side's connection so the other goroutine unblocks and exits.
+func pipe(client io.ReadWriteCloser, r io.Reader, target io.ReadWriteCloser) {
 	var wg sync.WaitGroup
 	wg.Add(2)
 
 	go func() {
 		defer wg.Done()
 		io.Copy(target, r)
+		target.Close()
 	}()
 
 	go func() {
 		defer wg.Done()
 		io.Copy(client, target)
+		client.Close()
 	}()
 
 	wg.Wait()
diff --git a/scripts/setup.sh b/scripts/setup.sh
@@ -1,5 +1,5 @@
 #!/usr/bin/env bash
-# setup.sh — build the mic binary and create a starter config.
+# setup.sh — build the mic binary, create a starter config, and start the proxy.
 set -euo pipefail
 
 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -28,8 +28,24 @@ if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
     echo "[~] mic is already running (PID $(cat "$PID_FILE"))."
 else
     echo "[+] Starting mic in the background..."
-    "$BINARY" --config "$CONFIG" &
+    "$BINARY" --config "$CONFIG" > "$ROOT_DIR/mic.log" 2>&1 &
     echo $! > "$PID_FILE"
-    echo "    PID $(cat "$PID_FILE") — log: mic.log"
+    echo "    PID $(cat "$PID_FILE") — log: $ROOT_DIR/mic.log"
     echo "    Stop with:  scripts/cleanup.sh"
 fi
+
+# ── client-front: MitM CA hint ─────────────────────────────────────────────────
+# Wait briefly for mic to write the CA files on first run.
+CA_CERT="$ROOT_DIR/ca.pem"
+sleep 0.5
+if [ -f "$CA_CERT" ]; then
+    echo ""
+    echo "[i] MitM CA certificate found: $CA_CERT"
+    echo "    Trust it once, then use mic as an HTTPS proxy:"
+    echo ""
+    echo "    curl --cacert $CA_CERT -x http://localhost:8080 https://tlsinfo.me/json"
+    echo ""
+    echo "    To add it to the system trust store (Debian/Ubuntu/Kali):"
+    echo "      sudo cp $CA_CERT /usr/local/share/ca-certificates/mic-ca.crt"
+    echo "      sudo update-ca-certificates"
+fi