3. 签名验签流程:标准流程与常见误区

签名验签这事儿,说起来就两步——签名和验签。但我在项目里见过太多人在这两步上栽跟头了。说白了,流程本身不复杂,复杂的是那些容易被忽略的细节。

今天我就把标准流程掰开揉碎了讲,顺便聊聊我踩过的那些坑。

3.1 标准签名生成流程

签名生成,本质上就是用私钥对数据的“摘要”做加密运算。嗯,这里要注意,我们签的不是原始数据本身,而是它的哈希值。

标准流程分四步:

  1. 计算摘要:对原始消息做哈希运算,得到固定长度的摘要值。
  2. 填充摘要:把摘要值按照签名算法的要求进行填充(比如 PKCS#1 v1.5 或 PSS)。
  3. 私钥加密:用签名者的私钥对填充后的摘要做加密运算。
  4. 输出签名:把加密结果作为签名值输出,通常和原始消息一起发送。

核心要点:签名 ≠ 加密。签名用的是私钥,验签用的是公钥。千万别搞反了。

我举个例子,用 RSA 签名时,流程大概是这样的:

// 伪代码示例:RSA 签名生成
Message m = "转账 1000 元给 Alice";
byte[] digest = SHA256.hash(m);          // 步骤1:计算摘要
byte[] padded = RSA_PKCS1_PADDING(digest); // 步骤2:填充
byte[] signature = RSA_private_encrypt(padded, privateKey); // 步骤3:私钥加密
// 输出 (m, signature)

我个人习惯在步骤1之前先检查一下原始数据的编码。UTF-8 还是 GBK?这个不统一,后面验签必挂。我在项目中遇到过两次这种问题,都是因为发送方和接收方用的编码不一致。

3.2 标准验签流程

验签是签名的逆过程,但逻辑上不完全对称。验签的目的是确认两件事:

  • 数据没有被篡改(完整性)
  • 数据确实来自声称的签名者(真实性)

标准验签流程也是四步:

  1. 分离签名与消息:从接收到的数据中提取出原始消息和签名值。
  2. 计算消息摘要:对原始消息做同样的哈希运算,得到摘要值。
  3. 公钥解密签名:用签名者的公钥对签名值做解密运算,得到填充后的摘要。
  4. 比对摘要:从解密结果中提取出原始摘要,与步骤2计算的摘要进行比对。一致则验签通过,否则失败。

注意:步骤4的比对必须是“常量时间比较”,不能用普通的字符串 equals。否则容易遭受时序攻击。我曾经在一个金融项目中看到有人用 String.equals 做比对,被安全审计直接打回。

验签的代码逻辑:

// 伪代码示例:RSA 验签
Message m = receivedMessage;
byte[] signature = receivedSignature;
byte[] digest = SHA256.hash(m);          // 步骤2:计算摘要
byte[] decrypted = RSA_public_decrypt(signature, publicKey); // 步骤3:公钥解密
byte[] expectedDigest = extractDigest(decrypted); // 从填充中提取摘要
boolean valid = constantTimeEquals(digest, expectedDigest); // 步骤4:常量时间比对

3.3 常见流程误区

这部分是我最想说的。很多开发者觉得签名验签就是调个 API,没什么技术含量。但实际项目中,坑全在细节里。

误区一:签名时直接加密原始数据

有人觉得“签名 = 用私钥加密原始数据”。这是错的。签名是对摘要做运算,不是对原始数据。你想想看,如果直接加密原始数据,那签名值会随着数据长度变化,性能差不说,还容易泄露数据内容。

误区二:验签时重新计算签名再比对

我见过有人这么写:

// 错误做法
byte[] computedSignature = sign(message, privateKey);
boolean valid = computedSignature.equals(receivedSignature);

这完全不对。验签用的是公钥,不是私钥。而且这种比对方式暴露了私钥的使用场景,存在安全风险。

误区三:忽略哈希算法的选择

MD5 和 SHA-1 已经不被推荐用于签名了。我在一个遗留系统里看到过用 MD5 做签名的,当时就建议客户尽快升级。现在至少要用 SHA-256。

哈希算法 安全状态 推荐场景
MD5 已破解,不推荐 仅用于校验,不用于签名
SHA-1 已破解,不推荐 仅用于兼容旧系统
SHA-256 安全 推荐用于签名
SHA-3 安全 新系统可考虑

误区四:填充模式选错

RSA 签名常用的填充模式有 PKCS#1 v1.5 和 PSS。PSS 更安全,但兼容性稍差。我建议新系统优先用 PSS。如果你还在用 v1.5,要注意它有一些已知的攻击向量。

小技巧:如果你不确定用什么填充模式,查一下你用的加密库的默认值。很多库默认用 PKCS#1 v1.5,但这不是最安全的选择。

误区五:公钥来源不验证

验签时用的公钥,必须来自可信的渠道。如果公钥被替换了,那验签就形同虚设。我在项目中遇到过有人直接从 HTTP 请求头里拿公钥的,这跟没验签一样。

3.4 签名验签流程全景图

下面这张图把整个流程串起来了。你可以看到签名和验签是对称的,但使用的密钥不同。

签名验签流程全景图 签名验签流程全景图 签名方 原始消息 私钥 计算摘要 → 填充 → 私钥加密 输出:消息 + 签名 网络传输(消息 + 签名) 验签方 接收消息 公钥 计算摘要 → 公钥解密 → 比对 验签通过 / 失败 消息 + 签名 关键点 • 签名用私钥,验签用公钥,两者不能混用 • 签名是对摘要做运算,不是对原始数据 • 验签的摘要比对必须使用常量时间比较

3.5 避坑指南

最后,我总结几个实战中容易踩的坑:

  • 密钥格式不统一:DER、PEM、PKCS#8、PKCS#12……不同系统之间密钥格式不一致,会导致验签失败。我建议统一用 PEM 格式,兼容性最好。
  • 签名值编码问题:签名值是二进制数据,传输时要用 Base64 编码。有人直接传二进制,结果被文本协议截断。
  • 时间戳问题:有些签名方案会带上时间戳,验签时检查时间有效性。如果系统时间不同步,签名会失效。
  • 重放攻击:签名本身不防重放。如果你需要防重放,要在签名内容里加入 nonce 或时间戳。

曾经我接手过一个项目,他们的签名验签流程看起来没问题,但线上总是偶发验签失败。查了三天,最后发现是签名方和验签方用的哈希算法不一样——一个用了 SHA-256,另一个用了 SHA-256 但 truncate 成了 128 位。这种低级错误,往往是最难排查的。