@ikenxuan/amagi 7.0.0-beta.7 → 7.0.0-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3898 @@
1
+ //#region src/utils/md5.ts
2
+ /**
3
+ * 纯 TypeScript 的 MD5(RFC 1321),不依赖任何运行环境内置模块。
4
+ *
5
+ * ## 为什么不用 `node:crypto`
6
+ *
7
+ * MD5 出现在三处签名里(抖音 X-Bogus、抖音 `x-secsdk-web-signature`、B站 `w_rid`),
8
+ * 而签名验证工具要在**浏览器**里跑 `@ikenxuan/amagi/signing` 这份代码 —— 页面存在的
9
+ * 意义是证明实现本身还是对的,所以实现必须能进浏览器。`node:crypto` 进不去,
10
+ * 这里就是那份「同一份代码」的MD5。
11
+ *
12
+ * 只给签名算法用。MD5 早已不具密码学安全性,不要把它当通用哈希用。
13
+ *
14
+ * ## 边界
15
+ *
16
+ * - 输入按字节处理;字符串先用 `TextEncoder` 转 UTF-8(与 `node:crypto`
17
+ * `createHash('md5')` 默认的 `'utf8'` 编码一致,两边输出逐字节相同)。
18
+ * - 长度计数用两次 `* 4294967296` 进位模拟 64 位比特长度,超出 `Number.MAX_SAFE_INTEGER`
19
+ * 字节的输入不受支持(签名场景不可能出现)。
20
+ */
21
+ /** 每个 round 使用的 32 位常量,来自 sin 函数绝对值的整数部分(RFC 1321 附录) */
22
+ const T = [
23
+ 3614090360,
24
+ 3905402710,
25
+ 606105819,
26
+ 3250441966,
27
+ 4118548399,
28
+ 1200080426,
29
+ 2821735955,
30
+ 4249261313,
31
+ 1770035416,
32
+ 2336552879,
33
+ 4294925233,
34
+ 2304563134,
35
+ 1804603682,
36
+ 4254626195,
37
+ 2792965006,
38
+ 1236535329,
39
+ 4129170786,
40
+ 3225465664,
41
+ 643717713,
42
+ 3921069994,
43
+ 3593408605,
44
+ 38016083,
45
+ 3634488961,
46
+ 3889429448,
47
+ 568446438,
48
+ 3275163606,
49
+ 4107603335,
50
+ 1163531501,
51
+ 2850285829,
52
+ 4243563512,
53
+ 1735328473,
54
+ 2368359562,
55
+ 4294588738,
56
+ 2272392833,
57
+ 1839030562,
58
+ 4259657740,
59
+ 2763975236,
60
+ 1272893353,
61
+ 4139469664,
62
+ 3200236656,
63
+ 681279174,
64
+ 3936430074,
65
+ 3572445317,
66
+ 76029189,
67
+ 3654602809,
68
+ 3873151461,
69
+ 530742520,
70
+ 3299628645,
71
+ 4096336452,
72
+ 1126891415,
73
+ 2878612391,
74
+ 4237533241,
75
+ 1700485571,
76
+ 2399980690,
77
+ 4293915773,
78
+ 2240044497,
79
+ 1873313359,
80
+ 4264355552,
81
+ 2734768916,
82
+ 1309151649,
83
+ 4149444226,
84
+ 3174756917,
85
+ 718787259,
86
+ 3951481745
87
+ ];
88
+ /** 每步循环左移的位数(RFC 1321 的 s 表) */
89
+ const S = [
90
+ 7,
91
+ 12,
92
+ 17,
93
+ 22,
94
+ 7,
95
+ 12,
96
+ 17,
97
+ 22,
98
+ 7,
99
+ 12,
100
+ 17,
101
+ 22,
102
+ 7,
103
+ 12,
104
+ 17,
105
+ 22,
106
+ 5,
107
+ 9,
108
+ 14,
109
+ 20,
110
+ 5,
111
+ 9,
112
+ 14,
113
+ 20,
114
+ 5,
115
+ 9,
116
+ 14,
117
+ 20,
118
+ 5,
119
+ 9,
120
+ 14,
121
+ 20,
122
+ 4,
123
+ 11,
124
+ 16,
125
+ 23,
126
+ 4,
127
+ 11,
128
+ 16,
129
+ 23,
130
+ 4,
131
+ 11,
132
+ 16,
133
+ 23,
134
+ 4,
135
+ 11,
136
+ 16,
137
+ 23,
138
+ 6,
139
+ 10,
140
+ 15,
141
+ 21,
142
+ 6,
143
+ 10,
144
+ 15,
145
+ 21,
146
+ 6,
147
+ 10,
148
+ 15,
149
+ 21,
150
+ 6,
151
+ 10,
152
+ 15,
153
+ 21
154
+ ];
155
+ const rotateLeft = (value, shift) => (value << shift | value >>> 32 - shift) >>> 0;
156
+ /** 小端序读一个 32 位字 */
157
+ const readWordLE = (bytes, offset) => (bytes[offset] | bytes[offset + 1] << 8 | bytes[offset + 2] << 16 | bytes[offset + 3] << 24) >>> 0;
158
+ /**
159
+ * 计算一份消息的 MD5,返回 32 位小写十六进制。
160
+ *
161
+ * @param input - 字节序列,或先按 UTF-8 编码的字符串
162
+ * @returns 32 个小写十六进制字符
163
+ */
164
+ const md5Hex = (input) => {
165
+ const message = typeof input === "string" ? new TextEncoder().encode(input) : input;
166
+ const byteLength = message.length;
167
+ const paddedLength = (byteLength + 8 >> 6) * 64 + 64;
168
+ const padded = new Uint8Array(paddedLength);
169
+ padded.set(message);
170
+ padded[byteLength] = 128;
171
+ const bitLength = byteLength * 8;
172
+ padded[paddedLength - 8] = bitLength & 255;
173
+ padded[paddedLength - 7] = bitLength >>> 8 & 255;
174
+ padded[paddedLength - 6] = bitLength >>> 16 & 255;
175
+ padded[paddedLength - 5] = bitLength >>> 24 & 255;
176
+ const highBits = Math.floor(bitLength / 4294967296);
177
+ padded[paddedLength - 4] = highBits & 255;
178
+ let a0 = 1732584193;
179
+ let b0 = 4023233417;
180
+ let c0 = 2562383102;
181
+ let d0 = 271733878;
182
+ for (let chunk = 0; chunk < paddedLength; chunk += 64) {
183
+ const words = new Array(16);
184
+ for (let index = 0; index < 16; index++) words[index] = readWordLE(padded, chunk + index * 4);
185
+ let a = a0;
186
+ let b = b0;
187
+ let c = c0;
188
+ let d = d0;
189
+ for (let step = 0; step < 64; step++) {
190
+ let f;
191
+ let g;
192
+ if (step < 16) {
193
+ f = b & c | ~b & d;
194
+ g = step;
195
+ } else if (step < 32) {
196
+ f = d & b | ~d & c;
197
+ g = (5 * step + 1) % 16;
198
+ } else if (step < 48) {
199
+ f = b ^ c ^ d;
200
+ g = (3 * step + 5) % 16;
201
+ } else {
202
+ f = c ^ (b | ~d);
203
+ g = 7 * step % 16;
204
+ }
205
+ const sum = a + f + T[step] + words[g] >>> 0;
206
+ const shifted = rotateLeft(sum, S[step]);
207
+ a = d;
208
+ d = c;
209
+ c = b;
210
+ b = b + shifted >>> 0;
211
+ }
212
+ a0 = a0 + a >>> 0;
213
+ b0 = b0 + b >>> 0;
214
+ c0 = c0 + c >>> 0;
215
+ d0 = d0 + d >>> 0;
216
+ }
217
+ const digest = /* @__PURE__ */ new Uint8Array(16);
218
+ for (const [index, word] of [
219
+ a0,
220
+ b0,
221
+ c0,
222
+ d0
223
+ ].entries()) {
224
+ digest[index * 4] = word & 255;
225
+ digest[index * 4 + 1] = word >>> 8 & 255;
226
+ digest[index * 4 + 2] = word >>> 16 & 255;
227
+ digest[index * 4 + 3] = word >>> 24 & 255;
228
+ }
229
+ let hex = "";
230
+ for (const byte of digest) hex += byte.toString(16).padStart(2, "0");
231
+ return hex;
232
+ };
233
+ //#endregion
234
+ //#region src/platforms/bilibili/sign/wbi.ts
235
+ /**
236
+ * B站 wbi 签名器(实例级)。
237
+ *
238
+ * 两处实现要点:
239
+ * - **走 transport**:`getNav` 用 `ctx.send` 发 `/nav`(`reason: 'prepare'`
240
+ * 进 trace),与主请求走同一条路。
241
+ * - **TTL 缓存在实例里**:keys 缓存挂在实例上,TTL 内连续签名只打一次 `/nav`。
242
+ * B站只有一个进程级实例(见下方类文档),所以这份缓存进程内共享。
243
+ *
244
+ * 签名算法本身(`mixinKeyEncTab` / `encWbi`)与旧版逐字一致。
245
+ */
246
+ /** wbi 密钥的 TTL(毫秒)。30 分钟内复用缓存 */
247
+ const WBI_TTL_MS = 18e5;
248
+ /** 混合密钥编码表 */
249
+ const mixinKeyEncTab = [
250
+ 46,
251
+ 47,
252
+ 18,
253
+ 2,
254
+ 53,
255
+ 8,
256
+ 23,
257
+ 32,
258
+ 15,
259
+ 50,
260
+ 10,
261
+ 31,
262
+ 58,
263
+ 3,
264
+ 45,
265
+ 35,
266
+ 27,
267
+ 43,
268
+ 5,
269
+ 49,
270
+ 33,
271
+ 9,
272
+ 42,
273
+ 19,
274
+ 29,
275
+ 28,
276
+ 14,
277
+ 39,
278
+ 12,
279
+ 38,
280
+ 41,
281
+ 13,
282
+ 37,
283
+ 48,
284
+ 7,
285
+ 16,
286
+ 24,
287
+ 55,
288
+ 40,
289
+ 61,
290
+ 26,
291
+ 17,
292
+ 0,
293
+ 1,
294
+ 60,
295
+ 51,
296
+ 30,
297
+ 4,
298
+ 22,
299
+ 25,
300
+ 54,
301
+ 21,
302
+ 56,
303
+ 59,
304
+ 6,
305
+ 63,
306
+ 57,
307
+ 62,
308
+ 11,
309
+ 36,
310
+ 20,
311
+ 34,
312
+ 44,
313
+ 52
314
+ ];
315
+ /** 从 URL 末尾取文件名部分(去扩展名),得到 img_key / sub_key */
316
+ const extractKey = (url) => url.slice(url.lastIndexOf("/") + 1, url.lastIndexOf("."));
317
+ /**
318
+ * 对 imgKey 和 subKey 进行字符顺序打乱编码。
319
+ * @param orig - img_key + sub_key 拼接
320
+ * @returns 32 位 mixin key
321
+ */
322
+ const getMixinKey = (orig) => mixinKeyEncTab.map((n) => orig[n]).join("").slice(0, 32);
323
+ /**
324
+ * 按给定时间戳计算 wbi 签名 —— **验证工具与 {@link encWbi} 共用的唯一实现**。
325
+ *
326
+ * 不修改传入的 `params`;时钟由调用方注入,所以对一份已签名的 URL 重算
327
+ * `w_rid` 时可以钉死它自己的 `wts`,逐字符复现。
328
+ * @param params - 请求参数(不含 wts / w_rid)
329
+ * @param img_key - 图片密钥
330
+ * @param sub_key - 子密钥
331
+ * @param wts - 秒级时间戳
332
+ * @returns 签名产物
333
+ */
334
+ const computeWbiSignature = (params, img_key, sub_key, wts) => {
335
+ const mixinKey = getMixinKey(img_key + sub_key);
336
+ const chr_filter = /[!'()*]/g;
337
+ const canonicalQuery = Object.keys({
338
+ ...params,
339
+ wts
340
+ }).sort().map((key) => {
341
+ const value = (key === "wts" ? wts : params[key]).toString().replace(chr_filter, "");
342
+ return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
343
+ }).join("&");
344
+ return {
345
+ wts,
346
+ w_rid: md5Hex(canonicalQuery + mixinKey),
347
+ canonicalQuery,
348
+ mixinKey
349
+ };
350
+ };
351
+ /**
352
+ * 为请求参数计算 wbi 签名。
353
+ * @param params - 请求参数(不含 wts / w_rid)
354
+ * @param img_key - 图片密钥
355
+ * @param sub_key - 子密钥
356
+ * @returns `&wts=..&w_rid=..` 查询串
357
+ */
358
+ const encWbi = (params, img_key, sub_key) => {
359
+ const curr_time = Math.round(Date.now() / 1e3);
360
+ Object.assign(params, { wts: curr_time });
361
+ const { wts, w_rid } = computeWbiSignature(params, img_key, sub_key, curr_time);
362
+ return `&wts=${wts}&w_rid=${w_rid}`;
363
+ };
364
+ /**
365
+ * B站 wbi 签名器实例。
366
+ *
367
+ * 进程级共享一个:`steps.ts` 模块级建 `sharedWbi`,`wbi()` / `qtparam()` 与
368
+ * `signers.ts` 的签名器表都复用它(`PLATFORM_RUNTIME` 模块求值时只装配一次)。
369
+ * keys 缓存挂在这个实例上 —— TTL 内 `sign` 不会重复打 `/nav`,两条路共享同一份缓存。
370
+ */
371
+ var WbiSigner = class {
372
+ ttlMs;
373
+ now;
374
+ nav;
375
+ /**
376
+ * @param ttlMs - keys 缓存有效期,默认 {@link WBI_TTL_MS}
377
+ * @param now - 时钟(可注入)
378
+ */
379
+ constructor(ttlMs = WBI_TTL_MS, now = Date.now) {
380
+ this.ttlMs = ttlMs;
381
+ this.now = now;
382
+ }
383
+ /**
384
+ * 取 `/nav` 响应体(带 TTL 缓存)。
385
+ *
386
+ * 走 `ctx.send`(reason `'prepare'`)。
387
+ * @param ctx - 执行上下文(提供 send 与 cookie)
388
+ * @returns `/nav` 响应体
389
+ */
390
+ async getNav(ctx) {
391
+ const cached = this.nav;
392
+ if (cached && this.now() - cached.fetchedAt < this.ttlMs) return cached.body;
393
+ const body = (await ctx.send({
394
+ method: "GET",
395
+ url: "https://api.bilibili.com/x/web-interface/nav",
396
+ headers: { cookie: ctx.cookie }
397
+ }, "prepare")).body;
398
+ this.nav = {
399
+ body,
400
+ fetchedAt: this.now()
401
+ };
402
+ return body;
403
+ }
404
+ /** 从 `/nav` 响应体提取 wbi keys */
405
+ keysOf(body) {
406
+ const img = body.data?.wbi_img?.img_url;
407
+ const sub = body.data?.wbi_img?.sub_url;
408
+ if (!img || !sub) throw new Error("wbi 密钥获取失败:/nav 响应缺少 wbi_img");
409
+ return {
410
+ img_key: extractKey(img),
411
+ sub_key: extractKey(sub)
412
+ };
413
+ }
414
+ /**
415
+ * 签名器:给请求 URL 追加 `&wts=..&w_rid=..`。
416
+ *
417
+ * `sign: 'wbi'` 的端点(comments / userDynamicList / userSpaceInfo 等)用它。
418
+ * 首次调用会触发 `/nav` 前置请求(reason `'prepare'`),TTL 内复用。
419
+ * @param spec - 请求描述
420
+ * @param ctx - 执行上下文
421
+ * @returns 带 wbi 签名的请求描述
422
+ */
423
+ sign = async (spec, ctx) => {
424
+ const nav = await this.getNav(ctx);
425
+ const { img_key, sub_key } = this.keysOf(nav);
426
+ const url = new URL(spec.url);
427
+ const params = {};
428
+ for (const [key, value] of url.searchParams.entries()) params[key] = value;
429
+ const query = encWbi(params, img_key, sub_key);
430
+ return {
431
+ ...spec,
432
+ url: spec.url + query
433
+ };
434
+ };
435
+ /** 清空 keys 缓存(下次 sign 重新打 `/nav`)。测试隔离用。 */
436
+ reset() {
437
+ this.nav = void 0;
438
+ }
439
+ };
440
+ /** 创建一个 wbi 签名器实例(B站生产路径只在 `steps.ts` 建一个进程级 `sharedWbi`;测试可各建各的) */
441
+ const createWbiSigner = (ttlMs, now) => new WbiSigner(ttlMs, now);
442
+ //#endregion
443
+ //#region src/contracts/cookie.ts
444
+ /**
445
+ * Cookie 契约。
446
+ *
447
+ * **全仓唯一的 cookie 解析与序列化实现。** 解析按 RFC 6265 的键值语义:
448
+ * 名字大小写敏感、值取第一个 `=` 之后的全部内容、不做 URL 解码。
449
+ *
450
+ * `contracts/` 是零依赖叶子层,本文件不 import 任何模块。
451
+ */
452
+ /**
453
+ * 解析 `name=value; name=value` 形式的 cookie 串。
454
+ *
455
+ * 规则:
456
+ * - 以第一个 `=` 分割,所以值里可以含 `=`(如 base64)。
457
+ * - 名与值两端去空白。
458
+ * - 名为空、或整段没有 `=` 的片段直接跳过。
459
+ * - 同名多次出现时**后者覆盖前者**。
460
+ * - 值不做 URL 解码 —— 签名依赖原始字节,解码会改变签名输入。
461
+ *
462
+ * cookie 名按 RFC 6265 是**大小写敏感**的,因此本函数不做任何大小写归一化。
463
+ * 这与 header 名大小写不敏感(见 `AmagiHeaders`)是两件不同的事。
464
+ * @param cookieString - cookie 串,`undefined` / `null` / 空串都安全
465
+ * @returns 名 → 值的普通对象,无有效项时是空对象
466
+ */
467
+ const parseCookie = (cookieString) => {
468
+ const result = {};
469
+ if (!cookieString) return result;
470
+ for (const pair of cookieString.split(";")) {
471
+ const index = pair.indexOf("=");
472
+ if (index <= 0) continue;
473
+ const name = pair.slice(0, index).trim();
474
+ if (!name) continue;
475
+ result[name] = pair.slice(index + 1).trim();
476
+ }
477
+ return result;
478
+ };
479
+ /**
480
+ * 取单条 cookie 的值。
481
+ *
482
+ * **按名精确匹配**,不是子串匹配 ——
483
+ * `getCookieValue('xa1=WRONG; a1=RIGHT', 'a1')` 返回 `'RIGHT'` 而不是 `'WRONG'`。
484
+ * @param cookieString - 完整 cookie 串
485
+ * @param name - cookie 名,大小写敏感
486
+ * @returns 值;不存在时返回 `undefined`
487
+ */
488
+ const getCookieValue = (cookieString, name) => {
489
+ if (!cookieString || !name) return void 0;
490
+ return parseCookie(cookieString)[name];
491
+ };
492
+ //#endregion
493
+ //#region src/platforms/douyin/sign/secsdkWebSign.ts
494
+ /**
495
+ * 抖音主站 `x-secsdk-web-signature` 纯算实现(TypeScript 移植)
496
+ *
497
+ * 移植自 cv-cat/DouYin_Spider `utils/secsdk_web_sign.py`,算法与常量按该实现逐条对齐。
498
+ *
499
+ * ## 算法
500
+ *
501
+ * ```text
502
+ * plain = {uifid}_{timestamp}_{WEBSIGN_CONST}_{canonical_query}
503
+ * signature = md5(plain) 的 32 位小写十六进制
504
+ * ```
505
+ *
506
+ * `WEBSIGN_CONST` 是 secsdk VMP 常量池里的写死盐,与账号 / 会话无关。
507
+ *
508
+ * ## canonical_query 规范化规则
509
+ *
510
+ * - 保持原始参数顺序,**不排序**;重复参数原样保留。
511
+ * - 每个 value 先解码(`+`→空格、`%XX`→字节)再用 `encodeURIComponent` 重编码。
512
+ * - key **只解码、不重编码**。
513
+ * - 无等号的裸参数补成 `k=`。
514
+ * - `&timestamp={ts}` 追加到 query 末尾后再参与哈希。
515
+ * - query 里没有 `uifid` 时,从 `UIFID` cookie 取值追加到末尾再签。
516
+ *
517
+ * ## 重要
518
+ *
519
+ * 签名算的是**规范化后的 query**,服务端也按收到的 query 校验,所以必须发送
520
+ * {@link signSecsdkWebUrl} 返回的 URL,不能拿原始 URL 直接发。
521
+ *
522
+ * 签名绑 query、**不绑 path**(本项目实测:同一份 query 换 path 仍然 200)。
523
+ *
524
+ * @module platform/douyin/secsdkWebSign
525
+ */
526
+ /** secsdk VM 常量池里的固定盐(与账号 / 会话无关) */
527
+ const WEBSIGN_CONST = "A96D855A08C0A9707F8BEF0D9A527E4E";
528
+ /** 签名参数名 */
529
+ const SECSDK_SIG_KEY = "x-secsdk-web-signature";
530
+ /** 时间戳参数名 */
531
+ const SECSDK_TS_KEY = "timestamp";
532
+ /** 走 secsdk 签名的 GET 接口路径(来自 SDK 的 webSign 策略配置) */
533
+ const PROTECTED_PATHS_GET = [
534
+ "/aweme/v1/web/aweme/detail/",
535
+ "/aweme/v1/web/aweme/post/",
536
+ "/aweme/v1/web/aweme/favorite/",
537
+ "/aweme/v1/web/aweme/listcollection/",
538
+ "/aweme/v1/web/mix/aweme/",
539
+ "/aweme/v1/web/tab/feed/",
540
+ "/aweme/v1/web/mix/list/",
541
+ "/aweme/v1/web/music/aweme/",
542
+ "/aweme/v1/web/music/list/",
543
+ "/aweme/v1/web/mix/detail/",
544
+ "/aweme/v1/web/mix/listcollection/",
545
+ "/aweme/v1/web/music/detail/",
546
+ "/aweme/v1/web/collects/list/",
547
+ "/aweme/v1/web/collects/video/list/"
548
+ ];
549
+ /** 走 secsdk 签名的 POST 接口路径 */
550
+ const PROTECTED_PATHS_POST = [
551
+ "/aweme/v1/web/aweme/detail/",
552
+ "/aweme/v1/web/aweme/post/",
553
+ "/aweme/v1/web/aweme/favorite/",
554
+ "/aweme/v1/web/aweme/listcollection/",
555
+ "/aweme/v1/web/mix/aweme/",
556
+ "/aweme/v1/web/tab/feed/"
557
+ ];
558
+ const HEX_PAIR = /^[0-9A-Fa-f]{2}$/;
559
+ /**
560
+ * 宽容的百分号解码,等价于 Python `urllib.parse.unquote(..., errors='replace')`:
561
+ * 合法的 `%XX` 解成字节,非法的 `%` 原样保留,最后按 UTF-8 解码(坏字节 → U+FFFD)。
562
+ *
563
+ * 不能直接用 `decodeURIComponent` —— 它遇到 `100%` 这种孤立百分号会抛异常,
564
+ * 而 SDK 的行为是把它当字面量留下(`100%` → 签成 `100%25`)。
565
+ *
566
+ * @param input - 待解码字符串
567
+ * @returns 解码后的字符串
568
+ */
569
+ const percentDecodeLoose = (input) => {
570
+ if (!input.includes("%")) return input;
571
+ const bytes = [];
572
+ let i = 0;
573
+ while (i < input.length) {
574
+ if (input[i] === "%" && HEX_PAIR.test(input.slice(i + 1, i + 3))) {
575
+ bytes.push(parseInt(input.slice(i + 1, i + 3), 16));
576
+ i += 3;
577
+ continue;
578
+ }
579
+ for (const byte of new TextEncoder().encode(input[i])) bytes.push(byte);
580
+ i += 1;
581
+ }
582
+ return new TextDecoder().decode(new Uint8Array(bytes));
583
+ };
584
+ /**
585
+ * 等价于 Python `unquote_plus`:先把 `+` 当空格,再做百分号解码。
586
+ *
587
+ * @param input - 待解码字符串
588
+ * @returns 解码后的字符串
589
+ */
590
+ const unquotePlusLoose = (input) => percentDecodeLoose(input.includes("+") ? input.replace(/\+/g, " ") : input);
591
+ /**
592
+ * 按 SDK 规则规范化 query 字符串:顺序不变,value 解码后用 `encodeURIComponent` 重编码,
593
+ * key 只解码不重编码,无等号的裸参数补成 `k=`。
594
+ *
595
+ * @param query - 原始 query 字符串(不含 `?`)
596
+ * @returns 规范化后的 query 字符串
597
+ */
598
+ const canonicalQuery = (query) => {
599
+ const parts = [];
600
+ for (const pair of query.split("&")) {
601
+ if (!pair) continue;
602
+ const eq = pair.indexOf("=");
603
+ const rawKey = eq === -1 ? pair : pair.slice(0, eq);
604
+ const rawValue = eq === -1 ? "" : pair.slice(eq + 1);
605
+ parts.push(`${unquotePlusLoose(rawKey)}=${encodeURIComponent(unquotePlusLoose(rawValue))}`);
606
+ }
607
+ return parts.join("&");
608
+ };
609
+ /**
610
+ * 拆成 `[base, query]`,并剥掉已有的 `timestamp` / 签名字段,保证可重复调用。
611
+ *
612
+ * @param url - 完整 URL
613
+ * @returns `[base, query]` 二元组
614
+ */
615
+ const splitUrl = (url) => {
616
+ const mark = url.indexOf("?");
617
+ if (mark === -1) return [url, ""];
618
+ return [url.slice(0, mark), url.slice(mark + 1).split("&").filter((pair) => {
619
+ if (!pair) return false;
620
+ const name = pair.split("=", 1)[0];
621
+ return name !== "timestamp" && name !== "x-secsdk-web-signature";
622
+ }).join("&")];
623
+ };
624
+ /**
625
+ * secsdk 找访客 id 时认的 cookie 名,**按此顺序取第一个非空值**。
626
+ *
627
+ * 抖音自己铸造访客身份时落的是 `UIFID_TEMP`,`UIFID` 是同一个值的另一种拼写;
628
+ * SDK 六种都认。只认 `UIFID=` 一种会漏掉只有 `UIFID_TEMP` 的 cookie —— 那种 cookie
629
+ * 明明带着可用的访客 id,却被判成「没有」,于是整条 secsdk 加签被跳过。
630
+ *
631
+ * 清单为线上实测的 SDK 查找顺序。
632
+ */
633
+ const UIFID_COOKIE_NAMES = [
634
+ "uifid",
635
+ "uifid_temp",
636
+ "uifidtemp",
637
+ "UIFID",
638
+ "UIFID_TEMP",
639
+ "UIFIDTEMP"
640
+ ];
641
+ /**
642
+ * 从 cookie 字符串里取访客 id。
643
+ *
644
+ * 按 {@link UIFID_COOKIE_NAMES} 的顺序取第一个非空值。用 `getCookieValue` 按名精确
645
+ * 匹配,所以 `UIFID_TEMP=temp; UIFID=real` 不会把 `UIFID` 读成 `temp`
646
+ * (cookie 名大小写敏感,`getCookieValue` 也不做归一化)。
647
+ *
648
+ * @param cookie - cookie 字符串
649
+ * @returns 访客 id,取不到时返回空串
650
+ */
651
+ const extractUifidFromCookie = (cookie) => {
652
+ if (!cookie) return "";
653
+ for (const name of UIFID_COOKIE_NAMES) {
654
+ const value = getCookieValue(cookie, name)?.trim();
655
+ if (value) return value;
656
+ }
657
+ return "";
658
+ };
659
+ /**
660
+ * 判断该 path 是否需要 secsdk 加签(依据 SDK 的 webSign 策略配置)。
661
+ *
662
+ * @param path - URL 的 pathname
663
+ * @param method - HTTP 方法,默认 `GET`
664
+ * @returns 是否需要加签
665
+ */
666
+ const isSecsdkProtected = (path, method = "GET") => {
667
+ return (String(method).toUpperCase() === "POST" ? PROTECTED_PATHS_POST : PROTECTED_PATHS_GET).includes(path);
668
+ };
669
+ /**
670
+ * 计算 `x-secsdk-web-signature`。
671
+ *
672
+ * @param url - 完整 API URL(scheme+host+path+query)。已带 `timestamp` /
673
+ * `x-secsdk-web-signature` 时会自动剥除,可重复调用。
674
+ * @param options - 时间戳与 uifid 兜底值
675
+ * @returns 时间戳、签名与**实际应当发送**的规范化 query
676
+ */
677
+ const signSecsdkWebQuery = (url, options = {}) => {
678
+ const ts = Math.trunc(options.ts ?? Date.now() / 1e3);
679
+ const [, rawQuery] = splitUrl(url);
680
+ let canon = canonicalQuery(rawQuery);
681
+ if (!canon.split("&").filter(Boolean).map((pair) => pair.split("=", 1)[0]).includes("uifid") && options.uifid) {
682
+ const appended = `uifid=${encodeURIComponent(options.uifid)}`;
683
+ canon = canon ? `${canon}&${appended}` : appended;
684
+ }
685
+ const signedQuery = canon ? `${canon}&${SECSDK_TS_KEY}=${ts}` : `${SECSDK_TS_KEY}=${ts}`;
686
+ let uifidValue = "";
687
+ for (const pair of signedQuery.split("&")) if (pair.startsWith("uifid=")) {
688
+ uifidValue = unquotePlusLoose(pair.slice(6));
689
+ break;
690
+ }
691
+ if (!uifidValue && options.uifid) uifidValue = options.uifid;
692
+ const plain = `${uifidValue}_${ts}_${WEBSIGN_CONST}_${signedQuery}`;
693
+ return {
694
+ ts,
695
+ signature: md5Hex(plain),
696
+ signedQuery
697
+ };
698
+ };
699
+ /**
700
+ * 返回可直接发送的完整 URL(规范化 query + timestamp + 签名)。
701
+ *
702
+ * 服务端按收到的 query 校验签名,所以必须发这个返回值,不能拿原始 URL 直接发。
703
+ *
704
+ * @param url - 完整 API URL
705
+ * @param options - 时间戳与 uifid 兜底值
706
+ * @returns 带签名的完整 URL
707
+ */
708
+ const signSecsdkWebUrl = (url, options = {}) => {
709
+ const [base] = splitUrl(url);
710
+ const { signature, signedQuery } = signSecsdkWebQuery(url, options);
711
+ return `${base}?${signedQuery}&${SECSDK_SIG_KEY}=${signature}`;
712
+ };
713
+ /**
714
+ * 对受保护接口补上 secsdk 签名;path 不在策略表里时原样返回。
715
+ *
716
+ * 这是给取数层用的唯一入口 —— 无条件套在 URL 上即可,不需要调用方判断端点。
717
+ *
718
+ * ## 取不到访客 id 时**整个不加签**
719
+ *
720
+ * 取不到就拿空串继续算的话,受保护端点会收到一个「签了名、但签的是一个不存在的
721
+ * 访客」的请求,抖音回 `403 Uifid Not Found` —— 且会被判成风控,**一个签名缺参的
722
+ * 问题看起来像账号被风控**。不加签则表外端点照常工作,表内端点由平台自己点名
723
+ * `uifid`,比自造错误有信息量。与 `webid` 的纪律同源:不传安全,传错致命。
724
+ *
725
+ * 访客 id **算不出来**:它由 secsdk 在真实浏览器里向铸造接口申请,所以这里只能
726
+ * 取、不能造。扫码登录拿回来的是身份 cookie(`sessionid` 那一族),不含访客 id,
727
+ * 这是「刚登录完反而被拦」的成因。
728
+ *
729
+ * @param url - 完整 API URL(应当是**最后一步**,即 a_bogus 等参数都已拼好之后)
730
+ * @param options - cookie / uifid / method / ts
731
+ * @returns 需要加签且拿到访客 id 的返回带签名的 URL,否则原样返回
732
+ */
733
+ const applySecsdkWebSign = (url, options = {}) => {
734
+ let pathname;
735
+ try {
736
+ pathname = new URL(url).pathname;
737
+ } catch {
738
+ return url;
739
+ }
740
+ if (!isSecsdkProtected(pathname, options.method)) return url;
741
+ const uifid = options.uifid || extractUifidFromCookie(options.cookie);
742
+ if (!uifid) return url;
743
+ return signSecsdkWebUrl(url, {
744
+ ts: options.ts,
745
+ uifid
746
+ });
747
+ };
748
+ //#endregion
749
+ //#region src/platforms/douyin/sign/sm3.ts
750
+ /**
751
+ * SM3 哈希(GB/T 32905-2016),纯 TS 实现。
752
+ *
753
+ * 抖音的 a_bogus 用它摘要 query / body / User-Agent。这个文件是移植件,
754
+ * 来源是 Douyin_TikTok_Download_API 的 `signing/native/sm3.py`(Apache-2.0),
755
+ * 与本仓库的 GPL-3.0 兼容。
756
+ *
757
+ * ## 为什么不用 passport/sm3.ts
758
+ *
759
+ * 那个是登录侧的另一份 SM3,入口收 `string` 并按 `charCodeAt` 逐字符取字节 ——
760
+ * 对中文会与 UTF-8 不同。a_bogus 要摘的是 query 与 base64 串(纯 ASCII),
761
+ * 但 UA 链上要摘要的是**按 JS 语义切出来的字节**,两份混用迟早出错,所以
762
+ * 签名侧自带一份以字节为入口的实现,不复用。
763
+ *
764
+ * ## 移植时改了什么
765
+ *
766
+ * 只改了整数语义:Python 的 `& 0xFFFFFFFF` 在 JS 里必须写成 `>>> 0`,
767
+ * 否则位运算的结果是带符号 32 位整数,`~x` 与算术右移都会带进符号位。
768
+ * 摘要字节输出与 Python 逐字节一致。
769
+ */
770
+ /** 32 位掩码,JS 位运算全是带符号的,每步之后都要用它收回无符号域 */
771
+ const MASK = 4294967295;
772
+ /** SM3 初始向量 */
773
+ const SM3_IV = [
774
+ 1937774191,
775
+ 1226093241,
776
+ 388252375,
777
+ 3666478592,
778
+ 2842636476,
779
+ 372324522,
780
+ 3817729613,
781
+ 2969243214
782
+ ];
783
+ const T0 = 2043430169;
784
+ const T1 = 2055708042;
785
+ /** 循环左移。`bits % 32 === 0` 时 JS 的移位计数会自动取模,结果仍正确 */
786
+ const rotl = (value, bits) => {
787
+ const n = bits % 32;
788
+ return (value << n & MASK | value >>> 32 - n) >>> 0;
789
+ };
790
+ const ff = (index, x, y, z) => index < 16 ? (x ^ y ^ z) >>> 0 : (x & y | x & z | y & z) >>> 0;
791
+ const gg = (index, x, y, z) => index < 16 ? (x ^ y ^ z) >>> 0 : (x & y | ~x & z) >>> 0;
792
+ /** 消息扩展:16 个字扩到 68 个,再派生 64 个 `w1` */
793
+ const expand = (block) => {
794
+ const w = block.slice(0, 16);
795
+ for (let i = 16; i < 68; i++) {
796
+ let x = (w[i - 16] ^ w[i - 9] ^ rotl(w[i - 3], 15)) >>> 0;
797
+ x = (x ^ rotl(x, 15) ^ rotl(x, 23)) >>> 0;
798
+ w.push((x ^ rotl(w[i - 13], 7) ^ w[i - 6]) >>> 0);
799
+ }
800
+ const w1 = [];
801
+ for (let i = 0; i < 64; i++) w1.push((w[i] ^ w[i + 4]) >>> 0);
802
+ return {
803
+ w,
804
+ w1
805
+ };
806
+ };
807
+ /** 压缩函数:8 字状态与一个 512 位分组混合 */
808
+ const compress = (state, block) => {
809
+ const { w, w1 } = expand(block);
810
+ let [a, b, c, d, e, f, g, h] = state;
811
+ for (let j = 0; j < 64; j++) {
812
+ const t = j < 16 ? T0 : T1;
813
+ const ss1 = rotl(rotl(a, 12) + e + rotl(t, j) & MASK, 7);
814
+ const ss2 = (ss1 ^ rotl(a, 12)) >>> 0;
815
+ const tt1 = ff(j, a, b, c) + d + ss2 + w1[j] & MASK;
816
+ const tt2 = gg(j, e, f, g) + h + ss1 + w[j] & MASK;
817
+ d = c;
818
+ c = rotl(b, 9);
819
+ b = a;
820
+ a = tt1;
821
+ h = g;
822
+ g = rotl(f, 19);
823
+ f = e;
824
+ e = (tt2 ^ rotl(tt2, 9) ^ rotl(tt2, 17)) >>> 0;
825
+ }
826
+ return state.map((word, index) => (word ^ [
827
+ a,
828
+ b,
829
+ c,
830
+ d,
831
+ e,
832
+ f,
833
+ g,
834
+ h
835
+ ][index]) >>> 0);
836
+ };
837
+ /** 填充:补 `0x80`、补零到 56 mod 64、末尾写 64 位大端比特长度 */
838
+ const pad = (message) => {
839
+ const bitLength = message.length * 8;
840
+ const padded = message.slice();
841
+ padded.push(128);
842
+ while (padded.length % 64 !== 56) padded.push(0);
843
+ const high = Math.floor(bitLength / 4294967296);
844
+ const low = bitLength >>> 0;
845
+ padded.push(high >>> 24 & 255, high >>> 16 & 255, high >>> 8 & 255, high & 255);
846
+ padded.push(low >>> 24 & 255, low >>> 16 & 255, low >>> 8 & 255, low & 255);
847
+ return padded;
848
+ };
849
+ /**
850
+ * 计算 SM3 摘要。
851
+ * @param message - 待摘要的字节
852
+ * @returns 32 字节摘要
853
+ */
854
+ const sm3Hash = (message) => {
855
+ const padded = pad(message);
856
+ let state = SM3_IV.slice();
857
+ for (let offset = 0; offset < padded.length; offset += 64) {
858
+ const block = [];
859
+ for (let i = 0; i < 16; i++) {
860
+ const at = offset + i * 4;
861
+ block.push((padded[at] << 24 | padded[at + 1] << 16 | padded[at + 2] << 8 | padded[at + 3]) >>> 0);
862
+ }
863
+ state = compress(state, block);
864
+ }
865
+ const out = [];
866
+ for (const word of state) out.push(word >>> 24 & 255, word >>> 16 & 255, word >>> 8 & 255, word & 255);
867
+ return out;
868
+ };
869
+ /** 摘要的十六进制形式,便于与公开测试向量对照 */
870
+ const sm3Hexdigest = (message) => sm3Hash(message).map((byte) => byte.toString(16).padStart(2, "0")).join("");
871
+ /**
872
+ * 摘要为 32 个整数的数组,与 a_bogus 内部各处取字节的方式对齐。
873
+ *
874
+ * 字符串按 **UTF-8** 编码后摘要(与 `sm3.py` 的 `sm3_to_array` 一致)。
875
+ * 注意 a_bogus 里另有一处 `jsBytes`,那个走的是 JS `charCodeAt` 语义,
876
+ * 两者不可互换 —— 见 `a_bogus.ts` 的说明。
877
+ * @param data - 字符串、字节数组或整数数组
878
+ * @returns 32 个 0..255 的整数
879
+ */
880
+ const sm3ToArray = (data) => sm3Hash(typeof data === "string" ? Array.from(new TextEncoder().encode(data)) : Array.from(data));
881
+ //#endregion
882
+ //#region src/platforms/douyin/sign/a_bogus.ts
883
+ /**
884
+ * a_bogus —— 抖音 Web 加在每个数据接口上的签名。
885
+ *
886
+ * ## 出处
887
+ *
888
+ * 移植自 Douyin_TikTok_Download_API 的 `signing/native/abogus.py`(Apache-2.0,
889
+ * 与本仓库 GPL-3.0 兼容)。那份是**自己逆向**抖音的 `bdms.js` v1.0.1.19-fix.01
890
+ * 得到的(2026-09-09),不是抄来的片段。
891
+ *
892
+ * 本文件**替换**了原先那份 2024-08 从公开片段原样复制的实现。两份的差别不是
893
+ * 代码风格,是常量:
894
+ *
895
+ * | | 旧实现 | 本实现 |
896
+ * |---|---|---|
897
+ * | 摘要盐值 | `cus` | `dhzx` |
898
+ * | 几何串 | 17 段、写死 `1536\|747\|…` | 9 段、可传入 |
899
+ * | 时钟 / 随机源 | 直接读 `Date.now()` / `Math.random()` | 可注入 |
900
+ * | 能否解码自证 | 否 | 是,见 {@link decode} |
901
+ *
902
+ * 盐值那条是**实测结论**而非推测:拿真实浏览器(bdms 1.0.1.19-fix.01,2026-09-09
903
+ * 捕获)产出的签名反推,三条摘要链在 `dhzx` 下全部命中,在 `cus` 下 query 与 body
904
+ * 两条都对不上。旧盐值能让请求偶尔通过,是因为抖音**抽样校验**——签名过期不报错,
905
+ * 只表现为被判定为高风险的概率上升,所以这个 bug 活了两年没被发现。
906
+ *
907
+ * ## 为什么可注入是重点
908
+ *
909
+ * 旧的 `export default (url, user_agent) => string` 收不下 `now_ms`、`browser_info`
910
+ * 和随机源,于是**无法对着固定样本复现**——而没有复现能力,就没有任何测试能证伪
911
+ * 一个常量。这正是盐值能过期两年的结构性原因。所以本文件的入口是
912
+ * {@link ABogus} 类,`rng` / `nowMs` / `browserInfo` 全部可注入,默认值保持生产可用。
913
+ *
914
+ * ## 噪声不是均匀随机的
915
+ *
916
+ * `Math.random` 永远进不了校验和、也改不了任何可解码字段,所以同一毫秒内的两个
917
+ * 签名大部分字节不同、含义相同。但**这不等于噪声可以随便填**:其中有三个字节不是
918
+ * 噪声,是 SDK 对自己的环境回报(它认为自己在哪个浏览器家族里、自己的探针是否还
919
+ * 在),任何拿到签名的人都能读回去。均匀随机写进去,落在浏览器不可能产出的取值上
920
+ * 就是一个把柄——{@link HEADER_NOISE_BANDS} 与 {@link tripwireNoise} 就是为此存在。
921
+ */
922
+ /** bdms.js 携带的五张字母表中,本文件用得到的两张:`s4` 编码成品签名,`s3` 编码进入第三条摘要的 UA */
923
+ const ALPHABETS = {
924
+ s3: "ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe",
925
+ s4: "Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe"
926
+ };
927
+ /** 摘要前拼在 query 与 body 后面的盐值。实测值,不是猜的——见文件头 */
928
+ const SALT = "dhzx";
929
+ /** header 携带的两个明文字节,是这套格式最接近魔数的东西,也是 {@link structureError} 的第一道检查 */
930
+ const HEADER_MAGIC = [3, 82];
931
+ /** `"1.0.1.19-fix.01"` 被 SDK 解析后的版本块,实测解码结果 */
932
+ const SDK_VERSION = [
933
+ 1,
934
+ 0,
935
+ 1,
936
+ 0
937
+ ];
938
+ /** 单字节,且是全部的密钥。密文不是教科书 RC4,见 {@link rc4} */
939
+ const PAYLOAD_KEY$1 = 211;
940
+ /** `2024-07-24T16:00:00Z`,有一个字段从此刻起数「两周」 */
941
+ const FORTNIGHT_EPOCH_MS = 17218368e5;
942
+ /** 抖音 Web 自己的标识,不是我们能选的:它同时出现在签名里和 query 里,两边不一致就是最廉价的把柄 */
943
+ const PAGE_ID = 6241;
944
+ const AID = 6383;
945
+ /** 噪声层用三字节藏一个字节,两张掩码互补:`0x91 | 0x42 | 0x2C === 0xFF`,这是整个噪声层可逆的原因 */
946
+ const NOISE_MASKS = [
947
+ 145,
948
+ 66,
949
+ 44
950
+ ];
951
+ const DATA_MASKS = [
952
+ 110,
953
+ 189,
954
+ 211
955
+ ];
956
+ /**
957
+ * 五十个标量字段在 body 里的排列顺序。
958
+ *
959
+ * 不是密码,只是一个固定置换;写成字面量而不是算出来,是因为字节码里就是这样,
960
+ * 而且照着真实捕获核对的人希望看到和解码器打印的同一个顺序。
961
+ */
962
+ const FIELD_ORDER = [
963
+ "L34",
964
+ "L44",
965
+ "L56",
966
+ "L61",
967
+ "L73",
968
+ "L29",
969
+ "L70",
970
+ "L45",
971
+ "L35",
972
+ "L49",
973
+ "L38",
974
+ "L66",
975
+ "L51",
976
+ "L68",
977
+ "L28",
978
+ "L48",
979
+ "L64",
980
+ "L47",
981
+ "L30",
982
+ "L71",
983
+ "L26",
984
+ "L55",
985
+ "L31",
986
+ "L69",
987
+ "L59",
988
+ "L40",
989
+ "L62",
990
+ "L63",
991
+ "L27",
992
+ "L72",
993
+ "L41",
994
+ "L74",
995
+ "L57",
996
+ "L52",
997
+ "L42",
998
+ "L39",
999
+ "L33",
1000
+ "L67",
1001
+ "L53",
1002
+ "L43",
1003
+ "L65",
1004
+ "L46",
1005
+ "L36",
1006
+ "L24",
1007
+ "L60",
1008
+ "L32",
1009
+ "L79",
1010
+ "L80",
1011
+ "L84",
1012
+ "L85"
1013
+ ];
1014
+ /** 三条摘要链的哨兵三元组:`[哨兵, 偏移, 兜底]`。写哨兵本身是 SDK 报告自己环境探针触发的暗号,我们永远写诚实值 */
1015
+ const CANARIES = [
1016
+ [
1017
+ 3,
1018
+ 11,
1019
+ 12
1020
+ ],
1021
+ [
1022
+ 4,
1023
+ 8,
1024
+ 9
1025
+ ],
1026
+ [
1027
+ 5,
1028
+ 12,
1029
+ 13
1030
+ ]
1031
+ ];
1032
+ /**
1033
+ * 三个被摘要的输入各自落在哪些标量上。
1034
+ *
1035
+ * 生成器和解码器**读同一张表**,所以解码器不可能与它所解的东西漂移。
1036
+ *
1037
+ * 每个输入三个字节就是全部的绑定,这个不对称正是重点:足够**证明**某个 query /
1038
+ * body / UA 就是这份签名封住的那一个,又远不足以把哈希倒推回去。把它们报成
1039
+ * 「query 本身」的解码器是在编造;拿候选值去校验的解码器才在说真话。
1040
+ */
1041
+ const DIGEST_CHAINS = {
1042
+ query: {
1043
+ slots: [
1044
+ "L48",
1045
+ "L49",
1046
+ "L51"
1047
+ ],
1048
+ indices: [9, 18],
1049
+ canary: CANARIES[0]
1050
+ },
1051
+ body: {
1052
+ slots: [
1053
+ "L52",
1054
+ "L53",
1055
+ "L55"
1056
+ ],
1057
+ indices: [10, 19],
1058
+ canary: CANARIES[1]
1059
+ },
1060
+ user_agent: {
1061
+ slots: [
1062
+ "L56",
1063
+ "L57",
1064
+ "L59"
1065
+ ],
1066
+ indices: [11, 21],
1067
+ canary: CANARIES[2]
1068
+ }
1069
+ };
1070
+ /** 未被插桩的真实页面回报的环境值:六个探针加一个反机器人位集 */
1071
+ const ENV_FLAGS = 1;
1072
+ const DETECT_FLAGS = 14;
1073
+ const NR_TAG = [
1074
+ 0,
1075
+ 0,
1076
+ 0,
1077
+ 0
1078
+ ];
1079
+ /** `window.onwheelx._Ax` 存在且被冻结——SDK 给自己装上再冻住的状态。12 表示被人解锁、11 表示缺失,两者都是抖音能打分的特征 */
1080
+ const TRIPWIRE_LOCKED = 3;
1081
+ /** `fn149` 给每页签名计数器分桶。6 表示「本页签名少于 140 次」,浏览器一生中大部分时间都在这里 */
1082
+ const CALL_BUCKET = 6;
1083
+ /** 1080p 上的 Chrome。指纹里没有屏幕尺寸时用它——**一个可信的常量好过随机值**,随请求变的几何本身就是特征 */
1084
+ const DEFAULT_BROWSER_INFO = "1920|947|1920|1032|1920|1032|1920|1080|Win32";
1085
+ /** 从屏幕高度猜可视区时要减掉的系统任务栏与浏览器自身 chrome */
1086
+ const TASKBAR_PX = 48;
1087
+ const CHROME_PX = 85;
1088
+ /**
1089
+ * SDK 认为自己身处哪个浏览器家族,按 `fn143` 的口径:每个家族一条 40 宽的带。
1090
+ *
1091
+ * 这些带**盖不满 0..255**。落在缝隙里的值没有浏览器能产出;落在错误家族带里的值与
1092
+ * 旁边发出去的 User-Agent 自相矛盾——两者都是抖音免费可打的分。均匀随机有 6% 的
1093
+ * 概率落进缝隙、78% 的概率落错家族。
1094
+ */
1095
+ const HEADER_NOISE_BANDS = {
1096
+ chrome: 0,
1097
+ firefox: 40,
1098
+ safari: 81,
1099
+ edge: 125,
1100
+ huawei: 170,
1101
+ other: 210
1102
+ };
1103
+ /** `fn145` 的健康位型:三个探针都在且被冻住。均匀随机有 94% 的概率报告出一个被剥离或解锁的页面 */
1104
+ const TRIPWIRE_SET = 178;
1105
+ const TRIPWIRE_FREE = 77;
1106
+ const toByte = (value) => value & 255;
1107
+ /**
1108
+ * 按九个字段拼出签名会逐字携带的 `navigator`/`screen` 串。
1109
+ *
1110
+ * 顺序不可商量:这是载荷里唯一一路以可读文本活到校验和的部分。
1111
+ * @param parts - 九个字段,顺序同 SDK 自己的对象字面量
1112
+ * @returns 以 `|` 连接的几何串
1113
+ */
1114
+ const buildBrowserInfo = (parts) => [
1115
+ parts.innerWidth,
1116
+ parts.innerHeight,
1117
+ parts.outerWidth,
1118
+ parts.outerHeight,
1119
+ parts.availWidth,
1120
+ parts.availHeight,
1121
+ parts.screenWidth,
1122
+ parts.screenHeight,
1123
+ parts.platform
1124
+ ].join("|");
1125
+ /**
1126
+ * 由一个屏幕尺寸推出一组自洽的九字段几何串。
1127
+ *
1128
+ * 浏览器报的四个矩形并不独立:窗口不超过工作区,工作区是屏幕减系统栏,可视区是窗口
1129
+ * 减浏览器自身。从一个数派生能让它们互相自洽,而随机拼出来的一组不会。
1130
+ * @param width - 屏幕宽
1131
+ * @param height - 屏幕高
1132
+ * @param platform - `navigator.platform`
1133
+ * @returns 九字段几何串
1134
+ */
1135
+ const browserInfoFromScreen = (width, height, platform) => {
1136
+ const availHeight = Math.max(height - TASKBAR_PX, 1);
1137
+ return buildBrowserInfo({
1138
+ innerWidth: width,
1139
+ innerHeight: Math.max(availHeight - CHROME_PX, 1),
1140
+ outerWidth: width,
1141
+ outerHeight: availHeight,
1142
+ availWidth: width,
1143
+ availHeight,
1144
+ screenWidth: width,
1145
+ screenHeight: height,
1146
+ platform
1147
+ });
1148
+ };
1149
+ /**
1150
+ * SDK 的 RC4 —— 但它不是 RC4。
1151
+ *
1152
+ * 两处刻意偏离,都在初始化:S 盒以**降序**恒等排列起手,密钥编排在教科书版本只做加法的
1153
+ * 地方做了乘法。密钥流生成部分没动。
1154
+ *
1155
+ * 这两处的意义比看起来大:合起来意味着拿标准 RC4 去暴力试每一个单字节密钥,对捕获到的
1156
+ * 载荷一无所获。逆向方在读懂字节码之前正是卡在这里,而这多半就是它们存在的理由。
1157
+ * @param key - 密钥字节
1158
+ * @param data - 待处理数据
1159
+ * @returns 与输入等长的结果
1160
+ */
1161
+ const rc4$1 = (key, data) => {
1162
+ const box = new Array(256);
1163
+ for (let i = 0; i < 256; i++) box[255 - i] = i;
1164
+ let j = 0;
1165
+ for (let i = 0; i < 256; i++) {
1166
+ j = (j * box[i] + j + key[i % key.length]) % 256;
1167
+ const swap = box[i];
1168
+ box[i] = box[j];
1169
+ box[j] = swap;
1170
+ }
1171
+ const out = new Array(data.length);
1172
+ let i = 0;
1173
+ j = 0;
1174
+ for (let index = 0; index < data.length; index++) {
1175
+ i = (i + 1) % 256;
1176
+ j = (j + box[i]) % 256;
1177
+ const swap = box[i];
1178
+ box[i] = box[j];
1179
+ box[j] = swap;
1180
+ out[index] = data[index] ^ box[(box[i] + box[j]) % 256];
1181
+ }
1182
+ return out;
1183
+ };
1184
+ /**
1185
+ * 按 SDK 的字母表做 base64,补位是字面量 `=`。
1186
+ * @param data - 待编码字节
1187
+ * @param alphabet - `s3` 或 `s4`
1188
+ * @returns base64 文本
1189
+ */
1190
+ const encodeBase64 = (data, alphabet = "s4") => {
1191
+ const table = ALPHABETS[alphabet];
1192
+ const out = [];
1193
+ for (let offset = 0; offset < data.length; offset += 3) {
1194
+ const chunk = data.slice(offset, offset + 3);
1195
+ let block = 0;
1196
+ for (let i = 0; i < 3; i++) block = block << 8 | (i < chunk.length ? chunk[i] : 0);
1197
+ const digits = [
1198
+ block >>> 18 & 63,
1199
+ block >>> 12 & 63,
1200
+ block >>> 6 & 63,
1201
+ block & 63
1202
+ ];
1203
+ for (const digit of digits.slice(0, chunk.length + 1)) out.push(table[digit]);
1204
+ }
1205
+ const padCount = (4 - out.length % 4) % 4;
1206
+ for (let i = 0; i < padCount; i++) out.push("=");
1207
+ return out.join("");
1208
+ };
1209
+ /**
1210
+ * {@link encodeBase64} 的逆。遇到非本表字符时抛错。
1211
+ * @param text - base64 文本
1212
+ * @param alphabet - `s3` 或 `s4`
1213
+ * @returns 解出的字节
1214
+ */
1215
+ const decodeBase64 = (text, alphabet = "s4") => {
1216
+ const table = ALPHABETS[alphabet];
1217
+ const index = /* @__PURE__ */ new Map();
1218
+ for (let i = 0; i < table.length; i++) index.set(table[i], i);
1219
+ const body = text.replace(/=+$/, "");
1220
+ const out = [];
1221
+ for (let offset = 0; offset < body.length; offset += 4) {
1222
+ const chunk = body.slice(offset, offset + 4);
1223
+ let block = 0;
1224
+ for (const char of chunk) {
1225
+ const digit = index.get(char);
1226
+ if (digit === void 0) throw new Error(`不是 ${alphabet} 字母表中的字符:${JSON.stringify(char)}`);
1227
+ block = block << 6 | digit;
1228
+ }
1229
+ block <<= 6 * (4 - chunk.length);
1230
+ const shifts = [
1231
+ 16,
1232
+ 8,
1233
+ 0
1234
+ ].slice(0, chunk.length - 1);
1235
+ for (const shift of shifts) out.push(block >>> shift & 255);
1236
+ }
1237
+ return out;
1238
+ };
1239
+ /**
1240
+ * 一个 JS 字符串被 SDK 转成字节的方式——**不是 UTF-8**。
1241
+ *
1242
+ * `fn139` 走 UTF-16 码元,低于 U+0100 的码元出一个字节,高于的出两个大端字节,
1243
+ * 于是代理对变四字节、一个汉字变两字节,而 UTF-8 会给三字节。对着 43 个真实签名测过:
1244
+ * 这条规则复现出声明的几何长度 43 次,UTF-8 复现 41 次。
1245
+ *
1246
+ * 它只在 U+00FF 以上才分叉,所以这个差别一直隐身,直到某个 `navigator.platform`
1247
+ * 里带了中文。而几何长度被校验和覆盖,写错会让整帧错位、签名整体失效,
1248
+ * 不是某一个字段被弄脏。
1249
+ * @param text - 待转换文本
1250
+ * @returns 按 JS 码元语义切出的字节
1251
+ */
1252
+ const jsBytes = (text) => {
1253
+ const out = [];
1254
+ for (let i = 0; i < text.length; i++) {
1255
+ const code = text.charCodeAt(i);
1256
+ if (code & 65280) out.push(code >>> 8 & 255);
1257
+ out.push(code & 255);
1258
+ }
1259
+ return out;
1260
+ };
1261
+ /** 取 `value` 的 `count` 个小端字节 */
1262
+ const leBytes = (value, count) => {
1263
+ const out = [];
1264
+ for (let index = 0; index < count; index++) out.push(Math.floor(value / 2 ** (8 * index)) & 255);
1265
+ return out;
1266
+ };
1267
+ /**
1268
+ * 摘要里从 `offset` 起第一个不等于 `sentinel` 的字节。
1269
+ *
1270
+ * 哨兵是保留值:写它本身是 SDK 告诉服务端自己的环境探针触发了。我们永远不写,
1271
+ * 所以这里只会报出摘要本身。
1272
+ * @param digest - 该链的摘要
1273
+ * @param offset - 起始下标
1274
+ * @param sentinel - 保留值
1275
+ * @param fallback - 全被保留时的兜底
1276
+ * @returns 该槽位的字节
1277
+ */
1278
+ const canary = (digest, offset, sentinel, fallback) => {
1279
+ for (let index = offset; index < digest.length; index++) if (digest[index] !== sentinel) return digest[index];
1280
+ return fallback;
1281
+ };
1282
+ /**
1283
+ * `SM3(SM3(text + salt))`,32 个整数。
1284
+ *
1285
+ * 盐值留成参数是为了**验证**:拿一份真实签名,把候选盐值逐个代进来,看哪一个能
1286
+ * 重现签名里封住的那三个字节。旧实现用错盐值两年没被发现,就是因为没有这条路径。
1287
+ * @param text - 待摘要文本
1288
+ * @param salt - 拼接的盐值
1289
+ * @returns 摘要
1290
+ */
1291
+ const digestWith = (text, salt) => sm3ToArray(sm3ToArray(text + salt));
1292
+ /**
1293
+ * `SM3(SM3(text + SALT))`,32 个整数。
1294
+ * @param text - 待摘要文本
1295
+ * @returns 摘要
1296
+ */
1297
+ const digestOf = (text) => digestWith(text, SALT);
1298
+ /**
1299
+ * 第三条链:把 UA 过一遍非标准 RC4、base64,再摘要**一次**。
1300
+ *
1301
+ * 与另外两条不同,这里是单次 SM3。RC4 的密钥是三字节,由环境探针拼出来,
1302
+ * 所以对同一个身份是常量,不是每次调用都变。
1303
+ *
1304
+ * ## 密钥必须来自**这份签名自己**报的环境值
1305
+ *
1306
+ * `flags` 默认取模块常量(签名时就是这两个值),但**解码时必须传入签名里解出的
1307
+ * `envFlags` / `detectFlags`** —— 它们随页面环境变(实测见过 14 和 4 两种),
1308
+ * 拿常量去校验一份 `detect_flags` 为 4 的签名,这条链必然报「对不上」。
1309
+ * 上游那份 Python 实现没有这个参数,这是移植时补的。
1310
+ * @param userAgent - 原始 User-Agent
1311
+ * @param flags - 环境探针值,默认取本模块的常量
1312
+ * @returns 摘要
1313
+ */
1314
+ const userAgentDigest = (userAgent, flags = {}) => {
1315
+ const envFlags = flags.envFlags ?? 1;
1316
+ const detectFlags = flags.detectFlags ?? 14;
1317
+ const key = [
1318
+ Math.floor(envFlags / 256),
1319
+ envFlags % 256,
1320
+ detectFlags % 256
1321
+ ];
1322
+ const sealed = rc4$1(key, jsBytes(userAgent.trim()).map(toByte));
1323
+ return sm3ToArray(encodeBase64(sealed, "s3"));
1324
+ };
1325
+ /**
1326
+ * 一条链在给定摘要下贡献的三个字节。
1327
+ * @param chain - 链定义
1328
+ * @param digest - 该链自己的摘要
1329
+ * @returns 三个字节
1330
+ */
1331
+ const chainBytes = (chain, digest) => [
1332
+ digest[chain.indices[0]],
1333
+ digest[chain.indices[1]],
1334
+ canary(digest, chain.canary[0], chain.canary[1], chain.canary[2])
1335
+ ];
1336
+ /** 由 UA 判断 `fn143` 会从哪条带取值。顺序要紧:Edge 和华为的 UA 里都写着 Chrome,Chrome 的写着 Safari,SDK 先测具体的再测笼统的 */
1337
+ const familyOf = (userAgent) => {
1338
+ const ua = userAgent.toLowerCase();
1339
+ for (const name of [
1340
+ "edg",
1341
+ "huawei",
1342
+ "firefox",
1343
+ "chrome",
1344
+ "safari"
1345
+ ]) if (ua.includes(name)) return name === "edg" ? "edge" : name;
1346
+ return "other";
1347
+ };
1348
+ /** `fn143`:一个扮成噪声的浏览器家族回报 */
1349
+ const headerNoise = (userAgent, rng) => HEADER_NOISE_BANDS[familyOf(userAgent)] + Math.floor(rng() * 40) & 255;
1350
+ /**
1351
+ * `fn144`:`nr()[4] & 64` 为空时走的支,实际就是走这支。
1352
+ *
1353
+ * 110 以下原样通过,以上 SDK 会把它逼成奇数,于是 110..240 里有一半取不到。
1354
+ * 一个均匀字节有 31% 的概率落在取不到的集合里。
1355
+ */
1356
+ const probeNoise = (rng) => {
1357
+ const value = Math.floor(rng() * 240);
1358
+ return value > 109 ? value + value % 2 + 1 : value;
1359
+ };
1360
+ /** `fn145`:「我的探针都在且被冻住」,外加真噪声 */
1361
+ const tripwireNoise = (rng) => Math.floor(rng() * 255) & TRIPWIRE_FREE | TRIPWIRE_SET;
1362
+ /**
1363
+ * 把两个字节摊到四个字节上,每字节的另外一半填噪声。
1364
+ *
1365
+ * 每个输出字节从载荷取一半位、从噪声字节取一半,掩码交替为 `0xAA`/`0x55`。还原只需要
1366
+ * 载荷那一半——但噪声那一半同样可还原,而 SDK 在其中两处拿它说了关于自己的事。
1367
+ * `low` / `high` 就是调用方提供这些回报的途径;不给时就是 SDK 无事可报时的普通抽取。
1368
+ */
1369
+ const maskPair = (pair, rng, overrides = {}) => {
1370
+ const noise = Math.floor(rng() * 65535);
1371
+ const low = toByte(overrides.low === void 0 ? noise & 255 : overrides.low);
1372
+ const high = toByte(overrides.high === void 0 ? noise >>> 8 & 255 : overrides.high);
1373
+ return [
1374
+ low & 170 | pair[0] & 85,
1375
+ low & 85 | pair[0] & 170,
1376
+ high & 170 | pair[1] & 85,
1377
+ high & 85 | pair[1] & 170
1378
+ ];
1379
+ };
1380
+ /** {@link maskPair} 的逆:把每个字节的载荷半边取回来 */
1381
+ const unmaskPair = (carrier) => [carrier[0] & 85 | carrier[1] & 170, carrier[2] & 85 | carrier[3] & 170];
1382
+ /**
1383
+ * 三个 body 字节加一个噪声字节变成四个载体字节。
1384
+ *
1385
+ * 每个载体留一部分自己的字节、其余从噪声取;第四个字节恰好收拢前三个让出去的位,
1386
+ * 且落在它们原来的位位置上。字节内部没有任何位移,这既是这一层可逆的原因,
1387
+ * 也是它看起来仍像密文的原因。
1388
+ */
1389
+ const expandNoise = (body, rng) => {
1390
+ const out = [];
1391
+ for (let offset = 0; offset < body.length; offset += 3) {
1392
+ const group = body.slice(offset, offset + 3);
1393
+ if (group.length < 3) {
1394
+ out.push(group[0]);
1395
+ if (group.length > 1 && group[1]) out.push(group[1]);
1396
+ continue;
1397
+ }
1398
+ const noise = Math.floor(rng() * 1e3) & 255;
1399
+ for (let index = 0; index < 3; index++) out.push(noise & NOISE_MASKS[index] | group[index] & DATA_MASKS[index]);
1400
+ out.push(group[0] & NOISE_MASKS[0] | group[1] & NOISE_MASKS[1] | group[2] & NOISE_MASKS[2]);
1401
+ }
1402
+ return out;
1403
+ };
1404
+ /** {@link expandNoise} 的逆 */
1405
+ const collapseNoise = (frame) => {
1406
+ const out = [];
1407
+ for (let offset = 0; offset < frame.length; offset += 4) {
1408
+ const group = frame.slice(offset, offset + 4);
1409
+ if (group.length < 4) {
1410
+ out.push(...group);
1411
+ continue;
1412
+ }
1413
+ for (let index = 0; index < 3; index++) out.push(group[index] & DATA_MASKS[index] | group[3] & NOISE_MASKS[index]);
1414
+ }
1415
+ return out;
1416
+ };
1417
+ /**
1418
+ * 为一个浏览器身份计算 `a_bogus`。
1419
+ *
1420
+ * 除配置外无状态、也很便宜,所以「一个身份一个实例」是自然的生命周期。
1421
+ * `rng` 存在的意义是让测试能钉住噪声——它产出什么,签名都是对的。
1422
+ */
1423
+ var ABogus = class {
1424
+ userAgent;
1425
+ browserInfo;
1426
+ pageId;
1427
+ aid;
1428
+ envFlags;
1429
+ detectFlags;
1430
+ rng;
1431
+ constructor(userAgent, options = {}) {
1432
+ this.userAgent = userAgent;
1433
+ this.browserInfo = options.browserInfo ?? "1920|947|1920|1032|1920|1032|1920|1080|Win32";
1434
+ this.pageId = options.pageId ?? 6241;
1435
+ this.aid = options.aid ?? 6383;
1436
+ this.envFlags = options.envFlags ?? 1;
1437
+ this.detectFlags = options.detectFlags ?? 14;
1438
+ this.rng = options.rng ?? Math.random;
1439
+ }
1440
+ /** 五十个标量,按离线解码器打印的名字 */
1441
+ fields(query, body, nowMs) {
1442
+ const digests = {
1443
+ query: digestOf(query),
1444
+ body: digestOf(body),
1445
+ user_agent: userAgentDigest(this.userAgent, {
1446
+ envFlags: this.envFlags,
1447
+ detectFlags: this.detectFlags
1448
+ })
1449
+ };
1450
+ const ink = nowMs - 1;
1451
+ const fortnights = Math.floor((nowMs - FORTNIGHT_EPOCH_MS) / 12096e5);
1452
+ const infoBytes = jsBytes(this.browserInfo);
1453
+ const tailBytes = jsBytes(`${nowMs + 3 & 255},`);
1454
+ const fields = {
1455
+ L24: 41,
1456
+ L26: fortnights,
1457
+ L27: 6,
1458
+ L28: 3,
1459
+ L35: this.envFlags & 255,
1460
+ L36: this.envFlags >>> 8 & 255,
1461
+ L38: 33,
1462
+ L39: 0,
1463
+ L66: 3,
1464
+ L79: infoBytes.length & 255,
1465
+ L80: infoBytes.length >>> 8 & 255,
1466
+ L84: tailBytes.length & 255,
1467
+ L85: tailBytes.length >>> 8 & 255
1468
+ };
1469
+ leBytes(nowMs, 6).forEach((byte, index) => fields[`L${29 + index}`] = byte);
1470
+ leBytes(this.detectFlags, 4).forEach((byte, index) => fields[`L${44 + index}`] = byte);
1471
+ NR_TAG.forEach((byte, index) => fields[`L${40 + index}`] = byte);
1472
+ leBytes(ink, 6).forEach((byte, index) => fields[`L${60 + index}`] = byte);
1473
+ leBytes(this.pageId, 4).forEach((byte, index) => fields[`L${67 + index}`] = byte);
1474
+ leBytes(this.aid, 4).forEach((byte, index) => fields[`L${71 + index}`] = byte);
1475
+ for (const [name, chain] of Object.entries(DIGEST_CHAINS)) {
1476
+ const written = chainBytes(chain, digests[name]);
1477
+ chain.slots.forEach((slot, index) => fields[slot] = written[index]);
1478
+ }
1479
+ return fields;
1480
+ }
1481
+ /**
1482
+ * 为一次请求算出 `a_bogus`。
1483
+ * @param query - 即将发出的 query 串,不含 `a_bogus` 自身
1484
+ * @param options - body / Content-Type / 时钟
1485
+ * @returns `a_bogus` 的值
1486
+ */
1487
+ getValue(query, options = {}) {
1488
+ const body = (options.contentType ?? "").toLowerCase().includes("multipart/form-data") ? "" : options.body ?? "";
1489
+ const now = options.nowMs ?? Date.now();
1490
+ if (now < 17218368e5) throw new Error(`时钟早于 a_bogus 纪元:${now}`);
1491
+ const fields = this.fields(query, body, now);
1492
+ const version = [...maskPair(SDK_VERSION.slice(0, 2), this.rng), ...maskPair(SDK_VERSION.slice(2, 4), this.rng, {
1493
+ low: probeNoise(this.rng),
1494
+ high: tripwireNoise(this.rng)
1495
+ })];
1496
+ let checksum = 0;
1497
+ for (const byte of version) checksum ^= byte;
1498
+ for (const name of FIELD_ORDER) checksum ^= fields[name];
1499
+ const bodyBytes = FIELD_ORDER.map((name) => fields[name]);
1500
+ bodyBytes.push(...jsBytes(this.browserInfo));
1501
+ bodyBytes.push(...jsBytes(`${now + 3 & 255},`));
1502
+ bodyBytes.push(checksum);
1503
+ const header = maskPair(HEADER_MAGIC, this.rng, { high: headerNoise(this.userAgent, this.rng) });
1504
+ const frame = expandNoise(bodyBytes, this.rng);
1505
+ const sealed = rc4$1([211], [...version, ...frame]);
1506
+ return encodeBase64([...header, ...sealed], "s4");
1507
+ }
1508
+ };
1509
+ /** 这些毛病意味着这串东西**根本不是** a_bogus,而不是「内容出乎意料」 */
1510
+ const DECODE_PROBLEMS = /* @__PURE__ */ new Set([
1511
+ "alphabet",
1512
+ "length not a multiple of four",
1513
+ "payload too short"
1514
+ ]);
1515
+ /**
1516
+ * `problem` 是否表示「不是 a_bogus」而非「内容不符预期」。
1517
+ * @param problem - {@link structureError} 的返回
1518
+ * @returns 是否属于格式性失败
1519
+ */
1520
+ const isDecodeProblem = (problem) => DECODE_PROBLEMS.has(problem ?? "");
1521
+ /**
1522
+ * `value` 第一处不满足 a_bogus 格式的地方,全都满足时返回 `null`。
1523
+ *
1524
+ * 这里每一条检查都由算法本身固定,所以两个都正确的实现无论噪声多不同,都会在这些
1525
+ * 检查上一致。**故意不检查长度**:长度跟着几何串走,两个都对但屏幕不同的浏览器就是
1526
+ * 会不一样。
1527
+ *
1528
+ * 最后一条检查才是值得拥有的那条。校验和是对载荷里本身就有的一些字段算的,所以能通过
1529
+ * 它的签名,必然是由一个在整套布局上与我们一致的实现装配出来的——版本块、五十槽置换、
1530
+ * 噪声展开、以及那个密码。不是真算法,凑不出一个。
1531
+ * @param value - 待检查的签名
1532
+ * @param alphabet - 字母表,默认 `s4`
1533
+ * @returns 问题标识或 `null`
1534
+ */
1535
+ const structureError = (value, alphabet = "s4") => {
1536
+ const body = value.replace(/=+$/, "");
1537
+ const table = ALPHABETS[alphabet];
1538
+ if (!body || body.includes("=") || [...body].some((char) => !table.includes(char))) return "alphabet";
1539
+ if (value.length % 4) return "length not a multiple of four";
1540
+ const payload = decodeBase64(value, alphabet);
1541
+ if (payload.length < 80) return "payload too short";
1542
+ if (unmaskPair(payload.slice(0, 4)).join(",") !== HEADER_MAGIC.join(",")) return "header magic";
1543
+ const plain = rc4$1([211], payload.slice(4));
1544
+ const version = plain.slice(0, 8);
1545
+ const frame = plain.slice(8);
1546
+ if (unmaskPair(version.slice(0, 4)).join(",") !== SDK_VERSION.slice(0, 2).join(",")) return "sdk version";
1547
+ if (unmaskPair(version.slice(4, 8)).join(",") !== SDK_VERSION.slice(2, 4).join(",")) return "sdk version";
1548
+ const unpacked = collapseNoise(frame);
1549
+ if (unpacked.length < FIELD_ORDER.length + 1) return "frame too short";
1550
+ const fields = {};
1551
+ FIELD_ORDER.forEach((name, index) => fields[name] = unpacked[index]);
1552
+ const infoLength = fields.L79 | fields.L80 << 8;
1553
+ const tailLength = fields.L84 | fields.L85 << 8;
1554
+ const end = FIELD_ORDER.length + infoLength + tailLength;
1555
+ if (end + 1 > unpacked.length) return "declared lengths overrun the frame";
1556
+ if (unpacked.length - (end + 1) > 2) return "declared lengths leave a tail";
1557
+ let checksum = 0;
1558
+ for (const byte of version) checksum ^= byte;
1559
+ for (const name of FIELD_ORDER) checksum ^= fields[name];
1560
+ if (unpacked[end] !== checksum) return "checksum";
1561
+ return null;
1562
+ };
1563
+ /**
1564
+ * 把一份 `a_bogus` 拆开。用于诊断与测试,不用于签名。
1565
+ *
1566
+ * 这是证明算法正确的那个函数:拿浏览器产出的签名跑一遍,它会还原出那个浏览器的屏幕
1567
+ * 尺寸、抖音自己的 `aid`、以及与旁边那个 `timestamp` 参数吻合的时钟。
1568
+ * @param value - 待拆解的签名
1569
+ * @param alphabet - 字母表,默认 `s4`
1570
+ * @returns 可还原的字段
1571
+ */
1572
+ const decode = (value, alphabet = "s4") => {
1573
+ const problem = structureError(value, alphabet);
1574
+ if (problem !== null) throw new Error(`不是一份格式良好的 a_bogus:${problem}`);
1575
+ const payload = decodeBase64(value, alphabet);
1576
+ const plain = rc4$1([211], payload.slice(4));
1577
+ const unpacked = collapseNoise(plain.slice(8));
1578
+ const fields = {};
1579
+ FIELD_ORDER.forEach((name, index) => fields[name] = unpacked[index]);
1580
+ const littleEndian = (first, count) => {
1581
+ let total = 0;
1582
+ for (let index = 0; index < count; index++) total += fields[`L${first + index}`] * 2 ** (8 * index);
1583
+ return total;
1584
+ };
1585
+ const infoLength = fields.L79 | fields.L80 << 8;
1586
+ const tailLength = fields.L84 | fields.L85 << 8;
1587
+ const start = FIELD_ORDER.length;
1588
+ const asText = (bytes) => new TextDecoder("utf-8", { fatal: false }).decode(Uint8Array.from(bytes));
1589
+ return {
1590
+ headerMagic: unmaskPair(payload.slice(0, 4)),
1591
+ sdkVersion: [...unmaskPair(plain.slice(0, 4)), ...unmaskPair(plain.slice(4, 8))],
1592
+ nowMs: littleEndian(29, 6),
1593
+ inkMs: littleEndian(60, 6),
1594
+ pageId: littleEndian(67, 4),
1595
+ aid: littleEndian(71, 4),
1596
+ envFlags: fields.L35,
1597
+ detectFlags: littleEndian(44, 4),
1598
+ callBucket: fields.L27,
1599
+ tripwire: fields.L66,
1600
+ browserInfo: asText(unpacked.slice(start, start + infoLength)),
1601
+ tail: asText(unpacked.slice(start + infoLength, start + infoLength + tailLength)),
1602
+ fields
1603
+ };
1604
+ };
1605
+ /**
1606
+ * 兼容入口:给一条完整 URL 和一个 UA,返回 `a_bogus` 的值。
1607
+ *
1608
+ * 与旧实现的签名一致,所以 `sign/index.ts` 与 legacy 那个类不用改调用方式。
1609
+ * 需要控制时钟、几何串或随机源时用 {@link ABogus}。
1610
+ * @param url - 待签名的完整 URL
1611
+ * @param userAgent - 发起请求用的 User-Agent
1612
+ * @param options - 透传给 {@link ABogus} 与 {@link ABogus.getValue} 的选项
1613
+ * @returns `a_bogus` 的值
1614
+ */
1615
+ var a_bogus_default = (url, userAgent, options = {}) => new ABogus(userAgent, options).getValue(new URLSearchParams(new URL(url).search).toString(), options);
1616
+ //#endregion
1617
+ //#region src/platforms/douyin/sign/tokens.ts
1618
+ /**
1619
+ * 假 msToken 的字符表,逐字符照抄 V4 的 `gen_random_str`。
1620
+ *
1621
+ * A-Z + a-z + 0-9 + `+-` 正好 64 个,拼出来就是标准 base64 的形状 ——
1622
+ * 假的 msToken 唯一要像真的地方就是形状。抄错表就毁在这上面:
1623
+ * 一个永远不含 `j`、中间还夹着 `=` 的 token,形状不对。
1624
+ */
1625
+ const MS_TOKEN_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-";
1626
+ /** 抖音假 msToken 的长度(`==` 后缀之前那一段) */
1627
+ const DOUYIN_MS_TOKEN_LENGTH = 126;
1628
+ /** 真 msToken 回包的长度,不在其中说明接口变了形,值不该信 */
1629
+ const DOUYIN_MS_TOKEN_SIZES = [120, 128];
1630
+ /**
1631
+ * 本地生成的假 msToken。
1632
+ *
1633
+ * 平台对一部分端点认它、另一部分不认。原实现把它当作真接口失败时的兜底,
1634
+ * 调用方应当知道拿到的是哪一个(原实现只返回 token,降级和健康长得一模一样)。
1635
+ *
1636
+ * @param length - `==` 之前的长度,默认 {@link DOUYIN_MS_TOKEN_LENGTH}
1637
+ * @param options - `rng` / `suffix` 注入点
1638
+ * @returns `length` 个字符表内字符 + `suffix`
1639
+ */
1640
+ const genFalseMsToken = (length = 126, options = {}) => {
1641
+ const { rng = Math.random, suffix = "==" } = options;
1642
+ let token = "";
1643
+ for (let index = 0; index < length; index++) token += MS_TOKEN_ALPHABET[Math.trunc(rng() * 64)];
1644
+ return token + suffix;
1645
+ };
1646
+ /** `verify_fp` / `s_v_web_id` 随机半段用的字符表(62 个:数字 + 大小写字母) */
1647
+ const VERIFY_FP_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
1648
+ /** 36 位尾巴里下划线所在的下标,构成 UUID 的 8-4-4-4-12 分段 */
1649
+ const VERIFY_FP_SEPARATORS = [
1650
+ 8,
1651
+ 13,
1652
+ 18,
1653
+ 23
1654
+ ];
1655
+ /** 尾巴总长度 */
1656
+ const VERIFY_FP_LENGTH = 36;
1657
+ /**
1658
+ * 36 进制转换(照抄 Python 的 `_to_base36`,不用 `Number#toString(36)`)。
1659
+ *
1660
+ * 两者对非负整数结果一致,但 Python 那版对 `<= 0` 返回 `"0"` 而不是 `"-..."` ——
1661
+ * 自实现是把这条边界一起搬过来,省得以后有人换个入参就踩到差异。
1662
+ *
1663
+ * @param value - 非负整数
1664
+ * @returns 36 进制小写字符串
1665
+ */
1666
+ const toBase36 = (value) => {
1667
+ if (value <= 0) return "0";
1668
+ let rest = Math.trunc(value);
1669
+ let digits = "";
1670
+ while (rest > 0) {
1671
+ const remainder = rest % 36;
1672
+ digits = (remainder < 10 ? String(remainder) : String.fromCharCode(97 + remainder - 10)) + digits;
1673
+ rest = Math.trunc(rest / 36);
1674
+ }
1675
+ return digits;
1676
+ };
1677
+ /**
1678
+ * 生成一个 `verify_fp`。
1679
+ *
1680
+ * 形状:`verify_<毫秒时间戳的 36 进制>_<36 位 UUID v4 骨架>` —— 尾巴的下划线在
1681
+ * 8/13/18/23,下标 14 恒为 `4`,下标 19 是变体位。
1682
+ *
1683
+ * 随机数只在**非固定位**上消耗:四个下划线和版本位那 5 个下标跳过不抽签,
1684
+ * 所以一条尾巴正好抽 31 次,抽签顺序按下标升序 —— 与 Python 逐次对齐,
1685
+ * 注入同一串均匀数才能得到同一串字符。
1686
+ *
1687
+ * @param options - `nowMs` / `rng` 注入点
1688
+ * @returns 可直接当作 `verifyFp` / `s_v_web_id` cookie 的值
1689
+ */
1690
+ const genVerifyFp = (options = {}) => {
1691
+ const { nowMs = Date.now(), rng = Math.random } = options;
1692
+ const alphabetSize = 62;
1693
+ const tail = new Array(36).fill("");
1694
+ for (const index of VERIFY_FP_SEPARATORS) tail[index] = "_";
1695
+ tail[14] = "4";
1696
+ for (let index = 0; index < 36; index++) {
1697
+ if (tail[index]) continue;
1698
+ const position = Math.trunc(rng() * alphabetSize);
1699
+ tail[index] = VERIFY_FP_ALPHABET[index === 19 ? 3 & position | 8 : position];
1700
+ }
1701
+ return `verify_${toBase36(nowMs)}_${tail.join("")}`;
1702
+ };
1703
+ /**
1704
+ * `s_v_web_id` 与 `verify_fp` 同形状、同算法,只是 cookie 名不同。
1705
+ *
1706
+ * @param options - `nowMs` / `rng` 注入点
1707
+ * @returns 与 {@link genVerifyFp} 同构的值
1708
+ */
1709
+ const genSVWebId = (options = {}) => genVerifyFp(options);
1710
+ /**
1711
+ * 拼上报接口的 JSON 请求体。
1712
+ *
1713
+ * 字段顺序照 Python 的 dict:magic → version → dataType → strData → tspFromClient。
1714
+ * 与 Python 唯一的差别是空白:`json.dumps` 默认带 `", "` / `": "` 分隔符,
1715
+ * `JSON.stringify` 是紧凑的;服务端按 JSON 解析,不看空白。
1716
+ *
1717
+ * @param spec - 上报接口的静态参数
1718
+ * @param timestampMs - 客户端时间戳(`tspFromClient`),默认 `Date.now()`
1719
+ * @returns 请求体字符串
1720
+ */
1721
+ const msTokenPayload = (spec, timestampMs = Date.now()) => JSON.stringify({
1722
+ magic: spec.magic,
1723
+ version: spec.version,
1724
+ dataType: spec.dataType,
1725
+ strData: spec.strData,
1726
+ tspFromClient: timestampMs
1727
+ });
1728
+ /** ttwid 注册接口(两个平台同域,只有 body 不同) */
1729
+ const TTWID_REGISTER_URL = "https://ttwid.bytedance.com/ttwid/union/register/";
1730
+ /** 抖音访客 ttwid 的注册体,不含任何凭证 */
1731
+ const DOUYIN_TTWID_PAYLOAD = {
1732
+ region: "cn",
1733
+ aid: 1768,
1734
+ needFid: false,
1735
+ service: "www.ixigua.com",
1736
+ migrate_info: {
1737
+ ticket: "",
1738
+ source: "node"
1739
+ },
1740
+ cbUrlProtocol: "https",
1741
+ union: true
1742
+ };
1743
+ /** 抖音访客 ttwid 注册:`POST {url}`,body 用 `data` */
1744
+ const DOUYIN_TTWID = {
1745
+ url: TTWID_REGISTER_URL,
1746
+ data: JSON.stringify(DOUYIN_TTWID_PAYLOAD)
1747
+ };
1748
+ /**
1749
+ * 拆开一个 `verify_fp` / `s_v_web_id`。
1750
+ *
1751
+ * 这类值的唯一真实载荷就是**生成时刻**(`verify_<36 进制毫秒>_<36 位 UUID v4 骨架>`),
1752
+ * 其余 31 个字符是纯随机。骨架的四个位置常量(分隔符、版本位、变体位)加上字母表
1753
+ * 约束构成指纹;时钟能原样读回,所以「这条 verify_fp 是什么时候生成的」是可判定的。
1754
+ *
1755
+ * 它**不能**辨别真假:假值的形状目标就是与真值一致(见 {@link genVerifyFp})。
1756
+ * @param value - 待检查的值
1757
+ * @returns 拆解结果;骨架不符时 `problems` 非空
1758
+ */
1759
+ const decodeVerifyFp = (value) => {
1760
+ const problems = [];
1761
+ if (!value.startsWith("verify_")) return {
1762
+ timestampMs: 0,
1763
+ problems: ["prefix"]
1764
+ };
1765
+ const body = value.slice(7);
1766
+ const firstUnderscore = body.indexOf("_");
1767
+ if (firstUnderscore === -1) return {
1768
+ timestampMs: 0,
1769
+ problems: ["prefix"]
1770
+ };
1771
+ const timestamp36 = body.slice(0, firstUnderscore);
1772
+ const tail = body.slice(firstUnderscore + 1);
1773
+ if (tail.length !== 36) return {
1774
+ timestampMs: 0,
1775
+ problems: ["tail length"]
1776
+ };
1777
+ if (!/^[0-9a-z]+$/.test(timestamp36)) return {
1778
+ timestampMs: 0,
1779
+ problems: ["prefix"]
1780
+ };
1781
+ for (const index of VERIFY_FP_SEPARATORS) if (tail[index] !== "_") problems.push("separators");
1782
+ if (tail[14] !== "4") problems.push("version");
1783
+ const variant = tail[19];
1784
+ if (variant === void 0 || !"89AB".includes(variant)) problems.push("variant");
1785
+ for (let index = 0; index < tail.length; index++) {
1786
+ if (VERIFY_FP_SEPARATORS.includes(index)) continue;
1787
+ if (!"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".includes(tail[index])) {
1788
+ problems.push("alphabet");
1789
+ break;
1790
+ }
1791
+ }
1792
+ const timestampMs = Number.parseInt(timestamp36, 36);
1793
+ return {
1794
+ timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
1795
+ problems
1796
+ };
1797
+ };
1798
+ //#endregion
1799
+ //#region src/platforms/douyin/sign/decode.ts
1800
+ /**
1801
+ * 把签名参数读回明文 —— 验证工具的解码层。
1802
+ *
1803
+ * 移植自 Douyin_TikTok_Download_API 的 `signing/native/decoding.py`(Apache-2.0)。
1804
+ * 存在的理由只有一个:**证明签名实现还是对的**。
1805
+ *
1806
+ * ## 为什么需要它
1807
+ *
1808
+ * 抖音对签名是抽样校验的,所以「请求成功」证明不了任何事。唯一能证伪一个常量的办法,
1809
+ * 是把真实签名拆开看。旧实现用错盐值(`cus`)活了两年没被发现,就是因为整套测试都是
1810
+ * 自证的——自生成快照、和被测量对象比自身、以及没有任何一条断言能证伪一个常量。
1811
+ *
1812
+ * 这个模块提供外部锚点:给它一份浏览器产出的签名,它会告诉你签名里封住的到底是什么,
1813
+ * 以及你手上的候选输入是不是它封的那一个。
1814
+ *
1815
+ * ## 「解码」在这里诚实地指三件不同的事
1816
+ *
1817
+ * 把它们混为一谈,就是这类工具开始撒谎的方式:
1818
+ *
1819
+ * - **可还原**:值就在载荷里,能原样取回。时钟、`aid`、`page_id`、屏幕几何串、
1820
+ * 版本串、调用计数。没有任何推断。
1821
+ * - **被绑定但不可还原**:载荷里带着某个东西的摘要——a_bogus 里是 query / body / UA
1822
+ * 各三个 SM3 字节。输入无法从摘要倒推出来,所以这个模块**从不假装能**。它做的是
1823
+ * **校验**:给它一个候选值,它回答这个候选是不是签名封住的那一个,以及有多少位证据
1824
+ * 支持这个结论。这才是哈希真正的逆运算,而且比猜一个值有用得多——它把
1825
+ * 「我的请求被拒了」变成「我发的这个签名是对着另一条 URL 算的」。
1826
+ * - **根本没被计算**:`msToken` 与访客令牌由平台签发或随机抽出来,底下没有明文可找。
1827
+ * 说出这一点就是答案,而不是「没能给出答案」。
1828
+ *
1829
+ * 每个字段都携带自己属于哪一类,读者不会被留在「标签旁边那个数字是读出来的还是推出来的」
1830
+ * 这种猜测里。
1831
+ *
1832
+ * 这里没有任何东西用于签名。它只跑在调用方已经持有的值上,不碰网络、不消耗身份。
1833
+ */
1834
+ /** 字段是什么。控制台按它上色,调用方也可以对它分支 */
1835
+ const KIND = {
1836
+ /** 从载荷里直接读出,就是签名者放进去的东西 */
1837
+ PLAIN: "plain",
1838
+ /** 一个时钟,同时给出原始数字与 ISO-8601 时刻 */
1839
+ TIME: "time",
1840
+ /** 哈希或哈希的一个字节。输入不可还原——见 checks */
1841
+ DIGEST: "digest",
1842
+ /** 内部冗余。在这里重算,这正是解码得以自证的依据 */
1843
+ CHECKSUM: "checksum",
1844
+ /** SDK 对它自认为身处其中的页面所做的回报 */
1845
+ ENVIRONMENT: "environment",
1846
+ /** 每次捕获里都有、但含义未被确认的东西。命名它,而不是编造它 */
1847
+ OPAQUE: "opaque"
1848
+ };
1849
+ /** 一个参数底下为什么没有明文 */
1850
+ const REASON = {
1851
+ MALFORMED: "malformed",
1852
+ NOT_COMPUTED: "not_computed",
1853
+ ONE_WAY: "one_way",
1854
+ UNKNOWN_PARAMETER: "unknown_parameter"
1855
+ };
1856
+ /** 候选输入是不是签名封住的那一个 */
1857
+ const CHECK = {
1858
+ MATCH: "match",
1859
+ DIFFERS: "differs",
1860
+ NOT_SUPPLIED: "not_supplied"
1861
+ };
1862
+ /** 每条摘要链钉住输入的位数 */
1863
+ const CHAIN_BITS = 24;
1864
+ const FORTNIGHT_MS = 12096e5;
1865
+ /** 毫秒时钟转 ISO-8601 UTC;数字荒诞到解析不了时退回原样,因为那通常正是它最有意思的地方 */
1866
+ const instant = (milliseconds) => {
1867
+ const date = new Date(milliseconds);
1868
+ return Number.isNaN(date.getTime()) ? String(milliseconds) : date.toISOString().replace(".000Z", "Z");
1869
+ };
1870
+ /** 秒/毫秒时钟的字段构造器;X-Bogus 的解码层(`x_bogus.ts`)也用它,两份拆解结果同构 */
1871
+ const clockField = (name, milliseconds, detail) => ({
1872
+ name,
1873
+ value: `${milliseconds} (${instant(milliseconds)})`,
1874
+ kind: KIND.TIME,
1875
+ detail
1876
+ });
1877
+ const check = (name, expected, actual, covered) => expected.join(",") === actual.join(",") ? {
1878
+ name,
1879
+ status: CHECK.MATCH,
1880
+ bits: CHAIN_BITS,
1881
+ covered
1882
+ } : {
1883
+ name,
1884
+ status: CHECK.DIFFERS,
1885
+ bits: CHAIN_BITS
1886
+ };
1887
+ /**
1888
+ * 拆开一份 `a_bogus`,并就调用方给出的候选值做校验。
1889
+ * @param value - `a_bogus` 的值
1890
+ * @param candidates - 候选的 query / body / User-Agent,给哪个校验哪个
1891
+ * @returns 拆解结果;格式不合法时 `recovered` 为 `false` 并给出 `reason`
1892
+ */
1893
+ const decodeABogus = (value, candidates = {}) => {
1894
+ const problem = structureError(value);
1895
+ if (problem !== null) return {
1896
+ parameter: "a_bogus",
1897
+ platform: "douyin",
1898
+ algorithm: "a_bogus",
1899
+ recovered: false,
1900
+ reason: `${REASON.MALFORMED}:${problem}`,
1901
+ fields: [],
1902
+ checks: [],
1903
+ notes: []
1904
+ };
1905
+ const raw = decode(value);
1906
+ const scalars = raw.fields;
1907
+ const fortnights = scalars.L26;
1908
+ const fields = [
1909
+ {
1910
+ name: "header_magic",
1911
+ value: `[${raw.headerMagic.join(", ")}]`,
1912
+ kind: KIND.PLAIN
1913
+ },
1914
+ {
1915
+ name: "sdk_version",
1916
+ value: raw.sdkVersion.join("."),
1917
+ kind: KIND.PLAIN
1918
+ },
1919
+ clockField("now_ms", raw.nowMs),
1920
+ clockField("ink_ms", raw.inkMs, "now_ms - 1,SDK 对自己的存活检查"),
1921
+ {
1922
+ name: "aid",
1923
+ value: String(raw.aid),
1924
+ kind: KIND.PLAIN
1925
+ },
1926
+ {
1927
+ name: "page_id",
1928
+ value: String(raw.pageId),
1929
+ kind: KIND.PLAIN
1930
+ },
1931
+ {
1932
+ name: "fortnights",
1933
+ value: `${fortnights} (${instant(FORTNIGHT_EPOCH_MS + fortnights * FORTNIGHT_MS)})`,
1934
+ kind: KIND.PLAIN,
1935
+ detail: "自 a_bogus 纪元起的「两周」数"
1936
+ },
1937
+ {
1938
+ name: "browser_info",
1939
+ value: raw.browserInfo,
1940
+ kind: KIND.ENVIRONMENT,
1941
+ detail: "屏幕|可视区|…"
1942
+ },
1943
+ {
1944
+ name: "tail",
1945
+ value: raw.tail,
1946
+ kind: KIND.PLAIN,
1947
+ detail: "(now_ms + 3) & 0xFF"
1948
+ },
1949
+ {
1950
+ name: "env_flags",
1951
+ value: String(raw.envFlags),
1952
+ kind: KIND.ENVIRONMENT
1953
+ },
1954
+ {
1955
+ name: "detect_flags",
1956
+ value: String(raw.detectFlags),
1957
+ kind: KIND.ENVIRONMENT
1958
+ },
1959
+ {
1960
+ name: "call_bucket",
1961
+ value: String(raw.callBucket),
1962
+ kind: KIND.ENVIRONMENT
1963
+ },
1964
+ {
1965
+ name: "tripwire",
1966
+ value: String(raw.tripwire),
1967
+ kind: KIND.ENVIRONMENT
1968
+ }
1969
+ ];
1970
+ const inputByChain = {
1971
+ query: candidates.query,
1972
+ body: candidates.body,
1973
+ user_agent: candidates.userAgent
1974
+ };
1975
+ const emptyBody = chainBytes(DIGEST_CHAINS.body, digestWith("", SALT));
1976
+ const checks = [];
1977
+ for (const [name, chain] of Object.entries(DIGEST_CHAINS)) {
1978
+ const carried = chain.slots.map((slot) => scalars[slot]);
1979
+ let detail = `${name} 链的 3 个字节,落在 ${chain.slots.join(", ")}`;
1980
+ if (name === "body" && carried.join(",") === emptyBody.join(",")) detail = `${detail};与空 body 一致,GET 就是这种情况`;
1981
+ fields.push({
1982
+ name: `${name}_digest`,
1983
+ value: carried.join(" "),
1984
+ kind: KIND.DIGEST,
1985
+ detail
1986
+ });
1987
+ const candidate = inputByChain[name];
1988
+ if (candidate === void 0) {
1989
+ checks.push({
1990
+ name,
1991
+ status: CHECK.NOT_SUPPLIED,
1992
+ bits: CHAIN_BITS
1993
+ });
1994
+ continue;
1995
+ }
1996
+ const digest = name === "user_agent" ? userAgentDigest(candidate, {
1997
+ envFlags: raw.envFlags,
1998
+ detectFlags: raw.detectFlags
1999
+ }) : digestWith(candidate, SALT);
2000
+ checks.push(check(name, chainBytes(chain, digest), carried, candidate));
2001
+ }
2002
+ return {
2003
+ parameter: "a_bogus",
2004
+ platform: "douyin",
2005
+ algorithm: "a_bogus",
2006
+ recovered: true,
2007
+ fields,
2008
+ checks,
2009
+ notes: ["checksum_verified", "noise_not_recoverable"]
2010
+ };
2011
+ };
2012
+ /**
2013
+ * 反推一份签名用的是哪个盐值 —— **这个工具存在的核心理由**。
2014
+ *
2015
+ * 做法与当初发现 `cus` 过期时完全一样:签名里封着 query 链的三个字节,把候选盐值逐个
2016
+ * 代进去重算摘要,能重现那三个字节的就是当前盐值。整件事不需要网络、不需要身份、
2017
+ * 也不依赖抖音是否接受了你的请求——这正是它能发现「请求照样成功但常量已经过期」的原因。
2018
+ *
2019
+ * ## 只有 query 链参与判定
2020
+ *
2021
+ * body 链在 GET 上与空串一致,而 UA 链**根本不吃盐值**(它走 RC4 加单次 SM3),
2022
+ * 两条对判定都没有贡献,所以这里的候选盐值只对 query 生效。证据量是 24 位,
2023
+ * 实战足够——但要说明,而不是藏起来。
2024
+ * @param value - 一份真实的 `a_bogus`
2025
+ * @param query - 该签名所覆盖的那条 query(不含 `a_bogus` 自身)
2026
+ * @param salts - 待测的盐值候选
2027
+ * @returns 判定结果
2028
+ */
2029
+ const diagnoseSalt = (value, query, salts = [SALT, "cus"]) => {
2030
+ if (structureError(value) !== null) return {
2031
+ salt: null,
2032
+ observed: [],
2033
+ results: []
2034
+ };
2035
+ const chain = DIGEST_CHAINS.query;
2036
+ const scalars = decode(value).fields;
2037
+ const observed = chain.slots.map((slot) => scalars[slot]);
2038
+ const results = salts.map((salt) => {
2039
+ const predicted = chainBytes(chain, digestWith(query, salt));
2040
+ return {
2041
+ salt,
2042
+ predicted,
2043
+ matched: predicted.join(",") === observed.join(",")
2044
+ };
2045
+ });
2046
+ return {
2047
+ salt: results.find((row) => row.matched)?.salt ?? null,
2048
+ observed,
2049
+ results
2050
+ };
2051
+ };
2052
+ /**
2053
+ * 只按形状猜一个值是什么参数。用于调用方不知道自己在看什么的时候。
2054
+ *
2055
+ * 只做形状判断,不做任何密码学断言——猜错时返回 `null` 比返回一个错答案好。
2056
+ * @param value - 待识别的值
2057
+ * @returns 参数名或 `null`
2058
+ */
2059
+ const identify = (value) => {
2060
+ if (!value) return null;
2061
+ if (structureError(value) === null) return "a_bogus";
2062
+ if (/^verify_[0-9a-z]+_/.test(value)) return "verifyFp";
2063
+ if (value.length === 28 && /^[A-Za-z0-9_=+/-]+$/.test(value)) return "X-Bogus";
2064
+ if (value.length >= 100 && /^[A-Za-z0-9+-]+=*$/.test(value)) return "msToken";
2065
+ if (/^[0-9a-f]{32}$/i.test(value)) return "x-secsdk-web-signature";
2066
+ return null;
2067
+ };
2068
+ /**
2069
+ * 签名**一定**不覆盖的参数。
2070
+ *
2071
+ * `a_bogus` 是签名自身;`timestamp` 与 `x-secsdk-web-signature` 由 secsdk 在签名
2072
+ * **之后**追加 —— 这一点在真实捕获与 amagi 自己的管线里一致。
2073
+ */
2074
+ const NEVER_SIGNED = [
2075
+ "a_bogus",
2076
+ "timestamp",
2077
+ "x-secsdk-web-signature"
2078
+ ];
2079
+ /**
2080
+ * 是否被签名覆盖**随管线而异**的参数。
2081
+ *
2082
+ * 不要猜。真实浏览器捕获里 `uifid` 在 `a_bogus` **之前**(因而被签名覆盖),
2083
+ * 而 amagi 的管线是 `webid → a_bogus → secsdk`,`uifid` 由 secsdk 在之后补上
2084
+ * (因而没被覆盖)。同一个参数名,两边结论相反 —— 所以这里把四种参数做成
2085
+ * 2⁴ 种组合逐个试,命中的那一种就是答案。
2086
+ */
2087
+ const SIGNING_AMBIGUOUS = [
2088
+ "uifid",
2089
+ "msToken",
2090
+ "verifyFp",
2091
+ "fp"
2092
+ ];
2093
+ /**
2094
+ * 反推一份签名**当时覆盖的那条 query**,以及用的是哪个盐值。
2095
+ *
2096
+ * 这是 {@link diagnoseSalt} 的加强版:那份要求调用方已经知道签名覆盖了哪些参数,
2097
+ * 而线上抓来的 URL 里,`uifid` / `msToken` / `verifyFp` / `fp` 到底在签名前还是
2098
+ * 签名后,取决于页面当时挂的是哪条管线 —— 同一份代码在浏览器里和在 amagi 里顺序
2099
+ * 就是不一样的。与其让调用方去猜,这里把 2⁴ 种组合全试一遍,报出命中的那一种。
2100
+ *
2101
+ * 命中意味着**两件事同时成立**:这是一份结构良好的 a_bogus,且它的 query 链在给定
2102
+ * 盐值下重现了这条 query。所以它是「验证」而不只是「解码」。
2103
+ * @param signature - `a_bogus` 的值(未做 URL 编码)
2104
+ * @param url - 已签名的完整 URL
2105
+ * @param salts - 待搜的盐值候选
2106
+ * @returns 命中结果;没有任何组合命中时返回 `null`
2107
+ */
2108
+ const recoverSignedQuery = (signature, url, salts = [SALT]) => {
2109
+ if (structureError(signature) !== null) return null;
2110
+ const chain = DIGEST_CHAINS.query;
2111
+ const scalars = decode(signature).fields;
2112
+ const observed = chain.slots.map((slot) => scalars[slot]).join(",");
2113
+ let tried = 0;
2114
+ for (let mask = 0; mask < 1 << SIGNING_AMBIGUOUS.length; mask++) {
2115
+ const kept = SIGNING_AMBIGUOUS.filter((_, index) => (mask & 1 << index) !== 0);
2116
+ const parsed = new URL(url);
2117
+ for (const name of NEVER_SIGNED) parsed.searchParams.delete(name);
2118
+ for (const name of SIGNING_AMBIGUOUS) if (!kept.includes(name)) parsed.searchParams.delete(name);
2119
+ const query = parsed.searchParams.toString();
2120
+ for (const salt of salts) {
2121
+ tried++;
2122
+ if (chainBytes(chain, digestWith(query, salt)).join(",") === observed) return {
2123
+ query,
2124
+ kept,
2125
+ salt,
2126
+ tried
2127
+ };
2128
+ }
2129
+ }
2130
+ return null;
2131
+ };
2132
+ /**
2133
+ * 从一条已签名的 URL 里重建出签名覆盖的那条 query(不再搜索,按固定规则摘参数)。
2134
+ *
2135
+ * **只在已知管线顺序时使用。** 面对来源不明的 URL 请用 {@link recoverSignedQuery},
2136
+ * 它会搜索并告诉你哪条重建成立。保留这个函数是为了让「摘掉签名参数」这件事在
2137
+ * 需要确定行为的地方仍然可用。
2138
+ * @param url - 已签名的完整 URL
2139
+ * @returns 摘掉 `a_bogus` / `timestamp` / `x-secsdk-web-signature` 之后的 query
2140
+ */
2141
+ const signedQueryOf = (url) => {
2142
+ const parsed = new URL(url);
2143
+ for (const name of NEVER_SIGNED) parsed.searchParams.delete(name);
2144
+ return parsed.searchParams.toString();
2145
+ };
2146
+ /**
2147
+ * 拆开一条 URL 上所有认得出的签名参数。
2148
+ *
2149
+ * 会先尝试 {@link recoverSignedQuery} 找出签名真正覆盖的那条 query;找不到时退回
2150
+ * {@link signedQueryOf} 的结果,并把这个不确定性体现在 `notes` 里。
2151
+ * @param url - 已签名的完整 URL
2152
+ * @param options - 可选的 User-Agent、显式 query 与盐值候选
2153
+ * @returns 每个参数一条拆解结果
2154
+ */
2155
+ const decodeUrl = (url, options = {}) => {
2156
+ const aBogus = new URL(url).searchParams.get("a_bogus");
2157
+ if (aBogus === null) return [];
2158
+ const signature = decodeURIComponent(aBogus);
2159
+ const recovery = options.query ? null : recoverSignedQuery(signature, url, options.salts ?? ["dhzx"]);
2160
+ const query = options.query ?? recovery?.query ?? signedQueryOf(url);
2161
+ const decoded = decodeABogus(signature, {
2162
+ query,
2163
+ body: options.body,
2164
+ userAgent: options.userAgent
2165
+ });
2166
+ if (recovery) {
2167
+ decoded.notes.push(`query_recovered:kept=${recovery.kept.join("+") || "(none)"}`);
2168
+ decoded.fields.push({
2169
+ name: "signed_query",
2170
+ value: recovery.query,
2171
+ kind: KIND.PLAIN,
2172
+ detail: `从 ${recovery.tried} 种组合中命中:保留了 ${recovery.kept.join("、") || "(不保留任何可选参数)"}`
2173
+ });
2174
+ } else if (!options.query) decoded.notes.push("query_reconstructed_by_rule:no_combination_matched");
2175
+ return [decoded];
2176
+ };
2177
+ //#endregion
2178
+ //#region src/platforms/douyin/sign/x_bogus.ts
2179
+ /**
2180
+ * X-Bogus 生成工具(抖音 / TikTok Web 的老签名)—— TypeScript 移植
2181
+ *
2182
+ * 移植自 Douyin_TikTok_Download_API `src/dtk/signing/native/xbogus.py`,常量、槽位与
2183
+ * **字节顺序**逐条对齐;那份 Python 又是 V4 `crawlers/douyin/web/xbogus.py` 的逐行移植,
2184
+ * 而 V4 是站点自己那份混淆 JS 的直译。所以这里对齐的不是「某份实现的做法」,
2185
+ * 而是站点校验时的做法。
2186
+ *
2187
+ * 整条依赖链只用 `TextEncoder` / `URL` 这类环境无关的原语(MD5 走 `utils/md5` 的纯
2188
+ * 实现),浏览器里能直接跑 —— `@ikenxuan/amagi/signing` 与文档站验证器用的就是这份。
2189
+ *
2190
+ * ## 载荷(19 字节)与槽位
2191
+ *
2192
+ * ```text
2193
+ * [64, 1/256, 1, 12, query 摘要[14..15], empty 摘要[14..15], ua 摘要[14..15],
2194
+ * 时间戳 4 字节, 536919696 4 字节, 校验位]
2195
+ * ```
2196
+ *
2197
+ * 三条摘要链各自算出 16 字节 MD5,只取第 14、15 字节(`DIGEST_INDICES`);`1/256`
2198
+ * 是站点留在载荷里的浮点常量,最终被截断成 `0`;最后一格是前 18 格的异或。
2199
+ *
2200
+ * ## 两个曾经的坑,都会产出「看着像模像样」的错签名
2201
+ *
2202
+ * 1. **字节顺序**:站点把载荷按奇偶位拆开,再交给一个**形参表本身就乱序**的编码函数,
2203
+ * 而那个函数正好把两段拼回原序 —— 拆分是死代码。旧移植漏掉还原这一步,把
2204
+ * 「偶数位在前、奇数位在后」直接送进 RC4,字节全体错位;再叠加把 `1/256` 写成 `1`,
2205
+ * 输出与站点/V4 完全不同。见 {@link interleave}。
2206
+ * 2. **十六进制解码**:≤ 32 字符一律按站点的判据当摘要解十六进制,旧实现遇到落单的
2207
+ * 半字节会悄悄补 0(Python 那版是悄悄丢掉末位),两个不同输入能撞出同一签名。
2208
+ * 见 {@link md5StrToArray}。
2209
+ *
2210
+ * ## 时钟
2211
+ *
2212
+ * V4 把 `time.time()` 烧在函数体里,签名没法在测试里钉死。这里把秒级时间戳做成
2213
+ * 注入点(`options.timestamp`),默认值才是 `Date.now()`。
2214
+ *
2215
+ * ## 解码层
2216
+ *
2217
+ * 和 `a_bogus` 一样,X-Bogus 的载荷能整个拆开:信封头两个明文字节之后跟着 RC4 密文,
2218
+ * 密钥就是那个单字节 `PAYLOAD_KEY`,RC4 对称所以「解密」就是再加密一遍。拆开后
2219
+ * 时间戳是明文,三条摘要链各留两个字节可做候选输入校验——{@link decodeXBogus}。
2220
+ *
2221
+ * @module platforms/douyin/sign/x_bogus
2222
+ */
2223
+ /** 输出字母表,与 a_bogus 的 `s2` 相同;两者可以各自演化,所以不共用一份 */
2224
+ const CHARACTER = "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=";
2225
+ /** 标准字母表的 base64(UA 摘要链用;V4 在这条链上用的是 `base64.b64encode`) */
2226
+ const STANDARD_BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2227
+ /** 作用在 User-Agent 上的 RC4 密钥(三个控制字节) */
2228
+ const UA_KEY = [
2229
+ 0,
2230
+ 1,
2231
+ 12
2232
+ ];
2233
+ /** 作用在载荷上的 RC4 密钥 */
2234
+ const PAYLOAD_KEY = [255];
2235
+ /** 站点混在时间戳旁边的固定常量 */
2236
+ const CANVAS_CONSTANT = 536919696;
2237
+ /** 空串的 MD5,算法会把它再哈希一轮,结果是个常量 */
2238
+ const EMPTY_MD5 = "d41d8cd98f00b204e9800998ecf8427e";
2239
+ /** 载荷开头四个槽位的字面量;`1/256` 那一格会被截断成 `0`,但不能顺手改写成 `0` */
2240
+ const PAYLOAD_LEAD = [
2241
+ 64,
2242
+ 1 / 256,
2243
+ 1,
2244
+ 12
2245
+ ];
2246
+ /** 每条摘要链贡献的两个字节在 16 字节摘要里的下标 */
2247
+ const DIGEST_INDICES = [14, 15];
2248
+ /** 信封开头的两个明文字节(`2` 与 `255`),密文接在它们后面 */
2249
+ const ENVELOPE_LEAD = [2, 255];
2250
+ /** 一份 X-Bogus 的定长:7 组 × 4 字符 = 21 字节 = 信封 2 + 载荷 19 */
2251
+ const X_BOGUS_LENGTH = 28;
2252
+ /** 不传 UA 时使用的默认值,与 V4 一字不差(改它等于改签名) */
2253
+ const DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0";
2254
+ /** 小写十六进制字符,V4 与 xbogus.py 都只认这一档 */
2255
+ const HEX_DIGITS = "0123456789abcdef";
2256
+ /** 字符串按 latin1 取字节:每个码元截到 8 位,与 `Buffer.from(s, 'latin1')` 一致 */
2257
+ const latin1Bytes = (value) => {
2258
+ const bytes = new Uint8Array(value.length);
2259
+ for (let index = 0; index < value.length; index++) bytes[index] = value.charCodeAt(index) & 255;
2260
+ return bytes;
2261
+ };
2262
+ /** 字节按 latin1 还原成字符串(信封与密文的搬运格式) */
2263
+ const bytesToLatin1 = (bytes) => {
2264
+ let out = "";
2265
+ for (let index = 0; index < bytes.length; index++) out += String.fromCharCode(bytes[index] & 255);
2266
+ return out;
2267
+ };
2268
+ /** 标准字母表的 base64(带 `=` 填充),与 `Buffer#toString('base64')` 输出一致 */
2269
+ const encodeStandardBase64 = (bytes) => {
2270
+ let out = "";
2271
+ for (let offset = 0; offset < bytes.length; offset += 3) {
2272
+ const chunk = [];
2273
+ for (let i = 0; i < 3; i++) chunk.push(offset + i < bytes.length ? bytes[offset + i] : 0);
2274
+ const block = chunk[0] << 16 | chunk[1] << 8 | chunk[2];
2275
+ for (const digit of [
2276
+ block >>> 18 & 63,
2277
+ block >>> 12 & 63,
2278
+ block >>> 6 & 63,
2279
+ block & 63
2280
+ ]) out += STANDARD_BASE64[digit];
2281
+ }
2282
+ const pad = (3 - bytes.length % 3) % 3;
2283
+ return out.slice(0, out.length - pad) + "=".repeat(pad);
2284
+ };
2285
+ /** RC4。对称流密码,解密与加密是同一个函数 */
2286
+ const rc4 = (key, data) => {
2287
+ const box = Array.from({ length: 256 }, (_, index) => index);
2288
+ let j = 0;
2289
+ for (let index = 0; index < 256; index++) {
2290
+ j = (j + box[index] + key[index % key.length]) % 256;
2291
+ [box[index], box[j]] = [box[j], box[index]];
2292
+ }
2293
+ const out = new Uint8Array(data.length);
2294
+ let i = 0;
2295
+ j = 0;
2296
+ for (let index = 0; index < data.length; index++) {
2297
+ i = (i + 1) % 256;
2298
+ j = (j + box[i]) % 256;
2299
+ [box[i], box[j]] = [box[j], box[i]];
2300
+ out[index] = data[index] ^ box[(box[i] + box[j]) % 256];
2301
+ }
2302
+ return out;
2303
+ };
2304
+ /**
2305
+ * 十六进制串 → 字节数组;超过 32 字符则按原始文本取码元(只取每码元的低位字节,
2306
+ * 与站点 `ord(c) & 0xff` 的语义一致)。
2307
+ *
2308
+ * **长度判据(≤ 32 走十六进制)是站点的**,不是这里加的:站点把任何不超过一份
2309
+ * 摘要长度的字符串都当作摘要来解。真实请求的 path + query 永远长过 32 字符,
2310
+ * 走的是文本分支,所以这条规则碰不到线上行为。
2311
+ *
2312
+ * **旧实现在这里会悄悄吞掉落单的半字节**,两个不同输入因此撞出同一份签名:
2313
+ * 参考实现(Python)用 `range(0, len - 1, 2)`,末位字符直接不参与运算,
2314
+ * `"abcdef"` 与 `"abcdefg"` 同签名;本仓库旧 TS 版把缺失的低半字节当成 0,
2315
+ * `"abc"` 与 `"abc0"` 同签名。两者的共同点是「输入不合法,却拿到一份像模像样的
2316
+ * 签名」—— 这比报错难查得多,所以这里选择**报错**:长度必须是偶数、字符必须是小写
2317
+ * 十六进制,否则抛出,绝不替调用方补位或丢位。
2318
+ */
2319
+ const md5StrToArray = (value) => {
2320
+ if (value.length > 32) {
2321
+ const codePoints = [];
2322
+ for (const char of value) codePoints.push(char.charCodeAt(0));
2323
+ return codePoints;
2324
+ }
2325
+ const nibbles = new Map(HEX_DIGITS.split("").map((char, index) => [char, index]));
2326
+ const bytes = [];
2327
+ for (let index = 0; index < value.length; index += 2) {
2328
+ const pair = value.slice(index, index + 2);
2329
+ const high = nibbles.get(value[index]);
2330
+ const low = index + 1 < value.length ? nibbles.get(value[index + 1]) : void 0;
2331
+ if (high === void 0 || low === void 0) throw new Error(`X-Bogus 签名:${JSON.stringify(value)} 不是偶数长度的小写十六进制串(${JSON.stringify(pair)} 处)`);
2332
+ bytes.push(high << 4 | low);
2333
+ }
2334
+ return bytes;
2335
+ };
2336
+ /** 字节摘要的十六进制 */
2337
+ const md5OfBytes = (bytes) => md5Hex(Uint8Array.from(bytes, (byte) => byte & 255));
2338
+ /** query 摘要链:两轮 MD5 */
2339
+ const queryDigestOf = (urlPath) => md5StrToArray(md5OfBytes(md5StrToArray(md5OfBytes(md5StrToArray(urlPath)))));
2340
+ /** empty 摘要链:哈希空串,是个常量。第 14、15 字节出现在每一份 X-Bogus 里 */
2341
+ const emptyDigestOf = () => md5StrToArray(md5OfBytes(md5StrToArray(EMPTY_MD5)));
2342
+ /** UA 摘要链:RC4 → 标准 base64 → MD5。base64 是纯 ASCII,直接按文本进 MD5(等价 latin1 字节) */
2343
+ const uaDigestOf = (userAgent) => md5StrToArray(md5Hex(encodeStandardBase64(rc4(UA_KEY, latin1Bytes(userAgent)))));
2344
+ /**
2345
+ * 偶数位在前、奇数位在后 —— 站点对载荷做的拆分。
2346
+ *
2347
+ * 拆分本身是死代码({@link interleave} 会把它原样拼回去),真正的作用只有
2348
+ * 一处:让 `1/256` 那一格被截断成整数。形状照抄站点,为的是别人对着 xbogus.py 读时
2349
+ * 能一句一句对上,也让「漏掉还原」这个坑不至于再犯一次。
2350
+ */
2351
+ const splitEvenOdd = (values) => {
2352
+ const head = [];
2353
+ const tail = [];
2354
+ for (let index = 0; index < values.length; index++) if (index % 2 === 0) head.push(values[index]);
2355
+ else tail.push(values[index]);
2356
+ return [...head, ...tail];
2357
+ };
2358
+ /** 把 {@link splitEvenOdd} 拆开的两段按下标交错回原序(顺便截断成整数)。 */
2359
+ const interleave = (values) => {
2360
+ const head = values.slice(0, 10);
2361
+ const tail = values.slice(10);
2362
+ const merged = [];
2363
+ for (let index = 0; index < head.length; index++) {
2364
+ merged.push(Math.trunc(head[index]));
2365
+ if (index < tail.length) merged.push(Math.trunc(tail[index]));
2366
+ }
2367
+ return merged;
2368
+ };
2369
+ /** 三个载荷字节 → 四个输出字符 */
2370
+ const encodeGroup = (first, second, third) => {
2371
+ const merged = (first & 255) << 16 | (second & 255) << 8 | third & 255;
2372
+ return CHARACTER[(merged & 16515072) >> 18] + CHARACTER[(merged & 258048) >> 12] + CHARACTER[(merged & 4032) >> 6] + CHARACTER[merged & 63];
2373
+ };
2374
+ var XBogus = class {
2375
+ /** V4 兼容字段:V4 会把结果同步写进 `params` / `xb`,本实现不写 */
2376
+ params;
2377
+ xb;
2378
+ /**
2379
+ * 生成 X-Bogus 签名。
2380
+ *
2381
+ * @param url - 完整的 URL 地址(签的是它的 pathname + search)
2382
+ * @param ua - 可选的 User-Agent,不提供则使用默认值
2383
+ * @param options - `timestamp` 注入点(秒级),省略时取当前时间
2384
+ * @returns 完整 URL(已带 `X-Bogus=`)、签名值与实际使用的 User-Agent
2385
+ */
2386
+ getXBogus(url, ua, options = {}) {
2387
+ const parsedUrl = new URL(url);
2388
+ const urlPath = parsedUrl.pathname + parsedUrl.search;
2389
+ const userAgent = ua ?? DEFAULT_USER_AGENT;
2390
+ const timestamp = options.timestamp ?? Math.floor(Date.now() / 1e3);
2391
+ const xbogus = signXBogus(urlPath, userAgent, timestamp);
2392
+ return {
2393
+ fullUrl: url.includes("?") ? `${url}&X-Bogus=${xbogus}` : `${url}?X-Bogus=${xbogus}`,
2394
+ xbogus,
2395
+ userAgent
2396
+ };
2397
+ }
2398
+ };
2399
+ /**
2400
+ * 对一条 path+search 签出 X-Bogus。
2401
+ *
2402
+ * {@link XBogus.getXBogus} 的函数式形态 —— 验证工具要用它对候选输入重签;
2403
+ * 输入是已经拆好的 `pathname + search`,不重复解析 URL。
2404
+ * @param urlPath - 参与 签名的 `pathname + search`
2405
+ * @param userAgent - 参与签名的 User-Agent
2406
+ * @param timestamp - 秒级时间戳
2407
+ * @returns 28 个字符的签名值
2408
+ */
2409
+ const signXBogus = (urlPath, userAgent, timestamp) => {
2410
+ const queryDigest = queryDigestOf(urlPath);
2411
+ const emptyDigest = emptyDigestOf();
2412
+ const uaDigest = uaDigestOf(userAgent);
2413
+ const payload = [
2414
+ ...PAYLOAD_LEAD,
2415
+ queryDigest[DIGEST_INDICES[0]],
2416
+ queryDigest[DIGEST_INDICES[1]],
2417
+ emptyDigest[DIGEST_INDICES[0]],
2418
+ emptyDigest[DIGEST_INDICES[1]],
2419
+ uaDigest[DIGEST_INDICES[0]],
2420
+ uaDigest[DIGEST_INDICES[1]],
2421
+ timestamp >> 24 & 255,
2422
+ timestamp >> 16 & 255,
2423
+ timestamp >> 8 & 255,
2424
+ timestamp & 255,
2425
+ 32,
2426
+ 0,
2427
+ 190,
2428
+ 144
2429
+ ];
2430
+ let checksum = Math.trunc(payload[0]);
2431
+ for (let index = 1; index < payload.length; index++) checksum ^= Math.trunc(payload[index]);
2432
+ payload.push(checksum);
2433
+ const encrypted = rc4(PAYLOAD_KEY, Uint8Array.from(interleave(splitEvenOdd(payload))));
2434
+ const garbled = `${String.fromCharCode(ENVELOPE_LEAD[0], ENVELOPE_LEAD[1])}${bytesToLatin1(encrypted)}`;
2435
+ let xbogus = "";
2436
+ for (let index = 0; index + 2 < garbled.length; index += 3) xbogus += encodeGroup(garbled.charCodeAt(index), garbled.charCodeAt(index + 1), garbled.charCodeAt(index + 2));
2437
+ return xbogus;
2438
+ };
2439
+ /**
2440
+ * 只凭签名本身能做的全部检查 —— 不需要任何候选输入。
2441
+ *
2442
+ * X-Bogus 里封着三个**常量**(载荷前四格、empty 摘要链字节、canvas 常量),
2443
+ * 它们与校验位一起构成指纹:一份能通过全部检查的值,必然出自这套算法的装配顺序。
2444
+ * @param value - 待检查的值(28 个字符)
2445
+ * @returns 第一个发现的问题;`null` 表示全部通过
2446
+ */
2447
+ const xBogusStructureError = (value) => {
2448
+ if (value.length !== X_BOGUS_LENGTH) return "length not 28";
2449
+ const table = new Map(CHARACTER.split("").map((char, index) => [char, index]));
2450
+ const bytes = [];
2451
+ for (let offset = 0; offset < value.length; offset += 4) {
2452
+ let block = 0;
2453
+ for (const char of value.slice(offset, offset + 4)) {
2454
+ const digit = table.get(char);
2455
+ if (digit === void 0) return "alphabet";
2456
+ block = block << 6 | digit;
2457
+ }
2458
+ bytes.push(block >>> 16 & 255, block >>> 8 & 255, block & 255);
2459
+ }
2460
+ if (bytes[0] !== ENVELOPE_LEAD[0] || bytes[1] !== ENVELOPE_LEAD[1]) return "envelope lead";
2461
+ const payload = Array.from(rc4(PAYLOAD_KEY, Uint8Array.from(bytes.slice(2))));
2462
+ const canvasBytes = [
2463
+ 32,
2464
+ 0,
2465
+ 190,
2466
+ 144
2467
+ ];
2468
+ const emptyBytes = emptyDigestOf();
2469
+ if (payload.slice(0, 4).join(",") !== [
2470
+ 64,
2471
+ 0,
2472
+ 1,
2473
+ 12
2474
+ ].join(",")) return "lead slots";
2475
+ if (payload[6] !== emptyBytes[DIGEST_INDICES[0]] || payload[7] !== emptyBytes[DIGEST_INDICES[1]]) return "empty digest";
2476
+ if (payload.slice(14, 18).join(",") !== canvasBytes.join(",")) return "canvas constant";
2477
+ let checksum = payload[0];
2478
+ for (let index = 1; index < 18; index++) checksum ^= payload[index];
2479
+ return checksum === payload[18] ? null : "checksum";
2480
+ };
2481
+ /**
2482
+ * 拆开一份 X-Bogus,并就调用方给出的候选值做校验。
2483
+ *
2484
+ * 能原样读回的只有一个值:秒级时间戳。三条摘要链各留两个字节(16 位),输入无法
2485
+ * 从中倒推,做的是校验 —— 给候选值,回答是不是它封的那一个。证据量比 a_bogus
2486
+ * 的 24 位小,但足以区分「对着的 URL」与「差一个参数的 URL」。
2487
+ * @param value - X-Bogus 的值(28 个字符)
2488
+ * @param candidates - 候选的 path+search 与 User-Agent,给哪个校验哪个
2489
+ * @returns 拆解结果;格式不合法时 `recovered` 为 `false` 并给出 `reason`
2490
+ */
2491
+ const decodeXBogus = (value, candidates = {}) => {
2492
+ const problem = xBogusStructureError(value);
2493
+ if (problem !== null) return {
2494
+ parameter: "X-Bogus",
2495
+ platform: "douyin",
2496
+ algorithm: "X-Bogus",
2497
+ recovered: false,
2498
+ reason: `${REASON.MALFORMED}:${problem}`,
2499
+ fields: [],
2500
+ checks: [],
2501
+ notes: []
2502
+ };
2503
+ const table = new Map(CHARACTER.split("").map((char, index) => [char, index]));
2504
+ const bytes = [];
2505
+ for (let offset = 0; offset < value.length; offset += 4) {
2506
+ let block = 0;
2507
+ for (const char of value.slice(offset, offset + 4)) block = block << 6 | table.get(char);
2508
+ bytes.push(block >>> 16 & 255, block >>> 8 & 255, block & 255);
2509
+ }
2510
+ const payload = Array.from(rc4(PAYLOAD_KEY, Uint8Array.from(bytes.slice(2))));
2511
+ const timestamp = (payload[10] << 24 | payload[11] << 16 | payload[12] << 8 | payload[13]) >>> 0;
2512
+ const carried = {
2513
+ query: [payload[4], payload[5]],
2514
+ empty: [payload[6], payload[7]],
2515
+ user_agent: [payload[8], payload[9]]
2516
+ };
2517
+ const fields = [
2518
+ {
2519
+ name: "header_lead",
2520
+ value: "[2, 255]",
2521
+ kind: KIND.PLAIN
2522
+ },
2523
+ {
2524
+ name: "lead_slots",
2525
+ value: "[64, 0, 1, 12]",
2526
+ kind: KIND.PLAIN,
2527
+ detail: "第三格在载荷里写的是 1/256,编码前被截断成 0"
2528
+ },
2529
+ clockField("timestamp", timestamp * 1e3, "秒级时钟,与 a_bogus 的毫秒时钟不同源"),
2530
+ {
2531
+ name: "canvas_constant",
2532
+ value: String(CANVAS_CONSTANT),
2533
+ kind: KIND.PLAIN
2534
+ },
2535
+ ...[
2536
+ "query",
2537
+ "empty",
2538
+ "user_agent"
2539
+ ].map((name) => ({
2540
+ name: `${name}_digest`,
2541
+ value: carried[name].join(" "),
2542
+ kind: KIND.DIGEST,
2543
+ detail: `${name} 链的 2 个字节(MD5 摘要的第 14、15 格)`
2544
+ })),
2545
+ {
2546
+ name: "checksum",
2547
+ value: String(payload[18]),
2548
+ kind: KIND.CHECKSUM,
2549
+ detail: "前 18 格逐格异或"
2550
+ }
2551
+ ];
2552
+ const candidateOf = {
2553
+ query: candidates.query,
2554
+ empty: "",
2555
+ user_agent: candidates.userAgent
2556
+ };
2557
+ const predict = (name) => {
2558
+ const digest = name === "query" ? queryDigestOf(candidates.query) : uaDigestOf(candidates.userAgent);
2559
+ return [digest[DIGEST_INDICES[0]], digest[DIGEST_INDICES[1]]];
2560
+ };
2561
+ return {
2562
+ parameter: "X-Bogus",
2563
+ platform: "douyin",
2564
+ algorithm: "X-Bogus",
2565
+ recovered: true,
2566
+ fields,
2567
+ checks: [
2568
+ "query",
2569
+ "empty",
2570
+ "user_agent"
2571
+ ].map((name) => {
2572
+ const observed = carried[name];
2573
+ if (name === "empty") {
2574
+ const expected = emptyDigestOf();
2575
+ return {
2576
+ name,
2577
+ status: expected[DIGEST_INDICES[0]] === observed[0] && expected[DIGEST_INDICES[1]] === observed[1] ? CHECK.MATCH : CHECK.DIFFERS,
2578
+ bits: 16
2579
+ };
2580
+ }
2581
+ const candidate = candidateOf[name];
2582
+ if (candidate === void 0) return {
2583
+ name,
2584
+ status: CHECK.NOT_SUPPLIED,
2585
+ bits: 16
2586
+ };
2587
+ return predict(name).join(",") === observed.join(",") ? {
2588
+ name,
2589
+ status: CHECK.MATCH,
2590
+ bits: 16,
2591
+ covered: candidate
2592
+ } : {
2593
+ name,
2594
+ status: CHECK.DIFFERS,
2595
+ bits: 16
2596
+ };
2597
+ }),
2598
+ notes: ["checksum_verified", "structure_constants_verified"]
2599
+ };
2600
+ };
2601
+ //#endregion
2602
+ //#region src/platforms/kuaishou/sign/hudr.ts
2603
+ const KUAISHOU_HUDR_PREFIX = "HUDR_";
2604
+ const KUAISHOU_HUDR_MASK_BYTE = 35;
2605
+ const KUAISHOU_HUDR_BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2606
+ const KUAISHOU_HUDR_CHACHA_KEY = [
2607
+ 4183807412,
2608
+ 394484062,
2609
+ 1106561997,
2610
+ 2378328696,
2611
+ 630790222,
2612
+ 2546784104,
2613
+ 2891127470,
2614
+ 1922531795
2615
+ ];
2616
+ const KUAISHOU_HUDR_CHACHA_NONCE = [
2617
+ 2215853858,
2618
+ 1643070585,
2619
+ 1849059804
2620
+ ];
2621
+ /** 将字符串转换为 Unicode code point 数组。 */
2622
+ const toCodePointArray = (value) => {
2623
+ return Array.from(value).map((char) => char.codePointAt(0) ?? 0);
2624
+ };
2625
+ /** 以小端序输出固定长度字节数组。 */
2626
+ const toLittleEndianBytes = (value, size = 4) => {
2627
+ if (size >= 4 && value >= Math.pow(2, 32)) return [
2628
+ 255,
2629
+ 255,
2630
+ 255,
2631
+ 255
2632
+ ];
2633
+ const result = [];
2634
+ for (let index = 0; index <= size - 1; index++) result[index] = value >>> 8 * index & 255;
2635
+ return result;
2636
+ };
2637
+ /** 对字节切片执行标准 Base64 编码。 */
2638
+ const encodeBase64Slice = (bytes, start, end) => {
2639
+ const result = [];
2640
+ for (let index = start; index < end; index += 3) {
2641
+ const value = (bytes[index] << 16 & 16711680) + (bytes[index + 1] << 8 & 65280) + (bytes[index + 2] & 255);
2642
+ result.push(KUAISHOU_HUDR_BASE64_ALPHABET[value >> 18 & 63], KUAISHOU_HUDR_BASE64_ALPHABET[value >> 12 & 63], KUAISHOU_HUDR_BASE64_ALPHABET[value >> 6 & 63], KUAISHOU_HUDR_BASE64_ALPHABET[value & 63]);
2643
+ }
2644
+ return result.join("");
2645
+ };
2646
+ /** 快手 `HUDR_` 使用的 Base64URL 变体编码。 */
2647
+ const encodeBase64Url = (bytes) => {
2648
+ const result = [];
2649
+ const remainder = bytes.length % 3;
2650
+ const tripletLength = bytes.length - remainder;
2651
+ for (let index = 0; index < tripletLength; index += 16383) {
2652
+ const end = tripletLength < index + 16383 ? tripletLength : index + 16383;
2653
+ result.push(encodeBase64Slice(bytes, index, end));
2654
+ }
2655
+ if (remainder === 1) {
2656
+ const value = bytes[bytes.length - 1];
2657
+ result.push(KUAISHOU_HUDR_BASE64_ALPHABET[value >> 2], KUAISHOU_HUDR_BASE64_ALPHABET[value << 4 & 63], "==");
2658
+ } else if (remainder === 2) {
2659
+ const value = (bytes[bytes.length - 2] << 8) + bytes[bytes.length - 1];
2660
+ result.push(KUAISHOU_HUDR_BASE64_ALPHABET[value >> 10], KUAISHOU_HUDR_BASE64_ALPHABET[value >> 4 & 63], KUAISHOU_HUDR_BASE64_ALPHABET[value << 2 & 63], "=");
2661
+ }
2662
+ return result.join("").replace(/[+/=]/g, (char) => {
2663
+ if (char === "+") return "-";
2664
+ if (char === "/") return "_";
2665
+ return ".";
2666
+ });
2667
+ };
2668
+ /**
2669
+ * 快手 `HUDR_` 所用的 ChaCha20 变体实现。
2670
+ *
2671
+ * 该实现并非通用密码库封装,而是为对齐页面 `$encode`
2672
+ * 数据流而最小化保留的局部算法。
2673
+ */
2674
+ var KuaishouChaChaCipher = class {
2675
+ key;
2676
+ nonce;
2677
+ wordIndex = 0;
2678
+ state = new Array(16).fill(0);
2679
+ constructor(key, nonce) {
2680
+ this.key = key;
2681
+ this.nonce = nonce;
2682
+ }
2683
+ rotateLeft(value, shift) {
2684
+ return (value << shift | value >>> 32 - shift) >>> 0;
2685
+ }
2686
+ quarterRound(target, a, b, c, d) {
2687
+ target[a] = target[a] + target[b] >>> 0;
2688
+ target[d] ^= target[a];
2689
+ target[d] = this.rotateLeft(target[d], 16);
2690
+ target[c] = target[c] + target[d] >>> 0;
2691
+ target[b] ^= target[c];
2692
+ target[b] = this.rotateLeft(target[b], 12);
2693
+ target[a] = target[a] + target[b] >>> 0;
2694
+ target[d] ^= target[a];
2695
+ target[d] = this.rotateLeft(target[d], 8);
2696
+ target[c] = target[c] + target[d] >>> 0;
2697
+ target[b] ^= target[c];
2698
+ target[b] = this.rotateLeft(target[b], 7);
2699
+ }
2700
+ refillBlock() {
2701
+ const working = this.state.slice();
2702
+ for (let round = 0; round < 20; round += 2) {
2703
+ this.quarterRound(working, 0, 4, 8, 12);
2704
+ this.quarterRound(working, 1, 5, 9, 13);
2705
+ this.quarterRound(working, 2, 6, 10, 14);
2706
+ this.quarterRound(working, 3, 7, 11, 15);
2707
+ this.quarterRound(working, 0, 5, 10, 15);
2708
+ this.quarterRound(working, 1, 6, 11, 12);
2709
+ this.quarterRound(working, 2, 7, 8, 13);
2710
+ this.quarterRound(working, 3, 4, 9, 14);
2711
+ }
2712
+ const mixedState = new Array(16).fill(0);
2713
+ for (let index = 0; index < 16; index++) mixedState[index] = working[index] + this.state[index] >>> 0;
2714
+ return mixedState;
2715
+ }
2716
+ encrypt(input) {
2717
+ this.wordIndex = 0;
2718
+ this.state[0] = 394484062;
2719
+ this.state[1] = 2378328696;
2720
+ this.state[2] = 630790222;
2721
+ this.state[3] = 1922531795;
2722
+ for (let index = 0; index < 8; index++) this.state[index + 4] = this.key[index] >>> 0;
2723
+ this.state[12] = 1;
2724
+ this.state[13] = this.nonce[0] >>> 0;
2725
+ this.state[14] = this.nonce[1] >>> 0;
2726
+ this.state[15] = this.nonce[2] >>> 0;
2727
+ let mixedState = this.refillBlock();
2728
+ const output = new Uint8Array(input.length);
2729
+ for (let index = 0; index < input.length; index++) {
2730
+ if (this.wordIndex === 64) {
2731
+ this.state[12] = this.state[12] + 1 >>> 0;
2732
+ mixedState = this.refillBlock();
2733
+ this.wordIndex = 0;
2734
+ }
2735
+ const keystreamByte = mixedState[this.wordIndex >> 2] >>> ((this.wordIndex & 3) << 3) & 255;
2736
+ this.wordIndex += 1;
2737
+ output[index] = input[index] ^ keystreamByte;
2738
+ }
2739
+ return output;
2740
+ }
2741
+ };
2742
+ /**
2743
+ * 构造 `HUDR_` 头部中的 `infoCache` 字段。
2744
+ *
2745
+ * @param scriptCount - 页面脚本数量
2746
+ * @returns `HUDR_` 载荷中的脚本信息缓存字节
2747
+ */
2748
+ const buildKuaishouHudrInfoCache = (scriptCount = 0) => {
2749
+ return [68, 0].concat(toLittleEndianBytes(scriptCount, 4));
2750
+ };
2751
+ /**
2752
+ * 构造快手 `HUDR_` 原始载荷。
2753
+ *
2754
+ * @param context - 生成 `HUDR_` 所需的上下文参数
2755
+ * @returns 尚未异或和加密的原始字节载荷
2756
+ */
2757
+ const buildKuaishouHudrPayload = (context) => {
2758
+ const stackTail = context.secs?.s ?? "";
2759
+ const secsCount = context.secs?.c ?? 0;
2760
+ const count = context.count || 0;
2761
+ return [
2762
+ 45,
2763
+ 61,
2764
+ 0,
2765
+ 2
2766
+ ].concat(buildKuaishouHudrInfoCache(context.scriptCount)).concat([112, 0], toLittleEndianBytes(count, 4)).concat([114, 1], toLittleEndianBytes(stackTail.length, 2), toCodePointArray(stackTail)).concat([115, 0], toLittleEndianBytes(secsCount, 4));
2767
+ };
2768
+ /**
2769
+ * 对 `HUDR_` 原始载荷执行页面同款掩码异或。
2770
+ *
2771
+ * @param payload - 原始 `HUDR_` 字节载荷
2772
+ * @returns 掩码处理后的字节数组
2773
+ */
2774
+ const maskKuaishouHudrPayload = (payload) => {
2775
+ const masked = new Uint8Array(payload.length);
2776
+ for (let index = 0; index < payload.length; index++) masked[index] = KUAISHOU_HUDR_MASK_BYTE ^ payload[index];
2777
+ return masked;
2778
+ };
2779
+ /**
2780
+ * 推导快手签名中的 `HUDR_` 段。
2781
+ *
2782
+ * @param context - 生成 `HUDR_` 所需的上下文参数
2783
+ * @returns `HUDR_` 的完整结果及若干中间态,便于对拍与调试
2784
+ */
2785
+ const deriveKuaishouHudrBody = (context) => {
2786
+ const payload = buildKuaishouHudrPayload(context);
2787
+ const maskedPayload = maskKuaishouHudrPayload(payload);
2788
+ const encrypted = new KuaishouChaChaCipher(KUAISHOU_HUDR_CHACHA_KEY, KUAISHOU_HUDR_CHACHA_NONCE).encrypt(maskedPayload);
2789
+ const body = encodeBase64Url(encrypted);
2790
+ return {
2791
+ body,
2792
+ full: `${KUAISHOU_HUDR_PREFIX}${body}`,
2793
+ infoCache: buildKuaishouHudrInfoCache(context.scriptCount),
2794
+ maskedPayload,
2795
+ nextCount: context.count + 1
2796
+ };
2797
+ };
2798
+ //#endregion
2799
+ //#region src/platforms/kuaishou/sign/primitives.ts
2800
+ const KUAISHOU_BLAKE2S_IV = [
2801
+ 2837534710,
2802
+ 2845986804,
2803
+ 2436420605,
2804
+ 706843635,
2805
+ 719254516,
2806
+ 2557931286,
2807
+ 2596197199,
2808
+ 2432949778
2809
+ ];
2810
+ const KUAISHOU_BLAKE2S_SIGMA = [
2811
+ [
2812
+ 0,
2813
+ 1,
2814
+ 2,
2815
+ 3,
2816
+ 4,
2817
+ 5,
2818
+ 6,
2819
+ 7,
2820
+ 8,
2821
+ 9,
2822
+ 10,
2823
+ 11,
2824
+ 12,
2825
+ 13,
2826
+ 14,
2827
+ 15
2828
+ ],
2829
+ [
2830
+ 14,
2831
+ 10,
2832
+ 4,
2833
+ 8,
2834
+ 9,
2835
+ 15,
2836
+ 13,
2837
+ 6,
2838
+ 1,
2839
+ 12,
2840
+ 0,
2841
+ 2,
2842
+ 11,
2843
+ 7,
2844
+ 5,
2845
+ 3
2846
+ ],
2847
+ [
2848
+ 11,
2849
+ 8,
2850
+ 12,
2851
+ 0,
2852
+ 5,
2853
+ 2,
2854
+ 15,
2855
+ 13,
2856
+ 10,
2857
+ 14,
2858
+ 3,
2859
+ 6,
2860
+ 7,
2861
+ 1,
2862
+ 9,
2863
+ 4
2864
+ ],
2865
+ [
2866
+ 7,
2867
+ 9,
2868
+ 3,
2869
+ 1,
2870
+ 13,
2871
+ 12,
2872
+ 11,
2873
+ 14,
2874
+ 2,
2875
+ 6,
2876
+ 5,
2877
+ 10,
2878
+ 4,
2879
+ 0,
2880
+ 15,
2881
+ 8
2882
+ ],
2883
+ [
2884
+ 9,
2885
+ 0,
2886
+ 5,
2887
+ 7,
2888
+ 2,
2889
+ 4,
2890
+ 10,
2891
+ 15,
2892
+ 14,
2893
+ 1,
2894
+ 11,
2895
+ 12,
2896
+ 6,
2897
+ 8,
2898
+ 3,
2899
+ 13
2900
+ ],
2901
+ [
2902
+ 2,
2903
+ 12,
2904
+ 6,
2905
+ 10,
2906
+ 0,
2907
+ 11,
2908
+ 8,
2909
+ 3,
2910
+ 4,
2911
+ 13,
2912
+ 7,
2913
+ 5,
2914
+ 15,
2915
+ 14,
2916
+ 1,
2917
+ 9
2918
+ ],
2919
+ [
2920
+ 12,
2921
+ 5,
2922
+ 1,
2923
+ 15,
2924
+ 14,
2925
+ 13,
2926
+ 4,
2927
+ 10,
2928
+ 0,
2929
+ 7,
2930
+ 6,
2931
+ 3,
2932
+ 9,
2933
+ 2,
2934
+ 8,
2935
+ 11
2936
+ ],
2937
+ [
2938
+ 13,
2939
+ 11,
2940
+ 7,
2941
+ 14,
2942
+ 12,
2943
+ 1,
2944
+ 3,
2945
+ 9,
2946
+ 5,
2947
+ 0,
2948
+ 15,
2949
+ 4,
2950
+ 8,
2951
+ 6,
2952
+ 2,
2953
+ 10
2954
+ ],
2955
+ [
2956
+ 6,
2957
+ 15,
2958
+ 14,
2959
+ 9,
2960
+ 11,
2961
+ 3,
2962
+ 0,
2963
+ 8,
2964
+ 12,
2965
+ 2,
2966
+ 13,
2967
+ 7,
2968
+ 1,
2969
+ 4,
2970
+ 10,
2971
+ 5
2972
+ ],
2973
+ [
2974
+ 10,
2975
+ 2,
2976
+ 8,
2977
+ 4,
2978
+ 7,
2979
+ 6,
2980
+ 1,
2981
+ 5,
2982
+ 15,
2983
+ 11,
2984
+ 9,
2985
+ 14,
2986
+ 3,
2987
+ 12,
2988
+ 13,
2989
+ 0
2990
+ ]
2991
+ ];
2992
+ const KUAISHOU_CTS_STATE_VECTOR = new Int8Array([
2993
+ 98,
2994
+ 0,
2995
+ 0,
2996
+ 128,
2997
+ 49,
2998
+ 117,
2999
+ 185,
3000
+ 253,
3001
+ 224,
3002
+ 172,
3003
+ 104,
3004
+ 36,
3005
+ 223,
3006
+ 155,
3007
+ 87,
3008
+ 19,
3009
+ 32,
3010
+ 0,
3011
+ 0,
3012
+ 64,
3013
+ 2,
3014
+ 0,
3015
+ 0,
3016
+ 16,
3017
+ 255,
3018
+ 255,
3019
+ 255,
3020
+ 127,
3021
+ 255,
3022
+ 255,
3023
+ 255,
3024
+ 63,
3025
+ 0,
3026
+ 0,
3027
+ 0,
3028
+ 240,
3029
+ 0,
3030
+ 0,
3031
+ 0,
3032
+ 192,
3033
+ 0,
3034
+ 0,
3035
+ 0,
3036
+ 128,
3037
+ 255,
3038
+ 255,
3039
+ 255,
3040
+ 15
3041
+ ]);
3042
+ const rotateRight = (value, shift) => {
3043
+ return (value >>> shift | value << 32 - shift) >>> 0;
3044
+ };
3045
+ const toUtf8Int8Array = (value) => {
3046
+ return new Int8Array(unescape(encodeURIComponent(value)).split("").map((char) => char.charCodeAt(0) || 0));
3047
+ };
3048
+ const toHex32 = (value) => {
3049
+ return (value >>> 0).toString(16).padStart(8, "0");
3050
+ };
3051
+ const blake2sQuarterRound = (state, a, b, c, d, x, y) => {
3052
+ state[a] = state[a] + state[b] + x >>> 0;
3053
+ state[d] = rotateRight(state[d] ^ state[a], 16);
3054
+ state[c] = state[c] + state[d] >>> 0;
3055
+ state[b] = rotateRight(state[b] ^ state[c], 12);
3056
+ state[a] = state[a] + state[b] + y >>> 0;
3057
+ state[d] = rotateRight(state[d] ^ state[a], 8);
3058
+ state[c] = state[c] + state[d] >>> 0;
3059
+ state[b] = rotateRight(state[b] ^ state[c], 7);
3060
+ };
3061
+ const blake2sCompress = (hash, words, offset, counter, length, isLastBlock) => {
3062
+ const work = new Array(16).fill(0);
3063
+ const block = new Array(16).fill(0);
3064
+ for (let index = 0; index < 8; index++) {
3065
+ work[index] = hash[index] >>> 0;
3066
+ work[index + 8] = KUAISHOU_BLAKE2S_IV[index] >>> 0;
3067
+ }
3068
+ work[12] ^= counter >>> 0;
3069
+ if (isLastBlock) work[14] ^= 4294967295;
3070
+ for (let index = 0; index < length; index++) block[index % 16] ^= words[offset + index] >>> 0;
3071
+ for (const sigma of KUAISHOU_BLAKE2S_SIGMA) {
3072
+ blake2sQuarterRound(work, 0, 4, 8, 12, block[sigma[0]], block[sigma[1]]);
3073
+ blake2sQuarterRound(work, 1, 5, 9, 13, block[sigma[2]], block[sigma[3]]);
3074
+ blake2sQuarterRound(work, 2, 6, 10, 14, block[sigma[4]], block[sigma[5]]);
3075
+ blake2sQuarterRound(work, 3, 7, 11, 15, block[sigma[6]], block[sigma[7]]);
3076
+ blake2sQuarterRound(work, 0, 5, 10, 15, block[sigma[8]], block[sigma[9]]);
3077
+ blake2sQuarterRound(work, 1, 6, 11, 12, block[sigma[10]], block[sigma[11]]);
3078
+ blake2sQuarterRound(work, 2, 7, 8, 13, block[sigma[12]], block[sigma[13]]);
3079
+ blake2sQuarterRound(work, 3, 4, 9, 14, block[sigma[14]], block[sigma[15]]);
3080
+ }
3081
+ for (let index = 0; index < 8; index++) hash[index] = (hash[index] ^ work[index] ^ work[index + 8]) >>> 0;
3082
+ return hash;
3083
+ };
3084
+ const deriveB2hasWords = (value) => {
3085
+ const utf8 = toUtf8Int8Array(value);
3086
+ const padding = utf8.length % 4 === 0 ? 0 : 4 - utf8.length % 4;
3087
+ const padded = new Int8Array(utf8.length + padding);
3088
+ for (let index = 0; index < utf8.length; index++) padded[index] = utf8[index];
3089
+ const words = new Array(padded.length / 4);
3090
+ for (let index = 0; index < padded.length; index += 4) words[index / 4] = new Int32Array(padded.slice(index, index + 4).buffer)[0];
3091
+ return words;
3092
+ };
3093
+ const deriveB2hasHash = (words) => {
3094
+ const hash = KUAISHOU_BLAKE2S_IV.slice();
3095
+ hash[0] ^= 16842784;
3096
+ let offset = 0;
3097
+ let length = words.length;
3098
+ let counter = 0;
3099
+ while (length > 64) {
3100
+ length -= 64;
3101
+ counter += 64;
3102
+ blake2sCompress(hash, words, offset, counter, 64, false);
3103
+ offset += 64;
3104
+ }
3105
+ return blake2sCompress(hash, words, offset, counter + length, length, true);
3106
+ };
3107
+ /**
3108
+ * 计算快手页面中的 `b2has` 字符串。
3109
+ *
3110
+ * 这是后续 `b2sa` / `cts` 链路的起点。
3111
+ *
3112
+ * @param value - 原始输入字符串
3113
+ * @returns `b2has` 结果字符串
3114
+ */
3115
+ const deriveKuaishouB2has = (value) => {
3116
+ return deriveB2hasHash(deriveB2hasWords(value)).map(toHex32).join("");
3117
+ };
3118
+ /**
3119
+ * 将输入字符串转换为快手 `b2sa` 所需的字节数组。
3120
+ *
3121
+ * @param value - 原始输入字符串
3122
+ * @returns `b2has` 结果转 UTF-8 后的 Int8Array
3123
+ */
3124
+ const deriveKuaishouB2sa = (value) => {
3125
+ return toUtf8Int8Array(deriveKuaishouB2has(value));
3126
+ };
3127
+ const createKuaishouCtsState = () => {
3128
+ return {
3129
+ s: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(12, 16).buffer)[0],
3130
+ u: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(8, 12).buffer)[0],
3131
+ c: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(4, 8).buffer)[0],
3132
+ l: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(0, 4).buffer)[0],
3133
+ p: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(16, 20).buffer)[0],
3134
+ f: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(20, 24).buffer)[0],
3135
+ d: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(24, 28).buffer)[0],
3136
+ y: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(28, 32).buffer)[0],
3137
+ h: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(44, 48).buffer)[0],
3138
+ E: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(40, 44).buffer)[0],
3139
+ m: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(36, 40).buffer)[0],
3140
+ b: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(32, 36).buffer)[0]
3141
+ };
3142
+ };
3143
+ const seedKuaishouCtsState = (state, seed) => {
3144
+ const chars = new Int8Array(seed.split("").map((char) => char.codePointAt(0) ?? 0)).slice(0, seed.length);
3145
+ for (let index = 0; index < 4; index++) {
3146
+ state.s = (state.s <<= 8) | chars[index + 4];
3147
+ state.u = (state.u <<= 8) | chars[index + 4];
3148
+ state.c = (state.c <<= 8) | chars[index + 4];
3149
+ }
3150
+ if (state.s === 0) state.s = 324508639;
3151
+ if (state.u === 0) state.u = 610839776;
3152
+ if (state.c === 0) state.c = 4256789809;
3153
+ };
3154
+ const deriveKuaishouCtsByte = (state, value) => {
3155
+ let result = 0;
3156
+ let rightBit = 1 & state.u;
3157
+ let leftBit = 1 & state.c;
3158
+ for (let index = 0; index < 8; index++) {
3159
+ if (1 & state.s) {
3160
+ state.s = state.s ^ state.l >> 1 & 4294967295 | state.E;
3161
+ rightBit = 1 & state.u ? (state.u = state.u ^ state.p >> 1 & 4294967295 | state.m, 1) : (state.u = state.u >> 1 & 4294967295 & state.y, 0);
3162
+ } else {
3163
+ state.s = state.s >> 1 & 4294967295 & state.d;
3164
+ leftBit = 1 & state.c ? (state.c = state.c ^ state.f >> 1 & 4294967295 | state.b, 1) : (state.c = state.c >> 1 & 4294967295 & state.h, 0);
3165
+ }
3166
+ const mixed = result << 1 & 4294967295 | rightBit ^ leftBit;
3167
+ result = mixed > 127 ? mixed - 256 : mixed < -128 ? mixed + 256 : mixed;
3168
+ }
3169
+ return value ^ result + 3;
3170
+ };
3171
+ /**
3172
+ * 执行快手页面中的 `cts` 字节流变换。
3173
+ *
3174
+ * 该变换会作用在 `b2sa` 的输出上,随后被 `$HE_` 使用。
3175
+ *
3176
+ * @param input - 输入字节数组
3177
+ * @returns 变换后的字节数组
3178
+ */
3179
+ const deriveKuaishouCts = (input) => {
3180
+ const state = createKuaishouCtsState();
3181
+ seedKuaishouCtsState(state, "Vuz4fCHxn1CO");
3182
+ const result = new Int8Array(input.length);
3183
+ for (let index = 0; index < input.length; index++) result[index] = deriveKuaishouCtsByte(state, input[index]);
3184
+ return result;
3185
+ };
3186
+ /**
3187
+ * 将字节数组编码为小写 hex 字符串。
3188
+ *
3189
+ * @param bytes - 任意可按字节访问的数组
3190
+ * @returns 小写 hex 字符串
3191
+ */
3192
+ const bytesToLowerHex = (bytes) => {
3193
+ let result = "";
3194
+ for (const byte of Array.from(bytes)) {
3195
+ const value = byte & 255;
3196
+ result += value === 0 ? "00" : `${value < 16 ? "0" : ""}${value.toString(16)}`;
3197
+ }
3198
+ return result;
3199
+ };
3200
+ /**
3201
+ * 将 hex 字符串还原为带符号字节数组。
3202
+ *
3203
+ * @param value - 偶数字节长度的 hex 字符串
3204
+ * @returns 带符号 byte 数组
3205
+ */
3206
+ const hexToSignedBytes = (value) => {
3207
+ const result = [];
3208
+ for (let index = 0; index < value.length; index += 2) {
3209
+ const parsed = parseInt(value.slice(index, index + 2), 16);
3210
+ const range = Math.pow(2, value.slice(index, index + 2).length / 2 * 8);
3211
+ result.push(parsed > range / 2 - 1 ? parsed - range : parsed);
3212
+ }
3213
+ return result;
3214
+ };
3215
+ /**
3216
+ * 对两个字节数组执行循环异或。
3217
+ *
3218
+ * @param left - 被处理的字节数组
3219
+ * @param right - 异或掩码字节数组
3220
+ * @returns 异或后的 Int8Array
3221
+ */
3222
+ const xorByteArrays = (left, right) => {
3223
+ let index = 0;
3224
+ const result = new Int8Array(left.length);
3225
+ while (index < left.length) for (let rightIndex = 0; rightIndex < right.length && index < left.length; rightIndex++) {
3226
+ result[index] = left[index] ^ 255 & right[rightIndex];
3227
+ index += 1;
3228
+ }
3229
+ return result;
3230
+ };
3231
+ /**
3232
+ * 将数值编码为固定长度的小端 hex。
3233
+ *
3234
+ * @param value - 原始数值
3235
+ * @param size - 输出字节长度
3236
+ * @returns 小端序 hex 字符串
3237
+ */
3238
+ const toLittleEndianHex = (value, size) => {
3239
+ const normalized = BigInt.asUintN(size * 8, typeof value === "bigint" ? value : BigInt(Math.trunc(value)));
3240
+ let result = "";
3241
+ for (let index = 0; index < size; index++) {
3242
+ const byte = Number(normalized >> BigInt(8 * index) & 255n);
3243
+ result += byte.toString(16).padStart(2, "0");
3244
+ }
3245
+ return result;
3246
+ };
3247
+ /**
3248
+ * 计算快手 `$HE_` 所使用的 8-bit LRC 校验值。
3249
+ *
3250
+ * @param sourceHex - 原始 hex 字符串
3251
+ * @returns 单字节 LRC 的 hex 表示
3252
+ */
3253
+ const computeKuaishouLrcHex = (sourceHex) => {
3254
+ return (-hexToSignedBytes(sourceHex).reduce((total, value) => total + (value & 255), 0) & 255).toString(16).padStart(2, "0");
3255
+ };
3256
+ /**
3257
+ * 对 `$HE_` 的 preHex + checksum 执行最终变换。
3258
+ *
3259
+ * 页面实现会以最后一个字节为 key,对前面的字节逐个异或,
3260
+ * 最后再整体转回小写 hex。
3261
+ *
3262
+ * @param prefixHex - 不含最终 LRC 的前缀 hex
3263
+ * @param checksumHex - 最终 LRC hex
3264
+ * @returns `$HE_` 最终输出的 hex 字符串
3265
+ */
3266
+ const transformKuaishouHeHex = (prefixHex, checksumHex) => {
3267
+ const input = hexToSignedBytes(`${prefixHex}${checksumHex}`);
3268
+ const output = new Int8Array(input.length);
3269
+ const xorKey = input[input.length - 1];
3270
+ for (let index = 0; index < input.length - 1; index++) output[index] = input[index] ^ xorKey;
3271
+ output[input.length - 1] = xorKey;
3272
+ return bytesToLowerHex(output);
3273
+ };
3274
+ //#endregion
3275
+ //#region src/platforms/kuaishou/sign/he.ts
3276
+ const KUAISHOU_HE_HEADER_HEX = "4B54";
3277
+ const KUAISHOU_HE_VERSION_HEX = "cda9";
3278
+ const KUAISHOU_HE_STARTUP_MARKER_HEX = "ab";
3279
+ const KUAISHOU_HE_FIXED_BODY_HEX = "0100000001";
3280
+ const KUAISHOU_HE_INPUT_XOR_MASK = [
3281
+ 45,
3282
+ 211,
3283
+ 69,
3284
+ 192
3285
+ ];
3286
+ const KUAISHOU_HE_COUNTER_XOR_MASK = 3131873467;
3287
+ const KUAISHOU_HE_TIME_XOR_MASK = 3360347992n;
3288
+ const KUAISHOU_HE_TAIL_HEX = "9b563eda7b563e";
3289
+ const KUAISHOU_HE_RANDOM_MAX = 0xffffffffffff;
3290
+ /**
3291
+ * 计算 `$HE_` 中的 hash field。
3292
+ *
3293
+ * 它由 `signInput + HUDRBody` 经 `b2sa -> cts -> 截断 -> 异或掩码`
3294
+ * 这条链路导出,是 `$HE_` 中与接口输入最直接相关的一段。
3295
+ *
3296
+ * @param signInput - 快手 `__NS_hxfalcon` 的 sign input
3297
+ * @param hudrBody - `HUDR_` 去前缀后的主体
3298
+ * @returns `$HE_` 载荷中的 4 字节 hash field hex
3299
+ */
3300
+ const deriveKuaishouHeHashFieldHex = (signInput, hudrBody) => {
3301
+ const hashInput = `${signInput}HUDR_${hudrBody}`;
3302
+ const digestHex = bytesToLowerHex(deriveKuaishouCts(deriveKuaishouB2sa(hashInput))).slice(0, 8);
3303
+ return bytesToLowerHex(xorByteArrays(hexToSignedBytes(digestHex), KUAISHOU_HE_INPUT_XOR_MASK));
3304
+ };
3305
+ /**
3306
+ * 推导快手签名中的 `$HE_` 段。
3307
+ *
3308
+ * @param context - 生成 `$HE_` 所需的上下文参数
3309
+ * @returns `$HE_` 的最终 hex、中间 hash field 和 preHex
3310
+ */
3311
+ const deriveKuaishouHeHex = (context) => {
3312
+ const random48 = Math.floor(context.randomValue * KUAISHOU_HE_RANDOM_MAX);
3313
+ const hashFieldHex = deriveKuaishouHeHashFieldHex(context.signInput, context.hudrBody);
3314
+ const timeXor = BigInt(context.timestamp) ^ KUAISHOU_HE_TIME_XOR_MASK;
3315
+ const preHex = [
3316
+ KUAISHOU_HE_HEADER_HEX,
3317
+ KUAISHOU_HE_VERSION_HEX,
3318
+ KUAISHOU_HE_STARTUP_MARKER_HEX,
3319
+ toLittleEndianHex(context.startupRandom, 6),
3320
+ toLittleEndianHex(random48, 6),
3321
+ KUAISHOU_HE_FIXED_BODY_HEX,
3322
+ toLittleEndianHex(context.count ^ KUAISHOU_HE_COUNTER_XOR_MASK, 4),
3323
+ hashFieldHex,
3324
+ toLittleEndianHex(timeXor, 6),
3325
+ KUAISHOU_HE_TAIL_HEX,
3326
+ computeKuaishouLrcHex(KUAISHOU_HE_TAIL_HEX)
3327
+ ].join("");
3328
+ return {
3329
+ finalHex: transformKuaishouHeHex(preHex, computeKuaishouLrcHex(preHex)),
3330
+ hashFieldHex,
3331
+ preHex
3332
+ };
3333
+ };
3334
+ /**
3335
+ * 一次性推导快手完整纯算法签名。
3336
+ *
3337
+ * 该方法会先生成 `HUDR_`,再拼出 `$HE_`,最终返回完整
3338
+ * `HUDR_...$HE_...` 形式的 `__NS_hxfalcon`。
3339
+ *
3340
+ * @param context - 快手纯算法签名上下文
3341
+ * @returns 完整签名结果及关键中间态
3342
+ */
3343
+ const deriveKuaishouPureSignature = (context) => {
3344
+ const hudr = deriveKuaishouHudrBody(context);
3345
+ const he = deriveKuaishouHeHex({
3346
+ count: context.count,
3347
+ hudrBody: hudr.body,
3348
+ randomValue: context.randomValue,
3349
+ signInput: context.signInput,
3350
+ startupRandom: context.startupRandom,
3351
+ timestamp: context.timestamp
3352
+ });
3353
+ return {
3354
+ ...hudr,
3355
+ he,
3356
+ signResult: `${hudr.full}$HE_${he.finalHex}`
3357
+ };
3358
+ };
3359
+ /** `$HE_` 最终输出的定长:布局 44 字节 + 末尾 1 字节异或键 */
3360
+ const HE_HEX_LENGTH = 90;
3361
+ /**
3362
+ * 拆开一段 `$HE_`。
3363
+ *
3364
+ * 最终输出的最后一个字节是**异或键**:它本身是前 44 字节的 LRC 校验,同时把那
3365
+ * 44 字节逐个异或成密文 —— 所以解码顺序固定为「拆键 → 还原 → 逐段校验 → 读字段」。
3366
+ * 能同时通过两层校验(信封 LRC + 尾段 LRC)与四个布局常量的值,必然是这套装配
3367
+ * 顺序产出的;`count` 与时间戳随后原样读回,不需要任何候选输入。
3368
+ *
3369
+ * 它**不**校验 hash field —— 那四字节绑的是 signInput + HUDR body,候选值给齐了
3370
+ * 可用 {@link deriveKuaishouHeHashFieldHex} 自行比对。
3371
+ * @param hex - `$HE_` 后面的那段 hex(90 个字符)
3372
+ * @returns 拆解结果;布局不符时 `problems` 非空,字段值不可信
3373
+ */
3374
+ const decodeKuaishouHe = (hex) => {
3375
+ const problems = [];
3376
+ const fail = (problem) => {
3377
+ problems.push(problem);
3378
+ };
3379
+ if (!/^[0-9a-fA-F]+$/.test(hex)) fail("not hex");
3380
+ if (hex.length !== HE_HEX_LENGTH) fail("length not 90");
3381
+ if (problems.length > 0) return {
3382
+ startupRandom: 0,
3383
+ random: 0,
3384
+ count: 0,
3385
+ timestampMs: 0,
3386
+ hashFieldHex: "",
3387
+ problems
3388
+ };
3389
+ const bytes = hexToSignedBytes(hex.toLowerCase());
3390
+ const key = bytes[bytes.length - 1];
3391
+ const pre = bytes.slice(0, -1).map((byte) => (byte ^ key) & 255);
3392
+ if ((-pre.reduce((total, value) => total + value, 0) & 255) !== (key & 255)) fail("envelope checksum");
3393
+ if ((-pre.slice(36, 43).reduce((total, value) => total + value, 0) & 255) !== pre[43]) fail("tail lrc");
3394
+ const expectBytes = (offset, expectedHex, problem) => {
3395
+ if (!hexToSignedBytes(expectedHex).map((byte) => byte & 255).every((byte, index) => pre[offset + index] === byte)) fail(problem);
3396
+ };
3397
+ expectBytes(0, KUAISHOU_HE_HEADER_HEX, "header magic");
3398
+ expectBytes(2, KUAISHOU_HE_VERSION_HEX, "version");
3399
+ expectBytes(4, KUAISHOU_HE_STARTUP_MARKER_HEX, "startup marker");
3400
+ expectBytes(17, KUAISHOU_HE_FIXED_BODY_HEX, "fixed body");
3401
+ expectBytes(36, KUAISHOU_HE_TAIL_HEX, "tail");
3402
+ const readLE = (offset, size) => {
3403
+ let value = 0;
3404
+ for (let index = 0; index < size; index++) value += pre[offset + index] * 2 ** (8 * index);
3405
+ return value;
3406
+ };
3407
+ const unmaskCounter = (readLE(22, 4) ^ KUAISHOU_HE_COUNTER_XOR_MASK) >>> 0;
3408
+ const unmaskTime = BigInt(Math.trunc(readLE(30, 6))) ^ KUAISHOU_HE_TIME_XOR_MASK;
3409
+ return {
3410
+ startupRandom: Math.trunc(readLE(5, 6)),
3411
+ random: Math.trunc(readLE(11, 6)),
3412
+ count: unmaskCounter,
3413
+ timestampMs: Number(unmaskTime),
3414
+ hashFieldHex: bytesToLowerHex(pre.slice(26, 30)),
3415
+ problems
3416
+ };
3417
+ };
3418
+ //#endregion
3419
+ Object.defineProperty(exports, "ABogus", {
3420
+ enumerable: true,
3421
+ get: function() {
3422
+ return ABogus;
3423
+ }
3424
+ });
3425
+ Object.defineProperty(exports, "AID", {
3426
+ enumerable: true,
3427
+ get: function() {
3428
+ return AID;
3429
+ }
3430
+ });
3431
+ Object.defineProperty(exports, "ALPHABETS", {
3432
+ enumerable: true,
3433
+ get: function() {
3434
+ return ALPHABETS;
3435
+ }
3436
+ });
3437
+ Object.defineProperty(exports, "CALL_BUCKET", {
3438
+ enumerable: true,
3439
+ get: function() {
3440
+ return CALL_BUCKET;
3441
+ }
3442
+ });
3443
+ Object.defineProperty(exports, "CANVAS_CONSTANT", {
3444
+ enumerable: true,
3445
+ get: function() {
3446
+ return CANVAS_CONSTANT;
3447
+ }
3448
+ });
3449
+ Object.defineProperty(exports, "CHECK", {
3450
+ enumerable: true,
3451
+ get: function() {
3452
+ return CHECK;
3453
+ }
3454
+ });
3455
+ Object.defineProperty(exports, "DEFAULT_BROWSER_INFO", {
3456
+ enumerable: true,
3457
+ get: function() {
3458
+ return DEFAULT_BROWSER_INFO;
3459
+ }
3460
+ });
3461
+ Object.defineProperty(exports, "DETECT_FLAGS", {
3462
+ enumerable: true,
3463
+ get: function() {
3464
+ return DETECT_FLAGS;
3465
+ }
3466
+ });
3467
+ Object.defineProperty(exports, "DIGEST_CHAINS", {
3468
+ enumerable: true,
3469
+ get: function() {
3470
+ return DIGEST_CHAINS;
3471
+ }
3472
+ });
3473
+ Object.defineProperty(exports, "DOUYIN_MS_TOKEN_LENGTH", {
3474
+ enumerable: true,
3475
+ get: function() {
3476
+ return DOUYIN_MS_TOKEN_LENGTH;
3477
+ }
3478
+ });
3479
+ Object.defineProperty(exports, "DOUYIN_MS_TOKEN_SIZES", {
3480
+ enumerable: true,
3481
+ get: function() {
3482
+ return DOUYIN_MS_TOKEN_SIZES;
3483
+ }
3484
+ });
3485
+ Object.defineProperty(exports, "DOUYIN_TTWID", {
3486
+ enumerable: true,
3487
+ get: function() {
3488
+ return DOUYIN_TTWID;
3489
+ }
3490
+ });
3491
+ Object.defineProperty(exports, "DOUYIN_TTWID_PAYLOAD", {
3492
+ enumerable: true,
3493
+ get: function() {
3494
+ return DOUYIN_TTWID_PAYLOAD;
3495
+ }
3496
+ });
3497
+ Object.defineProperty(exports, "ENV_FLAGS", {
3498
+ enumerable: true,
3499
+ get: function() {
3500
+ return ENV_FLAGS;
3501
+ }
3502
+ });
3503
+ Object.defineProperty(exports, "FIELD_ORDER", {
3504
+ enumerable: true,
3505
+ get: function() {
3506
+ return FIELD_ORDER;
3507
+ }
3508
+ });
3509
+ Object.defineProperty(exports, "FORTNIGHT_EPOCH_MS", {
3510
+ enumerable: true,
3511
+ get: function() {
3512
+ return FORTNIGHT_EPOCH_MS;
3513
+ }
3514
+ });
3515
+ Object.defineProperty(exports, "HEADER_MAGIC", {
3516
+ enumerable: true,
3517
+ get: function() {
3518
+ return HEADER_MAGIC;
3519
+ }
3520
+ });
3521
+ Object.defineProperty(exports, "KIND", {
3522
+ enumerable: true,
3523
+ get: function() {
3524
+ return KIND;
3525
+ }
3526
+ });
3527
+ Object.defineProperty(exports, "MS_TOKEN_ALPHABET", {
3528
+ enumerable: true,
3529
+ get: function() {
3530
+ return MS_TOKEN_ALPHABET;
3531
+ }
3532
+ });
3533
+ Object.defineProperty(exports, "PAGE_ID", {
3534
+ enumerable: true,
3535
+ get: function() {
3536
+ return PAGE_ID;
3537
+ }
3538
+ });
3539
+ Object.defineProperty(exports, "PAYLOAD_KEY", {
3540
+ enumerable: true,
3541
+ get: function() {
3542
+ return PAYLOAD_KEY$1;
3543
+ }
3544
+ });
3545
+ Object.defineProperty(exports, "REASON", {
3546
+ enumerable: true,
3547
+ get: function() {
3548
+ return REASON;
3549
+ }
3550
+ });
3551
+ Object.defineProperty(exports, "SALT", {
3552
+ enumerable: true,
3553
+ get: function() {
3554
+ return SALT;
3555
+ }
3556
+ });
3557
+ Object.defineProperty(exports, "SDK_VERSION", {
3558
+ enumerable: true,
3559
+ get: function() {
3560
+ return SDK_VERSION;
3561
+ }
3562
+ });
3563
+ Object.defineProperty(exports, "SECSDK_SIG_KEY", {
3564
+ enumerable: true,
3565
+ get: function() {
3566
+ return SECSDK_SIG_KEY;
3567
+ }
3568
+ });
3569
+ Object.defineProperty(exports, "SM3_IV", {
3570
+ enumerable: true,
3571
+ get: function() {
3572
+ return SM3_IV;
3573
+ }
3574
+ });
3575
+ Object.defineProperty(exports, "TRIPWIRE_LOCKED", {
3576
+ enumerable: true,
3577
+ get: function() {
3578
+ return TRIPWIRE_LOCKED;
3579
+ }
3580
+ });
3581
+ Object.defineProperty(exports, "TTWID_REGISTER_URL", {
3582
+ enumerable: true,
3583
+ get: function() {
3584
+ return TTWID_REGISTER_URL;
3585
+ }
3586
+ });
3587
+ Object.defineProperty(exports, "VERIFY_FP_ALPHABET", {
3588
+ enumerable: true,
3589
+ get: function() {
3590
+ return VERIFY_FP_ALPHABET;
3591
+ }
3592
+ });
3593
+ Object.defineProperty(exports, "VERIFY_FP_LENGTH", {
3594
+ enumerable: true,
3595
+ get: function() {
3596
+ return VERIFY_FP_LENGTH;
3597
+ }
3598
+ });
3599
+ Object.defineProperty(exports, "WEBSIGN_CONST", {
3600
+ enumerable: true,
3601
+ get: function() {
3602
+ return WEBSIGN_CONST;
3603
+ }
3604
+ });
3605
+ Object.defineProperty(exports, "XBogus", {
3606
+ enumerable: true,
3607
+ get: function() {
3608
+ return XBogus;
3609
+ }
3610
+ });
3611
+ Object.defineProperty(exports, "a_bogus_default", {
3612
+ enumerable: true,
3613
+ get: function() {
3614
+ return a_bogus_default;
3615
+ }
3616
+ });
3617
+ Object.defineProperty(exports, "applySecsdkWebSign", {
3618
+ enumerable: true,
3619
+ get: function() {
3620
+ return applySecsdkWebSign;
3621
+ }
3622
+ });
3623
+ Object.defineProperty(exports, "browserInfoFromScreen", {
3624
+ enumerable: true,
3625
+ get: function() {
3626
+ return browserInfoFromScreen;
3627
+ }
3628
+ });
3629
+ Object.defineProperty(exports, "buildBrowserInfo", {
3630
+ enumerable: true,
3631
+ get: function() {
3632
+ return buildBrowserInfo;
3633
+ }
3634
+ });
3635
+ Object.defineProperty(exports, "canary", {
3636
+ enumerable: true,
3637
+ get: function() {
3638
+ return canary;
3639
+ }
3640
+ });
3641
+ Object.defineProperty(exports, "canonicalQuery", {
3642
+ enumerable: true,
3643
+ get: function() {
3644
+ return canonicalQuery;
3645
+ }
3646
+ });
3647
+ Object.defineProperty(exports, "chainBytes", {
3648
+ enumerable: true,
3649
+ get: function() {
3650
+ return chainBytes;
3651
+ }
3652
+ });
3653
+ Object.defineProperty(exports, "computeWbiSignature", {
3654
+ enumerable: true,
3655
+ get: function() {
3656
+ return computeWbiSignature;
3657
+ }
3658
+ });
3659
+ Object.defineProperty(exports, "createWbiSigner", {
3660
+ enumerable: true,
3661
+ get: function() {
3662
+ return createWbiSigner;
3663
+ }
3664
+ });
3665
+ Object.defineProperty(exports, "decode", {
3666
+ enumerable: true,
3667
+ get: function() {
3668
+ return decode;
3669
+ }
3670
+ });
3671
+ Object.defineProperty(exports, "decodeABogus", {
3672
+ enumerable: true,
3673
+ get: function() {
3674
+ return decodeABogus;
3675
+ }
3676
+ });
3677
+ Object.defineProperty(exports, "decodeBase64", {
3678
+ enumerable: true,
3679
+ get: function() {
3680
+ return decodeBase64;
3681
+ }
3682
+ });
3683
+ Object.defineProperty(exports, "decodeKuaishouHe", {
3684
+ enumerable: true,
3685
+ get: function() {
3686
+ return decodeKuaishouHe;
3687
+ }
3688
+ });
3689
+ Object.defineProperty(exports, "decodeUrl", {
3690
+ enumerable: true,
3691
+ get: function() {
3692
+ return decodeUrl;
3693
+ }
3694
+ });
3695
+ Object.defineProperty(exports, "decodeVerifyFp", {
3696
+ enumerable: true,
3697
+ get: function() {
3698
+ return decodeVerifyFp;
3699
+ }
3700
+ });
3701
+ Object.defineProperty(exports, "decodeXBogus", {
3702
+ enumerable: true,
3703
+ get: function() {
3704
+ return decodeXBogus;
3705
+ }
3706
+ });
3707
+ Object.defineProperty(exports, "deriveKuaishouHeHashFieldHex", {
3708
+ enumerable: true,
3709
+ get: function() {
3710
+ return deriveKuaishouHeHashFieldHex;
3711
+ }
3712
+ });
3713
+ Object.defineProperty(exports, "deriveKuaishouHeHex", {
3714
+ enumerable: true,
3715
+ get: function() {
3716
+ return deriveKuaishouHeHex;
3717
+ }
3718
+ });
3719
+ Object.defineProperty(exports, "deriveKuaishouPureSignature", {
3720
+ enumerable: true,
3721
+ get: function() {
3722
+ return deriveKuaishouPureSignature;
3723
+ }
3724
+ });
3725
+ Object.defineProperty(exports, "diagnoseSalt", {
3726
+ enumerable: true,
3727
+ get: function() {
3728
+ return diagnoseSalt;
3729
+ }
3730
+ });
3731
+ Object.defineProperty(exports, "digestOf", {
3732
+ enumerable: true,
3733
+ get: function() {
3734
+ return digestOf;
3735
+ }
3736
+ });
3737
+ Object.defineProperty(exports, "digestWith", {
3738
+ enumerable: true,
3739
+ get: function() {
3740
+ return digestWith;
3741
+ }
3742
+ });
3743
+ Object.defineProperty(exports, "encodeBase64", {
3744
+ enumerable: true,
3745
+ get: function() {
3746
+ return encodeBase64;
3747
+ }
3748
+ });
3749
+ Object.defineProperty(exports, "extractKey", {
3750
+ enumerable: true,
3751
+ get: function() {
3752
+ return extractKey;
3753
+ }
3754
+ });
3755
+ Object.defineProperty(exports, "extractUifidFromCookie", {
3756
+ enumerable: true,
3757
+ get: function() {
3758
+ return extractUifidFromCookie;
3759
+ }
3760
+ });
3761
+ Object.defineProperty(exports, "genFalseMsToken", {
3762
+ enumerable: true,
3763
+ get: function() {
3764
+ return genFalseMsToken;
3765
+ }
3766
+ });
3767
+ Object.defineProperty(exports, "genSVWebId", {
3768
+ enumerable: true,
3769
+ get: function() {
3770
+ return genSVWebId;
3771
+ }
3772
+ });
3773
+ Object.defineProperty(exports, "genVerifyFp", {
3774
+ enumerable: true,
3775
+ get: function() {
3776
+ return genVerifyFp;
3777
+ }
3778
+ });
3779
+ Object.defineProperty(exports, "getCookieValue", {
3780
+ enumerable: true,
3781
+ get: function() {
3782
+ return getCookieValue;
3783
+ }
3784
+ });
3785
+ Object.defineProperty(exports, "identify", {
3786
+ enumerable: true,
3787
+ get: function() {
3788
+ return identify;
3789
+ }
3790
+ });
3791
+ Object.defineProperty(exports, "isDecodeProblem", {
3792
+ enumerable: true,
3793
+ get: function() {
3794
+ return isDecodeProblem;
3795
+ }
3796
+ });
3797
+ Object.defineProperty(exports, "isSecsdkProtected", {
3798
+ enumerable: true,
3799
+ get: function() {
3800
+ return isSecsdkProtected;
3801
+ }
3802
+ });
3803
+ Object.defineProperty(exports, "jsBytes", {
3804
+ enumerable: true,
3805
+ get: function() {
3806
+ return jsBytes;
3807
+ }
3808
+ });
3809
+ Object.defineProperty(exports, "msTokenPayload", {
3810
+ enumerable: true,
3811
+ get: function() {
3812
+ return msTokenPayload;
3813
+ }
3814
+ });
3815
+ Object.defineProperty(exports, "rc4", {
3816
+ enumerable: true,
3817
+ get: function() {
3818
+ return rc4$1;
3819
+ }
3820
+ });
3821
+ Object.defineProperty(exports, "recoverSignedQuery", {
3822
+ enumerable: true,
3823
+ get: function() {
3824
+ return recoverSignedQuery;
3825
+ }
3826
+ });
3827
+ Object.defineProperty(exports, "signSecsdkWebQuery", {
3828
+ enumerable: true,
3829
+ get: function() {
3830
+ return signSecsdkWebQuery;
3831
+ }
3832
+ });
3833
+ Object.defineProperty(exports, "signSecsdkWebUrl", {
3834
+ enumerable: true,
3835
+ get: function() {
3836
+ return signSecsdkWebUrl;
3837
+ }
3838
+ });
3839
+ Object.defineProperty(exports, "signXBogus", {
3840
+ enumerable: true,
3841
+ get: function() {
3842
+ return signXBogus;
3843
+ }
3844
+ });
3845
+ Object.defineProperty(exports, "signedQueryOf", {
3846
+ enumerable: true,
3847
+ get: function() {
3848
+ return signedQueryOf;
3849
+ }
3850
+ });
3851
+ Object.defineProperty(exports, "sm3Hash", {
3852
+ enumerable: true,
3853
+ get: function() {
3854
+ return sm3Hash;
3855
+ }
3856
+ });
3857
+ Object.defineProperty(exports, "sm3Hexdigest", {
3858
+ enumerable: true,
3859
+ get: function() {
3860
+ return sm3Hexdigest;
3861
+ }
3862
+ });
3863
+ Object.defineProperty(exports, "sm3ToArray", {
3864
+ enumerable: true,
3865
+ get: function() {
3866
+ return sm3ToArray;
3867
+ }
3868
+ });
3869
+ Object.defineProperty(exports, "structureError", {
3870
+ enumerable: true,
3871
+ get: function() {
3872
+ return structureError;
3873
+ }
3874
+ });
3875
+ Object.defineProperty(exports, "toBase36", {
3876
+ enumerable: true,
3877
+ get: function() {
3878
+ return toBase36;
3879
+ }
3880
+ });
3881
+ Object.defineProperty(exports, "unmaskPair", {
3882
+ enumerable: true,
3883
+ get: function() {
3884
+ return unmaskPair;
3885
+ }
3886
+ });
3887
+ Object.defineProperty(exports, "userAgentDigest", {
3888
+ enumerable: true,
3889
+ get: function() {
3890
+ return userAgentDigest;
3891
+ }
3892
+ });
3893
+ Object.defineProperty(exports, "xBogusStructureError", {
3894
+ enumerable: true,
3895
+ get: function() {
3896
+ return xBogusStructureError;
3897
+ }
3898
+ });