@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.
Files changed (201) hide show
  1. package/README.md +5 -0
  2. package/bin/coverage-md.mjs +99 -0
  3. package/captures/DIVERGENCE_REPORT.md +325 -0
  4. package/captures/IMPERSIONATION_GAPS_REPORT.md +367 -0
  5. package/captures/README.md +56 -0
  6. package/captures/REVIEW_FINDINGS.md +71 -0
  7. package/captures/_probe/analyze.py +67 -0
  8. package/captures/_probe/capture_bytes.py +127 -0
  9. package/captures/_probe/chrome131_determinism.py +32 -0
  10. package/captures/_probe/diagnose_capture.py +59 -0
  11. package/captures/_probe/golden_analysis.py +91 -0
  12. package/captures/_probe/inspect_golden.py +55 -0
  13. package/captures/_probe/inspect_sv.py +16 -0
  14. package/captures/_probe/make_golden.py +170 -0
  15. package/captures/_probe/output/chrome100.json +219 -0
  16. package/captures/_probe/output/chrome101.json +219 -0
  17. package/captures/_probe/output/chrome104.json +219 -0
  18. package/captures/_probe/output/chrome107.json +220 -0
  19. package/captures/_probe/output/chrome110.json +220 -0
  20. package/captures/_probe/output/chrome116.json +220 -0
  21. package/captures/_probe/output/chrome119.json +219 -0
  22. package/captures/_probe/output/chrome120.json +219 -0
  23. package/captures/_probe/output/chrome123.json +223 -0
  24. package/captures/_probe/output/chrome124.json +224 -0
  25. package/captures/_probe/output/chrome131.json +224 -0
  26. package/captures/_probe/output/chrome131_fresh.json +224 -0
  27. package/captures/_probe/output/chrome133a.json +224 -0
  28. package/captures/_probe/output/chrome136.json +224 -0
  29. package/captures/_probe/output/chrome99.json +219 -0
  30. package/captures/_probe/output/edge101.json +219 -0
  31. package/captures/_probe/output/edge99.json +219 -0
  32. package/captures/_probe/output/firefox133.json +227 -0
  33. package/captures/_probe/output/firefox135.json +230 -0
  34. package/captures/_probe/output/safari155.json +210 -0
  35. package/captures/_probe/output/safari170.json +214 -0
  36. package/captures/_probe/output/safari170_fresh.json +214 -0
  37. package/captures/_probe/output/safari180.json +216 -0
  38. package/captures/_probe/output/safari180_ios.json +216 -0
  39. package/captures/_probe/output/safari184.json +215 -0
  40. package/captures/_probe/output/safari260.json +211 -0
  41. package/captures/_probe/output/summary.json +2520 -0
  42. package/captures/_probe/parse_existing_settings.py +15 -0
  43. package/captures/_probe/peetprint.py +39 -0
  44. package/captures/_probe/probe.py +137 -0
  45. package/captures/_probe/raw_bytes/chrome-131_client_hello.bin +0 -0
  46. package/captures/_probe/raw_bytes/chrome-131_client_hello.hex +32 -0
  47. package/captures/_probe/raw_bytes/firefox-133_client_hello.bin +0 -0
  48. package/captures/_probe/raw_bytes/firefox-133_client_hello.hex +32 -0
  49. package/captures/_probe/raw_bytes/safari-17_client_hello.bin +0 -0
  50. package/captures/_probe/raw_bytes/safari-17_client_hello.hex +32 -0
  51. package/captures/_probe/relay_capture.py +137 -0
  52. package/captures/_probe/socks_capture.py +138 -0
  53. package/captures/_probe/validate_fresh.py +66 -0
  54. package/captures/_probe/validate_golden.py +97 -0
  55. package/captures/_probe/verify_capture.py +68 -0
  56. package/captures/chrome-131/http2/settings.bin +0 -0
  57. package/captures/chrome-131/http2/settings.meta.json +9 -0
  58. package/captures/chrome-131/oracle_capture.reference.json +224 -0
  59. package/captures/chrome-131/tls/client_hello.bin +0 -0
  60. package/captures/chrome-131/tls/client_hello.meta.json +20 -0
  61. package/captures/chrome-140/http2/settings.bin +0 -0
  62. package/captures/chrome-140/http2/settings.meta.json +9 -0
  63. package/captures/chrome-140/tls/client_hello.bin +0 -0
  64. package/captures/chrome-140/tls/client_hello.meta.json +12 -0
  65. package/captures/firefox-128/tls/client_hello.bin +0 -0
  66. package/captures/firefox-128/tls/client_hello.meta.json +12 -0
  67. package/captures/firefox-133/http2/settings.bin +0 -0
  68. package/captures/firefox-133/http2/settings.meta.json +9 -0
  69. package/captures/firefox-133/oracle_capture.reference.json +227 -0
  70. package/captures/firefox-133/tls/client_hello.bin +0 -0
  71. package/captures/firefox-133/tls/client_hello.meta.json +20 -0
  72. package/captures/safari-17/http2/settings.bin +0 -0
  73. package/captures/safari-17/http2/settings.meta.json +9 -0
  74. package/captures/safari-17/oracle_capture.reference.json +214 -0
  75. package/captures/safari-17/tls/client_hello.bin +0 -0
  76. package/captures/safari-17/tls/client_hello.meta.json +20 -0
  77. package/captures/test-malformed/http2/settings.bin +0 -0
  78. package/captures/test-malformed/http2/settings.meta.json +9 -0
  79. package/captures/test-no-extensions/tls/client_hello.bin +0 -0
  80. package/captures/test-no-extensions/tls/client_hello.meta.json +9 -0
  81. package/captures/test-wrong-type/http2/settings.bin +0 -0
  82. package/captures/test-wrong-type/http2/settings.meta.json +9 -0
  83. package/dist/bench/bench.d.ts +24 -6
  84. package/dist/bench/bench.d.ts.map +1 -1
  85. package/dist/bench/bench.js +110 -10
  86. package/dist/bench/bench.js.map +1 -1
  87. package/dist/captures/manifest.d.ts.map +1 -1
  88. package/dist/captures/manifest.js +7 -7
  89. package/dist/captures/manifest.js.map +1 -1
  90. package/dist/categories/compression.d.ts +6 -4
  91. package/dist/categories/compression.d.ts.map +1 -1
  92. package/dist/categories/compression.js +56 -6
  93. package/dist/categories/compression.js.map +1 -1
  94. package/dist/categories/cookies.d.ts +3 -2
  95. package/dist/categories/cookies.d.ts.map +1 -1
  96. package/dist/categories/cookies.js +216 -11
  97. package/dist/categories/cookies.js.map +1 -1
  98. package/dist/categories/redirects.d.ts +8 -2
  99. package/dist/categories/redirects.d.ts.map +1 -1
  100. package/dist/categories/redirects.js +97 -12
  101. package/dist/categories/redirects.js.map +1 -1
  102. package/dist/e2e/cert-gen.d.ts +31 -0
  103. package/dist/e2e/cert-gen.d.ts.map +1 -0
  104. package/dist/e2e/cert-gen.js +158 -0
  105. package/dist/e2e/cert-gen.js.map +1 -0
  106. package/dist/e2e/parse-clienthello.d.ts +177 -0
  107. package/dist/e2e/parse-clienthello.d.ts.map +1 -0
  108. package/dist/e2e/parse-clienthello.js +343 -0
  109. package/dist/e2e/parse-clienthello.js.map +1 -0
  110. package/dist/e2e/sink-server.d.ts +109 -0
  111. package/dist/e2e/sink-server.d.ts.map +1 -0
  112. package/dist/e2e/sink-server.js +292 -0
  113. package/dist/e2e/sink-server.js.map +1 -0
  114. package/dist/e2e/traffic-server.d.ts +48 -0
  115. package/dist/e2e/traffic-server.d.ts.map +1 -0
  116. package/dist/e2e/traffic-server.js +162 -0
  117. package/dist/e2e/traffic-server.js.map +1 -0
  118. package/dist/errors.d.ts.map +1 -1
  119. package/dist/errors.js.map +1 -1
  120. package/dist/fingerprint/index.d.ts +5 -2
  121. package/dist/fingerprint/index.d.ts.map +1 -1
  122. package/dist/fingerprint/index.js +3 -2
  123. package/dist/fingerprint/index.js.map +1 -1
  124. package/dist/fingerprint/ja3.d.ts.map +1 -1
  125. package/dist/fingerprint/ja3.js +33 -4
  126. package/dist/fingerprint/ja3.js.map +1 -1
  127. package/dist/fingerprint/ja4-errors.d.ts +17 -0
  128. package/dist/fingerprint/ja4-errors.d.ts.map +1 -0
  129. package/dist/fingerprint/ja4-errors.js +20 -0
  130. package/dist/fingerprint/ja4-errors.js.map +1 -0
  131. package/dist/fingerprint/ja4-reader.d.ts +44 -0
  132. package/dist/fingerprint/ja4-reader.d.ts.map +1 -0
  133. package/dist/fingerprint/ja4-reader.js +112 -0
  134. package/dist/fingerprint/ja4-reader.js.map +1 -0
  135. package/dist/fingerprint/ja4.d.ts +11 -43
  136. package/dist/fingerprint/ja4.d.ts.map +1 -1
  137. package/dist/fingerprint/ja4.js +54 -141
  138. package/dist/fingerprint/ja4.js.map +1 -1
  139. package/dist/fingerprint/ja4h.d.ts +46 -0
  140. package/dist/fingerprint/ja4h.d.ts.map +1 -0
  141. package/dist/fingerprint/ja4h.js +50 -0
  142. package/dist/fingerprint/ja4h.js.map +1 -0
  143. package/dist/golden/golden.d.ts +15 -1
  144. package/dist/golden/golden.d.ts.map +1 -1
  145. package/dist/golden/golden.js +42 -28
  146. package/dist/golden/golden.js.map +1 -1
  147. package/dist/index.d.ts +21 -1
  148. package/dist/index.d.ts.map +1 -1
  149. package/dist/index.js +36 -1
  150. package/dist/index.js.map +1 -1
  151. package/dist/node-provider.d.ts +42 -0
  152. package/dist/node-provider.d.ts.map +1 -0
  153. package/dist/node-provider.js +53 -0
  154. package/dist/node-provider.js.map +1 -0
  155. package/dist/provider.d.ts +52 -0
  156. package/dist/provider.d.ts.map +1 -0
  157. package/dist/provider.js +14 -0
  158. package/dist/provider.js.map +1 -0
  159. package/dist/reference/browser-provider.d.ts +33 -0
  160. package/dist/reference/browser-provider.d.ts.map +1 -0
  161. package/dist/reference/browser-provider.js +61 -0
  162. package/dist/reference/browser-provider.js.map +1 -0
  163. package/dist/reference/curl-provider.d.ts +40 -0
  164. package/dist/reference/curl-provider.d.ts.map +1 -0
  165. package/dist/reference/curl-provider.js +96 -0
  166. package/dist/reference/curl-provider.js.map +1 -0
  167. package/dist/reference/dump.d.ts +42 -0
  168. package/dist/reference/dump.d.ts.map +1 -0
  169. package/dist/reference/dump.js +102 -0
  170. package/dist/reference/dump.js.map +1 -0
  171. package/dist/reference/node-crypto-provider.d.ts +31 -0
  172. package/dist/reference/node-crypto-provider.d.ts.map +1 -0
  173. package/dist/reference/node-crypto-provider.js +199 -0
  174. package/dist/reference/node-crypto-provider.js.map +1 -0
  175. package/dist/reference/node-reference.d.ts.map +1 -1
  176. package/dist/reference/node-reference.js +2 -5
  177. package/dist/reference/node-reference.js.map +1 -1
  178. package/dist/reference/reference-errors.d.ts +36 -0
  179. package/dist/reference/reference-errors.d.ts.map +1 -0
  180. package/dist/reference/reference-errors.js +43 -0
  181. package/dist/reference/reference-errors.js.map +1 -0
  182. package/dist/reference/reference-types.d.ts +33 -0
  183. package/dist/reference/reference-types.d.ts.map +1 -0
  184. package/dist/reference/reference-types.js +9 -0
  185. package/dist/reference/reference-types.js.map +1 -0
  186. package/dist/reference/reference.d.ts +14 -77
  187. package/dist/reference/reference.d.ts.map +1 -1
  188. package/dist/reference/reference.js +43 -207
  189. package/dist/reference/reference.js.map +1 -1
  190. package/dist/rfc/rfcTests.d.ts +46 -8
  191. package/dist/rfc/rfcTests.d.ts.map +1 -1
  192. package/dist/rfc/rfcTests.js +203 -16
  193. package/dist/rfc/rfcTests.js.map +1 -1
  194. package/dist/types.d.ts +2 -1
  195. package/dist/types.d.ts.map +1 -1
  196. package/dist/types.js.map +1 -1
  197. package/dist/utils.d.ts +3 -0
  198. package/dist/utils.d.ts.map +1 -1
  199. package/dist/utils.js +11 -5
  200. package/dist/utils.js.map +1 -1
  201. 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.")