http 请求body加密

HTTP Body 整包加密 --- 原理与 Demo

对应 pay 项目实际实现:@fs/http + @fs/utils AESCrypto + rndKeyWf

  1. 核心问题: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。

  1. 密文结构(与 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 模块,原理相同

  1. 在 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' }))

  1. 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,便于对比密文与明文。

  1. 原生 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 })

优势:所有接口加密逻辑集中在一处,业务代码不用重复写加密。

  1. 响应解密(对称)

服务端返回时同样可能加密:

Content-Type: text/enc;ver=1

Body: Base64 密文字符串

响应拦截器检测 content-type 含 text/enc → AESDecrypt → JSON.parse → 业务拿到明文 response.data。

开发环境下 @fs/http 还会在控制台打印解密后的 request/response,方便调试。

  1. 与 TLS/HTTPS 的区别

HTTPS (TLS)

Body 应用层加密

加密层

传输层,保护整条 TCP 连接

应用层,只加密 body 内容

防什么

中间人窃听、篡改通道

即使 HTTPS 被解密(如 DevTools),body 仍是密文

密钥

证书协商

登录后的 rndKeyWf

谁解密

浏览器/OS

业务网关 / 后端服务

两者常同时使用:HTTPS 保护通道,应用层加密保护业务 payload。

  1. 快速对照表

场景

Network Payload

Content-Type

encrypt

Holdings 查询

密文 Base64

text/enc;ver=1

默认 2

UserOrderExport 导出

明文 JSON-RPC

application/json

0

未登录 / dev

明文

application/json

拦截器跳过

  1. 延伸阅读(项目内代码)

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 })`)
相关推荐
小杨勇敢飞2 小时前
记录一次 VMware 虚拟机固定 IP 后,FinalShell 连接不上的排查过程
网络·网络协议·tcp/ip
weixin_604236679 小时前
华三 路由器 极简核心配置
运维·服务器·网络·h3c·h3c路由器
换个昵称都难13 小时前
webrtc 音频模块FEC模块
网络·音视频·webrtc
youngerwang14 小时前
【从搬运工到协处理器:网卡芯片架构、算法、验证与边缘演进深度剖析】
网络·算法·架构·芯片
zjun100115 小时前
TCP专栏-4.四次挥手
网络协议·tcp/ip
智慧光迅AINOPOL16 小时前
校园在线巡课系统方案:督导全覆盖
网络·全光网解决方案·全光网·校园全光网·校园全光网解决方案
酉鬼女又兒16 小时前
零基础入门计算机网络:网络层核心任务、三大关键问题、两种服务类型与 TCP/IP 网际层协议体系全解析
服务器·网络·网络协议·tcp/ip·计算机网络·php·求职招聘
Urbano17 小时前
工装制作全流程科普:从面料到自动化生产
网络·人工智能
2401_8685347817 小时前
网规笔记 | 真题解析:2018年11月软考网规-网络安全案例分析
网络