RSA 是非对称加密的代表算法,几乎所有 TLS,在 HTTP 之上加一层 TLS 加密,防止中间人窃听和篡改。">HTTPS、SSH、签名场景都直接或间接用到它。但很多教程要么把它说成"两个大质数相乘",要么直接堆数学公式 —— 都不解决"它到底怎么工作、为什么安全、怎么用"的问题。这篇文章用具体数字 + 完整代码把 RSA 走一遍,然后讲清楚生产场景里怎么用、容易踩什么坑。
RSA 的核心思路:单向函数
RSA 建立在一个数论事实上:两个大质数相乘容易,把乘积分解回原质数极难。给你 p = 61、q = 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*q、phi = (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 太慢了,只在握手阶段用一下:
- 客户端拿到服务器的 RSA 公钥(从证书里)。
- 客户端用公钥加密一个随机的对称密钥(AES 密钥),发给服务器。
- 服务器用私钥解密,拿到对称密钥。
- 之后整个会话用 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