@browsercore/testing 0.1.3 → 0.2.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.
- package/README.md +5 -0
- package/bin/coverage-md.mjs +99 -0
- package/captures/DIVERGENCE_REPORT.md +325 -0
- package/captures/IMPERSIONATION_GAPS_REPORT.md +367 -0
- package/captures/README.md +56 -0
- package/captures/REVIEW_FINDINGS.md +71 -0
- package/captures/_probe/analyze.py +67 -0
- package/captures/_probe/capture_bytes.py +127 -0
- package/captures/_probe/chrome131_determinism.py +32 -0
- package/captures/_probe/diagnose_capture.py +59 -0
- package/captures/_probe/golden_analysis.py +91 -0
- package/captures/_probe/inspect_golden.py +55 -0
- package/captures/_probe/inspect_sv.py +16 -0
- package/captures/_probe/make_golden.py +170 -0
- package/captures/_probe/output/chrome100.json +219 -0
- package/captures/_probe/output/chrome101.json +219 -0
- package/captures/_probe/output/chrome104.json +219 -0
- package/captures/_probe/output/chrome107.json +220 -0
- package/captures/_probe/output/chrome110.json +220 -0
- package/captures/_probe/output/chrome116.json +220 -0
- package/captures/_probe/output/chrome119.json +219 -0
- package/captures/_probe/output/chrome120.json +219 -0
- package/captures/_probe/output/chrome123.json +223 -0
- package/captures/_probe/output/chrome124.json +224 -0
- package/captures/_probe/output/chrome131.json +224 -0
- package/captures/_probe/output/chrome131_fresh.json +224 -0
- package/captures/_probe/output/chrome133a.json +224 -0
- package/captures/_probe/output/chrome136.json +224 -0
- package/captures/_probe/output/chrome99.json +219 -0
- package/captures/_probe/output/edge101.json +219 -0
- package/captures/_probe/output/edge99.json +219 -0
- package/captures/_probe/output/firefox133.json +227 -0
- package/captures/_probe/output/firefox135.json +230 -0
- package/captures/_probe/output/safari155.json +210 -0
- package/captures/_probe/output/safari170.json +214 -0
- package/captures/_probe/output/safari170_fresh.json +214 -0
- package/captures/_probe/output/safari180.json +216 -0
- package/captures/_probe/output/safari180_ios.json +216 -0
- package/captures/_probe/output/safari184.json +215 -0
- package/captures/_probe/output/safari260.json +211 -0
- package/captures/_probe/output/summary.json +2520 -0
- package/captures/_probe/parse_existing_settings.py +15 -0
- package/captures/_probe/peetprint.py +39 -0
- package/captures/_probe/probe.py +137 -0
- package/captures/_probe/raw_bytes/chrome-131_client_hello.bin +0 -0
- package/captures/_probe/raw_bytes/chrome-131_client_hello.hex +32 -0
- package/captures/_probe/raw_bytes/firefox-133_client_hello.bin +0 -0
- package/captures/_probe/raw_bytes/firefox-133_client_hello.hex +32 -0
- package/captures/_probe/raw_bytes/safari-17_client_hello.bin +0 -0
- package/captures/_probe/raw_bytes/safari-17_client_hello.hex +32 -0
- package/captures/_probe/relay_capture.py +137 -0
- package/captures/_probe/socks_capture.py +138 -0
- package/captures/_probe/validate_fresh.py +66 -0
- package/captures/_probe/validate_golden.py +97 -0
- package/captures/_probe/verify_capture.py +68 -0
- package/captures/chrome-131/http2/settings.bin +0 -0
- package/captures/chrome-131/http2/settings.meta.json +9 -0
- package/captures/chrome-131/oracle_capture.reference.json +224 -0
- package/captures/chrome-131/tls/client_hello.bin +0 -0
- package/captures/chrome-131/tls/client_hello.meta.json +20 -0
- package/captures/chrome-140/http2/settings.bin +0 -0
- package/captures/chrome-140/http2/settings.meta.json +9 -0
- package/captures/chrome-140/tls/client_hello.bin +0 -0
- package/captures/chrome-140/tls/client_hello.meta.json +12 -0
- package/captures/firefox-128/tls/client_hello.bin +0 -0
- package/captures/firefox-128/tls/client_hello.meta.json +12 -0
- package/captures/firefox-133/http2/settings.bin +0 -0
- package/captures/firefox-133/http2/settings.meta.json +9 -0
- package/captures/firefox-133/oracle_capture.reference.json +227 -0
- package/captures/firefox-133/tls/client_hello.bin +0 -0
- package/captures/firefox-133/tls/client_hello.meta.json +20 -0
- package/captures/safari-17/http2/settings.bin +0 -0
- package/captures/safari-17/http2/settings.meta.json +9 -0
- package/captures/safari-17/oracle_capture.reference.json +214 -0
- package/captures/safari-17/tls/client_hello.bin +0 -0
- package/captures/safari-17/tls/client_hello.meta.json +20 -0
- package/captures/test-malformed/http2/settings.bin +0 -0
- package/captures/test-malformed/http2/settings.meta.json +9 -0
- package/captures/test-no-extensions/tls/client_hello.bin +0 -0
- package/captures/test-no-extensions/tls/client_hello.meta.json +9 -0
- package/captures/test-wrong-type/http2/settings.bin +0 -0
- package/captures/test-wrong-type/http2/settings.meta.json +9 -0
- package/dist/bench/bench.d.ts +24 -6
- package/dist/bench/bench.d.ts.map +1 -1
- package/dist/bench/bench.js +110 -10
- package/dist/bench/bench.js.map +1 -1
- package/dist/captures/manifest.d.ts.map +1 -1
- package/dist/captures/manifest.js +7 -7
- package/dist/captures/manifest.js.map +1 -1
- package/dist/categories/compression.d.ts +6 -4
- package/dist/categories/compression.d.ts.map +1 -1
- package/dist/categories/compression.js +56 -6
- package/dist/categories/compression.js.map +1 -1
- package/dist/categories/cookies.d.ts +3 -2
- package/dist/categories/cookies.d.ts.map +1 -1
- package/dist/categories/cookies.js +216 -11
- package/dist/categories/cookies.js.map +1 -1
- package/dist/categories/redirects.d.ts +8 -2
- package/dist/categories/redirects.d.ts.map +1 -1
- package/dist/categories/redirects.js +97 -12
- package/dist/categories/redirects.js.map +1 -1
- package/dist/e2e/cert-gen.d.ts +31 -0
- package/dist/e2e/cert-gen.d.ts.map +1 -0
- package/dist/e2e/cert-gen.js +158 -0
- package/dist/e2e/cert-gen.js.map +1 -0
- package/dist/e2e/parse-clienthello.d.ts +177 -0
- package/dist/e2e/parse-clienthello.d.ts.map +1 -0
- package/dist/e2e/parse-clienthello.js +343 -0
- package/dist/e2e/parse-clienthello.js.map +1 -0
- package/dist/e2e/sink-server.d.ts +109 -0
- package/dist/e2e/sink-server.d.ts.map +1 -0
- package/dist/e2e/sink-server.js +292 -0
- package/dist/e2e/sink-server.js.map +1 -0
- package/dist/e2e/traffic-server.d.ts +48 -0
- package/dist/e2e/traffic-server.d.ts.map +1 -0
- package/dist/e2e/traffic-server.js +162 -0
- package/dist/e2e/traffic-server.js.map +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/fingerprint/index.d.ts +5 -2
- package/dist/fingerprint/index.d.ts.map +1 -1
- package/dist/fingerprint/index.js +3 -2
- package/dist/fingerprint/index.js.map +1 -1
- package/dist/fingerprint/ja3.d.ts.map +1 -1
- package/dist/fingerprint/ja3.js +33 -4
- package/dist/fingerprint/ja3.js.map +1 -1
- package/dist/fingerprint/ja4-errors.d.ts +17 -0
- package/dist/fingerprint/ja4-errors.d.ts.map +1 -0
- package/dist/fingerprint/ja4-errors.js +20 -0
- package/dist/fingerprint/ja4-errors.js.map +1 -0
- package/dist/fingerprint/ja4-reader.d.ts +44 -0
- package/dist/fingerprint/ja4-reader.d.ts.map +1 -0
- package/dist/fingerprint/ja4-reader.js +112 -0
- package/dist/fingerprint/ja4-reader.js.map +1 -0
- package/dist/fingerprint/ja4.d.ts +11 -43
- package/dist/fingerprint/ja4.d.ts.map +1 -1
- package/dist/fingerprint/ja4.js +54 -141
- package/dist/fingerprint/ja4.js.map +1 -1
- package/dist/fingerprint/ja4h.d.ts +46 -0
- package/dist/fingerprint/ja4h.d.ts.map +1 -0
- package/dist/fingerprint/ja4h.js +50 -0
- package/dist/fingerprint/ja4h.js.map +1 -0
- package/dist/golden/golden.d.ts +15 -1
- package/dist/golden/golden.d.ts.map +1 -1
- package/dist/golden/golden.js +42 -28
- package/dist/golden/golden.js.map +1 -1
- package/dist/index.d.ts +21 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +36 -1
- package/dist/index.js.map +1 -1
- package/dist/node-provider.d.ts +42 -0
- package/dist/node-provider.d.ts.map +1 -0
- package/dist/node-provider.js +53 -0
- package/dist/node-provider.js.map +1 -0
- package/dist/provider.d.ts +52 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +14 -0
- package/dist/provider.js.map +1 -0
- package/dist/reference/browser-provider.d.ts +33 -0
- package/dist/reference/browser-provider.d.ts.map +1 -0
- package/dist/reference/browser-provider.js +61 -0
- package/dist/reference/browser-provider.js.map +1 -0
- package/dist/reference/curl-provider.d.ts +40 -0
- package/dist/reference/curl-provider.d.ts.map +1 -0
- package/dist/reference/curl-provider.js +96 -0
- package/dist/reference/curl-provider.js.map +1 -0
- package/dist/reference/dump.d.ts +42 -0
- package/dist/reference/dump.d.ts.map +1 -0
- package/dist/reference/dump.js +102 -0
- package/dist/reference/dump.js.map +1 -0
- package/dist/reference/node-crypto-provider.d.ts +31 -0
- package/dist/reference/node-crypto-provider.d.ts.map +1 -0
- package/dist/reference/node-crypto-provider.js +199 -0
- package/dist/reference/node-crypto-provider.js.map +1 -0
- package/dist/reference/node-reference.d.ts.map +1 -1
- package/dist/reference/node-reference.js +2 -5
- package/dist/reference/node-reference.js.map +1 -1
- package/dist/reference/reference-errors.d.ts +36 -0
- package/dist/reference/reference-errors.d.ts.map +1 -0
- package/dist/reference/reference-errors.js +43 -0
- package/dist/reference/reference-errors.js.map +1 -0
- package/dist/reference/reference-types.d.ts +33 -0
- package/dist/reference/reference-types.d.ts.map +1 -0
- package/dist/reference/reference-types.js +9 -0
- package/dist/reference/reference-types.js.map +1 -0
- package/dist/reference/reference.d.ts +14 -77
- package/dist/reference/reference.d.ts.map +1 -1
- package/dist/reference/reference.js +43 -207
- package/dist/reference/reference.js.map +1 -1
- package/dist/rfc/rfcTests.d.ts +46 -8
- package/dist/rfc/rfcTests.d.ts.map +1 -1
- package/dist/rfc/rfcTests.js +203 -16
- package/dist/rfc/rfcTests.js.map +1 -1
- package/dist/types.d.ts +2 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils.d.ts +3 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +11 -5
- package/dist/utils.js.map +1 -1
- package/package.json +68 -55
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Capture real TLS ClientHello wire bytes from curl_cffi.
|
|
4
|
+
|
|
5
|
+
Strategy: run a local TCP server that accepts a connection, reads the first
|
|
6
|
+
chunk (which is always the TLS ClientHello), saves the raw bytes, then
|
|
7
|
+
closes. curl_cffi connects impersonating a target profile with verify=False
|
|
8
|
+
so it performs the TLS handshake against our server. We capture the bytes
|
|
9
|
+
before any handshake reply, so curl_cffi will error — but we already have
|
|
10
|
+
the ClientHello.
|
|
11
|
+
"""
|
|
12
|
+
import socket, ssl, threading, time, os, json, struct
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from curl_cffi import requests
|
|
15
|
+
|
|
16
|
+
BASE = "/Users/matte/projects/browsercore/testing/captures"
|
|
17
|
+
CAPTURED = os.path.join(BASE, "_probe", "raw_bytes")
|
|
18
|
+
os.makedirs(CAPTURED, exist_ok=True)
|
|
19
|
+
|
|
20
|
+
HOST = "127.0.0.1"
|
|
21
|
+
|
|
22
|
+
def capture_one(profile, port):
|
|
23
|
+
"""Server reads raw ClientHello bytes, then we trigger curl_cffi to connect."""
|
|
24
|
+
captured = {"bytes": None, "error": None}
|
|
25
|
+
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
26
|
+
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
27
|
+
server.bind((HOST, port))
|
|
28
|
+
server.listen(1)
|
|
29
|
+
server.settimeout(10)
|
|
30
|
+
|
|
31
|
+
def accept_and_read():
|
|
32
|
+
try:
|
|
33
|
+
conn, addr = server.accept()
|
|
34
|
+
conn.settimeout(5)
|
|
35
|
+
# The ClientHello is the first thing the client sends.
|
|
36
|
+
data = b""
|
|
37
|
+
# Read enough to get the full ClientHello (usually 200-600 bytes).
|
|
38
|
+
# We loop briefly to gather all initial flight bytes.
|
|
39
|
+
try:
|
|
40
|
+
while True:
|
|
41
|
+
chunk = conn.recv(4096)
|
|
42
|
+
if not chunk:
|
|
43
|
+
break
|
|
44
|
+
data += chunk
|
|
45
|
+
# If we have a full TLS record header, check length for the first record.
|
|
46
|
+
if len(data) >= 5:
|
|
47
|
+
rec_len = struct.unpack(">H", data[3:5])[0]
|
|
48
|
+
if len(data) >= 5 + rec_len:
|
|
49
|
+
break # got at least the first record fully
|
|
50
|
+
except socket.timeout:
|
|
51
|
+
pass
|
|
52
|
+
captured["bytes"] = data
|
|
53
|
+
try:
|
|
54
|
+
conn.close()
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
except Exception as e:
|
|
58
|
+
captured["error"] = f"server: {e}"
|
|
59
|
+
finally:
|
|
60
|
+
server.close()
|
|
61
|
+
|
|
62
|
+
t = threading.Thread(target=accept_and_read)
|
|
63
|
+
t.start()
|
|
64
|
+
time.sleep(0.3) # ensure server is listening
|
|
65
|
+
|
|
66
|
+
# Trigger curl_cffi connection to our local server.
|
|
67
|
+
url = f"https://{HOST}:{port}/"
|
|
68
|
+
try:
|
|
69
|
+
requests.get(url, impersonate=profile, verify=False, timeout=8,
|
|
70
|
+
headers={"Host": "example.com"})
|
|
71
|
+
except Exception as e:
|
|
72
|
+
# Expected — we don't complete the handshake. Record for diagnostics.
|
|
73
|
+
captured["client_error"] = str(e)[:300]
|
|
74
|
+
|
|
75
|
+
t.join(timeout=10)
|
|
76
|
+
return captured
|
|
77
|
+
|
|
78
|
+
def parse_client_hello_summary(data):
|
|
79
|
+
"""Quick parse to confirm it looks like a real ClientHello, return byte len + first bytes."""
|
|
80
|
+
if not data:
|
|
81
|
+
return {"error": "no bytes captured"}
|
|
82
|
+
info = {"total_bytes": len(data), "first_8": data[:8].hex()}
|
|
83
|
+
if data[0] == 0x16:
|
|
84
|
+
rec_ver = struct.unpack(">H", data[1:3])[0]
|
|
85
|
+
rec_len = struct.unpack(">H", data[3:5])[0]
|
|
86
|
+
hs_type = data[5] if len(data) > 5 else None
|
|
87
|
+
info["record_version"] = f"0x{rec_ver:04x}"
|
|
88
|
+
info["record_length"] = rec_len
|
|
89
|
+
info["handshake_type"] = f"0x{hs_type:02x}" if hs_type is not None else None
|
|
90
|
+
if hs_type == 0x01 and len(data) >= 11:
|
|
91
|
+
hs_len = struct.unpack(">I", b'\x00'+data[6:9])[0]
|
|
92
|
+
client_ver = struct.unpack(">H", data[9:11])[0]
|
|
93
|
+
info["handshake_length"] = hs_len
|
|
94
|
+
info["client_version"] = f"0x{client_ver:04x}"
|
|
95
|
+
info["random"] = data[11:43].hex()
|
|
96
|
+
return info
|
|
97
|
+
|
|
98
|
+
PROFILES = {
|
|
99
|
+
"chrome-131": "chrome131",
|
|
100
|
+
"firefox-133": "firefox133",
|
|
101
|
+
"safari-17": "safari170",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
PORT = 8443
|
|
105
|
+
|
|
106
|
+
for profile_name, impersonate in PROFILES.items():
|
|
107
|
+
print(f"\n=== Capturing {profile_name} (impersonate={impersonate}) ===")
|
|
108
|
+
cap = capture_one(impersonate, PORT)
|
|
109
|
+
if cap["bytes"]:
|
|
110
|
+
data = cap["bytes"]
|
|
111
|
+
out_path = os.path.join(CAPTURED, f"{profile_name}_client_hello.bin")
|
|
112
|
+
with open(out_path, "wb") as f:
|
|
113
|
+
f.write(data)
|
|
114
|
+
info = parse_client_hello_summary(data)
|
|
115
|
+
print(f" Captured {len(data)} bytes -> {out_path}")
|
|
116
|
+
print(f" Summary: {json.dumps(info, indent=4)}")
|
|
117
|
+
# Also dump full hex (first 200 bytes)
|
|
118
|
+
hexout = os.path.join(CAPTURED, f"{profile_name}_client_hello.hex")
|
|
119
|
+
with open(hexout, "w") as f:
|
|
120
|
+
for i in range(0, min(len(data), 512), 16):
|
|
121
|
+
chunk = data[i:i+16]
|
|
122
|
+
hexpart = " ".join(f"{b:02x}" for b in chunk)
|
|
123
|
+
f.write(f"{i:04x}: {hexpart}\n")
|
|
124
|
+
else:
|
|
125
|
+
print(f" FAILED: {cap.get('error')} / {cap.get('client_error')}")
|
|
126
|
+
|
|
127
|
+
print("\nDone.")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Probe chrome131 multiple times to confirm non-deterministic extension ordering."""
|
|
3
|
+
import json, os
|
|
4
|
+
from curl_cffi import requests
|
|
5
|
+
OUT="/Users/matte/projects/browsercore/testing/captures/_probe/output"
|
|
6
|
+
|
|
7
|
+
def exts_order(d):
|
|
8
|
+
return [e['name'] for e in d['tls']['extensions']]
|
|
9
|
+
|
|
10
|
+
orders = []
|
|
11
|
+
for i in range(4):
|
|
12
|
+
r = requests.get("https://tls.peet.ws/api/all", impersonate="chrome131", timeout=30)
|
|
13
|
+
d = r.json()
|
|
14
|
+
order = exts_order(d)
|
|
15
|
+
ja3 = d['tls']['ja3_hash']
|
|
16
|
+
orders.append((i, ja3, order))
|
|
17
|
+
print(f"probe {i}: ja3={ja3}")
|
|
18
|
+
print(f" exts: {order}")
|
|
19
|
+
|
|
20
|
+
# Compare orders
|
|
21
|
+
base = orders[0][2]
|
|
22
|
+
print("\n=== Are all 4 identical? ===")
|
|
23
|
+
all_same = all(o[2]==base for o in orders)
|
|
24
|
+
print(f"All identical: {all_same}")
|
|
25
|
+
for i,(_,ja3,order) in enumerate(orders):
|
|
26
|
+
if order != base:
|
|
27
|
+
print(f" probe {i} DIFFERS")
|
|
28
|
+
# show first difference
|
|
29
|
+
for j,(a,b) in enumerate(zip(base,order)):
|
|
30
|
+
if a!=b:
|
|
31
|
+
print(f" first diff at index {j}: baseline={a} this={b}")
|
|
32
|
+
break
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Carefully parse captured chrome-131 ClientHello and compare extension names to oracle."""
|
|
3
|
+
import os, json, struct
|
|
4
|
+
|
|
5
|
+
RAW = "/Users/matte/projects/browsercore/testing/captures/_probe/raw_bytes"
|
|
6
|
+
OUT = "/Users/matte/projects/browsercore/testing/captures/_probe/output"
|
|
7
|
+
|
|
8
|
+
EXT_NAMES = {0:"server_name",5:"status_request",10:"supported_groups",11:"ec_point_formats",
|
|
9
|
+
13:"signature_algorithms",16:"ALPN",18:"signed_certificate_timestamp",
|
|
10
|
+
21:"padding",23:"extended_master_secret",27:"compress_certificate",
|
|
11
|
+
28:"record_size_limit",34:"delegated_credentials",35:"session_ticket",
|
|
12
|
+
43:"supported_versions",45:"psk_key_exchange_modes",51:"key_share",
|
|
13
|
+
65037:"ECH",65281:"renegotiation_info",17513:"application_settings_old",
|
|
14
|
+
17613:"application_settings"}
|
|
15
|
+
def is_grease(v): return (v & 0x0f0f) == 0x0a0a
|
|
16
|
+
|
|
17
|
+
def parse_full(data):
|
|
18
|
+
"""Parse assuming single TLS record, return detailed extension list."""
|
|
19
|
+
assert data[0]==0x16 and data[5]==0x01, f"not a CH record: {data[:6].hex()}"
|
|
20
|
+
rec_len = struct.unpack(">H", data[3:5])[0]
|
|
21
|
+
hs_len = struct.unpack(">I", b'\x00'+data[6:9])[0]
|
|
22
|
+
pos = 9
|
|
23
|
+
end = 9 + hs_len
|
|
24
|
+
ver = struct.unpack(">H", data[pos:pos+2])[0]; pos+=2
|
|
25
|
+
rand = data[pos:pos+32]; pos+=32
|
|
26
|
+
sid_len=data[pos]; pos+=1+sid_len
|
|
27
|
+
cs_len = struct.unpack(">H", data[pos:pos+2])[0]; pos+=2
|
|
28
|
+
ciphers=[struct.unpack(">H", data[pos+i:pos+i+2])[0] for i in range(0,cs_len,2)]
|
|
29
|
+
pos+=cs_len
|
|
30
|
+
comp_len=data[pos]; pos+=1+comp_len
|
|
31
|
+
exts=[]
|
|
32
|
+
if pos+2<=end:
|
|
33
|
+
ext_len=struct.unpack(">H", data[pos:pos+2])[0]; pos+=2
|
|
34
|
+
ext_end=pos+ext_len
|
|
35
|
+
while pos+4<=ext_end:
|
|
36
|
+
et=struct.unpack(">H", data[pos:pos+2])[0]
|
|
37
|
+
el=struct.unpack(">H", data[pos+2:pos+4])[0]
|
|
38
|
+
pos+=4
|
|
39
|
+
exts.append((et, el, pos)) # pos = ext data start
|
|
40
|
+
pos+=el
|
|
41
|
+
print(f" ext_end={ext_end} final pos={pos} record_end={5+rec_len}")
|
|
42
|
+
return ver, ciphers, exts
|
|
43
|
+
|
|
44
|
+
with open(os.path.join(RAW,"chrome-131_client_hello.bin"),"rb") as f:
|
|
45
|
+
d=f.read()
|
|
46
|
+
ver, ciphers, exts = parse_full(d)
|
|
47
|
+
print(f"chrome-131 captured: ver=0x{ver:04x} {len(ciphers)} ciphers, {len(exts)} extensions")
|
|
48
|
+
print(f" last byte index used: ext data end check")
|
|
49
|
+
print("\n Extension list (with GREASE flag):")
|
|
50
|
+
for et,el,dp in exts:
|
|
51
|
+
g = " [GREASE]" if is_grease(et) else ""
|
|
52
|
+
print(f" 0x{et:04x} ({et}) = {EXT_NAMES.get(et,'?')} len={el}{g}")
|
|
53
|
+
|
|
54
|
+
# Compare to oracle
|
|
55
|
+
od = json.load(open(os.path.join(OUT,"chrome131.json")))
|
|
56
|
+
oracle_ext_names = [e['name'] for e in od['tls']['extensions']]
|
|
57
|
+
print(f"\n Oracle extension names ({len(oracle_ext_names)}):")
|
|
58
|
+
for n in oracle_ext_names:
|
|
59
|
+
print(f" {n}")
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Compute JA3/JA4 of the synthetic golden hellos + extract supported_versions/peetprint from curl-cffi."""
|
|
3
|
+
import os, json, hashlib, struct
|
|
4
|
+
|
|
5
|
+
CAPTURES = "/Users/matte/projects/browsercore/testing/captures"
|
|
6
|
+
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
|
|
7
|
+
|
|
8
|
+
def parse_client_hello_ja3(d):
|
|
9
|
+
"""Replicate our library's parseClientHello to get the 5 JA3 segments."""
|
|
10
|
+
pos = 5 # TLS record wrapper assumed
|
|
11
|
+
assert d[0] == 0x16 and d[5] == 0x01, "expected TLS-wrapped ClientHello"
|
|
12
|
+
handshake_len = struct.unpack(">I", b'\x00'+d[6:9])[0]
|
|
13
|
+
pos = 9
|
|
14
|
+
end = 9 + handshake_len
|
|
15
|
+
version = struct.unpack(">H", d[pos:pos+2])[0]; pos += 2
|
|
16
|
+
random_bytes = d[pos:pos+32]; pos += 32
|
|
17
|
+
sid_len = d[pos]; pos += 1 + sid_len
|
|
18
|
+
cs_len = struct.unpack(">H", d[pos:pos+2])[0]; pos += 2
|
|
19
|
+
ciphers = []
|
|
20
|
+
for i in range(0, cs_len, 2):
|
|
21
|
+
ciphers.append(struct.unpack(">H", d[pos+i:pos+i+2])[0])
|
|
22
|
+
pos += cs_len
|
|
23
|
+
comp_len = d[pos]; pos += 1 + comp_len
|
|
24
|
+
ext_types=[]; supported_groups=[]; ec_pf=[]
|
|
25
|
+
if pos + 2 <= end:
|
|
26
|
+
ext_len = struct.unpack(">H", d[pos:pos+2])[0]; pos += 2
|
|
27
|
+
ext_end = pos + ext_len
|
|
28
|
+
while pos + 4 <= ext_end:
|
|
29
|
+
et = struct.unpack(">H", d[pos:pos+2])[0]
|
|
30
|
+
el = struct.unpack(">H", d[pos+2:pos+4])[0]
|
|
31
|
+
pos += 4
|
|
32
|
+
ext_types.append(et)
|
|
33
|
+
if et == 0x000a and el >= 4:
|
|
34
|
+
ll = struct.unpack(">H", d[pos:pos+2])[0]
|
|
35
|
+
for i in range(0,ll,2): supported_groups.append(struct.unpack(">H", d[pos+2+i:pos+4+i])[0])
|
|
36
|
+
elif et == 0x000b and el >= 1:
|
|
37
|
+
ll = d[pos]
|
|
38
|
+
for i in range(ll): ec_pf.append(d[pos+1+i])
|
|
39
|
+
pos += el
|
|
40
|
+
return version, ciphers, ext_types, supported_groups, ec_pf, random_bytes
|
|
41
|
+
|
|
42
|
+
def compute_ja3(segments):
|
|
43
|
+
s = ",".join(segments)
|
|
44
|
+
return hashlib.md5(s.encode()).hexdigest(), s
|
|
45
|
+
|
|
46
|
+
print("="*60)
|
|
47
|
+
print("GOLDEN CLIENT HELLO ANALYSIS")
|
|
48
|
+
print("="*60)
|
|
49
|
+
for name, path in [("chrome-140", "chrome-140/tls/client_hello.bin"), ("firefox-128", "firefox-128/tls/client_hello.bin")]:
|
|
50
|
+
p = os.path.join(CAPTURES, path)
|
|
51
|
+
with open(p,"rb") as f: d=f.read()
|
|
52
|
+
ver, ciphers, exts, sg, ecpf, rnd = parse_client_hello_ja3(d)
|
|
53
|
+
ja3_str = [str(ver), "-".join(str(c) for c in ciphers), "-".join(str(e) for e in exts), "-".join(str(g) for g in sg), "-".join(str(f) for f in ecpf)]
|
|
54
|
+
md5, j3s = compute_ja3(ja3_str)
|
|
55
|
+
print(f"\n{name} (synthetic):")
|
|
56
|
+
print(f" client_version=0x{ver:04x}")
|
|
57
|
+
print(f" random={rnd.hex()}")
|
|
58
|
+
print(f" ciphers ({len(ciphers)}): {[hex(c) for c in ciphers]}")
|
|
59
|
+
print(f" extensions ({len(exts)}): {[hex(e) for e in exts]}")
|
|
60
|
+
print(f" supported_groups: {[hex(g) for g in sg]}")
|
|
61
|
+
print(f" ec_point_formats: {ecpf}")
|
|
62
|
+
print(f" JA3 string: {j3s}")
|
|
63
|
+
print(f" JA3 hash: {md5}")
|
|
64
|
+
print(f" GREASE present: {'YES' if any((c&0xf0f)==0xa0f for c in ciphers) or any((e&0xf0f)==0xa0f for e in exts) else 'NO'}")
|
|
65
|
+
|
|
66
|
+
# Now extract supported_versions + peetprint from curl-cffi
|
|
67
|
+
print("\n\n" + "="*60)
|
|
68
|
+
print("SUPPORTED_VERSIONS + PEETPRINT from curl-cffi (real)")
|
|
69
|
+
print("="*60)
|
|
70
|
+
def extract_sv_pp(profile):
|
|
71
|
+
with open(os.path.join(OUT, f"{profile}.json")) as f:
|
|
72
|
+
d = json.load(f)
|
|
73
|
+
tls = d["tls"]
|
|
74
|
+
sv = None
|
|
75
|
+
for e in tls.get("extensions", []):
|
|
76
|
+
if "supported_versions" in e.get("name",""):
|
|
77
|
+
sv = e.get("supported_versions")
|
|
78
|
+
break
|
|
79
|
+
return {
|
|
80
|
+
"ja3_hash": tls.get("ja3_hash"),
|
|
81
|
+
"ja4": tls.get("ja4"),
|
|
82
|
+
"supported_versions": sv,
|
|
83
|
+
"peetprint_hash": tls.get("peetprint_hash"),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for p in ["chrome131","chrome133a","chrome136","firefox133","firefox135","safari170","safari180","safari184","edge99","chrome124"]:
|
|
87
|
+
info = extract_sv_pp(p)
|
|
88
|
+
print(f"{p:14s}: supported_versions={info['supported_versions']} peetprint={info['peetprint_hash']} ja3={info['ja3_hash']} ja4={info['ja4']}")
|
|
89
|
+
|
|
90
|
+
# chrome-140 / firefox-128 synthetic peetprint equivalent
|
|
91
|
+
print("\nNOTE: synthetic golden hellos have no peetprint (they are stubs).")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Inspect existing golden capture binaries: hex dump + attempt JA3 parse."""
|
|
3
|
+
import os, json
|
|
4
|
+
CAPTURES = "/Users/matte/projects/browsercore/testing/captures"
|
|
5
|
+
|
|
6
|
+
def hexdump(path, maxbytes=200):
|
|
7
|
+
with open(path, "rb") as f:
|
|
8
|
+
data = f.read(maxbytes)
|
|
9
|
+
out = []
|
|
10
|
+
for i in range(0, len(data), 16):
|
|
11
|
+
chunk = data[i:i+16]
|
|
12
|
+
hexpart = " ".join(f"{b:02x}" for b in chunk)
|
|
13
|
+
ascpart = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
|
|
14
|
+
out.append(f"{i:04x}: {hexpart:<48s} {ascpart}")
|
|
15
|
+
return "\n".join(out)
|
|
16
|
+
|
|
17
|
+
# Load + dump each golden
|
|
18
|
+
for rel in ["chrome-140/tls/client_hello.bin", "firefox-128/tls/client_hello.bin", "chrome-140/http2/settings.bin"]:
|
|
19
|
+
p = os.path.join(CAPTURES, rel)
|
|
20
|
+
print(f"\n{'='*60}")
|
|
21
|
+
print(f"FILE: {rel} ({os.path.getsize(p)} bytes)")
|
|
22
|
+
print(f"{'='*60}")
|
|
23
|
+
print(hexdump(p))
|
|
24
|
+
meta = p.replace(".bin", ".meta.json")
|
|
25
|
+
if os.path.exists(meta):
|
|
26
|
+
with open(meta) as f:
|
|
27
|
+
m = json.load(f)
|
|
28
|
+
print(f" meta.profile={m.get('profile')} protocol={m.get('protocol')} record={m.get('record')} randomizedFields={m.get('randomizedFields')}")
|
|
29
|
+
|
|
30
|
+
# Try to parse chrome-140 client hello as TLS
|
|
31
|
+
print("\n\n=== ATTEMPT TLS PARSE chrome-140 client_hello.bin ===")
|
|
32
|
+
import struct
|
|
33
|
+
p = os.path.join(CAPTURES, "chrome-140/tls/client_hello.bin")
|
|
34
|
+
with open(p,"rb") as f:
|
|
35
|
+
d = f.read()
|
|
36
|
+
print(f"First 4 bytes: {d[:4].hex()}")
|
|
37
|
+
print(f"First byte: 0x{d[0]:02x} ({'TLS record 0x16' if d[0]==0x16 else 'handshake 0x01' if d[0]==0x01 else 'other'})")
|
|
38
|
+
# Try treating as TLS record
|
|
39
|
+
if d[0] == 0x16:
|
|
40
|
+
rec_ver = struct.unpack(">H", d[1:3])[0]
|
|
41
|
+
rec_len = struct.unpack(">H", d[3:5])[0]
|
|
42
|
+
hs_type = d[5]
|
|
43
|
+
print(f" record version=0x{rec_ver:04x} record_len={rec_len} handshake_type=0x{hs_type:02x}")
|
|
44
|
+
if hs_type == 0x01:
|
|
45
|
+
hs_len = struct.unpack(">I", b'\x00'+d[6:9])[0]
|
|
46
|
+
client_ver = struct.unpack(">H", d[9:11])[0]
|
|
47
|
+
print(f" handshake_len={hs_len} client_version=0x{client_ver:04x}")
|
|
48
|
+
print(f" random (32 bytes): {d[11:43].hex()}")
|
|
49
|
+
print(f" Remaining after random ({len(d)-43} bytes): {d[43:].hex()}")
|
|
50
|
+
elif d[0] == 0x01:
|
|
51
|
+
print("Bare handshake")
|
|
52
|
+
else:
|
|
53
|
+
print("NOT a standard TLS record/handshake header")
|
|
54
|
+
# Maybe it's just extension/cipher data? Show interpretation
|
|
55
|
+
print("Treating raw: version bytes:", d[:2].hex())
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import json
|
|
3
|
+
d = json.load(open('/Users/matte/projects/browsercore/testing/captures/_probe/output/chrome131.json'))
|
|
4
|
+
for e in d['tls']['extensions']:
|
|
5
|
+
if 'supported_versions' in e.get('name',''):
|
|
6
|
+
print('=== supported_versions extension raw keys ===')
|
|
7
|
+
print(list(e.keys()))
|
|
8
|
+
print(json.dumps(e, indent=2))
|
|
9
|
+
break
|
|
10
|
+
print()
|
|
11
|
+
print('tls keys:', list(d['tls'].keys()))
|
|
12
|
+
print('peetprint_hash:', d['tls'].get('peetprint_hash'))
|
|
13
|
+
# check if there's a peetprint string field anywhere
|
|
14
|
+
for k,v in d['tls'].items():
|
|
15
|
+
if 'peet' in k.lower():
|
|
16
|
+
print(f' {k}: {str(v)[:200]}')
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Generate golden capture files for 3 new profiles under captures/<profile>/.
|
|
4
|
+
Produces: tls/client_hello.bin + .meta.json, http2/settings.bin + .meta.json.
|
|
5
|
+
SETTINGS frames are reconstructed from oracle data (they are non-randomized).
|
|
6
|
+
"""
|
|
7
|
+
import os, json, struct, shutil
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
CAPTURES = "/Users/matte/projects/browsercore/testing/captures"
|
|
11
|
+
RAW = os.path.join(CAPTURES, "_probe", "raw_bytes")
|
|
12
|
+
ORACLE = os.path.join(CAPTURES, "_probe", "output")
|
|
13
|
+
|
|
14
|
+
CREATED_AT = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
15
|
+
|
|
16
|
+
# SETTINGS ID mapping (IANA HTTP/2 SETTINGS identifiers)
|
|
17
|
+
SETTING_IDS = {
|
|
18
|
+
"HEADER_TABLE_SIZE": 0x0001,
|
|
19
|
+
"ENABLE_PUSH": 0x0002,
|
|
20
|
+
"MAX_CONCURRENT_STREAMS": 0x0003,
|
|
21
|
+
"INITIAL_WINDOW_SIZE": 0x0004,
|
|
22
|
+
"MAX_FRAME_SIZE": 0x0005,
|
|
23
|
+
"MAX_HEADER_LIST_SIZE": 0x0006,
|
|
24
|
+
"ENABLE_CONNECT_PROTOCOL": 0x0008,
|
|
25
|
+
"NO_RFC7540_PRIORITIES": 0x0009,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
def build_settings_frame(settings_list):
|
|
29
|
+
"""Build raw HTTP/2 SETTINGS frame bytes from ['KEY = VALUE', ...]."""
|
|
30
|
+
payload = b""
|
|
31
|
+
for s in settings_list:
|
|
32
|
+
k, v = s.split(" = ", 1)
|
|
33
|
+
sid = SETTING_IDS[k]
|
|
34
|
+
payload += struct.pack(">H", sid) + struct.pack(">I", int(v))
|
|
35
|
+
length = len(payload)
|
|
36
|
+
# Frame header: 3-byte length, 1-byte type (0x04=SETTINGS), 1-byte flags, 4-byte stream id (0)
|
|
37
|
+
header = struct.pack(">I", length)[1:] + bytes([0x04, 0x00]) + struct.pack(">I", 0)
|
|
38
|
+
return header + payload
|
|
39
|
+
|
|
40
|
+
def write_meta(path, profile, protocol, record, description, randomized_fields):
|
|
41
|
+
meta = {
|
|
42
|
+
"source": "curl-impersonate",
|
|
43
|
+
"profile": profile,
|
|
44
|
+
"protocol": protocol,
|
|
45
|
+
"record": record,
|
|
46
|
+
"description": description,
|
|
47
|
+
"randomizedFields": randomized_fields,
|
|
48
|
+
"createdAt": CREATED_AT,
|
|
49
|
+
}
|
|
50
|
+
with open(path, "w") as f:
|
|
51
|
+
json.dump(meta, f, indent=4)
|
|
52
|
+
f.write("\n")
|
|
53
|
+
|
|
54
|
+
# Profile definitions: (oracle_profile, golden_name, client_hello_bin, randomized_fields_for_ch, settings_list)
|
|
55
|
+
PROFILES = []
|
|
56
|
+
|
|
57
|
+
# Build from captured data
|
|
58
|
+
def build_profile(oracle_name, golden_name, randomized_fields):
|
|
59
|
+
cap_bin = os.path.join(RAW, f"{golden_name}_client_hello.bin")
|
|
60
|
+
if not os.path.exists(cap_bin):
|
|
61
|
+
print(f" SKIP {golden_name}: no capture bytes")
|
|
62
|
+
return
|
|
63
|
+
with open(cap_bin, "rb") as d:
|
|
64
|
+
ch_bytes = d.read()
|
|
65
|
+
# load oracle for settings
|
|
66
|
+
oracle_path = os.path.join(ORACLE, f"{oracle_name}.json")
|
|
67
|
+
if not os.path.exists(oracle_path):
|
|
68
|
+
oracle_path = os.path.join(ORACLE, f"{oracle_name}_fresh.json")
|
|
69
|
+
with open(oracle_path) as f:
|
|
70
|
+
od = json.load(f)
|
|
71
|
+
settings_list = None
|
|
72
|
+
for fr in od["http2"]["sent_frames"]:
|
|
73
|
+
if fr["frame_type"] == "SETTINGS":
|
|
74
|
+
settings_list = fr["settings"]
|
|
75
|
+
break
|
|
76
|
+
if settings_list is None:
|
|
77
|
+
print(f" SKIP {golden_name}: no SETTINGS in oracle")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
base = os.path.join(CAPTURES, golden_name)
|
|
81
|
+
tls_dir = os.path.join(base, "tls")
|
|
82
|
+
h2_dir = os.path.join(base, "http2")
|
|
83
|
+
os.makedirs(tls_dir, exist_ok=True)
|
|
84
|
+
os.makedirs(h2_dir, exist_ok=True)
|
|
85
|
+
|
|
86
|
+
# TLS client_hello
|
|
87
|
+
ch_path = os.path.join(tls_dir, "client_hello.bin")
|
|
88
|
+
with open(ch_path, "wb") as f:
|
|
89
|
+
f.write(ch_bytes)
|
|
90
|
+
write_meta(
|
|
91
|
+
ch_path.replace(".bin", ".meta.json"),
|
|
92
|
+
golden_name, "tls", "client_hello",
|
|
93
|
+
f"TLS 1.3 ClientHello generated by curl-impersonate against {od['tls'].get('extensions',[{}])[2].get('server_name','example.com') if False else 'tls.peet.ws'} impersonating {oracle_name}. Captured via transparent SOCKS5 relay with correct SNI. Fields below are intentionally randomized per RFC 8446/8701 and must be masked during golden comparison (Cat 14).",
|
|
94
|
+
randomized_fields,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# HTTP/2 settings
|
|
98
|
+
settings_bytes = build_settings_frame(settings_list)
|
|
99
|
+
settings_path = os.path.join(h2_dir, "settings.bin")
|
|
100
|
+
with open(settings_path, "wb") as f:
|
|
101
|
+
f.write(settings_bytes)
|
|
102
|
+
write_meta(
|
|
103
|
+
settings_path.replace(".bin", ".meta.json"),
|
|
104
|
+
golden_name, "http2", "settings",
|
|
105
|
+
f"First HTTP/2 SETTINGS frame sent by curl-impersonate {oracle_name}. HTTP/2 SETTINGS are not randomized, so the ignore-list is empty.",
|
|
106
|
+
[],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Also dump the full oracle capture as a reference JSON sidecar
|
|
110
|
+
ref_path = os.path.join(base, "oracle_capture.reference.json")
|
|
111
|
+
with open(ref_path, "w") as f:
|
|
112
|
+
json.dump(od, f, indent=2)
|
|
113
|
+
|
|
114
|
+
print(f" CREATED {golden_name}: client_hello.bin={len(ch_bytes)}B, settings.bin={len(settings_bytes)}B")
|
|
115
|
+
print(f" settings frame: {settings_list}")
|
|
116
|
+
return base
|
|
117
|
+
|
|
118
|
+
# chrome-131: non-deterministic ordering — mask ALL extension-related randomized ranges.
|
|
119
|
+
# Since the entire extension block order varies, we mask extensions (after session_id+random+ciphers+comp)
|
|
120
|
+
# and treat client_random + ephemeral as randomized. We compute offsets from actual bytes.
|
|
121
|
+
def chrome131_randomized(ch_bytes):
|
|
122
|
+
"""Compute randomized field ranges for chrome131 (everything that varies)."""
|
|
123
|
+
pos = 9 # after record(5)+hs_type(1)+hs_len(3)
|
|
124
|
+
ver = struct.unpack(">H", ch_bytes[pos:pos+2])[0]; pos += 2
|
|
125
|
+
# random at offset 12 (absolute), length 32
|
|
126
|
+
pos += 32
|
|
127
|
+
sid = ch_bytes[pos]; pos += 1 + sid
|
|
128
|
+
csl = struct.unpack(">H", ch_bytes[pos:pos+2])[0]; pos += 2
|
|
129
|
+
ciphers_start = pos
|
|
130
|
+
pos += csl
|
|
131
|
+
comp = ch_bytes[pos]; pos += 1 + comp
|
|
132
|
+
# extensions start here (absolute offset = pos). All of it varies in order + GREASE values.
|
|
133
|
+
ext_start = pos
|
|
134
|
+
return [
|
|
135
|
+
{"byteOffset": 12, "length": 32, "reason": "random"},
|
|
136
|
+
{"byteOffset": ext_start, "length": len(ch_bytes) - ext_start, "reason": "grease"},
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
def generic_randomized(ch_bytes):
|
|
140
|
+
"""random + grease for deterministic profiles (firefox, safari)."""
|
|
141
|
+
pos = 9
|
|
142
|
+
pos += 2 # ver
|
|
143
|
+
# random at offset 12
|
|
144
|
+
pos += 32
|
|
145
|
+
sid = ch_bytes[pos]; pos += 1 + sid
|
|
146
|
+
csl = struct.unpack(">H", ch_bytes[pos:pos+2])[0]; pos += 2
|
|
147
|
+
pos += csl
|
|
148
|
+
comp = ch_bytes[pos]; pos += 1 + comp
|
|
149
|
+
ext_start = pos
|
|
150
|
+
return [
|
|
151
|
+
{"byteOffset": 12, "length": 32, "reason": "random"},
|
|
152
|
+
{"byteOffset": ext_start, "length": len(ch_bytes) - ext_start, "reason": "grease"},
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
print("=== Generating golden captures ===\n")
|
|
156
|
+
|
|
157
|
+
for oracle_name, golden_name, rf_func in [
|
|
158
|
+
("chrome131", "chrome-131", chrome131_randomized),
|
|
159
|
+
("firefox133", "firefox-133", generic_randomized),
|
|
160
|
+
("safari170", "safari-17", generic_randomized),
|
|
161
|
+
]:
|
|
162
|
+
print(f"--- {golden_name} ---")
|
|
163
|
+
cap_bin = os.path.join(RAW, f"{golden_name}_client_hello.bin")
|
|
164
|
+
with open(cap_bin, "rb") as f:
|
|
165
|
+
ch = f.read()
|
|
166
|
+
rf = rf_func(ch)
|
|
167
|
+
print(f" randomized fields: {rf}")
|
|
168
|
+
build_profile(oracle_name, golden_name, rf)
|
|
169
|
+
|
|
170
|
+
print("\nDone.")
|