@ikenxuan/amagi 6.6.0 → 7.0.0-beta.1

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,698 @@
1
+ //#region src/platforms/douyin/sign/a_bogus.d.ts
2
+ /**
3
+ * a_bogus —— 抖音 Web 加在每个数据接口上的签名。
4
+ *
5
+ * ## 出处
6
+ *
7
+ * 移植自 Douyin_TikTok_Download_API 的 `signing/native/abogus.py`(Apache-2.0,
8
+ * 与本仓库 GPL-3.0 兼容)。那份是**自己逆向**抖音的 `bdms.js` v1.0.1.19-fix.01
9
+ * 得到的(2026-09-09),不是抄来的片段。
10
+ *
11
+ * 本文件**替换**了原先那份 2024-08 从公开片段原样复制的实现。两份的差别不是
12
+ * 代码风格,是常量:
13
+ *
14
+ * | | 旧实现 | 本实现 |
15
+ * |---|---|---|
16
+ * | 摘要盐值 | `cus` | `dhzx` |
17
+ * | 几何串 | 17 段、写死 `1536\|747\|…` | 9 段、可传入 |
18
+ * | 时钟 / 随机源 | 直接读 `Date.now()` / `Math.random()` | 可注入 |
19
+ * | 能否解码自证 | 否 | 是,见 {@link decode} |
20
+ *
21
+ * 盐值那条是**实测结论**而非推测:拿真实浏览器(bdms 1.0.1.19-fix.01,2026-09-09
22
+ * 捕获)产出的签名反推,三条摘要链在 `dhzx` 下全部命中,在 `cus` 下 query 与 body
23
+ * 两条都对不上。旧盐值能让请求偶尔通过,是因为抖音**抽样校验**——签名过期不报错,
24
+ * 只表现为被判定为高风险的概率上升,所以这个 bug 活了两年没被发现。
25
+ *
26
+ * ## 为什么可注入是重点
27
+ *
28
+ * 旧的 `export default (url, user_agent) => string` 收不下 `now_ms`、`browser_info`
29
+ * 和随机源,于是**无法对着固定样本复现**——而没有复现能力,就没有任何测试能证伪
30
+ * 一个常量。这正是盐值能过期两年的结构性原因。所以本文件的入口是
31
+ * {@link ABogus} 类,`rng` / `nowMs` / `browserInfo` 全部可注入,默认值保持生产可用。
32
+ *
33
+ * ## 噪声不是均匀随机的
34
+ *
35
+ * `Math.random` 永远进不了校验和、也改不了任何可解码字段,所以同一毫秒内的两个
36
+ * 签名大部分字节不同、含义相同。但**这不等于噪声可以随便填**:其中有三个字节不是
37
+ * 噪声,是 SDK 对自己的环境回报(它认为自己在哪个浏览器家族里、自己的探针是否还
38
+ * 在),任何拿到签名的人都能读回去。均匀随机写进去,落在浏览器不可能产出的取值上
39
+ * 就是一个把柄——{@link HEADER_NOISE_BANDS} 与 {@link tripwireNoise} 就是为此存在。
40
+ */
41
+ /** bdms.js 携带的五张字母表中,本文件用得到的两张:`s4` 编码成品签名,`s3` 编码进入第三条摘要的 UA */
42
+ declare const ALPHABETS: {
43
+ readonly s3: 'ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe';
44
+ readonly s4: 'Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe';
45
+ };
46
+ /** 摘要前拼在 query 与 body 后面的盐值。实测值,不是猜的——见文件头 */
47
+ declare const SALT = "dhzx";
48
+ /** header 携带的两个明文字节,是这套格式最接近魔数的东西,也是 {@link structureError} 的第一道检查 */
49
+ declare const HEADER_MAGIC: readonly number[];
50
+ /** `"1.0.1.19-fix.01"` 被 SDK 解析后的版本块,实测解码结果 */
51
+ declare const SDK_VERSION: readonly number[];
52
+ /** 单字节,且是全部的密钥。密文不是教科书 RC4,见 {@link rc4} */
53
+ declare const PAYLOAD_KEY = 211;
54
+ /** `2024-07-24T16:00:00Z`,有一个字段从此刻起数「两周」 */
55
+ declare const FORTNIGHT_EPOCH_MS = 1721836800000;
56
+ /** 抖音 Web 自己的标识,不是我们能选的:它同时出现在签名里和 query 里,两边不一致就是最廉价的把柄 */
57
+ declare const PAGE_ID = 6241;
58
+ declare const AID = 6383;
59
+ /**
60
+ * 五十个标量字段在 body 里的排列顺序。
61
+ *
62
+ * 不是密码,只是一个固定置换;写成字面量而不是算出来,是因为字节码里就是这样,
63
+ * 而且照着真实捕获核对的人希望看到和解码器打印的同一个顺序。
64
+ */
65
+ declare const FIELD_ORDER: readonly string[];
66
+ /** 一条摘要链:三个槽位名、两个直接写入的摘要下标、一个金丝雀三元组 */
67
+ interface DigestChain {
68
+ readonly slots: readonly [string, string, string];
69
+ readonly indices: readonly [number, number];
70
+ readonly canary: readonly [number, number, number];
71
+ }
72
+ /**
73
+ * 三个被摘要的输入各自落在哪些标量上。
74
+ *
75
+ * 生成器和解码器**读同一张表**,所以解码器不可能与它所解的东西漂移。
76
+ *
77
+ * 每个输入三个字节就是全部的绑定,这个不对称正是重点:足够**证明**某个 query /
78
+ * body / UA 就是这份签名封住的那一个,又远不足以把哈希倒推回去。把它们报成
79
+ * 「query 本身」的解码器是在编造;拿候选值去校验的解码器才在说真话。
80
+ */
81
+ declare const DIGEST_CHAINS: Readonly<Record<'query' | 'body' | 'user_agent', DigestChain>>;
82
+ /** 未被插桩的真实页面回报的环境值:六个探针加一个反机器人位集 */
83
+ declare const ENV_FLAGS = 1;
84
+ declare const DETECT_FLAGS = 14;
85
+ /** `window.onwheelx._Ax` 存在且被冻结——SDK 给自己装上再冻住的状态。12 表示被人解锁、11 表示缺失,两者都是抖音能打分的特征 */
86
+ declare const TRIPWIRE_LOCKED = 3;
87
+ /** `fn149` 给每页签名计数器分桶。6 表示「本页签名少于 140 次」,浏览器一生中大部分时间都在这里 */
88
+ declare const CALL_BUCKET = 6;
89
+ /** 1080p 上的 Chrome。指纹里没有屏幕尺寸时用它——**一个可信的常量好过随机值**,随请求变的几何本身就是特征 */
90
+ declare const DEFAULT_BROWSER_INFO = "1920|947|1920|1032|1920|1032|1920|1080|Win32";
91
+ /**
92
+ * 按九个字段拼出签名会逐字携带的 `navigator`/`screen` 串。
93
+ *
94
+ * 顺序不可商量:这是载荷里唯一一路以可读文本活到校验和的部分。
95
+ * @param parts - 九个字段,顺序同 SDK 自己的对象字面量
96
+ * @returns 以 `|` 连接的几何串
97
+ */
98
+ declare const buildBrowserInfo: (parts: {
99
+ innerWidth: number;
100
+ innerHeight: number;
101
+ outerWidth: number;
102
+ outerHeight: number;
103
+ availWidth: number;
104
+ availHeight: number;
105
+ screenWidth: number;
106
+ screenHeight: number;
107
+ platform: string;
108
+ }) => string;
109
+ /**
110
+ * 由一个屏幕尺寸推出一组自洽的九字段几何串。
111
+ *
112
+ * 浏览器报的四个矩形并不独立:窗口不超过工作区,工作区是屏幕减系统栏,可视区是窗口
113
+ * 减浏览器自身。从一个数派生能让它们互相自洽,而随机拼出来的一组不会。
114
+ * @param width - 屏幕宽
115
+ * @param height - 屏幕高
116
+ * @param platform - `navigator.platform`
117
+ * @returns 九字段几何串
118
+ */
119
+ declare const browserInfoFromScreen: (width: number, height: number, platform: string) => string;
120
+ /**
121
+ * SDK 的 RC4 —— 但它不是 RC4。
122
+ *
123
+ * 两处刻意偏离,都在初始化:S 盒以**降序**恒等排列起手,密钥编排在教科书版本只做加法的
124
+ * 地方做了乘法。密钥流生成部分没动。
125
+ *
126
+ * 这两处的意义比看起来大:合起来意味着拿标准 RC4 去暴力试每一个单字节密钥,对捕获到的
127
+ * 载荷一无所获。逆向方在读懂字节码之前正是卡在这里,而这多半就是它们存在的理由。
128
+ * @param key - 密钥字节
129
+ * @param data - 待处理数据
130
+ * @returns 与输入等长的结果
131
+ */
132
+ declare const rc4: (key: readonly number[], data: readonly number[]) => number[];
133
+ /**
134
+ * 按 SDK 的字母表做 base64,补位是字面量 `=`。
135
+ * @param data - 待编码字节
136
+ * @param alphabet - `s3` 或 `s4`
137
+ * @returns base64 文本
138
+ */
139
+ declare const encodeBase64: (data: readonly number[], alphabet?: keyof typeof ALPHABETS) => string;
140
+ /**
141
+ * {@link encodeBase64} 的逆。遇到非本表字符时抛错。
142
+ * @param text - base64 文本
143
+ * @param alphabet - `s3` 或 `s4`
144
+ * @returns 解出的字节
145
+ */
146
+ declare const decodeBase64: (text: string, alphabet?: keyof typeof ALPHABETS) => number[];
147
+ /**
148
+ * 一个 JS 字符串被 SDK 转成字节的方式——**不是 UTF-8**。
149
+ *
150
+ * `fn139` 走 UTF-16 码元,低于 U+0100 的码元出一个字节,高于的出两个大端字节,
151
+ * 于是代理对变四字节、一个汉字变两字节,而 UTF-8 会给三字节。对着 43 个真实签名测过:
152
+ * 这条规则复现出声明的几何长度 43 次,UTF-8 复现 41 次。
153
+ *
154
+ * 它只在 U+00FF 以上才分叉,所以这个差别一直隐身,直到某个 `navigator.platform`
155
+ * 里带了中文。而几何长度被校验和覆盖,写错会让整帧错位、签名整体失效,
156
+ * 不是某一个字段被弄脏。
157
+ * @param text - 待转换文本
158
+ * @returns 按 JS 码元语义切出的字节
159
+ */
160
+ declare const jsBytes: (text: string) => number[];
161
+ /**
162
+ * 摘要里从 `offset` 起第一个不等于 `sentinel` 的字节。
163
+ *
164
+ * 哨兵是保留值:写它本身是 SDK 告诉服务端自己的环境探针触发了。我们永远不写,
165
+ * 所以这里只会报出摘要本身。
166
+ * @param digest - 该链的摘要
167
+ * @param offset - 起始下标
168
+ * @param sentinel - 保留值
169
+ * @param fallback - 全被保留时的兜底
170
+ * @returns 该槽位的字节
171
+ */
172
+ declare const canary: (digest: readonly number[], offset: number, sentinel: number, fallback: number) => number;
173
+ /**
174
+ * `SM3(SM3(text + salt))`,32 个整数。
175
+ *
176
+ * 盐值留成参数是为了**验证**:拿一份真实签名,把候选盐值逐个代进来,看哪一个能
177
+ * 重现签名里封住的那三个字节。旧实现用错盐值两年没被发现,就是因为没有这条路径。
178
+ * @param text - 待摘要文本
179
+ * @param salt - 拼接的盐值
180
+ * @returns 摘要
181
+ */
182
+ declare const digestWith: (text: string, salt: string) => number[];
183
+ /**
184
+ * `SM3(SM3(text + SALT))`,32 个整数。
185
+ * @param text - 待摘要文本
186
+ * @returns 摘要
187
+ */
188
+ declare const digestOf: (text: string) => number[];
189
+ /**
190
+ * 第三条链:把 UA 过一遍非标准 RC4、base64,再摘要**一次**。
191
+ *
192
+ * 与另外两条不同,这里是单次 SM3。RC4 的密钥是三字节,由环境探针拼出来,
193
+ * 所以对同一个身份是常量,不是每次调用都变。
194
+ *
195
+ * ## 密钥必须来自**这份签名自己**报的环境值
196
+ *
197
+ * `flags` 默认取模块常量(签名时就是这两个值),但**解码时必须传入签名里解出的
198
+ * `envFlags` / `detectFlags`** —— 它们随页面环境变(实测见过 14 和 4 两种),
199
+ * 拿常量去校验一份 `detect_flags` 为 4 的签名,这条链必然报「对不上」。
200
+ * 上游那份 Python 实现没有这个参数,这是移植时补的。
201
+ * @param userAgent - 原始 User-Agent
202
+ * @param flags - 环境探针值,默认取本模块的常量
203
+ * @returns 摘要
204
+ */
205
+ declare const userAgentDigest: (userAgent: string, flags?: {
206
+ envFlags?: number;
207
+ detectFlags?: number;
208
+ }) => number[];
209
+ /**
210
+ * 一条链在给定摘要下贡献的三个字节。
211
+ * @param chain - 链定义
212
+ * @param digest - 该链自己的摘要
213
+ * @returns 三个字节
214
+ */
215
+ declare const chainBytes: (chain: DigestChain, digest: readonly number[]) => [number, number, number];
216
+ /** {@link maskPair} 的逆:把每个字节的载荷半边取回来 */
217
+ declare const unmaskPair: (carrier: readonly number[]) => [number, number];
218
+ /** {@link ABogus} 的构造参数 */
219
+ interface ABogusOptions {
220
+ /** 九字段几何串,默认 {@link DEFAULT_BROWSER_INFO}。传 {@link browserInfoFromScreen} 的产物可以让它与身份一致 */
221
+ browserInfo?: string;
222
+ /** 抖音页面 id,默认 {@link PAGE_ID}。登录页用的是另一个值,所以留成参数 */
223
+ pageId?: number;
224
+ /** 抖音应用 id,默认 {@link AID} */
225
+ aid?: number;
226
+ /** 六个环境探针的位集,默认 {@link ENV_FLAGS} */
227
+ envFlags?: number;
228
+ /** 反机器人检测位集,默认 {@link DETECT_FLAGS}。**它同时是 UA 链的 RC4 密钥字节之一**,所以换值会让 UA 链整体改变 */
229
+ detectFlags?: number;
230
+ /** 随机源,返回 [0,1)。默认 `Math.random`;传固定序列即可复现 */
231
+ rng?: () => number;
232
+ }
233
+ /** {@link ABogus.getValue} 的可选入参 */
234
+ interface ABogusSignOptions {
235
+ /** 请求体,GET 留空。它参与摘要,所以把非空 body 当空签名等于签错 */
236
+ body?: string;
237
+ /** 请求的 Content-Type,只用于一条规则:`multipart/form-data` 时 body 要按空处理 */
238
+ contentType?: string;
239
+ /** 毫秒时钟,默认 `Date.now()`。对着固定样本复现时必须传 */
240
+ nowMs?: number;
241
+ }
242
+ /**
243
+ * 为一个浏览器身份计算 `a_bogus`。
244
+ *
245
+ * 除配置外无状态、也很便宜,所以「一个身份一个实例」是自然的生命周期。
246
+ * `rng` 存在的意义是让测试能钉住噪声——它产出什么,签名都是对的。
247
+ */
248
+ declare class ABogus {
249
+ private readonly userAgent;
250
+ private readonly browserInfo;
251
+ private readonly pageId;
252
+ private readonly aid;
253
+ private readonly envFlags;
254
+ private readonly detectFlags;
255
+ private readonly rng;
256
+ constructor(userAgent: string, options?: ABogusOptions);
257
+ /** 五十个标量,按离线解码器打印的名字 */
258
+ private fields;
259
+ /**
260
+ * 为一次请求算出 `a_bogus`。
261
+ * @param query - 即将发出的 query 串,不含 `a_bogus` 自身
262
+ * @param options - body / Content-Type / 时钟
263
+ * @returns `a_bogus` 的值
264
+ */
265
+ getValue(query: string, options?: ABogusSignOptions): string;
266
+ }
267
+ /**
268
+ * `problem` 是否表示「不是 a_bogus」而非「内容不符预期」。
269
+ * @param problem - {@link structureError} 的返回
270
+ * @returns 是否属于格式性失败
271
+ */
272
+ declare const isDecodeProblem: (problem: string | null | undefined) => boolean;
273
+ /** {@link decode} 的结果 */
274
+ interface DecodedABogus {
275
+ headerMagic: [number, number];
276
+ sdkVersion: [number, number, number, number];
277
+ nowMs: number;
278
+ inkMs: number;
279
+ pageId: number;
280
+ aid: number;
281
+ envFlags: number;
282
+ detectFlags: number;
283
+ callBucket: number;
284
+ tripwire: number;
285
+ browserInfo: string;
286
+ tail: string;
287
+ fields: Record<string, number>;
288
+ }
289
+ /**
290
+ * `value` 第一处不满足 a_bogus 格式的地方,全都满足时返回 `null`。
291
+ *
292
+ * 这里每一条检查都由算法本身固定,所以两个都正确的实现无论噪声多不同,都会在这些
293
+ * 检查上一致。**故意不检查长度**:长度跟着几何串走,两个都对但屏幕不同的浏览器就是
294
+ * 会不一样。
295
+ *
296
+ * 最后一条检查才是值得拥有的那条。校验和是对载荷里本身就有的一些字段算的,所以能通过
297
+ * 它的签名,必然是由一个在整套布局上与我们一致的实现装配出来的——版本块、五十槽置换、
298
+ * 噪声展开、以及那个密码。不是真算法,凑不出一个。
299
+ * @param value - 待检查的签名
300
+ * @param alphabet - 字母表,默认 `s4`
301
+ * @returns 问题标识或 `null`
302
+ */
303
+ declare const structureError: (value: string, alphabet?: keyof typeof ALPHABETS) => string | null;
304
+ /**
305
+ * 把一份 `a_bogus` 拆开。用于诊断与测试,不用于签名。
306
+ *
307
+ * 这是证明算法正确的那个函数:拿浏览器产出的签名跑一遍,它会还原出那个浏览器的屏幕
308
+ * 尺寸、抖音自己的 `aid`、以及与旁边那个 `timestamp` 参数吻合的时钟。
309
+ * @param value - 待拆解的签名
310
+ * @param alphabet - 字母表,默认 `s4`
311
+ * @returns 可还原的字段
312
+ */
313
+ declare const decode: (value: string, alphabet?: keyof typeof ALPHABETS) => DecodedABogus;
314
+ //#endregion
315
+ //#region src/platforms/douyin/sign/decode.d.ts
316
+ /**
317
+ * 把签名参数读回明文 —— 验证工具的解码层。
318
+ *
319
+ * 移植自 Douyin_TikTok_Download_API 的 `signing/native/decoding.py`(Apache-2.0)。
320
+ * 存在的理由只有一个:**证明签名实现还是对的**。
321
+ *
322
+ * ## 为什么需要它
323
+ *
324
+ * 抖音对签名是抽样校验的,所以「请求成功」证明不了任何事。唯一能证伪一个常量的办法,
325
+ * 是把真实签名拆开看。旧实现用错盐值(`cus`)活了两年没被发现,就是因为整套测试都是
326
+ * 自证的——自生成快照、和被测量对象比自身、以及没有任何一条断言能证伪一个常量。
327
+ *
328
+ * 这个模块提供外部锚点:给它一份浏览器产出的签名,它会告诉你签名里封住的到底是什么,
329
+ * 以及你手上的候选输入是不是它封的那一个。
330
+ *
331
+ * ## 「解码」在这里诚实地指三件不同的事
332
+ *
333
+ * 把它们混为一谈,就是这类工具开始撒谎的方式:
334
+ *
335
+ * - **可还原**:值就在载荷里,能原样取回。时钟、`aid`、`page_id`、屏幕几何串、
336
+ * 版本串、调用计数。没有任何推断。
337
+ * - **被绑定但不可还原**:载荷里带着某个东西的摘要——a_bogus 里是 query / body / UA
338
+ * 各三个 SM3 字节。输入无法从摘要倒推出来,所以这个模块**从不假装能**。它做的是
339
+ * **校验**:给它一个候选值,它回答这个候选是不是签名封住的那一个,以及有多少位证据
340
+ * 支持这个结论。这才是哈希真正的逆运算,而且比猜一个值有用得多——它把
341
+ * 「我的请求被拒了」变成「我发的这个签名是对着另一条 URL 算的」。
342
+ * - **根本没被计算**:`msToken` 与访客令牌由平台签发或随机抽出来,底下没有明文可找。
343
+ * 说出这一点就是答案,而不是「没能给出答案」。
344
+ *
345
+ * 每个字段都携带自己属于哪一类,读者不会被留在「标签旁边那个数字是读出来的还是推出来的」
346
+ * 这种猜测里。
347
+ *
348
+ * 这里没有任何东西用于签名。它只跑在调用方已经持有的值上,不碰网络、不消耗身份。
349
+ */
350
+ /** 字段是什么。控制台按它上色,调用方也可以对它分支 */
351
+ declare const KIND: {
352
+ /** 从载荷里直接读出,就是签名者放进去的东西 */
353
+ readonly PLAIN: 'plain';
354
+ /** 一个时钟,同时给出原始数字与 ISO-8601 时刻 */
355
+ readonly TIME: 'time';
356
+ /** 哈希或哈希的一个字节。输入不可还原——见 checks */
357
+ readonly DIGEST: 'digest';
358
+ /** 内部冗余。在这里重算,这正是解码得以自证的依据 */
359
+ readonly CHECKSUM: 'checksum';
360
+ /** SDK 对它自认为身处其中的页面所做的回报 */
361
+ readonly ENVIRONMENT: 'environment';
362
+ /** 每次捕获里都有、但含义未被确认的东西。命名它,而不是编造它 */
363
+ readonly OPAQUE: 'opaque';
364
+ };
365
+ /** 一个参数底下为什么没有明文 */
366
+ declare const REASON: {
367
+ readonly MALFORMED: 'malformed';
368
+ readonly NOT_COMPUTED: 'not_computed';
369
+ readonly ONE_WAY: 'one_way';
370
+ readonly UNKNOWN_PARAMETER: 'unknown_parameter';
371
+ };
372
+ /** 候选输入是不是签名封住的那一个 */
373
+ declare const CHECK: {
374
+ readonly MATCH: 'match';
375
+ readonly DIFFERS: 'differs';
376
+ readonly NOT_SUPPLIED: 'not_supplied';
377
+ };
378
+ /** 从签名里读出的一个值 */
379
+ interface Field {
380
+ /** 稳定的 slug。展示层负责翻译;未知 slug 原样渲染 */
381
+ name: string;
382
+ value: string;
383
+ kind: string;
384
+ /** 来源,当名字本身不足以说明时 */
385
+ detail?: string;
386
+ }
387
+ /** 候选输入是不是这份签名所覆盖的那一个 */
388
+ interface Check {
389
+ name: string;
390
+ status: string;
391
+ /** 证据量。a_bogus 携带的三个 SM3 字节是 24 位——实战够用,但值得说明而不是藏起来 */
392
+ bits: number;
393
+ /** 被检查的那个确切字符串,**仅在命中时**返回,所以这里发布的原像必定能重现已有的签名 */
394
+ covered?: string;
395
+ }
396
+ /** 一个参数被拆开后的结果 */
397
+ interface Decoded {
398
+ parameter: string;
399
+ platform: string | null;
400
+ algorithm: string;
401
+ /** 是否有可还原的明文 */
402
+ recovered: boolean;
403
+ reason?: string;
404
+ fields: Field[];
405
+ checks: Check[];
406
+ notes: string[];
407
+ }
408
+ /** {@link decodeABogus} 的候选输入。给了哪个就校验哪条链 */
409
+ interface ABogusCandidates {
410
+ query?: string;
411
+ body?: string;
412
+ userAgent?: string;
413
+ }
414
+ /**
415
+ * 拆开一份 `a_bogus`,并就调用方给出的候选值做校验。
416
+ * @param value - `a_bogus` 的值
417
+ * @param candidates - 候选的 query / body / User-Agent,给哪个校验哪个
418
+ * @returns 拆解结果;格式不合法时 `recovered` 为 `false` 并给出 `reason`
419
+ */
420
+ declare const decodeABogus: (value: string, candidates?: ABogusCandidates) => Decoded;
421
+ /** 一次盐值判定的结果 */
422
+ interface SaltDiagnosis {
423
+ /** 命中的候选盐值;都不命中时为 `null` */
424
+ salt: string | null;
425
+ /** 签名里封住的那三个 query 链字节,判定的依据 */
426
+ observed: number[];
427
+ /** 每个候选盐值重算出来的三个字节,以及是否命中 */
428
+ results: {
429
+ salt: string;
430
+ predicted: number[];
431
+ matched: boolean;
432
+ }[];
433
+ }
434
+ /**
435
+ * 反推一份签名用的是哪个盐值 —— **这个工具存在的核心理由**。
436
+ *
437
+ * 做法与当初发现 `cus` 过期时完全一样:签名里封着 query 链的三个字节,把候选盐值逐个
438
+ * 代进去重算摘要,能重现那三个字节的就是当前盐值。整件事不需要网络、不需要身份、
439
+ * 也不依赖抖音是否接受了你的请求——这正是它能发现「请求照样成功但常量已经过期」的原因。
440
+ *
441
+ * ## 只有 query 链参与判定
442
+ *
443
+ * body 链在 GET 上与空串一致,而 UA 链**根本不吃盐值**(它走 RC4 加单次 SM3),
444
+ * 两条对判定都没有贡献,所以这里的候选盐值只对 query 生效。证据量是 24 位,
445
+ * 实战足够——但要说明,而不是藏起来。
446
+ * @param value - 一份真实的 `a_bogus`
447
+ * @param query - 该签名所覆盖的那条 query(不含 `a_bogus` 自身)
448
+ * @param salts - 待测的盐值候选
449
+ * @returns 判定结果
450
+ */
451
+ declare const diagnoseSalt: (value: string, query: string, salts?: readonly string[]) => SaltDiagnosis;
452
+ /**
453
+ * 只按形状猜一个值是什么参数。用于调用方不知道自己在看什么的时候。
454
+ *
455
+ * 只做形状判断,不做任何密码学断言——猜错时返回 `null` 比返回一个错答案好。
456
+ * @param value - 待识别的值
457
+ * @returns 参数名或 `null`
458
+ */
459
+ declare const identify: (value: string) => string | null;
460
+ /** 一次 query 重建的结果 */
461
+ interface SignedQueryRecovery {
462
+ /** 重建出的、签名所覆盖的那条 query */
463
+ query: string;
464
+ /** 命中的重建方式保留了哪些参数(`SIGNING_AMBIGUOUS` 的子集) */
465
+ kept: string[];
466
+ /** 命中的盐值;把多个候选盐值一起搜时才有区分意义 */
467
+ salt: string;
468
+ /** 试过的组合数,用于说明「这不是撞上的」 */
469
+ tried: number;
470
+ }
471
+ /**
472
+ * 反推一份签名**当时覆盖的那条 query**,以及用的是哪个盐值。
473
+ *
474
+ * 这是 {@link diagnoseSalt} 的加强版:那份要求调用方已经知道签名覆盖了哪些参数,
475
+ * 而线上抓来的 URL 里,`uifid` / `msToken` / `verifyFp` / `fp` 到底在签名前还是
476
+ * 签名后,取决于页面当时挂的是哪条管线 —— 同一份代码在浏览器里和在 amagi 里顺序
477
+ * 就是不一样的。与其让调用方去猜,这里把 2⁴ 种组合全试一遍,报出命中的那一种。
478
+ *
479
+ * 命中意味着**两件事同时成立**:这是一份结构良好的 a_bogus,且它的 query 链在给定
480
+ * 盐值下重现了这条 query。所以它是「验证」而不只是「解码」。
481
+ * @param signature - `a_bogus` 的值(未做 URL 编码)
482
+ * @param url - 已签名的完整 URL
483
+ * @param salts - 待搜的盐值候选
484
+ * @returns 命中结果;没有任何组合命中时返回 `null`
485
+ */
486
+ declare const recoverSignedQuery: (signature: string, url: string, salts?: readonly string[]) => SignedQueryRecovery | null;
487
+ /**
488
+ * 从一条已签名的 URL 里重建出签名覆盖的那条 query(不再搜索,按固定规则摘参数)。
489
+ *
490
+ * **只在已知管线顺序时使用。** 面对来源不明的 URL 请用 {@link recoverSignedQuery},
491
+ * 它会搜索并告诉你哪条重建成立。保留这个函数是为了让「摘掉签名参数」这件事在
492
+ * 需要确定行为的地方仍然可用。
493
+ * @param url - 已签名的完整 URL
494
+ * @returns 摘掉 `a_bogus` / `timestamp` / `x-secsdk-web-signature` 之后的 query
495
+ */
496
+ declare const signedQueryOf: (url: string) => string;
497
+ /**
498
+ * 拆开一条 URL 上所有认得出的签名参数。
499
+ *
500
+ * 会先尝试 {@link recoverSignedQuery} 找出签名真正覆盖的那条 query;找不到时退回
501
+ * {@link signedQueryOf} 的结果,并把这个不确定性体现在 `notes` 里。
502
+ * @param url - 已签名的完整 URL
503
+ * @param options - 可选的 User-Agent、显式 query 与盐值候选
504
+ * @returns 每个参数一条拆解结果
505
+ */
506
+ declare const decodeUrl: (url: string, options?: {
507
+ userAgent?: string;
508
+ body?: string;
509
+ query?: string;
510
+ salts?: readonly string[];
511
+ }) => Decoded[];
512
+ //#endregion
513
+ //#region src/platforms/douyin/sign/sm3.d.ts
514
+ /**
515
+ * SM3 哈希(GB/T 32905-2016),纯 TS 实现。
516
+ *
517
+ * 抖音的 a_bogus 用它摘要 query / body / User-Agent。这个文件是移植件,
518
+ * 来源是 Douyin_TikTok_Download_API 的 `signing/native/sm3.py`(Apache-2.0),
519
+ * 与本仓库的 GPL-3.0 兼容。
520
+ *
521
+ * ## 为什么不用 passport/sm3.ts
522
+ *
523
+ * 那个是登录侧的另一份 SM3,入口收 `string` 并按 `charCodeAt` 逐字符取字节 ——
524
+ * 对中文会与 UTF-8 不同。a_bogus 要摘的是 query 与 base64 串(纯 ASCII),
525
+ * 但 UA 链上要摘要的是**按 JS 语义切出来的字节**,两份混用迟早出错,所以
526
+ * 签名侧自带一份以字节为入口的实现,不复用。
527
+ *
528
+ * ## 移植时改了什么
529
+ *
530
+ * 只改了整数语义:Python 的 `& 0xFFFFFFFF` 在 JS 里必须写成 `>>> 0`,
531
+ * 否则位运算的结果是带符号 32 位整数,`~x` 与算术右移都会带进符号位。
532
+ * 摘要字节输出与 Python 逐字节一致。
533
+ */
534
+ /** SM3 初始向量 */
535
+ declare const SM3_IV: readonly number[];
536
+ /**
537
+ * 计算 SM3 摘要。
538
+ * @param message - 待摘要的字节
539
+ * @returns 32 字节摘要
540
+ */
541
+ declare const sm3Hash: (message: readonly number[]) => number[];
542
+ /** 摘要的十六进制形式,便于与公开测试向量对照 */
543
+ declare const sm3Hexdigest: (message: readonly number[]) => string;
544
+ /**
545
+ * 摘要为 32 个整数的数组,与 a_bogus 内部各处取字节的方式对齐。
546
+ *
547
+ * 字符串按 **UTF-8** 编码后摘要(与 `sm3.py` 的 `sm3_to_array` 一致)。
548
+ * 注意 a_bogus 里另有一处 `jsBytes`,那个走的是 JS `charCodeAt` 语义,
549
+ * 两者不可互换 —— 见 `a_bogus.ts` 的说明。
550
+ * @param data - 字符串、字节数组或整数数组
551
+ * @returns 32 个 0..255 的整数
552
+ */
553
+ declare const sm3ToArray: (data: string | readonly number[] | Uint8Array) => number[];
554
+ //#endregion
555
+ //#region src/platforms/douyin/sign/tokens.d.ts
556
+ /**
557
+ * 抖音访客 token 的纯算部分(TypeScript 移植)
558
+ *
559
+ * 移植自 Douyin_TikTok_Download_API `src/dtk/signing/native/tokens.py`,只取不触网的那几条:
560
+ * 假 `msToken`、`verify_fp` / `s_v_web_id`,以及 ttwid 注册要用的常量。
561
+ * `gen_real_ms_token` / `gen_ttwid` / `gen_odin_tt` 各要发一次 HTTP,不在这里。
562
+ *
563
+ * ## 随机与时钟为什么都是参数
564
+ *
565
+ * 原实现把 `Math.random()` / `Date.now()` 烧在函数体里,输出没法在测试里钉死。
566
+ * 这里统一改成可注入:随机源是 `() => number`(契约同 `Math.random`,返回 `[0, 1)`),
567
+ * 时钟是显式毫秒时间戳,默认值才是 `Math.random` / `Date.now`。
568
+ *
569
+ * **注意:注入的是「每次一个 `[0, 1)` 均匀数」的契约,不是 seed。** Python 的
570
+ * `Random.choice` 走 `_randbelow`(getrandbits + 拒绝采样),和 `random()` 不是同一串数,
571
+ * 所以同一个 seed 喂两边对不上;能对上的是「同一串均匀数喂进去,输出逐字节相同」。
572
+ *
573
+ * @module platforms/douyin/sign/tokens
574
+ */
575
+ /** 注入式随机源:每次调用返回一个 `[0, 1)` 均匀数,契约同 `Math.random` */
576
+ type Rng = () => number;
577
+ /**
578
+ * 假 msToken 的字符表,逐字符照抄 V4 的 `gen_random_str`。
579
+ *
580
+ * A-Z + a-z + 0-9 + `+-` 正好 64 个,拼出来就是标准 base64 的形状 ——
581
+ * 假的 msToken 唯一要像真的地方就是形状。抄错表就毁在这上面:
582
+ * 一个永远不含 `j`、中间还夹着 `=` 的 token,形状不对。
583
+ */
584
+ declare const MS_TOKEN_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-";
585
+ /** 抖音假 msToken 的长度(`==` 后缀之前那一段) */
586
+ declare const DOUYIN_MS_TOKEN_LENGTH = 126;
587
+ /** 真 msToken 回包的长度,不在其中说明接口变了形,值不该信 */
588
+ declare const DOUYIN_MS_TOKEN_SIZES: readonly number[];
589
+ /** {@link genFalseMsToken} 的注入点 */
590
+ interface FalseMsTokenOptions {
591
+ /** 随机源,默认 `Math.random` */
592
+ rng?: Rng;
593
+ /** 尾部填充,默认 `==`(真 msToken 靠它凑出 base64 的收尾) */
594
+ suffix?: string;
595
+ }
596
+ /**
597
+ * 本地生成的假 msToken。
598
+ *
599
+ * 平台对一部分端点认它、另一部分不认。原实现把它当作真接口失败时的兜底,
600
+ * 调用方应当知道拿到的是哪一个(原实现只返回 token,降级和健康长得一模一样)。
601
+ *
602
+ * @param length - `==` 之前的长度,默认 {@link DOUYIN_MS_TOKEN_LENGTH}
603
+ * @param options - `rng` / `suffix` 注入点
604
+ * @returns `length` 个字符表内字符 + `suffix`
605
+ */
606
+ declare const genFalseMsToken: (length?: number, options?: FalseMsTokenOptions) => string;
607
+ /** `verify_fp` / `s_v_web_id` 随机半段用的字符表(62 个:数字 + 大小写字母) */
608
+ declare const VERIFY_FP_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
609
+ /** 尾巴总长度 */
610
+ declare const VERIFY_FP_LENGTH = 36;
611
+ /**
612
+ * 36 进制转换(照抄 Python 的 `_to_base36`,不用 `Number#toString(36)`)。
613
+ *
614
+ * 两者对非负整数结果一致,但 Python 那版对 `<= 0` 返回 `"0"` 而不是 `"-..."` ——
615
+ * 自实现是把这条边界一起搬过来,省得以后有人换个入参就踩到差异。
616
+ *
617
+ * @param value - 非负整数
618
+ * @returns 36 进制小写字符串
619
+ */
620
+ declare const toBase36: (value: number) => string;
621
+ /** {@link genVerifyFp} / {@link genSVWebId} 的注入点 */
622
+ interface VerifyFpOptions {
623
+ /** 毫秒时间戳,默认 `Date.now()` */
624
+ nowMs?: number;
625
+ /** 随机源,默认 `Math.random` */
626
+ rng?: Rng;
627
+ }
628
+ /**
629
+ * 生成一个 `verify_fp`。
630
+ *
631
+ * 形状:`verify_<毫秒时间戳的 36 进制>_<36 位 UUID v4 骨架>` —— 尾巴的下划线在
632
+ * 8/13/18/23,下标 14 恒为 `4`,下标 19 是变体位。
633
+ *
634
+ * 随机数只在**非固定位**上消耗:四个下划线和版本位那 5 个下标跳过不抽签,
635
+ * 所以一条尾巴正好抽 31 次,抽签顺序按下标升序 —— 与 Python 逐次对齐,
636
+ * 注入同一串均匀数才能得到同一串字符。
637
+ *
638
+ * @param options - `nowMs` / `rng` 注入点
639
+ * @returns 可直接当作 `verifyFp` / `s_v_web_id` cookie 的值
640
+ */
641
+ declare const genVerifyFp: (options?: VerifyFpOptions) => string;
642
+ /**
643
+ * `s_v_web_id` 与 `verify_fp` 同形状、同算法,只是 cookie 名不同。
644
+ *
645
+ * @param options - `nowMs` / `rng` 注入点
646
+ * @returns 与 {@link genVerifyFp} 同构的值
647
+ */
648
+ declare const genSVWebId: (options?: VerifyFpOptions) => string;
649
+ /**
650
+ * 换真 msToken 的上报接口要的全部输入。
651
+ *
652
+ * `strData` 是平台 SDK 产出的不透明串 —— 不是凭证,但随 SDK 版本变,
653
+ * 所以它属于调用方的配置,不写死在这里。
654
+ */
655
+ interface MsTokenSpec {
656
+ url: string;
657
+ magic: number;
658
+ version: number;
659
+ dataType: number;
660
+ strData: string;
661
+ userAgent: string;
662
+ }
663
+ /**
664
+ * 拼上报接口的 JSON 请求体。
665
+ *
666
+ * 字段顺序照 Python 的 dict:magic → version → dataType → strData → tspFromClient。
667
+ * 与 Python 唯一的差别是空白:`json.dumps` 默认带 `", "` / `": "` 分隔符,
668
+ * `JSON.stringify` 是紧凑的;服务端按 JSON 解析,不看空白。
669
+ *
670
+ * @param spec - 上报接口的静态参数
671
+ * @param timestampMs - 客户端时间戳(`tspFromClient`),默认 `Date.now()`
672
+ * @returns 请求体字符串
673
+ */
674
+ declare const msTokenPayload: (spec: MsTokenSpec, timestampMs?: number) => string;
675
+ /** 注册 ttwid 的一次性 POST 要用的东西 */
676
+ interface TtwidSpec {
677
+ url: string;
678
+ data: string;
679
+ }
680
+ /** ttwid 注册接口(两个平台同域,只有 body 不同) */
681
+ declare const TTWID_REGISTER_URL = "https://ttwid.bytedance.com/ttwid/union/register/";
682
+ /** 抖音访客 ttwid 的注册体,不含任何凭证 */
683
+ declare const DOUYIN_TTWID_PAYLOAD: {
684
+ readonly region: 'cn';
685
+ readonly aid: 1768;
686
+ readonly needFid: false;
687
+ readonly service: 'www.ixigua.com';
688
+ readonly migrate_info: {
689
+ readonly ticket: '';
690
+ readonly source: 'node';
691
+ };
692
+ readonly cbUrlProtocol: 'https';
693
+ readonly union: true;
694
+ };
695
+ /** 抖音访客 ttwid 注册:`POST {url}`,body 用 `data` */
696
+ declare const DOUYIN_TTWID: TtwidSpec;
697
+ //#endregion
698
+ export { ABogus, type ABogusCandidates, type ABogusOptions, type ABogusSignOptions, AID, ALPHABETS, CALL_BUCKET, CHECK, type Check, DEFAULT_BROWSER_INFO, DETECT_FLAGS, DIGEST_CHAINS, DOUYIN_MS_TOKEN_LENGTH, DOUYIN_MS_TOKEN_SIZES, DOUYIN_TTWID, DOUYIN_TTWID_PAYLOAD, type Decoded, type DecodedABogus, type DigestChain, ENV_FLAGS, FIELD_ORDER, FORTNIGHT_EPOCH_MS, type Field, HEADER_MAGIC, KIND, MS_TOKEN_ALPHABET, type MsTokenSpec, PAGE_ID, PAYLOAD_KEY, REASON, SALT, SDK_VERSION, SM3_IV, type SaltDiagnosis, type SignedQueryRecovery, TRIPWIRE_LOCKED, TTWID_REGISTER_URL, type TtwidSpec, VERIFY_FP_ALPHABET, VERIFY_FP_LENGTH, type VerifyFpOptions, browserInfoFromScreen, buildBrowserInfo, canary, chainBytes, decode, decodeABogus, decodeBase64, decodeUrl, diagnoseSalt, digestOf, digestWith, encodeBase64, genFalseMsToken, genSVWebId, genVerifyFp, identify, isDecodeProblem, jsBytes, msTokenPayload, rc4, recoverSignedQuery, signedQueryOf, sm3Hash, sm3Hexdigest, sm3ToArray, structureError, toBase36, unmaskPair, userAgentDigest };