signManifest 和 generateManifest

This commit is contained in:
2025-12-07 16:25:37 +08:00
parent bb17b2e476
commit a7022c4c3f
4 changed files with 178 additions and 0 deletions

1
go.mod
View File

@@ -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

2
go.sum
View File

@@ -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=

View File

@@ -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
}

View File

@@ -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)
}