diff --git a/README.md b/README.md
@@ -1,13 +1,13 @@
# mic
mic (mina-is-cute) is a modular Go proxy for controlling outbound TLS fingerprints.
-It lets you specify a JA4-TLS hash and the proxy will use the corresponding `uTLS`
-preset when connecting to upstream servers, making your traffic look like a specific
-browser to any fingerprinting system.
+It lets you pick a browser fingerprint profile and the proxy will use the corresponding
+`uTLS` preset when connecting to upstream servers, making your traffic look like a
+specific browser to any fingerprinting system.
Two modes are supported:
-- **client-front** — HTTP CONNECT proxy with 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.
@@ -50,7 +50,7 @@ sequenceDiagram
end
```
-In both modes the target sees a TLS handshake that matches the configured JA4 hash,
+In both modes the target sees a TLS handshake that matches the configured fingerprint,
not the default Go TLS fingerprint.
---
@@ -61,134 +61,93 @@ not the default Go TLS fingerprint.
go build -o mic .
```
-Or with the helper script (builds, creates `mic.toml`, starts the proxy, and prints
-CA trust instructions):
-
-```bash
-scripts/setup.sh
-```
-
----
-
-## Configuration
-
-Copy the example config and edit it:
-
-```bash
-cp mic.example.toml mic.toml
-```
-
-```toml
-mode = "client-front" # or "server-front"
-
-[listen]
-addr = ":8080"
-
-[backend]
-addr = "127.0.0.1:443" # server-front only
-
-[fingerprint]
-[fingerprint.tls]
-ja4 = "t13d1516h2_8daaf6152771_b0da82dd1658" # Chrome 120
-
-# 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 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
-
-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_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.
-
---
## Running
+### client-front
+
```bash
-./mic --config mic.toml
+mic client --listen :8080 --fingerprint chrome-120 \
+ --intercept-cert ca.pem --intercept-key ca-key.pem
```
-### client-front
+`--intercept-cert` and `--intercept-key` enable MitM interception. mic generates the
+CA files on first run and reuses them. Import the cert once, then all traffic through
+the proxy is transparently intercepted and re-dialled with the configured fingerprint.
-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):
+**Trust the CA** (pick whichever applies):
```bash
-# curl — pass --cacert on every invocation, 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 — add to system store
-sudo cp ca.pem /usr/local/share/ca-certificates/mic-ca.crt
-sudo update-ca-certificates
+# Debian / Ubuntu / Kali
+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:
+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 your configured fingerprint
+# 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).
+
### server-front
-Generate a self-signed certificate for the proxy to present to clients:
+Generate a cert for the proxy to present to clients:
```bash
go run $(go env GOROOT)/src/crypto/tls/generate_cert.go --host="localhost,127.0.0.1"
# produces cert.pem and key.pem
```
-Set `mode = "server-front"`, point `fingerprint.tls.termination.cert/key` at those
-files, and set `backend.addr` to your upstream. Then connect directly to the proxy
-address with a TLS client.
-
-### Stop
-
```bash
-scripts/cleanup.sh
+mic server --listen :8080 --backend 10.0.0.1:443 \
+ --cert cert.pem --key key.pem \
+ --fingerprint chrome-120
```
+`--backend`, `--cert`, and `--key` are required. `--fingerprint` is optional; without
+it the proxy falls back to a randomised uTLS preset.
+
+### Available fingerprint profiles
+
+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.
+
+| Profile | JA4 hash |
+|---|---|
+| `chrome-120` | `t13d1516h2_8daaf6152771_02713d6af862` |
+| `chrome-120-pq` | same as `chrome-120`¹ |
+| `firefox-120` | `t13d1715h2_5b57614c22b0_5c2c66f702b0` |
+| `safari-16` | `t13d2014h2_a09f3c656075_14788d8d241b` |
+| `edge-106` | `t13d1516h2_8daaf6152771_e5627efa2ab1` |
+
+> ¹ `chrome-120-pq` (post-quantum) produces the same JA4 because JA4 does not
+> distinguish the X25519MLKEM768 key share. Use it when you specifically need the PQ
+> key exchange behaviour regardless of fingerprint visibility.
+
---
## Testing
-Unit tests:
-
```bash
+# unit tests
go test ./...
+
+# integration tests — spin up in-process TLS servers, no network required
+go test -tags integration -v ./proxy/... ./fingerprint/...
```
-Integration tests (spin up in-process TLS servers, no external dependencies):
+The integration suite includes JA4 fingerprint verification tests: for each profile,
+both `TestClientFront_JA4` and `TestServerFront_JA4` capture the raw ClientHello sent
+by the proxy's uTLS engine and assert the computed JA4 hash matches the expected value.
+This is the offline equivalent of running `cmd/probe` against tlsinfo.me.
-```bash
-scripts/run_integration_tests.sh
-# or: go test -tags integration -v ./proxy/...
-```
+CI runs both suites on every push via `.github/workflows/ci.yml`.
diff --git a/cmd/client.go b/cmd/client.go
@@ -0,0 +1,70 @@
+package cmd
+
+import (
+ "crypto/x509"
+ "log"
+ "os"
+
+ "github.com/djnnvx/mic/fingerprint"
+ "github.com/djnnvx/mic/proxy"
+ "github.com/spf13/cobra"
+)
+
+func newClientCmd() *cobra.Command {
+ var (
+ listen string
+ fpName string
+ caPath string
+ interceptCert string
+ interceptKey string
+ )
+
+ cmd := &cobra.Command{
+ Use: "client",
+ Short: "HTTP CONNECT proxy with optional MitM interception",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ p := &proxy.Proxy{ListenAddr: listen}
+
+ if caPath != "" {
+ caCert, err := os.ReadFile(caPath)
+ if err != nil {
+ log.Fatalf("[!] Failed to read CA certificate: %v", err)
+ }
+ pool := x509.NewCertPool()
+ pool.AppendCertsFromPEM(caCert)
+ p.CAPool = pool
+ }
+
+ if fpName != "" {
+ fp, err := fingerprint.ByName(fpName)
+ if err != nil {
+ log.Fatalf("[!] Failed to load fingerprint: %v", err)
+ }
+ p.Fingerprint = fp
+ log.Printf("[+] Using TLS fingerprint: %s", fp.Name())
+ }
+
+ if interceptCert != "" || interceptKey != "" {
+ ca, err := proxy.LoadOrGenerateCA(interceptCert, interceptKey)
+ 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>",
+ interceptCert, interceptCert, listen)
+ }
+
+ log.Printf("[+] Mode: client (HTTP CONNECT proxy on %s)", listen)
+ p.RegisterHandler(proxy.HttpsHandler)
+ return p.Run()
+ },
+ }
+
+ cmd.Flags().StringVarP(&listen, "listen", "l", ":8080", "listen address")
+ cmd.Flags().StringVarP(&fpName, "fingerprint", "f", "", "TLS fingerprint profile name (e.g. chrome-120)")
+ cmd.Flags().StringVar(&caPath, "ca", "", "path to upstream CA certificate")
+ cmd.Flags().StringVar(&interceptCert, "intercept-cert", "", "MitM CA cert path (enables interception)")
+ cmd.Flags().StringVar(&interceptKey, "intercept-key", "", "MitM CA key path (enables interception)")
+
+ return cmd
+}
diff --git a/cmd/root.go b/cmd/root.go
@@ -1,14 +1,9 @@
package cmd
import (
- "crypto/x509"
"fmt"
- "log"
"os"
- "github.com/djnnvx/mic/config"
- "github.com/djnnvx/mic/fingerprint"
- "github.com/djnnvx/mic/proxy"
"github.com/spf13/cobra"
)
@@ -16,7 +11,6 @@ var SKID_ASCII_ART = `
-
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣤⣤⣤⣤⣤⣤⣄⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀ ⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠛⠻⠿⢿⣿⣿⣿⣿⣿⣶⣤⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⢀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠻⣿⣿⣿⣿⣿⣿⣶⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀
@@ -35,7 +29,7 @@ var SKID_ASCII_ART = `
⠸⣿⣿⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡀⠀⠀⠀⢀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀
⠀⢹⣿⣿⣧⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠀⠀
⠀⠀⠹⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠀⠀⠀
- ⠀⠀⠀⠙⣿⣿⣿⣿⣿⣶⣤⣀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿mina is cute⣿⣿⣿⣿⣿⣿⣿⣿⠋⠀⠀⠀⠀
+ ⠀⠀⠀⠙⣿⣿⣿⣿⣿⣶⣤⣀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿mina is cute⣿⣿⣿⣿⣿⣿⣿⣿⠋⠀⠀⠀⠀
⠀⠀⠀⠀⠈⠻⣿⣿⣿⣿⣿⣿⣿⣷⣶⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿<3:)⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠉⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠈⠛⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋⠅⠀⠀⠀⠀⠀⠀⠀⠀⠀
@@ -46,86 +40,20 @@ var SKID_ASCII_ART = `
`
-func getParser(opts *MicCliOptions) *cobra.Command {
- var rootCmd = &cobra.Command{
+func newRootCmd() *cobra.Command {
+ rootCmd := &cobra.Command{
Use: "mic",
Short: "modular proxy for network-fingerprinting evasion",
Long: "mic (mina-is-cute) is a HTTPS proxy to evade networking fingerprinting detection",
- Run: func(cmd *cobra.Command, args []string) {
- cfg, err := config.Load(opts.ConfigPath)
- if err != nil {
- log.Fatalf("[!] Failed to load config: %v", err)
- }
-
- p := &proxy.Proxy{
- ListenAddr: cfg.Listen.Addr,
- BackendAddr: cfg.Backend.Addr,
- }
-
- // Load optional custom CA pool.
- if cfg.CA.Cert != "" {
- caCert, err := os.ReadFile(cfg.CA.Cert)
- if err != nil {
- log.Fatalf("[!] Failed to read CA certificate: %v", err)
- }
- pool := x509.NewCertPool()
- pool.AppendCertsFromPEM(caCert)
- p.CAPool = pool
- }
-
- // Load TLS fingerprint if a JA4 hash is configured.
- if cfg.Fingerprint.TLS.JA4 != "" {
- fp, err := fingerprint.NewTLS(cfg.Fingerprint.TLS.JA4)
- if err != nil {
- log.Fatalf("[!] Failed to load fingerprint: %v", err)
- }
- p.Fingerprint = fp
- log.Printf("[+] Using TLS fingerprint: %s", fp.Name())
- }
-
- 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)
- certFile := cfg.Fingerprint.TLS.Termination.Cert
- keyFile := cfg.Fingerprint.TLS.Termination.Key
- if certFile == "" || keyFile == "" {
- log.Fatalf("[!] server-front mode requires fingerprint.tls.termination.cert and .key")
- }
- p.RegisterHandler(proxy.ServerFrontHandler(certFile, keyFile))
- default:
- log.Fatalf("[!] Unknown mode %q; expected \"client-front\" or \"server-front\"", cfg.Mode)
- }
-
- if err := p.Run(); err != nil {
- log.Fatalf("[!] Proxy error: %v", err)
- }
- },
}
-
- defaults := GetDefaultOptions()
- rootCmd.Flags().StringVarP(&opts.ConfigPath, "config", "c", defaults.ConfigPath, "path to TOML config file")
-
+ rootCmd.AddCommand(newClientCmd())
+ rootCmd.AddCommand(newServerCmd())
return rootCmd
}
func Execute() {
fmt.Println(SKID_ASCII_ART)
-
- opts := GetDefaultOptions()
- cliParser := getParser(&opts)
- if err := cliParser.Execute(); err != nil {
+ if err := newRootCmd().Execute(); err != nil {
fmt.Printf("[!] Error: %s\n", err.Error())
os.Exit(1)
}
diff --git a/cmd/server.go b/cmd/server.go
@@ -0,0 +1,72 @@
+package cmd
+
+import (
+ "crypto/x509"
+ "log"
+ "os"
+
+ "github.com/djnnvx/mic/fingerprint"
+ "github.com/djnnvx/mic/proxy"
+ "github.com/spf13/cobra"
+)
+
+func newServerCmd() *cobra.Command {
+ var (
+ listen string
+ backend string
+ fpName string
+ caPath string
+ cert string
+ key string
+ )
+
+ cmd := &cobra.Command{
+ Use: "server",
+ Short: "TLS termination proxy with upstream fingerprinting",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if backend == "" {
+ log.Fatal("[!] --backend is required for server mode")
+ }
+ if cert == "" || key == "" {
+ log.Fatal("[!] --cert and --key are required for server mode")
+ }
+
+ p := &proxy.Proxy{
+ ListenAddr: listen,
+ BackendAddr: backend,
+ }
+
+ if caPath != "" {
+ caCert, err := os.ReadFile(caPath)
+ if err != nil {
+ log.Fatalf("[!] Failed to read CA certificate: %v", err)
+ }
+ pool := x509.NewCertPool()
+ pool.AppendCertsFromPEM(caCert)
+ p.CAPool = pool
+ }
+
+ if fpName != "" {
+ fp, err := fingerprint.ByName(fpName)
+ if err != nil {
+ log.Fatalf("[!] Failed to load fingerprint: %v", err)
+ }
+ p.Fingerprint = fp
+ log.Printf("[+] Using TLS fingerprint: %s", fp.Name())
+ }
+
+ log.Printf("[+] Mode: server (TLS termination → backend %s)", backend)
+ p.RegisterHandler(proxy.ServerFrontHandler(cert, key))
+ return p.Run()
+ },
+ }
+
+ cmd.Flags().StringVarP(&listen, "listen", "l", ":8080", "listen address")
+ cmd.Flags().StringVarP(&backend, "backend", "b", "", "backend host:port (required)")
+ cmd.Flags().StringVarP(&fpName, "fingerprint", "f", "", "TLS fingerprint profile name (e.g. chrome-120)")
+ cmd.Flags().StringVar(&caPath, "ca", "", "path to upstream CA certificate")
+ cmd.Flags().StringVar(&cert, "cert", "", "TLS certificate for incoming termination (required)")
+ cmd.Flags().StringVar(&key, "key", "", "TLS key for incoming termination (required)")
+
+ return cmd
+}
diff --git a/cmd/types.go b/cmd/types.go
@@ -1,11 +0,0 @@
-package cmd
-
-type MicCliOptions struct {
- ConfigPath string
-}
-
-func GetDefaultOptions() MicCliOptions {
- return MicCliOptions{
- ConfigPath: "mic.toml",
- }
-}
diff --git a/config/config.go b/config/config.go
@@ -1,63 +0,0 @@
-package config
-
-import (
- "fmt"
- "os"
-
- "github.com/pelletier/go-toml/v2"
-)
-
-type Config struct {
- Mode string `toml:"mode"`
- Listen Listen `toml:"listen"`
- Backend Backend `toml:"backend"`
- Fingerprint FpConf `toml:"fingerprint"`
- CA CAConf `toml:"ca"`
-}
-
-type Listen struct {
- Addr string `toml:"addr"`
-}
-
-type Backend struct {
- Addr string `toml:"addr"`
-}
-
-type CAConf struct {
- 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 {
- TLS TLSConf `toml:"tls"`
-}
-
-type TLSConf struct {
- JA4 string `toml:"ja4"`
- Termination TLSTermination `toml:"termination"`
-}
-
-type TLSTermination struct {
- Cert string `toml:"cert"`
- Key string `toml:"key"`
-}
-
-// Load reads and parses a TOML config file from path.
-func Load(path string) (*Config, error) {
- data, err := os.ReadFile(path)
- if err != nil {
- return nil, fmt.Errorf("reading config file %q: %w", path, err)
- }
-
- var cfg Config
- if err := toml.Unmarshal(data, &cfg); err != nil {
- return nil, fmt.Errorf("parsing config file %q: %w", path, err)
- }
-
- return &cfg, nil
-}
diff --git a/config/config_test.go b/config/config_test.go
@@ -1,94 +0,0 @@
-package config_test
-
-import (
- "os"
- "testing"
-
- "github.com/djnnvx/mic/config"
-)
-
-const validTOML = `
-mode = "client-front"
-
-[listen]
-addr = ":8080"
-
-[backend]
-addr = "127.0.0.1:443"
-
-[fingerprint]
-[fingerprint.tls]
-ja4 = "t13d1516h2_8daaf6152771_b0da82dd1658"
-
-[fingerprint.tls.termination]
-cert = "/path/to/cert.pem"
-key = "/path/to/key.pem"
-
-[ca]
-cert = "/path/to/ca.pem"
-`
-
-func writeTempFile(t *testing.T, content string) string {
- t.Helper()
- f, err := os.CreateTemp("", "mic-config-*.toml")
- if err != nil {
- t.Fatalf("creating temp file: %v", err)
- }
- defer f.Close()
- if _, err := f.WriteString(content); err != nil {
- t.Fatalf("writing temp file: %v", err)
- }
- return f.Name()
-}
-
-func TestLoad_Valid(t *testing.T) {
- path := writeTempFile(t, validTOML)
- defer os.Remove(path)
-
- cfg, err := config.Load(path)
- if err != nil {
- t.Fatalf("Load returned error: %v", err)
- }
-
- if cfg.Mode != "client-front" {
- t.Errorf("Mode = %q; want %q", cfg.Mode, "client-front")
- }
- if cfg.Listen.Addr != ":8080" {
- t.Errorf("Listen.Addr = %q; want %q", cfg.Listen.Addr, ":8080")
- }
- if cfg.Backend.Addr != "127.0.0.1:443" {
- t.Errorf("Backend.Addr = %q; want %q", cfg.Backend.Addr, "127.0.0.1:443")
- }
- if cfg.Fingerprint.TLS.JA4 != "t13d1516h2_8daaf6152771_b0da82dd1658" {
- t.Errorf("Fingerprint.TLS.JA4 = %q; want %q",
- cfg.Fingerprint.TLS.JA4, "t13d1516h2_8daaf6152771_b0da82dd1658")
- }
- if cfg.Fingerprint.TLS.Termination.Cert != "/path/to/cert.pem" {
- t.Errorf("Termination.Cert = %q; want %q",
- cfg.Fingerprint.TLS.Termination.Cert, "/path/to/cert.pem")
- }
- if cfg.Fingerprint.TLS.Termination.Key != "/path/to/key.pem" {
- t.Errorf("Termination.Key = %q; want %q",
- cfg.Fingerprint.TLS.Termination.Key, "/path/to/key.pem")
- }
- if cfg.CA.Cert != "/path/to/ca.pem" {
- t.Errorf("CA.Cert = %q; want %q", cfg.CA.Cert, "/path/to/ca.pem")
- }
-}
-
-func TestLoad_MissingFile(t *testing.T) {
- _, err := config.Load("/nonexistent/path/to/config.toml")
- if err == nil {
- t.Fatal("expected error for missing file, got nil")
- }
-}
-
-func TestLoad_InvalidTOML(t *testing.T) {
- path := writeTempFile(t, "this is not valid = toml [[[")
- defer os.Remove(path)
-
- _, err := config.Load(path)
- if err == nil {
- t.Fatal("expected error for invalid TOML, got nil")
- }
-}
diff --git a/fingerprint/fingerprint.go b/fingerprint/fingerprint.go
@@ -1,10 +1,9 @@
// Package fingerprint provides interfaces and implementations for controlling
-// outbound connection fingerprints (TLS ClientHello, TCP socket options, etc.).
+// outbound TLS connection fingerprints.
package fingerprint
import utls "github.com/bogdanfinn/utls"
-// Applier is the base interface for all fingerprint types.
type Applier interface {
Name() string
}
@@ -14,11 +13,3 @@ type TLSApplier interface {
Applier
ClientHelloID() utls.ClientHelloID
}
-
-// TCPApplier will control TCP socket options (window size, TTL, MSS, etc.).
-// Left for a future implementation.
-//
-// type TCPApplier interface {
-// Applier
-// Apply(fd uintptr) error
-// }
diff --git a/fingerprint/ja4.go b/fingerprint/ja4.go
@@ -0,0 +1,330 @@
+package fingerprint
+
+import (
+ "crypto/sha256"
+ "encoding/binary"
+ "fmt"
+ "net"
+ "sort"
+ "strings"
+)
+
+// ClientHelloFields holds the parsed TLS ClientHello data used to compute JA4.
+type ClientHelloFields struct {
+ LegacyVersion uint16
+ CipherSuites []uint16 // non-GREASE, wire order
+ Extensions []uint16 // non-GREASE extension types, wire order
+ SNIHost string
+ SNIIsIP bool
+ SupportedVersions []uint16 // from ext 0x002b, non-GREASE
+ ALPNValues []string // from ext 0x0010
+ SigAlgs []uint16 // from ext 0x000d, wire order
+}
+
+// isGREASE reports whether v is a GREASE value (RFC 8701).
+// The pattern: high byte == low byte, low nibble of each == 0xA.
+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) {
+ if len(raw) < 5 {
+ return nil, fmt.Errorf("ja4: record too short (%d bytes)", len(raw))
+ }
+ if raw[0] != 0x16 {
+ return nil, fmt.Errorf("ja4: 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("ja4: record body truncated (need %d, have %d)", 5+recLen, len(raw))
+ }
+ hs := raw[5 : 5+recLen]
+
+ if len(hs) < 4 {
+ return nil, fmt.Errorf("ja4: handshake header too short")
+ }
+ if hs[0] != 0x01 {
+ return nil, fmt.Errorf("ja4: not a ClientHello (handshake type=0x%02x)", hs[0])
+ }
+ // 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")
+ }
+ r := hs[4 : 4+chLen]
+
+ fields := &ClientHelloFields{}
+
+ if len(r) < 2 {
+ return nil, fmt.Errorf("ja4: too short for legacy version")
+ }
+ fields.LegacyVersion = binary.BigEndian.Uint16(r[:2])
+ r = r[2:]
+
+ if len(r) < 32 {
+ return nil, fmt.Errorf("ja4: too short for random")
+ }
+ r = r[32:]
+
+ if len(r) < 1 {
+ return nil, fmt.Errorf("ja4: too short for session ID length")
+ }
+ sidLen := int(r[0])
+ r = r[1:]
+ if len(r) < sidLen {
+ return nil, fmt.Errorf("ja4: too short for session ID")
+ }
+ r = r[sidLen:]
+
+ if len(r) < 2 {
+ return nil, fmt.Errorf("ja4: too short for cipher suites length")
+ }
+ csLen := int(binary.BigEndian.Uint16(r[:2]))
+ r = r[2:]
+ if len(r) < csLen || csLen%2 != 0 {
+ return nil, fmt.Errorf("ja4: cipher suites truncated or odd length")
+ }
+ for i := 0; i < csLen; i += 2 {
+ cs := binary.BigEndian.Uint16(r[i : i+2])
+ if !isGREASE(cs) {
+ fields.CipherSuites = append(fields.CipherSuites, cs)
+ }
+ }
+ r = r[csLen:]
+
+ if len(r) < 1 {
+ return nil, fmt.Errorf("ja4: too short for compression methods length")
+ }
+ cmLen := int(r[0])
+ r = r[1:]
+ if len(r) < cmLen {
+ return nil, fmt.Errorf("ja4: too short for compression methods")
+ }
+ r = r[cmLen:]
+
+ // Extensions are absent in very old TLS — not an error.
+ 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("ja4: 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("ja4: 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 0x0000:
+ parseSNI(extData, fields)
+ case 0x0010:
+ parseALPN(extData, fields)
+ case 0x002b:
+ parseSupportedVersions(extData, fields)
+ case 0x000d:
+ parseSigAlgs(extData, fields)
+ }
+ }
+
+ return fields, nil
+}
+
+func parseSNI(data []byte, f *ClientHelloFields) {
+ 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) >= 3 {
+ nameType := data[0]
+ nameLen := int(binary.BigEndian.Uint16(data[1:3]))
+ data = data[3:]
+ if len(data) < nameLen {
+ break
+ }
+ name := string(data[:nameLen])
+ data = data[nameLen:]
+ if nameType == 0 { // host_name (the only defined type)
+ f.SNIHost = name
+ f.SNIIsIP = net.ParseIP(name) != nil
+ return
+ }
+ }
+}
+
+func parseALPN(data []byte, f *ClientHelloFields) {
+ 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:]
+ }
+}
+
+func parseSupportedVersions(data []byte, f *ClientHelloFields) {
+ // ClientHello supported_versions uses a 1-byte list length, unlike most extensions.
+ if len(data) < 1 {
+ return
+ }
+ listLen := int(data[0])
+ data = data[1:]
+ if len(data) < listLen || listLen%2 != 0 {
+ return
+ }
+ for i := 0; i < listLen; i += 2 {
+ v := binary.BigEndian.Uint16(data[i : i+2])
+ if !isGREASE(v) {
+ f.SupportedVersions = append(f.SupportedVersions, v)
+ }
+ }
+}
+
+func parseSigAlgs(data []byte, f *ClientHelloFields) {
+ if len(data) < 2 {
+ return
+ }
+ listLen := int(binary.BigEndian.Uint16(data[:2]))
+ data = data[2:]
+ if len(data) < listLen || listLen%2 != 0 {
+ return
+ }
+ for i := 0; i < listLen; i += 2 {
+ f.SigAlgs = append(f.SigAlgs, binary.BigEndian.Uint16(data[i:i+2]))
+ }
+}
+
+func ComputeJA4(ch *ClientHelloFields) string {
+ return buildJA4a(ch) + "_" + buildJA4b(ch.CipherSuites) + "_" + buildJA4c(ch.Extensions, ch.SigAlgs)
+}
+
+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)
+ }
+
+ sniChar := "n"
+ if ch.SNIHost != "" {
+ if ch.SNIIsIP {
+ sniChar = "i"
+ } else {
+ sniChar = "d"
+ }
+ }
+
+ nc := min99(len(ch.CipherSuites))
+ ne := min99(len(ch.Extensions))
+
+ alpn := "00"
+ if len(ch.ALPNValues) > 0 {
+ v := ch.ALPNValues[0]
+ switch {
+ case len(v) >= 2:
+ alpn = v[:2]
+ case len(v) == 1:
+ alpn = v + "0"
+ }
+ }
+
+ return fmt.Sprintf("t%s%s%02d%02d%s", tlsVer, sniChar, nc, ne, alpn)
+}
+
+// min99 caps n at 99, the maximum that fits in the two-digit JA4_a counter.
+func min99(n int) int {
+ if n > 99 {
+ return 99
+ }
+ return n
+}
+
+func tlsVersionStr(v uint16) string {
+ switch v {
+ case 0x0304:
+ return "13"
+ case 0x0303:
+ return "12"
+ case 0x0302:
+ return "11"
+ case 0x0301:
+ return "10"
+ default:
+ return "00"
+ }
+}
+
+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] })
+
+ parts := make([]string, len(sorted))
+ for i, c := range sorted {
+ parts[i] = fmt.Sprintf("%04x", c)
+ }
+ h := sha256.Sum256([]byte(strings.Join(parts, ",")))
+ 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.
+func buildJA4c(exts []uint16, sigAlgs []uint16) string {
+ var filtered []uint16
+ for _, e := range exts {
+ if e != 0x0000 && e != 0x0010 {
+ filtered = append(filtered, e)
+ }
+ }
+ sort.Slice(filtered, func(i, j int) bool { return filtered[i] < filtered[j] })
+
+ extParts := make([]string, len(filtered))
+ for i, e := range filtered {
+ extParts[i] = fmt.Sprintf("%04x", e)
+ }
+
+ sigParts := make([]string, len(sigAlgs))
+ for i, s := range sigAlgs {
+ sigParts[i] = fmt.Sprintf("%04x", s)
+ }
+
+ h := sha256.Sum256([]byte(strings.Join(extParts, ",") + "_" + strings.Join(sigParts, ",")))
+ return fmt.Sprintf("%x", h)[:12]
+}
diff --git a/fingerprint/tcp.go b/fingerprint/tcp.go
@@ -1,11 +0,0 @@
-package fingerprint
-
-// TCPApplier is reserved for future TCP-level fingerprinting support
-// (TCP window size, TTL, MSS, timestamps, etc.).
-//
-// Future interface outline:
-//
-// type TCPApplier interface {
-// Applier
-// Apply(fd uintptr) error
-// }
diff --git a/fingerprint/tls.go b/fingerprint/tls.go
@@ -6,6 +6,15 @@ import (
utls "github.com/bogdanfinn/utls"
)
+// NameTable maps human-readable profile names to utls ClientHelloID presets.
+var NameTable = map[string]utls.ClientHelloID{
+ "chrome-120": utls.HelloChrome_120,
+ "chrome-120-pq": utls.HelloChrome_120_PQ,
+ "firefox-120": utls.HelloFirefox_120,
+ "safari-16": utls.HelloSafari_16_0,
+ "edge-106": utls.HelloEdge_106,
+}
+
// Table maps known JA4-TLS fingerprint hashes to utls ClientHelloID presets.
//
// Hashes are measured empirically against tlsinfo.me using cmd/probe — they
@@ -24,34 +33,28 @@ var Table = map[string]utls.ClientHelloID{
"t13d1516h2_8daaf6152771_e5627efa2ab1": utls.HelloEdge_106,
}
-// Lookup returns the utls.ClientHelloID for the given JA4 hash.
func Lookup(ja4 string) (utls.ClientHelloID, bool) {
id, ok := Table[ja4]
return id, ok
}
-// TLSFingerprint implements TLSApplier for a specific JA4 hash.
type TLSFingerprint struct {
- ja4 string
- id utls.ClientHelloID
+ name string
+ id utls.ClientHelloID
}
-// NewTLS creates a TLSFingerprint for the given JA4 hash.
-// Returns an error if the hash is not in the built-in table.
-func NewTLS(ja4 string) (*TLSFingerprint, error) {
- id, ok := Lookup(ja4)
+func ByName(name string) (*TLSFingerprint, error) {
+ id, ok := NameTable[name]
if !ok {
- return nil, fmt.Errorf("unknown JA4-TLS fingerprint %q", ja4)
+ return nil, fmt.Errorf("unknown TLS fingerprint profile %q", name)
}
- return &TLSFingerprint{ja4: ja4, id: id}, nil
+ return &TLSFingerprint{name: name, id: id}, nil
}
-// Name returns a human-readable identifier including the JA4 hash.
func (f *TLSFingerprint) Name() string {
- return "tls:" + f.ja4
+ return f.name
}
-// ClientHelloID returns the utls preset for this fingerprint.
func (f *TLSFingerprint) ClientHelloID() utls.ClientHelloID {
return f.id
}
diff --git a/fingerprint/tls_test.go b/fingerprint/tls_test.go
@@ -1,62 +1,61 @@
package fingerprint_test
import (
- "strings"
"testing"
utls "github.com/bogdanfinn/utls"
"github.com/djnnvx/mic/fingerprint"
)
-const knownJA4 = "t13d1516h2_8daaf6152771_02713d6af862"
-
-func TestLookup_Known(t *testing.T) {
- id, ok := fingerprint.Lookup(knownJA4)
- if !ok {
- t.Fatalf("Lookup(%q) returned false; expected true", knownJA4)
+func TestByName_Happy(t *testing.T) {
+ fp, err := fingerprint.ByName("chrome-120")
+ if err != nil {
+ t.Fatalf("ByName(%q) returned error: %v", "chrome-120", err)
}
- if id.Str() != utls.HelloChrome_131.Str() {
- t.Errorf("Lookup(%q) = %v; want HelloChrome_131", knownJA4, id)
+ if fp == nil {
+ t.Fatal("ByName returned nil fingerprint")
}
}
-func TestLookup_Unknown(t *testing.T) {
- _, ok := fingerprint.Lookup("unknown_hash_that_does_not_exist")
- if ok {
- t.Fatal("Lookup with unknown hash returned true; expected false")
+func TestByName_Error(t *testing.T) {
+ _, err := fingerprint.ByName("not-a-real-profile")
+ if err == nil {
+ t.Fatal("ByName with unknown name returned nil error; expected error")
}
}
-func TestNewTLS_Happy(t *testing.T) {
- fp, err := fingerprint.NewTLS(knownJA4)
+func TestName_ReturnsProfile(t *testing.T) {
+ fp, err := fingerprint.ByName("chrome-120")
if err != nil {
- t.Fatalf("NewTLS(%q) returned error: %v", knownJA4, err)
+ t.Fatalf("ByName: %v", err)
}
- if fp == nil {
- t.Fatal("NewTLS returned nil fingerprint")
+ if fp.Name() != "chrome-120" {
+ t.Errorf("Name() = %q; want %q", fp.Name(), "chrome-120")
}
}
-func TestNewTLS_Error(t *testing.T) {
- _, err := fingerprint.NewTLS("not_a_real_ja4_hash")
- if err == nil {
- t.Fatal("NewTLS with unknown hash returned nil error; expected error")
+func TestLookup_Known(t *testing.T) {
+ id, ok := fingerprint.Lookup("t13d1516h2_8daaf6152771_02713d6af862")
+ if !ok {
+ t.Fatal("Lookup returned false for known Chrome 120 JA4 hash")
+ }
+ if id.Str() != utls.HelloChrome_131.Str() {
+ t.Errorf("Lookup returned %v; want HelloChrome_131", id)
}
}
-func TestName_ContainsJA4(t *testing.T) {
- fp, err := fingerprint.NewTLS(knownJA4)
- if err != nil {
- t.Fatalf("NewTLS: %v", err)
- }
- name := fp.Name()
- if !strings.Contains(name, knownJA4) {
- t.Errorf("Name() = %q; does not contain JA4 hash %q", name, knownJA4)
+func TestLookup_Unknown(t *testing.T) {
+ _, ok := fingerprint.Lookup("unknown_hash_that_does_not_exist")
+ if ok {
+ t.Fatal("Lookup with unknown hash returned true; expected false")
}
}
-func TestTable_MinEntries(t *testing.T) {
- if len(fingerprint.Table) < 5 {
- t.Errorf("Table has %d entries; want at least 5", len(fingerprint.Table))
+func TestNameTable_HasExpectedProfiles(t *testing.T) {
+ expected := []string{"chrome-120", "chrome-120-pq", "firefox-120", "safari-16", "edge-106"}
+ for _, name := range expected {
+ if _, ok := fingerprint.NameTable[name]; !ok {
+ t.Errorf("NameTable missing profile %q", name)
+ }
}
}
diff --git a/go.mod b/go.mod
@@ -4,7 +4,6 @@ go 1.24.4
require (
github.com/bogdanfinn/utls v1.7.7-barnius
- github.com/pelletier/go-toml/v2 v2.2.4
github.com/spf13/cobra v1.9.1
golang.org/x/net v0.38.0
)
diff --git a/go.sum b/go.sum
@@ -7,8 +7,6 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
-github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
-github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
diff --git a/mic.example.toml b/mic.example.toml
@@ -1,33 +0,0 @@
-mode = "client-front" # "client-front" | "server-front"
-
-[listen]
-addr = ":8080"
-
-[backend]
-# Used in server-front mode; client-front uses the CONNECT target directly.
-addr = "127.0.0.1:443"
-
-[fingerprint]
-[fingerprint.tls]
-ja4 = "t13d1516h2_8daaf6152771_02713d6af862" # Chrome 120 (utls.HelloChrome_120)
-
-# server-front only: certificate for terminating incoming TLS
-[fingerprint.tls.termination]
-cert = "/path/to/cert.pem"
-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
-# ttl = 64
-# mss = 1460
diff --git a/proxy/ca.go b/proxy/ca.go
@@ -21,13 +21,12 @@ import (
type LocalCA struct {
cert *x509.Certificate
key *ecdsa.PrivateKey
- derCert []byte // raw DER of CA cert, appended as chain in issued certs
+ derCert []byte // raw DER, 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 {
@@ -37,8 +36,8 @@ func GenerateCA() (*LocalCA, error) {
}
// 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.
+// exist it generates a new CA and writes it to those paths. Pass empty strings
+// for an ephemeral in-memory CA.
func LoadOrGenerateCA(certPath, keyPath string) (*LocalCA, error) {
if certPath == "" || keyPath == "" {
return GenerateCA()
@@ -52,7 +51,6 @@ func LoadOrGenerateCA(certPath, keyPath string) (*LocalCA, error) {
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
@@ -114,7 +112,6 @@ func buildCA(key *ecdsa.PrivateKey) (*LocalCA, error) {
}, 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 {
@@ -137,13 +134,14 @@ func (ca *LocalCA) Save(certPath, keyPath string) error {
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).
+// CertPEM returns the CA certificate as PEM, suitable for curl --cacert or
+// importing into a system trust 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.
+// issueCert returns a leaf certificate for host signed by the CA, issuing and
+// caching a new one on first call.
func (ca *LocalCA) issueCert(host string) (tls.Certificate, error) {
ca.mu.Lock()
defer ca.mu.Unlock()
@@ -179,8 +177,7 @@ func (ca *LocalCA) issueCert(host string) (tls.Certificate, error) {
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},
+ Certificate: [][]byte{der, ca.derCert}, // CA appended so clients can verify the chain
PrivateKey: key,
}
ca.cache[host] = cert
diff --git a/proxy/https.go b/proxy/https.go
@@ -12,7 +12,6 @@ import (
func HttpsHandler(clientConn net.Conn, p *Proxy) {
defer clientConn.Close()
- // Read the first line of the request to check for the CONNECT method.
reader := bufio.NewReader(clientConn)
req, err := http.ReadRequest(reader)
if err != nil {
@@ -20,14 +19,11 @@ func HttpsHandler(clientConn net.Conn, p *Proxy) {
return
}
- // An HTTPS proxy relies on the HTTP CONNECT method.
if req.Method != http.MethodConnect {
log.Printf("Received non-CONNECT request: %s %s", req.Method, req.Host)
return
}
- // The Host field contains the destination host and port.
- // Default to :443 if no port is specified.
host := req.URL.Host
if !strings.Contains(host, ":") {
host = host + ":443"
@@ -43,9 +39,6 @@ func HttpsHandler(clientConn net.Conn, p *Proxy) {
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 {
@@ -58,10 +51,10 @@ func HttpsHandler(clientConn net.Conn, p *Proxy) {
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.
+ // 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.
nextProtos := []string{"http/1.1"}
if proto := targetConn.ConnectionState().NegotiatedProtocol; proto != "" {
nextProtos = []string{proto}
@@ -78,12 +71,9 @@ func HttpsHandler(clientConn net.Conn, p *Proxy) {
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/integration_test.go b/proxy/integration_test.go
@@ -19,13 +19,8 @@ import (
"github.com/djnnvx/mic/fingerprint"
)
-const (
- chrome120JA4 = "t13d1516h2_8daaf6152771_b0da82dd1658"
- testTimeout = 10 * time.Second
-)
+const testTimeout = 10 * time.Second
-// serveHandler starts a TCP listener and dispatches accepted connections to h.
-// The listener is closed when the test ends.
func serveHandler(t *testing.T, p *Proxy, h Handler) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
@@ -47,15 +42,11 @@ func serveHandler(t *testing.T, p *Proxy, h Handler) string {
return ln.Addr().String()
}
-// writeTempCertKey generates a self-signed cert/key, writes PEM files to disk,
-// and returns the file paths plus a CA pool that trusts the cert.
-// Files are removed when the test ends.
func writeTempCertKey(t *testing.T) (certFile, keyFile string, pool *x509.CertPool) {
t.Helper()
pool, tlsCert := generateSelfSignedCert(t)
- // Certificate PEM.
cf, err := os.CreateTemp("", "mic-cert-*.pem")
if err != nil {
t.Fatalf("creating cert temp file: %v", err)
@@ -64,7 +55,7 @@ func writeTempCertKey(t *testing.T) (certFile, keyFile string, pool *x509.CertPo
cf.Close()
certFile = cf.Name()
- // Private key PEM (PKCS8 — accepted by tls.LoadX509KeyPair as "PRIVATE KEY").
+ // PKCS8 format is required: tls.LoadX509KeyPair accepts "PRIVATE KEY" blocks.
keyBytes, err := x509.MarshalPKCS8PrivateKey(tlsCert.PrivateKey)
if err != nil {
t.Fatalf("marshaling private key: %v", err)
@@ -89,7 +80,6 @@ func writeTempCertKey(t *testing.T) (certFile, keyFile string, pool *x509.CertPo
//
// client → TCP CONNECT → proxy → uTLS(Chrome120) → httptest.TLSServer
func TestClientFront_Integration(t *testing.T) {
- // Start a target HTTPS server.
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "close")
w.WriteHeader(http.StatusOK)
@@ -97,18 +87,16 @@ func TestClientFront_Integration(t *testing.T) {
}))
defer ts.Close()
- // Trust the test server's certificate.
caPool := ts.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs
- fp, err := fingerprint.NewTLS(chrome120JA4)
+ fp, err := fingerprint.ByName("chrome-120")
if err != nil {
- t.Fatalf("fingerprint.NewTLS: %v", err)
+ t.Fatalf("fingerprint.ByName: %v", err)
}
p := &Proxy{CAPool: caPool, Fingerprint: fp}
proxyAddr := serveHandler(t, p, HttpsHandler)
- // Connect to the proxy over plain TCP.
conn, err := net.DialTimeout("tcp", proxyAddr, testTimeout)
if err != nil {
t.Fatalf("dial proxy: %v", err)
@@ -116,7 +104,6 @@ func TestClientFront_Integration(t *testing.T) {
defer conn.Close()
conn.SetDeadline(time.Now().Add(testTimeout))
- // Send HTTP CONNECT to open a tunnel to the target.
targetAddr := ts.Listener.Addr().String()
fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", targetAddr, targetAddr)
@@ -129,8 +116,8 @@ func TestClientFront_Integration(t *testing.T) {
t.Fatalf("CONNECT status: %d; want 200", resp.StatusCode)
}
- // After CONNECT the proxy has already done uTLS to the target.
- // We send raw HTTP — the proxy wraps it in TLS on our behalf.
+ // The proxy has already done uTLS to the target; raw bytes we send here are
+ // wrapped in that TLS connection on our behalf.
fmt.Fprintf(conn, "GET / HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", targetAddr)
resp, err = http.ReadResponse(reader, nil)
@@ -150,7 +137,6 @@ func TestClientFront_Integration(t *testing.T) {
//
// client → TLS → proxy (terminates TLS) → uTLS(Chrome120) → httptest.TLSServer
func TestServerFront_Integration(t *testing.T) {
- // Start the backend HTTPS server (what the proxy connects to upstream).
backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "close")
w.WriteHeader(http.StatusOK)
@@ -159,13 +145,11 @@ func TestServerFront_Integration(t *testing.T) {
defer backend.Close()
backendCAPool := backend.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs
-
- // Generate a cert/key pair the proxy will use to terminate incoming client TLS.
certFile, keyFile, proxyCertPool := writeTempCertKey(t)
- fp, err := fingerprint.NewTLS(chrome120JA4)
+ fp, err := fingerprint.ByName("chrome-120")
if err != nil {
- t.Fatalf("fingerprint.NewTLS: %v", err)
+ t.Fatalf("fingerprint.ByName: %v", err)
}
p := &Proxy{
@@ -175,7 +159,6 @@ func TestServerFront_Integration(t *testing.T) {
}
proxyAddr := serveHandler(t, p, ServerFrontHandler(certFile, keyFile))
- // Connect to the proxy with TLS, trusting the proxy's generated cert.
tlsConn, err := tls.DialWithDialer(
&net.Dialer{Timeout: testTimeout},
"tcp", proxyAddr,
@@ -187,8 +170,8 @@ func TestServerFront_Integration(t *testing.T) {
defer tlsConn.Close()
tlsConn.SetDeadline(time.Now().Add(testTimeout))
- // Send plain HTTP — the proxy decrypts from us and re-encrypts with the
- // configured fingerprint before forwarding to the backend.
+ // 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
@@ -0,0 +1,137 @@
+//go:build integration
+
+package proxy
+
+import (
+ "bufio"
+ "crypto/tls"
+ "fmt"
+ "io"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/djnnvx/mic/fingerprint"
+)
+
+// Values measured empirically via cmd/probe against
+// tlsinfo.me). chrome-120-pq is absent: it emits the same JA4 as chrome-120
+// because JA4 does not distinguish the X25519MLKEM768 key share.
+var expectedJA4 = map[string]string{
+ "chrome-120": "t13d1516h2_8daaf6152771_02713d6af862",
+ "firefox-120": "t13d1715h2_5b57614c22b0_5c2c66f702b0",
+ "safari-16": "t13d2014h2_a09f3c656075_14788d8d241b",
+ "edge-106": "t13d1516h2_8daaf6152771_e5627efa2ab1",
+}
+
+// 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
+func TestClientFront_JA4(t *testing.T) {
+ for name, want := range expectedJA4 {
+ name, want := name, want
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+
+ captureAddr, capturePool, ja4s := captureJA4Server(t)
+
+ fp, err := fingerprint.ByName(name)
+ if err != nil {
+ t.Fatalf("fingerprint.ByName(%q): %v", name, err)
+ }
+
+ proxyAddr := serveHandler(t, &Proxy{CAPool: capturePool, Fingerprint: fp}, HttpsHandler)
+
+ conn, err := net.DialTimeout("tcp", proxyAddr, testTimeout)
+ if err != nil {
+ t.Fatalf("dial proxy: %v", err)
+ }
+ defer conn.Close()
+ conn.SetDeadline(time.Now().Add(testTimeout)) //nolint:errcheck
+
+ fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", captureAddr, captureAddr)
+ if status, err := readHTTPStatus(bufio.NewReader(conn)); err != nil || status != 200 {
+ 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
+
+ if got := receiveJA4(t, ja4s); got != want {
+ t.Errorf("JA4 mismatch for %s:\n got %s\n want %s", name, got, want)
+ }
+ })
+ }
+}
+
+// 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
+func TestServerFront_JA4(t *testing.T) {
+ for name, want := range expectedJA4 {
+ name, want := name, want
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+
+ captureAddr, capturePool, ja4s := captureJA4Server(t)
+ proxyCertFile, proxyKeyFile, proxyPool := writeTempCertKey(t)
+
+ fp, err := fingerprint.ByName(name)
+ if err != nil {
+ t.Fatalf("fingerprint.ByName(%q): %v", name, err)
+ }
+
+ p := &Proxy{BackendAddr: captureAddr, CAPool: capturePool, Fingerprint: fp}
+ proxyAddr := serveHandler(t, p, ServerFrontHandler(proxyCertFile, proxyKeyFile))
+
+ tlsConn, err := tls.DialWithDialer(
+ &net.Dialer{Timeout: testTimeout},
+ "tcp", proxyAddr,
+ &tls.Config{RootCAs: proxyPool, ServerName: "localhost"},
+ )
+ if err != nil {
+ t.Fatalf("tls.Dial to proxy: %v", err)
+ }
+ defer tlsConn.Close()
+ tlsConn.SetDeadline(time.Now().Add(testTimeout)) //nolint:errcheck
+
+ fmt.Fprintf(tlsConn, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
+ io.Copy(io.Discard, tlsConn) //nolint:errcheck
+
+ if got := receiveJA4(t, ja4s); got != want {
+ t.Errorf("JA4 mismatch for %s:\n got %s\n want %s", name, got, want)
+ }
+ })
+ }
+}
+
+func readHTTPStatus(r *bufio.Reader) (int, error) {
+ line, err := r.ReadString('\n')
+ if err != nil {
+ return 0, err
+ }
+ var proto string
+ var status int
+ fmt.Sscanf(line, "%s %d", &proto, &status)
+ for {
+ h, err := r.ReadString('\n')
+ if err != nil || h == "\r\n" {
+ break
+ }
+ }
+ return status, nil
+}
+
+func receiveJA4(t *testing.T, ja4s <-chan string) string {
+ t.Helper()
+ select {
+ case ja4 := <-ja4s:
+ return ja4
+ case <-time.After(testTimeout):
+ t.Fatal("timed out waiting for captured JA4")
+ return ""
+ }
+}
diff --git a/proxy/ja4capture_test.go b/proxy/ja4capture_test.go
@@ -0,0 +1,100 @@
+//go:build integration
+
+package proxy
+
+import (
+ "bufio"
+ "bytes"
+ "crypto/tls"
+ "crypto/x509"
+ "fmt"
+ "io"
+ "net"
+ "testing"
+ "time"
+
+ "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.
+type teeConn struct {
+ net.Conn
+ r io.Reader
+}
+
+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.
+func captureJA4Server(t *testing.T) (addr string, certPool *x509.CertPool, ja4s <-chan string) {
+ t.Helper()
+
+ pool, tlsCert := generateSelfSignedCert(t)
+ tlsCfg := &tls.Config{Certificates: []tls.Certificate{tlsCert}}
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("captureJA4Server: listen: %v", err)
+ }
+ t.Cleanup(func() { ln.Close() })
+
+ _, port, _ := net.SplitHostPort(ln.Addr().String())
+
+ ch := make(chan string, 16)
+ go func() {
+ for {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ go serveCapture(conn, tlsCfg, ch)
+ }
+ }()
+
+ return "localhost:" + port, pool, ch
+}
+
+func serveCapture(conn net.Conn, cfg *tls.Config, ch chan<- string) {
+ defer conn.Close()
+ conn.SetDeadline(time.Now().Add(10 * time.Second)) //nolint:errcheck
+
+ var buf bytes.Buffer
+ tc := &teeConn{Conn: conn, r: io.TeeReader(conn, &buf)}
+
+ srv := tls.Server(tc, cfg)
+ if err := srv.Handshake(); err != nil {
+ if ja4, err2 := captureToJA4(buf.Bytes()); err2 == nil {
+ ch <- ja4
+ }
+ return
+ }
+
+ if ja4, err := captureToJA4(buf.Bytes()); err == nil {
+ ch <- ja4
+ }
+
+ reader := bufio.NewReader(srv)
+ for {
+ line, err := reader.ReadString('\n')
+ if err != nil || line == "\r\n" {
+ break
+ }
+ }
+ fmt.Fprintf(srv, "HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nOK\n")
+}
+
+func captureToJA4(raw []byte) (string, error) {
+ ch, err := fingerprint.ParseClientHello(raw)
+ if err != nil {
+ return "", err
+ }
+ return fingerprint.ComputeJA4(ch), nil
+}
diff --git a/proxy/proxy.go b/proxy/proxy.go
@@ -22,17 +22,14 @@ type Proxy struct {
handlers []Handler
}
-// Handler is a function that processes an incoming client connection.
type Handler func(conn net.Conn, p *Proxy)
-// RegisterHandler adds a handler to the proxy.
func (p *Proxy) RegisterHandler(h Handler) {
p.handlers = append(p.handlers, h)
}
-// dialTarget dials host (host:port), performs a uTLS handshake using p.Fingerprint,
-// and returns the ready-to-use connection. Falls back to HelloRandomized if no
-// fingerprint is configured.
+// 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.Dial("tcp", host)
if err != nil {
@@ -49,9 +46,7 @@ func (p *Proxy) dialTarget(host string) (*utls.UConn, error) {
serverName = host[:idx]
}
- cfg := &utls.Config{
- ServerName: serverName,
- }
+ cfg := &utls.Config{ServerName: serverName}
if p.CAPool != nil {
cfg.RootCAs = p.CAPool
}
@@ -64,9 +59,8 @@ func (p *Proxy) dialTarget(host string) (*utls.UConn, error) {
return uconn, nil
}
-// pipe copies data bidirectionally between client (reading from r) and target,
-// blocking until both directions are done. When either direction finishes it
-// closes that side's connection so the other goroutine unblocks and exits.
+// pipe copies bidirectionally between client (reading from r) and target.
+// When either direction finishes it closes that side so the other goroutine unblocks.
func pipe(client io.ReadWriteCloser, r io.Reader, target io.ReadWriteCloser) {
var wg sync.WaitGroup
wg.Add(2)
@@ -86,7 +80,6 @@ func pipe(client io.ReadWriteCloser, r io.Reader, target io.ReadWriteCloser) {
wg.Wait()
}
-// Run starts the proxy server and dispatches incoming connections to registered handlers.
func (p *Proxy) Run() error {
ln, err := net.Listen("tcp", p.ListenAddr)
if err != nil {
diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go
@@ -16,7 +16,6 @@ import (
"github.com/djnnvx/mic/fingerprint"
)
-// generateSelfSignedCert creates an in-memory self-signed cert/pool for testing.
func generateSelfSignedCert(t *testing.T) (*x509.CertPool, tls.Certificate) {
t.Helper()
@@ -58,7 +57,6 @@ func generateSelfSignedCert(t *testing.T) (*x509.CertPool, tls.Certificate) {
func TestDialTarget_HandshakeSucceeds(t *testing.T) {
pool, tlsCert := generateSelfSignedCert(t)
- // Start an in-process TLS server on a random port.
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
Certificates: []tls.Certificate{tlsCert},
})
@@ -67,7 +65,6 @@ func TestDialTarget_HandshakeSucceeds(t *testing.T) {
}
defer ln.Close()
- // Accept and drain one connection in the background.
go func() {
conn, err := ln.Accept()
if err != nil {
@@ -77,9 +74,9 @@ func TestDialTarget_HandshakeSucceeds(t *testing.T) {
io.Copy(io.Discard, conn)
}()
- fp, err := fingerprint.NewTLS("t13d1516h2_8daaf6152771_02713d6af862")
+ fp, err := fingerprint.ByName("chrome-120")
if err != nil {
- t.Fatalf("fingerprint.NewTLS: %v", err)
+ t.Fatalf("fingerprint.ByName: %v", err)
}
p := &Proxy{
@@ -94,7 +91,6 @@ func TestDialTarget_HandshakeSucceeds(t *testing.T) {
}
defer conn.Close()
- // Verify connection is usable for writes.
if _, err := conn.Write([]byte("hello\n")); err != nil {
t.Fatalf("writing to connection: %v", err)
}
@@ -120,7 +116,6 @@ func TestDialTarget_FallbackToRandomized(t *testing.T) {
io.Copy(io.Discard, conn)
}()
- // No fingerprint set — should fall back to HelloRandomized.
p := &Proxy{
ListenAddr: "127.0.0.1:0",
CAPool: pool,
@@ -128,8 +123,7 @@ func TestDialTarget_FallbackToRandomized(t *testing.T) {
conn, err := p.dialTarget(ln.Addr().String())
if err != nil {
- // HelloRandomized may select post-quantum curves not supported by stdlib TLS servers.
- // Skip rather than fail — the code path is exercised regardless.
+ // HelloRandomized may select post-quantum curves unsupported by stdlib TLS.
t.Skipf("dialTarget with randomized preset: %v", err)
}
conn.Close()
diff --git a/proxy/serverfront.go b/proxy/serverfront.go
@@ -30,7 +30,6 @@ func ServerFrontHandler(certFile, keyFile string) Handler {
}
defer tlsConn.Close()
- // Dial the backend with the configured uTLS fingerprint.
targetConn, err := p.dialTarget(p.BackendAddr)
if err != nil {
log.Printf("ServerFront: failed to connect to backend %s: %v", p.BackendAddr, err)
diff --git a/scripts/cleanup.sh b/scripts/cleanup.sh
@@ -1,45 +1,17 @@
#!/usr/bin/env bash
-# cleanup.sh — stop the proxy and remove build artifacts.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
BINARY="$ROOT_DIR/mic"
-PID_FILE="$ROOT_DIR/.mic.pid"
-# ── stop proxy via PID file ────────────────────────────────────────────────────
-if [ -f "$PID_FILE" ]; then
- PID="$(cat "$PID_FILE")"
- if kill -0 "$PID" 2>/dev/null; then
- echo "[+] Stopping mic (PID $PID)..."
- kill "$PID"
- # Wait briefly for clean shutdown.
- for i in $(seq 1 10); do
- kill -0 "$PID" 2>/dev/null || break
- sleep 0.2
- done
- if kill -0 "$PID" 2>/dev/null; then
- echo "[!] Process still alive after 2 s — sending SIGKILL."
- kill -9 "$PID" || true
- fi
- else
- echo "[~] PID $PID is no longer running."
- fi
- rm -f "$PID_FILE"
-else
- echo "[~] No PID file found."
-fi
-
-# ── catch any stray mic processes ─────────────────────────────────────────────
-if pgrep -x mic >/dev/null 2>&1; then
- echo "[+] Killing remaining mic processes..."
- pkill -x mic || true
-fi
-
-# ── remove binary ──────────────────────────────────────────────────────────────
if [ -f "$BINARY" ]; then
- echo "[+] Removing binary $BINARY..."
+ echo "[+] Removing binary..."
rm "$BINARY"
fi
-echo "[+] Cleanup complete."
+echo "[+] Done."
+echo ""
+echo "[i] CA files (ca.pem, ca-key.pem) are left in place."
+echo " Remove them manually if you no longer need them, and untrust the cert"
+echo " from your system store if you added it."
diff --git a/scripts/setup.sh b/scripts/setup.sh
@@ -1,51 +1,31 @@
#!/usr/bin/env bash
-# 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)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
BINARY="$ROOT_DIR/mic"
-CONFIG="$ROOT_DIR/mic.toml"
-EXAMPLE="$ROOT_DIR/mic.example.toml"
-PID_FILE="$ROOT_DIR/.mic.pid"
+CA_CERT="$ROOT_DIR/ca.pem"
+CA_KEY="$ROOT_DIR/ca-key.pem"
-# ── build ──────────────────────────────────────────────────────────────────────
echo "[+] Building mic..."
(cd "$ROOT_DIR" && go build -o mic .)
-echo " Binary: $BINARY"
-
-# ── config ─────────────────────────────────────────────────────────────────────
-if [ ! -f "$CONFIG" ]; then
- echo "[+] Creating mic.toml from mic.example.toml..."
- cp "$EXAMPLE" "$CONFIG"
- echo " Edit $CONFIG before starting (set mode, fingerprint.tls.ja4, etc.)"
-else
- echo "[~] mic.toml already exists — leaving it untouched."
-fi
-# ── start proxy ────────────────────────────────────────────────────────────────
-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" > "$ROOT_DIR/mic.log" 2>&1 &
- echo $! > "$PID_FILE"
- echo " PID $(cat "$PID_FILE") — log: $ROOT_DIR/mic.log"
- echo " Stop with: scripts/cleanup.sh"
-fi
+echo ""
+echo "[i] mic will run in client-front mode on :8080 with chrome-120 fingerprint."
+echo " MitM CA will be written to $CA_CERT on first run."
+echo " Trust it once, then use mic as a transparent HTTPS proxy:"
+echo ""
+echo " curl --cacert $CA_CERT -x http://localhost:8080 https://tlsinfo.me/json"
+echo ""
+echo " macOS system trust:"
+echo " sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain $CA_CERT"
+echo ""
+echo " Debian/Ubuntu/Kali system trust:"
+echo " sudo cp $CA_CERT /usr/local/share/ca-certificates/mic-ca.crt && sudo update-ca-certificates"
+echo ""
-# ── 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
+exec "$BINARY" client \
+ --listen :8080 \
+ --fingerprint chrome-120 \
+ --intercept-cert "$CA_CERT" \
+ --intercept-key "$CA_KEY"
diff --git a/test/client/main.go b/test/client/main.go
@@ -1,78 +0,0 @@
-package main
-
-import (
- "bufio"
- "log"
- "net"
- "net/http"
- "strings"
-
- utls "github.com/bogdanfinn/utls"
-)
-
-func main() {
- proxyAddr := "127.0.0.1:8080"
- targetAddr := "127.0.0.1:8443"
-
- // 1. Establish a TCP connection to the proxy.
- log.Printf("Connecting to proxy at %s", proxyAddr)
- proxyConn, err := net.Dial("tcp", proxyAddr)
- if err != nil {
- log.Fatalf("Failed to connect to proxy: %v", err)
- }
- defer proxyConn.Close()
-
- // 2. Manually send the HTTP CONNECT request to the proxy.
- connectReq, _ := http.NewRequest(http.MethodConnect, "https://"+targetAddr, nil)
- if err := connectReq.Write(proxyConn); err != nil {
- log.Fatalf("Failed to send CONNECT request to proxy: %v", err)
- }
-
- // 3. Read the proxy's response to the CONNECT request.
- reader := bufio.NewReader(proxyConn)
- resp, err := http.ReadResponse(reader, connectReq)
- if err != nil {
- log.Fatalf("Failed to read proxy response: %v", err)
- }
- if resp.StatusCode != http.StatusOK {
- log.Fatalf("Proxy returned non-200 status: %s", resp.Status)
- }
- log.Println("Proxy connection established.")
-
- // 4. Create a uTLS client from the proxied connection.
- // We'll use the Chrome 116 fingerprint for the client.
- utlsConfig := &utls.Config{ServerName: strings.Split(targetAddr, ":")[0]}
- uclient := utls.UClient(proxyConn, utlsConfig, utls.HelloRandomized, false, false, false)
-
- // 5. Perform the TLS handshake and get the handshake state.
- if err := uclient.Handshake(); err != nil {
- log.Fatalf("uTLS handshake failed: %v", err)
- }
-
- // 6. Extract and print key details from the server's certificate,
- // which are used to measure the server's JA4 fingerprint.
- log.Println("\n--- Server Fingerprint Measurement (JA4-like) ---")
- connState := uclient.ConnectionState()
- if len(connState.PeerCertificates) > 0 {
- cert := connState.PeerCertificates[0]
- log.Printf("Server Certificate Subject: %s", cert.Subject.CommonName)
- log.Printf("Server Certificate Issuer: %s", cert.Issuer.CommonName)
- log.Printf("Server Certificate Validity: %s to %s", cert.NotBefore, cert.NotAfter)
- }
-
- // 7. This section demonstrates how to use the tunneled connection to send a request.
- // The client will use the uTLS connection to send a simple HTTP GET request to the test server.
- log.Println("\n--- Sending Request to Test Server ---")
- req, _ := http.NewRequest(http.MethodGet, "https://"+targetAddr+"/", nil)
- if err := req.Write(uclient); err != nil {
- log.Fatalf("Failed to write request: %v", err)
- }
-
- // Read and print the response from the test server.
- resp, err = http.ReadResponse(bufio.NewReader(uclient), req)
- if err != nil {
- log.Fatalf("Failed to read response from server: %v", err)
- }
- defer resp.Body.Close()
- log.Printf("Server Response Status: %s", resp.Status)
-}
diff --git a/test/server/cert.pem b/test/server/cert.pem
@@ -1,19 +0,0 @@
------BEGIN CERTIFICATE-----
-MIIC/zCCAeegAwIBAgIQTs3QM4jgeBYOOKp+ZekqpjANBgkqhkiG9w0BAQsFADAS
-MRAwDgYDVQQKEwdBY21lIENvMB4XDTI1MDgyMDEyNTYwMloXDTI2MDgyMDEyNTYw
-MlowEjEQMA4GA1UEChMHQWNtZSBDbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC
-AQoCggEBAO2JDkMuo4bun8ZSXR3OsInpZpCXsJrcZ82SjItKyOdRcEDqNIsITmYO
-PmffVGHeTupCmONW0exZvh1BVS3HNHY80LG0usmYvR/vM0nIodUYByPiUO2xOh83
-8RpiqTZ7edp/+DiykK3pbceDzSfBs0FJi/03v/RTJxr+26xVNxnKMpTkigj8rDNA
-lKw8XMm0m7RK6CpPxP77D614hs/lDqKTo9qT9EL8tqeMfkA2+M5drsw9eAJs0ENw
-1UzS83G1EfCJE51KL93YYMFjhIh4PCf9B854gkSBSXZ0/8W51YmLLLRnCpQnxLN5
-Rb+lIbpYbnwe7LcN/N1mNkGvgvBYDKECAwEAAaNRME8wDgYDVR0PAQH/BAQDAgWg
-MBMGA1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwGgYDVR0RBBMwEYIJ
-bG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQCL8Ltm60bEKmNseEKF
-NLyotxv6AgF+63+WeQwEXrVFckfKb+DA8qcsFkv5/UigZPC+kQ8R90HgWtObeldK
-dJmob9g4IlPRcyY0ygURIsIdzYVeEluofn4mXjoNai4+ubaRvuVvq/IeSMV3QLxx
-8nbh5xuruxWBVuW78siwYcNwd4TTkXBd6BG1zziQMHQo24esLMJWZANte48dg2+E
-64Y0mA9pINVB4tTdgGuy6DlFt22019FGydeLIPqSvSJNAQtTXEhgphIKuusOhk/J
-C1tnexaN+NS3sYjxvbCeOZ41/UTzOzx1CZviaWWF1x5FRmwyYz1bdWejmBd9faaM
-MUvT
------END CERTIFICATE-----
diff --git a/test/server/key.pem b/test/server/key.pem
@@ -1,28 +0,0 @@
------BEGIN PRIVATE KEY-----
-MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDtiQ5DLqOG7p/G
-Ul0dzrCJ6WaQl7Ca3GfNkoyLSsjnUXBA6jSLCE5mDj5n31Rh3k7qQpjjVtHsWb4d
-QVUtxzR2PNCxtLrJmL0f7zNJyKHVGAcj4lDtsTofN/EaYqk2e3naf/g4spCt6W3H
-g80nwbNBSYv9N7/0Uyca/tusVTcZyjKU5IoI/KwzQJSsPFzJtJu0SugqT8T++w+t
-eIbP5Q6ik6Pak/RC/LanjH5ANvjOXa7MPXgCbNBDcNVM0vNxtRHwiROdSi/d2GDB
-Y4SIeDwn/QfOeIJEgUl2dP/FudWJiyy0ZwqUJ8SzeUW/pSG6WG58Huy3DfzdZjZB
-r4LwWAyhAgMBAAECggEAE5XGj6xOxP3ApFd++fz9+idwBeGb9bwEJKNxFyMPzl+d
-A9BlWKyGQLMeWu4p3o/rFDTXHX3qsE3BKr3ySf38ycSMTu3gRzL8YdLh+OY9m0mJ
-45MlEEdM/yBbrsza6tt8QC0A+5vHHHyWSQPWOVfAce4G4gmXHowLBpZkHAd14MuH
-qXuIrVZfXa59dHrqZdsyD1v08NEqEmicEqn33Tct2iLIbA2HOuPD3VzWfdQQcxk+
-2scIMlE50OnmaGBlDKnAkX7ddoYH+zAFlJVlqu8yp5MCp6tbug65fNxGc+vpFHZE
-rbTICT2Rp7OAE89CcXxVg189DWPyUC4TViG06LCdKQKBgQDtx/cEFwmYFlLA5X5C
-rP0q8pMkiCikHiaeGm1Ou6NdD33iUr3UqNwG3jzRWpwp7GrPr9sW5uKtqDv2JDfZ
-FEh6dVD9vWN7a0/iqBUgJhA0n4+Ao+iYmPIwnu5KO4qm9MPhtX6oeQKiaqU42QMr
-26fBLUxyrW78ZMR2avy7jndIuwKBgQD/vEVLKGakas2AQRF/qxc4Kefr9gDJprIj
-wscbApVYv7iIJbbGkpOCRWcRJoVrQzKCSoPHz7CZGJhpe6CMyzQPuAnghT9xvcjo
-AKS3yb1JmFfRqmteA9PBtYCtdSaznSDpcM5NA2ZpzLmHyeDmxOGAp+jzXZ5Va2ew
-hFj/7iboUwKBgDNcWhLcFwpu5/kamSc1zomUVAw0DPNGP/kupCW5ihPz4LEeRjrF
-LqGdJ23hMJItqvSpe516ycPQ7wK/2ABfzTgGVzQ30GE8UMkYnVeQYTe8r3aN8RPl
-FYtDCR2nts9RV25Nkco6OUNz3bRly7WRgK7NZhHvPGXwLi+V9Hle0I05AoGBALFh
-VCriVfd7ARkRiOKluFBDeNMI6dj0n6EAS/wnGZHaQVNl3VX0ew4jt26E6ovQqysG
-Jkukehhmn+iiUGT32R+CBo/NvIjXG5AYSigUbQ+r9FXAQAKQ4wL8MsmddX+l4Bsr
-HFz8m7+Mua8+zpkiPEtxq7mJ2PXkrqIX2meCN0hzAoGBAIcufuifo7M2CZWvNNur
-Hm4aNxu/ejERvFjXMBwDMU9VnzY1vV8WqpQZyiZhILM5fUf6kHLLI9IorsZMe2On
-BenT06uNM6FONqO7/Tb3gOsu1c84OdhqBNSnlkGJhh9FhVekiLxDZ7tcEsXMz60J
-+3yMTr67bSSBK9nxU7C8NQ1R
------END PRIVATE KEY-----
diff --git a/test/server/main.go b/test/server/main.go
@@ -1,26 +0,0 @@
-package main
-
-import (
- "fmt"
- "log"
- "net/http"
-)
-
-func main() {
- // 1. Define the handler function for all requests.
- http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
- log.Printf("Received request for: %s %s", r.Method, r.URL.Path)
- w.WriteHeader(http.StatusOK)
- fmt.Fprintf(w, "Hello! You have reached the test server. All is well.")
- })
-
- // 2. Set the address for the server to listen on.
- addr := "127.0.0.1:8443"
-
- // 3. Start the HTTPS server.
- log.Printf("Starting HTTPS test server on %s", addr)
- err := http.ListenAndServeTLS(addr, "cert.pem", "key.pem", nil)
- if err != nil {
- log.Fatalf("Test server failed: %v", err)
- }
-}
| | | | | | | | | | | | | | | | | | | | | | | | | | | | |