RSA-PSS

目录

简介

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

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

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 运算。

整体流程可以理解为:

      __________________________________________________________________

                                     +-----------+
                                     |     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_5RSA-PSS
填充结构00 01 FF...FF 00 DigestInfomaskedDB || H || BC
是否含算法标识有(DigestInfo DER)无,Hash 算法由协议约定
随机性确定性签名含随机 salt,同消息每次签名不同
RFC 8017 建议兼容老系统新项目优先推荐

PSS 的核心编码结构:

EM = maskedDB || H || 0xBC

其中:

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

术语说明

符号含义
MMessage,待签名消息
mHashHash(M),消息摘要
SSignature,签名结果
nRSA modulus,模数
eRSA public exponent,公钥指数
dRSA private exponent,私钥指数
kRSA 模数 n 的字节长度
hLenHash 输出长度,SHA-256 为 32 字节
sLensalt 长度,常取 32 或 hLen
salt签名时随机生成的盐值
HHash(0x00*8 || mHash || salt)
DBData block,数据块
maskedDB掩码后的 DB
MGF1Mask Generation Function 1
EMEncoded Message,编码后的消息
emLenEM 的字节长度,通常等于 k
emBitsmodBits - 1,编码有效位数
OS2IP字节串转整数
I2OSP整数转字节串
RSASP1RSA 签名原语
RSAVP1RSA 验签原语

EMSA-PSS 编码

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

输入:
  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 为例:

k      = 256 字节
hLen   = 32 字节
sLen   = 32 字节
emLen  = 256 字节
emBits = 2047

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

DB 结构:

DB = [190 字节 PS] || 0x01 || [32 字节 salt]

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

最终 EM 结构:

EM = [223 字节 maskedDB] || [32 字节 H] || 0xBC

MGF1 掩码生成

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

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 为例:

dbMask = SHA256(H || 00000000)
      || SHA256(H || 00000001)
      || SHA256(H || 00000002)
      || ...

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

DB = maskedDB xor dbMask

签名流程

签名函数可以表示为:

RSASSA-PSS-SIGN(K, M)

输入:

K = RSA 私钥
M = 待签名消息

输出:

S = 签名,长度为 k 字节

签名步骤:

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

验签流程

验签函数可以表示为:

RSASSA-PSS-VERIFY((n, e), M, S)

输入:

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

输出:

valid signature
invalid signature

验签步骤:

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',验签成功。
    否则,验签失败。

也可以用编码比较方式:

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

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

Python 示例

PyCryptodome 示例

需要安装:

pip install pycryptodome

示例代码:

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_result

工具验证结果:

rsa_pss_tool_result


OpenSSL 示例

1. 生成密钥

生成私钥:

openssl genrsa -out rsa_private.pem 2048

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

openssl rsa -in rsa_private.pem -pubout -out rsa_public.pem

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

openssl rsa -in rsa_private.pem -pubout | openssl rsa -pubin -modulus -noout
openssl rsa -in rsa_public.pem -pubin -modulus -noout

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

2. 准备消息

echo -n "hello rsa-pss" > message.txt

3. 签名

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

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:32salt 长度 32 字节

4. 验签

openssl dgst -sha256 -verify rsa_public.pem -signature signature.bin -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:32 message.txt

成功时输出:

Verified OK

如下图所示:

rsa_pss_openssl_result

5. 常用 rsa_pss_saltlen 取值

含义
32salt 长度 32 字节
-1salt 长度等于 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 filePowerShell 中误用 \ 续行

排查建议:

# 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):

#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 计算方式要一致

常见两种:

方式 A:mHash = SHA-256(firmware_binary)
方式 B:mHash = SHA-256(SHA-256(firmware_binary))

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

2. PSS 参数必须一致

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

3. RSA-2048 PSS 验签数据流

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_5RSA-PSS
编码00 01 FF...FF 00 DigestInfomaskedDB || H || BC
算法标识编码在 DigestInfo 中由协议约定
随机性确定性含随机 salt
安全性可用,但 RFC 建议迁移新项目推荐

2. 签名长度是多少?

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

RSA-1024 -> 128 字节
RSA-2048 -> 256 字节
RSA-3072 -> 384 字节
RSA-4096 -> 512 字节

3. saltLength 怎么选?

常见取值:

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

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

OpenSSL 对应关系:

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 里,通常:

mHash = Hash(M)

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

mHash = Hash(Hash(M))

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


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

不可以。

PKCS1-v1_5 签名不能用 PSS 验签
PSS 签名不能用 PKCS1-v1_5 验签

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


总结

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

签名:

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)
验签:

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 推荐的新方案。