第九章 数字签名:RSA签名与验签、DSA算法、C语言实现

数字签名,说白了就是现实世界中签名的电子版。你手写签名,别人能认出是你写的;数字签名也一样,它能证明消息确实是你发的,而且没被篡改过。

我刚开始接触密码学时,总觉得签名和加密是一回事。后来踩了坑才明白——加密是为了保密,签名是为了防伪。这两个目标完全不同。

这一章,我们就来聊聊两种主流的数字签名算法:RSA签名和DSA算法。我会结合C语言实现,把原理和坑都讲清楚。

9.1 数字签名的基础概念

先理清几个核心概念:

  • 签名者:用自己的私钥对消息摘要进行加密,生成签名
  • 验证者:用签名者的公钥解密签名,比对摘要是否一致
  • 消息摘要:对原始消息做哈希,固定长度,防篡改

你想想看,如果我把消息直接签名,那签名长度会跟消息一样长,太浪费了。所以实际做法是:先哈希,再签名

核心流程

签名:消息 → 哈希 → 私钥加密 → 签名

验签:消息 + 签名 → 哈希 → 公钥解密 → 比对哈希值

我在项目中遇到过有人直接用RSA加密整个文件当签名,结果文件大了几倍,性能惨不忍睹。嗯,这就是没理解摘要的作用。

9.2 RSA签名与验签

RSA签名,其实就是把RSA加密算法反过来用。私钥签名,公钥验签。

9.2.1 签名过程

  1. 对消息M做哈希,得到摘要H
  2. 用私钥对H做RSA加密,得到签名S
  3. 将(M, S)一起发送给接收方

9.2.2 验签过程

  1. 接收方对消息M做同样的哈希,得到H'
  2. 用公钥对签名S做RSA解密,得到H
  3. 比较H和H'是否相等

这里有个关键点:RSA签名必须使用PKCS#1 v1.5或PSS填充模式。直接裸加密是不安全的。

避坑指南:我曾经在项目中直接用了裸RSA加密做签名,结果被安全审计打回来。原因是裸RSA存在选择密文攻击的风险。一定要用标准填充模式。

9.2.3 C语言实现RSA签名

下面是一个使用OpenSSL库的RSA签名示例。我个人习惯用OpenSSL,因为它成熟且跨平台。

#include <openssl/rsa.h>
#include <openssl/pem.h>
#include <openssl/err.h>
#include <openssl/sha.h>

// RSA签名函数
int rsa_sign(const char *message, RSA *rsa_private, unsigned char **sig, unsigned int *sig_len) {
    unsigned char hash[SHA256_DIGEST_LENGTH];
    SHA256((unsigned char*)message, strlen(message), hash);

    *sig = (unsigned char*)malloc(RSA_size(rsa_private));
    if (!*sig) return -1;

    if (RSA_sign(NID_sha256, hash, SHA256_DIGEST_LENGTH, *sig, sig_len, rsa_private) != 1) {
        ERR_print_errors_fp(stderr);
        free(*sig);
        return -1;
    }
    return 0;
}

// RSA验签函数
int rsa_verify(const char *message, RSA *rsa_public, unsigned char *sig, unsigned int sig_len) {
    unsigned char hash[SHA256_DIGEST_LENGTH];
    SHA256((unsigned char*)message, strlen(message), hash);

    int ret = RSA_verify(NID_sha256, hash, SHA256_DIGEST_LENGTH, sig, sig_len, rsa_public);
    return ret; // 1表示验证成功,0表示失败
}

代码看起来简单,但有几个坑要注意:

  • 哈希算法要一致:签名和验签必须用同一种哈希,否则比对失败
  • 内存管理:签名缓冲区要足够大,RSA_size()返回的就是最大长度
  • 错误处理:RSA_sign和RSA_verify返回1才表示成功

小技巧:调试时可以用ERR_print_errors_fp(stderr)打印OpenSSL错误信息,能省不少排查时间。

9.3 DSA算法

DSA(Digital Signature Algorithm)是另一种签名算法。它和RSA最大的区别是:DSA只能签名,不能加密

为什么会这样?因为DSA基于离散对数问题,它的数学结构决定了它不适合做加密。我刚开始学的时候也纳闷,后来看了论文才明白——设计目标不同。

9.3.1 DSA签名过程

  1. 生成随机数k(每次签名都要不同!)
  2. 计算r = (g^k mod p) mod q
  3. 计算s = k^(-1) * (H(m) + x*r) mod q
  4. 签名就是(r, s)对

9.3.2 DSA验签过程

  1. 检查r和s是否在(0, q)范围内
  2. 计算w = s^(-1) mod q
  3. 计算u1 = H(m) * w mod q
  4. 计算u2 = r * w mod q
  5. 计算v = (g^u1 * y^u2 mod p) mod q
  6. 如果v == r,验签成功

关键点:DSA的随机数k绝对不能重复使用!如果两次签名用了同一个k,私钥就会被算出来。这不是理论问题,是真有人因此翻车。

9.3.3 C语言实现DSA签名

#include <openssl/dsa.h>
#include <openssl/err.h>
#include <openssl/sha.h>

// DSA签名函数
int dsa_sign(const char *message, DSA *dsa, unsigned char **sig, unsigned int *sig_len) {
    unsigned char hash[SHA256_DIGEST_LENGTH];
    SHA256((unsigned char*)message, strlen(message), hash);

    *sig = (unsigned char*)malloc(DSA_size(dsa));
    if (!*sig) return -1;

    if (DSA_sign(0, hash, SHA256_DIGEST_LENGTH, *sig, sig_len, dsa) != 1) {
        ERR_print_errors_fp(stderr);
        free(*sig);
        return -1;
    }
    return 0;
}

// DSA验签函数
int dsa_verify(const char *message, DSA *dsa, unsigned char *sig, unsigned int sig_len) {
    unsigned char hash[SHA256_DIGEST_LENGTH];
    SHA256((unsigned char*)message, strlen(message), hash);

    int ret = DSA_verify(0, hash, SHA256_DIGEST_LENGTH, sig, sig_len, dsa);
    return ret;
}

代码结构和RSA签名很像,但底层算法完全不同。DSA的签名长度是固定的,而RSA的签名长度取决于密钥长度。

9.4 RSA vs DSA:怎么选?

我经常被问到这个问题。直接给结论:

特性 RSA签名 DSA
签名速度 较慢(私钥操作) 较快
验签速度 较快(公钥操作) 较慢
密钥长度 2048位起 1024位起(推荐2048)
安全性基础 大整数分解 离散对数
能否加密 不能
随机数要求 无特殊要求 每次签名必须不同

我个人建议:如果系统已经用了RSA做加密,那就统一用RSA签名,减少代码复杂度。如果是从零开始,且对性能有要求,可以考虑DSA或ECDSA(椭圆曲线DSA)。

注意:DSA的随机数生成一定要用密码学安全的随机数生成器(CSPRNG)。用rand()或者time(NULL)做种子,那就是在给自己挖坑。

9.5 数字签名知识体系

下面这张图帮你理清本章的核心逻辑:

数字签名知识体系 签名者(私钥) 原始消息 M SHA-256 哈希 RSA/DSA 签名 签名 S 私钥加密 验证者(公钥) 接收 (M, S) 公钥解密签名 比对哈希值 通过/失败 传输 (M, S)

9.6 实战中的避坑指南

最后,分享几个我踩过的坑:

  • 密钥管理:私钥一定要加密存储。我曾经见过有人把私钥硬编码在代码里,结果代码泄露,整个系统沦陷。
  • 签名长度:RSA签名长度等于密钥长度(比如2048位就是256字节)。DSA签名长度固定,但不同参数集长度不同。接收方要做好长度检查。
  • 哈希算法选择:别再用了SHA-1了,已经被攻破。至少用SHA-256。
  • 随机数质量:DSA的随机数k必须真随机。用/dev/urandom或者OpenSSL的RAND_bytes()。

我的习惯:每次签名前,我都会先打印一下哈希值,确认输入没问题。这个小习惯帮我抓到了好几次数据传输出错的bug。

数字签名是安全体系的基石。RSA和DSA各有优劣,选哪个取决于你的场景。但无论选哪个,密钥保护和随机数质量永远是第一位的。

好了,这一章就到这里。代码示例可以直接拿去用,但记得改改参数和错误处理——毕竟生产环境和demo不一样。


公众号:蓝海资料掘金营,微信deep3321