diff --git a/go.mod b/go.mod index 9b33e7c..cc33f5c 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25 require ( github.com/bodgit/sevenzip v1.6.1 github.com/dsnet/compress v0.0.1 + github.com/gibson042/canonicaljson-go v1.0.3 github.com/go-openapi/errors v0.22.2 github.com/go-openapi/runtime v0.28.0 github.com/go-openapi/strfmt v0.23.0 diff --git a/go.sum b/go.sum index 873e95d..87126c9 100644 --- a/go.sum +++ b/go.sum @@ -67,6 +67,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gibson042/canonicaljson-go v1.0.3 h1:EAyF8L74AWabkyUmrvEFHEt/AGFQeD6RfwbAuf0j1bI= +github.com/gibson042/canonicaljson-go v1.0.3/go.mod h1:DsLpJTThXyGNO+KZlI85C1/KDcImpP67k/RKVjcaEqo= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= diff --git a/internal/domain/device/ota_service.go b/internal/domain/device/ota_service.go index 7730fb3..10b3281 100644 --- a/internal/domain/device/ota_service.go +++ b/internal/domain/device/ota_service.go @@ -2,16 +2,60 @@ package device import ( "context" + "crypto/ecdsa" + "crypto/md5" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" "fmt" + "io/fs" + "os" "path/filepath" + "strings" "time" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" "git.huangwc.com/pig/pig-farm-controller/internal/infra/models" "git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" "git.huangwc.com/pig/pig-farm-controller/internal/infra/utils/file" + + "github.com/gibson042/canonicaljson-go" ) +// Manifest 代表 OTA 升级的清单文件 (manifest.json) 的结构。 +// 它包含了固件的元数据和所有待更新文件的详细信息。 +type Manifest struct { + Version string `json:"version"` // 新固件的版本号 + Files []ManifestFile `json:"files"` // 待更新的文件列表 + + // Signature 是对 Manifest 内容(不含 Signature 字段本身)的数字签名。 + // + // **签名生成流程 (平台侧)**: + // 1. 将此结构体的 Signature 字段设置为空字符串 ""。 + // 2. 使用确定性 JSON 库 (如 canonicaljson) 将结构体序列化。 + // 3. 对序列化后的字节流计算 SHA-256 哈希。 + // 4. 使用平台私钥对哈希进行签名。 + // 5. 将签名结果进行 Base64 编码后,填充到此字段。 + // + // **签名校验流程 (设备侧)**: + // 1. 从接收到的 manifest.json 中解析出 Manifest 结构。 + // 2. 暂存 Signature 字段的值。 + // 3. **关键:将结构体中的 Signature 字段置为空字符串 "" (而不是移除该字段)。** + // 4. 使用确定性 JSON 规则(如 Python 的 json.dumps(sort_keys=True))将修改后的结构体序列化。 + // 5. 对序列化后的字节流计算 SHA-256 哈希。 + // 6. 使用平台公钥、暂存的签名和计算出的哈希进行验签。 + Signature string `json:"signature"` +} + +// ManifestFile 定义了清单文件中单个文件的元数据。 +type ManifestFile struct { + Path string `json:"path"` // 文件在设备上的目标绝对路径 + MD5 string `json:"md5"` // 文件的 MD5 校验和 + Size int64 `json:"size"` // 文件的大小(字节) +} + // otaServiceImpl 是 OtaService 接口的实现。 type otaServiceImpl struct { ctx context.Context @@ -73,3 +117,117 @@ func (o *otaServiceImpl) StopUpgrade(ctx context.Context, taskID uint32) error { logger.Infof("OTA 任务 %d 已被成功标记为手动停止", taskID) return nil } + +// generateManifest 遍历指定的固件包子目录,生成一个完整的 Manifest 对象。 +func (o *otaServiceImpl) generateManifest(packageSubDir string) (*Manifest, error) { + // 1. 读取版本文件 + versionBytes, err := file.ReadTempFile(packageSubDir, "version") + if err != nil { + return nil, fmt.Errorf("读取 version 文件失败: %w", err) + } + version := strings.TrimSpace(string(versionBytes)) + + var files []ManifestFile + + // 2. 使用 WalkTempDir 遍历 + err = file.WalkTempDir(packageSubDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.Name() == "version" { + return nil + } + + // 3. 获取逻辑相对路径 + relPath, err := file.GetRelativePathInTemp(path, packageSubDir) + if err != nil { + return fmt.Errorf("无法计算相对路径 '%s': %w", path, err) + } + + // 业务转换: 转换为设备端路径 + devicePath := filepath.ToSlash(relPath) + + // 跳过目录和忽略config目录下的所有文件 + if d.IsDir() { + if devicePath == "config" { + return fs.SkipDir + } + return nil + } + + // 4. 读取文件内容用于计算 (直接使用绝对路径,最高效) + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("读取文件失败 '%s': %w", path, err) + } + + // 计算元数据 + md5Sum := fmt.Sprintf("%x", md5.Sum(data)) + + // 5. 添加到列表 + files = append(files, ManifestFile{ + Path: devicePath, + MD5: md5Sum, + Size: int64(len(data)), + }) + return nil + }) + + if err != nil { + return nil, fmt.Errorf("生成清单时遍历目录失败: %w", err) + } + + // 6. 创建 Manifest 对象 + manifest := &Manifest{ + Version: version, + Files: files, + } + return manifest, nil +} + +// --- 数字签名常量 --- +// TODO:在生产环境中,强烈建议使用更安全的方式(如环境变量或密钥管理服务)来管理私钥。 +const pemEncodedPrivateKey = `-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIFDRC/3W22Fw1M/v36w8kO/n8a9A8sUnY2zD1bCgR6eBoAoGCCqGSM49 +AwEHoUQDQgAEWbV3aG6g6Fv5a3p4Y5N5a2b3aG6g6Fv5a3p4Y5N5a2b3aG6g6Fv5 +a3p4Y5N5a2b3aG6g6Fv5a3p4Y5N5a2Y= +-----END EC PRIVATE KEY-----` + +// signManifest 使用硬编码的 ECDSA 私钥对 manifest 进行签名。 +// 它遵循确定性 JSON 规范,以确保平台和设备之间可以生成完全一致的待签名数据。 +func (o *otaServiceImpl) signManifest(manifest *Manifest) error { + // 1. 加载私钥 + block, _ := pem.Decode([]byte(pemEncodedPrivateKey)) + if block == nil { + return fmt.Errorf("无法解码 PEM 格式的私钥") + } + privateKey, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return fmt.Errorf("无法解析 ECDSA 私钥: %w", err) + } + + // 2. 关键:将 Signature 字段置为空字符串,以准备用于签名的“纯净”数据。 + manifest.Signature = "" + + // 3. 使用 canonicaljson 库将“纯净”数据序列化为确定性的字节流。 + // 这确保了无论执行多少次,只要内容不变,生成的字节流就完全一样。 + signableData, err := canonicaljson.Marshal(manifest) + if err != nil { + return fmt.Errorf("无法将 manifest 序列化为确定性 JSON: %w", err) + } + + // 4. 对确定性的字节流进行哈希 + hash := sha256.Sum256(signableData) + + // 5. 使用私钥对哈希进行签名 + signatureBytes, err := ecdsa.SignASN1(rand.Reader, privateKey, hash[:]) + if err != nil { + return fmt.Errorf("无法对哈希进行签名: %w", err) + } + + // 6. 将签名结果进行 Base64 编码后,填充回 manifest 对象。 + // 此时 manifest 对象已包含所有信息,可以被序列化并写入最终的 manifest.json 文件。 + manifest.Signature = base64.StdEncoding.EncodeToString(signatureBytes) + + return nil +} diff --git a/internal/infra/utils/file/file.go b/internal/infra/utils/file/file.go index 71eb70c..2875b8e 100644 --- a/internal/infra/utils/file/file.go +++ b/internal/infra/utils/file/file.go @@ -2,6 +2,7 @@ package file import ( "fmt" + "io/fs" "os" "path/filepath" "sync" @@ -131,3 +132,19 @@ func ReadTempFile(subDir, fileName string) ([]byte, error) { } return data, nil } + +// WalkTempDir 遍历指定的临时子目录。 +// 它会自动处理根路径的拼接,并调用标准库的 filepath.WalkDir。 +// subDir: 要遍历的子目录。 +// fn: 应用于每个文件和目录的回调函数。 +func WalkTempDir(subDir string, fn fs.WalkDirFunc) error { + root := filepath.Join(instance.tempRoot, subDir) + return filepath.WalkDir(root, fn) +} + +// GetRelativePathInTemp 将一个在临时目录中的绝对路径,转换为相对于指定的子目录的相对路径。 +// 例如:absolutePath="C:\tmp\ota\123\lib\a.py", subDir="ota/123" -> 返回 "lib\a.py" +func GetRelativePathInTemp(absolutePath string, subDir string) (string, error) { + basePath := filepath.Join(instance.tempRoot, subDir) + return filepath.Rel(basePath, absolutePath) +}