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互质。对于两个质数的乘积npqφ(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! 密码: 123abc; 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只用于加密关键小数据如会话密钥、密码摘要而不是整个请求体。优化点使用固定的、较小的公钥指数e65537因为它二进制中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证书”、“公钥私钥对”、“数字签名”这些词时脑子里浮现的将不再是模糊的概念而是一串串清晰的数学公式和代码逻辑。这才是真正的“从零构建”带来的价值。