// integrity.go - Plugin 目录完整性校验 (SHA-256 sidecar 模型). // // 模块定位: // 在 plugin 加载时比对"目录当前内容的哈希"与"plugin.checksum 声明的哈希", // 防止本地 plugin 目录被其他进程/同步工具篡改. 这不是密码学签名 (不能防 // 伪造), 只是"篡改告警"层. // // # 设计背景 (2026-04-14) // // 原 scaffolding (commit 58c3ab5) 预设 manifest 字段 SecurityMetadata 作为 // checksum/signature 的描述性元数据. 但 checksum 放在 plugin.json 里会产生 // 循环依赖 (plugin.json 的哈希依赖自己的 checksum 字段), 解决循环的方案 // (canonical JSON / 字段留空再计算) 都有副作用. 改为 sidecar 文件模型后, // plugin.json 本身也会被纳入哈希计算, 不再是自引用. // // # 威胁模型 (SHA-256 完整性校验能防什么 / 不能防什么) // // **能防**: // - 其他本地进程篡改 plugin.json / skills/*.md / hooks.json // - Dropbox / iCloud 等同步工具造成的内容损坏 // - 开发者误改后忘记同步 checksum 文件 (能立刻被发现) // // **不能防**: // - 攻击者同时替换 plugin 目录 + plugin.checksum 文件 (伪造) // - Flyto 二进制本身被替换 // - plugin.json 里声明的外部 command 指向的文件被篡改 (OS 信任层) // // 如果需要防伪造, 未来扩展一个 Ed25519Verifier / SshsigVerifier, 用开发者 // 公钥签 plugin.checksum 文件 (然后 plugin.sig 作为另一个 sidecar 文件). // 届时 SignatureVerifier interface 不用改, 只是多一个实现. // // # 哈希算法 (确定性 + 跨平台 + 零外部依赖) // // 1. filepath.WalkDir(pluginDir) 收集所有普通文件的相对路径 P // 2. 从 P 中排除 "plugin.checksum" 本身 (避免自引用) // 3. 按字典序 (strings) 排序 P // 4. 初始化 sha256.New() hasher h // 5. 对每个 rel ∈ 排序后的 P: // h.Write( rel + "\n" ) // 路径分隔, 加 \n 保证无歧义边界 // h.Write( big-endian uint64 size ) // 8 字节文件长度前缀 // h.Write( file content, streaming ) // io.Copy, 大文件不 OOM // 6. 输出 hex.EncodeToString(h.Sum(nil)) (64 字符) // // **精妙之处** (CLEVER): 写入 (relpath + length + content) 而非只写 content. // 防止"边界攻击" - 两个不同文件集合的原始拼接流如果相同 (e.g. foo="AB" 和 // bar="" vs foo="A" 和 bar="B"), 不加长度/分隔符会哈希碰撞. 加上路径分隔 // 和长度前缀后, 任何重新切分都会改变 hasher 输入. // // 替代方案: <对每个文件独立 SHA-256 然后把 digest 们再 SHA-256> - 虽然也 // 能防边界攻击, 但多一层 hashing 没有额外密码学优势且状态更多, 否决. // // **跨平台**: relpath 使用 filepath.ToSlash 转成 / 分隔符写入 hasher, 这样 // Windows 下产生的 checksum 和 Unix 下一致. // // # 升华改进 (ELEVATED) // // ComputeChecksum 和 WriteChecksumFile 作为公开 SDK, 让第三方打包器 / CI // 管线 / 未来的 `flyto plugin checksum` CLI 能生成合法的 plugin.checksum 文件. // 签名生成和校验共用同一份底层算法 (computeDirChecksum), 保证生成器和校 // 验器永远一致, 不会出现"生成器和校验器算出的结果不同"这种静默 bug. // // 替代方案: <把 ComputeChecksum 作为 internal/unexport> - 否决: 强迫工具 // 方自己手搓 sha256 管线 = UX 破产, 也不能保证算法一致. package plugin import ( "crypto/sha256" "encoding/binary" "encoding/hex" "errors" "fmt" "io" "os" "path/filepath" "sort" "strings" ) // ChecksumFileName 是 plugin 目录下完整性校验 sidecar 文件的约定名称. // 文件存在 → plugin 启用完整性校验; 不存在 → SHA256IntegrityVerifier 宽松放行. const ChecksumFileName = "plugin.checksum" // ChecksumPrefix 是当前唯一支持的 checksum 算法前缀. // 格式: "sha256:" + 64 hex chars. // 未来引入 sha512/blake3 时可扩展 readChecksumFile 同时接受多种前缀. const ChecksumPrefix = "sha256:" // ErrChecksumMissing 表示 plugin 目录缺少 plugin.checksum 文件. // 通常不会直接暴露给调用方 (SHA256IntegrityVerifier 宽松模式下吞掉这个错误 // 返回 nil), 只有 RejectUnsignedVerifier 会把它升级成 ErrInvalidSignature. var ErrChecksumMissing = errors.New("plugin.checksum file not found") // SHA256IntegrityVerifier 是基于 plugin.checksum sidecar 文件的完整性验证器. // // 行为 (宽松模式): // - plugin.checksum 不存在: 返回 nil (不校验, 向后兼容已有 plugin) // - plugin.checksum 存在: 计算目录哈希并比对, 不一致返回 ErrInvalidSignature // // 严格模式通过 RejectUnsignedVerifier 包装获得 (拒绝无 checksum 的 plugin). // // 精妙之处 (CLEVER): 这是一个空 struct, 无状态, 天然线程安全. LoadAll // 并发加载多个 plugin 时共用一个 verifier 实例无竞态. type SHA256IntegrityVerifier struct{} // NewSHA256IntegrityVerifier 创建一个 SHA-256 完整性验证器. // // 使用示例: // // v := plugin.NewSHA256IntegrityVerifier() // p, err := plugin.LoadPluginWithVerifier(dir, v) func NewSHA256IntegrityVerifier() SignatureVerifier { return SHA256IntegrityVerifier{} } // Verify 实现 SignatureVerifier 接口. // // manifest 参数当前未使用 (plugin.checksum 是独立 sidecar, 与 manifest 解耦), // 保留是为了未来扩展: Ed25519Verifier 可能需要读 manifest.Name 做错误上下文 // 或根据 manifest 字段做策略差异. func (SHA256IntegrityVerifier) Verify(pluginDir string, manifest *Manifest) error { expected, err := readChecksumFile(pluginDir) if err != nil { if errors.Is(err, ErrChecksumMissing) { // 宽松模式: 没有 checksum 文件就放行, 保持向后兼容 return nil } return err } actual, err := computeDirChecksum(pluginDir) if err != nil { return fmt.Errorf("compute plugin checksum: %w", err) } if actual != expected { return fmt.Errorf("%w: plugin.checksum mismatch (expected sha256:%s, got sha256:%s)", ErrInvalidSignature, expected, actual) } return nil } // RejectUnsignedVerifier 是严格模式装饰器: 拒绝加载没有 plugin.checksum 的 // 目录, 有 checksum 的则 delegate 给 Inner 做实际验证. // // 使用示例 (企业合规场景): // // strict := plugin.RejectUnsignedVerifier{ // Inner: plugin.NewSHA256IntegrityVerifier(), // } // p, err := plugin.LoadPluginWithVerifier(dir, strict) // // Inner 为 nil 时默认使用 SHA256IntegrityVerifier{} 作为底层验证器. // // 精妙之处 (CLEVER): 用 decorator 模式而不是在 SHA256IntegrityVerifier 里 // 开一个 StrictMode bool 字段, 因为策略组合能力更强 - 未来可以用 // RejectUnsignedVerifier 包装 Ed25519Verifier / Pkcs7Verifier 等任意实现, // 而不用每个 verifier 都重复实现严格/宽松切换. type RejectUnsignedVerifier struct { // Inner 是实际执行验证的 verifier. 通常是 NewSHA256IntegrityVerifier(). // 为 nil 时默认使用 SHA256IntegrityVerifier{}. Inner SignatureVerifier } // Verify 实现 SignatureVerifier 接口. func (r RejectUnsignedVerifier) Verify(pluginDir string, manifest *Manifest) error { checksumPath := filepath.Join(pluginDir, ChecksumFileName) if _, err := os.Stat(checksumPath); err != nil { if os.IsNotExist(err) { name := "" if manifest != nil { name = manifest.Name } return fmt.Errorf("%w: plugin %q has no %s (strict mode rejects unsigned plugins)", ErrInvalidSignature, name, ChecksumFileName) } return fmt.Errorf("stat %s: %w", ChecksumFileName, err) } inner := r.Inner if inner == nil { inner = SHA256IntegrityVerifier{} } return inner.Verify(pluginDir, manifest) } // ComputeChecksum 计算 plugin 目录的完整性哈希 (公开 SDK, 供工具生成器使用). // // 返回格式: "sha256:<64 hex chars>" // // 使用场景: // - 打包工具在发布 plugin 前计算并写入 plugin.checksum // - CI 管线在 build 后自动生成 checksum // - `flyto plugin checksum ` CLI 命令 (未来) // // 本函数和 SHA256IntegrityVerifier.Verify 共用同一份内部算法 computeDirChecksum, // 保证生成器和校验器的结果永远一致. func ComputeChecksum(pluginDir string) (string, error) { hexDigest, err := computeDirChecksum(pluginDir) if err != nil { return "", err } return ChecksumPrefix + hexDigest, nil } // WriteChecksumFile 计算 plugin 目录哈希并写入 pluginDir/plugin.checksum. // // 幂等: 已存在的 checksum 文件被覆盖, 且 computeDirChecksum 会自动排除 // plugin.checksum 自身, 所以多次调用都会产生相同内容 (只要目录内容没变). // // 返回写入的完整 checksum 字符串 (含 "sha256:" 前缀), 供调用方打 log 或回显. func WriteChecksumFile(pluginDir string) (string, error) { checksum, err := ComputeChecksum(pluginDir) if err != nil { return "", err } path := filepath.Join(pluginDir, ChecksumFileName) // 尾部加 \n: POSIX 文本文件惯例 + 方便 cat 查看不串行 if err := os.WriteFile(path, []byte(checksum+"\n"), 0o644); err != nil { return "", fmt.Errorf("write %s: %w", path, err) } return checksum, nil } // readChecksumFile 读取 plugin.checksum 并返回期望的 hex 字符串 (去前缀). // 文件不存在时返回 ErrChecksumMissing. // 格式错误时返回 wrap ErrInvalidSignature 的 error. func readChecksumFile(pluginDir string) (string, error) { path := filepath.Join(pluginDir, ChecksumFileName) data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return "", ErrChecksumMissing } return "", fmt.Errorf("read %s: %w", path, err) } content := strings.TrimSpace(string(data)) if !strings.HasPrefix(content, ChecksumPrefix) { return "", fmt.Errorf("%w: %s has unknown algorithm prefix (expected %q, got %q)", ErrInvalidSignature, ChecksumFileName, ChecksumPrefix, content) } hexDigest := strings.TrimPrefix(content, ChecksumPrefix) if len(hexDigest) != 64 { return "", fmt.Errorf("%w: %s has invalid SHA-256 hex length (expected 64, got %d)", ErrInvalidSignature, ChecksumFileName, len(hexDigest)) } // 校验 hex 字符合法性 (防止 "sha256:XXXXXX..." 这种垃圾值过校验) if _, err := hex.DecodeString(hexDigest); err != nil { return "", fmt.Errorf("%w: %s contains non-hex characters: %v", ErrInvalidSignature, ChecksumFileName, err) } return hexDigest, nil } // computeDirChecksum 是 ComputeChecksum 和 SHA256IntegrityVerifier 的共用内部实现. // 返回不含 "sha256:" 前缀的 64 字符 hex 字符串. func computeDirChecksum(pluginDir string) (string, error) { // 第一步: 收集所有普通文件的相对路径 (排除 plugin.checksum 自己, 排除 symlink) var relPaths []string err := filepath.WalkDir(pluginDir, func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if d.IsDir() { return nil } // 保守策略: 拒绝 symlink (防止目录外的内容被纳入哈希, 以及目录外篡改 // 路径逃逸). 替代方案是 follow symlink, 但会让哈希行为依赖目标文件 // 的存在性, 跨机器复制时易破坏. if d.Type()&os.ModeSymlink != 0 { return nil } rel, err := filepath.Rel(pluginDir, path) if err != nil { return fmt.Errorf("rel path %s: %w", path, err) } // 跳过 plugin.checksum 自己 (避免自引用循环) if rel == ChecksumFileName { return nil } // 规范化路径分隔符为 / , 保证跨平台结果一致 relPaths = append(relPaths, filepath.ToSlash(rel)) return nil }) if err != nil { return "", fmt.Errorf("walk plugin dir: %w", err) } // 第二步: 字典序排序 (确定性) sort.Strings(relPaths) // 第三步: 按顺序写入 hasher h := sha256.New() for _, rel := range relPaths { // 相对路径 + 换行符作为段分隔 h.Write([]byte(rel + "\n")) // 还原为 OS 路径打开文件 absPath := filepath.Join(pluginDir, filepath.FromSlash(rel)) info, err := os.Stat(absPath) if err != nil { return "", fmt.Errorf("stat %s: %w", rel, err) } // 8 字节大端长度前缀 var lenBuf [8]byte binary.BigEndian.PutUint64(lenBuf[:], uint64(info.Size())) h.Write(lenBuf[:]) // 文件内容 (流式 io.Copy, 大文件不 OOM) f, err := os.Open(absPath) if err != nil { return "", fmt.Errorf("open %s: %w", rel, err) } _, copyErr := io.Copy(h, f) closeErr := f.Close() if copyErr != nil { return "", fmt.Errorf("read %s: %w", rel, copyErr) } if closeErr != nil { return "", fmt.Errorf("close %s: %w", rel, closeErr) } } return hex.EncodeToString(h.Sum(nil)), nil }