4. ONNX模型签名与验签:数字签名基础(RSA/ECDSA)

模型部署到生产环境后,最怕什么?

我个人最怕的是模型被人偷偷换了。你想想看,辛辛苦苦训练了几个月的模型,推理效果刚刚的,结果被人篡改了一下,输出全乱套了。更可怕的是,如果模型被植入了后门,那后果不堪设想。

所以这一章,我们来聊聊怎么给ONNX模型加上「数字签名」。说白了,就是给模型盖个章,证明它是你发布的、没有被改过。

4.1 数字签名是个啥?

数字签名不是加密。加密是为了不让别人看,签名是为了证明「这是我写的,而且没被改过」。我经常跟团队新人说:加密是锁门,签名是盖章

数字签名有两个核心作用:

  • 身份认证:证明这个模型确实是你签发的
  • 完整性校验:证明模型没有被篡改过

嗯,这里要注意:签名本身不保护模型内容的机密性。如果你既要保密又要防篡改,那就得先加密再签名,或者用签密方案。

4.2 RSA vs ECDSA:选哪个?

目前主流的签名算法就两个:RSA和ECDSA。我在项目中两种都用过,说说我的感受。

特性 RSA ECDSA
密钥长度 2048位起步 256位就够用
签名速度 较慢
验签速度 较快
安全性 成熟可靠 同等安全强度下密钥更短
兼容性 几乎所有平台都支持 部分老旧平台不支持

我个人习惯:如果是边缘设备部署,优先选ECDSA。因为密钥短、签名快,对资源受限的设备更友好。如果是云端部署,RSA也没问题,反正服务器算力充足。

我的建议: 如果你刚开始做模型签名,先用RSA 2048位起步。等流程跑通了,再考虑切换到ECDSA。别一上来就搞太复杂的东西。

4.3 对ONNX模型进行签名

签名流程其实不复杂。核心思路是:对模型的二进制内容做哈希,然后用私钥对哈希值签名

具体怎么做?我直接上代码。

import hashlib
import onnx
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import serialization

# 1. 生成RSA密钥对
private_key = rsa.generate_private_key(
    public_exponent=65537,
    key_size=2048,
)

# 2. 加载ONNX模型
model = onnx.load("model.onnx")
model_bytes = model.SerializeToString()

# 3. 计算模型哈希
model_hash = hashlib.sha256(model_bytes).digest()

# 4. 用私钥签名
signature = private_key.sign(
    model_hash,
    padding.PKCS1v15(),
    hashes.SHA256()
)

# 5. 将签名嵌入模型(作为元数据)
meta = model.metadata_props.add()
meta.key = "signature"
meta.value = signature.hex()

# 6. 保存签名后的模型
onnx.save(model, "model_signed.onnx")

这里有个细节:签名要放在模型的metadata里。ONNX的metadata是专门用来存这些附加信息的,不会影响模型的计算图。

注意: 签名时一定要对模型的原始二进制做哈希,而不是对加载后的对象。因为不同版本的ONNX库,序列化结果可能不一样。我曾经踩过这个坑,签名验签对不上,排查了半天才发现是序列化方式不同。

4.4 签名验证流程

验签是签名的逆过程。部署端拿到模型后,需要做以下几步:

  1. 从模型metadata中提取签名
  2. 重新计算模型的哈希值
  3. 用公钥验证签名是否匹配

代码实现如下:

import onnx
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding

def verify_model_signature(model_path, public_key):
    # 1. 加载模型
    model = onnx.load(model_path)
    model_bytes = model.SerializeToString()
    
    # 2. 提取签名
    signature_hex = None
    for prop in model.metadata_props:
        if prop.key == "signature":
            signature_hex = prop.value
            break
    
    if signature_hex is None:
        raise ValueError("模型没有签名!")
    
    # 3. 计算哈希
    model_hash = hashlib.sha256(model_bytes).digest()
    
    # 4. 验签
    try:
        public_key.verify(
            bytes.fromhex(signature_hex),
            model_hash,
            padding.PKCS1v15(),
            hashes.SHA256()
        )
        return True
    except Exception as e:
        print(f"验签失败:{e}")
        return False

为什么验签要放在部署端?因为部署环境是模型被攻击的最后一道防线。模型在传输过程中、存储过程中都可能被篡改,只有在加载到内存之前做验签,才能确保模型是安全的。

4.5 防止模型被篡改

签名能防篡改,但前提是公钥是安全的。如果攻击者替换了你的公钥,那他就可以用自己的私钥签名一个恶意模型,然后你的验签流程照样能通过。

所以,公钥的存储和分发才是关键。我见过几种做法:

  • 硬编码在代码里:简单粗暴,但代码被反编译就完了
  • 放在安全芯片里:硬件级保护,成本高
  • 从远程服务器获取:需要网络,且服务器本身要可信
  • 集成在部署框架中:比如ONNX Runtime的custom operator

我个人比较推荐的做法是:公钥放在部署框架的配置文件中,并对配置文件本身做完整性校验。这样既灵活又相对安全。

核心要点总结:

  • 数字签名 ≠ 加密,签名是为了防篡改和身份认证
  • RSA成熟稳定,ECDSA适合资源受限场景
  • 签名要基于模型的原始二进制哈希
  • 验签必须在模型加载到内存之前完成
  • 公钥的安全存储比签名算法本身更重要

嗯,这一章的内容就这些。下一章我们会聊更高级的话题——ONNX模型的加密部署。到时候会讲到怎么把整个模型加密,运行时再解密加载。感兴趣的话,我们继续往下走。