
1. 为什么要做接口层加密而不是只用 HTTPS1.1 HTTPS 其实没你想的那么全链路在给某公司一个前后端分离的管理后台做安全评估的时候我发现一个很有意思的现象HTTPS 已经开得很好证书也没问题但把网关收到的原始请求打到日志里基本等于把业务数据明文完整地抄了一遍。原因很简单——大部分 HTTPS 在实际链路里的终止点是网关或反向代理而不是你的 API 服务自己从网关到应用服务器这一段对外部是不可见的但对内部运维、排障工具、日志系统却是完全透明的。也就是说你在浏览器里看到的小锁头只保护了浏览器到网关这一段。一旦请求到了网关内部如果你做接口日志、审计系统、流量采集业务字段照样可以被完整还原。之前我就亲眼见过一个团队为了排查线上问题把整个请求体打到日志文件里面全是身份证号、手机号这类数据。事后他们删日志删了很久但到底有多少数据被谁看过根本说不清楚。1.2 应用层加密到底解决了什么问题接口数据传输加密指的是在 HTTP 协议之上由应用层自己再实现一套加密规则请求体是密文响应体也是密文密钥由两端动态协商每次会话独立。这样即使 HTTPS 被网关卸载即使某段网络被嗅探即使日志系统把报文完整记录下来对方看到的也只是一堆无法还原的密文。所以在 .NET Core 前后端分离项目里我最终落地方案的原则很朴素HTTPS 解决传输通道问题应用层加密解决数据在通道终点被旁路读取的问题。这两件事不冲突反而可以叠加。应用层加密不是为了替代 HTTPS而是在 HTTPS 之外给敏感数据加一道最后一百米的保护。1.3 什么时候需要这个方案什么时候不需要不是所有项目都应该上这套方案。如果你的系统只是对公网提供新闻、商品列表数据敏感度不高那 HTTPS 加 WAF 就足够了。但如果你的系统涉及用户信息、订单数据、医疗记录、财务数据或者需要对接等保、密评、内部审计那应用层加密就成了一个躲不开的需求。另外有一种场景我认为特别需要前后端分离带来的数据摆渡问题。前端把 JSON 提交给后端中间往往要经过多个跳转、代理、网关、消息通道。任何一个环节只要不小心打了日志JSON 就把所有敏感信息暴露了。接口层加密之后日志里只有一堆密文就算误打日志泄露的也不是原始数据。2. 加解密方案的整体设计混合加密与密钥协商2.1 为什么不直接固定一把 AES 密钥很多人第一反应是既然要加密那前端和后端约定一把 AES 密钥所有请求都用它加密不就行了这种方案实现是最简单的但也是最危险的。一旦密钥被提取出来攻击者就能解密历史上所有被截获的请求而且整个系统要么全部沦陷、要么只能紧急发版换密钥。固定密钥还有一个隐性问题密钥会零散地出现在前端代码、打包产物、配置文件、开发环境的临时脚本里你根本不知道它什么时候会丢。所以我在设计时选择了每次会话动态协商一把临时 AES 密钥的混合加密方案。整体思路是客户端每次发起会话时随机生成一把 AES-256 密钥用服务端的 RSA 公钥把这把 AES 密钥加密后传给服务端服务端用 RSA 私钥解密得到 AES 密钥记为会话密钥之后该会话中的所有请求和响应都用这把 CES 密钥做 AES-256-GCM 加解密会话过期后密钥作废重新握手。这样每一把 AES 密钥都只在一次会话内有效就算被提取也只能解密这一个会话不会波及其他用户。2.2 用 RSA 协商 AES 会话密钥的完整流程我把这套流程拆成两个接口第一个是握手准备接口通常叫/api/crypto/handshake。服务端返回自己的 RSA 公钥、密钥标识keyId和服务器当前时间。这里返回服务器时间很重要因为客户端握手时要把时间戳一起加密提交服务端可以据此校验握手是否过期这是防重放的第一步。第二个是建立会话接口通常叫/api/crypto/establish。客户端把随机生成的 AES 密钥、时间戳、随机数拼成一个 JSON用 RSA 公钥加密后提交。服务端解密、校验时间然后把 AES 密钥保存到会话存储中返回一个sessionId给客户端。之后客户端的每一个请求都在请求头里带上X-Session-Id请求体则是用 AES-GCM 加密后的密文。服务端根据sessionId找到对应 AES 密钥解密请求体交给业务层处理响应体也用同一把密钥加密后再返回给客户端。这个设计和 HTTPS 的握手过程有点像都是用非对称加密来协商对称密钥。对于前后端分离项目前端因为没有可靠的私钥存储环境所以不适合让它持有 RSA 私钥让它只拿公钥加密私钥留在服务端是更安全的选择。2.3 会话密钥的续期、过期和重启问题会话密钥不能永久有效。我一般给它设置 1 小时的过期时间并且每次成功请求都会把过期时间向后顺延这样用户不断操作会话就一直有效不会中途被迫重连。如果 1 小时内没有任何请求服务端就删除这把密钥下次请求返回会话过期前端收到后自动重新握手即可。有人问为什么不直接用 JWT 这类方案JWT 解决的是身份认证问题它不负责传输内容的加密。你完全可以在这套加密方案的外面继续用 JWT 或 Session 做登录态管理两者不冲突。服务端重启后内存里的会话密钥全部丢失这也是好事而不是坏事。客户端下一次请求会因为找不到会话而失败然后重新请求握手接口。对于前后端分离项目来说多一次握手请求的开销完全可以接受。2.4 GCM 模式比 CBC 强在哪AES 加密有很多模式我选 GCM 而不是最流行的 CBC原因有两条。第一条是 GCM 是 AEAD 模式也就是加密同时带认证。它输出的认证标签可以用来校验密文是否被篡改相当于天然集成了 HMAC 的功能。CBC 模式本身不带完整性校验如果你只做了 AES-CBC 加密攻击者可以篡改密文内容解密端完全无法察觉。为了补救你还得再单独算一遍 HMAC容易出错。第二条是 GCM 的性能。它内部使用了计数器模式可以借助硬件 AES-NI 指令做并行计算在 .NET Core 下吞吐量远高于同级别的 CBC。对于大量接口请求的场景这个差别很明显。3. 核心难点把加解密塞进 ASP.NET Core 中间件3.1 设计一个不破坏 MVC 模型绑定的请求解密层最初我考虑在 Action Filter 里做解密但后来放弃了。因为 Action Filter 执行时MVC 已经尝试读取请求体做模型绑定了等你把请求体解密再塞回去整个绑定流程已经被破坏。正确的切入点是在 ASP.NET Core 的中间件管道里处理。中间件在请求进入 MVC 之前执行我们可以在这一层把请求体从密文还原成明文然后替换掉HttpContext.Request.Body。这样 MVC 的模型绑定依然能正常读取 JSON业务代码完全不需要感知加密的存在。响应加密也一样中间件在_next(context)之后拿到响应内容把它加密写回真正的响应流。3.2 响应加密要怎么接管输出流响应加密有个麻烦就是业务代码可能直接用return Ok(json)或者写context.Response.WriteAsync(...)。为了能在_next(context)返回后拿到完整响应必须把context.Response.Body先替换成一个MemoryStream。等业务执行完把MemoryStream里的明文读取出来加密再恢复到原始响应流最后写入密文。这里要特别注意异常情况。如果业务代码中途抛了异常或者已经向响应流写入了大量数据我们必须保证finally块里把context.Response.Body恢复成原始对象否则后面的组件可能拿到一个已经关闭的流。3.3 中间件里读 Body 容易踩的坑请求体是一个Stream。在中间件里读 Body 时如果直接ReadToEnd很可能读到的是空内容因为 ASP.NET Core 默认请求体流只能读一次而且 MVC 内部早就把Request.Body的位置移到最后了。我惯用的做法是using var ms new MemoryStream(); await context.Request.Body.CopyToAsync(ms); var rawBytes ms.ToArray();读取之后把解密得到的结果重新包成一个MemoryStream赋给context.Request.Body并把Position置为 0。这样后续 MVC 模型绑定就可以正常工作。还要确认一点如果请求体为空比如 GET 请求、状态码为 204 的响应就不要强行走解密流程。我在代码里会先判断ContentLength 0或者读取到的字节长度为 0直接放行。3.4 query 和 header 里的敏感数据怎么处理加密只能针对请求体URL 路径和查询参数很难完全隐藏。因为 URL 可能被网络设备、负载均衡器、反向代理记录到访问日志里而且这些组成部分通常不归属于应用服务管。我给出的约定是所有敏感数据都必须放在请求体中不要放在 query 里。比如业务主键、用户标识、订单号这类信息一律用 POST 请求提交到 body避免出现在 URL 上。如果非要加密 query我建议把参数序列化后整体加密只保留一个密文参数例如GET /api/orders?dataeyJjaXBoZXIiOi...但这样做会造成网关无法路由、缓存无法生效所以除非特殊要求否则不推荐。更稳妥的做法是设计接口时就把 query 参数最小化仅保留 pageSize、pageIndex 这种不敏感的分页参数。4. 实操从密钥协商到请求响应的完整加密链路4.1 服务端启动RSA 密钥对的生成与维护在 .NET Core 里生成 RSA 密钥非常简单using System.Security.Cryptography; public sealed class RSAKeyProvider { private readonly LazyRSA _rsa new(() RSA.Create(2048)); public RSA Get() _rsa.Value; public string KeyId rsa-2024-01; public string ExportPublicKeyPem() _rsa.Value.ExportSubjectPublicKeyInfoPem(); }我用了 2048 位的 RSA密钥长度已经足够应对绝大多数场景。ExportSubjectPublicKeyInfoPem()导出的内容是标准的 SPKI 格式Web Crypto 里的crypto.subtle.importKey可以直接导入不需要再做格式转换。生产环境建议把 RSA 私钥放到配置中心或者密钥管理服务里应用启动时读取而不是每次启动随机生成。因为如果部署了多个实例每个实例的 RSA 私钥可以不一致但要求客户端 CM 只连到一台固定实例否则握手机制会变得很复杂。所以要么用单实例处理握手要么让多个实例共享同一把 RSA 私钥。4.2 握手接口下发公钥与建立会话我写了一个专门的CryptoController来处理握手和建会话[ApiController] [Route(api/crypto)] public class CryptoController : ControllerBase { private readonly RSAKeyProvider _rsaProvider; private readonly SessionKeyStore _sessions; public CryptoController(RSAKeyProvider rsaProvider, SessionKeyStore sessions) { _rsaProvider rsaProvider; _sessions sessions; } [HttpGet(handshake)] public IActionResult Handshake() { return Ok(new { keyId _rsaProvider.KeyId, publicKey _rsaProvider.ExportPublicKeyPem(), serverTime DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); } [HttpPost(establish)] public IActionResult Establish([FromBody] HandshakeRequest request) { byte[] decrypted; try { var encrypted Convert.FromBase64String(request.EncryptedData); using var rsa _rsaProvider.Get(); decrypted rsa.Decrypt(encrypted, RSAEncryptionPadding.OaepSHA256); } catch (Exception) { return BadRequest(new { message 密钥协商数据无法解密 }); } var payload JsonSerializer.DeserializeHandshakePayload(decrypted); var now DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); if (Math.Abs(now - payload.Timestamp) 5 * 60 * 1000) { return BadRequest(new { message 握手时间已过期 }); } var aesKey Convert.FromBase64String(payload.AesKey); var sessionId _sessions.Create(aesKey); return Ok(new { sessionId, expiresIn 3600 }); } } public class HandshakeRequest { public string EncryptedData { get; set; } } public class HandshakePayload { public string AesKey { get; set; } public long Timestamp { get; set; } }这里有一个关键点RSAEncryptionPadding.OaepSHA256必须和前端保持一致。前端如果用 Web Crypto对应配置是{ name: RSA-OAEP, hash: SHA-256 }如果前端用的是SHA-1对应后端就要用RSAEncryptionPadding.OaepSHA1。这个不一致是最常见的坑之一。4.3 前端侧用 Web Crypto 完成协商前端我用的是浏览器内置的 Web Crypto API不依赖第三方库。加密 AES 密钥并提交握手async function establishSession() { const handshake await fetch(/api/crypto/handshake).then(r r.json()); const publicKey await crypto.subtle.importKey( spki, base64UrlToArrayBuffer(handshake.publicKey), { name: RSA-OAEP, hash: SHA-256 }, false, [encrypt] ); const aesKey crypto.getRandomValues(new Uint8Array(32)); const timestamp Date.now(); const payload new TextEncoder().encode(JSON.stringify({ aesKey: arrayBufferToBase64Url(aesKey.buffer), timestamp })); const encryptedPayload await crypto.subtle.encrypt( { name: RSA-OAEP }, publicKey, payload ); const response await fetch(/api/crypto/establish, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ encryptedData: arrayBufferToBase64Url(encryptedPayload) }) }); const session await response.json(); return { aesKey, sessionId: session.sessionId }; }你可能注意到我在代码里用的是base64UrlToArrayBuffer而不是atob。原因很简单服务端为了 URL 安全通常会把 Base64 里的和/替换成-和_并且去掉补位符号。atob不认识这种格式会直接报错。4.4 请求加密中间件完整实现密钥协商完成后正常的业务请求就交给加密中间件处理。我在中间件里做了四件事校验请求头、解析密文、防重放校验、解密请求体。public class ApiCryptoMiddleware { private readonly RequestDelegate _next; private readonly SessionKeyStore _sessions; private readonly NonceCache _nonceCache; public ApiCryptoMiddleware(RequestDelegate next, SessionKeyStore sessions, NonceCache nonceCache) { _next next; _sessions sessions; _nonceCache nonceCache; } public async Task InvokeAsync(HttpContext context) { var path context.Request.Path.Value ?? ; if (path.StartsWith(/api/crypto/) || context.Request.ContentLength 0) { await _next(context); return; } var sessionId context.Request.Headers[X-Session-Id].ToString(); if (string.IsNullOrWhiteSpace(sessionId) || !_sessions.TryGet(sessionId, out var aesKey)) { context.Response.StatusCode StatusCodes.Status401Unauthorized; await context.Response.WriteAsync({\message\:\session invalid\}); return; } Envelope envelope; using (var ms new MemoryStream()) { await context.Request.Body.CopyToAsync(ms); var bodyBytes ms.ToArray(); if (bodyBytes.Length 0) { await _next(context); return; } try { envelope JsonSerializer.DeserializeEnvelope(bodyBytes); } catch (Exception) { context.Response.StatusCode StatusCodes.Status400BadRequest; await context.Response.WriteAsync({\message\:\invalid envelope\}); return; } } if (!ValidateReplay(envelope, sessionId)) { context.Response.StatusCode StatusCodes.Status400BadRequest; await context.Response.WriteAsync({\message\:\replay detected\}); return; } try { var cipherBytes Convert.FromBase64String(envelope.CipherText); var nonceBytes Convert.FromBase64String(envelope.Nonce); // 前端 WebCrypto 返回的是 [密文 tag]这里按约定切出后 16 字节做认证标签 var tagBytes cipherBytes[^16..]; var cipherData cipherBytes[..^16]; var plainBytes CryptoHelper.AesGcmDecrypt(aesKey, nonceBytes, cipherData, tagBytes); context.Request.Body new MemoryStream(plainBytes); } catch (CryptographicException) { context.Response.StatusCode StatusCodes.Status400BadRequest; await context.Response.WriteAsync({\message\:\decrypt failed\}); return; } var originalBody context.Response.Body; await using var responseBuffer new MemoryStream(); context.Response.Body responseBuffer; try { await _next(context); responseBuffer.Position 0; var responseBytes responseBuffer.ToArray(); if (responseBytes.Length 0 context.Response.StatusCode 300) { var nonce CryptoHelper.RandomNonce(); var encrypted CryptoHelper.AesGcmEncryptAsSingle(aesKey, nonce, responseBytes); var result JsonSerializer.Serialize(new Envelope { Nonce Convert.ToBase64String(nonce), CipherText Convert.ToBase64String(encrypted), Timestamp DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); context.Response.Body originalBody; context.Response.ContentType application/json; charsetutf-8; await context.Response.WriteAsync(result); } else { context.Response.Body originalBody; await responseBuffer.CopyToAsync(originalBody); } } finally { context.Response.Body originalBody; } } }中间件的核心逻辑都在InvokeAsync里代码不复杂但有几个细节必须说清楚。第一切 tag 的时候我用了cipherBytes[^16..]这是因为 Web Crypto 加密后的返回结果是密文认证标签拼接在一起的一共是明文长度 16字节。.NET 的AesGcm要求密文和 tag 分开所以必须切一下。第二ValidateReplay是防重放的关键不能省。后面单独讲。第三响应加密只能在业务成功执行后做如果业务已经返回了错误状态码比如 401、500我一般不再加密这些错误响应直接透传。原因很简单错误信息本身不包含敏感业务数据而且加密错误响应会让前端排查问题变得异常痛苦。4.5 响应加密与异常处理响应加密里最容易被忽略的是await _next(context)之后业务代码可能已经把响应内容写到了替换后的MemoryStream里但这并不代表它一定遵守了 MVC 的结束流程。比如你的业务代码直接操作了HttpResponse或者用了HttpContext.Response.WriteAsync这些内容也会写入替换后的流所以能正常捕获。但如果你在一个Action Filter里手动设置了Result可能根本还没有触发WriteAsync因为 MVC 的ObjectResult要到更后面的阶段才会真正序列化并写入响应流。好在我把中间件放在整个管道的靠前位置_next(context)会继续向下执行直到 MVC 完成序列化并写入MemoryStream所以最终内容都能截获到。这也再次说明做响应加密一定是中间件方案优于 Filter 方案。4.6 防重放时间戳加随机数接口层加密只能防止看不懂但防不了原样重放。攻击者如果截获了一个合法请求即使他不能解密也可以原封不动地把这段密文再发送一次服务端会判断这是一个有效请求从而造成重复下单、重复操作等后果。我的方案是时间戳加随机数双重校验。每次请求带上timestamp和随机生成的nonce服务端只接受时间差在 5 分钟内的请求并且同一个nonce在一段时间内只能出现一次。public sealed class NonceCache { private readonly ConcurrentDictionarystring, DateTimeOffset _cache new(); private readonly TimeSpan _window TimeSpan.FromMinutes(10); public bool TryAddOnce(string sessionId, string nonce, DateTimeOffset now) { var key ${sessionId}:{nonce}; if (_cache.ContainsKey(key)) { return false; } _cache.TryAdd(key, now.Add(_window)); return true; } public void Cleanup(DateTimeOffset now) { foreach (var item in _cache) { if (item.Value now) { _cache.TryRemove(item.Key, out _); } } } }需要注意的是对于多实例部署NonceCache必须使用 Redis 之类的分布式缓存否则请求打到不同实例上两个实例各自维护一套nonce记录防重放就失效了。单实例部署时用内存足够了。5. 常见问题、调试经验与踩坑记录5.1 前端和后端对 AES-GCM 密文的理解不一致这个真的是踩过最大的坑。Web Crypto 的crypto.subtle.encrypt返回的是密文tag的单块数据而 .NET 的AesGcm.Encrypt是密文和 tag 分开存放的。两边的理解不一致直接导致后端解密时总是抛AuthenticationTagMismatchException。解决办法就是我上面代码里写的切分动作前端返回的字节数组末尾 16 字节是 tag前面是密文。后端拿到之后必须要cipherBytes[..^16]和cipherBytes[^16..]分开再调用AesGcm.Decrypt。反过来也一样。如果后端是加密方要把密文和 tag 拼接在一起返回给前端Web Crypto 接口可以直接吃密文tag的合并格式。5.2 RSA 填充模式不一致引发的解密失败前端用 RSA-OAEP 加密时哈希算法可能写的是SHA-1后端却用OaepSHA256去解密结果必然失败。前端如果用了常见的第三方库比如某个纯 JS RSA 库默认往往是RSA-OAEP with SHA-1而后端默认习惯是 SHA-256两边必须显式统一。我的建议是统一使用SHA-256安全强度更高而且 Web Crypto 原生支持。如果接了老前端库没法改成 SHA-256那后端也要跟着改成OaepSHA1。这个配置最好单独抽成一个公共常量别埋在代码里。5.3 请求体被中间件吃掉导致 MVC 收不到数据这个问题很隐蔽。MemoryStream赋值给context.Request.Body之后如果没有把Position重置为 0MVC 模型绑定会直接失败因为它读到的流已经指向末尾。同时还要注意ASP.NET Core 默认的Request.Body是不能随意替换的在某些框架版本里可能会被其他组件重新设置。更稳妥的方式是在替换Request.Body的同时把Request.ContentLength设置为解密后的字节长度有些组件会依赖这个字段判断请求体是否存在。context.Request.Body new MemoryStream(plainBytes); context.Request.ContentLength plainBytes.Length;5.4 Swagger 和 Postman 调试怎么办启用加密中间件后Swagger 页面和健康检查接口也必须放行否则开发同学打开 Swagger 直接在页面里点Execute看到的全是解密失败。我通常的做法是给中间件增加一组ExcludePaths配置把/swagger、/health、/api/crypto/都排出去同时保留接口加密的默认行为。这样 Swagger 只用于接口列表查看和简单测试真正的联调用前端页面完成两边互不干扰。Postman 调试加密接口比较麻烦。因为你必须在脚本里动态完成握手、生成随机密钥、加密请求体工作量不小。我一般让开发自写一段 Node.js 脚本做加密请求测试比在 Postman 里折腾 Pre-request Script 舒服得多。5.5 大数据包和性能调优在性能测试中AES-GCM 的加解密速度根本不是瓶颈。真正容易拖垮吞吐的是频繁的流复制和内存分配。每一个请求都要把原始请求体读进内存解密后再生成一个新的MemoryStream响应也一样两倍的复制开销是跑不掉的。对于单次请求小于 1MB 的场景这点开销完全可接受。但如果你要处理大文件上传、视频流之类的接口就不建议走统一中间件了。文件上传可以走流式加密或者改用短时效的预签名直传方案避开整包加密这条路。会话密钥存储方面我建议定期清理过期会话防止内存被大量无效会话塞满。服务端每处理一个请求就把对应会话的有效期顺延一次超过 1 小时没有活动的会话直接删掉。不要把这些密钥写进数据库也没必要内存会话本来就是临时数据落盘反而增加泄露风险。5.6 最后的实操心得这套方案落地到生产之后我的感受是应用层加密并不是什么高深的技术真正的难点在于两端约定的统一和异常情况的处理。前后端分离项目里加密格式不一致永远比算法选错更容易让团队崩溃。所以我强烈建议在项目一开始就把接口加密的所有约定写进接口文档里包括 AES-GCM 的密钥长度、nonce 长度、认证标签长度、密文拼接顺序、Base64 格式、时间戳有效期一项都不要落下。宁可文档写得啰嗦也不要让前端同事一遍遍对着后端源码猜我这个字段到底是什么编码。最后再分享一个小经验中间件加密层不要追求覆盖所有接口给不需要加密的路径留一个白名单会让后续调试和功能迭代轻松很多。加密是手段不是目的别让安全方案本身变成阻碍项目交付的沉重负担。