mic.git


mic

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

commit 78cbd270c5ac1cabb896411d8393345928090cb9
parent 28f1125de79d403e838aabb4d8cf0499a4661df8
Author: djnn <[email protected]>
Date: Wed, 26 Aug 2026 14:58:04 +0900

fix(ca): write CA private key 0600

Diffstat:
Mproxy/ca.go | 46++++++++++++++++++++++++++++++++++------------
Mproxy/ca_test.go | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

2 files changed, 235 insertions(+), 12 deletions(-)

diff --git a/proxy/ca.go b/proxy/ca.go
@@ -16,6 +16,17 @@ import (
 	"time"
 )
 
+const (
+	leafLifetime = 24 * time.Hour
+	// Re-issue this long before expiry so a leaf cannot expire mid-handshake.
+	leafRenewMargin = time.Minute
+)
+
+type cachedLeaf struct {
+	cert     tls.Certificate
+	notAfter time.Time
+}
+
 // LocalCA is a self-signed CA used to issue per-host certificates for
 // MitM TLS interception in client-front mode.
 type LocalCA struct {
@@ -24,7 +35,7 @@ type LocalCA struct {
 	derCert []byte // raw DER, appended as chain in issued certs
 
 	mu    sync.Mutex
-	cache map[string]tls.Certificate
+	cache map[string]cachedLeaf
 }
 
 func GenerateCA() (*LocalCA, error) {
@@ -37,11 +48,16 @@ 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
-// for an ephemeral in-memory CA.
+// for both to get an ephemeral in-memory CA.
 func LoadOrGenerateCA(certPath, keyPath string) (*LocalCA, error) {
-	if certPath == "" || keyPath == "" {
+	if certPath == "" && keyPath == "" {
 		return GenerateCA()
 	}
+	// A half-specified pair used to silently return an ephemeral CA that was
+	// never written, so callers told users to import a file that never existed.
+	if certPath == "" || keyPath == "" {
+		return nil, fmt.Errorf("CA cert path and key path must both be set or both be empty")
+	}
 
 	ca, err := loadCA(certPath, keyPath)
 	if err == nil {
@@ -78,7 +94,7 @@ func loadCA(certPath, keyPath string) (*LocalCA, error) {
 		cert:    cert,
 		key:     key,
 		derCert: pair.Certificate[0],
-		cache:   make(map[string]tls.Certificate),
+		cache:   make(map[string]cachedLeaf),
 	}, nil
 }
 
@@ -108,7 +124,7 @@ func buildCA(key *ecdsa.PrivateKey) (*LocalCA, error) {
 		cert:    cert,
 		key:     key,
 		derCert: der,
-		cache:   make(map[string]tls.Certificate),
+		cache:   make(map[string]cachedLeaf),
 	}, nil
 }
 
@@ -126,11 +142,16 @@ func (ca *LocalCA) Save(certPath, keyPath string) error {
 	if err != nil {
 		return err
 	}
-	kf, err := os.Create(keyPath)
+	kf, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
 	if err != nil {
 		return err
 	}
 	defer kf.Close()
+	// O_CREATE's mode is ignored when the file already exists, and the mode of a
+	// new file is masked by umask, so set it explicitly either way.
+	if err := kf.Chmod(0o600); err != nil {
+		return err
+	}
 	return pem.Encode(kf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
 }
 
@@ -140,14 +161,14 @@ func (ca *LocalCA) CertPEM() []byte {
 	return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.derCert})
 }
 
-// issueCert returns a leaf certificate for host signed by the CA, issuing and
-// caching a new one on first call.
+// issueCert returns a leaf certificate for host signed by the CA, issuing a new
+// one when the host is unseen or its cached leaf is at or near expiry.
 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
+	if c, ok := ca.cache[host]; ok && time.Now().Before(c.notAfter.Add(-leafRenewMargin)) {
+		return c.cert, nil
 	}
 
 	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
@@ -158,11 +179,12 @@ func (ca *LocalCA) issueCert(host string) (tls.Certificate, error) {
 	if err != nil {
 		return tls.Certificate{}, err
 	}
+	notAfter := time.Now().Add(leafLifetime)
 	tmpl := &x509.Certificate{
 		SerialNumber: serial,
 		Subject:      pkix.Name{CommonName: host},
 		NotBefore:    time.Now().Add(-time.Hour),
-		NotAfter:     time.Now().Add(24 * time.Hour),
+		NotAfter:     notAfter,
 		KeyUsage:     x509.KeyUsageDigitalSignature,
 		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
 	}
@@ -180,6 +202,6 @@ func (ca *LocalCA) issueCert(host string) (tls.Certificate, error) {
 		Certificate: [][]byte{der, ca.derCert}, // CA appended so clients can verify the chain
 		PrivateKey:  key,
 	}
-	ca.cache[host] = cert
+	ca.cache[host] = cachedLeaf{cert: cert, notAfter: notAfter}
 	return cert, nil
 }
diff --git a/proxy/ca_test.go b/proxy/ca_test.go
@@ -4,7 +4,10 @@ import (
 	"bytes"
 	"crypto/x509"
 	"net"
+	"os"
+	"path/filepath"
 	"testing"
+	"time"
 )
 
 func TestIssueCert_SignedChain_DNS(t *testing.T) {
@@ -103,3 +106,201 @@ func TestIssueCert_Caches(t *testing.T) {
 		t.Errorf("cache size = %d; want 2", len(ca.cache))
 	}
 }
+
+// A MitM leaf must never be usable as a CA. This is the property that lets an
+// attacker with the leaf key mint certs for any host, so assert it directly.
+func TestIssueCert_LeafIsNotACA(t *testing.T) {
+	ca, err := GenerateCA()
+	if err != nil {
+		t.Fatalf("GenerateCA: %v", err)
+	}
+	cert, err := ca.issueCert("example.com")
+	if err != nil {
+		t.Fatalf("issueCert: %v", err)
+	}
+	leaf, err := x509.ParseCertificate(cert.Certificate[0])
+	if err != nil {
+		t.Fatalf("parse leaf: %v", err)
+	}
+
+	if leaf.IsCA {
+		t.Error("leaf has IsCA = true; a MitM leaf must not be a CA")
+	}
+	if leaf.KeyUsage&x509.KeyUsageCertSign != 0 {
+		t.Error("leaf KeyUsage includes CertSign; a MitM leaf must not sign certs")
+	}
+	if leaf.KeyUsage&x509.KeyUsageCRLSign != 0 {
+		t.Error("leaf KeyUsage includes CRLSign")
+	}
+	if leaf.KeyUsage != x509.KeyUsageDigitalSignature {
+		t.Errorf("leaf KeyUsage = %v; want DigitalSignature only", leaf.KeyUsage)
+	}
+	if len(leaf.ExtKeyUsage) != 1 || leaf.ExtKeyUsage[0] != x509.ExtKeyUsageServerAuth {
+		t.Errorf("leaf ExtKeyUsage = %v; want [ServerAuth]", leaf.ExtKeyUsage)
+	}
+}
+
+func TestIssueCert_ReissuesExpiredLeaf(t *testing.T) {
+	ca, err := GenerateCA()
+	if err != nil {
+		t.Fatalf("GenerateCA: %v", err)
+	}
+	first, err := ca.issueCert("example.com")
+	if err != nil {
+		t.Fatalf("issueCert: %v", err)
+	}
+
+	ca.mu.Lock()
+	entry := ca.cache["example.com"]
+	entry.notAfter = time.Now().Add(-time.Second)
+	ca.cache["example.com"] = entry
+	ca.mu.Unlock()
+
+	second, err := ca.issueCert("example.com")
+	if err != nil {
+		t.Fatalf("issueCert after expiry: %v", err)
+	}
+	if bytes.Equal(first.Certificate[0], second.Certificate[0]) {
+		t.Fatal("expired cached leaf was served again instead of being re-issued")
+	}
+	leaf, err := x509.ParseCertificate(second.Certificate[0])
+	if err != nil {
+		t.Fatalf("parse leaf: %v", err)
+	}
+	if !time.Now().Before(leaf.NotAfter) {
+		t.Errorf("re-issued leaf NotAfter = %v; already expired", leaf.NotAfter)
+	}
+}
+
+func TestIssueCert_ReissuesLeafInsideRenewMargin(t *testing.T) {
+	ca, err := GenerateCA()
+	if err != nil {
+		t.Fatalf("GenerateCA: %v", err)
+	}
+	first, err := ca.issueCert("example.com")
+	if err != nil {
+		t.Fatalf("issueCert: %v", err)
+	}
+
+	ca.mu.Lock()
+	entry := ca.cache["example.com"]
+	entry.notAfter = time.Now().Add(leafRenewMargin / 2)
+	ca.cache["example.com"] = entry
+	ca.mu.Unlock()
+
+	second, err := ca.issueCert("example.com")
+	if err != nil {
+		t.Fatalf("issueCert inside margin: %v", err)
+	}
+	if bytes.Equal(first.Certificate[0], second.Certificate[0]) {
+		t.Error("leaf expiring inside the renew margin was served again")
+	}
+}
+
+func TestSave_KeyIsNotWorldReadable(t *testing.T) {
+	ca, err := GenerateCA()
+	if err != nil {
+		t.Fatalf("GenerateCA: %v", err)
+	}
+
+	t.Run("fresh file", func(t *testing.T) {
+		dir := t.TempDir()
+		keyPath := filepath.Join(dir, "ca.key")
+		if err := ca.Save(filepath.Join(dir, "ca.pem"), keyPath); err != nil {
+			t.Fatalf("Save: %v", err)
+		}
+		assertKeyPerm(t, keyPath)
+	})
+
+	t.Run("overwrites pre-existing world readable file", func(t *testing.T) {
+		dir := t.TempDir()
+		keyPath := filepath.Join(dir, "ca.key")
+		f, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE, 0o666)
+		if err != nil {
+			t.Fatalf("pre-create key: %v", err)
+		}
+		f.Close()
+		if err := os.Chmod(keyPath, 0o666); err != nil {
+			t.Fatalf("chmod: %v", err)
+		}
+		if err := ca.Save(filepath.Join(dir, "ca.pem"), keyPath); err != nil {
+			t.Fatalf("Save: %v", err)
+		}
+		assertKeyPerm(t, keyPath)
+	})
+}
+
+func assertKeyPerm(t *testing.T, keyPath string) {
+	t.Helper()
+	fi, err := os.Stat(keyPath)
+	if err != nil {
+		t.Fatalf("stat key: %v", err)
+	}
+	if perm := fi.Mode().Perm(); perm&0o077 != 0 {
+		t.Errorf("CA key mode on disk = %#o; group/other must have no access", perm)
+	}
+	if _, err := loadCA(filepath.Join(filepath.Dir(keyPath), "ca.pem"), keyPath); err != nil {
+		t.Errorf("saved CA does not load back: %v", err)
+	}
+}
+
+func TestLoadOrGenerateCA_RoundTrip(t *testing.T) {
+	dir := t.TempDir()
+	certPath := filepath.Join(dir, "ca.pem")
+	keyPath := filepath.Join(dir, "ca.key")
+
+	first, err := LoadOrGenerateCA(certPath, keyPath)
+	if err != nil {
+		t.Fatalf("LoadOrGenerateCA (generate): %v", err)
+	}
+	if _, err := os.Stat(certPath); err != nil {
+		t.Fatalf("cert was not written: %v", err)
+	}
+
+	second, err := LoadOrGenerateCA(certPath, keyPath)
+	if err != nil {
+		t.Fatalf("LoadOrGenerateCA (load): %v", err)
+	}
+	if !bytes.Equal(first.derCert, second.derCert) {
+		t.Error("second call did not load the CA written by the first")
+	}
+}
+
+func TestLoadOrGenerateCA_CorruptCert(t *testing.T) {
+	dir := t.TempDir()
+	certPath := filepath.Join(dir, "ca.pem")
+	keyPath := filepath.Join(dir, "ca.key")
+
+	ca, err := GenerateCA()
+	if err != nil {
+		t.Fatalf("GenerateCA: %v", err)
+	}
+	if err := ca.Save(certPath, keyPath); err != nil {
+		t.Fatalf("Save: %v", err)
+	}
+	if err := os.WriteFile(certPath, []byte("not a certificate\n"), 0o644); err != nil {
+		t.Fatalf("corrupt cert: %v", err)
+	}
+
+	if _, err := LoadOrGenerateCA(certPath, keyPath); err == nil {
+		t.Fatal("LoadOrGenerateCA accepted a corrupt cert file")
+	}
+}
+
+func TestLoadOrGenerateCA_HalfSpecifiedPairRefused(t *testing.T) {
+	dir := t.TempDir()
+
+	if _, err := LoadOrGenerateCA(filepath.Join(dir, "ca.pem"), ""); err == nil {
+		t.Error("cert path without key path was accepted; want an error")
+	}
+	if _, err := LoadOrGenerateCA("", filepath.Join(dir, "ca.key")); err == nil {
+		t.Error("key path without cert path was accepted; want an error")
+	}
+	if _, err := os.Stat(filepath.Join(dir, "ca.pem")); !os.IsNotExist(err) {
+		t.Error("a refused half-specified pair still wrote a cert file")
+	}
+
+	if _, err := LoadOrGenerateCA("", ""); err != nil {
+		t.Errorf("both paths empty must stay valid (ephemeral CA): %v", err)
+	}
+}