少数派 的接口是简单的 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 字段只装受保护头。authorizationx-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-Typeapplication/json
X-Cipher-ModeAES-GCM
X-Gateway-Flag用户提供的 route flag
X-Noncebase64url(nonce)
X-TimestampString(ts)
X-Signaturebase64url(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-Noncebase64url, 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-typecontent-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.responseEnvelope[ciphertext, tag] 解密后的明文 JSON。

时间戳

X-TimestampDate.now() 的本地值。init 阶段 SDK 偷偷访问 https://vv.video.qq.com/checktime?otype=json,注入 <script>window.QZOutputJson.ttimeOffsetMs,所有签名 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 单次有效。