diff --git a/cmd/server/main.go b/cmd/server/main.go index bb4dbae..07c72f7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,6 +13,7 @@ import ( "github.com/gotunnel/pkg/crypto" "github.com/gotunnel/pkg/plugin" "github.com/gotunnel/pkg/plugin/builtin" + "github.com/gotunnel/pkg/plugin/sign" ) func main() { @@ -101,16 +102,67 @@ func loadJSPlugins(configs []config.JSPluginConfig) []tunnel.JSPluginEntry { continue } + // 加载签名文件 + sigPath := cfg.SigPath + if sigPath == "" { + sigPath = cfg.Path + ".sig" + } + signature, err := os.ReadFile(sigPath) + if err != nil { + log.Printf("[JSPlugin] Failed to load signature for %s: %v", cfg.Name, err) + continue + } + + // 服务端也验证签名,防止配置文件被篡改 + if err := verifyPluginSignature(cfg.Name, string(source), string(signature)); err != nil { + log.Printf("[JSPlugin] Signature verification failed for %s: %v", cfg.Name, err) + continue + } + plugins = append(plugins, tunnel.JSPluginEntry{ Name: cfg.Name, Source: string(source), + Signature: string(signature), AutoPush: cfg.AutoPush, Config: cfg.Config, AutoStart: cfg.AutoStart, }) - log.Printf("[JSPlugin] Loaded: %s from %s", cfg.Name, cfg.Path) + log.Printf("[JSPlugin] Loaded: %s from %s (verified)", cfg.Name, cfg.Path) } return plugins } + +// verifyPluginSignature 验证插件签名 +func verifyPluginSignature(name, source, signature string) error { + // 解码签名 + signed, err := sign.DecodeSignedPlugin(signature) + if err != nil { + return fmt.Errorf("decode signature: %w", err) + } + + // 检查插件是否被撤销 + if revoked, reason := sign.IsPluginRevoked(name, signed.Payload.Version); revoked { + return fmt.Errorf("plugin revoked: %s", reason) + } + + // 检查密钥是否已吊销 + if sign.IsKeyRevoked(signed.Payload.KeyID) { + return fmt.Errorf("signing key revoked: %s", signed.Payload.KeyID) + } + + // 获取公钥 + pubKey, err := sign.GetPublicKeyByID(signed.Payload.KeyID) + if err != nil { + return err + } + + // 验证插件名称 + if signed.Payload.Name != name { + return fmt.Errorf("name mismatch: %s vs %s", signed.Payload.Name, name) + } + + // 验证签名 + return sign.VerifyPlugin(pubKey, signed, source) +} diff --git a/internal/client/tunnel/client.go b/internal/client/tunnel/client.go index f7db276..5553ae7 100644 --- a/internal/client/tunnel/client.go +++ b/internal/client/tunnel/client.go @@ -12,6 +12,7 @@ import ( "github.com/gotunnel/pkg/plugin" "github.com/gotunnel/pkg/plugin/script" + "github.com/gotunnel/pkg/plugin/sign" "github.com/gotunnel/pkg/protocol" "github.com/gotunnel/pkg/relay" "github.com/hashicorp/yamux" @@ -35,11 +36,13 @@ type Client struct { ID string TLSEnabled bool TLSConfig *tls.Config + DataDir string // 数据目录 session *yamux.Session rules []protocol.ProxyRule mu sync.RWMutex pluginRegistry *plugin.Registry - runningPlugins map[string]plugin.ClientPlugin // 运行中的客户端插件 + runningPlugins map[string]plugin.ClientPlugin + versionStore *PluginVersionStore pluginMu sync.RWMutex } @@ -48,14 +51,30 @@ func NewClient(serverAddr, token, id string) *Client { if id == "" { id = loadClientID() } + + // 默认数据目录 + home, _ := os.UserHomeDir() + dataDir := filepath.Join(home, ".gotunnel") + return &Client{ ServerAddr: serverAddr, Token: token, ID: id, + DataDir: dataDir, runningPlugins: make(map[string]plugin.ClientPlugin), } } +// InitVersionStore 初始化版本存储 +func (c *Client) InitVersionStore() error { + store, err := NewPluginVersionStore(c.DataDir) + if err != nil { + return err + } + c.versionStore = store + return nil +} + // getIDFilePath 获取 ID 文件路径 func getIDFilePath() string { home, err := os.UserHomeDir() @@ -478,6 +497,14 @@ func (c *Client) handleJSPluginInstall(stream net.Conn, msg *protocol.Message) { log.Printf("[Client] Installing JS plugin: %s", req.PluginName) + // 验证官方签名 + if err := c.verifyJSPluginSignature(req.PluginName, req.Source, req.Signature); err != nil { + log.Printf("[Client] JS plugin %s signature verification failed: %v", req.PluginName, err) + c.sendJSPluginResult(stream, req.PluginName, false, "signature verification failed: "+err.Error()) + return + } + log.Printf("[Client] JS plugin %s signature verified", req.PluginName) + // 创建 JS 插件 jsPlugin, err := script.NewJSPlugin(req.PluginName, req.Source) if err != nil { @@ -493,6 +520,14 @@ func (c *Client) handleJSPluginInstall(stream net.Conn, msg *protocol.Message) { log.Printf("[Client] JS plugin %s installed", req.PluginName) c.sendJSPluginResult(stream, req.PluginName, true, "") + // 保存版本信息(防止降级攻击) + if c.versionStore != nil { + signed, _ := sign.DecodeSignedPlugin(req.Signature) + if signed != nil { + c.versionStore.SetVersion(req.PluginName, signed.Payload.Version) + } + } + // 自动启动 if req.AutoStart { c.startJSPlugin(jsPlugin, req) @@ -530,3 +565,58 @@ func (c *Client) startJSPlugin(handler plugin.ClientPlugin, req protocol.JSPlugi log.Printf("[Client] JS plugin %s started at %s", req.PluginName, localAddr) } + +// verifyJSPluginSignature 验证 JS 插件签名 +func (c *Client) verifyJSPluginSignature(pluginName, source, signature string) error { + if signature == "" { + return fmt.Errorf("missing signature") + } + + // 解码签名 + signed, err := sign.DecodeSignedPlugin(signature) + if err != nil { + return fmt.Errorf("decode signature: %w", err) + } + + // 检查插件是否被撤销 + if revoked, reason := sign.IsPluginRevoked(pluginName, signed.Payload.Version); revoked { + return fmt.Errorf("plugin %s v%s has been revoked: %s", + pluginName, signed.Payload.Version, reason) + } + + // 检查密钥是否已吊销 + if sign.IsKeyRevoked(signed.Payload.KeyID) { + return fmt.Errorf("signing key %s has been revoked", signed.Payload.KeyID) + } + + // 根据 KeyID 获取对应公钥 + pubKey, err := sign.GetPublicKeyByID(signed.Payload.KeyID) + if err != nil { + return fmt.Errorf("get public key: %w", err) + } + + // 验证插件名称匹配 + if signed.Payload.Name != pluginName { + return fmt.Errorf("plugin name mismatch: expected %s, got %s", + pluginName, signed.Payload.Name) + } + + // 验证签名和源码哈希 + if err := sign.VerifyPlugin(pubKey, signed, source); err != nil { + return err + } + + // 检查版本降级攻击 + if c.versionStore != nil { + currentVer := c.versionStore.GetVersion(pluginName) + if currentVer != "" { + cmp := sign.CompareVersions(signed.Payload.Version, currentVer) + if cmp < 0 { + return fmt.Errorf("version downgrade rejected: %s < %s", + signed.Payload.Version, currentVer) + } + } + } + + return nil +} diff --git a/internal/client/tunnel/version_store.go b/internal/client/tunnel/version_store.go new file mode 100644 index 0000000..5579ff9 --- /dev/null +++ b/internal/client/tunnel/version_store.go @@ -0,0 +1,67 @@ +package tunnel + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" +) + +// PluginVersionStore 插件版本存储 +type PluginVersionStore struct { + path string + versions map[string]string // pluginName -> version + mu sync.RWMutex +} + +// NewPluginVersionStore 创建版本存储 +func NewPluginVersionStore(dataDir string) (*PluginVersionStore, error) { + store := &PluginVersionStore{ + path: filepath.Join(dataDir, "plugin_versions.json"), + versions: make(map[string]string), + } + if err := store.load(); err != nil { + return nil, err + } + return store, nil +} + +// load 从文件加载版本信息 +func (s *PluginVersionStore) load() error { + data, err := os.ReadFile(s.path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + return json.Unmarshal(data, &s.versions) +} + +// save 保存版本信息到文件 +func (s *PluginVersionStore) save() error { + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + data, err := json.MarshalIndent(s.versions, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, data, 0600) +} + +// GetVersion 获取插件版本 +func (s *PluginVersionStore) GetVersion(name string) string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.versions[name] +} + +// SetVersion 设置插件版本 +func (s *PluginVersionStore) SetVersion(name, version string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.versions[name] = version + return s.save() +} diff --git a/internal/server/config/config.go b/internal/server/config/config.go index d13b8ef..fb6744a 100644 --- a/internal/server/config/config.go +++ b/internal/server/config/config.go @@ -20,6 +20,7 @@ type ServerConfig struct { type JSPluginConfig struct { Name string `yaml:"name"` Path string `yaml:"path"` // JS 文件路径 + SigPath string `yaml:"sig_path,omitempty"` // 签名文件路径 (默认为 path + ".sig") AutoPush []string `yaml:"auto_push,omitempty"` // 自动推送到的客户端 ID 列表 Config map[string]string `yaml:"config,omitempty"` // 插件配置 AutoStart bool `yaml:"auto_start,omitempty"` // 是否自动启动 @@ -27,9 +28,12 @@ type JSPluginConfig struct { // PluginStoreSettings 扩展商店设置 type PluginStoreSettings struct { - URL string `yaml:"url"` // 扩展商店URL,例如 GitHub 仓库的 raw URL + // 保留结构体以便未来扩展,但不暴露 URL 配置 } +// 官方插件商店(不可配置) +const OfficialPluginStoreURL = "https://git.92coco.cn:8443/flik/GoTunnel-Plugins/raw/branch/main/store.json" + // ServerSettings 服务端设置 type ServerSettings struct { BindAddr string `yaml:"bind_addr"` diff --git a/internal/server/db/interface.go b/internal/server/db/interface.go index 3dbfb15..983ce71 100644 --- a/internal/server/db/interface.go +++ b/internal/server/db/interface.go @@ -37,6 +37,7 @@ type PluginData struct { type JSPlugin struct { Name string `json:"name"` Source string `json:"source"` + Signature string `json:"signature"` // 官方签名 (Base64) Description string `json:"description"` Author string `json:"author"` AutoPush []string `json:"auto_push"` diff --git a/internal/server/db/sqlite.go b/internal/server/db/sqlite.go index a2f9089..d030811 100644 --- a/internal/server/db/sqlite.go +++ b/internal/server/db/sqlite.go @@ -93,6 +93,9 @@ func (s *SQLiteStore) init() error { return err } + // 迁移:添加 signature 列 + s.db.Exec(`ALTER TABLE js_plugins ADD COLUMN signature TEXT NOT NULL DEFAULT ''`) + return nil } @@ -323,7 +326,7 @@ func (s *SQLiteStore) GetAllJSPlugins() ([]JSPlugin, error) { defer s.mu.RUnlock() rows, err := s.db.Query(` - SELECT name, source, description, author, auto_push, config, auto_start, enabled + SELECT name, source, signature, description, author, auto_push, config, auto_start, enabled FROM js_plugins `) if err != nil { @@ -336,7 +339,7 @@ func (s *SQLiteStore) GetAllJSPlugins() ([]JSPlugin, error) { var p JSPlugin var autoPushJSON, configJSON string var autoStart, enabled int - err := rows.Scan(&p.Name, &p.Source, &p.Description, &p.Author, + err := rows.Scan(&p.Name, &p.Source, &p.Signature, &p.Description, &p.Author, &autoPushJSON, &configJSON, &autoStart, &enabled) if err != nil { return nil, err @@ -359,9 +362,9 @@ func (s *SQLiteStore) GetJSPlugin(name string) (*JSPlugin, error) { var autoPushJSON, configJSON string var autoStart, enabled int err := s.db.QueryRow(` - SELECT name, source, description, author, auto_push, config, auto_start, enabled + SELECT name, source, signature, description, author, auto_push, config, auto_start, enabled FROM js_plugins WHERE name = ? - `, name).Scan(&p.Name, &p.Source, &p.Description, &p.Author, + `, name).Scan(&p.Name, &p.Source, &p.Signature, &p.Description, &p.Author, &autoPushJSON, &configJSON, &autoStart, &enabled) if err != nil { return nil, err @@ -390,9 +393,9 @@ func (s *SQLiteStore) SaveJSPlugin(p *JSPlugin) error { _, err := s.db.Exec(` INSERT OR REPLACE INTO js_plugins - (name, source, description, author, auto_push, config, auto_start, enabled) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, p.Name, p.Source, p.Description, p.Author, + (name, source, signature, description, author, auto_push, config, auto_start, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, p.Name, p.Source, p.Signature, p.Description, p.Author, string(autoPushJSON), string(configJSON), autoStart, enabled) return err } diff --git a/internal/server/router/api.go b/internal/server/router/api.go index af00ea1..1a5e05f 100644 --- a/internal/server/router/api.go +++ b/internal/server/router/api.go @@ -59,6 +59,7 @@ type ServerInterface interface { type JSPluginInstallRequest struct { PluginName string `json:"plugin_name"` Source string `json:"source"` + Signature string `json:"signature"` RuleName string `json:"rule_name"` RemotePort int `json:"remote_port"` Config map[string]string `json:"config"` @@ -589,14 +590,8 @@ func (h *APIHandler) handleStorePlugins(rw http.ResponseWriter, r *http.Request) } cfg := h.app.GetConfig() - storeURL := cfg.PluginStore.URL - if storeURL == "" { - h.jsonResponse(rw, map[string]interface{}{ - "plugins": []StorePluginInfo{}, - "store_url": "", - }) - return - } + storeURL := config.OfficialPluginStoreURL + _ = cfg // 保留以便未来扩展 // 从远程URL获取插件列表 client := &http.Client{Timeout: 10 * time.Second} @@ -939,6 +934,7 @@ func (h *APIHandler) pushJSPluginToClient(rw http.ResponseWriter, pluginName, cl req := JSPluginInstallRequest{ PluginName: p.Name, Source: p.Source, + Signature: p.Signature, RuleName: p.Name, Config: p.Config, AutoStart: p.AutoStart, diff --git a/internal/server/tunnel/server.go b/internal/server/tunnel/server.go index ba4461c..23788e5 100644 --- a/internal/server/tunnel/server.go +++ b/internal/server/tunnel/server.go @@ -54,6 +54,7 @@ type Server struct { type JSPluginEntry struct { Name string Source string + Signature string AutoPush []string Config map[string]string AutoStart bool @@ -873,6 +874,7 @@ func (s *Server) InstallJSPluginToClient(clientID string, req router.JSPluginIns installReq := protocol.JSPluginInstallRequest{ PluginName: req.PluginName, Source: req.Source, + Signature: req.Signature, RuleName: req.RuleName, RemotePort: req.RemotePort, Config: req.Config, @@ -1035,6 +1037,7 @@ func (s *Server) autoPushJSPlugins(cs *ClientSession) { req := router.JSPluginInstallRequest{ PluginName: jp.Name, Source: jp.Source, + Signature: jp.Signature, RuleName: jp.Name, Config: jp.Config, AutoStart: jp.AutoStart, diff --git a/pkg/plugin/script/js.go b/pkg/plugin/script/js.go index 7baea40..777db07 100644 --- a/pkg/plugin/script/js.go +++ b/pkg/plugin/script/js.go @@ -20,6 +20,7 @@ type JSPlugin struct { vm *goja.Runtime metadata plugin.Metadata config map[string]string + sandbox *Sandbox running bool mu sync.Mutex } @@ -27,9 +28,10 @@ type JSPlugin struct { // NewJSPlugin 从 JS 源码创建插件 func NewJSPlugin(name, source string) (*JSPlugin, error) { p := &JSPlugin{ - name: name, - source: source, - vm: goja.New(), + name: name, + source: source, + vm: goja.New(), + sandbox: DefaultSandbox(), } if err := p.init(); err != nil { @@ -39,6 +41,11 @@ func NewJSPlugin(name, source string) (*JSPlugin, error) { return p, nil } +// SetSandbox 设置沙箱配置 +func (p *JSPlugin) SetSandbox(sandbox *Sandbox) { + p.sandbox = sandbox +} + // init 初始化 JS 运行时 func (p *JSPlugin) init() error { // 注入基础 API @@ -231,22 +238,50 @@ func (p *JSPlugin) createFsAPI() map[string]interface{} { } } -func (p *JSPlugin) fsReadFile(path string) string { +func (p *JSPlugin) fsReadFile(path string) map[string]interface{} { + if err := p.sandbox.ValidateReadPath(path); err != nil { + return map[string]interface{}{"error": err.Error(), "data": ""} + } + + info, err := os.Stat(path) + if err != nil { + return map[string]interface{}{"error": err.Error(), "data": ""} + } + if info.Size() > p.sandbox.MaxReadSize { + return map[string]interface{}{"error": "file too large", "data": ""} + } + data, err := os.ReadFile(path) if err != nil { - return "" + return map[string]interface{}{"error": err.Error(), "data": ""} } - return string(data) + return map[string]interface{}{"error": "", "data": string(data)} } -func (p *JSPlugin) fsWriteFile(path, content string) bool { - return os.WriteFile(path, []byte(content), 0644) == nil +func (p *JSPlugin) fsWriteFile(path, content string) map[string]interface{} { + if err := p.sandbox.ValidateWritePath(path); err != nil { + return map[string]interface{}{"error": err.Error(), "ok": false} + } + + if int64(len(content)) > p.sandbox.MaxWriteSize { + return map[string]interface{}{"error": "content too large", "ok": false} + } + + err := os.WriteFile(path, []byte(content), 0644) + if err != nil { + return map[string]interface{}{"error": err.Error(), "ok": false} + } + return map[string]interface{}{"error": "", "ok": true} } -func (p *JSPlugin) fsReadDir(path string) []map[string]interface{} { +func (p *JSPlugin) fsReadDir(path string) map[string]interface{} { + if err := p.sandbox.ValidateReadPath(path); err != nil { + return map[string]interface{}{"error": err.Error(), "entries": nil} + } + entries, err := os.ReadDir(path) if err != nil { - return nil + return map[string]interface{}{"error": err.Error(), "entries": nil} } var result []map[string]interface{} for _, e := range entries { @@ -257,15 +292,20 @@ func (p *JSPlugin) fsReadDir(path string) []map[string]interface{} { "size": info.Size(), }) } - return result + return map[string]interface{}{"error": "", "entries": result} } func (p *JSPlugin) fsStat(path string) map[string]interface{} { + if err := p.sandbox.ValidateReadPath(path); err != nil { + return map[string]interface{}{"error": err.Error()} + } + info, err := os.Stat(path) if err != nil { - return nil + return map[string]interface{}{"error": err.Error()} } return map[string]interface{}{ + "error": "", "name": info.Name(), "size": info.Size(), "isDir": info.IsDir(), @@ -273,17 +313,34 @@ func (p *JSPlugin) fsStat(path string) map[string]interface{} { } } -func (p *JSPlugin) fsExists(path string) bool { +func (p *JSPlugin) fsExists(path string) map[string]interface{} { + if err := p.sandbox.ValidateReadPath(path); err != nil { + return map[string]interface{}{"error": err.Error(), "exists": false} + } _, err := os.Stat(path) - return err == nil + return map[string]interface{}{"error": "", "exists": err == nil} } -func (p *JSPlugin) fsMkdir(path string) bool { - return os.MkdirAll(path, 0755) == nil +func (p *JSPlugin) fsMkdir(path string) map[string]interface{} { + if err := p.sandbox.ValidateWritePath(path); err != nil { + return map[string]interface{}{"error": err.Error(), "ok": false} + } + err := os.MkdirAll(path, 0755) + if err != nil { + return map[string]interface{}{"error": err.Error(), "ok": false} + } + return map[string]interface{}{"error": "", "ok": true} } -func (p *JSPlugin) fsRemove(path string) bool { - return os.RemoveAll(path) == nil +func (p *JSPlugin) fsRemove(path string) map[string]interface{} { + if err := p.sandbox.ValidateWritePath(path); err != nil { + return map[string]interface{}{"error": err.Error(), "ok": false} + } + err := os.RemoveAll(path) + if err != nil { + return map[string]interface{}{"error": err.Error(), "ok": false} + } + return map[string]interface{}{"error": "", "ok": true} } // ============================================================================= diff --git a/pkg/plugin/script/sandbox.go b/pkg/plugin/script/sandbox.go new file mode 100644 index 0000000..2c1291c --- /dev/null +++ b/pkg/plugin/script/sandbox.go @@ -0,0 +1,155 @@ +package script + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Sandbox 插件沙箱配置 +type Sandbox struct { + // 允许访问的路径列表(绝对路径) + AllowedPaths []string + // 允许写入的路径列表(必须是 AllowedPaths 的子集) + WritablePaths []string + // 禁止访问的路径(黑名单,优先级高于白名单) + DeniedPaths []string + // 是否允许网络访问 + AllowNetwork bool + // 最大文件读取大小 (bytes) + MaxReadSize int64 + // 最大文件写入大小 (bytes) + MaxWriteSize int64 +} + +// DefaultSandbox 返回默认沙箱配置(最小权限) +func DefaultSandbox() *Sandbox { + return &Sandbox{ + AllowedPaths: []string{}, + WritablePaths: []string{}, + DeniedPaths: defaultDeniedPaths(), + AllowNetwork: false, + MaxReadSize: 10 * 1024 * 1024, // 10MB + MaxWriteSize: 1 * 1024 * 1024, // 1MB + } +} + +// defaultDeniedPaths 返回默认禁止访问的路径 +func defaultDeniedPaths() []string { + home, _ := os.UserHomeDir() + denied := []string{ + "/etc/passwd", + "/etc/shadow", + "/etc/sudoers", + "/root", + "/.ssh", + "/.gnupg", + "/.aws", + "/.kube", + "/proc", + "/sys", + } + if home != "" { + denied = append(denied, + filepath.Join(home, ".ssh"), + filepath.Join(home, ".gnupg"), + filepath.Join(home, ".aws"), + filepath.Join(home, ".kube"), + filepath.Join(home, ".config"), + filepath.Join(home, ".local"), + ) + } + return denied +} + +// ValidateReadPath 验证读取路径是否允许 +func (s *Sandbox) ValidateReadPath(path string) error { + return s.validatePath(path, false) +} + +// ValidateWritePath 验证写入路径是否允许 +func (s *Sandbox) ValidateWritePath(path string) error { + return s.validatePath(path, true) +} + +func (s *Sandbox) validatePath(path string, write bool) error { + // 清理路径,防止路径遍历攻击 + cleanPath, err := s.cleanPath(path) + if err != nil { + return err + } + + // 检查黑名单(优先级最高) + if s.isDenied(cleanPath) { + return fmt.Errorf("access denied: path is in denied list") + } + + // 检查白名单 + allowedList := s.AllowedPaths + if write { + allowedList = s.WritablePaths + } + + if len(allowedList) == 0 { + return fmt.Errorf("access denied: no paths allowed") + } + + if !s.isAllowed(cleanPath, allowedList) { + if write { + return fmt.Errorf("access denied: path not in writable list") + } + return fmt.Errorf("access denied: path not in allowed list") + } + + return nil +} + +// cleanPath 清理并验证路径 +func (s *Sandbox) cleanPath(path string) (string, error) { + // 转换为绝对路径 + absPath, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("invalid path: %w", err) + } + + // 清理路径(解析 .. 和 .) + cleanPath := filepath.Clean(absPath) + + // 检查符号链接(防止通过符号链接绕过限制) + realPath, err := filepath.EvalSymlinks(cleanPath) + if err != nil { + // 文件可能不存在,使用清理后的路径 + if !os.IsNotExist(err) { + return "", fmt.Errorf("invalid path: %w", err) + } + realPath = cleanPath + } + + // 再次检查路径遍历 + if strings.Contains(realPath, "..") { + return "", fmt.Errorf("path traversal detected") + } + + return realPath, nil +} + +// isDenied 检查路径是否在黑名单中 +func (s *Sandbox) isDenied(path string) bool { + for _, denied := range s.DeniedPaths { + if strings.HasPrefix(path, denied) || path == denied { + return true + } + } + return false +} + +// isAllowed 检查路径是否在白名单中 +func (s *Sandbox) isAllowed(path string, allowedList []string) bool { + for _, allowed := range allowedList { + if strings.HasPrefix(path, allowed) || path == allowed { + return true + } + } + return false +} diff --git a/pkg/plugin/sign/official.go b/pkg/plugin/sign/official.go new file mode 100644 index 0000000..967a9f0 --- /dev/null +++ b/pkg/plugin/sign/official.go @@ -0,0 +1,77 @@ +package sign + +import ( + "crypto/ed25519" + "fmt" + "sync" + "time" +) + +// KeyEntry 密钥条目 +type KeyEntry struct { + ID string // 密钥 ID + PublicKey string // Base64 编码的公钥 + ValidFrom time.Time // 生效时间 + RevokedAt time.Time // 吊销时间(零值表示未吊销) +} + +// 官方公钥列表(支持密钥轮换) +var officialKeys = []KeyEntry{ + { + ID: "official-v1", + PublicKey: "0A0xRthj0wgPg8X8GJZ6/EnNpAUw5v7O//XLty+P5Yw=", + ValidFrom: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + }, + // 添加新密钥时,在此处追加 +} + +var ( + keyCache map[string]ed25519.PublicKey + keyCacheOnce sync.Once +) + +// initKeyCache 初始化密钥缓存 +func initKeyCache() { + keyCache = make(map[string]ed25519.PublicKey) + for _, entry := range officialKeys { + if pub, err := DecodePublicKey(entry.PublicKey); err == nil { + keyCache[entry.ID] = pub + } + } +} + +// GetOfficialPublicKey 获取默认官方公钥(兼容旧接口) +func GetOfficialPublicKey() (ed25519.PublicKey, error) { + return GetPublicKeyByID("official-v1") +} + +// GetPublicKeyByID 根据 ID 获取公钥 +func GetPublicKeyByID(keyID string) (ed25519.PublicKey, error) { + keyCacheOnce.Do(initKeyCache) + + pub, ok := keyCache[keyID] + if !ok { + return nil, fmt.Errorf("unknown key ID: %s", keyID) + } + return pub, nil +} + +// IsKeyRevoked 检查密钥是否已吊销 +func IsKeyRevoked(keyID string) bool { + for _, entry := range officialKeys { + if entry.ID == keyID { + return !entry.RevokedAt.IsZero() + } + } + return true // 未知密钥视为已吊销 +} + +// GetKeyEntry 获取密钥条目 +func GetKeyEntry(keyID string) *KeyEntry { + for i := range officialKeys { + if officialKeys[i].ID == keyID { + return &officialKeys[i] + } + } + return nil +} diff --git a/pkg/plugin/sign/payload.go b/pkg/plugin/sign/payload.go new file mode 100644 index 0000000..01c1855 --- /dev/null +++ b/pkg/plugin/sign/payload.go @@ -0,0 +1,107 @@ +package sign + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" +) + +// PluginPayload 插件签名载荷 +type PluginPayload struct { + Name string `json:"name"` // 插件名称 + Version string `json:"version"` // 版本号 + SourceHash string `json:"source_hash"` // 源码 SHA256 + KeyID string `json:"key_id"` // 签名密钥 ID + Timestamp int64 `json:"timestamp"` // 签名时间戳 +} + +// SignedPlugin 已签名的插件 +type SignedPlugin struct { + Payload PluginPayload `json:"payload"` + Signature string `json:"signature"` // Base64 签名 +} + +// NormalizeSource 规范化源码(统一换行符) +func NormalizeSource(source string) string { + // 统一换行符为 LF + normalized := strings.ReplaceAll(source, "\r\n", "\n") + normalized = strings.ReplaceAll(normalized, "\r", "\n") + // 去除尾部空白 + normalized = strings.TrimRight(normalized, " \t\n") + return normalized +} + +// HashSource 计算源码哈希 +func HashSource(source string) string { + normalized := NormalizeSource(source) + hash := sha256.Sum256([]byte(normalized)) + return hex.EncodeToString(hash[:]) +} + +// CreatePayload 创建签名载荷 +func CreatePayload(name, version, source, keyID string) *PluginPayload { + return &PluginPayload{ + Name: name, + Version: version, + SourceHash: HashSource(source), + KeyID: keyID, + Timestamp: time.Now().Unix(), + } +} + +// SignPlugin 签名插件 +func SignPlugin(priv ed25519.PrivateKey, payload *PluginPayload) (*SignedPlugin, error) { + // 序列化载荷 + data, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal payload: %w", err) + } + + // 签名 + sig := SignBase64(priv, data) + + return &SignedPlugin{ + Payload: *payload, + Signature: sig, + }, nil +} + +// VerifyPlugin 验证插件签名 +func VerifyPlugin(pub ed25519.PublicKey, signed *SignedPlugin, source string) error { + // 验证源码哈希 + expectedHash := HashSource(source) + if signed.Payload.SourceHash != expectedHash { + return fmt.Errorf("source hash mismatch") + } + + // 序列化载荷 + data, err := json.Marshal(signed.Payload) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + // 验证签名 + return VerifyBase64(pub, data, signed.Signature) +} + +// EncodeSignedPlugin 编码已签名插件为 JSON +func EncodeSignedPlugin(sp *SignedPlugin) (string, error) { + data, err := json.Marshal(sp) + if err != nil { + return "", err + } + return string(data), nil +} + +// DecodeSignedPlugin 从 JSON 解码已签名插件 +func DecodeSignedPlugin(data string) (*SignedPlugin, error) { + var sp SignedPlugin + if err := json.Unmarshal([]byte(data), &sp); err != nil { + return nil, err + } + return &sp, nil +} diff --git a/pkg/plugin/sign/revocation.go b/pkg/plugin/sign/revocation.go new file mode 100644 index 0000000..5b80936 --- /dev/null +++ b/pkg/plugin/sign/revocation.go @@ -0,0 +1,58 @@ +package sign + +import ( + "sync" +) + +// RevocationEntry 撤销条目 +type RevocationEntry struct { + PluginName string `json:"plugin_name"` // 插件名称 + Version string `json:"version,omitempty"` // 特定版本(空表示所有版本) + Reason string `json:"reason"` // 撤销原因 + RevokedAt int64 `json:"revoked_at"` // 撤销时间戳 +} + +// RevocationList 撤销列表 +type RevocationList struct { + Version int `json:"version"` // 列表版本 + UpdatedAt int64 `json:"updated_at"` // 更新时间 + Entries []RevocationEntry `json:"entries"` // 撤销条目 + Signature string `json:"signature"` // 列表签名 +} + +// 内置撤销列表(编译时确定) +var builtinRevocations = []RevocationEntry{ + // 示例:{PluginName: "malicious-plugin", Reason: "security vulnerability"} +} + +var ( + revocationCache map[string][]RevocationEntry // pluginName -> entries + revocationCacheOnce sync.Once +) + +// initRevocationCache 初始化撤销缓存 +func initRevocationCache() { + revocationCache = make(map[string][]RevocationEntry) + for _, entry := range builtinRevocations { + revocationCache[entry.PluginName] = append( + revocationCache[entry.PluginName], entry) + } +} + +// IsPluginRevoked 检查插件是否被撤销 +func IsPluginRevoked(name, version string) (bool, string) { + revocationCacheOnce.Do(initRevocationCache) + + entries, ok := revocationCache[name] + if !ok { + return false, "" + } + + for _, entry := range entries { + // 空版本表示所有版本都被撤销 + if entry.Version == "" || entry.Version == version { + return true, entry.Reason + } + } + return false, "" +} diff --git a/pkg/plugin/sign/sign.go b/pkg/plugin/sign/sign.go new file mode 100644 index 0000000..33a885f --- /dev/null +++ b/pkg/plugin/sign/sign.go @@ -0,0 +1,92 @@ +package sign + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" +) + +var ( + ErrInvalidSignature = errors.New("invalid signature") + ErrInvalidPublicKey = errors.New("invalid public key") + ErrInvalidPrivateKey = errors.New("invalid private key") +) + +// KeyPair Ed25519 密钥对 +type KeyPair struct { + PublicKey ed25519.PublicKey + PrivateKey ed25519.PrivateKey +} + +// GenerateKeyPair 生成新的密钥对 +func GenerateKeyPair() (*KeyPair, error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate key: %w", err) + } + return &KeyPair{PublicKey: pub, PrivateKey: priv}, nil +} + +// Sign 使用私钥签名数据 +func Sign(privateKey ed25519.PrivateKey, data []byte) []byte { + return ed25519.Sign(privateKey, data) +} + +// Verify 使用公钥验证签名 +func Verify(publicKey ed25519.PublicKey, data, signature []byte) bool { + return ed25519.Verify(publicKey, data, signature) +} + +// SignBase64 签名并返回 Base64 编码 +func SignBase64(privateKey ed25519.PrivateKey, data []byte) string { + sig := Sign(privateKey, data) + return base64.StdEncoding.EncodeToString(sig) +} + +// VerifyBase64 验证 Base64 编码的签名 +func VerifyBase64(publicKey ed25519.PublicKey, data []byte, sigB64 string) error { + sig, err := base64.StdEncoding.DecodeString(sigB64) + if err != nil { + return fmt.Errorf("decode signature: %w", err) + } + if !Verify(publicKey, data, sig) { + return ErrInvalidSignature + } + return nil +} + +// EncodePublicKey 编码公钥为 Base64 +func EncodePublicKey(pub ed25519.PublicKey) string { + return base64.StdEncoding.EncodeToString(pub) +} + +// DecodePublicKey 从 Base64 解码公钥 +func DecodePublicKey(s string) (ed25519.PublicKey, error) { + data, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return nil, err + } + if len(data) != ed25519.PublicKeySize { + return nil, ErrInvalidPublicKey + } + return ed25519.PublicKey(data), nil +} + +// EncodePrivateKey 编码私钥为 Base64 +func EncodePrivateKey(priv ed25519.PrivateKey) string { + return base64.StdEncoding.EncodeToString(priv) +} + +// DecodePrivateKey 从 Base64 解码私钥 +func DecodePrivateKey(s string) (ed25519.PrivateKey, error) { + data, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return nil, err + } + if len(data) != ed25519.PrivateKeySize { + return nil, ErrInvalidPrivateKey + } + return ed25519.PrivateKey(data), nil +} diff --git a/pkg/plugin/sign/version.go b/pkg/plugin/sign/version.go new file mode 100644 index 0000000..ef308d4 --- /dev/null +++ b/pkg/plugin/sign/version.go @@ -0,0 +1,47 @@ +package sign + +import ( + "strconv" + "strings" +) + +// CompareVersions 比较两个版本号 +// 返回: -1 (v1 < v2), 0 (v1 == v2), 1 (v1 > v2) +func CompareVersions(v1, v2 string) int { + parts1 := parseVersion(v1) + parts2 := parseVersion(v2) + + maxLen := len(parts1) + if len(parts2) > maxLen { + maxLen = len(parts2) + } + + for i := 0; i < maxLen; i++ { + var p1, p2 int + if i < len(parts1) { + p1 = parts1[i] + } + if i < len(parts2) { + p2 = parts2[i] + } + + if p1 < p2 { + return -1 + } + if p1 > p2 { + return 1 + } + } + return 0 +} + +func parseVersion(v string) []int { + v = strings.TrimPrefix(v, "v") + parts := strings.Split(v, ".") + result := make([]int, len(parts)) + for i, p := range parts { + n, _ := strconv.Atoi(p) + result[i] = n + } + return result +} diff --git a/pkg/protocol/message.go b/pkg/protocol/message.go index 46ff6d2..eea0c40 100644 --- a/pkg/protocol/message.go +++ b/pkg/protocol/message.go @@ -212,6 +212,7 @@ type ClientPluginConnRequest struct { type JSPluginInstallRequest struct { PluginName string `json:"plugin_name"` // 插件名称 Source string `json:"source"` // JS 源码 + Signature string `json:"signature"` // 官方签名 (Base64) RuleName string `json:"rule_name"` // 规则名称 RemotePort int `json:"remote_port"` // 服务端监听端口 Config map[string]string `json:"config"` // 插件配置