ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

前后端分离接口加密:RSA+AES混合加密方案与中间件实战

前后端分离接口加密:RSA+AES混合加密方案与中间件实战

1. 为什么前后端分离后不得不考虑接口数据传输加密

做前后端分离最酸爽的阶段,是接口联调刚跑通那阵。前后端各写各的,联调一过,需求上线,大家都很开心。直到某一次安全整改,我用抓包工具回放了一个订单提交请求,发现手机号、收货地址、订单金额全部在报文里明文躺着,随手改个金额居然还能重放成功,那一瞬间手心全是汗。也就是从那次开始,我认真把“接口数据传输加密”当成了一个独立的技术模块去设计,而不是简单塞个 HTTPS 就完事。

这篇文章写给正在做 .Net Core 前后端分离项目的同学,尤其适合那种接口已经上线、但敏感数据还在裸奔的团队。我会把这套方案的完整设计思路、后端加解密中间件、前端加密调用示例、以及我在实际项目中踩过的坑全部拆开讲一遍。你不需要是密码学专家,只要懂一点 .Net Core 中间件和 JavaScript 基础,就能照着把接口数据从“明文裸奔”改成“密文传输”。

1.1 HTTPS 之下,接口数据仍然在裸奔

很多人会问:我都上了 HTTPS,为什么还要做一层接口加密?这个问题我在面试候选人的时候也经常问。HTTPS 解决的是“传输过程中被窃听、被篡改”的问题,它保护的是客户端到服务器之间的这条链路。但现实情况是,一旦请求到了服务端,或者到了某个网关、某个业务系统内部,链路保护就结束了。尤其在前后端分离架构下,接口要经过域名解析、负载均衡、网关转发、业务服务处理,链路里的任何一环都可能存在日志记录、报文留痕,这些日志落到磁盘上就是明文。

另外还有一个非常现实的问题:调用方并不一定只有你的前端。有些接口要开放给第三方系统、内部其他服务、甚至外包团队维护的旧系统调用。你控制不了对方前端的环境,也控制不了对方服务端的日志规范。如果接口报文本身是明文,那数据安全就完全依赖于“所有下游都不要乱打日志”的自觉,这显然不可靠。给接口数据本身做一层业务级加密,相当于把数据的安全边界从“传输链路”扩展到了“报文本体”,无论报文到了哪一层,看不到密钥就解不开。

我在某个项目里就遇到过这种情况:数据库被扫出来一堆包含用户手机号的明文日志,排查下来发现不是我们服务打的,是上游某个对接方把请求体原样打进了自己的文件日志。如果当初接口数据就是加密的,对方日志里只会留下一堆密文,风险范围会小得多。

1.2 哪些接口适合做业务层加密

不是所有接口都需要做这一套东西。登录接口、支付接口、涉及用户隐私数据的查询接口,这些是优先级最高的。纯粹的静态资源、图片上传、文件下载、大流量匿名查询接口,强行加密反而会带来性能损耗和复杂度。我个人的建议是先梳理接口清单,按照“是否包含敏感字段、是否会成为攻击目标、调用频率和报文大小”这三个维度打标,优先给敏感读写接口加加密,再逐步扩大覆盖范围。

这一点非常关键,因为全站接口做加密最大的问题不是加密本身,而是排查问题变难了。本来出问题可以直接看报文,一加密啥都看不见。所以我会在网关层做白名单/黑名单机制,或者在后端中间件里判断请求是否遵循加密协议,只有带加密标记的请求才走解密流程,普通接口照常放行。这样既不影响老接口的兼容性,又能让加密能力按需启用。下文给出的中间件实现就是按这个思路设计的。

1.3 业务层加密能防住什么、防不住什么

说实话,这一层加密主要防的是“报文被非授权方直接读取”,比如链路日志泄露、抓包工具明文查看、报文内容被恶意篡改后重放。它能防住的是大部分被动攻击和一部分主动篡改。但它防不住发生在用户自己终端上的恶意行为——如果攻击者完全控制了用户的浏览器环境,那他连密钥都能拿到,加密对这种场景无能为力。所以加密只是纵深防御里的一层,该做的接口鉴权、频率限制、风控策略一个都不能少。

我曾经在设计方案时犯过一个错误:以为接口加了密就可以把校验逻辑省了,结果被同事怼了一句“你要是把密钥写在 JS 里,人家人肉破解你前端就行”。确实,任何纯前端方案都做不到绝对安全,我们的目标是把攻击成本抬高到一个不值得尝试的水平,而不是追求理论上的不可破解。想通了这一点,整个方案的设计思路就清晰了。

2. 整体方案设计:RSA+AES混合加密模型的拆解

2.1 AES负责数据加密,RSA负责密钥交换

要加密接口传输的业务数据,第一反应就是用 AES 这种对称加密。AES 加解密速度非常快,适合处理长度不定的 JSON 数据。但对称加密有一个天然问题:加密和解密用的是同一把密钥,如果前后端约定一个写死密钥,那密钥一旦泄露,加密就形同虚设;如果经常换,又没法安全地把新密钥告诉对方。

所以经典的解法是“混合加密”:用 AES 随机生成一把临时密钥,加密业务数据;再用 RSA 非对称加密把这把临时 AES 密钥安全地传给服务端。RSA 的特点是公钥加密、私钥解密,公钥可以随便发给前端,私钥只留在服务端。前端用公钥加密 AES 密钥,即使被截获,没有私钥也解不开。服务端拿到密文后用私钥解出 AES 密钥,再解业务数据。整个过程里,AES 负责“跑量”,RSA 负责“护送钥匙”,各干各擅长的事。

用生活化的比喻来说:AES 加密相当于给快递柜配了一把一次性锁,RSA 加密相当于把开锁的钥匙装进一个只有收件人才能打开的保险箱里一起寄过去。快递柜到处都是,但钥匙只有收件人拿得到。

2.2 请求、响应、签名三件事如何分工

在这个方案里,一个完整的加密交互是分三段的。第一段是请求加密:前端生成随机 AES 密钥,用 AES 加密业务数据得到 encData;用 RSA 公钥加密 AES 密钥得到 encKey;再把 encData、encKey、随机 IV、时间戳、签名一起放进请求体。第二段是服务端解密:先用 RSA 私钥解出 AES 密钥,再用 AES 密钥解密业务数据,然后校验时间戳和签名,防止报文被篡改或重放。第三段是响应加密:服务端直接复用请求里解出来的同一把 AES 密钥,加密返回的 JSON 数据,前端再拿本地缓存的 AES 密钥解密。这样一套流程下来,不只是请求报文,连响应报文也都是密文,抓包工具看到的只有一串无意义的字符。

签名这一段很多人会忽略,但它非常重要。加密只能保证别人“看不懂”,不能保证别人“没动过”。攻击者虽然不知道明文,但他可以把密文原样替换,或者把某个请求的 encData 换成另一次请求的 encData,制造数据混乱。所以我在请求里增加了一个 HMAC-SHA256 签名,把“时间戳 + 密文”用前后端约定的盐值计算出来一起传。服务端先验签名再解密,一旦密文被改,签名立刻对不上,请求直接被拒绝。

2.3 密钥管理、盐值与轮换策略

前端每次请求都要随机生成新的 AES 密钥,用完即弃,这个习惯一定要养成。如果为了省事把 AES 密钥在前端固定,那 RSA 交换密钥就失去意义了。IV 也必须每次随机,AES-CBC 模式下,相同的明文如果使用相同的 IV 和密钥,加密结果是完全一样的,这会给攻击者留下分析的空间。

RSA 密钥对的轮换要温和许多。一句话:别想着一次性全部换掉,而是要做“多密钥兼容”。我习惯在配置中心里维护一个主私钥和多个历史私钥。请求进来先拿当前主私钥解,解失败再依次尝试历史私钥。这样老版本客户端还拿着旧公钥加密,服务端依然能解开,客户端可以分批升级,而不是“明天上线,今天所有端必须换密钥”。这种兼容策略在真实生产环境里能救命,我在一次密钥迁移中深有体会,当时有一批老 APP 用户不升级,差点酿成大面积接口故障。

盐值不需要保密,它的作用是增加签名伪造难度,所以定一个足够长的随机字符串就行,别用“123456”这种。更严格的做法是签名也用 RSA 私钥签名、前端用公钥验签,但一般项目里 HMAC 已经够用,否则签名逻辑会复杂不少。

3. 后端实战:.Net Core 加密中间件完整实现

3.1 生成RSA密钥对并配置进系统

先说密钥生成。我习惯用 OpenSSL 一次性生成 2048 位的 PEM 格式密钥对:

openssl genrsa -out rsa_private_key.pem 2048 openssl rsa -in rsa_private_key.pem -pubout -out rsa_public_key.pem

生成的私钥文件是 PKCS#1 格式,开头是-----BEGIN RSA PRIVATE KEY-----,公钥文件是 SubjectPublicKeyInfo 格式,开头是-----BEGIN PUBLIC KEY-----。这两个文件前端只需要公钥,服务端只需要私钥。公钥可以放到前端项目的公共配置里,甚至写在前端代码里都不怕泄露,它本来就是公开的。

服务端处理私钥,如果你用的是 .NET 6 及以上版本,可以直接用ImportFromPem导入:

using var rsa = RSA.Create(); rsa.ImportFromPem(privateKeyPem);

注意一个问题,如果你还在用 .NET Core 3.1,ImportFromPem并不好用,需要手动剥掉 PEM 头尾,或者借助第三方解析库。我的建议是尽量升级到 .NET 6+,这不仅仅是密钥导入的问题,整个加密相关的 API 都会舒服很多。密钥配置不要写死在代码里,放进配置文件或配置中心,至少区分开发、测试、生产三套,防止测试环境密钥被拿去解密生产数据。

3.2 请求解密与验签中间件:让控制器拿到明文JSON

后端的关键是做一个解密的中间件,或者更准确地说,是做一个“先解密再走管线”的处理逻辑。中间件在请求到达控制器之前,把加密的请求体换成明文请求体,这样控制器和模型绑定根本感知不到加密过程,代码侵入性非常小。

先定义加密请求的 DTO:

public class EncryptedRequest { public string EncData { get; set; } public string EncKey { get; set; } public string Iv { get; set; } public long Timestamp { get; set; } public string Sign { get; set; } }

再写一个加密服务,集中处理解密、验签、响应加密:

public interface IEncryptionService { bool IsEncryptedRequest(string rawBody); byte[] ResolveAesKeyFromRequest(EncryptedRequest payload); string DecryptData(string encData, byte[] aesKey, string ivBase64); string EncryptResponse(string plainJson, byte[] aesKey); }

实现类里核心是几个私有方法:RSA 解密、AES 解密、AES 加密、HMAC 签名。RSA 解密时有个大坑,前端 JSEncrypt 加密默认用的是 PKCS#1 v1.5 填充,服务端也必须要用对应的填充模式:

private byte[] RsaDecrypt(string encryptedBase64) { var data = Convert.FromBase64String(encryptedBase64); return _rsa.Decrypt(data, RSAEncryptionPadding.Pkcs1); }

如果你在服务端配置成了 OAEP,前端没有同步改,解密一定会报“填充无效”的错。这个问题我在跨语言联调时遇到过不止一次。AES 解密同理,前端用的什么模式、什么填充、什么初始向量,服务端必须完全一致,差一个字节都不行。

中间件本体长这样:

public class EncryptedApiMiddleware { private readonly RequestDelegate _next; private readonly IEncryptionService _encryptionService; private readonly ILogger<EncryptedApiMiddleware> _logger; public EncryptedApiMiddleware(RequestDelegate next, IEncryptionService encryptionService, ILogger<EncryptedApiMiddleware> logger) { _next = next; _encryptionService = encryptionService; _logger = logger; } public async Task InvokeAsync(HttpContext context) { var request = context.Request; if (request.Method == HttpMethods.Post || request.Method == HttpMethods.Put) { if (request.ContentType != null && request.ContentType.Contains("application/json", StringComparison.OrdinalIgnoreCase)) { using var reader = new StreamReader(request.Body); var rawBody = await reader.ReadToEndAsync(); if (_encryptionService.IsEncryptedRequest(rawBody)) { var payload = JsonSerializer.Deserialize<EncryptedRequest>(rawBody); var aesKey = ResolveAesKeyAndValidate(payload); context.Items["CurrentAesKey"] = aesKey; var decryptedJson = _encryptionService.DecryptData(payload.EncData, aesKey, payload.Iv); request.Body = new MemoryStream(Encoding.UTF8.GetBytes(decryptedJson)); } } } var originalResponseBody = context.Response.Body; using var responseBuffer = new MemoryStream(); context.Response.Body = responseBuffer; await _next(context); responseBuffer.Position = 0; var plainResponse = await new StreamReader(responseBuffer).ReadToEndAsync(); if (ShouldEncryptResponse(context, plainResponse)) { var aesKey = context.Items["CurrentAesKey"] as byte[]; if (aesKey != null) { var encryptedResponse = _encryptionService.EncryptResponse(plainResponse, aesKey); context.Response.Body = originalResponseBody; context.Response.ContentType = "application/json"; await context.Response.WriteAsync(encryptedResponse); return; } } responseBuffer.Position = 0; await responseBuffer.CopyToAsync(originalResponseBody); } }

这里ResolveAesKeyAndValidate做三件事:用 RSA 私钥解出 AES 密钥;校验时间戳,超过 5 分钟直接拒绝;校验 HMAC 签名。签名和 AES 密钥都通过之后,才把明文 JSON 重新放回request.Body,后续模型绑定就可以正常反序列化到 DTO 了。

3.3 响应加密中间件:返回报文不再是明文

响应加密是我这套方案里改动成本最小、收益却很明显的一块。上面中间件在处理完后续管线后,把响应缓冲区里的明文 JSON 用同一个 AES 密钥加密,再写回原来的响应流。这样前端收到的永远是密文结构:

{ "encData": "一串看不懂的密文", "iv": "Base64编码的16字节向量" }

但这里有几个细节必须说清楚。中间件把response.Body替换成了MemoryStream,所以需要确保没有其他中间件在后面对这个流做二次写入。文件流、二进制流这类响应不建议走这个逻辑,所以ShouldEncryptResponse里我会判断响应头里的 Content-Type 和内容特征,只有 JSON 才加密。另外,一旦中间件决定加密响应,原来的响应头可能会失效,特别是 Content-Length,需要重新计算,否则客户端解析会出问题。

我在实际项目中踩过一个坑:某个导出接口返回的是 Excel 文件流,因为全局中间件开着,它试图把二进制流当作文本加密,前端拿到一堆乱码不说,文件也损坏了。后来我在ShouldEncryptResponse里加了清晰的判断,非 JSON 响应一律放行,才把这个隐患堵住。所以在设计阶段就要想清楚,加密中间件只处理它该处理的报文类型,不要试图包打天下。

3.4 注册中间件与依赖注入的先后顺序

中间件的注册顺序会影响最终效果。加密中间件需要放在UseRouting之后、控制器执行之前,因为它要截断请求体并改写响应。推荐把UseAuthentication、UseAuthorization放在它前面,因为认证鉴权需要的是接口级别的信息,不应该被密文挡住。如果先解密再鉴权,意味着未认证的请求也可以触发解密逻辑,给攻击者提供了可乘之机。

依赖注入那里要特别注意:中间件里用到的IEncryptionService必须注册为单例或者作用域服务,因为 RSA 对象创建的开销不小。我把RSA实例直接缓存在服务内部,通过配置中心注入私钥内容,启动时解析一次,后续所有请求复用。这样在高并发下 RSA 加解密的 CPU 压力能小很多。

4. 前端实战:JavaScript端加解密与接入示例

4.1 前端加密库选型与密钥加载

前端做 AES 和 RSA 加密,最省事的组合是crypto-js加jsencrypt。crypto-js提供 AES、HMAC、Base64 等基础能力,jsencrypt专门处理 RSA 公钥加密。两个库都比较成熟,直接 npm 安装即可。

公钥统一放在前端项目的常量文件里,注意保持完整的 PEM 头尾字符串,不要手贱删除换行。jsencrypt对公钥格式比较挑剔,缺失头尾或者格式不完整会导致encrypt方法返回 false,联调时很容易忽略这一点。

4.2 封装统一的加密请求函数

整个过程封装成一个函数最理想,这样业务代码完全感知不到加密的存在。下面是我在项目里常用的实现:

import CryptoJS from 'crypto-js'; import JSEncrypt from 'jsencrypt'; const RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- ...公钥内容... -----END PUBLIC KEY-----`; const SALT = '前后端约定的盐值'; async function requestEncrypted(url, method = 'POST', data = {}) { const aesKey = CryptoJS.lib.WordArray.random(16); const aesKeyBase64 = CryptoJS.enc.Base64.stringify(aesKey); const iv = CryptoJS.lib.WordArray.random(16); const plainJson = JSON.stringify(data); const encData = CryptoJS.AES.encrypt(plainJson, aesKey, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }).toString(); const encrypter = new JSEncrypt(); encrypter.setPublicKey(RSA_PUBLIC_KEY); const encKey = encrypter.encrypt(aesKeyBase64); const timestamp = new Date().getTime(); const sign = CryptoJS.HmacSHA256(timestamp + encData, SALT).toString(); const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ encData, encKey, iv: CryptoJS.enc.Base64.stringify(iv), timestamp, sign }) }); const encryptedResp = await response.json(); const decryptedBytes = CryptoJS.AES.decrypt(encryptedResp.encData, aesKey, { iv: CryptoJS.enc.Base64.parse(encryptedResp.iv), mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return JSON.parse(decryptedBytes.toString(CryptoJS.enc.Utf8)); }

这段代码的核心思路是:每次请求生成一把新的随机 AES 密钥,用来加密业务数据和解密响应数据;encKey只负责把这把密钥安全地送到服务端。业务层只要调用requestEncrypted('/api/order/submit', 'POST', orderData)就能得到解密后的 JSON 响应。

4.3 处理加密响应与错误码

前端解密响应时,要考虑到服务端可能返回的不是加密成功的数据,而是一个 401 或者业务错误码。如果服务端解不开请求,它大概率会直接抛异常,返回结构可能是{ code: 40001, message: '签名验证失败' }这样的明文。所以前端拿到响应后要先判断结构,如果字段里有encData才走解密,否则当作普通响应处理。我见过不少项目在这一步没做兼容,服务端一报错,前端直接崩溃在解析函数里。

另外一个前端细节:解密响应时尽量把解出来的 JSON 包一层 try-catch,因为一旦密钥错位、密文被篡改、或者响应被网关改写,解密结果可能不是合法 JSON。项目里我一般定义统一的业务返回包装,比如{ code, message, data },前端解密成功后先判断 code 再决定是否走成功分支。

5. 实测踩坑与问题排查实录

5.1 常见问题速查表

我把这段时间遇到的坑整理成一张速查表,方便你直接对照排查:

现象原因解决方案
前端调用接口一直提示“签名验证失败”前后端 HMAC 计算内容不一致,最常见是时间戳类型或字符串拼接格式不同严格统一签名输入串,比如都用字符串拼接的 timestamp + encData,别混入多余字符
服务端 RSA 解密报“填充无效”前端 JSEncrypt 默认 PKCS#1,服务端用了 OAEP,或两端 padding 不一致统一改成 RSAEncryptionPadding.Pkcs1
加密后的请求体偶尔解密乱码AES 密钥传的是 Base64 字符串,服务端却按原始字节解析约定好:encKey 加密的是“Base64字符串”,服务端解密后还要再做一次 Base64 解码
响应加密后前端解不出来响应加密用的不是请求密钥,或者响应 IV 没传给前端响应复用请求里的 AES 密钥;每次响应随机生成新 IV 并放进密文结构
换密钥后老客户端全挂只保留了一把私钥,旧公钥加密的数据无法解密服务端保留历史私钥列表,按顺序尝试解密
文件下载接口返回乱码全局响应加密把二进制流当文本加密了增加判断逻辑,只对 JSON 响应加密,其他流全放行

5.2 跨端加解密不一致的排查思路

跨语言联调最让人头大的就是“明明两边都按标准写的,结果就是解不开”。我的经验是先拆开验证:第一步验证 RSA 部分,单独用前端公钥加密一段固定字符串,服务端解密看能不能还原,这一步能确认 PEM 格式、padding 模式、Base64 编解码是否一致。第二步验证 AES 部分,直接把 AES 密钥写死,两端用同一把密钥加密同一段明文,比对密文是否一致。第三步再把密钥动态化,走完整链路。

这种逐层排查的思路能省大量时间。我见过最多的问题其实不在加密算法本身,而在编码上:前端用 Base64Url 转码,服务端用标准 Base64 解码;或者前端用 UTF-8 转字节,服务端默认系统编码处理,导致中文乱码。建议两端统一使用 Base64 + UTF-8 的组合,前者负责传输,后者负责字符解析,不要混用。

5.3 性能测试结果与优化建议

最后说下性能。我给某支付模块做过一轮加解密压力测试,单台 4 核 8G 的机器,纯接口加解密链路大概增加了 1 到 2 毫秒的平均延迟。RSA 解密是这里面最贵的操作,2048 位密钥单次解密大约在 1ms 量级;AES 对称加解密非常快,几百字节的报文基本可以忽略不计。整体来说,对于绝大多数业务接口,这个性能损耗是可以接受的。

但有两个场景要特别小心。一是超大报文,如果你把整个几十 MB 的文件内容塞进 JSON 里做 AES 加密,加密耗时和内存占用都会暴涨,这种情况更适合用文件流 + 分块加密或者只加密敏感字段。二是高并发且请求体很大的查询接口,解密逻辑会成为 CPU 热点,建议单独评估容量。还有一个优化手段是把 RSA 解密结果做短时间缓存,比如同一客户端同一把 AES 密钥在 5 分钟内重复请求,可以跳过 RSA 解密直接复用结果,但要注意别引入安全性问题。

日志管理这块我多说一句:加密链路一旦出问题,定位成本比明文时代高不少。我习惯在中间件里保留请求流水号,加密解密前后只记录长度、耗时、关键错误码,绝对不打印明文内容,也不打印完整的加密报文。曾经有同事为了排查方便把解密后的 JSON 打到日志里,结果被安全扫描扫出敏感字段,差点出事故。加密要防外面的人,也要防内部日志泄露,这一步别省。

这套方案落地之后,接口抓包再看不到明文,安全扫描也能轻松通过,前后端密钥分离,日常开发也基本无感。我在实际项目中体会最深的是:接口加密不是一次性改造,而是一个持续演进的过程,密钥要轮换、协议要升级、覆盖范围要逐步扩大,但只要你把框架搭得足够清晰,这些迭代都会顺畅很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进