国税接口加密报文分析:从前端 JavaScript 到 AES、SM2、SM3、SM4
补充虚拟测试密钥、环境准备、AES/SM4 离线 Demo、运行命令和常见错误的逐步说明。
国税接口加密报文分析:从前端 JavaScript 到 AES、SM2、SM3、SM4
这篇文章记录我整理国税业务接口加密报文的过程。项目的重点不是“把密文直接丢给某个解密函数”,而是先判断请求和响应分别处于哪一层保护,再按照前端代码的执行顺序还原数据流。
我把整个过程拆成四部分:
- 从页面加载和前端 JavaScript 中确认加密入口。
- 区分业务报文加密、会话初始化和签名参数。
- 在本地用固定测试数据验证 AES、SM2、SM3、SM4 的实现。
- 将响应统一转换为 JSON,并保存脱敏后的调试结果。
本文示例针对已经获得授权的测试环境和本地样例数据。示例代码不会包含真实账号、Token、Cookie、私钥、目标地址、验证码处理或反爬绕过参数。
一、先看项目结构
我把项目中与解密直接相关的文件分成三组:
国税解密decrypt/
├── runtime.js # 前端打包后的加密、解密入口
├── runtime1.js # 本地调用 runtime 解密函数的测试脚本
├── decrypt.py # Python 调用 JavaScript 解密函数
├── curl_request.py # 脱敏后的请求解析和 AES 结构测试
├── query_dkjsp.py # 业务查询流程与多层密码学封装
├── SM3.py # 另一套前端登录封装的调试代码
├── chunk.js / apps.js # 前端依赖和业务代码
└── jmbw解密/ # Jmbw 报文的独立测试目录
项目里有两种明显不同的前端实现:
- 一套前端把
datagram交给打包后的 JavaScript 函数处理,Python 通过execjs调用; - 另一套业务查询流程在 Python 中复现了 AES、SM2、SM3、SM4 和摘要链,同时保留 JavaScript 计算盐值的部分。
因此,第一步不是猜算法,而是确认“哪个字段由哪个函数产生”。
二、如何确认加密入口
我先从页面脚本中找出导出的函数。项目里的 runtime.js 最后暴露了一个解密入口,调用方只需要传入会话阶段生成的短期密钥和响应中的 datagram:
function get_decrypt(sessionKey, datagram) {
const fixedPart = buildFixedPart();
const decryptKey = sessionKey.substring(0, 8) + fixedPart;
return decryptPayload(datagram, deriveKey(decryptKey));
}
module.exports = {
get_decrypt
};
这里的函数名和常量已经抽象化。真正分析时,我关注的是调用关系:
响应 datagram
↓
get_decrypt(sessionKey, datagram)
↓
拼接派生材料
↓
对称解密
↓
JSON 字符串
如果只在 Python 中搜索 AES.new,很容易漏掉浏览器端实际完成的关键步骤。我的做法是先定位:
grep -nE "get_decrypt|get_data|get_secret|encryptCode|datagram" runtime.js
然后继续向上追踪 loader(...) 返回的模块,直到看到真正的加密、解密或摘要函数。打包代码很长时,我会优先记录以下信息:
输入字段:datagram、sessionKey、timestamp
输出字段:明文 JSON、signature、加密 datagram
编码方式:Hex、Base64、UTF-8
分组方式:ECB、CBC、SM2 C1C3C2 等
填充方式:PKCS#7 或协议自定义前缀
三、先区分编码、摘要、签名和加密
这几个概念在抓包数据里经常混在一起。
1. Base64 和 Hex 只是编码
Base64 和 Hex 都不提供保密性,只是把二进制数据变成便于传输的文本。
import base64
raw = b"local-test-payload"
base64_text = base64.b64encode(raw).decode("ascii")
hex_text = raw.hex()
assert base64.b64decode(base64_text) == raw
assert bytes.fromhex(hex_text) == raw
判断方式很简单:
- Base64 通常包含字母、数字、
+、/和末尾的=; - Hex 通常只包含
0-9a-f; - 解码后如果仍然是 JSON,说明外面可能只有编码;
- 解码后是不可读的定长二进制,才需要继续判断是否为密文。
2. MD5、SHA-1、SM3 是摘要
摘要算法把任意长度输入映射为固定长度输出,不能当作“可逆加密”。
import hashlib
def digest_chain(text: str) -> dict:
raw = text.encode("utf-8")
return {
"md5": hashlib.md5(raw).hexdigest(),
"sha1": hashlib.sha1(raw).hexdigest(),
}
项目里的签名链会把时间戳、路径、前置摘要和一段页面初始化材料拼在一起,再经过多次摘要。分析时我不把每一层都叫“加密”,而是明确记录:
原始字符串
-> MD5
-> SM3
-> SHA-1
-> 再次组合
-> 作为后续对称加密或签名的输入
3. AES、SM4、SM2 才是加密算法
AES 和 SM4 是对称加密,需要同一把密钥进行加密和解密;SM2 是公钥密码体系,常用于密钥交换、签名或小数据加密。
四、Jmbw 报文的 AES 结构
项目中有一类业务报文使用 AES 包装。它的结构可以概括为:
随机前缀 + JSON 文本
↓
PKCS#7 填充
↓
AES 对称加密
↓
Base64 或 Hex 表示
随机前缀的作用是让相同业务数据每次产生不同的密文。它不是密钥,也不是安全认证机制。解密以后先去掉填充,再去掉固定长度的前缀,剩余内容才是 JSON。
下面是一个完全离线的 AES 示例,使用测试密钥和测试明文:
import base64
import json
import secrets
import string
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
def random_prefix(length=32):
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def encrypt_demo(payload: dict, key_hex: str) -> str:
key = bytes.fromhex(key_hex)
prefix = random_prefix()
plain = prefix + json.dumps(
payload, ensure_ascii=False, separators=(",", ":")
)
encrypted = AES.new(key, AES.MODE_ECB).encrypt(
pad(plain.encode("utf-8"), AES.block_size)
)
return base64.b64encode(encrypted).decode("ascii")
def decrypt_demo(ciphertext: str, key_hex: str) -> dict:
key = bytes.fromhex(key_hex)
encrypted = base64.b64decode(ciphertext)
plain = AES.new(key, AES.MODE_ECB).decrypt(encrypted)
plain = unpad(plain, AES.block_size).decode("utf-8")
payload = plain[32:]
return json.loads(payload)
TEST_KEY = "00112233445566778899aabbccddeeff"
message = {"name": "local-test", "page": 1}
encoded = encrypt_demo(message, TEST_KEY)
assert decrypt_demo(encoded, TEST_KEY) == message
这里使用 ECB 只是为了演示项目中已经存在的报文结构。新系统设计不应该默认选择 ECB,因为相同明文块会产生相同密文块,缺少现代协议常用的随机 nonce 和认证标签。
五、如何推导业务报文密钥
项目中的 AES 密钥不是直接写成一把固定 AES key,而是由会话材料和请求路径经过字符串处理、摘要得到。抽象后可以表示为:
import hashlib
def derive_demo_key(session_material: str, raw_path: str) -> bytes:
seed = f"{session_material[:20]}+{raw_path}?"
return hashlib.md5(seed.encode("utf-8")).hexdigest().encode("ascii")
真正调试时,我会把每一步拆出来打印长度和摘要,不打印原始 Token:
def debug_key_derivation(session_material, raw_path):
key = derive_demo_key(session_material, raw_path)
print("session_material length:", len(session_material))
print("raw_path:", raw_path)
print("derived key length:", len(key))
print("derived key preview:", key[:6].decode("ascii") + "...")
return key
路径必须保持一致。以下内容任何一个发生变化,都可能导致最终密钥不同:
- 是否包含开头的
/; - 是否包含查询字符串;
- 查询参数的顺序;
- 时间戳是否参与派生;
- URL 是否经过编码;
- 使用的是响应路径还是请求路径。
所以我在本地测试时会把 raw_path 固定为测试值,并为每一个样例保存“输入、派生 key 长度、密文编码、明文 JSON”四项记录。
六、SM2 响应解密
另一类响应是椭圆曲线公钥体系产生的密文。项目中通过密文前缀判断是否进入 SM2 分支,之后把 Hex 文本转换为二进制,再按库支持的密文布局解密。
抽象流程如下:
响应 data
↓
判断是否为 SM2 密文格式
↓
Hex 解码
↓
按协议布局解析 C1、C3、C2
↓
使用受保护的私钥解密
↓
UTF-8 文本
SM2 常见的密文布局有 C1C3C2 和 C1C2C3。顺序不同,直接调用同一个解密函数会得到错误结果。排查时我会先确认:
- 密文是否带未参与计算的格式前缀;
- 使用的库默认是哪种布局;
- 明文是否有额外编码;
- 私钥是否来自当前会话;
- 解密后是否还要做 JSON 解析。
下面只展示接口层的抽象写法,不放真实私钥:
from gmssl.sm2 import CryptSM2
def decrypt_sm2_demo(cipher_hex: str, private_key_hex: str) -> str:
if cipher_hex.startswith("04"):
cipher_hex = cipher_hex[2:]
crypt = CryptSM2(
public_key="",
private_key=private_key_hex
)
crypt.mode = 1 # 由项目协议确认对应的密文布局
plaintext = crypt.decrypt(bytes.fromhex(cipher_hex))
return plaintext.decode("utf-8")
私钥不能写入代码仓库、文章、日志或截图。实际系统中应使用密钥管理服务或进程级安全存储,并在解密完成后尽量缩短明文和私钥的生命周期。
七、SM4 在会话初始化中的位置
项目的会话初始化阶段使用 SM4 保护一段短期会话数据。前端 JavaScript 先生成临时材料,再结合服务端返回的公钥或会话随机数构造后续参数。
这部分的关键不是把某个固定字符串当成永久密钥,而是确认每个阶段的输入输出:
阶段 A:获取初始化材料
阶段 B:生成临时会话密钥
阶段 C:用公钥保护临时密钥
阶段 D:使用 SM4 保护下一阶段数据
阶段 E:服务端返回新的会话上下文
本地只做算法自测时,可以使用随机 16 字节密钥验证 SM4 的基本能力:
from gmssl.sm4 import CryptSM4, SM4_ENCRYPT, SM4_DECRYPT
def sm4_roundtrip_demo():
key = bytes.fromhex("00112233445566778899aabbccddeeff")
plaintext = b"local-sm4-test!!"
enc = CryptSM4()
enc.set_key(key, SM4_ENCRYPT)
ciphertext = enc.crypt_ecb(plaintext)
dec = CryptSM4()
dec.set_key(key, SM4_DECRYPT)
recovered = dec.crypt_ecb(ciphertext)
assert recovered == plaintext
return ciphertext.hex()
如果测试失败,我先检查填充方式和库的加解密模式,再检查密钥长度。SM4 的密钥固定为 128 bit,业务协议是否额外做填充则要以实际前端实现为准。
八、签名链的复现方法
项目中还存在一个签名器。它不是简单的 md5(body),而是把路径、时间戳、浏览器环境摘要、页面初始化材料和报文摘要组合在一起。
可以用下面的安全抽象表示:
import hashlib
def md5_hex(value: str) -> str:
return hashlib.md5(value.encode("utf-8")).hexdigest()
def sha1_hex(value: str) -> str:
return hashlib.sha1(value.encode("utf-8")).hexdigest()
def sm3_hex(value: str) -> str:
from gmssl import sm3, func
return sm3.sm3_hash(func.bytes_to_list(value.encode("utf-8")))
def build_signature_material(
timestamp: str,
fingerprint_hash: str,
raw_path: str,
body_json: str,
salt: str,
) -> str:
base = f"{timestamp}-{fingerprint_hash}-{raw_path}"
first = md5_hex(sm3_hex(md5_hex(base)))
chain = f"{fingerprint_hash}{raw_path}&check={first}{timestamp}{salt}"
second = sm3_hex(md5_hex(sha1_hex(chain)))
body_hash = sm3_hex(md5_hex(body_json)) if body_json else second
return md5_hex(sha1_hex(f"{first}.{body_hash}.{timestamp}{salt}"))
这段代码用于说明如何拆分摘要链,不包含平台请求头、Cookie 或真实签名参数。调试时我会对每一层使用固定的本地输入,并把中间值写入单元测试的期望结果:
def test_signature_is_stable():
result = build_signature_material(
timestamp="20260101000000",
fingerprint_hash="test-fingerprint",
raw_path="/local/test",
body_json='{"page":1}',
salt="test-salt",
)
assert len(result) == 40
这里的测试目标是“相同输入得到相同输出”,不是把测试值拿去访问线上服务。
九、前端 JavaScript 与 Python 的协作
当加密实现藏在打包 JavaScript 里时,我没有直接把整份前端代码翻译成 Python,而是先保留 JavaScript 作为参考实现,再用 execjs 调用最小函数。
import execjs
from pathlib import Path
def load_runtime():
source = Path("runtime.js").read_text(encoding="utf-8")
return execjs.compile(source)
def decrypt_with_runtime(runtime, session_key, datagram):
result = runtime.call("get_decrypt", session_key, datagram)
if not isinstance(result, str):
raise TypeError("解密函数返回值不是字符串")
return result
这样做可以避免两个实现同时变化。等本地测试样例全部通过以后,再考虑把稳定的纯算法部分迁移到 Python。
使用 JavaScript 运行时有几个注意点:
- Node.js 版本要与本地测试环境一致;
- 打包代码依赖的全局对象要提前补齐;
- 不要把生产 Token 写入
runtime1.js; - 测试密文使用单独生成的样例;
- 每次升级前端脚本后重新跑固定测试向量。
十、统一的解密调度器
我最后把不同格式的响应统一成一个调度器。它只接受已经经过授权的本地响应样本和显式传入的密钥材料:
import json
def decode_response(data, context):
if not isinstance(data, dict):
raise ValueError("响应不是 JSON 对象")
encrypted = data.get("data") or data.get("datagram")
if not encrypted:
return data
mode = data.get("encryptCode")
if mode == "aes":
plain = decrypt_demo(encrypted, context["aes_key"])
elif mode == "sm2":
plain = decrypt_sm2_demo(
encrypted,
private_key_hex=context["private_key"],
)
elif mode == "runtime":
plain = decrypt_with_runtime(
context["runtime"],
context["session_key"],
encrypted,
)
else:
raise ValueError(f"未知加密模式:{mode}")
return json.loads(plain)
实际代码中我会把 decrypt_demo、decrypt_sm2_demo 和 JavaScript 调用分别测试,不让调度器承担算法细节。这样遇到解密失败时,可以很快判断是分支判断、编码方式、密钥派生还是 JSON 解析出了问题。
十一、错误排查清单
1. AES 解密报数据长度错误
优先检查密文编码。Base64 解码和 Hex 解码不能混用;AES 分组密文长度必须是 16 字节的整数倍。
2. AES 解密后出现乱码
检查密钥是否为 ASCII Hex 字符串,还是已经转换成二进制;再检查 UTF-8 解码和 PKCS#7 去填充。
3. 解密后前面有一段不可读字符
有些业务报文会在 JSON 前放固定长度或随机前缀。应根据本地样例确认前缀长度,不能见到乱码就随意截断。
4. SM2 解密失败
检查 Hex 前缀、C1C3C2/C1C2C3 顺序、私钥长度和曲线参数。错误的密文布局会导致看似“密钥不对”的报错。
5. 同一份数据每次签名不同
检查时间戳、随机数、页面初始化材料和报文 JSON 序列化顺序。签名链只要有一项输入变化,结果就会变化。
6. 浏览器能打开,脚本不能复现
这通常不等于“少了一个请求头”。应把浏览器流程拆成页面初始化、会话建立、业务请求和响应解析四段,逐段确认状态。遇到访问控制或人工验证时停止脚本,使用正常浏览器流程完成验证。
十二、日志和样例数据如何脱敏
我在调试日志中只保留下面这些信息:
def safe_preview(value, limit=12):
if not value:
return ""
value = str(value)
return value[:limit] + "..."
def log_context(context):
print("algorithm:", context.get("algorithm"))
print("cipher length:", len(context.get("ciphertext", "")))
print("key preview:", safe_preview(context.get("key")))
不记录以下内容:
- 完整 Cookie;
- 完整 access token;
- 完整 Authorization;
- 私钥和会话密钥;
- 企业税号、账号手机号和个人身份信息;
- 能直接恢复线上会话的完整请求。
保存样例时,我会把业务字段替换成虚构值,把密文重新用测试密钥生成,并在文件名中标明 sample 或 fixture,防止误把线上数据提交到代码仓库。
十三、一个完整的本地验证顺序
我本地验证时按下面顺序执行:
- 先确认 Python、Node.js 和密码学依赖可以导入。
- 用固定测试向量验证 Base64、Hex、MD5、SHA-1、SM3。
- 用随机生成的本地密钥验证 AES 和 SM4 往返。
- 用测试密钥对验证 SM2 加解密。
- 再调用
runtime.js的最小导出函数。 - 最后测试统一调度器和 JSON 解析。
python3 -m py_compile decrypt.py curl_request.py query_dkjsp.py SM3.py
node runtime1.js
如果测试只依赖本地样例,就不需要访问线上服务。线上联调必须使用正式授权账号和官方允许的访问方式,且不把响应中的敏感字段写进日志。
总结
这个项目的解密过程可以归纳为三条线:
前端运行时:JavaScript 导出函数负责会话阶段数据处理
业务报文:AES 加密,外层可能使用 Base64 或 Hex
安全上下文:SM2、SM3、SM4、SHA-1、MD5 共同参与会话和签名链
真正还原协议时,我先确定字段流向,再确认编码、填充、分组模式和密钥来源,最后用固定样例做本地验证。只要把“编码”和“加密”、“摘要”和“签名”、“会话材料”和“业务数据”分开,整个过程就能按层定位。
文章里的代码全部用于本地算法测试和协议结构说明。真实密钥、Token、Cookie、账号信息以及平台访问控制参数不进入公开文章。
十四、小白从零开始:运行离线密码学 Demo
这篇文章能完整复现的是算法层,不是线上登录和业务接口。第一次操作时,先只验证 AES 和 SM4 的本地往返。
进入项目目录:
cd "/Users/liuweifeng/bnballobj/web_nixiang/国税解密decrypt"
python3 --version
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install --upgrade pip
python3 -m pip install pycryptodome gmssl
python3 beginner_crypto_demo.py
Windows PowerShell 激活虚拟环境的命令是:
.\.venv\Scripts\Activate.ps1
正常输出会类似这样:
AES Base64 密文: <一段本地生成的密文>
SM4 Hex 密文: <一段本地生成的密文>
离线往返校验通过
每次 AES 密文可能不同,是因为这个 Demo 使用了固定明文但加密结果本身经过重新编码;真正重要的是解密后仍然等于原始 JSON。SM4 Demo 使用固定测试密钥和固定 16 字节明文,输出只用于确认加解密方向正确。
如果安装依赖失败,先确认当前命令使用的是虚拟环境中的 Python:
which python3
python3 -m pip show pycryptodome
python3 -m pip show gmssl
如果 AES 报 Data must be aligned,通常是没有做 PKCS#7 填充,或者把 Base64 文本误当成密文字节;如果 SM4 报长度错误,先确认明文长度是分组长度的整数倍。
十五、把 Demo 和原项目对应起来
我会按下面的顺序阅读原项目,不直接运行带有线上配置的入口:
beginner_crypto_demo.py → AES/SM4 基本往返
decrypt.py → Python 调用 JavaScript 运行时
runtime.js → 前端加密函数和导出入口
curl_request.py → 报文编码、填充和 JSON 解析
query_dkjsp.py → 多层摘要和业务流程
SM3.py → 另一套前端会话封装
本地排查时只检查四件事:
- 输入是 Base64 还是 Hex。
- 解码后长度是否满足分组要求。
- 是否存在随机前缀或 PKCS#7 填充。
- 解密结果是否能被 UTF-8 和 JSON 正常解析。
线上部分不能使用文章中的测试密钥直接复现。真实业务还依赖实时会话、服务端下发材料和权限范围,必须走正常登录和平台授权流程。文章中的 Demo 证明的是“算法实现可以正确往返”,不是“可以脱离授权访问线上数据”。
评论 0