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 +26 -0
- chrome_fp/client.py +113 -0
- chrome_fp/fingerprint.py +199 -0
- chrome_fp/hello.py +251 -0
- chrome_fp/http1.py +128 -0
- chrome_fp/http2.py +251 -0
- chrome_fp/mlkem.py +323 -0
- chrome_fp/session.py +544 -0
- chrome_fp/spec.py +164 -0
- chrome_fp/tls12.py +505 -0
- chrome_fp/tls13.py +714 -0
- chrome_fp-0.2.0.dist-info/METADATA +162 -0
- chrome_fp-0.2.0.dist-info/RECORD +15 -0
- chrome_fp-0.2.0.dist-info/WHEEL +5 -0
- chrome_fp-0.2.0.dist-info/top_level.txt +1 -0
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
|
chrome_fp/fingerprint.py
ADDED
|
@@ -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
|
+
)
|