RSA-PSS
目录
- 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-PSS 把 RSASP1 / RSAVP1 这两个 RSA 原语和 EMSA-PSS 编码方法组合起来使用。(RFC 8017 §8.1)
前置阅读:
- RSA-PKCS1 v1.5
- RSA 基础算法可参考 RSA2048
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_5 | RSA-PSS |
|---|---|---|
| 填充结构 | 00 01 FF...FF 00 DigestInfo | maskedDB || 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)
术语说明
| 符号 | 含义 |
|---|---|
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 || mHash || salt) |
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。
输入:
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,失败时抛出ValueError或TypeError。
结果如下:

工具验证结果:

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:32 | salt 长度 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
如下图所示:

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 中误用 \ 续行 |
排查建议:
# 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_5 | RSA-PSS |
|---|---|---|
| 编码 | 00 01 FF...FF 00 DigestInfo | maskedDB || 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,但多了 salt 和 MGF1,安全性更好,也是 RFC 8017 推荐的新方案。