chrome-fp 0.2.0__py3-none-any.whl

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.
chrome_fp/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """chrome_fp —— 与真 Chrome 逐字节同指纹的纯 Python HTTP 请求库
2
+
3
+ 用法:
4
+ from chrome_fp import Session
5
+ s = Session()
6
+ r = s.get("https://tls.peet.ws/api/all")
7
+ print(r.json()["tls"]["ja4"])
8
+ """
9
+
10
+ from .session import Session
11
+ from .hello import ClientHello, build_client_hello
12
+ from .fingerprint import ja3_from_hello, ja4_from_hello, parse_client_hello
13
+ from .spec import CHROME_MAJOR, CHROME_VERSION
14
+
15
+ __all__ = [
16
+ "Session",
17
+ "ClientHello",
18
+ "build_client_hello",
19
+ "ja3_from_hello",
20
+ "ja4_from_hello",
21
+ "parse_client_hello",
22
+ "CHROME_MAJOR",
23
+ "CHROME_VERSION",
24
+ ]
25
+
26
+ __version__ = "0.2.0"
chrome_fp/client.py ADDED
@@ -0,0 +1,113 @@
1
+ """连接分派: 发同一个 Chrome ClientHello, 读第一条 ServerHello, 自动走 TLS 1.3 或回落到 TLS 1.2。
2
+
3
+ 真 Chrome 遇到只支持 1.2 的服务器也是用同一个 ClientHello 回落的(我们发的 ClientHello 里
4
+ supported_versions 已含 0x0303、密码套件里也有 TLS1.2 套件), 所以**指纹完全不变**。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import socket
10
+ import struct
11
+ import sys
12
+
13
+ from . import tls12, tls13
14
+ from .hello import ClientHello
15
+
16
+
17
+ def parse_server_hello_exts(msg: bytes) -> tuple[dict[int, bytes], int]:
18
+ """返回 (扩展字典, 套件号)"""
19
+ body = msg[4:]
20
+ p = 2 + 32
21
+ sid_len = body[p]
22
+ p += 1 + sid_len
23
+ suite = struct.unpack(">H", body[p:p + 2])[0]
24
+ p += 2
25
+ comp_len = body[p]
26
+ p += 1 + comp_len
27
+ exts: dict[int, bytes] = {}
28
+ if p + 2 <= len(body):
29
+ ext_total = struct.unpack(">H", body[p:p + 2])[0]
30
+ p += 2
31
+ end = min(p + ext_total, len(body))
32
+ while p + 4 <= end:
33
+ et, el = struct.unpack(">HH", body[p:p + 4])
34
+ exts[et] = body[p + 4:p + 4 + el]
35
+ p += 4 + el
36
+ return exts, suite
37
+
38
+
39
+ def is_tls13(msg: bytes) -> bool:
40
+ exts, _ = parse_server_hello_exts(msg)
41
+ return exts.get(0x002B, b"")[:2] == b"\x03\x04"
42
+
43
+
44
+ def open_connection(
45
+ sock: socket.socket,
46
+ client_hello: ClientHello,
47
+ *,
48
+ verify_certs: bool = True,
49
+ ca_file: str | None = None,
50
+ timeout: float | None = 30.0,
51
+ allow_tls12: bool = True,
52
+ debug: bool = False,
53
+ ):
54
+ """发 ClientHello 并完成握手; 返回 TLS13Connection 或 TLS12Connection(接口一致)"""
55
+ if timeout is not None:
56
+ sock.settimeout(timeout)
57
+ sock.sendall(client_hello.record)
58
+ # 注: RFC 8446 D.4 的"CH 后立刻发 dummy CCS"实测会把只支持 TLS 1.2 的服务器搞坏
59
+ # (它们会把这个 CCS 当成密钥切换), 所以只在确认是 TLS 1.3 之后才发 —— 见 tls13.py
60
+ # 里发送 client Finished 之前那一条。
61
+
62
+ # ---- 预读第一条握手消息(用于判断版本) ----
63
+ raw = b""
64
+ hs_buf = b""
65
+ first: bytes | None = None
66
+ while first is None:
67
+ while len(raw) < 5:
68
+ chunk = sock.recv(65536)
69
+ if not chunk:
70
+ raise tls13.TLSException("连接被对端关闭")
71
+ raw += chunk
72
+ ctype = raw[0]
73
+ length = struct.unpack(">H", raw[3:5])[0]
74
+ while len(raw) < 5 + length:
75
+ chunk = sock.recv(65536)
76
+ if not chunk:
77
+ raise tls13.TLSException("连接被对端关闭")
78
+ raw += chunk
79
+ payload = raw[5:5 + length]
80
+ raw = raw[5 + length:]
81
+ if ctype == tls13.CT_CHANGE_CIPHER_SPEC:
82
+ continue
83
+ if ctype == tls13.CT_ALERT:
84
+ raise tls13.TLSException(f"收到 alert: {payload.hex()}")
85
+ hs_buf += payload
86
+ if len(hs_buf) >= 4:
87
+ msglen = int.from_bytes(hs_buf[1:4], "big")
88
+ if len(hs_buf) >= 4 + msglen:
89
+ first = hs_buf[:4 + msglen]
90
+ hs_buf = hs_buf[4 + msglen:]
91
+
92
+ if first[0] != tls13.HS_SERVER_HELLO:
93
+ raise tls13.TLSException(f"期待 ServerHello, 收到消息类型 {first[0]}")
94
+
95
+ tls13_ok = is_tls13(first)
96
+ if debug:
97
+ print(f"[client] ServerHello -> {'TLS1.3' if tls13_ok else 'TLS1.2'}", file=sys.stderr)
98
+
99
+ if tls13_ok:
100
+ conn = tls13.TLS13Connection(
101
+ sock, client_hello, verify_certs=verify_certs, ca_file=ca_file, timeout=timeout,
102
+ ch_sent=True, server_hello_msg=first, hs_buf=hs_buf, preload_buf=raw,
103
+ )
104
+ else:
105
+ if not allow_tls12:
106
+ raise tls13.TLSException("服务器只支持 TLS 1.2, 而 allow_tls12=False")
107
+ conn = tls12.TLS12Connection(
108
+ sock, client_hello, server_hello_msg=first, hs_buf=hs_buf, preload_buf=raw,
109
+ verify_certs=verify_certs, ca_file=ca_file, timeout=timeout,
110
+ )
111
+ alpn = conn.do_handshake()
112
+ conn.tls_version = "1.3" if tls13_ok else "1.2"
113
+ return conn
@@ -0,0 +1,199 @@
1
+ """ClientHello 解析 + JA3 / JA4 计算 (用于自检: 生成的握手是否与真 Chrome 一致)"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import struct
6
+ from hashlib import md5, sha256
7
+
8
+ GREASE = {0x0A0A + 0x1010 * i for i in range(16)}
9
+
10
+ EXT_NAME = {
11
+ 0x0000: "server_name", 0x0005: "status_request", 0x000A: "supported_groups",
12
+ 0x000B: "ec_point_formats", 0x000D: "signature_algorithms", 0x0010: "alpn",
13
+ 0x0012: "signed_certificate_timestamp", 0x0015: "padding", 0x0017: "extended_master_secret",
14
+ 0x001B: "compress_certificate", 0x001C: "record_size_limit", 0x0023: "session_ticket",
15
+ 0x002B: "supported_versions", 0x002D: "psk_key_exchange_modes", 0x0033: "key_share",
16
+ 0x44CD: "application_settings(ALPS)", 0x4469: "application_settings(old)",
17
+ 0xCA34: "trust_anchors", 0xFE0D: "encrypted_client_hello(GREASE)", 0xFF01: "renegotiation_info",
18
+ }
19
+
20
+ GROUP_NAME = {
21
+ 0x001D: "x25519", 0x0017: "secp256r1", 0x0018: "secp384r1", 0x0019: "secp521r1",
22
+ 0x001E: "x448", 0x11EC: "X25519MLKEM768", 0x6399: "X25519Kyber768Draft00",
23
+ 0x0100: "ffdhe2048", 0x0101: "ffdhe3072", 0x0102: "ffdhe4096",
24
+ }
25
+
26
+
27
+ def _u16(b: bytes, p: int) -> int:
28
+ return struct.unpack(">H", b[p:p + 2])[0]
29
+
30
+
31
+ def parse_client_hello(record: bytes) -> dict:
32
+ """解析 record 层字节, 返回结构化信息"""
33
+ body = record[5:]
34
+ p = 4
35
+ out: dict = {
36
+ "record_len": len(record),
37
+ "legacy_version": body[p:p + 2].hex(),
38
+ }
39
+ p += 2
40
+ out["random"] = body[p:p + 32].hex()
41
+ p += 32
42
+ sid_len = body[p]
43
+ out["session_id"] = body[p + 1:p + 1 + sid_len].hex()
44
+ p += 1 + sid_len
45
+ cs_len = _u16(body, p)
46
+ p += 2
47
+ ciphers = [body[p + i:p + i + 2].hex() for i in range(0, cs_len, 2)]
48
+ out["ciphers"] = ciphers
49
+ p += cs_len
50
+ comp_len = body[p]
51
+ out["compressions"] = body[p + 1:p + 1 + comp_len].hex()
52
+ p += 1 + comp_len
53
+ ext_total = _u16(body, p)
54
+ p += 2
55
+ end = p + ext_total
56
+ exts: list[tuple[int, bytes]] = []
57
+ while p + 4 <= end:
58
+ et = _u16(body, p)
59
+ el = _u16(body, p + 2)
60
+ exts.append((et, body[p + 4:p + 4 + el]))
61
+ p += 4 + el
62
+ out["extensions"] = exts
63
+ out["ext_types"] = [et for et, _ in exts]
64
+ out["ext_names"] = [
65
+ f"0x{et:04x}" + ("(GREASE)" if et in GREASE else f"({EXT_NAME.get(et, '?')})")
66
+ for et, _ in exts
67
+ ]
68
+ out["handshake_len"] = int.from_bytes(body[1:4], "big")
69
+
70
+ d = dict(exts)
71
+ if 0x000A in d:
72
+ n = _u16(d[0x000A], 0)
73
+ out["supported_groups"] = [
74
+ GROUP_NAME.get(_u16(d[0x000A], 2 + i), f"0x{_u16(d[0x000A], 2 + i):04x}")
75
+ for i in range(0, n, 2)
76
+ ]
77
+ if 0x000D in d:
78
+ n = _u16(d[0x000D], 0)
79
+ out["signature_algorithms"] = [f"0x{_u16(d[0x000D], 2 + i):04x}" for i in range(0, n, 2)]
80
+ if 0x002B in d:
81
+ n = d[0x002B][0]
82
+ out["supported_versions"] = [f"0x{_u16(d[0x002B], 1 + i):04x}" for i in range(0, n, 2)]
83
+ if 0x0033 in d:
84
+ b = d[0x0033]
85
+ total = _u16(b, 0)
86
+ ks, q = [], 2
87
+ while q < 2 + total:
88
+ g = _u16(b, q)
89
+ kl = _u16(b, q + 2)
90
+ ks.append({"group": GROUP_NAME.get(g, f"0x{g:04x}"), "len": kl})
91
+ q += 4 + kl
92
+ out["key_shares"] = ks
93
+ if 0x0000 in d:
94
+ b = d[0x0000]
95
+ name_len = _u16(b, 3) # [0:2]=list len, [2]=type, [3:5]=name len, [5:]=name
96
+ out["sni"] = b[5:5 + name_len].decode("ascii", "replace")
97
+ if 0x0010 in d:
98
+ b = d[0x0010]
99
+ n = _u16(b, 0)
100
+ protos, i = [], 2
101
+ while i < 2 + n:
102
+ ln = b[i]
103
+ protos.append(b[i + 1:i + 1 + ln].decode())
104
+ i += 1 + ln
105
+ out["alpn"] = protos
106
+ if 0xFE0D in d:
107
+ out["ech_len"] = len(d[0xFE0D])
108
+ out["ech_config_id"] = d[0xFE0D][5]
109
+ if 0xCA34 in d:
110
+ b = d[0xCA34]
111
+ inner = b[2:]
112
+ ids, i = [], 0
113
+ while i < len(inner):
114
+ ln = inner[i]
115
+ ids.append(inner[i + 1:i + 1 + ln].hex())
116
+ i += 1 + ln
117
+ out["trust_anchor_count"] = len(ids)
118
+ out["trust_anchor_set"] = sorted(ids)
119
+ if 0x001B in d:
120
+ out["compress_certificate"] = d[0x001B].hex()
121
+ if 0x44CD in d:
122
+ out["alps"] = d[0x44CD].hex()
123
+ if 0x0005 in d:
124
+ out["status_request"] = d[0x0005].hex()
125
+ return out
126
+
127
+
128
+ def ja3_from_hello(record: bytes) -> tuple[str, str]:
129
+ info = parse_client_hello(record)
130
+ ciphers_d = parse_client_hello(record)["ciphers"]
131
+ ciphers = ",".join(c for c in ciphers_d if int(c, 16) not in GREASE)
132
+ exts = ",".join(str(e) for e in info["ext_types"] if e not in GREASE)
133
+ d = dict(info["extensions"])
134
+ curves = ""
135
+ if 0x000A in d:
136
+ n = _u16(d[0x000A], 0)
137
+ curves = ",".join(
138
+ str(_u16(d[0x000A], 2 + i)) for i in range(0, n, 2)
139
+ if _u16(d[0x000A], 2 + i) not in GREASE
140
+ )
141
+ fmt = ""
142
+ if 0x000B in d:
143
+ b = d[0x000B]
144
+ fmt = ",".join(str(x) for x in b[1:1 + b[0]])
145
+ s = f"{ciphers},{exts},{curves},{fmt}"
146
+ return s, md5(s.encode()).hexdigest()
147
+
148
+
149
+ def ja4_from_hello(record: bytes) -> str:
150
+ """JA4 — 按 FoxIO 官方实现 (python/ja4.py to_ja4):
151
+ 扩展列表带 0x 前缀、升序; 签名算法按出现顺序、不带前缀; 二者用 "_" 连接; GREASE 全部剔除。
152
+ """
153
+ info = parse_client_hello(record)
154
+ d = dict(info["extensions"])
155
+ ciphers = sorted(c for c in info["ciphers"] if int(c, 16) not in GREASE)
156
+ exts = sorted(f"0x{e:04x}" for e in info["ext_types"] if e not in GREASE)
157
+ sigalgs: list[str] = []
158
+ if 0x000D in d:
159
+ n = _u16(d[0x000D], 0)
160
+ sigalgs = [
161
+ f"{_u16(d[0x000D], 2 + i):04x}"
162
+ for i in range(0, n, 2)
163
+ if _u16(d[0x000D], 2 + i) not in GREASE
164
+ ]
165
+ alpn = ""
166
+ if 0x0010 in d:
167
+ b = d[0x0010]
168
+ n = _u16(b, 0)
169
+ if n >= 2:
170
+ ln = b[2]
171
+ alpn = b[3:3 + ln].decode("ascii", "replace")
172
+
173
+ def h12(s: str) -> str:
174
+ return sha256(s.encode()).hexdigest()[:12]
175
+
176
+ a = "t" + "13" + ("d" if 0x0000 in d else "i") + f"{len(ciphers):02d}" + f"{len(exts):02d}" + (alpn or "00")
177
+ b = h12(",".join(ciphers))
178
+ ext_str = ",".join(exts)
179
+ if sigalgs:
180
+ ext_str += "_" + ",".join(sigalgs)
181
+ c = h12(ext_str)
182
+ return f"{a}_{b}_{c}"
183
+
184
+
185
+ def describe(record: bytes) -> str:
186
+ info = parse_client_hello(record)
187
+ _, j3 = ja3_from_hello(record)
188
+ lines = [
189
+ f"record_len={info['record_len']} handshake_len={info['handshake_len']}",
190
+ f"ciphers({len(info['ciphers'])}): {info['ciphers']}",
191
+ f"extensions({len(info['ext_types'])}): {info['ext_names']}",
192
+ f"groups: {info.get('supported_groups')}",
193
+ f"versions: {info.get('supported_versions')}",
194
+ f"key_shares: {info.get('key_shares')}",
195
+ f"sni: {info.get('sni')} alpn: {info.get('alpn')}",
196
+ f"ja3={j3}",
197
+ f"ja4={ja4_from_hello(record)}",
198
+ ]
199
+ return "\n".join(lines)
chrome_fp/hello.py ADDED
@@ -0,0 +1,251 @@
1
+ """按 Chrome/BoringSSL 源码规则逐字节构造 ClientHello。
2
+
3
+ 结构完全照 ssl_add_clienthello_tlsext (extensions.cc:4462+) 实现:
4
+ 1. 先写一个空的 GREASE 扩展 (ssl_grease_extension1)
5
+ 2. 按 kExtensions[] 表顺序(或 Fisher-Yates 随机置换后的顺序)逐个尝试添加扩展
6
+ 3. 最后写一个 1 字节的 GREASE 扩展 (ssl_grease_extension2)
7
+ 4. 长度落在 (0xff, 0x200) 时补 padding 扩展 (本库通常用不到, 保留规则)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import struct
14
+ from dataclasses import dataclass, field
15
+
16
+ from cryptography.hazmat.primitives.asymmetric import x25519
17
+
18
+ from . import mlkem, spec
19
+
20
+ HANDSHAKE_CLIENT_HELLO = 1
21
+ SSL3_HM_HEADER_LENGTH = 4
22
+
23
+
24
+ def _u8(x: int) -> bytes:
25
+ return struct.pack(">B", x)
26
+
27
+
28
+ def _u16(x: int) -> bytes:
29
+ return struct.pack(">H", x)
30
+
31
+
32
+ def _u16len(b: bytes) -> bytes:
33
+ return _u16(len(b)) + b
34
+
35
+
36
+ def _u8len(b: bytes) -> bytes:
37
+ return _u8(len(b)) + b
38
+
39
+
40
+ def _grease() -> int:
41
+ return spec.GREASE_VALUES[os.urandom(1)[0] % len(spec.GREASE_VALUES)]
42
+
43
+
44
+ @dataclass
45
+ class ClientHello:
46
+ """构造结果 + 完成握手所需的密钥材料"""
47
+
48
+ record: bytes
49
+ handshake: bytes
50
+ host: str
51
+ random: bytes
52
+ session_id: bytes
53
+ key_share_private_x25519: x25519.X25519PrivateKey
54
+ x25519_public: bytes
55
+ mlkem_ek: bytes
56
+ mlkem_dk: bytes
57
+ grease: dict = field(default_factory=dict)
58
+
59
+ @property
60
+ def ja3(self) -> tuple[str, str]:
61
+ from .fingerprint import ja3_from_hello
62
+
63
+ return ja3_from_hello(self.record)
64
+
65
+ def explain(self) -> str:
66
+ from .fingerprint import describe
67
+
68
+ return describe(self.record)
69
+
70
+
71
+ GREASE_PARTS = ("cipher", "ext", "group", "version", "sigalg", "keyshare")
72
+
73
+
74
+ def _extension_bodies(host: str, grease: dict, greases: dict, include_mlkem: bool = True,
75
+ grease_parts: tuple[str, ...] = GREASE_PARTS) -> dict[int, bytes]:
76
+ """返回 {扩展类型: 内容}, 内容与真 Chrome 完全一致"""
77
+ ek, dk = mlkem.keygen()
78
+ greases["mlkem_ek"] = ek
79
+ greases["mlkem_dk"] = dk
80
+
81
+ priv = x25519.X25519PrivateKey.generate()
82
+ pub = priv.public_key().public_bytes_raw()
83
+ greases["x25519_priv"] = priv
84
+ greases["x25519_pub"] = pub
85
+
86
+ bodies: dict[int, bytes] = {}
87
+
88
+ # server_name
89
+ host_b = host.encode("idna") if any(ord(c) > 127 for c in host) else host.encode()
90
+ bodies[0x0000] = _u16len(_u8(0x00) + _u16len(host_b))
91
+
92
+ # encrypted_client_hello (ECH GREASE): encrypted_client_hello.cc:732-784
93
+ config_id = os.urandom(1)
94
+ payload_len = 32 * (4 + os.urandom(1)[0] % 4) + 16 # {144,176,208,240}
95
+ ech = (
96
+ _u8(0x00) # type = outer
97
+ + _u16(0x0001) # kdf_id = HKDF-SHA256
98
+ + _u16(0x0001) # aead_id = AES-128-GCM
99
+ + config_id # config_id (random)
100
+ + _u16len(os.urandom(32)) # enc (x25519 public, 随机)
101
+ + _u16len(os.urandom(payload_len)) # payload (随机)
102
+ )
103
+ bodies[0xFE0D] = ech
104
+
105
+ bodies[0x0017] = b"" # extended_master_secret
106
+ bodies[0xFF01] = b"\x00" # renegotiation_info
107
+ groups = spec.SUPPORTED_GROUPS if include_mlkem else [g for g in spec.SUPPORTED_GROUPS if g != 0x11EC]
108
+ if "group" in grease_parts:
109
+ groups = [grease["group"], *groups]
110
+ bodies[0x000A] = _u16len(b"".join(_u16(g) for g in groups)) # supported_groups
111
+ bodies[0x000B] = b"\x01\x00" # ec_point_formats: uncompressed
112
+ bodies[0x0023] = b"" # session_ticket (empty)
113
+ bodies[0x0010] = _u16len(b"".join(_u8len(p) for p in spec.ALPN_PROTOCOLS))
114
+ bodies[0x0005] = b"\x01\x00\x00\x00\x00" # status_request (OCSP)
115
+ sigalgs = spec.SIGNATURE_ALGORITHMS if "sigalg" not in grease_parts else [grease["sigalg"], *spec.SIGNATURE_ALGORITHMS]
116
+ bodies[0x000D] = _u16len(b"".join(_u16(s) for s in sigalgs)) # signature_algorithms
117
+ bodies[0x0012] = b"" # signed_certificate_timestamp (empty)
118
+ ks = b""
119
+ if "keyshare" in grease_parts:
120
+ # GREASE 组值必须与 supported_groups 里的 GREASE 相同
121
+ # (OpenSSL extensions_srvr.c:703 会因 key_share 的组不在 supported_groups 里而报 illegal_parameter;
122
+ # 真 Chrome 两处本来就是同一个值, 见 8 份抓包样本)
123
+ ks += _u16(grease["group"]) + _u16len(b"\x00") # GREASE key share
124
+ if include_mlkem:
125
+ ks += _u16(0x11EC) + _u16len(ek + pub) # X25519MLKEM768
126
+ ks += _u16(0x001D) + _u16len(pub) # x25519
127
+ bodies[0x0033] = _u16len(ks) # key_share
128
+ bodies[0x002D] = b"\x01\x01" # psk_key_exchange_modes: psk_dhe_ke
129
+ versions = spec.SUPPORTED_VERSIONS if "version" not in grease_parts else [grease["version"], *spec.SUPPORTED_VERSIONS]
130
+ bodies[0x002B] = _u8len(b"".join(_u16(v) for v in versions)) # supported_versions
131
+ bodies[0x001B] = b"\x02\x00\x02" # compress_certificate: brotli
132
+ bodies[0x44CD] = _u16len(_u8len(b"h2")) # ALPS
133
+
134
+ # trust_anchors: 32 个 ID 随机排序
135
+ ids = [bytes.fromhex(x) for x in spec.TRUST_ANCHOR_IDS]
136
+ ids = _shuffle(ids)
137
+ blob = b"".join(_u8len(i) for i in ids)
138
+ bodies[0xCA34] = _u16len(blob)
139
+
140
+ return bodies
141
+
142
+
143
+ def _shuffle(items: list) -> list:
144
+ out = list(items)
145
+ for i in range(len(out) - 1, 0, -1):
146
+ j = os.urandom(4)[0] % (i + 1) if False else int.from_bytes(os.urandom(4), "big") % (i + 1)
147
+ out[i], out[j] = out[j], out[i]
148
+ return out
149
+
150
+
151
+ def _permutation(n: int) -> list[int]:
152
+ """BoringSSL ssl_setup_extension_permutation (extensions.cc:4306-4328) 的 Fisher-Yates"""
153
+ perm = list(range(n))
154
+ seeds = [int.from_bytes(os.urandom(4), "big") for _ in range(n - 1)]
155
+ for i in range(n - 1, 0, -1):
156
+ j = seeds[i - 1] % (i + 1)
157
+ perm[i], perm[j] = perm[j], perm[i]
158
+ return perm
159
+
160
+
161
+ def build_client_hello(
162
+ host: str,
163
+ *,
164
+ permute_extensions: bool = True,
165
+ include_grease: bool = True,
166
+ include_mlkem: bool = True,
167
+ grease_parts: tuple[str, ...] | None = None,
168
+ ) -> ClientHello:
169
+ """生成 record 层字节 + 握手密钥材料"""
170
+ grease = {
171
+ "cipher": _grease(),
172
+ "ext1": _grease(),
173
+ "ext2": _grease(),
174
+ "group": _grease(),
175
+ "version": _grease(),
176
+ "sigalg": _grease(),
177
+ }
178
+ materials: dict = {}
179
+ grease["keyshare_group"] = grease["group"] # key_share 与 supported_groups 共用同一个 GREASE 组值
180
+ if grease_parts is None:
181
+ grease_parts = GREASE_PARTS if include_grease else ()
182
+ if not include_grease:
183
+ grease_parts = ()
184
+ # GREASE 的 key_share 与 supported_groups 必须成对出现(否则服务器报 illegal_parameter)
185
+ if "keyshare" in grease_parts and "group" not in grease_parts:
186
+ grease_parts = (*grease_parts, "group")
187
+ bodies = _extension_bodies(host, grease, materials, include_mlkem=include_mlkem,
188
+ grease_parts=grease_parts)
189
+
190
+ client_random = os.urandom(32)
191
+ session_id = os.urandom(32)
192
+
193
+ hello = bytearray()
194
+ hello += b"\x03\x03" # legacy_version = TLS1.2
195
+ hello += client_random
196
+ hello += _u8len(session_id) # 32 字节随机 session id
197
+ ciphers = ([grease["cipher"]] if "cipher" in grease_parts else []) + spec.CIPHER_SUITES
198
+ hello += _u16len(b"".join(_u16(c) for c in ciphers))
199
+ hello += _u8len(b"\x00") # compression: null
200
+
201
+ # ---- 扩展 ----
202
+ exts = bytearray()
203
+ if include_grease:
204
+ exts += _u16(grease["ext1"]) + _u16(0) # 空 GREASE 扩展, 永远第一个
205
+
206
+ order = _permutation(spec.NUM_EXT_SLOTS) if permute_extensions else list(range(spec.NUM_EXT_SLOTS))
207
+ last_was_empty = False
208
+ for idx in order:
209
+ ext_type = spec.EXT_TABLE[idx]
210
+ body = bodies.get(ext_type)
211
+ if body is None:
212
+ continue
213
+ exts += _u16(ext_type) + _u16len(body)
214
+ last_was_empty = len(body) == 0
215
+
216
+ if include_grease:
217
+ exts += _u16(grease["ext2"]) + _u16len(b"\x00") # 1 字节 GREASE 扩展, 永远最后
218
+ last_was_empty = False
219
+
220
+ # ---- padding 规则 (extensions.cc:4527-4560) ----
221
+ msg_len = SSL3_HM_HEADER_LENGTH + len(hello) + 2 + len(exts)
222
+ padding_len = 0
223
+ if last_was_empty:
224
+ padding_len = 1
225
+ msg_len += 4 + padding_len
226
+ if 0xFF < msg_len < 0x200:
227
+ if padding_len:
228
+ msg_len -= 4 + padding_len
229
+ padding_len = 0x200 - msg_len
230
+ if padding_len == 0:
231
+ padding_len = 1
232
+ if padding_len:
233
+ exts += _u16(0x0015) + _u16len(b"\x00" * padding_len)
234
+
235
+ hello += _u16len(bytes(exts))
236
+
237
+ handshake = _u8(HANDSHAKE_CLIENT_HELLO) + len(hello).to_bytes(3, "big") + bytes(hello)
238
+ record = b"\x16\x03\x01" + _u16(len(handshake)) + handshake
239
+
240
+ return ClientHello(
241
+ record=record,
242
+ handshake=handshake,
243
+ host=host,
244
+ random=client_random,
245
+ session_id=session_id,
246
+ key_share_private_x25519=materials["x25519_priv"],
247
+ x25519_public=materials["x25519_pub"],
248
+ mlkem_ek=materials["mlkem_ek"],
249
+ mlkem_dk=materials["mlkem_dk"],
250
+ grease=grease,
251
+ )