RSA 加密算法详解:从数学原理到生产级代码

RSA 是非对称加密的代表算法,几乎所有 TLS,在 HTTP 之上加一层 TLS 加密,防止中间人窃听和篡改。">HTTPS、SSH、签名场景都直接或间接用到它。但很多教程要么把它说成"两个大质数相乘",要么直接堆数学公式 —— 都不解决"它到底怎么工作、为什么安全、怎么用"的问题。这篇文章用具体数字 + 完整代码把 RSA 走一遍,然后讲清楚生产场景里怎么用、容易踩什么坑。

RSA 的核心思路:单向函数

RSA 建立在一个数论事实上:两个大质数相乘容易,把乘积分解回原质数极难。给你 p = 61q = 53,你瞬间算出 n = 3233;但只给你 n = 3233,要把它分解成 61 × 53,小数能算,但当 n 是 600 位的数(2048-bit RSA),即使全世界算力联合也得几亿年。

这就是"单向函数"的核心:正向算容易,反向算难。整个 RSA 体系就是把这个数学事实包装成"任何人能用公钥加密,只有持私钥的人能解密"的协议。

RSA 数学:5 个步骤,看完即懂

步骤 1:选两个大质数 p 和 q

用 64 / 128 / 256 字节(对应 512 / 1024 / 2048 位)的质数。安全标准要求至少 2048 位。

步骤 2:计算模数 n = p × q

n 就是公钥和私钥的"模数",所有加解密都在 mod n 下进行。

步骤 3:计算欧拉函数 φ(n) = (p-1)(q-1)

φ(n) 是 "1 到 n-1 之间和 n 互质的数的个数"。当 n = pq 时,有这个简洁公式。φ(n) 必须保密 —— 知道 φ(n) 等价于能反向破解。

步骤 4:选公钥指数 e

1 < e < φ(n)gcd(e, φ(n)) = 1 的数。常用 65537 (0x10001),因为它二进制只有两个 1,能加速幂运算,且大到不会有小指数攻击。

步骤 5:计算私钥指数 d

d 是 e 在 mod φ(n) 下的逆元,即 (d × e) mod φ(n) = 1。用扩展欧几里得算法求。

得到:

  • 公钥:(n, e) —— 可以公开
  • 私钥:(n, d) —— 必须保密

加密与解密

加密:c = m^e mod n      // 用公钥加密
解密:m = c^d mod n      // 用私钥解密

为什么能行得通:c^d = (m^e)^d = m^(ed) ≡ m (mod n)    [欧拉定理]

动手:用 60 行 JS 走一遍小数版

// 不要用在生产!这是教学版,用小数字
function gcd(a, b) {
    while (b) [a, b] = [b, a % b];
    return a;
}

// 扩展欧几里得:返回 [gcd, x, y] 使得 ax + by = gcd
function extGcd(a, b) {
    if (b === 0n) return [a, 1n, 0n];
    const [g, x1, y1] = extGcd(b, a % b);
    return [g, y1, x1 - (a / b) * y1];
}

// 模逆元
function modInverse(a, m) {
    const [g, x] = extGcd(a, m);
    if (g !== 1n) throw new Error('no modular inverse');
    return ((x % m) + m) % m;
}

// 快速幂取模:计算 (base^exp) mod m
function modPow(base, exp, m) {
    let result = 1n;
    base = base % m;
    while (exp > 0n) {
        if (exp % 2n === 1n) result = (result * base) % m;
        exp /= 2n;
        base = (base * base) % m;
    }
    return result;
}

function rsaToy() {
    // 教学小数字
    const p = 61n, q = 53n;
    const n = p * q;                         // 3233
    const phi = (p - 1n) * (q - 1n);         // 3120
    const e = 17n;                            // 选一个和 phi 互质的小数
    const d = modInverse(e, phi);            // 算出 d
    console.log({ n, e, d });                // 公钥 (3233, 17),私钥 d = 2753

    const m = 65n;                            // 待加密的明文
    const c = modPow(m, e, n);               // 加密
    const m2 = modPow(c, d, n);              // 解密
    console.log({ original: m, cipher: c, decrypted: m2 });   // m2 === m
}
rsaToy();

输出:

{ n: 3233n, e: 17n, d: 2753n }
{ original: 65n, cipher: 2790n, decrypted: 65n }

三行代码:n = p*qphi = (p-1)*(q-1)d = inv(e, phi)。整个 RSA 的"魔法"就在这三行里。

从玩具到生产:Node 内置 crypto

真实使用永远不要自己写,语言/库提供经过审计的实现。Node.js 直接用 crypto 模块:

const crypto = require('crypto');

// 生成 2048 位 RSA 密钥对
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
    modulusLength: 2048,
    publicKeyEncoding:  { type: 'spki', format: 'pem' },
    privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});

// 用公钥加密
const message = Buffer.from('hello world');
const encrypted = crypto.publicEncrypt({
    key: publicKey,
    padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,  // 推荐
    oaepHash: 'sha256',
}, message);

// 用私钥解密
const decrypted = crypto.privateDecrypt({
    key: privateKey,
    padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
    oaepHash: 'sha256',
}, encrypted);

console.log(decrypted.toString());   // hello world

填充(padding):为什么不能"原始" RSA

上面玩具版里我们直接 m^e mod n,但真实场景必须填充。原因:

  • 确定性:同样的明文每次加密得到同样的密文 —— 攻击者一比对就知道你发的是什么。
  • 短消息攻击:m 很小时 m^e 不超过 n,攻击者直接开 e 次方就还原了。
  • 消息可塑性:RSA 有同态性质,c1 × c2 mod n 解密后等于 m1 × m2 mod n,攻击者能构造特定密文。

OAEP(Optimal Asymmetric Encryption Padding)是现代推荐的填充方式,内部用了哈希 + 随机数,解决上述所有问题。能用 OAEP 就别用 PKCS#1 v1.5(它的设计漏洞在 1998 年被 Bleichenbacher 攻击曝光,至今每隔几年就在某个产品里被重新利用)。

RSA 签名:加密的反向用法

加密是"用公钥加密,私钥解密"。签名是"用私钥签名,公钥验证"。本质数学一样,语义不同。

// 签名
const sign = crypto.createSign('SHA256');
sign.update('important message');
const signature = sign.sign(privateKey);

// 验证
const verify = crypto.createVerify('SHA256');
verify.update('important message');
const valid = verify.verify(publicKey, signature);
console.log(valid);   // true

注意签名前要先哈希再签 —— RSA 本身只能处理小于模数长度的数据。常用的标准:RSA-PSS(推荐)或 RSA-PKCS#1 v1.5(老协议兼容用)。

RSA 在生产中的真实位置

误区:很多人以为"HTTPS 用 RSA 加密所有数据"。实际不是 —— RSA 太慢了,只在握手阶段用一下:

  1. 客户端拿到服务器的 RSA 公钥(从证书里)。
  2. 客户端用公钥加密一个随机的对称密钥(AES 密钥),发给服务器。
  3. 服务器用私钥解密,拿到对称密钥。
  4. 之后整个会话用 AES 加密,RSA 不再参与。

这种"非对称加密交换对称密钥,再用对称加密"的模式叫混合加密。原因:RSA 的 m^d mod n 在 2048 位下要做几百次大数乘法,比 AES 慢上千倍。所以 RSA 只用在"交换密钥""签名""少量关键数据"这种不适合对称加密的场景。

现代 TLS 1.3 甚至已经不用 RSA 做密钥交换了 —— 改用 ECDHE(椭圆曲线 Diffie-Hellman),因为它密钥更短、运算更快、有前向安全性。RSA 在 TLS 1.3 里只剩"证书签名"一个用途。

密钥长度选多少

1024 位  已被认为不安全(2010 年起逐渐淘汰)
2048 位  当前主流,推荐用至 2030 年
3072 位  对应 128 位对称密钥强度,长期使用建议
4096 位  对长期保密文件(医疗/法律)
8192 位  极端场景,加解密慢到肉痛,几乎没人用

用 ECC(椭圆曲线)的对应等价强度:2048 RSA ≈ 224 ECC,3072 RSA ≈ 256 ECC。新项目用 ECC,密钥短、运算快,生产环境主推 Ed25519、secp256r1。

SSH key 与 GitHub 用的就是这个

# 生成一对密钥(RSA 4096 位)
ssh-keygen -t rsa -b 4096 -C "you@example.com"
# 生成在 ~/.ssh/id_rsa (私钥) 和 ~/.ssh/id_rsa.pub (公钥)

# 推荐用 Ed25519,更短更快
ssh-keygen -t ed25519 -C "you@example.com"

# 把公钥贴到 GitHub Settings -> SSH keys
cat ~/.ssh/id_rsa.pub

# 之后 git push 用公钥认证:
# - 你的客户端用私钥签名一个随机挑战
# - GitHub 用你公钥验证,通过即认证成功

常见的几个坑

坑 1:私钥泄漏。 任何写在 git 仓库、log、上传到 pastebin 的私钥都视为已泄漏。永远不要把私钥提交到代码仓库,即使是私有仓库 —— 哪天仓库变 public 或被入侵就完了。

坑 2:用了弱熵生成密钥。 密钥生成依赖好的随机源。早年 Debian OpenSSL 漏洞导致几千万弱密钥;Java 老版本 SecureRandom 在某些 Linux 下熵不够会阻塞。务必用语言内置的、被审计过的 API。

坑 3:加密大文件。 RSA 单次能加密的数据小于密钥长度。要加密大文件,生成临时对称密钥,用 AES 加密文件,RSA 加密那个对称密钥 —— 也就是混合加密。

坑 4:用 RSA 直接做"数据指纹"。 别犯 —— "指纹"用哈希(SHA-256),签名用 RSA-PSS over 哈希。把概念分清。

从代码看 OAEP 填充到底做了什么

抽象上 OAEP 解决"原始 RSA 不安全",但具体步骤值得一看。OAEP 编码大致流程:

消息 M  (短于 n 字节 - 2*hashlen - 2)
   ↓ 拼接随机种子 seed (hashlen 字节,每次加密都随机)
   ↓ 拼接 0 填充到正好 (n字节 - hashlen - 1) 长
   |  M || padding (含一个 0x01 分隔符 + 必要 0 填充)
   ↓ 用 seed 通过 MGF1 (基于 hash 的伪随机生成器) 派生 mask1
   ↓ DB = (M || padding) XOR mask1
   ↓ 用 DB 通过 MGF1 派生 mask2
   ↓ maskedSeed = seed XOR mask2
   ↓ 最终编码:0x00 || maskedSeed || DB
   ↓ 然后 m^e mod n

关键认知:seed 是每次随机的,所以同一明文每次密文都不一样;XOR 链让任何一位变化都扩散到整个块,失去同态性;0x01 分隔符让解密能识别填充边界。这是为什么 OAEP 能挡住 Bleichenbacher 那种"通过填充错误探测明文"的攻击。

RSA-PSS 签名:和 OAEP 同源的现代填充

签名也有"老填充"和"新填充":PKCS#1 v1.5(老)和 PSS(现代推荐)。PSS 同样用了随机 salt,让同样的消息每次签名结果都不同,杜绝伪造攻击。

// Node 用 PSS 签名
const signature = crypto.sign('sha256', Buffer.from('msg'), {
    key: privateKey,
    padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
    saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
});

// 验证
const valid = crypto.verify('sha256', Buffer.from('msg'), {
    key: publicKey,
    padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
    saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
}, signature);

JWT 与 RSA:常见混合用法

很多 JWT 实现用 RSA 签名:RS256 表示 "RSA-PKCS#1 v1.5 + SHA-256",PS256 表示 "RSA-PSS + SHA-256",RS384/RS512 类推。OAuth、OIDC 的 id_token 都是这套。

const jwt = require('jsonwebtoken');

// 签发 token
const token = jwt.sign(
    { sub: 'mores', exp: Math.floor(Date.now() / 1000) + 3600 },
    privateKey,
    { algorithm: 'RS256' },
);

// 验证 token —— 客户端只需要服务器的公钥
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
console.log(payload);

RSA 签名 JWT 的好处:资源服务器只需要公钥就能验证 token,不需要联系认证服务器。这让微服务架构里的鉴权大幅简化 —— 公钥广播给所有服务,每个服务本地验签,无需每次都查中心系统。代价是 token 泄漏后不能"撤销"(只能等过期),要靠短过期 + refresh token 机制兜底。

密钥保管的工程实践

算法再强,密钥保管不当也白搭。生产经验:

  • 私钥不进代码仓库.gitignore 一定包含 *.pem *.key
  • 用 KMS / Vault 管密钥。AWS KMS、HashiCorp Vault、阿里云 KMS 都提供"密钥永不下载、只能在服务端签名/解密"的模式。
  • 定期轮换。RSA 密钥的有效期通常 1-3 年,到期前要有迁移机制(新旧公钥并存,逐步替换)。
  • HSM(硬件安全模块)用于最关键场景。私钥根本不离开硬件,所有签名操作都在 HSM 内完成。CA、证书签发、金融机构标配。

用 OpenSSL 命令行做日常 RSA 操作

不依赖任何编程语言,openssl 命令行能完成所有常见 RSA 任务。这套指令值得记一记:

# 生成 2048 位私钥
openssl genrsa -out private.pem 2048

# 从私钥导出公钥
openssl rsa -in private.pem -pubout -out public.pem

# 查看私钥详情(模数、指数等)
openssl rsa -in private.pem -text -noout

# 用公钥加密小文件(RSA 原生只能加密 < 密钥长度的数据)
openssl pkeyutl -encrypt -inkey public.pem -pubin -in plain.txt -out cipher.bin

# 用私钥解密
openssl pkeyutl -decrypt -inkey private.pem -in cipher.bin -out plain2.txt

# 签名一个文件(实际是先 SHA256 再签)
openssl dgst -sha256 -sign private.pem -out sig.bin doc.txt

# 验证签名
openssl dgst -sha256 -verify public.pem -signature sig.bin doc.txt
# 输出 Verified OK 表示通过

# 把私钥加密保护(AES-256 加密整个 PEM 文件)
openssl rsa -in private.pem -aes256 -out private.enc.pem
# 之后每次用都要输入密码

# 生成证书签名请求 CSR(向 CA 申请证书时用)
openssl req -new -key private.pem -out req.csr -subj "/CN=example.com"

# 用自己的私钥签发自签名证书(开发环境用)
openssl req -x509 -new -key private.pem -days 365 -out cert.pem -subj "/CN=example.com"

把这些命令收进自己的 cheatsheet,日常调试 HTTPS、JWT、SSH 时不用再现搜。所有的"高级"密钥操作,在命令行里都是几行就能完成的事。

常见的 RSA 调试场景

开发里遇到 RSA 相关问题,最常见的几种:

  • 密钥格式不匹配:PKCS#1 vs PKCS#8,PEM vs DER,SPKI vs RSAPublicKey。不同语言/库默认偏好不同,转换用 openssl rsa -traditional-pubin -RSAPublicKey_out
  • 换行符问题:从 Windows 拷贝 PEM 到 Linux,CRLF 让一些库解析失败。dos2unix 或重新生成。
  • 填充不匹配:一端用 OAEP 加密,另一端用 PKCS#1 v1.5 解密 —— 解密成功但内容乱码。两端必须约定相同填充。
  • 消息超长:RSA 加密上限 = 密钥长度 - 填充开销。2048 位 RSA + OAEP-SHA256 只能加密 ≤ 190 字节明文。要发更长数据,用混合加密。

排查这类问题最快的方法:两端都用 openssl 命令行做一遍,看输出能不能互通。任一端命令行行得通而代码行不通,基本就是库的配置问题。

写在最后

RSA 的数学不复杂,概念也不深 —— 难的是把它用对。生产环境里你不会自己写 m^e mod n,但你会面对填充选择、密钥长度、密钥保管、签名 vs 加密、混合加密的设计决策。这些每一个错都可能让"算法本身安全"等于零。

给你三条永远适用的原则:用经过审计的库,不自己实现;OAEP / PSS 是默认,PKCS#1 v1.5 是兼容;新项目优先 ECC(Ed25519 / X25519)。把这三条记牢,再读 RFC、看 TLS 协议、写身份认证系统时,你都能做出正确选择。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

手写词法分析器完全指南:200 行实现一个能用的 Tokenizer

2026-5-15 11:21:08

技术教程

函数式编程实战指南:从纯函数到 Maybe / Either 容器

2026-5-15 11:21:09

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索