1. 项目概述:为什么需要手搓RSA加密通信?
在前后端分离的现代Web开发里,数据安全是个绕不开的话题。你肯定遇到过这样的场景:用户在前端页面输入了密码、身份证号或者支付信息,点击提交,这些敏感数据就通过网络飞向了后端服务器。这个传输过程,如果用的是最基础的HTTP协议,数据就是“裸奔”的,任何一个中间环节都可能被截获和窥探。虽然HTTPS(HTTP over SSL/TLS)已经成为标配,它能解决传输层的加密问题,但有时候我们还需要在应用层再加一把锁。比如,后端需要对前端提交的密文进行签名验证,或者前端需要加密一些在本地存储的配置信息,防止被轻易篡改。这时候,非对称加密算法RSA就派上用场了。
RSA这个名字你可能不陌生,它是由三位科学家姓氏首字母组成的。它的核心魅力在于“非对称”:有一对密钥,一个叫公钥(Public Key),可以大大方方发给任何人;另一个叫私钥(Private Key),必须自己严加保管。用公钥加密的数据,只有对应的私钥才能解开;反过来,用私钥签名的数据,任何人都能用公钥验证其真伪。这个特性天生就适合前后端通信:后端生成密钥对,把公钥丢给前端;前端用这个公钥加密敏感数据再发送;后端收到后,用自己的私钥解密。这样一来,即使请求被截获,攻击者没有私钥也束手无策。
这个项目,就是带你从零开始,用Python作为后端,用原生JavaScript作为前端,实现一套完整的RSA加密解密通信流程。我们不依赖那些高度封装的、一键完成所有步骤的第三方库(虽然它们很好用),而是选择相对底层的cryptography(Python)和jsencrypt(JS)库,目的是让你能看清密钥的格式、加密的输入输出、以及数据在前后端流转时需要的那些关键转换步骤。相信我,亲手实现一遍之后,你对证书、密钥、编码这些概念的理解会深刻得多。
2. 核心原理与工具选型:为什么是它们?
在动手之前,我们得先搞清楚要用什么工具,以及为什么选它们。市面上相关的库很多,每个选择背后都有权衡。
2.1 RSA算法再认识:不是所有RSA都一样
首先得破除一个误区:不是说用了RSA就万事大吉了。RSA算法本身在实现时,有很多参数和模式,选错了轻则无法解密,重则存在安全风险。
密钥长度:这是安全性的基石。现在普遍认为1024位的RSA密钥已经不够安全,容易被暴力破解。所以我们的项目直接从2048位起步。cryptography库默认生成的就是2048位,这是一个当前兼顾性能与安全性的选择。当然,对安全性要求极高的场景(如金融根证书)可以考虑4096位,但加解密速度会明显下降。
填充方案(Padding):这是最容易出问题的地方。原始的RSA算法是“教科书式”的,存在固有的安全缺陷。因此在实际使用时,必须对明文进行填充。最常见的两种是:
- PKCS1_v1_5: 比较老的填充标准,仍然被广泛支持,但在某些特定情况下可能存在风险。
- OAEP(Optimal Asymmetric Encryption Padding): 目前推荐使用的填充方式,安全性比PKCS1_v1_5更高。我们的项目将统一使用OAEP填充。
编码与格式:密钥和加密后的数据在计算机里都是一串二进制字节(bytes)。为了在网络传输或存储中方便处理,我们需要把它们编码成文本。最常用的就是Base64和PEM格式。
- PEM格式: 这是一种用来封装密钥和证书的文本格式,通常以
-----BEGIN XXX-----和-----END XXX-----包裹着Base64编码的内容。它非常常见,易于阅读和分发。 - Base64字符串: 纯粹的数据编码,没有PEM的头尾标记,更紧凑。前端
jsencrypt库通常需要PEM格式的公钥。
2.2 后端工具:Python cryptography
为什么选cryptography而不是rsa或PyCrypto?
cryptography: 这是目前Python生态中密码学库的“事实标准”。它底层由C语言实现,速度快,且由专业的密码学工程师维护,API设计现代、清晰,安全性有保障。它同时支持高层次(易于使用)和低层次(高度控制)的API。rsa: 一个纯Python实现的库,虽然简单易懂,但性能较差,且在一些高级特性和安全性更新上可能滞后。PyCrypto/PyCryptodome: 功能强大但API较为老旧和复杂,对于新手不够友好。
我们的项目将使用cryptography.hazmat.primitives.asymmetric模块下的rsa和padding来生成密钥、进行加解密。hazmat是“危险材料”的意思,提醒我们这里提供的都是底层原语,需要正确使用。
2.3 前端工具:JSEncrypt
为什么选jsencrypt而不是node-rsa或自己用Web Crypto API?
jsencrypt: 一个专门为浏览器环境设计的、轻量级的RSA加密库。它最大的优点是API极其简单,几行代码就能完成加密,并且完美支持从PEM格式字符串加载公钥。这对于前端开发者来说非常友好。- Web Crypto API: 这是浏览器原生的加密API,更安全、性能可能更好。但是,它的API相对复杂,并且对密钥格式(尤其是导入PEM格式的密钥)支持不那么直接,需要额外的转换步骤,对新手门槛较高。
node-rsa: 主要用于Node.js后端环境,在纯浏览器端使用不便。
jsencrypt封装了这些复杂性,让我们能专注于业务逻辑。它内部默认使用的也是OAEP填充,与我们的后端设置匹配。
注意:
jsencrypt是一个纯前端库,它的私钥操作(如解密、签名)在浏览器中执行是不安全的,因为私钥可能会泄露。在我们的场景中,前端只负责用公钥加密,私钥解密永远在后端安全的服务器环境中进行。
3. 后端实现:生成密钥与提供接口
后端的任务是三件事:生成RSA密钥对、提供一个接口让前端获取公钥、提供另一个接口接收前端加密数据并解密。
3.1 环境准备与依赖安装
首先,确保你的Python环境(建议3.7以上)已经就绪。我们使用pip安装必需的库。除了cryptography,我们还需要一个Web框架来提供接口,这里选择轻量级的Flask。
pip install cryptography flask如果安装速度慢,可以考虑使用国内的镜像源,例如:
pip install cryptography flask -i https://pypi.tuna.tsinghua.edu.cn/simple3.2 核心代码分步解析
我们创建一个名为rsa_server.py的文件。
第一步:生成并持久化RSA密钥对
密钥对生成一次即可,不需要每次启动服务都重新生成。我们将它们保存为PEM格式的文件。
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives import serialization import os def generate_rsa_key_pair(): """ 生成2048位的RSA私钥和对应的公钥,并保存为PEM文件。 """ # 生成私钥 private_key = rsa.generate_private_key( public_exponent=65537, # 标准公钥指数,固定用这个值 key_size=2048, ) # 序列化私钥为PEM格式(不加密) private_pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption() # 生产环境应考虑加密存储私钥 ) # 从私钥导出公钥 public_key = private_key.public_key() public_pem = public_key.public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo ) # 保存到文件 with open('private_key.pem', 'wb') as f: f.write(private_pem) with open('public_key.pem', 'wb') as f: f.write(public_pem) print("RSA密钥对已生成并保存为 private_key.pem 和 public_key.pem") return private_key, public_key # 如果文件不存在,则生成密钥 if not os.path.exists('private_key.pem'): generate_rsa_key_pair()关键点解析:
public_exponent=65537: 这是一个质数,二进制表示为10000000000000001,在安全性和计算效率之间取得了很好的平衡,是RSA的标准选择。serialization.NoEncryption(): 这里为了演示简便,私钥文件没有用密码进行二次加密。在生产环境中,这是极其危险的!私钥文件必须用强密码加密存储,并在程序运行时从安全的地方(如环境变量、密钥管理服务)获取密码后再加载。- 生成的
public_pem正是前端jsencrypt所需要的格式。
第二步:创建Flask应用与接口
from flask import Flask, jsonify, request from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives import hashes import base64 app = Flask(__name__) # 从文件加载密钥(启动时加载一次) with open('private_key.pem', 'rb') as f: private_key = serialization.load_pem_private_key( f.read(), password=None, # 如果私钥有密码,这里需要提供 ) with open('public_key.pem', 'rb') as f: public_key_pem = f.read().decode('utf-8') # 转换为字符串供前端使用 @app.route('/get_public_key', methods=['GET']) def get_public_key(): """接口1:向前端返回公钥(PEM格式字符串)""" return jsonify({'public_key': public_key_pem}) @app.route('/decrypt_data', methods=['POST']) def decrypt_data(): """接口2:接收前端加密的Base64数据,并用私钥解密""" data = request.get_json() if not data or 'encrypted_data' not in data: return jsonify({'error': 'Missing encrypted_data'}), 400 encrypted_b64 = data['encrypted_data'] try: # 1. 前端传回的是Base64字符串,需要解码为字节 encrypted_bytes = base64.b64decode(encrypted_b64) # 2. 使用私钥进行解密,填充方式必须与前端加密时一致(OAEP) decrypted_bytes = private_key.decrypt( encrypted_bytes, padding.OAEP( mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None ) ) # 3. 将解密后的字节转换为字符串(假设前端加密的是文本) decrypted_text = decrypted_bytes.decode('utf-8') return jsonify({ 'status': 'success', 'decrypted_data': decrypted_text }) except Exception as e: # 捕获所有异常,例如:填充错误、数据被篡改、密钥不匹配等 return jsonify({'error': f'Decryption failed: {str(e)}'}), 400 if __name__ == '__main__': app.run(debug=True, port=5000)关键点解析:
padding.OAEP: 这里明确指定了OAEP填充方案,并使用了SHA256作为哈希算法。这个配置必须与前端的jsencrypt默认配置完全匹配,否则解密会失败。jsencrypt默认使用的就是SHA256的OAEP。base64.b64decode: 这是一个关键转换。网络传输中二进制数据不方便,所以前端会将加密后的字节数组进行Base64编码再传输。后端必须先解码,才能进行解密操作。- 错误处理: 解密过程可能因为多种原因失败(数据损坏、密钥不对、填充错误)。用
try...except捕获异常并返回友好的错误信息至关重要,避免将服务器内部细节暴露给客户端。
4. 前端实现:加密与发送数据
前端的工作流程是:页面加载后,从后端获取公钥;当用户提交表单时,用公钥加密敏感字段;将加密后的Base64字符串通过AJAX发送给后端解密接口。
4.1 引入JSEncrypt库
你可以直接使用CDN,或者下载jsencrypt.min.js到本地。这里使用CDN方式,在HTML的<head>中引入。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>RSA加密通信演示</title> <script src="https://cdn.jsdelivr.net/npm/jsencrypt@3.3.1/bin/jsencrypt.min.js"></script> </head> <body> <!-- 页面内容 --> </body> </html>4.2 核心JavaScript逻辑
我们在页面中添加一个简单的表单,并编写对应的JavaScript代码。
<body> <h2>敏感信息提交(RSA加密)</h2> <form id="secureForm"> <div> <label for="username">用户名:</label> <input type="text" id="username" name="username"> </div> <div> <label for="password">密码:</label> <input type="password" id="password" name="password"> </div> <button type="submit">提交</button> </form> <div id="result"></div> <script> // 全局变量,用于存储从后端获取的公钥 let publicKeyPem = ''; // 页面加载完成后,自动从后端获取公钥 window.onload = function() { fetch('http://localhost:5000/get_public_key') .then(response => response.json()) .then(data => { publicKeyPem = data.public_key; console.log('公钥获取成功'); document.getElementById('result').innerHTML = '<p style="color:green;">已获取服务器公钥,可以安全提交。</p>'; }) .catch(error => { console.error('获取公钥失败:', error); document.getElementById('result').innerHTML = '<p style="color:red;">无法获取公钥,请检查后端服务。</p>'; }); }; // 表单提交事件处理 document.getElementById('secureForm').addEventListener('submit', function(event) { event.preventDefault(); // 阻止表单默认提交 if (!publicKeyPem) { alert('公钥未就绪,请稍后再试。'); return; } const username = document.getElementById('username').value; const password = document.getElementById('password').value; // 构造要加密的数据对象(通常只加密敏感字段) const plainData = { password: password // 只加密密码,用户名可以明文传输 // 你可以根据需要添加其他字段,如身份证号、手机号等 }; const plainText = JSON.stringify(plainData); // 转换为JSON字符串 // 使用JSEncrypt进行加密 const encryptor = new JSEncrypt(); encryptor.setPublicKey(publicKeyPem); // 设置公钥 const encryptedBase64 = encryptor.encrypt(plainText); // 加密并返回Base64字符串 if (!encryptedBase64) { alert('加密失败!请检查公钥格式或待加密数据。'); return; } console.log('加密后的Base64数据:', encryptedBase64); // 将加密后的数据发送到后端解密接口 fetch('http://localhost:5000/decrypt_data', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ username: username, // 明文用户名 encrypted_data: encryptedBase64 // 加密后的密码数据 }) }) .then(response => response.json()) .then(data => { const resultDiv = document.getElementById('result'); if (data.status === 'success') { // 解密成功,显示解密后的数据(仅用于演示,生产环境不应显示密码) const decryptedObj = JSON.parse(data.decrypted_data); resultDiv.innerHTML = `<p style="color:green;">提交成功!</p> <p>后端解密出的密码是:${decryptedObj.password}</p> <p>(此为演示,实际场景不会返回密码明文)</p>`; console.log('后端解密结果:', decryptedObj); } else { resultDiv.innerHTML = `<p style="color:red;">提交失败:${data.error}</p>`; } }) .catch(error => { console.error('请求失败:', error); document.getElementById('result').innerHTML = `<p style="color:red;">网络请求异常:${error.message}</p>`; }); }); </script> </body>关键点解析:
encryptor.setPublicKey(publicKeyPem): 这是最关键的一步,必须确保传入的publicKeyPem是完整的、格式正确的PEM字符串(包含-----BEGIN PUBLIC KEY-----和-----END PUBLIC KEY-----)。encryptor.encrypt(plainText): 该方法接受一个字符串参数,返回一个Base64编码的字符串。它内部完成了RSA-OAEP加密和Base64编码。- 加密策略: 我们只加密了
password字段,而username是明文传输的。这是一种常见的权衡,因为用户名通常不算是最高机密,且可能需要用于日志或查询。你可以根据实际需求决定加密哪些字段。注意,RSA算法本身有长度限制(与密钥长度有关),加密的数据不能太大。对于很长的数据,通常采用“混合加密”:用RSA加密一个随机的对称密钥(如AES密钥),再用这个对称密钥加密大量数据。 - 跨域问题: 本例中前端页面通过
file://协议打开,直接请求localhost:5000会遇到跨域问题(CORS)。为了解决这个问题,你需要在后端Flask应用中添加CORS支持,或者使用一个简单的HTTP服务器(如python -m http.server)来运行前端页面,使其通过http://协议访问。更简单的方法是,在后端app.run()之前添加一个Flask的CORS扩展,或者手动添加响应头(仅用于开发测试):
注意: 生产环境中应将@app.after_request def add_cors_headers(response): response.headers['Access-Control-Allow-Origin'] = '*' response.headers['Access-Control-Allow-Headers'] = 'Content-Type' return response'*'替换为具体的前端域名,并配置更严格的CORS策略。
5. 运行、测试与问题排查
5.1 完整运行流程
准备后端:
- 将上面的
rsa_server.py代码保存。 - 在终端进入文件所在目录,运行
python rsa_server.py。首次运行会生成private_key.pem和public_key.pem文件。 - 看到输出
* Running on http://127.0.0.1:5000/表示后端启动成功。
- 将上面的
准备前端:
- 将上面的HTML和JS代码保存为
index.html。 - 由于有跨域请求,不能直接双击打开。可以在
index.html所在目录打开终端,运行一个简单的HTTP服务器:# Python 3 python -m http.server 8000 - 然后在浏览器中访问
http://localhost:8000。
- 将上面的HTML和JS代码保存为
测试:
- 打开浏览器控制台(F12),刷新页面,应该能看到“公钥获取成功”的日志。
- 在表单中输入用户名和密码,点击提交。
- 观察浏览器控制台的网络请求,可以看到一个
POST /decrypt_data的请求,请求体里包含了encrypted_data这个长长的Base64字符串。 - 如果一切正常,页面上会显示“提交成功!”以及后端解密出的密码(仅演示)。后端终端也会打印出相应的请求日志。
5.2 常见问题与排查技巧实录
在实际操作中,你几乎一定会遇到下面这几个问题。我把它们和解决方法整理成了表格。
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
前端报错:JSEncrypt is not defined | 1.jsencrypt库未成功加载。2. 脚本执行顺序问题。 | 1. 检查浏览器开发者工具的“网络(Network)”标签页,确认jsencrypt.min.js是否加载成功(状态码200)。2. 确保 <script>标签在调用JSEncrypt的代码之前。 |
前端加密失败,encrypt()返回false | 1. 公钥字符串格式错误或损坏。 2. 待加密数据不是字符串类型。 3. 待加密数据过长,超出RSA密钥长度限制。 | 1. 在setPublicKey后,打印encryptor.getPublicKey()或直接console.log(publicKeyPem),检查公钥字符串是否完整,首尾的-----BEGIN...标记是否齐全,中间是否有非法字符或换行符丢失。2. 确保 plainText是字符串。使用JSON.stringify转换对象。3. 对于2048位密钥,OAEP填充下能加密的明文长度大约在190字节左右。如果数据太长,需要采用“混合加密”方案。 |
后端解密失败,报填充错误 (Invalid padding) | 这是最常见的问题!前后端填充方案不匹配。 | 1.确认后端使用的是padding.OAEP,并且哈希算法是hashes.SHA256()。2.确认前端 jsencrypt是较新版本(如3.x)。旧版本可能默认使用PKCS1_v1_5。你可以尝试在前端初始化时指定:const encryptor = new JSEncrypt({default_key_size: 2048, default_public_exponent: '010001', log: false});,但主要确保库版本。 |
后端解密失败,报ValueError: Encryption/decryption failed | 1. 前端传过来的Base64字符串在传输过程中可能被修改(如URL编码问题)。 2. 私钥与加密用的公钥不匹配。 | 1. 在后端解密前,打印接收到的encrypted_b64,与前端加密后打印的encryptedBase64进行对比,看是否一致。注意Base64字符串中的+、/、=在URL传输时可能需要特殊处理。如果通过URL参数传递,需使用encodeURIComponent和decodeURIComponent。我们用的是POST JSON,通常没问题。2. 确保后端加载的 private_key.pem与当初生成public_key.pem的私钥是同一对。可以重新生成一对密钥试试。 |
| 浏览器控制台报CORS跨域错误 | 前端页面源(如http://localhost:8000)与后端API源(http://localhost:5000)不同。 | 按照上文【关键点解析4】的方法,在后端Flask应用中添加CORS响应头。这是开发阶段最便捷的解决方案。生产环境需精确配置。 |
| 前端获取公钥失败 | 后端/get_public_key接口未启动或路径错误。 | 1. 直接在浏览器访问http://localhost:5000/get_public_key,看是否能返回JSON数据。2. 检查前端 fetch的URL是否正确。3. 检查后端Flask服务是否正常运行,有无报错。 |
一个关键的实操心得:当遇到解密失败时,不要只看后端错误日志。一定要打开浏览器的开发者工具,在“网络(Network)”标签页中找到那个发送加密数据的请求,点击查看“请求负载(Request Payload)”,把里面的encrypted_data值完整复制出来。然后,写一个简单的Python测试脚本,用你的私钥手动解密这个字符串,看看是否能成功。这样可以快速定位问题是出在前端加密环节,还是后端解密环节,或者是数据传输环节。
6. 进阶考量与安全实践
实现基础功能只是第一步,要把这套机制用于真实项目,还需要考虑更多。
6.1 性能与数据长度限制
RSA算法计算比较慢,且加密的数据长度受密钥长度限制。2048位密钥的RSA,其能加密的明文长度(字节)约为:密钥长度/8 - 填充开销。对于OAEP填充,这个值大约在190字节左右。
这意味着你不能用它直接加密一整篇文章或一张图片。正确的做法是采用“混合加密”:
- 前端随机生成一个对称密钥(比如AES-256的密钥)。
- 前端用这个对称密钥加密你的大量数据(如文件、长文本)。
- 前端用后端的RSA公钥加密这个对称密钥。
- 前端将
加密后的对称密钥和用该对称密钥加密的数据一起发送给后端。 - 后端用RSA私钥解密出对称密钥,再用对称密钥解密出原始数据。
这样既利用了RSA的非对称特性安全交换密钥,又利用了对称加密算法的高效性来处理大数据。
6.2 密钥管理与轮转
- 私钥安全: 再次强调,
private_key.pem绝不能放在代码仓库或能被公开访问的地方。生产环境中应该:- 使用密码加密存储私钥文件。
- 将密码存储在环境变量或专业的密钥/密码管理服务(如HashiCorp Vault, AWS KMS)中。
- 在应用启动时从安全源加载密钥。
- 公钥分发: 我们的例子是通过一个接口动态获取。也可以考虑将公钥直接嵌入前端代码或配置文件,但这样不利于轮转。
- 密钥轮转: 任何密钥都不应该永久使用。应制定策略定期(如每年)更换密钥对。更换时,需要有一个过渡期,新旧公钥同时有效,以确保正在进行的会话或请求不会中断。
6.3 完善通信安全
我们的示例只实现了“加密”,一个完整的安全通信还需要考虑:
- 防重放攻击: 攻击者可能截获你加密的请求数据包,然后原封不动地重复发送给服务器。可以在待加密的数据中加入一个时间戳和随机数(Nonce),后端验证该时间戳在合理窗口内,且随机数未被使用过。
- 完整性校验: 虽然RSA-OAEP本身提供一定的完整性保护,但更常见的做法是同时进行签名。后端可以用私钥对响应数据签名,前端用公钥验签,确保数据在传输过程中未被篡改。
- HTTPS是基础:应用层RSA加密绝不能替代HTTPS!HTTPS提供了端到端的传输层加密、服务器身份认证(防中间人攻击)和完整性保护。我们实现的RSA加密是在HTTPS这个安全通道之上,对最敏感的数据再加的一把锁,属于“纵深防御”策略。
6.4 前端代码的隐蔽性
我们前端的公钥是硬编码或从接口获取的,这本身是公开信息,没问题。但加密逻辑是暴露在浏览器源码中的。虽然混淆和压缩能增加一点分析难度,但一个有心的攻击者仍然可以分析出你的加密流程。因此,前端安全永远是一种“增加攻击成本”的博弈,核心机密和关键逻辑必须放在后端。不要试图在前端隐藏真正的加密算法或密钥。
手把手实现一遍之后,你会发现RSA加密通信的脉络变得非常清晰:生成密钥、分发公钥、前端加密、后端解密。每一步的坑,比如填充方案要对齐、Base64编码解码、CORS问题、数据长度限制,都是实践中必须跨过去的坎。这套流程不仅适用于密码传输,任何需要前端保密提交的数据,比如问卷中的个人信息、一键登录的临时令牌等,都可以套用这个模式。