少数派 的接口是简单的 REST API,但上面包了一层签名。我的目标是调用查询流量接口
从具体查询的 POST 请求入手,结构是一个加密的密文和似乎为密钥的 tag
// req
POST / HTTP/1.1
Content-Type: application/json
X-Cipher-Mode: AES-GCM
X-Gateway-Flag: flagA
X-Nonce: ...
X-Timestamp: ...
X-Signature: ...
{ "ciphertext": "", "tag": "" }
// resp
{ "ciphertext": "", "contentType": "application/json", "tag": "" }跟踪发起程序定位到是 umi.js 中向 wt.get("/user/stat/getTrafficLog") 路径请求,经过 sec.js 网关加密发出。
从 sec.js 的首行能看出是 jsjiami.com.v7 混淆算法,有个这工具以 sojsonv7 为参数可以解析
再让 AI 分析理解逻辑,整理为可读的代码,并重写为单独的 lib,重放请求来验证,以下是 MiniMax-M3 生成的技术文档:
加密网关协议:逆向分析报告
样本: sec.js
作用:浏览器里拦截同源 XHR/fetch,加密后转发到第三方网关。
下面三块是协议核心:密钥派生、请求信封、响应信封。
密钥派生
代码里 deriveSessionKeys走两段 HKDF-SHA256,IKM 是从 meta[name="secure-setting"] 解出的 master_key_b64url 字段,32 字节。
const ENC_SALT = utf8('secure-gateway-enc');
const MAC_SALT = utf8('secure-gateway-mac');
const prk = HMAC-SHA256(zeroKey, masterKey); // 32 字节全 0
const encKey = HMAC-SHA256(prk, ENC_SALT || 0x01).slice(0, 32);
const macKey = HMAC-SHA256(prk, MAC_SALT || 0x01).slice(0, 32);HKDF 走非标准「单块」路径——PRK 直接用 0^32 当 IKM 算,T(1) 拉 32 字节结束。salt 是字面字符串而非随机值,不过 master_key_b64url 本身是高熵 32 字节,缺这点随机性无所谓。
bootstrap 配置本身的加密不走 HKDF,是另一段独立协议:HMAC-SHA256 校验 + 与 SHA-256(MASTER_KEY_HEX) 循环 XOR。MASTER_KEY_HEX 是 32 字符的十六进制串(写死在 IIFE 顶部)。
请求信封
canonical payload
buildCanonicalRequest 构造:
const canonical = {
method: 'GET',
path: '/api/v1/example',
query: 'a=123&b=456',
headers: {
authorization: 'Bearer <token>' // 受保护头
},
body: '',
contentType: undefined
};几个观察点:
headers字段只装受保护头。authorization和x-api-key单独搬过来- 其它普通头(Accept、Content-Language)留在明文 HTTP 头里
headers字段递归按 key 字母序排序(sortJsonKeysDeep),保证签名输入确定性body在 GET/HEAD 时强制空字符串
AES-GCM 加密
const nonce = crypto.getRandomValues(new Uint8Array(16)); // 16 bytes
const ts = Date.now(); // 毫秒
const iv = nonce.slice(0, 12); // 12 bytes
const aad = concatBytes([uint64BE(ts), nonce]); // 24 bytes
const encrypted = AES-GCM(encKey, iv, aad, utf8(canonicalStr));
const ciphertext = encrypted.slice(0, -16);
const tag = encrypted.slice(-16);AAD 是 uint64BE(timestamp)(8) || nonce(16)。把 timestamp 和 nonce 一起绑进 GCM 标签。
HMAC 签名
const sigInput = concatBytes([uint64BE(ts), nonce, utf8(canonicalStr)]);
const sig = HMAC-SHA256(macKey, sigInput);签名覆盖 timestamp + nonce + canonical 三元组。
出站头
| 头 | 值 |
|---|---|
Content-Type | application/json |
X-Cipher-Mode | AES-GCM |
X-Gateway-Flag | 用户提供的 route flag |
X-Nonce | base64url(nonce) |
X-Timestamp | String(ts) |
X-Signature | base64url(sig) |
明文头里普通头继续透传,受保护头被剥离:
const outHeaders = new Headers();
for (const [k, v] of Object.entries(req.headers)) {
if (k === 'authorization' || k === 'x-api-key') continue;
outHeaders.set(k, v);
}出站 body
{
"ciphertext": "<base64url>",
"tag": "<base64url>"
}tag 字段缺失时 SDK 仍发送 envelope(不带该字段)。
响应信封
入站头
| 头 | 必要 |
|---|---|
X-Response-Encrypted | 必须 AES-GCM |
X-Response-Nonce | base64url, 16 bytes |
Content-Type | 通常 application/json |
解密
const nonce = b64UrlToBytes(resp.headers['X-Response-Nonce']);
const iv = nonce.slice(0, 12);
const data = concatBytes([b64UrlToBytes(envelope.ciphertext),
b64UrlToBytes(envelope.tag)]);
const plain = AES-GCM.decrypt(encKey, iv, data); // 无 AAD响应路径不构造 AAD。这是和出站路径最明显的差异。
响应头清理
剥离 x-response-*、content-type、content-length,再根据 envelope 的 contentType 字段重设。
流量样例
入口 XHR(页面视角):
GET /api/v1/example?a=123&b=456 HTTP/1.1
Host: example.com
Accept: application/json
Content-Language: zh-CN
Authorization: Bearer <token>出站(SDK → 网关):
POST / HTTP/1.1
Host: gw.example.net
Content-Type: application/json
X-Cipher-Mode: AES-GCM
X-Gateway-Flag: flagA
X-Nonce: 9XkzTbVQmP3LwN7sR2cYdA
X-Timestamp: 1717200000123
X-Signature: HjK4mP9xQ7vR2nLwT8cYbF3kZ6gN1oE5iU0sA9dG2hJ4
{"ciphertext":"qR67rKR0vzc_LUKL1B-TG6...","tag":"Dph-A3rPUe_Fx0KKH7hfvg"}明文 HTTP 流量里没有 Authorization 头——它在 headers.authorization 字段里,加密后塞进 envelope。
入站(网关 → SDK):
HTTP/1.1 200 OK
Content-Type: application/json
X-Response-Encrypted: AES-GCM
X-Response-Nonce: 7BnQ2xYkMzF8wR4jT9pLsV
{"ciphertext":"xY8zW2kR5tN7pL3qM1jH6...","tag":"G8bK4dN2sX9pR5wT0cYbF3"}页面 XHR 看到的 xhr.response 是 Envelope[ciphertext, tag] 解密后的明文 JSON。
时间戳
X-Timestamp 是 Date.now() 的本地值。init 阶段 SDK 偷偷访问 https://vv.video.qq.com/checktime?otype=json,注入 <script> 抓 window.QZOutputJson.t 算 timeOffsetMs,所有签名 ts 都加上这个偏移。
const rtt = receivedAt - sentAt;
const serverNow = serverMs + Math.floor(rtt / 2);
settle(serverNow - receivedAt);QQ 视频时间 API 全节点都能用,IP 信任度高。1.5 秒超时,同步失败 timeOffsetMs = 0,不影响启动。
base64url 编解码
function b64UrlToBytes(s) {
const pad = (4 - s.length % 4) % 4;
const std = s.replace(/-/g, '+').replace(/_/g, '/') + '===='.slice(0, pad);
const bin = Buffer.from(std, 'base64').toString('binary');
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function bytesToB64Url(bytes) {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return Buffer.from(bin, 'binary').toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}JSON 排序
canonical payload 在加密前 headers 字段递归按 key 字母序排序:
function sortJsonKeysDeep(v) {
if (Array.isArray(v)) return v.map(sortJsonKeysDeep);
if (v && typeof v === 'object') {
const out = {};
Object.keys(v).sort().forEach(k => { out[k] = sortJsonKeysDeep(v[k]); });
return out;
}
return v;
}作用是保证签名输入确定性。authorization 必须排在 Bearer 之前(按字母),不然每次签名都不一样。
几点观察
B 端 nonce 16 字节、A 端 IV 取前 12 字节。GCM 标准 12 字节 IV,下游能解。
X-Gateway-Flag 用作路由标记,看起来支持多租户/多集群。
X-Response-Encrypted 头必要性。SDK 不做透传,无此头直接报错。设计上强制所有响应都加密。
时间戳不出现在 AAD 之外——但也不能重用,因为 nonce 单次有效。