Metadata-Version: 2.4
Name: chrome-fp
Version: 0.3.0
Summary: 与真 Chrome 153 逐字节同指纹(JA4/HTTP2 Akamai/请求头顺序)的纯 Python HTTP 请求库, 用法与 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: 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（**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()
```

## 本轮做了什么：拿真机抓包把库对齐

本机是非管理员，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` 即可，分析流程完全一样。

### 抓出来的差异（已全部修掉）

| # | 项目 | 真 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 前缀 | 带 5 字节 weight 前缀 | 默认不写 |
| 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 完全一致**。

### 验证结果

```bash
python tools/verify_live.py
```

让**本库**走同一个代理访问同一个本地 h2 服务器，再和 Chrome 的抓包对比：

```
[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 类请求的头顺序与取值全部逐条相同
```

## 实测结果

| 项目 | 真 Chrome 153 | 本库 |
|---|---|---|
| JA4 | `t13d1517h2_8daaf6152771_<随机>` | **一致**（17/17 条样本） |
| HTTP/2 Akamai | `1:65536;2:0;4:6291456;6:262144\|15663105\|0\|m,a,s,p` | **一致**（10/10 条连接） |
| 扩展数 | 17 真实 + 2 GREASE | **一致** |
| trust_anchors | 28 个 ID（集合固定、顺序随机） | **一致** |
| 请求头顺序/取值 | 导航与子资源各一套 | **一致**（逐条比对） |

实网站点：`python tools/live_sites.py` → 5/5
（example.com / taobao h2+TLS1.3，baidu / qq / sohu TLS1.2 回落 + HTTP/1.1）

## 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 没有的额外关键字）

```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 才打开
    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`：关闭扩展随机置换（调试用）

## 测试

```bash
python -m unittest discover -s tests -v      # 38 个用例
python tools/verify_live.py                  # 端到端: 本库 vs 真 Chrome 抓包
python tools/live_sites.py                   # 实网站点冒烟
```

`tests/test_chrome153_fingerprint.py` 直接读 `capture/chrome153/` 的真机数据，
把库的 ClientHello 与请求头钉死在真 Chrome 上（没有抓包数据会自动跳过）。

## 构建

```bash
python -m pip install build wheel
python -m build                 # 产出 dist/chrome_fp-0.3.0-py3-none-any.whl 和 .tar.gz
python tools/verify_build.py    # 校验产物(清单/METADATA/隔离安装冒烟/sdist 自举)
```

`verify_build.py` 会把 wheel 解到临时目录，用**那个副本**跑一遍真实功能
（拼 ClientHello、算 JA4、requests 风格 prepare_request），确保校验的不是源码树；
再确认 sdist 解包后能自己重新构建出 wheel。

- wheel 里只有 `chrome_fp` 包（14 个模块）+ LICENSE + METADATA
- sdist 额外带 `tests/`、`tools/`，但不含体积大的 `capture/` 抓包数据
- `chrome_fp.zip` 是同样内容的便携 zip（源码 + README + pyproject + LICENSE）

## 目录结构

```
chrome_fp/
  spec.py         Chrome 153 指纹常量 + 每种资源类型的头顺序 profile（全部标注出处）
  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, EMS, ServerKeyExchange 验签）
  client.py       发同一个 ClientHello, 按 ServerHello 自动分派 1.3 / 1.2
  mlkem.py        ML-KEM-768 (FIPS 203) 纯 Python
  http2.py        HTTP/2 客户端（Chrome 帧序/SETTINGS/头顺序）
  http1.py        HTTP/1.1 客户端（ALPN=http/1.1 时, 头名用 Title-Case）
  session.py      requests 风格的 Session / Response / PreparedRequest
  api.py          模块级 get/post/...（与 requests 同名同签名）
  structures.py   CaseInsensitiveDict / RequestsCookieJar
  exceptions.py   与 requests.exceptions 对应的异常层次
tools/            抓包(代理/本地h2服务器/解密)、分析(tshark/HTTP2)、对比、实网冒烟
tests/            单元 + 与真机抓包的回归测试
capture/          本轮真 Chrome 153 的抓包与判定结果
```

## 已知边界

- **TLS 1.2 回落已实现**（ECDHE + AES-GCM / ChaCha20-Poly1305，含 RSA 密钥交换、
  EMS/ALPN/证书校验）。用**同一个 ClientHello** 回落，所以 JA4 等指纹不变。
  不支持：CBC 类套件（极少见，会明确报错）、会话恢复、0-RTT、客户端证书。
- **客户端证书**未实现：传 `cert=` 会抛 `NotImplementedRequestError`（不静默忽略）。
- **`files=` 的 multipart/form-data** 已实现；`stream=` 只是接收（响应始终缓存，
  `iter_content` 仍可用）。
- **已知问题**：`www.google.com` / `www.googleapis.com` 握手能完成（JA4 一致），
  但 GFE 随后用 `unexpected_message` 断链 —— 它在等某个特定形态的客户端消息。
  其余实测站点正常。
- **JA3 每次连接都不同**，这是真 Chrome 的行为（扩展顺序随机置换），不是 bug；
  稳定的标识是 **JA4**。
- 扩展顺序、GREASE 取值、ECH GREASE 长度、trust_anchors 顺序都是每次连接随机的
  （与 Chrome 相同分布）。
- QUIC/HTTP3 未实现（Chrome 走 TCP 时就是这套指纹）。

## 真值来源

1. 本机真 Chrome **153.0.8010.48**（Windows x64）的抓包：22 条 ClientHello +
   10 条 HTTP/2 连接（`capture/chrome153/`），JA4 由 tshark 4.6.4 判定
2. 本地 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`
