@browsercore/testing 0.1.2 → 0.2.0

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 (178) hide show
  1. package/README.md +5 -0
  2. package/captures/DIVERGENCE_REPORT.md +325 -0
  3. package/captures/IMPERSIONATION_GAPS_REPORT.md +367 -0
  4. package/captures/README.md +56 -0
  5. package/captures/REVIEW_FINDINGS.md +71 -0
  6. package/captures/_probe/analyze.py +67 -0
  7. package/captures/_probe/capture_bytes.py +127 -0
  8. package/captures/_probe/chrome131_determinism.py +32 -0
  9. package/captures/_probe/diagnose_capture.py +59 -0
  10. package/captures/_probe/golden_analysis.py +91 -0
  11. package/captures/_probe/inspect_golden.py +55 -0
  12. package/captures/_probe/inspect_sv.py +16 -0
  13. package/captures/_probe/make_golden.py +170 -0
  14. package/captures/_probe/output/chrome100.json +219 -0
  15. package/captures/_probe/output/chrome101.json +219 -0
  16. package/captures/_probe/output/chrome104.json +219 -0
  17. package/captures/_probe/output/chrome107.json +220 -0
  18. package/captures/_probe/output/chrome110.json +220 -0
  19. package/captures/_probe/output/chrome116.json +220 -0
  20. package/captures/_probe/output/chrome119.json +219 -0
  21. package/captures/_probe/output/chrome120.json +219 -0
  22. package/captures/_probe/output/chrome123.json +223 -0
  23. package/captures/_probe/output/chrome124.json +224 -0
  24. package/captures/_probe/output/chrome131.json +224 -0
  25. package/captures/_probe/output/chrome131_fresh.json +224 -0
  26. package/captures/_probe/output/chrome133a.json +224 -0
  27. package/captures/_probe/output/chrome136.json +224 -0
  28. package/captures/_probe/output/chrome99.json +219 -0
  29. package/captures/_probe/output/edge101.json +219 -0
  30. package/captures/_probe/output/edge99.json +219 -0
  31. package/captures/_probe/output/firefox133.json +227 -0
  32. package/captures/_probe/output/firefox135.json +230 -0
  33. package/captures/_probe/output/safari155.json +210 -0
  34. package/captures/_probe/output/safari170.json +214 -0
  35. package/captures/_probe/output/safari170_fresh.json +214 -0
  36. package/captures/_probe/output/safari180.json +216 -0
  37. package/captures/_probe/output/safari180_ios.json +216 -0
  38. package/captures/_probe/output/safari184.json +215 -0
  39. package/captures/_probe/output/safari260.json +211 -0
  40. package/captures/_probe/output/summary.json +2520 -0
  41. package/captures/_probe/parse_existing_settings.py +15 -0
  42. package/captures/_probe/peetprint.py +39 -0
  43. package/captures/_probe/probe.py +137 -0
  44. package/captures/_probe/raw_bytes/chrome-131_client_hello.bin +0 -0
  45. package/captures/_probe/raw_bytes/chrome-131_client_hello.hex +32 -0
  46. package/captures/_probe/raw_bytes/firefox-133_client_hello.bin +0 -0
  47. package/captures/_probe/raw_bytes/firefox-133_client_hello.hex +32 -0
  48. package/captures/_probe/raw_bytes/safari-17_client_hello.bin +0 -0
  49. package/captures/_probe/raw_bytes/safari-17_client_hello.hex +32 -0
  50. package/captures/_probe/relay_capture.py +137 -0
  51. package/captures/_probe/socks_capture.py +138 -0
  52. package/captures/_probe/validate_fresh.py +66 -0
  53. package/captures/_probe/validate_golden.py +97 -0
  54. package/captures/_probe/verify_capture.py +68 -0
  55. package/captures/chrome-131/http2/settings.bin +0 -0
  56. package/captures/chrome-131/http2/settings.meta.json +9 -0
  57. package/captures/chrome-131/oracle_capture.reference.json +224 -0
  58. package/captures/chrome-131/tls/client_hello.bin +0 -0
  59. package/captures/chrome-131/tls/client_hello.meta.json +20 -0
  60. package/captures/chrome-140/http2/settings.bin +0 -0
  61. package/captures/chrome-140/http2/settings.meta.json +9 -0
  62. package/captures/chrome-140/tls/client_hello.bin +0 -0
  63. package/captures/chrome-140/tls/client_hello.meta.json +12 -0
  64. package/captures/firefox-128/tls/client_hello.bin +0 -0
  65. package/captures/firefox-128/tls/client_hello.meta.json +12 -0
  66. package/captures/firefox-133/http2/settings.bin +0 -0
  67. package/captures/firefox-133/http2/settings.meta.json +9 -0
  68. package/captures/firefox-133/oracle_capture.reference.json +227 -0
  69. package/captures/firefox-133/tls/client_hello.bin +0 -0
  70. package/captures/firefox-133/tls/client_hello.meta.json +20 -0
  71. package/captures/safari-17/http2/settings.bin +0 -0
  72. package/captures/safari-17/http2/settings.meta.json +9 -0
  73. package/captures/safari-17/oracle_capture.reference.json +214 -0
  74. package/captures/safari-17/tls/client_hello.bin +0 -0
  75. package/captures/safari-17/tls/client_hello.meta.json +20 -0
  76. package/dist/bench/bench.d.ts +22 -6
  77. package/dist/bench/bench.d.ts.map +1 -1
  78. package/dist/bench/bench.js +105 -10
  79. package/dist/bench/bench.js.map +1 -1
  80. package/dist/captures/manifest.d.ts.map +1 -1
  81. package/dist/captures/manifest.js +2 -3
  82. package/dist/captures/manifest.js.map +1 -1
  83. package/dist/categories/compression.d.ts +5 -2
  84. package/dist/categories/compression.d.ts.map +1 -1
  85. package/dist/categories/compression.js +54 -6
  86. package/dist/categories/compression.js.map +1 -1
  87. package/dist/categories/cookies.d.ts +3 -2
  88. package/dist/categories/cookies.d.ts.map +1 -1
  89. package/dist/categories/cookies.js +228 -11
  90. package/dist/categories/cookies.js.map +1 -1
  91. package/dist/categories/redirects.d.ts +8 -2
  92. package/dist/categories/redirects.d.ts.map +1 -1
  93. package/dist/categories/redirects.js +97 -12
  94. package/dist/categories/redirects.js.map +1 -1
  95. package/dist/e2e/cert-gen.d.ts +30 -0
  96. package/dist/e2e/cert-gen.d.ts.map +1 -0
  97. package/dist/e2e/cert-gen.js +158 -0
  98. package/dist/e2e/cert-gen.js.map +1 -0
  99. package/dist/e2e/parse-clienthello.d.ts +177 -0
  100. package/dist/e2e/parse-clienthello.d.ts.map +1 -0
  101. package/dist/e2e/parse-clienthello.js +343 -0
  102. package/dist/e2e/parse-clienthello.js.map +1 -0
  103. package/dist/e2e/sink-server.d.ts +109 -0
  104. package/dist/e2e/sink-server.d.ts.map +1 -0
  105. package/dist/e2e/sink-server.js +292 -0
  106. package/dist/e2e/sink-server.js.map +1 -0
  107. package/dist/errors.d.ts.map +1 -1
  108. package/dist/errors.js.map +1 -1
  109. package/dist/fingerprint/index.d.ts +5 -2
  110. package/dist/fingerprint/index.d.ts.map +1 -1
  111. package/dist/fingerprint/index.js +3 -2
  112. package/dist/fingerprint/index.js.map +1 -1
  113. package/dist/fingerprint/ja3.d.ts.map +1 -1
  114. package/dist/fingerprint/ja3.js +33 -4
  115. package/dist/fingerprint/ja3.js.map +1 -1
  116. package/dist/fingerprint/ja4-errors.d.ts +17 -0
  117. package/dist/fingerprint/ja4-errors.d.ts.map +1 -0
  118. package/dist/fingerprint/ja4-errors.js +20 -0
  119. package/dist/fingerprint/ja4-errors.js.map +1 -0
  120. package/dist/fingerprint/ja4-reader.d.ts +44 -0
  121. package/dist/fingerprint/ja4-reader.d.ts.map +1 -0
  122. package/dist/fingerprint/ja4-reader.js +112 -0
  123. package/dist/fingerprint/ja4-reader.js.map +1 -0
  124. package/dist/fingerprint/ja4.d.ts +11 -43
  125. package/dist/fingerprint/ja4.d.ts.map +1 -1
  126. package/dist/fingerprint/ja4.js +54 -141
  127. package/dist/fingerprint/ja4.js.map +1 -1
  128. package/dist/fingerprint/ja4h.d.ts +46 -0
  129. package/dist/fingerprint/ja4h.d.ts.map +1 -0
  130. package/dist/fingerprint/ja4h.js +50 -0
  131. package/dist/fingerprint/ja4h.js.map +1 -0
  132. package/dist/golden/golden.d.ts +15 -1
  133. package/dist/golden/golden.d.ts.map +1 -1
  134. package/dist/golden/golden.js +35 -22
  135. package/dist/golden/golden.js.map +1 -1
  136. package/dist/index.d.ts +17 -1
  137. package/dist/index.d.ts.map +1 -1
  138. package/dist/index.js +26 -1
  139. package/dist/index.js.map +1 -1
  140. package/dist/reference/browser-provider.d.ts +33 -0
  141. package/dist/reference/browser-provider.d.ts.map +1 -0
  142. package/dist/reference/browser-provider.js +60 -0
  143. package/dist/reference/browser-provider.js.map +1 -0
  144. package/dist/reference/curl-provider.d.ts +40 -0
  145. package/dist/reference/curl-provider.d.ts.map +1 -0
  146. package/dist/reference/curl-provider.js +96 -0
  147. package/dist/reference/curl-provider.js.map +1 -0
  148. package/dist/reference/dump.d.ts +42 -0
  149. package/dist/reference/dump.d.ts.map +1 -0
  150. package/dist/reference/dump.js +102 -0
  151. package/dist/reference/dump.js.map +1 -0
  152. package/dist/reference/node-reference.d.ts.map +1 -1
  153. package/dist/reference/node-reference.js +2 -5
  154. package/dist/reference/node-reference.js.map +1 -1
  155. package/dist/reference/reference-errors.d.ts +36 -0
  156. package/dist/reference/reference-errors.d.ts.map +1 -0
  157. package/dist/reference/reference-errors.js +43 -0
  158. package/dist/reference/reference-errors.js.map +1 -0
  159. package/dist/reference/reference-types.d.ts +33 -0
  160. package/dist/reference/reference-types.d.ts.map +1 -0
  161. package/dist/reference/reference-types.js +9 -0
  162. package/dist/reference/reference-types.js.map +1 -0
  163. package/dist/reference/reference.d.ts +14 -77
  164. package/dist/reference/reference.d.ts.map +1 -1
  165. package/dist/reference/reference.js +43 -207
  166. package/dist/reference/reference.js.map +1 -1
  167. package/dist/rfc/rfcTests.d.ts +46 -8
  168. package/dist/rfc/rfcTests.d.ts.map +1 -1
  169. package/dist/rfc/rfcTests.js +203 -16
  170. package/dist/rfc/rfcTests.js.map +1 -1
  171. package/dist/types.d.ts +2 -1
  172. package/dist/types.d.ts.map +1 -1
  173. package/dist/types.js.map +1 -1
  174. package/dist/utils.d.ts +3 -0
  175. package/dist/utils.d.ts.map +1 -1
  176. package/dist/utils.js +11 -5
  177. package/dist/utils.js.map +1 -1
  178. package/package.json +61 -50
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env python3
2
+ """Parse the existing synthetic chrome-140 settings.bin to document what it actually contains."""
3
+ import struct
4
+ p="/Users/matte/projects/browsercore/testing/captures/chrome-140/http2/settings.bin"
5
+ with open(p,"rb") as f: d=f.read()
6
+ print(f"chrome-140 settings.bin: {len(d)} bytes, hex: {d.hex()}")
7
+ length=struct.unpack(">I", b'\x00'+d[0:3])[0]
8
+ ftype=d[3]; flags=d[4]; stream=struct.unpack(">I",d[5:9])[0]
9
+ print(f"frame: length={length} type=0x{ftype:02x} flags=0x{flags:02x} stream={stream}")
10
+ SETTING_IDS_REV = {1:"HEADER_TABLE_SIZE",2:"ENABLE_PUSH",3:"MAX_CONCURRENT_STREAMS",
11
+ 4:"INITIAL_WINDOW_SIZE",5:"MAX_FRAME_SIZE",6:"MAX_HEADER_LIST_SIZE"}
12
+ for i in range(0,len(d)-9,6):
13
+ sid=struct.unpack(">H", d[9+i:11+i])[0]
14
+ val=struct.unpack(">I", d[11+i:15+i])[0]
15
+ print(f" setting {SETTING_IDS_REV.get(sid,sid)} = {val}")
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env python3
2
+ import json, os
3
+ OUT = "/Users/matte/projects/browsercore/testing/captures/_probe/output"
4
+
5
+ def get_sv(ext_list):
6
+ for e in ext_list:
7
+ if 'supported_versions' in e.get('name',''):
8
+ return e.get('versions', [])
9
+ return None
10
+
11
+ PROFILES = ["chrome124","chrome131","chrome133a","chrome136","chrome120","chrome119","chrome116","chrome110",
12
+ "firefox133","firefox135",
13
+ "safari170","safari180","safari184",
14
+ "edge99","edge101"]
15
+
16
+ print(f"{'profile':14s} | supported_versions")
17
+ print("-"*90)
18
+ for p in PROFILES:
19
+ d = json.load(open(os.path.join(OUT, f"{p}.json")))
20
+ tls = d['tls']
21
+ sv = get_sv(tls['extensions'])
22
+ print(f"{p:14s} | {sv}")
23
+
24
+ print("\n\n=== PEETPRINT (full ordering fingerprint) ===")
25
+ for p in PROFILES:
26
+ d = json.load(open(os.path.join(OUT, f"{p}.json")))
27
+ pp = d['tls'].get('peetprint','')
28
+ print(f"\n{p}:")
29
+ print(f" {pp}")
30
+
31
+ print("\n\n=== SETTINGS + WINDOW_UPDATE + AKAMAI (for settings.frame divergence) ===")
32
+ for p in PROFILES:
33
+ d = json.load(open(os.path.join(OUT, f"{p}.json")))
34
+ h2 = d['http2']
35
+ settings = wu = None
36
+ for fr in h2.get('sent_frames',[]):
37
+ if fr['frame_type']=='SETTINGS': settings = fr.get('settings')
38
+ if fr['frame_type']=='WINDOW_UPDATE': wu = fr.get('increment')
39
+ print(f"{p:14s} | SETTINGS={settings} WINDOW_UPDATE={wu} akamai={h2.get('akamai_fingerprint')}")
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Probe multiple browser profiles with curl_cffi against tls.peet.ws/api/all.
4
+ Saves full JSON per profile + a summary TSV of key fingerprint fields.
5
+ """
6
+ import json
7
+ import sys
8
+ import os
9
+ import traceback
10
+ from datetime import datetime, timezone
11
+ from curl_cffi import requests
12
+
13
+ OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
14
+ os.makedirs(OUT, exist_ok=True)
15
+
16
+ PROFILES = [
17
+ "chrome99", "chrome100", "chrome101", "chrome104", "chrome107",
18
+ "chrome110", "chrome116", "chrome119", "chrome120", "chrome123",
19
+ "chrome124", "chrome131", "chrome133a", "chrome136",
20
+ "edge99", "edge101",
21
+ "safari155", "safari170", "safari180", "safari184",
22
+ "safari180_ios", "safari260",
23
+ "firefox133", "firefox135",
24
+ ]
25
+
26
+ URL = "https://tls.peet.ws/api/all"
27
+
28
+
29
+ def probe(profile):
30
+ resp = requests.get(URL, impersonate=profile, timeout=30)
31
+ resp.raise_for_status()
32
+ data = resp.json()
33
+ # Save raw JSON
34
+ with open(os.path.join(OUT, f"{profile}.json"), "w") as f:
35
+ json.dump(data, f, indent=2)
36
+ return data
37
+
38
+
39
+ def extract(d, profile):
40
+ """Extract summary fields from a tls.peet.ws response."""
41
+ tls = d.get("tls", {})
42
+ h2 = d.get("http2", {})
43
+ # SETTINGS
44
+ settings_map = {}
45
+ settings_order = []
46
+ window_update = None
47
+ headers_list = []
48
+ header_order = []
49
+ priority_info = None
50
+ akamai = h2.get("akamai_fingerprint", "")
51
+ for fr in h2.get("sent_frames", []):
52
+ if fr.get("frame_type") == "SETTINGS":
53
+ for s in fr.get("settings", []):
54
+ k, v = s.split(" = ", 1)
55
+ settings_map[k] = v
56
+ settings_order.append(k)
57
+ elif fr.get("frame_type") == "WINDOW_UPDATE":
58
+ window_update = fr.get("increment")
59
+ elif fr.get("frame_type") == "HEADERS":
60
+ headers_list = fr.get("headers", [])
61
+ header_order = [h.split(":")[0] for h in headers_list]
62
+ if "priority" in fr:
63
+ priority_info = fr["priority"]
64
+ # TLS extension order (names)
65
+ ext_names = [e.get("name", "?") for e in tls.get("extensions", [])]
66
+ # supported_versions + key_share groups order + sig_algs + ciphers
67
+ supported_versions = []
68
+ key_share_groups = []
69
+ sig_algs = []
70
+ ec_point_formats = []
71
+ ciphers = tls.get("ciphers", [])
72
+ for e in tls.get("extensions", []):
73
+ nm = e.get("name", "")
74
+ if "supported_versions" in nm:
75
+ supported_versions = e.get("supported_versions", [])
76
+ elif "key_share" in nm:
77
+ for k in e.get("shared_keys", []):
78
+ key_share_groups += list(k.keys())
79
+ elif "signature_algorithms" in nm:
80
+ sig_algs = e.get("signature_algorithms", [])
81
+ elif "ec_point_formats" in nm or "psk_key_exchange" in nm:
82
+ pass
83
+ if "ec_point_formats" in nm:
84
+ ec_point_formats = e.get("elliptic_curves", [])
85
+ # request headers ordering (non-pseudo)
86
+ request_headers = [h.split(":")[0] for h in headers_list]
87
+ pseudo_order = [h for h in request_headers if h.startswith(":")]
88
+ regular_order = [h for h in request_headers if not h.startswith(":")]
89
+ return {
90
+ "profile": profile,
91
+ "http_version": d.get("http_version"),
92
+ "user_agent": d.get("user_agent"),
93
+ "ja3_hash": tls.get("ja3_hash"),
94
+ "ja3": tls.get("ja3"),
95
+ "ja4": tls.get("ja4"),
96
+ "ja4_r": tls.get("ja4_r"),
97
+ "tls_version_negotiated": tls.get("tls_version_negotiated"),
98
+ "tls_version_record": tls.get("tls_version_record"),
99
+ "ciphers": ciphers,
100
+ "settings_order": settings_order,
101
+ "settings_map": settings_map,
102
+ "window_update": window_update,
103
+ "akamai_fingerprint": akamai,
104
+ "pseudo_header_order": pseudo_order,
105
+ "regular_header_order": regular_order,
106
+ "priority_info": priority_info,
107
+ "extension_names": ext_names,
108
+ "supported_versions": supported_versions,
109
+ "key_share_groups": key_share_groups,
110
+ "sig_algs": sig_algs,
111
+ "ec_point_formats": ec_point_formats,
112
+ "num_extensions": len(ext_names),
113
+ }
114
+
115
+
116
+ def main():
117
+ results = []
118
+ failures = []
119
+ for p in PROFILES:
120
+ try:
121
+ d = probe(p)
122
+ s = extract(d, p)
123
+ results.append(s)
124
+ print(f"OK {p:16s} ja3={s['ja3_hash']} ja4={s['ja4']} settings={s['settings_order']}")
125
+ except Exception as e:
126
+ failures.append((p, str(e)))
127
+ print(f"FAIL {p:16s} -> {e}")
128
+ # Save summary
129
+ with open(os.path.join(OUT, "summary.json"), "w") as f:
130
+ json.dump({"probed_at": datetime.now(timezone.utc).isoformat(),
131
+ "results": results, "failures": failures}, f, indent=2)
132
+ print(f"\n=== {len(results)} OK, {len(failures)} FAILED ===")
133
+ print("Failures:", failures)
134
+
135
+
136
+ if __name__ == "__main__":
137
+ main()
@@ -0,0 +1,32 @@
1
+ 0000: 16 03 01 07 00 01 00 06 fc 03 03 dd 4a 8c f2 b0
2
+ 0010: 4d f4 86 81 b1 04 f1 46 58 ff ef 12 b5 09 e0 c5
3
+ 0020: 82 2a 60 66 79 05 c4 fe 46 54 44 20 47 fd 5a 59
4
+ 0030: b5 61 5a a1 e5 bc 14 77 cb e2 2d 43 5d a0 a3 9f
5
+ 0040: fb 6f b7 35 37 c4 92 14 47 a3 92 ba 00 20 7a 7a
6
+ 0050: 13 01 13 02 13 03 c0 2b c0 2f c0 2c c0 30 cc a9
7
+ 0060: cc a8 c0 13 c0 14 00 9c 00 9d 00 2f 00 35 01 00
8
+ 0070: 06 93 1a 1a 00 00 fe 0d 01 1a 00 00 01 00 01 ec
9
+ 0080: 00 20 ab 64 17 1c 93 3d b9 b7 ce 53 f7 bd 10 7d
10
+ 0090: fe c6 25 da 94 44 ef c0 f2 6f 95 f6 02 07 17 10
11
+ 00a0: 70 21 00 f0 2f 41 3f fb 36 f5 34 4d 0d f8 a1 83
12
+ 00b0: 71 d8 0e 0f 4c bd d8 92 2d 24 17 10 8a b3 b9 ce
13
+ 00c0: c5 a7 72 c0 f5 bf c3 d2 9f 25 d9 23 8f 3d 65 da
14
+ 00d0: ad ba 3c c1 36 56 13 10 af 30 f7 46 f4 05 94 3a
15
+ 00e0: 3f c9 d2 f4 b7 a3 e9 ed 67 7b d7 c9 c1 da 27 75
16
+ 00f0: b8 fa 51 e3 16 01 da 0a 59 19 11 a1 f7 da 22 07
17
+ 0100: da 02 28 71 45 d1 3e f3 06 cd 33 4f dc d1 96 a6
18
+ 0110: 80 0c 48 ac 70 2f d8 1d 7e 33 48 98 19 72 6a 07
19
+ 0120: 73 45 64 75 0d a5 fa bd b7 0b b8 f4 79 d7 d8 46
20
+ 0130: 40 21 c5 4b 65 00 50 fd 4c da 9a 8c c5 d6 4c 90
21
+ 0140: 28 95 84 be 4d d1 84 8d 9b 8e 66 0a 5b 4a 64 a3
22
+ 0150: 13 eb 2c 01 d0 2d 44 f5 11 27 4c f9 e4 c9 f0 b8
23
+ 0160: ce 2c 60 8f be 1d e0 dc 6c b1 c2 5d ba 5d 8c ea
24
+ 0170: 88 8a 64 09 41 fe 8a 4f d5 fb cd 53 21 8e af 46
25
+ 0180: 11 53 2b 1f d8 e2 59 d6 ab 79 4a 8a 35 85 22 b4
26
+ 0190: e2 88 50 4d 00 2d 00 02 01 01 00 17 00 00 00 0b
27
+ 01a0: 00 02 01 00 00 10 00 0e 00 0c 02 68 32 08 68 74
28
+ 01b0: 74 70 2f 31 2e 31 00 23 00 00 00 12 00 00 44 69
29
+ 01c0: 00 05 00 03 02 68 32 00 33 04 ef 04 ed 9a 9a 00
30
+ 01d0: 01 00 11 ec 04 c0 04 7a 9d cc 10 30 0d e5 84 e0
31
+ 01e0: 87 60 a0 5a c8 de b9 57 a3 a2 b4 40 f7 32 4f 80
32
+ 01f0: a5 32 b9 47 65 dc a6 15 b8 cc 81 90 45 78 69 0b
@@ -0,0 +1,32 @@
1
+ 0000: 16 03 01 07 2c 01 00 07 28 03 03 0a 65 63 35 d1
2
+ 0010: 4c 8d 12 18 0c 76 91 8c 07 34 c6 1b c1 7a ea c4
3
+ 0020: 62 8e ab fa 79 86 d5 59 2b dd a3 20 b5 9b d5 86
4
+ 0030: 75 5f 8b c6 5c 72 ca f2 54 34 f9 61 47 34 af c2
5
+ 0040: 67 50 10 3f ff e9 65 45 f1 0f 58 c5 00 22 13 01
6
+ 0050: 13 03 13 02 c0 2b c0 2f cc a9 cc a8 c0 2c c0 30
7
+ 0060: c0 0a c0 09 c0 13 c0 14 00 9c 00 9d 00 2f 00 35
8
+ 0070: 01 00 06 bd 00 17 00 00 ff 01 00 01 00 00 0a 00
9
+ 0080: 10 00 0e 11 ec 00 1d 00 17 00 18 00 19 01 00 01
10
+ 0090: 01 00 0b 00 02 01 00 00 23 00 00 00 10 00 0e 00
11
+ 00a0: 0c 02 68 32 08 68 74 74 70 2f 31 2e 31 00 05 00
12
+ 00b0: 05 01 00 00 00 00 00 22 00 0a 00 08 04 03 05 03
13
+ 00c0: 06 03 02 03 00 33 05 2f 05 2d 11 ec 04 c0 71 60
14
+ 00d0: 02 c2 b4 7d c1 a1 5c 1f e6 93 09 fc 5d ac 99 10
15
+ 00e0: da 70 c3 16 f1 41 ea 22 b5 9f 03 94 0d 53 57 0d
16
+ 00f0: 29 c2 52 49 4e 76 92 c2 5b 29 7e ee 35 0f 48 5c
17
+ 0100: 3a 0a 55 37 dc 87 07 17 34 03 79 c5 7e 87 95 a1
18
+ 0110: c2 cb 75 fa f4 0a 1e 9a a0 13 57 62 2a 37 bd 48
19
+ 0120: 5c 62 90 27 25 50 19 6d 48 23 47 33 25 4a b1 f8
20
+ 0130: 65 04 56 01 33 eb 06 a0 b4 bb 5d 12 70 b6 ea 77
21
+ 0140: 27 53 57 56 8a 4d dc a7 2f 3e 86 10 03 a0 b7 2c
22
+ 0150: e9 a1 f0 30 6a 84 b4 46 ed 58 60 0f 64 8b 21 41
23
+ 0160: 05 60 86 ba 5b 7b 64 4c 4b b7 06 fc 8e ae c9 8a
24
+ 0170: b7 d5 9b 74 87 7e d7 eb 3d 62 6a 62 5d da c9 a6
25
+ 0180: 54 79 fe aa 00 ef 69 99 54 19 14 54 6c 4e c8 aa
26
+ 0190: 52 e1 16 c1 ab 73 84 79 c0 a8 e5 f0 1b c1 52 cf
27
+ 01a0: b1 27 c0 ff 57 46 35 41 92 b5 01 ac 06 41 32 ba
28
+ 01b0: 82 00 0b 87 a4 2d 99 7e 59 a1 c5 45 f7 1f cc 66
29
+ 01c0: 8a de 1c 73 40 fa 14 08 fa 6d 28 51 5e 96 b7 a3
30
+ 01d0: 60 d8 83 f0 08 83 ab 20 bf 8f 98 ca 97 fc 66 fb
31
+ 01e0: f2 7e 19 73 b1 ac 06 2a 99 eb ac 15 bb 3d 79 f9
32
+ 01f0: 4c f3 24 1e b4 63 40 a4 57 2f 44 8b a0 f7 a4 2a
@@ -0,0 +1,32 @@
1
+ 0000: 16 03 01 02 00 01 00 01 fc 03 03 e9 b2 26 d5 ec
2
+ 0010: 37 3d 47 70 f8 29 c1 b0 96 fb 2d eb be 32 04 8e
3
+ 0020: a5 99 7e ec 07 1b d1 3d da e7 a9 20 c8 61 d3 f4
4
+ 0030: c9 17 71 a2 5c a6 b3 f6 60 c4 40 64 97 ec e8 2d
5
+ 0040: 40 c5 f0 42 9a 2c f1 c8 5d 2c 33 c4 00 2a 2a 2a
6
+ 0050: 13 01 13 02 13 03 c0 2c c0 2b cc a9 c0 30 c0 2f
7
+ 0060: cc a8 c0 0a c0 09 c0 14 c0 13 00 9d 00 9c 00 35
8
+ 0070: 00 2f c0 08 c0 12 00 0a 01 00 01 89 3a 3a 00 00
9
+ 0080: 00 17 00 00 ff 01 00 01 00 00 0a 00 0c 00 0a 6a
10
+ 0090: 6a 00 1d 00 17 00 18 00 19 00 0b 00 02 01 00 00
11
+ 00a0: 10 00 0e 00 0c 02 68 32 08 68 74 74 70 2f 31 2e
12
+ 00b0: 31 00 05 00 05 01 00 00 00 00 00 0d 00 18 00 16
13
+ 00c0: 04 03 08 04 04 01 05 03 02 03 08 05 08 05 05 01
14
+ 00d0: 08 06 06 01 02 01 00 12 00 00 00 33 00 2b 00 29
15
+ 00e0: 6a 6a 00 01 00 00 1d 00 20 77 0a 4e b5 d4 f0 fb
16
+ 00f0: 66 f2 37 91 d4 0b b5 f1 6e 05 1b 7e c6 89 78 82
17
+ 0100: 34 a3 37 a9 3f 57 98 87 5f 00 2d 00 02 01 01 00
18
+ 0110: 2b 00 0b 0a 6a 6a 03 04 03 03 03 02 03 01 00 1b
19
+ 0120: 00 03 02 00 01 8a 8a 00 01 00 00 15 00 d7 00 00
20
+ 0130: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
21
+ 0140: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
22
+ 0150: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
23
+ 0160: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
24
+ 0170: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
25
+ 0180: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
26
+ 0190: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
27
+ 01a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
28
+ 01b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
29
+ 01c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
30
+ 01d0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
31
+ 01e0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
32
+ 01f0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Transparent TLS relay that captures the authentic ClientHello bytes.
4
+
5
+ curl_cffi connects to https://tls.peet.ws:443 but we resolve tls.peet.ws to
6
+ 127.0.0.1 (via a hosts redirect done by connecting to a local port with the
7
+ Host header set to tls.peet.ws). Actually simpler: we listen locally, and when
8
+ a client connects we:
9
+ 1. read the first flight (ClientHello) from the client — this has SNI=tls.peet.ws
10
+ because curl_cffi derives SNI from the URL host, which we keep as tls.peet.ws.
11
+ 2. open a real TCP+TLS connection to the actual tls.peet.ws
12
+ 3. save the ClientHello bytes
13
+ 4. forward those bytes + relay the rest bidirectionally
14
+
15
+ The trick: curl_cffi gets the real tls.peet.ws IP via DNS but we intercept by
16
+ having curl_cffi connect through us. We do this by pointing curl_cffi at
17
+ https://tls.peet.ws/ and overriding resolution with a custom approach:
18
+ curl_cffi/requests allows `impersonate` and we can set the request to go to a
19
+ local socket via a pre-resolved address. Simplest reliable method: add an
20
+ entry mapping tls.peet.ws -> 127.0.0.1 using a tiny DNS override is complex.
21
+
22
+ Instead we use the CONNECT-less approach: curl_cffi lets us pass the URL as
23
+ https://tls.peet.ws and we intercept resolution by monkeypatching via a custom
24
+ transport is heavy. So we use the PROXY feature: set https proxy to our local
25
+ relay. With an HTTP CONNECT proxy, curl still sends SNI=tls.peet.ws inside the
26
+ tunnel and we relay raw bytes (no decryption), so we can sniff the ClientHello.
27
+ """
28
+ import socket, threading, time, struct, json, os
29
+ from datetime import datetime, timezone
30
+ from curl_cffi import requests
31
+
32
+ RAW = "/Users/matte/projects/browsercore/testing/captures/_probe/raw_bytes"
33
+ os.makedirs(RAW, exist_ok=True)
34
+
35
+ RELAY_HOST = "127.0.0.1"
36
+ RELAY_PORT = 8443
37
+ REAL_HOST = "tls.peet.ws"
38
+ REAL_PORT = 443
39
+
40
+ def relay_capture(profile, outname):
41
+ server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
42
+ try:
43
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
44
+ except Exception:
45
+ pass
46
+ server.bind((RELAY_HOST, RELAY_PORT))
47
+ server.listen(1)
48
+ server.settimeout(15)
49
+
50
+ client_hello = {"data": None}
51
+ upstream_done = threading.Event()
52
+
53
+ def accept_and_relay():
54
+ try:
55
+ client_sock, addr = server.accept()
56
+ client_sock.settimeout(8)
57
+ # Read initial flight from client (ClientHello).
58
+ initial = b""
59
+ try:
60
+ while True:
61
+ chunk = client_sock.recv(4096)
62
+ if not chunk:
63
+ break
64
+ initial += chunk
65
+ if len(initial) >= 5:
66
+ rec_len = struct.unpack(">H", initial[3:5])[0]
67
+ if len(initial) >= 5 + rec_len:
68
+ break
69
+ except socket.timeout:
70
+ pass
71
+ client_hello["data"] = initial
72
+
73
+ # Now connect to the real server and forward.
74
+ upstream = socket.create_connection((REAL_HOST, REAL_PORT), timeout=8)
75
+ upstream.settimeout(8)
76
+ upstream.sendall(initial)
77
+ # Bidirectional relay until one side closes.
78
+ def relay(src, dst):
79
+ try:
80
+ while True:
81
+ d = src.recv(4096)
82
+ if not d:
83
+ break
84
+ dst.sendall(d)
85
+ except Exception:
86
+ pass
87
+ t1 = threading.Thread(target=relay, args=(client_sock, upstream))
88
+ t2 = threading.Thread(target=relay, args=(upstream, client_sock))
89
+ t1.start(); t2.start()
90
+ t1.join(timeout=15); t2.join(timeout=15)
91
+ try: client_sock.close()
92
+ except Exception: pass
93
+ try: upstream.close()
94
+ except Exception: pass
95
+ except Exception as e:
96
+ client_hello["server_error"] = str(e)
97
+ finally:
98
+ try: server.close()
99
+ except Exception: pass
100
+ upstream_done.set()
101
+
102
+ t = threading.Thread(target=accept_and_relay)
103
+ t.start()
104
+ time.sleep(0.4)
105
+
106
+ # Use our relay as an HTTPS proxy. curl will do CONNECT tls.peet.ws:443
107
+ # through 127.0.0.1:8443, then TLS with SNI=tls.peet.ws inside.
108
+ proxy = f"http://{RELAY_HOST}:{RELAY_PORT}"
109
+ captured_client_error = None
110
+ try:
111
+ # First do a plain GET through the proxy to the real host.
112
+ requests.get(
113
+ f"https://{REAL_HOST}/api/all",
114
+ impersonate=profile,
115
+ proxies={"https": proxy, "http": proxy},
116
+ timeout=20,
117
+ verify=True,
118
+ )
119
+ except Exception as e:
120
+ captured_client_error = str(e)[:300]
121
+
122
+ t.join(timeout=20)
123
+ data = client_hello.get("data")
124
+ if data:
125
+ path = os.path.join(RAW, f"{outname}_client_hello.bin")
126
+ with open(path, "wb") as f:
127
+ f.write(data)
128
+ print(f" [{profile}] captured {len(data)} bytes -> {path}")
129
+ else:
130
+ print(f" [{profile}] FAILED capture: {client_hello.get('server_error')} / {captured_client_error}")
131
+ return data
132
+
133
+ PROFILES = [("chrome131", "chrome-131"), ("firefox133", "firefox-133"), ("safari170", "safari-17")]
134
+ for prof, outname in PROFILES:
135
+ print(f"\n=== Relay-capturing {prof} -> {outname} ===")
136
+ relay_capture(prof, outname)
137
+ time.sleep(0.5)
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SOCKS5 proxy that captures the raw TLS ClientHello.
4
+
5
+ curl_cffi connects to the SOCKS5 proxy saying "connect to tls.peet.ws:443".
6
+ We open the real connection, complete the SOCKS handshake, then relay — but
7
+ capture the first TLS record (ClientHello) the client sends before forwarding
8
+ it upstream. Because the destination is tls.peet.ws, SNI is correct.
9
+ """
10
+ import socket, struct, threading, time, os
11
+ from curl_cffi import requests
12
+
13
+ RAW = "/Users/matte/projects/browsercore/testing/captures/_probe/raw_bytes"
14
+ os.makedirs(RAW, exist_ok=True)
15
+
16
+ RELAY_HOST = "127.0.0.1"
17
+ RELAY_PORT = 8443
18
+ REAL_HOST = "tls.peet.ws"
19
+ REAL_PORT = 443
20
+
21
+ def socks_capture(profile, outname):
22
+ server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
23
+ try:
24
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
25
+ except Exception:
26
+ pass
27
+ server.bind((RELAY_HOST, RELAY_PORT))
28
+ server.listen(1)
29
+ server.settimeout(20)
30
+
31
+ result = {"data": None}
32
+
33
+ def handle():
34
+ try:
35
+ client, addr = server.accept()
36
+ client.settimeout(10)
37
+ # ---- SOCKS5 handshake ----
38
+ # Client sends: VER=0x05, NAUTH, auth methods
39
+ hdr = client.recv(2)
40
+ if not hdr or hdr[0] != 0x05:
41
+ client.close(); return
42
+ nmethods = hdr[1]
43
+ client.recv(nmethods) # read auth methods
44
+ # Reply: no auth required
45
+ client.sendall(b'\x05\x00')
46
+ # Client request: VER, CMD=0x01(connect), RSV, ATYP, DST.ADDR, DST.PORT
47
+ req = client.recv(4)
48
+ if not req or req[0] != 0x05 or req[1] != 0x01:
49
+ client.close(); return
50
+ atyp = req[3]
51
+ if atyp == 0x01: # IPv4
52
+ dst = socket.inet_ntoa(client.recv(4))
53
+ elif atyp == 0x04: # IPv6
54
+ dst = socket.inet_ntop(socket.AF_INET6, client.recv(16))
55
+ elif atyp == 0x03: # domain
56
+ dlen = client.recv(1)[0]
57
+ dst = client.recv(dlen).decode()
58
+ else:
59
+ client.close(); return
60
+ port_bytes = client.recv(2)
61
+ dst_port = struct.unpack(">H", port_bytes)[0]
62
+ # Connect to the real destination
63
+ upstream = socket.create_connection((REAL_HOST, REAL_PORT), timeout=10)
64
+ upstream.settimeout(10)
65
+ # SOCKS success reply (bound addr = 0.0.0.0:0)
66
+ client.sendall(b'\x05\x00\x00\x01' + socket.inet_aton('0.0.0.0') + struct.pack(">H", 0))
67
+
68
+ # ---- Now relay, capturing client's first TLS flight ----
69
+ initial = b""
70
+ try:
71
+ while True:
72
+ chunk = client.recv(4096)
73
+ if not chunk:
74
+ break
75
+ initial += chunk
76
+ if len(initial) >= 5:
77
+ rec_len = struct.unpack(">H", initial[3:5])[0]
78
+ if len(initial) >= 5 + rec_len:
79
+ break
80
+ except socket.timeout:
81
+ pass
82
+ result["data"] = initial
83
+ # forward captured bytes + continue relay
84
+ upstream.sendall(initial)
85
+ def relay(src, dst_s):
86
+ try:
87
+ while True:
88
+ d = src.recv(4096)
89
+ if not d: break
90
+ dst_s.sendall(d)
91
+ except Exception:
92
+ pass
93
+ t1 = threading.Thread(target=relay, args=(client, upstream))
94
+ t2 = threading.Thread(target=relay, args=(upstream, client))
95
+ t1.start(); t2.start()
96
+ t1.join(timeout=20); t2.join(timeout=20)
97
+ try: client.close()
98
+ except Exception: pass
99
+ try: upstream.close()
100
+ except Exception: pass
101
+ except Exception as e:
102
+ result["server_error"] = str(e)
103
+ finally:
104
+ try: server.close()
105
+ except Exception: pass
106
+
107
+ t = threading.Thread(target=handle)
108
+ t.start()
109
+ time.sleep(0.4)
110
+
111
+ proxy = f"socks5h://{RELAY_HOST}:{RELAY_PORT}"
112
+ try:
113
+ requests.get(
114
+ f"https://{REAL_HOST}/api/all",
115
+ impersonate=profile,
116
+ proxies={"https": proxy, "http": proxy},
117
+ timeout=25,
118
+ verify=True,
119
+ )
120
+ except Exception as e:
121
+ result["client_error"] = str(e)[:300]
122
+
123
+ t.join(timeout=25)
124
+ data = result.get("data")
125
+ if data:
126
+ path = os.path.join(RAW, f"{outname}_client_hello.bin")
127
+ with open(path, "wb") as f:
128
+ f.write(data)
129
+ print(f" [{profile}] captured {len(data)} bytes -> {path}")
130
+ else:
131
+ print(f" [{profile}] FAILED: {result.get('server_error')} / {result.get('client_error')}")
132
+ return data
133
+
134
+ PROFILES = [("chrome131", "chrome-131"), ("firefox133", "firefox-133"), ("safari170", "safari-17")]
135
+ for prof, outname in PROFILES:
136
+ print(f"\n=== SOCKS-capturing {prof} -> {outname} ===")
137
+ socks_capture(prof, outname)
138
+ time.sleep(0.5)
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env python3
2
+ """Fresh re-probe chrome131 + safari170 through oracle, compare to captured bytes."""
3
+ import json, os, hashlib, struct
4
+ from curl_cffi import requests
5
+ OUT = "/Users/matte/projects/browsercore/testing/captures/_probe/output"
6
+
7
+ def parse_ja3(data):
8
+ pos=5
9
+ handshake_len=struct.unpack(">I", b'\x00'+data[6:9])[0]; pos=9
10
+ end=9+handshake_len
11
+ ver=struct.unpack(">H", data[pos:pos+2])[0]; pos+=2
12
+ pos+=32
13
+ sid=data[pos]; pos+=1+sid
14
+ csl=struct.unpack(">H", data[pos:pos+2])[0]; pos+=2
15
+ ciphers=[struct.unpack(">H", data[pos+i:pos+i+2])[0] for i in range(0,csl,2)]
16
+ pos+=csl
17
+ cl=data[pos]; pos+=1+cl
18
+ exts=[]; sg=[]; ecpf=[]
19
+ if pos+2<=end:
20
+ el=struct.unpack(">H", data[pos:pos+2])[0]; pos+=2
21
+ eend=pos+el
22
+ while pos+4<=eend:
23
+ et=struct.unpack(">H", data[pos:pos+2])[0]
24
+ xl=struct.unpack(">H", data[pos+2:pos+4])[0]; pos+=4
25
+ exts.append(et)
26
+ if et==0x000a and xl>=4:
27
+ ll=struct.unpack(">H", data[pos:pos+2])[0]
28
+ for i in range(0,ll,2): sg.append(struct.unpack(">H", data[pos+2+i:pos+4+i])[0])
29
+ elif et==0x000b and xl>=1:
30
+ ll=data[pos]
31
+ for i in range(ll): ecpf.append(data[pos+1+i])
32
+ pos+=xl
33
+ return ver, ciphers, exts, sg, ecpf
34
+
35
+ def ja3hash(ver,ciphers,exts,sg,ecpf):
36
+ s=",".join([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)])
37
+ return hashlib.md5(s.encode()).hexdigest(), s
38
+
39
+ RAW="/Users/matte/projects/browsercore/testing/captures/_probe/raw_bytes"
40
+
41
+ for profile, cap, label in [("chrome131","chrome-131","chrome-131"),("safari170","safari-17","safari-17")]:
42
+ # fresh oracle probe
43
+ r=requests.get("https://tls.peet.ws/api/all", impersonate=profile, timeout=30)
44
+ od=r.json()
45
+ # save fresh
46
+ with open(os.path.join(OUT,f"{profile}_fresh.json"),"w") as f:
47
+ json.dump(od,f,indent=2)
48
+ oja3=od['tls']['ja3_hash']
49
+ oja3str=od['tls']['ja3']
50
+ oexts=[e['name'] for e in od['tls']['extensions']]
51
+ # captured bytes
52
+ with open(os.path.join(RAW,f"{cap}_client_hello.bin"),"rb") as f: d=f.read()
53
+ ver,ciphers,exts,sg,ecpf=parse_ja3(d)
54
+ cja3, cja3str = ja3hash(ver,ciphers,exts,sg,ecpf)
55
+ # GREASE-stripped comparison
56
+ def strip_grease(vals): return [v for v in vals if (v & 0x0f0f)!=0x0a0a]
57
+ cja3_strip, cja3str_strip = ja3hash(ver, strip_grease(ciphers), strip_grease(exts), strip_grease(sg), ecpf)
58
+ print(f"\n{label}:")
59
+ print(f" oracle ja3={oja3} str={oja3str}")
60
+ print(f" capture ja3={cja3} str={cja3str}")
61
+ print(f" capture ja3 (GREASE-stripped)={cja3_strip} str={cja3str_strip}")
62
+ print(f" match stripped? {cja3_strip==oja3}")
63
+ print(f" oracle exts: {oexts}")
64
+ print(f" capture exts: {exts} (hex {[hex(e) for e in exts]})")
65
+ # check SNI present in capture
66
+ print(f" capture has server_name(0)? {0 in exts}")