RSA-PSS

RSA-PSS

简介

PKCS #1 是 RSA 密码算法相关的规范,RFC 8017 对应 PKCS #1 v2.2 。(RFC 8017)

本文整理 RSASSA-PSS 签名方案,也就是常说的:

text 复制代码
RSA + PSS Signature
RSASSA-PSS

它和 RSASSA-PKCS1-v1_5 都是 RSA 签名方案,但编码方式完全不同。RSASSA-PSSRSASP1 / RSAVP1 这两个 RSA 原语和 EMSA-PSS 编码方法组合起来使用。(RFC 8017 §8.1)

前置阅读:


PSS 的核心思想

RSA 签名不能直接对原始消息 M 做私钥运算,而是先通过 EMSA-PSS 把消息编码成固定长度的 EM,再执行 RSA 运算。

整体流程可以理解为:

text 复制代码
      __________________________________________________________________

                                     +-----------+
                                     |     M     |
                                     +-----------+
                                           |
                                           V
                                         Hash
                                           |
                                           V
                             +--------+----------+----------+
                        M' = |Padding1|  mHash   |   salt   |
                             +--------+----------+----------+
                                            |
                  +--------+----------+     V
            DB =  |Padding2|   salt   |   Hash
                  +--------+----------+     |
                            |               |
                            V               |
                           xor <--- MGF <---|
                            |               |
                            |               |
                            V               V
                  +-------------------+----------+--+
            EM =  |    maskedDB       |     H    |bc|
                  +-------------------+----------+--+
      __________________________________________________________________

与 PKCS1-v1_5 最大的区别:

对比项 PKCS1-v1_5 RSA-PSS
填充结构 00 01 FF...FF 00 DigestInfo `maskedDB
是否含算法标识 有(DigestInfo DER) 无,Hash 算法由协议约定
随机性 确定性签名 含随机 salt,同消息每次签名不同
RFC 8017 建议 兼容老系统 新项目优先推荐

PSS 的核心编码结构:

text 复制代码
EM = maskedDB || H || 0xBC

其中:

text 复制代码
maskedDB = DB xor MGF1(H, emLen - hLen - 1)
DB       = PS || 0x01 || salt
PS       = 若干个 0x00
H        = Hash(0x00 * 8 || mHash || salt)
mHash    = Hash(M)

术语说明

符号 含义
M Message,待签名消息
mHash Hash(M),消息摘要
S Signature,签名结果
n RSA modulus,模数
e RSA public exponent,公钥指数
d RSA private exponent,私钥指数
k RSA 模数 n 的字节长度
hLen Hash 输出长度,SHA-256 为 32 字节
sLen salt 长度,常取 32 或 hLen
salt 签名时随机生成的盐值
H `Hash(0x00*8
DB Data block,数据块
maskedDB 掩码后的 DB
MGF1 Mask Generation Function 1
EM Encoded Message,编码后的消息
emLen EM 的字节长度,通常等于 k
emBits modBits - 1,编码有效位数
OS2IP 字节串转整数
I2OSP 整数转字节串
RSASP1 RSA 签名原语
RSAVP1 RSA 验签原语

EMSA-PSS 编码

编码过程见 RFC 8017 §9.1.1 EMSA-PSS-ENCODE

text 复制代码
输入:
  M        待编码消息
  emBits   编码位数,RSASSA-PSS 中取 modBits - 1
  Hash     Hash 函数,如 SHA-256
  MGF      掩码生成函数,如 MGF1
  sLen     salt 长度

步骤:

1. mHash = Hash(M)

2. 生成长度为 sLen 的随机 salt

3. 构造:
   M' = 0x00 * 8 || mHash || salt

4. 计算:
   H = Hash(M')

5. 构造 PS:
   PS = 0x00 * (emLen - sLen - hLen - 2)

6. 拼接:
   DB = PS || 0x01 || salt

7. 计算掩码:
   dbMask = MGF(H, emLen - hLen - 1)

8. 计算:
   maskedDB = DB xor dbMask

9. 设置 EM 最高有效位为 0(编码约束)

10. 输出:
    EM = maskedDB || H || 0xBC

RSA-2048 + SHA-256 + saltLen=32 为例:

text 复制代码
k      = 256 字节
hLen   = 32 字节
sLen   = 32 字节
emLen  = 256 字节
emBits = 2047

maskedDB 长度 = emLen - hLen - 1 = 223 字节
PS 长度      = emLen - sLen - hLen - 2 = 190 字节

DB 结构:

text 复制代码
DB = [190 字节 PS] || 0x01 || [32 字节 salt]

实际解码时,DB 第一个字节可能是 0x80 而不是 0x00,这是 RSA-2048 编码时 最高位必须为 0 的约束导致的,属于正常现象。后面仍然是 0x00...0x01||salt 结构。

最终 EM 结构:

text 复制代码
EM = [223 字节 maskedDB] || [32 字节 H] || 0xBC

MGF1 掩码生成

PSS 使用 MGF1 从种子 H 派生掩码,定义见 RFC 8017 §B.2.1

text 复制代码
MGF1(seed, maskLen):

  T = 空
  counter = 0

  while len(T) < maskLen:
      T = T || Hash(seed || I2OSP(counter, 4))
      counter = counter + 1

  return T 的前 maskLen 字节

以 SHA-256 为例:

text 复制代码
dbMask = SHA256(H || 00000000)
      || SHA256(H || 00000001)
      || SHA256(H || 00000002)
      || ...

验签时从签名恢复 EM,取出 H,再重新计算 dbMask,即可还原 DB

text 复制代码
DB = maskedDB xor dbMask

签名流程

签名函数可以表示为:

text 复制代码
RSASSA-PSS-SIGN(K, M)

输入:

text 复制代码
K = RSA 私钥
M = 待签名消息

输出:

text 复制代码
S = 签名,长度为 k 字节

签名步骤:

text 复制代码
1. 对消息 M 做 EMSA-PSS 编码:

   EM = EMSA-PSS-ENCODE(M, emBits, ...)

2. 把 EM 转换成整数:

   m = OS2IP(EM)

3. 使用 RSA 私钥做签名运算:

   s = RSASP1(K, m)

   普通形式:

   s = m^d mod n

4. 把整数签名 s 转换成固定长度字节串:

   S = I2OSP(s, k)

5. 输出签名 S

验签流程

验签函数可以表示为:

text 复制代码
RSASSA-PSS-VERIFY((n, e), M, S)

输入:

text 复制代码
(n, e) = RSA 公钥
M      = 原始消息
S      = 待验证签名

输出:

text 复制代码
valid signature
invalid signature

验签步骤:

text 复制代码
1. 检查签名长度:
   len(S) == k

2. 把签名 S 转换成整数:
   s = OS2IP(S)

3. 使用 RSA 公钥恢复编码消息:
   m = RSAVP1((n, e), s)
   普通形式:
   m = s^e mod n

4. 把整数 m 转回编码消息:
   EM = I2OSP(m, k)

5. 检查 EM 尾部:
   EM 最后一个字节必须是 0xBC

6. 拆分 EM:
   maskedDB = EM[0 : emLen - hLen - 1]
   H        = EM[emLen - hLen - 1 : emLen - 1]

7. 还原 DB:
   dbMask = MGF(H, emLen - hLen - 1)
   DB     = maskedDB xor dbMask

8. 从 DB 提取 salt:
   DB = PS || 0x01 || salt

9. 重新计算消息摘要:
   mHash = Hash(M)
   M'    = 0x00 * 8 || mHash || salt
   H'    = Hash(M')

10. 比较:
    如果 H == H',验签成功。
    否则,验签失败。

也可以用编码比较方式:

text 复制代码
EM' = EMSA-PSS-ENCODE(M, emBits, ...)

但验签时 salt 来自签名本身,所以实际实现通常走上面的 H 比较路径。

Python 示例

PyCryptodome 示例

需要安装:

bash 复制代码
pip install pycryptodome

示例代码:

python 复制代码
from binascii import hexlify

from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA
from Crypto.Signature import pss

# 生成 RSA 密钥
key = RSA.generate(2048)

private_key = key
public_key = key.publickey()

print("Public Key:")
print(key.publickey().export_key().decode())

message = b"hello rsa-pss"

# 计算 Hash
print("Hash:")
h = SHA256.new(message)
print(h.hexdigest())

# 签名
signature = pss.new(private_key).sign(h)

print("Signature:")
print(hexlify(signature).decode())

# 验签
try:
    pss.new(public_key).verify(h, signature)
    print("Verify: valid signature")
except (ValueError, TypeError):
    print("Verify: invalid signature")

说明:

  • pss.new(private_key).sign(h) 中的 h 是已经计算好的 SHA256 摘要对象。
  • pss.new(public_key).verify(h, signature) 没有布尔返回值 ;验签成功时正常返回 None,失败时抛出 ValueErrorTypeError

结果如下:

!rsa_pss_python_resultrsa_pss_python_result

工具验证结果:

!rsa_pss_tool_resultrsa_pss_tool_result


OpenSSL 示例

1. 生成密钥

生成私钥:

bash 复制代码
openssl genrsa -out rsa_private.pem 2048

从私钥导出公钥(必须与签名私钥配套):

bash 复制代码
openssl rsa -in rsa_private.pem -pubout -out rsa_public.pem

检查公钥、私钥是否匹配:

bash 复制代码
openssl rsa -in rsa_private.pem -pubout | openssl rsa -pubin -modulus -noout
openssl rsa -in rsa_public.pem -pubin -modulus -noout

两次输出的 Modulus= 必须完全一致,否则验签必然失败。

2. 准备消息

bash 复制代码
echo -n "hello rsa-pss" > message.txt

3. 签名

使用 SHA-256 + RSA-PSS 签名:

bash 复制代码
openssl dgst -sha256 -sign rsa_private.pem -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:32 -out signature.bin message.txt

参数说明:

参数 含义
-sha256 使用 SHA-256 计算 mHash
-sigopt rsa_padding_mode:pss 使用 RSA-PSS 填充
-sigopt rsa_pss_saltlen:32 salt 长度 32 字节

4. 验签

bash 复制代码
openssl dgst -sha256 -verify rsa_public.pem -signature signature.bin -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:32 message.txt

成功时输出:

text 复制代码
Verified OK

如下图所示:

!rsa_pss_openssl_resultrsa_pss_openssl_result

5. 常用 rsa_pss_saltlen 取值

含义
32 salt 长度 32 字节
-1 salt 长度等于 digest 长度
-2 自动选择最大 salt 长度

签名端和验签端的 rsa_pss_saltlen 必须一致。

6. 常见验签失败

现象 常见原因
last octet invalid 公钥与签名不匹配,或 signature.bin 不是 PSS 签名
first octet invalid 公钥与私钥不是一对,或签名损坏
Verification failure(无详细错误) 消息内容与签名时不一致(如多了 \r\n
Can only sign or verify one file PowerShell 中误用 \ 续行

排查建议:

bash 复制代码
# 1. 确认密钥匹配
openssl rsa -in rsa_private.pem -pubout | openssl rsa -pubin -modulus -noout
openssl rsa -in rsa_public.pem -pubin -modulus -noout

# 2. 用配套密钥重新签名后再验签
openssl dgst -sha256 -sign rsa_private.pem -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:32 -out signature.bin message.txt
openssl dgst -sha256 -verify rsa_public.pem -signature signature.bin -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:32 message.txt

C 语言示例

PSS 验签核心逻辑(SHA-256,saltLen = 32):

c 复制代码
#define SHA256_DIGEST_SIZE 32U

static int mgf1_sha256(const uint8 *seed, uint32 seed_len,
                       uint8 *mask, uint32 mask_len)
{
    uint8 counter[4];
    uint8 digest[SHA256_DIGEST_SIZE];
    uint32 offset = 0U;
    uint32 counter_val = 0U;

    while(offset < mask_len)
    {
        counter[0] = (uint8)((counter_val >> 24) & 0xFFU);
        counter[1] = (uint8)((counter_val >> 16) & 0xFFU);
        counter[2] = (uint8)((counter_val >> 8) & 0xFFU);
        counter[3] = (uint8)(counter_val & 0xFFU);

        /* Hash(seed || counter) */
        sha256_init();
        sha256_update(seed, seed_len);
        sha256_update(counter, 4U);
        sha256_final(digest);

        uint32 copy_len = mask_len - offset;
        if(copy_len > SHA256_DIGEST_SIZE)
        {
            copy_len = SHA256_DIGEST_SIZE;
        }

        memcpy(&mask[offset], digest, copy_len);
        offset += copy_len;
        counter_val++;
    }

    return 0;
}

Std_ReturnType Crypto_Rsa_Rsassa_Pss_Verify(const uint8 *signature, uint32 sig_len,
                                            const uint8 *mhash, uint32 mhash_len,
                                            uint32 salt_len,
                                            const rsa_pk_t *pk)
{
    uint8 em[RSA_MAX_MODULUS_LEN];
    uint8 db[RSA_MAX_MODULUS_LEN];
    uint8 db_mask[RSA_MAX_MODULUS_LEN];
    uint8 hash_input[8U + SHA256_DIGEST_SIZE + 64U];
    uint8 h_recomputed[SHA256_DIGEST_SIZE];
    uint32 em_len;
    uint32 masked_db_len;
    uint32 separator_index;
    uint32 i;

    if((signature == NULL) || (mhash == NULL) || (pk == NULL))
    {
        return E_NOT_OK;
    }

    if(mhash_len != SHA256_DIGEST_SIZE)
    {
        return E_NOT_OK;
    }

    em_len = (pk->bits + 7U) / 8U;
    if(sig_len != em_len)
    {
        return E_NOT_OK;
    }

  /* Step 1: EM = s^e mod n */
    if(public_block_operation(em, &em_len, signature, sig_len, pk) != 0)
    {
        return E_NOT_OK;
    }

  /* Step 2: check trailer */
    if(em[em_len - 1U] != 0xBCU)
    {
        return E_NOT_OK;
    }

  /* Step 3: split EM */
    masked_db_len = em_len - SHA256_DIGEST_SIZE - 1U;
    const uint8 *h_from_sig = &em[masked_db_len];

  /* Step 4: DB = maskedDB xor MGF1(H) */
    if(mgf1_sha256(h_from_sig, SHA256_DIGEST_SIZE,
                   db_mask, masked_db_len) != 0)
    {
        return E_NOT_OK;
    }

    for(i = 0U; i < masked_db_len; ++i)
    {
        db[i] = em[i] ^ db_mask[i];
    }

  /* Step 5: extract salt */
    separator_index = masked_db_len - salt_len - 1U;
    if(db[separator_index] != 0x01U)
    {
        return E_NOT_OK;
    }

  /* Step 6: H' = SHA256(0x00*8 || mHash || salt) */
    memset(hash_input, 0, 8U);
    memcpy(&hash_input[8U], mhash, SHA256_DIGEST_SIZE);
    memcpy(&hash_input[8U + SHA256_DIGEST_SIZE],
           &db[separator_index + 1U], salt_len);

    sha256(hash_input, 8U + SHA256_DIGEST_SIZE + salt_len, h_recomputed);

  /* Step 7: compare H and H' */
    for(i = 0U; i < SHA256_DIGEST_SIZE; ++i)
    {
        if(h_from_sig[i] != h_recomputed[i])
        {
            return E_NOT_OK;
        }
    }

    return E_OK;
}

固件验签实践要点

实际固件验签时,除了 PSS 本身,还要先统一 固件输入格式mHash 计算方式

1. mHash 计算方式要一致

常见两种:

text 复制代码
方式 A:mHash = SHA-256(firmware_binary)
方式 B:mHash = SHA-256(SHA-256(firmware_binary))

签名端和验签端必须使用同一种。

2. PSS 参数必须一致

text 复制代码
Hash算法     : SHA-256
MGF          : MGF1(SHA-256)
saltLength   : 32(或 hLen / max,但必须双方一致)
消息输入模式 : 原始消息 或 Prehashed(mHash)

3. RSA-2048 PSS 验签数据流

text 复制代码
firmware
↓
mHash = SHA-256(firmware)
↓
RSA-PSS-VERIFY(mHash)
↓
EM = s^e mod n
↓
提取 salt
↓
H' = SHA-256(0x00*8 || mHash || salt)
↓
比较 H 与 H'

常见问题

1. PSS 和 PKCS1-v1_5 有什么区别?

项目 PKCS1-v1_5 RSA-PSS
编码 00 01 FF...FF 00 DigestInfo `maskedDB
算法标识 编码在 DigestInfo 中 由协议约定
随机性 确定性 含随机 salt
安全性 可用,但 RFC 建议迁移 新项目推荐

2. 签名长度是多少?

签名长度等于 RSA 模数长度:

text 复制代码
RSA-1024 -> 128 字节
RSA-2048 -> 256 字节
RSA-3072 -> 384 字节
RSA-4096 -> 512 字节

3. saltLength 怎么选?

常见取值:

text 复制代码
32           与 SHA-256 摘要等长,嵌入式项目常见
hLen         与 Hash 输出等长
MAX_LENGTH   尽可能长的 salt

关键原则:签名端和验签端必须完全一致。

OpenSSL 对应关系:

text 复制代码
rsa_pss_saltlen:32   -> 固定 32 字节
rsa_pss_saltlen:-1   -> digest 长度
rsa_pss_saltlen:-2   -> 最大 salt 长度

4. 为什么 PSS 签名每次不一样?

因为每次签名都会生成新的随机 salt

即使消息相同、密钥相同,只要 salt 不同,最终签名就不同。这是 PSS 的设计特性,不是错误。

验签时不重新生成 salt,而是从签名恢复的 DB 中提取 salt,再重算 H' 进行比较。


5. mHash 和 Hash(M) 是一回事吗?

在标准 PSS 里,通常:

text 复制代码
mHash = Hash(M)

有些固件方案会再做一次 Hash:

text 复制代码
mHash = Hash(Hash(M))

这不是 PSS 标准本身的要求,而是业务协议约定。联调时必须双方一致。


6. PKCS1-v1_5 和 RSA-PSS 可以互相验签吗?

不可以。

text 复制代码
PKCS1-v1_5 签名不能用 PSS 验签
PSS 签名不能用 PKCS1-v1_5 验签

它们底层都是 RSA,但 EM 编码结构完全不同。


总结

RSASSA-PSS 的核心流程可以总结为:

text 复制代码
签名:

M
↓
mHash = Hash(M)
↓
随机 salt
↓
H = Hash(0x00*8 || mHash || salt)
↓
EM = maskedDB || H || 0xBC
↓
m = OS2IP(EM)
↓
s = m^d mod n
↓
S = I2OSP(s, k)
text 复制代码
验签:

S
↓
s = OS2IP(S)
↓
m = s^e mod n
↓
EM = I2OSP(m, k)
↓
还原 DB,提取 salt
↓
mHash = Hash(M)
↓
H' = Hash(0x00*8 || mHash || salt)
↓
比较 H 与 H'

PSS 签名 = Hash 消息 + 随机 salt + MGF1 掩码编码 + RSA 私钥运算。

与 PKCS1-v1_5 相比,PSS 没有 DigestInfo,但多了 saltMGF1,安全性更好,也是 RFC 8017 推荐的新方案。

rsa_pss_python_result: https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=https%3A%2F%2Foss.peng1028.cn%2FRSA%2FRSA-PSS-PythonResult.png\&pos_id=img-HYciycm2-1782569754899) "rsa_pss_python_result"

rsa_pss_tool_result: https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=https%3A%2F%2Foss.peng1028.cn%2FRSA%2FRSA-PSS-ToolResult.png\&pos_id=img-NTduGp06-1782569755291) "rsa_pss_tool_result"

rsa_pss_openssl_result: https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=https%3A%2F%2Foss.peng1028.cn%2FRSA%2FRSA-PSS-OpenSSL.png\&pos_id=img-zptX1nLE-1782569755750) "rsa_pss_openssl_result"