HTTP Body 整包加密 --- 原理与 Demo
对应 pay 项目实际实现:@fs/http + @fs/utils AESCrypto + rndKeyWf
- 核心问题:HTTP 能加密整个 body 吗?
能。 HTTP 协议本身不关心 body 里是 JSON 还是乱码,body 本质就是一段字节流。
所谓「整包加密」的做法是:
先把要发的业务数据 JSON.stringify 成字符串
用对称密钥(如 rndKeyWf)做 AES-GCM 加密
把密文编码成 Base64 字符串
用这个字符串替换 axios/fetch 的 body
设置 Content-Type: text/enc;ver=1 告诉服务端需要解密
┌─────────────────────────────────────────────────────────┐
│ 业务代码 │
│ post(url, { jsonrpc, method, params }) │
└────────────────────┬────────────────────────────────────┘
│ 请求拦截器(发出前)
▼
┌─────────────────────────────────────────────────────────┐
│ JSON.stringify → AES-GCM(rndKey) → Base64 字符串 │
│ config.data = "cFxzPEObVEdzAVRFQACP5UYnBVz5..." │
│ Content-Type: text/enc;ver=1 │
└────────────────────┬────────────────────────────────────┘
│ 网络传输
▼
服务端解密
DevTools Network 里看到的「一长串字符」,就是第 4 步之后的 body。
- 密文结构(与 pay 项目一致)
Base64( nonce(12字节) + ciphertext + authTag(16字节) )
部分
长度
作用
nonce
12 字节
随机数,每次请求不同,防重放
ciphertext
变长
AES-GCM 加密后的数据
authTag
16 字节
完整性校验,防篡改
算法:AES-256-GCM
密钥:登录后下发的 rndKey / rndKeyWf(存在 localStorage)
库:项目用 asmcrypto.js(@fs/utils 封装),Demo 用 Node 内置 crypto 模块,原理相同
- 在 pay 项目里谁负责加密?
层级
文件/包
职责
业务 API
src/apis/wealth/index.js
marginOptions(data, { encrypt: 0 }) 控制是否加密
HTTP 入口
src/httpRequest/http.js
注册 LOGINEncryptInterceptor / LOGINDecryptInterceptor
拦截器
@fs/http
发出前替换 config.data,收到后解密 response.data
算法
@fs/utils AESCrypto
AESEncrypt / AESDecrypt
何时加密?
LOGINEncryptInterceptor 的 runWhen 条件(需同时满足):
接口路径版本 v1 及以上
config.encrypt === LOGIN (2)
已登录(session + rndKey 存在)
非 dev 环境
何时不加密?
// 导出等接口显式关闭
post(url, marginOptions(data, { encrypt: 0, responseType: 'blob' }))
- Demo 说明
文件结构
docs/http-body-encryption/
├── README.md # 本文档
├── crypto-util.js # AES-GCM 加解密工具(对齐项目结构)
├── demo-native.js # 原生 fetch 手写加密
└── demo-axios.js # axios 拦截器自动加密
运行
原生 fetch:手动 stringify → encrypt → 作为 body 发送
node docs/http-body-encryption/demo-native.js
axios:业务只传对象,拦截器自动加密 body
node docs/http-body-encryption/demo-axios.js
两个 Demo 均为本地离线运行,不依赖外网。axios 版用 mockAdapter 模拟服务端 echo,便于对比密文与明文。
- 原生 fetch vs axios 拦截器
原生 fetch(demo-native.js)
思路:业务自己完成「序列化 → 加密 → 赋值 body」。
const plainText = JSON.stringify(jsonRpcBody)
const encryptedBody = encryptBody(plainText, RND_KEY)
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'text/enc;ver=1' },
body: encryptedBody, // 字符串,不是对象
})
axios 拦截器(demo-axios.js)
思路:业务仍传 JSON 对象,拦截器在发出前统一加密 ------ 与 pay 项目 @fs/http 一致。
// 请求拦截器
config.data = encryptBody(JSON.stringify(config.data), RND_KEY)
config.headers'Content-Type' = 'text/enc;ver=1'
// 业务侧无感知
http.post(url, { jsonrpc: '2.0', method: '...', params: {} }, { encrypt: 2 })
优势:所有接口加密逻辑集中在一处,业务代码不用重复写加密。
- 响应解密(对称)
服务端返回时同样可能加密:
Content-Type: text/enc;ver=1
Body: Base64 密文字符串
响应拦截器检测 content-type 含 text/enc → AESDecrypt → JSON.parse → 业务拿到明文 response.data。
开发环境下 @fs/http 还会在控制台打印解密后的 request/response,方便调试。
- 与 TLS/HTTPS 的区别
HTTPS (TLS)
Body 应用层加密
加密层
传输层,保护整条 TCP 连接
应用层,只加密 body 内容
防什么
中间人窃听、篡改通道
即使 HTTPS 被解密(如 DevTools),body 仍是密文
密钥
证书协商
登录后的 rndKeyWf
谁解密
浏览器/OS
业务网关 / 后端服务
两者常同时使用:HTTPS 保护通道,应用层加密保护业务 payload。
- 快速对照表
场景
Network Payload
Content-Type
encrypt
Holdings 查询
密文 Base64
text/enc;ver=1
默认 2
UserOrderExport 导出
明文 JSON-RPC
application/json
0
未登录 / dev
明文
application/json
拦截器跳过
- 延伸阅读(项目内代码)
src/httpRequest/http.js --- 拦截器注册、rndKeyWf 配置
src/httpRequest/encrypt.js --- 本地 AES-GCM / ECDH 实现
node_modules/@fs/http/dist/lib/http/FSH5Http/encryptInterceptors/LOGINEncryptInterceptor.js
node_modules/@fs/utils/dist/lib/crypto/AES.js --- AESEncrypt 源码
javascript
在这里插入代码片/**
* 与 pay 项目 @fs/utils AESCrypto 对齐的简化实现:
* Base64( nonce(12字节) + AES-GCM密文(含 auth tag) )
*/
const crypto = require('crypto')
const NONCE_LEN = 12
const KEY_LEN = 32
function normalizeKey(key) {
const buf = Buffer.from(key, 'utf8')
if (buf.length === KEY_LEN) return buf
const out = Buffer.alloc(KEY_LEN)
buf.copy(out)
return out
}
function encryptBody(plainText, key) {
const nonce = crypto.randomBytes(NONCE_LEN)
const cipher = crypto.createCipheriv('aes-256-gcm', normalizeKey(key), nonce)
const encrypted = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return Buffer.concat([nonce, encrypted, tag]).toString('base64')
}
function decryptBody(cipherBase64, key) {
const buf = Buffer.from(cipherBase64, 'base64')
const nonce = buf.subarray(0, NONCE_LEN)
const tag = buf.subarray(buf.length - 16)
const ciphertext = buf.subarray(NONCE_LEN, buf.length - 16)
const decipher = crypto.createDecipheriv('aes-256-gcm', normalizeKey(key), nonce)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')
}
module.exports = { encryptBody, decryptBody }
javascript
在这里插入代码片
/**
* axios 拦截器演示:发出前自动加密整包 body
* 运行: node docs/http-body-encryption/demo-axios.js
*/
const axios = require('axios')
const { encryptBody, decryptBody } = require('./crypto-util')
const RND_KEY = 'demo-rnd-key-32-bytes-long!!!!!'
const ENCRYPT_TYPES = { NO_ENCRYPT: 0, LOGIN: 2 }
// 记录拦截器实际发出的 body,用于离线演示
let lastOutgoingBody = null
function createLoginEncryptInterceptor() {
return config => {
if (config.encrypt !== ENCRYPT_TYPES.LOGIN) return config
if (config.method !== 'post' || config.data == null) return config
const plainText = typeof config.data === 'string' ? config.data : JSON.stringify(config.data)
config.headers = config.headers || {}
config.headers['Content-Type'] = 'text/enc;ver=1'
config.data = encryptBody(plainText, RND_KEY)
lastOutgoingBody = config.data
return config
}
}
function createLoginDecryptInterceptor() {
return response => {
const contentType = response.headers?.['content-type'] || ''
if (typeof response.data === 'string' && contentType.includes('text/enc')) {
const plain = decryptBody(response.data, RND_KEY)
response.data = JSON.parse(plain)
}
return response
}
}
// 自定义 adapter:不真正发网络请求,模拟服务端 echo 密文
function mockAdapter(config) {
const cipherBody = config.data
const isEncrypted = config.encrypt === ENCRYPT_TYPES.LOGIN
return Promise.resolve({
data: isEncrypted ? cipherBody : config.data,
status: 200,
statusText: 'OK',
headers: {
'content-type': isEncrypted ? 'text/enc;ver=1' : 'application/json',
},
config,
})
}
const http = axios.create({ adapter: mockAdapter })
http.interceptors.request.use(createLoginEncryptInterceptor())
http.interceptors.response.use(createLoginDecryptInterceptor())
async function main() {
const jsonRpcBody = {
jsonrpc: '2.0',
method: '/wealth/cbp/v1/UserOrderList',
id: 'axios-demo-id',
params: { startDate: '2025-06-10', endDate: '2026-06-10' },
}
console.log('=== axios 拦截器:整包 body 加密 ===\n')
console.log('[1] 业务传入的 data (对象):')
console.log(jsonRpcBody)
console.log()
// encrypt: LOGIN → 拦截器自动加密
const encryptedResp = await http.post('/wealth/cbp/v1/UserOrderList', jsonRpcBody, {
encrypt: ENCRYPT_TYPES.LOGIN,
})
console.log('[2] 拦截器发出前的 data 类型: object')
console.log('[3] 拦截器发出后的 body 类型: string (密文)')
console.log('实际 HTTP body:', String(lastOutgoingBody).slice(0, 72) + '...')
console.log('是否为 JSON 开头:', String(lastOutgoingBody).startsWith('{'))
console.log()
console.log('[4] 响应拦截器解密后 response.data:')
console.log(encryptedResp.data)
console.log()
// encrypt: 0 → 明文
const plainResp = await http.post('/wealth/cbp/v1/UserOrderExport', jsonRpcBody, {
encrypt: ENCRYPT_TYPES.NO_ENCRYPT,
})
console.log('[5] encrypt:0 时 body 保持明文对象:')
console.log(plainResp.data)
}
main().catch(console.error)
javascript
在这里插入代码片/**
* 原生方式演示:手动加密整包 body
* 运行: node docs/http-body-encryption/demo-native.js
*/
const { encryptBody, decryptBody } = require('./crypto-util')
const RND_KEY = 'demo-rnd-key-32-bytes-long!!!!!'
const jsonRpcBody = {
jsonrpc: '2.0',
method: '/wealth/cbp/v1/Holdings',
id: 'demo-request-id',
params: {
uin: 1171975,
fundTypes: [4],
productTypes: [1, 9],
},
}
function simulateHttpSend(plainObject, key) {
// Step 1: 业务对象 → JSON 字符串
const plainText = JSON.stringify(plainObject)
// Step 2: 加密 → body 变成 Base64 密文字符串(不再是 JSON)
const httpBody = encryptBody(plainText, key)
// Step 3: 模拟 HTTP 请求配置(fetch 的 body 就是这个字符串)
const request = {
method: 'POST',
headers: {
'Content-Type': 'text/enc;ver=1',
'X-session': 'sessionWf=demo-session-token',
},
body: httpBody,
}
return { request, plainText, httpBody }
}
function simulateServerDecrypt(httpBody, key) {
const plainText = decryptBody(httpBody, key)
return JSON.parse(plainText)
}
console.log('=== 原生方式:整包 body 加密 ===\n')
const { request, plainText, httpBody } = simulateHttpSend(jsonRpcBody, RND_KEY)
console.log('[1] 明文 body:')
console.log(plainText)
console.log()
console.log('[2] 加密后 HTTP body (字符串,非 JSON):')
console.log(httpBody.slice(0, 72) + '...')
console.log('body 类型:', typeof request.body)
console.log('Content-Type:', request.headers['Content-Type'])
console.log()
console.log('[3] 服务端解密还原:')
const restored = simulateServerDecrypt(request.body, RND_KEY)
console.log('method:', restored.method)
console.log('params:', restored.params)
console.log()
console.log('[4] 等价 fetch 调用:')
console.log(`fetch(url, { method: 'POST', headers: {...}, body: encryptedString })`)