Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions bind/clash/mitm_ca.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package clash

import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/ProjectClash/Clash/component/mitm"
)

// MITMCaInfo describes the active MITM root certificate so the App can show
// what it is asking the user to install and trust.
type MITMCaInfo struct {
Enabled bool `json:"enabled"`
Fingerprint string `json:"fingerprint,omitempty"`
NotAfter string `json:"notAfter,omitempty"`
StoreDir string `json:"storeDir,omitempty"`
}

// MITMState reports whether MITM is active and describes its CA. When MITM
// is off the answer carries Enabled=false and nothing else; the App treats
// that as "no profile to install".
func MITMState() (*MITMCaInfo, error) {
info, err := recoverAppCall("MITMState", func() (*MITMCaInfo, error) {
return mitmStateUnguarded()
})
if err != nil {
return nil, bridgeSafeError(err)
}
return info, nil
}

func mitmStateUnguarded() (*MITMCaInfo, error) {
dispatcher := mitm.ActiveDispatcher()
if dispatcher == nil || !dispatcher.Enable() {
return &MITMCaInfo{Enabled: false}, nil
}
engine := dispatcher.Engine()
if engine == nil {
return &MITMCaInfo{Enabled: false}, nil
}
fingerprint, err := engine.CaFingerprintHex()
if err != nil {
return nil, err
}
info := &MITMCaInfo{Enabled: true, Fingerprint: fingerprint}
if notAfter, err := engine.CaNotAfter(); err == nil {
info.NotAfter = notAfter.UTC().Format("2006-01-02T15:04:05Z")
}
return info, nil
}

// ExportMITMCa writes the active MITM root certificate to caPath in PEM form
// and returns the SHA-256 fingerprint hex of the DER. The App passes a path
// inside its own container; the core only writes that one file.
func ExportMITMCa(caPath string) (string, error) {
fingerprint, err := recoverAppCall("ExportMITMCa", func() (string, error) {
return exportMITMCaUnguarded(caPath)
})
if err != nil {
return "", bridgeSafeError(err)
}
return bridgeSafeString(fingerprint), nil
}

func exportMITMCaUnguarded(caPath string) (string, error) {
if strings.TrimSpace(caPath) == "" {
return "", fmt.Errorf("clash: mitm: empty CA export path")
}
dispatcher := mitm.ActiveDispatcher()
if dispatcher == nil || !dispatcher.Enable() {
return "", fmt.Errorf("clash: mitm is not enabled")
}
engine := dispatcher.Engine()
if engine == nil {
return "", fmt.Errorf("clash: mitm is not enabled")
}
pemBytes, err := dispatcher.CaCertificatePEM()
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(caPath), 0o700); err != nil {
return "", fmt.Errorf("clash: mitm: create CA directory: %w", err)
}
if err := os.WriteFile(caPath, pemBytes, 0o644); err != nil {
return "", fmt.Errorf("clash: mitm: write CA file: %w", err)
}
// Fingerprint the DER, not the PEM bytes: MITMState reports the DER
// hash, and the two surfaces must agree or the App cannot verify that
// the installed profile matches the displayed fingerprint.
fingerprint, err := engine.CaFingerprintHex()
if err != nil {
return "", err
}
return bridgeSafeString(fingerprint), nil
}

// ResetMITMCa regenerates the root certificate. Installed trust profiles
// stop working until the user reinstalls, so only the App's explicit reset
// action calls this.
func ResetMITMCa() error {
_, err := recoverAppCall("ResetMITMCa", func() (struct{}, error) {
dispatcher := mitm.ActiveDispatcher()
if dispatcher == nil || !dispatcher.Enable() {
return struct{}{}, fmt.Errorf("clash: mitm is not enabled")
}
engine := dispatcher.Engine()
if engine == nil {
return struct{}{}, fmt.Errorf("clash: mitm is not enabled")
}
if err := engine.Regenerate(); err != nil {
return struct{}{}, err
}
if dir := dispatcher.StoreDir(); dir != "" {
if err := mitm.SaveState(engine, dir); err != nil {
return struct{}{}, err
}
}
return struct{}{}, nil
})
if err != nil {
return bridgeSafeError(err)
}
return nil
}
91 changes: 91 additions & 0 deletions bind/clash/mitm_ca_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package clash

import (
"crypto/sha256"
"encoding/hex"
"encoding/pem"
"os"
"path/filepath"
"testing"

"github.com/ProjectClash/Clash/component/mitm"
)

// startMITM installs a dispatcher with a live engine for the duration of the
// test and restores the disabled state afterwards.
func startMITM(t *testing.T) {
t.Helper()
cfg := &mitm.Config{Enable: true, HostGates: []string{"example.com"}}
if err := cfg.Normalize(); err != nil {
t.Fatalf("Normalize: %v", err)
}
dispatcher, err := mitm.NewDispatcher(cfg)
if err != nil {
t.Fatalf("NewDispatcher: %v", err)
}
mitm.UpdateMITM(dispatcher)
t.Cleanup(func() { mitm.UpdateMITM(nil) })
}

// TestExportMITMCaFingerprintMatchesMITMState pins the contract that both
// surfaces report the SAME fingerprint — SHA-256 of the certificate DER.
// ExportMITMCa used to hash the PEM bytes, which can never equal the
// DER-based fingerprint MITMState shows the user.
func TestExportMITMCaFingerprintMatchesMITMState(t *testing.T) {
startMITM(t)
state, err := MITMState()
if err != nil {
t.Fatalf("MITMState: %v", err)
}
if !state.Enabled || state.Fingerprint == "" {
t.Fatalf("MITMState = %+v, want enabled with a fingerprint", state)
}

path := filepath.Join(t.TempDir(), "export", "ca.pem")
exportFp, err := ExportMITMCa(path)
if err != nil {
t.Fatalf("ExportMITMCa: %v", err)
}
if exportFp != state.Fingerprint {
t.Fatalf("fingerprint mismatch: ExportMITMCa=%s MITMState=%s", exportFp, state.Fingerprint)
}

// Independent check: the fingerprint is SHA-256 of the DER in the file.
pemBytes, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode(pemBytes)
if block == nil {
t.Fatal("exported file is not PEM")
}
sum := sha256.Sum256(block.Bytes)
if hex.EncodeToString(sum[:]) != exportFp {
t.Fatalf("ExportMITMCa fingerprint is not SHA-256 of the DER: %s vs %s", exportFp, hex.EncodeToString(sum[:]))
}

// The export parent dir must not be world-readable (0700, consistent
// with the mitm state dir).
info, err := os.Stat(filepath.Dir(path))
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o700 {
t.Fatalf("export dir perms = %v, want 0700", got)
}
}

func TestMITMStateDisabledWhenNoDispatcher(t *testing.T) {
mitm.UpdateMITM(nil)
t.Cleanup(func() { mitm.UpdateMITM(nil) })
state, err := MITMState()
if err != nil {
t.Fatalf("MITMState: %v", err)
}
if state.Enabled {
t.Fatalf("MITMState = %+v with no dispatcher, want enabled=false", state)
}
if _, err := ExportMITMCa(filepath.Join(t.TempDir(), "ca.pem")); err == nil {
t.Fatal("ExportMITMCa must fail when MITM is off")
}
}
Loading