3、固件签名工具链:OpenSSL命令行签名、Python脚本签名、HASH计算与校验

说到固件签名,很多刚入行的朋友第一反应就是「拿个工具跑一下」。但实际项目里,签名工具链的选型和配置,往往决定了整个可信启动流程的成败。我个人习惯把工具链分成三层:底层密码库、命令行工具、自动化脚本。今天我们就从这三层入手,把 OpenSSL 和 Python 这两把刀磨快。

3.1 核心概念:签名到底在做什么?

先理清一个基本问题:固件签名 ≠ 加密。签名是「用私钥对固件的 HASH 值做非对称运算」,验证方用公钥还原 HASH 并比对。说白了,签名不隐藏固件内容,只保证固件没被篡改。

整个流程可以拆成三步:

  1. 计算固件的 HASH(摘要)
  2. 用私钥对 HASH 签名,生成签名文件
  3. 将签名附加到固件尾部,或单独存放

验证时反过来:提取签名中的 HASH,重新计算固件 HASH,两者一致则通过。

关键点:签名算法通常使用 RSA 或 ECDSA。RSA 兼容性好,但密钥长度大(2048/4096位);ECDSA 签名短、性能高,适合资源受限的嵌入式环境。我建议新项目优先考虑 ECDSA P-256。

3.2 OpenSSL 命令行签名实战

OpenSSL 是嵌入式签名绕不开的工具。虽然它参数多、文档晦涩,但掌握几个核心命令就够了。

3.2.1 生成密钥对

先准备一对 RSA 2048 密钥:

# 生成私钥
openssl genrsa -out private.pem 2048

# 从私钥提取公钥
openssl rsa -in private.pem -pubout -out public.pem

如果是 ECDSA P-256:

# 生成 ECDSA 私钥
openssl ecparam -genkey -name prime256v1 -out ecdsa_private.pem

# 提取公钥
openssl ec -in ecdsa_private.pem -pubout -out ecdsa_public.pem

我的习惯:私钥文件权限一定要设为 600,且不要放在代码仓库里。我曾经见过同事把私钥传到 Git 上,结果整个项目组都得换密钥——那场面,嗯,很尴尬。

3.2.2 计算 HASH 并签名

假设固件文件是 firmware.bin,我们先用 SHA-256 算 HASH,再用私钥签名:

# 计算 HASH(输出十六进制)
openssl dgst -sha256 firmware.bin

# 计算 HASH 并签名,输出 DER 格式签名文件
openssl dgst -sha256 -sign private.pem -out firmware.sig firmware.bin

这里有个细节:dgst -sign 会自动先算 HASH 再签名,一步到位。但有些 bootloader 要求单独传入 HASH 值,那就需要分两步:

# 第一步:计算 HASH 并保存
openssl dgst -sha256 -binary firmware.bin > firmware.hash

# 第二步:对 HASH 文件签名
openssl pkeyutl -sign -inkey private.pem -in firmware.hash -out firmware.sig

3.2.3 验证签名

验证时用公钥:

openssl dgst -sha256 -verify public.pem -signature firmware.sig firmware.bin

输出 Verified OK 表示通过。如果失败,检查密钥是否匹配、固件是否被修改。

避坑指南:我曾经遇到一个 case,签名验证总失败,查了两天才发现是 OpenSSL 版本差异导致的签名格式问题。1.0.x 和 1.1.x 的默认摘要算法不同。建议统一使用 -sha256 显式指定,不要依赖默认值。

3.3 Python 脚本签名:自动化与集成

命令行适合调试,但生产环境需要自动化。Python 的 cryptography 库是首选,它比 pycrypto 更安全、更现代。

3.3.1 安装依赖

pip install cryptography

3.3.2 完整的签名脚本

下面是一个生产级别的签名脚本,支持 RSA 和 ECDSA:

from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa, ec
from cryptography.hazmat.backends import default_backend
import sys

def sign_firmware(firmware_path, private_key_path, sig_path, algorithm='rsa'):
    # 1. 读取固件
    with open(firmware_path, 'rb') as f:
        firmware_data = f.read()

    # 2. 加载私钥
    with open(private_key_path, 'rb') as f:
        private_key = serialization.load_pem_private_key(
            f.read(),
            password=None,
            backend=default_backend()
        )

    # 3. 计算 HASH 并签名
    if algorithm == 'rsa':
        signature = private_key.sign(
            firmware_data,
            padding.PKCS1v15(),
            hashes.SHA256()
        )
    elif algorithm == 'ecdsa':
        signature = private_key.sign(
            firmware_data,
            ec.ECDSA(hashes.SHA256())
        )
    else:
        raise ValueError('Unsupported algorithm')

    # 4. 保存签名
    with open(sig_path, 'wb') as f:
        f.write(signature)

    print(f'Signature saved to {sig_path}')
    print(f'Signature length: {len(signature)} bytes')

if __name__ == '__main__':
    sign_firmware(sys.argv[1], sys.argv[2], sys.argv[3])

3.3.3 HASH 计算与校验函数

单独提取 HASH 计算逻辑,方便集成到 CI/CD 流程:

import hashlib

def compute_firmware_hash(file_path):
    """计算固件 SHA-256 HASH"""
    sha256 = hashlib.sha256()
    with open(file_path, 'rb') as f:
        # 分块读取,避免大固件占用过多内存
        for chunk in iter(lambda: f.read(4096), b''):
            sha256.update(chunk)
    return sha256.hexdigest()

def verify_firmware_hash(file_path, expected_hash):
    """校验固件 HASH"""
    actual_hash = compute_firmware_hash(file_path)
    if actual_hash == expected_hash:
        print('HASH verification PASSED')
        return True
    else:
        print(f'HASH mismatch: expected {expected_hash}, got {actual_hash}')
        return False

个人经验:分块读取这个细节很重要。有一次客户固件有 64MB,一次性读入内存直接 OOM 了。后来改成 4KB 分块,问题解决。嵌入式固件虽然通常不大,但养成好习惯总没错。

3.4 签名格式与固件打包

签名完成后,需要把签名附加到固件中。常见格式有两种:

格式 结构 适用场景
尾部附加 [固件数据] + [签名长度(4字节)] + [签名数据] Bootloader 从固定偏移读取签名
头部嵌入 [签名头] + [固件数据] U-Boot、TF-A 等标准格式

尾部附加的 Python 实现:

def append_signature(firmware_path, sig_path, output_path):
    with open(firmware_path, 'rb') as f:
        fw_data = f.read()
    with open(sig_path, 'rb') as f:
        sig_data = f.read()

    # 签名长度用小端序 4 字节
    sig_len = len(sig_data).to_bytes(4, 'little')

    with open(output_path, 'wb') as f:
        f.write(fw_data)
        f.write(sig_len)
        f.write(sig_data)

    print(f'Output firmware size: {len(fw_data) + 4 + len(sig_data)} bytes')

3.5 工具链对比与选型建议

工具 优点 缺点 推荐场景
OpenSSL 命令行 通用、调试方便 参数复杂、不易集成 原型验证、手动测试
Python + cryptography 可编程、易集成 CI 依赖库版本 生产环境、自动化流水线
硬件 HSM 私钥不出设备、合规 成本高、配置复杂 高安全等级产品

我的建议:开发阶段用 OpenSSL 命令行快速验证,CI/CD 环境用 Python 脚本自动化。如果产品需要通过 FIPS 或国密认证,必须上硬件安全模块(HSM)。

3.6 本章知识体系

下面这张图总结了固件签名工具链的核心流程和工具关系:

固件签名工具链核心流程 固件 binary SHA-256 HASH 私钥签名 签名文件 .sig 签名固件包 工具选择 • OpenSSL 命令行 • Python cryptography • HSM 硬件模块 • 自定义签名工具 验证流程 1. 提取签名 + 固件 2. 公钥解密签名得 HASH 3. 重新计算固件 HASH 4. 比对两个 HASH

3.7 常见问题与避坑

  • 签名长度不一致:RSA 2048 签名固定 256 字节,ECDSA P-256 签名 64-72 字节(DER 编码)。解析时不要硬编码长度。
  • 字节序问题:ARM 平台通常用小端序,x86 也是小端。但有些 bootloader 要求大端,签名打包前确认目标平台。
  • HASH 算法不匹配:签名时用 SHA-256,验证时也必须用 SHA-256。我见过有人签名用 SHA-1,验证用 SHA-256,折腾半天才发现。
  • 密钥管理:私钥绝对不能出现在固件或代码中。生产环境建议用 HSM 或密钥管理服务(KMS)。

重要提醒:签名工具链的测试不能只在开发环境跑。一定要在目标硬件上做完整的签名-烧录-启动验证。我曾经在 QEMU 上测试一切正常,换到真机就翻车——原因是真机的 bootloader 对签名格式有额外要求。