@ikenxuan/amagi 6.6.0 → 7.0.0-beta.2

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,1001 @@
1
+ //#region src/platforms/douyin/sign/sm3.ts
2
+ /**
3
+ * SM3 哈希(GB/T 32905-2016),纯 TS 实现。
4
+ *
5
+ * 抖音的 a_bogus 用它摘要 query / body / User-Agent。这个文件是移植件,
6
+ * 来源是 Douyin_TikTok_Download_API 的 `signing/native/sm3.py`(Apache-2.0),
7
+ * 与本仓库的 GPL-3.0 兼容。
8
+ *
9
+ * ## 为什么不用 passport/sm3.ts
10
+ *
11
+ * 那个是登录侧的另一份 SM3,入口收 `string` 并按 `charCodeAt` 逐字符取字节 ——
12
+ * 对中文会与 UTF-8 不同。a_bogus 要摘的是 query 与 base64 串(纯 ASCII),
13
+ * 但 UA 链上要摘要的是**按 JS 语义切出来的字节**,两份混用迟早出错,所以
14
+ * 签名侧自带一份以字节为入口的实现,不复用。
15
+ *
16
+ * ## 移植时改了什么
17
+ *
18
+ * 只改了整数语义:Python 的 `& 0xFFFFFFFF` 在 JS 里必须写成 `>>> 0`,
19
+ * 否则位运算的结果是带符号 32 位整数,`~x` 与算术右移都会带进符号位。
20
+ * 摘要字节输出与 Python 逐字节一致。
21
+ */
22
+ /** 32 位掩码,JS 位运算全是带符号的,每步之后都要用它收回无符号域 */
23
+ const MASK = 4294967295;
24
+ /** SM3 初始向量 */
25
+ const SM3_IV = [
26
+ 1937774191,
27
+ 1226093241,
28
+ 388252375,
29
+ 3666478592,
30
+ 2842636476,
31
+ 372324522,
32
+ 3817729613,
33
+ 2969243214
34
+ ];
35
+ const T0 = 2043430169;
36
+ const T1 = 2055708042;
37
+ /** 循环左移。`bits % 32 === 0` 时 JS 的移位计数会自动取模,结果仍正确 */
38
+ const rotl = (value, bits) => {
39
+ const n = bits % 32;
40
+ return (value << n & MASK | value >>> 32 - n) >>> 0;
41
+ };
42
+ const ff = (index, x, y, z) => index < 16 ? (x ^ y ^ z) >>> 0 : (x & y | x & z | y & z) >>> 0;
43
+ const gg = (index, x, y, z) => index < 16 ? (x ^ y ^ z) >>> 0 : (x & y | ~x & z) >>> 0;
44
+ /** 消息扩展:16 个字扩到 68 个,再派生 64 个 `w1` */
45
+ const expand = (block) => {
46
+ const w = block.slice(0, 16);
47
+ for (let i = 16; i < 68; i++) {
48
+ let x = (w[i - 16] ^ w[i - 9] ^ rotl(w[i - 3], 15)) >>> 0;
49
+ x = (x ^ rotl(x, 15) ^ rotl(x, 23)) >>> 0;
50
+ w.push((x ^ rotl(w[i - 13], 7) ^ w[i - 6]) >>> 0);
51
+ }
52
+ const w1 = [];
53
+ for (let i = 0; i < 64; i++) w1.push((w[i] ^ w[i + 4]) >>> 0);
54
+ return {
55
+ w,
56
+ w1
57
+ };
58
+ };
59
+ /** 压缩函数:8 字状态与一个 512 位分组混合 */
60
+ const compress = (state, block) => {
61
+ const { w, w1 } = expand(block);
62
+ let [a, b, c, d, e, f, g, h] = state;
63
+ for (let j = 0; j < 64; j++) {
64
+ const t = j < 16 ? T0 : T1;
65
+ const ss1 = rotl(rotl(a, 12) + e + rotl(t, j) & MASK, 7);
66
+ const ss2 = (ss1 ^ rotl(a, 12)) >>> 0;
67
+ const tt1 = ff(j, a, b, c) + d + ss2 + w1[j] & MASK;
68
+ const tt2 = gg(j, e, f, g) + h + ss1 + w[j] & MASK;
69
+ d = c;
70
+ c = rotl(b, 9);
71
+ b = a;
72
+ a = tt1;
73
+ h = g;
74
+ g = rotl(f, 19);
75
+ f = e;
76
+ e = (tt2 ^ rotl(tt2, 9) ^ rotl(tt2, 17)) >>> 0;
77
+ }
78
+ return state.map((word, index) => (word ^ [
79
+ a,
80
+ b,
81
+ c,
82
+ d,
83
+ e,
84
+ f,
85
+ g,
86
+ h
87
+ ][index]) >>> 0);
88
+ };
89
+ /** 填充:补 `0x80`、补零到 56 mod 64、末尾写 64 位大端比特长度 */
90
+ const pad = (message) => {
91
+ const bitLength = message.length * 8;
92
+ const padded = message.slice();
93
+ padded.push(128);
94
+ while (padded.length % 64 !== 56) padded.push(0);
95
+ const high = Math.floor(bitLength / 4294967296);
96
+ const low = bitLength >>> 0;
97
+ padded.push(high >>> 24 & 255, high >>> 16 & 255, high >>> 8 & 255, high & 255);
98
+ padded.push(low >>> 24 & 255, low >>> 16 & 255, low >>> 8 & 255, low & 255);
99
+ return padded;
100
+ };
101
+ /**
102
+ * 计算 SM3 摘要。
103
+ * @param message - 待摘要的字节
104
+ * @returns 32 字节摘要
105
+ */
106
+ const sm3Hash = (message) => {
107
+ const padded = pad(message);
108
+ let state = SM3_IV.slice();
109
+ for (let offset = 0; offset < padded.length; offset += 64) {
110
+ const block = [];
111
+ for (let i = 0; i < 16; i++) {
112
+ const at = offset + i * 4;
113
+ block.push((padded[at] << 24 | padded[at + 1] << 16 | padded[at + 2] << 8 | padded[at + 3]) >>> 0);
114
+ }
115
+ state = compress(state, block);
116
+ }
117
+ const out = [];
118
+ for (const word of state) out.push(word >>> 24 & 255, word >>> 16 & 255, word >>> 8 & 255, word & 255);
119
+ return out;
120
+ };
121
+ /** 摘要的十六进制形式,便于与公开测试向量对照 */
122
+ const sm3Hexdigest = (message) => sm3Hash(message).map((byte) => byte.toString(16).padStart(2, "0")).join("");
123
+ /**
124
+ * 摘要为 32 个整数的数组,与 a_bogus 内部各处取字节的方式对齐。
125
+ *
126
+ * 字符串按 **UTF-8** 编码后摘要(与 `sm3.py` 的 `sm3_to_array` 一致)。
127
+ * 注意 a_bogus 里另有一处 `jsBytes`,那个走的是 JS `charCodeAt` 语义,
128
+ * 两者不可互换 —— 见 `a_bogus.ts` 的说明。
129
+ * @param data - 字符串、字节数组或整数数组
130
+ * @returns 32 个 0..255 的整数
131
+ */
132
+ const sm3ToArray = (data) => sm3Hash(typeof data === "string" ? Array.from(new TextEncoder().encode(data)) : Array.from(data));
133
+ //#endregion
134
+ //#region src/platforms/douyin/sign/a_bogus.ts
135
+ /**
136
+ * a_bogus —— 抖音 Web 加在每个数据接口上的签名。
137
+ *
138
+ * ## 出处
139
+ *
140
+ * 移植自 Douyin_TikTok_Download_API 的 `signing/native/abogus.py`(Apache-2.0,
141
+ * 与本仓库 GPL-3.0 兼容)。那份是**自己逆向**抖音的 `bdms.js` v1.0.1.19-fix.01
142
+ * 得到的(2026-09-09),不是抄来的片段。
143
+ *
144
+ * 本文件**替换**了原先那份 2024-08 从公开片段原样复制的实现。两份的差别不是
145
+ * 代码风格,是常量:
146
+ *
147
+ * | | 旧实现 | 本实现 |
148
+ * |---|---|---|
149
+ * | 摘要盐值 | `cus` | `dhzx` |
150
+ * | 几何串 | 17 段、写死 `1536\|747\|…` | 9 段、可传入 |
151
+ * | 时钟 / 随机源 | 直接读 `Date.now()` / `Math.random()` | 可注入 |
152
+ * | 能否解码自证 | 否 | 是,见 {@link decode} |
153
+ *
154
+ * 盐值那条是**实测结论**而非推测:拿真实浏览器(bdms 1.0.1.19-fix.01,2026-09-09
155
+ * 捕获)产出的签名反推,三条摘要链在 `dhzx` 下全部命中,在 `cus` 下 query 与 body
156
+ * 两条都对不上。旧盐值能让请求偶尔通过,是因为抖音**抽样校验**——签名过期不报错,
157
+ * 只表现为被判定为高风险的概率上升,所以这个 bug 活了两年没被发现。
158
+ *
159
+ * ## 为什么可注入是重点
160
+ *
161
+ * 旧的 `export default (url, user_agent) => string` 收不下 `now_ms`、`browser_info`
162
+ * 和随机源,于是**无法对着固定样本复现**——而没有复现能力,就没有任何测试能证伪
163
+ * 一个常量。这正是盐值能过期两年的结构性原因。所以本文件的入口是
164
+ * {@link ABogus} 类,`rng` / `nowMs` / `browserInfo` 全部可注入,默认值保持生产可用。
165
+ *
166
+ * ## 噪声不是均匀随机的
167
+ *
168
+ * `Math.random` 永远进不了校验和、也改不了任何可解码字段,所以同一毫秒内的两个
169
+ * 签名大部分字节不同、含义相同。但**这不等于噪声可以随便填**:其中有三个字节不是
170
+ * 噪声,是 SDK 对自己的环境回报(它认为自己在哪个浏览器家族里、自己的探针是否还
171
+ * 在),任何拿到签名的人都能读回去。均匀随机写进去,落在浏览器不可能产出的取值上
172
+ * 就是一个把柄——{@link HEADER_NOISE_BANDS} 与 {@link tripwireNoise} 就是为此存在。
173
+ */
174
+ /** bdms.js 携带的五张字母表中,本文件用得到的两张:`s4` 编码成品签名,`s3` 编码进入第三条摘要的 UA */
175
+ const ALPHABETS = {
176
+ s3: "ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe",
177
+ s4: "Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe"
178
+ };
179
+ /** 摘要前拼在 query 与 body 后面的盐值。实测值,不是猜的——见文件头 */
180
+ const SALT = "dhzx";
181
+ /** header 携带的两个明文字节,是这套格式最接近魔数的东西,也是 {@link structureError} 的第一道检查 */
182
+ const HEADER_MAGIC = [3, 82];
183
+ /** `"1.0.1.19-fix.01"` 被 SDK 解析后的版本块,实测解码结果 */
184
+ const SDK_VERSION = [
185
+ 1,
186
+ 0,
187
+ 1,
188
+ 0
189
+ ];
190
+ /** 单字节,且是全部的密钥。密文不是教科书 RC4,见 {@link rc4} */
191
+ const PAYLOAD_KEY = 211;
192
+ /** `2024-07-24T16:00:00Z`,有一个字段从此刻起数「两周」 */
193
+ const FORTNIGHT_EPOCH_MS = 17218368e5;
194
+ /** 抖音 Web 自己的标识,不是我们能选的:它同时出现在签名里和 query 里,两边不一致就是最廉价的把柄 */
195
+ const PAGE_ID = 6241;
196
+ const AID = 6383;
197
+ /** 噪声层用三字节藏一个字节,两张掩码互补:`0x91 | 0x42 | 0x2C === 0xFF`,这是整个噪声层可逆的原因 */
198
+ const NOISE_MASKS = [
199
+ 145,
200
+ 66,
201
+ 44
202
+ ];
203
+ const DATA_MASKS = [
204
+ 110,
205
+ 189,
206
+ 211
207
+ ];
208
+ /**
209
+ * 五十个标量字段在 body 里的排列顺序。
210
+ *
211
+ * 不是密码,只是一个固定置换;写成字面量而不是算出来,是因为字节码里就是这样,
212
+ * 而且照着真实捕获核对的人希望看到和解码器打印的同一个顺序。
213
+ */
214
+ const FIELD_ORDER = [
215
+ "L34",
216
+ "L44",
217
+ "L56",
218
+ "L61",
219
+ "L73",
220
+ "L29",
221
+ "L70",
222
+ "L45",
223
+ "L35",
224
+ "L49",
225
+ "L38",
226
+ "L66",
227
+ "L51",
228
+ "L68",
229
+ "L28",
230
+ "L48",
231
+ "L64",
232
+ "L47",
233
+ "L30",
234
+ "L71",
235
+ "L26",
236
+ "L55",
237
+ "L31",
238
+ "L69",
239
+ "L59",
240
+ "L40",
241
+ "L62",
242
+ "L63",
243
+ "L27",
244
+ "L72",
245
+ "L41",
246
+ "L74",
247
+ "L57",
248
+ "L52",
249
+ "L42",
250
+ "L39",
251
+ "L33",
252
+ "L67",
253
+ "L53",
254
+ "L43",
255
+ "L65",
256
+ "L46",
257
+ "L36",
258
+ "L24",
259
+ "L60",
260
+ "L32",
261
+ "L79",
262
+ "L80",
263
+ "L84",
264
+ "L85"
265
+ ];
266
+ /** 三条摘要链的哨兵三元组:`[哨兵, 偏移, 兜底]`。写哨兵本身是 SDK 报告自己环境探针触发的暗号,我们永远写诚实值 */
267
+ const CANARIES = [
268
+ [
269
+ 3,
270
+ 11,
271
+ 12
272
+ ],
273
+ [
274
+ 4,
275
+ 8,
276
+ 9
277
+ ],
278
+ [
279
+ 5,
280
+ 12,
281
+ 13
282
+ ]
283
+ ];
284
+ /**
285
+ * 三个被摘要的输入各自落在哪些标量上。
286
+ *
287
+ * 生成器和解码器**读同一张表**,所以解码器不可能与它所解的东西漂移。
288
+ *
289
+ * 每个输入三个字节就是全部的绑定,这个不对称正是重点:足够**证明**某个 query /
290
+ * body / UA 就是这份签名封住的那一个,又远不足以把哈希倒推回去。把它们报成
291
+ * 「query 本身」的解码器是在编造;拿候选值去校验的解码器才在说真话。
292
+ */
293
+ const DIGEST_CHAINS = {
294
+ query: {
295
+ slots: [
296
+ "L48",
297
+ "L49",
298
+ "L51"
299
+ ],
300
+ indices: [9, 18],
301
+ canary: CANARIES[0]
302
+ },
303
+ body: {
304
+ slots: [
305
+ "L52",
306
+ "L53",
307
+ "L55"
308
+ ],
309
+ indices: [10, 19],
310
+ canary: CANARIES[1]
311
+ },
312
+ user_agent: {
313
+ slots: [
314
+ "L56",
315
+ "L57",
316
+ "L59"
317
+ ],
318
+ indices: [11, 21],
319
+ canary: CANARIES[2]
320
+ }
321
+ };
322
+ /** 未被插桩的真实页面回报的环境值:六个探针加一个反机器人位集 */
323
+ const ENV_FLAGS = 1;
324
+ const DETECT_FLAGS = 14;
325
+ const NR_TAG = [
326
+ 0,
327
+ 0,
328
+ 0,
329
+ 0
330
+ ];
331
+ /** `window.onwheelx._Ax` 存在且被冻结——SDK 给自己装上再冻住的状态。12 表示被人解锁、11 表示缺失,两者都是抖音能打分的特征 */
332
+ const TRIPWIRE_LOCKED = 3;
333
+ /** `fn149` 给每页签名计数器分桶。6 表示「本页签名少于 140 次」,浏览器一生中大部分时间都在这里 */
334
+ const CALL_BUCKET = 6;
335
+ /** 1080p 上的 Chrome。指纹里没有屏幕尺寸时用它——**一个可信的常量好过随机值**,随请求变的几何本身就是特征 */
336
+ const DEFAULT_BROWSER_INFO = "1920|947|1920|1032|1920|1032|1920|1080|Win32";
337
+ /** 从屏幕高度猜可视区时要减掉的系统任务栏与浏览器自身 chrome */
338
+ const TASKBAR_PX = 48;
339
+ const CHROME_PX = 85;
340
+ /**
341
+ * SDK 认为自己身处哪个浏览器家族,按 `fn143` 的口径:每个家族一条 40 宽的带。
342
+ *
343
+ * 这些带**盖不满 0..255**。落在缝隙里的值没有浏览器能产出;落在错误家族带里的值与
344
+ * 旁边发出去的 User-Agent 自相矛盾——两者都是抖音免费可打的分。均匀随机有 6% 的
345
+ * 概率落进缝隙、78% 的概率落错家族。
346
+ */
347
+ const HEADER_NOISE_BANDS = {
348
+ chrome: 0,
349
+ firefox: 40,
350
+ safari: 81,
351
+ edge: 125,
352
+ huawei: 170,
353
+ other: 210
354
+ };
355
+ /** `fn145` 的健康位型:三个探针都在且被冻住。均匀随机有 94% 的概率报告出一个被剥离或解锁的页面 */
356
+ const TRIPWIRE_SET = 178;
357
+ const TRIPWIRE_FREE = 77;
358
+ const toByte = (value) => value & 255;
359
+ /**
360
+ * 按九个字段拼出签名会逐字携带的 `navigator`/`screen` 串。
361
+ *
362
+ * 顺序不可商量:这是载荷里唯一一路以可读文本活到校验和的部分。
363
+ * @param parts - 九个字段,顺序同 SDK 自己的对象字面量
364
+ * @returns 以 `|` 连接的几何串
365
+ */
366
+ const buildBrowserInfo = (parts) => [
367
+ parts.innerWidth,
368
+ parts.innerHeight,
369
+ parts.outerWidth,
370
+ parts.outerHeight,
371
+ parts.availWidth,
372
+ parts.availHeight,
373
+ parts.screenWidth,
374
+ parts.screenHeight,
375
+ parts.platform
376
+ ].join("|");
377
+ /**
378
+ * 由一个屏幕尺寸推出一组自洽的九字段几何串。
379
+ *
380
+ * 浏览器报的四个矩形并不独立:窗口不超过工作区,工作区是屏幕减系统栏,可视区是窗口
381
+ * 减浏览器自身。从一个数派生能让它们互相自洽,而随机拼出来的一组不会。
382
+ * @param width - 屏幕宽
383
+ * @param height - 屏幕高
384
+ * @param platform - `navigator.platform`
385
+ * @returns 九字段几何串
386
+ */
387
+ const browserInfoFromScreen = (width, height, platform) => {
388
+ const availHeight = Math.max(height - TASKBAR_PX, 1);
389
+ return buildBrowserInfo({
390
+ innerWidth: width,
391
+ innerHeight: Math.max(availHeight - CHROME_PX, 1),
392
+ outerWidth: width,
393
+ outerHeight: availHeight,
394
+ availWidth: width,
395
+ availHeight,
396
+ screenWidth: width,
397
+ screenHeight: height,
398
+ platform
399
+ });
400
+ };
401
+ /**
402
+ * SDK 的 RC4 —— 但它不是 RC4。
403
+ *
404
+ * 两处刻意偏离,都在初始化:S 盒以**降序**恒等排列起手,密钥编排在教科书版本只做加法的
405
+ * 地方做了乘法。密钥流生成部分没动。
406
+ *
407
+ * 这两处的意义比看起来大:合起来意味着拿标准 RC4 去暴力试每一个单字节密钥,对捕获到的
408
+ * 载荷一无所获。逆向方在读懂字节码之前正是卡在这里,而这多半就是它们存在的理由。
409
+ * @param key - 密钥字节
410
+ * @param data - 待处理数据
411
+ * @returns 与输入等长的结果
412
+ */
413
+ const rc4 = (key, data) => {
414
+ const box = new Array(256);
415
+ for (let i = 0; i < 256; i++) box[255 - i] = i;
416
+ let j = 0;
417
+ for (let i = 0; i < 256; i++) {
418
+ j = (j * box[i] + j + key[i % key.length]) % 256;
419
+ const swap = box[i];
420
+ box[i] = box[j];
421
+ box[j] = swap;
422
+ }
423
+ const out = new Array(data.length);
424
+ let i = 0;
425
+ j = 0;
426
+ for (let index = 0; index < data.length; index++) {
427
+ i = (i + 1) % 256;
428
+ j = (j + box[i]) % 256;
429
+ const swap = box[i];
430
+ box[i] = box[j];
431
+ box[j] = swap;
432
+ out[index] = data[index] ^ box[(box[i] + box[j]) % 256];
433
+ }
434
+ return out;
435
+ };
436
+ /**
437
+ * 按 SDK 的字母表做 base64,补位是字面量 `=`。
438
+ * @param data - 待编码字节
439
+ * @param alphabet - `s3` 或 `s4`
440
+ * @returns base64 文本
441
+ */
442
+ const encodeBase64 = (data, alphabet = "s4") => {
443
+ const table = ALPHABETS[alphabet];
444
+ const out = [];
445
+ for (let offset = 0; offset < data.length; offset += 3) {
446
+ const chunk = data.slice(offset, offset + 3);
447
+ let block = 0;
448
+ for (let i = 0; i < 3; i++) block = block << 8 | (i < chunk.length ? chunk[i] : 0);
449
+ const digits = [
450
+ block >>> 18 & 63,
451
+ block >>> 12 & 63,
452
+ block >>> 6 & 63,
453
+ block & 63
454
+ ];
455
+ for (const digit of digits.slice(0, chunk.length + 1)) out.push(table[digit]);
456
+ }
457
+ const padCount = (4 - out.length % 4) % 4;
458
+ for (let i = 0; i < padCount; i++) out.push("=");
459
+ return out.join("");
460
+ };
461
+ /**
462
+ * {@link encodeBase64} 的逆。遇到非本表字符时抛错。
463
+ * @param text - base64 文本
464
+ * @param alphabet - `s3` 或 `s4`
465
+ * @returns 解出的字节
466
+ */
467
+ const decodeBase64 = (text, alphabet = "s4") => {
468
+ const table = ALPHABETS[alphabet];
469
+ const index = /* @__PURE__ */ new Map();
470
+ for (let i = 0; i < table.length; i++) index.set(table[i], i);
471
+ const body = text.replace(/=+$/, "");
472
+ const out = [];
473
+ for (let offset = 0; offset < body.length; offset += 4) {
474
+ const chunk = body.slice(offset, offset + 4);
475
+ let block = 0;
476
+ for (const char of chunk) {
477
+ const digit = index.get(char);
478
+ if (digit === void 0) throw new Error(`不是 ${alphabet} 字母表中的字符:${JSON.stringify(char)}`);
479
+ block = block << 6 | digit;
480
+ }
481
+ block <<= 6 * (4 - chunk.length);
482
+ const shifts = [
483
+ 16,
484
+ 8,
485
+ 0
486
+ ].slice(0, chunk.length - 1);
487
+ for (const shift of shifts) out.push(block >>> shift & 255);
488
+ }
489
+ return out;
490
+ };
491
+ /**
492
+ * 一个 JS 字符串被 SDK 转成字节的方式——**不是 UTF-8**。
493
+ *
494
+ * `fn139` 走 UTF-16 码元,低于 U+0100 的码元出一个字节,高于的出两个大端字节,
495
+ * 于是代理对变四字节、一个汉字变两字节,而 UTF-8 会给三字节。对着 43 个真实签名测过:
496
+ * 这条规则复现出声明的几何长度 43 次,UTF-8 复现 41 次。
497
+ *
498
+ * 它只在 U+00FF 以上才分叉,所以这个差别一直隐身,直到某个 `navigator.platform`
499
+ * 里带了中文。而几何长度被校验和覆盖,写错会让整帧错位、签名整体失效,
500
+ * 不是某一个字段被弄脏。
501
+ * @param text - 待转换文本
502
+ * @returns 按 JS 码元语义切出的字节
503
+ */
504
+ const jsBytes = (text) => {
505
+ const out = [];
506
+ for (let i = 0; i < text.length; i++) {
507
+ const code = text.charCodeAt(i);
508
+ if (code & 65280) out.push(code >>> 8 & 255);
509
+ out.push(code & 255);
510
+ }
511
+ return out;
512
+ };
513
+ /** 取 `value` 的 `count` 个小端字节 */
514
+ const leBytes = (value, count) => {
515
+ const out = [];
516
+ for (let index = 0; index < count; index++) out.push(Math.floor(value / 2 ** (8 * index)) & 255);
517
+ return out;
518
+ };
519
+ /**
520
+ * 摘要里从 `offset` 起第一个不等于 `sentinel` 的字节。
521
+ *
522
+ * 哨兵是保留值:写它本身是 SDK 告诉服务端自己的环境探针触发了。我们永远不写,
523
+ * 所以这里只会报出摘要本身。
524
+ * @param digest - 该链的摘要
525
+ * @param offset - 起始下标
526
+ * @param sentinel - 保留值
527
+ * @param fallback - 全被保留时的兜底
528
+ * @returns 该槽位的字节
529
+ */
530
+ const canary = (digest, offset, sentinel, fallback) => {
531
+ for (let index = offset; index < digest.length; index++) if (digest[index] !== sentinel) return digest[index];
532
+ return fallback;
533
+ };
534
+ /**
535
+ * `SM3(SM3(text + salt))`,32 个整数。
536
+ *
537
+ * 盐值留成参数是为了**验证**:拿一份真实签名,把候选盐值逐个代进来,看哪一个能
538
+ * 重现签名里封住的那三个字节。旧实现用错盐值两年没被发现,就是因为没有这条路径。
539
+ * @param text - 待摘要文本
540
+ * @param salt - 拼接的盐值
541
+ * @returns 摘要
542
+ */
543
+ const digestWith = (text, salt) => sm3ToArray(sm3ToArray(text + salt));
544
+ /**
545
+ * `SM3(SM3(text + SALT))`,32 个整数。
546
+ * @param text - 待摘要文本
547
+ * @returns 摘要
548
+ */
549
+ const digestOf = (text) => digestWith(text, SALT);
550
+ /**
551
+ * 第三条链:把 UA 过一遍非标准 RC4、base64,再摘要**一次**。
552
+ *
553
+ * 与另外两条不同,这里是单次 SM3。RC4 的密钥是三字节,由环境探针拼出来,
554
+ * 所以对同一个身份是常量,不是每次调用都变。
555
+ *
556
+ * ## 密钥必须来自**这份签名自己**报的环境值
557
+ *
558
+ * `flags` 默认取模块常量(签名时就是这两个值),但**解码时必须传入签名里解出的
559
+ * `envFlags` / `detectFlags`** —— 它们随页面环境变(实测见过 14 和 4 两种),
560
+ * 拿常量去校验一份 `detect_flags` 为 4 的签名,这条链必然报「对不上」。
561
+ * 上游那份 Python 实现没有这个参数,这是移植时补的。
562
+ * @param userAgent - 原始 User-Agent
563
+ * @param flags - 环境探针值,默认取本模块的常量
564
+ * @returns 摘要
565
+ */
566
+ const userAgentDigest = (userAgent, flags = {}) => {
567
+ const envFlags = flags.envFlags ?? 1;
568
+ const detectFlags = flags.detectFlags ?? 14;
569
+ const key = [
570
+ Math.floor(envFlags / 256),
571
+ envFlags % 256,
572
+ detectFlags % 256
573
+ ];
574
+ const sealed = rc4(key, jsBytes(userAgent.trim()).map(toByte));
575
+ return sm3ToArray(encodeBase64(sealed, "s3"));
576
+ };
577
+ /**
578
+ * 一条链在给定摘要下贡献的三个字节。
579
+ * @param chain - 链定义
580
+ * @param digest - 该链自己的摘要
581
+ * @returns 三个字节
582
+ */
583
+ const chainBytes = (chain, digest) => [
584
+ digest[chain.indices[0]],
585
+ digest[chain.indices[1]],
586
+ canary(digest, chain.canary[0], chain.canary[1], chain.canary[2])
587
+ ];
588
+ /** 由 UA 判断 `fn143` 会从哪条带取值。顺序要紧:Edge 和华为的 UA 里都写着 Chrome,Chrome 的写着 Safari,SDK 先测具体的再测笼统的 */
589
+ const familyOf = (userAgent) => {
590
+ const ua = userAgent.toLowerCase();
591
+ for (const name of [
592
+ "edg",
593
+ "huawei",
594
+ "firefox",
595
+ "chrome",
596
+ "safari"
597
+ ]) if (ua.includes(name)) return name === "edg" ? "edge" : name;
598
+ return "other";
599
+ };
600
+ /** `fn143`:一个扮成噪声的浏览器家族回报 */
601
+ const headerNoise = (userAgent, rng) => HEADER_NOISE_BANDS[familyOf(userAgent)] + Math.floor(rng() * 40) & 255;
602
+ /**
603
+ * `fn144`:`nr()[4] & 64` 为空时走的支,实际就是走这支。
604
+ *
605
+ * 110 以下原样通过,以上 SDK 会把它逼成奇数,于是 110..240 里有一半取不到。
606
+ * 一个均匀字节有 31% 的概率落在取不到的集合里。
607
+ */
608
+ const probeNoise = (rng) => {
609
+ const value = Math.floor(rng() * 240);
610
+ return value > 109 ? value + value % 2 + 1 : value;
611
+ };
612
+ /** `fn145`:「我的探针都在且被冻住」,外加真噪声 */
613
+ const tripwireNoise = (rng) => Math.floor(rng() * 255) & TRIPWIRE_FREE | TRIPWIRE_SET;
614
+ /**
615
+ * 把两个字节摊到四个字节上,每字节的另外一半填噪声。
616
+ *
617
+ * 每个输出字节从载荷取一半位、从噪声字节取一半,掩码交替为 `0xAA`/`0x55`。还原只需要
618
+ * 载荷那一半——但噪声那一半同样可还原,而 SDK 在其中两处拿它说了关于自己的事。
619
+ * `low` / `high` 就是调用方提供这些回报的途径;不给时就是 SDK 无事可报时的普通抽取。
620
+ */
621
+ const maskPair = (pair, rng, overrides = {}) => {
622
+ const noise = Math.floor(rng() * 65535);
623
+ const low = toByte(overrides.low === void 0 ? noise & 255 : overrides.low);
624
+ const high = toByte(overrides.high === void 0 ? noise >>> 8 & 255 : overrides.high);
625
+ return [
626
+ low & 170 | pair[0] & 85,
627
+ low & 85 | pair[0] & 170,
628
+ high & 170 | pair[1] & 85,
629
+ high & 85 | pair[1] & 170
630
+ ];
631
+ };
632
+ /** {@link maskPair} 的逆:把每个字节的载荷半边取回来 */
633
+ const unmaskPair = (carrier) => [carrier[0] & 85 | carrier[1] & 170, carrier[2] & 85 | carrier[3] & 170];
634
+ /**
635
+ * 三个 body 字节加一个噪声字节变成四个载体字节。
636
+ *
637
+ * 每个载体留一部分自己的字节、其余从噪声取;第四个字节恰好收拢前三个让出去的位,
638
+ * 且落在它们原来的位位置上。字节内部没有任何位移,这既是这一层可逆的原因,
639
+ * 也是它看起来仍像密文的原因。
640
+ */
641
+ const expandNoise = (body, rng) => {
642
+ const out = [];
643
+ for (let offset = 0; offset < body.length; offset += 3) {
644
+ const group = body.slice(offset, offset + 3);
645
+ if (group.length < 3) {
646
+ out.push(group[0]);
647
+ if (group.length > 1 && group[1]) out.push(group[1]);
648
+ continue;
649
+ }
650
+ const noise = Math.floor(rng() * 1e3) & 255;
651
+ for (let index = 0; index < 3; index++) out.push(noise & NOISE_MASKS[index] | group[index] & DATA_MASKS[index]);
652
+ out.push(group[0] & NOISE_MASKS[0] | group[1] & NOISE_MASKS[1] | group[2] & NOISE_MASKS[2]);
653
+ }
654
+ return out;
655
+ };
656
+ /** {@link expandNoise} 的逆 */
657
+ const collapseNoise = (frame) => {
658
+ const out = [];
659
+ for (let offset = 0; offset < frame.length; offset += 4) {
660
+ const group = frame.slice(offset, offset + 4);
661
+ if (group.length < 4) {
662
+ out.push(...group);
663
+ continue;
664
+ }
665
+ for (let index = 0; index < 3; index++) out.push(group[index] & DATA_MASKS[index] | group[3] & NOISE_MASKS[index]);
666
+ }
667
+ return out;
668
+ };
669
+ /**
670
+ * 为一个浏览器身份计算 `a_bogus`。
671
+ *
672
+ * 除配置外无状态、也很便宜,所以「一个身份一个实例」是自然的生命周期。
673
+ * `rng` 存在的意义是让测试能钉住噪声——它产出什么,签名都是对的。
674
+ */
675
+ var ABogus = class {
676
+ userAgent;
677
+ browserInfo;
678
+ pageId;
679
+ aid;
680
+ envFlags;
681
+ detectFlags;
682
+ rng;
683
+ constructor(userAgent, options = {}) {
684
+ this.userAgent = userAgent;
685
+ this.browserInfo = options.browserInfo ?? "1920|947|1920|1032|1920|1032|1920|1080|Win32";
686
+ this.pageId = options.pageId ?? 6241;
687
+ this.aid = options.aid ?? 6383;
688
+ this.envFlags = options.envFlags ?? 1;
689
+ this.detectFlags = options.detectFlags ?? 14;
690
+ this.rng = options.rng ?? Math.random;
691
+ }
692
+ /** 五十个标量,按离线解码器打印的名字 */
693
+ fields(query, body, nowMs) {
694
+ const digests = {
695
+ query: digestOf(query),
696
+ body: digestOf(body),
697
+ user_agent: userAgentDigest(this.userAgent, {
698
+ envFlags: this.envFlags,
699
+ detectFlags: this.detectFlags
700
+ })
701
+ };
702
+ const ink = nowMs - 1;
703
+ const fortnights = Math.floor((nowMs - FORTNIGHT_EPOCH_MS) / 12096e5);
704
+ const infoBytes = jsBytes(this.browserInfo);
705
+ const tailBytes = jsBytes(`${nowMs + 3 & 255},`);
706
+ const fields = {
707
+ L24: 41,
708
+ L26: fortnights,
709
+ L27: 6,
710
+ L28: 3,
711
+ L35: this.envFlags & 255,
712
+ L36: this.envFlags >>> 8 & 255,
713
+ L38: 33,
714
+ L39: 0,
715
+ L66: 3,
716
+ L79: infoBytes.length & 255,
717
+ L80: infoBytes.length >>> 8 & 255,
718
+ L84: tailBytes.length & 255,
719
+ L85: tailBytes.length >>> 8 & 255
720
+ };
721
+ leBytes(nowMs, 6).forEach((byte, index) => fields[`L${29 + index}`] = byte);
722
+ leBytes(this.detectFlags, 4).forEach((byte, index) => fields[`L${44 + index}`] = byte);
723
+ NR_TAG.forEach((byte, index) => fields[`L${40 + index}`] = byte);
724
+ leBytes(ink, 6).forEach((byte, index) => fields[`L${60 + index}`] = byte);
725
+ leBytes(this.pageId, 4).forEach((byte, index) => fields[`L${67 + index}`] = byte);
726
+ leBytes(this.aid, 4).forEach((byte, index) => fields[`L${71 + index}`] = byte);
727
+ for (const [name, chain] of Object.entries(DIGEST_CHAINS)) {
728
+ const written = chainBytes(chain, digests[name]);
729
+ chain.slots.forEach((slot, index) => fields[slot] = written[index]);
730
+ }
731
+ return fields;
732
+ }
733
+ /**
734
+ * 为一次请求算出 `a_bogus`。
735
+ * @param query - 即将发出的 query 串,不含 `a_bogus` 自身
736
+ * @param options - body / Content-Type / 时钟
737
+ * @returns `a_bogus` 的值
738
+ */
739
+ getValue(query, options = {}) {
740
+ const body = (options.contentType ?? "").toLowerCase().includes("multipart/form-data") ? "" : options.body ?? "";
741
+ const now = options.nowMs ?? Date.now();
742
+ if (now < 17218368e5) throw new Error(`时钟早于 a_bogus 纪元:${now}`);
743
+ const fields = this.fields(query, body, now);
744
+ const version = [...maskPair(SDK_VERSION.slice(0, 2), this.rng), ...maskPair(SDK_VERSION.slice(2, 4), this.rng, {
745
+ low: probeNoise(this.rng),
746
+ high: tripwireNoise(this.rng)
747
+ })];
748
+ let checksum = 0;
749
+ for (const byte of version) checksum ^= byte;
750
+ for (const name of FIELD_ORDER) checksum ^= fields[name];
751
+ const bodyBytes = FIELD_ORDER.map((name) => fields[name]);
752
+ bodyBytes.push(...jsBytes(this.browserInfo));
753
+ bodyBytes.push(...jsBytes(`${now + 3 & 255},`));
754
+ bodyBytes.push(checksum);
755
+ const header = maskPair(HEADER_MAGIC, this.rng, { high: headerNoise(this.userAgent, this.rng) });
756
+ const frame = expandNoise(bodyBytes, this.rng);
757
+ const sealed = rc4([211], [...version, ...frame]);
758
+ return encodeBase64([...header, ...sealed], "s4");
759
+ }
760
+ };
761
+ /** 这些毛病意味着这串东西**根本不是** a_bogus,而不是「内容出乎意料」 */
762
+ const DECODE_PROBLEMS = /* @__PURE__ */ new Set([
763
+ "alphabet",
764
+ "length not a multiple of four",
765
+ "payload too short"
766
+ ]);
767
+ /**
768
+ * `problem` 是否表示「不是 a_bogus」而非「内容不符预期」。
769
+ * @param problem - {@link structureError} 的返回
770
+ * @returns 是否属于格式性失败
771
+ */
772
+ const isDecodeProblem = (problem) => DECODE_PROBLEMS.has(problem ?? "");
773
+ /**
774
+ * `value` 第一处不满足 a_bogus 格式的地方,全都满足时返回 `null`。
775
+ *
776
+ * 这里每一条检查都由算法本身固定,所以两个都正确的实现无论噪声多不同,都会在这些
777
+ * 检查上一致。**故意不检查长度**:长度跟着几何串走,两个都对但屏幕不同的浏览器就是
778
+ * 会不一样。
779
+ *
780
+ * 最后一条检查才是值得拥有的那条。校验和是对载荷里本身就有的一些字段算的,所以能通过
781
+ * 它的签名,必然是由一个在整套布局上与我们一致的实现装配出来的——版本块、五十槽置换、
782
+ * 噪声展开、以及那个密码。不是真算法,凑不出一个。
783
+ * @param value - 待检查的签名
784
+ * @param alphabet - 字母表,默认 `s4`
785
+ * @returns 问题标识或 `null`
786
+ */
787
+ const structureError = (value, alphabet = "s4") => {
788
+ const body = value.replace(/=+$/, "");
789
+ const table = ALPHABETS[alphabet];
790
+ if (!body || body.includes("=") || [...body].some((char) => !table.includes(char))) return "alphabet";
791
+ if (value.length % 4) return "length not a multiple of four";
792
+ const payload = decodeBase64(value, alphabet);
793
+ if (payload.length < 80) return "payload too short";
794
+ if (unmaskPair(payload.slice(0, 4)).join(",") !== HEADER_MAGIC.join(",")) return "header magic";
795
+ const plain = rc4([211], payload.slice(4));
796
+ const version = plain.slice(0, 8);
797
+ const frame = plain.slice(8);
798
+ if (unmaskPair(version.slice(0, 4)).join(",") !== SDK_VERSION.slice(0, 2).join(",")) return "sdk version";
799
+ if (unmaskPair(version.slice(4, 8)).join(",") !== SDK_VERSION.slice(2, 4).join(",")) return "sdk version";
800
+ const unpacked = collapseNoise(frame);
801
+ if (unpacked.length < FIELD_ORDER.length + 1) return "frame too short";
802
+ const fields = {};
803
+ FIELD_ORDER.forEach((name, index) => fields[name] = unpacked[index]);
804
+ const infoLength = fields.L79 | fields.L80 << 8;
805
+ const tailLength = fields.L84 | fields.L85 << 8;
806
+ const end = FIELD_ORDER.length + infoLength + tailLength;
807
+ if (end + 1 > unpacked.length) return "declared lengths overrun the frame";
808
+ if (unpacked.length - (end + 1) > 2) return "declared lengths leave a tail";
809
+ let checksum = 0;
810
+ for (const byte of version) checksum ^= byte;
811
+ for (const name of FIELD_ORDER) checksum ^= fields[name];
812
+ if (unpacked[end] !== checksum) return "checksum";
813
+ return null;
814
+ };
815
+ /**
816
+ * 把一份 `a_bogus` 拆开。用于诊断与测试,不用于签名。
817
+ *
818
+ * 这是证明算法正确的那个函数:拿浏览器产出的签名跑一遍,它会还原出那个浏览器的屏幕
819
+ * 尺寸、抖音自己的 `aid`、以及与旁边那个 `timestamp` 参数吻合的时钟。
820
+ * @param value - 待拆解的签名
821
+ * @param alphabet - 字母表,默认 `s4`
822
+ * @returns 可还原的字段
823
+ */
824
+ const decode = (value, alphabet = "s4") => {
825
+ const problem = structureError(value, alphabet);
826
+ if (problem !== null) throw new Error(`不是一份格式良好的 a_bogus:${problem}`);
827
+ const payload = decodeBase64(value, alphabet);
828
+ const plain = rc4([211], payload.slice(4));
829
+ const unpacked = collapseNoise(plain.slice(8));
830
+ const fields = {};
831
+ FIELD_ORDER.forEach((name, index) => fields[name] = unpacked[index]);
832
+ const littleEndian = (first, count) => {
833
+ let total = 0;
834
+ for (let index = 0; index < count; index++) total += fields[`L${first + index}`] * 2 ** (8 * index);
835
+ return total;
836
+ };
837
+ const infoLength = fields.L79 | fields.L80 << 8;
838
+ const tailLength = fields.L84 | fields.L85 << 8;
839
+ const start = FIELD_ORDER.length;
840
+ const asText = (bytes) => new TextDecoder("utf-8", { fatal: false }).decode(Uint8Array.from(bytes));
841
+ return {
842
+ headerMagic: unmaskPair(payload.slice(0, 4)),
843
+ sdkVersion: [...unmaskPair(plain.slice(0, 4)), ...unmaskPair(plain.slice(4, 8))],
844
+ nowMs: littleEndian(29, 6),
845
+ inkMs: littleEndian(60, 6),
846
+ pageId: littleEndian(67, 4),
847
+ aid: littleEndian(71, 4),
848
+ envFlags: fields.L35,
849
+ detectFlags: littleEndian(44, 4),
850
+ callBucket: fields.L27,
851
+ tripwire: fields.L66,
852
+ browserInfo: asText(unpacked.slice(start, start + infoLength)),
853
+ tail: asText(unpacked.slice(start + infoLength, start + infoLength + tailLength)),
854
+ fields
855
+ };
856
+ };
857
+ /**
858
+ * 兼容入口:给一条完整 URL 和一个 UA,返回 `a_bogus` 的值。
859
+ *
860
+ * 与旧实现的签名一致,所以 `sign/index.ts` 与 legacy 那个类不用改调用方式。
861
+ * 需要控制时钟、几何串或随机源时用 {@link ABogus}。
862
+ * @param url - 待签名的完整 URL
863
+ * @param userAgent - 发起请求用的 User-Agent
864
+ * @param options - 透传给 {@link ABogus} 与 {@link ABogus.getValue} 的选项
865
+ * @returns `a_bogus` 的值
866
+ */
867
+ var a_bogus_default = (url, userAgent, options = {}) => new ABogus(userAgent, options).getValue(new URLSearchParams(new URL(url).search).toString(), options);
868
+ //#endregion
869
+ //#region src/platforms/douyin/sign/tokens.ts
870
+ /**
871
+ * 假 msToken 的字符表,逐字符照抄 V4 的 `gen_random_str`。
872
+ *
873
+ * A-Z + a-z + 0-9 + `+-` 正好 64 个,拼出来就是标准 base64 的形状 ——
874
+ * 假的 msToken 唯一要像真的地方就是形状。抄错表就毁在这上面:
875
+ * 一个永远不含 `j`、中间还夹着 `=` 的 token,形状不对。
876
+ */
877
+ const MS_TOKEN_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-";
878
+ /** 抖音假 msToken 的长度(`==` 后缀之前那一段) */
879
+ const DOUYIN_MS_TOKEN_LENGTH = 126;
880
+ /** 真 msToken 回包的长度,不在其中说明接口变了形,值不该信 */
881
+ const DOUYIN_MS_TOKEN_SIZES = [120, 128];
882
+ /**
883
+ * 本地生成的假 msToken。
884
+ *
885
+ * 平台对一部分端点认它、另一部分不认。原实现把它当作真接口失败时的兜底,
886
+ * 调用方应当知道拿到的是哪一个(原实现只返回 token,降级和健康长得一模一样)。
887
+ *
888
+ * @param length - `==` 之前的长度,默认 {@link DOUYIN_MS_TOKEN_LENGTH}
889
+ * @param options - `rng` / `suffix` 注入点
890
+ * @returns `length` 个字符表内字符 + `suffix`
891
+ */
892
+ const genFalseMsToken = (length = 126, options = {}) => {
893
+ const { rng = Math.random, suffix = "==" } = options;
894
+ let token = "";
895
+ for (let index = 0; index < length; index++) token += MS_TOKEN_ALPHABET[Math.trunc(rng() * 64)];
896
+ return token + suffix;
897
+ };
898
+ /** `verify_fp` / `s_v_web_id` 随机半段用的字符表(62 个:数字 + 大小写字母) */
899
+ const VERIFY_FP_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
900
+ /** 36 位尾巴里下划线所在的下标,构成 UUID 的 8-4-4-4-12 分段 */
901
+ const VERIFY_FP_SEPARATORS = [
902
+ 8,
903
+ 13,
904
+ 18,
905
+ 23
906
+ ];
907
+ /** 尾巴总长度 */
908
+ const VERIFY_FP_LENGTH = 36;
909
+ /**
910
+ * 36 进制转换(照抄 Python 的 `_to_base36`,不用 `Number#toString(36)`)。
911
+ *
912
+ * 两者对非负整数结果一致,但 Python 那版对 `<= 0` 返回 `"0"` 而不是 `"-..."` ——
913
+ * 自实现是把这条边界一起搬过来,省得以后有人换个入参就踩到差异。
914
+ *
915
+ * @param value - 非负整数
916
+ * @returns 36 进制小写字符串
917
+ */
918
+ const toBase36 = (value) => {
919
+ if (value <= 0) return "0";
920
+ let rest = Math.trunc(value);
921
+ let digits = "";
922
+ while (rest > 0) {
923
+ const remainder = rest % 36;
924
+ digits = (remainder < 10 ? String(remainder) : String.fromCharCode(97 + remainder - 10)) + digits;
925
+ rest = Math.trunc(rest / 36);
926
+ }
927
+ return digits;
928
+ };
929
+ /**
930
+ * 生成一个 `verify_fp`。
931
+ *
932
+ * 形状:`verify_<毫秒时间戳的 36 进制>_<36 位 UUID v4 骨架>` —— 尾巴的下划线在
933
+ * 8/13/18/23,下标 14 恒为 `4`,下标 19 是变体位。
934
+ *
935
+ * 随机数只在**非固定位**上消耗:四个下划线和版本位那 5 个下标跳过不抽签,
936
+ * 所以一条尾巴正好抽 31 次,抽签顺序按下标升序 —— 与 Python 逐次对齐,
937
+ * 注入同一串均匀数才能得到同一串字符。
938
+ *
939
+ * @param options - `nowMs` / `rng` 注入点
940
+ * @returns 可直接当作 `verifyFp` / `s_v_web_id` cookie 的值
941
+ */
942
+ const genVerifyFp = (options = {}) => {
943
+ const { nowMs = Date.now(), rng = Math.random } = options;
944
+ const alphabetSize = 62;
945
+ const tail = new Array(36).fill("");
946
+ for (const index of VERIFY_FP_SEPARATORS) tail[index] = "_";
947
+ tail[14] = "4";
948
+ for (let index = 0; index < 36; index++) {
949
+ if (tail[index]) continue;
950
+ const position = Math.trunc(rng() * alphabetSize);
951
+ tail[index] = VERIFY_FP_ALPHABET[index === 19 ? 3 & position | 8 : position];
952
+ }
953
+ return `verify_${toBase36(nowMs)}_${tail.join("")}`;
954
+ };
955
+ /**
956
+ * `s_v_web_id` 与 `verify_fp` 同形状、同算法,只是 cookie 名不同。
957
+ *
958
+ * @param options - `nowMs` / `rng` 注入点
959
+ * @returns 与 {@link genVerifyFp} 同构的值
960
+ */
961
+ const genSVWebId = (options = {}) => genVerifyFp(options);
962
+ /**
963
+ * 拼上报接口的 JSON 请求体。
964
+ *
965
+ * 字段顺序照 Python 的 dict:magic → version → dataType → strData → tspFromClient。
966
+ * 与 Python 唯一的差别是空白:`json.dumps` 默认带 `", "` / `": "` 分隔符,
967
+ * `JSON.stringify` 是紧凑的;服务端按 JSON 解析,不看空白。
968
+ *
969
+ * @param spec - 上报接口的静态参数
970
+ * @param timestampMs - 客户端时间戳(`tspFromClient`),默认 `Date.now()`
971
+ * @returns 请求体字符串
972
+ */
973
+ const msTokenPayload = (spec, timestampMs = Date.now()) => JSON.stringify({
974
+ magic: spec.magic,
975
+ version: spec.version,
976
+ dataType: spec.dataType,
977
+ strData: spec.strData,
978
+ tspFromClient: timestampMs
979
+ });
980
+ /** ttwid 注册接口(两个平台同域,只有 body 不同) */
981
+ const TTWID_REGISTER_URL = "https://ttwid.bytedance.com/ttwid/union/register/";
982
+ /** 抖音访客 ttwid 的注册体,不含任何凭证 */
983
+ const DOUYIN_TTWID_PAYLOAD = {
984
+ region: "cn",
985
+ aid: 1768,
986
+ needFid: false,
987
+ service: "www.ixigua.com",
988
+ migrate_info: {
989
+ ticket: "",
990
+ source: "node"
991
+ },
992
+ cbUrlProtocol: "https",
993
+ union: true
994
+ };
995
+ /** 抖音访客 ttwid 注册:`POST {url}`,body 用 `data` */
996
+ const DOUYIN_TTWID = {
997
+ url: TTWID_REGISTER_URL,
998
+ data: JSON.stringify(DOUYIN_TTWID_PAYLOAD)
999
+ };
1000
+ //#endregion
1001
+ export { a_bogus_default as A, isDecodeProblem as B, FORTNIGHT_EPOCH_MS as C, SALT as D, PAYLOAD_KEY as E, decode as F, userAgentDigest as G, rc4 as H, decodeBase64 as I, sm3Hexdigest as J, SM3_IV as K, digestOf as L, buildBrowserInfo as M, canary as N, SDK_VERSION as O, chainBytes as P, digestWith as R, FIELD_ORDER as S, PAGE_ID as T, structureError as U, jsBytes as V, unmaskPair as W, sm3ToArray as Y, CALL_BUCKET as _, MS_TOKEN_ALPHABET as a, DIGEST_CHAINS as b, VERIFY_FP_LENGTH as c, genVerifyFp as d, msTokenPayload as f, ALPHABETS as g, AID as h, DOUYIN_TTWID_PAYLOAD as i, browserInfoFromScreen as j, TRIPWIRE_LOCKED as k, genFalseMsToken as l, ABogus as m, DOUYIN_MS_TOKEN_SIZES as n, TTWID_REGISTER_URL as o, toBase36 as p, sm3Hash as q, DOUYIN_TTWID as r, VERIFY_FP_ALPHABET as s, DOUYIN_MS_TOKEN_LENGTH as t, genSVWebId as u, DEFAULT_BROWSER_INFO as v, HEADER_MAGIC as w, ENV_FLAGS as x, DETECT_FLAGS as y, encodeBase64 as z };