From 2b8d1c9e3d5bed6fc964e5b8f7035f155089e2a9 Mon Sep 17 00:00:00 2001 From: leokun Date: Wed, 12 Aug 2026 20:04:29 +0800 Subject: [PATCH] fix: generate a per-installation root CA --- cursor-proxy-debugger/proxy.go | 4 +- cursor-proxy-debugger/web.go | 4 +- internal/app/runner.go | 27 +-- internal/appdata/paths.go | 5 + internal/backend/README.md | 4 +- internal/certs/ca.go | 86 ++++---- internal/certs/ca.key | 27 --- internal/certs/local_ca.go | 191 ++++++++++++++++++ internal/certs/local_ca_test.go | 125 ++++++++++++ .../{ca.crt => testdata/legacy_shared_ca.crt} | 0 internal/client/cursor.go | 4 + internal/cursor/darwin_cert.go | 34 ++++ internal/cursor/windows_cert.go | 27 +++ internal/cursor/windows_cert_stub.go | 5 + 14 files changed, 454 insertions(+), 89 deletions(-) delete mode 100644 internal/certs/ca.key create mode 100644 internal/certs/local_ca.go create mode 100644 internal/certs/local_ca_test.go rename internal/certs/{ca.crt => testdata/legacy_shared_ca.crt} (100%) diff --git a/cursor-proxy-debugger/proxy.go b/cursor-proxy-debugger/proxy.go index 21a53c3..df0b011 100644 --- a/cursor-proxy-debugger/proxy.go +++ b/cursor-proxy-debugger/proxy.go @@ -28,6 +28,7 @@ type exchangeContext struct { type Server struct { config Config certManager *certs.Manager + caCertPEM []byte store *exchangeStore counter atomic.Uint64 proxyServer *http.Server @@ -43,13 +44,14 @@ func New(config Config) (*Server, error) { if err := validateLoopbackAddress(config.UIAddr); err != nil { return nil, err } - manager, err := certs.NewEmbeddedManager() + manager, caCertPEM, err := certs.NewGeneratedManager() if err != nil { return nil, fmt.Errorf("加载 MITM CA 失败:%w", err) } server := &Server{ config: config, certManager: manager, + caCertPEM: caCertPEM, store: newExchangeStore(config.MaxExchanges), } proxyHandler, err := server.newProxyHandler() diff --git a/cursor-proxy-debugger/web.go b/cursor-proxy-debugger/web.go index 255f6f6..6e8049d 100644 --- a/cursor-proxy-debugger/web.go +++ b/cursor-proxy-debugger/web.go @@ -8,8 +8,6 @@ import ( "net/http" "strings" "time" - - "cursor/internal/certs" ) //go:embed web/* @@ -93,7 +91,7 @@ func (server *Server) handleEvents(writer http.ResponseWriter, request *http.Req func (server *Server) handleCACertificate(writer http.ResponseWriter, _ *http.Request) { writer.Header().Set("Content-Type", "application/x-x509-ca-cert") writer.Header().Set("Content-Disposition", `attachment; filename="cursor-local-proxy-ca.crt"`) - _, _ = writer.Write(certs.EmbeddedCACertPEM()) + _, _ = writer.Write(server.caCertPEM) } func writeJSON(writer http.ResponseWriter, status int, payload any) { diff --git a/internal/app/runner.go b/internal/app/runner.go index 159790f..ab69850 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -70,20 +70,21 @@ func Run(resources EmbeddedResources) error { logger.Init() netproxy.InstallDefaultTransport() - embeddedCACertPEM := certs.EmbeddedCACertPEM() - logEmbeddedCAInfo(embeddedCACertPEM) - - certManager, err := certs.NewEmbeddedManager() + if err := appdata.EnsureAssistantHome(); err != nil { + return err + } + certManager, caCertPEM, err := certs.LoadOrCreateManager(appdata.CACertFilePath(), appdata.CAKeyFilePath()) if err != nil { return err } + logCAInfo(caCertPEM) defaultBackendBaseURL := "http://" + serverconfig.DefaultBackendListenAddr proxyServer, err := mitm.NewProxyServer(serverconfig.DefaultProxyListenAddr, defaultBackendBaseURL, "", "", certManager) if err != nil { return err } - proxyService := bridge.NewProxyService(proxyServer, certManager, embeddedCACertPEM) + proxyService := bridge.NewProxyService(proxyServer, certManager, caCertPEM) adAssetBaseURL := defaultBackendBaseURL if cfg, err := proxyService.LoadUserConfig(); err == nil { adAssetBaseURL = browserReachableLoopbackBaseURL(cfg.BackendListenAddr) @@ -442,20 +443,20 @@ func browserReachableLoopbackBaseURL(listenAddr string) string { return "http://" + net.JoinHostPort(host, port) } -// logEmbeddedCAInfo 用于处理与 logEmbeddedCAInfo 相关的逻辑。 -func logEmbeddedCAInfo(certPEM []byte) { +// logCAInfo 记录当前安装专属 CA 的公开信息。 +func logCAInfo(certPEM []byte) { if len(certPEM) == 0 { - logger.Errorf("embedded CA is empty") + logger.Errorf("installation CA is empty") return } - cert, err := parseEmbeddedCert(certPEM) + cert, err := parseCert(certPEM) if err != nil { - logger.Errorf("parse embedded CA failed: %v", err) + logger.Errorf("parse installation CA failed: %v", err) return } sum := sha256.Sum256(cert.Raw) logger.Infof( - "embedded CA loaded: sha256=%s subject=%s valid=%s~%s", + "installation CA loaded: sha256=%s subject=%s valid=%s~%s", strings.ToUpper(hex.EncodeToString(sum[:])), cert.Subject.String(), cert.NotBefore.Format(time.RFC3339), @@ -463,8 +464,8 @@ func logEmbeddedCAInfo(certPEM []byte) { ) } -// parseEmbeddedCert 用于处理与 parseEmbeddedCert 相关的逻辑。 -func parseEmbeddedCert(data []byte) (*x509.Certificate, error) { +// parseCert 解析 DER 或 PEM 编码的证书。 +func parseCert(data []byte) (*x509.Certificate, error) { if block, _ := pem.Decode(data); block != nil { return x509.ParseCertificate(block.Bytes) } diff --git a/internal/appdata/paths.go b/internal/appdata/paths.go index c016f4f..956c97e 100644 --- a/internal/appdata/paths.go +++ b/internal/appdata/paths.go @@ -70,3 +70,8 @@ func LogsRootPath() string { func CACertFilePath() string { return filepath.Join(DataRootPath(), "ca.crt") } + +// CAKeyFilePath 返回仅供本安装使用的 CA 私钥路径。 +func CAKeyFilePath() string { + return filepath.Join(DataRootPath(), "ca.key") +} diff --git a/internal/backend/README.md b/internal/backend/README.md index 3d52a36..371475f 100644 --- a/internal/backend/README.md +++ b/internal/backend/README.md @@ -97,6 +97,7 @@ internal/backend/ - `~/.cursor-local-assistant-v2/config.yaml` - `~/.cursor-local-assistant-v2/data/ca.crt` +- `~/.cursor-local-assistant-v2/data/ca.key` - `~/.cursor-local-assistant-v2/data/ads/` - `~/.cursor-local-assistant-v2/history/` - `~/.cursor-local-assistant-v2/logs/` @@ -104,7 +105,8 @@ internal/backend/ 约定: - `config.yaml` 是用户配置 -- `data/ca.crt` 是注入给宿主的 CA 证书 +- `data/ca.crt` 是首次运行时为当前用户生成、注入给宿主的 CA 证书 +- `data/ca.key` 是与该证书配套的本地私钥,权限固定为 `0600`,不得打包或提交到仓库 - `data/ads/` 是广告包与资源缓存目录 - `history/` 是会话事实与全局 usage JSON 目录,不属于日志 - `logs/` 只保留必要文本运行日志 diff --git a/internal/certs/ca.go b/internal/certs/ca.go index 75ae069..a23e546 100644 --- a/internal/certs/ca.go +++ b/internal/certs/ca.go @@ -1,6 +1,7 @@ package certs import ( + "bytes" "crypto" "crypto/ecdsa" "crypto/ed25519" @@ -9,33 +10,24 @@ import ( "crypto/tls" "crypto/x509" "crypto/x509/pkix" - _ "embed" "encoding/pem" "errors" + "fmt" "math/big" "net" - "os" "strings" "sync" "time" ) -// embeddedCACertPEM 表示当前模块中的 embeddedCACertPEM 状态值。 -// -//go:embed ca.crt -var embeddedCACertPEM []byte - -// embeddedCAKeyPEM 表示当前模块中的 embeddedCAKeyPEM 状态值。 -// -//go:embed ca.key -var embeddedCAKeyPEM []byte - // Manager 定义了当前模块中的 Manager 类型。 type Manager struct { // caCert 表示当前声明中的 caCert。 caCert *x509.Certificate // caKey 表示当前声明中的 caKey。 caKey crypto.PrivateKey + // caCertPEM 保存可注入宿主信任存储的 CA 证书,不包含私钥。 + caCertPEM []byte // mu 表示当前声明中的 mu。 mu sync.Mutex @@ -52,28 +44,26 @@ func NewManager(caCertPath, caKeyPath string) (*Manager, error) { return NewManagerFromPEM(certPEM, keyPEM) } -// NewEmbeddedManager 用于处理与 NewEmbeddedManager 相关的逻辑。 -func NewEmbeddedManager() (*Manager, error) { - return NewManagerFromPEM(embeddedCACertPEM, embeddedCAKeyPEM) -} - -// EmbeddedCACertPEM 用于处理与 EmbeddedCACertPEM 相关的逻辑。 -func EmbeddedCACertPEM() []byte { - return cloneBytes(embeddedCACertPEM) -} - -// EmbeddedCAKeyPEM 用于处理与 EmbeddedCAKeyPEM 相关的逻辑。 -func EmbeddedCAKeyPEM() []byte { - return cloneBytes(embeddedCAKeyPEM) -} - // NewManagerFromPEM 用于处理与 NewManagerFromPEM 相关的逻辑。 func NewManagerFromPEM(caCertPEM, caKeyPEM []byte) (*Manager, error) { caCert, caKey, err := loadCAFromPEM(caCertPEM, caKeyPEM) if err != nil { return nil, err } - return &Manager{caCert: caCert, caKey: caKey, cache: make(map[string]*tls.Certificate)}, nil + return &Manager{ + caCert: caCert, + caKey: caKey, + caCertPEM: cloneBytes(caCertPEM), + cache: make(map[string]*tls.Certificate), + }, nil +} + +// CACertPEM 返回当前 Manager 使用的 CA 证书。返回值不包含私钥。 +func (m *Manager) CACertPEM() []byte { + if m == nil { + return nil + } + return cloneBytes(m.caCertPEM) } // CATLSCertificate 用于处理与 CATLSCertificate 相关的逻辑。 @@ -185,19 +175,6 @@ func marshalPrivateKeyPEM(key any) ([]byte, error) { } } -// loadCAPEMFromFiles 用于处理与 loadCAPEMFromFiles 相关的逻辑。 -func loadCAPEMFromFiles(certPath, keyPath string) ([]byte, []byte, error) { - certPEM, err := os.ReadFile(certPath) - if err != nil { - return nil, nil, err - } - keyPEM, err := os.ReadFile(keyPath) - if err != nil { - return nil, nil, err - } - return certPEM, keyPEM, nil -} - // loadCAFromPEM 用于处理与 loadCAFromPEM 相关的逻辑。 func loadCAFromPEM(certPEM, keyPEM []byte) (*x509.Certificate, crypto.PrivateKey, error) { certBlock, _ := pem.Decode(certPEM) @@ -214,28 +191,49 @@ func loadCAFromPEM(certPEM, keyPEM []byte) (*x509.Certificate, crypto.PrivateKey return nil, nil, errors.New("invalid CA key PEM") } + var caKey crypto.PrivateKey switch keyBlock.Type { case "RSA PRIVATE KEY": key, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes) if err != nil { return nil, nil, err } - return caCert, key, nil + caKey = key case "EC PRIVATE KEY": key, err := x509.ParseECPrivateKey(keyBlock.Bytes) if err != nil { return nil, nil, err } - return caCert, key, nil + caKey = key case "PRIVATE KEY": key, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes) if err != nil { return nil, nil, err } - return caCert, key, nil + caKey = key default: return nil, nil, errors.New("unsupported CA key format") } + + if !caCert.IsCA || !caCert.BasicConstraintsValid || caCert.KeyUsage&x509.KeyUsageCertSign == 0 { + return nil, nil, errors.New("certificate is not a valid signing CA") + } + signer, ok := caKey.(crypto.Signer) + if !ok { + return nil, nil, errors.New("CA private key cannot sign certificates") + } + certPublicKey, err := x509.MarshalPKIXPublicKey(caCert.PublicKey) + if err != nil { + return nil, nil, fmt.Errorf("marshal CA certificate public key: %w", err) + } + privatePublicKey, err := x509.MarshalPKIXPublicKey(signer.Public()) + if err != nil { + return nil, nil, fmt.Errorf("marshal CA private key public key: %w", err) + } + if !bytes.Equal(certPublicKey, privatePublicKey) { + return nil, nil, errors.New("CA certificate and private key do not match") + } + return caCert, caKey, nil } // normalizeHost 用于处理与 normalizeHost 相关的逻辑。 diff --git a/internal/certs/ca.key b/internal/certs/ca.key deleted file mode 100644 index c0a78a3..0000000 --- a/internal/certs/ca.key +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEAyh3jND/aFusuRjGTmhQtX2hkF1qroNjEEKCxWPlfprvdl8Tx -upxNv1TQcm+K9KsS7OxnKYP4Qtv068XLbaCCGuoA/xpor6enrT85KulBq0j8z/g1 -y0VWxjz3xN9F9ND13h9yxDZCn76egbRkhFxpXow67jLsIlrqWDSltERlTKh2cJ1g -hTRuQNSr7jtKQgFAR3aQ6dTzOP4fCOtLYn63jL4+YcxdGoK66tx8eFHq8oBLYvsc -LNjjkaAHilZFwA4Jr7zHBofTRBg04eZum2UTaRqmSyT65ifXm4vRdQa4k1FS1gnq -hPMhIjST8b6RkxvbLNmFpkOfI9eCMRpFG7y+NQIDAQABAoIBAATU9ZVOcHmLSkop -zcBJerM09O2dAIziGb/XA55fqdJ728aQ0gGW0oIANlKCCaWjQFrTJP04VzNL/F01 -l5EpnOqlTPxMRpPqc2cAI677sBL29fpH0gtnvzUSiI7Xkp3RcAtNH6qCrJGSlkn+ -BMgoSGmW+yKuK3h/yWnt6kc2umA8fN+bHKhS3pI56PMW8qVnny9n92RaCA7Uf/4j -XDewIreiH5jRqRwrPbOpjDFmv+W18LWZQiTwwxfY6sRZiZpsfsHidzfFGUFZXMlq -2P3FCqoF4oMM1rRgBlHhDR7JHmSkFpZG639HJTXLllpyDbj3H86nsjIj9WZYKn+h -B9k9bcECgYEA1HDzRCqoRZh6cL46KiYjv+LwmEKMa3nPb4ljywgLbRkzTMyVs0MK -fDsDoTLFY9PBhveypU5gjTbQqtyBsDtbU+dH9Eks1FdL9P8bJf0ZOJFRiEB7uB4a -z3V9tcXHwH4l2bCWbWThQGFwRudDAoY0EH89oSA/WHayjOKe1Wi4ovUCgYEA848B -cYi+Qbkk+fOv9gSJn8KS1LH/jE28S/e7E4YkTfYUuu+7wr8bdRKUNpPIsLX0Fo9R -KpJX0Oyjjady9n/8ARZRmD8Upl+F7Sl7Ro6F8+nfqQUrxbVDiIL3b+aZF+cDfFrB -/xL5kyZZqTtFfP360tfYnlS6Sssd4E2Jsj0fJkECgYEA0gG+WbqZkgMDtwQ114jQ -elZLZRkUWwKVjzsQDZssQHNTBS6RJh619M0Z73aTLvYcL+IZFdT/GVoAuYc2JRLo -W28c8F6OFHMfwVeWbN1g20y8fqbQJtiLxF3vIYwcxStvG123tvisu8oXBeCDm7Ez -MsO2FtwcAsWECEXWojzdmSkCgYEA18GvPawtHnuszd+Z2Q5b/DKZb+Hex6N1Ura5 -+qmyL333D0Kfyf0RjbxPn6l690+4UuPSuyu4r1Nx72KO7N6jlzL2RTBcUqX8NgOx -OOe4skJT5561EAdrM9sQ5wgYRpxW8ipUAGoGvNwUQV5ISFmVgIHFWz0jam5UoQcP -G94ZYgECgYBN4PB8BuAwMRqhTCLL5RJKtcdC/Ls9xdWrmswtQQ4OSBUTLGdeMhEN -E23F0d+NdtiTWDYRDJ7z6KAF8CcOwWMt2sNbrxgNRuDzyfLqbuVLOM8iY3xA50Nl -clZWujKILWp47+gA6/AqJLv2lA2LK8gM8zYzzm/nCuLCBLYH/hOiCA== ------END RSA PRIVATE KEY----- diff --git a/internal/certs/local_ca.go b/internal/certs/local_ca.go new file mode 100644 index 0000000..faf971c --- /dev/null +++ b/internal/certs/local_ca.go @@ -0,0 +1,191 @@ +package certs + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "math/big" + "os" + "path/filepath" + "strings" + "time" +) + +const legacySharedCASHA256 = "836E6BB84F6C3E63316DBB4EC257223AF09F7490E7AAE09030B8515ED61EE9FF" + +// LoadOrCreateManager loads the installation-specific CA, generating it on +// first run. The private key is persisted only in the supplied local path. +func LoadOrCreateManager(certPath, keyPath string) (*Manager, []byte, error) { + certPEM, certErr := os.ReadFile(certPath) + keyPEM, keyErr := os.ReadFile(keyPath) + + if certErr == nil && isLegacySharedCA(certPEM) { + return generateAndPersistManager(certPath, keyPath) + } + if certErr == nil && keyErr == nil { + manager, err := NewManagerFromPEM(certPEM, keyPEM) + if err != nil { + return nil, nil, fmt.Errorf("load installation CA: %w", err) + } + if err := os.Chmod(keyPath, 0o600); err != nil { + return nil, nil, fmt.Errorf("restrict installation CA private key permissions: %w", err) + } + return manager, manager.CACertPEM(), nil + } + if errors.Is(certErr, os.ErrNotExist) && errors.Is(keyErr, os.ErrNotExist) { + return generateAndPersistManager(certPath, keyPath) + } + // A key without a certificate cannot have been installed as a trusted root. + // This is safe to recover if the first-run write was interrupted. + if errors.Is(certErr, os.ErrNotExist) && keyErr == nil { + return generateAndPersistManager(certPath, keyPath) + } + if certErr != nil && !errors.Is(certErr, os.ErrNotExist) { + return nil, nil, fmt.Errorf("read installation CA certificate: %w", certErr) + } + if keyErr != nil && !errors.Is(keyErr, os.ErrNotExist) { + return nil, nil, fmt.Errorf("read installation CA private key: %w", keyErr) + } + return nil, nil, errors.New("installation CA is incomplete; both certificate and private key are required") +} + +// NewGeneratedManager creates an in-memory CA suitable for short-lived tools. +func NewGeneratedManager() (*Manager, []byte, error) { + certPEM, keyPEM, err := generateCA() + if err != nil { + return nil, nil, err + } + manager, err := NewManagerFromPEM(certPEM, keyPEM) + if err != nil { + return nil, nil, err + } + return manager, manager.CACertPEM(), nil +} + +func generateAndPersistManager(certPath, keyPath string) (*Manager, []byte, error) { + if filepath.Dir(certPath) != filepath.Dir(keyPath) { + return nil, nil, errors.New("installation CA certificate and key must share a directory") + } + certPEM, keyPEM, err := generateCA() + if err != nil { + return nil, nil, err + } + if err := os.MkdirAll(filepath.Dir(certPath), 0o700); err != nil { + return nil, nil, fmt.Errorf("create installation CA directory: %w", err) + } + // Write the private key first so a crash cannot leave a new certificate + // without the signing key needed by the proxy. + if err := writeLocalCAFile(keyPath, keyPEM, 0o600); err != nil { + return nil, nil, fmt.Errorf("persist installation CA private key: %w", err) + } + if err := writeLocalCAFile(certPath, certPEM, 0o644); err != nil { + return nil, nil, fmt.Errorf("persist installation CA certificate: %w", err) + } + manager, err := NewManagerFromPEM(certPEM, keyPEM) + if err != nil { + return nil, nil, err + } + return manager, manager.CACertPEM(), nil +} + +func generateCA() ([]byte, []byte, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 3072) + if err != nil { + return nil, nil, fmt.Errorf("generate installation CA private key: %w", err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, nil, fmt.Errorf("generate installation CA serial: %w", err) + } + publicKeyDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + if err != nil { + return nil, nil, fmt.Errorf("marshal installation CA public key: %w", err) + } + subjectKeyID := sha256.Sum256(publicKeyDER) + now := time.Now() + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: "Cursor BYOK Local CA", + Organization: []string{"Cursor BYOK"}, + }, + NotBefore: now.Add(-5 * time.Minute), + NotAfter: now.AddDate(10, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLen: 0, + MaxPathLenZero: true, + SubjectKeyId: append([]byte(nil), subjectKeyID[:20]...), + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + if err != nil { + return nil, nil, fmt.Errorf("create installation CA certificate: %w", err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}) + return certPEM, keyPEM, nil +} + +func writeLocalCAFile(path string, data []byte, mode os.FileMode) error { + temp, err := os.CreateTemp(filepath.Dir(path), ".ca-*") + if err != nil { + return err + } + tempPath := temp.Name() + defer os.Remove(tempPath) + if err := temp.Chmod(mode); err != nil { + _ = temp.Close() + return err + } + if _, err := temp.Write(data); err != nil { + _ = temp.Close() + return err + } + if err := temp.Sync(); err != nil { + _ = temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if err := os.Rename(tempPath, path); err != nil { + return err + } + return os.Chmod(path, mode) +} + +func isLegacySharedCA(certPEM []byte) bool { + block, _ := pem.Decode(certPEM) + if block == nil { + return false + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return false + } + sum := sha256.Sum256(cert.Raw) + return strings.EqualFold(hex.EncodeToString(sum[:]), legacySharedCASHA256) +} + +// loadCAPEMFromFiles reads an explicitly supplied CA pair. +func loadCAPEMFromFiles(certPath, keyPath string) ([]byte, []byte, error) { + certPEM, err := os.ReadFile(certPath) + if err != nil { + return nil, nil, err + } + keyPEM, err := os.ReadFile(keyPath) + if err != nil { + return nil, nil, err + } + return certPEM, keyPEM, nil +} diff --git a/internal/certs/local_ca_test.go b/internal/certs/local_ca_test.go new file mode 100644 index 0000000..0415a71 --- /dev/null +++ b/internal/certs/local_ca_test.go @@ -0,0 +1,125 @@ +package certs + +import ( + "bytes" + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestLoadOrCreateManagerPersistsAndReusesInstallationCA(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "ca.crt") + keyPath := filepath.Join(dir, "ca.key") + + manager, certPEM, err := LoadOrCreateManager(certPath, keyPath) + if err != nil { + t.Fatalf("LoadOrCreateManager() error = %v", err) + } + if isLegacySharedCA(certPEM) { + t.Fatal("generated CA reused the legacy shared certificate") + } + keyInfo, err := os.Stat(keyPath) + if err != nil { + t.Fatalf("stat private key: %v", err) + } + if runtime.GOOS != "windows" && keyInfo.Mode().Perm() != 0o600 { + t.Fatalf("private key mode = %o, want 600", keyInfo.Mode().Perm()) + } + + leaf, err := manager.CertificateForServerName("api2.cursor.sh") + if err != nil { + t.Fatalf("CertificateForServerName() error = %v", err) + } + ca := parseCertificatePEM(t, certPEM) + roots := x509.NewCertPool() + roots.AddCert(ca) + if _, err := leaf.Leaf.Verify(x509.VerifyOptions{DNSName: "api2.cursor.sh", Roots: roots}); err != nil { + t.Fatalf("verify generated leaf: %v", err) + } + + _, reusedCertPEM, err := LoadOrCreateManager(certPath, keyPath) + if err != nil { + t.Fatalf("second LoadOrCreateManager() error = %v", err) + } + if !bytes.Equal(certPEM, reusedCertPEM) { + t.Fatal("installation CA changed between loads") + } +} + +func TestLoadOrCreateManagerCreatesUniqueCAsPerInstallation(t *testing.T) { + firstDir := t.TempDir() + secondDir := t.TempDir() + _, firstCert, err := LoadOrCreateManager(filepath.Join(firstDir, "ca.crt"), filepath.Join(firstDir, "ca.key")) + if err != nil { + t.Fatalf("create first CA: %v", err) + } + _, secondCert, err := LoadOrCreateManager(filepath.Join(secondDir, "ca.crt"), filepath.Join(secondDir, "ca.key")) + if err != nil { + t.Fatalf("create second CA: %v", err) + } + if bytes.Equal(firstCert, secondCert) { + t.Fatal("separate installations received the same CA certificate") + } +} + +func TestLoadOrCreateManagerReplacesLegacySharedCertificate(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "ca.crt") + keyPath := filepath.Join(dir, "ca.key") + legacyCert, err := os.ReadFile(filepath.Join("testdata", "legacy_shared_ca.crt")) + if err != nil { + t.Fatalf("read legacy certificate fixture: %v", err) + } + if !isLegacySharedCA(legacyCert) { + t.Fatal("legacy certificate fixture fingerprint changed") + } + if err := os.WriteFile(certPath, legacyCert, 0o644); err != nil { + t.Fatalf("write legacy certificate: %v", err) + } + + _, generatedCert, err := LoadOrCreateManager(certPath, keyPath) + if err != nil { + t.Fatalf("migrate legacy certificate: %v", err) + } + if bytes.Equal(legacyCert, generatedCert) || isLegacySharedCA(generatedCert) { + t.Fatal("legacy shared certificate was not replaced") + } + if _, err := os.Stat(keyPath); err != nil { + t.Fatalf("generated private key missing: %v", err) + } +} + +func TestNewManagerFromPEMRejectsMismatchedKey(t *testing.T) { + firstDir := t.TempDir() + secondDir := t.TempDir() + _, _, err := LoadOrCreateManager(filepath.Join(firstDir, "ca.crt"), filepath.Join(firstDir, "ca.key")) + if err != nil { + t.Fatalf("create first CA: %v", err) + } + _, _, err = LoadOrCreateManager(filepath.Join(secondDir, "ca.crt"), filepath.Join(secondDir, "ca.key")) + if err != nil { + t.Fatalf("create second CA: %v", err) + } + certPEM, _ := os.ReadFile(filepath.Join(firstDir, "ca.crt")) + keyPEM, _ := os.ReadFile(filepath.Join(secondDir, "ca.key")) + if _, err := NewManagerFromPEM(certPEM, keyPEM); err == nil { + t.Fatal("NewManagerFromPEM() accepted a mismatched private key") + } +} + +func parseCertificatePEM(t *testing.T, certPEM []byte) *x509.Certificate { + t.Helper() + block, _ := pem.Decode(certPEM) + if block == nil { + t.Fatal("certificate PEM is invalid") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("parse certificate: %v", err) + } + return cert +} diff --git a/internal/certs/ca.crt b/internal/certs/testdata/legacy_shared_ca.crt similarity index 100% rename from internal/certs/ca.crt rename to internal/certs/testdata/legacy_shared_ca.crt diff --git a/internal/client/cursor.go b/internal/client/cursor.go index 9c78a10..84080be 100644 --- a/internal/client/cursor.go +++ b/internal/client/cursor.go @@ -5,6 +5,7 @@ import ( goruntime "runtime" "cursor/internal/cursor" + "cursor/internal/logger" ) // ApplyCursorSettings 用于处理与 ApplyCursorSettings 相关的逻辑。 @@ -21,6 +22,9 @@ func (s *ProxyService) ApplyCursorSettings() error { if err != nil { return fmt.Errorf("ensure ca cert file: %w", err) } + if err := cursor.EnsureLegacySharedCACertRemoved(); err != nil { + logger.Errorf("remove legacy shared ca cert failed, continuing with installation CA: %v", err) + } switch goruntime.GOOS { case "windows": diff --git a/internal/cursor/darwin_cert.go b/internal/cursor/darwin_cert.go index 5175078..b1a0115 100644 --- a/internal/cursor/darwin_cert.go +++ b/internal/cursor/darwin_cert.go @@ -16,6 +16,7 @@ import ( const ( darwinSecurityExe = "security" darwinLoginKeychainName = "login.keychain-db" + legacySharedCASHA1 = "C14B7488C5AB83F098BEB2603F1135595A381FC0" ) func getCertSHA1Fingerprint(certPEM []byte) (string, error) { @@ -36,7 +37,10 @@ func isCACertInstalled(certPEM []byte) (bool, error) { if err != nil { return false, fmt.Errorf("获取证书指纹失败: %w", err) } + return isCACertFingerprintInstalled(fingerprint) +} +func isCACertFingerprintInstalled(fingerprint string) (bool, error) { out, err := exec.Command(darwinSecurityExe, "find-certificate", "-a", "-Z", darwinLoginKeychainName).CombinedOutput() if err != nil { return false, fmt.Errorf("检查 macOS 登录钥匙串失败: %w: %s", err, strings.TrimSpace(string(out))) @@ -50,6 +54,36 @@ func isCACertInstalled(certPEM []byte) (bool, error) { return installed, nil } +// EnsureLegacySharedCACertRemoved removes the compromised CA shipped by older versions. +func EnsureLegacySharedCACertRemoved() error { + installed, err := isCACertFingerprintInstalled(legacySharedCASHA1) + if err != nil { + return fmt.Errorf("检查旧版共享 CA 失败: %w", err) + } + if !installed { + return nil + } + out, err := exec.Command( + darwinSecurityExe, + "delete-certificate", + "-Z", legacySharedCASHA1, + "-t", + darwinLoginKeychainName, + ).CombinedOutput() + if err != nil { + return fmt.Errorf("从 macOS 登录钥匙串删除旧版共享 CA 失败: %w: %s", err, strings.TrimSpace(string(out))) + } + installed, err = isCACertFingerprintInstalled(legacySharedCASHA1) + if err != nil { + return fmt.Errorf("验证旧版共享 CA 删除状态失败: %w", err) + } + if installed { + return fmt.Errorf("删除命令已执行,但 macOS 登录钥匙串中仍存在旧版共享 CA") + } + logger.Infof("ensureLegacySharedCACertRemoved: legacy shared CA removed from macOS login keychain") + return nil +} + func installCACertToDarwinKeychain(certPEM []byte, certPath string) error { fingerprint, err := getCertSHA1Fingerprint(certPEM) if err != nil { diff --git a/internal/cursor/windows_cert.go b/internal/cursor/windows_cert.go index 9d8d100..26eccd4 100644 --- a/internal/cursor/windows_cert.go +++ b/internal/cursor/windows_cert.go @@ -20,6 +20,7 @@ const ( windowsCertutilExe = "certutil.exe" windowsPowerShellExe = "powershell.exe" windowsUserCancelCode = 1223 + legacySharedCASHA1 = "C14B7488C5AB83F098BEB2603F1135595A381FC0" ) // getCertThumbprint 获取证书的SHA1指纹,用于唯一标识证书 @@ -51,7 +52,10 @@ func isCACertInstalled(certPEM []byte) (bool, error) { if err != nil { return false, fmt.Errorf("获取证书指纹失败: %w", err) } + return isCACertThumbprintInstalled(thumbprint) +} +func isCACertThumbprintInstalled(thumbprint string) (bool, error) { cmd := exec.Command(windowsCertutilExe, "-verifystore", windowsRootStoreName, thumbprint) cmd.SysProcAttr = hideWindow() output, err := cmd.CombinedOutput() @@ -76,6 +80,29 @@ func isCACertInstalled(certPEM []byte) (bool, error) { return false, nil } +// EnsureLegacySharedCACertRemoved removes the compromised CA shipped by older versions. +func EnsureLegacySharedCACertRemoved() error { + installed, err := isCACertThumbprintInstalled(legacySharedCASHA1) + if err != nil { + return fmt.Errorf("检查旧版共享 CA 失败: %w", err) + } + if !installed { + return nil + } + if err := runElevatedCertutil("-delstore", windowsRootStoreName, legacySharedCASHA1); err != nil { + return fmt.Errorf("从 Windows 系统信任存储删除旧版共享 CA 失败: %w", err) + } + installed, err = isCACertThumbprintInstalled(legacySharedCASHA1) + if err != nil { + return fmt.Errorf("验证旧版共享 CA 删除状态失败: %w", err) + } + if installed { + return fmt.Errorf("删除命令已执行,但 Windows 系统信任存储中仍存在旧版共享 CA") + } + logger.Infof("ensureLegacySharedCACertRemoved: legacy shared CA removed from Windows system store") + return nil +} + func quotePowerShellLiteral(value string) string { return "'" + strings.ReplaceAll(value, "'", "''") + "'" } diff --git a/internal/cursor/windows_cert_stub.go b/internal/cursor/windows_cert_stub.go index c5cb46d..ab8139b 100644 --- a/internal/cursor/windows_cert_stub.go +++ b/internal/cursor/windows_cert_stub.go @@ -8,3 +8,8 @@ import "fmt" func EnsureCACertInstalled(_ []byte, certPath string) error { return fmt.Errorf("ensureCACertInstalled: 当前平台暂不支持,certPath=%s", certPath) } + +// EnsureLegacySharedCACertRemoved is a no-op on unsupported platforms. +func EnsureLegacySharedCACertRemoved() error { + return nil +}