1. 项目概述当公文流转遇上RSA加密最近在做一个挺有意思的项目一个基于Web的、集成了RSA加密功能的公文管理系统。这听起来可能有点“老生常谈”毕竟公文管理和加密都不是新概念但当你真正把这两者结合并且要在一个Web环境里跑起来时你会发现里面全是细节和“坑”。这个项目的核心目标很明确在浏览器里安全地完成公文的起草、流转、审批和归档。安全是重中之重而RSA非对称加密就是为这个“重中之重”上的保险。为什么是RSA在公文流转场景里最大的痛点就是如何确保公文内容在传输和存储过程中的机密性与完整性。传统的用户名密码登录只能解决“谁进来了”的问题解决不了“数据会不会被中间人偷看或篡改”的问题。想象一下一份涉及重要决策的请示报告从A部门传到B部门再呈报给领导这中间的每一个网络环节理论上都存在风险。RSA的公钥加密、私钥解密机制完美适配了这种“一对多”的广播式安全需求。系统可以用一个公开的公钥对公文进行加密只有持有对应私钥的授权人才能解密查看。这样一来即使数据包在传输中被截获没有私钥也是白搭。这个系统适合谁呢首先是各类对内部文件流转安全性有要求的企事业单位、政府机构的IT开发或运维人员你们需要一套现成的、可落地的安全方案。其次是对Web全栈开发尤其是前后端安全交互、密码学应用感兴趣的中高级开发者。我会把从架构设计、前后端密钥处理、到实际加解密性能优化的整个“踩坑”历程都摊开来讲你可以直接拿去参考甚至复现一个更完善的版本。2. 系统核心架构与设计思路拆解做一个带强加密的系统和做一个普通的管理系统设计思路有本质区别。你不能只想着增删改查得时刻把“密钥生命周期管理”和“加解密性能”放在核心位置。2.1 整体技术栈选型与考量前端我选择了Vue 3 TypeScript Vite。Vue 3的Composition API在管理复杂的加密组件状态时非常清晰TypeScript能极大减少在处理BigInteger大整数RSA运算的基础等类型时可能出现的低级错误Vite则提供了极快的热更新速度方便调试。为什么不选React其实都可以只是Vue的单文件组件在组织“上传-加密-发送”这类连贯操作时我个人觉得模板更直观。后端选用Spring Boot。原因很简单生态成熟特别是对密码学操作的支持。Java自带的java.security包功能强大且稳定像KeyPairGenerator、Cipher这些类都是久经考验的。虽然Node.js也有crypto模块但在处理企业级应用、复杂的权限模型以及与各种中间件如消息队列、定时任务集成时Spring Boot的整套解决方案更省心。数据库用了MySQL存储加密后的密文TEXT或BLOB类型和与公文关联的元数据如公文号、标题、发送方、接收方、加密使用的密钥ID等。最关键的加密库前端用的是jsencrypt和crypto-js。jsencrypt专门用于RSA加解密API简洁crypto-js则用于辅助工作比如在RSA加密前先使用更快的AES算法加密公文内容本身然后用RSA加密AES的密钥这是一种典型的混合加密模式能兼顾效率和安全性。后端就直接使用Java标准库。2.2 核心业务流程与安全边界设计系统的核心流程围绕“加密发送”和“解密查看”展开。公文发送流程用户在前端起草公文点击“加密发送”。前端随机生成一个AES密钥比如256位用这个AES密钥对称加密公文正文和附件如果附件不大。对称加密速度快适合大数据量。前端向后台请求接收者的RSA公钥。这里注意不是用发送者的私钥而是用接收者的公钥。这确保了只有目标接收者能解密。前端用接收者的RSA公钥加密上一步生成的AES密钥。因为AES密钥本身很短一个字符串非常适合RSA加密。前端将RSA加密后的AES密钥AES加密后的公文内容一起打包发送给后端。后端验证用户权限后将这两个密文包存储到数据库。至此后端服务器也从未接触过明文公文内容或明文AES密钥实现了端到端加密的增强安全。公文查看流程授权用户在前端查看公文列表点击某一条。前端从后端获取到该条公文对应的密文包加密的AES密钥 加密的公文内容。前端使用用户自己的RSA私钥去尝试解密“加密的AES密钥”。这个私钥通常由用户在首次登录时在本地生成并妥善保管例如导出为文件或由系统提供基于口令的保护存储绝对不上传至服务器。这是安全设计的核心。如果私钥匹配即用户是预期的接收者则能成功解密出AES密钥。再用解密出来的AES密钥去解密公文内容最终在浏览器中渲染出明文。关键设计心法这个设计的精髓在于“密钥不上云”。用户的RSA私钥始终留在其本地前端环境如浏览器的IndexedDB但需注意清理策略。服务器只存储公钥和密文。即使服务器被完全攻破攻击者拿到的也只是无法解密的密文和一堆公钥无法获得任何一篇公文的内容。这比单纯用SSL/TLS传输层加密又进了一步实现了应用层的数据安全。3. 前后端RSA密钥生成与处理全解析密钥是安全的基石处理不好整个系统就是纸糊的。3.1 前端密钥对的生成与存储在前端生成RSA密钥对我们使用jsencrypt。但需要注意的是纯JavaScript生成大强度的密钥如2048位以上在性能上是个挑战且可能不够随机。因此在实际生产中更推荐的做法是密钥对由后端的高强度随机数生成器生成然后通过安全通道如已建立的HTTPS连接将私钥加密后分发给前端。不过对于研究或内部系统前端生成也是一种可行方案。// 使用 jsencrypt 生成密钥对示例生产环境需考虑性能和安全 import JSEncrypt from jsencrypt; function generateKeyPair() { const encrypt new JSEncrypt({ default_key_size: 2048 }); // 指定密钥长度 // 生成密钥对这是一个同步操作对于2048位密钥可能会有可感知的延迟 encrypt.getKey(); const privateKey encrypt.getPrivateKey(); // 获取PEM格式的私钥 const publicKey encrypt.getPublicKey(); // 获取PEM格式的公钥 return { privateKey, publicKey }; }私钥存储是前端最大的安全挑战。你不能把它明文丢在localStorage里。我采用的方案是基于用户口令的加密当用户第一次生成或导入私钥时要求其输入一个强口令。使用这个口令通过crypto-js的PBKDF2算法派生出一个密钥再用这个密钥通过AES算法加密原始的PEM格式私钥然后将加密后的密文存储在localStorage或IndexedDB中。会话级内存存储用户登录成功后在内存中解密并持有私钥明文。当用户关闭浏览器标签页或显式退出登录时内存中的私钥被清除。这样私钥的明文只在浏览器会话内存中存在相对安全。// 使用口令加密私钥的示例 import CryptoJS from crypto-js; function encryptPrivateKeyWithPassword(privateKeyPem, password) { const salt CryptoJS.lib.WordArray.random(128/8); // 生成随机盐 const key CryptoJS.PBKDF2(password, salt, { keySize: 256/32, iterations: 10000 }); const iv CryptoJS.lib.WordArray.random(128/8); const encrypted CryptoJS.AES.encrypt(privateKeyPem, key, { iv: iv }); // 需要将 salt, iv 和加密后的密文一起存储解密时需要它们 return { ciphertext: encrypted.ciphertext.toString(CryptoJS.enc.Base64), salt: salt.toString(CryptoJS.enc.Base64), iv: iv.toString(CryptoJS.enc.Base64) }; }3.2 后端公钥的存储与管理后端需要建立一个user_key表至少包含字段user_id,public_key_pem存储PEM格式的公钥key_algorithm如RSAkey_length如2048created_at。当用户在前端生成密钥对后需要将公钥上传到后端进行注册或更新。这个接口必须是经过严格身份认证的如JWT Token并且要防止重放攻击确保公钥的更新是用户本人操作。后端接收到公钥后必须做两件事格式验证验证上传的字符串是否是有效的PEM格式RSA公钥。可以尝试用java.security.spec.X509EncodedKeySpec去解析解析失败则拒绝。权限绑定将验证通过的公钥与当前登录的用户ID强绑定。之后任何用该公钥加密的内容法律和逻辑意义上都意味着是发给该用户的。一个关键细节公钥理论上可以公开但在系统内我们依然将其视为敏感信息。因为如果攻击者能够替换掉某个用户的公钥那么后续所有发给该用户的“加密”公文实际上都被加密到了攻击者的公钥下攻击者可以用自己的私钥解密造成严重的中间人攻击。因此公钥上传接口的安全性和审计日志至关重要。4. 公文加解密流程的详细实现理论讲完我们来看具体代码怎么跑通整个流程。这里会涉及前后端的配合。4.1 前端加密发送模块实现假设我们有一个发送公文的表单包含了标题、正文和附件。// SendDocument.vue 组件中的核心方法 async handleEncryptAndSend() { // 1. 获取接收者的公钥 (假设已选中接收者其ID为 receiverId) const receiverPublicKeyPem await this.$api.getPublicKey(this.receiverId); // 2. 生成随机的AES密钥 (用于加密公文内容) const aesKey CryptoJS.lib.WordArray.random(256/8); // 32字节 AES-256 const aesKeyString CryptoJS.enc.Base64.stringify(aesKey); // 3. 使用AES密钥加密公文内容 const content this.form.content; const encryptedContent CryptoJS.AES.encrypt(content, aesKey, { mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }).toString(); // 4. 使用接收者的RSA公钥加密AES密钥 const encryptor new JSEncrypt(); encryptor.setPublicKey(receiverPublicKeyPem); // RSA加密对输入数据长度有限制需要分块或用于加密密钥。AES密钥长度固定可直接加密。 const encryptedAesKey encryptor.encrypt(aesKeyString); if (!encryptedAesKey) { // 加密失败可能是公钥格式错误或内容太长 throw new Error(RSA加密AES密钥失败); } // 5. 构建发送给后端的数据包 const payload { title: this.form.title, receiverId: this.receiverId, encryptedContent: encryptedContent, // AES加密后的公文内容 encryptedAesKey: encryptedAesKey, // RSA加密后的AES密钥 // 还可以包括IV初始化向量用于AES解密这里示例简化了 // iv: CryptoJS.enc.Base64.stringify(iv), attachments: [] // 附件处理略同样可采用AES加密后上传 }; // 6. 发送到后端 await this.$api.sendDocument(payload); this.$message.success(公文加密发送成功); }4.2 后端接收与存储逻辑后端接收到这个数据包后它的角色是一个“安全的邮局”只负责验明身份、登记信息、保管密文包裹而不需要知道包裹里是什么。// DocumentController.java PostMapping(/send) public ResponseEntity? sendDocument(RequestBody DocumentSendDTO dto, AuthenticationPrincipal UserPrincipal user) { // 1. 权限校验发送者是否有权发送公文给目标接收者 // ... 业务逻辑校验 // 2. 数据校验确保必要的密文字段存在 if (StringUtils.isBlank(dto.getEncryptedContent()) || StringUtils.isBlank(dto.getEncryptedAesKey())) { return ResponseEntity.badRequest().body(加密数据不完整); } // 3. 构建实体并保存 Document document new Document(); document.setTitle(dto.getTitle()); document.setSenderId(user.getId()); document.setReceiverId(dto.getReceiverId()); document.setEncryptedContent(dto.getEncryptedContent()); // 直接存密文 document.setEncryptedAesKey(dto.getEncryptedAesKey()); // 直接存加密后的密钥 document.setStatus(DocumentStatus.PENDING); // 待处理状态 document.setSendTime(LocalDateTime.now()); documentRepository.save(document); // 4. 可选的触发通知告诉接收者有新公文通知内容不包含任何密文 notificationService.notifyNewDocument(dto.getReceiverId(), document.getId()); return ResponseEntity.ok(发送成功); }重要提示后端存储的encrypted_content和encrypted_aes_key字段类型要设为TEXT或BLOB因为经过Base64编码后的密文会很长。特别是RSA加密后的数据2048位密钥加密会产生256字节的密文Base64后更长。4.3 前端解密查看模块实现接收者查看公文时前端需要完成核心的解密工作。// DocumentDetail.vue 组件中的核心方法 async loadAndDecryptDocument(documentId) { // 1. 从后端获取密文数据包 const documentData await this.$api.getDocumentById(documentId); // 2. 从本地安全存储中获取用户自己的RSA私钥并解密 const encryptedPrivateKeyInfo localStorage.getItem(encryptedPrivateKey); const password this.$store.state.user.passwordHint; // 实际应由用户再次输入口令 const privateKeyPem await this.decryptPrivateKey(encryptedPrivateKeyInfo, password); if (!privateKeyPem) { throw new Error(无法解密本地私钥请检查口令); } // 3. 使用私钥解密AES密钥 const decryptor new JSEncrypt(); decryptor.setPrivateKey(privateKeyPem); const aesKeyBase64 decryptor.decrypt(documentData.encryptedAesKey); if (!aesKeyBase64) { // 解密失败可能的原因1. 私钥不匹配非接收者2. 密文被篡改3. 密钥格式问题。 throw new Error(解密AES密钥失败您可能无权查看此公文或数据已损坏); } // 4. 将解密出的AES密钥转换为CryptoJS可用的格式 const aesKey CryptoJS.enc.Base64.parse(aesKeyBase64); // 5. 使用AES密钥解密公文内容 (假设使用CBC模式需要IV) // 实际中IV需要和加密内容一起存储和传输 const iv CryptoJS.enc.Base64.parse(documentData.iv); // 从数据中获取IV const decryptedBytes CryptoJS.AES.decrypt( documentData.encryptedContent, aesKey, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); // 6. 将解密后的数据转为明文字符串 const plaintextContent decryptedBytes.toString(CryptoJS.enc.Utf8); if (!plaintextContent) { throw new Error(解密公文内容失败数据可能不完整); } // 7. 渲染明文内容 this.content plaintextContent; }这个过程清晰地展示了“混合加密”的威力RSA用于安全交换密钥AES用于高效加密数据。前端承担了主要的加解密计算压力后端则轻量化处理专注于业务流转和存储。5. 性能优化与安全性增强实践一个可用的系统和一个好用的系统之间隔着性能和安全的鸿沟。5.1 应对大文件与性能瓶颈RSA算法本身非常慢尤其是前端JavaScript执行。加密一个几K的AES密钥没问题但如果你试图用RSA直接加密几兆的公文附件浏览器肯定会卡死甚至崩溃。解决方案就是严格遵循“混合加密”模式文本内容如上述流程用AES加密。大附件在前端使用FileReader将文件分片例如每1MB一片。为这个文件生成一个随机的fileKeyAES密钥。用fileKey加密每一个文件分片。用接收者的RSA公钥加密这个fileKey。将加密后的分片和加密后的fileKey上传到后端或对象存储。后端同样只存储密文。下载解密时先解密fileKey再解密各个分片最后在浏览器中拼接或触发下载。Web Worker的运用加解密是CPU密集型操作如果在主线程进行会阻塞UI导致页面“假死”。可以将jsencrypt和crypto-js的加解密操作放到Web Worker中执行保持主线程流畅。// 在主线程中 const cryptoWorker new Worker(/js/crypto-worker.js); cryptoWorker.postMessage({ type: ENCRYPT_CONTENT, content: largeContent, publicKey: receiverPublicKey }); cryptoWorker.onmessage (e) { const { encryptedContent, encryptedKey } e.data; // 处理加密结果 };5.2 密钥管理与更新策略密钥轮换RSA密钥对不应该永久使用。应设定一个有效期如一年系统定期提醒用户更新密钥对。更新时旧公钥仍需保留用于解密历史公文新公钥用于加密未来公文。这需要在数据库设计中考虑密钥版本管理。私钥丢失恢复这是一个难题。因为设计是“密钥不上云”服务器不存私钥所以一旦用户丢失私钥如更换电脑、清除浏览器数据所有用旧公钥加密的公文将永久无法解密。必须在系统设计初期就明确告知用户风险并提供严格的私钥备份指引如导出加密的私钥文件并离线保存。绝对不要设计“后台管理员可解密所有公文”的后门那会彻底破坏系统的安全性前提。公钥吊销如果怀疑某个用户的私钥已泄露需要立即吊销其公钥。后端应维护一个公钥吊销列表CRL在发送公文查询公钥时先检查该公钥是否已被吊销。5.3 防御常见Web攻击XSS跨站脚本攻击这是前端加密系统的头号杀手。如果网站存在XSS漏洞攻击者可以注入恶意脚本直接读取内存中的私钥明文或监听解密过程。必须严格实施输入输出编码使用CSP内容安全策略头部避免内联脚本对来自用户的所有数据进行消毒处理。CSRF跨站请求伪造攻击者可能诱导已登录的用户点击恶意链接以其身份发送伪造的公文。确保所有状态变更的API如发送、审批都使用CSRF Token或验证同源策略。中间人攻击MITM虽然我们用了RSA但前提是公钥的获取通道是安全的。必须全程使用HTTPSTLS 1.2来保护前端与后端的所有通信防止公钥在传输中被替换。6. 开发与部署中的“坑”与解决方案实录在实际开发和部署中我遇到了不少预料之外的问题这里记录几个典型的。6.1 前端加密库的兼容性与大小问题jsencrypt在处理某些格式的PEM密钥时可能会报错特别是由其他工具如OpenSSL生成的密钥。确保前后端使用兼容的PEM格式通常是PKCS#8。一个常见的处理函数是格式化密钥字符串确保它有正确的头尾标识。function formatPemKey(rawKeyString) { // 确保密钥以正确的头尾包裹 if (!rawKeyString.includes(-----BEGIN)) { if (rawKeyString.includes(PRIVATE)) { rawKeyString -----BEGIN PRIVATE KEY-----\n${rawKeyString}\n-----END PRIVATE KEY-----; } else { rawKeyString -----BEGIN PUBLIC KEY-----\n${rawKeyString}\n-----END PUBLIC KEY-----; } } // 确保换行符是\n return rawKeyString.replace(/\\r\\n/g, \n).replace(/\\r/g, \n); }另外crypto-js和jsencrypt直接打包会让前端资源体积增大。使用Vite或Webpack的代码分割将加密相关的代码单独打包成chunk或者考虑使用更轻量的替代库如node-forge的浏览器版本并在生产环境下开启Gzip/Brotli压缩。6.2 后端数据库存储密文的性能考量密文特别是Base64编码后比明文长很多。频繁地对长文本字段进行查询和传输会影响性能。索引优化不要在encrypted_content这样的长文本字段上建索引。查询应基于元数据字段如公文号、发送/接收者ID、时间、状态等。分页查询公文列表接口一定要做分页避免一次性拉取大量包含密文摘要的数据。内容摘要在存储密文的同时可以存储一个由明文标题和发送时间等生成的哈希值如SHA-256用于快速比对或去重而无需解密内容。6.3 用户引导与体验平衡安全性和用户体验往往是对立的。要求用户自己管理私钥对大多数非技术用户来说门槛太高。渐进式引导用户首次登录时用清晰的图文步骤引导其生成密钥、设置保护口令、并完成备份。可以将备份文件加密后的私钥直接提供给用户下载。会话管理为了避免用户每次操作都输入口令解密私钥可以在用户输入一次正确口令后将解密出的私钥明文保存在内存或Session Storage标签页关闭即清除中并在用户一段时间无操作后自动清除。降级方案慎用对于极少数无法支持前端复杂加密的场景如某些老旧浏览器可以考虑一个“安全等级较低”的模式比如仅使用后端存储加密数据库层面加密并在界面上明确提示用户此模式的安全风险。但这会破坏统一的安全模型需谨慎评估。6.4 调试与日志记录在加密系统中调试非常困难因为你看到的数据都是密文。开发环境开关在开发环境中可以设置一个全局开关允许将加密暂时关闭或者使用固定的测试密钥对以便调试业务逻辑。结构化日志在后端记录关键操作日志如“用户A于时间T用密钥ID K1加密发送了公文D给用户B”。但绝对不要在日志中记录任何明文内容、密钥或完整的密文。日志应专注于操作流水和元数据。前端错误捕获用try-catch仔细包裹所有加解密操作并将友好的错误信息反馈给用户例如“解密失败请检查您的私钥是否正确”或“网络异常加密数据可能不完整”而不是将JavaScript的底层加密库错误直接抛出。这个项目做下来最深的一点体会是构建一个安全的系统技术方案只占一半另一半是对细节的偏执和对用户行为的合理引导。任何一个环节的疏忽比如一个XSS漏洞、一次错误的公钥覆盖、或者用户随手丢弃了私钥备份都可能让之前所有的加密努力付诸东流。它不像普通的业务系统bug可能只是功能失效在这里bug往往直接意味着安全防线崩塌。所以代码要严谨测试要全面对用户的教育和提醒也要做到位。这套基于Web的RSA加密公文管理系统算是在浏览器这个“相对不可信”的环境里尽可能地为敏感数据筑起了一道坚固的防线。