signManifest 和 generateManifest
This commit is contained in:
1
go.mod
1
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user