ja4proxy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ja4proxy/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """ja4proxy: High-performance universal proxy server with browser TLS (JA4) and HTTP/2 impersonation."""
2
+
3
+ __version__ = "0.1.0"
4
+ __author__ = "cipherwire"
5
+
6
+ from ja4proxy.engine.bridge import NativeEngine
7
+ from ja4proxy.server import ProxyServer
8
+
9
+ __all__ = ["ProxyServer", "NativeEngine", "__version__"]
ja4proxy/certs.py ADDED
@@ -0,0 +1,180 @@
1
+ import datetime
2
+ import ipaddress
3
+ import os
4
+ import ssl
5
+ import threading
6
+
7
+ from cryptography import x509
8
+ from cryptography.hazmat.primitives import hashes, serialization
9
+ from cryptography.hazmat.primitives.asymmetric import rsa
10
+ from cryptography.x509.oid import NameOID
11
+
12
+
13
+ def _set_private_file_permissions(path: str) -> None:
14
+ """Restricts file permissions to 0600 on POSIX platforms."""
15
+ if os.name != "nt":
16
+ try:
17
+ os.chmod(path, 0o600)
18
+ except OSError:
19
+ pass
20
+
21
+
22
+ class CertificateAuthority:
23
+ """Manages the local root CA and signs dynamic leaf certificates for HTTPS interception."""
24
+
25
+ def __init__(self, cert_dir: str | None = None):
26
+ if cert_dir:
27
+ self.cert_dir = cert_dir
28
+ else:
29
+ self.cert_dir = os.path.join(os.path.expanduser("~"), ".ja4proxy")
30
+ os.makedirs(self.cert_dir, exist_ok=True)
31
+
32
+ self.ca_cert_path = os.path.join(self.cert_dir, "ca.pem")
33
+ self.ca_key_path = os.path.join(self.cert_dir, "ca.key")
34
+
35
+ self._lock = threading.Lock()
36
+ self._leaf_cache: dict[str, ssl.SSLContext] = {}
37
+
38
+ self._ensure_ca()
39
+
40
+ def _ensure_ca(self) -> None:
41
+ """Loads existing CA certificate/key or generates a fresh 10-year root CA."""
42
+ if os.path.exists(self.ca_cert_path) and os.path.exists(self.ca_key_path):
43
+ with open(self.ca_key_path, "rb") as f:
44
+ self.ca_key = serialization.load_pem_private_key(
45
+ f.read(), password=None
46
+ )
47
+ with open(self.ca_cert_path, "rb") as f:
48
+ self.ca_cert = x509.load_pem_x509_certificate(f.read())
49
+ _set_private_file_permissions(self.ca_key_path)
50
+ return
51
+
52
+ # Generate fresh 2048-bit RSA key for CA
53
+ self.ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
54
+ ca_name = x509.Name(
55
+ [
56
+ x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
57
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, "ja4proxy Development"),
58
+ x509.NameAttribute(NameOID.COMMON_NAME, "ja4proxy Root CA"),
59
+ ]
60
+ )
61
+
62
+ now = datetime.datetime.now(datetime.timezone.utc)
63
+ self.ca_cert = (
64
+ x509.CertificateBuilder()
65
+ .subject_name(ca_name)
66
+ .issuer_name(ca_name)
67
+ .public_key(self.ca_key.public_key())
68
+ .serial_number(x509.random_serial_number())
69
+ .not_valid_before(now - datetime.timedelta(days=1))
70
+ .not_valid_after(now + datetime.timedelta(days=3650))
71
+ .add_extension(
72
+ x509.BasicConstraints(ca=True, path_length=None), critical=True
73
+ )
74
+ .add_extension(
75
+ x509.KeyUsage(
76
+ digital_signature=True,
77
+ content_commitment=False,
78
+ key_encipherment=False,
79
+ data_encipherment=False,
80
+ key_agreement=False,
81
+ key_cert_sign=True,
82
+ crl_sign=True,
83
+ encipher_only=False,
84
+ decipher_only=False,
85
+ ),
86
+ critical=True,
87
+ )
88
+ .sign(self.ca_key, hashes.SHA256())
89
+ )
90
+
91
+ with open(self.ca_key_path, "wb") as f:
92
+ f.write(
93
+ self.ca_key.private_bytes(
94
+ encoding=serialization.Encoding.PEM,
95
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
96
+ encryption_algorithm=serialization.NoEncryption(),
97
+ )
98
+ )
99
+ _set_private_file_permissions(self.ca_key_path)
100
+
101
+ with open(self.ca_cert_path, "wb") as f:
102
+ f.write(self.ca_cert.public_bytes(serialization.Encoding.PEM))
103
+
104
+ def get_ssl_context_for_host(self, host: str) -> ssl.SSLContext:
105
+ """
106
+ Dynamically generates or retrieves a cached SSLContext for the target hostname,
107
+ signed by this CA with appropriate Subject Alternative Names (SAN).
108
+ """
109
+ h = host.strip()
110
+ if h.startswith("["):
111
+ end = h.find("]")
112
+ clean_host = h[1:end] if end != -1 else h
113
+ elif ":" in h:
114
+ parts = h.split(":")
115
+ clean_host = parts[0] if len(parts) == 2 else h
116
+ else:
117
+ clean_host = h
118
+
119
+ with self._lock:
120
+ if clean_host in self._leaf_cache:
121
+ return self._leaf_cache[clean_host]
122
+
123
+ leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
124
+
125
+ subject_name = x509.Name(
126
+ [
127
+ x509.NameAttribute(NameOID.COMMON_NAME, clean_host),
128
+ ]
129
+ )
130
+
131
+ try:
132
+ ip_addr = ipaddress.ip_address(clean_host)
133
+ san = x509.SubjectAlternativeName([x509.IPAddress(ip_addr)])
134
+ except ValueError:
135
+ san = x509.SubjectAlternativeName([x509.DNSName(clean_host)])
136
+
137
+ now = datetime.datetime.now(datetime.timezone.utc)
138
+ leaf_cert = (
139
+ x509.CertificateBuilder()
140
+ .subject_name(subject_name)
141
+ .issuer_name(self.ca_cert.subject)
142
+ .public_key(leaf_key.public_key())
143
+ .serial_number(x509.random_serial_number())
144
+ .not_valid_before(now - datetime.timedelta(days=1))
145
+ .not_valid_after(now + datetime.timedelta(days=365))
146
+ .add_extension(
147
+ x509.BasicConstraints(ca=False, path_length=None), critical=True
148
+ )
149
+ .add_extension(san, critical=False)
150
+ .sign(self.ca_key, hashes.SHA256())
151
+ )
152
+
153
+ cert_pem = leaf_cert.public_bytes(serialization.Encoding.PEM)
154
+ key_pem = leaf_key.private_bytes(
155
+ encoding=serialization.Encoding.PEM,
156
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
157
+ encryption_algorithm=serialization.NoEncryption(),
158
+ )
159
+
160
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
161
+
162
+ leaf_cert_path = os.path.join(self.cert_dir, f"_leaf_{clean_host}.pem")
163
+ leaf_key_path = os.path.join(self.cert_dir, f"_leaf_{clean_host}.key")
164
+ with open(leaf_cert_path, "wb") as f:
165
+ f.write(cert_pem)
166
+ with open(leaf_key_path, "wb") as f:
167
+ f.write(key_pem)
168
+ _set_private_file_permissions(leaf_key_path)
169
+
170
+ try:
171
+ ctx.load_cert_chain(certfile=leaf_cert_path, keyfile=leaf_key_path)
172
+ finally:
173
+ try:
174
+ os.remove(leaf_cert_path)
175
+ os.remove(leaf_key_path)
176
+ except OSError:
177
+ pass
178
+
179
+ self._leaf_cache[clean_host] = ctx
180
+ return ctx
ja4proxy/cli.py ADDED
@@ -0,0 +1,154 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import shutil
5
+ import sys
6
+
7
+ import click
8
+
9
+ from ja4proxy import __version__
10
+ from ja4proxy.certs import CertificateAuthority
11
+ from ja4proxy.engine.bridge import NativeEngine
12
+ from ja4proxy.profiles import SUPPORTED_PROFILES, normalize_profile
13
+ from ja4proxy.server import ProxyServer
14
+
15
+
16
+ @click.group()
17
+ @click.version_option(__version__, prog_name="ja4proxy")
18
+ def main() -> None:
19
+ """ja4proxy: High-performance universal proxy server with browser TLS (JA4) and HTTP/2 impersonation."""
20
+ pass
21
+
22
+
23
+ @main.command()
24
+ @click.option("--host", default="127.0.0.1", help="Binding IP address.")
25
+ @click.option("--port", "-p", default=8888, type=int, help="Port to listen on.")
26
+ @click.option(
27
+ "--profile",
28
+ default="chrome_133",
29
+ help="Default browser profile (e.g. chrome_133, safari_18_0, firefox_133).",
30
+ )
31
+ @click.option(
32
+ "--upstream-proxy",
33
+ default=None,
34
+ help="Default upstream proxy URL (e.g. socks5://user:pass@host:1080).",
35
+ )
36
+ @click.option(
37
+ "--cert-dir", default=None, help="Directory to store CA and leaf certificates."
38
+ )
39
+ @click.option(
40
+ "--allow-private-networks",
41
+ is_flag=True,
42
+ help="Allow forwarding requests to private and loopback IP ranges.",
43
+ )
44
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose debug logging.")
45
+ def start(
46
+ host: str,
47
+ port: int,
48
+ profile: str,
49
+ upstream_proxy: str | None,
50
+ cert_dir: str | None,
51
+ allow_private_networks: bool,
52
+ verbose: bool,
53
+ ) -> None:
54
+ """Starts the ja4proxy server."""
55
+ log_level = logging.DEBUG if verbose else logging.INFO
56
+ logging.basicConfig(
57
+ level=log_level, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
58
+ )
59
+
60
+ prof = normalize_profile(profile)
61
+ server = ProxyServer(
62
+ host=host,
63
+ port=port,
64
+ default_profile=prof,
65
+ cert_dir=cert_dir,
66
+ allow_private_networks=allow_private_networks,
67
+ upstream_proxy=upstream_proxy,
68
+ )
69
+
70
+ ca_path = server.ca.ca_cert_path
71
+ click.echo(
72
+ f"[*] ja4proxy v{__version__} starting on http://{host}:{port}"
73
+ )
74
+ click.echo(f"[*] Default Profile: {prof}")
75
+ click.echo(f"[*] Protocols Supported: HTTP CONNECT, HTTP Gateway, SOCKS5 (Auto-detect)")
76
+ click.echo(f"[*] CA Certificate: {ca_path}")
77
+ click.echo(
78
+ "[*] Ready for HTTP and SOCKS5 clients (Python requests, Node axios, cURL, etc.)"
79
+ )
80
+
81
+ try:
82
+ asyncio.run(server.serve_forever())
83
+ except KeyboardInterrupt:
84
+ click.echo("\n[*] Shutting down ja4proxy...")
85
+
86
+
87
+ @main.command(name="profiles")
88
+ def list_profiles() -> None:
89
+ """Lists all supported browser profiles for TLS and HTTP/2 emulation."""
90
+ click.echo("Supported Browser Profiles:")
91
+ for prof in SUPPORTED_PROFILES:
92
+ click.echo(f" - {prof}")
93
+
94
+
95
+ @main.command(name="ca")
96
+ @click.option(
97
+ "--export",
98
+ "-e",
99
+ "export_path",
100
+ default=None,
101
+ help="Export root CA certificate (ca.pem) to specified path.",
102
+ )
103
+ def manage_ca(export_path: str | None) -> None:
104
+ """Inspects or exports the local Root CA certificate."""
105
+ ca = CertificateAuthority()
106
+ click.echo(f"Root CA Certificate: {ca.ca_cert_path}")
107
+ click.echo(f"Root CA Private Key: {ca.ca_key_path}")
108
+
109
+ if export_path:
110
+ dest = os.path.abspath(export_path)
111
+ shutil.copyfile(ca.ca_cert_path, dest)
112
+ click.echo(f"Exported CA to: {dest}")
113
+
114
+
115
+ @main.command(name="test")
116
+ @click.option(
117
+ "--profile",
118
+ default="chrome_133",
119
+ help="Browser profile to test against tls.peet.ws.",
120
+ )
121
+ def test_handshake(profile: str) -> None:
122
+ """Performs a direct live handshake against tls.peet.ws to inspect upstream JA4."""
123
+ import json
124
+
125
+ prof = normalize_profile(profile)
126
+ click.echo(f"Testing upstream handshake for profile: {prof}...")
127
+
128
+ async def _test():
129
+ engine = NativeEngine.get_instance()
130
+ res = await engine.request(
131
+ method="GET",
132
+ url="https://tls.peet.ws/api/all",
133
+ profile=prof,
134
+ timeout_seconds=15,
135
+ )
136
+ return res
137
+
138
+ try:
139
+ res = asyncio.run(_test())
140
+ click.echo(f"Status Code: {res['status']}")
141
+ click.echo(f"Negotiated Protocol: {res['protocol']}")
142
+
143
+ body = json.loads(res["body"].decode("utf-8", errors="replace"))
144
+ tls_info = body.get("tls", {})
145
+ click.echo(f"Remote Peer JA4: {tls_info.get('ja4')}")
146
+ click.echo(f"Remote Peer JA3: {tls_info.get('ja3')}")
147
+ click.echo(f"HTTP Version: {body.get('http_version')}")
148
+ except Exception as e:
149
+ click.echo(f"Error executing test: {e}", err=True)
150
+ sys.exit(1)
151
+
152
+
153
+ if __name__ == "__main__":
154
+ main()
@@ -0,0 +1,192 @@
1
+ import asyncio
2
+ import base64
3
+ import ctypes
4
+ import json
5
+ import threading
6
+ from typing import Any
7
+
8
+ from ja4proxy.engine.loader import resolve_native_library
9
+ from ja4proxy.profiles import get_default_headers, normalize_profile
10
+
11
+
12
+ class EngineError(Exception):
13
+ """Raised when the native uTLS engine fails to execute a request."""
14
+
15
+
16
+ class NativeEngine:
17
+ """In-process bridge to the Go uTLS / fhttp shared library via ctypes."""
18
+
19
+ _instance: "NativeEngine | None" = None
20
+ _init_lock = threading.Lock()
21
+
22
+ def __init__(self, lib_path: str | None = None):
23
+ path = lib_path or resolve_native_library()
24
+ self.lib = ctypes.cdll.LoadLibrary(path)
25
+
26
+ # Set up C function signatures
27
+ self.lib.request.argtypes = [ctypes.c_char_p]
28
+ self.lib.request.restype = ctypes.c_char_p
29
+
30
+ self.lib.freeMemory.argtypes = [ctypes.c_char_p]
31
+ self.lib.freeMemory.restype = None
32
+
33
+ self.lib.destroySession.argtypes = [ctypes.c_char_p]
34
+ self.lib.destroySession.restype = ctypes.c_char_p
35
+
36
+ @classmethod
37
+ def get_instance(cls) -> "NativeEngine":
38
+ if cls._instance is None:
39
+ with cls._init_lock:
40
+ if cls._instance is None:
41
+ cls._instance = cls()
42
+ return cls._instance
43
+
44
+ def _execute_sync(self, payload: dict[str, Any]) -> dict[str, Any]:
45
+ """Synchronously invokes the C function and frees the Go-allocated memory."""
46
+ payload_bytes = json.dumps(payload).encode("utf-8")
47
+ resp_ptr = self.lib.request(payload_bytes)
48
+
49
+ if not resp_ptr:
50
+ raise EngineError("Native engine returned null pointer")
51
+
52
+ resp_id = None
53
+ try:
54
+ raw_bytes = ctypes.string_at(resp_ptr)
55
+ decoded = raw_bytes.decode("utf-8", errors="replace")
56
+ resp_obj = json.loads(decoded)
57
+ if isinstance(resp_obj, dict):
58
+ resp_id = resp_obj.get("id")
59
+ return resp_obj
60
+ except Exception as e:
61
+ raise EngineError(f"Failed to parse native engine response: {e}") from e
62
+ finally:
63
+ if resp_id:
64
+ try:
65
+ self.lib.freeMemory(resp_id.encode("utf-8"))
66
+ except Exception:
67
+ pass
68
+
69
+ async def execute_async(self, payload: dict[str, Any]) -> dict[str, Any]:
70
+ """Asynchronously executes request in threadpool, releasing Python GIL."""
71
+ loop = asyncio.get_running_loop()
72
+ return await loop.run_in_executor(None, self._execute_sync, payload)
73
+
74
+ def destroy_session_sync(self, session_id: str) -> bool:
75
+ """Destroys an allocated session in the Go engine to release memory and cookies."""
76
+ payload = json.dumps({"sessionId": session_id}).encode("utf-8")
77
+ resp_ptr = self.lib.destroySession(payload)
78
+ if not resp_ptr:
79
+ return False
80
+
81
+ resp_id = None
82
+ try:
83
+ raw_bytes = ctypes.string_at(resp_ptr)
84
+ resp_obj = json.loads(raw_bytes.decode("utf-8", errors="replace"))
85
+ if isinstance(resp_obj, dict):
86
+ resp_id = resp_obj.get("id")
87
+ return bool(resp_obj.get("success", False))
88
+ return False
89
+ except Exception:
90
+ return False
91
+ finally:
92
+ if resp_id:
93
+ try:
94
+ self.lib.freeMemory(resp_id.encode("utf-8"))
95
+ except Exception:
96
+ pass
97
+
98
+ async def destroy_session(self, session_id: str) -> bool:
99
+ loop = asyncio.get_running_loop()
100
+ return await loop.run_in_executor(None, self.destroy_session_sync, session_id)
101
+
102
+ async def request(
103
+ self,
104
+ method: str,
105
+ url: str,
106
+ headers: dict[str, str] | None = None,
107
+ body: bytes | None = None,
108
+ profile: str = "chrome_133",
109
+ proxy_url: str = "",
110
+ timeout_seconds: int = 30,
111
+ follow_redirects: bool = False,
112
+ insecure_skip_verify: bool = False,
113
+ session_id: str = "ja4_default",
114
+ ) -> dict[str, Any]:
115
+ """
116
+ Dispatches an HTTP request through the native uTLS engine.
117
+ Merges canonical browser headers to ensure the target never sees automated client signatures.
118
+ """
119
+ prof = normalize_profile(profile)
120
+ is_byte_req = False
121
+ encoded_body = ""
122
+ if body:
123
+ encoded_body = base64.b64encode(body).decode("utf-8")
124
+ is_byte_req = True
125
+
126
+ # Start with default browser headers for the profile
127
+ merged_headers: dict[str, str] = get_default_headers(prof)
128
+
129
+ # Overlay client headers (case-insensitive merge)
130
+ if headers:
131
+ for k, v in headers.items():
132
+ if v is not None:
133
+ merged_headers[k.lower()] = str(v)
134
+
135
+ payload = {
136
+ "sessionId": session_id,
137
+ "tlsClientIdentifier": prof,
138
+ "requestMethod": method.upper(),
139
+ "requestUrl": url,
140
+ "requestBody": encoded_body,
141
+ "isByteRequest": is_byte_req,
142
+ "isByteResponse": True,
143
+ "headers": merged_headers,
144
+ "requestCookies": [],
145
+ "proxyUrl": proxy_url,
146
+ "timeoutSeconds": timeout_seconds,
147
+ "followRedirects": follow_redirects,
148
+ "insecureSkipVerify": insecure_skip_verify,
149
+ "forceHttp1": False,
150
+ "withProtocolRacing": False,
151
+ "disableHttp3": True,
152
+ "withRandomTLSExtensionOrder": False,
153
+ }
154
+
155
+ raw = await self.execute_async(payload)
156
+
157
+ status_code = raw.get("status", 0)
158
+ raw_headers = raw.get("headers", {})
159
+ used_protocol = raw.get("usedProtocol", "")
160
+
161
+ # Decode response body
162
+ raw_body = raw.get("body", "")
163
+ body_bytes = b""
164
+ if isinstance(raw_body, str) and raw_body:
165
+ if raw_body.startswith("data:") and "," in raw_body:
166
+ raw_body = raw_body.split(",", 1)[1]
167
+ try:
168
+ body_bytes = base64.b64decode(raw_body)
169
+ except Exception:
170
+ body_bytes = raw_body.encode("utf-8")
171
+
172
+ # Map native failure (status 0) to 502 Bad Gateway
173
+ if status_code == 0:
174
+ status_code = 502
175
+ error_msg = raw.get("errorMessage") or (
176
+ body_bytes.decode("utf-8", errors="replace")
177
+ if body_bytes
178
+ else "Bad Gateway: upstream connection failed"
179
+ )
180
+ body_bytes = (
181
+ error_msg.encode("utf-8")
182
+ if isinstance(error_msg, str)
183
+ else error_msg
184
+ )
185
+
186
+ return {
187
+ "status": status_code,
188
+ "headers": raw_headers,
189
+ "body": body_bytes,
190
+ "protocol": used_protocol,
191
+ "target": raw.get("target", url),
192
+ }
@@ -0,0 +1,162 @@
1
+ import ctypes
2
+ import hashlib
3
+ import os
4
+ import platform
5
+ import sys
6
+ import threading
7
+ import urllib.request
8
+
9
+ OWNER = "bogdanfinn"
10
+ REPO = "tls-client"
11
+ DEFAULT_VERSION = "1.16.0"
12
+
13
+ RELEASE_DOWNLOAD_URL = (
14
+ f"https://github.com/{OWNER}/{REPO}/releases/download/v{DEFAULT_VERSION}/{{asset}}"
15
+ )
16
+
17
+ ASSET_SHA256: dict[str, str] = {
18
+ "tls-client-xgo-1.16.0-darwin-amd64.dylib": "9cb5fd24bceb74cedd4372bdbdf013058c650fa6f8df2a1336918d8ee54128d8",
19
+ "tls-client-xgo-1.16.0-darwin-arm64.dylib": "f3a6f5ffadc4a7f5184d9880380e522fbaf6890c623eae1ac0805071f42f5aaa",
20
+ "tls-client-xgo-1.16.0-linux-386.so": "5ea81308e53e236dae73b35bd4a09af1201191c32713ea2968b85bbcfe94b616",
21
+ "tls-client-xgo-1.16.0-linux-amd64.so": "75f19133e3cb9b16ab3d2dfe3376c7590aab2fe6f2294debac661bfd63dca6a3",
22
+ "tls-client-xgo-1.16.0-linux-arm-5.so": "02cca934f2fc15db658e2d94ed472691224656b696c6531ffec2d0aad2997b2f",
23
+ "tls-client-xgo-1.16.0-linux-arm-6.so": "5c991eca511de6d0ea04f12100c89a7750696213e246226e4050d88bf7e9136b",
24
+ "tls-client-xgo-1.16.0-linux-arm-7.so": "ee9125b4580bf3e1515304e6faeb4ce30eb88f47c5158349a14d38b25448c358",
25
+ "tls-client-xgo-1.16.0-linux-arm64.so": "91f88d35fa64284c811373003d2a26caa3145eaeefd1c0e9576f10724a914332",
26
+ "tls-client-xgo-1.16.0-windows-386.dll": "f41099111446ec3252166b640156a2213d387511e4e80e849ce8b8a0871be47f",
27
+ "tls-client-xgo-1.16.0-windows-amd64.dll": "33d9b5a4a1a902bae57494186ddce91af32fb67326d94ea04776447a81c15934",
28
+ }
29
+
30
+ _loader_lock = threading.Lock()
31
+
32
+
33
+ def get_system_platform() -> tuple[str, str]:
34
+ """Detects system OS and CPU architecture."""
35
+ system_os = platform.system().lower()
36
+ machine = platform.machine().lower()
37
+
38
+ if system_os == "darwin":
39
+ return "darwin", ("arm64" if machine == "arm64" else "amd64")
40
+
41
+ if system_os in ("windows", "win32", "cygwin"):
42
+ return "windows", ("amd64" if ctypes.sizeof(ctypes.c_voidp) == 8 else "386")
43
+
44
+ sys_os = "linux"
45
+ if machine in ("aarch64", "arm64"):
46
+ return sys_os, "arm64"
47
+ if machine in ("x86_64", "amd64"):
48
+ return sys_os, "amd64"
49
+ if machine in ("i386", "i486", "i586", "i686", "x86"):
50
+ return sys_os, "386"
51
+ return sys_os, "amd64"
52
+
53
+
54
+ def get_asset_filename() -> str:
55
+ """Builds the asset filename for the current platform."""
56
+ sys_os, arch = get_system_platform()
57
+ ext = "dll" if sys_os == "windows" else ("dylib" if sys_os == "darwin" else "so")
58
+ return f"tls-client-xgo-{DEFAULT_VERSION}-{sys_os}-{arch}.{ext}"
59
+
60
+
61
+ def get_cache_directory() -> str:
62
+ """Returns the persistent directory for storing native binaries."""
63
+ env_dir = os.getenv("JA4PROXY_CACHE_DIR")
64
+ if env_dir:
65
+ os.makedirs(env_dir, exist_ok=True)
66
+ return env_dir
67
+
68
+ if sys.platform == "win32":
69
+ base = os.getenv("LOCALAPPDATA") or os.path.join(
70
+ os.path.expanduser("~"), "AppData", "Local"
71
+ )
72
+ cache_dir = os.path.join(base, "ja4proxy")
73
+ else:
74
+ xdg = os.getenv("XDG_CACHE_HOME") or os.path.join(
75
+ os.path.expanduser("~"), ".cache"
76
+ )
77
+ cache_dir = os.path.join(xdg, "ja4proxy")
78
+
79
+ os.makedirs(cache_dir, exist_ok=True)
80
+ return cache_dir
81
+
82
+
83
+ def _compute_sha256(path: str) -> str:
84
+ hasher = hashlib.sha256()
85
+ with open(path, "rb") as f:
86
+ for chunk in iter(lambda: f.read(65536), b""):
87
+ hasher.update(chunk)
88
+ return hasher.hexdigest()
89
+
90
+
91
+ def resolve_native_library() -> str:
92
+ """
93
+ Finds or downloads the native Go shared library.
94
+ Resolves in order:
95
+ 1. JA4PROXY_LIB_PATH env var
96
+ 2. Local ja4proxy cache directory
97
+ 3. Existing horaa-tls cache directory (fallback reuse)
98
+ 4. Direct download from GitHub releases with SHA-256 verification
99
+ """
100
+ with _loader_lock:
101
+ custom_path = os.getenv("JA4PROXY_LIB_PATH")
102
+ if custom_path and os.path.exists(custom_path):
103
+ return custom_path
104
+
105
+ asset_name = get_asset_filename()
106
+ cache_dir = get_cache_directory()
107
+ target_path = os.path.join(cache_dir, asset_name)
108
+
109
+ if os.path.exists(target_path):
110
+ expected_hash = ASSET_SHA256.get(asset_name)
111
+ if expected_hash:
112
+ actual_hash = _compute_sha256(target_path)
113
+ if actual_hash == expected_hash:
114
+ return target_path
115
+
116
+ # Check existing horaa-tls cache location
117
+ if sys.platform == "win32":
118
+ local_appdata = os.getenv("LOCALAPPDATA") or ""
119
+ horaa_path = os.path.join(local_appdata, "horaa-tls", asset_name)
120
+ if os.path.exists(horaa_path):
121
+ expected_hash = ASSET_SHA256.get(asset_name)
122
+ if not expected_hash or _compute_sha256(horaa_path) == expected_hash:
123
+ return horaa_path
124
+ else:
125
+ xdg = os.getenv("XDG_CACHE_HOME") or os.path.join(
126
+ os.path.expanduser("~"), ".cache"
127
+ )
128
+ horaa_path = os.path.join(xdg, "horaa-tls", asset_name)
129
+ if os.path.exists(horaa_path):
130
+ expected_hash = ASSET_SHA256.get(asset_name)
131
+ if not expected_hash or _compute_sha256(horaa_path) == expected_hash:
132
+ return horaa_path
133
+
134
+ # Download from GitHub release
135
+ download_url = RELEASE_DOWNLOAD_URL.format(asset=asset_name)
136
+ temp_path = target_path + ".tmp"
137
+ try:
138
+ req = urllib.request.Request(
139
+ download_url, headers={"User-Agent": "ja4proxy/0.1.0"}
140
+ )
141
+ with urllib.request.urlopen(req, timeout=60) as resp, open(
142
+ temp_path, "wb"
143
+ ) as out:
144
+ for chunk in iter(lambda: resp.read(65536), b""):
145
+ out.write(chunk)
146
+
147
+ expected_hash = ASSET_SHA256.get(asset_name)
148
+ if expected_hash:
149
+ actual_hash = _compute_sha256(temp_path)
150
+ if actual_hash != expected_hash:
151
+ raise RuntimeError(
152
+ f"SHA-256 mismatch for {asset_name}: expected {expected_hash}, got {actual_hash}"
153
+ )
154
+
155
+ os.replace(temp_path, target_path)
156
+ return target_path
157
+ finally:
158
+ if os.path.exists(temp_path):
159
+ try:
160
+ os.remove(temp_path)
161
+ except OSError:
162
+ pass