1. 项目概述:为什么要在前端搞RSA?
最近在做一个前后端分离的项目,涉及到用户密码的传输,安全这根弦一下子就绷紧了。直接明文传密码?那简直是给中间人攻击者送“外卖”。用对称加密比如AES?密钥怎么安全地给前端又成了新问题。这时候,非对称加密RSA就成了一个非常自然的选择:前端用公钥加密,后端用私钥解密,公钥可以放心地暴露给任何人,而私钥牢牢锁在后端服务器,完美解决了密钥分发难题。
但说实话,很多前端同学对RSA的理解可能就停留在“npm install jsencrypt”这一步,调个encrypt方法就完事了。知其然不知其所以然,一旦遇到问题,比如加密后的字符串后端解不开、或者性能瓶颈,就完全抓瞎。这个项目,就是要把RSA从黑盒变成白盒,我们不依赖任何第三方库,从最底层的数学原理开始,用纯JavaScript实现一套完整的RSA加密解密流程。这不仅能让你彻底搞懂非对称加密的来龙去脉,更能让你在面试官问到“RSA原理”时,可以底气十足地从欧拉定理讲到模幂运算。
2. RSA核心原理深度拆解:不止于“大质数相乘”
很多人对RSA的印象就是“找两个大质数p和q,乘起来得到n,然后选个e,再算个d,就搞定了”。这话没错,但太笼统。我们得掰开揉碎了看,每一步为什么这么做,背后的数学怎么支撑起安全的。
2.1 密钥生成的数学基石
RSA的安全性核心基于“大数质因数分解”的困难性。但光有这个还不够,整套机制能运转起来,依赖的是数论中的欧拉定理和模反元素。
第一步:选择两个不相等的质数p和q这是所有运算的起点。为什么必须是质数?
- 计算n的欧拉函数φ(n)方便:对于质数p,φ(p) = p - 1(因为1到p-1都与p互质)。对于两个质数的乘积n=pq,φ(n) = (p-1)(q-1)。这个计算是瞬间完成的。
- 确保分解困难:如果p和q是合数,那么n的因数可能更多,分解难度可能会意外降低。选择质数是保证问题复杂度的标准做法。
实操心得:在真正的密码学应用中,p和q不是随便找的质数,而是“强质数”或“安全质数”,它有额外的要求(比如(p-1)/2也是质数),以防止某些特殊的因式分解攻击(如Pollard‘s p-1算法)。在我们自己的实现中,由于数字不会太大,可以简化处理,但心里要知道这个区别。
第二步:计算n和φ(n)
n = p * q:这就是我们的模数,公钥和私钥的一部分。它会公开。φ(n) = (p-1) * (q-1):欧拉函数值。这是整个过程中最核心的机密,必须彻底销毁(p, q, φ(n)都不能保留),一旦泄露,私钥d就可以被算出来。
第三步:选择公钥指数ee需要满足两个条件:
1 < e < φ(n)e和φ(n)互质(即gcd(e, φ(n)) = 1)。
通常选择e = 65537 (0x10001)。为什么?
- 它是一个质数,与绝大多数φ(n)互质的概率极高。
- 它的二进制表示是
10000000000000001,只有两个比特位是1。这使得模幂运算m^e mod n可以通过快速算法非常高效地计算,性能好。 - 历史证明其安全性足够。太小(如3)的e在某些场景下可能有风险。
第四步:计算私钥指数dd是e关于模φ(n)的模反元素。即满足:(d * e) mod φ(n) = 1或者说,d是方程e*d + k*φ(n) = 1的一个解(k为某个整数)。这可以通过扩展欧几里得算法高效求解。
至此,我们得到了:
- 公钥:
(n, e) - 私钥:
(n, d)
p, q, φ(n) 在生成d后就应该从内存中彻底清除。
2.2 加密与解密的本质
原理很简单,但蕴含了欧拉定理的魔力。
- 加密(公钥操作):对于明文消息m(需要是整数,且
0 ≤ m < n),计算密文c = m^e mod n。 - 解密(私钥操作):对于密文c,计算明文
m' = c^d mod n。
为什么解密后能得到原文?根据欧拉定理,如果m与n互质,则有m^φ(n) ≡ 1 (mod n)。 解密运算:c^d ≡ (m^e)^d ≡ m^(e*d) (mod n)。 由于e*d ≡ 1 (mod φ(n)),所以存在整数k使得e*d = 1 + k*φ(n)。 因此,m^(e*d) ≡ m^(1 + k*φ(n)) ≡ m * (m^φ(n))^k (mod n)。 如果m与n互质,由欧拉定理,m^φ(n) ≡ 1 (mod n),所以上式≡ m * 1^k ≡ m (mod n)。 即使m与n不互质(概率极低),利用中国剩余定理也能证明解密依然成立。所以,m' ≡ m (mod n),又因为m和m‘都在[0, n)范围内,所以m' = m。
2.3 前端实现的特殊挑战
在浏览器环境用JavaScript实现RSA,和在后端用Python/Java实现,挑战完全不同:
- 大整数支持:RSA的n、e、d都是非常大的整数(至少1024位,即300多位十进制数)。JavaScript原生的
Number类型最大安全整数是2^53 - 1,远远不够。我们必须依赖BigInt类型(ES2020标准),它能表示任意精度的整数。 - 性能瓶颈:模幂运算
m^e mod n是指数级运算,e很大(如65537),直接计算m**e再取模,中间结果会巨大无比,内存会爆炸。必须使用模幂运算优化算法,如“平方-乘算法”。 - 数据转换:我们要加密的是字符串(如密码),但RSA运算对象是整数。需要将字符串编码成一个大整数(编码),解密后再解码回字符串。这个编码/解码方案需要保证是确定的、可逆的,并且编码后的整数必须小于n。
- 密钥格式:实际应用中,公钥私钥通常以PEM格式(带
-----BEGIN XXX KEY-----头尾的Base64编码文本)交换。我们需要实现简单的PEM解析器,从中提取出n和e(或d)。
3. 核心模块设计与JavaScript实现
我们不搞“一步到位”的代码粘贴,而是分模块搭建,就像搭积木一样,每个模块都搞清楚。
3.1 大整数工具模块
这是我们的基石,主要解决模幂运算、模逆运算和随机大质数生成。
// utils.js - 大整数运算工具 class BigIntUtils { /** * 模幂运算:计算 (base^exponent) % modulus * 使用平方-乘算法,避免中间结果溢出(虽然BigInt不会溢出,但计算量巨大)。 * @param {bigint} base * @param {bigint} exponent * @param {bigint} modulus * @returns {bigint} */ static modPow(base, exponent, modulus) { if (modulus === 1n) return 0n; let result = 1n; base = base % modulus; let exp = exponent; while (exp > 0n) { // 如果当前指数位为1,则乘上当前的base if (exp % 2n === 1n) { result = (result * base) % modulus; } // 指数右移一位,底数平方 exp = exp >> 1n; // 等价于 exp = exp / 2n (取整) base = (base * base) % modulus; } return result; } /** * 扩展欧几里得算法:计算 a 和 b 的最大公约数 gcd(a,b),并找到 x, y 使得 ax + by = gcd(a,b) * 用于求解模反元素(私钥d)。 * @param {bigint} a * @param {bigint} b * @returns {{gcd: bigint, x: bigint, y: bigint}} */ static extendedEuclidean(a, b) { if (b === 0n) { return { gcd: a, x: 1n, y: 0n }; } const prev = this.extendedEuclidean(b, a % b); return { gcd: prev.gcd, x: prev.y, y: prev.x - (a / b) * prev.y // 注意:这里是BigInt除法,会自动取整 }; } /** * 求模反元素:计算 e 关于模 phi 的逆元 d,即 (e * d) % phi == 1 * @param {bigint} e * @param {bigint} phi * @returns {bigint | null} 逆元 d,如果不存在则返回 null */ static modInverse(e, phi) { const { gcd, x } = this.extendedEuclidean(e, phi); if (gcd !== 1n) { console.error(`e (${e}) 和 phi (${phi}) 不互质,无法求逆元。`); return null; } // 确保返回正数 return (x % phi + phi) % phi; } /** * 简单的大质数生成(仅用于演示,非密码学安全)。 * 密码学安全的质数生成需要更复杂的算法(如Miller-Rabin素性测试多次迭代)。 * @param {number} bitLength - 质数的近似比特长度 * @returns {bigint} */ static generateProbablePrime(bitLength) { // 这是一个简化的示例。实际应用请使用成熟的密码学库。 const min = 1n << BigInt(bitLength - 1); const max = (1n << BigInt(bitLength)) - 1n; while (true) { // 生成一个随机大奇数 const randomNum = min + BigInt(Math.floor(Math.random() * Number(max - min))); const candidate = randomNum | 1n; // 确保是奇数 // 简单的试除法判断(效率很低,仅用于小数字演示) if (this.isProbablePrimeSimple(candidate)) { return candidate; } } } static isProbablePrimeSimple(n) { if (n <= 1n) return false; if (n <= 3n) return true; if (n % 2n === 0n || n % 3n === 0n) return false; let i = 5n; while (i * i <= n) { if (n % i === 0n || n % (i + 2n) === 0n) return false; i += 6n; } return true; } }注意事项:这里的
generateProbablePrime函数是极度简化的,绝对不适用于真实的生产环境。真实的RSA密钥生成需要使用密码学安全的随机数生成器(CSPRNG)和像Miller-Rabin这样的概率性素性测试进行多次迭代。前端环境生成RSA密钥对并不常见,通常由后端生成或使用浏览器原生API(如crypto.subtle.generateKey)。我们这里实现是为了理解过程。
3.2 RSA密钥对生成模块
有了工具,我们就可以按照原理部分的步骤生成密钥对了。
// rsa-keygen.js import { BigIntUtils } from './utils.js'; class RSAKeyGenerator { /** * 生成RSA密钥对 * @param {number} bitLength - 模数n的比特长度,例如1024, 2048 * @returns {{publicKey: {n: bigint, e: bigint}, privateKey: {n: bigint, d: bigint}}} */ static generateKeyPair(bitLength = 512) { // 演示用512位,实际至少2048位 console.log(`正在生成 ${bitLength} 位RSA密钥对...`); // 1. 选择两个大质数p和q,长度约为n的一半 const p = BigIntUtils.generateProbablePrime(bitLength / 2); const q = BigIntUtils.generateProbablePrime(bitLength / 2); // 确保p和q不相等 if (p === q) { // 极端情况,重新生成q return this.generateKeyPair(bitLength); } console.log(`p: ${p}`); console.log(`q: ${q}`); // 2. 计算 n = p * q 和 φ(n) = (p-1)*(q-1) const n = p * q; const phi = (p - 1n) * (q - 1n); console.log(`n: ${n}`); console.log(`φ(n): ${phi}`); // 3. 选择公钥指数e,通常为65537 const e = 65537n; // 检查e是否与φ(n)互质 if (BigIntUtils.extendedEuclidean(e, phi).gcd !== 1n) { // 极小概率事件,如果发生,需要重新选择p和q(或选择其他e) console.warn(`e (65537) 与 φ(n) 不互质,重新生成密钥对。`); return this.generateKeyPair(bitLength); } // 4. 计算私钥指数d,满足 e*d ≡ 1 (mod φ(n)) const d = BigIntUtils.modInverse(e, phi); if (d === null) { throw new Error('计算私钥指数d失败'); } console.log(`e: ${e}`); console.log(`d: ${d}`); // 5. 返回密钥对(在实际应用中,应安全地销毁p, q, phi) return { publicKey: { n, e }, privateKey: { n, d } }; } /** * 将密钥对象转换为PEM格式字符串(简化版,仅包含Base64编码的DER结构) * 真实PEM解析更复杂,这里做简单拼接演示。 * @param {object} keyObj - 公钥 {n, e} 或私钥 {n, d} * @param {string} keyType - 'public' 或 'private' * @returns {string} */ static exportToPEM(keyObj, keyType) { // 简化:将n和e(或d)转换成Base64。真实PKCS#1格式有特定的ASN.1结构。 let keyData; if (keyType === 'public') { // 简单拼接 n 和 e,用逗号分隔,然后Base64 keyData = btoa(`${keyObj.n},${keyObj.e}`); return `-----BEGIN PUBLIC KEY-----\n${keyData}\n-----END PUBLIC KEY-----`; } else if (keyType === 'private') { // 注意:私钥包含更多信息,这里极度简化,切勿用于真实交换! keyData = btoa(`${keyObj.n},${keyObj.d}`); return `-----BEGIN RSA PRIVATE KEY-----\n${keyData}\n-----END RSA PRIVATE KEY-----`; } else { throw new Error('Invalid key type'); } } /** * 从PEM格式字符串解析密钥对象(简化版) * @param {string} pemString * @param {string} keyType - 'public' 或 'private' * @returns {object} */ static importFromPEM(pemString, keyType) { const header = keyType === 'public' ? '-----BEGIN PUBLIC KEY-----' : '-----BEGIN RSA PRIVATE KEY-----'; const footer = keyType === 'public' ? '-----END PUBLIC KEY-----' : '-----END RSA PRIVATE KEY-----'; const base64Data = pemString.replace(header, '').replace(footer, '').replace(/\n/g, '').trim(); const dataStr = atob(base64Data); const parts = dataStr.split(','); if (keyType === 'public' && parts.length === 2) { return { n: BigInt(parts[0]), e: BigInt(parts[1]) }; } else if (keyType === 'private' && parts.length === 2) { return { n: BigInt(parts[0]), d: BigInt(parts[1]) }; } else { throw new Error('Invalid PEM format or key type mismatch'); } } }3.3 数据编码与加密解密模块
这是连接“字符串世界”和“大整数世界”的桥梁。我们需要一个确定性的方法把字符串(比如“Hello123”)转成一个小于n的大整数。
// rsa-crypto.js import { BigIntUtils } from './utils.js'; class RSACrypto { /** * 将字符串编码为大整数。 * 方案:将每个字符的UTF-16代码点(charCodeAt)拼接成16进制字符串,再转换为BigInt。 * 注意:编码后的整数必须小于模数n。 * @param {string} text * @returns {bigint} */ static encodeString(text) { let hexString = ''; for (let i = 0; i < text.length; i++) { // 将代码点转换为4位十六进制,并填充前导零 hexString += text.charCodeAt(i).toString(16).padStart(4, '0'); } // 如果hexString为空,则返回0n return hexString ? BigInt('0x' + hexString) : 0n; } /** * 将大整数解码回字符串。 * 是encodeString的逆过程。 * @param {bigint} bigInt * @returns {string} */ static decodeString(bigInt) { let hexString = bigInt.toString(16); // 确保十六进制字符串长度是4的倍数(因为每个字符用了4位十六进制) if (hexString.length % 4 !== 0) { hexString = hexString.padStart(hexString.length + (4 - hexString.length % 4), '0'); } let text = ''; for (let i = 0; i < hexString.length; i += 4) { const hexCode = hexString.substr(i, 4); const codePoint = parseInt(hexCode, 16); text += String.fromCharCode(codePoint); } // 去除可能因填充产生的空字符 return text.replace(/\x00/g, ''); } /** * RSA加密 * @param {string} plaintext - 明文 * @param {object} publicKey - 公钥 {n, e} * @returns {string} 加密后的密文(Base64编码,便于传输) */ static encrypt(plaintext, publicKey) { const { n, e } = publicKey; // 1. 编码明文为整数m const m = this.encodeString(plaintext); console.log(`编码后的明文整数 m: ${m}`); // 2. 检查 m < n,这是RSA加密的必要条件 if (m >= n) { throw new Error(`明文编码后(${m})不小于模数n(${n})。请使用更短的明文或更大的密钥。`); } // 3. 计算密文 c = m^e mod n const c = BigIntUtils.modPow(m, e, n); console.log(`加密后的密文整数 c: ${c}`); // 4. 将大整数c转换为Base64字符串以便传输 // 先将BigInt转为16进制字符串,再转为字节数组,最后Base64 const hexC = c.toString(16); const byteArray = []; for (let i = 0; i < hexC.length; i += 2) { byteArray.push(parseInt(hexC.substr(i, 2), 16)); } const base64C = btoa(String.fromCharCode(...byteArray)); return base64C; } /** * RSA解密 * @param {string} ciphertextBase64 - Base64编码的密文 * @param {object} privateKey - 私钥 {n, d} * @returns {string} 解密后的明文 */ static decrypt(ciphertextBase64, privateKey) { const { n, d } = privateKey; // 1. 将Base64密文还原为大整数c const byteArray = Uint8Array.from(atob(ciphertextBase64), c => c.charCodeAt(0)); let hexC = ''; byteArray.forEach(byte => { hexC += byte.toString(16).padStart(2, '0'); }); const c = BigInt('0x' + (hexC || '0')); // 处理空字符串 console.log(`解密前的密文整数 c: ${c}`); // 2. 计算明文 m' = c^d mod n const mDecrypted = BigIntUtils.modPow(c, d, n); console.log(`解密后的明文整数 m': ${mDecrypted}`); // 3. 将整数解码为字符串 return this.decodeString(mDecrypted); } }实操心得:编码方案的选择上面用的编码方案(UTF-16代码点转16进制)很简单,但有明显缺点:1) 编码效率不高,一个字符用了4位十六进制(2字节),但实际可能只用了一部分值域;2) 没有考虑Unicode字符超出BMP(基本多文种平面)的情况(
charCodeAtvscodePointAt)。 更健壮的方案是使用TextEncoder将字符串转为Uint8Array,再将其视为一个大端字节序的大整数。但要注意,JavaScript的BigInt是从十六进制字符串构造的,而十六进制字符串是从字节数组转换来的,需要处理好字节序和前导零。这里为了原理清晰,使用了简化方案。在实际项目中,如果直接处理二进制数据,编码/解码步骤可以更高效。
4. 完整流程演示与集成测试
现在我们把所有模块组合起来,跑一个完整的流程。
// main.js - 集成演示 import { RSAKeyGenerator } from './rsa-keygen.js'; import { RSACrypto } from './rsa-crypto.js'; function runDemo() { console.log('=== RSA从前端原理到实现 - 完整演示 ==='); // 1. 生成密钥对(演示用512位,速度快) const keyPair = RSAKeyGenerator.generateKeyPair(512); console.log('公钥 (n, e):', keyPair.publicKey); console.log('私钥 (n, d):', keyPair.privateKey); // 2. 模拟导出/导入PEM格式(简化版) const publicKeyPEM = RSAKeyGenerator.exportToPEM(keyPair.publicKey, 'public'); const privateKeyPEM = RSAKeyGenerator.exportToPEM(keyPair.privateKey, 'private'); console.log('\n--- 模拟密钥交换 ---'); console.log('公钥PEM格式:'); console.log(publicKeyPEM); console.log('\n私钥PEM格式(切勿泄露!):'); console.log(privateKeyPEM); // 假设公钥通过网络传输给了前端,前端解析它 const importedPublicKey = RSAKeyGenerator.importFromPEM(publicKeyPEM, 'public'); console.log('\n前端解析出的公钥:', importedPublicKey); // 3. 前端加密数据 const originalMessage = 'Hello, RSA! 密码: 123@abc'; console.log(`\n--- 前端加密 ---`); console.log(`原始明文: "${originalMessage}"`); let encryptedBase64; try { encryptedBase64 = RSACrypto.encrypt(originalMessage, importedPublicKey); console.log(`加密后的密文(Base64): ${encryptedBase64}`); } catch (error) { console.error('加密失败:', error.message); return; } // 4. 后端(或持有私钥的一方)解密 console.log(`\n--- 后端解密 ---`); // 后端解析私钥PEM(实际中私钥不会这样传输,而是安全存储) const importedPrivateKey = RSAKeyGenerator.importFromPEM(privateKeyPEM, 'private'); const decryptedMessage = RSACrypto.decrypt(encryptedBase64, importedPrivateKey); console.log(`解密后的明文: "${decryptedMessage}"`); // 5. 验证 if (decryptedMessage === originalMessage) { console.log('\n✅ 成功!加密解密验证通过。'); } else { console.log('\n❌ 失败!解密结果与原文不符。'); console.log(`原文长度: ${originalMessage.length}, 解密文长度: ${decryptedMessage.length}`); } } // 运行演示 runDemo();将上述代码保存为HTML文件,并通过一个支持ES6模块的本地服务器(如live-server)打开,在浏览器控制台就能看到完整的运行日志。
5. 常见问题、性能考量与实战建议
自己实现一遍后,你会对下面这些常见问题有更深的理解。
5.1 为什么加密的明文不能太长?
这是由RSA的原理决定的:加密后的密文c是一个介于0到n-1之间的整数。而我们的编码过程是将整个明文字符串转成一个整数m。如果明文字符串太长,编码后的整数m可能会大于或等于模数n,导致加密失败(m >= n时,m^e mod n无法唯一还原m)。
解决方案:
- 使用混合加密(推荐):这是实际中的标准做法。RSA用来加密一个随机的对称密钥(比如AES-256的密钥),然后用这个对称密钥去加密实际的大段数据。这样既利用了RSA的非对称特性解决密钥分发,又利用了对称加密的高效性。
- 分块加密:将长明文按固定大小分块,每块单独用RSA加密。但RSA加密很慢,且每块大小受限于n的字节数(例如2048位的n是256字节,还要留出填充方案的字节,所以实际数据块更小),效率很低,一般不用于直接加密数据。
5.2 为什么我的实现和jsencrypt结果不一样?
如果你用我们的代码加密“hello”,再用jsencrypt加密“hello”,得到的Base64字符串肯定不同。原因有几个:
- 编码方案不同:jsencrypt内部可能使用了不同的字符串到整数的编码方式(如PKCS#1 v1.5填充方案)。填充不仅解决了明文长度问题,还增加了安全性(防止某些攻击)。
- 密钥格式:jsencrypt使用的PEM密钥是标准的PKCS#1或PKCS#8格式,包含了完整的ASN.1结构。我们简化的PEM只是把n和e用逗号拼接,完全不兼容。
- 大数表示:虽然都基于BigInt,但内部运算和转换的细节可能有差异。
重要提示:我们的实现是教学目的,用于透彻理解原理。在生产环境中,请务必使用经过严格审计的成熟库,如
jsencrypt、node-rsa或Web Crypto API。自己实现的密码学代码极易因侧信道攻击或细微错误而导致安全漏洞。
5.3 前端RSA加密的性能如何?
很慢。一次RSA加密(尤其是2048位)在浏览器中可能需要几十到几百毫秒,这与CPU性能、JavaScript引擎优化有关。这也是为什么RSA只用于加密关键小数据(如会话密钥、密码摘要),而不是整个请求体。
优化点:
- 使用固定的、较小的公钥指数
e=65537,因为它二进制中1很少,模幂运算快。 - 确保你的
modPow函数使用了高效的“平方-乘”算法,如上文所示。 - 对于频繁操作,考虑使用Web Worker将加密计算放到后台线程,避免阻塞UI。
5.4 如何在后端验证前端的RSA加密?
前端加密后,传输给后端的是Base64编码的密文字符串。后端(如Node.js、Java、Python)需要:
- 用对应的私钥(通常从文件或环境变量加载)初始化解密器。
- 将Base64密文解码为字节数组/大整数。
- 执行RSA解密操作。
- 得到解密后的明文(或对称密钥)。
关键点是前后端的填充方案必须一致。如果前端用了jsencrypt(默认使用PKCS#1 v1.5填充),后端也必须使用相同的填充模式来解密。
5.5 除了加密,RSA还能做什么?
- 数字签名:过程与加密相反。发送者用私钥对消息的哈希值进行“签名”(即加密哈希值),接收者用公钥“验签”(即解密签名,与重新计算的哈希值对比)。这证明了消息的来源可信和完整性。
- 密钥交换:如前所述,是TLS/SSL等安全协议的基础。
自己动手实现一遍RSA,哪怕只是一个教学版本的,你对非对称加密的理解也会远超停留在API调用层面。下次当你看到“SSL证书”、“公钥私钥对”、“数字签名”这些词时,脑子里浮现的将不再是模糊的概念,而是一串串清晰的数学公式和代码逻辑。这才是真正的“从零构建”带来的价值。