AI 模型供应链安全防线:镜像里的权重文件要做签名校验
一、一个被忽视的供应链缺口:模型镜像本身的完整性
生产环境的推理镜像构建流程是这样的:从 HuggingFace 下载基础模型权重 → 微调 → 打包进 Docker 镜像 → 推送到私有仓库 → 部署。整个过程没有一个环节验证了权重文件的完整性。
问题不在于 HuggingFace 是否可信。而是任何一个下载环节都可能出问题:
- 代理或 CDN 缓存了损坏或篡改的权重文件
- 开发环境中了恶意依赖,在构建时替换权重
- CI/CD 流水线被入侵,镜像在构建后被篡改
这些不是理论假设。PyTorch 的torch.load默认使用pickle反序列化,这意味着一个恶意权重文件不仅可以给出错误推理结果,还可以执行任意代码。把权重文件当作信任源头而不做任何校验,等于给攻击者开了后门。
推理服务的供应链应该和普通容器镜像一样有完整的签名验证链:权重下载 → SHA256 校验 → 微调后签名 → 镜像打包 → 镜像签名 → 部署前验签。任何一环的哈希不匹配都应该阻断部署。
二、供应链签名架构:从文件哈希到镜像签名
一个完整的模型供应链签名流程覆盖三个层次:模型文件本身、打包后的模型归档、最终镜像。
flowchart TD A[HuggingFace/本地训练] --> B[计算权重文件 SHA256] B --> C[使用 Ed25519 私钥签名] C --> D[签名文件与权重一起打包] D --> E[构建 Docker 镜像] E --> F[Sign: cosign sign 镜像] F --> G[推送至镜像仓库] G --> H[部署前验证] H --> I{三层签名校验} I -->|权重签名无效| R1[阻断部署] I -->|模型归档签名无效| R2[阻断部署] I -->|镜像签名无效| R3[阻断部署] I -->|全部通过| J[允许部署]第一层:权重文件摘要。对每个权重文件计算 SHA256 哈希,生成签名清单。清单文件包含文件名到哈希的映射,然后用私钥对清单签名。
第二层:模型归档签名。将权重文件、分词器配置、模型配置打包成 tar.gz 归档,对归档整体签名。这个层次确保不仅单个文件未被篡改,文件集合的完整性也是完整的。
第三层:镜像签名。使用 cosign 对最终 Docker 镜像签名。镜像签名依赖 OCI 注册中心的签名规范,在推送后和部署前验证。
三层签名的好处是分层验证。如果只需要验证权重文件,不需要拉取完整镜像。每个环节的验证可以独立并行,提高效率。
三、Go 实现的权重签名验证工具
package modelsign import ( "crypto/ed25519" "crypto/sha256" "crypto/x509" "encoding/pem" "fmt" "io" "os" ) // Manifest 权重文件签名清单 type Manifest struct { Files map[string]string `json:"files"` // filename -> sha256 hex // Sig 对 Files 的 JSON 序列化做的 Ed25519 签名 Sig []byte `json:"sig"` } // Verifier 权重签名验证器 type Verifier struct { publicKey ed25519.PublicKey } // NewVerifier 从 PEM 编码的公钥初始化验证器 func NewVerifier(pubKeyPEM []byte) (*Verifier, error) { block, _ := pem.Decode(pubKeyPEM) if block == nil { return nil, fmt.Errorf("解析公钥 PEM 失败") } pub, err := x509.ParsePKIXPublicKey(block.Bytes) if err != nil { return nil, fmt.Errorf("解析公钥失败: %w", err) } edPub, ok := pub.(ed25519.PublicKey) if !ok { return nil, fmt.Errorf("不支持的公钥类型,需要 Ed25519") } return &Verifier{publicKey: edPub}, nil } // VerifyModelDir 验证模型目录中所有权重文件的完整性 func (v *Verifier) VerifyModelDir( modelDir string, manifestFile string, ) error { // 1. 读取并验证清单签名 manifest, err := v.loadAndVerifyManifest(manifestFile) if err != nil { return fmt.Errorf("清单验签失败: %w", err) } // 2. 逐文件验证哈希 for filename, expectedHash := range manifest.Files { filePath := filepath.Join(modelDir, filename) actualHash, err := computeSHA256(filePath) if err != nil { return fmt.Errorf( "计算 %s 哈希失败: %w", filename, err, ) } if actualHash != expectedHash { return &HashMismatchError{ File: filename, Expected: expectedHash, Actual: actualHash, } } } return nil } // loadAndVerifyManifest 验证清单文件的 Ed25519 签名 func (v *Verifier) loadAndVerifyManifest(path string) (*Manifest, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("读取清单失败: %w", err) } var manifest Manifest if err := json.Unmarshal(data, &manifest); err != nil { return nil, fmt.Errorf("解析清单失败: %w", err) } // 重新计算清单内容的序列化并验证签名 payload, _ := json.Marshal(manifest.Files) if !ed25519.Verify(v.publicKey, payload, manifest.Sig) { return nil, fmt.Errorf("清单签名无效——文件可能被篡改") } return &manifest, nil } // computeSHA256 高效计算大文件的 SHA256 哈希 func computeSHA256(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() h := sha256.New() // 32KB 缓冲区,适合大权重文件 if _, err := io.CopyBuffer(h, f, make([]byte, 32*1024)); err != nil { return "", err } return fmt.Sprintf("%x", h.Sum(nil)), nil }代码设计要点:
- 使用 Ed25519 而非 RSA 做签名。Ed25519 的签名验证速度快约 8 倍,对大量小文件的批量验证性能影响更小
io.CopyBuffer使用 32KB 缓冲区,在内存和系统调用次数之间平衡- 签名验证失败时返回明确的错误信息,不区分"文件缺失"和"哈希不匹配"能让问题定位更高效
四、签名验证的性能成本与运维负担
首次加载延迟。对一个包含 50GB 权重文件的模型,全量 SHA256 验证需要 3-8 秒(取决于磁盘 I/O 速度)。这 3-8 秒发生在 Pod 启动阶段,对启动延迟敏感的弹性扩缩场景可能有影响。优化方案是只验证清单签名而非逐文件哈希——前提是信任文件集合整体未被篡改。
签名私钥的管理。三层签名的密钥管理需要一个明确的体系。权重签名的私钥应该离线存储在硬件安全模块(HSM)中,只在模型发布时使用。公钥通过 ConfigMap 注入到推理 Pod,支持热更新。密钥轮换时要保留旧公钥一段时间,以支持旧版本模型的验证。
不适合全量验证的场景:
- 频繁扩缩容的无状态推理服务,3-8 秒的验证延迟会叠加到冷启动时间
- 边车模式加载的 LoRA 适配器,体积小但加载频繁
- 开发环境的快速迭代
折中方案是:生产环境强制全量验证,开发环境只验证归档签名。
五、总结
模型权重不是信任的源头,而是需要验证的输入。三层签名架构提供从文件到镜像的完整验证链:
- 权重文件签名:Ed25519 对每个文件的 SHA256 清单签名
- 归档签名:对打包后的模型归档整体签名
- 镜像签名:cosign 对最终部署的镜像签名
部署前必须通过全部三层验证,任何一层不匹配都应阻断部署。这不是过度设计,是供应链安全的基线要求。