Metadata-Version: 2.4
Name: chrome-fp
Version: 0.7.0
Summary: 与真 Chrome 逐字节同指纹(JA4/HTTP2 Akamai/请求头顺序)的纯 Python HTTP 请求库, 支持 Chrome 153/154 多版本 profile, API 与 requests 逐项对齐
Author: DreamXiaoJing
License-Expression: MIT
Project-URL: Homepage, https://github.com/DreamXiaoJing
Project-URL: Source, https://github.com/DreamXiaoJing/chrome-fp
Project-URL: Issues, https://github.com/DreamXiaoJing/chrome-fp/issues
Keywords: chrome,tls,fingerprint,ja4,http2,http3,client-hello,requests
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: cryptography>=42
Requires-Dist: hpack>=4
Requires-Dist: brotli>=1.1
Requires-Dist: zstandard>=0.22
Requires-Dist: certifi
Provides-Extra: encoding
Requires-Dist: charset-normalizer>=3; extra == "encoding"
Provides-Extra: test
Requires-Dist: h2>=4; extra == "test"
Provides-Extra: dev
Requires-Dist: pqcrypto>=0.7; extra == "dev"
Requires-Dist: kyber-py; extra == "dev"
Dynamic: license-file

# chrome-fp — 与真 Chrome 逐字节同指纹的纯 Python 请求库, 用法和 requests 一样

用 Python 发 HTTP 请求, 但 TLS/HTTP2 指纹跟本机真 Chrome（**默认 154.0.8037.98**，
可用 `chrome_version="153"` 切到 **153.0.8010.48**）一致：
JA4、HTTP/2 Akamai 指纹、请求头顺序与取值、扩展集合、GREASE、ALPS、trust_anchors、
PQ 混合密钥共享（X25519MLKEM768）全部对齐。

```python
import chrome_fp as requests            # 就当成 requests 用

r = requests.get("https://example.com/", params={"a": 1}, timeout=10)
print(r.status_code, r.headers["Content-Type"], r.elapsed)
print(r.text, r.json() if "json" in r.headers.get("content-type", "") else "")

with requests.Session() as s:
    s.headers.update({"x-token": "abc"})
    r = s.post("https://httpbin.org/post", json={"a": 1})
    r.raise_for_status()
```

## 版本 profile：Chrome 154（默认）/ 153

`Session(chrome_version=...)` 选版本，默认跟着最新 Stable（**154**，本机 154.0.8037.98）：

```python
from chrome_fp import Session
Session()                        # Chrome 154（默认）
Session(chrome_version="153")    # Chrome 153（153.0.8010.48）
```

2026-10-03 在本机做了 **153 ↔ 154 的 A/B 实抓**：Chrome for Testing 153.0.8010.47 与
本机 Stable 154.0.8037.98 各抓 20+ 条真实连接（`tools/run_capture.py` 起本地 TLS/HTTP2
探针 + 拉真 Chrome，`tools/analyze_hello.py` 解析）：

| 项目 | Chrome 153 | Chrome 154 | 结论 |
|---|---|---|---|
| JA4_a / JA4_b | `t13d1517h2` / `8daaf6152771` | 完全相同 | 不变 |
| cipher / supported_groups / sig_algs | — | 完全相同 | 不变 |
| 扩展集合（17 个） | — | 完全相同 | 不变 |
| `trust_anchors`(0xca34) | 28 个 ID | 同一集合 | 不变 |
| H2 SETTINGS / WINDOW_UPDATE / 帧序 | — | 完全相同 | 不变 |
| `sec-ch-ua` 品牌 | `"Google Chrome";v="153", "Not_A Brand";v="8", "Chromium";v="153"` | `"Chromium";v="154", "Google Chrome";v="154", "Not A(Brand";v="99"` | **随版本变** |
| UA / full-version | `Chrome/153.0.0.0` | `Chrome/154.0.0.0` | **随版本变** |

> **153 → 154 的 TLS/HTTP2 线格式没有变化**，差异只在版本字符串与 UA 品牌串。
> profile 以 `dataclasses.replace` 从 153 派生 154，`spec.PROFILES` 里继续加版本即可。
>
> 实现细节：`spec` 模块级常量（`CHROME_VERSION`、`CIPHER_SUITES`…）**跟随"当前生效
> profile"**（PEP 562 模块级 `__getattr__` + ContextVar），所以 `Session(chrome_version="153")`
> 建出来之后，同线程里读 `spec.CHROME_VERSION` 会得到 153；每个 `Session.request()` 也会
> 重新切回自己的版本，多版本可共存于同一进程。旧代码里的全大写常量名**继续可用**。

同一套探针也用来验证**本库自己**：把库指向探针抓一遍，与真 Chrome 154 逐项对比 ——
ClientHello 的 cipher / groups / sig_algs / 扩展集合 / trust_anchors / session_id /
key_share / ALPN **8 项全一致**，JA4（含去 GREASE 的 c 段）三处字符串完全相同。

本轮还修掉一处**旧实现与真机不符**的地方：真 Chrome 的 HEADERS 帧带 `PRIORITY` 标志
（flags `0x25`），前面有 5 字节 `E=1 / depends_on=0 / weight=按 RFC 9218 urgency 查表`
（`u=0→256`、`u=1→220`、`u=3→147`）。153/154 实测**都带**，之前实现少发这 5 字节。

## 抓包与验证工具链（2026-10 现状）

旧的 `tools/` 在目录清理中丢失，本轮重写了一条**不依赖 tshark、也不需要管理员权限**的探针链，
153 / 154 两个版本都用它校对：

```bash
# 1) 抓真 Chrome: 起本地 TLS/HTTP2 探针 + 拉 Chrome(临时 profile, 不碰你在用的配置)
python tools/run_capture.py --out capture/chrome154 --ports 8443-8462 --seconds 25
#    Chrome for Testing 等非默认安装: --chrome <path> --extra=--no-sandbox

# 2) 解析 ClientHello / JA4 / trust_anchors, 并对着 profile 逐项 diff
python tools/analyze_hello.py --dir capture/chrome154 --diff

# 3) 把**本库**指向同一个探针, 和真 Chrome 的字节逐项对比
python tools/tap_probe.py --ports 8443-8446 --out capture/library154 --wait 45
python tools/verify_against_probe.py --port 8443
```

| 工具 | 作用 |
|---|---|
| `tools/tap_probe.py` | 本地 TLS/HTTP2 探针：MemoryBIO 驱动，**握手期的原始字节逐字节留档**，并解出 H2 帧与请求头 |
| `tools/run_capture.py` | 起探针 + 拉真 Chrome + 收日志（Chrome 是 GUI 子系统程序，PowerShell 抓不到它的 stdout） |
| `tools/launch_chrome.py` | 单独启动 Chrome 打探针（host-resolver-rules + 临时 profile + `--no-proxy-server`） |
| `tools/analyze_hello.py` | 解析 ClientHello / JA4 / 扩展 / trust_anchors，`--diff` 直接与 profile 对比 |
| `tools/verify_against_probe.py` | 用本库发请求走同一探针，与真 Chrome 逐项比字节 |

抓包证据放在 `capture/`（`chrome154*`、`chrome153cft`、`library154`）；按"仓库只留代码"的取舍，
`capture/` 在 `.gitignore` 里，没有提交。

## 第 1 轮（0.2.x → 0.3.0）：对齐 Chrome 153（历史记录）

> 下面这段是当时（0.3.0）的做法与结论，保留作历史记录。它引用的
> `tools/tap_proxy.py`、`analyze_capture.py`、`tls13_decrypt.py`、`h2_frames.py`、`diff_fp.py`
> 和 `capture/chrome153/` 已经在目录清理中丢失，**当前可用的工具见上一节**。

本机是非管理员，Npcap/dumpcap 直接拒绝抓包
（`You do not have permission to capture on device`）。所以改用**应用层旁路抓包**：

```
Chrome ──CONNECT──> tools/tap_proxy.py (旧, 已丢失) ──> 真实服务器 / 本地 h2 服务器
                          │
                          ├─ 原样录下双向字节（不改一个字节）
                          ├─ 合成 TCP 头写成 .pcap  ──> tshark / Wireshark 打开
                          └─ Chrome 的 SSLKEYLOGFILE ──> 解密看 HTTP/2 帧
```

产物在 `capture/chrome153/`（另有 `capture/library/` 是**本库**走同一流程的抓包，用来对比）：

| 文件 | 内容 |
|---|---|
| `chrome_tap.pcap` | 合成 TCP 的抓包文件，Wireshark 可直接打开（304 包 / 23 条隧道） |
| `sslkeylog.txt` | Chrome 自己写的 TLS 会话密钥 |
| `hello/*.bin` | 真 Chrome 发出的 ClientHello 原始字节（17 条样本） |
| `streams/*.bin` | 每条隧道双向的原始字节流 |
| `h2_client.json` | 解密后解出的请求头（顺序 + 取值） |
| `summary.txt` | 按资源类型归类的头顺序汇总 |
| `tls_diff.txt` | 与库的逐扩展对比结论（`完全一致`） |
| `report.json` | tshark 判定结果（JA4/JA3/扩展/key_share…） |

当时的复现命令（**工具已丢失，现在跑不了**；当前入口见「抓包与验证工具链」一节）：

```bash
python tools/run_capture.py --out capture/chrome153 --sites https://example.com/   # 旧版入口
python tools/analyze_capture.py --dir capture/chrome153      # tshark 出 JA4 等
python tools/tls13_decrypt.py   capture/chrome153            # 自己解密出 h2 明文
python tools/h2_frames.py       capture/chrome153            # 解 HTTP/2 帧与请求头
python tools/diff_fp.py --dir   capture/chrome153            # 与库逐扩展对比
```

> tshark 4.6 能把服务端方向的 TLS 解出来，但客户端方向一直停在
> `Encrypted Application Data`。抓包本身没问题（record 结构完整、密钥齐全），
> 所以 `tools/tls13_decrypt.py` 按 RFC 8446 自己解，并与 tshark 对服务端的判定互相印证。

### 在 Wireshark / tshark 里直接看

`capture/chrome153/chrome_tap.pcap` 是标准 pcap（以太网链路层 + 完整 TCP 三次握手，
tshark 能正常重组），双击就能用 Wireshark 打开：

```bash
# 1) 让 Wireshark 用 Chrome 的 keylog 解密
#    GUI:  编辑 → 首选项 → Protocols → TLS → (Pre)-Master-Secret log filename
#          选 capture/chrome153/sslkeylog.txt
#    tshark 等价写法: -o tls.keylog_file:capture/chrome153/sslkeylog.txt

# 2) 看真 Chrome 的 JA4（tshark 4.6 原生支持）
tshark -r capture/chrome153/chrome_tap.pcap \
       -o tls.keylog_file:capture/chrome153/sslkeylog.txt \
       -Y "tls.handshake.type==1" \
       -T fields -e tls.handshake.extensions_server_name -e tls.handshake.ja4

# 3) 看解密后的 HTTP/2 请求头（顺序就是 Chrome 的真实顺序）
tshark -r capture/chrome153/chrome_tap.pcap \
       -o tls.keylog_file:capture/chrome153/sslkeylog.txt \
       -Y "http2.header.name" -T fields -e http2.streamid -e http2.header.name -e http2.header.value
```

> 本机是非管理员，`dumpcap.exe -i <任意网卡>` 会直接报
> `You do not have permission to capture on device ... Admin-only Mode`，
> 所以**抓包驱动用不了**（Npcap 的 NPCAP 组也不在当前令牌里，UAC 又不方便点）。
> 旁路代理拿到的字节和网卡抓包看到的完全一样，只是没有 TCP 重传之类的噪声；
> 如果你想要网卡级抓包，用管理员权限跑
> `dumpcap -i <网卡> -w out.pcapng` 即可，分析流程完全一样。

### 抓出来的差异（0.3.0 已修；其中 #3 在 0.6.0 修正为相反结论）

| # | 项目 | 真 Chrome 153 | 旧库 (0.2.3) | 处理 |
|---|---|---|---|---|
| 1 | `trust_anchors`(0xca34) ID 数量 | **28 个** | 32 个（152 的集合） | 去掉 `d6790902/03/09/0e` 四个 |
| 2 | HTTP/2 `PRIORITY` 帧 | **一个都不发** | 默认发 3/5/7/9 四帧 | `send_priority_tree` 默认关 |
| 3 | `HEADERS` 帧标志 | **带 `PRIORITY` 前缀**（flags `0x25`） | 带 5 字节 weight 前缀 | 0.3.0 当时误判为"不带"、把前缀删了；**0.6.0 实抓修正为带上**（与 #2 的"不发 PRIORITY 帧"不矛盾：帧不发，但 HEADERS 的标志位和前缀要带） |
| 4 | Akamai 指纹第 3/4 段 | `0` / `m,a,s,p` | 会被 #2 改成 `00:256,...` | 修正；`m,a,s,p` 其实是**伪头顺序** |
| 5 | `sec-ch-ua` | `"Google Chrome";v="153", "Not_A Brand";v="8", "Chromium";v="153"` | 152 的品牌、顺序也不同 | 更新 |
| 6 | `sec-ch-ua-platform` | `"Windows"` | `"Linux"` | 更新 |
| 7 | `user-agent` | Windows NT 10.0; Win64; x64 + Chrome/153 | X11; Linux x86_64 + Chrome/152 | 更新 |
| 8 | 请求头顺序 | **导航 / 子资源两套不同顺序** | 只有一套写死的顺序 | 按 `dest`/`mode` 选 profile |
| 9 | `accept` / `priority` / `referer` / `origin` | 随资源类型变 | 全部写死 | 按 dest 取值 |
| 10 | `sec-fetch-user` | 只有顶层 document 导航才有 | 导航一律带 | 修正 |

TLS 层其余部分（15 个密码套件、17 个扩展+2 GREASE、11 个签名算法、
4 个 group、ECH GREASE 结构、ALPS、compress_certificate、status_request、
EMS、session_id…）经逐字节比对**与 Chrome 153 完全一致**。

### 验证结果

当时用 `tools/verify_live.py` 让**本库**走同一个代理访问同一个本地 h2 服务器，再和 Chrome 的抓包对比
（该脚本已丢失；现在的等价做法是 `tools/verify_against_probe.py`）：

```
[2/4] TLS ClientHello 对比     密码套件/扩展集合/扩展数量/trust_anchors/groups/sigalgs/versions  全部 OK
[3/4] HTTP/2 指纹对比         Akamai = 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p   一致
                              PRIORITY 帧数量 = 0                                            一致
[4/4] 请求头逐条对比           document / style / font / script / image / iframe / fetch / xhr / POST
                              —— 9 类请求的头顺序与取值全部逐条相同
```

## 实测结果

下表是本轮（2026-10-03）两个版本各自的样本量；"本库"一列是**把库指向同一探针**抓出来的结果。

| 项目 | 真 Chrome 153（20~22 条样本） | 真 Chrome 154（22 条样本 / 20 条连接） | 本库 |
|---|---|---|---|
| JA4 | `t13d1517h2_8daaf6152771_<随机>` | 同左（逐字符相同，含去 GREASE 的 c 段） | **一致** |
| HTTP/2 Akamai | `1:65536;2:0;4:6291456;6:262144\|15663105\|0\|m,a,s,p` | 同左 | **一致** |
| 扩展集合 | 17 真实 + 2 GREASE | 同左 | **一致** |
| trust_anchors | 28 个 ID（集合固定、顺序随启动变） | 同一集合 | **一致** |
| HEADERS 的 PRIORITY 前缀 | 带（`u=0→256` / `u=1→220` / `u=3→147`） | 同左 | **一致** |
| 请求头顺序/取值 | 导航与子资源各一套 | 同左 | **一致**（逐条比对） |

实网站点冒烟（`tools/live_sites.py`，该脚本已丢失，结论保留）：

```
example.com / taobao  h2 + TLS1.3
baidu / qq / sohu     TLS1.2 回落 + HTTP/1.1        5/5 OK
```

### JA4 计算：0.7.0 修正为严格照 FoxIO 官方实现

`ja4_from_hello()` 之前把 c 段的输入串拼错了（**扩展带 `0x` 前缀**、且**没有剔除 SNI/ALPN**），
于是本库报出来的 JA4 和 tshark / Cloudflare / peet.ws 算的不是同一个东西。按官方
`python/common.py:get_hex_sorted` 对齐后：

```python
c = [ x[2:] for x in values if x not in GREASE_TABLE ]      # 去 "0x" 前缀 + 剔 GREASE
if field == 'extensions' and sort:
    c = [ x for x in c if x not in ['0000', '0010']]        # 扩展列表再剔 SNI / ALPN
actual_length = min(len(c), 99)                              # a 段的计数不剔 SNI/ALPN
```

修正后的实测值（`tests/test_ja4.py` 用"照官方源码另写一遍"的方式独立校验，并钉住真机抓包）：

| 对象 | 修正前（错误） | 修正后 = 官方 JA4 |
|---|---|---|
| 真机 CfT 153 抓包 | `…_a8dc8a9aef7a` | **`t13d1518h2_8daaf6152771_4980c97edce0`** |
| 本库 `154-cft` 生成 | 同上（与真机一致，但都是错的） | 与真机抓包**逐字符相同** |
| 真机 Stable 154 抓包 | `…_9099627350db` | `t13d1517h2_8daaf6152771_cb7bf5808d99` |

> 注意 a 段/ b 段一直是对的，只有 c 段错；所以只看前缀（`t13d1517h2_8daaf6152771`）发现不了。

## requests 兼容的用法

`Session` / `Response` 的属性与方法刻意对齐 requests：

```python
import chrome_fp as requests
from chrome_fp.exceptions import HTTPError, TooManyRedirects

s = requests.Session()
s.headers.update({"x-token": "abc"})      # 大小写不敏感, 单次请求头可覆盖
s.params = {"v": "1"}
s.cookies.set("sid", "x", domain="example.com")
s.auth = ("user", "pass")                 # 或 basic_auth/自定义可调用对象
s.proxies = {"https": "http://127.0.0.1:7892"}
s.max_redirects = 10

r = s.request("POST", "https://example.com/api",
              params={"p": 1}, json={"a": 1}, timeout=(5, 20),
              headers={"x-extra": "1"}, cookies={"k": "v"},
              allow_redirects=True, hooks={"response": [lambda r: None]})
r.status_code; r.reason; r.ok; r.headers["CONTENT-TYPE"]; r.text; r.json()
r.content; r.raw; r.url; r.encoding; r.apparent_encoding; r.elapsed; r.history
r.cookies; r.request; r.is_redirect; r.links; r.next
r.raise_for_status(); list(r.iter_content(4096)); list(r.iter_lines())
```

模块级 `chrome_fp.get/post/put/patch/delete/head/options/request` 与 requests 同名同签名。
异常层次在 `chrome_fp.exceptions`（`RequestException` / `HTTPError` / `Timeout` /
`TooManyRedirects` / `MissingSchema` / `InvalidURL` / `JSONDecodeError` …）。

**几处刻意的 requests 语义**（不是 bug）：

- `r.encoding` 按 requests 规则来：Content-Type 带 charset 就用它；`text/*` 没写 charset
  时是 `ISO-8859-1`；`application/json` 是 `utf-8`。想要正确的中文文本就
  `r.encoding = r.apparent_encoding`（用 charset_normalizer，和 requests 一样）。
- `r.json()` 解析失败抛 `chrome_fp.exceptions.JSONDecodeError`（同时是 `json.JSONDecodeError`）。
- `r.iter_lines()` 在 `decode_unicode=False` 时给 bytes。

### 与 requests 的 API 对齐（实测，不是自称）

用 `tools/api_parity.py` 把本库和**真 requests**（2.34.2）逐项对比过，下面这些**语法层面**
的对齐已经补上，把 requests 的代码原样搬过来不会 AttributeError/TypeError：

| 项目 | 说明 |
|---|---|
| 顶层异常名 | `ConnectTimeout` / `ReadTimeout` / `URLRequired` / `JSONDecodeError` / `ProxyError` / `SSLError` / `InvalidHeader` / `InvalidProxyURL` / `RetryError` / `RequestsWarning` … 都能 `import chrome_fp as requests; except requests.ReadTimeout` |
| `requests.codes` | `codes.ok` / `codes["not_found"]` / `codes[500]`；`status_codes.codes is codes`（与真 requests 语义一致） |
| `Request` + `prepare_request` | `s.prepare_request(requests.Request("GET", url, params={...}))` 与 `s.prepare_request("GET", url, ...)` 两种写法都认 |
| `s.send(request, **kwargs)` | 第一个参数名就是 `request`；接受 `stream` / `verify` / `cert` / `proxies` / `timeout`，多余 kwargs 不报错 |
| `PreparedRequest` | `path_url` / `copy()` / `register_hook()` / `deregister_hook()` |
| 会话级默认值 | `s.stream`、`s.hooks`（`{"response": [...]}`）生效，`request(stream=None)` 取会话默认 |
| 子模块 | `auth`（`HTTPBasicAuth`/`HTTPProxyAuth`）、`utils`（`cookiejar_from_dict`/`dict_from_cookiejar`/`get_encoding_from_headers`/`requote_uri`）、`cookies`、`adapters`、`hooks`、`models`、`sessions`、`status_codes` |
| auth 可调用对象 | `auth=lambda r: ...` / 自定义 `AuthBase` 子类都能用（与本库的元组写法并存） |

**没跟进来的部分（都是 requests 的内部实现，不是调用语法）**，用到了会明确报错或直接没有：

- `PreparedRequest.prepare_*`（`prepare_body`/`prepare_url`…）：本库由 `Session.prepare_request`
  一次性构造，不暴露这些内部步骤。
- `Session.resolve_redirects` / `get_redirect_target` / `should_strip_auth` / `rebuild_*`：
  重定向循环在 `Session.request` 里内联实现。
- `HTTPDigestAuth`：需要"401 后按 `WWW-Authenticate` 重发"，本库不重放请求体，调用会抛
  `NotImplementedRequestError`；`HTTPAdapter` 是占位（本库只有内置指纹传输层）。
- `requests.utils` / `requests.compat` / `requests.packages` / `requests.urllib3` 等内部模块
  及其泄漏到顶层的名字（`chardet_version`、`NullHandler`…）不提供。

### 指纹控制（requests 没有的额外关键字）

```python
Session(
    proxy="http://127.0.0.1:7892",     # 也支持 proxies={...} 和 HTTP(S)_PROXY 环境变量
    mode="cors",                       # navigate | cors | no-cors  —— 决定 sec-fetch-*
    dest="empty",                      # document|iframe|empty|script|style|image|font|preflight
    user_agent=None,                   # 覆盖 UA
    origin=None, referer=None,         # CORS / 子资源请求的上下文
    sec_fetch_site=None,               # none|same-origin|same-site|cross-site
    priority=None,                     # 覆盖 RFC 9218 priority 头
    send_priority_tree=False,          # 只有模拟 Chrome 152 才打开(153/154 都不发 PRIORITY 帧)
    verify=True, ca_file=None, allow_tls12=True,
    timeout=30, max_redirects=30, trust_env=True,
    keylog_file=None,                  # 写 NSS key log, Wireshark 可直接解密本库流量
)
```

单次请求也能覆盖：`s.get(url, mode="navigate", dest="document", referer=..., origin=..., priority=...)`。

`sec-fetch-site` 需要调用方给上下文（库不知道"发起方页面"是谁），默认：
顶层 document 导航 = `none`，其余 = `same-origin`；跨站时显式传 `cross-site`。

底层构造单条 ClientHello（不含网络）：

```python
from chrome_fp import build_client_hello, ja4_from_hello
ch = build_client_hello("example.com")
print(ja4_from_hello(ch.record), len(ch.record))
ch.record          # 可直接写 socket 的原始字节
```

- `include_mlkem=False`：不发 X25519MLKEM768
- `grease_parts=()`：完全关闭 GREASE
- `permute_extensions=False`：关闭扩展随机置换（调试用）

## 协议实现覆盖

### TLS

| 能力 | 状态 |
|---|---|
| TLS 1.3 / 1.2（同一 ClientHello 自动分派） | ✅ |
| 密码套件：AES-GCM / ChaCha20-Poly1305 / **AES-CBC-SHA** | ✅ |
| RSA 密钥交换（009c/009d/002f/0035） | ✅ |
| X25519MLKEM768 混合密钥共享（纯 Python ML-KEM-768） | ✅ |
| **HelloRetryRequest**（换 key_share 组 / cookie） | ✅ |
| 证书链校验（交叉签名根、EKU 缺失放行、通配符） | ✅ |
| CertificateVerify 验签、压缩证书（brotli）、ALPS | ✅ |
| **客户端证书 mTLS**（`cert=`，TLS 1.3 与 1.2） | ✅ |
| KeyUpdate / NewSessionTicket 容忍 | ✅ |
| SSLKEYLOGFILE 导出（Wireshark 可解密本库流量） | ✅ |
| 会话恢复（PSK / session ticket）、0-RTT、ECH 真加密 | ❌ 见下 |

`cert=` 的用法和 requests 一致：

```python
s.get(url, cert="client.pem")                  # 证书+私钥在同一个 PEM
s.get(url, cert=("client.pem", "client.key"))  # 分开两个文件
Session(cert=...)                              # 会话级
```

### HTTP/2

| 能力 | 状态 |
|---|---|
| Chrome 帧序（preface/SETTINGS/WINDOW_UPDATE/HEADERS，153/154 一致） | ✅ |
| **HEADERS 的 PRIORITY 前缀**（`0x25` + `E=1/dep=0/weight=urgency` 查表，153/154 都带） | ✅ |
| **TLS 记录分帧**（preface+SETTINGS+WINDOW_UPDATE 合成一条 record） | ✅ |
| **HPACK 编码选择与 Chromium 一致**（见下） | ✅ |
| **请求体流控**（连接窗口 + 流窗口，等 WINDOW_UPDATE） | ✅ |
| **尊重对端 SETTINGS_MAX_FRAME_SIZE / INITIAL_WINDOW_SIZE** | ✅ |
| **PUSH_PROMISE**（回 RST_STREAM(CANCEL)） | ✅ |
| CONTINUATION / 1xx / trailer / GOAWAY / RST_STREAM | ✅ |
| 流式响应（`stream=True`）与流式请求体（生成器） | ✅ |

**HPACK 为什么重要**：头**解码后**一样 ≠ **编码后**一样。JA3/JA4/Akamai 都不看 HPACK，
但服务器/中间盒 dump 一下 HEADERS 帧的原始载荷，就能用编码方式把客户端区分开。
`hpack_chromium.py` 复刻了 Chromium 的选择规则，规则是从真机抓包里**统计**出来的
（当时用 `tools/diff_wire.py` 复核，该脚本已丢失；本轮的探针 `tools/tap_probe.py`
能把每条连接的 HEADERS 原始载荷按序留档，可用来重做同样的比对）：

| 规则 | 证据（真 Chrome 153 抓包） |
|---|---|
| 静态表精确命中 → 索引表示 | `:method: GET`、`:scheme: https` |
| **`:method` / `:path` 命中不到精确项 → 字面量+不索引(0x04)** | OPTIONS 的 `:method`、所有 `:path` |
| 其余头 → 字面量+增量索引(0x40) | `cookie` / `referer` 实测都是 `0x44`（**不是**"从不索引"） |
| Huffman **择优，平局取原始字节** | `/?n=0` 平局→raw；`/style.css` 更短→Huffman |

验证方式是把抓包里每条连接上的请求**按原顺序重放**再逐块比字节（HPACK 动态表是连接级
状态，不按顺序重放比出来的差异是噪声）：真 Chrome 抓包 **32/32**、本库抓包 **11/11**
全部逐字节复现。

### Client Hints（Accept-CH）

网站用 `Accept-CH` 点单后，真 Chrome 会在**后续**请求里带上高熵 client hints。完全不发的话，
凡是下发过 `Accept-CH` 的站（不少 CDN 都发）一眼就能看出不是浏览器 —— 这比 JA3 更硬。
本库按规范跟踪每个源的 `Accept-CH`，命中后按 Chrome 的**内部固定顺序**回发
（顺序与取值来自探针抓包；本轮重写的探针 `tools/tap_probe.py` 会把每个请求的头顺序
原样记进 `probe.json`，可直接复核）。

```python
Session(client_hints=True)                            # 默认开
Session(client_hints=False)                           # 完全不发
Session(client_hint_values={"device-memory": "4"})    # 覆盖取值
```

> ⚠️ 如实说明：探针抓包里 UA 派生那几个（`full-version` / `full-version-list` / `arch` /
> `bitness` / `platform-version`）是**空串** —— 因为抓包时为让 UA 逐字节一致加了
> `--user-agent`，Chrome 会把 UA 派生的高熵 hint 置空。正常 UA 下它们有真实值，本库按
> Chrome 153 / Windows x86_64 填。**这几个取值没有抓包直接验证**，需要精确对齐时逐项覆盖。

### HTTP/1.1

| 能力 | 状态 |
|---|---|
| 定长 / chunked 响应、gzip/deflate/br/zstd | ✅ |
| **chunked 请求体**（生成器/无长度文件对象） | ✅ |
| **真流式响应**（`stream=True` + `iter_content`，增量解压） | ✅ |
| 连接复用（响应头标题大小写按 Chrome 习惯） | ✅ |
| `Expect: 100-continue` | ❌ 未实现（极少见） |

### 连接层

DNS 返回多个地址（尤其 IPv6 排在前面）时会**自己遍历并 IPv4 优先**，每个地址限时
5 秒、整体不超过 `timeout`。踩过的坑：`socket.create_connection` 按 DNS 顺序逐个用
**完整 timeout** 尝试，两个黑洞 IPv6 地址就能烧光整个预算 —— 实测 example.com 要 12 秒
才连上、`timeout` 小一点直接失败，而同一时刻 requests 只要 0.6 秒。修好后 1.1 秒。

## 还能被检测出来吗（现状）

分三层看，别混为一谈：

| 层 | 现状 |
|---|---|
| TLS/HTTP2 **内容**指纹（JA3/JA4/Akamai/扩展集合/头顺序） | 对齐（见上面各表） |
| **线上字节细节**（TLS 记录分帧、HPACK 编码） | **已对齐**（当时用 `tools/diff_wire.py` 复核 32/32、11/11；该脚本已丢失） |
| **Client Hints**（Accept-CH） | 机制已实现，UA 派生取值未直接抓包验证 |
| 行为 / JS / 跨层一致性 | **做不到**，见下 |

具体来说，下面这些仍然是"是不是浏览器"的硬伤，**不是指纹库能解决的**：

1. **不执行 JS**。Cloudflare / Akamai / DataDome / PerimeterX 最终都靠主动 JS 挑战 +
   行为判定；TLS 指纹只决定"别在门口就被拦"。
2. **行为不像人**。单发请求、无子资源、无缓存/条件请求、请求节奏固定、长期零会话恢复。
3. **跨层矛盾**：库声明 Windows 平台，但 TCP/IP 栈用的是宿主机 —— **在 Linux 上跑必然
   出现"Windows 浏览器 + Linux TCP 指纹"**，这一条比任何指纹都硬。（Windows 上自洽）
4. **Cookie 语义**仍未实现 `Secure` / `SameSite` / partitioned / `__Host-` 前缀与排序规则，
   服务器可以用一个 `Secure` cookie 测出来。
5. **无 HTTP 缓存**，从不发 `If-None-Match` / `If-Modified-Since`。
6. `sec-fetch-site` / `referer` / `origin` 依赖调用方给上下文，给错就与真实页面矛盾。

## 故意不实现的部分（附原因）

- **TLS 1.3 会话恢复（PSK / session ticket）**：恢复用的 ClientHello 会多出
  `pre_shared_key` 扩展，**JA4 与首次连接不同**。本库的真值来自 Chrome 全新 profile
  的首次连接（17 条真实扩展 + 2 GREASE、无 PSK），没有抓到 Chrome 的恢复握手样本。
  在没有真值的情况下实现，只会让指纹从"确定对"变成"可能错"，所以宁可不做。
  要拿真值需要在**同一个 profile** 里连打两次 —— 本轮的 `tools/run_capture.py` 每次都用
  全新临时 profile，所以抓不到 `pre_shared_key`，需要改造后才有样本。
- **0-RTT / early_data**：本库的 ClientHello 与真 Chrome 一样不带 `early_data`（0x002a），
  发早期数据会直接改变指纹。
- **ECH 真加密**：需要 DNS HTTPS 记录里的 ECHConfigList + HPKE。Chrome 只在站点发布
  ECH 时才真加密，本库一律发 ECH GREASE（与抓包一致）。
- **QUIC / HTTP/3**：要另写一整套 QUIC 传输（丢包恢复、拥塞控制、QPACK…），
  是另一个量级的工程；Chrome 走 TCP 时就是本库复刻的这套指纹。
- `Expect: 100-continue`：极少见，收益低。

## 测试

```bash
python -m pip install ".[test]"              # h2 是测试用的独立 HTTP/2 裁判
python -m unittest discover -s tests -v      # 97 个用例(80 通过 / 17 跳过)
python -m pytest tests -q                    # 同上, 输出更紧凑
```

> 没装 `h2` 时，用到它的两组用例（`test_http2_features` / `test_client_hints`）会**整组跳过**
> 而不是挂死 —— 之前缺 `h2` 会让服务线程直接崩掉、客户端一直等响应（CI 上卡了 10 分钟才被发现）；
> 现在配了 `timeout-minutes`，就算再挂也是 15 分钟失败。
> 需要抓包数据的用例（`test_chrome153_fingerprint.py`）找不到 `capture/chrome153/` 时也自动跳过。

| 测试文件 | 覆盖 |
|---|---|
| `test_profiles.py` | **多版本 profile**：153/154 注册与切换、旧常量兼容、JA4 前缀、HEADERS 优先级前缀 |
| `test_chrome153_fingerprint.py` | 把 ClientHello / 请求头钉死在真机抓包上（含 key_share 私钥配对回归；无抓包数据时跳过） |
| `test_hpack_chromium.py` | **HPACK 编码逐字节复现真 Chrome**（含动态表连续状态） |
| `test_client_hints.py` | Accept-CH 跟踪、高熵 hint 顺序/取值、cookie 位置 |
| `test_requests_api.py` | requests 语法、cookie jar、重定向、多地址连接 |
| `test_tls_features.py` | HRR、TLS1.2 CBC、mTLS（各自带本地 TLS 服务器） |
| `test_http2_features.py` | 请求体流控、MAX_FRAME_SIZE、PUSH_PROMISE（h2 库的流控检查当裁判） |
| `test_streaming.py` | chunked 上传、`stream=True` 真流式、增量解压 |

## 构建与发布

### 自动发版（GitHub Actions，推荐）

`.github/workflows/release.yml` —— **push 一个 `v*` tag 就自动发版**：

```
push tag v0.6.0
      └─ test    跑 unittest（97 个用例）
      └─ build   python -m build + twine check
                 tools/check_release.py --tag $TAG --dist dist
                 （tag / pyproject.toml / __init__.py 三者版本必须一致 + 产物内容检查）
                 sha256sum > dist/SHA256SUMS
      └─ release tools/make_release_notes.py 生成正文（含每个产物的 SHA256）
                 gh release create —— 上传 *.whl / *.tar.gz / SHA256SUMS
      └─ pypi    可选，见下
```

本机一条命令：

```bash
python tools/release.py patch        # 0.6.0 -> 0.6.1: 改版本号 + 跑测试 + 提交 + 打 tag + push
python tools/release.py minor        # 0.6.0 -> 0.7.0
python tools/release.py 0.7.0        # 指定版本
python tools/release.py patch --dry-run     # 只打印要做什么
```

`tools/release.py` 会先跑测试，测试不过就把版本号改回来并中止；之后 Actions 接管构建与上传，
本机不需要 twine。手动触发 `workflow_dispatch` 只跑测试+构建（产物留在 Actions artifact）。

`.github/workflows/ci.yml`：push 到 `main` / PR 时跑测试 + 构建校验（目前只跑 Python 3.12，
即本机验过的版本；要扩矩阵就改 `python-version` 列表）。

**PyPI 发布（本仓库已启用）**：tag 推送时会自动发到 PyPI —— PyPI 项目页已配好
Trusted Publisher（Repository=`DreamXiaoJing/chrome-fp`、Workflow=`release.yml`、
Environment=`pypi`），仓库变量 `ENABLE_PYPI` = `true`，用 OIDC 换 token，**没有任何长期密钥**。

> 换到别的仓库/账号要重做两步：① PyPI → 项目 → *Settings → Publishing* 里加 GitHub
> Trusted Publisher（owner / repo / workflow / environment 四项，和上面工作流一致）；
> ② 仓库 Settings → Variables 里加 `ENABLE_PYPI=true`。没配之前 `pypi` job 会自动跳过，
> 不会让流程变红。

### 手动构建（备用）

```bash
python -m pip install build wheel twine
python -m build                    # 产出 dist/chrome_fp-0.6.0-py3-none-any.whl 和 .tar.gz
python -m twine check dist/*       # README 渲染与元数据检查
python -m twine upload dist/*      # -u __token__ -p pypi-xxxx (建议用 TWINE_PASSWORD 环境变量)
python tools/check_release.py --tag v0.6.0 --dist dist   # 版本 + 产物自检
```

- **当前仓库版本：0.7.0**（在 0.6.0 基础上：与 requests 的 API 逐项对齐 + 新增
  Chrome for Testing 渠道 profile `154-cft` + zstd 无 content-size 的流式解压回退）
  —— 已提交，**尚未发 PyPI**
- 已发布：**chrome-fp 0.5.0**（<https://pypi.org/project/chrome-fp/0.5.0/>）
  —— Chromium 一致的 HPACK 编码 / TLS 记录分帧 / Accept-CH 高熵 hints
- 历史版本：0.4.0（HRR / TLS1.2 CBC / 客户端证书 / HTTP2 请求体流控 / 真流式）、
  0.3.0（Chrome 153 指纹对齐）、0.2.x（Chrome 152）

```bash
pip install chrome-fp             # 或者 pip install "chrome-fp[encoding]"
```

产物内容（0.6.0 实测）：

- wheel：`chrome_fp` 包 **17 个模块** + `LICENSE` + `METADATA`
- sdist：额外带 `tests/`（8 个测试文件），**不含 `tools/`** —— 旧 `MANIFEST.in` 在目录
  清理中丢失，需要把工具链也打进 sdist 的话得补回 `MANIFEST.in`（或写进
  `[tool.setuptools]` 的 `sdist` 配置）
- `capture/` 抓包数据不进任何产物

> 0.5.0 之前的构建/发布校验脚本（`tools/verify_build.py`、`tools/verify_pypi.py`）
> 已随目录清理丢失，本轮没有重建；上面列的是当前能跑的命令。

## 目录结构

```
chrome_fp/
  spec.py         多版本 profile（Profile 数据类 + PROFILES 注册表 + 153/154 常量 +
                  每种资源类型的头顺序，全部标注出处；旧的大写常量名继续可用）
  hello.py        按 BoringSSL ssl_add_clienthello_tlsext 规则拼 ClientHello
  fingerprint.py  解析 + JA3/JA4 计算
  tls13.py        纯 Python TLS 1.3 客户端（record 层/密钥调度/CV 校验/证书链校验/KeyUpdate/keylog）
  tls12.py        TLS 1.2 回落（ECDHE/RSA + GCM/ChaCha20/**CBC**, EMS, 客户端证书）
  clientcert.py   客户端证书加载 + 签名算法选择 + TLS1.2/1.3 证书消息构造
  client.py       发同一个 ClientHello, 按 ServerHello 自动分派 1.3 / 1.2
  mlkem.py        ML-KEM-768 (FIPS 203) 纯 Python
  http2.py        HTTP/2 客户端（帧序/SETTINGS/头顺序/请求体流控/推送/流式）
  hpack_chromium.py  HPACK 编码选择与 Chromium 一致(编码字节可逐字节复现)
  _huffman_table.py  RFC 7541 附录 B 的 Huffman 表(生成物, 不依赖 hpack 内部实现)
  http1.py        HTTP/1.1 客户端（Title-Case 头名 / chunked / 流式）
  session.py      requests 风格的 Session / Response / PreparedRequest
  api.py          模块级 get/post/...（与 requests 同名同签名）
  structures.py   CaseInsensitiveDict / RequestsCookieJar
  exceptions.py   与 requests.exceptions 对应的异常层次
  codes.py        requests.codes 等价物(状态码名 → 数字)
  auth.py         HTTPBasicAuth / HTTPProxyAuth(HTTPDigestAuth 明确未实现)
  utils.py        cookiejar_from_dict / dict_from_cookiejar / get_encoding_from_headers …
  adapters.py     HTTPAdapter 占位(本库只有内置指纹传输层)
  hooks.py        default_hooks / dispatch_hook
  models.py       requests.models 兼容(Request/PreparedRequest/Response)
  sessions.py     requests.sessions 兼容(Session/session())
  cookies.py      requests.cookies 兼容
  status_codes.py requests.status_codes 兼容
tools/            真机抓包探针(tap_probe/run_capture/launch_chrome) + 指纹解析分析
                  (analyze_hello) + 本库与真机对比(verify_against_probe)
                  + API 对齐审计(api_parity, 与真 requests 逐项比)
                  + 发版工具(release.py 改版本号打 tag / check_release.py 一致性校验 /
                  make_release_notes.py 生成 Release 正文)
                  + cdp.py(用 9222 上已开的 Chrome/Edge 做只读检查与表单填写)
.github/workflows/  release.yml（tag 自动发版）+ ci.yml（push/PR 跑测试与构建）
tests/            单元 + 与真机抓包的回归测试（97 个用例, 其中 17 个需要抓包数据）
capture/          真机抓包证据（chrome154* / chrome153cft / library154；.gitignore 未提交）
```

## 已知边界

- **`www.google.com` / `www.googleapis.com`**：握手能完成（JA4 一致），但 GFE 随后用
  `unexpected_message` 断链。本机到 Google 的 IP 被黑洞/污染（`tcp` 都连不上），
  暂时无法进一步定位。其余实测站点正常。
- **JA3 每次连接都不同**，这是真 Chrome 的行为（扩展顺序随机置换），不是 bug；
  稳定的标识是 **JA4**。
- 扩展顺序、GREASE 取值、ECH GREASE 长度**每次连接**都重新随机（与 Chrome 相同分布）。
- `trust_anchors` 的**顺序**是在**启动之间**变的（CfT 153 两次独立启动给出两个不同顺序），
  同一次启动内固定；**集合**恒定。本库发的是其中一个合法顺序（与 Stable 154 实测一致），
  所以集合一定对、顺序可能落在另一个排列上。
- 会话恢复 / 0-RTT / ECH 真加密 / QUIC-HTTP3 故意未实现，原因见上面「故意不实现的部分」。
- 连接策略是 **IPv4 优先**（Chrome 用 happy eyeballs 并发尝试）。对黑白洞 IPv6 的环境
  这是必要的取舍，但严格来说与 Chrome 的连接时序不同 —— 这不影响 TLS/HTTP2 指纹。

## 真值来源

1. 本机真 Chrome 的抓包：
   - **154.0.8037.98**（Windows x64, Stable）：22 条 ClientHello + 20 条 HTTP/2 连接，
     另有第二次启动复核 UA 品牌与 trust_anchors 顺序（`capture/chrome154*`）
   - **153.0.8010.48**（Windows x64, Stable）：22 条 ClientHello + 10 条 HTTP/2 连接，
     JA4 由 tshark 4.6.4 判定 —— **这批数据（`capture/chrome153/`）已在目录清理中丢失**，
     只剩结论；好在 153 的线格式与 154 完全一致（见上面的 A/B 表）
   - A/B 对照用的 Chrome for Testing **153.0.8010.47**（`capture/chrome153cft/`）；
     注意它是 Chromium 分支（`sec-ch-ua` 无 Google Chrome 品牌），且多一个构建特有的
     扩展 `0x12e0`，所以只用于对照 TLS/H2 线格式，不当作 Stable 153 的品牌基准
2. 抓包工具（本轮新增/重写，替代已删除的旧 tools）：
   - `tools/tap_probe.py` —— 本地 TLS/HTTP2 探针，记录 ClientHello 原字节 + 解密后的
     H2 帧与请求头（MemoryBIO 驱动，握手期的原始字节逐字节留档）
   - `tools/run_capture.py` —— 起探针 + 拉真 Chrome + 收日志的一条龙入口
   - `tools/analyze_hello.py` —— 解析 ClientHello/JA4/trust_anchors 并与 profile 逐项 diff
   - `tools/verify_against_probe.py` —— 把**本库**指向同一探针，和真 Chrome 对比字节
3. 本地 Chromium/BoringSSL 源码：
   - 扩展表顺序与置换：`boringssl/src/ssl/extensions.cc:4067-4295, 4306-4328`
   - GREASE 首尾与 padding 规则：`extensions.cc:4489-4560`
   - ECH GREASE 长度：`boringssl/src/ssl/encrypted_client_hello.cc:732-784`
   - trust_anchors(0xca34)：`include/openssl/tls1.h:141` + `extensions.cc:2948-2965`
   - 混合密钥共享拼接顺序：`boringssl/src/ssl/ssl_key_share.cc:308-346`
   - TLS 客户端配置：`net/socket/ssl_client_socket_impl.cc`
